Skip to main content

stella_docx_kernel/projection/
ooxml.rs

1use std::collections::{BTreeSet, HashMap, HashSet};
2
3use quick_xml::XmlVersion;
4use quick_xml::escape::resolve_predefined_entity;
5use quick_xml::events::{BytesStart, Event};
6use quick_xml::reader::NsReader;
7use unicode_script::{Script, UnicodeScript};
8
9use crate::projection::compatibility::{CompatibilityAction, MarkupCompatibility};
10use crate::projection::namespaces::OoxmlNamespace;
11use crate::projection::review::{
12    AttributedRevision, ReviewDetail, ReviewFactSet, ReviewFactUnknownReason, ReviewPoint,
13    ReviewSpan, RevisionContent, RevisionFactKind, RevisionPayload,
14};
15use crate::projection::structure::{
16    ParagraphProperties, RawBookmarkRange, RawInternalReference, StructuralFactUnknownReason,
17    StyleSheet, TableStyleSelection, TextProperties, TextStyleInput,
18};
19use crate::projection::styles::{
20    parse_alignment, parse_indentation, parse_level_attribute, parse_outline_level_attribute,
21    parse_u32_attribute, semantic_highlight_value, word_style_id,
22};
23use crate::{FormattingCompleteness, FormattingUnknownReason, ParagraphContainer, ProjectionError};
24
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub struct PackageParagraphId(u32);
27
28impl PackageParagraphId {
29    #[must_use]
30    pub fn parse(value: &str) -> Option<Self> {
31        if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
32            return None;
33        }
34        let value = u32::from_str_radix(value, 16).ok()?;
35        (value != 0 && i32::try_from(value).is_ok()).then_some(Self(value))
36    }
37
38    #[must_use]
39    pub const fn value(self) -> u32 {
40        self.0
41    }
42}
43
44#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
45pub enum TextStyle {
46    Bold,
47    Highlight,
48    Superscript,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct TextFormattingSpan {
53    pub start_utf16: u32,
54    pub end_utf16: u32,
55    pub style: TextStyle,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ParagraphStructure {
60    pub table_ordinal: usize,
61    pub row: usize,
62    pub column: usize,
63}
64
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
66pub enum RevisionView {
67    #[default]
68    Current,
69    Original,
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum TextMaterialization {
74    #[default]
75    WordHost,
76    ReadablePlainText,
77}
78
79#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
80pub enum RevisionUnsupportedReason {
81    IncompatibleParagraphMerge,
82    StructuralTableRevision,
83    UnsupportedRevisionMarkup,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum RevisionProjectionStatus {
88    Complete,
89    Incomplete(Vec<RevisionUnsupportedReason>),
90}
91
92impl RevisionProjectionStatus {
93    #[must_use]
94    pub const fn is_complete(&self) -> bool {
95        matches!(self, Self::Complete)
96    }
97}
98
99/// How a tracked paragraph mark changes the paragraph break. A moved-away
100/// mark (`w:moveFrom`) behaves like a deleted one and a moved-here mark
101/// (`w:moveTo`) like an inserted one, in both review views.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103enum ParagraphMarkRevision {
104    /// `w:ins` or `w:moveTo`: the break exists only in the current view.
105    Added,
106    /// `w:del` or `w:moveFrom`: the break exists only in the original view.
107    Removed,
108}
109
110impl ParagraphMarkRevision {
111    fn from_element(name: &[u8]) -> Option<Self> {
112        match name {
113            b"ins" | b"moveTo" => Some(Self::Added),
114            b"del" | b"moveFrom" => Some(Self::Removed),
115            _ => None,
116        }
117    }
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub(super) enum TextControl {
122    Tab,
123    LineBreak,
124    PageBreak,
125    ColumnBreak,
126    CarriageReturn,
127    NoteReference,
128    SoftHyphen,
129    NoBreakHyphen,
130}
131
132impl TextControl {
133    pub(super) const fn materialize(
134        self,
135        materialization: TextMaterialization,
136    ) -> Option<&'static str> {
137        match materialization {
138            TextMaterialization::WordHost => Some(match self {
139                Self::Tab => "\t",
140                Self::LineBreak => "\u{000b}",
141                Self::PageBreak => "\u{000c}",
142                Self::ColumnBreak => "\u{000e}",
143                Self::CarriageReturn => "\r",
144                // Footnote and endnote reference marks both materialize as U+0002.
145                Self::NoteReference => "\u{0002}",
146                Self::SoftHyphen => "\u{001f}",
147                Self::NoBreakHyphen => "\u{001e}",
148            }),
149            TextMaterialization::ReadablePlainText => match self {
150                Self::Tab => Some("\t"),
151                Self::LineBreak | Self::PageBreak | Self::ColumnBreak | Self::CarriageReturn => {
152                    Some("\n")
153                }
154                Self::NoteReference => None,
155                Self::SoftHyphen => Some("\u{00ad}"),
156                Self::NoBreakHyphen => Some("\u{2011}"),
157            },
158        }
159    }
160}
161
162pub(super) struct RawProjectedParagraph {
163    pub ordinal: usize,
164    pub package_paragraph_id: Option<PackageParagraphId>,
165    pub text: String,
166    utf16_len: u32,
167    pub formatting: Vec<TextFormattingSpan>,
168    pub structure: Option<ParagraphStructure>,
169    pub container: ParagraphContainer,
170    pub properties: ParagraphProperties,
171}
172
173pub(super) struct RawDocumentProjection {
174    pub paragraphs: Vec<RawProjectedParagraph>,
175    pub bookmarks: Result<Vec<RawBookmarkRange>, StructuralFactUnknownReason>,
176    pub references: Result<Vec<RawInternalReference>, StructuralFactUnknownReason>,
177    pub formatting_completeness: FormattingCompleteness,
178    pub revision_status: RevisionProjectionStatus,
179    pub review_revisions: Option<ReviewFactSet<AttributedRevision>>,
180    pub review_comment_anchors: HashMap<String, ReviewSpan>,
181}
182
183struct ParagraphBuilder {
184    package_paragraph_id: Option<PackageParagraphId>,
185    text: String,
186    utf16_len: u32,
187    formatting: Vec<TextFormattingSpan>,
188    structure: Option<ParagraphStructure>,
189    container: ParagraphContainer,
190    properties: ParagraphProperties,
191    resolved_text_base: Option<Result<TextProperties, ()>>,
192    paragraph_mark_revision: Option<ParagraphMarkRevision>,
193    paragraph_review_indices: Vec<usize>,
194}
195
196impl ParagraphBuilder {
197    fn append(&mut self, text: &str, styles: TextProperties) -> Result<(), ProjectionError> {
198        let units = u32::try_from(text.encode_utf16().count())
199            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
200        let end = self
201            .utf16_len
202            .checked_add(units)
203            .ok_or(ProjectionError::InvalidDocumentXml)?;
204        self.append_bold(text, styles, end)?;
205        if styles.highlighted == Some(true) {
206            self.append_style(end, TextStyle::Highlight);
207        }
208        if styles.superscript == Some(true) {
209            self.append_style(end, TextStyle::Superscript);
210        }
211        self.text.push_str(text);
212        self.utf16_len = end;
213        Ok(())
214    }
215
216    fn append_bold(
217        &mut self,
218        text: &str,
219        styles: TextProperties,
220        end_utf16: u32,
221    ) -> Result<(), ProjectionError> {
222        if styles.force_complex_script == Some(true) || styles.right_to_left == Some(true) {
223            if styles.complex_script_bold == Some(true) {
224                self.append_style(end_utf16, TextStyle::Bold);
225            }
226            return Ok(());
227        }
228
229        let regular_bold = styles.bold == Some(true);
230        let complex_bold = styles.complex_script_bold == Some(true);
231        if regular_bold == complex_bold {
232            if regular_bold {
233                self.append_style(end_utf16, TextStyle::Bold);
234            }
235            return Ok(());
236        }
237
238        let mut slot = text
239            .chars()
240            .find_map(character_bold_slot)
241            .unwrap_or(BoldSlot::Regular);
242        let mut range_start = self.utf16_len;
243        let mut position = self.utf16_len;
244        for character in text.chars() {
245            let character_slot = character_bold_slot(character).unwrap_or(slot);
246            if character_slot != slot {
247                if slot.enabled(regular_bold, complex_bold) {
248                    self.append_style_range(range_start, position, TextStyle::Bold);
249                }
250                range_start = position;
251                slot = character_slot;
252            }
253            let character_units = if character.len_utf16() == 1 { 1 } else { 2 };
254            position = position
255                .checked_add(character_units)
256                .ok_or(ProjectionError::InvalidDocumentXml)?;
257        }
258        if slot.enabled(regular_bold, complex_bold) {
259            self.append_style_range(range_start, position, TextStyle::Bold);
260        }
261        if position != end_utf16 {
262            return Err(ProjectionError::InvalidDocumentXml);
263        }
264        Ok(())
265    }
266
267    fn resolve_text_base(&mut self, styles: &StyleSheet) -> Result<TextProperties, ()> {
268        if let Some(resolved) = self.resolved_text_base {
269            return resolved;
270        }
271        let resolved = styles.resolve_text(TextStyleInput {
272            paragraph: &self.properties,
273            character_style_id: None,
274            direct: TextProperties::default(),
275        });
276        self.resolved_text_base = Some(resolved);
277        resolved
278    }
279
280    fn append_style(&mut self, end_utf16: u32, style: TextStyle) {
281        self.append_style_range(self.utf16_len, end_utf16, style);
282    }
283
284    fn append_style_range(&mut self, start_utf16: u32, end_utf16: u32, style: TextStyle) {
285        if start_utf16 >= end_utf16 {
286            return;
287        }
288        append_formatting_span(
289            &mut self.formatting,
290            TextFormattingSpan {
291                start_utf16,
292                end_utf16,
293                style,
294            },
295        );
296    }
297
298    fn truncate(&mut self, utf8_len: usize, utf16_len: u32) {
299        self.text.truncate(utf8_len);
300        self.utf16_len = utf16_len;
301        self.formatting.retain(|span| span.start_utf16 < utf16_len);
302        for span in &mut self.formatting {
303            span.end_utf16 = span.end_utf16.min(utf16_len);
304        }
305    }
306}
307
308#[derive(Clone, Copy, Eq, PartialEq)]
309enum BoldSlot {
310    Regular,
311    Complex,
312}
313
314impl BoldSlot {
315    const fn enabled(self, regular: bool, complex: bool) -> bool {
316        match self {
317            Self::Regular => regular,
318            Self::Complex => complex,
319        }
320    }
321}
322
323#[allow(clippy::too_many_lines)] // Keep HarfBuzz's Unicode 17 shaper table visible and auditable.
324fn character_bold_slot(character: char) -> Option<BoldSlot> {
325    match character.script() {
326        Script::Common | Script::Inherited => None,
327        // This positive set tracks the Unicode 17 scripts that HarfBuzz's
328        // src/hb-ot-shaper.hh routes through its Arabic, Hebrew, Indic, Khmer,
329        // Myanmar, Thai, or Universal Shaping Engine paths. Hangul remains in
330        // the regular OOXML bold slot: its dedicated shaper does not make it a
331        // complex-script run.
332        Script::Adlam
333        | Script::Ahom
334        | Script::Arabic
335        | Script::Balinese
336        | Script::Batak
337        | Script::Beria_Erfe
338        | Script::Bengali
339        | Script::Bhaiksuki
340        | Script::Brahmi
341        | Script::Buginese
342        | Script::Buhid
343        | Script::Chakma
344        | Script::Cham
345        | Script::Chorasmian
346        | Script::Cypro_Minoan
347        | Script::Devanagari
348        | Script::Dives_Akuru
349        | Script::Dogra
350        | Script::Duployan
351        | Script::Egyptian_Hieroglyphs
352        | Script::Elymaic
353        | Script::Garay
354        | Script::Grantha
355        | Script::Gujarati
356        | Script::Gunjala_Gondi
357        | Script::Gurung_Khema
358        | Script::Gurmukhi
359        | Script::Hanifi_Rohingya
360        | Script::Hanunoo
361        | Script::Hebrew
362        | Script::Javanese
363        | Script::Kaithi
364        | Script::Kannada
365        | Script::Kawi
366        | Script::Kayah_Li
367        | Script::Kharoshthi
368        | Script::Khmer
369        | Script::Khitan_Small_Script
370        | Script::Khojki
371        | Script::Khudawadi
372        | Script::Kirat_Rai
373        | Script::Lao
374        | Script::Lepcha
375        | Script::Limbu
376        | Script::Mahajani
377        | Script::Makasar
378        | Script::Malayalam
379        | Script::Mandaic
380        | Script::Manichaean
381        | Script::Marchen
382        | Script::Masaram_Gondi
383        | Script::Medefaidrin
384        | Script::Meetei_Mayek
385        | Script::Miao
386        | Script::Modi
387        | Script::Mongolian
388        | Script::Multani
389        | Script::Myanmar
390        | Script::Nag_Mundari
391        | Script::Nandinagari
392        | Script::Newa
393        | Script::Nko
394        | Script::Nyiakeng_Puachue_Hmong
395        | Script::Old_Sogdian
396        | Script::Old_Uyghur
397        | Script::Ol_Onal
398        | Script::Oriya
399        | Script::Pahawh_Hmong
400        | Script::Phags_Pa
401        | Script::Psalter_Pahlavi
402        | Script::Rejang
403        | Script::Saurashtra
404        | Script::Sharada
405        | Script::Siddham
406        | Script::Sidetic
407        | Script::Sinhala
408        | Script::Sogdian
409        | Script::Soyombo
410        | Script::Sundanese
411        | Script::Sunuwar
412        | Script::Syloti_Nagri
413        | Script::Syriac
414        | Script::Tagalog
415        | Script::Tagbanwa
416        | Script::Tai_Le
417        | Script::Tai_Tham
418        | Script::Tai_Viet
419        | Script::Tai_Yo
420        | Script::Tamil
421        | Script::Tangsa
422        | Script::Takri
423        | Script::Telugu
424        | Script::Thaana
425        | Script::Thai
426        | Script::Tibetan
427        | Script::Tifinagh
428        | Script::Tirhuta
429        | Script::Todhri
430        | Script::Tolong_Siki
431        | Script::Toto
432        | Script::Tulu_Tigalari
433        | Script::Vithkuqi
434        | Script::Wancho
435        | Script::Yezidi
436        | Script::Zanabazar_Square => Some(BoldSlot::Complex),
437        _ => Some(BoldSlot::Regular),
438    }
439}
440
441struct RunFrame {
442    text: String,
443    direct_styles: TextProperties,
444    character_style_id: Option<String>,
445    hidden: bool,
446    direct_child_count: usize,
447    property_review_indices: Vec<usize>,
448}
449
450struct RunPropertiesFrame {
451    run_frame: usize,
452    hidden_eligible: bool,
453}
454
455#[derive(Clone, Copy)]
456struct ContainerReview {
457    paragraph_start: usize,
458    property_review: Option<usize>,
459    grid_review: Option<usize>,
460    exception_review: Option<usize>,
461}
462
463struct TableFrame {
464    ordinal: usize,
465    next_row: usize,
466    style_id: Option<String>,
467    review: ContainerReview,
468}
469
470struct RowFrame {
471    table_ordinal: usize,
472    row: usize,
473    next_column: usize,
474    review: ContainerReview,
475}
476
477#[derive(Clone, Copy)]
478struct CellFrame {
479    table_ordinal: usize,
480    row: usize,
481    column: usize,
482    review: ContainerReview,
483}
484
485struct SdtFrame {
486    placeholder: bool,
487    text_utf8_start: usize,
488    text_utf16_start: u32,
489}
490
491#[derive(Clone, Copy)]
492enum PseudoTextKind {
493    Text { preserve_space: bool },
494    MathText,
495    Instruction,
496}
497
498struct PseudoTextFrame {
499    kind: PseudoTextKind,
500    text: String,
501}
502
503impl PseudoTextFrame {
504    const fn text(preserve_space: bool) -> Self {
505        Self {
506            kind: PseudoTextKind::Text { preserve_space },
507            text: String::new(),
508        }
509    }
510
511    const fn instruction() -> Self {
512        Self {
513            kind: PseudoTextKind::Instruction,
514            text: String::new(),
515        }
516    }
517
518    const fn math_text() -> Self {
519        Self {
520            kind: PseudoTextKind::MathText,
521            text: String::new(),
522        }
523    }
524}
525
526#[derive(Clone, Copy)]
527enum FieldSource {
528    Projected(ReviewPoint),
529    Hidden,
530    Unsupported,
531}
532
533struct FieldFrame {
534    source: FieldSource,
535    instruction: String,
536    separated: bool,
537}
538
539#[derive(Clone, Debug)]
540enum BookmarkPoint {
541    Paragraph(ReviewPoint),
542    ParagraphBoundary(usize),
543}
544
545#[derive(Clone, Debug)]
546struct PendingBookmarkRange {
547    id: u32,
548    name: String,
549    start: BookmarkPoint,
550    end: BookmarkPoint,
551}
552
553fn resolve_bookmark_ranges(
554    paragraphs: &[RawProjectedParagraph],
555    ranges: Vec<PendingBookmarkRange>,
556) -> Option<Vec<RawBookmarkRange>> {
557    ranges
558        .into_iter()
559        .map(|range| {
560            let same_boundary = matches!(
561                (&range.start, &range.end),
562                (
563                    BookmarkPoint::ParagraphBoundary(start),
564                    BookmarkPoint::ParagraphBoundary(end)
565                ) if start == end
566            );
567            let start = resolve_bookmark_point(paragraphs, &range.start, BoundarySide::Start)?;
568            let end = resolve_bookmark_point(
569                paragraphs,
570                &range.end,
571                if same_boundary {
572                    BoundarySide::Start
573                } else {
574                    BoundarySide::End
575                },
576            )?;
577            bookmark_points_are_ordered(&start, &end).then_some(RawBookmarkRange {
578                id: range.id,
579                name: range.name,
580                start,
581                end,
582            })
583        })
584        .collect()
585}
586
587#[derive(Clone, Copy)]
588enum BoundarySide {
589    Start,
590    End,
591}
592
593fn resolve_bookmark_point(
594    paragraphs: &[RawProjectedParagraph],
595    point: &BookmarkPoint,
596    side: BoundarySide,
597) -> Option<ReviewPoint> {
598    let boundary = match point {
599        BookmarkPoint::Paragraph(point) => return Some(*point),
600        BookmarkPoint::ParagraphBoundary(boundary) => boundary,
601    };
602    if *boundary > paragraphs.len() || paragraphs.is_empty() {
603        return None;
604    }
605    match side {
606        BoundarySide::Start => paragraphs.get(*boundary).map_or_else(
607            || paragraph_end(paragraphs.last()?),
608            |paragraph| {
609                Some(ReviewPoint {
610                    paragraph_ordinal: paragraph.ordinal,
611                    utf8: 0,
612                    utf16: 0,
613                })
614            },
615        ),
616        BoundarySide::End => boundary.checked_sub(1).map_or_else(
617            || {
618                Some(ReviewPoint {
619                    paragraph_ordinal: paragraphs.first()?.ordinal,
620                    utf8: 0,
621                    utf16: 0,
622                })
623            },
624            |paragraph| paragraph_end(paragraphs.get(paragraph)?),
625        ),
626    }
627}
628
629fn paragraph_end(paragraph: &RawProjectedParagraph) -> Option<ReviewPoint> {
630    Some(ReviewPoint {
631        paragraph_ordinal: paragraph.ordinal,
632        utf8: u32::try_from(paragraph.text.len()).ok()?,
633        utf16: paragraph.utf16_len,
634    })
635}
636
637const fn bookmark_points_are_ordered(start: &ReviewPoint, end: &ReviewPoint) -> bool {
638    start.paragraph_ordinal < end.paragraph_ordinal
639        || (start.paragraph_ordinal == end.paragraph_ordinal
640            && start.utf8 <= end.utf8
641            && start.utf16 <= end.utf16)
642}
643
644#[derive(Clone, Copy)]
645enum OwnedRevisionSpan {
646    Paragraph(ReviewSpan),
647    Content(ReviewSpan),
648}
649
650struct RevisionFrame {
651    review_index: Option<usize>,
652    start: Option<ReviewPoint>,
653    suppressed: bool,
654    text: String,
655}
656
657#[derive(Default)]
658enum ReviewRevisionCollection {
659    #[default]
660    Disabled,
661    Complete {
662        maximum_facts: usize,
663        remaining_detail_bytes: usize,
664        revisions: Vec<AttributedRevision>,
665    },
666    LimitExceeded,
667}
668
669#[derive(Clone, Copy)]
670pub(super) struct ReviewProjectionLimits {
671    pub maximum_facts: usize,
672    pub maximum_detail_bytes: usize,
673}
674
675#[derive(Clone, Copy)]
676enum SnapshotOwner {
677    Run { frame: usize, hidden_eligible: bool },
678    Paragraph,
679    ParagraphMark,
680    Table,
681    Row,
682    Cell,
683    Grid,
684    Section,
685    TableExceptions,
686    Unsupported,
687}
688
689#[derive(Clone, Copy)]
690enum SnapshotStatus {
691    AwaitingRoot,
692    Applied,
693    Unsupported,
694}
695
696struct SnapshotFrame {
697    owner: SnapshotOwner,
698    status: SnapshotStatus,
699}
700
701enum Frame {
702    Other,
703    Body,
704    BlockContent,
705    Table(TableFrame),
706    Row(RowFrame),
707    Cell(CellFrame),
708    Paragraph,
709    Run(RunFrame),
710    RunProperties(RunPropertiesFrame),
711    ParagraphProperties,
712    ParagraphMarkProperties,
713    TableProperties,
714    TableGrid,
715    TableRowProperties,
716    TableCellProperties,
717    TablePropertyExceptions,
718    SectionProperties,
719    NumberingProperties,
720    Hyperlink(Option<RawInternalReference>),
721    Revision(RevisionFrame),
722    ChangeSnapshot(SnapshotFrame),
723    SnapshotIgnored,
724    Math,
725    MathRun,
726    Textbox,
727    Sdt(SdtFrame),
728    PseudoText(PseudoTextFrame),
729}
730
731impl Frame {
732    const fn paragraph_context(&self) -> Option<(ParagraphContainer, Option<ParagraphStructure>)> {
733        match self {
734            Self::Body => Some((ParagraphContainer::Body, None)),
735            Self::Cell(cell) => Some((
736                ParagraphContainer::TableCell,
737                Some(ParagraphStructure {
738                    table_ordinal: cell.table_ordinal,
739                    row: cell.row,
740                    column: cell.column,
741                }),
742            )),
743            Self::Other
744            | Self::BlockContent
745            | Self::Table(_)
746            | Self::Row(_)
747            | Self::Paragraph
748            | Self::Run(_)
749            | Self::RunProperties(_)
750            | Self::ParagraphProperties
751            | Self::ParagraphMarkProperties
752            | Self::TableProperties
753            | Self::TableGrid
754            | Self::TableRowProperties
755            | Self::TableCellProperties
756            | Self::TablePropertyExceptions
757            | Self::SectionProperties
758            | Self::NumberingProperties
759            | Self::Hyperlink(_)
760            | Self::Revision(_)
761            | Self::ChangeSnapshot(_)
762            | Self::SnapshotIgnored
763            | Self::Math
764            | Self::MathRun
765            | Self::Textbox
766            | Self::Sdt(_)
767            | Self::PseudoText(_) => None,
768        }
769    }
770}
771
772#[allow(clippy::too_many_lines)] // A single streaming event loop keeps XML parsing one-pass.
773pub(super) fn project_document_xml(
774    xml: &[u8],
775    maximum_paragraphs: usize,
776    revision_view: RevisionView,
777    text_materialization: TextMaterialization,
778    styles: Result<&StyleSheet, FormattingUnknownReason>,
779    review_limits: Option<ReviewProjectionLimits>,
780) -> Result<RawDocumentProjection, ProjectionError> {
781    let mut reader = NsReader::from_reader(xml);
782    reader.config_mut().check_end_names = true;
783    let mut state = ProjectionState {
784        maximum_paragraphs,
785        revision_view,
786        text_materialization,
787        bookmarks_complete: true,
788        references_complete: true,
789        formatting_completeness: FormattingCompleteness::from_styles(styles.map(|_| ())),
790        review_revisions: review_limits.map_or(ReviewRevisionCollection::Disabled, |limits| {
791            ReviewRevisionCollection::Complete {
792                maximum_facts: limits.maximum_facts,
793                remaining_detail_bytes: limits.maximum_detail_bytes,
794                revisions: Vec::new(),
795            }
796        }),
797        ..ProjectionState::default()
798    };
799    let mut compatibility = MarkupCompatibility::default();
800    loop {
801        match reader
802            .read_event()
803            .map_err(|_| ProjectionError::InvalidDocumentXml)?
804        {
805            Event::Start(element) => {
806                let (namespace, _) = reader.resolver().resolve_element(element.name());
807                let namespace = OoxmlNamespace::from_resolved(&namespace);
808                if compatibility.start(&reader, namespace, &element)?
809                    == CompatibilityAction::Process
810                {
811                    state.start(&reader, namespace, &element)?;
812                }
813            }
814            Event::Empty(element) => {
815                let (namespace, _) = reader.resolver().resolve_element(element.name());
816                let namespace = OoxmlNamespace::from_resolved(&namespace);
817                if compatibility.empty(&reader, namespace, &element)?
818                    == CompatibilityAction::Process
819                {
820                    state.start(&reader, namespace, &element)?;
821                    state.end(styles)?;
822                }
823            }
824            Event::Text(text) => {
825                if !compatibility.is_suppressed()
826                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
827                {
828                    let decoded = text
829                        .xml10_content()
830                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
831                    state.append_pseudo_content(&decoded)?;
832                }
833            }
834            Event::CData(text) => {
835                if !compatibility.is_suppressed()
836                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
837                {
838                    let decoded = text
839                        .xml10_content()
840                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
841                    state.append_pseudo_content(&decoded)?;
842                }
843            }
844            Event::GeneralRef(reference) => {
845                if !compatibility.is_suppressed()
846                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
847                {
848                    let resolved_character = reference
849                        .resolve_char_ref()
850                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
851                    let resolved = if let Some(character) = resolved_character {
852                        character.to_string()
853                    } else {
854                        let name = reference
855                            .decode()
856                            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
857                        resolve_predefined_entity(&name)
858                            .ok_or(ProjectionError::InvalidDocumentXml)?
859                            .to_owned()
860                    };
861                    state.append_pseudo_content(&resolved)?;
862                }
863            }
864            Event::End(element) => {
865                let (namespace, local_name) = reader.resolver().resolve_element(element.name());
866                if compatibility.end(
867                    OoxmlNamespace::from_resolved(&namespace),
868                    local_name.as_ref(),
869                )? == CompatibilityAction::Process
870                {
871                    state.end(styles)?;
872                }
873            }
874            Event::Eof => break,
875            _ => {}
876        }
877    }
878    if !state.body_seen {
879        return Err(ProjectionError::MissingDocumentBody);
880    }
881    if state.current_paragraph.is_some() || !state.frames.is_empty() || !compatibility.is_complete()
882    {
883        return Err(ProjectionError::InvalidDocumentXml);
884    }
885    let mut bookmarks = if state.bookmarks_complete && state.open_bookmarks.is_empty() {
886        resolve_bookmark_ranges(&state.paragraphs, state.bookmarks)
887            .ok_or(StructuralFactUnknownReason::IncompleteBookmarkRanges)
888    } else {
889        Err(StructuralFactUnknownReason::IncompleteBookmarkRanges)
890    };
891    let paragraph_origins = if state.paragraph_mark_revisions.is_empty() {
892        None
893    } else {
894        normalize_paragraph_revision_view(
895            &mut state.paragraphs,
896            &mut state.paragraph_mark_revisions,
897            state.revision_view,
898            &mut state.revision_unsupported,
899        )?
900    };
901    if let Some(origins) = paragraph_origins {
902        if let Ok(ranges) = &mut bookmarks {
903            for range in ranges {
904                relocate_block_point(&origins, &mut range.start)?;
905                relocate_block_point(&origins, &mut range.end)?;
906            }
907        }
908        for reference in &mut state.references {
909            relocate_block_point(&origins, &mut reference.source)?;
910        }
911        state.review_comment_anchors.retain(|_, anchor| {
912            relocate_review_span(&origins, *anchor).is_some_and(|relocated| {
913                *anchor = relocated;
914                true
915            })
916        });
917        if let ReviewRevisionCollection::Complete { revisions, .. } = &mut state.review_revisions {
918            for revision in revisions {
919                let ReviewDetail::Known(content) = &mut revision.content else {
920                    continue;
921                };
922                match relocate_review_span(&origins, content.span) {
923                    Some(span) => content.span = span,
924                    None => {
925                        revision.content =
926                            ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation);
927                    }
928                }
929            }
930        }
931    }
932    let references = if state.references_complete && state.fields.is_empty() {
933        Ok(state.references)
934    } else {
935        Err(StructuralFactUnknownReason::UnsupportedInternalReferences)
936    };
937    Ok(RawDocumentProjection {
938        paragraphs: state.paragraphs,
939        bookmarks,
940        references,
941        formatting_completeness: state.formatting_completeness,
942        revision_status: if state.revision_unsupported.is_empty() {
943            RevisionProjectionStatus::Complete
944        } else {
945            RevisionProjectionStatus::Incomplete(state.revision_unsupported.into_iter().collect())
946        },
947        review_revisions: match state.review_revisions {
948            ReviewRevisionCollection::Disabled => None,
949            ReviewRevisionCollection::Complete { revisions, .. } => {
950                Some(ReviewFactSet::Known(revisions))
951            }
952            ReviewRevisionCollection::LimitExceeded => Some(ReviewFactSet::Unknown(
953                ReviewFactUnknownReason::ResourceLimit,
954            )),
955        },
956        review_comment_anchors: state.review_comment_anchors,
957    })
958}
959
960#[derive(Default)]
961struct ProjectionState {
962    frames: Vec<Frame>,
963    paragraphs: Vec<RawProjectedParagraph>,
964    current_paragraph: Option<ParagraphBuilder>,
965    body_seen: bool,
966    next_table_ordinal: usize,
967    maximum_paragraphs: usize,
968    open_bookmarks: HashMap<u32, (String, BookmarkPoint)>,
969    seen_bookmark_ids: HashSet<u32>,
970    bookmarks: Vec<PendingBookmarkRange>,
971    references: Vec<RawInternalReference>,
972    bookmarks_complete: bool,
973    references_complete: bool,
974    formatting_completeness: FormattingCompleteness,
975    fields: Vec<FieldFrame>,
976    paragraph_mark_revisions: HashMap<usize, ParagraphMarkRevision>,
977    revision_view: RevisionView,
978    text_materialization: TextMaterialization,
979    revision_unsupported: BTreeSet<RevisionUnsupportedReason>,
980    review_revisions: ReviewRevisionCollection,
981    open_review_comment_anchors: HashMap<String, ReviewPoint>,
982    review_comment_anchors: HashMap<String, ReviewSpan>,
983    invalid_review_comment_anchors: HashSet<String>,
984}
985
986impl ProjectionState {
987    #[allow(clippy::too_many_lines)] // The frame transition table is one streaming state machine.
988    fn start(
989        &mut self,
990        reader: &NsReader<&[u8]>,
991        namespace: OoxmlNamespace,
992        element: &BytesStart<'_>,
993    ) -> Result<(), ProjectionError> {
994        let local_name = element.local_name();
995        let name = if namespace == OoxmlNamespace::Wordprocessing {
996            local_name.as_ref()
997        } else {
998            b""
999        };
1000        let direct_run_child = if let Some(Frame::Run(run)) = self.frames.last_mut() {
1001            let child = run.direct_child_count;
1002            run.direct_child_count = run
1003                .direct_child_count
1004                .checked_add(1)
1005                .ok_or(ProjectionError::InvalidDocumentXml)?;
1006            let frame_index = self
1007                .frames
1008                .len()
1009                .checked_sub(1)
1010                .ok_or(ProjectionError::InvalidDocumentXml)?;
1011            Some((frame_index, child))
1012        } else {
1013            None
1014        };
1015
1016        if name == b"body" {
1017            if self.body_seen {
1018                return Err(ProjectionError::InvalidDocumentXml);
1019            }
1020            self.body_seen = true;
1021            self.frames.push(Frame::Body);
1022            return Ok(());
1023        }
1024        if !self.inside_body() {
1025            self.frames.push(Frame::Other);
1026            return Ok(());
1027        }
1028        let inside_snapshot = self.inside_change_snapshot();
1029        if inside_snapshot && self.start_snapshot_child(name)? {
1030            return Ok(());
1031        }
1032        let attributed_revision = if inside_snapshot {
1033            None
1034        } else {
1035            self.record_attributed_revision(reader, element, name)?
1036        };
1037        if name == b"txbxContent" {
1038            self.frames.push(Frame::Textbox);
1039            return Ok(());
1040        }
1041        if self.inside_textbox() {
1042            if matches!(
1043                name,
1044                b"commentRangeStart" | b"commentRangeEnd" | b"commentReference"
1045            ) {
1046                self.suppress_review_comment_anchor(reader, element)?;
1047            }
1048            self.frames.push(Frame::Other);
1049            return Ok(());
1050        }
1051        match name {
1052            b"commentRangeStart" => self.start_review_comment_anchor(reader, element)?,
1053            b"commentRangeEnd" => self.end_review_comment_anchor(reader, element)?,
1054            b"commentReference" => self.record_review_comment_reference(reader, element)?,
1055            _ => {}
1056        }
1057        if is_change_snapshot(name) {
1058            if let Some(review_index) = attributed_revision {
1059                match (name, self.frames.last()) {
1060                    (b"rPrChange", Some(Frame::RunProperties(properties))) => {
1061                        let run_frame = properties.run_frame;
1062                        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1063                            run.property_review_indices.push(review_index);
1064                        } else if self.inside_paragraph_mark_properties()
1065                            && let Some(paragraph) = self.current_paragraph.as_mut()
1066                        {
1067                            paragraph.paragraph_review_indices.push(review_index);
1068                        }
1069                    }
1070                    (b"pPrChange", Some(Frame::ParagraphProperties)) => {
1071                        if let Some(paragraph) = self.current_paragraph.as_mut() {
1072                            paragraph.paragraph_review_indices.push(review_index);
1073                        }
1074                    }
1075                    _ => self.record_container_property_revision(name, review_index)?,
1076                }
1077            }
1078            let owner = self.snapshot_owner(name);
1079            self.frames.push(Frame::ChangeSnapshot(SnapshotFrame {
1080                owner,
1081                status: SnapshotStatus::AwaitingRoot,
1082            }));
1083            return Ok(());
1084        }
1085        if is_unsupported_revision_markup(name) {
1086            self.revision_unsupported
1087                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1088            self.frames.push(Frame::Other);
1089            return Ok(());
1090        }
1091        if namespace == OoxmlNamespace::OfficeMath {
1092            let frame = match local_name.as_ref() {
1093                b"oMath" | b"oMathPara" if self.current_paragraph.is_some() => {
1094                    // Math text participates in paragraph offsets, but OMML run
1095                    // properties form a separate formatting hierarchy.
1096                    self.formatting_completeness.mark_formatting_unread();
1097                    Frame::Math
1098                }
1099                b"r" if self.frames.iter().any(|frame| matches!(frame, Frame::Math)) => {
1100                    Frame::MathRun
1101                }
1102                b"t" if matches!(self.frames.last(), Some(Frame::MathRun)) => {
1103                    Frame::PseudoText(PseudoTextFrame::math_text())
1104                }
1105                _ => Frame::Other,
1106            };
1107            self.frames.push(frame);
1108            return Ok(());
1109        }
1110
1111        let frame = match name {
1112            b"tbl" => {
1113                let ordinal = self.next_table_ordinal;
1114                self.next_table_ordinal = self
1115                    .next_table_ordinal
1116                    .checked_add(1)
1117                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1118                Frame::Table(TableFrame {
1119                    ordinal,
1120                    next_row: 0,
1121                    style_id: None,
1122                    review: ContainerReview {
1123                        paragraph_start: self.paragraphs.len(),
1124                        property_review: None,
1125                        grid_review: None,
1126                        exception_review: None,
1127                    },
1128                })
1129            }
1130            b"tr" => {
1131                let Some(table) = enclosing_table(&mut self.frames) else {
1132                    self.frames.push(Frame::Other);
1133                    return Ok(());
1134                };
1135                let row = table.next_row;
1136                table.next_row = table
1137                    .next_row
1138                    .checked_add(1)
1139                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1140                Frame::Row(RowFrame {
1141                    table_ordinal: table.ordinal,
1142                    row,
1143                    next_column: 0,
1144                    review: ContainerReview {
1145                        paragraph_start: self.paragraphs.len(),
1146                        property_review: None,
1147                        grid_review: None,
1148                        exception_review: None,
1149                    },
1150                })
1151            }
1152            b"tc" => {
1153                let Some(row) = enclosing_row(&mut self.frames) else {
1154                    self.frames.push(Frame::Other);
1155                    return Ok(());
1156                };
1157                let column = row.next_column;
1158                row.next_column = row
1159                    .next_column
1160                    .checked_add(1)
1161                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1162                Frame::Cell(CellFrame {
1163                    table_ordinal: row.table_ordinal,
1164                    row: row.row,
1165                    column,
1166                    review: ContainerReview {
1167                        paragraph_start: self.paragraphs.len(),
1168                        property_review: None,
1169                        grid_review: None,
1170                        exception_review: None,
1171                    },
1172                })
1173            }
1174            b"p" => {
1175                if self.current_paragraph.is_some() {
1176                    self.frames.push(Frame::Other);
1177                    return Ok(());
1178                }
1179                if self.paragraphs.len() >= self.maximum_paragraphs {
1180                    return Err(ProjectionError::TooManyParagraphs);
1181                }
1182                let package_paragraph_id = attribute_2010(reader, element, b"paraId")?
1183                    .as_deref()
1184                    .and_then(PackageParagraphId::parse);
1185                let (container, structure) = self
1186                    .frames
1187                    .iter()
1188                    .rev()
1189                    .find_map(Frame::paragraph_context)
1190                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1191                self.current_paragraph = Some(ParagraphBuilder {
1192                    package_paragraph_id,
1193                    text: String::new(),
1194                    utf16_len: 0,
1195                    formatting: Vec::new(),
1196                    structure,
1197                    container,
1198                    properties: ParagraphProperties {
1199                        table_style: self
1200                            .frames
1201                            .iter()
1202                            .rev()
1203                            .find_map(|frame| {
1204                                if let Frame::Table(table) = frame {
1205                                    Some(table.style_id.clone().map_or(
1206                                        TableStyleSelection::Default,
1207                                        TableStyleSelection::Explicit,
1208                                    ))
1209                                } else {
1210                                    None
1211                                }
1212                            })
1213                            .unwrap_or_default(),
1214                        ..ParagraphProperties::default()
1215                    },
1216                    resolved_text_base: None,
1217                    paragraph_mark_revision: None,
1218                    paragraph_review_indices: Vec::new(),
1219                });
1220                Frame::Paragraph
1221            }
1222            b"r" if self.current_paragraph.is_some() => Frame::Run(RunFrame {
1223                text: String::new(),
1224                direct_styles: TextProperties::default(),
1225                character_style_id: None,
1226                hidden: false,
1227                direct_child_count: 0,
1228                property_review_indices: Vec::new(),
1229            }),
1230            b"rPr" => {
1231                let Some((run_frame, child_index)) = direct_run_child else {
1232                    self.frames.push(Frame::RunProperties(RunPropertiesFrame {
1233                        run_frame: usize::MAX,
1234                        hidden_eligible: false,
1235                    }));
1236                    return Ok(());
1237                };
1238                Frame::RunProperties(RunPropertiesFrame {
1239                    run_frame,
1240                    hidden_eligible: child_index == 0,
1241                })
1242            }
1243            b"sdtContent" | b"customXml" => Frame::BlockContent,
1244            b"pPr" => Frame::ParagraphProperties,
1245            b"tblStyle" if matches!(self.frames.last(), Some(Frame::TableProperties)) => {
1246                if let Some(table) = enclosing_table(&mut self.frames) {
1247                    table.style_id = word_style_id(attribute(reader, element, b"val")?);
1248                }
1249                Frame::Other
1250            }
1251            b"tblPr" => Frame::TableProperties,
1252            b"tblGrid" => Frame::TableGrid,
1253            b"trPr" => Frame::TableRowProperties,
1254            b"tcPr" => Frame::TableCellProperties,
1255            b"tblPrEx" => Frame::TablePropertyExceptions,
1256            b"sectPr" => Frame::SectionProperties,
1257            b"pStyle" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1258                if let Some(paragraph) = self.current_paragraph.as_mut() {
1259                    paragraph.properties.style_id =
1260                        word_style_id(attribute(reader, element, b"val")?);
1261                    paragraph.resolved_text_base = None;
1262                }
1263                Frame::Other
1264            }
1265            b"ind" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1266                if let Some(paragraph) = self.current_paragraph.as_mut() {
1267                    paragraph.properties.indentation = parse_indentation(reader, element)?;
1268                }
1269                Frame::Other
1270            }
1271            // `w:jc` is also legal under `w:trPr` and `w:tblPr`, where it means
1272            // row or table justification. Only a direct child of `w:pPr` is
1273            // paragraph alignment.
1274            b"jc" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1275                if let Some(paragraph) = self.current_paragraph.as_mut() {
1276                    paragraph.properties.alignment = Some(parse_alignment(reader, element)?);
1277                }
1278                Frame::Other
1279            }
1280            b"outlineLvl" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1281                if let Some(paragraph) = self.current_paragraph.as_mut() {
1282                    paragraph.properties.outline_level =
1283                        Some(parse_outline_level_attribute(reader, element)?);
1284                }
1285                Frame::Other
1286            }
1287            b"numPr" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1288                if let Some(paragraph) = self.current_paragraph.as_mut() {
1289                    paragraph.properties.numbering.present = true;
1290                }
1291                Frame::NumberingProperties
1292            }
1293            b"numId" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1294                if let Some(paragraph) = self.current_paragraph.as_mut() {
1295                    paragraph.properties.numbering.num_id =
1296                        Some(parse_u32_attribute(reader, element)?);
1297                }
1298                Frame::Other
1299            }
1300            b"ilvl" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1301                if let Some(paragraph) = self.current_paragraph.as_mut() {
1302                    paragraph.properties.numbering.level =
1303                        Some(parse_level_attribute(reader, element)?);
1304                }
1305                Frame::Other
1306            }
1307            b"bookmarkStart" => {
1308                self.start_bookmark(reader, element)?;
1309                Frame::Other
1310            }
1311            b"bookmarkEnd" => {
1312                self.end_bookmark(reader, element)?;
1313                Frame::Other
1314            }
1315            b"hyperlink" => {
1316                if self.pseudo_text_is_suppressed() {
1317                    self.frames.push(Frame::Hyperlink(None));
1318                    return Ok(());
1319                }
1320                let anchor = attribute(reader, element, b"anchor")?;
1321                let reference =
1322                    anchor
1323                        .clone()
1324                        .filter(|anchor| !anchor.is_empty())
1325                        .and_then(|anchor| {
1326                            self.current_point().map(|source| RawInternalReference {
1327                                reference_id: anchor,
1328                                source,
1329                            })
1330                        });
1331                if anchor.is_some() && reference.is_none() {
1332                    self.references_complete = false;
1333                }
1334                Frame::Hyperlink(reference)
1335            }
1336            b"fldSimple" => {
1337                self.simple_field(reader, element)?;
1338                Frame::Other
1339            }
1340            b"fldChar" => {
1341                self.field_character(reader, element)?;
1342                Frame::Other
1343            }
1344            b"instrText" | b"delInstrText" => Frame::PseudoText(PseudoTextFrame::instruction()),
1345            b"ins" | b"del" | b"moveFrom" | b"moveTo" => {
1346                if self.inside_table_row_properties() {
1347                    self.revision_unsupported
1348                        .insert(RevisionUnsupportedReason::StructuralTableRevision);
1349                    Frame::Other
1350                } else if self.inside_paragraph_mark_properties() {
1351                    if let Some(paragraph) = self.current_paragraph.as_mut() {
1352                        paragraph.paragraph_mark_revision =
1353                            ParagraphMarkRevision::from_element(name)
1354                                .or(paragraph.paragraph_mark_revision);
1355                        paragraph
1356                            .paragraph_review_indices
1357                            .extend(attributed_revision);
1358                    }
1359                    Frame::Other
1360                } else {
1361                    Frame::Revision(RevisionFrame {
1362                        review_index: attributed_revision,
1363                        start: self.current_review_point(),
1364                        suppressed: revision_is_suppressed(name, self.revision_view),
1365                        text: String::new(),
1366                    })
1367                }
1368            }
1369            b"cellIns" | b"cellDel" | b"cellMerge" => {
1370                self.revision_unsupported
1371                    .insert(RevisionUnsupportedReason::StructuralTableRevision);
1372                Frame::Other
1373            }
1374            b"sdt" => {
1375                let (text_utf8_start, text_utf16_start) =
1376                    self.current_paragraph.as_ref().map_or((0, 0), |paragraph| {
1377                        (paragraph.text.len(), paragraph.utf16_len)
1378                    });
1379                Frame::Sdt(SdtFrame {
1380                    placeholder: false,
1381                    text_utf8_start,
1382                    text_utf16_start,
1383                })
1384            }
1385            b"showingPlcHdr" => {
1386                if on_off_element_enabled(reader, element)? {
1387                    self.mark_placeholder();
1388                }
1389                Frame::Other
1390            }
1391            b"vanish" => {
1392                if on_off_element_enabled(reader, element)? {
1393                    self.mark_hidden_run();
1394                }
1395                Frame::Other
1396            }
1397            b"rStyle" => {
1398                let style_id = word_style_id(attribute(reader, element, b"val")?);
1399                if matches!(style_id.as_deref(), Some("HideTWBInt" | "HideTWBExt")) {
1400                    self.mark_hidden_run();
1401                }
1402                self.set_run_character_style(style_id);
1403                Frame::Other
1404            }
1405            b"b" => {
1406                self.mark_bold_run(on_off_element_enabled(reader, element)?);
1407                Frame::Other
1408            }
1409            b"bCs" => {
1410                self.mark_complex_script_bold_run(on_off_element_enabled(reader, element)?);
1411                Frame::Other
1412            }
1413            b"cs" => {
1414                self.mark_complex_script_run(on_off_element_enabled(reader, element)?);
1415                Frame::Other
1416            }
1417            b"rtl" => {
1418                self.mark_right_to_left_run(on_off_element_enabled(reader, element)?);
1419                Frame::Other
1420            }
1421            b"highlight" => {
1422                if let Some(highlighted) = attribute(reader, element, b"val")?
1423                    .as_deref()
1424                    .and_then(semantic_highlight_value)
1425                {
1426                    self.mark_highlighted_run(highlighted);
1427                }
1428                Frame::Other
1429            }
1430            b"vertAlign" => {
1431                match attribute(reader, element, b"val")?.as_deref() {
1432                    Some("superscript") => self.mark_superscript_run(true),
1433                    Some("baseline" | "subscript") => self.mark_superscript_run(false),
1434                    _ => {}
1435                }
1436                Frame::Other
1437            }
1438            b"t" | b"delText" => {
1439                Frame::PseudoText(PseudoTextFrame::text(preserves_xml_space(reader, element)?))
1440            }
1441            b"tab" | b"ptab" => {
1442                self.append_text_control(TextControl::Tab)?;
1443                Frame::Other
1444            }
1445            b"br" => {
1446                let control = match attribute(reader, element, b"type")?.as_deref() {
1447                    Some("page") => TextControl::PageBreak,
1448                    Some("column") => TextControl::ColumnBreak,
1449                    _ => TextControl::LineBreak,
1450                };
1451                self.append_text_control(control)?;
1452                Frame::Other
1453            }
1454            b"cr" => {
1455                self.append_text_control(TextControl::CarriageReturn)?;
1456                Frame::Other
1457            }
1458            b"footnoteReference" | b"endnoteReference" => {
1459                let visible =
1460                    attribute(reader, element, b"customMarkFollows")?.is_none_or(|value| {
1461                        matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")
1462                    });
1463                if visible {
1464                    self.append_text_control(TextControl::NoteReference)?;
1465                }
1466                Frame::Other
1467            }
1468            b"softHyphen" => {
1469                self.append_text_control(TextControl::SoftHyphen)?;
1470                Frame::Other
1471            }
1472            b"noBreakHyphen" => {
1473                self.append_text_control(TextControl::NoBreakHyphen)?;
1474                Frame::Other
1475            }
1476            b"sym" => {
1477                if let Some(value) = attribute(reader, element, b"char")? {
1478                    let font = attribute(reader, element, b"font")?;
1479                    self.append_pseudo_text(&symbol_text(&value, font.as_deref()))?;
1480                }
1481                Frame::Other
1482            }
1483            _ => Frame::Other,
1484        };
1485        self.frames.push(frame);
1486        Ok(())
1487    }
1488
1489    fn end(
1490        &mut self,
1491        styles: Result<&StyleSheet, FormattingUnknownReason>,
1492    ) -> Result<(), ProjectionError> {
1493        let frame = self
1494            .frames
1495            .pop()
1496            .ok_or(ProjectionError::InvalidDocumentXml)?;
1497        match frame {
1498            Frame::Paragraph => self.finish_paragraph(styles)?,
1499            Frame::Table(table) => self.finish_container_review(table.review)?,
1500            Frame::Row(row) => self.finish_container_review(row.review)?,
1501            Frame::Cell(cell) => self.finish_container_review(cell.review)?,
1502            Frame::Hyperlink(Some(reference)) => self.references.push(reference),
1503            Frame::PseudoText(frame) => match frame.kind {
1504                PseudoTextKind::Text { preserve_space } => {
1505                    let text = if preserve_space {
1506                        frame.text.as_str()
1507                    } else {
1508                        trim_xml_whitespace(&frame.text)
1509                    };
1510                    self.append_pseudo_text(text)?;
1511                }
1512                PseudoTextKind::MathText => self.append_pseudo_text(&frame.text)?,
1513                PseudoTextKind::Instruction => self.append_field_instruction(&frame.text),
1514            },
1515            Frame::Run(run) => {
1516                let start = self.current_review_point();
1517                if !run.hidden
1518                    && !self.pseudo_text_is_suppressed()
1519                    && let Some(paragraph) = self.current_paragraph.as_mut()
1520                {
1521                    let mut effective = match styles {
1522                        Ok(styles) => {
1523                            let resolved = if run.character_style_id.is_none()
1524                                && run.direct_styles == TextProperties::default()
1525                            {
1526                                paragraph.resolve_text_base(styles)
1527                            } else {
1528                                styles.resolve_text(TextStyleInput {
1529                                    paragraph: &paragraph.properties,
1530                                    character_style_id: run.character_style_id.as_deref(),
1531                                    direct: run.direct_styles,
1532                                })
1533                            };
1534                            resolved.unwrap_or_else(|()| {
1535                                self.formatting_completeness.mark_styles_unread();
1536                                run.direct_styles
1537                            })
1538                        }
1539                        Err(_) => run.direct_styles,
1540                    };
1541                    // Highlight spans describe direct run markup, never style inheritance.
1542                    effective.highlighted = run.direct_styles.highlighted;
1543                    paragraph.append(&run.text, effective)?;
1544                }
1545                if let (Some(start), Some(end)) = (start, self.current_review_point()) {
1546                    self.locate_owned_revisions(
1547                        &run.property_review_indices,
1548                        OwnedRevisionSpan::Content(ReviewSpan { start, end }),
1549                    )?;
1550                }
1551            }
1552            Frame::ChangeSnapshot(snapshot) => {
1553                if self.revision_view == RevisionView::Original
1554                    && !matches!(snapshot.status, SnapshotStatus::Applied)
1555                {
1556                    self.revision_unsupported
1557                        .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1558                }
1559            }
1560            Frame::Revision(revision) => self.finish_attributed_revision(revision)?,
1561            _ => {}
1562        }
1563        Ok(())
1564    }
1565
1566    fn snapshot_owner(&self, name: &[u8]) -> SnapshotOwner {
1567        match (name, self.frames.last()) {
1568            (b"rPrChange", Some(Frame::RunProperties(properties))) => {
1569                if matches!(self.frames.get(properties.run_frame), Some(Frame::Run(_))) {
1570                    SnapshotOwner::Run {
1571                        frame: properties.run_frame,
1572                        hidden_eligible: properties.hidden_eligible,
1573                    }
1574                } else if self.inside_paragraph_mark_properties() {
1575                    SnapshotOwner::ParagraphMark
1576                } else {
1577                    SnapshotOwner::Unsupported
1578                }
1579            }
1580            (b"pPrChange", Some(Frame::ParagraphProperties))
1581                if self.current_paragraph.is_some() =>
1582            {
1583                SnapshotOwner::Paragraph
1584            }
1585            (b"tblPrChange", Some(Frame::TableProperties))
1586                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1587            {
1588                SnapshotOwner::Table
1589            }
1590            (b"trPrChange", Some(Frame::TableRowProperties))
1591                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1592            {
1593                SnapshotOwner::Row
1594            }
1595            (b"tcPrChange", Some(Frame::TableCellProperties))
1596                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Cell(_))) =>
1597            {
1598                SnapshotOwner::Cell
1599            }
1600            (b"tblGridChange", Some(Frame::TableGrid))
1601                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1602            {
1603                SnapshotOwner::Grid
1604            }
1605            (b"sectPrChange", Some(Frame::SectionProperties))
1606                if matches!(
1607                    self.frames.iter().rev().nth(1),
1608                    Some(Frame::Body | Frame::ParagraphProperties)
1609                ) =>
1610            {
1611                SnapshotOwner::Section
1612            }
1613            (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1614                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1615            {
1616                SnapshotOwner::TableExceptions
1617            }
1618            _ => SnapshotOwner::Unsupported,
1619        }
1620    }
1621
1622    fn start_snapshot_child(&mut self, name: &[u8]) -> Result<bool, ProjectionError> {
1623        if self.revision_view == RevisionView::Current {
1624            self.frames.push(Frame::SnapshotIgnored);
1625            return Ok(true);
1626        }
1627        if let Some(Frame::ChangeSnapshot(snapshot)) = self.frames.last_mut() {
1628            let owner = snapshot.owner;
1629            let matches_root = matches!(
1630                (owner, name),
1631                (
1632                    SnapshotOwner::Run { .. } | SnapshotOwner::ParagraphMark,
1633                    b"rPr"
1634                ) | (SnapshotOwner::Paragraph, b"pPr")
1635                    | (SnapshotOwner::Table, b"tblPr")
1636                    | (SnapshotOwner::Row, b"trPr")
1637                    | (SnapshotOwner::Cell, b"tcPr")
1638                    | (SnapshotOwner::Grid, b"tblGrid")
1639                    | (SnapshotOwner::Section, b"sectPr")
1640                    | (SnapshotOwner::TableExceptions, b"tblPrEx")
1641            );
1642            if !matches_root || !matches!(snapshot.status, SnapshotStatus::AwaitingRoot) {
1643                snapshot.status = SnapshotStatus::Unsupported;
1644                self.frames.push(Frame::SnapshotIgnored);
1645                return Ok(true);
1646            }
1647            snapshot.status = SnapshotStatus::Applied;
1648            let frame = self.restore_snapshot_owner(owner)?;
1649            self.frames.push(frame);
1650            return Ok(true);
1651        }
1652        if matches!(self.frames.last(), Some(Frame::ParagraphMarkProperties))
1653            && matches!(name, b"moveFrom" | b"moveTo")
1654        {
1655            self.revision_unsupported
1656                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1657            self.frames.push(Frame::SnapshotIgnored);
1658            return Ok(true);
1659        }
1660        let supported_property = matches!(
1661            (self.frames.last(), name),
1662            (
1663                Some(Frame::RunProperties(_)),
1664                b"rStyle"
1665                    | b"b"
1666                    | b"bCs"
1667                    | b"cs"
1668                    | b"rtl"
1669                    | b"highlight"
1670                    | b"vertAlign"
1671                    | b"vanish"
1672            ) | (
1673                Some(Frame::ParagraphProperties),
1674                b"pStyle" | b"ind" | b"jc" | b"outlineLvl" | b"numPr"
1675            ) | (Some(Frame::NumberingProperties), b"numId" | b"ilvl")
1676                | (Some(Frame::TableProperties), b"tblStyle")
1677                | (Some(Frame::ParagraphMarkProperties), b"ins" | b"del")
1678        );
1679        if supported_property {
1680            return Ok(false);
1681        }
1682        self.frames.push(Frame::SnapshotIgnored);
1683        Ok(true)
1684    }
1685
1686    fn restore_snapshot_owner(&mut self, owner: SnapshotOwner) -> Result<Frame, ProjectionError> {
1687        match owner {
1688            SnapshotOwner::Run {
1689                frame,
1690                hidden_eligible,
1691            } => {
1692                let Some(Frame::Run(run)) = self.frames.get_mut(frame) else {
1693                    return Err(ProjectionError::InvalidDocumentXml);
1694                };
1695                run.direct_styles = TextProperties::default();
1696                run.character_style_id = None;
1697                run.hidden = false;
1698                Ok(Frame::RunProperties(RunPropertiesFrame {
1699                    run_frame: frame,
1700                    hidden_eligible,
1701                }))
1702            }
1703            SnapshotOwner::Paragraph => {
1704                // CT_PPrBase excludes the independent paragraph-mark properties.
1705                let paragraph = self
1706                    .current_paragraph
1707                    .as_mut()
1708                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1709                paragraph.properties = ParagraphProperties {
1710                    table_style: std::mem::take(&mut paragraph.properties.table_style),
1711                    ..ParagraphProperties::default()
1712                };
1713                paragraph.resolved_text_base = None;
1714                Ok(Frame::ParagraphProperties)
1715            }
1716            SnapshotOwner::Table => {
1717                let table =
1718                    enclosing_table(&mut self.frames).ok_or(ProjectionError::InvalidDocumentXml)?;
1719                table.style_id = None;
1720                Ok(Frame::TableProperties)
1721            }
1722            SnapshotOwner::Row => Ok(Frame::TableRowProperties),
1723            SnapshotOwner::Cell => Ok(Frame::TableCellProperties),
1724            SnapshotOwner::Grid => Ok(Frame::TableGrid),
1725            SnapshotOwner::Section => Ok(Frame::SectionProperties),
1726            SnapshotOwner::TableExceptions => Ok(Frame::TablePropertyExceptions),
1727            SnapshotOwner::ParagraphMark => {
1728                let paragraph = self
1729                    .current_paragraph
1730                    .as_mut()
1731                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1732                paragraph.paragraph_mark_revision = None;
1733                Ok(Frame::ParagraphMarkProperties)
1734            }
1735            SnapshotOwner::Unsupported => Err(ProjectionError::InvalidDocumentXml),
1736        }
1737    }
1738
1739    fn record_container_property_revision(
1740        &mut self,
1741        name: &[u8],
1742        review_index: usize,
1743    ) -> Result<(), ProjectionError> {
1744        let valid_parent = matches!(
1745            (name, self.frames.last()),
1746            (b"tblPrChange", Some(Frame::TableProperties))
1747                | (b"tblGridChange", Some(Frame::TableGrid))
1748                | (b"trPrChange", Some(Frame::TableRowProperties))
1749                | (b"tcPrChange", Some(Frame::TableCellProperties))
1750                | (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1751        );
1752        if !valid_parent {
1753            return Ok(());
1754        }
1755        let review = self
1756            .frames
1757            .iter_mut()
1758            .rev()
1759            .find_map(|frame| match (name, frame) {
1760                (b"tblPrChange" | b"tblGridChange", Frame::Table(table)) => Some(&mut table.review),
1761                (b"trPrChange" | b"tblPrExChange", Frame::Row(row)) => Some(&mut row.review),
1762                (b"tcPrChange", Frame::Cell(cell)) => Some(&mut cell.review),
1763                _ => None,
1764            });
1765        if let Some(review) = review {
1766            let slot = match name {
1767                b"tblGridChange" => &mut review.grid_review,
1768                b"tblPrExChange" => &mut review.exception_review,
1769                _ => &mut review.property_review,
1770            };
1771            if slot.replace(review_index).is_some() {
1772                return Err(ProjectionError::InvalidDocumentXml);
1773            }
1774        }
1775        Ok(())
1776    }
1777
1778    fn finish_container_review(&mut self, review: ContainerReview) -> Result<(), ProjectionError> {
1779        if review.paragraph_start >= self.paragraphs.len() {
1780            return Ok(());
1781        }
1782        let end = self
1783            .paragraphs
1784            .last()
1785            .and_then(paragraph_end)
1786            .ok_or(ProjectionError::InvalidDocumentXml)?;
1787        for review_index in [
1788            review.property_review,
1789            review.grid_review,
1790            review.exception_review,
1791        ]
1792        .into_iter()
1793        .flatten()
1794        {
1795            self.locate_owned_revisions(
1796                std::slice::from_ref(&review_index),
1797                OwnedRevisionSpan::Content(ReviewSpan {
1798                    start: paragraph_start(review.paragraph_start),
1799                    end,
1800                }),
1801            )?;
1802        }
1803        Ok(())
1804    }
1805
1806    fn finish_paragraph(
1807        &mut self,
1808        styles: Result<&StyleSheet, FormattingUnknownReason>,
1809    ) -> Result<(), ProjectionError> {
1810        let mut paragraph = self
1811            .current_paragraph
1812            .take()
1813            .ok_or(ProjectionError::InvalidDocumentXml)?;
1814        if styles.is_ok_and(|styles| paragraph.resolve_text_base(styles).is_err()) {
1815            self.formatting_completeness.mark_styles_unread();
1816        }
1817        let ordinal = self.paragraphs.len();
1818        if let Some(revision) = paragraph.paragraph_mark_revision {
1819            self.paragraph_mark_revisions.insert(ordinal, revision);
1820        }
1821        let mark = ReviewPoint {
1822            paragraph_ordinal: ordinal,
1823            utf8: u32::try_from(paragraph.text.len())
1824                .map_err(|_| ProjectionError::InvalidDocumentXml)?,
1825            utf16: paragraph.utf16_len,
1826        };
1827        self.locate_owned_revisions(
1828            &paragraph.paragraph_review_indices,
1829            OwnedRevisionSpan::Paragraph(ReviewSpan {
1830                start: paragraph_start(ordinal),
1831                end: mark,
1832            }),
1833        )?;
1834        self.paragraphs.push(RawProjectedParagraph {
1835            ordinal: self.paragraphs.len(),
1836            package_paragraph_id: paragraph.package_paragraph_id,
1837            text: paragraph.text,
1838            utf16_len: paragraph.utf16_len,
1839            formatting: paragraph.formatting,
1840            structure: paragraph.structure,
1841            container: paragraph.container,
1842            properties: paragraph.properties,
1843        });
1844        Ok(())
1845    }
1846
1847    fn record_attributed_revision(
1848        &mut self,
1849        reader: &NsReader<&[u8]>,
1850        element: &BytesStart<'_>,
1851        name: &[u8],
1852    ) -> Result<Option<usize>, ProjectionError> {
1853        let Some(kind) = revision_fact_kind(name) else {
1854            return Ok(None);
1855        };
1856        let ReviewRevisionCollection::Complete {
1857            maximum_facts,
1858            revisions,
1859            ..
1860        } = &mut self.review_revisions
1861        else {
1862            return Ok(None);
1863        };
1864        if revisions.len() >= *maximum_facts {
1865            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1866            return Ok(None);
1867        }
1868        let review_index = revisions.len();
1869        revisions.push(AttributedRevision {
1870            kind,
1871            author: attribute(reader, element, b"author")?.unwrap_or_default(),
1872            date: attribute(reader, element, b"date")?,
1873            revision_id: attribute(reader, element, b"id")?,
1874            content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
1875        });
1876        Ok(Some(review_index))
1877    }
1878
1879    fn finish_attributed_revision(
1880        &mut self,
1881        revision: RevisionFrame,
1882    ) -> Result<(), ProjectionError> {
1883        let (Some(review_index), Some(start), Some(end)) = (
1884            revision.review_index,
1885            revision.start,
1886            self.current_review_point(),
1887        ) else {
1888            return Ok(());
1889        };
1890        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1891        else {
1892            return Ok(());
1893        };
1894        let attributed = revisions
1895            .get_mut(review_index)
1896            .ok_or(ProjectionError::InvalidDocumentXml)?;
1897        attributed.content = ReviewDetail::Known(RevisionContent {
1898            span: ReviewSpan { start, end },
1899            payload: RevisionPayload::from_text(revision.text),
1900        });
1901        Ok(())
1902    }
1903
1904    fn locate_owned_revisions(
1905        &mut self,
1906        review_indices: &[usize],
1907        owner: OwnedRevisionSpan,
1908    ) -> Result<(), ProjectionError> {
1909        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1910        else {
1911            return Ok(());
1912        };
1913        for &review_index in review_indices {
1914            let revision = revisions
1915                .get_mut(review_index)
1916                .ok_or(ProjectionError::InvalidDocumentXml)?;
1917            let span = match owner {
1918                OwnedRevisionSpan::Content(span) => span,
1919                OwnedRevisionSpan::Paragraph(span) => {
1920                    if revision.kind == RevisionFactKind::ParagraphPropertiesChange {
1921                        span
1922                    } else {
1923                        ReviewSpan {
1924                            start: span.end,
1925                            end: span.end,
1926                        }
1927                    }
1928                }
1929            };
1930            let payload = match revision.kind {
1931                RevisionFactKind::RunPropertiesChange
1932                | RevisionFactKind::ParagraphPropertiesChange
1933                | RevisionFactKind::TablePropertiesChange
1934                | RevisionFactKind::TablePropertiesExceptionChange
1935                | RevisionFactKind::TableRowPropertiesChange
1936                | RevisionFactKind::TableCellPropertiesChange
1937                | RevisionFactKind::TableGridChange => RevisionPayload::FormattingOnly,
1938                _ => RevisionPayload::ParagraphMark,
1939            };
1940            revision.content = ReviewDetail::Known(RevisionContent { span, payload });
1941        }
1942        Ok(())
1943    }
1944
1945    fn inside_body(&self) -> bool {
1946        self.frames.iter().any(|frame| matches!(frame, Frame::Body))
1947    }
1948
1949    fn inside_textbox(&self) -> bool {
1950        self.frames
1951            .iter()
1952            .any(|frame| matches!(frame, Frame::Textbox))
1953    }
1954
1955    fn inside_change_snapshot(&self) -> bool {
1956        self.frames
1957            .iter()
1958            .any(|frame| matches!(frame, Frame::ChangeSnapshot(_)))
1959    }
1960
1961    fn inside_paragraph_mark_properties(&self) -> bool {
1962        self.current_paragraph.is_some()
1963            && self
1964                .frames
1965                .iter()
1966                .any(|frame| matches!(frame, Frame::ParagraphProperties))
1967            && !self
1968                .frames
1969                .iter()
1970                .any(|frame| matches!(frame, Frame::Run(_)))
1971    }
1972
1973    fn inside_table_row_properties(&self) -> bool {
1974        self.frames
1975            .iter()
1976            .any(|frame| matches!(frame, Frame::TableRowProperties))
1977    }
1978
1979    fn pseudo_text_is_suppressed(&self) -> bool {
1980        self.frames.iter().any(|frame| {
1981            matches!(
1982                frame,
1983                Frame::ParagraphProperties
1984                    | Frame::RunProperties(_)
1985                    | Frame::ChangeSnapshot(_)
1986                    | Frame::Textbox
1987            ) || matches!(frame, Frame::Revision(revision) if revision.suppressed)
1988                || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1989        })
1990    }
1991
1992    fn pseudo_text_is_structurally_suppressed(&self) -> bool {
1993        self.frames.iter().any(|frame| {
1994            matches!(
1995                frame,
1996                Frame::ParagraphProperties
1997                    | Frame::RunProperties(_)
1998                    | Frame::ChangeSnapshot(_)
1999                    | Frame::Textbox
2000            ) || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
2001        })
2002    }
2003
2004    fn append_pseudo_text(&mut self, text: &str) -> Result<(), ProjectionError> {
2005        if self.current_paragraph.is_none()
2006            || self.pseudo_text_is_structurally_suppressed()
2007            || self
2008                .frames
2009                .iter()
2010                .rev()
2011                .any(|frame| matches!(frame, Frame::Run(run) if run.hidden))
2012        {
2013            return Ok(());
2014        }
2015        self.append_review_text(text);
2016        if self.pseudo_text_is_suppressed() {
2017            return Ok(());
2018        }
2019        if let Some(run) = self.frames.iter_mut().rev().find_map(|frame| match frame {
2020            Frame::Run(run) => Some(run),
2021            _ => None,
2022        }) {
2023            run.text.push_str(text);
2024            return Ok(());
2025        }
2026        self.current_paragraph
2027            .as_mut()
2028            .ok_or(ProjectionError::InvalidDocumentXml)?
2029            .append(text, TextProperties::default())
2030    }
2031
2032    fn append_review_text(&mut self, text: &str) {
2033        let ReviewRevisionCollection::Complete {
2034            remaining_detail_bytes,
2035            ..
2036        } = &mut self.review_revisions
2037        else {
2038            return;
2039        };
2040        let mut limit_exceeded = false;
2041        for frame in &mut self.frames {
2042            if let Frame::Revision(revision) = frame
2043                && revision.review_index.is_some()
2044            {
2045                let Some(remaining) = remaining_detail_bytes.checked_sub(text.len()) else {
2046                    limit_exceeded = true;
2047                    break;
2048                };
2049                *remaining_detail_bytes = remaining;
2050                revision.text.push_str(text);
2051            }
2052        }
2053        if limit_exceeded {
2054            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
2055        }
2056    }
2057
2058    fn current_review_point(&self) -> Option<ReviewPoint> {
2059        let paragraph = self.current_paragraph.as_ref()?;
2060        let run_text = if self.pseudo_text_is_suppressed() {
2061            None
2062        } else {
2063            self.frames.iter().rev().find_map(|frame| match frame {
2064                Frame::Run(run) if !run.hidden => Some(run.text.as_str()),
2065                _ => None,
2066            })
2067        };
2068        let run_utf8 = run_text.map_or(0, str::len);
2069        let run_utf16 = run_text.map_or(0, |text| text.encode_utf16().count());
2070        Some(ReviewPoint {
2071            paragraph_ordinal: self.paragraphs.len(),
2072            utf8: u32::try_from(paragraph.text.len().checked_add(run_utf8)?).ok()?,
2073            utf16: paragraph
2074                .utf16_len
2075                .checked_add(u32::try_from(run_utf16).ok()?)?,
2076        })
2077    }
2078
2079    fn next_paragraph_start_review_point(&self) -> Option<ReviewPoint> {
2080        if self.current_paragraph.is_some() {
2081            return self.current_review_point();
2082        }
2083        self.inside_body().then_some(ReviewPoint {
2084            paragraph_ordinal: self.paragraphs.len(),
2085            utf8: 0,
2086            utf16: 0,
2087        })
2088    }
2089
2090    fn previous_paragraph_end_review_point(&self) -> Option<ReviewPoint> {
2091        if self.current_paragraph.is_some() {
2092            return self.current_review_point();
2093        }
2094        self.paragraphs.last().and_then(paragraph_end)
2095    }
2096
2097    fn review_comment_id(
2098        reader: &NsReader<&[u8]>,
2099        element: &BytesStart<'_>,
2100    ) -> Result<Option<String>, ProjectionError> {
2101        attribute(reader, element, b"id")
2102    }
2103
2104    fn start_review_comment_anchor(
2105        &mut self,
2106        reader: &NsReader<&[u8]>,
2107        element: &BytesStart<'_>,
2108    ) -> Result<(), ProjectionError> {
2109        let (Some(comment_id), Some(point)) = (
2110            Self::review_comment_id(reader, element)?,
2111            self.next_paragraph_start_review_point(),
2112        ) else {
2113            return Ok(());
2114        };
2115        if self.invalid_review_comment_anchors.contains(&comment_id) {
2116            return Ok(());
2117        }
2118        if self
2119            .open_review_comment_anchors
2120            .insert(comment_id.clone(), point)
2121            .is_some()
2122        {
2123            self.open_review_comment_anchors.remove(&comment_id);
2124            self.review_comment_anchors.remove(&comment_id);
2125            self.invalid_review_comment_anchors.insert(comment_id);
2126        }
2127        Ok(())
2128    }
2129
2130    fn suppress_review_comment_anchor(
2131        &mut self,
2132        reader: &NsReader<&[u8]>,
2133        element: &BytesStart<'_>,
2134    ) -> Result<(), ProjectionError> {
2135        let Some(comment_id) = Self::review_comment_id(reader, element)? else {
2136            return Ok(());
2137        };
2138        self.open_review_comment_anchors.remove(&comment_id);
2139        self.review_comment_anchors.remove(&comment_id);
2140        self.invalid_review_comment_anchors.insert(comment_id);
2141        Ok(())
2142    }
2143
2144    fn end_review_comment_anchor(
2145        &mut self,
2146        reader: &NsReader<&[u8]>,
2147        element: &BytesStart<'_>,
2148    ) -> Result<(), ProjectionError> {
2149        let (Some(comment_id), Some(end)) = (
2150            Self::review_comment_id(reader, element)?,
2151            self.previous_paragraph_end_review_point(),
2152        ) else {
2153            return Ok(());
2154        };
2155        if self.invalid_review_comment_anchors.contains(&comment_id) {
2156            return Ok(());
2157        }
2158        let Some(start) = self.open_review_comment_anchors.remove(&comment_id) else {
2159            self.review_comment_anchors.remove(&comment_id);
2160            self.invalid_review_comment_anchors.insert(comment_id);
2161            return Ok(());
2162        };
2163        if self
2164            .review_comment_anchors
2165            .insert(comment_id.clone(), ReviewSpan { start, end })
2166            .is_some()
2167        {
2168            self.review_comment_anchors.remove(&comment_id);
2169            self.invalid_review_comment_anchors.insert(comment_id);
2170        }
2171        Ok(())
2172    }
2173
2174    fn record_review_comment_reference(
2175        &mut self,
2176        reader: &NsReader<&[u8]>,
2177        element: &BytesStart<'_>,
2178    ) -> Result<(), ProjectionError> {
2179        let (Some(comment_id), Some(point)) = (
2180            Self::review_comment_id(reader, element)?,
2181            self.current_review_point(),
2182        ) else {
2183            return Ok(());
2184        };
2185        if self.invalid_review_comment_anchors.contains(&comment_id) {
2186            return Ok(());
2187        }
2188        if !self.review_comment_anchors.contains_key(&comment_id)
2189            && !self.open_review_comment_anchors.contains_key(&comment_id)
2190        {
2191            self.review_comment_anchors.insert(
2192                comment_id,
2193                ReviewSpan {
2194                    start: point,
2195                    end: point,
2196                },
2197            );
2198        }
2199        Ok(())
2200    }
2201
2202    fn append_pseudo_content(&mut self, text: &str) -> Result<(), ProjectionError> {
2203        let Some(Frame::PseudoText(frame)) = self.frames.last_mut() else {
2204            return Err(ProjectionError::InvalidDocumentXml);
2205        };
2206        frame.text.push_str(text);
2207        Ok(())
2208    }
2209
2210    fn append_text_control(&mut self, control: TextControl) -> Result<(), ProjectionError> {
2211        if let Some(text) = control.materialize(self.text_materialization) {
2212            self.append_pseudo_text(text)?;
2213        }
2214        Ok(())
2215    }
2216
2217    fn append_field_instruction(&mut self, text: &str) {
2218        let Some(field) = self.fields.last_mut() else {
2219            self.references_complete = false;
2220            return;
2221        };
2222        if matches!(field.source, FieldSource::Hidden) {
2223            return;
2224        }
2225        if field.separated || field.instruction.len().saturating_add(text.len()) > 4096 {
2226            self.references_complete = false;
2227            return;
2228        }
2229        field.instruction.push_str(text);
2230    }
2231
2232    fn simple_field(
2233        &mut self,
2234        reader: &NsReader<&[u8]>,
2235        element: &BytesStart<'_>,
2236    ) -> Result<(), ProjectionError> {
2237        let instruction = attribute(reader, element, b"instr")?;
2238        let source = self.current_field_source();
2239        if matches!(source, FieldSource::Hidden) {
2240            return Ok(());
2241        }
2242        let Some(instruction) = instruction.filter(|value| value.len() <= 4096) else {
2243            self.references_complete = false;
2244            return Ok(());
2245        };
2246        self.record_field_reference(&instruction, source);
2247        Ok(())
2248    }
2249
2250    fn field_character(
2251        &mut self,
2252        reader: &NsReader<&[u8]>,
2253        element: &BytesStart<'_>,
2254    ) -> Result<(), ProjectionError> {
2255        match attribute(reader, element, b"fldCharType")?.as_deref() {
2256            Some("begin") => self.fields.push(FieldFrame {
2257                source: self.current_field_source(),
2258                instruction: String::new(),
2259                separated: false,
2260            }),
2261            Some("separate") => {
2262                let Some(mut field) = self.fields.pop() else {
2263                    self.references_complete = false;
2264                    return Ok(());
2265                };
2266                if field.separated {
2267                    self.references_complete = false;
2268                } else {
2269                    self.record_field_reference(&field.instruction, field.source);
2270                    field.separated = true;
2271                }
2272                self.fields.push(field);
2273            }
2274            Some("end") => {
2275                let Some(field) = self.fields.pop() else {
2276                    self.references_complete = false;
2277                    return Ok(());
2278                };
2279                if !field.separated {
2280                    self.record_field_reference(&field.instruction, field.source);
2281                }
2282            }
2283            _ => self.references_complete = false,
2284        }
2285        Ok(())
2286    }
2287
2288    fn current_field_source(&self) -> FieldSource {
2289        if self.pseudo_text_is_suppressed() {
2290            return FieldSource::Hidden;
2291        }
2292        self.current_review_point()
2293            .map_or(FieldSource::Unsupported, FieldSource::Projected)
2294    }
2295
2296    fn record_field_reference(&mut self, instruction: &str, source: FieldSource) {
2297        if matches!(source, FieldSource::Hidden) {
2298            return;
2299        }
2300        match internal_reference_target(instruction) {
2301            Ok(Some(reference_id)) => {
2302                let FieldSource::Projected(source) = source else {
2303                    self.references_complete = false;
2304                    return;
2305                };
2306                self.references.push(RawInternalReference {
2307                    reference_id,
2308                    source,
2309                });
2310            }
2311            Ok(None) => {}
2312            Err(()) => self.references_complete = false,
2313        }
2314    }
2315
2316    fn current_point(&self) -> Option<ReviewPoint> {
2317        if self.current_paragraph.is_some() {
2318            return self.current_review_point();
2319        }
2320        if !matches!(self.frames.last(), Some(Frame::Body)) {
2321            return None;
2322        }
2323        self.paragraphs
2324            .last()
2325            .map_or(Some(paragraph_start(0)), paragraph_end)
2326    }
2327
2328    fn current_bookmark_point(&self) -> Option<BookmarkPoint> {
2329        if self.current_paragraph.is_some() {
2330            return self.current_point().map(BookmarkPoint::Paragraph);
2331        }
2332        matches!(
2333            self.frames.last(),
2334            Some(
2335                Frame::Body
2336                    | Frame::BlockContent
2337                    | Frame::Table(_)
2338                    | Frame::Row(_)
2339                    | Frame::Cell(_)
2340            )
2341        )
2342        .then(|| BookmarkPoint::ParagraphBoundary(self.paragraphs.len()))
2343    }
2344
2345    fn start_bookmark(
2346        &mut self,
2347        reader: &NsReader<&[u8]>,
2348        element: &BytesStart<'_>,
2349    ) -> Result<(), ProjectionError> {
2350        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2351        let name = attribute(reader, element, b"name")?;
2352        let point = self.current_bookmark_point();
2353        let (Some(id), Some(name), Some(point)) = (id, name, point) else {
2354            self.bookmarks_complete = false;
2355            return Ok(());
2356        };
2357        if name.is_empty()
2358            || !self.seen_bookmark_ids.insert(id)
2359            || self.open_bookmarks.insert(id, (name, point)).is_some()
2360        {
2361            self.bookmarks_complete = false;
2362        }
2363        Ok(())
2364    }
2365
2366    fn end_bookmark(
2367        &mut self,
2368        reader: &NsReader<&[u8]>,
2369        element: &BytesStart<'_>,
2370    ) -> Result<(), ProjectionError> {
2371        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2372        let point = self.current_bookmark_point();
2373        let (Some(id), Some(end)) = (id, point) else {
2374            self.bookmarks_complete = false;
2375            return Ok(());
2376        };
2377        let Some((name, start)) = self.open_bookmarks.remove(&id) else {
2378            self.bookmarks_complete = false;
2379            return Ok(());
2380        };
2381        self.bookmarks.push(PendingBookmarkRange {
2382            id,
2383            name,
2384            start,
2385            end,
2386        });
2387        Ok(())
2388    }
2389
2390    fn mark_placeholder(&mut self) {
2391        let Some(sdt) = self.frames.iter_mut().rev().find_map(|frame| match frame {
2392            Frame::Sdt(sdt) => Some(sdt),
2393            _ => None,
2394        }) else {
2395            return;
2396        };
2397        sdt.placeholder = true;
2398        if let Some(paragraph) = self.current_paragraph.as_mut() {
2399            paragraph.truncate(sdt.text_utf8_start, sdt.text_utf16_start);
2400        }
2401    }
2402
2403    fn mark_hidden_run(&mut self) {
2404        let Some((run_frame, hidden_eligible)) =
2405            self.frames.iter().rev().find_map(|frame| match frame {
2406                Frame::RunProperties(properties) => {
2407                    Some((properties.run_frame, properties.hidden_eligible))
2408                }
2409                _ => None,
2410            })
2411        else {
2412            return;
2413        };
2414        if !hidden_eligible || run_frame == usize::MAX {
2415            return;
2416        }
2417        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2418            run.hidden = true;
2419        }
2420    }
2421
2422    fn set_run_character_style(&mut self, style_id: Option<String>) {
2423        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2424            return;
2425        };
2426        let run_frame = properties.run_frame;
2427        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2428            run.character_style_id = style_id;
2429        }
2430    }
2431
2432    fn mark_bold_run(&mut self, enabled: bool) {
2433        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2434            return;
2435        };
2436        let run_frame = properties.run_frame;
2437        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2438            run.direct_styles.bold = Some(enabled);
2439        }
2440    }
2441
2442    fn mark_complex_script_bold_run(&mut self, enabled: bool) {
2443        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2444            return;
2445        };
2446        let run_frame = properties.run_frame;
2447        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2448            run.direct_styles.complex_script_bold = Some(enabled);
2449        }
2450    }
2451
2452    fn mark_complex_script_run(&mut self, enabled: bool) {
2453        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2454            return;
2455        };
2456        let run_frame = properties.run_frame;
2457        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2458            run.direct_styles.force_complex_script = Some(enabled);
2459        }
2460    }
2461
2462    fn mark_right_to_left_run(&mut self, enabled: bool) {
2463        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2464            return;
2465        };
2466        let run_frame = properties.run_frame;
2467        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2468            run.direct_styles.right_to_left = Some(enabled);
2469        }
2470    }
2471
2472    fn mark_highlighted_run(&mut self, enabled: bool) {
2473        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2474            return;
2475        };
2476        let run_frame = properties.run_frame;
2477        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2478            run.direct_styles.highlighted = Some(enabled);
2479        }
2480    }
2481
2482    fn mark_superscript_run(&mut self, enabled: bool) {
2483        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2484            return;
2485        };
2486        let run_frame = properties.run_frame;
2487        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2488            run.direct_styles.superscript = Some(enabled);
2489        }
2490    }
2491}
2492
2493const fn revision_fact_kind(name: &[u8]) -> Option<RevisionFactKind> {
2494    match name {
2495        b"ins" => Some(RevisionFactKind::Insertion),
2496        b"del" => Some(RevisionFactKind::Deletion),
2497        b"moveFrom" => Some(RevisionFactKind::MoveFrom),
2498        b"moveTo" => Some(RevisionFactKind::MoveTo),
2499        b"cellIns" => Some(RevisionFactKind::CellInsertion),
2500        b"cellDel" => Some(RevisionFactKind::CellDeletion),
2501        b"cellMerge" => Some(RevisionFactKind::CellMerge),
2502        b"pPrChange" => Some(RevisionFactKind::ParagraphPropertiesChange),
2503        b"rPrChange" => Some(RevisionFactKind::RunPropertiesChange),
2504        b"sectPrChange" => Some(RevisionFactKind::SectionPropertiesChange),
2505        b"tblPrChange" => Some(RevisionFactKind::TablePropertiesChange),
2506        b"tblPrExChange" => Some(RevisionFactKind::TablePropertiesExceptionChange),
2507        b"trPrChange" => Some(RevisionFactKind::TableRowPropertiesChange),
2508        b"tcPrChange" => Some(RevisionFactKind::TableCellPropertiesChange),
2509        b"tblGridChange" => Some(RevisionFactKind::TableGridChange),
2510        b"customXmlDelRangeStart" => Some(RevisionFactKind::CustomXmlDeletionRangeStart),
2511        b"customXmlDelRangeEnd" => Some(RevisionFactKind::CustomXmlDeletionRangeEnd),
2512        b"customXmlInsRangeStart" => Some(RevisionFactKind::CustomXmlInsertionRangeStart),
2513        b"customXmlInsRangeEnd" => Some(RevisionFactKind::CustomXmlInsertionRangeEnd),
2514        b"customXmlMoveFromRangeStart" => Some(RevisionFactKind::CustomXmlMoveFromRangeStart),
2515        b"customXmlMoveFromRangeEnd" => Some(RevisionFactKind::CustomXmlMoveFromRangeEnd),
2516        b"customXmlMoveToRangeStart" => Some(RevisionFactKind::CustomXmlMoveToRangeStart),
2517        b"customXmlMoveToRangeEnd" => Some(RevisionFactKind::CustomXmlMoveToRangeEnd),
2518        _ => None,
2519    }
2520}
2521
2522fn enclosing_table(frames: &mut [Frame]) -> Option<&mut TableFrame> {
2523    for frame in frames.iter_mut().rev() {
2524        match frame {
2525            Frame::Table(table) => return Some(table),
2526            Frame::Row(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2527            _ => {}
2528        }
2529    }
2530    None
2531}
2532
2533fn enclosing_row(frames: &mut [Frame]) -> Option<&mut RowFrame> {
2534    for frame in frames.iter_mut().rev() {
2535        match frame {
2536            Frame::Row(row) => return Some(row),
2537            Frame::Table(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2538            _ => {}
2539        }
2540    }
2541    None
2542}
2543
2544fn revision_is_suppressed(name: &[u8], view: RevisionView) -> bool {
2545    match view {
2546        RevisionView::Current => matches!(name, b"del" | b"moveFrom"),
2547        RevisionView::Original => matches!(name, b"ins" | b"moveTo"),
2548    }
2549}
2550
2551fn is_change_snapshot(name: &[u8]) -> bool {
2552    matches!(
2553        name,
2554        b"pPrChange"
2555            | b"rPrChange"
2556            | b"sectPrChange"
2557            | b"tblPrChange"
2558            | b"tblPrExChange"
2559            | b"trPrChange"
2560            | b"tcPrChange"
2561            | b"tblGridChange"
2562    )
2563}
2564
2565fn is_unsupported_revision_markup(name: &[u8]) -> bool {
2566    matches!(
2567        name,
2568        b"customXmlDelRangeStart"
2569            | b"customXmlDelRangeEnd"
2570            | b"customXmlInsRangeStart"
2571            | b"customXmlInsRangeEnd"
2572            | b"customXmlMoveFromRangeStart"
2573            | b"customXmlMoveFromRangeEnd"
2574            | b"customXmlMoveToRangeStart"
2575            | b"customXmlMoveToRangeEnd"
2576    )
2577}
2578
2579const fn paragraph_break_is_removed(
2580    revision: Option<ParagraphMarkRevision>,
2581    view: RevisionView,
2582) -> bool {
2583    matches!(
2584        (revision, view),
2585        (Some(ParagraphMarkRevision::Removed), RevisionView::Current)
2586            | (Some(ParagraphMarkRevision::Added), RevisionView::Original)
2587    )
2588}
2589
2590// Share the compiled coalescer between run projection and paragraph joins.
2591#[inline(never)]
2592fn append_formatting_span(spans: &mut Vec<TextFormattingSpan>, span: TextFormattingSpan) {
2593    if let Some(adjacent) = spans
2594        .iter_mut()
2595        .rev()
2596        .find(|previous| previous.style == span.style)
2597        && adjacent.end_utf16 == span.start_utf16
2598    {
2599        adjacent.end_utf16 = span.end_utf16;
2600        return;
2601    }
2602    spans.push(span);
2603}
2604
2605/// Merges paragraphs whose break the selected view removes. When any merge
2606/// happens, returns where each source paragraph starts in the merged text, so
2607/// review points recorded against source paragraphs can be translated.
2608fn normalize_paragraph_revision_view(
2609    paragraphs: &mut Vec<RawProjectedParagraph>,
2610    paragraph_mark_revisions: &mut HashMap<usize, ParagraphMarkRevision>,
2611    view: RevisionView,
2612    unsupported: &mut BTreeSet<RevisionUnsupportedReason>,
2613) -> Result<Option<Vec<ReviewPoint>>, ProjectionError> {
2614    let mut normalized: Vec<RawProjectedParagraph> = Vec::with_capacity(paragraphs.len());
2615    let mut origins = Vec::with_capacity(paragraphs.len());
2616    let mut merge_previous = false;
2617    let mut merged_any = false;
2618
2619    for (source_ordinal, mut paragraph) in std::mem::take(paragraphs).into_iter().enumerate() {
2620        let merge_next =
2621            paragraph_break_is_removed(paragraph_mark_revisions.remove(&source_ordinal), view);
2622        if merge_previous {
2623            let previous = normalized
2624                .last_mut()
2625                .ok_or(ProjectionError::InvalidDocumentXml)?;
2626            if previous.structure != paragraph.structure {
2627                unsupported.insert(RevisionUnsupportedReason::IncompatibleParagraphMerge);
2628                paragraph.ordinal = normalized.len();
2629                origins.push(paragraph_start(paragraph.ordinal));
2630                normalized.push(paragraph);
2631                merge_previous = merge_next;
2632                continue;
2633            }
2634            let utf16_offset = previous.utf16_len;
2635            origins.push(ReviewPoint {
2636                paragraph_ordinal: previous.ordinal,
2637                utf8: u32::try_from(previous.text.len())
2638                    .map_err(|_| ProjectionError::InvalidDocumentXml)?,
2639                utf16: utf16_offset,
2640            });
2641            for mut span in paragraph.formatting {
2642                span.start_utf16 = span
2643                    .start_utf16
2644                    .checked_add(utf16_offset)
2645                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2646                span.end_utf16 = span
2647                    .end_utf16
2648                    .checked_add(utf16_offset)
2649                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2650                append_formatting_span(&mut previous.formatting, span);
2651            }
2652            previous.text.push_str(&paragraph.text);
2653            previous.utf16_len = previous
2654                .utf16_len
2655                .checked_add(paragraph.utf16_len)
2656                .ok_or(ProjectionError::InvalidDocumentXml)?;
2657            merge_previous = merge_next;
2658            merged_any = true;
2659            continue;
2660        }
2661
2662        paragraph.ordinal = normalized.len();
2663        origins.push(paragraph_start(paragraph.ordinal));
2664        normalized.push(paragraph);
2665        merge_previous = merge_next;
2666    }
2667
2668    *paragraphs = normalized;
2669    Ok(merged_any.then_some(origins))
2670}
2671
2672const fn paragraph_start(paragraph_ordinal: usize) -> ReviewPoint {
2673    ReviewPoint {
2674        paragraph_ordinal,
2675        utf8: 0,
2676        utf16: 0,
2677    }
2678}
2679
2680/// Translates a point recorded against a source paragraph into the merged
2681/// paragraph coordinates. `None` means the point names no source paragraph.
2682fn relocate_review_point(origins: &[ReviewPoint], point: ReviewPoint) -> Option<ReviewPoint> {
2683    let origin = origins.get(point.paragraph_ordinal)?;
2684    Some(ReviewPoint {
2685        paragraph_ordinal: origin.paragraph_ordinal,
2686        utf8: origin.utf8.checked_add(point.utf8)?,
2687        utf16: origin.utf16.checked_add(point.utf16)?,
2688    })
2689}
2690
2691fn relocate_block_point(
2692    origins: &[ReviewPoint],
2693    point: &mut ReviewPoint,
2694) -> Result<(), ProjectionError> {
2695    *point = relocate_review_point(origins, *point).ok_or(ProjectionError::InvalidDocumentXml)?;
2696    Ok(())
2697}
2698
2699fn relocate_review_span(origins: &[ReviewPoint], span: ReviewSpan) -> Option<ReviewSpan> {
2700    Some(ReviewSpan {
2701        start: relocate_review_point(origins, span.start)?,
2702        end: relocate_review_point(origins, span.end)?,
2703    })
2704}
2705
2706fn internal_reference_target(instruction: &str) -> Result<Option<String>, ()> {
2707    let tokens = field_tokens(instruction)?;
2708    let Some(command) = tokens.first() else {
2709        return Err(());
2710    };
2711    if ["REF", "PAGEREF", "NOTEREF"]
2712        .iter()
2713        .any(|candidate| command.eq_ignore_ascii_case(candidate))
2714    {
2715        return tokens
2716            .get(1)
2717            .filter(|target| !target.is_empty() && !target.starts_with('\\'))
2718            .cloned()
2719            .map(Some)
2720            .ok_or(());
2721    }
2722    if command.eq_ignore_ascii_case("HYPERLINK") {
2723        for (index, token) in tokens.iter().enumerate().skip(1) {
2724            if token.eq_ignore_ascii_case("\\l") {
2725                return tokens
2726                    .get(index.checked_add(1).ok_or(())?)
2727                    .filter(|target| !target.is_empty())
2728                    .cloned()
2729                    .map(Some)
2730                    .ok_or(());
2731            }
2732        }
2733    }
2734    Ok(None)
2735}
2736
2737fn field_tokens(instruction: &str) -> Result<Vec<String>, ()> {
2738    let mut tokens = Vec::new();
2739    let mut token = String::new();
2740    let mut quoted = false;
2741    for character in instruction.chars() {
2742        match character {
2743            '"' => quoted = !quoted,
2744            whitespace if whitespace.is_whitespace() && !quoted => {
2745                if !token.is_empty() {
2746                    tokens.push(std::mem::take(&mut token));
2747                }
2748            }
2749            _ => token.push(character),
2750        }
2751    }
2752    if quoted {
2753        return Err(());
2754    }
2755    if !token.is_empty() {
2756        tokens.push(token);
2757    }
2758    Ok(tokens)
2759}
2760
2761fn attribute(
2762    reader: &NsReader<&[u8]>,
2763    element: &BytesStart<'_>,
2764    name: &[u8],
2765) -> Result<Option<String>, ProjectionError> {
2766    for attribute in element.attributes() {
2767        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2768        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2769        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing
2770            && local_name.as_ref() == name
2771        {
2772            return attribute
2773                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2774                .map(|value| Some(value.into_owned()))
2775                .map_err(|_| ProjectionError::InvalidDocumentXml);
2776        }
2777    }
2778    Ok(None)
2779}
2780
2781fn preserves_xml_space(
2782    reader: &NsReader<&[u8]>,
2783    element: &BytesStart<'_>,
2784) -> Result<bool, ProjectionError> {
2785    const XML_NAMESPACE: &[u8] = b"http://www.w3.org/XML/1998/namespace";
2786
2787    for attribute in element.attributes() {
2788        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2789        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2790        if matches!(
2791            namespace,
2792            quick_xml::name::ResolveResult::Bound(namespace)
2793                if namespace.as_ref() == XML_NAMESPACE
2794        ) && local_name.as_ref() == b"space"
2795        {
2796            let value = attribute
2797                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2798                .map_err(|_| ProjectionError::InvalidDocumentXml)?;
2799            return match value.as_ref() {
2800                "default" => Ok(false),
2801                "preserve" => Ok(true),
2802                _ => Err(ProjectionError::InvalidDocumentXml),
2803            };
2804        }
2805    }
2806    Ok(false)
2807}
2808
2809fn on_off_element_enabled(
2810    reader: &NsReader<&[u8]>,
2811    element: &BytesStart<'_>,
2812) -> Result<bool, ProjectionError> {
2813    Ok(attribute(reader, element, b"val")?
2814        .is_none_or(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")))
2815}
2816
2817fn trim_xml_whitespace(value: &str) -> &str {
2818    value.trim_matches(['\u{0009}', '\u{000a}', '\u{000d}', '\u{0020}'])
2819}
2820
2821fn attribute_2010(
2822    reader: &NsReader<&[u8]>,
2823    element: &BytesStart<'_>,
2824    name: &[u8],
2825) -> Result<Option<String>, ProjectionError> {
2826    for attribute in element.attributes() {
2827        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2828        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2829        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing2010
2830            && local_name.as_ref() == name
2831        {
2832            return attribute
2833                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2834                .map(|value| Some(value.into_owned()))
2835                .map_err(|_| ProjectionError::InvalidDocumentXml);
2836        }
2837    }
2838    Ok(None)
2839}
2840
2841fn symbol_text(value: &str, font: Option<&str>) -> String {
2842    let prefix = value
2843        .chars()
2844        .take_while(char::is_ascii_hexdigit)
2845        .collect::<String>();
2846    let Some(code) = (!prefix.is_empty())
2847        .then(|| u32::from_str_radix(&prefix, 16).ok())
2848        .flatten()
2849    else {
2850        return value.to_owned();
2851    };
2852    if let Some(replacement) = normalized_font_symbol(font, code) {
2853        return replacement.to_owned();
2854    }
2855    char::from_u32(code).unwrap_or('\u{fffd}').to_string()
2856}
2857
2858struct FontSymbolMapping {
2859    font: &'static str,
2860    encoded: u32,
2861    unicode: &'static str,
2862}
2863
2864// OOXML resolves w:sym through its named symbol font rather than the run font:
2865// https://learn.microsoft.com/dotnet/api/documentformat.openxml.wordprocessing.symbolchar
2866// Unicode's Wingdings mapping identifies the corresponding black-circle glyph:
2867// https://www.unicode.org/L2/L2011/11196-n4022-wingdings.pdf
2868// Keep this compatibility table explicit; unsupported font/code pairs preserve
2869// their encoded value instead of guessing from the glyph shape.
2870const FONT_SYMBOL_MAPPINGS: &[FontSymbolMapping] = &[FontSymbolMapping {
2871    font: "Wingdings",
2872    encoded: 0xF06C,
2873    unicode: "●",
2874}];
2875
2876fn normalized_font_symbol(font: Option<&str>, encoded: u32) -> Option<&'static str> {
2877    let font = font?;
2878    FONT_SYMBOL_MAPPINGS
2879        .iter()
2880        .find(|mapping| mapping.encoded == encoded && mapping.font.eq_ignore_ascii_case(font))
2881        .map(|mapping| mapping.unicode)
2882}