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