xml_core/reader.rs
1//! Low-level XML reading.
2
3use quick_xml::events::Event;
4
5use crate::error::{Error, Result};
6
7/// Reads a sequence of generic XML events from an in-memory UTF-8 string.
8///
9/// This is a thin wrapper around [`quick_xml::Reader`], scoped to `xml-core`'s
10/// own error type so that callers never need to depend on `quick_xml`
11/// directly.
12///
13/// # Whitespace handling
14///
15/// Unlike some XML tooling, this reader does **not** trim whitespace from
16/// text nodes automatically. Whether leading/trailing whitespace inside an
17/// element is significant depends on the vocabulary being parsed (for
18/// example, `xml:space="preserve"` in WordprocessingML) — a decision that
19/// belongs to the higher-level crates, not to this generic layer.
20///
21/// # Scope
22///
23/// This first version only supports reading from an in-memory `&str`. Real
24/// OOXML parts are read fully into memory from their ZIP entry before
25/// parsing anyway, so this covers the toolkit's actual needs; streaming
26/// from an arbitrary [`std::io::Read`] source can be added later if needed.
27pub struct Reader<'a> {
28 inner: quick_xml::Reader<&'a [u8]>,
29}
30
31impl<'a> Reader<'a> {
32 /// Creates a new reader over the given XML string.
33 pub fn from_xml_str(xml: &'a str) -> Self {
34 Self {
35 inner: quick_xml::Reader::from_str(xml),
36 }
37 }
38
39 /// Reads the next XML event.
40 ///
41 /// Returns `Ok(Event::Eof)` once the end of the input has been reached.
42 pub fn read_event(&mut self) -> Result<Event<'a>> {
43 self.inner.read_event().map_err(Error::from)
44 }
45}
46
47/// Decodes an XML text event's content: character-encoding decoding (e.g.
48/// UTF-16 to UTF-8) via [`quick_xml::events::BytesText::decode`], plus a
49/// defensive `unescape()` pass in case any literal `&...;` survived in the
50/// event's own bytes (see below for why that's normally not needed).
51///
52/// # Why a `Text` event alone isn't the whole story
53///
54/// Despite what a quick read of `BytesText::decode`'s doc comment might
55/// suggest, a `quick_xml` `Reader` does **not** hand back one `Event::Text`
56/// containing an entire text node's escaped content verbatim. Starting
57/// with the version this project depends on (`quick_xml` 0.41), any
58/// `&entity;`/`&#NNN;` reference found in text content is split out into
59/// its own dedicated event, [`quick_xml::events::Event::GeneralRef`] —
60/// confirmed by reading `quick_xml`'s own `Event` enum documentation after
61/// a real round-trip test (an apostrophe in a footnote's text) came back
62/// not as a literal `'` and not restored to `'`, but *entirely
63/// missing*, which a pure "forgot to unescape" bug wouldn't explain (that
64/// would leave the entity text verbatim, not delete it). The surrounding
65/// plain-text fragments arrive as ordinary (already-unescaped-looking)
66/// `Text` events; the reference itself must be resolved separately via
67/// [`decode_general_ref`] and appended in between — see `word-ooxml`'s
68/// `parse_paragraph` for the accumulation loop that does this. Callers
69/// that only ever call `.decode()` (or this function) on `Text` events and
70/// silently ignore `GeneralRef` — as this crate's readers did before this
71/// was diagnosed — see every entity vanish, not survive unescaped.
72///
73/// This function's own `unescape()` call is consequently closer to a
74/// no-op/safety-net for stray literal `&` sequences than the primary fix;
75/// kept anyway since it's harmless and cheap. See the CHANGELOG for the
76/// full story.
77pub fn decode_text(text: &quick_xml::events::BytesText<'_>) -> Result<String> {
78 let decoded = text.decode()?;
79 let unescaped = quick_xml::escape::unescape(&decoded)?;
80 Ok(unescaped.into_owned())
81}
82
83/// Resolves a `&entity;`/`&#NNN;` general reference
84/// ([`quick_xml::events::Event::GeneralRef`], `BytesRef`) found in text
85/// content to the character(s) it represents — see [`decode_text`]'s doc
86/// comment for why this is a *separate* event from `Text` in `quick_xml`
87/// 0.41, and therefore a separate, necessary step, not an edge case.
88///
89/// Handles the two kinds of reference this toolkit's own `Writer` (and any
90/// real Word-authored document) can produce: a numeric character reference
91/// (`A`/`A`, resolved via [`quick_xml::events::BytesRef::resolve_char_ref`])
92/// and the five predefined XML entities (`&`/`<`/`>`/`'`/
93/// `"`, resolved via [`quick_xml::escape::resolve_xml_entity`]).
94/// Returns `Ok(None)` for anything else (a custom, DTD-declared entity —
95/// this crate has no DTD support to resolve one) — callers drop it rather
96/// than failing the whole document, the same forgiving policy applied to
97/// other malformed/unsupported constructs throughout this toolkit's
98/// readers.
99pub fn decode_general_ref(reference: &quick_xml::events::BytesRef<'_>) -> Result<Option<String>> {
100 if let Some(ch) = reference.resolve_char_ref()? {
101 return Ok(Some(ch.to_string()));
102 }
103 let name = reference.decode()?;
104 Ok(quick_xml::escape::resolve_xml_entity(&name).map(str::to_string))
105}
106
107/// Decodes an XML attribute's raw value, undoing XML entity escaping —
108/// mirrors [`decode_text`] for attribute values. `quick_xml`'s own
109/// `Attribute::from((&str, &str))` (used throughout this toolkit's writers
110/// via `BytesStart::push_attribute`) escapes the value the exact same way
111/// `BytesText::new` does, so this is needed for the same reason.
112///
113/// Returns `Ok(None)` if `value` isn't valid UTF-8, rather than an error —
114/// this crate's readers treat a malformed attribute as simply absent
115/// rather than failing the whole document (see callers). An `Err` here
116/// specifically means the bytes *were* valid UTF-8 but contained a
117/// malformed or unknown entity reference, a stronger signal of a genuinely
118/// broken document.
119pub fn decode_attribute_value(value: &[u8]) -> Result<Option<String>> {
120 let Ok(text) = std::str::from_utf8(value) else {
121 return Ok(None);
122 };
123 let unescaped = quick_xml::escape::unescape(text)?;
124 Ok(Some(unescaped.into_owned()))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn reads_a_simple_element_with_text() {
133 let mut reader = Reader::from_xml_str("<a>hello</a>");
134
135 assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));
136
137 match reader.read_event().unwrap() {
138 Event::Text(text) => assert_eq!(text.decode().unwrap(), "hello"),
139 other => panic!("expected a Text event, got {other:?}"),
140 }
141
142 assert!(matches!(reader.read_event().unwrap(), Event::End(_)));
143 assert!(matches!(reader.read_event().unwrap(), Event::Eof));
144 }
145
146 #[test]
147 fn a_text_node_with_entities_arrives_as_interleaved_text_and_generalref_events() {
148 // Demonstrates the mechanism `decode_text`/`decode_general_ref`'s
149 // doc comments describe: a single logical text node containing
150 // entity references is NOT handed back as one `Event::Text` with
151 // the entities left escaped inside it — each reference is its own
152 // `Event::GeneralRef`, interleaved with plain `Event::Text`
153 // fragments for whatever surrounds it.
154 let mut reader = Reader::from_xml_str(
155 "<a>Bells & whistles, 'quoted' and <tagged></a>",
156 );
157 reader.read_event().unwrap(); // consume <a>
158
159 let mut reconstructed = String::new();
160 loop {
161 match reader.read_event().unwrap() {
162 Event::Text(text) => reconstructed.push_str(&decode_text(&text).unwrap()),
163 Event::GeneralRef(reference) => {
164 if let Some(resolved) = decode_general_ref(&reference).unwrap() {
165 reconstructed.push_str(&resolved);
166 }
167 }
168 Event::End(_) => break,
169 other => panic!("unexpected event: {other:?}"),
170 }
171 }
172
173 assert_eq!(reconstructed, "Bells & whistles, 'quoted' and <tagged>");
174 }
175
176 #[test]
177 fn decode_general_ref_resolves_numeric_character_references() {
178 let mut reader = Reader::from_xml_str("<a>AB</a>");
179 reader.read_event().unwrap(); // consume <a>
180
181 let mut resolved = String::new();
182 loop {
183 match reader.read_event().unwrap() {
184 Event::GeneralRef(reference) => {
185 resolved.push_str(&decode_general_ref(&reference).unwrap().unwrap())
186 }
187 Event::End(_) => break,
188 other => panic!("unexpected event: {other:?}"),
189 }
190 }
191
192 assert_eq!(resolved, "AB");
193 }
194
195 #[test]
196 fn decode_attribute_value_unescapes_entities() {
197 let mut reader = Reader::from_xml_str(r#"<a k="Bells & whistles"/>"#);
198
199 match reader.read_event().unwrap() {
200 Event::Empty(start) => {
201 let attribute = start.attributes().next().unwrap().unwrap();
202 assert_eq!(attribute.value.as_ref(), b"Bells & whistles");
203 assert_eq!(
204 decode_attribute_value(attribute.value.as_ref())
205 .unwrap()
206 .as_deref(),
207 Some("Bells & whistles")
208 );
209 }
210 other => panic!("expected an Empty event, got {other:?}"),
211 }
212 }
213
214 #[test]
215 fn reads_attributes_on_an_empty_element() {
216 let mut reader = Reader::from_xml_str(r#"<a k="v"/>"#);
217
218 match reader.read_event().unwrap() {
219 Event::Empty(start) => {
220 let attribute = start.attributes().next().unwrap().unwrap();
221 assert_eq!(attribute.key.as_ref(), b"k");
222 assert_eq!(attribute.value.as_ref(), b"v");
223 }
224 other => panic!("expected an Empty event, got {other:?}"),
225 }
226 }
227
228 #[test]
229 fn reports_malformed_xml_as_an_error() {
230 let mut reader = Reader::from_xml_str("<a><b></a>");
231
232 // First event is the opening `<a>` tag.
233 assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));
234 // Second event is the opening `<b>` tag.
235 assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));
236 // The mismatched closing tag `</a>` must surface as an `Error`, not a panic.
237 assert!(reader.read_event().is_err());
238 }
239}