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