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;
7
8use crate::ProjectionError;
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,
18};
19use crate::projection::styles::{parse_indentation, parse_level_attribute, parse_u32_attribute};
20
21#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
22pub struct PackageParagraphId(u32);
23
24impl PackageParagraphId {
25    #[must_use]
26    pub fn parse(value: &str) -> Option<Self> {
27        if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
28            return None;
29        }
30        let value = u32::from_str_radix(value, 16).ok()?;
31        (value != 0 && i32::try_from(value).is_ok()).then_some(Self(value))
32    }
33
34    #[must_use]
35    pub const fn value(self) -> u32 {
36        self.0
37    }
38}
39
40#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
41pub enum TextStyle {
42    Bold,
43    Highlight,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct TextFormattingSpan {
48    pub start_utf16: u32,
49    pub end_utf16: u32,
50    pub style: TextStyle,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct ParagraphStructure {
55    pub table_ordinal: usize,
56    pub row: usize,
57    pub column: usize,
58}
59
60#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
61pub enum RevisionView {
62    #[default]
63    Current,
64    Original,
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
68pub enum TextMaterialization {
69    #[default]
70    WordHost,
71    ReadablePlainText,
72}
73
74#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
75pub enum RevisionUnsupportedReason {
76    IncompatibleParagraphMerge,
77    StructuralTableRevision,
78    UnsupportedRevisionMarkup,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub enum RevisionProjectionStatus {
83    Complete,
84    Incomplete(Vec<RevisionUnsupportedReason>),
85}
86
87impl RevisionProjectionStatus {
88    #[must_use]
89    pub const fn is_complete(&self) -> bool {
90        matches!(self, Self::Complete)
91    }
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95enum ParagraphMarkRevision {
96    Insertion,
97    Deletion,
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub(super) enum TextControl {
102    Tab,
103    LineBreak,
104    PageBreak,
105    ColumnBreak,
106    CarriageReturn,
107    FootnoteReference,
108    SoftHyphen,
109    NoBreakHyphen,
110}
111
112impl TextControl {
113    pub(super) const fn materialize(
114        self,
115        materialization: TextMaterialization,
116    ) -> Option<&'static str> {
117        match materialization {
118            TextMaterialization::WordHost => Some(match self {
119                Self::Tab => "\t",
120                Self::LineBreak => "\u{000b}",
121                Self::PageBreak => "\u{000c}",
122                Self::ColumnBreak => "\u{000e}",
123                Self::CarriageReturn => "\r",
124                Self::FootnoteReference => "\u{0002}",
125                Self::SoftHyphen => "\u{001f}",
126                Self::NoBreakHyphen => "\u{001e}",
127            }),
128            TextMaterialization::ReadablePlainText => match self {
129                Self::Tab => Some("\t"),
130                Self::LineBreak | Self::PageBreak | Self::ColumnBreak | Self::CarriageReturn => {
131                    Some("\n")
132                }
133                Self::FootnoteReference => None,
134                Self::SoftHyphen => Some("\u{00ad}"),
135                Self::NoBreakHyphen => Some("\u{2011}"),
136            },
137        }
138    }
139}
140
141pub(super) struct RawProjectedParagraph {
142    pub ordinal: usize,
143    pub package_paragraph_id: Option<PackageParagraphId>,
144    pub text: String,
145    utf16_len: u32,
146    pub formatting: Vec<TextFormattingSpan>,
147    pub structure: Option<ParagraphStructure>,
148    pub properties: ParagraphProperties,
149}
150
151pub(super) struct RawDocumentProjection {
152    pub paragraphs: Vec<RawProjectedParagraph>,
153    pub bookmarks: Result<Vec<RawBookmarkRange>, StructuralFactUnknownReason>,
154    pub references: Result<Vec<RawInternalReference>, StructuralFactUnknownReason>,
155    pub revision_status: RevisionProjectionStatus,
156    pub review_revisions: Option<ReviewFactSet<AttributedRevision>>,
157    pub review_comment_anchors: HashMap<String, ReviewSpan>,
158}
159
160struct ParagraphBuilder {
161    package_paragraph_id: Option<PackageParagraphId>,
162    text: String,
163    utf16_len: u32,
164    formatting: Vec<TextFormattingSpan>,
165    structure: Option<ParagraphStructure>,
166    properties: ParagraphProperties,
167    paragraph_mark_revision: Option<ParagraphMarkRevision>,
168}
169
170impl ParagraphBuilder {
171    fn append(&mut self, text: &str, bold: bool, highlighted: bool) -> Result<(), ProjectionError> {
172        let units = u32::try_from(text.encode_utf16().count())
173            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
174        let end = self
175            .utf16_len
176            .checked_add(units)
177            .ok_or(ProjectionError::InvalidDocumentXml)?;
178        if bold {
179            self.append_style(end, TextStyle::Bold);
180        }
181        if highlighted {
182            self.append_style(end, TextStyle::Highlight);
183        }
184        self.text.push_str(text);
185        self.utf16_len = end;
186        Ok(())
187    }
188
189    fn append_style(&mut self, end_utf16: u32, style: TextStyle) {
190        if self.utf16_len >= end_utf16 {
191            return;
192        }
193        if let Some(previous) = self
194            .formatting
195            .iter_mut()
196            .rev()
197            .find(|span| span.style == style)
198            && previous.end_utf16 == self.utf16_len
199        {
200            previous.end_utf16 = end_utf16;
201            return;
202        }
203        self.formatting.push(TextFormattingSpan {
204            start_utf16: self.utf16_len,
205            end_utf16,
206            style,
207        });
208    }
209
210    fn truncate(&mut self, utf8_len: usize, utf16_len: u32) {
211        self.text.truncate(utf8_len);
212        self.utf16_len = utf16_len;
213        self.formatting.retain(|span| span.start_utf16 < utf16_len);
214        for span in &mut self.formatting {
215            span.end_utf16 = span.end_utf16.min(utf16_len);
216        }
217    }
218}
219
220struct RunFrame {
221    text: String,
222    bold: bool,
223    highlighted: bool,
224    hidden: bool,
225    direct_child_count: usize,
226}
227
228struct RunPropertiesFrame {
229    run_frame: usize,
230    hidden_eligible: bool,
231}
232
233struct TableFrame {
234    ordinal: usize,
235    next_row: usize,
236}
237
238struct RowFrame {
239    table_ordinal: usize,
240    row: usize,
241    next_column: usize,
242}
243
244#[derive(Clone, Copy)]
245struct CellFrame {
246    table_ordinal: usize,
247    row: usize,
248    column: usize,
249}
250
251struct SdtFrame {
252    placeholder: bool,
253    text_utf8_start: usize,
254    text_utf16_start: u32,
255}
256
257#[derive(Clone, Copy)]
258enum PseudoTextKind {
259    Text { preserve_space: bool },
260    Instruction,
261}
262
263struct PseudoTextFrame {
264    kind: PseudoTextKind,
265    text: String,
266}
267
268impl PseudoTextFrame {
269    const fn text(preserve_space: bool) -> Self {
270        Self {
271            kind: PseudoTextKind::Text { preserve_space },
272            text: String::new(),
273        }
274    }
275
276    const fn instruction() -> Self {
277        Self {
278            kind: PseudoTextKind::Instruction,
279            text: String::new(),
280        }
281    }
282}
283
284struct FieldFrame {
285    source: Option<RawBlockPoint>,
286    instruction: String,
287    separated: bool,
288}
289
290struct RevisionFrame {
291    review_index: Option<usize>,
292    start: Option<ReviewPoint>,
293    suppressed: bool,
294    text: String,
295}
296
297#[derive(Default)]
298enum ReviewRevisionCollection {
299    #[default]
300    Disabled,
301    Complete {
302        maximum_facts: usize,
303        remaining_detail_bytes: usize,
304        revisions: Vec<AttributedRevision>,
305    },
306    LimitExceeded,
307}
308
309#[derive(Clone, Copy)]
310pub(super) struct ReviewProjectionLimits {
311    pub maximum_facts: usize,
312    pub maximum_detail_bytes: usize,
313}
314
315enum Frame {
316    Other,
317    Body,
318    Table(TableFrame),
319    Row(RowFrame),
320    Cell(CellFrame),
321    Paragraph,
322    Run(RunFrame),
323    RunProperties(RunPropertiesFrame),
324    ParagraphProperties,
325    TableRowProperties,
326    TableCellProperties,
327    NumberingProperties,
328    Hyperlink(Option<RawInternalReference>),
329    Revision(RevisionFrame),
330    ChangeSnapshot,
331    Textbox,
332    Sdt(SdtFrame),
333    PseudoText(PseudoTextFrame),
334}
335
336#[allow(clippy::too_many_lines)] // A single streaming event loop keeps XML parsing one-pass.
337pub(super) fn project_document_xml(
338    xml: &[u8],
339    maximum_paragraphs: usize,
340    revision_view: RevisionView,
341    text_materialization: TextMaterialization,
342    review_limits: Option<ReviewProjectionLimits>,
343) -> Result<RawDocumentProjection, ProjectionError> {
344    let mut reader = NsReader::from_reader(xml);
345    reader.config_mut().check_end_names = true;
346    let mut state = ProjectionState {
347        maximum_paragraphs,
348        revision_view,
349        text_materialization,
350        bookmarks_complete: true,
351        references_complete: true,
352        review_revisions: review_limits.map_or(ReviewRevisionCollection::Disabled, |limits| {
353            ReviewRevisionCollection::Complete {
354                maximum_facts: limits.maximum_facts,
355                remaining_detail_bytes: limits.maximum_detail_bytes,
356                revisions: Vec::new(),
357            }
358        }),
359        ..ProjectionState::default()
360    };
361    let mut compatibility = MarkupCompatibility::default();
362    loop {
363        match reader
364            .read_event()
365            .map_err(|_| ProjectionError::InvalidDocumentXml)?
366        {
367            Event::Start(element) => {
368                let (namespace, _) = reader.resolver().resolve_element(element.name());
369                let namespace = OoxmlNamespace::from_resolved(&namespace);
370                if compatibility.start(&reader, namespace, &element)?
371                    == CompatibilityAction::Process
372                {
373                    state.start(&reader, namespace, &element)?;
374                }
375            }
376            Event::Empty(element) => {
377                let (namespace, _) = reader.resolver().resolve_element(element.name());
378                let namespace = OoxmlNamespace::from_resolved(&namespace);
379                if compatibility.empty(&reader, namespace, &element)?
380                    == CompatibilityAction::Process
381                {
382                    state.start(&reader, namespace, &element)?;
383                    state.end()?;
384                }
385            }
386            Event::Text(text) => {
387                if !compatibility.is_suppressed()
388                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
389                {
390                    let decoded = text
391                        .xml10_content()
392                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
393                    state.append_pseudo_content(&decoded)?;
394                }
395            }
396            Event::CData(text) => {
397                if !compatibility.is_suppressed()
398                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
399                {
400                    let decoded = text
401                        .xml10_content()
402                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
403                    state.append_pseudo_content(&decoded)?;
404                }
405            }
406            Event::GeneralRef(reference) => {
407                if !compatibility.is_suppressed()
408                    && matches!(state.frames.last(), Some(Frame::PseudoText(_)))
409                {
410                    let resolved_character = reference
411                        .resolve_char_ref()
412                        .map_err(|_| ProjectionError::InvalidDocumentXml)?;
413                    let resolved = if let Some(character) = resolved_character {
414                        character.to_string()
415                    } else {
416                        let name = reference
417                            .decode()
418                            .map_err(|_| ProjectionError::InvalidDocumentXml)?;
419                        resolve_predefined_entity(&name)
420                            .ok_or(ProjectionError::InvalidDocumentXml)?
421                            .to_owned()
422                    };
423                    state.append_pseudo_content(&resolved)?;
424                }
425            }
426            Event::End(element) => {
427                let (namespace, local_name) = reader.resolver().resolve_element(element.name());
428                if compatibility.end(
429                    OoxmlNamespace::from_resolved(&namespace),
430                    local_name.as_ref(),
431                )? == CompatibilityAction::Process
432                {
433                    state.end()?;
434                }
435            }
436            Event::Eof => break,
437            _ => {}
438        }
439    }
440    if !state.body_seen {
441        return Err(ProjectionError::MissingDocumentBody);
442    }
443    if state.current_paragraph.is_some() || !state.frames.is_empty() || !compatibility.is_complete()
444    {
445        return Err(ProjectionError::InvalidDocumentXml);
446    }
447    let paragraph_merges = if state.paragraph_mark_revisions.is_empty() {
448        false
449    } else {
450        normalize_paragraph_revision_view(
451            &mut state.paragraphs,
452            &mut state.paragraph_mark_revisions,
453            state.revision_view,
454            &mut state.revision_unsupported,
455        )?
456    };
457    if paragraph_merges {
458        state.bookmarks_complete = false;
459        state.references_complete = false;
460        state.review_comment_anchors.clear();
461        if let ReviewRevisionCollection::Complete { revisions, .. } = &mut state.review_revisions {
462            for revision in revisions {
463                revision.content =
464                    ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation);
465            }
466        }
467    }
468    let bookmarks = if state.bookmarks_complete
469        && state.open_bookmarks.is_empty()
470        && (state.bookmarks.is_empty() || !state.paragraphs.is_empty())
471    {
472        Ok(state.bookmarks)
473    } else {
474        Err(StructuralFactUnknownReason::IncompleteBookmarkRanges)
475    };
476    let references = if state.references_complete && state.fields.is_empty() {
477        Ok(state.references)
478    } else {
479        Err(StructuralFactUnknownReason::UnsupportedInternalReferences)
480    };
481    Ok(RawDocumentProjection {
482        paragraphs: state.paragraphs,
483        bookmarks,
484        references,
485        revision_status: if state.revision_unsupported.is_empty() {
486            RevisionProjectionStatus::Complete
487        } else {
488            RevisionProjectionStatus::Incomplete(state.revision_unsupported.into_iter().collect())
489        },
490        review_revisions: match state.review_revisions {
491            ReviewRevisionCollection::Disabled => None,
492            ReviewRevisionCollection::Complete { revisions, .. } => {
493                Some(ReviewFactSet::Known(revisions))
494            }
495            ReviewRevisionCollection::LimitExceeded => Some(ReviewFactSet::Unknown(
496                ReviewFactUnknownReason::ResourceLimit,
497            )),
498        },
499        review_comment_anchors: state.review_comment_anchors,
500    })
501}
502
503#[derive(Default)]
504struct ProjectionState {
505    frames: Vec<Frame>,
506    paragraphs: Vec<RawProjectedParagraph>,
507    current_paragraph: Option<ParagraphBuilder>,
508    body_seen: bool,
509    next_table_ordinal: usize,
510    maximum_paragraphs: usize,
511    open_bookmarks: HashMap<u32, (String, RawBlockPoint)>,
512    seen_bookmark_ids: HashSet<u32>,
513    bookmarks: Vec<RawBookmarkRange>,
514    references: Vec<RawInternalReference>,
515    bookmarks_complete: bool,
516    references_complete: bool,
517    fields: Vec<FieldFrame>,
518    paragraph_mark_revisions: HashMap<usize, ParagraphMarkRevision>,
519    revision_view: RevisionView,
520    text_materialization: TextMaterialization,
521    revision_unsupported: BTreeSet<RevisionUnsupportedReason>,
522    review_revisions: ReviewRevisionCollection,
523    open_review_comment_anchors: HashMap<String, ReviewPoint>,
524    review_comment_anchors: HashMap<String, ReviewSpan>,
525    invalid_review_comment_anchors: HashSet<String>,
526}
527
528impl ProjectionState {
529    #[allow(clippy::too_many_lines)] // The frame transition table is one streaming state machine.
530    fn start(
531        &mut self,
532        reader: &NsReader<&[u8]>,
533        namespace: OoxmlNamespace,
534        element: &BytesStart<'_>,
535    ) -> Result<(), ProjectionError> {
536        let local_name = element.local_name();
537        let name = if namespace == OoxmlNamespace::Wordprocessing {
538            local_name.as_ref()
539        } else {
540            b""
541        };
542        let direct_run_child = if let Some(Frame::Run(run)) = self.frames.last_mut() {
543            let child = run.direct_child_count;
544            run.direct_child_count = run
545                .direct_child_count
546                .checked_add(1)
547                .ok_or(ProjectionError::InvalidDocumentXml)?;
548            let frame_index = self
549                .frames
550                .len()
551                .checked_sub(1)
552                .ok_or(ProjectionError::InvalidDocumentXml)?;
553            Some((frame_index, child))
554        } else {
555            None
556        };
557
558        if name == b"body" {
559            if self.body_seen {
560                return Err(ProjectionError::InvalidDocumentXml);
561            }
562            self.body_seen = true;
563            self.frames.push(Frame::Body);
564            return Ok(());
565        }
566        if !self.inside_body() {
567            self.frames.push(Frame::Other);
568            return Ok(());
569        }
570        if self.inside_change_snapshot() {
571            self.frames.push(Frame::Other);
572            return Ok(());
573        }
574        let attributed_revision = self.record_attributed_revision(reader, element, name)?;
575        if name == b"txbxContent" {
576            self.frames.push(Frame::Textbox);
577            return Ok(());
578        }
579        if self.inside_textbox() {
580            if matches!(
581                name,
582                b"commentRangeStart" | b"commentRangeEnd" | b"commentReference"
583            ) {
584                self.suppress_review_comment_anchor(reader, element)?;
585            }
586            self.frames.push(Frame::Other);
587            return Ok(());
588        }
589        match name {
590            b"commentRangeStart" => self.start_review_comment_anchor(reader, element)?,
591            b"commentRangeEnd" => self.end_review_comment_anchor(reader, element)?,
592            b"commentReference" => self.record_review_comment_reference(reader, element)?,
593            _ => {}
594        }
595        if is_change_snapshot(name) {
596            if self.revision_view == RevisionView::Original {
597                self.revision_unsupported
598                    .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
599            }
600            self.frames.push(Frame::ChangeSnapshot);
601            return Ok(());
602        }
603        if is_unsupported_revision_markup(name) {
604            self.revision_unsupported
605                .insert(RevisionUnsupportedReason::UnsupportedRevisionMarkup);
606            self.frames.push(Frame::Other);
607            return Ok(());
608        }
609
610        let frame = match name {
611            b"tbl" => {
612                let ordinal = self.next_table_ordinal;
613                self.next_table_ordinal = self
614                    .next_table_ordinal
615                    .checked_add(1)
616                    .ok_or(ProjectionError::InvalidDocumentXml)?;
617                Frame::Table(TableFrame {
618                    ordinal,
619                    next_row: 0,
620                })
621            }
622            b"tr" => {
623                let Some(table) = enclosing_table(&mut self.frames) else {
624                    self.frames.push(Frame::Other);
625                    return Ok(());
626                };
627                let row = table.next_row;
628                table.next_row = table
629                    .next_row
630                    .checked_add(1)
631                    .ok_or(ProjectionError::InvalidDocumentXml)?;
632                Frame::Row(RowFrame {
633                    table_ordinal: table.ordinal,
634                    row,
635                    next_column: 0,
636                })
637            }
638            b"tc" => {
639                let Some(row) = enclosing_row(&mut self.frames) else {
640                    self.frames.push(Frame::Other);
641                    return Ok(());
642                };
643                let column = row.next_column;
644                row.next_column = row
645                    .next_column
646                    .checked_add(1)
647                    .ok_or(ProjectionError::InvalidDocumentXml)?;
648                Frame::Cell(CellFrame {
649                    table_ordinal: row.table_ordinal,
650                    row: row.row,
651                    column,
652                })
653            }
654            b"p" => {
655                if self.current_paragraph.is_some() {
656                    self.frames.push(Frame::Other);
657                    return Ok(());
658                }
659                if self.paragraphs.len() >= self.maximum_paragraphs {
660                    return Err(ProjectionError::TooManyParagraphs);
661                }
662                let package_paragraph_id = attribute_2010(reader, element, b"paraId")?
663                    .as_deref()
664                    .and_then(PackageParagraphId::parse);
665                let structure = self.frames.iter().rev().find_map(|frame| match frame {
666                    Frame::Cell(cell) => Some(ParagraphStructure {
667                        table_ordinal: cell.table_ordinal,
668                        row: cell.row,
669                        column: cell.column,
670                    }),
671                    _ => None,
672                });
673                self.current_paragraph = Some(ParagraphBuilder {
674                    package_paragraph_id,
675                    text: String::new(),
676                    utf16_len: 0,
677                    formatting: Vec::new(),
678                    structure,
679                    properties: ParagraphProperties::default(),
680                    paragraph_mark_revision: None,
681                });
682                Frame::Paragraph
683            }
684            b"r" if self.current_paragraph.is_some() => Frame::Run(RunFrame {
685                text: String::new(),
686                bold: false,
687                highlighted: false,
688                hidden: false,
689                direct_child_count: 0,
690            }),
691            b"rPr" => {
692                let Some((run_frame, child_index)) = direct_run_child else {
693                    self.frames.push(Frame::RunProperties(RunPropertiesFrame {
694                        run_frame: usize::MAX,
695                        hidden_eligible: false,
696                    }));
697                    return Ok(());
698                };
699                Frame::RunProperties(RunPropertiesFrame {
700                    run_frame,
701                    hidden_eligible: child_index == 0,
702                })
703            }
704            b"pPr" => Frame::ParagraphProperties,
705            b"trPr" => Frame::TableRowProperties,
706            b"tcPr" => Frame::TableCellProperties,
707            b"pStyle" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
708                if let Some(paragraph) = self.current_paragraph.as_mut() {
709                    paragraph.properties.style_id = attribute(reader, element, b"val")?;
710                }
711                Frame::Other
712            }
713            b"ind" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
714                if let Some(paragraph) = self.current_paragraph.as_mut() {
715                    paragraph.properties.indentation = parse_indentation(reader, element)?;
716                }
717                Frame::Other
718            }
719            b"numPr" if matches!(self.frames.last(), Some(Frame::ParagraphProperties)) => {
720                if let Some(paragraph) = self.current_paragraph.as_mut() {
721                    paragraph.properties.numbering.present = true;
722                }
723                Frame::NumberingProperties
724            }
725            b"numId" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
726                if let Some(paragraph) = self.current_paragraph.as_mut() {
727                    paragraph.properties.numbering.num_id =
728                        Some(parse_u32_attribute(reader, element)?);
729                }
730                Frame::Other
731            }
732            b"ilvl" if matches!(self.frames.last(), Some(Frame::NumberingProperties)) => {
733                if let Some(paragraph) = self.current_paragraph.as_mut() {
734                    paragraph.properties.numbering.level =
735                        Some(parse_level_attribute(reader, element)?);
736                }
737                Frame::Other
738            }
739            b"bookmarkStart" => {
740                self.start_bookmark(reader, element)?;
741                Frame::Other
742            }
743            b"bookmarkEnd" => {
744                self.end_bookmark(reader, element)?;
745                Frame::Other
746            }
747            b"hyperlink" => {
748                let anchor = attribute(reader, element, b"anchor")?;
749                let reference =
750                    anchor
751                        .clone()
752                        .filter(|anchor| !anchor.is_empty())
753                        .and_then(|anchor| {
754                            self.current_point().map(|source| RawInternalReference {
755                                reference_id: anchor,
756                                source,
757                            })
758                        });
759                if anchor.is_some() && reference.is_none() {
760                    self.references_complete = false;
761                }
762                Frame::Hyperlink(reference)
763            }
764            b"fldSimple" => {
765                self.simple_field(reader, element)?;
766                Frame::Other
767            }
768            b"fldChar" => {
769                self.field_character(reader, element)?;
770                Frame::Other
771            }
772            b"instrText" | b"delInstrText" => Frame::PseudoText(PseudoTextFrame::instruction()),
773            b"ins" | b"del" | b"moveFrom" | b"moveTo" => {
774                if self.inside_table_row_properties() {
775                    self.revision_unsupported
776                        .insert(RevisionUnsupportedReason::StructuralTableRevision);
777                    Frame::Other
778                } else if self.inside_paragraph_mark_properties() && matches!(name, b"ins" | b"del")
779                {
780                    if let Some(paragraph) = self.current_paragraph.as_mut() {
781                        paragraph.paragraph_mark_revision = Some(if name == b"ins" {
782                            ParagraphMarkRevision::Insertion
783                        } else {
784                            ParagraphMarkRevision::Deletion
785                        });
786                    }
787                    Frame::Other
788                } else {
789                    Frame::Revision(RevisionFrame {
790                        review_index: attributed_revision,
791                        start: self.current_review_point(),
792                        suppressed: revision_is_suppressed(name, self.revision_view),
793                        text: String::new(),
794                    })
795                }
796            }
797            b"cellIns" | b"cellDel" | b"cellMerge" => {
798                self.revision_unsupported
799                    .insert(RevisionUnsupportedReason::StructuralTableRevision);
800                Frame::Other
801            }
802            b"sdt" => {
803                let (text_utf8_start, text_utf16_start) =
804                    self.current_paragraph.as_ref().map_or((0, 0), |paragraph| {
805                        (paragraph.text.len(), paragraph.utf16_len)
806                    });
807                Frame::Sdt(SdtFrame {
808                    placeholder: false,
809                    text_utf8_start,
810                    text_utf16_start,
811                })
812            }
813            b"showingPlcHdr" => {
814                if on_off_element_enabled(reader, element)? {
815                    self.mark_placeholder();
816                }
817                Frame::Other
818            }
819            b"vanish" => {
820                if on_off_element_enabled(reader, element)? {
821                    self.mark_hidden_run();
822                }
823                Frame::Other
824            }
825            b"rStyle" => {
826                if matches!(
827                    attribute(reader, element, b"val")?.as_deref(),
828                    Some("HideTWBInt" | "HideTWBExt")
829                ) {
830                    self.mark_hidden_run();
831                }
832                Frame::Other
833            }
834            b"b" | b"bCs" => {
835                if on_off_element_enabled(reader, element)? {
836                    self.mark_bold_run();
837                }
838                Frame::Other
839            }
840            b"highlight" => {
841                if attribute(reader, element, b"val")?
842                    .as_deref()
843                    .is_some_and(is_semantic_highlight_color)
844                {
845                    self.mark_highlighted_run();
846                }
847                Frame::Other
848            }
849            b"t" | b"delText" => {
850                Frame::PseudoText(PseudoTextFrame::text(preserves_xml_space(reader, element)?))
851            }
852            b"tab" | b"ptab" => {
853                self.append_text_control(TextControl::Tab)?;
854                Frame::Other
855            }
856            b"br" => {
857                let control = match attribute(reader, element, b"type")?.as_deref() {
858                    Some("page") => TextControl::PageBreak,
859                    Some("column") => TextControl::ColumnBreak,
860                    _ => TextControl::LineBreak,
861                };
862                self.append_text_control(control)?;
863                Frame::Other
864            }
865            b"cr" => {
866                self.append_text_control(TextControl::CarriageReturn)?;
867                Frame::Other
868            }
869            b"footnoteReference" => {
870                let visible =
871                    attribute(reader, element, b"customMarkFollows")?.is_none_or(|value| {
872                        matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")
873                    });
874                if visible {
875                    self.append_text_control(TextControl::FootnoteReference)?;
876                }
877                Frame::Other
878            }
879            b"softHyphen" => {
880                self.append_text_control(TextControl::SoftHyphen)?;
881                Frame::Other
882            }
883            b"noBreakHyphen" => {
884                self.append_text_control(TextControl::NoBreakHyphen)?;
885                Frame::Other
886            }
887            b"sym" => {
888                if let Some(value) = attribute(reader, element, b"char")? {
889                    self.append_pseudo_text(&symbol_text(&value))?;
890                }
891                Frame::Other
892            }
893            _ => Frame::Other,
894        };
895        self.frames.push(frame);
896        Ok(())
897    }
898
899    fn end(&mut self) -> Result<(), ProjectionError> {
900        let frame = self
901            .frames
902            .pop()
903            .ok_or(ProjectionError::InvalidDocumentXml)?;
904        match frame {
905            Frame::Paragraph => {
906                let paragraph = self
907                    .current_paragraph
908                    .take()
909                    .ok_or(ProjectionError::InvalidDocumentXml)?;
910                let ordinal = self.paragraphs.len();
911                if let Some(revision) = paragraph.paragraph_mark_revision {
912                    self.paragraph_mark_revisions.insert(ordinal, revision);
913                }
914                self.paragraphs.push(RawProjectedParagraph {
915                    ordinal: self.paragraphs.len(),
916                    package_paragraph_id: paragraph.package_paragraph_id,
917                    text: paragraph.text,
918                    utf16_len: paragraph.utf16_len,
919                    formatting: paragraph.formatting,
920                    structure: paragraph.structure,
921                    properties: paragraph.properties,
922                });
923            }
924            Frame::Hyperlink(Some(reference)) => self.references.push(reference),
925            Frame::PseudoText(frame) => match frame.kind {
926                PseudoTextKind::Text { preserve_space } => {
927                    let text = if preserve_space {
928                        frame.text.as_str()
929                    } else {
930                        trim_xml_whitespace(&frame.text)
931                    };
932                    self.append_pseudo_text(text)?;
933                }
934                PseudoTextKind::Instruction => self.append_field_instruction(&frame.text),
935            },
936            Frame::Run(run) if !run.hidden && !self.pseudo_text_is_suppressed() => {
937                if let Some(paragraph) = self.current_paragraph.as_mut() {
938                    paragraph.append(&run.text, run.bold, run.highlighted)?;
939                }
940            }
941            Frame::Revision(revision) => self.finish_attributed_revision(revision)?,
942            _ => {}
943        }
944        Ok(())
945    }
946
947    fn record_attributed_revision(
948        &mut self,
949        reader: &NsReader<&[u8]>,
950        element: &BytesStart<'_>,
951        name: &[u8],
952    ) -> Result<Option<usize>, ProjectionError> {
953        let Some(kind) = revision_fact_kind(name) else {
954            return Ok(None);
955        };
956        let ReviewRevisionCollection::Complete {
957            maximum_facts,
958            revisions,
959            ..
960        } = &mut self.review_revisions
961        else {
962            return Ok(None);
963        };
964        if revisions.len() >= *maximum_facts {
965            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
966            return Ok(None);
967        }
968        let review_index = revisions.len();
969        revisions.push(AttributedRevision {
970            kind,
971            author: attribute(reader, element, b"author")?.unwrap_or_default(),
972            date: attribute(reader, element, b"date")?,
973            revision_id: attribute(reader, element, b"id")?,
974            content: ReviewDetail::Unknown(ReviewFactUnknownReason::UnsupportedLocation),
975        });
976        Ok(Some(review_index))
977    }
978
979    fn finish_attributed_revision(
980        &mut self,
981        revision: RevisionFrame,
982    ) -> Result<(), ProjectionError> {
983        let (Some(review_index), Some(start), Some(end)) = (
984            revision.review_index,
985            revision.start,
986            self.current_review_point(),
987        ) else {
988            return Ok(());
989        };
990        let ReviewRevisionCollection::Complete { revisions, .. } = &mut self.review_revisions
991        else {
992            return Ok(());
993        };
994        let attributed = revisions
995            .get_mut(review_index)
996            .ok_or(ProjectionError::InvalidDocumentXml)?;
997        attributed.content = ReviewDetail::Known(RevisionContent {
998            span: ReviewSpan { start, end },
999            formatting_only: revision.text.is_empty(),
1000            text: revision.text,
1001        });
1002        Ok(())
1003    }
1004
1005    fn inside_body(&self) -> bool {
1006        self.frames.iter().any(|frame| matches!(frame, Frame::Body))
1007    }
1008
1009    fn inside_textbox(&self) -> bool {
1010        self.frames
1011            .iter()
1012            .any(|frame| matches!(frame, Frame::Textbox))
1013    }
1014
1015    fn inside_change_snapshot(&self) -> bool {
1016        self.frames
1017            .iter()
1018            .any(|frame| matches!(frame, Frame::ChangeSnapshot))
1019    }
1020
1021    fn inside_paragraph_mark_properties(&self) -> bool {
1022        self.current_paragraph.is_some()
1023            && self
1024                .frames
1025                .iter()
1026                .any(|frame| matches!(frame, Frame::ParagraphProperties))
1027            && !self
1028                .frames
1029                .iter()
1030                .any(|frame| matches!(frame, Frame::Run(_)))
1031    }
1032
1033    fn inside_table_row_properties(&self) -> bool {
1034        self.frames
1035            .iter()
1036            .any(|frame| matches!(frame, Frame::TableRowProperties))
1037    }
1038
1039    fn pseudo_text_is_suppressed(&self) -> bool {
1040        self.frames.iter().any(|frame| {
1041            matches!(
1042                frame,
1043                Frame::ParagraphProperties
1044                    | Frame::RunProperties(_)
1045                    | Frame::ChangeSnapshot
1046                    | Frame::Textbox
1047            ) || matches!(frame, Frame::Revision(revision) if revision.suppressed)
1048                || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1049        })
1050    }
1051
1052    fn pseudo_text_is_structurally_suppressed(&self) -> bool {
1053        self.frames.iter().any(|frame| {
1054            matches!(
1055                frame,
1056                Frame::ParagraphProperties
1057                    | Frame::RunProperties(_)
1058                    | Frame::ChangeSnapshot
1059                    | Frame::Textbox
1060            ) || matches!(frame, Frame::Sdt(sdt) if sdt.placeholder)
1061        })
1062    }
1063
1064    fn append_pseudo_text(&mut self, text: &str) -> Result<(), ProjectionError> {
1065        if self.current_paragraph.is_none()
1066            || self.pseudo_text_is_structurally_suppressed()
1067            || self
1068                .frames
1069                .iter()
1070                .rev()
1071                .any(|frame| matches!(frame, Frame::Run(run) if run.hidden))
1072        {
1073            return Ok(());
1074        }
1075        self.append_review_text(text);
1076        if self.pseudo_text_is_suppressed() {
1077            return Ok(());
1078        }
1079        if let Some(run) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1080            Frame::Run(run) => Some(run),
1081            _ => None,
1082        }) {
1083            run.text.push_str(text);
1084            return Ok(());
1085        }
1086        self.current_paragraph
1087            .as_mut()
1088            .ok_or(ProjectionError::InvalidDocumentXml)?
1089            .append(text, false, false)
1090    }
1091
1092    fn append_review_text(&mut self, text: &str) {
1093        let ReviewRevisionCollection::Complete {
1094            remaining_detail_bytes,
1095            ..
1096        } = &mut self.review_revisions
1097        else {
1098            return;
1099        };
1100        let mut limit_exceeded = false;
1101        for frame in &mut self.frames {
1102            if let Frame::Revision(revision) = frame
1103                && revision.review_index.is_some()
1104            {
1105                let Some(remaining) = remaining_detail_bytes.checked_sub(text.len()) else {
1106                    limit_exceeded = true;
1107                    break;
1108                };
1109                *remaining_detail_bytes = remaining;
1110                revision.text.push_str(text);
1111            }
1112        }
1113        if limit_exceeded {
1114            self.review_revisions = ReviewRevisionCollection::LimitExceeded;
1115        }
1116    }
1117
1118    fn current_review_point(&self) -> Option<ReviewPoint> {
1119        let paragraph = self.current_paragraph.as_ref()?;
1120        let run_text = self.frames.iter().rev().find_map(|frame| match frame {
1121            Frame::Run(run) if !run.hidden && !self.pseudo_text_is_suppressed() => {
1122                Some(run.text.as_str())
1123            }
1124            _ => None,
1125        });
1126        let run_utf8 = run_text.map_or(0, str::len);
1127        let run_utf16 = run_text.map_or(0, |text| text.encode_utf16().count());
1128        Some(ReviewPoint {
1129            paragraph_ordinal: self.paragraphs.len(),
1130            utf8: u32::try_from(paragraph.text.len().checked_add(run_utf8)?).ok()?,
1131            utf16: paragraph
1132                .utf16_len
1133                .checked_add(u32::try_from(run_utf16).ok()?)?,
1134        })
1135    }
1136
1137    fn next_paragraph_start_review_point(&self) -> Option<ReviewPoint> {
1138        if self.current_paragraph.is_some() {
1139            return self.current_review_point();
1140        }
1141        self.inside_body().then_some(ReviewPoint {
1142            paragraph_ordinal: self.paragraphs.len(),
1143            utf8: 0,
1144            utf16: 0,
1145        })
1146    }
1147
1148    fn previous_paragraph_end_review_point(&self) -> Option<ReviewPoint> {
1149        if self.current_paragraph.is_some() {
1150            return self.current_review_point();
1151        }
1152        let paragraph = self.paragraphs.last()?;
1153        Some(ReviewPoint {
1154            paragraph_ordinal: paragraph.ordinal,
1155            utf8: u32::try_from(paragraph.text.len()).ok()?,
1156            utf16: paragraph.utf16_len,
1157        })
1158    }
1159
1160    fn review_comment_id(
1161        reader: &NsReader<&[u8]>,
1162        element: &BytesStart<'_>,
1163    ) -> Result<Option<String>, ProjectionError> {
1164        attribute(reader, element, b"id")
1165    }
1166
1167    fn start_review_comment_anchor(
1168        &mut self,
1169        reader: &NsReader<&[u8]>,
1170        element: &BytesStart<'_>,
1171    ) -> Result<(), ProjectionError> {
1172        let (Some(comment_id), Some(point)) = (
1173            Self::review_comment_id(reader, element)?,
1174            self.next_paragraph_start_review_point(),
1175        ) else {
1176            return Ok(());
1177        };
1178        if self.invalid_review_comment_anchors.contains(&comment_id) {
1179            return Ok(());
1180        }
1181        if self
1182            .open_review_comment_anchors
1183            .insert(comment_id.clone(), point)
1184            .is_some()
1185        {
1186            self.open_review_comment_anchors.remove(&comment_id);
1187            self.review_comment_anchors.remove(&comment_id);
1188            self.invalid_review_comment_anchors.insert(comment_id);
1189        }
1190        Ok(())
1191    }
1192
1193    fn suppress_review_comment_anchor(
1194        &mut self,
1195        reader: &NsReader<&[u8]>,
1196        element: &BytesStart<'_>,
1197    ) -> Result<(), ProjectionError> {
1198        let Some(comment_id) = Self::review_comment_id(reader, element)? else {
1199            return Ok(());
1200        };
1201        self.open_review_comment_anchors.remove(&comment_id);
1202        self.review_comment_anchors.remove(&comment_id);
1203        self.invalid_review_comment_anchors.insert(comment_id);
1204        Ok(())
1205    }
1206
1207    fn end_review_comment_anchor(
1208        &mut self,
1209        reader: &NsReader<&[u8]>,
1210        element: &BytesStart<'_>,
1211    ) -> Result<(), ProjectionError> {
1212        let (Some(comment_id), Some(end)) = (
1213            Self::review_comment_id(reader, element)?,
1214            self.previous_paragraph_end_review_point(),
1215        ) else {
1216            return Ok(());
1217        };
1218        if self.invalid_review_comment_anchors.contains(&comment_id) {
1219            return Ok(());
1220        }
1221        let Some(start) = self.open_review_comment_anchors.remove(&comment_id) else {
1222            self.review_comment_anchors.remove(&comment_id);
1223            self.invalid_review_comment_anchors.insert(comment_id);
1224            return Ok(());
1225        };
1226        if self
1227            .review_comment_anchors
1228            .insert(comment_id.clone(), ReviewSpan { start, end })
1229            .is_some()
1230        {
1231            self.review_comment_anchors.remove(&comment_id);
1232            self.invalid_review_comment_anchors.insert(comment_id);
1233        }
1234        Ok(())
1235    }
1236
1237    fn record_review_comment_reference(
1238        &mut self,
1239        reader: &NsReader<&[u8]>,
1240        element: &BytesStart<'_>,
1241    ) -> Result<(), ProjectionError> {
1242        let (Some(comment_id), Some(point)) = (
1243            Self::review_comment_id(reader, element)?,
1244            self.current_review_point(),
1245        ) else {
1246            return Ok(());
1247        };
1248        if self.invalid_review_comment_anchors.contains(&comment_id) {
1249            return Ok(());
1250        }
1251        if !self.review_comment_anchors.contains_key(&comment_id)
1252            && !self.open_review_comment_anchors.contains_key(&comment_id)
1253        {
1254            self.review_comment_anchors.insert(
1255                comment_id,
1256                ReviewSpan {
1257                    start: point,
1258                    end: point,
1259                },
1260            );
1261        }
1262        Ok(())
1263    }
1264
1265    fn append_pseudo_content(&mut self, text: &str) -> Result<(), ProjectionError> {
1266        let Some(Frame::PseudoText(frame)) = self.frames.last_mut() else {
1267            return Err(ProjectionError::InvalidDocumentXml);
1268        };
1269        frame.text.push_str(text);
1270        Ok(())
1271    }
1272
1273    fn append_text_control(&mut self, control: TextControl) -> Result<(), ProjectionError> {
1274        if let Some(text) = control.materialize(self.text_materialization) {
1275            self.append_pseudo_text(text)?;
1276        }
1277        Ok(())
1278    }
1279
1280    fn append_field_instruction(&mut self, text: &str) {
1281        let Some(field) = self.fields.last_mut() else {
1282            self.references_complete = false;
1283            return;
1284        };
1285        if field.separated || field.instruction.len().saturating_add(text.len()) > 4096 {
1286            self.references_complete = false;
1287            return;
1288        }
1289        field.instruction.push_str(text);
1290    }
1291
1292    fn simple_field(
1293        &mut self,
1294        reader: &NsReader<&[u8]>,
1295        element: &BytesStart<'_>,
1296    ) -> Result<(), ProjectionError> {
1297        let instruction = attribute(reader, element, b"instr")?;
1298        let source = self.current_point();
1299        let Some(instruction) = instruction.filter(|value| value.len() <= 4096) else {
1300            self.references_complete = false;
1301            return Ok(());
1302        };
1303        self.record_field_reference(&instruction, source);
1304        Ok(())
1305    }
1306
1307    fn field_character(
1308        &mut self,
1309        reader: &NsReader<&[u8]>,
1310        element: &BytesStart<'_>,
1311    ) -> Result<(), ProjectionError> {
1312        match attribute(reader, element, b"fldCharType")?.as_deref() {
1313            Some("begin") => self.fields.push(FieldFrame {
1314                source: self.current_field_point(),
1315                instruction: String::new(),
1316                separated: false,
1317            }),
1318            Some("separate") => {
1319                let Some(mut field) = self.fields.pop() else {
1320                    self.references_complete = false;
1321                    return Ok(());
1322                };
1323                if field.separated {
1324                    self.references_complete = false;
1325                } else {
1326                    self.record_field_reference(&field.instruction, field.source.clone());
1327                    field.separated = true;
1328                }
1329                self.fields.push(field);
1330            }
1331            Some("end") => {
1332                let Some(field) = self.fields.pop() else {
1333                    self.references_complete = false;
1334                    return Ok(());
1335                };
1336                if !field.separated {
1337                    self.record_field_reference(&field.instruction, field.source);
1338                }
1339            }
1340            _ => self.references_complete = false,
1341        }
1342        Ok(())
1343    }
1344
1345    fn current_field_point(&self) -> Option<RawBlockPoint> {
1346        let paragraph = self.current_paragraph.as_ref()?;
1347        if self.pseudo_text_is_suppressed() {
1348            return None;
1349        }
1350        if self.frames.iter().rev().find_map(|frame| match frame {
1351            Frame::Run(run) => Some(!run.text.is_empty()),
1352            _ => None,
1353        }) == Some(true)
1354        {
1355            return None;
1356        }
1357        Some(RawBlockPoint {
1358            paragraph: self.paragraphs.len(),
1359            utf8: u32::try_from(paragraph.text.len()).ok()?,
1360            utf16: paragraph.utf16_len,
1361        })
1362    }
1363
1364    fn record_field_reference(&mut self, instruction: &str, source: Option<RawBlockPoint>) {
1365        match internal_reference_target(instruction) {
1366            Ok(Some(reference_id)) => {
1367                let Some(source) = source else {
1368                    self.references_complete = false;
1369                    return;
1370                };
1371                self.references.push(RawInternalReference {
1372                    reference_id,
1373                    source,
1374                });
1375            }
1376            Ok(None) => {}
1377            Err(()) => self.references_complete = false,
1378        }
1379    }
1380
1381    fn current_point(&self) -> Option<RawBlockPoint> {
1382        if let Some(paragraph) = self.current_paragraph.as_ref() {
1383            if self.pseudo_text_is_suppressed()
1384                || self
1385                    .frames
1386                    .iter()
1387                    .any(|frame| matches!(frame, Frame::Run(_)))
1388            {
1389                return None;
1390            }
1391            return Some(RawBlockPoint {
1392                paragraph: self.paragraphs.len(),
1393                utf8: u32::try_from(paragraph.text.len()).ok()?,
1394                utf16: paragraph.utf16_len,
1395            });
1396        }
1397        if !matches!(self.frames.last(), Some(Frame::Body)) {
1398            return None;
1399        }
1400        self.paragraphs.last().map_or(
1401            Some(RawBlockPoint {
1402                paragraph: 0,
1403                utf8: 0,
1404                utf16: 0,
1405            }),
1406            |paragraph| {
1407                Some(RawBlockPoint {
1408                    paragraph: paragraph.ordinal,
1409                    utf8: u32::try_from(paragraph.text.len()).ok()?,
1410                    utf16: u32::try_from(paragraph.text.encode_utf16().count()).ok()?,
1411                })
1412            },
1413        )
1414    }
1415
1416    fn start_bookmark(
1417        &mut self,
1418        reader: &NsReader<&[u8]>,
1419        element: &BytesStart<'_>,
1420    ) -> Result<(), ProjectionError> {
1421        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
1422        let name = attribute(reader, element, b"name")?;
1423        let point = self.current_point();
1424        let (Some(id), Some(name), Some(point)) = (id, name, point) else {
1425            self.bookmarks_complete = false;
1426            return Ok(());
1427        };
1428        if name.is_empty()
1429            || !self.seen_bookmark_ids.insert(id)
1430            || self.open_bookmarks.insert(id, (name, point)).is_some()
1431        {
1432            self.bookmarks_complete = false;
1433        }
1434        Ok(())
1435    }
1436
1437    fn end_bookmark(
1438        &mut self,
1439        reader: &NsReader<&[u8]>,
1440        element: &BytesStart<'_>,
1441    ) -> Result<(), ProjectionError> {
1442        let id = attribute(reader, element, b"id")?.and_then(|value| value.parse::<u32>().ok());
1443        let point = self.current_point();
1444        let (Some(id), Some(end)) = (id, point) else {
1445            self.bookmarks_complete = false;
1446            return Ok(());
1447        };
1448        let Some((name, start)) = self.open_bookmarks.remove(&id) else {
1449            self.bookmarks_complete = false;
1450            return Ok(());
1451        };
1452        self.bookmarks.push(RawBookmarkRange {
1453            id,
1454            name,
1455            start,
1456            end,
1457        });
1458        Ok(())
1459    }
1460
1461    fn mark_placeholder(&mut self) {
1462        let Some(sdt) = self.frames.iter_mut().rev().find_map(|frame| match frame {
1463            Frame::Sdt(sdt) => Some(sdt),
1464            _ => None,
1465        }) else {
1466            return;
1467        };
1468        sdt.placeholder = true;
1469        if let Some(paragraph) = self.current_paragraph.as_mut() {
1470            paragraph.truncate(sdt.text_utf8_start, sdt.text_utf16_start);
1471        }
1472    }
1473
1474    fn mark_hidden_run(&mut self) {
1475        let Some((run_frame, hidden_eligible)) =
1476            self.frames.iter().rev().find_map(|frame| match frame {
1477                Frame::RunProperties(properties) => {
1478                    Some((properties.run_frame, properties.hidden_eligible))
1479                }
1480                _ => None,
1481            })
1482        else {
1483            return;
1484        };
1485        if !hidden_eligible || run_frame == usize::MAX {
1486            return;
1487        }
1488        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1489            run.hidden = true;
1490        }
1491    }
1492
1493    fn mark_bold_run(&mut self) {
1494        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1495            return;
1496        };
1497        let run_frame = properties.run_frame;
1498        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1499            run.bold = true;
1500        }
1501    }
1502
1503    fn mark_highlighted_run(&mut self) {
1504        let Some(Frame::RunProperties(properties)) = self.frames.last() else {
1505            return;
1506        };
1507        let run_frame = properties.run_frame;
1508        if let Some(Frame::Run(run)) = self.frames.get_mut(run_frame) {
1509            run.highlighted = true;
1510        }
1511    }
1512}
1513
1514const fn revision_fact_kind(name: &[u8]) -> Option<RevisionFactKind> {
1515    match name {
1516        b"ins" => Some(RevisionFactKind::Insertion),
1517        b"del" => Some(RevisionFactKind::Deletion),
1518        b"moveFrom" => Some(RevisionFactKind::MoveFrom),
1519        b"moveTo" => Some(RevisionFactKind::MoveTo),
1520        b"cellIns" => Some(RevisionFactKind::CellInsertion),
1521        b"cellDel" => Some(RevisionFactKind::CellDeletion),
1522        b"cellMerge" => Some(RevisionFactKind::CellMerge),
1523        b"pPrChange" => Some(RevisionFactKind::ParagraphPropertiesChange),
1524        b"rPrChange" => Some(RevisionFactKind::RunPropertiesChange),
1525        b"sectPrChange" => Some(RevisionFactKind::SectionPropertiesChange),
1526        b"tblPrChange" => Some(RevisionFactKind::TablePropertiesChange),
1527        b"trPrChange" => Some(RevisionFactKind::TableRowPropertiesChange),
1528        b"tcPrChange" => Some(RevisionFactKind::TableCellPropertiesChange),
1529        b"tblGridChange" => Some(RevisionFactKind::TableGridChange),
1530        b"customXmlDelRangeStart" => Some(RevisionFactKind::CustomXmlDeletionRangeStart),
1531        b"customXmlDelRangeEnd" => Some(RevisionFactKind::CustomXmlDeletionRangeEnd),
1532        b"customXmlInsRangeStart" => Some(RevisionFactKind::CustomXmlInsertionRangeStart),
1533        b"customXmlInsRangeEnd" => Some(RevisionFactKind::CustomXmlInsertionRangeEnd),
1534        b"customXmlMoveFromRangeStart" => Some(RevisionFactKind::CustomXmlMoveFromRangeStart),
1535        b"customXmlMoveFromRangeEnd" => Some(RevisionFactKind::CustomXmlMoveFromRangeEnd),
1536        b"customXmlMoveToRangeStart" => Some(RevisionFactKind::CustomXmlMoveToRangeStart),
1537        b"customXmlMoveToRangeEnd" => Some(RevisionFactKind::CustomXmlMoveToRangeEnd),
1538        _ => None,
1539    }
1540}
1541
1542fn enclosing_table(frames: &mut [Frame]) -> Option<&mut TableFrame> {
1543    for frame in frames.iter_mut().rev() {
1544        match frame {
1545            Frame::Table(table) => return Some(table),
1546            Frame::Row(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
1547            _ => {}
1548        }
1549    }
1550    None
1551}
1552
1553fn enclosing_row(frames: &mut [Frame]) -> Option<&mut RowFrame> {
1554    for frame in frames.iter_mut().rev() {
1555        match frame {
1556            Frame::Row(row) => return Some(row),
1557            Frame::Table(_) | Frame::Cell(_) | Frame::Paragraph | Frame::Run(_) => return None,
1558            _ => {}
1559        }
1560    }
1561    None
1562}
1563
1564fn revision_is_suppressed(name: &[u8], view: RevisionView) -> bool {
1565    match view {
1566        RevisionView::Current => matches!(name, b"del" | b"moveFrom"),
1567        RevisionView::Original => matches!(name, b"ins" | b"moveTo"),
1568    }
1569}
1570
1571fn is_change_snapshot(name: &[u8]) -> bool {
1572    matches!(
1573        name,
1574        b"pPrChange"
1575            | b"rPrChange"
1576            | b"sectPrChange"
1577            | b"tblPrChange"
1578            | b"trPrChange"
1579            | b"tcPrChange"
1580            | b"tblGridChange"
1581    )
1582}
1583
1584fn is_unsupported_revision_markup(name: &[u8]) -> bool {
1585    matches!(
1586        name,
1587        b"customXmlDelRangeStart"
1588            | b"customXmlDelRangeEnd"
1589            | b"customXmlInsRangeStart"
1590            | b"customXmlInsRangeEnd"
1591            | b"customXmlMoveFromRangeStart"
1592            | b"customXmlMoveFromRangeEnd"
1593            | b"customXmlMoveToRangeStart"
1594            | b"customXmlMoveToRangeEnd"
1595    )
1596}
1597
1598const fn paragraph_break_is_removed(
1599    revision: Option<ParagraphMarkRevision>,
1600    view: RevisionView,
1601) -> bool {
1602    matches!(
1603        (revision, view),
1604        (Some(ParagraphMarkRevision::Deletion), RevisionView::Current)
1605            | (
1606                Some(ParagraphMarkRevision::Insertion),
1607                RevisionView::Original
1608            )
1609    )
1610}
1611
1612fn normalize_paragraph_revision_view(
1613    paragraphs: &mut Vec<RawProjectedParagraph>,
1614    paragraph_mark_revisions: &mut HashMap<usize, ParagraphMarkRevision>,
1615    view: RevisionView,
1616    unsupported: &mut BTreeSet<RevisionUnsupportedReason>,
1617) -> Result<bool, ProjectionError> {
1618    let mut normalized: Vec<RawProjectedParagraph> = Vec::with_capacity(paragraphs.len());
1619    let mut merge_previous = false;
1620    let mut merged_any = false;
1621
1622    for (source_ordinal, mut paragraph) in std::mem::take(paragraphs).into_iter().enumerate() {
1623        let merge_next =
1624            paragraph_break_is_removed(paragraph_mark_revisions.remove(&source_ordinal), view);
1625        if merge_previous {
1626            let previous = normalized
1627                .last_mut()
1628                .ok_or(ProjectionError::InvalidDocumentXml)?;
1629            if previous.structure != paragraph.structure {
1630                unsupported.insert(RevisionUnsupportedReason::IncompatibleParagraphMerge);
1631                paragraph.ordinal = normalized.len();
1632                normalized.push(paragraph);
1633                merge_previous = merge_next;
1634                continue;
1635            }
1636            let utf16_offset = previous.utf16_len;
1637            for mut span in paragraph.formatting {
1638                span.start_utf16 = span
1639                    .start_utf16
1640                    .checked_add(utf16_offset)
1641                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1642                span.end_utf16 = span
1643                    .end_utf16
1644                    .checked_add(utf16_offset)
1645                    .ok_or(ProjectionError::InvalidDocumentXml)?;
1646                previous.formatting.push(span);
1647            }
1648            previous.text.push_str(&paragraph.text);
1649            previous.utf16_len = previous
1650                .utf16_len
1651                .checked_add(paragraph.utf16_len)
1652                .ok_or(ProjectionError::InvalidDocumentXml)?;
1653            merge_previous = merge_next;
1654            merged_any = true;
1655            continue;
1656        }
1657
1658        paragraph.ordinal = normalized.len();
1659        normalized.push(paragraph);
1660        merge_previous = merge_next;
1661    }
1662
1663    *paragraphs = normalized;
1664    Ok(merged_any)
1665}
1666
1667fn internal_reference_target(instruction: &str) -> Result<Option<String>, ()> {
1668    let tokens = field_tokens(instruction)?;
1669    let Some(command) = tokens.first() else {
1670        return Err(());
1671    };
1672    if ["REF", "PAGEREF", "NOTEREF"]
1673        .iter()
1674        .any(|candidate| command.eq_ignore_ascii_case(candidate))
1675    {
1676        return tokens
1677            .get(1)
1678            .filter(|target| !target.is_empty() && !target.starts_with('\\'))
1679            .cloned()
1680            .map(Some)
1681            .ok_or(());
1682    }
1683    if command.eq_ignore_ascii_case("HYPERLINK") {
1684        for (index, token) in tokens.iter().enumerate().skip(1) {
1685            if token.eq_ignore_ascii_case("\\l") {
1686                return tokens
1687                    .get(index.checked_add(1).ok_or(())?)
1688                    .filter(|target| !target.is_empty())
1689                    .cloned()
1690                    .map(Some)
1691                    .ok_or(());
1692            }
1693        }
1694    }
1695    Ok(None)
1696}
1697
1698fn field_tokens(instruction: &str) -> Result<Vec<String>, ()> {
1699    let mut tokens = Vec::new();
1700    let mut token = String::new();
1701    let mut quoted = false;
1702    for character in instruction.chars() {
1703        match character {
1704            '"' => quoted = !quoted,
1705            whitespace if whitespace.is_whitespace() && !quoted => {
1706                if !token.is_empty() {
1707                    tokens.push(std::mem::take(&mut token));
1708                }
1709            }
1710            _ => token.push(character),
1711        }
1712    }
1713    if quoted {
1714        return Err(());
1715    }
1716    if !token.is_empty() {
1717        tokens.push(token);
1718    }
1719    Ok(tokens)
1720}
1721
1722/// Returns whether an OOXML `ST_HighlightColor` value represents a visible
1723/// semantic marker. Run shading (`w:shd`) is a distinct formatting fact.
1724#[must_use]
1725pub fn is_semantic_highlight_color(value: &str) -> bool {
1726    matches!(
1727        value,
1728        "black"
1729            | "blue"
1730            | "cyan"
1731            | "darkBlue"
1732            | "darkCyan"
1733            | "darkGreen"
1734            | "darkMagenta"
1735            | "darkRed"
1736            | "darkYellow"
1737            | "green"
1738            | "magenta"
1739            | "red"
1740            | "yellow"
1741    )
1742}
1743
1744fn attribute(
1745    reader: &NsReader<&[u8]>,
1746    element: &BytesStart<'_>,
1747    name: &[u8],
1748) -> Result<Option<String>, ProjectionError> {
1749    for attribute in element.attributes() {
1750        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
1751        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
1752        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing
1753            && local_name.as_ref() == name
1754        {
1755            return attribute
1756                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
1757                .map(|value| Some(value.into_owned()))
1758                .map_err(|_| ProjectionError::InvalidDocumentXml);
1759        }
1760    }
1761    Ok(None)
1762}
1763
1764fn preserves_xml_space(
1765    reader: &NsReader<&[u8]>,
1766    element: &BytesStart<'_>,
1767) -> Result<bool, ProjectionError> {
1768    const XML_NAMESPACE: &[u8] = b"http://www.w3.org/XML/1998/namespace";
1769
1770    for attribute in element.attributes() {
1771        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
1772        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
1773        if matches!(
1774            namespace,
1775            quick_xml::name::ResolveResult::Bound(namespace)
1776                if namespace.as_ref() == XML_NAMESPACE
1777        ) && local_name.as_ref() == b"space"
1778        {
1779            let value = attribute
1780                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
1781                .map_err(|_| ProjectionError::InvalidDocumentXml)?;
1782            return match value.as_ref() {
1783                "default" => Ok(false),
1784                "preserve" => Ok(true),
1785                _ => Err(ProjectionError::InvalidDocumentXml),
1786            };
1787        }
1788    }
1789    Ok(false)
1790}
1791
1792fn on_off_element_enabled(
1793    reader: &NsReader<&[u8]>,
1794    element: &BytesStart<'_>,
1795) -> Result<bool, ProjectionError> {
1796    Ok(attribute(reader, element, b"val")?
1797        .is_none_or(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "off")))
1798}
1799
1800fn trim_xml_whitespace(value: &str) -> &str {
1801    value.trim_matches(['\u{0009}', '\u{000a}', '\u{000d}', '\u{0020}'])
1802}
1803
1804fn attribute_2010(
1805    reader: &NsReader<&[u8]>,
1806    element: &BytesStart<'_>,
1807    name: &[u8],
1808) -> Result<Option<String>, ProjectionError> {
1809    for attribute in element.attributes() {
1810        let attribute = attribute.map_err(|_| ProjectionError::InvalidDocumentXml)?;
1811        let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
1812        if OoxmlNamespace::from_resolved(&namespace) == OoxmlNamespace::Wordprocessing2010
1813            && local_name.as_ref() == name
1814        {
1815            return attribute
1816                .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
1817                .map(|value| Some(value.into_owned()))
1818                .map_err(|_| ProjectionError::InvalidDocumentXml);
1819        }
1820    }
1821    Ok(None)
1822}
1823
1824fn symbol_text(value: &str) -> String {
1825    let prefix = value
1826        .chars()
1827        .take_while(char::is_ascii_hexdigit)
1828        .collect::<String>();
1829    let Some(code) = (!prefix.is_empty())
1830        .then(|| u32::from_str_radix(&prefix, 16).ok())
1831        .flatten()
1832    else {
1833        return value.to_owned();
1834    };
1835    char::from_u32(code).unwrap_or('\u{fffd}').to_string()
1836}