Skip to main content

oxml_core/
xml_text.rs

1//! Helpers for turning quick-xml text events into plain Rust strings.
2//!
3//! Since quick-xml 0.41 a character or general entity reference (`&`,
4//! `A`) is reported as its own [`Event::GeneralRef`] rather than being
5//! folded into the surrounding [`Event::Text`]. Any loop that accumulates the
6//! text of an element therefore has to handle both events, or entities are
7//! silently dropped from the result.
8//!
9//! [`Event::GeneralRef`]: quick_xml::events::Event::GeneralRef
10//! [`Event::Text`]: quick_xml::events::Event::Text
11
12use quick_xml::Reader;
13use quick_xml::escape::unescape;
14use quick_xml::events::{BytesRef, BytesText, Event};
15use quick_xml::name::QName;
16
17/// Decode a [`BytesText`] and resolve any XML entities it still contains.
18///
19/// Use this for spans produced by [`Reader::read_text`], which returns the raw
20/// markup between the tags — entities in it are *not* pre-resolved.
21pub fn decode_escaped(text: &BytesText<'_>) -> String {
22    let Ok(raw) = text.decode() else {
23        return String::new();
24    };
25    match unescape(&raw) {
26        Ok(unescaped) => unescaped.into_owned(),
27        // Malformed or unknown entity: keep the raw text rather than losing it.
28        Err(_) => raw.into_owned(),
29    }
30}
31
32/// Decode an [`Event::Text`] payload, which quick-xml has already stripped of
33/// entity references (those arrive separately as [`Event::GeneralRef`]).
34///
35/// [`Event::GeneralRef`]: quick_xml::events::Event::GeneralRef
36/// [`Event::Text`]: quick_xml::events::Event::Text
37pub fn decode_plain(text: &BytesText<'_>) -> String {
38    text.decode().map(|c| c.into_owned()).unwrap_or_default()
39}
40
41/// Resolve a single entity reference event to the text it stands for.
42///
43/// Handles numeric references (`&#65;`, `&#x41;`) and the five XML predefined
44/// entities. An unresolvable reference is reproduced verbatim (`&name;`) so it
45/// survives a round trip instead of vanishing.
46pub fn resolve_entity(entity: &BytesRef<'_>) -> String {
47    let Ok(name) = entity.decode() else {
48        return String::new();
49    };
50    match unescape(&format!("&{name};")) {
51        Ok(resolved) => resolved.into_owned(),
52        Err(_) => format!("&{name};"),
53    }
54}
55
56/// Read the full text content of the element that `start_name` opened,
57/// resolving entity references.
58///
59/// This is the event-loop counterpart to [`decode_escaped`]: it consumes events
60/// up to the matching end tag, concatenating [`Event::Text`], [`Event::CData`]
61/// and [`Event::GeneralRef`] payloads.
62///
63/// [`Event::CData`]: quick_xml::events::Event::CData
64/// [`Event::GeneralRef`]: quick_xml::events::Event::GeneralRef
65/// [`Event::Text`]: quick_xml::events::Event::Text
66pub fn read_element_text(reader: &mut Reader<&[u8]>, start_name: QName<'_>) -> String {
67    let end = start_name.as_ref().to_vec();
68    let mut out = String::new();
69    let mut buf = Vec::new();
70    let mut depth = 1u32;
71
72    // An entity splits the value into several Text events, and trimming each of
73    // them individually would eat the spaces on either side of the entity —
74    // "Title &amp; Co." would come back as "Title&Co.". Read untrimmed and
75    // restore the caller's setting afterwards.
76    let (trim_start, trim_end) = {
77        let config = reader.config_mut();
78        let previous = (config.trim_text_start, config.trim_text_end);
79        config.trim_text(false);
80        previous
81    };
82
83    loop {
84        match reader.read_event_into(&mut buf) {
85            Ok(Event::Start(ref e)) => {
86                if e.name().as_ref() == end {
87                    depth += 1;
88                }
89            }
90            Ok(Event::End(ref e)) => {
91                if e.name().as_ref() == end {
92                    depth -= 1;
93                    if depth == 0 {
94                        break;
95                    }
96                }
97            }
98            Ok(Event::Text(ref e)) => out.push_str(&decode_plain(e)),
99            Ok(Event::CData(ref e)) => {
100                if let Ok(decoded) = e.decode() {
101                    out.push_str(&decoded);
102                }
103            }
104            Ok(Event::GeneralRef(ref e)) => out.push_str(&resolve_entity(e)),
105            Ok(Event::Eof) | Err(_) => break,
106            _ => {}
107        }
108        buf.clear();
109    }
110
111    let config = reader.config_mut();
112    config.trim_text_start = trim_start;
113    config.trim_text_end = trim_end;
114
115    out
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn text_of(xml: &str) -> String {
123        let mut reader = Reader::from_str(xml);
124        let mut buf = Vec::new();
125        loop {
126            match reader.read_event_into(&mut buf) {
127                Ok(Event::Start(ref e)) if e.name().as_ref() == b"t" => {
128                    return read_element_text(&mut reader, e.name());
129                }
130                Ok(Event::Eof) => return String::new(),
131                _ => {}
132            }
133        }
134    }
135
136    #[test]
137    fn entities_survive_text_accumulation() {
138        assert_eq!(text_of("<t>a &amp; b</t>"), "a & b");
139        assert_eq!(text_of("<t>&lt;tag&gt;</t>"), "<tag>");
140        assert_eq!(text_of("<t>&#65;&#x42;</t>"), "AB");
141        assert_eq!(text_of("<t>plain</t>"), "plain");
142    }
143
144    #[test]
145    fn whitespace_around_entities_is_kept_even_when_trimming() {
146        let mut reader = Reader::from_str("<t>Title &amp; Co. &lt;tagged&gt;</t>");
147        reader.config_mut().trim_text(true);
148        let mut buf = Vec::new();
149        loop {
150            match reader.read_event_into(&mut buf) {
151                Ok(Event::Start(ref e)) => {
152                    assert_eq!(
153                        read_element_text(&mut reader, e.name()),
154                        "Title & Co. <tagged>"
155                    );
156                    // The caller's trim setting must be restored.
157                    assert!(reader.config().trim_text_start);
158                    return;
159                }
160                Ok(Event::Eof) => panic!("no start tag"),
161                _ => {}
162            }
163        }
164    }
165
166    #[test]
167    fn unknown_entity_is_preserved_verbatim() {
168        assert_eq!(text_of("<t>a &nbsp; b</t>"), "a &nbsp; b");
169    }
170
171    #[test]
172    fn read_text_span_is_unescaped() {
173        let xml = "<t>a &amp; b</t>";
174        let mut reader = Reader::from_str(xml);
175        let mut buf = Vec::new();
176        loop {
177            match reader.read_event_into(&mut buf) {
178                Ok(Event::Start(ref e)) => {
179                    let span = reader.read_text(e.name()).unwrap();
180                    assert_eq!(decode_escaped(&span), "a & b");
181                    return;
182                }
183                Ok(Event::Eof) => panic!("no start tag"),
184                _ => {}
185            }
186        }
187    }
188
189    #[test]
190    fn xml_text_handles_cdata_mixed_nested_and_general_refs() {
191        assert_eq!(
192            text_of("<t>start<![CDATA[<raw>]]><n>nested &amp; text</n>end&nbsp;</t>"),
193            "start<raw>nested & textend&nbsp;"
194        );
195    }
196}