rwml 0.1.0

Native Rust toolkit for Microsoft Word — read, write, edit, and render legacy .doc (Word 97-2003, [MS-DOC]) and modern .docx (OOXML): one document model, package-preserving edits, field evaluation, Markdown/HTML export, PDF preview
Documentation
//! Shared XML text helpers for `.docx` parts.

use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;

use super::{is_page_break_type, local};

#[derive(Default)]
pub(crate) struct AlternateContentBranchState {
    took_branch: bool,
}

pub(crate) fn skip_alternate_content_branch(
    stack: &mut [AlternateContentBranchState],
    name: &[u8],
) -> bool {
    if !matches!(name, b"Choice" | b"Fallback") {
        return false;
    }
    let Some(state) = stack.last_mut() else {
        return false;
    };
    if state.took_branch {
        true
    } else {
        state.took_branch = true;
        false
    }
}

pub(crate) fn skip_subtree(r: &mut Reader<&[u8]>) {
    let mut depth = 1usize;
    loop {
        match r.read_event() {
            Ok(Event::Start(_)) => depth += 1,
            Ok(Event::End(_)) => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    break;
                }
            }
            Ok(Event::Eof) | Err(_) => break,
            _ => {}
        }
    }
}

/// Read text content through the current element's end.
///
/// `unescape` resolves standard XML entities but errors on unknown/custom
/// entities. In that case keep the raw text verbatim rather than dropping the
/// node or resolving external entities.
pub(crate) fn read_text(r: &mut Reader<&[u8]>) -> String {
    let mut s = String::new();
    loop {
        match r.read_event() {
            Ok(Event::Text(t)) => match t.unescape().ok().map(|c| c.into_owned()) {
                Some(c) => s.push_str(&c),
                None => s.push_str(&String::from_utf8_lossy(t.into_inner().as_ref())),
            },
            Ok(Event::CData(t)) => s.push_str(&String::from_utf8_lossy(t.into_inner().as_ref())),
            Ok(Event::End(_)) | Ok(Event::Eof) | Err(_) => break,
            _ => {}
        }
    }
    s
}

pub(crate) fn read_i64_text(r: &mut Reader<&[u8]>) -> Option<i64> {
    read_text(r).trim().parse().ok()
}

pub(crate) fn inline_marker_text(e: &BytesStart<'_>) -> Option<&'static str> {
    match local(e.name().as_ref()) {
        b"tab" => Some("\t"),
        b"br" => {
            if is_page_break_type(e) {
                Some("\u{000C}")
            } else {
                Some("\n")
            }
        }
        b"cr" => Some("\n"),
        b"noBreakHyphen" => Some("-"),
        b"softHyphen" => Some("\u{00ad}"),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{read_i64_text, read_text};
    use quick_xml::events::Event;
    use quick_xml::Reader;

    #[test]
    fn read_text_unescapes_cdata_and_keeps_unknown_entities_raw() {
        let mut r = Reader::from_str("<w:t>A &amp; B<![CDATA[ <C> ]]>&unknown;</w:t>");
        assert!(matches!(r.read_event(), Ok(Event::Start(_))));

        assert_eq!(read_text(&mut r), "A & B <C> &unknown;");
    }

    #[test]
    fn read_i64_text_trims_ooxml_text() {
        let mut r = Reader::from_str("<wp:posOffset> 91440 </wp:posOffset>");
        assert!(matches!(r.read_event(), Ok(Event::Start(_))));

        assert_eq!(read_i64_text(&mut r), Some(91_440));
    }
}