xml-core 1.0.0

Low-level generic XML reading/writing, shared building block for all OOXML formats in the toolkit.
Documentation
//! Low-level XML reading.

use quick_xml::events::Event;

use crate::error::{Error, Result};

/// Reads a sequence of generic XML events from an in-memory UTF-8 string.
///
/// This is a thin wrapper around [`quick_xml::Reader`], scoped to `xml-core`'s
/// own error type so that callers never need to depend on `quick_xml`
/// directly.
///
/// # Whitespace handling
///
/// Unlike some XML tooling, this reader does **not** trim whitespace from
/// text nodes automatically. Whether leading/trailing whitespace inside an
/// element is significant depends on the vocabulary being parsed (for
/// example, `xml:space="preserve"` in WordprocessingML) — a decision that
/// belongs to the higher-level crates, not to this generic layer.
///
/// # Scope
///
/// This first version only supports reading from an in-memory `&str`. Real
/// OOXML parts are read fully into memory from their ZIP entry before
/// parsing anyway, so this covers the toolkit's actual needs; streaming
/// from an arbitrary [`std::io::Read`] source can be added later if needed.
pub struct Reader<'a> {
    inner: quick_xml::Reader<&'a [u8]>,
}

impl<'a> Reader<'a> {
    /// Creates a new reader over the given XML string.
    pub fn from_xml_str(xml: &'a str) -> Self {
        Self {
            inner: quick_xml::Reader::from_str(xml),
        }
    }

    /// Reads the next XML event.
    ///
    /// Returns `Ok(Event::Eof)` once the end of the input has been reached.
    pub fn read_event(&mut self) -> Result<Event<'a>> {
        self.inner.read_event().map_err(Error::from)
    }
}

/// Decodes an XML text event's content: character-encoding decoding (e.g.
/// UTF-16 to UTF-8) via [`quick_xml::events::BytesText::decode`], plus a
/// defensive `unescape()` pass in case any literal `&...;` survived in the
/// event's own bytes (see below for why that's normally not needed).
///
/// # Why a `Text` event alone isn't the whole story
///
/// Despite what a quick read of `BytesText::decode`'s doc comment might
/// suggest, a `quick_xml` `Reader` does **not** hand back one `Event::Text`
/// containing an entire text node's escaped content verbatim. Starting
/// with the version this project depends on (`quick_xml` 0.41), any
/// `&entity;`/`&#NNN;` reference found in text content is split out into
/// its own dedicated event, [`quick_xml::events::Event::GeneralRef`] —
/// confirmed by reading `quick_xml`'s own `Event` enum documentation after
/// a real round-trip test (an apostrophe in a footnote's text) came back
/// not as a literal `&apos;` and not restored to `'`, but *entirely
/// missing*, which a pure "forgot to unescape" bug wouldn't explain (that
/// would leave the entity text verbatim, not delete it). The surrounding
/// plain-text fragments arrive as ordinary (already-unescaped-looking)
/// `Text` events; the reference itself must be resolved separately via
/// [`decode_general_ref`] and appended in between — see `word-ooxml`'s
/// `parse_paragraph` for the accumulation loop that does this. Callers
/// that only ever call `.decode()` (or this function) on `Text` events and
/// silently ignore `GeneralRef` — as this crate's readers did before this
/// was diagnosed — see every entity vanish, not survive unescaped.
///
/// This function's own `unescape()` call is consequently closer to a
/// no-op/safety-net for stray literal `&` sequences than the primary fix;
/// kept anyway since it's harmless and cheap. See the CHANGELOG for the
/// full story.
pub fn decode_text(text: &quick_xml::events::BytesText<'_>) -> Result<String> {
    let decoded = text.decode()?;
    let unescaped = quick_xml::escape::unescape(&decoded)?;
    Ok(unescaped.into_owned())
}

/// Resolves a `&entity;`/`&#NNN;` general reference
/// ([`quick_xml::events::Event::GeneralRef`], `BytesRef`) found in text
/// content to the character(s) it represents — see [`decode_text`]'s doc
/// comment for why this is a *separate* event from `Text` in `quick_xml`
/// 0.41, and therefore a separate, necessary step, not an edge case.
///
/// Handles the two kinds of reference this toolkit's own `Writer` (and any
/// real Word-authored document) can produce: a numeric character reference
/// (`&#65;`/`&#x41;`, resolved via [`quick_xml::events::BytesRef::resolve_char_ref`])
/// and the five predefined XML entities (`&amp;`/`&lt;`/`&gt;`/`&apos;`/
/// `&quot;`, resolved via [`quick_xml::escape::resolve_xml_entity`]).
/// Returns `Ok(None)` for anything else (a custom, DTD-declared entity —
/// this crate has no DTD support to resolve one) — callers drop it rather
/// than failing the whole document, the same forgiving policy applied to
/// other malformed/unsupported constructs throughout this toolkit's
/// readers.
pub fn decode_general_ref(reference: &quick_xml::events::BytesRef<'_>) -> Result<Option<String>> {
    if let Some(ch) = reference.resolve_char_ref()? {
        return Ok(Some(ch.to_string()));
    }
    let name = reference.decode()?;
    Ok(quick_xml::escape::resolve_xml_entity(&name).map(str::to_string))
}

