mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 20:16:50 -06:00
539 lines
17 KiB
Rust
539 lines
17 KiB
Rust
//! A small, bounded XML DOM over quick-xml for reading parts, plus an escaping string builder for
|
|
//! writing them.
|
|
//!
|
|
//! Reading is lenient about namespaces: elements and attributes are matched by local name, which is
|
|
//! how real-world files are best understood (prefixes are conventional and stable in practice).
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
use quick_xml::events::Event;
|
|
|
|
use crate::PptxError;
|
|
|
|
/// Maximum element nesting accepted when parsing.
|
|
pub const MAX_DEPTH: usize = 256;
|
|
/// Maximum number of elements in one part.
|
|
pub const MAX_NODES: usize = 4_000_000;
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct El {
|
|
/// Qualified name as written (`p:sp`).
|
|
pub name: String,
|
|
pub attrs: Vec<(String, String)>,
|
|
pub children: Vec<Node>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Node {
|
|
El(El),
|
|
Text(String),
|
|
}
|
|
|
|
fn local_of(name: &str) -> &str {
|
|
name.rsplit_once(':').map(|(_, l)| l).unwrap_or(name)
|
|
}
|
|
|
|
impl El {
|
|
pub fn local(&self) -> &str {
|
|
local_of(&self.name)
|
|
}
|
|
pub fn prefix(&self) -> &str {
|
|
self.name.split_once(':').map(|(p, _)| p).unwrap_or("")
|
|
}
|
|
pub fn is(&self, local: &str) -> bool {
|
|
self.local() == local
|
|
}
|
|
/// Attribute by local name (`id` matches `id`; `r:id` matches `id` only when no plain `id`
|
|
/// exists). Pass a qualified name (`r:id`) to require that prefix's local name with any prefix.
|
|
pub fn attr(&self, name: &str) -> Option<&str> {
|
|
if let Some((_, l)) = name.split_once(':') {
|
|
return self.attrs.iter().find(|(k, _)| k.contains(':') && !k.starts_with("xmlns") && local_of(k) == l).map(|(_, v)| v.as_str());
|
|
}
|
|
self.attrs
|
|
.iter()
|
|
.find(|(k, _)| k == name)
|
|
.or_else(|| self.attrs.iter().find(|(k, _)| !k.starts_with("xmlns") && local_of(k) == name))
|
|
.map(|(_, v)| v.as_str())
|
|
}
|
|
pub fn has_attr(&self, name: &str) -> bool {
|
|
self.attr(name).is_some()
|
|
}
|
|
pub fn set_attr(&mut self, name: &str, value: &str) {
|
|
if let Some(a) = self.attrs.iter_mut().find(|(k, _)| k == name) {
|
|
a.1 = value.to_string();
|
|
} else {
|
|
self.attrs.push((name.to_string(), value.to_string()));
|
|
}
|
|
}
|
|
pub fn i64(&self, name: &str) -> Option<i64> {
|
|
let v = self.attr(name)?.trim();
|
|
v.parse::<i64>().ok().or_else(|| v.parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| f.clamp(-9.0e18, 9.0e18) as i64))
|
|
}
|
|
pub fn i32(&self, name: &str) -> Option<i32> {
|
|
self.i64(name).map(|v| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32)
|
|
}
|
|
pub fn u32(&self, name: &str) -> Option<u32> {
|
|
self.i64(name).map(|v| v.clamp(0, u32::MAX as i64) as u32)
|
|
}
|
|
pub fn f64(&self, name: &str) -> Option<f64> {
|
|
self.attr(name)?.trim().parse::<f64>().ok().filter(|f| f.is_finite())
|
|
}
|
|
pub fn bool(&self, name: &str) -> Option<bool> {
|
|
match self.attr(name)?.trim() {
|
|
"1" | "true" | "on" => Some(true),
|
|
"0" | "false" | "off" => Some(false),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn elements(&self) -> impl Iterator<Item = &El> {
|
|
self.children.iter().filter_map(|n| match n {
|
|
Node::El(e) => Some(e),
|
|
Node::Text(_) => None,
|
|
})
|
|
}
|
|
pub fn elements_mut(&mut self) -> impl Iterator<Item = &mut El> {
|
|
self.children.iter_mut().filter_map(|n| match n {
|
|
Node::El(e) => Some(e),
|
|
Node::Text(_) => None,
|
|
})
|
|
}
|
|
pub fn child(&self, local: &str) -> Option<&El> {
|
|
self.elements().find(|e| e.is(local))
|
|
}
|
|
pub fn children_named<'a>(&'a self, local: &'a str) -> impl Iterator<Item = &'a El> + 'a {
|
|
self.elements().filter(move |e| e.is(local))
|
|
}
|
|
/// Follow a chain of child local names.
|
|
pub fn path(&self, names: &[&str]) -> Option<&El> {
|
|
let mut cur = self;
|
|
for n in names {
|
|
cur = cur.child(n)?;
|
|
}
|
|
Some(cur)
|
|
}
|
|
/// First descendant (depth-first, bounded) with this local name.
|
|
pub fn find(&self, local: &str) -> Option<&El> {
|
|
fn rec<'a>(e: &'a El, local: &str, depth: usize) -> Option<&'a El> {
|
|
if depth > MAX_DEPTH {
|
|
return None;
|
|
}
|
|
for c in e.elements() {
|
|
if c.is(local) {
|
|
return Some(c);
|
|
}
|
|
if let Some(f) = rec(c, local, depth + 1) {
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
rec(self, local, 0)
|
|
}
|
|
/// All descendants with this local name (bounded depth).
|
|
pub fn find_all<'a>(&'a self, local: &str, out: &mut Vec<&'a El>) {
|
|
fn rec<'a>(e: &'a El, local: &str, depth: usize, out: &mut Vec<&'a El>) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for c in e.elements() {
|
|
if c.is(local) {
|
|
out.push(c);
|
|
}
|
|
rec(c, local, depth + 1, out);
|
|
}
|
|
}
|
|
rec(self, local, 0, out)
|
|
}
|
|
/// Concatenated text content of this element and its descendants.
|
|
pub fn text(&self) -> String {
|
|
let mut s = String::new();
|
|
fn rec(e: &El, s: &mut String, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
for c in &e.children {
|
|
match c {
|
|
Node::Text(t) => s.push_str(t),
|
|
Node::El(e) => rec(e, s, depth + 1),
|
|
}
|
|
}
|
|
}
|
|
rec(self, &mut s, 0);
|
|
s
|
|
}
|
|
/// Value of `<x val="…"/>` child.
|
|
pub fn child_val(&self, local: &str) -> Option<&str> {
|
|
self.child(local).and_then(|c| c.attr("val"))
|
|
}
|
|
/// Serialize to a string (no XML declaration).
|
|
pub fn to_xml(&self) -> String {
|
|
let mut s = String::new();
|
|
write_el(self, &mut s, 0);
|
|
s
|
|
}
|
|
/// Remove descendant elements with any of these local names (bounded).
|
|
pub fn remove_all(&mut self, locals: &[&str]) {
|
|
fn rec(e: &mut El, locals: &[&str], depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
e.children.retain(|c| !matches!(c, Node::El(x) if locals.contains(&x.local())));
|
|
for c in e.elements_mut() {
|
|
rec(c, locals, depth + 1);
|
|
}
|
|
}
|
|
rec(self, locals, 0)
|
|
}
|
|
}
|
|
|
|
fn write_el(e: &El, s: &mut String, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
s.push('<');
|
|
s.push_str(&e.name);
|
|
for (k, v) in &e.attrs {
|
|
let _ = write!(s, " {}=\"{}\"", k, esc_attr(v));
|
|
}
|
|
if e.children.is_empty() {
|
|
s.push_str("/>");
|
|
return;
|
|
}
|
|
s.push('>');
|
|
for c in &e.children {
|
|
match c {
|
|
Node::Text(t) => s.push_str(&esc(t)),
|
|
Node::El(c) => write_el(c, s, depth + 1),
|
|
}
|
|
}
|
|
s.push_str("</");
|
|
s.push_str(&e.name);
|
|
s.push('>');
|
|
}
|
|
|
|
/// A parsed document: its root element and the namespace declarations on the root.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Doc {
|
|
pub root: El,
|
|
}
|
|
|
|
impl Doc {
|
|
/// Namespace declarations (`xmlns:x`, `xmlns`) on the root element.
|
|
pub fn ns_decls(&self) -> Vec<(String, String)> {
|
|
self.root.attrs.iter().filter(|(k, _)| k == "xmlns" || k.starts_with("xmlns:")).cloned().collect()
|
|
}
|
|
}
|
|
|
|
/// Parse XML bytes into a DOM. Bounded depth and node count; entity references are resolved.
|
|
pub fn parse(bytes: &[u8]) -> Result<Doc, PptxError> {
|
|
// Skip a UTF-8 BOM; UTF-16 parts (rare) are decoded first.
|
|
let owned;
|
|
let bytes = if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
|
|
owned = decode_utf16(bytes);
|
|
owned.as_bytes()
|
|
} else {
|
|
bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)
|
|
};
|
|
let mut reader = quick_xml::Reader::from_reader(bytes);
|
|
{
|
|
let c = reader.config_mut();
|
|
c.trim_text(false);
|
|
c.check_end_names = false;
|
|
c.allow_unmatched_ends = true;
|
|
c.expand_empty_elements = false;
|
|
}
|
|
let mut stack: Vec<El> = vec![];
|
|
let mut root: Option<El> = None;
|
|
let mut nodes = 0usize;
|
|
let mut buf = Vec::new();
|
|
loop {
|
|
let ev = reader.read_event_into(&mut buf).map_err(|e| PptxError::Xml(format!("{e}")))?;
|
|
match ev {
|
|
Event::Start(b) | Event::Empty(b) if root.is_some() => {
|
|
// Content after the root element: ignore.
|
|
let _ = b;
|
|
}
|
|
Event::Start(b) => {
|
|
nodes += 1;
|
|
if nodes > MAX_NODES || stack.len() >= MAX_DEPTH {
|
|
return Err(PptxError::Xml("document too large or too deeply nested".into()));
|
|
}
|
|
stack.push(start_el(&b, &reader));
|
|
}
|
|
Event::Empty(b) => {
|
|
nodes += 1;
|
|
if nodes > MAX_NODES {
|
|
return Err(PptxError::Xml("document too large".into()));
|
|
}
|
|
let el = start_el(&b, &reader);
|
|
match stack.last_mut() {
|
|
Some(p) => p.children.push(Node::El(el)),
|
|
None => root = Some(el),
|
|
}
|
|
}
|
|
Event::End(_) => {
|
|
if let Some(el) = stack.pop() {
|
|
match stack.last_mut() {
|
|
Some(p) => p.children.push(Node::El(el)),
|
|
None => root = Some(el),
|
|
}
|
|
}
|
|
}
|
|
Event::Text(t) => {
|
|
if let Some(p) = stack.last_mut() {
|
|
let s = t.decode().map(|c| c.into_owned()).unwrap_or_default();
|
|
push_text(p, &s);
|
|
}
|
|
}
|
|
Event::CData(t) => {
|
|
if let Some(p) = stack.last_mut() {
|
|
let s = String::from_utf8_lossy(&t).into_owned();
|
|
push_text(p, &s);
|
|
}
|
|
}
|
|
Event::GeneralRef(r) => {
|
|
if let Some(p) = stack.last_mut() {
|
|
let s = if r.is_char_ref() {
|
|
r.resolve_char_ref().ok().flatten().map(String::from).unwrap_or_default()
|
|
} else {
|
|
match r.decode().map(|c| c.into_owned()).unwrap_or_default().as_str() {
|
|
"amp" => "&".into(),
|
|
"lt" => "<".into(),
|
|
"gt" => ">".into(),
|
|
"quot" => "\"".into(),
|
|
"apos" => "'".into(),
|
|
_ => String::new(),
|
|
}
|
|
};
|
|
push_text(p, &s);
|
|
}
|
|
}
|
|
Event::Eof => break,
|
|
_ => {}
|
|
}
|
|
buf.clear();
|
|
}
|
|
// Unclosed elements (truncated document): fold them up so partial content is still usable.
|
|
while let Some(el) = stack.pop() {
|
|
match stack.last_mut() {
|
|
Some(p) => p.children.push(Node::El(el)),
|
|
None => root = Some(el),
|
|
}
|
|
}
|
|
let root = root.ok_or_else(|| PptxError::Xml("no root element".into()))?;
|
|
Ok(Doc { root })
|
|
}
|
|
|
|
fn push_text(p: &mut El, s: &str) {
|
|
if s.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(Node::Text(t)) = p.children.last_mut() {
|
|
t.push_str(s);
|
|
} else {
|
|
p.children.push(Node::Text(s.to_string()));
|
|
}
|
|
}
|
|
|
|
fn start_el(b: &quick_xml::events::BytesStart, reader: &quick_xml::Reader<&[u8]>) -> El {
|
|
let name = String::from_utf8_lossy(b.name().as_ref()).into_owned();
|
|
let mut attrs = vec![];
|
|
for a in b.attributes().with_checks(false).flatten() {
|
|
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
|
let v =
|
|
a.decode_and_unescape_value(reader.decoder()).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned());
|
|
attrs.push((k, v));
|
|
}
|
|
El { name, attrs, children: vec![] }
|
|
}
|
|
|
|
fn decode_utf16(bytes: &[u8]) -> String {
|
|
let le = bytes.starts_with(&[0xFF, 0xFE]);
|
|
let units: Vec<u16> =
|
|
bytes.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) }).collect();
|
|
let s = String::from_utf16_lossy(&units);
|
|
// Drop the encoding declaration so quick-xml reads it as UTF-8.
|
|
match s.strip_prefix("<?xml").and_then(|r| r.find("?>").map(|i| i + 7)) {
|
|
Some(end) => s.get(end..).unwrap_or("").to_string(),
|
|
None => s,
|
|
}
|
|
}
|
|
|
|
/// Is `c` allowed in XML 1.0 text?
|
|
fn xml_char(c: char) -> bool {
|
|
matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && c != '\u{FFFE}' && c != '\u{FFFF}')
|
|
}
|
|
|
|
/// Escape text content (and drop characters XML 1.0 forbids).
|
|
pub fn esc(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'&' => o.push_str("&"),
|
|
'<' => o.push_str("<"),
|
|
'>' => o.push_str(">"),
|
|
c if xml_char(c) => o.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
o
|
|
}
|
|
|
|
/// Escape an attribute value.
|
|
pub fn esc_attr(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'&' => o.push_str("&"),
|
|
'<' => o.push_str("<"),
|
|
'>' => o.push_str(">"),
|
|
'"' => o.push_str("""),
|
|
'\n' => o.push_str(" "),
|
|
'\r' => o.push_str(" "),
|
|
'\t' => o.push_str("	"),
|
|
c if xml_char(c) => o.push(c),
|
|
_ => {}
|
|
}
|
|
}
|
|
o
|
|
}
|
|
|
|
/// A string builder for XML parts.
|
|
#[derive(Default)]
|
|
pub struct W {
|
|
pub s: String,
|
|
}
|
|
|
|
/// Attribute list builder.
|
|
#[derive(Default)]
|
|
pub struct A {
|
|
pub v: Vec<(&'static str, String)>,
|
|
}
|
|
|
|
impl A {
|
|
pub fn new() -> Self {
|
|
A { v: vec![] }
|
|
}
|
|
pub fn a(mut self, k: &'static str, v: impl ToString) -> Self {
|
|
self.v.push((k, v.to_string()));
|
|
self
|
|
}
|
|
pub fn o<T: ToString>(mut self, k: &'static str, v: Option<T>) -> Self {
|
|
if let Some(v) = v {
|
|
self.v.push((k, v.to_string()));
|
|
}
|
|
self
|
|
}
|
|
/// Boolean attribute written as `1`/`0` when `Some`.
|
|
pub fn b(mut self, k: &'static str, v: Option<bool>) -> Self {
|
|
if let Some(v) = v {
|
|
self.v.push((k, if v { "1" } else { "0" }.to_string()));
|
|
}
|
|
self
|
|
}
|
|
/// Boolean attribute written only when true.
|
|
pub fn t(mut self, k: &'static str, v: bool) -> Self {
|
|
if v {
|
|
self.v.push((k, "1".to_string()));
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
impl W {
|
|
pub fn new() -> Self {
|
|
W { s: String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n") }
|
|
}
|
|
pub fn frag() -> Self {
|
|
W { s: String::new() }
|
|
}
|
|
fn attrs(&mut self, a: &A) {
|
|
for (k, v) in &a.v {
|
|
let _ = write!(self.s, " {}=\"{}\"", k, esc_attr(v));
|
|
}
|
|
}
|
|
pub fn open(&mut self, name: &str, a: A) {
|
|
self.s.push('<');
|
|
self.s.push_str(name);
|
|
self.attrs(&a);
|
|
self.s.push('>');
|
|
}
|
|
pub fn open0(&mut self, name: &str) {
|
|
self.s.push('<');
|
|
self.s.push_str(name);
|
|
self.s.push('>');
|
|
}
|
|
pub fn close(&mut self, name: &str) {
|
|
self.s.push_str("</");
|
|
self.s.push_str(name);
|
|
self.s.push('>');
|
|
}
|
|
pub fn empty(&mut self, name: &str, a: A) {
|
|
self.s.push('<');
|
|
self.s.push_str(name);
|
|
self.attrs(&a);
|
|
self.s.push_str("/>");
|
|
}
|
|
pub fn empty0(&mut self, name: &str) {
|
|
self.s.push('<');
|
|
self.s.push_str(name);
|
|
self.s.push_str("/>");
|
|
}
|
|
/// `<name val="v"/>`
|
|
pub fn val(&mut self, name: &str, v: impl ToString) {
|
|
self.empty(name, A::new().a("val", v));
|
|
}
|
|
pub fn text(&mut self, t: &str) {
|
|
self.s.push_str(&esc(t));
|
|
}
|
|
/// `<name>text</name>`
|
|
pub fn elt(&mut self, name: &str, t: &str) {
|
|
self.open0(name);
|
|
self.text(t);
|
|
self.close(name);
|
|
}
|
|
pub fn raw(&mut self, xml: &str) {
|
|
self.s.push_str(xml);
|
|
}
|
|
pub fn finish(self) -> Vec<u8> {
|
|
self.s.into_bytes()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_basic_and_entities() {
|
|
let d = parse(b"<?xml version=\"1.0\"?><a:p xmlns:a=\"x\"><a:r><a:t>A & B A <</a:t></a:r><a:x r:id=\"rId1\" id=\"5\"/></a:p>")
|
|
.unwrap();
|
|
assert_eq!(d.root.local(), "p");
|
|
assert_eq!(d.root.find("t").map(|t| t.text()), Some("A & B A <".to_string()));
|
|
let x = d.root.child("x").unwrap();
|
|
assert_eq!(x.attr("id"), Some("5"));
|
|
assert_eq!(x.attr("r:id"), Some("rId1"));
|
|
assert_eq!(d.ns_decls().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_and_garbage() {
|
|
assert!(parse(b"<a><b><c>text").is_ok());
|
|
assert!(parse(b"").is_err());
|
|
let deep = "<a>".repeat(MAX_DEPTH + 10);
|
|
assert!(parse(deep.as_bytes()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn escape_drops_control_chars() {
|
|
assert_eq!(esc("a\u{b}b<\u{0}"), "ab<");
|
|
assert_eq!(esc_attr("\"x\"\n"), ""x" ");
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_serialize() {
|
|
let d = parse(b"<p:sp a=\"1\"><p:t>x & y</p:t><e/></p:sp>").unwrap();
|
|
assert_eq!(d.root.to_xml(), "<p:sp a=\"1\"><p:t>x & y</p:t><e/></p:sp>");
|
|
}
|
|
}
|