Skip to main content

stella_docx_kernel/
semantic.rs

1#![forbid(unsafe_code)]
2
3use std::collections::HashMap;
4
5use quick_xml::{
6    XmlVersion,
7    escape::resolve_predefined_entity,
8    events::{BytesStart, Event},
9    name::{Namespace, PrefixDeclaration, ResolveResult},
10    reader::NsReader,
11};
12use thiserror::Error;
13
14const WORDPROCESSING_TRANSITIONAL: &[u8] =
15    b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
16const WORDPROCESSING_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
17const OFFICE_RELATIONSHIPS_TRANSITIONAL: &[u8] =
18    b"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
19const OFFICE_RELATIONSHIPS_STRICT: &[u8] =
20    b"http://purl.oclc.org/ooxml/officeDocument/relationships";
21const MARKUP_COMPATIBILITY_TRANSITIONAL: &[u8] =
22    b"http://schemas.openxmlformats.org/markup-compatibility/2006";
23const MARKUP_COMPATIBILITY_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/markup-compatibility/main";
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct ScanLimits {
27    pub blocks: usize,
28    pub segments: usize,
29    pub depth: usize,
30}
31
32impl Default for ScanLimits {
33    fn default() -> Self {
34        Self {
35            blocks: 100_000,
36            segments: 1_000_000,
37            depth: 256,
38        }
39    }
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum RevisionKind {
44    Deletion,
45    Insertion,
46    MoveFrom,
47    MoveTo,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum InlineContext {
52    Hyperlink {
53        relationship_id: Option<String>,
54        anchor: Option<String>,
55    },
56    Revision(RevisionKind),
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum SegmentSource {
61    Break,
62    Tab,
63    Text,
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct TextSegment {
68    pub start_utf16: usize,
69    pub end_utf16: usize,
70    pub source: SegmentSource,
71    pub contexts: Vec<InlineContext>,
72    pub xml_path: Vec<usize>,
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum BlockLocation {
77    Paragraph {
78        xml_path: Vec<usize>,
79    },
80    TableCellParagraph {
81        xml_path: Vec<usize>,
82        table_path: Vec<usize>,
83        row_path: Vec<usize>,
84        cell_path: Vec<usize>,
85    },
86    TextBoxParagraph {
87        xml_path: Vec<usize>,
88        text_box_path: Vec<usize>,
89    },
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct TextBlock {
94    pub text: String,
95    pub location: BlockLocation,
96    pub segments: Vec<TextSegment>,
97}
98
99#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
100pub struct PartCoverage {
101    pub hyperlink_segments: usize,
102    pub revision_segments: usize,
103    pub unsupported_alternate_content: usize,
104    pub unsupported_symbols: usize,
105    pub unsupported_field_instructions: usize,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct PartScan {
110    pub blocks: Vec<TextBlock>,
111    pub coverage: PartCoverage,
112}
113
114#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
115pub enum ScanError {
116    #[error("WordprocessingML part is invalid XML")]
117    InvalidXml,
118    #[error("WordprocessingML text is outside a paragraph")]
119    TextOutsideParagraph,
120    #[error("WordprocessingML part exceeds the configured nesting depth")]
121    TooDeep,
122    #[error("WordprocessingML part exceeds the configured block limit")]
123    TooManyBlocks,
124    #[error("WordprocessingML part exceeds the configured segment limit")]
125    TooManySegments,
126    #[error("WordprocessingML text length overflowed")]
127    TextLengthOverflow,
128    #[error("WordprocessingML part must be UTF-8 encoded")]
129    UnsupportedEncoding,
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133enum NamespaceKind {
134    MarkupCompatibility,
135    OfficeRelationships,
136    Other,
137    Wordprocessing,
138}
139
140fn namespace_kind(namespace: &ResolveResult<'_>) -> NamespaceKind {
141    let ResolveResult::Bound(Namespace(value)) = namespace else {
142        return NamespaceKind::Other;
143    };
144    if matches!(*value, WORDPROCESSING_TRANSITIONAL | WORDPROCESSING_STRICT) {
145        NamespaceKind::Wordprocessing
146    } else if matches!(
147        *value,
148        OFFICE_RELATIONSHIPS_TRANSITIONAL | OFFICE_RELATIONSHIPS_STRICT
149    ) {
150        NamespaceKind::OfficeRelationships
151    } else if matches!(
152        *value,
153        MARKUP_COMPATIBILITY_TRANSITIONAL | MARKUP_COMPATIBILITY_STRICT
154    ) {
155        NamespaceKind::MarkupCompatibility
156    } else {
157        NamespaceKind::Other
158    }
159}
160
161fn attribute(
162    reader: &NsReader<&[u8]>,
163    element: &BytesStart<'_>,
164    namespace: NamespaceKind,
165    local_name: &[u8],
166) -> Result<Option<String>, ScanError> {
167    for item in element.attributes() {
168        let item = item.map_err(|_| ScanError::InvalidXml)?;
169        let (resolved, name) = reader.resolver().resolve_attribute(item.key);
170        if namespace_kind(&resolved) == namespace && name.as_ref() == local_name {
171            return item
172                .decoded_and_normalized_value(XmlVersion::default(), reader.decoder())
173                .map(|value| Some(value.into_owned()))
174                .map_err(|_| ScanError::InvalidXml);
175        }
176    }
177    Ok(None)
178}
179
180fn unqualified_attribute(
181    reader: &NsReader<&[u8]>,
182    element: &BytesStart<'_>,
183    local_name: &[u8],
184) -> Result<Option<String>, ScanError> {
185    for item in element.attributes() {
186        let item = item.map_err(|_| ScanError::InvalidXml)?;
187        let (resolved, name) = reader.resolver().resolve_attribute(item.key);
188        if matches!(resolved, ResolveResult::Unbound) && name.as_ref() == local_name {
189            return item
190                .decoded_and_normalized_value(XmlVersion::default(), reader.decoder())
191                .map(|value| Some(value.into_owned()))
192                .map_err(|_| ScanError::InvalidXml);
193        }
194    }
195    Ok(None)
196}
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199enum FrameKind {
200    AlternateContent { branch_selected: bool },
201    AlternateContentBranch,
202    Other,
203    Paragraph(usize),
204    Run,
205    Text,
206}
207
208struct Frame {
209    kind: FrameKind,
210    next_child: usize,
211    context_pushed: bool,
212    table_path: Option<Vec<usize>>,
213    row_path: Option<Vec<usize>>,
214    cell_path: Option<Vec<usize>>,
215    text_box_path: Option<Vec<usize>>,
216    content_suppressed: bool,
217}
218
219impl Frame {
220    const fn root() -> Self {
221        Self {
222            kind: FrameKind::Other,
223            next_child: 0,
224            context_pushed: false,
225            table_path: None,
226            row_path: None,
227            cell_path: None,
228            text_box_path: None,
229            content_suppressed: false,
230        }
231    }
232}
233
234struct BlockBuilder {
235    text: String,
236    utf16_len: usize,
237    location: BlockLocation,
238    segments: Vec<TextSegment>,
239}
240
241struct PendingText {
242    block_id: Option<usize>,
243    path: Vec<usize>,
244    value: String,
245}
246
247struct Scanner<F> {
248    frames: Vec<Frame>,
249    element_path: Vec<usize>,
250    builders: HashMap<usize, BlockBuilder>,
251    completed: HashMap<usize, TextBlock>,
252    coverage: PartCoverage,
253    active_contexts: Vec<InlineContext>,
254    limits: ScanLimits,
255    next_block_id: usize,
256    next_emit_id: usize,
257    segment_count: usize,
258    pending_text: Option<PendingText>,
259    root_seen: bool,
260    sink: F,
261}
262
263impl<F> Scanner<F>
264where
265    F: FnMut(TextBlock) -> Result<(), ScanError>,
266{
267    fn new(limits: ScanLimits, sink: F) -> Self {
268        Self {
269            frames: vec![Frame::root()],
270            element_path: Vec::new(),
271            builders: HashMap::new(),
272            completed: HashMap::new(),
273            coverage: PartCoverage::default(),
274            active_contexts: Vec::new(),
275            limits,
276            next_block_id: 0,
277            next_emit_id: 0,
278            segment_count: 0,
279            pending_text: None,
280            root_seen: false,
281            sink,
282        }
283    }
284
285    fn enter_element(&mut self) -> Result<(), ScanError> {
286        let parent = self.frames.last_mut().ok_or(ScanError::InvalidXml)?;
287        let child = parent.next_child;
288        parent.next_child = parent
289            .next_child
290            .checked_add(1)
291            .ok_or(ScanError::InvalidXml)?;
292        self.element_path.push(child);
293        Ok(())
294    }
295
296    fn current_block_id(&self) -> Option<usize> {
297        self.frames.iter().rev().find_map(|frame| match frame.kind {
298            FrameKind::Paragraph(block_id) => Some(block_id),
299            FrameKind::AlternateContent { .. }
300            | FrameKind::AlternateContentBranch
301            | FrameKind::Other
302            | FrameKind::Run
303            | FrameKind::Text => None,
304        })
305    }
306
307    fn inside_run(&self) -> bool {
308        self.frames
309            .iter()
310            .rev()
311            .any(|frame| frame.kind == FrameKind::Run)
312    }
313
314    fn contexts(&self) -> Vec<InlineContext> {
315        self.active_contexts.clone()
316    }
317
318    fn nearest_path(&self, select: impl Fn(&Frame) -> Option<&Vec<usize>>) -> Option<Vec<usize>> {
319        self.frames.iter().rev().find_map(select).cloned()
320    }
321
322    fn record_coverage(&mut self, namespace: NamespaceKind, name: &[u8]) -> Result<(), ScanError> {
323        if namespace == NamespaceKind::MarkupCompatibility && name == b"AlternateContent" {
324            self.coverage.unsupported_alternate_content = self
325                .coverage
326                .unsupported_alternate_content
327                .checked_add(1)
328                .ok_or(ScanError::TooManySegments)?;
329        } else if namespace == NamespaceKind::Wordprocessing && name == b"sym" {
330            self.coverage.unsupported_symbols = self
331                .coverage
332                .unsupported_symbols
333                .checked_add(1)
334                .ok_or(ScanError::TooManySegments)?;
335        } else if namespace == NamespaceKind::Wordprocessing
336            && matches!(name, b"instrText" | b"fldSimple")
337        {
338            self.coverage.unsupported_field_instructions = self
339                .coverage
340                .unsupported_field_instructions
341                .checked_add(1)
342                .ok_or(ScanError::TooManySegments)?;
343        }
344        Ok(())
345    }
346
347    fn block_location(&self, path: &[usize]) -> BlockLocation {
348        self.nearest_path(|frame| frame.text_box_path.as_ref())
349            .map_or_else(
350                || {
351                    let table = self.nearest_path(|frame| frame.table_path.as_ref());
352                    let row = self.nearest_path(|frame| frame.row_path.as_ref());
353                    let cell = self.nearest_path(|frame| frame.cell_path.as_ref());
354                    if let (Some(table_path), Some(row_path), Some(cell_path)) = (table, row, cell)
355                    {
356                        BlockLocation::TableCellParagraph {
357                            xml_path: path.to_vec(),
358                            table_path,
359                            row_path,
360                            cell_path,
361                        }
362                    } else {
363                        BlockLocation::Paragraph {
364                            xml_path: path.to_vec(),
365                        }
366                    }
367                },
368                |text_box| BlockLocation::TextBoxParagraph {
369                    xml_path: path.to_vec(),
370                    text_box_path: text_box,
371                },
372            )
373    }
374
375    fn begin_paragraph(&mut self, path: &[usize]) -> Result<FrameKind, ScanError> {
376        if self.next_block_id >= self.limits.blocks {
377            return Err(ScanError::TooManyBlocks);
378        }
379        let block_id = self.next_block_id;
380        self.next_block_id = self
381            .next_block_id
382            .checked_add(1)
383            .ok_or(ScanError::TooManyBlocks)?;
384        self.builders.insert(
385            block_id,
386            BlockBuilder {
387                text: String::new(),
388                utf16_len: 0,
389                location: self.block_location(path),
390                segments: Vec::new(),
391            },
392        );
393        Ok(FrameKind::Paragraph(block_id))
394    }
395
396    fn inline_context(
397        reader: &NsReader<&[u8]>,
398        element: &BytesStart<'_>,
399        name: &[u8],
400    ) -> Result<Option<InlineContext>, ScanError> {
401        match name {
402            b"hyperlink" => Ok(Some(InlineContext::Hyperlink {
403                relationship_id: attribute(
404                    reader,
405                    element,
406                    NamespaceKind::OfficeRelationships,
407                    b"id",
408                )?,
409                anchor: attribute(reader, element, NamespaceKind::Wordprocessing, b"anchor")?,
410            })),
411            b"del" => Ok(Some(InlineContext::Revision(RevisionKind::Deletion))),
412            b"ins" => Ok(Some(InlineContext::Revision(RevisionKind::Insertion))),
413            b"moveFrom" => Ok(Some(InlineContext::Revision(RevisionKind::MoveFrom))),
414            b"moveTo" => Ok(Some(InlineContext::Revision(RevisionKind::MoveTo))),
415            _ => Ok(None),
416        }
417    }
418
419    fn alternate_choice_supported(
420        reader: &NsReader<&[u8]>,
421        element: &BytesStart<'_>,
422    ) -> Result<bool, ScanError> {
423        let Some(requires) = unqualified_attribute(reader, element, b"Requires")? else {
424            return Ok(false);
425        };
426        let mut prefixes = requires.split_ascii_whitespace().peekable();
427        if prefixes.peek().is_none() {
428            return Ok(false);
429        }
430        Ok(prefixes.all(|required_prefix| {
431            reader.resolver().bindings().any(|(prefix, namespace)| {
432                matches!(prefix, PrefixDeclaration::Named(value) if value == required_prefix.as_bytes())
433                    && matches!(
434                        namespace_kind(&ResolveResult::Bound(namespace)),
435                        NamespaceKind::OfficeRelationships | NamespaceKind::Wordprocessing
436                    )
437            })
438        }))
439    }
440
441    fn push_plain_frame(&mut self, kind: FrameKind, content_suppressed: bool) {
442        self.frames.push(Frame {
443            kind,
444            next_child: 0,
445            context_pushed: false,
446            table_path: None,
447            row_path: None,
448            cell_path: None,
449            text_box_path: None,
450            content_suppressed,
451        });
452    }
453
454    fn start_alternate_content(
455        &mut self,
456        reader: &NsReader<&[u8]>,
457        element: &BytesStart<'_>,
458        namespace: NamespaceKind,
459        name: &[u8],
460        parent_suppressed: bool,
461    ) -> Result<bool, ScanError> {
462        let parent_is_alternate_content = matches!(
463            self.frames.last().map(|frame| frame.kind),
464            Some(FrameKind::AlternateContent { .. })
465        );
466        if parent_is_alternate_content {
467            let eligible = namespace == NamespaceKind::MarkupCompatibility
468                && match name {
469                    b"Choice" => Self::alternate_choice_supported(reader, element)?,
470                    b"Fallback" => true,
471                    _ => false,
472                };
473            let parent = self.frames.last_mut().ok_or(ScanError::InvalidXml)?;
474            let FrameKind::AlternateContent { branch_selected } = &mut parent.kind else {
475                return Err(ScanError::InvalidXml);
476            };
477            let selected = !*branch_selected && eligible && !parent_suppressed;
478            if selected {
479                *branch_selected = true;
480            }
481            self.push_plain_frame(FrameKind::AlternateContentBranch, !selected);
482            return Ok(true);
483        }
484        if namespace == NamespaceKind::MarkupCompatibility && name == b"AlternateContent" {
485            self.push_plain_frame(
486                FrameKind::AlternateContent {
487                    branch_selected: false,
488                },
489                parent_suppressed,
490            );
491            return Ok(true);
492        }
493        Ok(false)
494    }
495
496    fn start(
497        &mut self,
498        reader: &NsReader<&[u8]>,
499        element: &BytesStart<'_>,
500    ) -> Result<(), ScanError> {
501        if self.frames.len() >= self.limits.depth {
502            return Err(ScanError::TooDeep);
503        }
504        if self.frames.len() == 1 {
505            if self.root_seen {
506                return Err(ScanError::InvalidXml);
507            }
508            self.root_seen = true;
509        }
510        self.enter_element()?;
511        let (resolved, local_name) = reader.resolver().resolve_element(element.name());
512        let namespace = namespace_kind(&resolved);
513        let name = local_name.as_ref();
514        self.record_coverage(namespace, name)?;
515
516        let parent_suppressed = self
517            .frames
518            .last()
519            .ok_or(ScanError::InvalidXml)?
520            .content_suppressed;
521        if self.start_alternate_content(reader, element, namespace, name, parent_suppressed)? {
522            return Ok(());
523        }
524        if parent_suppressed {
525            self.push_plain_frame(FrameKind::Other, true);
526            return Ok(());
527        }
528        if namespace != NamespaceKind::Wordprocessing {
529            self.push_plain_frame(FrameKind::Other, false);
530            return Ok(());
531        }
532        let table_path = (name == b"tbl").then(|| self.element_path.clone());
533        let row_path = (name == b"tr").then(|| self.element_path.clone());
534        let cell_path = (name == b"tc").then(|| self.element_path.clone());
535        let text_box_path = (name == b"txbxContent").then(|| self.element_path.clone());
536        let context = Self::inline_context(reader, element, name)?;
537        let context_pushed = context.is_some();
538        if let Some(context) = context {
539            self.active_contexts.push(context);
540        }
541
542        let kind = if name == b"p" {
543            let path = self.element_path.clone();
544            self.begin_paragraph(&path)?
545        } else if name == b"r" && self.current_block_id().is_some() {
546            FrameKind::Run
547        } else if matches!(name, b"t" | b"delText") && self.inside_run() {
548            self.pending_text = Some(PendingText {
549                block_id: self.current_block_id(),
550                path: self.element_path.clone(),
551                value: String::new(),
552            });
553            FrameKind::Text
554        } else {
555            if name == b"tab" && self.inside_run() {
556                self.append_segment("\t", SegmentSource::Tab, self.element_path.clone())?;
557            } else if matches!(name, b"br" | b"cr") && self.inside_run() {
558                self.append_segment("\n", SegmentSource::Break, self.element_path.clone())?;
559            }
560            FrameKind::Other
561        };
562
563        self.frames.push(Frame {
564            kind,
565            next_child: 0,
566            context_pushed,
567            table_path,
568            row_path,
569            cell_path,
570            text_box_path,
571            content_suppressed: false,
572        });
573        Ok(())
574    }
575
576    fn append_text(&mut self, value: &str) {
577        let Some(pending) = self.pending_text.as_mut() else {
578            return;
579        };
580        pending.value.push_str(value);
581    }
582
583    fn append_segment(
584        &mut self,
585        value: &str,
586        source: SegmentSource,
587        path: Vec<usize>,
588    ) -> Result<(), ScanError> {
589        let Some(block_id) = self.current_block_id() else {
590            return Ok(());
591        };
592        self.append_segment_to(block_id, value, source, path)
593    }
594
595    fn append_segment_to(
596        &mut self,
597        block_id: usize,
598        value: &str,
599        source: SegmentSource,
600        path: Vec<usize>,
601    ) -> Result<(), ScanError> {
602        if value.is_empty() {
603            return Ok(());
604        }
605        if self.segment_count >= self.limits.segments {
606            return Err(ScanError::TooManySegments);
607        }
608        self.segment_count = self
609            .segment_count
610            .checked_add(1)
611            .ok_or(ScanError::TooManySegments)?;
612        let contexts = self.contexts();
613        let builder = self
614            .builders
615            .get_mut(&block_id)
616            .ok_or(ScanError::InvalidXml)?;
617        let units = value.encode_utf16().count();
618        let end_utf16 = builder
619            .utf16_len
620            .checked_add(units)
621            .ok_or(ScanError::TextLengthOverflow)?;
622        if contexts
623            .iter()
624            .any(|item| matches!(item, InlineContext::Hyperlink { .. }))
625        {
626            self.coverage.hyperlink_segments = self
627                .coverage
628                .hyperlink_segments
629                .checked_add(1)
630                .ok_or(ScanError::TooManySegments)?;
631        }
632        if contexts
633            .iter()
634            .any(|item| matches!(item, InlineContext::Revision(_)))
635        {
636            self.coverage.revision_segments = self
637                .coverage
638                .revision_segments
639                .checked_add(1)
640                .ok_or(ScanError::TooManySegments)?;
641        }
642        builder.text.push_str(value);
643        builder.segments.push(TextSegment {
644            start_utf16: builder.utf16_len,
645            end_utf16,
646            source,
647            contexts,
648            xml_path: path,
649        });
650        builder.utf16_len = end_utf16;
651        Ok(())
652    }
653
654    fn end(&mut self) -> Result<(), ScanError> {
655        let frame = self.frames.pop().ok_or(ScanError::InvalidXml)?;
656        match frame.kind {
657            FrameKind::Paragraph(block_id) => {
658                let builder = self
659                    .builders
660                    .remove(&block_id)
661                    .ok_or(ScanError::InvalidXml)?;
662                if self
663                    .completed
664                    .insert(
665                        block_id,
666                        TextBlock {
667                            text: builder.text,
668                            location: builder.location,
669                            segments: builder.segments,
670                        },
671                    )
672                    .is_some()
673                {
674                    return Err(ScanError::InvalidXml);
675                }
676                self.flush_completed()?;
677            }
678            FrameKind::Text => {
679                let pending = self.pending_text.take().ok_or(ScanError::InvalidXml)?;
680                if !pending.value.is_empty() {
681                    let block_id = pending.block_id.ok_or(ScanError::TextOutsideParagraph)?;
682                    self.append_segment_to(
683                        block_id,
684                        &pending.value,
685                        SegmentSource::Text,
686                        pending.path,
687                    )?;
688                }
689            }
690            FrameKind::AlternateContent { .. }
691            | FrameKind::AlternateContentBranch
692            | FrameKind::Other
693            | FrameKind::Run => {}
694        }
695        if frame.context_pushed {
696            self.active_contexts.pop().ok_or(ScanError::InvalidXml)?;
697        }
698        self.element_path.pop().ok_or(ScanError::InvalidXml)?;
699        Ok(())
700    }
701
702    fn flush_completed(&mut self) -> Result<(), ScanError> {
703        while let Some(block) = self.completed.remove(&self.next_emit_id) {
704            (self.sink)(block)?;
705            self.next_emit_id = self
706                .next_emit_id
707                .checked_add(1)
708                .ok_or(ScanError::TooManyBlocks)?;
709        }
710        Ok(())
711    }
712
713    fn finish(mut self) -> Result<PartCoverage, ScanError> {
714        self.flush_completed()?;
715        if !self.root_seen
716            || self.frames.len() != 1
717            || !self.element_path.is_empty()
718            || !self.builders.is_empty()
719            || !self.completed.is_empty()
720            || self.pending_text.is_some()
721            || !self.active_contexts.is_empty()
722            || self.next_emit_id != self.next_block_id
723        {
724            return Err(ScanError::InvalidXml);
725        }
726        Ok(self.coverage)
727    }
728}
729
730/// Scan one `WordprocessingML` part and deliver completed blocks in document
731/// order without retaining a second whole-part block collection.
732///
733/// A sink may observe blocks before the scanner reaches a later malformed
734/// element. Callers that require atomic behavior must stage sink output until
735/// this function returns successfully.
736///
737/// The input boundary is deliberately UTF-8. DOCX producers conventionally
738/// store `WordprocessingML` parts as UTF-8; callers with another XML encoding
739/// must transcode before scanning.
740///
741/// # Errors
742///
743/// Returns [`ScanError`] when the XML is malformed, violates a configured
744/// resource bound, contains text in an invalid location, or the sink rejects a
745/// block.
746pub fn scan_wordprocessing_part_with(
747    xml: &[u8],
748    limits: ScanLimits,
749    sink: impl FnMut(TextBlock) -> Result<(), ScanError>,
750) -> Result<PartCoverage, ScanError> {
751    std::str::from_utf8(xml).map_err(|_| ScanError::UnsupportedEncoding)?;
752    let mut reader = NsReader::from_reader(xml);
753    reader.config_mut().check_end_names = true;
754    let mut scanner = Scanner::new(limits, sink);
755    loop {
756        match reader.read_event().map_err(|_| ScanError::InvalidXml)? {
757            Event::Start(element) => scanner.start(&reader, &element)?,
758            Event::Empty(element) => {
759                scanner.start(&reader, &element)?;
760                scanner.end()?;
761            }
762            Event::Text(text) => {
763                let decoded = text.xml10_content().map_err(|_| ScanError::InvalidXml)?;
764                scanner.append_text(&decoded);
765            }
766            Event::CData(text) => {
767                let decoded = text.xml10_content().map_err(|_| ScanError::InvalidXml)?;
768                scanner.append_text(&decoded);
769            }
770            Event::GeneralRef(reference) => {
771                let value = if let Some(character) = reference
772                    .resolve_char_ref()
773                    .map_err(|_| ScanError::InvalidXml)?
774                {
775                    character.to_string()
776                } else {
777                    let name = reference.decode().map_err(|_| ScanError::InvalidXml)?;
778                    resolve_predefined_entity(&name)
779                        .ok_or(ScanError::InvalidXml)?
780                        .to_owned()
781                };
782                scanner.append_text(&value);
783            }
784            Event::End(_) => scanner.end()?,
785            Event::DocType(_) => return Err(ScanError::InvalidXml),
786            Event::Eof => break,
787            _ => {}
788        }
789    }
790    scanner.finish()
791}
792
793/// Scan one `WordprocessingML` part into an owned semantic projection.
794///
795/// # Errors
796///
797/// Returns [`ScanError`] when the XML is malformed, violates a configured
798/// resource bound, or contains `WordprocessingML` text outside a paragraph.
799pub fn scan_wordprocessing_part(xml: &[u8], limits: ScanLimits) -> Result<PartScan, ScanError> {
800    let mut blocks = Vec::new();
801    let coverage = scan_wordprocessing_part_with(xml, limits, |block| {
802        blocks.push(block);
803        Ok(())
804    })?;
805    Ok(PartScan { blocks, coverage })
806}
807
808#[cfg(test)]
809mod tests {
810    use std::fmt::Write as _;
811
812    use proptest::test_runner::TestCaseError;
813    use proptest::{collection, prop_assert, prop_assert_eq, proptest};
814
815    use super::{
816        BlockLocation, InlineContext, RevisionKind, ScanLimits, SegmentSource,
817        scan_wordprocessing_part,
818    };
819
820    const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
821    const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
822    const MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
823
824    #[test]
825    fn scans_locations_contexts_controls_and_utf16() -> Result<(), super::ScanError> {
826        let xml = format!(
827            "<x:document xmlns:x=\"{W}\" xmlns:rel=\"{R}\"><x:body><x:p><x:r><x:t>😀 </x:t></x:r><x:hyperlink rel:id=\"r1\" x:anchor=\"a\"><x:r><x:t>A</x:t></x:r></x:hyperlink><x:ins><x:r><x:t>B</x:t></x:r></x:ins><x:r><x:tab/><x:br/></x:r></x:p><x:tbl><x:tr><x:tc><x:p><x:r><x:t>C</x:t></x:r></x:p></x:tc></x:tr></x:tbl></x:body></x:document>"
828        );
829        let scan = scan_wordprocessing_part(xml.as_bytes(), ScanLimits::default())?;
830        assert_eq!(scan.blocks.len(), 2);
831        let first = scan.blocks.first().ok_or(super::ScanError::InvalidXml)?;
832        let second = scan.blocks.get(1).ok_or(super::ScanError::InvalidXml)?;
833        assert_eq!(first.text, "😀 AB\t\n");
834        assert_eq!(
835            first
836                .segments
837                .first()
838                .ok_or(super::ScanError::InvalidXml)?
839                .end_utf16,
840            3
841        );
842        assert_eq!(
843            first
844                .segments
845                .get(3)
846                .ok_or(super::ScanError::InvalidXml)?
847                .source,
848            SegmentSource::Tab
849        );
850        assert!(matches!(
851          first
852            .segments
853            .get(1)
854            .and_then(|segment| segment.contexts.first()),
855          Some(InlineContext::Hyperlink {
856            relationship_id: Some(value),
857            anchor: Some(anchor),
858          }) if value == "r1" && anchor == "a"
859        ));
860        assert!(matches!(
861            first
862                .segments
863                .get(2)
864                .and_then(|segment| segment.contexts.first()),
865            Some(InlineContext::Revision(RevisionKind::Insertion))
866        ));
867        assert!(matches!(
868            second.location,
869            BlockLocation::TableCellParagraph { .. }
870        ));
871        Ok(())
872    }
873
874    #[test]
875    fn projects_exactly_one_markup_compatibility_branch() -> Result<(), super::ScanError> {
876        let unsupported_choice = format!(
877            "<w:document xmlns:w=\"{W}\" xmlns:mc=\"{MC}\" xmlns:x=\"urn:unsupported\"><w:body><mc:AlternateContent><mc:Choice Requires=\"x\"><w:p><w:r><w:t>choice</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>fallback</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"
878        );
879        let fallback_scan =
880            scan_wordprocessing_part(unsupported_choice.as_bytes(), ScanLimits::default())?;
881        assert_eq!(
882            fallback_scan
883                .blocks
884                .iter()
885                .map(|block| block.text.as_str())
886                .collect::<Vec<_>>(),
887            ["fallback"]
888        );
889        assert_eq!(fallback_scan.coverage.unsupported_alternate_content, 1);
890
891        let supported_choice = format!(
892            "<w:document xmlns:w=\"{W}\" xmlns:mc=\"{MC}\"><w:body><mc:AlternateContent><mc:Choice Requires=\"w\"><w:p><w:r><w:t>choice</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>fallback</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"
893        );
894        let choice_scan =
895            scan_wordprocessing_part(supported_choice.as_bytes(), ScanLimits::default())?;
896        assert_eq!(
897            choice_scan
898                .blocks
899                .iter()
900                .map(|block| block.text.as_str())
901                .collect::<Vec<_>>(),
902            ["choice"]
903        );
904        Ok(())
905    }
906
907    #[test]
908    fn emits_text_controls_only_from_run_content() -> Result<(), super::ScanError> {
909        let xml = format!(
910            "<w:document xmlns:w=\"{W}\"><w:body><w:p><w:pPr><w:tabs><w:tab w:val=\"left\"/></w:tabs></w:pPr><w:r><w:t>A</w:t><w:tab/><w:br/></w:r></w:p></w:body></w:document>"
911        );
912        let scan = scan_wordprocessing_part(xml.as_bytes(), ScanLimits::default())?;
913        let block = scan.blocks.first().ok_or(super::ScanError::InvalidXml)?;
914        assert_eq!(block.text, "A\t\n");
915        assert_eq!(
916            block
917                .segments
918                .iter()
919                .map(|segment| segment.source)
920                .collect::<Vec<_>>(),
921            [
922                SegmentSource::Text,
923                SegmentSource::Tab,
924                SegmentSource::Break
925            ]
926        );
927        Ok(())
928    }
929
930    #[test]
931    fn decodes_xml_references_exactly_once() -> Result<(), super::ScanError> {
932        let xml = format!(
933            "<w:document xmlns:w=\"{W}\"><w:body><w:p><w:r><w:t>&amp;amp; &lt; &#x1F600;</w:t></w:r></w:p></w:body></w:document>"
934        );
935        let scan = scan_wordprocessing_part(xml.as_bytes(), ScanLimits::default())?;
936        assert_eq!(
937            scan.blocks
938                .first()
939                .ok_or(super::ScanError::InvalidXml)?
940                .text,
941            "&amp; < 😀"
942        );
943        Ok(())
944    }
945
946    #[test]
947    fn reports_the_utf8_input_boundary_explicitly() {
948        assert_eq!(
949            scan_wordprocessing_part(&[0xff], ScanLimits::default()),
950            Err(super::ScanError::UnsupportedEncoding)
951        );
952    }
953
954    #[test]
955    fn rejects_namespace_spoofing_and_accepts_strict_ooxml() -> Result<(), super::ScanError> {
956        let spoof = b"<w:document xmlns:w=\"urn:not-word\"><w:body><w:p><w:r><w:t>secret</w:t></w:r></w:p></w:body></w:document>";
957        let spoof_scan = scan_wordprocessing_part(spoof, ScanLimits::default())?;
958        assert!(spoof_scan.blocks.is_empty());
959
960        let strict = b"<d:document xmlns:d=\"http://purl.oclc.org/ooxml/wordprocessingml/main\"><d:body><d:p><d:r><d:t>strict</d:t></d:r></d:p></d:body></d:document>";
961        let strict_scan = scan_wordprocessing_part(strict, ScanLimits::default())?;
962        assert_eq!(
963            strict_scan
964                .blocks
965                .first()
966                .ok_or(super::ScanError::InvalidXml)?
967                .text,
968            "strict"
969        );
970        Ok(())
971    }
972
973    #[test]
974    fn nested_namespace_rebinding_cannot_spoof_wordprocessing_elements()
975    -> Result<(), super::ScanError> {
976        let xml = format!(
977            "<w:document xmlns:w=\"{W}\"><w:body><w:p><w:r><w:t>before</w:t></w:r></w:p><x xmlns:w=\"urn:not-word\"><w:p><w:r><w:t>hidden</w:t></w:r></w:p></x><w:p><w:r><w:t>after</w:t></w:r></w:p></w:body></w:document>"
978        );
979        let scan = scan_wordprocessing_part(xml.as_bytes(), ScanLimits::default())?;
980        let texts = scan
981            .blocks
982            .iter()
983            .map(|block| block.text.as_str())
984            .collect::<Vec<_>>();
985        assert_eq!(texts, ["before", "after"]);
986        Ok(())
987    }
988
989    #[test]
990    fn rejects_multiple_roots_doctypes_and_resource_limit_overruns() {
991        let multiple_roots = format!("<w:p xmlns:w=\"{W}\"/><w:p xmlns:w=\"{W}\"/>");
992        assert_eq!(
993            scan_wordprocessing_part(multiple_roots.as_bytes(), ScanLimits::default()),
994            Err(super::ScanError::InvalidXml)
995        );
996        let doctype = format!("<!DOCTYPE w:document><w:document xmlns:w=\"{W}\"/>");
997        assert_eq!(
998            scan_wordprocessing_part(doctype.as_bytes(), ScanLimits::default()),
999            Err(super::ScanError::InvalidXml)
1000        );
1001        let two_blocks =
1002            format!("<w:document xmlns:w=\"{W}\"><w:body><w:p/><w:p/></w:body></w:document>");
1003        assert_eq!(
1004            scan_wordprocessing_part(
1005                two_blocks.as_bytes(),
1006                ScanLimits {
1007                    blocks: 1,
1008                    ..ScanLimits::default()
1009                }
1010            ),
1011            Err(super::ScanError::TooManyBlocks)
1012        );
1013    }
1014
1015    proptest! {
1016      #[test]
1017      fn arbitrary_prefixes_and_run_splits_preserve_text_and_utf16_offsets(
1018        prefix in "[a-z]{1,8}",
1019        fragments in collection::vec("[A-Za-z0-9 ]{0,16}", 1..32),
1020      ) {
1021        let mut runs = String::new();
1022        for fragment in &fragments {
1023          write!(
1024            &mut runs,
1025            "<{prefix}:r><{prefix}:t>{fragment}</{prefix}:t></{prefix}:r>"
1026          )
1027          .map_err(|error| TestCaseError::fail(error.to_string()))?;
1028        }
1029        let xml = format!(
1030          "<{prefix}:document xmlns:{prefix}=\"{W}\"><{prefix}:body><{prefix}:p>{runs}</{prefix}:p></{prefix}:body></{prefix}:document>"
1031        );
1032        let scan = scan_wordprocessing_part(xml.as_bytes(), ScanLimits::default())
1033          .map_err(|error| TestCaseError::fail(error.to_string()))?;
1034        let block = scan
1035          .blocks
1036          .first()
1037          .ok_or_else(|| TestCaseError::fail("missing projected paragraph"))?;
1038        prop_assert_eq!(&block.text, &fragments.concat());
1039        let mut expected_start = 0_usize;
1040        for segment in &block.segments {
1041          prop_assert_eq!(segment.start_utf16, expected_start);
1042          prop_assert!(segment.end_utf16 >= segment.start_utf16);
1043          expected_start = segment.end_utf16;
1044        }
1045        prop_assert_eq!(expected_start, block.text.encode_utf16().count());
1046      }
1047    }
1048}