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::{FormattingProjectionStatus, 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_status: FormattingProjectionStatus,
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_status: match styles {
747            Ok(_) => FormattingProjectionStatus::Complete,
748            Err(reason) => FormattingProjectionStatus::Incomplete(reason),
749        },
750        review_revisions: review_limits.map_or(ReviewRevisionCollection::Disabled, |limits| {
751            ReviewRevisionCollection::Complete {
752                maximum_facts: limits.maximum_facts,
753                remaining_detail_bytes: limits.maximum_detail_bytes,
754                revisions: Vec::new(),
755            }
756        }),
757        ..ProjectionState::default()
758    };
759    let mut compatibility = MarkupCompatibility::default();
760    loop {
761        match reader
762            .read_event()
763            .map_err(|_| ProjectionError::InvalidDocumentXml)?
764        {
765            Event::Start(element) => {
766                let (namespace, _) = reader.resolver().resolve_element(element.name());
767                let namespace = OoxmlNamespace::from_resolved(&namespace);
768                if compatibility.start(&reader, namespace, &element)?
769                    == CompatibilityAction::Process
770                {
771                    state.start(&reader, namespace, &element)?;
772                }
773            }
774            Event::Empty(element) => {
775                let (namespace, _) = reader.resolver().resolve_element(element.name());
776                let namespace = OoxmlNamespace::from_resolved(&namespace);
777                if compatibility.empty(&reader, namespace, &element)?
778                    == CompatibilityAction::Process
779                {
780                    state.start(&reader, namespace, &element)?;
781                    state.end(styles)?;
782                }
783            }
784            Event::Text(text) => {
785                if !compatibility.is_suppressed()
786                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
787                {
788                    let decoded = text
789                        .xml10_content()
790                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
791                    state.append_pseudo_content(&decoded)?;
792                }
793            }
794            Event::CData(text) => {
795                if !compatibility.is_suppressed()
796                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
797                {
798                    let decoded = text
799                        .xml10_content()
800                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
801                    state.append_pseudo_content(&decoded)?;
802                }
803            }
804            Event::GeneralRef(reference) => {
805                if !compatibility.is_suppressed()
806                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
807                {
808                    let resolved_character = reference
809                        .resolve_char_ref()
810                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
811                    let resolved = if let Some(character) = resolved_character {
812                        character.to_string()
813                    } else {
814                        let name = reference
815                            .decode()
816                            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
817                        resolve_predefined_entity(&name)
818                            .ok_or(ProjectionError::InvalidDocumentXml)?
819                            .to_owned()
820                    };
821                    state.append_pseudo_content(&resolved)?;
822                }
823            }
824            Event::End(element) => {
825                let (namespace, local_name) = reader.resolver().resolve_element(element.name());
826                if compatibility.end(
827                    OoxmlNamespace::from_resolved(&namespace),
828                    local_name.as_ref(),
829                )? == CompatibilityAction::Process
830                {
831                    state.end(styles)?;
832                }
833            }
834            Event::Eof => break,
835            _ => {}
836        }
837    }
838    if !state.body_seen {
839        return Err(ProjectionError::MissingDocumentBody);
840    }
841    if state.current_paragraph.is_some() || !state.frames.is_empty() || !compatibility.is_complete()
842    {
843        return Err(ProjectionError::InvalidDocumentXml);
844    }
845    let mut bookmarks = if state.bookmarks_complete && state.open_bookmarks.is_empty() {
846        resolve_bookmark_ranges(&state.paragraphs, state.bookmarks)
847            .ok_or(StructuralFactUnknownReason::IncompleteBookmarkRanges)
848    } else {
849        Err(StructuralFactUnknownReason::IncompleteBookmarkRanges)
850    };
851    let paragraph_origins = if state.paragraph_mark_revisions.is_empty() {
852        None
853    } else {
854        normalize_paragraph_revision_view(
855            &mut state.paragraphs,
856            &mut state.paragraph_mark_revisions,
857            state.revision_view,
858            &mut state.revision_unsupported,
859        )?
860    };
861    if let Some(origins) = paragraph_origins {
862        if let Ok(ranges) = &mut bookmarks {
863            for range in ranges {
864                relocate_block_point(&origins, &mut range.start)?;
865                relocate_block_point(&origins, &mut range.end)?;
866            }
867        }
868        for reference in &mut state.references {
869            relocate_block_point(&origins, &mut reference.source)?;
870        }
871        state.review_comment_anchors.retain(|_, anchor| {
872            relocate_review_span(&origins, *anchor).is_some_and(|relocated| {
873                *anchor = relocated;
874                true
875            })
876        });
877        if let ReviewRevisionCollection::Complete { revisions, .. } = &mut state.review_revisions {
878            for revision in revisions {
879                let ReviewDetail::Known(content) = &mut revision.content else {
880                    continue;
881                };
882                match relocate_review_span(&origins, content.span) {
883                    Some(span) => content.span = span,
884                    None => {
885                        revision.content =
886                            ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation);
887                    }
888                }
889            }
890        }
891    }
892    let references = if state.references_complete && state.fields.is_empty() {
893        Ok(state.references)
894    } else {
895        Err(StructuralFactUnknownReason::UnsupportedInternalReferences)
896    };
897    Ok(RawDocumentProjection {
898        paragraphs: state.paragraphs,
899        bookmarks,
900        references,
901        formatting_status: state.formatting_status,
902        revision_status: if state.revision_unsupported.is_empty() {
903            RevisionProjectionStatus::Complete
904        } else {
905            RevisionProjectionStatus::Incomplete(state.revision_unsupported.into_iter().collect())
906        },
907        review_revisions: match state.review_revisions {
908            ReviewRevisionCollection::Disabled => None,
909            ReviewRevisionCollection::Complete { revisions, .. } => {
910                Some(ReviewFactSet::Known(revisions))
911            }
912            ReviewRevisionCollection::LimitExceeded => Some(ReviewFactSet::Unknown(
913                ReviewFactUnknownReason::ResourceLimit,
914            )),
915        },
916        review_comment_anchors: state.review_comment_anchors,
917    })
918}
919
920#[derive(Default)]
921struct ProjectionState {
922    frames: Vec<Frame>,
923    paragraphs: Vec<RawProjectedParagraph>,
924    current_paragraph: Option<ParagraphBuilder>,
925    body_seen: bool,
926    next_table_ordinal: usize,
927    maximum_paragraphs: usize,
928    open_bookmarks: HashMap<u32, (String, BookmarkPoint)>,
929    seen_bookmark_ids: HashSet<u32>,
930    bookmarks: Vec<PendingBookmarkRange>,
931    references: Vec<RawInternalReference>,
932    bookmarks_complete: bool,
933    references_complete: bool,
934    formatting_status: FormattingProjectionStatus,
935    fields: Vec<FieldFrame>,
936    paragraph_mark_revisions: HashMap<usize, ParagraphMarkRevision>,
937    revision_view: RevisionView,
938    text_materialization: TextMaterialization,
939    revision_unsupported: BTreeSet<RevisionUnsupportedReason>,
940    review_revisions: ReviewRevisionCollection,
941    open_review_comment_anchors: HashMap<String, ReviewPoint>,
942    review_comment_anchors: HashMap<String, ReviewSpan>,
943    invalid_review_comment_anchors: HashSet<String>,
944}
945
946impl ProjectionState {
947    #[allow(clippy::too_many_lines)] // The frame transition table is one streaming state machine.
948    fn start(
949        &mut self,
950        reader: &NsReader<&[u8]>,
951        namespace: OoxmlNamespace,
952        element: &BytesStart<'_>,
953    ) -> Result<(), ProjectionError> {
954        let local_name = element.local_name();
955        let name = if namespace == OoxmlNamespace::Wordprocessing {
956            local_name.as_ref()
957        } else {
958            b""
959        };
960        let direct_run_child = if let Some(Frame::Run(run)) = self.frames.last_mut() {
961            let child = run.direct_child_count;
962            run.direct_child_count = run
963                .direct_child_count
964                .checked_add(1)
965                .ok_or(ProjectionError::InvalidDocumentXml)?;
966            let frame_index = self
967                .frames
968                .len()
969                .checked_sub(1)
970                .ok_or(ProjectionError::InvalidDocumentXml)?;
971            Some((frame_index, child))
972        } else {
973            None
974        };
975
976        if name == b"body" {
977            if self.body_seen {
978                return Err(ProjectionError::InvalidDocumentXml);
979            }
980            self.body_seen = true;
981            self.frames.push(Frame::Body);
982            return Ok(());
983        }
984        if !self.inside_body() {
985            self.frames.push(Frame::Other);
986            return Ok(());
987        }
988        let inside_snapshot = self.inside_change_snapshot();
989        if inside_snapshot && self.start_snapshot_child(name)? {
990            return Ok(());
991        }
992        let attributed_revision = if inside_snapshot {
993            None
994        } else {
995            self.record_attributed_revision(reader, element, name)?
996        };
997        if name == b"txbxContent" {
998            self.frames.push(Frame::Textbox);
999            return Ok(());
1000        }
1001        if self.inside_textbox() {
1002            if matches!(
1003                name,
1004                b"commentRangeStart" | b"commentRangeEnd" | b"commentReference"
1005            ) {
1006                self.suppress_review_comment_anchor(reader, element)?;
1007            }
1008            self.frames.push(Frame::Other);
1009            return Ok(());
1010        }
1011        match name {
1012            b"commentRangeStart" => self.start_review_comment_anchor(reader, element)?,
1013            b"commentRangeEnd" => self.end_review_comment_anchor(reader, element)?,
1014            b"commentReference" => self.record_review_comment_reference(reader, element)?,
1015            _ => {}
1016        }
1017        if is_change_snapshot(name) {
1018            if let Some(review_index) = attributed_revision {
1019                match (name, self.frames.last()) {
1020                    (b"rPrChange", Some(Frame::RunProperties(properties))) => {
1021                        let run_frame = properties.run_frame;
1022                        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1023                            run.property_review_indices.push(review_index);
1024                        } else if self.inside_paragraph_mark_properties()
1025                            && let Some(paragraph) = self.current_paragraph.as_mut()
1026                        {
1027                            paragraph.paragraph_review_indices.push(review_index);
1028                        }
1029                    }
1030                    (b"pPrChange", Some(Frame::ParagraphProperties)) => {
1031                        if let Some(paragraph) = self.current_paragraph.as_mut() {
1032                            paragraph.paragraph_review_indices.push(review_index);
1033                        }
1034                    }
1035                    _ => self.record_container_property_revision(name, review_index)?,
1036                }
1037            }
1038            let owner = self.snapshot_owner(name);
1039            self.frames.push(Frame::ChangeSnapshot(SnapshotFrame {
1040                owner,
1041                status: SnapshotStatus::AwaitingRoot,
1042            }));
1043            return Ok(());
1044        }
1045        if is_unsupported_revision_markup(name) {
1046            self.revision_unsupported
1047                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1048            self.frames.push(Frame::Other);
1049            return Ok(());
1050        }
1051        if namespace == OoxmlNamespace::OfficeMath {
1052            let frame = match local_name.as_ref() {
1053                b"oMath" | b"oMathPara" if self.current_paragraph.is_some() => {
1054                    // Math text participates in paragraph offsets, but OMML run
1055                    // properties form a separate formatting hierarchy.
1056                    self.formatting_status = FormattingProjectionStatus::Incomplete(
1057                        FormattingUnknownReason::UnsupportedStyles,
1058                    );
1059                    Frame::Math
1060                }
1061                b"r" if self.frames.iter().any(|frame| matches!(frame, Frame::Math)) => {
1062                    Frame::MathRun
1063                }
1064                b"t" if matches!(self.frames.last(), Some(Frame::MathRun)) => {
1065                    Frame::PseudoText(PseudoTextFrame::math_text())
1066                }
1067                _ => Frame::Other,
1068            };
1069            self.frames.push(frame);
1070            return Ok(());
1071        }
1072
1073        let frame = match name {
1074            b"tbl" => {
1075                let ordinal = self.next_table_ordinal;
1076                self.next_table_ordinal = self
1077                    .next_table_ordinal
1078                    .checked_add(1)
1079                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1080                Frame::Table(TableFrame {
1081                    ordinal,
1082                    next_row: 0,
1083                    style_id: None,
1084                    review: ContainerReview {
1085                        paragraph_start: self.paragraphs.len(),
1086                        property_review: None,
1087                        grid_review: None,
1088                        exception_review: None,
1089                    },
1090                })
1091            }
1092            b"tr" => {
1093                let Some(table) = enclosing_table(&mut self.frames) else {
1094                    self.frames.push(Frame::Other);
1095                    return Ok(());
1096                };
1097                let row = table.next_row;
1098                table.next_row = table
1099                    .next_row
1100                    .checked_add(1)
1101                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1102                Frame::Row(RowFrame {
1103                    table_ordinal: table.ordinal,
1104                    row,
1105                    next_column: 0,
1106                    review: ContainerReview {
1107                        paragraph_start: self.paragraphs.len(),
1108                        property_review: None,
1109                        grid_review: None,
1110                        exception_review: None,
1111                    },
1112                })
1113            }
1114            b"tc" => {
1115                let Some(row) = enclosing_row(&mut self.frames) else {
1116                    self.frames.push(Frame::Other);
1117                    return Ok(());
1118                };
1119                let column = row.next_column;
1120                row.next_column = row
1121                    .next_column
1122                    .checked_add(1)
1123                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1124                Frame::Cell(CellFrame {
1125                    table_ordinal: row.table_ordinal,
1126                    row: row.row,
1127                    column,
1128                    review: ContainerReview {
1129                        paragraph_start: self.paragraphs.len(),
1130                        property_review: None,
1131                        grid_review: None,
1132                        exception_review: None,
1133                    },
1134                })
1135            }
1136            b"p" => {
1137                if self.current_paragraph.is_some() {
1138                    self.frames.push(Frame::Other);
1139                    return Ok(());
1140                }
1141                if self.paragraphs.len() >= self.maximum_paragraphs {
1142                    return Err(ProjectionError::TooManyParagraphs);
1143                }
1144                let package_paragraph_id = attribute_2010(reader, element, b"paraId")?
1145                    .as_deref()
1146                    .and_then(PackageParagraphId::parse);
1147                let structure = self.frames.iter().rev().find_map(|frame| match frame {
1148                    Frame::Cell(cell) => Some(ParagraphStructure {
1149                        table_ordinal: cell.table_ordinal,
1150                        row: cell.row,
1151                        column: cell.column,
1152                    }),
1153                    _ => None,
1154                });
1155                self.current_paragraph = Some(ParagraphBuilder {
1156                    package_paragraph_id,
1157                    text: String::new(),
1158                    utf16_len: 0,
1159                    formatting: Vec::new(),
1160                    structure,
1161                    properties: ParagraphProperties {
1162                        table_style: self
1163                            .frames
1164                            .iter()
1165                            .rev()
1166                            .find_map(|frame| {
1167                                if let Frame::Table(table) = frame {
1168                                    Some(table.style_id.clone().map_or(
1169                                        TableStyleSelection::Default,
1170                                        TableStyleSelection::Explicit,
1171                                    ))
1172                                } else {
1173                                    None
1174                                }
1175                            })
1176                            .unwrap_or_default(),
1177                        ..ParagraphProperties::default()
1178                    },
1179                    resolved_text_base: None,
1180                    paragraph_mark_revision: None,
1181                    paragraph_review_indices: Vec::new(),
1182                });
1183                Frame::Paragraph
1184            }
1185            b"r" if self.current_paragraph.is_some() => Frame::Run(RunFrame {
1186                text: String::new(),
1187                direct_styles: TextProperties::default(),
1188                character_style_id: None,
1189                hidden: false,
1190                direct_child_count: 0,
1191                property_review_indices: Vec::new(),
1192            }),
1193            b"rPr" => {
1194                let Some((run_frame, child_index)) = direct_run_child else {
1195                    self.frames.push(Frame::RunProperties(RunPropertiesFrame {
1196                        run_frame: usize::MAX,
1197                        hidden_eligible: false,
1198                    }));
1199                    return Ok(());
1200                };
1201                Frame::RunProperties(RunPropertiesFrame {
1202                    run_frame,
1203                    hidden_eligible: child_index == 0,
1204                })
1205            }
1206            b"sdtContent" | b"customXml" => Frame::BlockContent,
1207            b"pPr" => Frame::ParagraphProperties,
1208            b"tblStyle" if matches!(self.frames.last(), Some(Frame::TableProperties)) => {
1209                if let Some(table) = enclosing_table(&mut self.frames) {
1210                    table.style_id = word_style_id(attribute(reader, element, b"val")?);
1211                }
1212                Frame::Other
1213            }
1214            b"tblPr" => Frame::TableProperties,
1215            b"tblGrid" => Frame::TableGrid,
1216            b"trPr" => Frame::TableRowProperties,
1217            b"tcPr" => Frame::TableCellProperties,
1218            b"tblPrEx" => Frame::TablePropertyExceptions,
1219            b"sectPr" => Frame::SectionProperties,
1220            b"pStyle" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1221                if let Some(paragraph) = self.current_paragraph.as_mut() {
1222                    paragraph.properties.style_id =
1223                        word_style_id(attribute(reader, element, b"val")?);
1224                    paragraph.resolved_text_base = None;
1225                }
1226                Frame::Other
1227            }
1228            b"ind" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1229                if let Some(paragraph) = self.current_paragraph.as_mut() {
1230                    paragraph.properties.indentation = parse_indentation(reader, element)?;
1231                }
1232                Frame::Other
1233            }
1234            // `w:jc` is also legal under `w:trPr` and `w:tblPr`, where it means
1235            // row or table justification. Only a direct child of `w:pPr` is
1236            // paragraph alignment.
1237            b"jc" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1238                if let Some(paragraph) = self.current_paragraph.as_mut() {
1239                    paragraph.properties.alignment = Some(parse_alignment(reader, element)?);
1240                }
1241                Frame::Other
1242            }
1243            b"outlineLvl" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1244                if let Some(paragraph) = self.current_paragraph.as_mut() {
1245                    paragraph.properties.outline_level =
1246                        Some(parse_outline_level_attribute(reader, element)?);
1247                }
1248                Frame::Other
1249            }
1250            b"numPr" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1251                if let Some(paragraph) = self.current_paragraph.as_mut() {
1252                    paragraph.properties.numbering.present = true;
1253                }
1254                Frame::NumberingProperties
1255            }
1256            b"numId" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1257                if let Some(paragraph) = self.current_paragraph.as_mut() {
1258                    paragraph.properties.numbering.num_id =
1259                        Some(parse_u32_attribute(reader, element)?);
1260                }
1261                Frame::Other
1262            }
1263            b"ilvl" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1264                if let Some(paragraph) = self.current_paragraph.as_mut() {
1265                    paragraph.properties.numbering.level =
1266                        Some(parse_level_attribute(reader, element)?);
1267                }
1268                Frame::Other
1269            }
1270            b"bookmarkStart" => {
1271                self.start_bookmark(reader, element)?;
1272                Frame::Other
1273            }
1274            b"bookmarkEnd" => {
1275                self.end_bookmark(reader, element)?;
1276                Frame::Other
1277            }
1278            b"hyperlink" => {
1279                if self.pseudo_text_is_suppressed() {
1280                    self.frames.push(Frame::Hyperlink(None));
1281                    return Ok(());
1282                }
1283                let anchor = attribute(reader, element, b"anchor")?;
1284                let reference =
1285                    anchor
1286                        .clone()
1287                        .filter(|anchor| !anchor.is_empty())
1288                        .and_then(|anchor| {
1289                            self.current_point().map(|source| RawInternalReference {
1290                                reference_id: anchor,
1291                                source,
1292                            })
1293                        });
1294                if anchor.is_some() && reference.is_none() {
1295                    self.references_complete = false;
1296                }
1297                Frame::Hyperlink(reference)
1298            }
1299            b"fldSimple" => {
1300                self.simple_field(reader, element)?;
1301                Frame::Other
1302            }
1303            b"fldChar" => {
1304                self.field_character(reader, element)?;
1305                Frame::Other
1306            }
1307            b"instrText" | b"delInstrText" => Frame::PseudoText(PseudoTextFrame::instruction()),
1308            b"ins" | b"del" | b"moveFrom" | b"moveTo" => {
1309                if self.inside_table_row_properties() {
1310                    self.revision_unsupported
1311                        .insert(RevisionUnsupportedReason::StructuralTableRevision);
1312                    Frame::Other
1313                } else if self.inside_paragraph_mark_properties() {
1314                    if let Some(paragraph) = self.current_paragraph.as_mut() {
1315                        paragraph.paragraph_mark_revision =
1316                            ParagraphMarkRevision::from_element(name)
1317                                .or(paragraph.paragraph_mark_revision);
1318                        paragraph
1319                            .paragraph_review_indices
1320                            .extend(attributed_revision);
1321                    }
1322                    Frame::Other
1323                } else {
1324                    Frame::Revision(RevisionFrame {
1325                        review_index: attributed_revision,
1326                        start: self.current_review_point(),
1327                        suppressed: revision_is_suppressed(name, self.revision_view),
1328                        text: String::new(),
1329                    })
1330                }
1331            }
1332            b"cellIns" | b"cellDel" | b"cellMerge" => {
1333                self.revision_unsupported
1334                    .insert(RevisionUnsupportedReason::StructuralTableRevision);
1335                Frame::Other
1336            }
1337            b"sdt" => {
1338                let (text_utf8_start, text_utf16_start) =
1339                    self.current_paragraph.as_ref().map_or((0, 0), |paragraph| {
1340                        (paragraph.text.len(), paragraph.utf16_len)
1341                    });
1342                Frame::Sdt(SdtFrame {
1343                    placeholder: false,
1344                    text_utf8_start,
1345                    text_utf16_start,
1346                })
1347            }
1348            b"showingPlcHdr" => {
1349                if on_off_element_enabled(reader, element)? {
1350                    self.mark_placeholder();
1351                }
1352                Frame::Other
1353            }
1354            b"vanish" => {
1355                if on_off_element_enabled(reader, element)? {
1356                    self.mark_hidden_run();
1357                }
1358                Frame::Other
1359            }
1360            b"rStyle" => {
1361                let style_id = word_style_id(attribute(reader, element, b"val")?);
1362                if matches!(style_id.as_deref(), Some("HideTWBInt" | "HideTWBExt")) {
1363                    self.mark_hidden_run();
1364                }
1365                self.set_run_character_style(style_id);
1366                Frame::Other
1367            }
1368            b"b" => {
1369                self.mark_bold_run(on_off_element_enabled(reader, element)?);
1370                Frame::Other
1371            }
1372            b"bCs" => {
1373                self.mark_complex_script_bold_run(on_off_element_enabled(reader, element)?);
1374                Frame::Other
1375            }
1376            b"cs" => {
1377                self.mark_complex_script_run(on_off_element_enabled(reader, element)?);
1378                Frame::Other
1379            }
1380            b"rtl" => {
1381                self.mark_right_to_left_run(on_off_element_enabled(reader, element)?);
1382                Frame::Other
1383            }
1384            b"highlight" => {
1385                if let Some(highlighted) = attribute(reader, element, b"val")?
1386                    .as_deref()
1387                    .and_then(semantic_highlight_value)
1388                {
1389                    self.mark_highlighted_run(highlighted);
1390                }
1391                Frame::Other
1392            }
1393            b"vertAlign" => {
1394                match attribute(reader, element, b"val")?.as_deref() {
1395                    Some("superscript") => self.mark_superscript_run(true),
1396                    Some("baseline" | "subscript") => self.mark_superscript_run(false),
1397                    _ => {}
1398                }
1399                Frame::Other
1400            }
1401            b"t" | b"delText" => {
1402                Frame::PseudoText(PseudoTextFrame::text(preserves_xml_space(reader, element)?))
1403            }
1404            b"tab" | b"ptab" => {
1405                self.append_text_control(TextControl::Tab)?;
1406                Frame::Other
1407            }
1408            b"br" => {
1409                let control = match attribute(reader, element, b"type")?.as_deref() {
1410                    Some("page") => TextControl::PageBreak,
1411                    Some("column") => TextControl::ColumnBreak,
1412                    _ => TextControl::LineBreak,
1413                };
1414                self.append_text_control(control)?;
1415                Frame::Other
1416            }
1417            b"cr" => {
1418                self.append_text_control(TextControl::CarriageReturn)?;
1419                Frame::Other
1420            }
1421            b"footnoteReference" | b"endnoteReference" => {
1422                let visible =
1423                    attribute(reader, element, b"customMarkFollows")?.is_none_or(|value| {
1424                        matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")
1425                    });
1426                if visible {
1427                    self.append_text_control(TextControl::NoteReference)?;
1428                }
1429                Frame::Other
1430            }
1431            b"softHyphen" => {
1432                self.append_text_control(TextControl::SoftHyphen)?;
1433                Frame::Other
1434            }
1435            b"noBreakHyphen" => {
1436                self.append_text_control(TextControl::NoBreakHyphen)?;
1437                Frame::Other
1438            }
1439            b"sym" => {
1440                if let Some(value) = attribute(reader, element, b"char")? {
1441                    let font = attribute(reader, element, b"font")?;
1442                    self.append_pseudo_text(&symbol_text(&value, font.as_deref()))?;
1443                }
1444                Frame::Other
1445            }
1446            _ => Frame::Other,
1447        };
1448        self.frames.push(frame);
1449        Ok(())
1450    }
1451
1452    fn end(
1453        &mut self,
1454        styles: Result<&StyleSheet, FormattingUnknownReason>,
1455    ) -> Result<(), ProjectionError> {
1456        let frame = self
1457            .frames
1458            .pop()
1459            .ok_or(ProjectionError::InvalidDocumentXml)?;
1460        match frame {
1461            Frame::Paragraph => self.finish_paragraph(styles)?,
1462            Frame::Table(table) => self.finish_container_review(table.review)?,
1463            Frame::Row(row) => self.finish_container_review(row.review)?,
1464            Frame::Cell(cell) => self.finish_container_review(cell.review)?,
1465            Frame::Hyperlink(Some(reference)) => self.references.push(reference),
1466            Frame::PseudoText(frame) => match frame.kind {
1467                PseudoTextKind::Text { preserve_space } => {
1468                    let text = if preserve_space {
1469                        frame.text.as_str()
1470                    } else {
1471                        trim_xml_whitespace(&frame.text)
1472                    };
1473                    self.append_pseudo_text(text)?;
1474                }
1475                PseudoTextKind::MathText => self.append_pseudo_text(&frame.text)?,
1476                PseudoTextKind::Instruction => self.append_field_instruction(&frame.text),
1477            },
1478            Frame::Run(run) => {
1479                let start = self.current_review_point();
1480                if !run.hidden
1481                    && !self.pseudo_text_is_suppressed()
1482                    && let Some(paragraph) = self.current_paragraph.as_mut()
1483                {
1484                    let mut effective = match styles {
1485                        Ok(styles) => {
1486                            let resolved = if run.character_style_id.is_none()
1487                                && run.direct_styles == TextProperties::default()
1488                            {
1489                                paragraph.resolve_text_base(styles)
1490                            } else {
1491                                styles.resolve_text(TextStyleInput {
1492                                    paragraph: &paragraph.properties,
1493                                    character_style_id: run.character_style_id.as_deref(),
1494                                    direct: run.direct_styles,
1495                                })
1496                            };
1497                            resolved.unwrap_or_else(|()| {
1498                                self.formatting_status = FormattingProjectionStatus::Incomplete(
1499                                    FormattingUnknownReason::UnsupportedStyles,
1500                                );
1501                                run.direct_styles
1502                            })
1503                        }
1504                        Err(_) => run.direct_styles,
1505                    };
1506                    // Highlight spans describe direct run markup, never style inheritance.
1507                    effective.highlighted = run.direct_styles.highlighted;
1508                    paragraph.append(&run.text, effective)?;
1509                }
1510                if let (Some(start), Some(end)) = (start, self.current_review_point()) {
1511                    self.locate_owned_revisions(
1512                        &run.property_review_indices,
1513                        OwnedRevisionSpan::Content(ReviewSpan { start, end }),
1514                    )?;
1515                }
1516            }
1517            Frame::ChangeSnapshot(snapshot) => {
1518                if self.revision_view == RevisionView::Original
1519                    && !matches!(snapshot.status, SnapshotStatus::Applied)
1520                {
1521                    self.revision_unsupported
1522                        .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1523                }
1524            }
1525            Frame::Revision(revision) => self.finish_attributed_revision(revision)?,
1526            _ => {}
1527        }
1528        Ok(())
1529    }
1530
1531    fn snapshot_owner(&self, name: &[u8]) -> SnapshotOwner {
1532        match (name, self.frames.last()) {
1533            (b"rPrChange", Some(Frame::RunProperties(properties))) => {
1534                if matches!(self.frames.get(properties.run_frame), Some(Frame::Run(_))) {
1535                    SnapshotOwner::Run {
1536                        frame: properties.run_frame,
1537                        hidden_eligible: properties.hidden_eligible,
1538                    }
1539                } else if self.inside_paragraph_mark_properties() {
1540                    SnapshotOwner::ParagraphMark
1541                } else {
1542                    SnapshotOwner::Unsupported
1543                }
1544            }
1545            (b"pPrChange", Some(Frame::ParagraphProperties))
1546                if self.current_paragraph.is_some() =>
1547            {
1548                SnapshotOwner::Paragraph
1549            }
1550            (b"tblPrChange", Some(Frame::TableProperties))
1551                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1552            {
1553                SnapshotOwner::Table
1554            }
1555            (b"trPrChange", Some(Frame::TableRowProperties))
1556                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1557            {
1558                SnapshotOwner::Row
1559            }
1560            (b"tcPrChange", Some(Frame::TableCellProperties))
1561                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Cell(_))) =>
1562            {
1563                SnapshotOwner::Cell
1564            }
1565            (b"tblGridChange", Some(Frame::TableGrid))
1566                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1567            {
1568                SnapshotOwner::Grid
1569            }
1570            (b"sectPrChange", Some(Frame::SectionProperties))
1571                if matches!(
1572                    self.frames.iter().rev().nth(1),
1573                    Some(Frame::Body | Frame::ParagraphProperties)
1574                ) =>
1575            {
1576                SnapshotOwner::Section
1577            }
1578            (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1579                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1580            {
1581                SnapshotOwner::TableExceptions
1582            }
1583            _ => SnapshotOwner::Unsupported,
1584        }
1585    }
1586
1587    fn start_snapshot_child(&mut self, name: &[u8]) -> Result<bool, ProjectionError> {
1588        if self.revision_view == RevisionView::Current {
1589            self.frames.push(Frame::SnapshotIgnored);
1590            return Ok(true);
1591        }
1592        if let Some(Frame::ChangeSnapshot(snapshot)) = self.frames.last_mut() {
1593            let owner = snapshot.owner;
1594            let matches_root = matches!(
1595                (owner, name),
1596                (
1597                    SnapshotOwner::Run { .. } | SnapshotOwner::ParagraphMark,
1598                    b"rPr"
1599                ) | (SnapshotOwner::Paragraph, b"pPr")
1600                    | (SnapshotOwner::Table, b"tblPr")
1601                    | (SnapshotOwner::Row, b"trPr")
1602                    | (SnapshotOwner::Cell, b"tcPr")
1603                    | (SnapshotOwner::Grid, b"tblGrid")
1604                    | (SnapshotOwner::Section, b"sectPr")
1605                    | (SnapshotOwner::TableExceptions, b"tblPrEx")
1606            );
1607            if !matches_root || !matches!(snapshot.status, SnapshotStatus::AwaitingRoot) {
1608                snapshot.status = SnapshotStatus::Unsupported;
1609                self.frames.push(Frame::SnapshotIgnored);
1610                return Ok(true);
1611            }
1612            snapshot.status = SnapshotStatus::Applied;
1613            let frame = self.restore_snapshot_owner(owner)?;
1614            self.frames.push(frame);
1615            return Ok(true);
1616        }
1617        if matches!(self.frames.last(), Some(Frame::ParagraphMarkProperties))
1618            && matches!(name, b"moveFrom" | b"moveTo")
1619        {
1620            self.revision_unsupported
1621                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1622            self.frames.push(Frame::SnapshotIgnored);
1623            return Ok(true);
1624        }
1625        let supported_property = matches!(
1626            (self.frames.last(), name),
1627            (
1628                Some(Frame::RunProperties(_)),
1629                b"rStyle"
1630                    | b"b"
1631                    | b"bCs"
1632                    | b"cs"
1633                    | b"rtl"
1634                    | b"highlight"
1635                    | b"vertAlign"
1636                    | b"vanish"
1637            ) | (
1638                Some(Frame::ParagraphProperties),
1639                b"pStyle" | b"ind" | b"jc" | b"outlineLvl" | b"numPr"
1640            ) | (Some(Frame::NumberingProperties), b"numId" | b"ilvl")
1641                | (Some(Frame::TableProperties), b"tblStyle")
1642                | (Some(Frame::ParagraphMarkProperties), b"ins" | b"del")
1643        );
1644        if supported_property {
1645            return Ok(false);
1646        }
1647        self.frames.push(Frame::SnapshotIgnored);
1648        Ok(true)
1649    }
1650
1651    fn restore_snapshot_owner(&mut self, owner: SnapshotOwner) -> Result<Frame, ProjectionError> {
1652        match owner {
1653            SnapshotOwner::Run {
1654                frame,
1655                hidden_eligible,
1656            } => {
1657                let Some(Frame::Run(run)) = self.frames.get_mut(frame) else {
1658                    return Err(ProjectionError::InvalidDocumentXml);
1659                };
1660                run.direct_styles = TextProperties::default();
1661                run.character_style_id = None;
1662                run.hidden = false;
1663                Ok(Frame::RunProperties(RunPropertiesFrame {
1664                    run_frame: frame,
1665                    hidden_eligible,
1666                }))
1667            }
1668            SnapshotOwner::Paragraph => {
1669                // CT_PPrBase excludes the independent paragraph-mark properties.
1670                let paragraph = self
1671                    .current_paragraph
1672                    .as_mut()
1673                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1674                paragraph.properties = ParagraphProperties {
1675                    table_style: std::mem::take(&mut paragraph.properties.table_style),
1676                    ..ParagraphProperties::default()
1677                };
1678                paragraph.resolved_text_base = None;
1679                Ok(Frame::ParagraphProperties)
1680            }
1681            SnapshotOwner::Table => {
1682                let table =
1683                    enclosing_table(&mut self.frames).ok_or(ProjectionError::InvalidDocumentXml)?;
1684                table.style_id = None;
1685                Ok(Frame::TableProperties)
1686            }
1687            SnapshotOwner::Row => Ok(Frame::TableRowProperties),
1688            SnapshotOwner::Cell => Ok(Frame::TableCellProperties),
1689            SnapshotOwner::Grid => Ok(Frame::TableGrid),
1690            SnapshotOwner::Section => Ok(Frame::SectionProperties),
1691            SnapshotOwner::TableExceptions => Ok(Frame::TablePropertyExceptions),
1692            SnapshotOwner::ParagraphMark => {
1693                let paragraph = self
1694                    .current_paragraph
1695                    .as_mut()
1696                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1697                paragraph.paragraph_mark_revision = None;
1698                Ok(Frame::ParagraphMarkProperties)
1699            }
1700            SnapshotOwner::Unsupported => Err(ProjectionError::InvalidDocumentXml),
1701        }
1702    }
1703
1704    fn record_container_property_revision(
1705        &mut self,
1706        name: &[u8],
1707        review_index: usize,
1708    ) -> Result<(), ProjectionError> {
1709        let valid_parent = matches!(
1710            (name, self.frames.last()),
1711            (b"tblPrChange", Some(Frame::TableProperties))
1712                | (b"tblGridChange", Some(Frame::TableGrid))
1713                | (b"trPrChange", Some(Frame::TableRowProperties))
1714                | (b"tcPrChange", Some(Frame::TableCellProperties))
1715                | (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1716        );
1717        if !valid_parent {
1718            return Ok(());
1719        }
1720        let review = self
1721            .frames
1722            .iter_mut()
1723            .rev()
1724            .find_map(|frame| match (name, frame) {
1725                (b"tblPrChange" | b"tblGridChange", Frame::Table(table)) => Some(&mut table.review),
1726                (b"trPrChange" | b"tblPrExChange", Frame::Row(row)) => Some(&mut row.review),
1727                (b"tcPrChange", Frame::Cell(cell)) => Some(&mut cell.review),
1728                _ => None,
1729            });
1730        if let Some(review) = review {
1731            let slot = match name {
1732                b"tblGridChange" => &mut review.grid_review,
1733                b"tblPrExChange" => &mut review.exception_review,
1734                _ => &mut review.property_review,
1735            };
1736            if slot.replace(review_index).is_some() {
1737                return Err(ProjectionError::InvalidDocumentXml);
1738            }
1739        }
1740        Ok(())
1741    }
1742
1743    fn finish_container_review(&mut self, review: ContainerReview) -> Result<(), ProjectionError> {
1744        if review.paragraph_start >= self.paragraphs.len() {
1745            return Ok(());
1746        }
1747        let end = self
1748            .paragraphs
1749            .last()
1750            .and_then(paragraph_end)
1751            .ok_or(ProjectionError::InvalidDocumentXml)?;
1752        for review_index in [
1753            review.property_review,
1754            review.grid_review,
1755            review.exception_review,
1756        ]
1757        .into_iter()
1758        .flatten()
1759        {
1760            self.locate_owned_revisions(
1761                std::slice::from_ref(&review_index),
1762                OwnedRevisionSpan::Content(ReviewSpan {
1763                    start: paragraph_start(review.paragraph_start),
1764                    end,
1765                }),
1766            )?;
1767        }
1768        Ok(())
1769    }
1770
1771    fn finish_paragraph(
1772        &mut self,
1773        styles: Result<&StyleSheet, FormattingUnknownReason>,
1774    ) -> Result<(), ProjectionError> {
1775        let mut paragraph = self
1776            .current_paragraph
1777            .take()
1778            .ok_or(ProjectionError::InvalidDocumentXml)?;
1779        if styles.is_ok_and(|styles| paragraph.resolve_text_base(styles).is_err()) {
1780            self.formatting_status =
1781                FormattingProjectionStatus::Incomplete(FormattingUnknownReason::UnsupportedStyles);
1782        }
1783        let ordinal = self.paragraphs.len();
1784        if let Some(revision) = paragraph.paragraph_mark_revision {
1785            self.paragraph_mark_revisions.insert(ordinal, revision);
1786        }
1787        let mark = ReviewPoint {
1788            paragraph_ordinal: ordinal,
1789            utf8: u32::try_from(paragraph.text.len())
1790                .map_err(|_| ProjectionError::InvalidDocumentXml)?,
1791            utf16: paragraph.utf16_len,
1792        };
1793        self.locate_owned_revisions(
1794            &paragraph.paragraph_review_indices,
1795            OwnedRevisionSpan::Paragraph(ReviewSpan {
1796                start: paragraph_start(ordinal),
1797                end: mark,
1798            }),
1799        )?;
1800        self.paragraphs.push(RawProjectedParagraph {
1801            ordinal: self.paragraphs.len(),
1802            package_paragraph_id: paragraph.package_paragraph_id,
1803            text: paragraph.text,
1804            utf16_len: paragraph.utf16_len,
1805            formatting: paragraph.formatting,
1806            structure: paragraph.structure,
1807            properties: paragraph.properties,
1808        });
1809        Ok(())
1810    }
1811
1812    fn record_attributed_revision(
1813        &mut self,
1814        reader: &NsReader<&[u8]>,
1815        element: &BytesStart<'_>,
1816        name: &[u8],
1817    ) -> Result<Option<usize>, ProjectionError> {
1818        let Some(kind) = revision_fact_kind(name) else {
1819            return Ok(None);
1820        };
1821        let ReviewRevisionCollection::Complete {
1822            maximum_facts,
1823            revisions,
1824            ..
1825        } = &mut self.review_revisions
1826        else {
1827            return Ok(None);
1828        };
1829        if revisions.len() >= *maximum_facts {
1830            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1831            return Ok(None);
1832        }
1833        let review_index = revisions.len();
1834        revisions.push(AttributedRevision {
1835            kind,
1836            author: attribute(reader, element, b"author")?.unwrap_or_default(),
1837            date: attribute(reader, element, b"date")?,
1838            revision_id: attribute(reader, element, b"id")?,
1839            content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
1840        });
1841        Ok(Some(review_index))
1842    }
1843
1844    fn finish_attributed_revision(
1845        &mut self,
1846        revision: RevisionFrame,
1847    ) -> Result<(), ProjectionError> {
1848        let (Some(review_index), Some(start), Some(end)) = (
1849            revision.review_index,
1850            revision.start,
1851            self.current_review_point(),
1852        ) else {
1853            return Ok(());
1854        };
1855        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1856        else {
1857            return Ok(());
1858        };
1859        let attributed = revisions
1860            .get_mut(review_index)
1861            .ok_or(ProjectionError::InvalidDocumentXml)?;
1862        attributed.content = ReviewDetail::Known(RevisionContent {
1863            span: ReviewSpan { start, end },
1864            payload: RevisionPayload::from_text(revision.text),
1865        });
1866        Ok(())
1867    }
1868
1869    fn locate_owned_revisions(
1870        &mut self,
1871        review_indices: &[usize],
1872        owner: OwnedRevisionSpan,
1873    ) -> Result<(), ProjectionError> {
1874        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1875        else {
1876            return Ok(());
1877        };
1878        for &review_index in review_indices {
1879            let revision = revisions
1880                .get_mut(review_index)
1881                .ok_or(ProjectionError::InvalidDocumentXml)?;
1882            let span = match owner {
1883                OwnedRevisionSpan::Content(span) => span,
1884                OwnedRevisionSpan::Paragraph(span) => {
1885                    if revision.kind == RevisionFactKind::ParagraphPropertiesChange {
1886                        span
1887                    } else {
1888                        ReviewSpan {
1889                            start: span.end,
1890                            end: span.end,
1891                        }
1892                    }
1893                }
1894            };
1895            let payload = match revision.kind {
1896                RevisionFactKind::RunPropertiesChange
1897                | RevisionFactKind::ParagraphPropertiesChange
1898                | RevisionFactKind::TablePropertiesChange
1899                | RevisionFactKind::TablePropertiesExceptionChange
1900                | RevisionFactKind::TableRowPropertiesChange
1901                | RevisionFactKind::TableCellPropertiesChange
1902                | RevisionFactKind::TableGridChange => RevisionPayload::FormattingOnly,
1903                _ => RevisionPayload::ParagraphMark,
1904            };
1905            revision.content = ReviewDetail::Known(RevisionContent { span, payload });
1906        }
1907        Ok(())
1908    }
1909
1910    fn inside_body(&self) -> bool {
1911        self.frames.iter().any(|frame| matches!(frame, Frame::Body))
1912    }
1913
1914    fn inside_textbox(&self) -> bool {
1915        self.frames
1916            .iter()
1917            .any(|frame| matches!(frame, Frame::Textbox))
1918    }
1919
1920    fn inside_change_snapshot(&self) -> bool {
1921        self.frames
1922            .iter()
1923            .any(|frame| matches!(frame, Frame::ChangeSnapshot(_)))
1924    }
1925
1926    fn inside_paragraph_mark_properties(&self) -> bool {
1927        self.current_paragraph.is_some()
1928            && self
1929                .frames
1930                .iter()
1931                .any(|frame| matches!(frame, Frame::ParagraphProperties))
1932            && !self
1933                .frames
1934                .iter()
1935                .any(|frame| matches!(frame, Frame::Run(_)))
1936    }
1937
1938    fn inside_table_row_properties(&self) -> bool {
1939        self.frames
1940            .iter()
1941            .any(|frame| matches!(frame, Frame::TableRowProperties))
1942    }
1943
1944    fn pseudo_text_is_suppressed(&self) -> bool {
1945        self.frames.iter().any(|frame| {
1946            matches!(
1947                frame,
1948                Frame::ParagraphProperties
1949                    | Frame::RunProperties(_)
1950                    | Frame::ChangeSnapshot(_)
1951                    | Frame::Textbox
1952            ) || matches!(frame, Frame::Revision(revision) if revision.suppressed)
1953                || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1954        })
1955    }
1956
1957    fn pseudo_text_is_structurally_suppressed(&self) -> bool {
1958        self.frames.iter().any(|frame| {
1959            matches!(
1960                frame,
1961                Frame::ParagraphProperties
1962                    | Frame::RunProperties(_)
1963                    | Frame::ChangeSnapshot(_)
1964                    | Frame::Textbox
1965            ) || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1966        })
1967    }
1968
1969    fn append_pseudo_text(&mut self, text: &str) -> Result<(), ProjectionError> {
1970        if self.current_paragraph.is_none()
1971            || self.pseudo_text_is_structurally_suppressed()
1972            || self
1973                .frames
1974                .iter()
1975                .rev()
1976                .any(|frame| matches!(frame, Frame::Run(run) if run.hidden))
1977        {
1978            return Ok(());
1979        }
1980        self.append_review_text(text);
1981        if self.pseudo_text_is_suppressed() {
1982            return Ok(());
1983        }
1984        if let Some(run) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1985            Frame::Run(run) => Some(run),
1986            _ => None,
1987        }) {
1988            run.text.push_str(text);
1989            return Ok(());
1990        }
1991        self.current_paragraph
1992            .as_mut()
1993            .ok_or(ProjectionError::InvalidDocumentXml)?
1994            .append(text, TextProperties::default())
1995    }
1996
1997    fn append_review_text(&mut self, text: &str) {
1998        let ReviewRevisionCollection::Complete {
1999            remaining_detail_bytes,
2000            ..
2001        } = &mut self.review_revisions
2002        else {
2003            return;
2004        };
2005        let mut limit_exceeded = false;
2006        for frame in &mut self.frames {
2007            if let Frame::Revision(revision) = frame
2008                && revision.review_index.is_some()
2009            {
2010                let Some(remaining) = remaining_detail_bytes.checked_sub(text.len()) else {
2011                    limit_exceeded = true;
2012                    break;
2013                };
2014                *remaining_detail_bytes = remaining;
2015                revision.text.push_str(text);
2016            }
2017        }
2018        if limit_exceeded {
2019            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
2020        }
2021    }
2022
2023    fn current_review_point(&self) -> Option<ReviewPoint> {
2024        let paragraph = self.current_paragraph.as_ref()?;
2025        let run_text = if self.pseudo_text_is_suppressed() {
2026            None
2027        } else {
2028            self.frames.iter().rev().find_map(|frame| match frame {
2029                Frame::Run(run) if !run.hidden => Some(run.text.as_str()),
2030                _ => None,
2031            })
2032        };
2033        let run_utf8 = run_text.map_or(0, str::len);
2034        let run_utf16 = run_text.map_or(0, |text| text.encode_utf16().count());
2035        Some(ReviewPoint {
2036            paragraph_ordinal: self.paragraphs.len(),
2037            utf8: u32::try_from(paragraph.text.len().checked_add(run_utf8)?).ok()?,
2038            utf16: paragraph
2039                .utf16_len
2040                .checked_add(u32::try_from(run_utf16).ok()?)?,
2041        })
2042    }
2043
2044    fn next_paragraph_start_review_point(&self) -> Option<ReviewPoint> {
2045        if self.current_paragraph.is_some() {
2046            return self.current_review_point();
2047        }
2048        self.inside_body().then_some(ReviewPoint {
2049            paragraph_ordinal: self.paragraphs.len(),
2050            utf8: 0,
2051            utf16: 0,
2052        })
2053    }
2054
2055    fn previous_paragraph_end_review_point(&self) -> Option<ReviewPoint> {
2056        if self.current_paragraph.is_some() {
2057            return self.current_review_point();
2058        }
2059        self.paragraphs.last().and_then(paragraph_end)
2060    }
2061
2062    fn review_comment_id(
2063        reader: &NsReader<&[u8]>,
2064        element: &BytesStart<'_>,
2065    ) -> Result<Option<String>, ProjectionError> {
2066        attribute(reader, element, b"id")
2067    }
2068
2069    fn start_review_comment_anchor(
2070        &mut self,
2071        reader: &NsReader<&[u8]>,
2072        element: &BytesStart<'_>,
2073    ) -> Result<(), ProjectionError> {
2074        let (Some(comment_id), Some(point)) = (
2075            Self::review_comment_id(reader, element)?,
2076            self.next_paragraph_start_review_point(),
2077        ) else {
2078            return Ok(());
2079        };
2080        if self.invalid_review_comment_anchors.contains(&comment_id) {
2081            return Ok(());
2082        }
2083        if self
2084            .open_review_comment_anchors
2085            .insert(comment_id.clone(), point)
2086            .is_some()
2087        {
2088            self.open_review_comment_anchors.remove(&comment_id);
2089            self.review_comment_anchors.remove(&comment_id);
2090            self.invalid_review_comment_anchors.insert(comment_id);
2091        }
2092        Ok(())
2093    }
2094
2095    fn suppress_review_comment_anchor(
2096        &mut self,
2097        reader: &NsReader<&[u8]>,
2098        element: &BytesStart<'_>,
2099    ) -> Result<(), ProjectionError> {
2100        let Some(comment_id) = Self::review_comment_id(reader, element)? else {
2101            return Ok(());
2102        };
2103        self.open_review_comment_anchors.remove(&comment_id);
2104        self.review_comment_anchors.remove(&comment_id);
2105        self.invalid_review_comment_anchors.insert(comment_id);
2106        Ok(())
2107    }
2108
2109    fn end_review_comment_anchor(
2110        &mut self,
2111        reader: &NsReader<&[u8]>,
2112        element: &BytesStart<'_>,
2113    ) -> Result<(), ProjectionError> {
2114        let (Some(comment_id), Some(end)) = (
2115            Self::review_comment_id(reader, element)?,
2116            self.previous_paragraph_end_review_point(),
2117        ) else {
2118            return Ok(());
2119        };
2120        if self.invalid_review_comment_anchors.contains(&comment_id) {
2121            return Ok(());
2122        }
2123        let Some(start) = self.open_review_comment_anchors.remove(&comment_id) else {
2124            self.review_comment_anchors.remove(&comment_id);
2125            self.invalid_review_comment_anchors.insert(comment_id);
2126            return Ok(());
2127        };
2128        if self
2129            .review_comment_anchors
2130            .insert(comment_id.clone(), ReviewSpan { start, end })
2131            .is_some()
2132        {
2133            self.review_comment_anchors.remove(&comment_id);
2134            self.invalid_review_comment_anchors.insert(comment_id);
2135        }
2136        Ok(())
2137    }
2138
2139    fn record_review_comment_reference(
2140        &mut self,
2141        reader: &NsReader<&[u8]>,
2142        element: &BytesStart<'_>,
2143    ) -> Result<(), ProjectionError> {
2144        let (Some(comment_id), Some(point)) = (
2145            Self::review_comment_id(reader, element)?,
2146            self.current_review_point(),
2147        ) else {
2148            return Ok(());
2149        };
2150        if self.invalid_review_comment_anchors.contains(&comment_id) {
2151            return Ok(());
2152        }
2153        if !self.review_comment_anchors.contains_key(&comment_id)
2154            && !self.open_review_comment_anchors.contains_key(&comment_id)
2155        {
2156            self.review_comment_anchors.insert(
2157                comment_id,
2158                ReviewSpan {
2159                    start: point,
2160                    end: point,
2161                },
2162            );
2163        }
2164        Ok(())
2165    }
2166
2167    fn append_pseudo_content(&mut self, text: &str) -> Result<(), ProjectionError> {
2168        let Some(Frame::PseudoText(frame)) = self.frames.last_mut() else {
2169            return Err(ProjectionError::InvalidDocumentXml);
2170        };
2171        frame.text.push_str(text);
2172        Ok(())
2173    }
2174
2175    fn append_text_control(&mut self, control: TextControl) -> Result<(), ProjectionError> {
2176        if let Some(text) = control.materialize(self.text_materialization) {
2177            self.append_pseudo_text(text)?;
2178        }
2179        Ok(())
2180    }
2181
2182    fn append_field_instruction(&mut self, text: &str) {
2183        let Some(field) = self.fields.last_mut() else {
2184            self.references_complete = false;
2185            return;
2186        };
2187        if matches!(field.source, FieldSource::Hidden) {
2188            return;
2189        }
2190        if field.separated || field.instruction.len().saturating_add(text.len()) > 4096 {
2191            self.references_complete = false;
2192            return;
2193        }
2194        field.instruction.push_str(text);
2195    }
2196
2197    fn simple_field(
2198        &mut self,
2199        reader: &NsReader<&[u8]>,
2200        element: &BytesStart<'_>,
2201    ) -> Result<(), ProjectionError> {
2202        let instruction = attribute(reader, element, b"instr")?;
2203        let source = self.current_field_source();
2204        if matches!(source, FieldSource::Hidden) {
2205            return Ok(());
2206        }
2207        let Some(instruction) = instruction.filter(|value| value.len() <= 4096) else {
2208            self.references_complete = false;
2209            return Ok(());
2210        };
2211        self.record_field_reference(&instruction, source);
2212        Ok(())
2213    }
2214
2215    fn field_character(
2216        &mut self,
2217        reader: &NsReader<&[u8]>,
2218        element: &BytesStart<'_>,
2219    ) -> Result<(), ProjectionError> {
2220        match attribute(reader, element, b"fldCharType")?.as_deref() {
2221            Some("begin") => self.fields.push(FieldFrame {
2222                source: self.current_field_source(),
2223                instruction: String::new(),
2224                separated: false,
2225            }),
2226            Some("separate") => {
2227                let Some(mut field) = self.fields.pop() else {
2228                    self.references_complete = false;
2229                    return Ok(());
2230                };
2231                if field.separated {
2232                    self.references_complete = false;
2233                } else {
2234                    self.record_field_reference(&field.instruction, field.source);
2235                    field.separated = true;
2236                }
2237                self.fields.push(field);
2238            }
2239            Some("end") => {
2240                let Some(field) = self.fields.pop() else {
2241                    self.references_complete = false;
2242                    return Ok(());
2243                };
2244                if !field.separated {
2245                    self.record_field_reference(&field.instruction, field.source);
2246                }
2247            }
2248            _ => self.references_complete = false,
2249        }
2250        Ok(())
2251    }
2252
2253    fn current_field_source(&self) -> FieldSource {
2254        if self.pseudo_text_is_suppressed() {
2255            return FieldSource::Hidden;
2256        }
2257        self.current_review_point()
2258            .map_or(FieldSource::Unsupported, FieldSource::Projected)
2259    }
2260
2261    fn record_field_reference(&mut self, instruction: &str, source: FieldSource) {
2262        if matches!(source, FieldSource::Hidden) {
2263            return;
2264        }
2265        match internal_reference_target(instruction) {
2266            Ok(Some(reference_id)) => {
2267                let FieldSource::Projected(source) = source else {
2268                    self.references_complete = false;
2269                    return;
2270                };
2271                self.references.push(RawInternalReference {
2272                    reference_id,
2273                    source,
2274                });
2275            }
2276            Ok(None) => {}
2277            Err(()) => self.references_complete = false,
2278        }
2279    }
2280
2281    fn current_point(&self) -> Option<ReviewPoint> {
2282        if self.current_paragraph.is_some() {
2283            return self.current_review_point();
2284        }
2285        if !matches!(self.frames.last(), Some(Frame::Body)) {
2286            return None;
2287        }
2288        self.paragraphs
2289            .last()
2290            .map_or(Some(paragraph_start(0)), paragraph_end)
2291    }
2292
2293    fn current_bookmark_point(&self) -> Option<BookmarkPoint> {
2294        if self.current_paragraph.is_some() {
2295            return self.current_point().map(BookmarkPoint::Paragraph);
2296        }
2297        matches!(
2298            self.frames.last(),
2299            Some(
2300                Frame::Body
2301                    | Frame::BlockContent
2302                    | Frame::Table(_)
2303                    | Frame::Row(_)
2304                    | Frame::Cell(_)
2305            )
2306        )
2307        .then(|| BookmarkPoint::ParagraphBoundary(self.paragraphs.len()))
2308    }
2309
2310    fn start_bookmark(
2311        &mut self,
2312        reader: &NsReader<&[u8]>,
2313        element: &BytesStart<'_>,
2314    ) -> Result<(), ProjectionError> {
2315        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2316        let name = attribute(reader, element, b"name")?;
2317        let point = self.current_bookmark_point();
2318        let (Some(id), Some(name), Some(point)) = (id, name, point) else {
2319            self.bookmarks_complete = false;
2320            return Ok(());
2321        };
2322        if name.is_empty()
2323            || !self.seen_bookmark_ids.insert(id)
2324            || self.open_bookmarks.insert(id, (name, point)).is_some()
2325        {
2326            self.bookmarks_complete = false;
2327        }
2328        Ok(())
2329    }
2330
2331    fn end_bookmark(
2332        &mut self,
2333        reader: &NsReader<&[u8]>,
2334        element: &BytesStart<'_>,
2335    ) -> Result<(), ProjectionError> {
2336        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2337        let point = self.current_bookmark_point();
2338        let (Some(id), Some(end)) = (id, point) else {
2339            self.bookmarks_complete = false;
2340            return Ok(());
2341        };
2342        let Some((name, start)) = self.open_bookmarks.remove(&id) else {
2343            self.bookmarks_complete = false;
2344            return Ok(());
2345        };
2346        self.bookmarks.push(PendingBookmarkRange {
2347            id,
2348            name,
2349            start,
2350            end,
2351        });
2352        Ok(())
2353    }
2354
2355    fn mark_placeholder(&mut self) {
2356        let Some(sdt) = self.frames.iter_mut().rev().find_map(|frame| match frame {
2357            Frame::Sdt(sdt) => Some(sdt),
2358            _ => None,
2359        }) else {
2360            return;
2361        };
2362        sdt.placeholder = true;
2363        if let Some(paragraph) = self.current_paragraph.as_mut() {
2364            paragraph.truncate(sdt.text_utf8_start, sdt.text_utf16_start);
2365        }
2366    }
2367
2368    fn mark_hidden_run(&mut self) {
2369        let Some((run_frame, hidden_eligible)) =
2370            self.frames.iter().rev().find_map(|frame| match frame {
2371                Frame::RunProperties(properties) => {
2372                    Some((properties.run_frame, properties.hidden_eligible))
2373                }
2374                _ => None,
2375            })
2376        else {
2377            return;
2378        };
2379        if !hidden_eligible || run_frame == usize::MAX {
2380            return;
2381        }
2382        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2383            run.hidden = true;
2384        }
2385    }
2386
2387    fn set_run_character_style(&mut self, style_id: Option<String>) {
2388        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2389            return;
2390        };
2391        let run_frame = properties.run_frame;
2392        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2393            run.character_style_id = style_id;
2394        }
2395    }
2396
2397    fn mark_bold_run(&mut self, enabled: bool) {
2398        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2399            return;
2400        };
2401        let run_frame = properties.run_frame;
2402        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2403            run.direct_styles.bold = Some(enabled);
2404        }
2405    }
2406
2407    fn mark_complex_script_bold_run(&mut self, enabled: bool) {
2408        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2409            return;
2410        };
2411        let run_frame = properties.run_frame;
2412        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2413            run.direct_styles.complex_script_bold = Some(enabled);
2414        }
2415    }
2416
2417    fn mark_complex_script_run(&mut self, enabled: bool) {
2418        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2419            return;
2420        };
2421        let run_frame = properties.run_frame;
2422        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2423            run.direct_styles.force_complex_script = Some(enabled);
2424        }
2425    }
2426
2427    fn mark_right_to_left_run(&mut self, enabled: bool) {
2428        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2429            return;
2430        };
2431        let run_frame = properties.run_frame;
2432        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2433            run.direct_styles.right_to_left = Some(enabled);
2434        }
2435    }
2436
2437    fn mark_highlighted_run(&mut self, enabled: bool) {
2438        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2439            return;
2440        };
2441        let run_frame = properties.run_frame;
2442        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2443            run.direct_styles.highlighted = Some(enabled);
2444        }
2445    }
2446
2447    fn mark_superscript_run(&mut self, enabled: bool) {
2448        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2449            return;
2450        };
2451        let run_frame = properties.run_frame;
2452        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2453            run.direct_styles.superscript = Some(enabled);
2454        }
2455    }
2456}
2457
2458const fn revision_fact_kind(name: &[u8]) -> Option<RevisionFactKind> {
2459    match name {
2460        b"ins" => Some(RevisionFactKind::Insertion),
2461        b"del" => Some(RevisionFactKind::Deletion),
2462        b"moveFrom" => Some(RevisionFactKind::MoveFrom),
2463        b"moveTo" => Some(RevisionFactKind::MoveTo),
2464        b"cellIns" => Some(RevisionFactKind::CellInsertion),
2465        b"cellDel" => Some(RevisionFactKind::CellDeletion),
2466        b"cellMerge" => Some(RevisionFactKind::CellMerge),
2467        b"pPrChange" => Some(RevisionFactKind::ParagraphPropertiesChange),
2468        b"rPrChange" => Some(RevisionFactKind::RunPropertiesChange),
2469        b"sectPrChange" => Some(RevisionFactKind::SectionPropertiesChange),
2470        b"tblPrChange" => Some(RevisionFactKind::TablePropertiesChange),
2471        b"tblPrExChange" => Some(RevisionFactKind::TablePropertiesExceptionChange),
2472        b"trPrChange" => Some(RevisionFactKind::TableRowPropertiesChange),
2473        b"tcPrChange" => Some(RevisionFactKind::TableCellPropertiesChange),
2474        b"tblGridChange" => Some(RevisionFactKind::TableGridChange),
2475        b"customXmlDelRangeStart" => Some(RevisionFactKind::CustomXmlDeletionRangeStart),
2476        b"customXmlDelRangeEnd" => Some(RevisionFactKind::CustomXmlDeletionRangeEnd),
2477        b"customXmlInsRangeStart" => Some(RevisionFactKind::CustomXmlInsertionRangeStart),
2478        b"customXmlInsRangeEnd" => Some(RevisionFactKind::CustomXmlInsertionRangeEnd),
2479        b"customXmlMoveFromRangeStart" => Some(RevisionFactKind::CustomXmlMoveFromRangeStart),
2480        b"customXmlMoveFromRangeEnd" => Some(RevisionFactKind::CustomXmlMoveFromRangeEnd),
2481        b"customXmlMoveToRangeStart" => Some(RevisionFactKind::CustomXmlMoveToRangeStart),
2482        b"customXmlMoveToRangeEnd" => Some(RevisionFactKind::CustomXmlMoveToRangeEnd),
2483        _ => None,
2484    }
2485}
2486
2487fn enclosing_table(frames: &mut [Frame]) -> Option<&mut TableFrame> {
2488    for frame in frames.iter_mut().rev() {
2489        match frame {
2490            Frame::Table(table) => return Some(table),
2491            Frame::Row(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2492            _ => {}
2493        }
2494    }
2495    None
2496}
2497
2498fn enclosing_row(frames: &mut [Frame]) -> Option<&mut RowFrame> {
2499    for frame in frames.iter_mut().rev() {
2500        match frame {
2501            Frame::Row(row) => return Some(row),
2502            Frame::Table(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2503            _ => {}
2504        }
2505    }
2506    None
2507}
2508
2509fn revision_is_suppressed(name: &[u8], view: RevisionView) -> bool {
2510    match view {
2511        RevisionView::Current => matches!(name, b"del" | b"moveFrom"),
2512        RevisionView::Original => matches!(name, b"ins" | b"moveTo"),
2513    }
2514}
2515
2516fn is_change_snapshot(name: &[u8]) -> bool {
2517    matches!(
2518        name,
2519        b"pPrChange"
2520            | b"rPrChange"
2521            | b"sectPrChange"
2522            | b"tblPrChange"
2523            | b"tblPrExChange"
2524            | b"trPrChange"
2525            | b"tcPrChange"
2526            | b"tblGridChange"
2527    )
2528}
2529
2530fn is_unsupported_revision_markup(name: &[u8]) -> bool {
2531    matches!(
2532        name,
2533        b"customXmlDelRangeStart"
2534            | b"customXmlDelRangeEnd"
2535            | b"customXmlInsRangeStart"
2536            | b"customXmlInsRangeEnd"
2537            | b"customXmlMoveFromRangeStart"
2538            | b"customXmlMoveFromRangeEnd"
2539            | b"customXmlMoveToRangeStart"
2540            | b"customXmlMoveToRangeEnd"
2541    )
2542}
2543
2544const fn paragraph_break_is_removed(
2545    revision: Option<ParagraphMarkRevision>,
2546    view: RevisionView,
2547) -> bool {
2548    matches!(
2549        (revision, view),
2550        (Some(ParagraphMarkRevision::Removed), RevisionView::Current)
2551            | (Some(ParagraphMarkRevision::Added), RevisionView::Original)
2552    )
2553}
2554
2555// Share the compiled coalescer between run projection and paragraph joins.
2556#[inline(never)]
2557fn append_formatting_span(spans: &mut Vec<TextFormattingSpan>, span: TextFormattingSpan) {
2558    if let Some(adjacent) = spans
2559        .iter_mut()
2560        .rev()
2561        .find(|previous| previous.style == span.style)
2562        && adjacent.end_utf16 == span.start_utf16
2563    {
2564        adjacent.end_utf16 = span.end_utf16;
2565        return;
2566    }
2567    spans.push(span);
2568}
2569
2570/// Merges paragraphs whose break the selected view removes. When any merge
2571/// happens, returns where each source paragraph starts in the merged text, so
2572/// review points recorded against source paragraphs can be translated.
2573fn normalize_paragraph_revision_view(
2574    paragraphs: &mut Vec<RawProjectedParagraph>,
2575    paragraph_mark_revisions: &mut HashMap<usize, ParagraphMarkRevision>,
2576    view: RevisionView,
2577    unsupported: &mut BTreeSet<RevisionUnsupportedReason>,
2578) -> Result<Option<Vec<ReviewPoint>>, ProjectionError> {
2579    let mut normalized: Vec<RawProjectedParagraph> = Vec::with_capacity(paragraphs.len());
2580    let mut origins = Vec::with_capacity(paragraphs.len());
2581    let mut merge_previous = false;
2582    let mut merged_any = false;
2583
2584    for (source_ordinal, mut paragraph) in std::mem::take(paragraphs).into_iter().enumerate() {
2585        let merge_next =
2586            paragraph_break_is_removed(paragraph_mark_revisions.remove(&source_ordinal), view);
2587        if merge_previous {
2588            let previous = normalized
2589                .last_mut()
2590                .ok_or(ProjectionError::InvalidDocumentXml)?;
2591            if previous.structure != paragraph.structure {
2592                unsupported.insert(RevisionUnsupportedReason::IncompatibleParagraphMerge);
2593                paragraph.ordinal = normalized.len();
2594                origins.push(paragraph_start(paragraph.ordinal));
2595                normalized.push(paragraph);
2596                merge_previous = merge_next;
2597                continue;
2598            }
2599            let utf16_offset = previous.utf16_len;
2600            origins.push(ReviewPoint {
2601                paragraph_ordinal: previous.ordinal,
2602                utf8: u32::try_from(previous.text.len())
2603                    .map_err(|_| ProjectionError::InvalidDocumentXml)?,
2604                utf16: utf16_offset,
2605            });
2606            for mut span in paragraph.formatting {
2607                span.start_utf16 = span
2608                    .start_utf16
2609                    .checked_add(utf16_offset)
2610                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2611                span.end_utf16 = span
2612                    .end_utf16
2613                    .checked_add(utf16_offset)
2614                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2615                append_formatting_span(&mut previous.formatting, span);
2616            }
2617            previous.text.push_str(&paragraph.text);
2618            previous.utf16_len = previous
2619                .utf16_len
2620                .checked_add(paragraph.utf16_len)
2621                .ok_or(ProjectionError::InvalidDocumentXml)?;
2622            merge_previous = merge_next;
2623            merged_any = true;
2624            continue;
2625        }
2626
2627        paragraph.ordinal = normalized.len();
2628        origins.push(paragraph_start(paragraph.ordinal));
2629        normalized.push(paragraph);
2630        merge_previous = merge_next;
2631    }
2632
2633    *paragraphs = normalized;
2634    Ok(merged_any.then_some(origins))
2635}
2636
2637const fn paragraph_start(paragraph_ordinal: usize) -> ReviewPoint {
2638    ReviewPoint {
2639        paragraph_ordinal,
2640        utf8: 0,
2641        utf16: 0,
2642    }
2643}
2644
2645/// Translates a point recorded against a source paragraph into the merged
2646/// paragraph coordinates. `None` means the point names no source paragraph.
2647fn relocate_review_point(origins: &[ReviewPoint], point: ReviewPoint) -> Option<ReviewPoint> {
2648    let origin = origins.get(point.paragraph_ordinal)?;
2649    Some(ReviewPoint {
2650        paragraph_ordinal: origin.paragraph_ordinal,
2651        utf8: origin.utf8.checked_add(point.utf8)?,
2652        utf16: origin.utf16.checked_add(point.utf16)?,
2653    })
2654}
2655
2656fn relocate_block_point(
2657    origins: &[ReviewPoint],
2658    point: &mut ReviewPoint,
2659) -> Result<(), ProjectionError> {
2660    *point = relocate_review_point(origins, *point).ok_or(ProjectionError::InvalidDocumentXml)?;
2661    Ok(())
2662}
2663
2664fn relocate_review_span(origins: &[ReviewPoint], span: ReviewSpan) -> Option<ReviewSpan> {
2665    Some(ReviewSpan {
2666        start: relocate_review_point(origins, span.start)?,
2667        end: relocate_review_point(origins, span.end)?,
2668    })
2669}
2670
2671fn internal_reference_target(instruction: &str) -> Result<Option<String>, ()> {
2672    let tokens = field_tokens(instruction)?;
2673    let Some(command) = tokens.first() else {
2674        return Err(());
2675    };
2676    if ["REF", "PAGEREF", "NOTEREF"]
2677        .iter()
2678        .any(|candidate| command.eq_ignore_ascii_case(candidate))
2679    {
2680        return tokens
2681            .get(1)
2682            .filter(|target| !target.is_empty() && !target.starts_with('\\'))
2683            .cloned()
2684            .map(Some)
2685            .ok_or(());
2686    }
2687    if command.eq_ignore_ascii_case("HYPERLINK") {
2688        for (index, token) in tokens.iter().enumerate().skip(1) {
2689            if token.eq_ignore_ascii_case("\\l") {
2690                return tokens
2691                    .get(index.checked_add(1).ok_or(())?)
2692                    .filter(|target| !target.is_empty())
2693                    .cloned()
2694                    .map(Some)
2695                    .ok_or(());
2696            }
2697        }
2698    }
2699    Ok(None)
2700}
2701
2702fn field_tokens(instruction: &str) -> Result<Vec<String>, ()> {
2703    let mut tokens = Vec::new();
2704    let mut token = String::new();
2705    let mut quoted = false;
2706    for character in instruction.chars() {
2707        match character {
2708            '"' => quoted = !quoted,
2709            whitespace if whitespace.is_whitespace() && !quoted => {
2710                if !token.is_empty() {
2711                    tokens.push(std::mem::take(&mut token));
2712                }
2713            }
2714            _ => token.push(character),
2715        }
2716    }
2717    if quoted {
2718        return Err(());
2719    }
2720    if !token.is_empty() {
2721        tokens.push(token);
2722    }
2723    Ok(tokens)
2724}
2725
2726fn attribute(
2727    reader: &NsReader<&[u8]>,
2728    element: &BytesStart<'_>,
2729    name: &[u8],
2730) -> Result<Option<String>, ProjectionError> {
2731    for attribute in element.attributes() {
2732        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2733        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2734        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing
2735            && local_name.as_ref() == name
2736        {
2737            return attribute
2738                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2739                .map(|value| Some(value.into_owned()))
2740                .map_err(|_| ProjectionError::InvalidDocumentXml);
2741        }
2742    }
2743    Ok(None)
2744}
2745
2746fn preserves_xml_space(
2747    reader: &NsReader<&[u8]>,
2748    element: &BytesStart<'_>,
2749) -> Result<bool, ProjectionError> {
2750    const XML_NAMESPACE: &[u8] = b"http://www.w3.org/XML/1998/namespace";
2751
2752    for attribute in element.attributes() {
2753        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2754        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2755        if matches!(
2756            namespace,
2757            quick_xml::name::ResolveResult::Bound(namespace)
2758                if namespace.as_ref() == XML_NAMESPACE
2759        ) && local_name.as_ref() == b"space"
2760        {
2761            let value = attribute
2762                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2763                .map_err(|_| ProjectionError::InvalidDocumentXml)?;
2764            return match value.as_ref() {
2765                "default" => Ok(false),
2766                "preserve" => Ok(true),
2767                _ => Err(ProjectionError::InvalidDocumentXml),
2768            };
2769        }
2770    }
2771    Ok(false)
2772}
2773
2774fn on_off_element_enabled(
2775    reader: &NsReader<&[u8]>,
2776    element: &BytesStart<'_>,
2777) -> Result<bool, ProjectionError> {
2778    Ok(attribute(reader, element, b"val")?
2779        .is_none_or(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")))
2780}
2781
2782fn trim_xml_whitespace(value: &str) -> &str {
2783    value.trim_matches(['\u{0009}', '\u{000a}', '\u{000d}', '\u{0020}'])
2784}
2785
2786fn attribute_2010(
2787    reader: &NsReader<&[u8]>,
2788    element: &BytesStart<'_>,
2789    name: &[u8],
2790) -> Result<Option<String>, ProjectionError> {
2791    for attribute in element.attributes() {
2792        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2793        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2794        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing2010
2795            && local_name.as_ref() == name
2796        {
2797            return attribute
2798                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2799                .map(|value| Some(value.into_owned()))
2800                .map_err(|_| ProjectionError::InvalidDocumentXml);
2801        }
2802    }
2803    Ok(None)
2804}
2805
2806fn symbol_text(value: &str, font: Option<&str>) -> String {
2807    let prefix = value
2808        .chars()
2809        .take_while(char::is_ascii_hexdigit)
2810        .collect::<String>();
2811    let Some(code) = (!prefix.is_empty())
2812        .then(|| u32::from_str_radix(&prefix, 16).ok())
2813        .flatten()
2814    else {
2815        return value.to_owned();
2816    };
2817    if let Some(replacement) = normalized_font_symbol(font, code) {
2818        return replacement.to_owned();
2819    }
2820    char::from_u32(code).unwrap_or('\u{fffd}').to_string()
2821}
2822
2823struct FontSymbolMapping {
2824    font: &'static str,
2825    encoded: u32,
2826    unicode: &'static str,
2827}
2828
2829// OOXML resolves w:sym through its named symbol font rather than the run font:
2830// https://learn.microsoft.com/dotnet/api/documentformat.openxml.wordprocessing.symbolchar
2831// Unicode's Wingdings mapping identifies the corresponding black-circle glyph:
2832// https://www.unicode.org/L2/L2011/11196-n4022-wingdings.pdf
2833// Keep this compatibility table explicit; unsupported font/code pairs preserve
2834// their encoded value instead of guessing from the glyph shape.
2835const FONT_SYMBOL_MAPPINGS: &[FontSymbolMapping] = &[FontSymbolMapping {
2836    font: "Wingdings",
2837    encoded: 0xF06C,
2838    unicode: "●",
2839}];
2840
2841fn normalized_font_symbol(font: Option<&str>, encoded: u32) -> Option<&'static str> {
2842    let font = font?;
2843    FONT_SYMBOL_MAPPINGS
2844        .iter()
2845        .find(|mapping| mapping.encoded == encoded && mapping.font.eq_ignore_ascii_case(font))
2846        .map(|mapping| mapping.unicode)
2847}