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)] pub(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)] 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(¶graph.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#[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}