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