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 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                    paragraph.append(&run.text, effective)?;
1507                }
1508                if let (Some(start), Some(end)) = (start, self.current_review_point()) {
1509                    self.locate_owned_revisions(
1510                        &run.property_review_indices,
1511                        OwnedRevisionSpan::Content(ReviewSpan { start, end }),
1512                    )?;
1513                }
1514            }
1515            Frame::ChangeSnapshot(snapshot) => {
1516                if self.revision_view == RevisionView::Original
1517                    && !matches!(snapshot.status, SnapshotStatus::Applied)
1518                {
1519                    self.revision_unsupported
1520                        .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1521                }
1522            }
1523            Frame::Revision(revision) => self.finish_attributed_revision(revision)?,
1524            _ => {}
1525        }
1526        Ok(())
1527    }
1528
1529    fn snapshot_owner(&self, name: &[u8]) -> SnapshotOwner {
1530        match (name, self.frames.last()) {
1531            (b"rPrChange", Some(Frame::RunProperties(properties))) => {
1532                if matches!(self.frames.get(properties.run_frame), Some(Frame::Run(_))) {
1533                    SnapshotOwner::Run {
1534                        frame: properties.run_frame,
1535                        hidden_eligible: properties.hidden_eligible,
1536                    }
1537                } else if self.inside_paragraph_mark_properties() {
1538                    SnapshotOwner::ParagraphMark
1539                } else {
1540                    SnapshotOwner::Unsupported
1541                }
1542            }
1543            (b"pPrChange", Some(Frame::ParagraphProperties))
1544                if self.current_paragraph.is_some() =>
1545            {
1546                SnapshotOwner::Paragraph
1547            }
1548            (b"tblPrChange", Some(Frame::TableProperties))
1549                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1550            {
1551                SnapshotOwner::Table
1552            }
1553            (b"trPrChange", Some(Frame::TableRowProperties))
1554                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1555            {
1556                SnapshotOwner::Row
1557            }
1558            (b"tcPrChange", Some(Frame::TableCellProperties))
1559                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Cell(_))) =>
1560            {
1561                SnapshotOwner::Cell
1562            }
1563            (b"tblGridChange", Some(Frame::TableGrid))
1564                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Table(_))) =>
1565            {
1566                SnapshotOwner::Grid
1567            }
1568            (b"sectPrChange", Some(Frame::SectionProperties))
1569                if matches!(
1570                    self.frames.iter().rev().nth(1),
1571                    Some(Frame::Body | Frame::ParagraphProperties)
1572                ) =>
1573            {
1574                SnapshotOwner::Section
1575            }
1576            (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1577                if matches!(self.frames.iter().rev().nth(1), Some(Frame::Row(_))) =>
1578            {
1579                SnapshotOwner::TableExceptions
1580            }
1581            _ => SnapshotOwner::Unsupported,
1582        }
1583    }
1584
1585    fn start_snapshot_child(&mut self, name: &[u8]) -> Result<bool, ProjectionError> {
1586        if self.revision_view == RevisionView::Current {
1587            self.frames.push(Frame::SnapshotIgnored);
1588            return Ok(true);
1589        }
1590        if let Some(Frame::ChangeSnapshot(snapshot)) = self.frames.last_mut() {
1591            let owner = snapshot.owner;
1592            let matches_root = matches!(
1593                (owner, name),
1594                (
1595                    SnapshotOwner::Run { .. } | SnapshotOwner::ParagraphMark,
1596                    b"rPr"
1597                ) | (SnapshotOwner::Paragraph, b"pPr")
1598                    | (SnapshotOwner::Table, b"tblPr")
1599                    | (SnapshotOwner::Row, b"trPr")
1600                    | (SnapshotOwner::Cell, b"tcPr")
1601                    | (SnapshotOwner::Grid, b"tblGrid")
1602                    | (SnapshotOwner::Section, b"sectPr")
1603                    | (SnapshotOwner::TableExceptions, b"tblPrEx")
1604            );
1605            if !matches_root || !matches!(snapshot.status, SnapshotStatus::AwaitingRoot) {
1606                snapshot.status = SnapshotStatus::Unsupported;
1607                self.frames.push(Frame::SnapshotIgnored);
1608                return Ok(true);
1609            }
1610            snapshot.status = SnapshotStatus::Applied;
1611            let frame = self.restore_snapshot_owner(owner)?;
1612            self.frames.push(frame);
1613            return Ok(true);
1614        }
1615        if matches!(self.frames.last(), Some(Frame::ParagraphMarkProperties))
1616            && matches!(name, b"moveFrom" | b"moveTo")
1617        {
1618            self.revision_unsupported
1619                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
1620            self.frames.push(Frame::SnapshotIgnored);
1621            return Ok(true);
1622        }
1623        let supported_property = matches!(
1624            (self.frames.last(), name),
1625            (
1626                Some(Frame::RunProperties(_)),
1627                b"rStyle"
1628                    | b"b"
1629                    | b"bCs"
1630                    | b"cs"
1631                    | b"rtl"
1632                    | b"highlight"
1633                    | b"vertAlign"
1634                    | b"vanish"
1635            ) | (
1636                Some(Frame::ParagraphProperties),
1637                b"pStyle" | b"ind" | b"jc" | b"outlineLvl" | b"numPr"
1638            ) | (Some(Frame::NumberingProperties), b"numId" | b"ilvl")
1639                | (Some(Frame::TableProperties), b"tblStyle")
1640                | (Some(Frame::ParagraphMarkProperties), b"ins" | b"del")
1641        );
1642        if supported_property {
1643            return Ok(false);
1644        }
1645        self.frames.push(Frame::SnapshotIgnored);
1646        Ok(true)
1647    }
1648
1649    fn restore_snapshot_owner(&mut self, owner: SnapshotOwner) -> Result<Frame, ProjectionError> {
1650        match owner {
1651            SnapshotOwner::Run {
1652                frame,
1653                hidden_eligible,
1654            } => {
1655                let Some(Frame::Run(run)) = self.frames.get_mut(frame) else {
1656                    return Err(ProjectionError::InvalidDocumentXml);
1657                };
1658                run.direct_styles = TextProperties::default();
1659                run.character_style_id = None;
1660                run.hidden = false;
1661                Ok(Frame::RunProperties(RunPropertiesFrame {
1662                    run_frame: frame,
1663                    hidden_eligible,
1664                }))
1665            }
1666            SnapshotOwner::Paragraph => {
1667                // CT_PPrBase excludes the independent paragraph-mark properties.
1668                let paragraph = self
1669                    .current_paragraph
1670                    .as_mut()
1671                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1672                paragraph.properties = ParagraphProperties {
1673                    table_style: std::mem::take(&mut paragraph.properties.table_style),
1674                    ..ParagraphProperties::default()
1675                };
1676                paragraph.resolved_text_base = None;
1677                Ok(Frame::ParagraphProperties)
1678            }
1679            SnapshotOwner::Table => {
1680                let table =
1681                    enclosing_table(&mut self.frames).ok_or(ProjectionError::InvalidDocumentXml)?;
1682                table.style_id = None;
1683                Ok(Frame::TableProperties)
1684            }
1685            SnapshotOwner::Row => Ok(Frame::TableRowProperties),
1686            SnapshotOwner::Cell => Ok(Frame::TableCellProperties),
1687            SnapshotOwner::Grid => Ok(Frame::TableGrid),
1688            SnapshotOwner::Section => Ok(Frame::SectionProperties),
1689            SnapshotOwner::TableExceptions => Ok(Frame::TablePropertyExceptions),
1690            SnapshotOwner::ParagraphMark => {
1691                let paragraph = self
1692                    .current_paragraph
1693                    .as_mut()
1694                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1695                paragraph.paragraph_mark_revision = None;
1696                Ok(Frame::ParagraphMarkProperties)
1697            }
1698            SnapshotOwner::Unsupported => Err(ProjectionError::InvalidDocumentXml),
1699        }
1700    }
1701
1702    fn record_container_property_revision(
1703        &mut self,
1704        name: &[u8],
1705        review_index: usize,
1706    ) -> Result<(), ProjectionError> {
1707        let valid_parent = matches!(
1708            (name, self.frames.last()),
1709            (b"tblPrChange", Some(Frame::TableProperties))
1710                | (b"tblGridChange", Some(Frame::TableGrid))
1711                | (b"trPrChange", Some(Frame::TableRowProperties))
1712                | (b"tcPrChange", Some(Frame::TableCellProperties))
1713                | (b"tblPrExChange", Some(Frame::TablePropertyExceptions))
1714        );
1715        if !valid_parent {
1716            return Ok(());
1717        }
1718        let review = self
1719            .frames
1720            .iter_mut()
1721            .rev()
1722            .find_map(|frame| match (name, frame) {
1723                (b"tblPrChange" | b"tblGridChange", Frame::Table(table)) => Some(&mut table.review),
1724                (b"trPrChange" | b"tblPrExChange", Frame::Row(row)) => Some(&mut row.review),
1725                (b"tcPrChange", Frame::Cell(cell)) => Some(&mut cell.review),
1726                _ => None,
1727            });
1728        if let Some(review) = review {
1729            let slot = match name {
1730                b"tblGridChange" => &mut review.grid_review,
1731                b"tblPrExChange" => &mut review.exception_review,
1732                _ => &mut review.property_review,
1733            };
1734            if slot.replace(review_index).is_some() {
1735                return Err(ProjectionError::InvalidDocumentXml);
1736            }
1737        }
1738        Ok(())
1739    }
1740
1741    fn finish_container_review(&mut self, review: ContainerReview) -> Result<(), ProjectionError> {
1742        if review.paragraph_start >= self.paragraphs.len() {
1743            return Ok(());
1744        }
1745        let end = self
1746            .paragraphs
1747            .last()
1748            .and_then(paragraph_end)
1749            .ok_or(ProjectionError::InvalidDocumentXml)?;
1750        for review_index in [
1751            review.property_review,
1752            review.grid_review,
1753            review.exception_review,
1754        ]
1755        .into_iter()
1756        .flatten()
1757        {
1758            self.locate_owned_revisions(
1759                std::slice::from_ref(&review_index),
1760                OwnedRevisionSpan::Content(ReviewSpan {
1761                    start: paragraph_start(review.paragraph_start),
1762                    end,
1763                }),
1764            )?;
1765        }
1766        Ok(())
1767    }
1768
1769    fn finish_paragraph(
1770        &mut self,
1771        styles: Result<&StyleSheet, FormattingUnknownReason>,
1772    ) -> Result<(), ProjectionError> {
1773        let mut paragraph = self
1774            .current_paragraph
1775            .take()
1776            .ok_or(ProjectionError::InvalidDocumentXml)?;
1777        if styles.is_ok_and(|styles| paragraph.resolve_text_base(styles).is_err()) {
1778            self.formatting_status =
1779                FormattingProjectionStatus::Incomplete(FormattingUnknownReason::UnsupportedStyles);
1780        }
1781        let ordinal = self.paragraphs.len();
1782        if let Some(revision) = paragraph.paragraph_mark_revision {
1783            self.paragraph_mark_revisions.insert(ordinal, revision);
1784        }
1785        let mark = ReviewPoint {
1786            paragraph_ordinal: ordinal,
1787            utf8: u32::try_from(paragraph.text.len())
1788                .map_err(|_| ProjectionError::InvalidDocumentXml)?,
1789            utf16: paragraph.utf16_len,
1790        };
1791        self.locate_owned_revisions(
1792            &paragraph.paragraph_review_indices,
1793            OwnedRevisionSpan::Paragraph(ReviewSpan {
1794                start: paragraph_start(ordinal),
1795                end: mark,
1796            }),
1797        )?;
1798        self.paragraphs.push(RawProjectedParagraph {
1799            ordinal: self.paragraphs.len(),
1800            package_paragraph_id: paragraph.package_paragraph_id,
1801            text: paragraph.text,
1802            utf16_len: paragraph.utf16_len,
1803            formatting: paragraph.formatting,
1804            structure: paragraph.structure,
1805            properties: paragraph.properties,
1806        });
1807        Ok(())
1808    }
1809
1810    fn record_attributed_revision(
1811        &mut self,
1812        reader: &NsReader<&[u8]>,
1813        element: &BytesStart<'_>,
1814        name: &[u8],
1815    ) -> Result<Option<usize>, ProjectionError> {
1816        let Some(kind) = revision_fact_kind(name) else {
1817            return Ok(None);
1818        };
1819        let ReviewRevisionCollection::Complete {
1820            maximum_facts,
1821            revisions,
1822            ..
1823        } = &mut self.review_revisions
1824        else {
1825            return Ok(None);
1826        };
1827        if revisions.len() >= *maximum_facts {
1828            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1829            return Ok(None);
1830        }
1831        let review_index = revisions.len();
1832        revisions.push(AttributedRevision {
1833            kind,
1834            author: attribute(reader, element, b"author")?.unwrap_or_default(),
1835            date: attribute(reader, element, b"date")?,
1836            revision_id: attribute(reader, element, b"id")?,
1837            content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
1838        });
1839        Ok(Some(review_index))
1840    }
1841
1842    fn finish_attributed_revision(
1843        &mut self,
1844        revision: RevisionFrame,
1845    ) -> Result<(), ProjectionError> {
1846        let (Some(review_index), Some(start), Some(end)) = (
1847            revision.review_index,
1848            revision.start,
1849            self.current_review_point(),
1850        ) else {
1851            return Ok(());
1852        };
1853        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1854        else {
1855            return Ok(());
1856        };
1857        let attributed = revisions
1858            .get_mut(review_index)
1859            .ok_or(ProjectionError::InvalidDocumentXml)?;
1860        attributed.content = ReviewDetail::Known(RevisionContent {
1861            span: ReviewSpan { start, end },
1862            payload: RevisionPayload::from_text(revision.text),
1863        });
1864        Ok(())
1865    }
1866
1867    fn locate_owned_revisions(
1868        &mut self,
1869        review_indices: &[usize],
1870        owner: OwnedRevisionSpan,
1871    ) -> Result<(), ProjectionError> {
1872        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1873        else {
1874            return Ok(());
1875        };
1876        for &review_index in review_indices {
1877            let revision = revisions
1878                .get_mut(review_index)
1879                .ok_or(ProjectionError::InvalidDocumentXml)?;
1880            let span = match owner {
1881                OwnedRevisionSpan::Content(span) => span,
1882                OwnedRevisionSpan::Paragraph(span) => {
1883                    if revision.kind == RevisionFactKind::ParagraphPropertiesChange {
1884                        span
1885                    } else {
1886                        ReviewSpan {
1887                            start: span.end,
1888                            end: span.end,
1889                        }
1890                    }
1891                }
1892            };
1893            let payload = match revision.kind {
1894                RevisionFactKind::RunPropertiesChange
1895                | RevisionFactKind::ParagraphPropertiesChange
1896                | RevisionFactKind::TablePropertiesChange
1897                | RevisionFactKind::TablePropertiesExceptionChange
1898                | RevisionFactKind::TableRowPropertiesChange
1899                | RevisionFactKind::TableCellPropertiesChange
1900                | RevisionFactKind::TableGridChange => RevisionPayload::FormattingOnly,
1901                _ => RevisionPayload::ParagraphMark,
1902            };
1903            revision.content = ReviewDetail::Known(RevisionContent { span, payload });
1904        }
1905        Ok(())
1906    }
1907
1908    fn inside_body(&self) -> bool {
1909        self.frames.iter().any(|frame| matches!(frame, Frame::Body))
1910    }
1911
1912    fn inside_textbox(&self) -> bool {
1913        self.frames
1914            .iter()
1915            .any(|frame| matches!(frame, Frame::Textbox))
1916    }
1917
1918    fn inside_change_snapshot(&self) -> bool {
1919        self.frames
1920            .iter()
1921            .any(|frame| matches!(frame, Frame::ChangeSnapshot(_)))
1922    }
1923
1924    fn inside_paragraph_mark_properties(&self) -> bool {
1925        self.current_paragraph.is_some()
1926            && self
1927                .frames
1928                .iter()
1929                .any(|frame| matches!(frame, Frame::ParagraphProperties))
1930            && !self
1931                .frames
1932                .iter()
1933                .any(|frame| matches!(frame, Frame::Run(_)))
1934    }
1935
1936    fn inside_table_row_properties(&self) -> bool {
1937        self.frames
1938            .iter()
1939            .any(|frame| matches!(frame, Frame::TableRowProperties))
1940    }
1941
1942    fn pseudo_text_is_suppressed(&self) -> bool {
1943        self.frames.iter().any(|frame| {
1944            matches!(
1945                frame,
1946                Frame::ParagraphProperties
1947                    | Frame::RunProperties(_)
1948                    | Frame::ChangeSnapshot(_)
1949                    | Frame::Textbox
1950            ) || matches!(frame, Frame::Revision(revision) if revision.suppressed)
1951                || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1952        })
1953    }
1954
1955    fn pseudo_text_is_structurally_suppressed(&self) -> bool {
1956        self.frames.iter().any(|frame| {
1957            matches!(
1958                frame,
1959                Frame::ParagraphProperties
1960                    | Frame::RunProperties(_)
1961                    | Frame::ChangeSnapshot(_)
1962                    | Frame::Textbox
1963            ) || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1964        })
1965    }
1966
1967    fn append_pseudo_text(&mut self, text: &str) -> Result<(), ProjectionError> {
1968        if self.current_paragraph.is_none()
1969            || self.pseudo_text_is_structurally_suppressed()
1970            || self
1971                .frames
1972                .iter()
1973                .rev()
1974                .any(|frame| matches!(frame, Frame::Run(run) if run.hidden))
1975        {
1976            return Ok(());
1977        }
1978        self.append_review_text(text);
1979        if self.pseudo_text_is_suppressed() {
1980            return Ok(());
1981        }
1982        if let Some(run) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1983            Frame::Run(run) => Some(run),
1984            _ => None,
1985        }) {
1986            run.text.push_str(text);
1987            return Ok(());
1988        }
1989        self.current_paragraph
1990            .as_mut()
1991            .ok_or(ProjectionError::InvalidDocumentXml)?
1992            .append(text, TextProperties::default())
1993    }
1994
1995    fn append_review_text(&mut self, text: &str) {
1996        let ReviewRevisionCollection::Complete {
1997            remaining_detail_bytes,
1998            ..
1999        } = &mut self.review_revisions
2000        else {
2001            return;
2002        };
2003        let mut limit_exceeded = false;
2004        for frame in &mut self.frames {
2005            if let Frame::Revision(revision) = frame
2006                && revision.review_index.is_some()
2007            {
2008                let Some(remaining) = remaining_detail_bytes.checked_sub(text.len()) else {
2009                    limit_exceeded = true;
2010                    break;
2011                };
2012                *remaining_detail_bytes = remaining;
2013                revision.text.push_str(text);
2014            }
2015        }
2016        if limit_exceeded {
2017            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
2018        }
2019    }
2020
2021    fn current_review_point(&self) -> Option<ReviewPoint> {
2022        let paragraph = self.current_paragraph.as_ref()?;
2023        let run_text = if self.pseudo_text_is_suppressed() {
2024            None
2025        } else {
2026            self.frames.iter().rev().find_map(|frame| match frame {
2027                Frame::Run(run) if !run.hidden => Some(run.text.as_str()),
2028                _ => None,
2029            })
2030        };
2031        let run_utf8 = run_text.map_or(0, str::len);
2032        let run_utf16 = run_text.map_or(0, |text| text.encode_utf16().count());
2033        Some(ReviewPoint {
2034            paragraph_ordinal: self.paragraphs.len(),
2035            utf8: u32::try_from(paragraph.text.len().checked_add(run_utf8)?).ok()?,
2036            utf16: paragraph
2037                .utf16_len
2038                .checked_add(u32::try_from(run_utf16).ok()?)?,
2039        })
2040    }
2041
2042    fn next_paragraph_start_review_point(&self) -> Option<ReviewPoint> {
2043        if self.current_paragraph.is_some() {
2044            return self.current_review_point();
2045        }
2046        self.inside_body().then_some(ReviewPoint {
2047            paragraph_ordinal: self.paragraphs.len(),
2048            utf8: 0,
2049            utf16: 0,
2050        })
2051    }
2052
2053    fn previous_paragraph_end_review_point(&self) -> Option<ReviewPoint> {
2054        if self.current_paragraph.is_some() {
2055            return self.current_review_point();
2056        }
2057        self.paragraphs.last().and_then(paragraph_end)
2058    }
2059
2060    fn review_comment_id(
2061        reader: &NsReader<&[u8]>,
2062        element: &BytesStart<'_>,
2063    ) -> Result<Option<String>, ProjectionError> {
2064        attribute(reader, element, b"id")
2065    }
2066
2067    fn start_review_comment_anchor(
2068        &mut self,
2069        reader: &NsReader<&[u8]>,
2070        element: &BytesStart<'_>,
2071    ) -> Result<(), ProjectionError> {
2072        let (Some(comment_id), Some(point)) = (
2073            Self::review_comment_id(reader, element)?,
2074            self.next_paragraph_start_review_point(),
2075        ) else {
2076            return Ok(());
2077        };
2078        if self.invalid_review_comment_anchors.contains(&comment_id) {
2079            return Ok(());
2080        }
2081        if self
2082            .open_review_comment_anchors
2083            .insert(comment_id.clone(), point)
2084            .is_some()
2085        {
2086            self.open_review_comment_anchors.remove(&comment_id);
2087            self.review_comment_anchors.remove(&comment_id);
2088            self.invalid_review_comment_anchors.insert(comment_id);
2089        }
2090        Ok(())
2091    }
2092
2093    fn suppress_review_comment_anchor(
2094        &mut self,
2095        reader: &NsReader<&[u8]>,
2096        element: &BytesStart<'_>,
2097    ) -> Result<(), ProjectionError> {
2098        let Some(comment_id) = Self::review_comment_id(reader, element)? else {
2099            return Ok(());
2100        };
2101        self.open_review_comment_anchors.remove(&comment_id);
2102        self.review_comment_anchors.remove(&comment_id);
2103        self.invalid_review_comment_anchors.insert(comment_id);
2104        Ok(())
2105    }
2106
2107    fn end_review_comment_anchor(
2108        &mut self,
2109        reader: &NsReader<&[u8]>,
2110        element: &BytesStart<'_>,
2111    ) -> Result<(), ProjectionError> {
2112        let (Some(comment_id), Some(end)) = (
2113            Self::review_comment_id(reader, element)?,
2114            self.previous_paragraph_end_review_point(),
2115        ) else {
2116            return Ok(());
2117        };
2118        if self.invalid_review_comment_anchors.contains(&comment_id) {
2119            return Ok(());
2120        }
2121        let Some(start) = self.open_review_comment_anchors.remove(&comment_id) else {
2122            self.review_comment_anchors.remove(&comment_id);
2123            self.invalid_review_comment_anchors.insert(comment_id);
2124            return Ok(());
2125        };
2126        if self
2127            .review_comment_anchors
2128            .insert(comment_id.clone(), ReviewSpan { start, end })
2129            .is_some()
2130        {
2131            self.review_comment_anchors.remove(&comment_id);
2132            self.invalid_review_comment_anchors.insert(comment_id);
2133        }
2134        Ok(())
2135    }
2136
2137    fn record_review_comment_reference(
2138        &mut self,
2139        reader: &NsReader<&[u8]>,
2140        element: &BytesStart<'_>,
2141    ) -> Result<(), ProjectionError> {
2142        let (Some(comment_id), Some(point)) = (
2143            Self::review_comment_id(reader, element)?,
2144            self.current_review_point(),
2145        ) else {
2146            return Ok(());
2147        };
2148        if self.invalid_review_comment_anchors.contains(&comment_id) {
2149            return Ok(());
2150        }
2151        if !self.review_comment_anchors.contains_key(&comment_id)
2152            && !self.open_review_comment_anchors.contains_key(&comment_id)
2153        {
2154            self.review_comment_anchors.insert(
2155                comment_id,
2156                ReviewSpan {
2157                    start: point,
2158                    end: point,
2159                },
2160            );
2161        }
2162        Ok(())
2163    }
2164
2165    fn append_pseudo_content(&mut self, text: &str) -> Result<(), ProjectionError> {
2166        let Some(Frame::PseudoText(frame)) = self.frames.last_mut() else {
2167            return Err(ProjectionError::InvalidDocumentXml);
2168        };
2169        frame.text.push_str(text);
2170        Ok(())
2171    }
2172
2173    fn append_text_control(&mut self, control: TextControl) -> Result<(), ProjectionError> {
2174        if let Some(text) = control.materialize(self.text_materialization) {
2175            self.append_pseudo_text(text)?;
2176        }
2177        Ok(())
2178    }
2179
2180    fn append_field_instruction(&mut self, text: &str) {
2181        let Some(field) = self.fields.last_mut() else {
2182            self.references_complete = false;
2183            return;
2184        };
2185        if matches!(field.source, FieldSource::Hidden) {
2186            return;
2187        }
2188        if field.separated || field.instruction.len().saturating_add(text.len()) > 4096 {
2189            self.references_complete = false;
2190            return;
2191        }
2192        field.instruction.push_str(text);
2193    }
2194
2195    fn simple_field(
2196        &mut self,
2197        reader: &NsReader<&[u8]>,
2198        element: &BytesStart<'_>,
2199    ) -> Result<(), ProjectionError> {
2200        let instruction = attribute(reader, element, b"instr")?;
2201        let source = self.current_field_source();
2202        if matches!(source, FieldSource::Hidden) {
2203            return Ok(());
2204        }
2205        let Some(instruction) = instruction.filter(|value| value.len() <= 4096) else {
2206            self.references_complete = false;
2207            return Ok(());
2208        };
2209        self.record_field_reference(&instruction, source);
2210        Ok(())
2211    }
2212
2213    fn field_character(
2214        &mut self,
2215        reader: &NsReader<&[u8]>,
2216        element: &BytesStart<'_>,
2217    ) -> Result<(), ProjectionError> {
2218        match attribute(reader, element, b"fldCharType")?.as_deref() {
2219            Some("begin") => self.fields.push(FieldFrame {
2220                source: self.current_field_source(),
2221                instruction: String::new(),
2222                separated: false,
2223            }),
2224            Some("separate") => {
2225                let Some(mut field) = self.fields.pop() else {
2226                    self.references_complete = false;
2227                    return Ok(());
2228                };
2229                if field.separated {
2230                    self.references_complete = false;
2231                } else {
2232                    self.record_field_reference(&field.instruction, field.source);
2233                    field.separated = true;
2234                }
2235                self.fields.push(field);
2236            }
2237            Some("end") => {
2238                let Some(field) = self.fields.pop() else {
2239                    self.references_complete = false;
2240                    return Ok(());
2241                };
2242                if !field.separated {
2243                    self.record_field_reference(&field.instruction, field.source);
2244                }
2245            }
2246            _ => self.references_complete = false,
2247        }
2248        Ok(())
2249    }
2250
2251    fn current_field_source(&self) -> FieldSource {
2252        if self.pseudo_text_is_suppressed() {
2253            return FieldSource::Hidden;
2254        }
2255        self.current_review_point()
2256            .map_or(FieldSource::Unsupported, FieldSource::Projected)
2257    }
2258
2259    fn record_field_reference(&mut self, instruction: &str, source: FieldSource) {
2260        if matches!(source, FieldSource::Hidden) {
2261            return;
2262        }
2263        match internal_reference_target(instruction) {
2264            Ok(Some(reference_id)) => {
2265                let FieldSource::Projected(source) = source else {
2266                    self.references_complete = false;
2267                    return;
2268                };
2269                self.references.push(RawInternalReference {
2270                    reference_id,
2271                    source,
2272                });
2273            }
2274            Ok(None) => {}
2275            Err(()) => self.references_complete = false,
2276        }
2277    }
2278
2279    fn current_point(&self) -> Option<ReviewPoint> {
2280        if self.current_paragraph.is_some() {
2281            return self.current_review_point();
2282        }
2283        if !matches!(self.frames.last(), Some(Frame::Body)) {
2284            return None;
2285        }
2286        self.paragraphs
2287            .last()
2288            .map_or(Some(paragraph_start(0)), paragraph_end)
2289    }
2290
2291    fn current_bookmark_point(&self) -> Option<BookmarkPoint> {
2292        if self.current_paragraph.is_some() {
2293            return self.current_point().map(BookmarkPoint::Paragraph);
2294        }
2295        matches!(
2296            self.frames.last(),
2297            Some(
2298                Frame::Body
2299                    | Frame::BlockContent
2300                    | Frame::Table(_)
2301                    | Frame::Row(_)
2302                    | Frame::Cell(_)
2303            )
2304        )
2305        .then(|| BookmarkPoint::ParagraphBoundary(self.paragraphs.len()))
2306    }
2307
2308    fn start_bookmark(
2309        &mut self,
2310        reader: &NsReader<&[u8]>,
2311        element: &BytesStart<'_>,
2312    ) -> Result<(), ProjectionError> {
2313        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2314        let name = attribute(reader, element, b"name")?;
2315        let point = self.current_bookmark_point();
2316        let (Some(id), Some(name), Some(point)) = (id, name, point) else {
2317            self.bookmarks_complete = false;
2318            return Ok(());
2319        };
2320        if name.is_empty()
2321            || !self.seen_bookmark_ids.insert(id)
2322            || self.open_bookmarks.insert(id, (name, point)).is_some()
2323        {
2324            self.bookmarks_complete = false;
2325        }
2326        Ok(())
2327    }
2328
2329    fn end_bookmark(
2330        &mut self,
2331        reader: &NsReader<&[u8]>,
2332        element: &BytesStart<'_>,
2333    ) -> Result<(), ProjectionError> {
2334        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
2335        let point = self.current_bookmark_point();
2336        let (Some(id), Some(end)) = (id, point) else {
2337            self.bookmarks_complete = false;
2338            return Ok(());
2339        };
2340        let Some((name, start)) = self.open_bookmarks.remove(&id) else {
2341            self.bookmarks_complete = false;
2342            return Ok(());
2343        };
2344        self.bookmarks.push(PendingBookmarkRange {
2345            id,
2346            name,
2347            start,
2348            end,
2349        });
2350        Ok(())
2351    }
2352
2353    fn mark_placeholder(&mut self) {
2354        let Some(sdt) = self.frames.iter_mut().rev().find_map(|frame| match frame {
2355            Frame::Sdt(sdt) => Some(sdt),
2356            _ => None,
2357        }) else {
2358            return;
2359        };
2360        sdt.placeholder = true;
2361        if let Some(paragraph) = self.current_paragraph.as_mut() {
2362            paragraph.truncate(sdt.text_utf8_start, sdt.text_utf16_start);
2363        }
2364    }
2365
2366    fn mark_hidden_run(&mut self) {
2367        let Some((run_frame, hidden_eligible)) =
2368            self.frames.iter().rev().find_map(|frame| match frame {
2369                Frame::RunProperties(properties) => {
2370                    Some((properties.run_frame, properties.hidden_eligible))
2371                }
2372                _ => None,
2373            })
2374        else {
2375            return;
2376        };
2377        if !hidden_eligible || run_frame == usize::MAX {
2378            return;
2379        }
2380        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2381            run.hidden = true;
2382        }
2383    }
2384
2385    fn set_run_character_style(&mut self, style_id: Option<String>) {
2386        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2387            return;
2388        };
2389        let run_frame = properties.run_frame;
2390        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2391            run.character_style_id = style_id;
2392        }
2393    }
2394
2395    fn mark_bold_run(&mut self, enabled: bool) {
2396        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2397            return;
2398        };
2399        let run_frame = properties.run_frame;
2400        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2401            run.direct_styles.bold = Some(enabled);
2402        }
2403    }
2404
2405    fn mark_complex_script_bold_run(&mut self, enabled: bool) {
2406        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2407            return;
2408        };
2409        let run_frame = properties.run_frame;
2410        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2411            run.direct_styles.complex_script_bold = Some(enabled);
2412        }
2413    }
2414
2415    fn mark_complex_script_run(&mut self, enabled: bool) {
2416        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2417            return;
2418        };
2419        let run_frame = properties.run_frame;
2420        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2421            run.direct_styles.force_complex_script = Some(enabled);
2422        }
2423    }
2424
2425    fn mark_right_to_left_run(&mut self, enabled: bool) {
2426        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2427            return;
2428        };
2429        let run_frame = properties.run_frame;
2430        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2431            run.direct_styles.right_to_left = Some(enabled);
2432        }
2433    }
2434
2435    fn mark_highlighted_run(&mut self, enabled: bool) {
2436        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2437            return;
2438        };
2439        let run_frame = properties.run_frame;
2440        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2441            run.direct_styles.highlighted = Some(enabled);
2442        }
2443    }
2444
2445    fn mark_superscript_run(&mut self, enabled: bool) {
2446        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2447            return;
2448        };
2449        let run_frame = properties.run_frame;
2450        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2451            run.direct_styles.superscript = Some(enabled);
2452        }
2453    }
2454}
2455
2456const fn revision_fact_kind(name: &[u8]) -> Option<RevisionFactKind> {
2457    match name {
2458        b"ins" => Some(RevisionFactKind::Insertion),
2459        b"del" => Some(RevisionFactKind::Deletion),
2460        b"moveFrom" => Some(RevisionFactKind::MoveFrom),
2461        b"moveTo" => Some(RevisionFactKind::MoveTo),
2462        b"cellIns" => Some(RevisionFactKind::CellInsertion),
2463        b"cellDel" => Some(RevisionFactKind::CellDeletion),
2464        b"cellMerge" => Some(RevisionFactKind::CellMerge),
2465        b"pPrChange" => Some(RevisionFactKind::ParagraphPropertiesChange),
2466        b"rPrChange" => Some(RevisionFactKind::RunPropertiesChange),
2467        b"sectPrChange" => Some(RevisionFactKind::SectionPropertiesChange),
2468        b"tblPrChange" => Some(RevisionFactKind::TablePropertiesChange),
2469        b"tblPrExChange" => Some(RevisionFactKind::TablePropertiesExceptionChange),
2470        b"trPrChange" => Some(RevisionFactKind::TableRowPropertiesChange),
2471        b"tcPrChange" => Some(RevisionFactKind::TableCellPropertiesChange),
2472        b"tblGridChange" => Some(RevisionFactKind::TableGridChange),
2473        b"customXmlDelRangeStart" => Some(RevisionFactKind::CustomXmlDeletionRangeStart),
2474        b"customXmlDelRangeEnd" => Some(RevisionFactKind::CustomXmlDeletionRangeEnd),
2475        b"customXmlInsRangeStart" => Some(RevisionFactKind::CustomXmlInsertionRangeStart),
2476        b"customXmlInsRangeEnd" => Some(RevisionFactKind::CustomXmlInsertionRangeEnd),
2477        b"customXmlMoveFromRangeStart" => Some(RevisionFactKind::CustomXmlMoveFromRangeStart),
2478        b"customXmlMoveFromRangeEnd" => Some(RevisionFactKind::CustomXmlMoveFromRangeEnd),
2479        b"customXmlMoveToRangeStart" => Some(RevisionFactKind::CustomXmlMoveToRangeStart),
2480        b"customXmlMoveToRangeEnd" => Some(RevisionFactKind::CustomXmlMoveToRangeEnd),
2481        _ => None,
2482    }
2483}
2484
2485fn enclosing_table(frames: &mut [Frame]) -> Option<&mut TableFrame> {
2486    for frame in frames.iter_mut().rev() {
2487        match frame {
2488            Frame::Table(table) => return Some(table),
2489            Frame::Row(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2490            _ => {}
2491        }
2492    }
2493    None
2494}
2495
2496fn enclosing_row(frames: &mut [Frame]) -> Option<&mut RowFrame> {
2497    for frame in frames.iter_mut().rev() {
2498        match frame {
2499            Frame::Row(row) => return Some(row),
2500            Frame::Table(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2501            _ => {}
2502        }
2503    }
2504    None
2505}
2506
2507fn revision_is_suppressed(name: &[u8], view: RevisionView) -> bool {
2508    match view {
2509        RevisionView::Current => matches!(name, b"del" | b"moveFrom"),
2510        RevisionView::Original => matches!(name, b"ins" | b"moveTo"),
2511    }
2512}
2513
2514fn is_change_snapshot(name: &[u8]) -> bool {
2515    matches!(
2516        name,
2517        b"pPrChange"
2518            | b"rPrChange"
2519            | b"sectPrChange"
2520            | b"tblPrChange"
2521            | b"tblPrExChange"
2522            | b"trPrChange"
2523            | b"tcPrChange"
2524            | b"tblGridChange"
2525    )
2526}
2527
2528fn is_unsupported_revision_markup(name: &[u8]) -> bool {
2529    matches!(
2530        name,
2531        b"customXmlDelRangeStart"
2532            | b"customXmlDelRangeEnd"
2533            | b"customXmlInsRangeStart"
2534            | b"customXmlInsRangeEnd"
2535            | b"customXmlMoveFromRangeStart"
2536            | b"customXmlMoveFromRangeEnd"
2537            | b"customXmlMoveToRangeStart"
2538            | b"customXmlMoveToRangeEnd"
2539    )
2540}
2541
2542const fn paragraph_break_is_removed(
2543    revision: Option<ParagraphMarkRevision>,
2544    view: RevisionView,
2545) -> bool {
2546    matches!(
2547        (revision, view),
2548        (Some(ParagraphMarkRevision::Removed), RevisionView::Current)
2549            | (Some(ParagraphMarkRevision::Added), RevisionView::Original)
2550    )
2551}
2552
2553// Share the compiled coalescer between run projection and paragraph joins.
2554#[inline(never)]
2555fn append_formatting_span(spans: &mut Vec<TextFormattingSpan>, span: TextFormattingSpan) {
2556    if let Some(adjacent) = spans
2557        .iter_mut()
2558        .rev()
2559        .find(|previous| previous.style == span.style)
2560        && adjacent.end_utf16 == span.start_utf16
2561    {
2562        adjacent.end_utf16 = span.end_utf16;
2563        return;
2564    }
2565    spans.push(span);
2566}
2567
2568/// Merges paragraphs whose break the selected view removes. When any merge
2569/// happens, returns where each source paragraph starts in the merged text, so
2570/// review points recorded against source paragraphs can be translated.
2571fn normalize_paragraph_revision_view(
2572    paragraphs: &mut Vec<RawProjectedParagraph>,
2573    paragraph_mark_revisions: &mut HashMap<usize, ParagraphMarkRevision>,
2574    view: RevisionView,
2575    unsupported: &mut BTreeSet<RevisionUnsupportedReason>,
2576) -> Result<Option<Vec<ReviewPoint>>, ProjectionError> {
2577    let mut normalized: Vec<RawProjectedParagraph> = Vec::with_capacity(paragraphs.len());
2578    let mut origins = Vec::with_capacity(paragraphs.len());
2579    let mut merge_previous = false;
2580    let mut merged_any = false;
2581
2582    for (source_ordinal, mut paragraph) in std::mem::take(paragraphs).into_iter().enumerate() {
2583        let merge_next =
2584            paragraph_break_is_removed(paragraph_mark_revisions.remove(&source_ordinal), view);
2585        if merge_previous {
2586            let previous = normalized
2587                .last_mut()
2588                .ok_or(ProjectionError::InvalidDocumentXml)?;
2589            if previous.structure != paragraph.structure {
2590                unsupported.insert(RevisionUnsupportedReason::IncompatibleParagraphMerge);
2591                paragraph.ordinal = normalized.len();
2592                origins.push(paragraph_start(paragraph.ordinal));
2593                normalized.push(paragraph);
2594                merge_previous = merge_next;
2595                continue;
2596            }
2597            let utf16_offset = previous.utf16_len;
2598            origins.push(ReviewPoint {
2599                paragraph_ordinal: previous.ordinal,
2600                utf8: u32::try_from(previous.text.len())
2601                    .map_err(|_| ProjectionError::InvalidDocumentXml)?,
2602                utf16: utf16_offset,
2603            });
2604            for mut span in paragraph.formatting {
2605                span.start_utf16 = span
2606                    .start_utf16
2607                    .checked_add(utf16_offset)
2608                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2609                span.end_utf16 = span
2610                    .end_utf16
2611                    .checked_add(utf16_offset)
2612                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2613                append_formatting_span(&mut previous.formatting, span);
2614            }
2615            previous.text.push_str(&paragraph.text);
2616            previous.utf16_len = previous
2617                .utf16_len
2618                .checked_add(paragraph.utf16_len)
2619                .ok_or(ProjectionError::InvalidDocumentXml)?;
2620            merge_previous = merge_next;
2621            merged_any = true;
2622            continue;
2623        }
2624
2625        paragraph.ordinal = normalized.len();
2626        origins.push(paragraph_start(paragraph.ordinal));
2627        normalized.push(paragraph);
2628        merge_previous = merge_next;
2629    }
2630
2631    *paragraphs = normalized;
2632    Ok(merged_any.then_some(origins))
2633}
2634
2635const fn paragraph_start(paragraph_ordinal: usize) -> ReviewPoint {
2636    ReviewPoint {
2637        paragraph_ordinal,
2638        utf8: 0,
2639        utf16: 0,
2640    }
2641}
2642
2643/// Translates a point recorded against a source paragraph into the merged
2644/// paragraph coordinates. `None` means the point names no source paragraph.
2645fn relocate_review_point(origins: &[ReviewPoint], point: ReviewPoint) -> Option<ReviewPoint> {
2646    let origin = origins.get(point.paragraph_ordinal)?;
2647    Some(ReviewPoint {
2648        paragraph_ordinal: origin.paragraph_ordinal,
2649        utf8: origin.utf8.checked_add(point.utf8)?,
2650        utf16: origin.utf16.checked_add(point.utf16)?,
2651    })
2652}
2653
2654fn relocate_block_point(
2655    origins: &[ReviewPoint],
2656    point: &mut ReviewPoint,
2657) -> Result<(), ProjectionError> {
2658    *point = relocate_review_point(origins, *point).ok_or(ProjectionError::InvalidDocumentXml)?;
2659    Ok(())
2660}
2661
2662fn relocate_review_span(origins: &[ReviewPoint], span: ReviewSpan) -> Option<ReviewSpan> {
2663    Some(ReviewSpan {
2664        start: relocate_review_point(origins, span.start)?,
2665        end: relocate_review_point(origins, span.end)?,
2666    })
2667}
2668
2669fn internal_reference_target(instruction: &str) -> Result<Option<String>, ()> {
2670    let tokens = field_tokens(instruction)?;
2671    let Some(command) = tokens.first() else {
2672        return Err(());
2673    };
2674    if ["REF", "PAGEREF", "NOTEREF"]
2675        .iter()
2676        .any(|candidate| command.eq_ignore_ascii_case(candidate))
2677    {
2678        return tokens
2679            .get(1)
2680            .filter(|target| !target.is_empty() && !target.starts_with('\\'))
2681            .cloned()
2682            .map(Some)
2683            .ok_or(());
2684    }
2685    if command.eq_ignore_ascii_case("HYPERLINK") {
2686        for (index, token) in tokens.iter().enumerate().skip(1) {
2687            if token.eq_ignore_ascii_case("\\l") {
2688                return tokens
2689                    .get(index.checked_add(1).ok_or(())?)
2690                    .filter(|target| !target.is_empty())
2691                    .cloned()
2692                    .map(Some)
2693                    .ok_or(());
2694            }
2695        }
2696    }
2697    Ok(None)
2698}
2699
2700fn field_tokens(instruction: &str) -> Result<Vec<String>, ()> {
2701    let mut tokens = Vec::new();
2702    let mut token = String::new();
2703    let mut quoted = false;
2704    for character in instruction.chars() {
2705        match character {
2706            '"' => quoted = !quoted,
2707            whitespace if whitespace.is_whitespace() && !quoted => {
2708                if !token.is_empty() {
2709                    tokens.push(std::mem::take(&mut token));
2710                }
2711            }
2712            _ => token.push(character),
2713        }
2714    }
2715    if quoted {
2716        return Err(());
2717    }
2718    if !token.is_empty() {
2719        tokens.push(token);
2720    }
2721    Ok(tokens)
2722}
2723
2724fn attribute(
2725    reader: &NsReader<&[u8]>,
2726    element: &BytesStart<'_>,
2727    name: &[u8],
2728) -> Result<Option<String>, ProjectionError> {
2729    for attribute in element.attributes() {
2730        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2731        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2732        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing
2733            && local_name.as_ref() == name
2734        {
2735            return attribute
2736                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2737                .map(|value| Some(value.into_owned()))
2738                .map_err(|_| ProjectionError::InvalidDocumentXml);
2739        }
2740    }
2741    Ok(None)
2742}
2743
2744fn preserves_xml_space(
2745    reader: &NsReader<&[u8]>,
2746    element: &BytesStart<'_>,
2747) -> Result<bool, ProjectionError> {
2748    const XML_NAMESPACE: &[u8] = b"http://www.w3.org/XML/1998/namespace";
2749
2750    for attribute in element.attributes() {
2751        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2752        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2753        if matches!(
2754            namespace,
2755            quick_xml::name::ResolveResult::Bound(namespace)
2756                if namespace.as_ref() == XML_NAMESPACE
2757        ) && local_name.as_ref() == b"space"
2758        {
2759            let value = attribute
2760                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2761                .map_err(|_| ProjectionError::InvalidDocumentXml)?;
2762            return match value.as_ref() {
2763                "default" => Ok(false),
2764                "preserve" => Ok(true),
2765                _ => Err(ProjectionError::InvalidDocumentXml),
2766            };
2767        }
2768    }
2769    Ok(false)
2770}
2771
2772fn on_off_element_enabled(
2773    reader: &NsReader<&[u8]>,
2774    element: &BytesStart<'_>,
2775) -> Result<bool, ProjectionError> {
2776    Ok(attribute(reader, element, b"val")?
2777        .is_none_or(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")))
2778}
2779
2780fn trim_xml_whitespace(value: &str) -> &str {
2781    value.trim_matches(['\u{0009}', '\u{000a}', '\u{000d}', '\u{0020}'])
2782}
2783
2784fn attribute_2010(
2785    reader: &NsReader<&[u8]>,
2786    element: &BytesStart<'_>,
2787    name: &[u8],
2788) -> Result<Option<String>, ProjectionError> {
2789    for attribute in element.attributes() {
2790        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2791        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2792        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing2010
2793            && local_name.as_ref() == name
2794        {
2795            return attribute
2796                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2797                .map(|value| Some(value.into_owned()))
2798                .map_err(|_| ProjectionError::InvalidDocumentXml);
2799        }
2800    }
2801    Ok(None)
2802}
2803
2804fn symbol_text(value: &str, font: Option<&str>) -> String {
2805    let prefix = value
2806        .chars()
2807        .take_while(char::is_ascii_hexdigit)
2808        .collect::<String>();
2809    let Some(code) = (!prefix.is_empty())
2810        .then(|| u32::from_str_radix(&prefix, 16).ok())
2811        .flatten()
2812    else {
2813        return value.to_owned();
2814    };
2815    if let Some(replacement) = normalized_font_symbol(font, code) {
2816        return replacement.to_owned();
2817    }
2818    char::from_u32(code).unwrap_or('\u{fffd}').to_string()
2819}
2820
2821struct FontSymbolMapping {
2822    font: &'static str,
2823    encoded: u32,
2824    unicode: &'static str,
2825}
2826
2827// OOXML resolves w:sym through its named symbol font rather than the run font:
2828// https://learn.microsoft.com/dotnet/api/documentformat.openxml.wordprocessing.symbolchar
2829// Unicode's Wingdings mapping identifies the corresponding black-circle glyph:
2830// https://www.unicode.org/L2/L2011/11196-n4022-wingdings.pdf
2831// Keep this compatibility table explicit; unsupported font/code pairs preserve
2832// their encoded value instead of guessing from the glyph shape.
2833const FONT_SYMBOL_MAPPINGS: &[FontSymbolMapping] = &[FontSymbolMapping {
2834    font: "Wingdings",
2835    encoded: 0xF06C,
2836    unicode: "●",
2837}];
2838
2839fn normalized_font_symbol(font: Option<&str>, encoded: u32) -> Option<&'static str> {
2840    let font = font?;
2841    FONT_SYMBOL_MAPPINGS
2842        .iter()
2843        .find(|mapping| mapping.encoded == encoded && mapping.font.eq_ignore_ascii_case(font))
2844        .map(|mapping| mapping.unicode)
2845}