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