mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
713 lines
32 KiB
Rust
713 lines
32 KiB
Rust
//! A small, bounded XML tree read with `craft-xml`, plus a string-building XML writer.
|
||
//!
|
||
//! Element and attribute names are rewritten to *canonical prefixes* by namespace URI (`w:p`,
|
||
//! `r:id`, `a:blip`…), so the reader doesn't care which prefixes a producer chose. Names in
|
||
//! unknown namespaces keep their local name behind a `?:` prefix.
|
||
|
||
use crate::DocxError;
|
||
|
||
/// Maximum element nesting accepted by the parser.
|
||
pub const MAX_DEPTH: usize = 256;
|
||
/// Maximum number of elements in one part.
|
||
pub const MAX_ELEMENTS: usize = 8_000_000;
|
||
/// Maximum number of attributes on one element (#3702).
|
||
pub const MAX_ATTRS: usize = 1024;
|
||
|
||
/// Known namespaces and the canonical prefix we use for them (read and write).
|
||
pub const NAMESPACES: &[(&str, &str)] = &[
|
||
("w", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"),
|
||
("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
|
||
("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"),
|
||
("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
|
||
("c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
|
||
("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"),
|
||
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||
("w14", "http://schemas.microsoft.com/office/word/2010/wordml"),
|
||
("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
|
||
("w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
|
||
("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"),
|
||
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
|
||
("wpg", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"),
|
||
("wpc", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"),
|
||
("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
|
||
("dsp", "http://schemas.microsoft.com/office/drawing/2008/diagram"),
|
||
("m", "http://schemas.openxmlformats.org/officeDocument/2006/math"),
|
||
("v", "urn:schemas-microsoft-com:vml"),
|
||
("o", "urn:schemas-microsoft-com:office:office"),
|
||
("w10", "urn:schemas-microsoft-com:office:word"),
|
||
// Office 2016 charts (`cx:chart`), and the namespaces their `mc:Choice` requires.
|
||
("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"),
|
||
("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"),
|
||
("cx2", "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"),
|
||
("cx3", "http://schemas.microsoft.com/office/drawing/2016/5/9/chartex"),
|
||
("cx4", "http://schemas.microsoft.com/office/drawing/2016/5/10/chartex"),
|
||
("cx5", "http://schemas.microsoft.com/office/drawing/2016/5/11/chartex"),
|
||
("cx6", "http://schemas.microsoft.com/office/drawing/2016/5/12/chartex"),
|
||
("cx7", "http://schemas.microsoft.com/office/drawing/2016/5/13/chartex"),
|
||
("cx8", "http://schemas.microsoft.com/office/drawing/2016/5/14/chartex"),
|
||
];
|
||
|
||
/// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the
|
||
/// namespace of its `adec:decorative` element.
|
||
pub const DECORATIVE_EXT: &str = "{C183D7F6-B498-43B3-948B-1728B52AA6E4}";
|
||
pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/2017/decorative";
|
||
|
||
/// Namespaces that only appear in non-body parts.
|
||
const OTHER_NAMESPACES: &[(&str, &str)] = &[
|
||
("b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||
("rel", "http://schemas.openxmlformats.org/package/2006/relationships"),
|
||
("ct", "http://schemas.openxmlformats.org/package/2006/content-types"),
|
||
("cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
|
||
("dc", "http://purl.org/dc/elements/1.1/"),
|
||
("dcterms", "http://purl.org/dc/terms/"),
|
||
("xsi", "http://www.w3.org/2001/XMLSchema-instance"),
|
||
("xml", "http://www.w3.org/XML/1998/namespace"),
|
||
("ep", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"),
|
||
("op", "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"),
|
||
("vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"),
|
||
];
|
||
|
||
/// ISO/IEC 29500 Strict namespaces map onto the transitional prefixes.
|
||
const STRICT: &[(&str, &str)] = &[
|
||
("w", "http://purl.oclc.org/ooxml/wordprocessingml/main"),
|
||
("r", "http://purl.oclc.org/ooxml/officeDocument/relationships"),
|
||
("wp", "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing"),
|
||
("a", "http://purl.oclc.org/ooxml/drawingml/main"),
|
||
("pic", "http://purl.oclc.org/ooxml/drawingml/picture"),
|
||
("m", "http://purl.oclc.org/ooxml/officeDocument/math"),
|
||
];
|
||
|
||
fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||
if let Some((p, _)) = extra.iter().find(|(_, u)| u.as_bytes() == uri) {
|
||
return p;
|
||
}
|
||
for (p, u) in NAMESPACES.iter().chain(OTHER_NAMESPACES).chain(STRICT) {
|
||
if u.as_bytes() == uri {
|
||
return p;
|
||
}
|
||
}
|
||
"?"
|
||
}
|
||
|
||
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
|
||
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
|
||
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
|
||
|
||
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
|
||
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
|
||
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
|
||
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
|
||
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
|
||
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
|
||
match (parent, local) {
|
||
(_, "wsp") => "wps",
|
||
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
|
||
(_, "wpc") => "wpc",
|
||
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
|
||
(Some("wps:txbx"), "txbxContent") => "w",
|
||
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
|
||
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
|
||
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
|
||
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
|
||
_ => "wp",
|
||
}
|
||
}
|
||
|
||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||
let prefix = prefix_for(uri, &[]);
|
||
if prefix != "?" {
|
||
return prefix;
|
||
}
|
||
match uri {
|
||
b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14",
|
||
b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se",
|
||
b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid",
|
||
b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16",
|
||
b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex",
|
||
b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh",
|
||
_ => "?",
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Node {
|
||
El(El),
|
||
Text(String),
|
||
}
|
||
|
||
/// Elements whose markup names a relationship: the id is in an attribute of the relationship
|
||
/// namespace (`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`…) or VML's `o:relid`, and — from a
|
||
/// producer that left the prefix out — in the unqualified attribute of the same local name, the
|
||
/// alias [`El::attr`] accepts. On any other element an unqualified `id` is the element's own
|
||
/// (a drawing's `wp:docPr`, a picture's `pic:cNvPr`, a VML shape, a page background) and names
|
||
/// no part.
|
||
const REL_ELEMENTS: &[&str] = &[
|
||
"a:blip",
|
||
"a:hlinkClick",
|
||
"a:hlinkHover",
|
||
"a:audioFile",
|
||
"a:videoFile",
|
||
"c:chart",
|
||
"cx:chart",
|
||
"dgm:relIds",
|
||
"o:OLEObject",
|
||
"v:fill",
|
||
"v:imagedata",
|
||
"w:attachedTemplate",
|
||
"w:control",
|
||
"w:embedBold",
|
||
"w:embedBoldItalic",
|
||
"w:embedItalic",
|
||
"w:embedRegular",
|
||
"w:footerReference",
|
||
"w:headerReference",
|
||
"w:hyperlink",
|
||
"w:object",
|
||
"w:subDoc",
|
||
"w14:contentPart",
|
||
];
|
||
|
||
/// The local names a relationship attribute has, so an unqualified one is recognised as the
|
||
/// relationship id it names.
|
||
const REL_ALIASES: &[&str] = &["id", "embed", "link", "pict", "dm", "lo", "qs", "cs", "relid"];
|
||
|
||
/// Is the attribute `k` of the element named `element` a relationship id?
|
||
///
|
||
/// The relationship namespace (`r:`) and VML's `o:relid` always are. An unqualified attribute
|
||
/// is one only on an element that carries a relationship ([`REL_ELEMENTS`]) and only with the
|
||
/// local name of a relationship attribute ([`REL_ALIASES`]) — the alias [`El::attr`] accepts.
|
||
/// Everywhere else an unqualified `id` is the element's own and names no part.
|
||
pub fn is_rel_attr(element: &str, k: &str) -> bool {
|
||
k.starts_with("r:") || k == "o:relid" || (REL_ELEMENTS.contains(&element) && REL_ALIASES.contains(&k))
|
||
}
|
||
|
||
/// An element with canonical-prefixed name and attributes.
|
||
#[derive(Clone, Debug, PartialEq, Default)]
|
||
pub struct El {
|
||
pub name: String,
|
||
pub attrs: Vec<(String, String)>,
|
||
pub kids: Vec<Node>,
|
||
}
|
||
|
||
impl El {
|
||
/// Attribute by canonical name (`w:val`); falls back to an unqualified attribute with the
|
||
/// same local name (some producers omit the prefix).
|
||
pub fn attr(&self, name: &str) -> Option<&str> {
|
||
if let Some((_, v)) = self.attrs.iter().find(|(k, _)| k == name) {
|
||
return Some(v);
|
||
}
|
||
let local = name.rsplit(':').next().unwrap_or(name);
|
||
if local != name {
|
||
return self.attrs.iter().find(|(k, _)| k == local).map(|(_, v)| v.as_str());
|
||
}
|
||
None
|
||
}
|
||
/// Child elements.
|
||
pub fn els(&self) -> impl Iterator<Item = &El> {
|
||
self.kids.iter().filter_map(|n| match n {
|
||
Node::El(e) => Some(e),
|
||
Node::Text(_) => None,
|
||
})
|
||
}
|
||
pub fn child(&self, name: &str) -> Option<&El> {
|
||
self.els().find(|e| e.name == name)
|
||
}
|
||
pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a El> + 'a {
|
||
self.els().filter(move |e| e.name == name)
|
||
}
|
||
/// `w:val` of a child.
|
||
pub fn child_val(&self, name: &str) -> Option<&str> {
|
||
self.child(name).and_then(|c| c.attr("w:val"))
|
||
}
|
||
/// Direct text content.
|
||
pub fn text(&self) -> String {
|
||
let mut s = String::new();
|
||
for n in &self.kids {
|
||
if let Node::Text(t) = n {
|
||
s.push_str(t);
|
||
}
|
||
}
|
||
s
|
||
}
|
||
/// All descendant text (depth-bounded by the parser).
|
||
pub fn deep_text(&self) -> String {
|
||
let mut s = String::new();
|
||
fn go(e: &El, s: &mut String, depth: usize) {
|
||
if depth > MAX_DEPTH {
|
||
return;
|
||
}
|
||
for n in &e.kids {
|
||
match n {
|
||
Node::Text(t) => s.push_str(t),
|
||
Node::El(c) => go(c, s, depth + 1),
|
||
}
|
||
}
|
||
}
|
||
go(self, &mut s, 0);
|
||
s
|
||
}
|
||
/// First descendant with this name (depth-first, bounded).
|
||
pub fn find(&self, name: &str) -> Option<&El> {
|
||
fn go<'a>(e: &'a El, name: &str, depth: usize) -> Option<&'a El> {
|
||
if depth > MAX_DEPTH {
|
||
return None;
|
||
}
|
||
for c in e.els() {
|
||
if c.name == name {
|
||
return Some(c);
|
||
}
|
||
if let Some(f) = go(c, name, depth + 1) {
|
||
return Some(f);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
go(self, name, 0)
|
||
}
|
||
/// The element as XML with canonical prefixes (for re-emitting kept content). Elements and
|
||
/// attributes in unknown namespaces are dropped.
|
||
pub fn to_xml(&self) -> String {
|
||
fn go(e: &El, s: &mut String, depth: usize) {
|
||
if depth > MAX_DEPTH || e.name.starts_with("?:") {
|
||
return;
|
||
}
|
||
s.push('<');
|
||
s.push_str(&e.name);
|
||
for (k, v) in &e.attrs {
|
||
if k.starts_with("?:") {
|
||
continue;
|
||
}
|
||
s.push(' ');
|
||
s.push_str(k);
|
||
s.push_str("=\"");
|
||
s.push_str(&esc(v));
|
||
s.push('"');
|
||
}
|
||
if e.kids.is_empty() {
|
||
s.push_str("/>");
|
||
return;
|
||
}
|
||
s.push('>');
|
||
for n in &e.kids {
|
||
match n {
|
||
Node::Text(t) => s.push_str(&esc(t)),
|
||
Node::El(c) => go(c, s, depth + 1),
|
||
}
|
||
}
|
||
s.push_str("</");
|
||
s.push_str(&e.name);
|
||
s.push('>');
|
||
}
|
||
let mut s = String::new();
|
||
go(self, &mut s, 0);
|
||
s
|
||
}
|
||
/// Local part of the name.
|
||
pub fn local(&self) -> &str {
|
||
self.name.rsplit(':').next().unwrap_or(&self.name)
|
||
}
|
||
}
|
||
|
||
/// UTF-16 LE/BE text with a byte-order mark as UTF-8 (`None` without one). Lossy: an unpaired
|
||
/// surrogate becomes U+FFFD and a trailing odd byte is dropped. The result is at most 1.5× the input.
|
||
pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
|
||
let (rest, le) = match bytes.get(..2) {
|
||
Some([0xFF, 0xFE]) => (bytes.get(2..)?, true),
|
||
Some([0xFE, 0xFF]) => (bytes.get(2..)?, false),
|
||
_ => return None,
|
||
};
|
||
let units = rest.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
|
||
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
|
||
}
|
||
|
||
/// Parse XML bytes into the root element.
|
||
pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||
parse_ns(bytes, &[])
|
||
}
|
||
|
||
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
|
||
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
|
||
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
|
||
parse_with(bytes, extra, false)
|
||
}
|
||
|
||
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
|
||
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
|
||
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
|
||
parse_with(bytes, &[], true)
|
||
}
|
||
|
||
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
|
||
let namespaces: Vec<(&str, &str)> =
|
||
extra.iter().copied().chain(NAMESPACES.iter().copied()).chain(OTHER_NAMESPACES.iter().copied()).chain(STRICT.iter().copied()).collect();
|
||
// The prefix of an element: its namespace's, with two exceptions for Strict documents
|
||
// (wordcraft#3548): the WordprocessingDrawing namespace reads as Word 2010's several
|
||
// ([`drawing_prefix`]), and Word's `wne:txbxContent` in a shape's text box reads as
|
||
// `w:txbxContent`.
|
||
let element = |n: &craft_xml::NsName<'_>| -> Option<&'static str> {
|
||
match n.prefix {
|
||
"wp" => Some(drawing_prefix(n.local, n.parent)),
|
||
"?" if n.uri.as_bytes() == WNE && n.local == "txbxContent" && n.parent == Some("wps:txbx") => Some("w"),
|
||
_ => None,
|
||
}
|
||
};
|
||
// Requires holds namespace prefixes, not feature names: resolved while the scope exists.
|
||
let requires = |el: &str, attr: &str, value: &str, scope: &craft_xml::Scope<'_>| -> Option<String> {
|
||
if el != "mc:Choice" || attr != "Requires" {
|
||
return None;
|
||
}
|
||
let requirements: Vec<_> = value.split_whitespace().take(65).collect();
|
||
if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) {
|
||
return Some("?".to_string());
|
||
}
|
||
Some(requirements.into_iter().map(|p| scope.uri(p).map_or("?", |u| requirement_prefix(u.as_bytes()))).collect::<Vec<_>>().join(" "))
|
||
};
|
||
let canonical = craft_xml::Canonical { namespaces: &namespaces, unknown: "?", element: Some(&element), attr_value: Some(&requires) };
|
||
let limits = craft_xml::Limits {
|
||
// The flat (single-file) form of a package may be larger than any one part.
|
||
max_input: 2 << 30,
|
||
// `MAX_DEPTH` open elements, and an empty element inside the deepest (craft-xml counts the
|
||
// empty one as a level).
|
||
max_depth: MAX_DEPTH + 1,
|
||
// Text stretches count as nodes too: two for each element.
|
||
max_nodes: 2 * MAX_ELEMENTS,
|
||
max_elements: MAX_ELEMENTS,
|
||
max_attrs: MAX_ATTRS,
|
||
max_attrs_total: 2 * MAX_ELEMENTS,
|
||
};
|
||
let mut options = craft_xml::Options::canonical(&canonical).with_limits(limits);
|
||
if strict {
|
||
options = options.strict();
|
||
}
|
||
let root = options.parse(bytes).map_err(|e| if e.is_limit() { DocxError::Limit(e.to_string()) } else { DocxError::Xml(e.to_string()) })?;
|
||
Ok(from_tree(root))
|
||
}
|
||
|
||
/// The `craft-xml` tree as this module's (whose shape the reader was written against).
|
||
// shortcut: one copy of each parsed part; read craft_xml::El directly when the reader is ported to it.
|
||
fn from_tree(e: craft_xml::El) -> El {
|
||
let mut kids = Vec::with_capacity(e.children.len());
|
||
for n in e.children {
|
||
match n {
|
||
craft_xml::Node::El(c) => kids.push(Node::El(from_tree(c))),
|
||
craft_xml::Node::Text(t) | craft_xml::Node::CData(t) => match kids.last_mut() {
|
||
Some(Node::Text(prev)) => prev.push_str(&t),
|
||
_ => kids.push(Node::Text(t)),
|
||
},
|
||
craft_xml::Node::Pi { .. } | craft_xml::Node::Comment(_) => {}
|
||
}
|
||
}
|
||
El { name: e.name, attrs: e.attrs, kids }
|
||
}
|
||
|
||
/// This module's tree as `craft-xml`'s, for the shared readers (`craft-drawingml`, `craft-math`)
|
||
/// that take one. Both use the canonical prefixes.
|
||
// shortcut: copies the subtree; goes when the reader reads craft_xml::El directly.
|
||
pub(crate) fn to_craft(e: &El) -> craft_xml::El {
|
||
let mut out = craft_xml::El { name: e.name.clone(), attrs: e.attrs.clone(), children: Vec::with_capacity(e.kids.len()) };
|
||
for k in &e.kids {
|
||
out.children.push(match k {
|
||
Node::El(c) => craft_xml::Node::El(to_craft(c)),
|
||
Node::Text(t) => craft_xml::Node::Text(t.clone()),
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Is `c` allowed in XML 1.0 text?
|
||
fn xml_char_ok(c: char) -> bool {
|
||
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||
}
|
||
|
||
/// Escape text for element content or attribute values (drops characters XML can't carry).
|
||
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(">"),
|
||
'"' => o.push_str("""),
|
||
'\'' => o.push_str("'"),
|
||
'\t' => o.push_str("	"),
|
||
'\n' => o.push_str(" "),
|
||
'\r' => o.push_str(" "),
|
||
c if xml_char_ok(c) => o.push(c),
|
||
_ => {}
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
/// A string-building XML writer.
|
||
#[derive(Default)]
|
||
pub struct W {
|
||
pub s: String,
|
||
}
|
||
|
||
impl W {
|
||
pub fn new() -> W {
|
||
W { s: String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n") }
|
||
}
|
||
fn attrs(&mut self, attrs: &[(&str, &str)]) {
|
||
for (k, v) in attrs {
|
||
self.s.push(' ');
|
||
self.s.push_str(k);
|
||
self.s.push_str("=\"");
|
||
self.s.push_str(&esc(v));
|
||
self.s.push('"');
|
||
}
|
||
}
|
||
pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||
self.s.push('<');
|
||
self.s.push_str(name);
|
||
self.attrs(attrs);
|
||
self.s.push('>');
|
||
}
|
||
pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||
self.s.push('<');
|
||
self.s.push_str(name);
|
||
self.attrs(attrs);
|
||
self.s.push_str("/>");
|
||
}
|
||
pub fn close(&mut self, name: &str) {
|
||
self.s.push_str("</");
|
||
self.s.push_str(name);
|
||
self.s.push('>');
|
||
}
|
||
pub fn text(&mut self, t: &str) {
|
||
self.s.push_str(&esc(t));
|
||
}
|
||
/// `<name>text</name>`.
|
||
pub fn leaf(&mut self, name: &str, attrs: &[(&str, &str)], t: &str) {
|
||
self.open(name, attrs);
|
||
self.text(t);
|
||
self.close(name);
|
||
}
|
||
/// `<name w:val="v"/>`.
|
||
pub fn val(&mut self, name: &str, v: &str) {
|
||
self.empty(name, &[("w:val", v)]);
|
||
}
|
||
pub fn raw(&mut self, s: &str) {
|
||
self.s.push_str(s);
|
||
}
|
||
pub fn into_bytes(self) -> Vec<u8> {
|
||
self.s.into_bytes()
|
||
}
|
||
}
|
||
|
||
/// `xmlns:` declarations for the body-part namespaces, plus `mc:Ignorable`.
|
||
pub fn body_ns() -> Vec<(String, String)> {
|
||
let mut v: Vec<(String, String)> = NAMESPACES.iter().map(|(p, u)| (format!("xmlns:{p}"), (*u).to_string())).collect();
|
||
v.push(("mc:Ignorable".into(), "w14 w15 wp14".into()));
|
||
v
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
|
||
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
|
||
for xml in bad {
|
||
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
|
||
}
|
||
assert!(
|
||
parse_strict(
|
||
b"<a>x & y</a>
|
||
"
|
||
)
|
||
.is_ok()
|
||
);
|
||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||
}
|
||
|
||
/// Every element name in `e`, depth first.
|
||
fn names(e: &El, out: &mut Vec<String>) {
|
||
out.push(e.name.clone());
|
||
for c in e.els() {
|
||
names(c, out);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn strict_drawing_names_read_as_word_2010_names() {
|
||
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
|
||
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
|
||
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
|
||
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
|
||
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
|
||
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
|
||
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
|
||
</a:graphicData></a:graphic></d:anchor>
|
||
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
|
||
let mut got = Vec::new();
|
||
names(&parse(xml).unwrap(), &mut got);
|
||
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
|
||
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
|
||
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
|
||
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
|
||
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
|
||
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
|
||
assert_eq!(got.join(" "), want);
|
||
// The Transitional namespaces read as before.
|
||
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
|
||
let mut got = Vec::new();
|
||
names(&parse(xml).unwrap(), &mut got);
|
||
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
|
||
}
|
||
|
||
#[test]
|
||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||
// The relationship namespace, and VML's `o:relid`.
|
||
assert!(is_rel_attr("w:hyperlink", "r:id"));
|
||
assert!(is_rel_attr("v:imagedata", "o:relid"));
|
||
// The unqualified alias `El::attr` accepts, on an element that carries a relationship.
|
||
assert!(is_rel_attr("w:hyperlink", "id"));
|
||
assert!(is_rel_attr("v:imagedata", "id"));
|
||
assert!(is_rel_attr("a:blip", "embed"));
|
||
assert!(is_rel_attr("dgm:relIds", "dm"));
|
||
// An ordinary `id` — the element's own, naming no part — is not a relationship.
|
||
assert!(!is_rel_attr("v:shape", "id"));
|
||
assert!(!is_rel_attr("pic:cNvPr", "id"));
|
||
assert!(!is_rel_attr("wp:docPr", "id"));
|
||
assert!(!is_rel_attr("v:background", "id"));
|
||
// An attribute that is not a relationship attribute at all.
|
||
assert!(!is_rel_attr("w:hyperlink", "anchor"));
|
||
assert!(!is_rel_attr("v:imagedata", "title"));
|
||
}
|
||
|
||
#[test]
|
||
fn utf16_parts_parse_and_odd_ones_never_panic() {
|
||
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
|
||
for le in [true, false] {
|
||
let mut b = if le { vec![0xFF, 0xFE] } else { vec![0xFE, 0xFF] };
|
||
b.extend(src.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }));
|
||
let root = parse(&b).unwrap();
|
||
assert_eq!(root.els().next().and_then(|e| e.attr("Target")), Some("café😀.xml"), "le {le}");
|
||
}
|
||
// Odd length, unpaired surrogates, a bare BOM: an error or a tree, never a panic.
|
||
for b in
|
||
[&[0xFF, 0xFE][..], &[0xFE, 0xFF, 0x00], &[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'a', 0, b'/', 0, b'>', 0], &[0xFF, 0xFE, 0x00, 0xDC, 0x00]]
|
||
{
|
||
let _ = parse(b);
|
||
}
|
||
assert_eq!(utf16_to_utf8(&[0xFF, 0xFE, b'a', 0, 0x00, 0xD8, b'b']).as_deref(), Some("a\u{FFFD}"));
|
||
assert_eq!(utf16_to_utf8(b"<a/>"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_with_namespaces() {
|
||
let x = br#"<?xml version="1.0"?><x:document xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:rr="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><x:p rr:id="r1" x:val="a&b"><x:t xml:space="preserve"> hi A< </x:t></x:p></x:document>"#;
|
||
let e = parse(x).unwrap();
|
||
assert_eq!(e.name, "w:document");
|
||
let p = e.child("w:p").unwrap();
|
||
assert_eq!(p.attr("r:id"), Some("r1"));
|
||
assert_eq!(p.attr("w:val"), Some("a&b"));
|
||
let t = p.child("w:t").unwrap();
|
||
assert_eq!(t.text(), " hi A< ");
|
||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||
}
|
||
|
||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||
#[test]
|
||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||
let x =
|
||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||
}
|
||
|
||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||
#[test]
|
||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||
let e = parse(x).unwrap();
|
||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||
// The writer escapes a CR, so a CR in the text survives a save.
|
||
let x = format!(
|
||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||
esc("a\r\nb\rc")
|
||
);
|
||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||
}
|
||
|
||
#[test]
|
||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||
let e = parse(x).unwrap();
|
||
let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect();
|
||
assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]);
|
||
}
|
||
|
||
#[test]
|
||
fn deep_nesting_is_error() {
|
||
let mut s = String::new();
|
||
for _ in 0..1000 {
|
||
s.push_str("<a>");
|
||
}
|
||
assert!(parse(s.as_bytes()).is_err());
|
||
}
|
||
|
||
/// A start tag with thousands of `xmlns:` declarations is refused instead of allocated
|
||
/// (RUSTSEC-2026-0195: unbounded namespace bindings in the resolver).
|
||
#[test]
|
||
fn too_many_namespace_declarations_is_error() {
|
||
let mut s = String::from("<a xmlns:a0=\"urn:a0\"");
|
||
for i in 1..10_000 {
|
||
s.push_str(&format!(" xmlns:a{i}=\"urn:a{i}\""));
|
||
}
|
||
s.push_str("><b/></a>");
|
||
assert!(parse(s.as_bytes()).is_err());
|
||
}
|
||
|
||
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
|
||
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
|
||
#[test]
|
||
fn too_many_attributes_on_one_element_is_capped() {
|
||
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
|
||
const CAP: usize = 1024;
|
||
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
|
||
let duplicate = " a=\"\"".repeat(200_000);
|
||
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
|
||
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
|
||
match parse(x.as_bytes()) {
|
||
Err(DocxError::Limit(_)) => {}
|
||
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
|
||
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
|
||
}
|
||
}
|
||
// Real-world attribute counts are untouched.
|
||
let x = format!(
|
||
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
|
||
NAMESPACES[0].1
|
||
);
|
||
let p = parse(x.as_bytes()).unwrap();
|
||
assert_eq!(p.attrs.len(), 4);
|
||
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
|
||
assert_eq!(fonts.attrs.len(), 9);
|
||
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
|
||
// Far more than any real producer writes, still well under any sane cap.
|
||
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
|
||
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
|
||
assert_eq!(p.attrs.len(), 256);
|
||
assert_eq!(p.attr("a255"), Some("255"));
|
||
}
|
||
|
||
#[test]
|
||
fn garbage_is_error_not_panic() {
|
||
assert!(parse(b"").is_err());
|
||
assert!(parse(b"\xff\xfe<").is_err());
|
||
let _ = parse(b"<a><b></a>");
|
||
let _ = parse(b"<a x='1' x='2'/>");
|
||
}
|
||
|
||
#[test]
|
||
fn escapes() {
|
||
assert_eq!(esc("a<b&\"\u{1}"), "a<b&"");
|
||
}
|
||
}
|