Skip to main content

stella_docx_kernel/projection/
ooxml.rs

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