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