/// Decodes an XML attribute's raw value, undoing XML entity escaping —
/// mirrors [`decode_text`] for attribute values. `quick_xml`'s own
/// `Attribute::from((&str, &str))` (used throughout this toolkit's writers
/// via `BytesStart::push_attribute`) escapes the value the exact same way
/// `BytesText::new` does, so this is needed for the same reason.
///
/// Returns `Ok(None)` if `value` isn't valid UTF-8, rather than an error —
/// this crate's readers treat a malformed attribute as simply absent
/// rather than failing the whole document (see callers). An `Err` here
/// specifically means the bytes *were* valid UTF-8 but contained a
/// malformed or unknown entity reference, a stronger signal of a genuinely
/// broken document.
pub fn decode_attribute_value(value: &[u8]) -> Result<Option<String>> {
    let Ok(text) = std::str::from_utf8(value) else {
        return Ok(None);
    };
    let unescaped = quick_xml::escape::unescape(text)?;
    Ok(Some(unescaped.into_owned()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reads_a_simple_element_with_text() {
        let mut reader = Reader::from_xml_str("<a>hello</a>");

        assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));

        match reader.read_event().unwrap() {
            Event::Text(text) => assert_eq!(text.decode().unwrap(), "hello"),
            other => panic!("expected a Text event, got {other:?}"),
        }

        assert!(matches!(reader.read_event().unwrap(), Event::End(_)));
        assert!(matches!(reader.read_event().unwrap(), Event::Eof));
    }

    #[test]
    fn a_text_node_with_entities_arrives_as_interleaved_text_and_generalref_events() {
        // Demonstrates the mechanism `decode_text`/`decode_general_ref`'s
        // doc comments describe: a single logical text node containing
        // entity references is NOT handed back as one `Event::Text` with
        // the entities left escaped inside it — each reference is its own
        // `Event::GeneralRef`, interleaved with plain `Event::Text`
        // fragments for whatever surrounds it.
        let mut reader = Reader::from_xml_str(
            "<a>Bells &amp; whistles, &apos;quoted&apos; and &lt;tagged&gt;</a>",
        );
        reader.read_event().unwrap(); // consume <a>

        let mut reconstructed = String::new();
        loop {
            match reader.read_event().unwrap() {
                Event::Text(text) => reconstructed.push_str(&decode_text(&text).unwrap()),
                Event::GeneralRef(reference) => {
                    if let Some(resolved) = decode_general_ref(&reference).unwrap() {
                        reconstructed.push_str(&resolved);
                    }
                }
                Event::End(_) => break,
                other => panic!("unexpected event: {other:?}"),
            }
        }

        assert_eq!(reconstructed, "Bells & whistles, 'quoted' and <tagged>");
    }

    #[test]
    fn decode_general_ref_resolves_numeric_character_references() {
        let mut reader = Reader::from_xml_str("<a>&#65;&#x42;</a>");
        reader.read_event().unwrap(); // consume <a>

        let mut resolved = String::new();
        loop {
            match reader.read_event().unwrap() {
                Event::GeneralRef(reference) => {
                    resolved.push_str(&decode_general_ref(&reference).unwrap().unwrap())
                }
                Event::End(_) => break,
                other => panic!("unexpected event: {other:?}"),
            }
        }

        assert_eq!(resolved, "AB");
    }

    #[test]
    fn decode_attribute_value_unescapes_entities() {
        let mut reader = Reader::from_xml_str(r#"<a k="Bells &amp; whistles"/>"#);

        match reader.read_event().unwrap() {
            Event::Empty(start) => {
                let attribute = start.attributes().next().unwrap().unwrap();
                assert_eq!(attribute.value.as_ref(), b"Bells &amp; whistles");
                assert_eq!(
                    decode_attribute_value(attribute.value.as_ref())
                        .unwrap()
                        .as_deref(),
                    Some("Bells & whistles")
                );
            }
            other => panic!("expected an Empty event, got {other:?}"),
        }
    }

    #[test]
    fn reads_attributes_on_an_empty_element() {
        let mut reader = Reader::from_xml_str(r#"<a k="v"/>"#);

        match reader.read_event().unwrap() {
            Event::Empty(start) => {
                let attribute = start.attributes().next().unwrap().unwrap();
                assert_eq!(attribute.key.as_ref(), b"k");
                assert_eq!(attribute.value.as_ref(), b"v");
            }
            other => panic!("expected an Empty event, got {other:?}"),
        }
    }

    #[test]
    fn reports_malformed_xml_as_an_error() {
        let mut reader = Reader::from_xml_str("<a><b></a>");

        // First event is the opening `<a>` tag.
        assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));
        // Second event is the opening `<b>` tag.
        assert!(matches!(reader.read_event().unwrap(), Event::Start(_)));
        // The mismatched closing tag `</a>` must surface as an `Error`, not a panic.
        assert!(reader.read_event().is_err());
    }
}