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