odox_core/xml/mod.rs
1//! The document tree: namespace-qualified names, elements, and the parser and
2//! writer that turn a part of a package into one and back.
3//!
4//! This is the substrate the rest of the crate reads through. It is a faithful
5//! tree rather than a model: attribute order, the choice between `<a/>` and
6//! `<a></a>`, comments, processing instructions and the whitespace between
7//! elements all survive a read and a write, so that a part this crate has no
8//! opinion about is returned unchanged.
9//!
10//! Text is held *unescaped*, and escaped again on the way out in the canonical
11//! form. Byte identity across a round trip is therefore not claimed; what is
12//! claimed, and what `tests/roundtrip.rs` measures, is that parsing the output
13//! gives back an equal tree.
14//
15// Author: David M. Anderson
16// Built with AI assistance (Claude, Anthropic)
17
18mod read;
19mod write;
20
21/// An XML namespace, resolved from the URI it was declared with.
22///
23/// ODF spells every one of these with a conventional prefix and an invariant
24/// URI. Matching is on this rather than on the prefix, because the prefix is the
25/// document's choice and the URI is the format's.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub enum Ns {
28 /// `urn:oasis:names:tc:opendocument:xmlns:office:1.0`
29 Office,
30 /// `urn:oasis:names:tc:opendocument:xmlns:text:1.0`
31 Text,
32 /// `urn:oasis:names:tc:opendocument:xmlns:style:1.0`
33 Style,
34 /// `urn:oasis:names:tc:opendocument:xmlns:table:1.0`
35 Table,
36 /// `urn:oasis:names:tc:opendocument:xmlns:drawing:1.0`
37 Draw,
38 /// `urn:oasis:names:tc:opendocument:xmlns:presentation:1.0`
39 Presentation,
40 /// `urn:oasis:names:tc:opendocument:xmlns:chart:1.0`
41 Chart,
42 /// `urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0`, which is
43 /// where most of the formatting attributes live.
44 Fo,
45 /// `urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0`
46 Svg,
47 /// `urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0`
48 Number,
49 /// `urn:oasis:names:tc:opendocument:xmlns:meta:1.0`
50 Meta,
51 /// `urn:oasis:names:tc:opendocument:xmlns:manifest:1.0`
52 Manifest,
53 /// `urn:oasis:names:tc:opendocument:xmlns:config:1.0`
54 Config,
55 /// `urn:oasis:names:tc:opendocument:xmlns:of:1.2`, the formula namespace.
56 Of,
57 /// `http://www.w3.org/1999/xlink`
58 Xlink,
59 /// `http://purl.org/dc/elements/1.1/`
60 Dc,
61 /// `urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0`
62 Loext,
63 /// `urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0`
64 Calcext,
65 /// A namespace declaration: `xmlns:foo="..."`. Held as an attribute so that
66 /// writing a tree re-declares exactly what reading it found.
67 Xmlns,
68 /// A namespace this crate has no name for. The URI is kept so that the
69 /// element is written back into the namespace it came from.
70 Other(Box<str>),
71 /// No namespace: an unprefixed name with no default declaration in scope.
72 None,
73}
74
75impl Ns {
76 /// The namespace a URI denotes.
77 pub fn from_uri(uri: &str) -> Self {
78 match uri {
79 "urn:oasis:names:tc:opendocument:xmlns:office:1.0" => Self::Office,
80 "urn:oasis:names:tc:opendocument:xmlns:text:1.0" => Self::Text,
81 "urn:oasis:names:tc:opendocument:xmlns:style:1.0" => Self::Style,
82 "urn:oasis:names:tc:opendocument:xmlns:table:1.0" => Self::Table,
83 "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" => Self::Draw,
84 "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" => Self::Presentation,
85 "urn:oasis:names:tc:opendocument:xmlns:chart:1.0" => Self::Chart,
86 "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" => Self::Fo,
87 "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" => Self::Svg,
88 "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" => Self::Number,
89 "urn:oasis:names:tc:opendocument:xmlns:meta:1.0" => Self::Meta,
90 "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" => Self::Manifest,
91 "urn:oasis:names:tc:opendocument:xmlns:config:1.0" => Self::Config,
92 "urn:oasis:names:tc:opendocument:xmlns:of:1.2" => Self::Of,
93 "http://www.w3.org/1999/xlink" => Self::Xlink,
94 "http://purl.org/dc/elements/1.1/" => Self::Dc,
95 "urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" => Self::Loext,
96 "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" => Self::Calcext,
97 other => Self::Other(other.into()),
98 }
99 }
100}
101
102/// A name as it appeared, and the namespace it resolved to.
103///
104/// The prefix is kept alongside the resolved namespace because writing is a
105/// faithful re-emission: a document that spells the text namespace `t:` rather
106/// than `text:` is written back its own way.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Name {
109 /// The prefix as written, without the colon. `None` for an unprefixed name.
110 pub prefix: Option<Box<str>>,
111 /// The part after the colon.
112 pub local: Box<str>,
113 /// Where the prefix pointed.
114 pub ns: Ns,
115}
116
117impl Name {
118 /// Whether this is the given ODF name.
119 pub fn is(&self, ns: &Ns, local: &str) -> bool {
120 self.ns == *ns && &*self.local == local
121 }
122}
123
124/// An attribute and its value, with the value unescaped.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct Attribute {
127 /// The attribute's name.
128 pub name: Name,
129 /// Its value, unescaped.
130 pub value: String,
131}
132
133/// One node of a parsed part.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum Node {
136 /// An element and everything under it.
137 Element(Element),
138 /// Character data, unescaped. Whitespace is significant inside a paragraph
139 /// and is never trimmed here.
140 Text(String),
141 /// `<![CDATA[...]]>`, kept as its own kind so that it is written back as one.
142 CData(String),
143 /// `<!-- ... -->`.
144 Comment(String),
145 /// `<?target content?>`, excluding the XML declaration.
146 ProcessingInstruction(String),
147}
148
149/// An element: its name, its attributes in the order they were written, and its
150/// children.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Element {
153 /// The element's name.
154 pub name: Name,
155 /// Attributes in document order, including any namespace declarations.
156 pub attrs: Vec<Attribute>,
157 /// Children in document order.
158 pub children: Vec<Node>,
159 /// True when the element was written `<a/>`. Only consulted when there are
160 /// no children, and exists so that a round trip does not rewrite every
161 /// empty element in the document.
162 pub self_closing: bool,
163}
164
165impl Element {
166 /// A new element with no attributes and no children.
167 pub fn new(prefix: &str, local: &str, ns: Ns) -> Self {
168 Self {
169 name: Name {
170 prefix: if prefix.is_empty() {
171 None
172 } else {
173 Some(prefix.into())
174 },
175 local: local.into(),
176 ns,
177 },
178 attrs: Vec::new(),
179 children: Vec::new(),
180 self_closing: true,
181 }
182 }
183
184 /// Whether this element has the given ODF name.
185 pub fn is(&self, ns: &Ns, local: &str) -> bool {
186 self.name.is(ns, local)
187 }
188
189 /// The value of an attribute, by namespace and local name.
190 pub fn attr(&self, ns: &Ns, local: &str) -> Option<&str> {
191 self.attrs
192 .iter()
193 .find(|a| a.name.is(ns, local))
194 .map(|a| a.value.as_str())
195 }
196
197 /// The value of an attribute, read as an integer. Absent, or present and
198 /// unreadable, are the same answer: ODF's own default applies either way and
199 /// a document that says `table:number-columns-repeated="many"` is not a
200 /// reason to refuse the document.
201 pub fn attr_usize(&self, ns: &Ns, local: &str) -> Option<usize> {
202 self.attr(ns, local)?.trim().parse().ok()
203 }
204
205 /// Every child element, in order.
206 pub fn elements(&self) -> impl Iterator<Item = &Element> {
207 self.children.iter().filter_map(|n| match n {
208 Node::Element(e) => Some(e),
209 _ => None,
210 })
211 }
212
213 /// The first child element with the given name.
214 pub fn child(&self, ns: &Ns, local: &str) -> Option<&Element> {
215 self.elements().find(|e| e.is(ns, local))
216 }
217
218 /// The first descendant element with the given name, depth first.
219 pub fn descendant(&self, ns: &Ns, local: &str) -> Option<&Element> {
220 for e in self.elements() {
221 if e.is(ns, local) {
222 return Some(e);
223 }
224 if let Some(found) = e.descendant(ns, local) {
225 return Some(found);
226 }
227 }
228 None
229 }
230
231 /// Every character of text under this element, with ODF's own whitespace
232 /// elements turned back into the characters they stand for.
233 ///
234 /// `text:s` is a run of spaces, `text:tab` a tab and `text:line-break` a
235 /// newline, because ODF collapses literal runs of whitespace and spells out
236 /// what it means instead. This is what a spreadsheet cell's displayed string
237 /// and a search over a document both read.
238 pub fn plain_text(&self) -> String {
239 let mut out = String::new();
240 self.write_plain_text(&mut out);
241 out
242 }
243
244 fn write_plain_text(&self, out: &mut String) {
245 for child in &self.children {
246 match child {
247 Node::Text(t) | Node::CData(t) => out.push_str(t),
248 Node::Element(e) if e.is(&Ns::Text, "s") => {
249 let n = e.attr_usize(&Ns::Text, "c").unwrap_or(1);
250 for _ in 0..n {
251 out.push(' ');
252 }
253 }
254 Node::Element(e) if e.is(&Ns::Text, "tab") => out.push('\t'),
255 Node::Element(e) if e.is(&Ns::Text, "line-break") => out.push('\n'),
256 // A note's body is not part of the text it hangs off, and
257 // neither is the deleted half of a tracked change.
258 Node::Element(e) if e.is(&Ns::Text, "note") => {}
259 Node::Element(e) => e.write_plain_text(out),
260 Node::Comment(_) | Node::ProcessingInstruction(_) => {}
261 }
262 }
263 }
264}
265
266pub use read::parse;
267pub use write::serialize;