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_alignment, parse_indentation, parse_level_attribute, parse_outline_level_attribute,
21    parse_u32_attribute, semantic_highlight_value, word_style_id,
22};
23use crate::{FormattingProjectionStatus, FormattingUnknownReason, ProjectionError};
24
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub struct PackageParagraphId(u32);
27
28impl PackageParagraphId {
29    #[must_use]
30    pub fn parse(value: &str) -> Option<Self> {
31        if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
32            return None;
33        }
34        let value = u32::from_str_radix(value, 16).ok()?;
35        (value != 0 && i32::try_from(value).is_ok()).then_some(Self(value))
36    }
37
38    #[must_use]
39    pub const fn value(self) -> u32 {
40        self.0
41    }
42}
43
44#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
45pub enum TextStyle {
46    Bold,
47    Highlight,
48    Superscript,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct TextFormattingSpan {
53    pub start_utf16: u32,
54    pub end_utf16: u32,
55    pub style: TextStyle,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ParagraphStructure {
60    pub table_ordinal: usize,
61    pub row: usize,
62    pub column: usize,
63}
64
65#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
66pub enum RevisionView {
67    #[default]
68    Current,
69    Original,
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum TextMaterialization {
74    #[default]
75    WordHost,
76    ReadablePlainText,
77}
78
79#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
80pub enum RevisionUnsupportedReason {
81    IncompatibleParagraphMerge,
82    StructuralTableRevision,
83    UnsupportedRevisionMarkup,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum RevisionProjectionStatus {
88    Complete,
89    Incomplete(Vec<RevisionUnsupportedReason>),
90}
91
92impl RevisionProjectionStatus {
93    #[must_use]
94    pub const fn is_complete(&self) -> bool {
95        matches!(self, Self::Complete)
96    }
97}
98
99#[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            // `w:jc` is also legal under `w:trPr` and `w:tblPr`, where it means
1071            // row or table justification. Only a direct child of `w:pPr` is
1072            // paragraph alignment.
1073            b"jc" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1074                if let Some(paragraph) = self.current_paragraph.as_mut() {
1075                    paragraph.properties.alignment = Some(parse_alignment(reader, element)?);
1076                }
1077                Frame::Other
1078            }
1079            b"outlineLvl" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1080                if let Some(paragraph) = self.current_paragraph.as_mut() {
1081                    paragraph.properties.outline_level =
1082                        Some(parse_outline_level_attribute(reader, element)?);
1083                }
1084                Frame::Other
1085            }
1086            b"numPr" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
1087                if let Some(paragraph) = self.current_paragraph.as_mut() {
1088                    paragraph.properties.numbering.present = true;
1089                }
1090                Frame::NumberingProperties
1091            }
1092            b"numId" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1093                if let Some(paragraph) = self.current_paragraph.as_mut() {
1094                    paragraph.properties.numbering.num_id =
1095                        Some(parse_u32_attribute(reader, element)?);
1096                }
1097                Frame::Other
1098            }
1099            b"ilvl" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
1100                if let Some(paragraph) = self.current_paragraph.as_mut() {
1101                    paragraph.properties.numbering.level =
1102                        Some(parse_level_attribute(reader, element)?);
1103                }
1104                Frame::Other
1105            }
1106            b"bookmarkStart" => {
1107                self.start_bookmark(reader, element)?;
1108                Frame::Other
1109            }
1110            b"bookmarkEnd" => {
1111                self.end_bookmark(reader, element)?;
1112                Frame::Other
1113            }
1114            b"hyperlink" => {
1115                let anchor = attribute(reader, element, b"anchor")?;
1116                let reference =
1117                    anchor
1118                        .clone()
1119                        .filter(|anchor| !anchor.is_empty())
1120                        .and_then(|anchor| {
1121                            self.current_point().map(|source| RawInternalReference {
1122                                reference_id: anchor,
1123                                source,
1124                            })
1125                        });
1126                if anchor.is_some() && reference.is_none() {
1127                    self.references_complete = false;
1128                }
1129                Frame::Hyperlink(reference)
1130            }
1131            b"fldSimple" => {
1132                self.simple_field(reader, element)?;
1133                Frame::Other
1134            }
1135            b"fldChar" => {
1136                self.field_character(reader, element)?;
1137                Frame::Other
1138            }
1139            b"instrText" | b"delInstrText" => Frame::PseudoText(PseudoTextFrame::instruction()),
1140            b"ins" | b"del" | b"moveFrom" | b"moveTo" => {
1141                if self.inside_table_row_properties() {
1142                    self.revision_unsupported
1143                        .insert(RevisionUnsupportedReason::StructuralTableRevision);
1144                    Frame::Other
1145                } else if self.inside_paragraph_mark_properties() && matches!(name, b"ins" | b"del")
1146                {
1147                    if let Some(paragraph) = self.current_paragraph.as_mut() {
1148                        paragraph.paragraph_mark_revision = Some(if name == b"ins" {
1149                            ParagraphMarkRevision::Insertion
1150                        } else {
1151                            ParagraphMarkRevision::Deletion
1152                        });
1153                    }
1154                    Frame::Other
1155                } else {
1156                    Frame::Revision(RevisionFrame {
1157                        review_index: attributed_revision,
1158                        start: self.current_review_point(),
1159                        suppressed: revision_is_suppressed(name, self.revision_view),
1160                        text: String::new(),
1161                    })
1162                }
1163            }
1164            b"cellIns" | b"cellDel" | b"cellMerge" => {
1165                self.revision_unsupported
1166                    .insert(RevisionUnsupportedReason::StructuralTableRevision);
1167                Frame::Other
1168            }
1169            b"sdt" => {
1170                let (text_utf8_start, text_utf16_start) =
1171                    self.current_paragraph.as_ref().map_or((0, 0), |paragraph| {
1172                        (paragraph.text.len(), paragraph.utf16_len)
1173                    });
1174                Frame::Sdt(SdtFrame {
1175                    placeholder: false,
1176                    text_utf8_start,
1177                    text_utf16_start,
1178                })
1179            }
1180            b"showingPlcHdr" => {
1181                if on_off_element_enabled(reader, element)? {
1182                    self.mark_placeholder();
1183                }
1184                Frame::Other
1185            }
1186            b"vanish" => {
1187                if on_off_element_enabled(reader, element)? {
1188                    self.mark_hidden_run();
1189                }
1190                Frame::Other
1191            }
1192            b"rStyle" => {
1193                let style_id = word_style_id(attribute(reader, element, b"val")?);
1194                if matches!(style_id.as_deref(), Some("HideTWBInt" | "HideTWBExt")) {
1195                    self.mark_hidden_run();
1196                }
1197                self.set_run_character_style(style_id);
1198                Frame::Other
1199            }
1200            b"b" => {
1201                self.mark_bold_run(on_off_element_enabled(reader, element)?);
1202                Frame::Other
1203            }
1204            b"bCs" => {
1205                self.mark_complex_script_bold_run(on_off_element_enabled(reader, element)?);
1206                Frame::Other
1207            }
1208            b"cs" => {
1209                self.mark_complex_script_run(on_off_element_enabled(reader, element)?);
1210                Frame::Other
1211            }
1212            b"rtl" => {
1213                self.mark_right_to_left_run(on_off_element_enabled(reader, element)?);
1214                Frame::Other
1215            }
1216            b"highlight" => {
1217                if let Some(highlighted) = attribute(reader, element, b"val")?
1218                    .as_deref()
1219                    .and_then(semantic_highlight_value)
1220                {
1221                    self.mark_highlighted_run(highlighted);
1222                }
1223                Frame::Other
1224            }
1225            b"vertAlign" => {
1226                match attribute(reader, element, b"val")?.as_deref() {
1227                    Some("superscript") => self.mark_superscript_run(true),
1228                    Some("baseline" | "subscript") => self.mark_superscript_run(false),
1229                    _ => {}
1230                }
1231                Frame::Other
1232            }
1233            b"t" | b"delText" => {
1234                Frame::PseudoText(PseudoTextFrame::text(preserves_xml_space(reader, element)?))
1235            }
1236            b"tab" | b"ptab" => {
1237                self.append_text_control(TextControl::Tab)?;
1238                Frame::Other
1239            }
1240            b"br" => {
1241                let control = match attribute(reader, element, b"type")?.as_deref() {
1242                    Some("page") => TextControl::PageBreak,
1243                    Some("column") => TextControl::ColumnBreak,
1244                    _ => TextControl::LineBreak,
1245                };
1246                self.append_text_control(control)?;
1247                Frame::Other
1248            }
1249            b"cr" => {
1250                self.append_text_control(TextControl::CarriageReturn)?;
1251                Frame::Other
1252            }
1253            b"footnoteReference" => {
1254                let visible =
1255                    attribute(reader, element, b"customMarkFollows")?.is_none_or(|value| {
1256                        matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")
1257                    });
1258                if visible {
1259                    self.append_text_control(TextControl::FootnoteReference)?;
1260                }
1261                Frame::Other
1262            }
1263            b"softHyphen" => {
1264                self.append_text_control(TextControl::SoftHyphen)?;
1265                Frame::Other
1266            }
1267            b"noBreakHyphen" => {
1268                self.append_text_control(TextControl::NoBreakHyphen)?;
1269                Frame::Other
1270            }
1271            b"sym" => {
1272                if let Some(value) = attribute(reader, element, b"char")? {
1273                    let font = attribute(reader, element, b"font")?;
1274                    self.append_pseudo_text(&symbol_text(&value, font.as_deref()))?;
1275                }
1276                Frame::Other
1277            }
1278            _ => Frame::Other,
1279        };
1280        self.frames.push(frame);
1281        Ok(())
1282    }
1283
1284    fn end(
1285        &mut self,
1286        styles: Result<&StyleSheet, FormattingUnknownReason>,
1287    ) -> Result<(), ProjectionError> {
1288        let frame = self
1289            .frames
1290            .pop()
1291            .ok_or(ProjectionError::InvalidDocumentXml)?;
1292        match frame {
1293            Frame::Paragraph => {
1294                let mut paragraph = self
1295                    .current_paragraph
1296                    .take()
1297                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1298                if styles.is_ok_and(|styles| paragraph.resolve_text_base(styles).is_err()) {
1299                    self.formatting_status = FormattingProjectionStatus::Incomplete(
1300                        FormattingUnknownReason::UnsupportedStyles,
1301                    );
1302                }
1303                if styles.is_ok_and(|styles| {
1304                    styles
1305                        .paragraph_uses_numbering(&paragraph.properties)
1306                        .unwrap_or(false)
1307                }) {
1308                    // Numbering-level run properties are another formatting
1309                    // hierarchy level. Retain known spans, but do not present
1310                    // them as authoritative until that level is projected.
1311                    self.formatting_status = FormattingProjectionStatus::Incomplete(
1312                        FormattingUnknownReason::UnsupportedStyles,
1313                    );
1314                }
1315                if paragraph.structure.is_some()
1316                    && self.formatting_status == FormattingProjectionStatus::Complete
1317                {
1318                    // Table-style run properties are a distinct style-hierarchy level.
1319                    // Until that level is projected, retain best-known spans but do not
1320                    // claim that they are authoritative effective formatting.
1321                    self.formatting_status = FormattingProjectionStatus::Incomplete(
1322                        FormattingUnknownReason::UnsupportedStyles,
1323                    );
1324                }
1325                let ordinal = self.paragraphs.len();
1326                if let Some(revision) = paragraph.paragraph_mark_revision {
1327                    self.paragraph_mark_revisions.insert(ordinal, revision);
1328                }
1329                self.paragraphs.push(RawProjectedParagraph {
1330                    ordinal: self.paragraphs.len(),
1331                    package_paragraph_id: paragraph.package_paragraph_id,
1332                    text: paragraph.text,
1333                    utf16_len: paragraph.utf16_len,
1334                    formatting: paragraph.formatting,
1335                    structure: paragraph.structure,
1336                    properties: paragraph.properties,
1337                });
1338            }
1339            Frame::Hyperlink(Some(reference)) => self.references.push(reference),
1340            Frame::PseudoText(frame) => match frame.kind {
1341                PseudoTextKind::Text { preserve_space } => {
1342                    let text = if preserve_space {
1343                        frame.text.as_str()
1344                    } else {
1345                        trim_xml_whitespace(&frame.text)
1346                    };
1347                    self.append_pseudo_text(text)?;
1348                }
1349                PseudoTextKind::MathText => self.append_pseudo_text(&frame.text)?,
1350                PseudoTextKind::Instruction => self.append_field_instruction(&frame.text),
1351            },
1352            Frame::Run(run) if !run.hidden && !self.pseudo_text_is_suppressed() => {
1353                if let Some(paragraph) = self.current_paragraph.as_mut() {
1354                    let effective = match styles {
1355                        Ok(styles) => {
1356                            let resolved = if run.character_style_id.is_none()
1357                                && run.direct_styles == TextProperties::default()
1358                            {
1359                                paragraph.resolve_text_base(styles)
1360                            } else {
1361                                styles.resolve_text(
1362                                    paragraph.properties.style_id.as_deref(),
1363                                    run.character_style_id.as_deref(),
1364                                    run.direct_styles,
1365                                )
1366                            };
1367                            resolved.unwrap_or_else(|()| {
1368                                self.formatting_status = FormattingProjectionStatus::Incomplete(
1369                                    FormattingUnknownReason::UnsupportedStyles,
1370                                );
1371                                run.direct_styles
1372                            })
1373                        }
1374                        Err(_) => run.direct_styles,
1375                    };
1376                    paragraph.append(&run.text, effective)?;
1377                }
1378            }
1379            Frame::Revision(revision) => self.finish_attributed_revision(revision)?,
1380            _ => {}
1381        }
1382        Ok(())
1383    }
1384
1385    fn record_attributed_revision(
1386        &mut self,
1387        reader: &NsReader<&[u8]>,
1388        element: &BytesStart<'_>,
1389        name: &[u8],
1390    ) -> Result<Option<usize>, ProjectionError> {
1391        let Some(kind) = revision_fact_kind(name) else {
1392            return Ok(None);
1393        };
1394        let ReviewRevisionCollection::Complete {
1395            maximum_facts,
1396            revisions,
1397            ..
1398        } = &mut self.review_revisions
1399        else {
1400            return Ok(None);
1401        };
1402        if revisions.len() >= *maximum_facts {
1403            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1404            return Ok(None);
1405        }
1406        let review_index = revisions.len();
1407        revisions.push(AttributedRevision {
1408            kind,
1409            author: attribute(reader, element, b"author")?.unwrap_or_default(),
1410            date: attribute(reader, element, b"date")?,
1411            revision_id: attribute(reader, element, b"id")?,
1412            content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
1413        });
1414        Ok(Some(review_index))
1415    }
1416
1417    fn finish_attributed_revision(
1418        &mut self,
1419        revision: RevisionFrame,
1420    ) -> Result<(), ProjectionError> {
1421        let (Some(review_index), Some(start), Some(end)) = (
1422            revision.review_index,
1423            revision.start,
1424            self.current_review_point(),
1425        ) else {
1426            return Ok(());
1427        };
1428        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
1429        else {
1430            return Ok(());
1431        };
1432        let attributed = revisions
1433            .get_mut(review_index)
1434            .ok_or(ProjectionError::InvalidDocumentXml)?;
1435        attributed.content = ReviewDetail::Known(RevisionContent {
1436            span: ReviewSpan { start, end },
1437            formatting_only: revision.text.is_empty(),
1438            text: revision.text,
1439        });
1440        Ok(())
1441    }
1442
1443    fn inside_body(&self) -> bool {
1444        self.frames.iter().any(|frame| matches!(frame, Frame::Body))
1445    }
1446
1447    fn inside_textbox(&self) -> bool {
1448        self.frames
1449            .iter()
1450            .any(|frame| matches!(frame, Frame::Textbox))
1451    }
1452
1453    fn inside_change_snapshot(&self) -> bool {
1454        self.frames
1455            .iter()
1456            .any(|frame| matches!(frame, Frame::ChangeSnapshot))
1457    }
1458
1459    fn inside_paragraph_mark_properties(&self) -> bool {
1460        self.current_paragraph.is_some()
1461            && self
1462                .frames
1463                .iter()
1464                .any(|frame| matches!(frame, Frame::ParagraphProperties))
1465            && !self
1466                .frames
1467                .iter()
1468                .any(|frame| matches!(frame, Frame::Run(_)))
1469    }
1470
1471    fn inside_table_row_properties(&self) -> bool {
1472        self.frames
1473            .iter()
1474            .any(|frame| matches!(frame, Frame::TableRowProperties))
1475    }
1476
1477    fn pseudo_text_is_suppressed(&self) -> bool {
1478        self.frames.iter().any(|frame| {
1479            matches!(
1480                frame,
1481                Frame::ParagraphProperties
1482                    | Frame::RunProperties(_)
1483                    | Frame::ChangeSnapshot
1484                    | Frame::Textbox
1485            ) || matches!(frame, Frame::Revision(revision) if revision.suppressed)
1486                || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1487        })
1488    }
1489
1490    fn pseudo_text_is_structurally_suppressed(&self) -> bool {
1491        self.frames.iter().any(|frame| {
1492            matches!(
1493                frame,
1494                Frame::ParagraphProperties
1495                    | Frame::RunProperties(_)
1496                    | Frame::ChangeSnapshot
1497                    | Frame::Textbox
1498            ) || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1499        })
1500    }
1501
1502    fn append_pseudo_text(&mut self, text: &str) -> Result<(), ProjectionError> {
1503        if self.current_paragraph.is_none()
1504            || self.pseudo_text_is_structurally_suppressed()
1505            || self
1506                .frames
1507                .iter()
1508                .rev()
1509                .any(|frame| matches!(frame, Frame::Run(run) if run.hidden))
1510        {
1511            return Ok(());
1512        }
1513        self.append_review_text(text);
1514        if self.pseudo_text_is_suppressed() {
1515            return Ok(());
1516        }
1517        if let Some(run) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1518            Frame::Run(run) => Some(run),
1519            _ => None,
1520        }) {
1521            run.text.push_str(text);
1522            return Ok(());
1523        }
1524        self.current_paragraph
1525            .as_mut()
1526            .ok_or(ProjectionError::InvalidDocumentXml)?
1527            .append(text, TextProperties::default())
1528    }
1529
1530    fn append_review_text(&mut self, text: &str) {
1531        let ReviewRevisionCollection::Complete {
1532            remaining_detail_bytes,
1533            ..
1534        } = &mut self.review_revisions
1535        else {
1536            return;
1537        };
1538        let mut limit_exceeded = false;
1539        for frame in &mut self.frames {
1540            if let Frame::Revision(revision) = frame
1541                && revision.review_index.is_some()
1542            {
1543                let Some(remaining) = remaining_detail_bytes.checked_sub(text.len()) else {
1544                    limit_exceeded = true;
1545                    break;
1546                };
1547                *remaining_detail_bytes = remaining;
1548                revision.text.push_str(text);
1549            }
1550        }
1551        if limit_exceeded {
1552            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1553        }
1554    }
1555
1556    fn current_review_point(&self) -> Option<ReviewPoint> {
1557        let paragraph = self.current_paragraph.as_ref()?;
1558        let run_text = self.frames.iter().rev().find_map(|frame| match frame {
1559            Frame::Run(run) if !run.hidden && !self.pseudo_text_is_suppressed() => {
1560                Some(run.text.as_str())
1561            }
1562            _ => None,
1563        });
1564        let run_utf8 = run_text.map_or(0, str::len);
1565        let run_utf16 = run_text.map_or(0, |text| text.encode_utf16().count());
1566        Some(ReviewPoint {
1567            paragraph_ordinal: self.paragraphs.len(),
1568            utf8: u32::try_from(paragraph.text.len().checked_add(run_utf8)?).ok()?,
1569            utf16: paragraph
1570                .utf16_len
1571                .checked_add(u32::try_from(run_utf16).ok()?)?,
1572        })
1573    }
1574
1575    fn next_paragraph_start_review_point(&self) -> Option<ReviewPoint> {
1576        if self.current_paragraph.is_some() {
1577            return self.current_review_point();
1578        }
1579        self.inside_body().then_some(ReviewPoint {
1580            paragraph_ordinal: self.paragraphs.len(),
1581            utf8: 0,
1582            utf16: 0,
1583        })
1584    }
1585
1586    fn previous_paragraph_end_review_point(&self) -> Option<ReviewPoint> {
1587        if self.current_paragraph.is_some() {
1588            return self.current_review_point();
1589        }
1590        let paragraph = self.paragraphs.last()?;
1591        Some(ReviewPoint {
1592            paragraph_ordinal: paragraph.ordinal,
1593            utf8: u32::try_from(paragraph.text.len()).ok()?,
1594            utf16: paragraph.utf16_len,
1595        })
1596    }
1597
1598    fn review_comment_id(
1599        reader: &NsReader<&[u8]>,
1600        element: &BytesStart<'_>,
1601    ) -> Result<Option<String>, ProjectionError> {
1602        attribute(reader, element, b"id")
1603    }
1604
1605    fn start_review_comment_anchor(
1606        &mut self,
1607        reader: &NsReader<&[u8]>,
1608        element: &BytesStart<'_>,
1609    ) -> Result<(), ProjectionError> {
1610        let (Some(comment_id), Some(point)) = (
1611            Self::review_comment_id(reader, element)?,
1612            self.next_paragraph_start_review_point(),
1613        ) else {
1614            return Ok(());
1615        };
1616        if self.invalid_review_comment_anchors.contains(&comment_id) {
1617            return Ok(());
1618        }
1619        if self
1620            .open_review_comment_anchors
1621            .insert(comment_id.clone(), point)
1622            .is_some()
1623        {
1624            self.open_review_comment_anchors.remove(&comment_id);
1625            self.review_comment_anchors.remove(&comment_id);
1626            self.invalid_review_comment_anchors.insert(comment_id);
1627        }
1628        Ok(())
1629    }
1630
1631    fn suppress_review_comment_anchor(
1632        &mut self,
1633        reader: &NsReader<&[u8]>,
1634        element: &BytesStart<'_>,
1635    ) -> Result<(), ProjectionError> {
1636        let Some(comment_id) = Self::review_comment_id(reader, element)? else {
1637            return Ok(());
1638        };
1639        self.open_review_comment_anchors.remove(&comment_id);
1640        self.review_comment_anchors.remove(&comment_id);
1641        self.invalid_review_comment_anchors.insert(comment_id);
1642        Ok(())
1643    }
1644
1645    fn end_review_comment_anchor(
1646        &mut self,
1647        reader: &NsReader<&[u8]>,
1648        element: &BytesStart<'_>,
1649    ) -> Result<(), ProjectionError> {
1650        let (Some(comment_id), Some(end)) = (
1651            Self::review_comment_id(reader, element)?,
1652            self.previous_paragraph_end_review_point(),
1653        ) else {
1654            return Ok(());
1655        };
1656        if self.invalid_review_comment_anchors.contains(&comment_id) {
1657            return Ok(());
1658        }
1659        let Some(start) = self.open_review_comment_anchors.remove(&comment_id) else {
1660            self.review_comment_anchors.remove(&comment_id);
1661            self.invalid_review_comment_anchors.insert(comment_id);
1662            return Ok(());
1663        };
1664        if self
1665            .review_comment_anchors
1666            .insert(comment_id.clone(), ReviewSpan { start, end })
1667            .is_some()
1668        {
1669            self.review_comment_anchors.remove(&comment_id);
1670            self.invalid_review_comment_anchors.insert(comment_id);
1671        }
1672        Ok(())
1673    }
1674
1675    fn record_review_comment_reference(
1676        &mut self,
1677        reader: &NsReader<&[u8]>,
1678        element: &BytesStart<'_>,
1679    ) -> Result<(), ProjectionError> {
1680        let (Some(comment_id), Some(point)) = (
1681            Self::review_comment_id(reader, element)?,
1682            self.current_review_point(),
1683        ) else {
1684            return Ok(());
1685        };
1686        if self.invalid_review_comment_anchors.contains(&comment_id) {
1687            return Ok(());
1688        }
1689        if !self.review_comment_anchors.contains_key(&comment_id)
1690            && !self.open_review_comment_anchors.contains_key(&comment_id)
1691        {
1692            self.review_comment_anchors.insert(
1693                comment_id,
1694                ReviewSpan {
1695                    start: point,
1696                    end: point,
1697                },
1698            );
1699        }
1700        Ok(())
1701    }
1702
1703    fn append_pseudo_content(&mut self, text: &str) -> Result<(), ProjectionError> {
1704        let Some(Frame::PseudoText(frame)) = self.frames.last_mut() else {
1705            return Err(ProjectionError::InvalidDocumentXml);
1706        };
1707        frame.text.push_str(text);
1708        Ok(())
1709    }
1710
1711    fn append_text_control(&mut self, control: TextControl) -> Result<(), ProjectionError> {
1712        if let Some(text) = control.materialize(self.text_materialization) {
1713            self.append_pseudo_text(text)?;
1714        }
1715        Ok(())
1716    }
1717
1718    fn append_field_instruction(&mut self, text: &str) {
1719        let Some(field) = self.fields.last_mut() else {
1720            self.references_complete = false;
1721            return;
1722        };
1723        if field.separated || field.instruction.len().saturating_add(text.len()) > 4096 {
1724            self.references_complete = false;
1725            return;
1726        }
1727        field.instruction.push_str(text);
1728    }
1729
1730    fn simple_field(
1731        &mut self,
1732        reader: &NsReader<&[u8]>,
1733        element: &BytesStart<'_>,
1734    ) -> Result<(), ProjectionError> {
1735        let instruction = attribute(reader, element, b"instr")?;
1736        let source = self.current_point();
1737        let Some(instruction) = instruction.filter(|value| value.len() <= 4096) else {
1738            self.references_complete = false;
1739            return Ok(());
1740        };
1741        self.record_field_reference(&instruction, source);
1742        Ok(())
1743    }
1744
1745    fn field_character(
1746        &mut self,
1747        reader: &NsReader<&[u8]>,
1748        element: &BytesStart<'_>,
1749    ) -> Result<(), ProjectionError> {
1750        match attribute(reader, element, b"fldCharType")?.as_deref() {
1751            Some("begin") => self.fields.push(FieldFrame {
1752                source: self.current_field_point(),
1753                instruction: String::new(),
1754                separated: false,
1755            }),
1756            Some("separate") => {
1757                let Some(mut field) = self.fields.pop() else {
1758                    self.references_complete = false;
1759                    return Ok(());
1760                };
1761                if field.separated {
1762                    self.references_complete = false;
1763                } else {
1764                    self.record_field_reference(&field.instruction, field.source.clone());
1765                    field.separated = true;
1766                }
1767                self.fields.push(field);
1768            }
1769            Some("end") => {
1770                let Some(field) = self.fields.pop() else {
1771                    self.references_complete = false;
1772                    return Ok(());
1773                };
1774                if !field.separated {
1775                    self.record_field_reference(&field.instruction, field.source);
1776                }
1777            }
1778            _ => self.references_complete = false,
1779        }
1780        Ok(())
1781    }
1782
1783    fn current_field_point(&self) -> Option<RawBlockPoint> {
1784        let paragraph = self.current_paragraph.as_ref()?;
1785        if self.pseudo_text_is_suppressed() {
1786            return None;
1787        }
1788        if self.frames.iter().rev().find_map(|frame| match frame {
1789            Frame::Run(run) => Some(!run.text.is_empty()),
1790            _ => None,
1791        }) == Some(true)
1792        {
1793            return None;
1794        }
1795        Some(RawBlockPoint {
1796            paragraph: self.paragraphs.len(),
1797            utf8: u32::try_from(paragraph.text.len()).ok()?,
1798            utf16: paragraph.utf16_len,
1799        })
1800    }
1801
1802    fn record_field_reference(&mut self, instruction: &str, source: Option<RawBlockPoint>) {
1803        match internal_reference_target(instruction) {
1804            Ok(Some(reference_id)) => {
1805                let Some(source) = source else {
1806                    self.references_complete = false;
1807                    return;
1808                };
1809                self.references.push(RawInternalReference {
1810                    reference_id,
1811                    source,
1812                });
1813            }
1814            Ok(None) => {}
1815            Err(()) => self.references_complete = false,
1816        }
1817    }
1818
1819    fn current_point(&self) -> Option<RawBlockPoint> {
1820        if let Some(paragraph) = self.current_paragraph.as_ref() {
1821            if self.pseudo_text_is_suppressed()
1822                || self
1823                    .frames
1824                    .iter()
1825                    .any(|frame| matches!(frame, Frame::Run(_)))
1826            {
1827                return None;
1828            }
1829            return Some(RawBlockPoint {
1830                paragraph: self.paragraphs.len(),
1831                utf8: u32::try_from(paragraph.text.len()).ok()?,
1832                utf16: paragraph.utf16_len,
1833            });
1834        }
1835        if !matches!(self.frames.last(), Some(Frame::Body)) {
1836            return None;
1837        }
1838        self.paragraphs.last().map_or(
1839            Some(RawBlockPoint {
1840                paragraph: 0,
1841                utf8: 0,
1842                utf16: 0,
1843            }),
1844            |paragraph| {
1845                Some(RawBlockPoint {
1846                    paragraph: paragraph.ordinal,
1847                    utf8: u32::try_from(paragraph.text.len()).ok()?,
1848                    utf16: u32::try_from(paragraph.text.encode_utf16().count()).ok()?,
1849                })
1850            },
1851        )
1852    }
1853
1854    fn current_bookmark_point(&self) -> Option<BookmarkPoint> {
1855        if self.pseudo_text_is_suppressed() {
1856            return None;
1857        }
1858        if self.current_paragraph.is_some() {
1859            return self.current_point().map(BookmarkPoint::Paragraph);
1860        }
1861        matches!(
1862            self.frames.last(),
1863            Some(Frame::Body | Frame::Table(_) | Frame::Row(_) | Frame::Cell(_))
1864        )
1865        .then(|| BookmarkPoint::ParagraphBoundary(self.paragraphs.len()))
1866    }
1867
1868    fn start_bookmark(
1869        &mut self,
1870        reader: &NsReader<&[u8]>,
1871        element: &BytesStart<'_>,
1872    ) -> Result<(), ProjectionError> {
1873        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
1874        let name = attribute(reader, element, b"name")?;
1875        let point = self.current_bookmark_point();
1876        let (Some(id), Some(name), Some(point)) = (id, name, point) else {
1877            self.bookmarks_complete = false;
1878            return Ok(());
1879        };
1880        if name.is_empty()
1881            || !self.seen_bookmark_ids.insert(id)
1882            || self.open_bookmarks.insert(id, (name, point)).is_some()
1883        {
1884            self.bookmarks_complete = false;
1885        }
1886        Ok(())
1887    }
1888
1889    fn end_bookmark(
1890        &mut self,
1891        reader: &NsReader<&[u8]>,
1892        element: &BytesStart<'_>,
1893    ) -> Result<(), ProjectionError> {
1894        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
1895        let point = self.current_bookmark_point();
1896        let (Some(id), Some(end)) = (id, point) else {
1897            self.bookmarks_complete = false;
1898            return Ok(());
1899        };
1900        let Some((name, start)) = self.open_bookmarks.remove(&id) else {
1901            self.bookmarks_complete = false;
1902            return Ok(());
1903        };
1904        self.bookmarks.push(PendingBookmarkRange {
1905            id,
1906            name,
1907            start,
1908            end,
1909        });
1910        Ok(())
1911    }
1912
1913    fn mark_placeholder(&mut self) {
1914        let Some(sdt) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1915            Frame::Sdt(sdt) => Some(sdt),
1916            _ => None,
1917        }) else {
1918            return;
1919        };
1920        sdt.placeholder = true;
1921        if let Some(paragraph) = self.current_paragraph.as_mut() {
1922            paragraph.truncate(sdt.text_utf8_start, sdt.text_utf16_start);
1923        }
1924    }
1925
1926    fn mark_hidden_run(&mut self) {
1927        let Some((run_frame, hidden_eligible)) =
1928            self.frames.iter().rev().find_map(|frame| match frame {
1929                Frame::RunProperties(properties) => {
1930                    Some((properties.run_frame, properties.hidden_eligible))
1931                }
1932                _ => None,
1933            })
1934        else {
1935            return;
1936        };
1937        if !hidden_eligible || run_frame == usize::MAX {
1938            return;
1939        }
1940        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1941            run.hidden = true;
1942        }
1943    }
1944
1945    fn set_run_character_style(&mut self, style_id: Option<String>) {
1946        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1947            return;
1948        };
1949        let run_frame = properties.run_frame;
1950        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1951            run.character_style_id = style_id;
1952        }
1953    }
1954
1955    fn mark_bold_run(&mut self, enabled: bool) {
1956        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1957            return;
1958        };
1959        let run_frame = properties.run_frame;
1960        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1961            run.direct_styles.bold = Some(enabled);
1962        }
1963    }
1964
1965    fn mark_complex_script_bold_run(&mut self, enabled: bool) {
1966        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1967            return;
1968        };
1969        let run_frame = properties.run_frame;
1970        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1971            run.direct_styles.complex_script_bold = Some(enabled);
1972        }
1973    }
1974
1975    fn mark_complex_script_run(&mut self, enabled: bool) {
1976        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1977            return;
1978        };
1979        let run_frame = properties.run_frame;
1980        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1981            run.direct_styles.force_complex_script = Some(enabled);
1982        }
1983    }
1984
1985    fn mark_right_to_left_run(&mut self, enabled: bool) {
1986        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1987            return;
1988        };
1989        let run_frame = properties.run_frame;
1990        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1991            run.direct_styles.right_to_left = Some(enabled);
1992        }
1993    }
1994
1995    fn mark_highlighted_run(&mut self, enabled: bool) {
1996        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1997            return;
1998        };
1999        let run_frame = properties.run_frame;
2000        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2001            run.direct_styles.highlighted = Some(enabled);
2002        }
2003    }
2004
2005    fn mark_superscript_run(&mut self, enabled: bool) {
2006        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
2007            return;
2008        };
2009        let run_frame = properties.run_frame;
2010        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
2011            run.direct_styles.superscript = Some(enabled);
2012        }
2013    }
2014}
2015
2016const fn revision_fact_kind(name: &[u8]) -> Option<RevisionFactKind> {
2017    match name {
2018        b"ins" => Some(RevisionFactKind::Insertion),
2019        b"del" => Some(RevisionFactKind::Deletion),
2020        b"moveFrom" => Some(RevisionFactKind::MoveFrom),
2021        b"moveTo" => Some(RevisionFactKind::MoveTo),
2022        b"cellIns" => Some(RevisionFactKind::CellInsertion),
2023        b"cellDel" => Some(RevisionFactKind::CellDeletion),
2024        b"cellMerge" => Some(RevisionFactKind::CellMerge),
2025        b"pPrChange" => Some(RevisionFactKind::ParagraphPropertiesChange),
2026        b"rPrChange" => Some(RevisionFactKind::RunPropertiesChange),
2027        b"sectPrChange" => Some(RevisionFactKind::SectionPropertiesChange),
2028        b"tblPrChange" => Some(RevisionFactKind::TablePropertiesChange),
2029        b"trPrChange" => Some(RevisionFactKind::TableRowPropertiesChange),
2030        b"tcPrChange" => Some(RevisionFactKind::TableCellPropertiesChange),
2031        b"tblGridChange" => Some(RevisionFactKind::TableGridChange),
2032        b"customXmlDelRangeStart" => Some(RevisionFactKind::CustomXmlDeletionRangeStart),
2033        b"customXmlDelRangeEnd" => Some(RevisionFactKind::CustomXmlDeletionRangeEnd),
2034        b"customXmlInsRangeStart" => Some(RevisionFactKind::CustomXmlInsertionRangeStart),
2035        b"customXmlInsRangeEnd" => Some(RevisionFactKind::CustomXmlInsertionRangeEnd),
2036        b"customXmlMoveFromRangeStart" => Some(RevisionFactKind::CustomXmlMoveFromRangeStart),
2037        b"customXmlMoveFromRangeEnd" => Some(RevisionFactKind::CustomXmlMoveFromRangeEnd),
2038        b"customXmlMoveToRangeStart" => Some(RevisionFactKind::CustomXmlMoveToRangeStart),
2039        b"customXmlMoveToRangeEnd" => Some(RevisionFactKind::CustomXmlMoveToRangeEnd),
2040        _ => None,
2041    }
2042}
2043
2044fn enclosing_table(frames: &mut [Frame]) -> Option<&mut TableFrame> {
2045    for frame in frames.iter_mut().rev() {
2046        match frame {
2047            Frame::Table(table) => return Some(table),
2048            Frame::Row(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2049            _ => {}
2050        }
2051    }
2052    None
2053}
2054
2055fn enclosing_row(frames: &mut [Frame]) -> Option<&mut RowFrame> {
2056    for frame in frames.iter_mut().rev() {
2057        match frame {
2058            Frame::Row(row) => return Some(row),
2059            Frame::Table(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
2060            _ => {}
2061        }
2062    }
2063    None
2064}
2065
2066fn revision_is_suppressed(name: &[u8], view: RevisionView) -> bool {
2067    match view {
2068        RevisionView::Current => matches!(name, b"del" | b"moveFrom"),
2069        RevisionView::Original => matches!(name, b"ins" | b"moveTo"),
2070    }
2071}
2072
2073fn is_change_snapshot(name: &[u8]) -> bool {
2074    matches!(
2075        name,
2076        b"pPrChange"
2077            | b"rPrChange"
2078            | b"sectPrChange"
2079            | b"tblPrChange"
2080            | b"trPrChange"
2081            | b"tcPrChange"
2082            | b"tblGridChange"
2083    )
2084}
2085
2086fn is_unsupported_revision_markup(name: &[u8]) -> bool {
2087    matches!(
2088        name,
2089        b"customXmlDelRangeStart"
2090            | b"customXmlDelRangeEnd"
2091            | b"customXmlInsRangeStart"
2092            | b"customXmlInsRangeEnd"
2093            | b"customXmlMoveFromRangeStart"
2094            | b"customXmlMoveFromRangeEnd"
2095            | b"customXmlMoveToRangeStart"
2096            | b"customXmlMoveToRangeEnd"
2097    )
2098}
2099
2100const fn paragraph_break_is_removed(
2101    revision: Option<ParagraphMarkRevision>,
2102    view: RevisionView,
2103) -> bool {
2104    matches!(
2105        (revision, view),
2106        (Some(ParagraphMarkRevision::Deletion), RevisionView::Current)
2107            | (
2108                Some(ParagraphMarkRevision::Insertion),
2109                RevisionView::Original
2110            )
2111    )
2112}
2113
2114fn normalize_paragraph_revision_view(
2115    paragraphs: &mut Vec<RawProjectedParagraph>,
2116    paragraph_mark_revisions: &mut HashMap<usize, ParagraphMarkRevision>,
2117    view: RevisionView,
2118    unsupported: &mut BTreeSet<RevisionUnsupportedReason>,
2119) -> Result<bool, ProjectionError> {
2120    let mut normalized: Vec<RawProjectedParagraph> = Vec::with_capacity(paragraphs.len());
2121    let mut merge_previous = false;
2122    let mut merged_any = false;
2123
2124    for (source_ordinal, mut paragraph) in std::mem::take(paragraphs).into_iter().enumerate() {
2125        let merge_next =
2126            paragraph_break_is_removed(paragraph_mark_revisions.remove(&source_ordinal), view);
2127        if merge_previous {
2128            let previous = normalized
2129                .last_mut()
2130                .ok_or(ProjectionError::InvalidDocumentXml)?;
2131            if previous.structure != paragraph.structure {
2132                unsupported.insert(RevisionUnsupportedReason::IncompatibleParagraphMerge);
2133                paragraph.ordinal = normalized.len();
2134                normalized.push(paragraph);
2135                merge_previous = merge_next;
2136                continue;
2137            }
2138            let utf16_offset = previous.utf16_len;
2139            for mut span in paragraph.formatting {
2140                span.start_utf16 = span
2141                    .start_utf16
2142                    .checked_add(utf16_offset)
2143                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2144                span.end_utf16 = span
2145                    .end_utf16
2146                    .checked_add(utf16_offset)
2147                    .ok_or(ProjectionError::InvalidDocumentXml)?;
2148                previous.formatting.push(span);
2149            }
2150            previous.text.push_str(&paragraph.text);
2151            previous.utf16_len = previous
2152                .utf16_len
2153                .checked_add(paragraph.utf16_len)
2154                .ok_or(ProjectionError::InvalidDocumentXml)?;
2155            merge_previous = merge_next;
2156            merged_any = true;
2157            continue;
2158        }
2159
2160        paragraph.ordinal = normalized.len();
2161        normalized.push(paragraph);
2162        merge_previous = merge_next;
2163    }
2164
2165    *paragraphs = normalized;
2166    Ok(merged_any)
2167}
2168
2169fn internal_reference_target(instruction: &str) -> Result<Option<String>, ()> {
2170    let tokens = field_tokens(instruction)?;
2171    let Some(command) = tokens.first() else {
2172        return Err(());
2173    };
2174    if ["REF", "PAGEREF", "NOTEREF"]
2175        .iter()
2176        .any(|candidate| command.eq_ignore_ascii_case(candidate))
2177    {
2178        return tokens
2179            .get(1)
2180            .filter(|target| !target.is_empty() && !target.starts_with('\\'))
2181            .cloned()
2182            .map(Some)
2183            .ok_or(());
2184    }
2185    if command.eq_ignore_ascii_case("HYPERLINK") {
2186        for (index, token) in tokens.iter().enumerate().skip(1) {
2187            if token.eq_ignore_ascii_case("\\l") {
2188                return tokens
2189                    .get(index.checked_add(1).ok_or(())?)
2190                    .filter(|target| !target.is_empty())
2191                    .cloned()
2192                    .map(Some)
2193                    .ok_or(());
2194            }
2195        }
2196    }
2197    Ok(None)
2198}
2199
2200fn field_tokens(instruction: &str) -> Result<Vec<String>, ()> {
2201    let mut tokens = Vec::new();
2202    let mut token = String::new();
2203    let mut quoted = false;
2204    for character in instruction.chars() {
2205        match character {
2206            '"' => quoted = !quoted,
2207            whitespace if whitespace.is_whitespace() && !quoted => {
2208                if !token.is_empty() {
2209                    tokens.push(std::mem::take(&mut token));
2210                }
2211            }
2212            _ => token.push(character),
2213        }
2214    }
2215    if quoted {
2216        return Err(());
2217    }
2218    if !token.is_empty() {
2219        tokens.push(token);
2220    }
2221    Ok(tokens)
2222}
2223
2224fn attribute(
2225    reader: &NsReader<&[u8]>,
2226    element: &BytesStart<'_>,
2227    name: &[u8],
2228) -> Result<Option<String>, ProjectionError> {
2229    for attribute in element.attributes() {
2230        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2231        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2232        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing
2233            && local_name.as_ref() == name
2234        {
2235            return attribute
2236                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2237                .map(|value| Some(value.into_owned()))
2238                .map_err(|_| ProjectionError::InvalidDocumentXml);
2239        }
2240    }
2241    Ok(None)
2242}
2243
2244fn preserves_xml_space(
2245    reader: &NsReader<&[u8]>,
2246    element: &BytesStart<'_>,
2247) -> Result<bool, ProjectionError> {
2248    const XML_NAMESPACE: &[u8] = b"http://www.w3.org/XML/1998/namespace";
2249
2250    for attribute in element.attributes() {
2251        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2252        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2253        if matches!(
2254            namespace,
2255            quick_xml::name::ResolveResult::Bound(namespace)
2256                if namespace.as_ref() == XML_NAMESPACE
2257        ) && local_name.as_ref() == b"space"
2258        {
2259            let value = attribute
2260                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2261                .map_err(|_| ProjectionError::InvalidDocumentXml)?;
2262            return match value.as_ref() {
2263                "default" => Ok(false),
2264                "preserve" => Ok(true),
2265                _ => Err(ProjectionError::InvalidDocumentXml),
2266            };
2267        }
2268    }
2269    Ok(false)
2270}
2271
2272fn on_off_element_enabled(
2273    reader: &NsReader<&[u8]>,
2274    element: &BytesStart<'_>,
2275) -> Result<bool, ProjectionError> {
2276    Ok(attribute(reader, element, b"val")?
2277        .is_none_or(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")))
2278}
2279
2280fn trim_xml_whitespace(value: &str) -> &str {
2281    value.trim_matches(['\u{0009}', '\u{000a}', '\u{000d}', '\u{0020}'])
2282}
2283
2284fn attribute_2010(
2285    reader: &NsReader<&[u8]>,
2286    element: &BytesStart<'_>,
2287    name: &[u8],
2288) -> Result<Option<String>, ProjectionError> {
2289    for attribute in element.attributes() {
2290        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
2291        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2292        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing2010
2293            && local_name.as_ref() == name
2294        {
2295            return attribute
2296                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
2297                .map(|value| Some(value.into_owned()))
2298                .map_err(|_| ProjectionError::InvalidDocumentXml);
2299        }
2300    }
2301    Ok(None)
2302}
2303
2304fn symbol_text(value: &str, font: Option<&str>) -> String {
2305    let prefix = value
2306        .chars()
2307        .take_while(char::is_ascii_hexdigit)
2308        .collect::<String>();
2309    let Some(code) = (!prefix.is_empty())
2310        .then(|| u32::from_str_radix(&prefix, 16).ok())
2311        .flatten()
2312    else {
2313        return value.to_owned();
2314    };
2315    if let Some(replacement) = normalized_font_symbol(font, code) {
2316        return replacement.to_owned();
2317    }
2318    char::from_u32(code).unwrap_or('\u{fffd}').to_string()
2319}
2320
2321struct FontSymbolMapping {
2322    font: &'static str,
2323    encoded: u32,
2324    unicode: &'static str,
2325}
2326
2327// OOXML resolves w:sym through its named symbol font rather than the run font:
2328// https://learn.microsoft.com/dotnet/api/documentformat.openxml.wordprocessing.symbolchar
2329// Unicode's Wingdings mapping identifies the corresponding black-circle glyph:
2330// https://www.unicode.org/L2/L2011/11196-n4022-wingdings.pdf
2331// Keep this compatibility table explicit; unsupported font/code pairs preserve
2332// their encoded value instead of guessing from the glyph shape.
2333const FONT_SYMBOL_MAPPINGS: &[FontSymbolMapping] = &[FontSymbolMapping {
2334    font: "Wingdings",
2335    encoded: 0xF06C,
2336    unicode: "●",
2337}];
2338
2339fn normalized_font_symbol(font: Option<&str>, encoded: u32) -> Option<&'static str> {
2340    let font = font?;
2341    FONT_SYMBOL_MAPPINGS
2342        .iter()
2343        .find(|mapping| mapping.encoded == encoded && mapping.font.eq_ignore_ascii_case(font))
2344        .map(|mapping| mapping.unicode)
2345}