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