1use crate::check_parser_iteration;
4use projectatlas_core::symbols::{CodeSymbol, ParserKind, SymbolGraph, SymbolKind};
5use projectatlas_core::{IndexWorkControl, IndexWorkFailure, IndexWorkStage};
6use quick_xml::NsReader;
7use quick_xml::events::BytesStart;
8use quick_xml::events::{BytesRef, Event};
9use quick_xml::name::{QName, ResolveResult};
10use std::collections::{BTreeMap, HashMap, HashSet};
11use std::fmt;
12use std::io::{Cursor, Read};
13use std::path::Path;
14use std::sync::{Mutex, MutexGuard, TryLockError};
15use std::time::Duration;
16use thiserror::Error;
17use zip::read::{ZipFile, ZipFileEntry};
18use zip::result::{ZipError, ZipResult};
19use zip::unstable::stream::{ZipStreamReader, ZipStreamVisitor};
20use zip::{CompressionMethod, ZipArchive};
21
22#[path = "../../../packaging/pdf-parser/limits.rs"]
23mod limits;
24mod pdf_runtime;
25
26pub const LOPDF_VERSION: &str = "0.44.0";
28pub const PDF_EXTRACT_VERSION: &str = "0.12.0+projectatlas";
30pub const QUICK_XML_VERSION: &str = "0.42.0";
32pub const MAX_DOCUMENT_COMPRESSED_BYTES: usize = limits::INPUT_LIMIT;
34pub const MAX_DOCUMENT_EXPANDED_BYTES: usize = limits::EXPANDED_LIMIT;
36pub const MAX_DOCUMENT_OUTPUT_BYTES: usize = limits::OUTPUT_LIMIT;
38pub const MAX_DOCUMENT_MEMORY_BYTES: usize = 96 * 1024 * 1024;
40pub const MAX_DOCUMENT_ENTRIES: usize = 256;
42pub const MAX_DOCUMENT_RECURSION_DEPTH: usize = 1;
44const MAX_DOCX_XML_DEPTH: usize = 64;
46const MAX_DOCX_PART_NAME_BYTES: usize = 1024;
48const MAX_DOCX_REFERENCE_ID_BYTES: usize = 256;
50const MAX_DOCX_FONT_NAME_BYTES: usize = 256;
52const MAX_DOCX_METADATA_BYTES: usize = 2 * 1024 * 1024;
54const MAX_DOCX_METADATA_RECORDS: usize = 4096;
56const MAX_DOCX_DECOMPRESSION_WORK_BYTES: usize = MAX_DOCUMENT_EXPANDED_BYTES * 2;
58const MAX_DOCX_IGNORABLE_NAMESPACES: usize = 64;
60pub const MAX_DOCUMENT_FACTS: usize = limits::FACT_LIMIT;
62pub const DOCX_DOCUMENT_PART: &str = "word/document.xml";
64pub const DOCUMENT_COVERAGE_SYMBOL: &str = "document-text-coverage";
66
67static DOCUMENT_EXECUTION: Mutex<()> = Mutex::new(());
70
71fn lock_document_execution(
73 control: &IndexWorkControl,
74 stage: IndexWorkStage,
75) -> Result<MutexGuard<'static, ()>, DocumentExtractionError> {
76 loop {
77 control.check(stage)?;
78 match DOCUMENT_EXECUTION.try_lock() {
79 Ok(guard) => return Ok(guard),
80 Err(TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
82 Err(TryLockError::WouldBlock) => {
83 std::thread::park_timeout(Duration::from_millis(10));
84 }
85 }
86 }
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum DocumentFormat {
92 Pdf,
94 Docx,
96}
97
98impl DocumentFormat {
99 #[must_use]
101 pub const fn language(self) -> &'static str {
102 match self {
103 Self::Pdf => "pdf",
104 Self::Docx => "docx",
105 }
106 }
107}
108
109impl fmt::Display for DocumentFormat {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 formatter.write_str(self.language())
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
117pub enum DocumentCompleteness {
118 Complete,
120 Partial {
122 gaps: Vec<DocumentCoverageGap>,
124 },
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum DocumentCoverageGap {
130 UnknownSymbolMapping,
132 UnevaluatedField,
134 UnexaminedStory,
136 ConditionalStory,
138 UnresolvedVisibility,
140 ResourceLimit(DocumentLimit),
142}
143
144impl DocumentCoverageGap {
145 #[must_use]
147 pub const fn as_str(self) -> &'static str {
148 match self {
149 Self::UnknownSymbolMapping => "unknown_symbol_mapping",
150 Self::UnevaluatedField => "unevaluated_field",
151 Self::UnexaminedStory => "unexamined_story",
152 Self::ConditionalStory => "conditional_story",
153 Self::UnresolvedVisibility => "unresolved_visibility",
154 Self::ResourceLimit(DocumentLimit::OutputBytes) => "resource_limit:output_bytes",
155 Self::ResourceLimit(DocumentLimit::FactCount) => "resource_limit:fact_count",
156 Self::ResourceLimit(DocumentLimit::MemoryBytes) => "resource_limit:memory_bytes",
157 Self::ResourceLimit(DocumentLimit::ExpandedBytes) => "resource_limit:expanded_bytes",
158 Self::ResourceLimit(DocumentLimit::ParserWorkBytes) => {
159 "resource_limit:parser_work_bytes"
160 }
161 Self::ResourceLimit(DocumentLimit::ExecutionFuel) => "resource_limit:execution_fuel",
162 Self::ResourceLimit(_) => "resource_limit:other",
163 }
164 }
165}
166
167#[derive(Clone, Debug, Eq, PartialEq)]
169pub enum DocumentLocator {
170 Pdf {
172 page: usize,
174 text_start: usize,
176 text_end: usize,
178 },
179 Docx {
181 part: String,
183 paragraph: usize,
185 run: usize,
187 text_start: usize,
189 text_end: usize,
191 },
192}
193
194impl fmt::Display for DocumentLocator {
195 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196 match self {
197 Self::Pdf {
198 page,
199 text_start,
200 text_end,
201 } => write!(
202 formatter,
203 "pdf:page={page};text-span={text_start}..{text_end}"
204 ),
205 Self::Docx {
206 part,
207 paragraph,
208 run,
209 text_start,
210 text_end,
211 } => write!(
212 formatter,
213 "docx:part={part};paragraph={paragraph};run={run};text-span={text_start}..{text_end}"
214 ),
215 }
216 }
217}
218
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub enum DocumentParserProvenance {
222 PdfExtract,
224 QuickXml,
226}
227
228impl fmt::Display for DocumentParserProvenance {
229 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230 formatter.write_str(match self {
231 Self::PdfExtract => "pdf-extract-0.12.0+projectatlas",
232 Self::QuickXml => "quick-xml-0.42.0",
233 })
234 }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct DocumentFact {
240 pub text: String,
242 pub locator: DocumentLocator,
244 pub line_start: usize,
246 pub line_end: usize,
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct DocumentSymbol {
253 pub font: Option<String>,
255 pub code: Option<u16>,
257 pub locator: DocumentLocator,
259 pub line: usize,
261 pub unicode: Option<char>,
263}
264
265#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct DocumentFacts {
268 pub format: DocumentFormat,
270 pub text: String,
272 pub facts: Vec<DocumentFact>,
274 pub symbols: Vec<DocumentSymbol>,
276 pub completeness: DocumentCompleteness,
278 pub provenance: DocumentParserProvenance,
280}
281
282impl DocumentFacts {
283 #[must_use]
286 pub fn symbol_graph(&self, path: &str, _language: Option<&str>) -> SymbolGraph {
287 let language = Some(self.format.language().to_owned());
288 let mut symbols: Vec<CodeSymbol> = self
289 .facts
290 .iter()
291 .enumerate()
292 .map(|(index, fact)| CodeSymbol {
293 path: path.to_owned(),
294 language: language.clone(),
295 name: format!("document-block-{}", index + 1),
296 kind: SymbolKind::Value,
297 signature: fact.locator.to_string(),
298 exported: false,
299 documentation: None,
300 line_start: fact.line_start,
301 line_end: fact.line_end,
302 source_selector: None,
303 parent: None,
304 parser: ParserKind::Structural,
305 detail: Some(format!(
306 "format={};provenance={};completeness={:?};text-bytes={}",
307 self.format,
308 self.provenance,
309 self.completeness,
310 fact.text.len()
311 )),
312 })
313 .collect();
314 symbols.extend(self.symbols.iter().enumerate().map(|(index, symbol)| {
315 let code = symbol
316 .code
317 .map_or_else(|| "missing".to_owned(), |code| format!("{code:04X}"));
318 CodeSymbol {
319 path: path.to_owned(),
320 language: language.clone(),
321 name: format!("document-symbol-{}", index + 1),
322 kind: SymbolKind::Value,
323 signature: format!("{};font={:?};code={code}", symbol.locator, symbol.font),
324 exported: false,
325 documentation: None,
326 line_start: symbol.line,
327 line_end: symbol.line,
328 source_selector: None,
329 parent: None,
330 parser: ParserKind::Structural,
331 detail: Some(format!(
332 "unicode={:?};completeness={:?}",
333 symbol.unicode, self.completeness
334 )),
335 }
336 }));
337 if let DocumentCompleteness::Partial { gaps } = &self.completeness {
338 symbols.push(CodeSymbol {
339 path: path.to_owned(),
340 language: language.clone(),
341 name: DOCUMENT_COVERAGE_SYMBOL.to_owned(),
342 kind: SymbolKind::Unknown,
343 signature: gaps
344 .iter()
345 .map(|gap| gap.as_str())
346 .collect::<Vec<_>>()
347 .join(","),
348 exported: false,
349 documentation: None,
350 line_start: 1,
351 line_end: 1,
352 source_selector: None,
353 parent: None,
354 parser: ParserKind::Structural,
355 detail: Some("document text coverage is incomplete".to_owned()),
356 });
357 }
358 SymbolGraph {
359 path: path.to_owned(),
360 language,
361 parser: ParserKind::Structural,
362 symbols,
363 relations: Vec::new(),
364 }
365 }
366}
367
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
370pub enum DocumentLimit {
371 InputBytes,
373 CompressedBytes,
375 ExpandedBytes,
377 ParserWorkBytes,
379 OutputBytes,
381 MemoryBytes,
383 EntryCount,
385 FactCount,
387 NestingDepth,
389 ExecutionFuel,
391}
392
393impl fmt::Display for DocumentLimit {
394 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
395 formatter.write_str(match self {
396 Self::InputBytes => "input_bytes",
397 Self::CompressedBytes => "compressed_bytes",
398 Self::ExpandedBytes => "expanded_bytes",
399 Self::ParserWorkBytes => "parser_work_bytes",
400 Self::OutputBytes => "output_bytes",
401 Self::MemoryBytes => "memory_bytes",
402 Self::EntryCount => "entry_count",
403 Self::FactCount => "fact_count",
404 Self::NestingDepth => "nesting_depth",
405 Self::ExecutionFuel => "execution_fuel",
406 })
407 }
408}
409
410#[derive(Debug, Error)]
412pub enum DocumentExtractionError {
413 #[error("unsupported document format for language {language}")]
415 UnsupportedFormat {
416 language: String,
418 },
419 #[error("document magic does not match expected {expected} format; found {found}")]
421 MismatchedMagic {
422 expected: DocumentFormat,
424 found: &'static str,
426 },
427 #[error("document exceeded {limit}: observed {observed}, limit {maximum}")]
429 ResourceLimit {
430 limit: DocumentLimit,
432 observed: usize,
434 maximum: usize,
436 },
437 #[error("encrypted PDF documents are unsupported")]
439 EncryptedPdf,
440 #[error("unsupported PDF text semantics")]
442 UnsupportedPdfInput,
443 #[error("malformed {format} document: {message}")]
445 Malformed {
446 format: DocumentFormat,
448 message: String,
450 },
451 #[error("invalid DOCX package: {message}")]
453 InvalidDocxPackage {
454 message: String,
456 },
457 #[error("unsupported DOCX package input: {message}")]
459 UnsupportedDocxInput {
460 message: String,
462 },
463 #[error(transparent)]
465 Work(#[from] IndexWorkFailure),
466}
467
468#[must_use]
470pub fn document_format_for_path(path: &str, language: Option<&str>) -> Option<DocumentFormat> {
471 let language = language.or_else(|| Path::new(path).extension()?.to_str())?;
472 match language.to_ascii_lowercase().as_str() {
473 "pdf" => Some(DocumentFormat::Pdf),
474 "docx" => Some(DocumentFormat::Docx),
475 _ => None,
476 }
477}
478
479pub fn extract_document_text_controlled(
486 bytes: &[u8],
487 path: &str,
488 language: Option<&str>,
489 control: &IndexWorkControl,
490) -> Result<DocumentFacts, DocumentExtractionError> {
491 extract_document_controlled_with_stage(
492 bytes,
493 path,
494 language,
495 control,
496 IndexWorkStage::TextIndex,
497 )
498}
499
500pub fn extract_document_graph_controlled(
507 bytes: &[u8],
508 path: &str,
509 language: Option<&str>,
510 control: &IndexWorkControl,
511) -> Result<SymbolGraph, DocumentExtractionError> {
512 Ok(
513 extract_document_symbol_facts_controlled(bytes, path, language, control)?
514 .symbol_graph(path, language),
515 )
516}
517
518pub fn extract_document_symbol_facts_controlled(
525 bytes: &[u8],
526 path: &str,
527 language: Option<&str>,
528 control: &IndexWorkControl,
529) -> Result<DocumentFacts, DocumentExtractionError> {
530 extract_document_controlled_with_stage(
531 bytes,
532 path,
533 language,
534 control,
535 IndexWorkStage::SymbolParsing,
536 )
537}
538
539fn extract_document_controlled_with_stage(
541 bytes: &[u8],
542 path: &str,
543 language: Option<&str>,
544 control: &IndexWorkControl,
545 stage: IndexWorkStage,
546) -> Result<DocumentFacts, DocumentExtractionError> {
547 control.check(stage)?;
548 if bytes.len() > MAX_DOCUMENT_COMPRESSED_BYTES {
549 return Err(DocumentExtractionError::ResourceLimit {
550 limit: DocumentLimit::InputBytes,
551 observed: bytes.len(),
552 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
553 });
554 }
555 let format = document_format_for_path(path, language).ok_or_else(|| {
556 DocumentExtractionError::UnsupportedFormat {
557 language: language.unwrap_or("unknown").to_owned(),
558 }
559 })?;
560 let _execution = lock_document_execution(control, stage)?;
561 let facts = match format {
562 DocumentFormat::Pdf => extract_pdf(bytes, control, stage)?,
563 DocumentFormat::Docx => extract_docx(bytes, control, stage)?,
564 };
565 control.check(stage)?;
566 Ok(facts)
567}
568
569fn extract_pdf(
571 bytes: &[u8],
572 control: &IndexWorkControl,
573 stage: IndexWorkStage,
574) -> Result<DocumentFacts, DocumentExtractionError> {
575 if !bytes.starts_with(b"%PDF-") {
576 return Err(DocumentExtractionError::MismatchedMagic {
577 expected: DocumentFormat::Pdf,
578 found: if bytes.starts_with(b"PK\x03\x04") {
579 "docx"
580 } else {
581 "unknown"
582 },
583 });
584 }
585 let pages = match pdf_runtime::extract_pages(bytes, control, stage) {
586 Ok(pdf_runtime::PdfPages::Complete(pages)) => pages,
587 Ok(pdf_runtime::PdfPages::Incomplete(limit)) => {
588 return Ok(empty_partial_document(
589 DocumentFormat::Pdf,
590 DocumentParserProvenance::PdfExtract,
591 DocumentCoverageGap::ResourceLimit(limit),
592 ));
593 }
594 Err(error) => return Err(error),
595 };
596 pdf_pages_to_facts(&pages, control, stage)
597}
598
599fn pdf_pages_to_facts(
601 pages: &[(u32, String)],
602 control: &IndexWorkControl,
603 stage: IndexWorkStage,
604) -> Result<DocumentFacts, DocumentExtractionError> {
605 let mut text = String::new();
606 let mut facts = Vec::new();
607 for (page_number, page) in pages {
608 control.check(stage)?;
609 let mut page_offset = 0usize;
610 for line in page.split('\n') {
611 let source_start = page_offset;
612 page_offset = page_offset.saturating_add(line.len().saturating_add(1));
613 if line.trim().is_empty() {
614 continue;
615 }
616 if facts.len() >= MAX_DOCUMENT_FACTS {
617 return Ok(empty_partial_document(
618 DocumentFormat::Pdf,
619 DocumentParserProvenance::PdfExtract,
620 DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount),
621 ));
622 }
623 let required = text
624 .len()
625 .saturating_add(usize::from(!text.is_empty()))
626 .saturating_add(line.len());
627 if required > MAX_DOCUMENT_OUTPUT_BYTES {
628 return Ok(empty_partial_document(
629 DocumentFormat::Pdf,
630 DocumentParserProvenance::PdfExtract,
631 DocumentCoverageGap::ResourceLimit(DocumentLimit::OutputBytes),
632 ));
633 }
634 if !text.is_empty() {
635 push_output_byte(&mut text, b'\n')?;
636 }
637 text.push_str(line);
638 let end = text.len();
639 facts.push(DocumentFact {
640 line_start: facts.len() + 1,
641 line_end: facts.len() + 1,
642 text: line.to_owned(),
643 locator: DocumentLocator::Pdf {
644 page: usize::try_from(*page_number).map_err(|_error| {
645 DocumentExtractionError::Malformed {
646 format: DocumentFormat::Pdf,
647 message: "PDF page number exceeds host range".to_owned(),
648 }
649 })?,
650 text_start: source_start,
651 text_end: source_start.saturating_add(line.len()),
652 },
653 });
654 debug_assert!(end <= MAX_DOCUMENT_OUTPUT_BYTES);
655 }
656 }
657 Ok(DocumentFacts {
658 format: DocumentFormat::Pdf,
659 text,
660 facts,
661 symbols: Vec::new(),
662 completeness: DocumentCompleteness::Complete,
663 provenance: DocumentParserProvenance::PdfExtract,
664 })
665}
666
667fn push_output_byte(text: &mut String, byte: u8) -> Result<(), DocumentExtractionError> {
669 if text.len().saturating_add(1) > MAX_DOCUMENT_OUTPUT_BYTES {
670 return Err(DocumentExtractionError::ResourceLimit {
671 limit: DocumentLimit::OutputBytes,
672 observed: text.len().saturating_add(1),
673 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
674 });
675 }
676 text.push(char::from(byte));
677 Ok(())
678}
679
680fn check_memory_budget(observed: usize) -> Result<(), DocumentExtractionError> {
682 if observed > MAX_DOCUMENT_MEMORY_BYTES {
683 return Err(DocumentExtractionError::ResourceLimit {
684 limit: DocumentLimit::MemoryBytes,
685 observed,
686 maximum: MAX_DOCUMENT_MEMORY_BYTES,
687 });
688 }
689 Ok(())
690}
691
692struct DocxArchiveAdmission<'a> {
694 names: HashSet<String>,
696 entries: usize,
698 control: &'a IndexWorkControl,
700 stage: IndexWorkStage,
702 failure: Option<DocumentExtractionError>,
704}
705
706impl DocxArchiveAdmission<'_> {
707 fn admit_part(&mut self, metadata: &ZipFileEntry<'_>) -> Result<(), DocumentExtractionError> {
709 self.control.check(self.stage)?;
710 self.entries =
711 self.entries
712 .checked_add(1)
713 .ok_or(DocumentExtractionError::ResourceLimit {
714 limit: DocumentLimit::EntryCount,
715 observed: usize::MAX,
716 maximum: MAX_DOCUMENT_ENTRIES,
717 })?;
718 if self.entries > MAX_DOCUMENT_ENTRIES {
719 return Err(DocumentExtractionError::ResourceLimit {
720 limit: DocumentLimit::EntryCount,
721 observed: self.entries,
722 maximum: MAX_DOCUMENT_ENTRIES,
723 });
724 }
725 if std::str::from_utf8(metadata.name_raw()).is_err() {
726 return Err(DocumentExtractionError::InvalidDocxPackage {
727 message: "DOCX package part metadata is not UTF-8".to_owned(),
728 });
729 }
730 let name =
731 metadata
732 .name()
733 .map_err(|error| DocumentExtractionError::InvalidDocxPackage {
734 message: error.to_string(),
735 })?;
736 if name.len() > MAX_DOCX_PART_NAME_BYTES {
737 return Err(DocumentExtractionError::InvalidDocxPackage {
738 message: format!("DOCX package part name exceeds {MAX_DOCX_PART_NAME_BYTES} bytes"),
739 });
740 }
741 if metadata.enclosed_name().is_none()
742 || name.contains('\\')
743 || name.starts_with('/')
744 || !self.names.insert(name.to_string())
745 {
746 return Err(DocumentExtractionError::InvalidDocxPackage {
747 message: format!("unsafe or duplicate package part {name}"),
748 });
749 }
750 Ok(())
751 }
752}
753
754impl ZipStreamVisitor for DocxArchiveAdmission<'_> {
755 fn visit_file<R: Read>(&mut self, _file: &mut ZipFile<'_, R>) -> ZipResult<()> {
756 Err(ZipError::InvalidArchive(
757 "DOCX metadata traversal reached a local entry".into(),
758 ))
759 }
760
761 fn visit_additional_metadata(&mut self, metadata: &ZipFileEntry<'_>) -> ZipResult<()> {
762 self.admit_part(metadata).map_err(|error| {
763 self.failure = Some(error);
764 ZipError::InvalidArchive("DOCX metadata admission failed".into())
765 })
766 }
767}
768
769fn extract_docx(
771 bytes: &[u8],
772 control: &IndexWorkControl,
773 stage: IndexWorkStage,
774) -> Result<DocumentFacts, DocumentExtractionError> {
775 if !bytes.starts_with(b"PK\x03\x04") {
776 return Err(DocumentExtractionError::MismatchedMagic {
777 expected: DocumentFormat::Docx,
778 found: if bytes.starts_with(b"%PDF-") {
779 "pdf"
780 } else {
781 "unknown"
782 },
783 });
784 }
785 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|error| {
786 DocumentExtractionError::InvalidDocxPackage {
787 message: error.to_string(),
788 }
789 })?;
790 if archive.len() > MAX_DOCUMENT_ENTRIES {
791 return Err(DocumentExtractionError::ResourceLimit {
792 limit: DocumentLimit::EntryCount,
793 observed: archive.len(),
794 maximum: MAX_DOCUMENT_ENTRIES,
795 });
796 }
797 let mut admission = DocxArchiveAdmission {
798 names: HashSet::new(),
799 entries: 0,
800 control,
801 stage,
802 failure: None,
803 };
804 let mut metadata = Cursor::new(bytes);
805 metadata.set_position(archive.central_directory_start());
806 let result = ZipStreamReader::new(metadata).visit(&mut admission);
809 if let Some(error) = admission.failure {
810 return Err(error);
811 }
812 result.map_err(|error| DocumentExtractionError::InvalidDocxPackage {
813 message: error.to_string(),
814 })?;
815 if admission.entries != archive.len() {
816 return Err(DocumentExtractionError::InvalidDocxPackage {
817 message: "DOCX metadata traversal is incomplete".to_owned(),
818 });
819 }
820 let names = admission.names;
821 let mut compressed_bytes = 0usize;
822 let mut expanded_bytes = 0usize;
823 for index in 0..archive.len() {
824 check_parser_iteration(index, &mut || control.check(stage))?;
825 let entry = archive.by_index_raw(index).map_err(|error| {
826 DocumentExtractionError::InvalidDocxPackage {
827 message: error.to_string(),
828 }
829 })?;
830 let name = entry
831 .name()
832 .map_err(|error| DocumentExtractionError::InvalidDocxPackage {
833 message: error.to_string(),
834 })?;
835 let compression = entry.compression();
836 let compressed_size = entry.compressed_size();
837 let expanded_size = entry.size();
838 if !matches!(
839 compression,
840 CompressionMethod::Stored | CompressionMethod::Deflated
841 ) {
842 return Err(DocumentExtractionError::UnsupportedDocxInput {
843 message: format!("unsupported compression for package part {name}"),
844 });
845 }
846 let name = name.into_owned();
847 drop(entry);
848 archive.by_index(index).map_err(|error| match error {
849 zip::result::ZipError::UnsupportedArchive(message) => {
850 DocumentExtractionError::UnsupportedDocxInput {
851 message: message.to_owned(),
852 }
853 }
854 error => DocumentExtractionError::InvalidDocxPackage {
855 message: error.to_string(),
856 },
857 })?;
858 if name.ends_with('/') {
859 continue;
860 }
861 let compressed = usize::try_from(compressed_size).map_err(|_error| {
862 DocumentExtractionError::ResourceLimit {
863 limit: DocumentLimit::CompressedBytes,
864 observed: usize::MAX,
865 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
866 }
867 })?;
868 let expanded = usize::try_from(expanded_size).map_err(|_error| {
869 DocumentExtractionError::ResourceLimit {
870 limit: DocumentLimit::ExpandedBytes,
871 observed: usize::MAX,
872 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
873 }
874 })?;
875 compressed_bytes = compressed_bytes.saturating_add(compressed);
876 expanded_bytes = expanded_bytes.saturating_add(expanded);
877 if compressed_bytes > MAX_DOCUMENT_COMPRESSED_BYTES {
878 return Err(DocumentExtractionError::ResourceLimit {
879 limit: DocumentLimit::CompressedBytes,
880 observed: compressed_bytes,
881 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
882 });
883 }
884 if expanded_bytes > MAX_DOCUMENT_EXPANDED_BYTES {
885 return Err(DocumentExtractionError::ResourceLimit {
886 limit: DocumentLimit::ExpandedBytes,
887 observed: expanded_bytes,
888 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
889 });
890 }
891 }
892 let mut read_budget = DocxReadBudget::default();
893 if !names.contains("[Content_Types].xml") || !names.contains("_rels/.rels") {
894 return Err(DocumentExtractionError::InvalidDocxPackage {
895 message: "DOCX package is missing content types or root relationships".to_owned(),
896 });
897 }
898 let content_types = if names.contains("[Content_Types].xml") {
899 let manifest = read_docx_package_part(
900 &mut archive,
901 "[Content_Types].xml",
902 &mut read_budget,
903 control,
904 stage,
905 )?;
906 Some(parse_docx_content_types(&manifest, control, stage)?)
907 } else {
908 None
909 };
910 let main_part = if names.contains("_rels/.rels") {
911 let package_rels = read_docx_package_part(
912 &mut archive,
913 "_rels/.rels",
914 &mut read_budget,
915 control,
916 stage,
917 )?;
918 let relationships = parse_docx_relationships(&package_rels, control, stage)?;
919 let mut main = relationships
920 .values()
921 .filter(|(kind, _, _)| kind == "officeDocument");
922 let Some((_, target, external)) = main.next() else {
923 return Err(DocumentExtractionError::InvalidDocxPackage {
924 message: "DOCX package has no unique internal main-document relationship"
925 .to_owned(),
926 });
927 };
928 if main.next().is_some() {
929 return Err(DocumentExtractionError::InvalidDocxPackage {
930 message: "DOCX package has no unique internal main-document relationship"
931 .to_owned(),
932 });
933 }
934 if *external {
935 return Err(DocumentExtractionError::InvalidDocxPackage {
936 message: "DOCX main-document relationship is external".to_owned(),
937 });
938 }
939 resolve_docx_story_target(target, "", &names)?
940 } else {
941 DOCX_DOCUMENT_PART.to_owned()
942 };
943 if let Some(types) = &content_types {
944 validate_docx_content_type(&main_part, "document.main", types)?;
945 }
946 let xml =
947 match read_docx_package_part(&mut archive, &main_part, &mut read_budget, control, stage) {
948 Ok(xml) => xml,
949 Err(error) => {
950 if let Some(limit) = accepted_docx_limit(&error) {
951 return Ok(empty_partial_document(
952 DocumentFormat::Docx,
953 DocumentParserProvenance::QuickXml,
954 DocumentCoverageGap::ResourceLimit(limit),
955 ));
956 }
957 return Err(error);
958 }
959 };
960 control.check(stage)?;
961 let memory_check = check_docx_story_memory(bytes.len(), xml.capacity(), 0);
963 if let Err(error) = memory_check {
964 if let Some(limit) = accepted_docx_limit(&error) {
965 return Ok(empty_partial_document(
966 DocumentFormat::Docx,
967 DocumentParserProvenance::QuickXml,
968 DocumentCoverageGap::ResourceLimit(limit),
969 ));
970 }
971 return Err(error);
972 }
973 let mut references = DocxStoryReferences::default();
974 let mut document = match parse_docx_part(
975 &xml,
976 &main_part,
977 "document",
978 None,
979 None,
980 &mut references,
981 control,
982 stage,
983 ) {
984 Ok(document) => document,
985 Err(error) => {
986 if let Some(limit) = accepted_docx_limit(&error) {
987 return Ok(empty_partial_document(
988 DocumentFormat::Docx,
989 DocumentParserProvenance::QuickXml,
990 DocumentCoverageGap::ResourceLimit(limit),
991 ));
992 }
993 return Err(error);
994 }
995 };
996 drop(xml);
997 let settings = match docx_settings(
998 &mut archive,
999 &main_part,
1000 &names,
1001 content_types.as_ref(),
1002 &mut read_budget,
1003 control,
1004 stage,
1005 ) {
1006 Ok(enabled) => enabled,
1007 Err(error) => {
1008 if let Some(limit) = accepted_docx_limit(&error) {
1009 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1010 return Ok(document);
1011 }
1012 return Err(error);
1013 }
1014 };
1015 if settings.unexamined_compatibility {
1016 mark_docx_gap(&mut document, DocumentCoverageGap::UnexaminedStory);
1017 }
1018 if settings.even_odd_headers && references.has_parity_variant() {
1019 mark_docx_gap(&mut document, DocumentCoverageGap::ConditionalStory);
1020 }
1021 let mut total_references = references.count();
1022 let mut document_newlines = document.text.bytes().filter(|byte| *byte == b'\n').count();
1023 let main_relationship_part = docx_relationship_part(&main_part);
1024 let main_relationships = if names.contains(&main_relationship_part) {
1025 let xml = read_docx_package_part(
1026 &mut archive,
1027 &main_relationship_part,
1028 &mut read_budget,
1029 control,
1030 stage,
1031 )?;
1032 Some(parse_docx_relationships(&xml, control, stage)?)
1033 } else {
1034 None
1035 };
1036 if main_relationships
1037 .as_ref()
1038 .is_some_and(|relationships| relationships.values().any(|(kind, _, _)| kind == "styles"))
1039 {
1040 mark_docx_gap(&mut document, DocumentCoverageGap::UnresolvedVisibility);
1041 }
1042 let mut selected: BTreeMap<(u8, String), (DocxStoryKind, HashSet<String>)> = BTreeMap::new();
1044 if let Err(error) = queue_docx_story_references(
1045 &mut archive,
1046 &main_part,
1047 &references,
1048 &names,
1049 content_types.as_ref(),
1050 &main_part,
1051 main_relationships.as_ref(),
1052 settings.even_odd_headers,
1053 &mut read_budget,
1054 &mut selected,
1055 control,
1056 stage,
1057 ) {
1058 if let Some(limit) = accepted_docx_limit(&error) {
1059 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1060 return Ok(document);
1061 }
1062 return Err(error);
1063 }
1064 let mut parsed: HashMap<String, (DocxStoryKind, HashSet<String>)> = HashMap::new();
1065 while let Some(((_, part), (kind, ids))) = selected.pop_first() {
1066 control.check(stage)?;
1067 let previous = parsed
1068 .entry(part.clone())
1069 .or_insert_with(|| (kind, HashSet::new()));
1070 if previous.0 != kind {
1071 return Err(DocumentExtractionError::InvalidDocxPackage {
1072 message: "DOCX story part has conflicting relationship types".to_owned(),
1073 });
1074 }
1075 let previous = &mut previous.1;
1076 let special_ids = if previous.is_empty() {
1077 match kind {
1078 DocxStoryKind::Footnotes => Some(&settings.footnote_special_ids),
1079 DocxStoryKind::Endnotes => Some(&settings.endnote_special_ids),
1080 _ => None,
1081 }
1082 } else {
1083 None
1084 };
1085 let pending_ids = if kind.item_name().is_some() {
1086 ids.difference(previous).cloned().collect::<HashSet<_>>()
1087 } else if previous.is_empty() {
1088 HashSet::new()
1089 } else {
1090 continue;
1091 };
1092 if kind.item_name().is_some() && pending_ids.is_empty() {
1093 continue;
1094 }
1095 if kind.item_name().is_some() {
1096 previous.extend(pending_ids.iter().cloned());
1097 } else {
1098 previous.insert(String::new());
1099 }
1100 let story_xml =
1101 match read_docx_package_part(&mut archive, &part, &mut read_budget, control, stage) {
1102 Ok(xml) => xml,
1103 Err(error) => {
1104 if let Some(limit) = accepted_docx_limit(&error) {
1105 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1106 break;
1107 }
1108 return Err(error);
1109 }
1110 };
1111 if let Err(error) =
1112 check_docx_story_memory(bytes.len(), story_xml.capacity(), document.text.capacity())
1113 {
1114 if let Some(limit) = accepted_docx_limit(&error) {
1115 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1116 break;
1117 }
1118 return Err(error);
1119 }
1120 let mut child_references = DocxStoryReferences::default();
1121 let story = match parse_docx_part(
1122 &story_xml,
1123 &part,
1124 kind.root_name(),
1125 kind.item_name().map(|_| &pending_ids),
1126 special_ids,
1127 &mut child_references,
1128 control,
1129 stage,
1130 ) {
1131 Ok(story) => story,
1132 Err(error) => {
1133 if let Some(limit) = accepted_docx_limit(&error) {
1134 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1135 break;
1136 }
1137 return Err(error);
1138 }
1139 };
1140 if let Err(error) = append_docx_story(&mut document, story, &mut document_newlines) {
1141 if let Some(limit) = accepted_docx_limit(&error) {
1142 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1143 break;
1144 }
1145 return Err(error);
1146 }
1147 total_references = total_references.saturating_add(child_references.count());
1148 if total_references > MAX_DOCUMENT_FACTS {
1149 mark_docx_gap(
1150 &mut document,
1151 DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount),
1152 );
1153 break;
1154 }
1155 if settings.even_odd_headers && child_references.has_parity_variant() {
1156 mark_docx_gap(&mut document, DocumentCoverageGap::ConditionalStory);
1157 }
1158 if let Err(error) = queue_docx_story_references(
1159 &mut archive,
1160 &part,
1161 &child_references,
1162 &names,
1163 content_types.as_ref(),
1164 &main_part,
1165 main_relationships.as_ref(),
1166 settings.even_odd_headers,
1167 &mut read_budget,
1168 &mut selected,
1169 control,
1170 stage,
1171 ) {
1172 if let Some(limit) = accepted_docx_limit(&error) {
1173 mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1174 break;
1175 }
1176 return Err(error);
1177 }
1178 }
1179 Ok(document)
1180}
1181
1182fn accepted_docx_limit(error: &DocumentExtractionError) -> Option<DocumentLimit> {
1184 match error {
1185 DocumentExtractionError::ResourceLimit { limit, .. }
1186 if matches!(
1187 limit,
1188 DocumentLimit::OutputBytes
1189 | DocumentLimit::FactCount
1190 | DocumentLimit::MemoryBytes
1191 | DocumentLimit::ParserWorkBytes
1192 ) =>
1193 {
1194 Some(*limit)
1195 }
1196 _ => None,
1197 }
1198}
1199
1200fn check_docx_story_memory(
1202 source_bytes: usize,
1203 story_capacity: usize,
1204 retained_text_capacity: usize,
1205) -> Result<(), DocumentExtractionError> {
1206 check_memory_budget(
1207 source_bytes
1208 .saturating_add(story_capacity.saturating_mul(4))
1209 .saturating_add(retained_text_capacity.saturating_mul(2))
1210 .saturating_add(MAX_DOCUMENT_ENTRIES.saturating_mul(MAX_DOCX_PART_NAME_BYTES))
1211 .saturating_add(MAX_DOCUMENT_FACTS.saturating_mul(MAX_DOCX_PART_NAME_BYTES * 2))
1212 .saturating_add(MAX_DOCUMENT_FACTS.saturating_mul(MAX_DOCX_REFERENCE_ID_BYTES * 4))
1213 .saturating_add(MAX_DOCX_METADATA_BYTES.saturating_mul(8))
1214 .saturating_add(MAX_DOCX_IGNORABLE_NAMESPACES * std::mem::size_of::<String>())
1215 .saturating_add(MAX_DOCUMENT_OUTPUT_BYTES.saturating_mul(4))
1216 .saturating_add(MAX_DOCX_XML_DEPTH.saturating_mul(
1217 std::mem::size_of::<(DocxTextContext, usize)>()
1218 + MAX_DOCX_XML_DEPTH * std::mem::size_of::<DocxFieldPhase>()
1219 + std::mem::size_of::<DocxSimpleField>()
1220 + std::mem::size_of::<DocxSdtContext>()
1221 + std::mem::size_of::<DocxSdtTag>()
1222 + std::mem::size_of::<DocxAlternative>(),
1223 ))
1224 .saturating_add(
1225 MAX_DOCUMENT_FACTS
1226 .saturating_mul(std::mem::size_of::<DocumentFact>())
1227 .saturating_mul(2),
1228 ),
1229 )
1230}
1231
1232fn empty_partial_document(
1234 format: DocumentFormat,
1235 provenance: DocumentParserProvenance,
1236 gap: DocumentCoverageGap,
1237) -> DocumentFacts {
1238 DocumentFacts {
1239 format,
1240 text: String::new(),
1241 facts: Vec::new(),
1242 symbols: Vec::new(),
1243 completeness: DocumentCompleteness::Partial { gaps: vec![gap] },
1244 provenance,
1245 }
1246}
1247
1248fn mark_docx_gap(document: &mut DocumentFacts, gap: DocumentCoverageGap) {
1250 if let DocumentCompleteness::Complete = document.completeness {
1251 document.completeness = DocumentCompleteness::Partial { gaps: Vec::new() };
1252 }
1253 if let DocumentCompleteness::Partial { gaps } = &mut document.completeness
1254 && !gaps.contains(&gap)
1255 {
1256 gaps.push(gap);
1257 }
1258}
1259
1260fn docx_relationship_part(source_part: &str) -> String {
1262 match source_part.rsplit_once('/') {
1263 Some((directory, filename)) => format!("{directory}/_rels/{filename}.rels"),
1264 None => format!("_rels/{source_part}.rels"),
1265 }
1266}
1267
1268#[allow(clippy::too_many_arguments)]
1269fn queue_docx_story_references(
1271 archive: &mut ZipArchive<Cursor<&[u8]>>,
1272 origin_part: &str,
1273 references: &DocxStoryReferences,
1274 names: &HashSet<String>,
1275 content_types: Option<&DocxContentTypes>,
1276 main_part: &str,
1277 main_relationships: Option<&HashMap<String, (String, String, bool)>>,
1278 even_odd_headers: bool,
1279 read_budget: &mut DocxReadBudget,
1280 selected: &mut BTreeMap<(u8, String), (DocxStoryKind, HashSet<String>)>,
1281 control: &IndexWorkControl,
1282 stage: IndexWorkStage,
1283) -> Result<(), DocumentExtractionError> {
1284 let has_story_links = !references.linked_parts.is_empty()
1285 || !references.imported_parts.is_empty()
1286 || !references.content_parts.is_empty()
1287 || (even_odd_headers && !references.even_parts.is_empty());
1288 if !has_story_links && references.items.is_empty() && !references.glossary_placeholder {
1289 return Ok(());
1290 }
1291 let relationship_origin = if has_story_links {
1292 origin_part
1293 } else {
1294 main_part
1295 };
1296 let relationship_part = docx_relationship_part(relationship_origin);
1297 let source_relationships = if relationship_origin == main_part {
1298 None
1299 } else {
1300 let xml = read_docx_package_part(archive, &relationship_part, read_budget, control, stage)?;
1301 Some(parse_docx_relationships(&xml, control, stage)?)
1302 };
1303 let missing_main = || DocumentExtractionError::InvalidDocxPackage {
1304 message: format!(
1305 "required part {} is missing",
1306 docx_relationship_part(main_part)
1307 ),
1308 };
1309 let relationships = source_relationships
1310 .as_ref()
1311 .or(main_relationships)
1312 .ok_or_else(missing_main)?;
1313 let item_relationships = if references.items.is_empty() && !references.glossary_placeholder {
1314 relationships
1315 } else {
1316 main_relationships.ok_or_else(missing_main)?
1317 };
1318 let mut relationship_by_kind = HashMap::new();
1319 for (index, (relationship_kind, target, external)) in item_relationships.values().enumerate() {
1320 check_parser_iteration(index, &mut || control.check(stage))?;
1321 relationship_by_kind
1322 .entry(relationship_kind.as_str())
1323 .and_modify(|unique| *unique = None)
1324 .or_insert(Some((target.as_str(), *external)));
1325 }
1326 for (kind, id) in references
1327 .linked_parts
1328 .iter()
1329 .chain(references.even_parts.iter().filter(|_| even_odd_headers))
1330 {
1331 let (relationship_kind, target, external) =
1332 relationships
1333 .get(id)
1334 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1335 message: format!("referenced DOCX relationship {id} is missing"),
1336 })?;
1337 if relationship_kind != kind.relationship_name() || *external {
1338 return Err(DocumentExtractionError::InvalidDocxPackage {
1339 message: format!("DOCX relationship {id} has an unsafe or wrong story type"),
1340 });
1341 }
1342 let part = resolve_docx_story_target(target, origin_part, names)?;
1343 if let Some(types) = content_types {
1344 validate_docx_content_type(&part, kind.content_type(), types)?;
1345 }
1346 if *kind == DocxStoryKind::Subdocument {
1347 continue;
1348 }
1349 let story = selected
1350 .entry((u8::from(kind.item_name().is_some()), part))
1351 .or_insert_with(|| (*kind, HashSet::new()));
1352 if story.0 != *kind {
1353 return Err(DocumentExtractionError::InvalidDocxPackage {
1354 message: "DOCX story part has conflicting relationship types".to_owned(),
1355 });
1356 }
1357 }
1358 for id in &references.imported_parts {
1359 let (kind, target, external) =
1360 relationships
1361 .get(id)
1362 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1363 message: format!("referenced DOCX import relationship {id} is missing"),
1364 })?;
1365 if !matches!(kind.as_str(), "aFChunk" | "afChunk") || *external {
1366 return Err(DocumentExtractionError::InvalidDocxPackage {
1367 message: format!("DOCX import relationship {id} has an unsafe or wrong type"),
1368 });
1369 }
1370 resolve_docx_story_target(target, origin_part, names)?;
1371 }
1372 for id in &references.content_parts {
1373 let (kind, target, external) =
1374 relationships
1375 .get(id)
1376 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1377 message: format!("referenced DOCX content part relationship {id} is missing"),
1378 })?;
1379 if kind != "customXml" || *external {
1380 return Err(DocumentExtractionError::InvalidDocxPackage {
1381 message: format!("DOCX content part relationship {id} is unsafe or wrong type"),
1382 });
1383 }
1384 resolve_docx_story_target(target, origin_part, names)?;
1385 }
1386 for (kind, id) in &references.items {
1387 let (target, external) = match relationship_by_kind.get(kind.relationship_name()) {
1388 None => {
1389 return Err(DocumentExtractionError::InvalidDocxPackage {
1390 message: format!(
1391 "referenced DOCX {} part is missing",
1392 kind.relationship_name()
1393 ),
1394 });
1395 }
1396 Some(None) => {
1397 return Err(DocumentExtractionError::InvalidDocxPackage {
1398 message: format!(
1399 "DOCX {} relationship is ambiguous",
1400 kind.relationship_name()
1401 ),
1402 });
1403 }
1404 Some(Some(relationship)) => *relationship,
1405 };
1406 if external {
1407 return Err(DocumentExtractionError::InvalidDocxPackage {
1408 message: format!("DOCX {} relationship is external", kind.relationship_name()),
1409 });
1410 }
1411 let part = resolve_docx_story_target(target, main_part, names)?;
1412 if let Some(types) = content_types {
1413 validate_docx_content_type(&part, kind.content_type(), types)?;
1414 }
1415 let story = selected
1416 .entry((1, part))
1417 .or_insert_with(|| (*kind, HashSet::new()));
1418 if story.0 != *kind {
1419 return Err(DocumentExtractionError::InvalidDocxPackage {
1420 message: "DOCX story part has conflicting relationship types".to_owned(),
1421 });
1422 }
1423 story.1.insert(id.clone());
1424 }
1425 if references.glossary_placeholder {
1426 let mut glossary = item_relationships
1427 .values()
1428 .filter(|(kind, _, _)| kind == "glossaryDocument");
1429 let (_, target, external) =
1430 glossary
1431 .next()
1432 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1433 message: "referenced DOCX glossary relationship is missing".to_owned(),
1434 })?;
1435 if glossary.next().is_some() {
1436 return Err(DocumentExtractionError::InvalidDocxPackage {
1437 message: "referenced DOCX glossary relationship is ambiguous".to_owned(),
1438 });
1439 }
1440 if *external {
1441 return Err(DocumentExtractionError::InvalidDocxPackage {
1442 message: "referenced DOCX glossary relationship is external".to_owned(),
1443 });
1444 }
1445 let part = resolve_docx_story_target(target, main_part, names)?;
1446 if let Some(types) = content_types {
1447 validate_docx_content_type(&part, "document.glossary", types)?;
1448 }
1449 }
1450 Ok(())
1451}
1452
1453#[derive(Default)]
1455struct DocxSettings {
1456 even_odd_headers: bool,
1458 footnote_special_ids: HashSet<String>,
1460 endnote_special_ids: HashSet<String>,
1462 unexamined_compatibility: bool,
1464}
1465
1466fn docx_settings(
1468 archive: &mut ZipArchive<Cursor<&[u8]>>,
1469 main_part: &str,
1470 names: &HashSet<String>,
1471 content_types: Option<&DocxContentTypes>,
1472 read_budget: &mut DocxReadBudget,
1473 control: &IndexWorkControl,
1474 stage: IndexWorkStage,
1475) -> Result<DocxSettings, DocumentExtractionError> {
1476 let relationship_part = docx_relationship_part(main_part);
1477 if !names.contains(&relationship_part) {
1478 return Ok(DocxSettings::default());
1479 }
1480 let rel_xml = read_docx_package_part(archive, &relationship_part, read_budget, control, stage)?;
1481 let relationships = parse_docx_relationships(&rel_xml, control, stage)?;
1482 let mut settings_links = relationships
1483 .values()
1484 .filter(|(kind, _, _)| kind == "settings");
1485 let Some((_, target, external)) = settings_links.next() else {
1486 return Ok(DocxSettings::default());
1487 };
1488 if settings_links.next().is_some() || *external {
1489 return Err(DocumentExtractionError::InvalidDocxPackage {
1490 message: "DOCX settings relationship is ambiguous or external".to_owned(),
1491 });
1492 }
1493 let part = resolve_docx_story_target(target, main_part, names)?;
1494 if let Some(types) = content_types {
1495 validate_docx_content_type(&part, "settings", types)?;
1496 }
1497 let xml = read_docx_package_part(archive, &part, read_budget, control, stage)?;
1498 if xml.len() > MAX_DOCX_METADATA_BYTES {
1499 return Err(DocumentExtractionError::InvalidDocxPackage {
1500 message: "DOCX settings exceed the package metadata bound".to_owned(),
1501 });
1502 }
1503 let mut reader = NsReader::from_reader(xml.as_slice());
1504 reader.config_mut().expand_empty_elements = true;
1505 reader.config_mut().check_comments = true;
1506 let mut depth = 0usize;
1507 let mut seen_root = false;
1508 let mut seen_switch = false;
1509 let mut seen_footnote_properties = false;
1510 let mut seen_endnote_properties = false;
1511 let mut settings = DocxSettings::default();
1512 let mut note_kind = None;
1513 let mut alternatives: Vec<DocxAlternative> = Vec::new();
1514 let mut selected_branches: Vec<usize> = Vec::new();
1515 let mut skipped_branch_depth = None;
1516 let mut event_index = 0usize;
1517 loop {
1518 check_parser_iteration(event_index, &mut || control.check(stage))?;
1519 event_index = event_index.saturating_add(1);
1520 let (namespace, event) =
1521 reader
1522 .read_resolved_event()
1523 .map_err(|error| DocumentExtractionError::Malformed {
1524 format: DocumentFormat::Docx,
1525 message: error.to_string(),
1526 })?;
1527 let wordprocessing = match &namespace {
1528 ResolveResult::Bound(namespace) => wordprocessing_namespace(namespace.as_ref()),
1529 ResolveResult::Unbound => false,
1530 ResolveResult::Unknown(prefix) => {
1531 return Err(DocumentExtractionError::Malformed {
1532 format: DocumentFormat::Docx,
1533 message: format!("DOCX XML contained an undeclared namespace prefix: {prefix}"),
1534 });
1535 }
1536 };
1537 let compatibility = matches!(&namespace, ResolveResult::Bound(namespace)
1538 if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006");
1539 if matches!(event, Event::Decl(_)) && event_index != 1 {
1540 return Err(DocumentExtractionError::Malformed {
1541 format: DocumentFormat::Docx,
1542 message: "DOCX XML declaration must be the first XML event".to_owned(),
1543 });
1544 }
1545 match event {
1546 Event::Start(event) => {
1547 depth += 1;
1548 if depth > MAX_DOCX_XML_DEPTH {
1549 return Err(DocumentExtractionError::ResourceLimit {
1550 limit: DocumentLimit::NestingDepth,
1551 observed: depth,
1552 maximum: MAX_DOCX_XML_DEPTH,
1553 });
1554 }
1555 validate_docx_attributes(&event, &reader, control, stage)?;
1556 if skipped_branch_depth.is_some() {
1557 continue;
1558 }
1559 let name = event.local_name();
1560 if depth == 1 && (seen_root || !wordprocessing || name.as_ref() != "settings") {
1561 return Err(DocumentExtractionError::Malformed {
1562 format: DocumentFormat::Docx,
1563 message: "DOCX settings have an invalid root".to_owned(),
1564 });
1565 }
1566 if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
1567 let alternative = alternatives
1568 .last_mut()
1569 .filter(|item| item.depth + 1 == depth && !item.fallback_seen)
1570 .ok_or_else(|| DocumentExtractionError::Malformed {
1571 format: DocumentFormat::Docx,
1572 message: "DOCX settings compatibility branch has invalid placement"
1573 .to_owned(),
1574 })?;
1575 let supported = if name.as_ref() == "Choice" {
1576 alternative.choice_seen = true;
1577 docx_choice_supported(&reader, &event, control, stage)?
1578 } else {
1579 if !alternative.choice_seen {
1580 return Err(DocumentExtractionError::Malformed {
1581 format: DocumentFormat::Docx,
1582 message: "DOCX settings compatibility fallback requires a choice"
1583 .to_owned(),
1584 });
1585 }
1586 alternative.fallback_seen = true;
1587 true
1588 };
1589 if !supported && alternative.selection == DocxAlternativeSelection::Unselected {
1590 settings.unexamined_compatibility = true;
1591 alternative.selection = DocxAlternativeSelection::Uncertain;
1592 }
1593 if alternative.selection == DocxAlternativeSelection::Unselected && supported {
1594 alternative.selection = DocxAlternativeSelection::Selected;
1595 selected_branches.push(depth);
1596 } else {
1597 skipped_branch_depth = Some(depth);
1598 }
1599 continue;
1600 }
1601 if alternatives
1602 .last()
1603 .is_some_and(|item| item.depth + 1 == depth)
1604 {
1605 return Err(DocumentExtractionError::Malformed {
1606 format: DocumentFormat::Docx,
1607 message: "DOCX settings alternatives must contain choices or fallback"
1608 .to_owned(),
1609 });
1610 }
1611 if compatibility && name.as_ref() == "AlternateContent" {
1612 alternatives.push(DocxAlternative {
1613 depth,
1614 selection: DocxAlternativeSelection::Unselected,
1615 choice_seen: false,
1616 fallback_seen: false,
1617 });
1618 continue;
1619 }
1620 let logical_parent = depth - 1 - 2 * selected_branches.len();
1621 if logical_parent == 0 {
1622 seen_root = true;
1623 } else if logical_parent == 1
1624 && wordprocessing
1625 && event.local_name().as_ref() == "evenAndOddHeaders"
1626 {
1627 if seen_switch {
1628 return Err(DocumentExtractionError::Malformed {
1629 format: DocumentFormat::Docx,
1630 message: "DOCX even/odd header setting is duplicated".to_owned(),
1631 });
1632 }
1633 seen_switch = true;
1634 settings.even_odd_headers = docx_on_off(&event, &reader)?;
1635 } else if logical_parent == 1 && wordprocessing {
1636 note_kind = match event.local_name().as_ref() {
1637 "footnotePr" if !seen_footnote_properties => {
1638 seen_footnote_properties = true;
1639 Some(DocxStoryKind::Footnotes)
1640 }
1641 "endnotePr" if !seen_endnote_properties => {
1642 seen_endnote_properties = true;
1643 Some(DocxStoryKind::Endnotes)
1644 }
1645 "footnotePr" | "endnotePr" => {
1646 return Err(DocumentExtractionError::Malformed {
1647 format: DocumentFormat::Docx,
1648 message: "DOCX document-wide note properties are duplicated"
1649 .to_owned(),
1650 });
1651 }
1652 _ => None,
1653 };
1654 } else if logical_parent == 2
1655 && wordprocessing
1656 && let Some(kind) = note_kind
1657 && event.local_name().as_ref()
1658 == if kind == DocxStoryKind::Footnotes {
1659 "footnote"
1660 } else {
1661 "endnote"
1662 }
1663 {
1664 let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
1665 .ok_or_else(|| DocumentExtractionError::Malformed {
1666 format: DocumentFormat::Docx,
1667 message: "DOCX special note setting is missing its id".to_owned(),
1668 })?;
1669 let id = canonical_docx_story_id(&id)?;
1670 let inserted = match kind {
1671 DocxStoryKind::Footnotes => settings.footnote_special_ids.insert(id),
1672 DocxStoryKind::Endnotes => settings.endnote_special_ids.insert(id),
1673 _ => unreachable!(),
1674 };
1675 if inserted {
1676 let observed = settings
1677 .footnote_special_ids
1678 .len()
1679 .saturating_add(settings.endnote_special_ids.len());
1680 if observed > MAX_DOCUMENT_FACTS {
1681 return Err(DocumentExtractionError::ResourceLimit {
1682 limit: DocumentLimit::FactCount,
1683 observed,
1684 maximum: MAX_DOCUMENT_FACTS,
1685 });
1686 }
1687 }
1688 }
1689 }
1690 Event::End(event) => {
1691 if depth == 0 {
1692 return Err(DocumentExtractionError::Malformed {
1693 format: DocumentFormat::Docx,
1694 message: "DOCX settings have an unmatched closing element".to_owned(),
1695 });
1696 }
1697 if let Some(skipped) = skipped_branch_depth {
1698 if depth == skipped {
1699 skipped_branch_depth = None;
1700 }
1701 depth -= 1;
1702 continue;
1703 }
1704 if compatibility
1705 && event.local_name().as_ref() == "AlternateContent"
1706 && alternatives
1707 .pop()
1708 .is_none_or(|item| item.depth != depth || !item.choice_seen)
1709 {
1710 return Err(DocumentExtractionError::Malformed {
1711 format: DocumentFormat::Docx,
1712 message: "DOCX settings alternatives require a choice".to_owned(),
1713 });
1714 }
1715 if compatibility
1716 && matches!(event.local_name().as_ref(), "Choice" | "Fallback")
1717 && selected_branches.last() == Some(&depth)
1718 {
1719 selected_branches.pop();
1720 }
1721 if wordprocessing
1722 && depth - 2 * selected_branches.len() == 2
1723 && matches!(event.local_name().as_ref(), "footnotePr" | "endnotePr")
1724 {
1725 note_kind = None;
1726 }
1727 depth -= 1;
1728 }
1729 Event::PI(instruction) if instruction.target().eq_ignore_ascii_case("xml") => {
1730 return Err(DocumentExtractionError::Malformed {
1731 format: DocumentFormat::Docx,
1732 message: "DOCX XML processing instruction uses the reserved XML target"
1733 .to_owned(),
1734 });
1735 }
1736 Event::DocType(_) => {
1737 return Err(DocumentExtractionError::Malformed {
1738 format: DocumentFormat::Docx,
1739 message: "DOCX settings DOCTYPE is unsupported".to_owned(),
1740 });
1741 }
1742 Event::Text(text) if depth == 0 && !text.as_ref().chars().all(xml_whitespace) => {
1743 return Err(DocumentExtractionError::Malformed {
1744 format: DocumentFormat::Docx,
1745 message: "DOCX settings contain text outside the root".to_owned(),
1746 });
1747 }
1748 Event::CData(_) if depth == 0 => {
1749 return Err(DocumentExtractionError::Malformed {
1750 format: DocumentFormat::Docx,
1751 message: "DOCX settings contain CDATA outside the root".to_owned(),
1752 });
1753 }
1754 Event::GeneralRef(reference) => {
1755 decode_docx_reference(&reference)?;
1756 if depth == 0 {
1757 return Err(DocumentExtractionError::Malformed {
1758 format: DocumentFormat::Docx,
1759 message: "DOCX settings contain a reference outside the root".to_owned(),
1760 });
1761 }
1762 }
1763 Event::Eof => break,
1764 _ => {}
1765 }
1766 }
1767 if !seen_root
1768 || depth != 0
1769 || !alternatives.is_empty()
1770 || !selected_branches.is_empty()
1771 || skipped_branch_depth.is_some()
1772 {
1773 return Err(DocumentExtractionError::Malformed {
1774 format: DocumentFormat::Docx,
1775 message: "DOCX settings ended before the root closed".to_owned(),
1776 });
1777 }
1778 Ok(settings)
1779}
1780
1781#[derive(Default)]
1782struct DocxReadBudget {
1784 unique_expanded: usize,
1786 work_expanded: usize,
1788 read_parts: HashSet<String>,
1790}
1791
1792#[derive(Default)]
1794struct DocxContentTypes {
1795 overrides: HashMap<String, String>,
1797 defaults: HashMap<String, String>,
1799}
1800
1801fn read_docx_package_part(
1803 archive: &mut ZipArchive<Cursor<&[u8]>>,
1804 name: &str,
1805 read_budget: &mut DocxReadBudget,
1806 control: &IndexWorkControl,
1807 stage: IndexWorkStage,
1808) -> Result<Vec<u8>, DocumentExtractionError> {
1809 let mut entry =
1810 archive
1811 .by_name(name)
1812 .map_err(|_error| DocumentExtractionError::InvalidDocxPackage {
1813 message: format!("required part {name} is missing"),
1814 })?;
1815 let mut xml = Vec::new();
1816 let mut chunk = [0_u8; 8192];
1817 loop {
1818 control.check(stage)?;
1819 let read = entry.read(&mut chunk).map_err(|error| {
1820 DocumentExtractionError::InvalidDocxPackage {
1821 message: error.to_string(),
1822 }
1823 })?;
1824 if read == 0 {
1825 break;
1826 }
1827 let observed = read_budget
1828 .unique_expanded
1829 .saturating_add(xml.len())
1830 .saturating_add(read);
1831 if !read_budget.read_parts.contains(name) && observed > MAX_DOCUMENT_EXPANDED_BYTES {
1832 return Err(DocumentExtractionError::ResourceLimit {
1833 limit: DocumentLimit::ExpandedBytes,
1834 observed,
1835 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
1836 });
1837 }
1838 let work = read_budget
1839 .work_expanded
1840 .saturating_add(xml.len())
1841 .saturating_add(read);
1842 if work > MAX_DOCX_DECOMPRESSION_WORK_BYTES {
1843 return Err(DocumentExtractionError::ResourceLimit {
1844 limit: DocumentLimit::ParserWorkBytes,
1845 observed: work,
1846 maximum: MAX_DOCX_DECOMPRESSION_WORK_BYTES,
1847 });
1848 }
1849 xml.extend_from_slice(&chunk[..read]);
1850 }
1851 if read_budget.read_parts.insert(name.to_owned()) {
1852 read_budget.unique_expanded = read_budget.unique_expanded.saturating_add(xml.len());
1853 }
1854 read_budget.work_expanded = read_budget.work_expanded.saturating_add(xml.len());
1855 decode_docx_xml(xml, control, stage)
1856}
1857
1858fn decode_docx_xml(
1860 xml: Vec<u8>,
1861 control: &IndexWorkControl,
1862 stage: IndexWorkStage,
1863) -> Result<Vec<u8>, DocumentExtractionError> {
1864 let encoding = if xml.starts_with(&[0xff, 0xfe]) {
1865 Some((true, 2))
1866 } else if xml.starts_with(&[0xfe, 0xff]) {
1867 Some((false, 2))
1868 } else if xml.len() >= 2 && xml[1] == 0 && matches!(xml[0], b'<' | b' ' | b'\t' | b'\r' | b'\n')
1869 {
1870 Some((true, 0))
1871 } else if xml.len() >= 2 && xml[0] == 0 && matches!(xml[1], b'<' | b' ' | b'\t' | b'\r' | b'\n')
1872 {
1873 Some((false, 0))
1874 } else {
1875 None
1876 };
1877 let Some((little_endian, bom_bytes)) = encoding else {
1878 validate_docx_xml_encoding(&xml, None)?;
1879 validate_docx_xml_characters(&xml, control, stage)?;
1880 return Ok(xml);
1881 };
1882 let (chunks, remainder) = xml[bom_bytes..].as_chunks::<2>();
1883 if !remainder.is_empty() {
1884 return Err(DocumentExtractionError::Malformed {
1885 format: DocumentFormat::Docx,
1886 message: "DOCX UTF-16 XML has an incomplete code unit".to_owned(),
1887 });
1888 }
1889 let units = || {
1890 chunks.iter().map(|pair| {
1891 if little_endian {
1892 u16::from_le_bytes([pair[0], pair[1]])
1893 } else {
1894 u16::from_be_bytes([pair[0], pair[1]])
1895 }
1896 })
1897 };
1898 let decoded_characters = || {
1899 char::decode_utf16(units()).map(|character| {
1900 character.map_err(|_error| DocumentExtractionError::Malformed {
1901 format: DocumentFormat::Docx,
1902 message: "DOCX UTF-16 XML contains an invalid surrogate".to_owned(),
1903 })
1904 })
1905 };
1906 let mut decoded_bytes = 0usize;
1907 for (index, character) in decoded_characters().enumerate() {
1908 check_parser_iteration(index, &mut || control.check(stage))?;
1909 decoded_bytes = decoded_bytes.saturating_add(character?.len_utf8());
1910 if decoded_bytes > MAX_DOCUMENT_EXPANDED_BYTES {
1911 return Err(DocumentExtractionError::ResourceLimit {
1912 limit: DocumentLimit::MemoryBytes,
1913 observed: decoded_bytes,
1914 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
1915 });
1916 }
1917 }
1918 check_memory_budget(
1920 MAX_DOCUMENT_COMPRESSED_BYTES
1921 .saturating_add(xml.capacity())
1922 .saturating_add(decoded_bytes)
1923 .saturating_add(MAX_DOCUMENT_OUTPUT_BYTES)
1924 .saturating_add(MAX_DOCX_METADATA_BYTES)
1925 .saturating_add(MAX_DOCUMENT_FACTS * 256 * 4),
1926 )?;
1927 let mut decoded = String::with_capacity(decoded_bytes);
1928 for (index, character) in decoded_characters().enumerate() {
1929 check_parser_iteration(index, &mut || control.check(stage))?;
1930 decoded.push(character?);
1931 }
1932 let decoded = decoded.into_bytes();
1933 validate_docx_xml_encoding(&decoded, Some(little_endian))?;
1934 validate_docx_xml_characters(&decoded, control, stage)?;
1935 Ok(decoded)
1936}
1937
1938fn validate_docx_xml_characters(
1940 xml: &[u8],
1941 control: &IndexWorkControl,
1942 stage: IndexWorkStage,
1943) -> Result<(), DocumentExtractionError> {
1944 let text = std::str::from_utf8(xml).map_err(|error| DocumentExtractionError::Malformed {
1945 format: DocumentFormat::Docx,
1946 message: error.to_string(),
1947 })?;
1948 for (index, character) in text.chars().enumerate() {
1949 check_parser_iteration(index, &mut || control.check(stage))?;
1950 if !xml10_character(character) {
1951 return Err(DocumentExtractionError::Malformed {
1952 format: DocumentFormat::Docx,
1953 message: "DOCX XML contains an invalid character".to_owned(),
1954 });
1955 }
1956 }
1957 Ok(())
1958}
1959
1960fn xml10_character(character: char) -> bool {
1962 matches!(
1963 character,
1964 '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'
1965 )
1966}
1967
1968fn xml_whitespace(character: char) -> bool {
1970 matches!(character, ' ' | '\t' | '\r' | '\n')
1971}
1972
1973fn xml_whitespace_tokens(value: &str) -> impl Iterator<Item = &str> {
1975 value
1976 .split(xml_whitespace)
1977 .filter(|token| !token.is_empty())
1978}
1979
1980fn internal_xml_whitespace(depth: usize, value: &str) -> bool {
1982 depth > 0 && value.chars().all(xml_whitespace)
1983}
1984
1985fn validate_docx_xml_encoding(
1987 xml: &[u8],
1988 utf16_little_endian: Option<bool>,
1989) -> Result<(), DocumentExtractionError> {
1990 let mut reader = NsReader::from_reader(xml.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(xml));
1991 reader.config_mut().check_comments = true;
1992 let Event::Decl(declaration) =
1993 reader
1994 .read_event()
1995 .map_err(|error| DocumentExtractionError::Malformed {
1996 format: DocumentFormat::Docx,
1997 message: error.to_string(),
1998 })?
1999 else {
2000 return Ok(());
2001 };
2002 let attributes = BytesStart::from_content(declaration.as_ref(), 3);
2003 let mut encoding = None;
2004 let mut fields = 0usize;
2005 for attribute in attributes.attributes() {
2006 let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2007 format: DocumentFormat::Docx,
2008 message: error.to_string(),
2009 })?;
2010 let value = attribute.value.as_ref();
2011 match (fields, attribute.key.as_ref()) {
2012 (0, "version") if value == "1.0" => {}
2013 (1, "encoding") => encoding = Some(value.to_owned()),
2014 (1 | 2, "standalone")
2015 if matches!(value, "yes" | "no") && (fields == 1 || encoding.is_some()) => {}
2016 _ => {
2017 return Err(DocumentExtractionError::Malformed {
2018 format: DocumentFormat::Docx,
2019 message: "DOCX XML declaration has invalid or duplicate fields".to_owned(),
2020 });
2021 }
2022 }
2023 fields += 1;
2024 }
2025 if fields == 0 {
2026 return Err(DocumentExtractionError::Malformed {
2027 format: DocumentFormat::Docx,
2028 message: "DOCX XML declaration is missing version 1.0".to_owned(),
2029 });
2030 }
2031 let Some(encoding) = encoding else {
2032 return Ok(());
2033 };
2034 let encoding = encoding.as_str();
2035 let utf8 = encoding.eq_ignore_ascii_case("UTF-8");
2036 let ascii = encoding.eq_ignore_ascii_case("US-ASCII");
2037 let utf16 = encoding.eq_ignore_ascii_case("UTF-16");
2038 let utf16_le = encoding.eq_ignore_ascii_case("UTF-16LE");
2039 let utf16_be = encoding.eq_ignore_ascii_case("UTF-16BE");
2040 if !(utf8 || ascii || utf16 || utf16_le || utf16_be) {
2041 return Err(DocumentExtractionError::UnsupportedDocxInput {
2042 message: "DOCX XML encoding is not supported; UTF-8 or UTF-16 is required".to_owned(),
2043 });
2044 }
2045 let matches_bytes = match utf16_little_endian {
2046 None => utf8 || (ascii && xml.is_ascii()),
2047 Some(true) => utf16 || utf16_le,
2048 Some(false) => utf16 || utf16_be,
2049 };
2050 if !matches_bytes {
2051 return Err(DocumentExtractionError::Malformed {
2052 format: DocumentFormat::Docx,
2053 message: "DOCX XML encoding declaration does not match its bytes".to_owned(),
2054 });
2055 }
2056 Ok(())
2057}
2058
2059fn parse_docx_content_types(
2061 xml: &[u8],
2062 control: &IndexWorkControl,
2063 stage: IndexWorkStage,
2064) -> Result<DocxContentTypes, DocumentExtractionError> {
2065 const CONTENT_TYPES_NS: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
2066 if xml.len() > MAX_DOCX_METADATA_BYTES {
2067 return Err(DocumentExtractionError::InvalidDocxPackage {
2068 message: "DOCX content types exceed the package metadata bound".to_owned(),
2069 });
2070 }
2071 let mut reader = NsReader::from_reader(xml);
2072 reader.config_mut().expand_empty_elements = true;
2073 reader.config_mut().check_comments = true;
2074 let mut types = DocxContentTypes::default();
2075 let mut depth = 0usize;
2076 let mut closed = false;
2077 let mut events = 0usize;
2078 loop {
2079 check_parser_iteration(events, &mut || control.check(stage))?;
2080 events = events.saturating_add(1);
2081 let (namespace, event) =
2082 reader
2083 .read_resolved_event()
2084 .map_err(|error| DocumentExtractionError::Malformed {
2085 format: DocumentFormat::Docx,
2086 message: error.to_string(),
2087 })?;
2088 let valid_namespace = matches!(namespace, ResolveResult::Bound(namespace)
2089 if namespace.as_ref() == CONTENT_TYPES_NS);
2090 match event {
2091 Event::Start(event) => {
2092 depth += 1;
2093 let name = event.local_name();
2094 if !valid_namespace
2095 || !matches!(
2096 (depth, name.as_ref()),
2097 (1, "Types") | (2, "Override" | "Default")
2098 )
2099 || closed
2100 {
2101 return Err(DocumentExtractionError::InvalidDocxPackage {
2102 message: "DOCX content types have an invalid XML structure".to_owned(),
2103 });
2104 }
2105 validate_docx_attributes(&event, &reader, control, stage)?;
2106 if depth == 2 {
2107 if types.overrides.len() + types.defaults.len() == MAX_DOCX_METADATA_RECORDS {
2108 return Err(DocumentExtractionError::InvalidDocxPackage {
2109 message:
2110 "DOCX content type overrides exceed the package metadata bound"
2111 .to_owned(),
2112 });
2113 }
2114 let attribute = |name| -> Result<String, DocumentExtractionError> {
2115 let value = event
2116 .try_get_attribute(name)
2117 .map_err(|error| DocumentExtractionError::Malformed {
2118 format: DocumentFormat::Docx,
2119 message: error.to_string(),
2120 })?
2121 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
2122 message: format!("DOCX content type override is missing {name}"),
2123 })?;
2124 Ok(quick_xml::escape::unescape(&value.value)
2125 .map_err(|error| DocumentExtractionError::Malformed {
2126 format: DocumentFormat::Docx,
2127 message: error.to_string(),
2128 })?
2129 .into_owned())
2130 };
2131 let kind = attribute("ContentType")?;
2132 if kind.is_empty() {
2133 return Err(DocumentExtractionError::InvalidDocxPackage {
2134 message: "DOCX content type is empty".to_owned(),
2135 });
2136 }
2137 if name.as_ref() == "Override" {
2138 let part = attribute("PartName")?;
2139 if !part.starts_with('/')
2140 || part.contains(['\\', '?', '#', ':'])
2141 || part[1..].split('/').any(|component| {
2142 component.is_empty()
2143 || matches!(component, "." | "..")
2144 || !safe_docx_uri_component(component)
2145 })
2146 || types.overrides.insert(part.clone(), kind).is_some()
2147 {
2148 return Err(DocumentExtractionError::InvalidDocxPackage {
2149 message: format!("unsafe or duplicate DOCX content type {part}"),
2150 });
2151 }
2152 } else {
2153 let extension = attribute("Extension")?.to_ascii_lowercase();
2154 if extension.is_empty()
2155 || !extension.bytes().all(|byte| byte.is_ascii_alphanumeric())
2156 || types.defaults.insert(extension.clone(), kind).is_some()
2157 {
2158 return Err(DocumentExtractionError::InvalidDocxPackage {
2159 message: format!("unsafe or duplicate DOCX extension {extension}"),
2160 });
2161 }
2162 }
2163 }
2164 }
2165 Event::End(_) => {
2166 if depth == 0 {
2167 return Err(DocumentExtractionError::InvalidDocxPackage {
2168 message: "DOCX content types XML has an unmatched end".to_owned(),
2169 });
2170 }
2171 depth -= 1;
2172 if depth == 0 {
2173 closed = true;
2174 }
2175 }
2176 Event::Text(text) if text.as_ref().chars().all(xml_whitespace) => {}
2177 Event::CData(text) if internal_xml_whitespace(depth, text.as_ref()) => {}
2178 Event::GeneralRef(reference) => {
2179 let text = decode_docx_reference(&reference)?;
2180 if !internal_xml_whitespace(depth, &text) {
2181 return Err(DocumentExtractionError::InvalidDocxPackage {
2182 message: "DOCX content types contain non-structural XML text".to_owned(),
2183 });
2184 }
2185 }
2186 Event::Decl(_) if events == 1 => {}
2187 Event::Decl(_) => {
2188 return Err(DocumentExtractionError::Malformed {
2189 format: DocumentFormat::Docx,
2190 message: "DOCX XML declaration must be the first XML event".to_owned(),
2191 });
2192 }
2193 Event::Comment(_) => {}
2194 Event::PI(instruction) if !instruction.target().eq_ignore_ascii_case("xml") => {}
2195 Event::PI(_) => {
2196 return Err(DocumentExtractionError::Malformed {
2197 format: DocumentFormat::Docx,
2198 message: "DOCX XML processing instruction uses the reserved XML target"
2199 .to_owned(),
2200 });
2201 }
2202 Event::Eof => break,
2203 _ => {
2204 return Err(DocumentExtractionError::InvalidDocxPackage {
2205 message: "DOCX content types contain unsupported XML".to_owned(),
2206 });
2207 }
2208 }
2209 }
2210 if !closed || depth != 0 {
2211 return Err(DocumentExtractionError::InvalidDocxPackage {
2212 message: "DOCX content types XML is incomplete".to_owned(),
2213 });
2214 }
2215 Ok(types)
2216}
2217
2218fn validate_docx_content_type(
2220 part: &str,
2221 expected_suffix: &str,
2222 types: &DocxContentTypes,
2223) -> Result<(), DocumentExtractionError> {
2224 let expected = format!(
2225 "application/vnd.openxmlformats-officedocument.wordprocessingml.{expected_suffix}+xml"
2226 );
2227 let actual = types.overrides.get(&format!("/{part}")).or_else(|| {
2228 part.rsplit_once('.')
2229 .and_then(|(_, extension)| types.defaults.get(&extension.to_ascii_lowercase()))
2230 });
2231 if actual.is_none_or(|actual| actual != &expected) {
2232 return Err(DocumentExtractionError::InvalidDocxPackage {
2233 message: format!("DOCX story part {part} has a missing or wrong content type"),
2234 });
2235 }
2236 Ok(())
2237}
2238
2239fn parse_docx_relationships(
2241 xml: &[u8],
2242 control: &IndexWorkControl,
2243 stage: IndexWorkStage,
2244) -> Result<HashMap<String, (String, String, bool)>, DocumentExtractionError> {
2245 const RELATIONSHIPS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
2246 const OFFICE_RELATIONSHIP_PREFIX: &str =
2247 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
2248 if xml.len() > MAX_DOCX_METADATA_BYTES {
2249 return Err(DocumentExtractionError::InvalidDocxPackage {
2250 message: "DOCX relationships exceed the package metadata bound".to_owned(),
2251 });
2252 }
2253 let mut reader = NsReader::from_reader(xml);
2254 reader.config_mut().expand_empty_elements = true;
2255 reader.config_mut().check_comments = true;
2256 let mut result = HashMap::new();
2257 let mut depth = 0usize;
2258 let mut closed = false;
2259 let mut events = 0usize;
2260 loop {
2261 check_parser_iteration(events, &mut || control.check(stage))?;
2262 events = events.saturating_add(1);
2263 let (namespace, event) =
2264 reader
2265 .read_resolved_event()
2266 .map_err(|error| DocumentExtractionError::Malformed {
2267 format: DocumentFormat::Docx,
2268 message: error.to_string(),
2269 })?;
2270 let valid_namespace = matches!(namespace, ResolveResult::Bound(namespace)
2271 if namespace.as_ref() == RELATIONSHIPS_NS);
2272 match event {
2273 Event::Start(event) => {
2274 depth += 1;
2275 let name = event.local_name();
2276 if !valid_namespace
2277 || !matches!(
2278 (depth, name.as_ref()),
2279 (1, "Relationships") | (2, "Relationship")
2280 )
2281 || closed
2282 {
2283 return Err(DocumentExtractionError::InvalidDocxPackage {
2284 message: "DOCX relationships have an invalid XML structure".to_owned(),
2285 });
2286 }
2287 validate_docx_attributes(&event, &reader, control, stage)?;
2288 if depth == 2 {
2289 if result.len() == MAX_DOCX_METADATA_RECORDS {
2290 return Err(DocumentExtractionError::InvalidDocxPackage {
2291 message: "DOCX relationships exceed the package metadata record bound"
2292 .to_owned(),
2293 });
2294 }
2295 let attribute = |name| -> Result<String, DocumentExtractionError> {
2296 let value = event
2297 .try_get_attribute(name)
2298 .map_err(|error| DocumentExtractionError::Malformed {
2299 format: DocumentFormat::Docx,
2300 message: error.to_string(),
2301 })?
2302 .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
2303 message: format!("DOCX relationship is missing {name}"),
2304 })?;
2305 Ok(quick_xml::escape::unescape(&value.value)
2306 .map_err(|error| DocumentExtractionError::Malformed {
2307 format: DocumentFormat::Docx,
2308 message: error.to_string(),
2309 })?
2310 .into_owned())
2311 };
2312 let id = attribute("Id")?;
2313 let kind = attribute("Type")?;
2314 let target = attribute("Target")?;
2315 let target_mode = event.try_get_attribute("TargetMode").map_err(|error| {
2316 DocumentExtractionError::Malformed {
2317 format: DocumentFormat::Docx,
2318 message: error.to_string(),
2319 }
2320 })?;
2321 let target_mode = target_mode
2322 .as_ref()
2323 .map(|value| quick_xml::escape::unescape(&value.value))
2324 .transpose()
2325 .map_err(|error| DocumentExtractionError::Malformed {
2326 format: DocumentFormat::Docx,
2327 message: error.to_string(),
2328 })?;
2329 let external = match target_mode.as_deref() {
2330 None | Some("Internal") => false,
2331 Some("External") => true,
2332 Some(_) => {
2333 return Err(DocumentExtractionError::InvalidDocxPackage {
2334 message: "DOCX relationship has an invalid TargetMode".to_owned(),
2335 });
2336 }
2337 };
2338 let kind = kind
2339 .strip_prefix(OFFICE_RELATIONSHIP_PREFIX)
2340 .or_else(|| {
2341 kind.strip_prefix(
2342 "http://purl.oclc.org/ooxml/officeDocument/relationships/",
2343 )
2344 })
2345 .unwrap_or("")
2346 .to_owned();
2347 if result
2348 .insert(id.clone(), (kind, target, external))
2349 .is_some()
2350 {
2351 return Err(DocumentExtractionError::InvalidDocxPackage {
2352 message: format!("duplicate DOCX relationship {id}"),
2353 });
2354 }
2355 }
2356 }
2357 Event::End(_) => {
2358 if depth == 0 {
2359 return Err(DocumentExtractionError::InvalidDocxPackage {
2360 message: "DOCX relationship XML has an unmatched end".to_owned(),
2361 });
2362 }
2363 depth -= 1;
2364 if depth == 0 {
2365 closed = true;
2366 }
2367 }
2368 Event::Text(text) if text.as_ref().chars().all(xml_whitespace) => {}
2369 Event::CData(text) if internal_xml_whitespace(depth, text.as_ref()) => {}
2370 Event::GeneralRef(reference) => {
2371 let text = decode_docx_reference(&reference)?;
2372 if !internal_xml_whitespace(depth, &text) {
2373 return Err(DocumentExtractionError::InvalidDocxPackage {
2374 message: "DOCX relationships contain non-structural XML text".to_owned(),
2375 });
2376 }
2377 }
2378 Event::Decl(_) if events == 1 => {}
2379 Event::Decl(_) => {
2380 return Err(DocumentExtractionError::Malformed {
2381 format: DocumentFormat::Docx,
2382 message: "DOCX XML declaration must be the first XML event".to_owned(),
2383 });
2384 }
2385 Event::Comment(_) => {}
2386 Event::PI(instruction) if !instruction.target().eq_ignore_ascii_case("xml") => {}
2387 Event::PI(_) => {
2388 return Err(DocumentExtractionError::Malformed {
2389 format: DocumentFormat::Docx,
2390 message: "DOCX XML processing instruction uses the reserved XML target"
2391 .to_owned(),
2392 });
2393 }
2394 Event::Eof => break,
2395 _ => {
2396 return Err(DocumentExtractionError::InvalidDocxPackage {
2397 message: "DOCX relationships contain unsupported XML".to_owned(),
2398 });
2399 }
2400 }
2401 }
2402 if !closed || depth != 0 {
2403 return Err(DocumentExtractionError::InvalidDocxPackage {
2404 message: "DOCX relationships XML is incomplete".to_owned(),
2405 });
2406 }
2407 Ok(result)
2408}
2409
2410fn resolve_docx_story_target(
2412 target: &str,
2413 origin_part: &str,
2414 names: &HashSet<String>,
2415) -> Result<String, DocumentExtractionError> {
2416 let mut components = if origin_part.is_empty() {
2417 Vec::new()
2418 } else {
2419 let mut components = origin_part
2420 .split('/')
2421 .map(str::to_owned)
2422 .collect::<Vec<_>>();
2423 components.pop();
2424 components
2425 };
2426 let target = if let Some(absolute) = target.strip_prefix('/') {
2427 components.clear();
2428 absolute
2429 } else {
2430 target
2431 };
2432 if target.is_empty() || target.contains(['\\', '?', '#', ':']) {
2433 return Err(DocumentExtractionError::InvalidDocxPackage {
2434 message: format!("unsafe DOCX story target {target}"),
2435 });
2436 }
2437 for component in target.split('/') {
2438 let normalized = normalize_docx_target_component(component).ok_or_else(|| {
2439 DocumentExtractionError::InvalidDocxPackage {
2440 message: format!("unsafe DOCX story target {target}"),
2441 }
2442 })?;
2443 match normalized.as_str() {
2444 "" => {
2445 return Err(DocumentExtractionError::InvalidDocxPackage {
2446 message: format!("unsafe DOCX story target {target}"),
2447 });
2448 }
2449 "." => {}
2450 ".." => {
2451 if components.pop().is_none() {
2452 return Err(DocumentExtractionError::InvalidDocxPackage {
2453 message: format!("unsafe DOCX story target {target}"),
2454 });
2455 }
2456 }
2457 _ => components.push(normalized),
2458 }
2459 }
2460 let part = components.join("/");
2461 if !names.contains(&part) {
2462 return Err(DocumentExtractionError::InvalidDocxPackage {
2463 message: format!("referenced DOCX story part {part} is missing"),
2464 });
2465 }
2466 Ok(part)
2467}
2468
2469fn safe_docx_uri_component(component: &str) -> bool {
2471 normalize_docx_target_component(component).is_some_and(|normalized| normalized == component)
2472}
2473
2474fn normalize_docx_target_component(component: &str) -> Option<String> {
2476 let bytes = component.as_bytes();
2477 let mut normalized = Vec::with_capacity(bytes.len());
2478 let mut index = 0;
2479 while index < bytes.len() {
2480 if bytes[index] != b'%' {
2481 normalized.push(bytes[index]);
2482 index += 1;
2483 continue;
2484 }
2485 if index + 2 >= bytes.len()
2486 || !bytes[index + 1].is_ascii_hexdigit()
2487 || !bytes[index + 2].is_ascii_hexdigit()
2488 {
2489 return None;
2490 }
2491 let encoded = u8::from_str_radix(&component[index + 1..index + 3], 16).ok()?;
2492 if matches!(encoded, b'/' | b'\\') {
2493 return None;
2494 }
2495 if encoded.is_ascii_alphanumeric() || b"-._~".contains(&encoded) {
2496 normalized.push(encoded);
2497 } else {
2498 normalized.extend_from_slice(&bytes[index..index + 3]);
2499 }
2500 index += 3;
2501 }
2502 String::from_utf8(normalized).ok()
2503}
2504
2505fn append_docx_story(
2507 document: &mut DocumentFacts,
2508 story: DocumentFacts,
2509 document_newlines: &mut usize,
2510) -> Result<(), DocumentExtractionError> {
2511 let DocumentFacts {
2512 text,
2513 facts,
2514 symbols,
2515 completeness,
2516 ..
2517 } = story;
2518 let separator = usize::from(!document.text.is_empty() && !text.is_empty());
2519 let total = document
2520 .text
2521 .len()
2522 .saturating_add(separator)
2523 .saturating_add(text.len());
2524 if total > MAX_DOCUMENT_OUTPUT_BYTES {
2525 return Err(DocumentExtractionError::ResourceLimit {
2526 limit: DocumentLimit::OutputBytes,
2527 observed: total,
2528 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
2529 });
2530 }
2531 let fact_count = document
2532 .facts
2533 .len()
2534 .saturating_add(document.symbols.len())
2535 .saturating_add(facts.len())
2536 .saturating_add(symbols.len());
2537 if fact_count > MAX_DOCUMENT_FACTS {
2538 return Err(DocumentExtractionError::ResourceLimit {
2539 limit: DocumentLimit::FactCount,
2540 observed: fact_count,
2541 maximum: MAX_DOCUMENT_FACTS,
2542 });
2543 }
2544 let line_offset = if document.text.is_empty() {
2545 0
2546 } else {
2547 *document_newlines + separator
2548 };
2549 *document_newlines += text.bytes().filter(|byte| *byte == b'\n').count() + separator;
2550 if separator != 0 {
2551 document.text.push('\n');
2552 }
2553 document.text.push_str(&text);
2554 document.facts.extend(facts.into_iter().map(|mut fact| {
2555 fact.line_start += line_offset;
2556 fact.line_end += line_offset;
2557 fact
2558 }));
2559 document
2560 .symbols
2561 .extend(symbols.into_iter().map(|mut symbol| {
2562 symbol.line += line_offset;
2563 symbol
2564 }));
2565 if let DocumentCompleteness::Partial { gaps } = completeness {
2566 if let DocumentCompleteness::Complete = document.completeness {
2567 document.completeness = DocumentCompleteness::Partial { gaps: Vec::new() };
2568 }
2569 if let DocumentCompleteness::Partial { gaps: existing } = &mut document.completeness {
2570 for gap in gaps {
2571 if !existing.contains(&gap) {
2572 existing.push(gap);
2573 }
2574 }
2575 }
2576 }
2577 Ok(())
2578}
2579
2580#[derive(Default)]
2582struct RawDocxRun {
2583 depth: usize,
2585 content_seen: bool,
2587 properties_depth: Option<usize>,
2589 drawing_depth: Option<usize>,
2591 hidden: bool,
2593 spec_vanish: bool,
2595 text: String,
2597 text_start: usize,
2599}
2600
2601#[derive(Default)]
2603struct DocxTextContext {
2604 number: usize,
2606 run_number: usize,
2608 open: bool,
2610 run: Option<RawDocxRun>,
2612 fields: Vec<DocxFieldPhase>,
2614}
2615
2616#[derive(Clone, Copy, PartialEq, Eq)]
2618enum DocxFieldPhase {
2619 Instruction,
2621 Result {
2623 has_cached_text: bool,
2625 },
2626}
2627
2628struct DocxSimpleField {
2630 depth: usize,
2632 has_cached_text: bool,
2634 in_instruction: bool,
2636}
2637
2638fn mark_docx_cached_result(fields: &mut [DocxFieldPhase], simple: &mut [DocxSimpleField]) {
2640 for field in fields {
2641 if let DocxFieldPhase::Result { has_cached_text } = field {
2642 *has_cached_text = true;
2643 }
2644 }
2645 for field in simple {
2646 field.has_cached_text = true;
2647 }
2648}
2649
2650#[derive(Clone, Copy, PartialEq, Eq)]
2652enum DocxTextCarrier {
2653 Rendered,
2655 Ignored,
2657}
2658
2659fn wordprocessing_namespace(namespace: &str) -> bool {
2661 matches!(
2662 namespace,
2663 "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2664 | "http://purl.oclc.org/ooxml/wordprocessingml/main"
2665 )
2666}
2667
2668fn docx_choice_supported(
2670 reader: &NsReader<&[u8]>,
2671 event: &BytesStart<'_>,
2672 control: &IndexWorkControl,
2673 stage: IndexWorkStage,
2674) -> Result<bool, DocumentExtractionError> {
2675 let requires = event
2676 .try_get_attribute("Requires")
2677 .map_err(|error| DocumentExtractionError::Malformed {
2678 format: DocumentFormat::Docx,
2679 message: error.to_string(),
2680 })?
2681 .ok_or_else(|| DocumentExtractionError::Malformed {
2682 format: DocumentFormat::Docx,
2683 message: "DOCX compatibility choice requires namespace prefixes".to_owned(),
2684 })?;
2685 let requires = quick_xml::escape::unescape(&requires.value).map_err(|error| {
2686 DocumentExtractionError::Malformed {
2687 format: DocumentFormat::Docx,
2688 message: error.to_string(),
2689 }
2690 })?;
2691 if xml_whitespace_tokens(&requires).next().is_none() {
2692 return Err(DocumentExtractionError::Malformed {
2693 format: DocumentFormat::Docx,
2694 message: "DOCX compatibility choice requires namespace prefixes".to_owned(),
2695 });
2696 }
2697 let mut supported = true;
2698 for (index, prefix) in xml_whitespace_tokens(&requires).enumerate() {
2699 check_parser_iteration(index, &mut || control.check(stage))?;
2700 if prefix.contains(':') {
2701 return Err(DocumentExtractionError::Malformed {
2702 format: DocumentFormat::Docx,
2703 message: format!("DOCX compatibility choice has invalid prefix {prefix}"),
2704 });
2705 }
2706 let qualified = format!("{prefix}:choice");
2707 let (namespace, _) = reader.resolver().resolve_element(QName(&qualified));
2708 match namespace {
2709 ResolveResult::Bound(namespace) if wordprocessing_namespace(namespace.as_ref()) => {}
2710 ResolveResult::Bound(_) => supported = false,
2711 ResolveResult::Unbound | ResolveResult::Unknown(_) => {
2712 return Err(DocumentExtractionError::Malformed {
2713 format: DocumentFormat::Docx,
2714 message: format!("DOCX compatibility choice has undeclared prefix {prefix}"),
2715 });
2716 }
2717 }
2718 }
2719 Ok(supported)
2720}
2721
2722struct DocxAlternative {
2724 depth: usize,
2726 selection: DocxAlternativeSelection,
2728 choice_seen: bool,
2730 fallback_seen: bool,
2732}
2733
2734#[derive(Clone, Copy, Eq, PartialEq)]
2736enum DocxAlternativeSelection {
2737 Unselected,
2739 Selected,
2741 Uncertain,
2743}
2744
2745#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2747enum DocxStoryKind {
2748 Header,
2750 Footer,
2752 Footnotes,
2754 Endnotes,
2756 Comments,
2758 Subdocument,
2760}
2761
2762impl DocxStoryKind {
2763 fn content_type(self) -> &'static str {
2765 match self {
2766 Self::Header => "header",
2767 Self::Footer => "footer",
2768 Self::Footnotes => "footnotes",
2769 Self::Endnotes => "endnotes",
2770 Self::Comments => "comments",
2771 Self::Subdocument => "document.main",
2772 }
2773 }
2774
2775 fn relationship_name(self) -> &'static str {
2777 match self {
2778 Self::Header => "header",
2779 Self::Footer => "footer",
2780 Self::Footnotes => "footnotes",
2781 Self::Endnotes => "endnotes",
2782 Self::Comments => "comments",
2783 Self::Subdocument => "subDocument",
2784 }
2785 }
2786
2787 fn root_name(self) -> &'static str {
2789 match self {
2790 Self::Header => "hdr",
2791 Self::Footer => "ftr",
2792 Self::Footnotes => "footnotes",
2793 Self::Endnotes => "endnotes",
2794 Self::Comments => "comments",
2795 Self::Subdocument => "document",
2796 }
2797 }
2798
2799 fn item_name(self) -> Option<&'static str> {
2801 match self {
2802 Self::Footnotes => Some("footnote"),
2803 Self::Endnotes => Some("endnote"),
2804 Self::Comments => Some("comment"),
2805 Self::Header | Self::Footer | Self::Subdocument => None,
2806 }
2807 }
2808}
2809
2810#[derive(Default)]
2811struct DocxStoryReferences {
2813 linked_parts: Vec<(DocxStoryKind, String)>,
2815 has_default_header_footer: bool,
2817 even_parts: Vec<(DocxStoryKind, String)>,
2819 imported_parts: Vec<String>,
2821 content_parts: Vec<String>,
2823 items: Vec<(DocxStoryKind, String)>,
2825 glossary_placeholder: bool,
2827}
2828
2829impl DocxStoryReferences {
2830 fn count(&self) -> usize {
2832 self.linked_parts
2833 .len()
2834 .saturating_add(self.even_parts.len())
2835 .saturating_add(self.imported_parts.len())
2836 .saturating_add(self.content_parts.len())
2837 .saturating_add(self.items.len())
2838 .saturating_add(usize::from(self.glossary_placeholder))
2839 }
2840
2841 fn has_parity_variant(&self) -> bool {
2843 self.has_default_header_footer || !self.even_parts.is_empty()
2844 }
2845}
2846
2847#[derive(Clone, Copy, Eq, PartialEq)]
2848enum DocxHeaderVariant {
2850 Default,
2852 First,
2854 Even,
2856}
2857
2858struct DocxHeaderSection {
2860 depth: usize,
2862 logical_depth: usize,
2864 title_page: Option<bool>,
2866 links: Vec<(DocxStoryKind, String, DocxHeaderVariant)>,
2868}
2869
2870#[derive(Clone, Copy, Default, Eq, PartialEq)]
2872enum DocxSectionAncestor {
2873 #[default]
2874 Other,
2876 Document,
2878 Body,
2880 Paragraph,
2882 ParagraphProperties,
2884}
2885
2886#[derive(Clone, Copy, Default, Eq, PartialEq)]
2888enum DocxBlockAncestor {
2889 #[default]
2890 Other,
2892 Content,
2894 Table,
2896 Row,
2898 SdtContent,
2900 SdtTable,
2902 SdtRow,
2904}
2905
2906#[derive(Clone, Copy, Default, Eq, PartialEq)]
2908enum DocxSdtTag {
2909 #[default]
2910 Other,
2912 Sdt,
2914 Properties,
2916 Placeholder,
2918 Content,
2920}
2921
2922struct DocxSdtContext {
2924 depth: usize,
2926 placeholder: bool,
2928 showing: bool,
2930 content_depth: Option<usize>,
2932 content_nonempty: bool,
2934}
2935
2936fn office_relationship_namespace(namespace: &str) -> bool {
2938 matches!(
2939 namespace,
2940 "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2941 | "http://purl.oclc.org/ooxml/officeDocument/relationships"
2942 )
2943}
2944
2945fn validate_docx_attributes(
2947 event: &BytesStart<'_>,
2948 reader: &NsReader<&[u8]>,
2949 control: &IndexWorkControl,
2950 stage: IndexWorkStage,
2951) -> Result<(), DocumentExtractionError> {
2952 for (index, attribute) in event.attributes().enumerate() {
2953 check_parser_iteration(index, &mut || control.check(stage))?;
2954 let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2955 format: DocumentFormat::Docx,
2956 message: error.to_string(),
2957 })?;
2958 if let (ResolveResult::Unknown(prefix), _) =
2959 reader.resolver().resolve_attribute(attribute.key)
2960 {
2961 return Err(DocumentExtractionError::Malformed {
2962 format: DocumentFormat::Docx,
2963 message: format!("DOCX XML attribute has an undeclared namespace prefix: {prefix}"),
2964 });
2965 }
2966 let value = quick_xml::escape::unescape(&attribute.value).map_err(|error| {
2967 DocumentExtractionError::Malformed {
2968 format: DocumentFormat::Docx,
2969 message: error.to_string(),
2970 }
2971 })?;
2972 if !value.chars().all(xml10_character) {
2973 return Err(DocumentExtractionError::Malformed {
2974 format: DocumentFormat::Docx,
2975 message: "DOCX XML attribute contains an invalid character".to_owned(),
2976 });
2977 }
2978 }
2979 Ok(())
2980}
2981
2982fn docx_attribute(
2984 event: &BytesStart<'_>,
2985 reader: &NsReader<&[u8]>,
2986 local_name: &str,
2987 namespace_matches: fn(&str) -> bool,
2988) -> Result<Option<String>, DocumentExtractionError> {
2989 let mut found = None;
2990 for attribute in event.attributes() {
2991 let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2992 format: DocumentFormat::Docx,
2993 message: error.to_string(),
2994 })?;
2995 let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
2996 if let ResolveResult::Unknown(prefix) = &namespace {
2997 return Err(DocumentExtractionError::Malformed {
2998 format: DocumentFormat::Docx,
2999 message: format!("DOCX XML attribute has an undeclared namespace prefix: {prefix}"),
3000 });
3001 }
3002 if local.as_ref() == local_name
3003 && matches!(namespace, ResolveResult::Bound(namespace)
3004 if namespace_matches(namespace.as_ref()))
3005 {
3006 if found.is_some() {
3007 return Err(DocumentExtractionError::Malformed {
3008 format: DocumentFormat::Docx,
3009 message: format!("DOCX {local_name} attribute is duplicated"),
3010 });
3011 }
3012 let value = quick_xml::escape::unescape(&attribute.value).map_err(|error| {
3013 DocumentExtractionError::Malformed {
3014 format: DocumentFormat::Docx,
3015 message: error.to_string(),
3016 }
3017 })?;
3018 if local_name == "id" && value.len() > MAX_DOCX_REFERENCE_ID_BYTES {
3019 return Err(DocumentExtractionError::ResourceLimit {
3020 limit: DocumentLimit::MemoryBytes,
3021 observed: value.len(),
3022 maximum: MAX_DOCX_REFERENCE_ID_BYTES,
3023 });
3024 }
3025 found = Some(value.into_owned());
3026 }
3027 }
3028 Ok(found)
3029}
3030
3031fn canonical_docx_story_id(id: &str) -> Result<String, DocumentExtractionError> {
3033 let id = id.trim_matches([' ', '\t', '\r', '\n']);
3034 let (negative, digits) = match id.strip_prefix('-') {
3035 Some(digits) => (true, digits),
3036 None => (false, id.strip_prefix('+').unwrap_or(id)),
3037 };
3038 if digits.is_empty() || !digits.bytes().all(|digit| digit.is_ascii_digit()) {
3039 return Err(DocumentExtractionError::Malformed {
3040 format: DocumentFormat::Docx,
3041 message: "DOCX story item ID is not a decimal integer".to_owned(),
3042 });
3043 }
3044 let digits = digits.trim_start_matches('0');
3045 if digits.is_empty() {
3046 return Ok("0".to_owned());
3047 }
3048 Ok(if negative {
3049 format!("-{digits}")
3050 } else {
3051 digits.to_owned()
3052 })
3053}
3054
3055fn docx_on_off(
3057 event: &BytesStart<'_>,
3058 reader: &NsReader<&[u8]>,
3059) -> Result<bool, DocumentExtractionError> {
3060 match docx_attribute(event, reader, "val", wordprocessing_namespace)?.as_deref() {
3061 None | Some("true" | "1" | "on") => Ok(true),
3062 Some("false" | "0" | "off") => Ok(false),
3063 Some(value) => Err(DocumentExtractionError::Malformed {
3064 format: DocumentFormat::Docx,
3065 message: format!("DOCX on/off value {value:?} is invalid"),
3066 }),
3067 }
3068}
3069
3070#[cfg(test)]
3072fn parse_docx(
3073 xml: &[u8],
3074 control: &IndexWorkControl,
3075 stage: IndexWorkStage,
3076) -> Result<DocumentFacts, DocumentExtractionError> {
3077 let xml = decode_docx_xml(xml.to_vec(), control, stage)?;
3078 parse_docx_part(
3079 &xml,
3080 DOCX_DOCUMENT_PART,
3081 "document",
3082 None,
3083 None,
3084 &mut DocxStoryReferences::default(),
3085 control,
3086 stage,
3087 )
3088}
3089
3090fn parse_docx_part(
3092 xml: &[u8],
3093 part: &str,
3094 root_name: &str,
3095 selected_ids: Option<&HashSet<String>>,
3096 selected_special_ids: Option<&HashSet<String>>,
3097 references: &mut DocxStoryReferences,
3098 control: &IndexWorkControl,
3099 stage: IndexWorkStage,
3100) -> Result<DocumentFacts, DocumentExtractionError> {
3101 let mut reader = NsReader::from_reader(xml);
3102 reader.config_mut().trim_text(false);
3103 reader.config_mut().expand_empty_elements = true;
3104 reader.config_mut().check_comments = true;
3105 let mut output = String::new();
3106 let mut output_line = 1usize;
3107 let mut facts = Vec::new();
3108 let mut symbols = Vec::new();
3109 let mut gaps = Vec::new();
3110 let mut paragraph_number = 0usize;
3111 let mut paragraph = DocxTextContext::default();
3112 let mut text_boxes = Vec::new();
3113 let mut text_carrier = None;
3114 let mut text_start = 0;
3115 let mut preserve_space = [false; MAX_DOCX_XML_DEPTH + 1];
3116 let mut sdt_tags = [DocxSdtTag::Other; MAX_DOCX_XML_DEPTH + 1];
3117 let mut section_ancestors = [DocxSectionAncestor::Other; MAX_DOCX_XML_DEPTH + 1];
3118 let mut inside_main_body = [false; MAX_DOCX_XML_DEPTH + 1];
3119 let mut comment_range_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3120 let mut block_ancestors = [DocxBlockAncestor::Other; MAX_DOCX_XML_DEPTH + 1];
3121 let mut rendered_run_ancestry = [false; MAX_DOCX_XML_DEPTH + 1];
3122 let mut direct_run_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3123 let mut import_anchor_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3124 let mut text_box_content_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3125 let mut sdt_stack: Vec<DocxSdtContext> = Vec::new();
3126 let mut simple_fields: Vec<DocxSimpleField> = Vec::new();
3127 let mut header_section: Option<DocxHeaderSection> = None;
3128 let mut inherited_first: [Option<String>; 2] = [None, None];
3129 let mut inherited_default = [false; 2];
3130 let mut alternatives: Vec<DocxAlternative> = Vec::new();
3131 let mut selected_branches: Vec<usize> = Vec::new();
3132 let mut ignorable_namespaces: Vec<String> = Vec::new();
3133 let mut skipped_branch_depth = None;
3134 let mut skipped_text_box = false;
3135 let mut conditional_note_start = None;
3136 let mut deleted_depth = None;
3137 let mut foreign_depth = None;
3138 let mut foreign_opaque_depth = None;
3139 let mut element_depth = 0usize;
3140 let mut root_seen = false;
3141 let mut root_closed = false;
3142 let mut main_body_seen = false;
3143 let mut main_body_unexamined = false;
3144 let mut event_index = 0usize;
3145 let mut seen_ids = HashSet::new();
3146 let mut seen_special_ids = HashSet::new();
3147 loop {
3148 check_parser_iteration(event_index, &mut || control.check(stage))?;
3149 event_index = event_index.saturating_add(1);
3150 let (namespace, event) =
3151 reader
3152 .read_resolved_event()
3153 .map_err(|error| DocumentExtractionError::Malformed {
3154 format: DocumentFormat::Docx,
3155 message: error.to_string(),
3156 })?;
3157 let compatibility = matches!(&namespace, ResolveResult::Bound(namespace)
3158 if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006");
3159 let wordprocessing = match &namespace {
3160 ResolveResult::Bound(namespace) => wordprocessing_namespace(namespace.as_ref()),
3161 ResolveResult::Unbound => false,
3162 ResolveResult::Unknown(prefix) => {
3163 return Err(DocumentExtractionError::Malformed {
3164 format: DocumentFormat::Docx,
3165 message: format!("DOCX XML contained an undeclared namespace prefix: {prefix}"),
3166 });
3167 }
3168 };
3169 let recognized_drawing = matches!(&namespace, ResolveResult::Bound(namespace)
3170 if matches!(namespace.as_ref(),
3171 "http://schemas.openxmlformats.org/drawingml/2006/main"
3172 | "http://purl.oclc.org/ooxml/drawingml/main"
3173 | "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
3174 | "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing"
3175 | "http://schemas.openxmlformats.org/drawingml/2006/picture"
3176 | "http://purl.oclc.org/ooxml/drawingml/picture"
3177 | "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
3178 | "urn:schemas-microsoft-com:vml"));
3179 let text_box_content_parent = match (&namespace, &event) {
3180 (ResolveResult::Bound(namespace), Event::Start(element) | Event::Empty(element)) => {
3181 matches!(
3182 (namespace.as_ref(), element.local_name().as_ref()),
3183 (
3184 "http://schemas.openxmlformats.org/drawingml/2006/main"
3185 | "http://purl.oclc.org/ooxml/drawingml/main",
3186 "graphicData",
3187 ) | ("urn:schemas-microsoft-com:vml", "textbox")
3188 | (
3189 "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
3190 "txbx",
3191 )
3192 )
3193 }
3194 _ => false,
3195 };
3196 if matches!(event, Event::Decl(_)) && event_index != 1 {
3197 return Err(DocumentExtractionError::Malformed {
3198 format: DocumentFormat::Docx,
3199 message: "DOCX XML declaration must be the first XML event".to_owned(),
3200 });
3201 }
3202 if foreign_depth.is_some() && text_carrier.is_none() {
3203 let has_text = match &event {
3204 Event::Text(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
3205 Event::CData(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
3206 Event::GeneralRef(reference) => Some(
3207 decode_docx_reference(reference)?
3208 .chars()
3209 .any(|c| !c.is_ascii_whitespace()),
3210 ),
3211 _ => None,
3212 };
3213 if let Some(has_text) = has_text {
3214 if has_text && deleted_depth.is_none() && skipped_branch_depth.is_none() {
3215 return Err(DocumentExtractionError::UnsupportedDocxInput {
3216 message: "foreign-namespace text requires unsupported semantic decoding"
3217 .to_owned(),
3218 });
3219 }
3220 continue;
3221 }
3222 }
3223 match event {
3224 Event::Start(event) => {
3225 if text_carrier.is_some() {
3226 return Err(DocumentExtractionError::Malformed {
3227 format: DocumentFormat::Docx,
3228 message: "DOCX text elements cannot contain nested markup".to_owned(),
3229 });
3230 }
3231 let name = event.local_name();
3232 if element_depth == 0 {
3233 if root_seen || !wordprocessing || name.as_ref() != root_name {
3234 return Err(DocumentExtractionError::Malformed {
3235 format: DocumentFormat::Docx,
3236 message: "DOCX XML must contain one WordprocessingML document root"
3237 .to_owned(),
3238 });
3239 }
3240 root_seen = true;
3241 }
3242 element_depth = element_depth.saturating_add(1);
3243 if element_depth > MAX_DOCX_XML_DEPTH {
3244 return Err(DocumentExtractionError::ResourceLimit {
3245 limit: DocumentLimit::NestingDepth,
3246 observed: element_depth,
3247 maximum: MAX_DOCX_XML_DEPTH,
3248 });
3249 }
3250 preserve_space[element_depth] = preserve_space[element_depth - 1];
3251 sdt_tags[element_depth] = DocxSdtTag::Other;
3252 if wordprocessing
3254 && name.as_ref() == "p"
3255 && (selected_ids.is_some()
3256 || skipped_branch_depth.is_none()
3257 || skipped_text_box)
3258 {
3259 paragraph_number += 1;
3260 }
3261 if let Some(branch_depth) = skipped_branch_depth {
3262 validate_docx_attributes(&event, &reader, control, stage)?;
3263 if root_name == "document"
3264 && wordprocessing
3265 && name.as_ref() == "body"
3266 && element_depth == branch_depth + 1
3267 && alternatives.last().is_some_and(|alternative| {
3268 alternative.selection == DocxAlternativeSelection::Uncertain
3269 && alternative.depth + 1 == branch_depth
3270 && alternative.depth == 2 + 2 * selected_branches.len()
3271 })
3272 {
3273 main_body_unexamined = true;
3274 }
3275 continue;
3276 }
3277 if wordprocessing
3278 && (foreign_opaque_depth.is_some()
3279 || (foreign_depth.is_some()
3280 && text_boxes.is_empty()
3281 && name.as_ref() != "txbxContent"))
3282 {
3283 return Err(DocumentExtractionError::UnsupportedDocxInput {
3284 message: "Word content inside an opaque foreign wrapper cannot be verified as rendered".to_owned(),
3285 });
3286 }
3287 if wordprocessing
3288 && deleted_depth.is_none()
3289 && matches!(name.as_ref(), "rStyle" | "pStyle")
3290 && !gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)
3291 {
3292 gaps.push(DocumentCoverageGap::UnresolvedVisibility);
3293 }
3294 let ignorable = !wordprocessing
3295 && !compatibility
3296 && matches!(&namespace, ResolveResult::Bound(namespace)
3297 if ignorable_namespaces.iter().any(|ignored| ignored == namespace.as_ref()));
3298 if wordprocessing
3299 && matches!(name.as_ref(), "footnote" | "endnote" | "comment")
3300 && (!matches!(
3301 (root_name, name.as_ref()),
3302 ("footnotes", "footnote")
3303 | ("endnotes", "endnote")
3304 | ("comments", "comment")
3305 ) || element_depth != 2 + 2 * selected_branches.len())
3306 {
3307 return Err(DocumentExtractionError::Malformed {
3308 format: DocumentFormat::Docx,
3309 message: "DOCX story item is outside its collection root".to_owned(),
3310 });
3311 }
3312 if element_depth == 2 + 2 * selected_branches.len()
3313 && let Some(selected_ids) = selected_ids
3314 && matches!(root_name, "footnotes" | "endnotes" | "comments")
3315 {
3316 let expected = match root_name {
3317 "footnotes" => "footnote",
3318 "endnotes" => "endnote",
3319 _ => "comment",
3320 };
3321 if wordprocessing && name.as_ref() != expected {
3322 return Err(DocumentExtractionError::Malformed {
3323 format: DocumentFormat::Docx,
3324 message: "DOCX story root contains a non-item child".to_owned(),
3325 });
3326 }
3327 if wordprocessing {
3328 let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
3329 .ok_or_else(|| DocumentExtractionError::Malformed {
3330 format: DocumentFormat::Docx,
3331 message: "DOCX story item is missing its id".to_owned(),
3332 })?;
3333 let id = canonical_docx_story_id(&id)?;
3334 let note_type = if matches!(root_name, "footnotes" | "endnotes") {
3335 docx_attribute(&event, &reader, "type", wordprocessing_namespace)?
3336 } else {
3337 None
3338 };
3339 let selected = selected_ids.contains(&id);
3340 if selected && note_type.as_deref().is_some_and(|kind| kind != "normal") {
3341 return Err(DocumentExtractionError::InvalidDocxPackage {
3342 message: "DOCX note reference targets a non-normal item".to_owned(),
3343 });
3344 }
3345 let special = selected_special_ids.is_some_and(|ids| ids.contains(&id))
3346 && matches!(
3347 note_type.as_deref(),
3348 Some("separator" | "continuationSeparator" | "continuationNotice")
3349 );
3350 if !selected && !special {
3351 validate_docx_attributes(&event, &reader, control, stage)?;
3352 skipped_branch_depth = Some(element_depth);
3353 continue;
3354 }
3355 let first_occurrence = if selected {
3356 seen_ids.insert(id)
3357 } else {
3358 seen_special_ids.insert(id)
3359 };
3360 if !first_occurrence {
3361 return Err(DocumentExtractionError::Malformed {
3362 format: DocumentFormat::Docx,
3363 message: "DOCX selected story item ID is duplicated".to_owned(),
3364 });
3365 }
3366 if matches!(
3367 note_type.as_deref(),
3368 Some("continuationSeparator" | "continuationNotice")
3369 ) {
3370 conditional_note_start =
3371 Some((facts.len(), symbols.len(), references.count()));
3372 }
3373 }
3374 }
3375 if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
3376 let alternative = alternatives
3377 .last_mut()
3378 .filter(|alternative| {
3379 alternative.depth + 1 == element_depth && !alternative.fallback_seen
3380 })
3381 .ok_or_else(|| DocumentExtractionError::Malformed {
3382 format: DocumentFormat::Docx,
3383 message: "DOCX compatibility branch has invalid placement".to_owned(),
3384 })?;
3385 let supported = if name.as_ref() == "Choice" {
3386 alternative.choice_seen = true;
3387 docx_choice_supported(&reader, &event, control, stage)?
3388 } else {
3389 if !alternative.choice_seen {
3390 return Err(DocumentExtractionError::Malformed {
3391 format: DocumentFormat::Docx,
3392 message: "DOCX compatibility fallback requires a preceding choice"
3393 .to_owned(),
3394 });
3395 }
3396 alternative.fallback_seen = true;
3397 true
3398 };
3399 if name.as_ref() == "Choice"
3400 && !supported
3401 && alternative.selection == DocxAlternativeSelection::Unselected
3402 && !gaps.contains(&DocumentCoverageGap::UnexaminedStory)
3403 {
3404 gaps.push(DocumentCoverageGap::UnexaminedStory);
3405 }
3406 if name.as_ref() == "Choice"
3407 && !supported
3408 && alternative.selection == DocxAlternativeSelection::Unselected
3409 {
3410 alternative.selection = DocxAlternativeSelection::Uncertain;
3411 }
3412 if alternative.selection == DocxAlternativeSelection::Unselected && supported {
3413 alternative.selection = DocxAlternativeSelection::Selected;
3414 } else {
3415 validate_docx_attributes(&event, &reader, control, stage)?;
3416 skipped_branch_depth = Some(element_depth);
3417 continue;
3418 }
3419 }
3420 {
3421 let suppressed = deleted_depth.is_some()
3422 || (wordprocessing
3423 && matches!(name.as_ref(), "del" | "moveFrom" | "sectPrChange"));
3424 for (index, attribute) in event.attributes().enumerate() {
3425 check_parser_iteration(index, &mut || control.check(stage))?;
3426 let attribute =
3427 attribute.map_err(|error| DocumentExtractionError::Malformed {
3428 format: DocumentFormat::Docx,
3429 message: error.to_string(),
3430 })?;
3431 let (attribute_namespace, attribute_name) =
3432 reader.resolver().resolve_attribute(attribute.key);
3433 if let ResolveResult::Unknown(prefix) = &attribute_namespace {
3434 return Err(DocumentExtractionError::Malformed {
3435 format: DocumentFormat::Docx,
3436 message: format!(
3437 "DOCX XML attribute has an undeclared namespace prefix: {prefix}"
3438 ),
3439 });
3440 }
3441 let value =
3442 quick_xml::escape::unescape(&attribute.value).map_err(|error| {
3443 DocumentExtractionError::Malformed {
3444 format: DocumentFormat::Docx,
3445 message: error.to_string(),
3446 }
3447 })?;
3448 if !value.chars().all(xml10_character) {
3449 return Err(DocumentExtractionError::Malformed {
3450 format: DocumentFormat::Docx,
3451 message: "DOCX XML attribute contains an invalid character"
3452 .to_owned(),
3453 });
3454 }
3455 if suppressed {
3456 continue;
3457 }
3458 if attribute_name.as_ref() == "space"
3459 && matches!(&attribute_namespace, ResolveResult::Bound(namespace)
3460 if namespace.as_ref() == "http://www.w3.org/XML/1998/namespace")
3461 {
3462 preserve_space[element_depth] = match value.as_ref() {
3463 "default" => false,
3464 "preserve" => true,
3465 _ => {
3466 return Err(DocumentExtractionError::Malformed {
3467 format: DocumentFormat::Docx,
3468 message: "DOCX xml:space must be default or preserve"
3469 .to_owned(),
3470 });
3471 }
3472 };
3473 }
3474 if !matches!(attribute_namespace, ResolveResult::Bound(namespace)
3475 if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006")
3476 || !matches!(
3477 attribute_name.as_ref(),
3478 "Ignorable" | "ProcessContent" | "MustUnderstand"
3479 )
3480 {
3481 continue;
3482 }
3483 if xml_whitespace_tokens(&value).next().is_none() {
3484 continue;
3485 }
3486 if attribute_name.as_ref() != "Ignorable" || element_depth != 1 {
3487 return Err(DocumentExtractionError::UnsupportedDocxInput {
3488 message: "DOCX compatibility policy supports only root Ignorable namespaces".to_owned(),
3489 });
3490 }
3491 for (index, prefix) in xml_whitespace_tokens(&value).enumerate() {
3492 check_parser_iteration(index, &mut || control.check(stage))?;
3493 if prefix.contains(':') {
3494 return Err(DocumentExtractionError::Malformed {
3495 format: DocumentFormat::Docx,
3496 message: "DOCX Ignorable policy requires namespace prefixes, not qualified names".to_owned(),
3497 });
3498 }
3499 let qualified = format!("{prefix}:ignored");
3500 let (resolved, _) =
3501 reader.resolver().resolve_element(QName(&qualified));
3502 let ResolveResult::Bound(namespace) = resolved else {
3503 return Err(DocumentExtractionError::Malformed {
3504 format: DocumentFormat::Docx,
3505 message: "DOCX Ignorable policy names an undeclared namespace"
3506 .to_owned(),
3507 });
3508 };
3509 if ignorable_namespaces
3510 .iter()
3511 .any(|ignored| ignored == namespace.as_ref())
3512 {
3513 continue;
3514 }
3515 if ignorable_namespaces.len() == MAX_DOCX_IGNORABLE_NAMESPACES {
3516 return Err(DocumentExtractionError::UnsupportedDocxInput {
3517 message: "DOCX root Ignorable policy exceeds the supported namespace count".to_owned(),
3518 });
3519 }
3520 ignorable_namespaces.push(namespace.as_ref().to_owned());
3521 }
3522 }
3523 }
3524 if ignorable {
3525 skipped_branch_depth = Some(element_depth);
3526 continue;
3527 }
3528 if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
3529 selected_branches.push(element_depth);
3530 continue;
3531 }
3532 if alternatives
3533 .last()
3534 .is_some_and(|alternative| alternative.depth + 1 == element_depth)
3535 {
3536 return Err(DocumentExtractionError::Malformed {
3537 format: DocumentFormat::Docx,
3538 message: "DOCX compatibility alternatives must contain choices and an optional fallback".to_owned(),
3539 });
3540 }
3541 if compatibility && name.as_ref() == "AlternateContent" {
3542 alternatives.push(DocxAlternative {
3543 depth: element_depth,
3544 selection: DocxAlternativeSelection::Unselected,
3545 choice_seen: false,
3546 fallback_seen: false,
3547 });
3548 continue;
3549 }
3550 if !wordprocessing && !compatibility {
3551 if foreign_depth.is_none() {
3552 foreign_depth = Some(element_depth);
3553 }
3554 if !recognized_drawing && foreign_opaque_depth.is_none() {
3555 foreign_opaque_depth = Some(element_depth);
3556 }
3557 }
3558 let mut parent_depth = element_depth - 1;
3559 for &branch_depth in selected_branches.iter().rev() {
3560 if branch_depth == parent_depth {
3561 parent_depth -= 2;
3562 }
3563 }
3564 text_box_content_parents[element_depth] = text_box_content_parent;
3565 section_ancestors[element_depth] = if wordprocessing {
3566 match (name.as_ref(), section_ancestors[parent_depth]) {
3567 ("document", _) if element_depth == 1 => DocxSectionAncestor::Document,
3568 ("body", DocxSectionAncestor::Document) => DocxSectionAncestor::Body,
3569 ("p", _) if inside_main_body[parent_depth] => {
3570 DocxSectionAncestor::Paragraph
3571 }
3572 ("pPr", DocxSectionAncestor::Paragraph) => {
3573 DocxSectionAncestor::ParagraphProperties
3574 }
3575 _ => DocxSectionAncestor::Other,
3576 }
3577 } else {
3578 DocxSectionAncestor::Other
3579 };
3580 if root_name == "document" && wordprocessing && name.as_ref() == "body" {
3581 if section_ancestors[element_depth] != DocxSectionAncestor::Body
3582 || main_body_seen
3583 {
3584 return Err(DocumentExtractionError::Malformed {
3585 format: DocumentFormat::Docx,
3586 message: "DOCX main document requires one direct body".to_owned(),
3587 });
3588 }
3589 main_body_seen = true;
3590 }
3591 inside_main_body[element_depth] = if wordprocessing && name.as_ref() == "body" {
3592 section_ancestors[element_depth] == DocxSectionAncestor::Body
3593 } else if wordprocessing
3594 && matches!(
3595 name.as_ref(),
3596 "document"
3597 | "hdr"
3598 | "ftr"
3599 | "footnotes"
3600 | "endnotes"
3601 | "comments"
3602 | "docPartBody"
3603 | "txbxContent"
3604 )
3605 {
3606 false
3607 } else {
3608 inside_main_body[parent_depth]
3609 || section_ancestors[element_depth] == DocxSectionAncestor::Body
3610 };
3611 block_ancestors[element_depth] = if wordprocessing {
3612 match (name.as_ref(), block_ancestors[parent_depth]) {
3613 ("hdr" | "ftr", _) if element_depth == 1 && name.as_ref() == root_name => {
3614 DocxBlockAncestor::Content
3615 }
3616 ("body", _)
3617 if section_ancestors[element_depth] == DocxSectionAncestor::Body =>
3618 {
3619 DocxBlockAncestor::Content
3620 }
3621 ("footnote" | "endnote" | "comment", _)
3622 if parent_depth == 1
3623 && matches!(
3624 (root_name, name.as_ref()),
3625 ("footnotes", "footnote")
3626 | ("endnotes", "endnote")
3627 | ("comments", "comment")
3628 ) =>
3629 {
3630 DocxBlockAncestor::Content
3631 }
3632 ("tbl", DocxBlockAncestor::Content)
3633 | ("sdtContent", DocxBlockAncestor::SdtTable)
3634 | ("customXml", DocxBlockAncestor::Table) => DocxBlockAncestor::Table,
3635 ("tr", DocxBlockAncestor::Table)
3636 | ("sdtContent", DocxBlockAncestor::SdtRow)
3637 | ("customXml", DocxBlockAncestor::Row) => DocxBlockAncestor::Row,
3638 ("sdt", DocxBlockAncestor::Content) => DocxBlockAncestor::SdtContent,
3639 ("sdt", DocxBlockAncestor::Table) => DocxBlockAncestor::SdtTable,
3640 ("sdt", DocxBlockAncestor::Row) => DocxBlockAncestor::SdtRow,
3641 ("txbxContent", _)
3642 | ("tc", DocxBlockAncestor::Row)
3643 | ("sdtContent", DocxBlockAncestor::SdtContent)
3644 | (
3645 "customXml" | "ins" | "moveTo" | "del" | "moveFrom",
3646 DocxBlockAncestor::Content,
3647 ) => DocxBlockAncestor::Content,
3648 _ => DocxBlockAncestor::Other,
3649 }
3650 } else {
3651 DocxBlockAncestor::Other
3652 };
3653 rendered_run_ancestry[element_depth] = wordprocessing
3654 && (name.as_ref() == "p"
3655 || (rendered_run_ancestry[parent_depth]
3656 && matches!(
3657 name.as_ref(),
3658 "sdt"
3659 | "sdtContent"
3660 | "hyperlink"
3661 | "fldSimple"
3662 | "customXml"
3663 | "smartTag"
3664 | "ins"
3665 | "moveTo"
3666 | "del"
3667 | "moveFrom"
3668 | "bdo"
3669 | "dir"
3670 )));
3671 direct_run_parents[element_depth] =
3672 rendered_run_ancestry[element_depth] && name.as_ref() != "sdt";
3673 import_anchor_parents[element_depth] = wordprocessing
3674 && block_ancestors[element_depth] == DocxBlockAncestor::Content
3675 && matches!(
3676 name.as_ref(),
3677 "body"
3678 | "hdr"
3679 | "ftr"
3680 | "footnote"
3681 | "endnote"
3682 | "comment"
3683 | "tc"
3684 | "txbxContent"
3685 );
3686 comment_range_parents[element_depth] = wordprocessing
3688 && matches!(
3689 name.as_ref(),
3690 "bdo"
3691 | "body"
3692 | "comment"
3693 | "customXml"
3694 | "dir"
3695 | "docPartBody"
3696 | "endnote"
3697 | "fldSimple"
3698 | "footnote"
3699 | "ftr"
3700 | "hdr"
3701 | "hyperlink"
3702 | "ins"
3703 | "moveTo"
3704 | "p"
3705 | "rt"
3706 | "rubyBase"
3707 | "sdt"
3708 | "sdtContent"
3709 | "tbl"
3710 | "tc"
3711 | "tr"
3712 | "txbxContent"
3713 );
3714 if wordprocessing && deleted_depth.is_none() {
3715 sdt_tags[element_depth] = match (name.as_ref(), sdt_tags[parent_depth]) {
3716 ("sdt", _) => DocxSdtTag::Sdt,
3717 ("sdtPr", DocxSdtTag::Sdt) => DocxSdtTag::Properties,
3718 ("placeholder", DocxSdtTag::Properties) => DocxSdtTag::Placeholder,
3719 ("sdtContent", DocxSdtTag::Sdt) => DocxSdtTag::Content,
3720 _ => DocxSdtTag::Other,
3721 };
3722 if !paragraph.fields.contains(&DocxFieldPhase::Instruction)
3723 && (matches!(name.as_ref(), "altChunk" | "subDoc")
3724 || (paragraph.run.as_ref().is_some_and(|run| !run.hidden)
3725 && matches!(
3726 name.as_ref(),
3727 "sym"
3728 | "drawing"
3729 | "pict"
3730 | "object"
3731 | "annotationRef"
3732 | "contentPart"
3733 | "tab"
3734 | "ptab"
3735 | "br"
3736 | "cr"
3737 | "lastRenderedPageBreak"
3738 | "noBreakHyphen"
3739 | "softHyphen"
3740 | "pgNum"
3741 | "dayShort"
3742 | "dayLong"
3743 | "monthShort"
3744 | "monthLong"
3745 | "yearShort"
3746 | "yearLong"
3747 | "footnoteReference"
3748 | "endnoteReference"
3749 | "commentReference"
3750 | "footnoteRef"
3751 | "endnoteRef"
3752 | "separator"
3753 | "continuationSeparator"
3754 )))
3755 {
3756 for context in &mut sdt_stack {
3757 if context
3758 .content_depth
3759 .is_some_and(|depth| element_depth > depth)
3760 {
3761 context.content_nonempty = true;
3762 }
3763 }
3764 }
3765 }
3766 let logical_depth = element_depth - 2 * selected_branches.len();
3767 if wordprocessing
3768 && matches!(
3769 name.as_ref(),
3770 "t" | "instrText"
3771 | "delText"
3772 | "delInstrText"
3773 | "fldChar"
3774 | "sym"
3775 | "drawing"
3776 | "pict"
3777 | "object"
3778 | "contentPart"
3779 | "tab"
3780 | "ptab"
3781 | "br"
3782 | "cr"
3783 | "lastRenderedPageBreak"
3784 | "noBreakHyphen"
3785 | "softHyphen"
3786 | "pgNum"
3787 | "dayShort"
3788 | "dayLong"
3789 | "monthShort"
3790 | "monthLong"
3791 | "yearShort"
3792 | "yearLong"
3793 | "footnoteReference"
3794 | "endnoteReference"
3795 | "commentReference"
3796 | "footnoteRef"
3797 | "endnoteRef"
3798 | "annotationRef"
3799 | "separator"
3800 | "continuationSeparator"
3801 )
3802 && paragraph
3803 .run
3804 .as_ref()
3805 .is_some_and(|run| logical_depth != run.depth + 1)
3806 {
3807 return Err(DocumentExtractionError::Malformed {
3808 format: DocumentFormat::Docx,
3809 message: "DOCX run content is not a direct child of its run".to_owned(),
3810 });
3811 }
3812 if wordprocessing
3813 && paragraph.run.is_some()
3814 && matches!(name.as_ref(), "sectPr" | "altChunk" | "subDoc")
3815 {
3816 return Err(DocumentExtractionError::Malformed {
3817 format: DocumentFormat::Docx,
3818 message: "DOCX non-run content appeared inside a run".to_owned(),
3819 });
3820 }
3821 if wordprocessing
3822 && matches!(name.as_ref(), "commentRangeStart" | "commentRangeEnd")
3823 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
3824 && deleted_depth.is_none()
3825 && !paragraph.fields.contains(&DocxFieldPhase::Instruction)
3826 {
3827 return Err(DocumentExtractionError::Malformed {
3828 format: DocumentFormat::Docx,
3829 message: "DOCX comment range appeared inside a rendered run".to_owned(),
3830 });
3831 }
3832 if wordprocessing && let Some(run) = paragraph.run.as_mut() {
3833 if logical_depth == run.depth + 1 {
3834 if name.as_ref() == "rPr" {
3835 if run.content_seen {
3836 return Err(DocumentExtractionError::Malformed {
3837 format: DocumentFormat::Docx,
3838 message: "DOCX run properties follow run content".to_owned(),
3839 });
3840 }
3841 run.properties_depth = Some(logical_depth);
3842 } else {
3843 run.content_seen = true;
3844 if matches!(name.as_ref(), "drawing" | "pict" | "object") {
3845 run.drawing_depth = Some(element_depth);
3846 }
3847 }
3848 }
3849 if name.as_ref() == "vanish"
3850 && run.properties_depth == logical_depth.checked_sub(1)
3851 && deleted_depth.is_none()
3852 {
3853 run.hidden |= docx_on_off(&event, &reader)?;
3854 }
3855 if name.as_ref() == "specVanish"
3856 && run.properties_depth == logical_depth.checked_sub(1)
3857 && deleted_depth.is_none()
3858 {
3859 run.spec_vanish |= docx_on_off(&event, &reader)?;
3860 }
3861 }
3862 match if wordprocessing { name.as_ref() } else { "" } {
3863 "sectPr" if deleted_depth.is_none() => {
3864 if root_name != "document"
3865 || !matches!(
3866 section_ancestors[parent_depth],
3867 DocxSectionAncestor::Body
3868 | DocxSectionAncestor::ParagraphProperties
3869 )
3870 {
3871 return Err(DocumentExtractionError::Malformed {
3872 format: DocumentFormat::Docx,
3873 message: "DOCX section properties have invalid ancestry".to_owned(),
3874 });
3875 }
3876 if header_section.is_some() {
3877 return Err(DocumentExtractionError::Malformed {
3878 format: DocumentFormat::Docx,
3879 message: "DOCX section properties cannot nest".to_owned(),
3880 });
3881 }
3882 header_section = Some(DocxHeaderSection {
3883 depth: element_depth,
3884 logical_depth,
3885 title_page: None,
3886 links: Vec::new(),
3887 });
3888 }
3889 "titlePg" if deleted_depth.is_none() => {
3890 let section = header_section
3891 .as_mut()
3892 .filter(|section| logical_depth == section.logical_depth + 1)
3893 .ok_or_else(|| DocumentExtractionError::Malformed {
3894 format: DocumentFormat::Docx,
3895 message: "DOCX title-page setting is outside section properties"
3896 .to_owned(),
3897 })?;
3898 if section
3899 .title_page
3900 .replace(docx_on_off(&event, &reader)?)
3901 .is_some()
3902 {
3903 return Err(DocumentExtractionError::Malformed {
3904 format: DocumentFormat::Docx,
3905 message: "DOCX section has duplicate title-page settings"
3906 .to_owned(),
3907 });
3908 }
3909 }
3910 "fldSimple" if deleted_depth.is_none() => {
3911 if !paragraph.open || !direct_run_parents[parent_depth] {
3912 return Err(DocumentExtractionError::Malformed {
3913 format: DocumentFormat::Docx,
3914 message: "DOCX simple field is outside rendered paragraph content"
3915 .to_owned(),
3916 });
3917 }
3918 simple_fields.push(DocxSimpleField {
3919 depth: element_depth,
3920 has_cached_text: false,
3921 in_instruction: paragraph.fields.contains(&DocxFieldPhase::Instruction),
3922 });
3923 }
3924 "sdt" if deleted_depth.is_none() => sdt_stack.push(DocxSdtContext {
3925 depth: element_depth,
3926 placeholder: false,
3927 showing: false,
3928 content_depth: None,
3929 content_nonempty: false,
3930 }),
3931 "docPart" if sdt_tags[parent_depth] == DocxSdtTag::Placeholder => {
3932 let name =
3933 docx_attribute(&event, &reader, "val", wordprocessing_namespace)?
3934 .ok_or_else(|| DocumentExtractionError::Malformed {
3935 format: DocumentFormat::Docx,
3936 message: "DOCX placeholder docPart is missing w:val".to_owned(),
3937 })?;
3938 if name.is_empty() {
3939 return Err(DocumentExtractionError::Malformed {
3940 format: DocumentFormat::Docx,
3941 message: "DOCX placeholder docPart name is empty".to_owned(),
3942 });
3943 }
3944 if let Some(context) = sdt_stack.last_mut() {
3945 context.placeholder = true;
3946 }
3947 }
3948 "showingPlcHdr" if sdt_tags[parent_depth] == DocxSdtTag::Properties => {
3949 if let Some(context) = sdt_stack.last_mut() {
3950 context.showing = docx_on_off(&event, &reader)?;
3951 }
3952 }
3953 "sdtContent" if sdt_tags[element_depth] == DocxSdtTag::Content => {
3954 if let Some(context) = sdt_stack.last_mut() {
3955 context.content_depth = Some(element_depth);
3956 }
3957 }
3958 "headerReference" | "footerReference" if deleted_depth.is_none() => {
3959 let kind = if name.as_ref() == "headerReference" {
3960 DocxStoryKind::Header
3961 } else {
3962 DocxStoryKind::Footer
3963 };
3964 let id =
3965 docx_attribute(&event, &reader, "id", office_relationship_namespace)?
3966 .ok_or_else(|| DocumentExtractionError::Malformed {
3967 format: DocumentFormat::Docx,
3968 message: "DOCX header/footer reference is missing r:id".to_owned(),
3969 })?;
3970 let variant = match docx_attribute(
3971 &event,
3972 &reader,
3973 "type",
3974 wordprocessing_namespace,
3975 )?
3976 .as_deref()
3977 {
3978 None | Some("default") => DocxHeaderVariant::Default,
3979 Some("first") => DocxHeaderVariant::First,
3980 Some("even") => DocxHeaderVariant::Even,
3981 Some(value) => {
3982 return Err(DocumentExtractionError::Malformed {
3983 format: DocumentFormat::Docx,
3984 message: format!(
3985 "DOCX header/footer type {value:?} is invalid"
3986 ),
3987 });
3988 }
3989 };
3990 let section = header_section
3991 .as_mut()
3992 .filter(|section| logical_depth == section.logical_depth + 1)
3993 .ok_or_else(|| DocumentExtractionError::Malformed {
3994 format: DocumentFormat::Docx,
3995 message:
3996 "DOCX header/footer reference is not a direct section child"
3997 .to_owned(),
3998 })?;
3999 if section
4000 .links
4001 .iter()
4002 .any(|(existing_kind, _, existing_variant)| {
4003 *existing_kind == kind && *existing_variant == variant
4004 })
4005 {
4006 return Err(DocumentExtractionError::Malformed {
4007 format: DocumentFormat::Docx,
4008 message: "DOCX section has a duplicate header/footer variant"
4009 .to_owned(),
4010 });
4011 }
4012 section.links.push((kind, id, variant));
4013 }
4014 "subDoc"
4015 if deleted_depth.is_none()
4016 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4017 {
4018 if !direct_run_parents[parent_depth] {
4019 return Err(DocumentExtractionError::Malformed {
4020 format: DocumentFormat::Docx,
4021 message:
4022 "DOCX subdocument anchor is outside rendered paragraph content"
4023 .to_owned(),
4024 });
4025 }
4026 let id =
4027 docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4028 .ok_or_else(|| DocumentExtractionError::Malformed {
4029 format: DocumentFormat::Docx,
4030 message: "DOCX subdocument reference is missing r:id".to_owned(),
4031 })?;
4032 references
4033 .linked_parts
4034 .push((DocxStoryKind::Subdocument, id));
4035 if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4036 gaps.push(DocumentCoverageGap::UnexaminedStory);
4037 }
4038 }
4039 "altChunk"
4040 if deleted_depth.is_none()
4041 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4042 {
4043 if !import_anchor_parents[parent_depth] {
4044 return Err(DocumentExtractionError::Malformed {
4045 format: DocumentFormat::Docx,
4046 message:
4047 "DOCX alternate-format import is outside rendered block content"
4048 .to_owned(),
4049 });
4050 }
4051 let id =
4052 docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4053 .ok_or_else(|| DocumentExtractionError::Malformed {
4054 format: DocumentFormat::Docx,
4055 message: "DOCX alternate-format import is missing r:id".to_owned(),
4056 })?;
4057 references.imported_parts.push(id);
4058 if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4059 gaps.push(DocumentCoverageGap::UnexaminedStory);
4060 }
4061 }
4062 "ruby" => {
4063 if deleted_depth.is_some() {
4064 skipped_branch_depth = Some(element_depth);
4065 } else {
4066 return Err(DocumentExtractionError::UnsupportedDocxInput {
4067 message: "ruby annotations require unsupported nested run decoding"
4068 .to_owned(),
4069 });
4070 }
4071 }
4072 "del" | "moveFrom" | "sectPrChange" if deleted_depth.is_none() => {
4073 deleted_depth = Some(element_depth);
4074 }
4075 "txbxContent" => {
4076 if !text_box_content_parents[parent_depth] {
4077 return Err(DocumentExtractionError::Malformed {
4078 format: DocumentFormat::Docx,
4079 message: "DOCX text box has no supported drawing parent".to_owned(),
4080 });
4081 }
4082 if !paragraph.run.as_ref().is_some_and(|run| {
4083 run.drawing_depth.is_some() && run.properties_depth.is_none()
4084 }) {
4085 return Err(DocumentExtractionError::Malformed {
4086 format: DocumentFormat::Docx,
4087 message: "DOCX text box has no rendered drawing owner".to_owned(),
4088 });
4089 }
4090 if paragraph.run.as_ref().is_some_and(|run| run.hidden)
4091 || paragraph.fields.contains(&DocxFieldPhase::Instruction)
4092 {
4093 skipped_branch_depth = Some(element_depth);
4094 skipped_text_box = true;
4095 continue;
4096 }
4097 if let Some(run) = paragraph.run.as_mut() {
4098 publish_docx_run_fragment(
4099 run,
4100 part,
4101 paragraph.number,
4102 paragraph.run_number,
4103 &mut output,
4104 &mut output_line,
4105 &mut facts,
4106 symbols.len(),
4107 )?;
4108 }
4109 text_boxes.push((
4110 std::mem::take(&mut paragraph),
4111 output.len(),
4112 facts.len(),
4113 ));
4114 }
4115 "p" if !paragraph.open => {
4116 if block_ancestors[parent_depth] != DocxBlockAncestor::Content {
4117 return Err(DocumentExtractionError::Malformed {
4118 format: DocumentFormat::Docx,
4119 message: "DOCX paragraph is outside rendered block content"
4120 .to_owned(),
4121 });
4122 }
4123 paragraph.open = true;
4124 paragraph.number = paragraph_number;
4125 paragraph.run_number = 0;
4126 if !output.is_empty() && deleted_depth.is_none() {
4127 push_output_byte(&mut output, b'\n')?;
4128 output_line += 1;
4129 }
4130 }
4131 "r" if paragraph.open && paragraph.run.is_none() => {
4132 if !direct_run_parents[parent_depth] {
4133 return Err(DocumentExtractionError::Malformed {
4134 format: DocumentFormat::Docx,
4135 message: "DOCX run is outside rendered paragraph content"
4136 .to_owned(),
4137 });
4138 }
4139 paragraph.run_number += 1;
4140 paragraph.run = Some(RawDocxRun {
4141 depth: logical_depth,
4142 ..RawDocxRun::default()
4143 });
4144 }
4145 "t" | "instrText" | "delText" | "delInstrText" if paragraph.run.is_some() => {
4146 text_start = paragraph.run.as_ref().map_or(0, |run| run.text.len());
4147 let ignored = deleted_depth.is_some()
4148 || paragraph.run.as_ref().is_some_and(|run| run.hidden)
4149 || matches!(name.as_ref(), "delText" | "delInstrText")
4150 || paragraph.fields.contains(&DocxFieldPhase::Instruction);
4151 text_carrier = Some(if ignored {
4152 DocxTextCarrier::Ignored
4153 } else {
4154 DocxTextCarrier::Rendered
4155 });
4156 }
4157 "fldChar" if paragraph.run.is_some() && deleted_depth.is_some() => {}
4158 "fldChar" if paragraph.run.is_some() => {
4159 let field_type = docx_attribute(
4160 &event,
4161 &reader,
4162 "fldCharType",
4163 wordprocessing_namespace,
4164 )?;
4165 match field_type.as_deref() {
4166 Some("begin") => {
4167 if paragraph.fields.len() >= MAX_DOCX_XML_DEPTH {
4168 return Err(DocumentExtractionError::ResourceLimit {
4169 limit: DocumentLimit::NestingDepth,
4170 observed: paragraph.fields.len() + 1,
4171 maximum: MAX_DOCX_XML_DEPTH,
4172 });
4173 }
4174 paragraph.fields.push(DocxFieldPhase::Instruction);
4175 }
4176 Some("separate") if !paragraph.fields.is_empty() => {
4177 if let Some(phase) = paragraph.fields.last_mut() {
4178 *phase = DocxFieldPhase::Result {
4179 has_cached_text: false,
4180 };
4181 }
4182 }
4183 Some("end") if !paragraph.fields.is_empty() => {
4184 if paragraph.fields.pop()
4185 != Some(DocxFieldPhase::Result {
4186 has_cached_text: true,
4187 })
4188 && !paragraph.fields.contains(&DocxFieldPhase::Instruction)
4189 && !gaps.contains(&DocumentCoverageGap::UnevaluatedField)
4190 {
4191 gaps.push(DocumentCoverageGap::UnevaluatedField);
4192 }
4193 }
4194 _ => {
4195 return Err(DocumentExtractionError::Malformed {
4196 format: DocumentFormat::Docx,
4197 message: "DOCX field marker has invalid type or nesting"
4198 .to_owned(),
4199 });
4200 }
4201 }
4202 }
4203 "tab"
4204 | "ptab"
4205 | "br"
4206 | "cr"
4207 | "lastRenderedPageBreak"
4208 | "noBreakHyphen"
4209 | "softHyphen"
4210 if deleted_depth.is_none()
4211 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4212 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4213 {
4214 if let Some(run) = paragraph.run.as_mut() {
4215 mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4216 append_docx_run_text(
4217 run,
4218 match name.as_ref() {
4219 "tab" | "ptab" => "\t",
4220 "noBreakHyphen" => "\u{2011}",
4221 "softHyphen" => "\u{00ad}",
4222 _ => "\n",
4223 },
4224 output.len(),
4225 )?;
4226 }
4227 }
4228 "pgNum" | "dayShort" | "dayLong" | "monthShort" | "monthLong" | "yearShort"
4229 | "yearLong"
4230 if paragraph.run.is_some()
4231 && deleted_depth.is_none()
4232 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4233 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4234 {
4235 if let Some(run) = paragraph.run.as_mut() {
4236 append_docx_run_text(run, "\u{fffc}", output.len())?;
4237 }
4238 if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4239 gaps.push(DocumentCoverageGap::UnevaluatedField);
4240 }
4241 }
4242 "contentPart"
4243 if paragraph.run.is_some()
4244 && deleted_depth.is_none()
4245 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4246 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4247 {
4248 let id =
4249 docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4250 .ok_or_else(|| DocumentExtractionError::Malformed {
4251 format: DocumentFormat::Docx,
4252 message: "DOCX content part is missing r:id".to_owned(),
4253 })?;
4254 references.content_parts.push(id);
4255 if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4256 gaps.push(DocumentCoverageGap::UnexaminedStory);
4257 }
4258 }
4259 "footnoteRef" | "endnoteRef" | "annotationRef"
4260 if paragraph.run.is_some()
4261 && deleted_depth.is_none()
4262 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4263 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4264 {
4265 if (name.as_ref() == "footnoteRef" && root_name != "footnotes")
4266 || (name.as_ref() == "endnoteRef" && root_name != "endnotes")
4267 || (name.as_ref() == "annotationRef" && root_name != "comments")
4268 {
4269 return Err(DocumentExtractionError::Malformed {
4270 format: DocumentFormat::Docx,
4271 message: "DOCX story reference mark is outside its story"
4272 .to_owned(),
4273 });
4274 }
4275 if let Some(run) = paragraph.run.as_mut() {
4276 append_docx_run_text(run, "\u{fffc}", output.len())?;
4277 }
4278 if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4279 gaps.push(DocumentCoverageGap::UnevaluatedField);
4280 }
4281 }
4282 "footnoteReference" | "endnoteReference" | "commentReference"
4283 if paragraph.run.is_some()
4284 && deleted_depth.is_none()
4285 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4286 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4287 {
4288 let kind = match name.as_ref() {
4289 "footnoteReference" => DocxStoryKind::Footnotes,
4290 "endnoteReference" => DocxStoryKind::Endnotes,
4291 _ => DocxStoryKind::Comments,
4292 };
4293 let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
4294 .ok_or_else(|| DocumentExtractionError::Malformed {
4295 format: DocumentFormat::Docx,
4296 message: "DOCX note/comment reference is missing w:id".to_owned(),
4297 })?;
4298 let id = canonical_docx_story_id(&id)?;
4299 references.items.push((kind, id));
4300 if let Some(run) = paragraph.run.as_mut() {
4301 append_docx_run_text(run, "\u{fffc}", output.len())?;
4302 }
4303 if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4304 gaps.push(DocumentCoverageGap::UnevaluatedField);
4305 }
4306 }
4307 "commentRangeStart" | "commentRangeEnd"
4308 if deleted_depth.is_none()
4309 && !paragraph.run.as_ref().is_some_and(|run| run.hidden)
4310 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4311 {
4312 if !comment_range_parents[parent_depth] {
4313 return Err(DocumentExtractionError::Malformed {
4314 format: DocumentFormat::Docx,
4315 message: "DOCX comment range is outside rendered content"
4316 .to_owned(),
4317 });
4318 }
4319 let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
4320 .ok_or_else(|| DocumentExtractionError::Malformed {
4321 format: DocumentFormat::Docx,
4322 message: "DOCX comment range is missing w:id".to_owned(),
4323 })?;
4324 canonical_docx_story_id(&id)?;
4326 }
4327 "sym"
4328 if paragraph.run.is_some()
4329 && deleted_depth.is_none()
4330 && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4331 && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4332 {
4333 let mut font = None;
4334 let mut code = None;
4335 for (index, attribute) in event.attributes().enumerate() {
4336 check_parser_iteration(index, &mut || control.check(stage))?;
4337 let attribute =
4338 attribute.map_err(|error| DocumentExtractionError::Malformed {
4339 format: DocumentFormat::Docx,
4340 message: error.to_string(),
4341 })?;
4342 let (namespace, local) =
4343 reader.resolver().resolve_attribute(attribute.key);
4344 if !matches!(namespace, ResolveResult::Bound(namespace)
4345 if wordprocessing_namespace(namespace.as_ref()))
4346 {
4347 continue;
4348 }
4349 let value =
4350 quick_xml::escape::unescape(&attribute.value).map_err(|error| {
4351 DocumentExtractionError::Malformed {
4352 format: DocumentFormat::Docx,
4353 message: error.to_string(),
4354 }
4355 })?;
4356 match local.as_ref() {
4357 "font" => {
4358 if font.is_some() {
4359 return Err(DocumentExtractionError::Malformed {
4360 format: DocumentFormat::Docx,
4361 message: "DOCX font attribute is duplicated".to_owned(),
4362 });
4363 }
4364 if value.len() > MAX_DOCX_FONT_NAME_BYTES {
4365 return Err(DocumentExtractionError::ResourceLimit {
4366 limit: DocumentLimit::MemoryBytes,
4367 observed: value.len(),
4368 maximum: MAX_DOCX_FONT_NAME_BYTES,
4369 });
4370 }
4371 font = Some(value.into_owned());
4372 }
4373 "char" => {
4374 if code.is_some() {
4375 return Err(DocumentExtractionError::Malformed {
4376 format: DocumentFormat::Docx,
4377 message: "DOCX char attribute is duplicated".to_owned(),
4378 });
4379 }
4380 if value.len() != 4
4381 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
4382 {
4383 return Err(DocumentExtractionError::Malformed {
4384 format: DocumentFormat::Docx,
4385 message: "DOCX symbol code must be four hex digits"
4386 .to_owned(),
4387 });
4388 }
4389 code =
4390 Some(u16::from_str_radix(&value, 16).map_err(|error| {
4391 DocumentExtractionError::Malformed {
4392 format: DocumentFormat::Docx,
4393 message: error.to_string(),
4394 }
4395 })?);
4396 }
4397 _ => {}
4398 }
4399 }
4400 if facts.len().saturating_add(symbols.len()) >= MAX_DOCUMENT_FACTS {
4401 return Err(DocumentExtractionError::ResourceLimit {
4402 limit: DocumentLimit::FactCount,
4403 observed: facts.len().saturating_add(symbols.len()) + 1,
4404 maximum: MAX_DOCUMENT_FACTS,
4405 });
4406 }
4407 let unicode = match (font.as_deref(), code) {
4410 (Some(name), Some(0x0061 | 0xF061))
4411 if name.eq_ignore_ascii_case("Symbol") =>
4412 {
4413 Some('α')
4414 }
4415 _ => None,
4416 };
4417 let Some(run) = paragraph.run.as_mut() else {
4418 return Err(DocumentExtractionError::Malformed {
4419 format: DocumentFormat::Docx,
4420 message: "DOCX symbol appeared outside an active run".to_owned(),
4421 });
4422 };
4423 let line =
4424 output_line + run.text.bytes().filter(|byte| *byte == b'\n').count();
4425 let text_start = run.text_start + run.text.len();
4426 let rendered = unicode.unwrap_or('\u{fffc}');
4427 mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4428 append_docx_run_text(run, &rendered.to_string(), output.len())?;
4429 symbols.push(DocumentSymbol {
4430 font,
4431 code,
4432 locator: DocumentLocator::Docx {
4433 part: part.to_owned(),
4434 paragraph: paragraph.number,
4435 run: paragraph.run_number,
4436 text_start,
4437 text_end: text_start + rendered.len_utf8(),
4438 },
4439 line,
4440 unicode,
4441 });
4442 if unicode.is_none()
4443 && !gaps.contains(&DocumentCoverageGap::UnknownSymbolMapping)
4444 {
4445 gaps.push(DocumentCoverageGap::UnknownSymbolMapping);
4446 }
4447 }
4448 "p" | "r" | "t" | "instrText" | "delText" | "delInstrText" | "fldChar" => {
4449 return Err(DocumentExtractionError::Malformed {
4450 format: DocumentFormat::Docx,
4451 message: "DOCX paragraph, run, or text nesting is invalid".to_owned(),
4452 });
4453 }
4454 _ => {}
4455 }
4456 let reference_count = references.count().saturating_add(
4457 header_section
4458 .as_ref()
4459 .map_or(0, |section| section.links.len()),
4460 );
4461 if reference_count > MAX_DOCUMENT_FACTS {
4462 return Err(DocumentExtractionError::ResourceLimit {
4463 limit: DocumentLimit::FactCount,
4464 observed: reference_count,
4465 maximum: MAX_DOCUMENT_FACTS,
4466 });
4467 }
4468 }
4469 Event::Text(event) => {
4470 if skipped_branch_depth.is_some() {
4471 continue;
4472 }
4473 if text_carrier.is_some() {
4474 let Some(run) = paragraph.run.as_mut() else {
4475 return Err(DocumentExtractionError::Malformed {
4476 format: DocumentFormat::Docx,
4477 message: "text appeared outside a run".to_owned(),
4478 });
4479 };
4480 if text_carrier == Some(DocxTextCarrier::Rendered) {
4481 append_docx_run_text(run, event.xml10_content().as_ref(), output.len())?;
4482 }
4483 } else if !event.as_ref().chars().all(xml_whitespace) {
4484 return Err(DocumentExtractionError::Malformed {
4485 format: DocumentFormat::Docx,
4486 message: "DOCX XML contained text outside its document root".to_owned(),
4487 });
4488 }
4489 }
4490 Event::CData(event) => {
4491 if skipped_branch_depth.is_some() {
4492 continue;
4493 }
4494 if text_carrier.is_none() {
4495 if root_seen && !root_closed && event.as_ref().chars().all(xml_whitespace) {
4496 continue;
4497 }
4498 return Err(DocumentExtractionError::Malformed {
4499 format: DocumentFormat::Docx,
4500 message: "CDATA appeared outside a run".to_owned(),
4501 });
4502 }
4503 let Some(run) = paragraph.run.as_mut() else {
4504 return Err(DocumentExtractionError::Malformed {
4505 format: DocumentFormat::Docx,
4506 message: "CDATA appeared outside a run".to_owned(),
4507 });
4508 };
4509 if text_carrier == Some(DocxTextCarrier::Rendered) {
4510 append_docx_run_text(run, event.xml10_content().as_ref(), output.len())?;
4511 }
4512 }
4513 Event::GeneralRef(reference) => {
4514 let text = decode_docx_reference(&reference)?;
4515 if skipped_branch_depth.is_some() {
4516 continue;
4517 }
4518 if text_carrier.is_none() {
4519 if root_seen && !root_closed && text.chars().all(xml_whitespace) {
4520 continue;
4521 }
4522 return Err(DocumentExtractionError::Malformed {
4523 format: DocumentFormat::Docx,
4524 message: "entity appeared outside a text run".to_owned(),
4525 });
4526 }
4527 let Some(run) = paragraph.run.as_mut() else {
4528 return Err(DocumentExtractionError::Malformed {
4529 format: DocumentFormat::Docx,
4530 message: "entity appeared outside a run".to_owned(),
4531 });
4532 };
4533 if text_carrier == Some(DocxTextCarrier::Rendered) {
4534 append_docx_run_text(run, &text, output.len())?;
4535 }
4536 }
4537 Event::PI(instruction) if instruction.target().eq_ignore_ascii_case("xml") => {
4538 return Err(DocumentExtractionError::Malformed {
4539 format: DocumentFormat::Docx,
4540 message: "DOCX XML processing instruction uses the reserved XML target"
4541 .to_owned(),
4542 });
4543 }
4544 Event::DocType(_) => {
4545 return Err(DocumentExtractionError::Malformed {
4546 format: DocumentFormat::Docx,
4547 message: "DOCX XML DOCTYPE and external declarations are unsupported"
4548 .to_owned(),
4549 });
4550 }
4551 Event::End(event) => {
4552 if element_depth == 0 {
4553 return Err(DocumentExtractionError::Malformed {
4554 format: DocumentFormat::Docx,
4555 message: "DOCX XML contained an unmatched closing element".to_owned(),
4556 });
4557 }
4558 if foreign_opaque_depth == Some(element_depth) {
4559 foreign_opaque_depth = None;
4560 }
4561 if foreign_depth == Some(element_depth) {
4562 foreign_depth = None;
4563 }
4564 if let Some(depth) = skipped_branch_depth {
4565 if element_depth == depth {
4566 skipped_branch_depth = None;
4567 skipped_text_box = false;
4568 }
4569 sdt_tags[element_depth] = DocxSdtTag::Other;
4570 element_depth -= 1;
4571 continue;
4572 }
4573 if compatibility && event.local_name().as_ref() == "AlternateContent" {
4574 let alternative = alternatives.pop().filter(|alternative| {
4575 alternative.depth == element_depth && alternative.choice_seen
4576 });
4577 if alternative.is_none() {
4578 return Err(DocumentExtractionError::Malformed {
4579 format: DocumentFormat::Docx,
4580 message: "DOCX compatibility alternatives require at least one choice"
4581 .to_owned(),
4582 });
4583 }
4584 }
4585 if compatibility
4586 && matches!(event.local_name().as_ref(), "Choice" | "Fallback")
4587 && selected_branches.last() == Some(&element_depth)
4588 {
4589 selected_branches.pop();
4590 }
4591 let name = event.local_name();
4592 if wordprocessing
4593 && name.as_ref() == "fldSimple"
4594 && simple_fields
4595 .last()
4596 .is_some_and(|field| field.depth == element_depth)
4597 && let Some(field) = simple_fields.pop()
4598 && !field.has_cached_text
4599 && !field.in_instruction
4600 && !gaps.contains(&DocumentCoverageGap::UnevaluatedField)
4601 {
4602 gaps.push(DocumentCoverageGap::UnevaluatedField);
4603 }
4604 if wordprocessing
4605 && name.as_ref() == "sdt"
4606 && sdt_stack
4607 .last()
4608 .is_some_and(|context| context.depth == element_depth)
4609 {
4610 let Some(context) = sdt_stack.pop() else {
4611 return Err(DocumentExtractionError::Malformed {
4612 format: DocumentFormat::Docx,
4613 message: "DOCX structured tag state is incomplete".to_owned(),
4614 });
4615 };
4616 if !paragraph.fields.contains(&DocxFieldPhase::Instruction)
4617 && context.placeholder
4618 && (context.showing || !context.content_nonempty)
4619 {
4620 references.glossary_placeholder = true;
4621 if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4622 gaps.push(DocumentCoverageGap::UnexaminedStory);
4623 }
4624 }
4625 }
4626 if deleted_depth == Some(element_depth) {
4627 deleted_depth = None;
4628 }
4629 if wordprocessing
4630 && name.as_ref() == "rPr"
4631 && let Some(run) = paragraph.run.as_mut()
4632 && run.properties_depth == Some(element_depth - 2 * selected_branches.len())
4633 {
4634 run.properties_depth = None;
4635 if run.spec_vanish
4636 && !run.hidden
4637 && !gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)
4638 {
4639 gaps.push(DocumentCoverageGap::UnresolvedVisibility);
4640 }
4641 }
4642 if wordprocessing
4643 && matches!(name.as_ref(), "drawing" | "pict" | "object")
4644 && let Some(run) = paragraph.run.as_mut()
4645 && run.drawing_depth == Some(element_depth)
4646 {
4647 run.drawing_depth = None;
4648 }
4649 if wordprocessing
4650 && name.as_ref() == "sectPr"
4651 && header_section
4652 .as_ref()
4653 .is_some_and(|section| section.depth == element_depth)
4654 {
4655 let Some(section) = header_section.take() else {
4656 return Err(DocumentExtractionError::Malformed {
4657 format: DocumentFormat::Docx,
4658 message: "DOCX section state is incomplete".to_owned(),
4659 });
4660 };
4661 for (kind, id, variant) in section.links {
4662 let index = usize::from(kind == DocxStoryKind::Footer);
4663 match variant {
4664 DocxHeaderVariant::Default => {
4665 inherited_default[index] = true;
4666 references.has_default_header_footer = true;
4667 references.linked_parts.push((kind, id));
4668 }
4669 DocxHeaderVariant::First => {
4670 inherited_first[index] = Some(id);
4671 }
4672 DocxHeaderVariant::Even => references.even_parts.push((kind, id)),
4673 }
4674 }
4675 if section.title_page.unwrap_or(false) {
4676 if inherited_default.contains(&true)
4677 && !gaps.contains(&DocumentCoverageGap::ConditionalStory)
4678 {
4679 gaps.push(DocumentCoverageGap::ConditionalStory);
4680 }
4681 for (index, id) in inherited_first.iter().enumerate() {
4682 if let Some(id) = id {
4683 let kind = if index == 0 {
4684 DocxStoryKind::Header
4685 } else {
4686 DocxStoryKind::Footer
4687 };
4688 references.linked_parts.push((kind, id.clone()));
4689 }
4690 }
4691 }
4692 }
4693 match if wordprocessing { name.as_ref() } else { "" } {
4694 "t" | "instrText" | "delText" | "delInstrText" => {
4695 if text_carrier == Some(DocxTextCarrier::Rendered)
4696 && !preserve_space[element_depth]
4697 && let Some(run) = paragraph.run.as_mut()
4698 {
4699 let text = &run.text[text_start..];
4701 let trimmed = text.trim_matches([' ', '\t', '\r', '\n']);
4702 let leading =
4703 text.len() - text.trim_start_matches([' ', '\t', '\r', '\n']).len();
4704 let end = text_start + leading + trimmed.len();
4705 run.text.truncate(end);
4706 run.text.drain(text_start..text_start + leading);
4707 }
4708 if text_carrier == Some(DocxTextCarrier::Rendered)
4709 && paragraph
4710 .run
4711 .as_ref()
4712 .is_some_and(|run| run.text.len() > text_start)
4713 {
4714 mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4715 for context in &mut sdt_stack {
4716 if context.content_depth.is_some() {
4717 context.content_nonempty = true;
4718 }
4719 }
4720 }
4721 text_carrier = None;
4722 }
4723 "r" => {
4724 if let Some(mut run) = paragraph.run.take() {
4725 publish_docx_run_fragment(
4726 &mut run,
4727 part,
4728 paragraph.number,
4729 paragraph.run_number,
4730 &mut output,
4731 &mut output_line,
4732 &mut facts,
4733 symbols.len(),
4734 )?;
4735 }
4736 }
4737 "txbxContent" => {
4738 if !paragraph.fields.is_empty() {
4739 return Err(DocumentExtractionError::Malformed {
4740 format: DocumentFormat::Docx,
4741 message: "DOCX text box ended inside an incomplete field"
4742 .to_owned(),
4743 });
4744 }
4745 let Some((mut outer, previous_bytes, previous_facts)) = text_boxes.pop()
4746 else {
4747 return Err(DocumentExtractionError::Malformed {
4748 format: DocumentFormat::Docx,
4749 message: "DOCX text box had no matching container".to_owned(),
4750 });
4751 };
4752 if facts.len() > previous_facts {
4753 mark_docx_cached_result(&mut outer.fields, &mut simple_fields);
4754 }
4755 paragraph = outer;
4756 if output.len() > previous_bytes && !output.ends_with('\n') {
4757 push_output_byte(&mut output, b'\n')?;
4758 output_line += 1;
4759 }
4760 }
4761 "p" => paragraph.open = false,
4762 _ => {}
4763 }
4764 if wordprocessing
4765 && matches!(name.as_ref(), "footnote" | "endnote")
4766 && element_depth == 2 + 2 * selected_branches.len()
4767 && let Some((fact_count, symbol_count, reference_count)) =
4768 conditional_note_start.take()
4769 && (facts.len() > fact_count
4770 || symbols.len() > symbol_count
4771 || references.count() > reference_count)
4772 && !gaps.contains(&DocumentCoverageGap::ConditionalStory)
4773 {
4774 gaps.push(DocumentCoverageGap::ConditionalStory);
4775 }
4776 sdt_tags[element_depth] = DocxSdtTag::Other;
4777 element_depth -= 1;
4778 if element_depth == 0 {
4779 root_closed = true;
4780 }
4781 }
4782 Event::Eof => break,
4783 _ => {}
4784 }
4785 }
4786 if !root_seen
4787 || !root_closed
4788 || (root_name == "document" && !main_body_seen && !main_body_unexamined)
4789 || element_depth != 0
4790 || paragraph.open
4791 || !text_boxes.is_empty()
4792 || !alternatives.is_empty()
4793 || !selected_branches.is_empty()
4794 || skipped_branch_depth.is_some()
4795 || conditional_note_start.is_some()
4796 || deleted_depth.is_some()
4797 || foreign_depth.is_some()
4798 || foreign_opaque_depth.is_some()
4799 || paragraph.run.is_some()
4800 || text_carrier.is_some()
4801 || !paragraph.fields.is_empty()
4802 || !simple_fields.is_empty()
4803 || !sdt_stack.is_empty()
4804 || header_section.is_some()
4805 {
4806 return Err(DocumentExtractionError::Malformed {
4807 format: DocumentFormat::Docx,
4808 message: "DOCX XML ended before all elements were closed".to_owned(),
4809 });
4810 }
4811 if let Some(selected_ids) = selected_ids
4812 && !selected_ids.is_subset(&seen_ids)
4813 {
4814 return Err(DocumentExtractionError::InvalidDocxPackage {
4815 message: format!("referenced DOCX story item is absent from {part}"),
4816 });
4817 }
4818 if let Some(selected_special_ids) = selected_special_ids
4819 && !selected_special_ids.is_subset(&seen_special_ids)
4820 {
4821 return Err(DocumentExtractionError::InvalidDocxPackage {
4822 message: format!("listed DOCX special note is absent from {part}"),
4823 });
4824 }
4825 Ok(DocumentFacts {
4826 format: DocumentFormat::Docx,
4827 text: output,
4828 facts,
4829 symbols,
4830 completeness: if gaps.is_empty() {
4831 DocumentCompleteness::Complete
4832 } else {
4833 DocumentCompleteness::Partial { gaps }
4834 },
4835 provenance: DocumentParserProvenance::QuickXml,
4836 })
4837}
4838
4839fn publish_docx_run_fragment(
4841 run: &mut RawDocxRun,
4842 part: &str,
4843 paragraph: usize,
4844 run_number: usize,
4845 output: &mut String,
4846 output_line: &mut usize,
4847 facts: &mut Vec<DocumentFact>,
4848 symbol_count: usize,
4849) -> Result<(), DocumentExtractionError> {
4850 if run.text.is_empty() {
4851 return Ok(());
4852 }
4853 let observed = facts.len().saturating_add(symbol_count).saturating_add(1);
4854 if observed > MAX_DOCUMENT_FACTS {
4855 return Err(DocumentExtractionError::ResourceLimit {
4856 limit: DocumentLimit::FactCount,
4857 observed,
4858 maximum: MAX_DOCUMENT_FACTS,
4859 });
4860 }
4861 let required = output.len().saturating_add(run.text.len());
4862 if required > MAX_DOCUMENT_OUTPUT_BYTES {
4863 return Err(DocumentExtractionError::ResourceLimit {
4864 limit: DocumentLimit::OutputBytes,
4865 observed: required,
4866 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
4867 });
4868 }
4869 let text = std::mem::take(&mut run.text);
4870 let line_start = *output_line;
4871 *output_line += text.bytes().filter(|byte| *byte == b'\n').count();
4872 let line_end = *output_line - usize::from(text.ends_with('\n'));
4874 output.push_str(&text);
4875 let text_end = run.text_start + text.len();
4876 facts.push(DocumentFact {
4877 line_start,
4878 line_end,
4879 text,
4880 locator: DocumentLocator::Docx {
4881 part: part.to_owned(),
4882 paragraph,
4883 run: run_number,
4884 text_start: run.text_start,
4885 text_end,
4886 },
4887 });
4888 run.text_start = text_end;
4889 Ok(())
4890}
4891
4892fn append_docx_run_text(
4894 run: &mut RawDocxRun,
4895 text: &str,
4896 published_bytes: usize,
4897) -> Result<(), DocumentExtractionError> {
4898 let required = published_bytes
4899 .saturating_add(run.text.len())
4900 .saturating_add(text.len());
4901 if required > MAX_DOCUMENT_OUTPUT_BYTES {
4902 return Err(DocumentExtractionError::ResourceLimit {
4903 limit: DocumentLimit::OutputBytes,
4904 observed: required,
4905 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
4906 });
4907 }
4908 run.text.push_str(text);
4909 Ok(())
4910}
4911
4912fn decode_docx_reference(reference: &BytesRef<'_>) -> Result<String, DocumentExtractionError> {
4914 if let Some(character) =
4915 reference
4916 .resolve_char_ref()
4917 .map_err(|error| DocumentExtractionError::Malformed {
4918 format: DocumentFormat::Docx,
4919 message: error.to_string(),
4920 })?
4921 {
4922 if !xml10_character(character) {
4923 return Err(DocumentExtractionError::Malformed {
4924 format: DocumentFormat::Docx,
4925 message: "DOCX XML contained an invalid character reference".to_owned(),
4926 });
4927 }
4928 return Ok(character.to_string());
4929 }
4930 match reference.as_ref() {
4931 "amp" => Ok("&".to_owned()),
4932 "apos" => Ok("'".to_owned()),
4933 "gt" => Ok(">".to_owned()),
4934 "lt" => Ok("<".to_owned()),
4935 "quot" => Ok("\"".to_owned()),
4936 _ => Err(DocumentExtractionError::Malformed {
4937 format: DocumentFormat::Docx,
4938 message: "DOCX XML contained an unsupported entity reference".to_owned(),
4939 }),
4940 }
4941}
4942
4943#[cfg(test)]
4944#[allow(clippy::expect_used)]
4945mod tests {
4946 use super::*;
4947 use projectatlas_core::{IndexCancellation, IndexWorkControl};
4948 use std::fmt::Write as _;
4949 use std::io::Write;
4950 use std::time::{Duration, Instant};
4951 use zip::CompressionMethod;
4952 use zip::ZipWriter;
4953 use zip::write::SimpleFileOptions as FileOptions;
4954
4955 fn control() -> IndexWorkControl {
4956 IndexWorkControl::new(IndexCancellation::new(), None)
4957 }
4958
4959 fn docx_drawing_text_box(content: &str, id: u32) -> String {
4960 format!(
4961 concat!(
4962 "<w:drawing><wp:inline xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\">",
4963 "<wp:extent cx=\"914400\" cy=\"914400\"/><wp:docPr id=\"{id}\" name=\"Text box {id}\"/>",
4964 "<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\">",
4965 "<a:graphicData uri=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">",
4966 "<wps:wsp xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">",
4967 "<wps:cNvSpPr txBox=\"1\"/><wps:spPr><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></wps:spPr><wps:txbx><w:txbxContent>{content}</w:txbxContent></wps:txbx>",
4968 "<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing>"
4969 ),
4970 id = id,
4971 content = content
4972 )
4973 }
4974
4975 fn docx_archive(xml: &[u8], method: CompressionMethod) -> Vec<u8> {
4976 docx_archive_with_parts_and_method(&[(DOCX_DOCUMENT_PART, xml)], method)
4977 }
4978
4979 fn docx_archive_with_parts(parts: &[(&str, &[u8])]) -> Vec<u8> {
4980 docx_archive_with_parts_and_method(parts, CompressionMethod::Deflated)
4981 }
4982
4983 fn docx_archive_with_parts_and_method(
4984 parts: &[(&str, &[u8])],
4985 method: CompressionMethod,
4986 ) -> Vec<u8> {
4987 let mut bytes = Vec::new();
4988 {
4989 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
4990 let main = if parts.iter().any(|(name, _)| *name == DOCX_DOCUMENT_PART) {
4991 Some(DOCX_DOCUMENT_PART)
4992 } else if parts.iter().any(|(name, _)| *name == "document.xml") {
4993 Some("document.xml")
4994 } else {
4995 None
4996 };
4997 if !parts.iter().any(|(name, _)| *name == "[Content_Types].xml") {
4998 let mut manifest = String::from(
4999 "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
5000 );
5001 for (name, _) in parts {
5002 if !Path::new(name)
5003 .extension()
5004 .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
5005 {
5006 continue;
5007 }
5008 let suffix = if Some(*name) == main || name.ends_with("/child.xml") {
5009 Some("document.main")
5010 } else if name.contains("glossary") {
5011 Some("document.glossary")
5012 } else if name.contains("header")
5013 || name.contains("first")
5014 || name.contains("even")
5015 || name.contains("default")
5016 || name.contains("prior")
5017 {
5018 Some("header")
5019 } else if name.contains("footer") {
5020 Some("footer")
5021 } else if name.contains("footnotes") {
5022 Some("footnotes")
5023 } else if name.contains("endnotes") {
5024 Some("endnotes")
5025 } else if name.contains("comments") {
5026 Some("comments")
5027 } else if name.contains("settings") {
5028 Some("settings")
5029 } else {
5030 None
5031 };
5032 if let Some(suffix) = suffix {
5033 write!(
5034 &mut manifest,
5035 "<Override PartName=\"/{name}\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.{suffix}+xml\"/>"
5036 )
5037 .expect("fixture manifest override");
5038 }
5039 }
5040 manifest.push_str("</Types>");
5041 writer
5042 .start_file("[Content_Types].xml", FileOptions::default())
5043 .expect("fixture manifest");
5044 writer
5045 .write_all(manifest.as_bytes())
5046 .expect("fixture manifest XML");
5047 }
5048 if !parts.iter().any(|(name, _)| *name == "_rels/.rels")
5049 && let Some(main) = main
5050 {
5051 let rels = format!(
5052 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"main\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"{main}\"/></Relationships>"
5053 );
5054 writer
5055 .start_file("_rels/.rels", FileOptions::default())
5056 .expect("fixture root relationships");
5057 writer
5058 .write_all(rels.as_bytes())
5059 .expect("fixture root relationship XML");
5060 }
5061 for (name, xml) in parts {
5062 writer
5063 .start_file(*name, FileOptions::default().compression_method(method))
5064 .expect("fixture part");
5065 writer.write_all(xml).expect("fixture XML");
5066 }
5067 writer.finish().expect("fixture archive");
5068 }
5069 bytes
5070 }
5071
5072 fn rewrite_zip_method(bytes: &mut [u8], method: u16) {
5073 for index in 0..bytes.len().saturating_sub(4) {
5074 match &bytes[index..index + 4] {
5075 b"PK\x03\x04" => {
5076 bytes[index + 8..index + 10].copy_from_slice(&method.to_le_bytes());
5077 }
5078 b"PK\x01\x02" => {
5079 bytes[index + 10..index + 12].copy_from_slice(&method.to_le_bytes());
5080 }
5081 _ => {}
5082 }
5083 }
5084 }
5085
5086 #[test]
5087 fn document_admission_observes_queued_deadline_and_cancellation() {
5088 let lease = lock_document_execution(&control(), IndexWorkStage::TextIndex)
5089 .expect("initial document lease");
5090 let docx = docx_archive(
5091 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#,
5092 CompressionMethod::Stored,
5093 );
5094 for (path, bytes) in [
5095 ("guide.docx", docx.as_slice()),
5096 ("guide.pdf", minimal_pdf().as_slice()),
5097 ] {
5098 let waiting =
5099 IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_millis(100)));
5100 assert!(
5101 matches!(
5102 extract_document_text_controlled(bytes, path, None, &waiting),
5103 Err(DocumentExtractionError::Work(
5104 IndexWorkFailure::DeadlineExceeded {
5105 stage: IndexWorkStage::TextIndex
5106 }
5107 ))
5108 ),
5109 "{path} must wait for the shared document lease"
5110 );
5111 }
5112 let cancellation = IndexCancellation::new();
5113 let waiting = IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(5)));
5114 let cancel = std::thread::spawn(move || {
5115 std::thread::sleep(Duration::from_millis(20));
5116 cancellation.cancel();
5117 });
5118 let result = extract_document_text_controlled(&docx, "guide.docx", None, &waiting);
5119 cancel.join().expect("cancellation thread");
5120 assert!(matches!(
5121 result,
5122 Err(DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
5123 stage: IndexWorkStage::TextIndex
5124 }))
5125 ));
5126 drop(lease);
5127 assert!(extract_document_text_controlled(&docx, "guide.docx", None, &control()).is_ok());
5128 }
5129
5130 fn mark_zip_encrypted(bytes: &mut [u8]) {
5131 for index in 0..bytes.len().saturating_sub(4) {
5132 let flags = match &bytes[index..index + 4] {
5133 b"PK\x03\x04" => &mut bytes[index + 6..index + 8],
5134 b"PK\x01\x02" => &mut bytes[index + 8..index + 10],
5135 _ => continue,
5136 };
5137 let value = u16::from_le_bytes([flags[0], flags[1]]) | 1;
5138 flags.copy_from_slice(&value.to_le_bytes());
5139 }
5140 }
5141
5142 #[test]
5143 fn path_admission_is_case_insensitive_but_not_speculative() {
5144 assert_eq!(
5145 document_format_for_path("docs/guide.PDF", None),
5146 Some(DocumentFormat::Pdf)
5147 );
5148 assert_eq!(
5149 document_format_for_path("docs/guide.docx", Some("pdf")),
5150 Some(DocumentFormat::Pdf)
5151 );
5152 assert_eq!(document_format_for_path("docs/guide.doc", None), None);
5153 for language in ["text", "rust", "markdown", ""] {
5154 for path in ["docs/guide.pdf", "docs/guide.docx"] {
5155 assert_eq!(document_format_for_path(path, Some(language)), None);
5156 }
5157 }
5158 }
5159
5160 #[test]
5161 fn mismatched_magic_fails_closed_before_parser_work() {
5162 let error = extract_document_text_controlled(b"PK\x03\x04", "guide.pdf", None, &control())
5163 .expect_err("DOCX bytes must not enter the PDF parser");
5164 assert!(matches!(
5165 error,
5166 DocumentExtractionError::MismatchedMagic {
5167 expected: DocumentFormat::Pdf,
5168 found: "docx"
5169 }
5170 ));
5171 }
5172
5173 #[test]
5174 fn cancellation_is_observed_before_parser_work() {
5175 let cancellation = IndexCancellation::new();
5176 cancellation.cancel();
5177 let control = IndexWorkControl::new(cancellation, None);
5178 let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
5179 .expect_err("canceled work must not parse");
5180 assert!(matches!(
5181 error,
5182 DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
5183 stage: IndexWorkStage::TextIndex
5184 })
5185 ));
5186 }
5187
5188 #[test]
5189 fn expired_document_deadline_is_observed_before_parser_work() {
5190 let control = IndexWorkControl::with_deadline(
5191 IndexCancellation::new(),
5192 Instant::now()
5193 .checked_sub(Duration::from_secs(1))
5194 .expect("current instant supports one-second subtraction"),
5195 );
5196 let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
5197 .expect_err("expired work must not parse");
5198 assert!(matches!(
5199 error,
5200 DocumentExtractionError::Work(IndexWorkFailure::DeadlineExceeded {
5201 stage: IndexWorkStage::TextIndex
5202 })
5203 ));
5204 }
5205
5206 #[test]
5207 fn document_input_bytes_are_bounded_before_parser_work() {
5208 let mut bytes = vec![b'x'; MAX_DOCUMENT_COMPRESSED_BYTES + 1];
5209 bytes[..5].copy_from_slice(b"%PDF-");
5210 let error = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5211 .expect_err("oversized input must be rejected before parsing");
5212 assert!(matches!(
5213 error,
5214 DocumentExtractionError::ResourceLimit {
5215 limit: DocumentLimit::InputBytes,
5216 observed,
5217 maximum: MAX_DOCUMENT_COMPRESSED_BYTES
5218 } if observed == MAX_DOCUMENT_COMPRESSED_BYTES + 1
5219 ));
5220 }
5221
5222 pub(super) fn minimal_pdf() -> Vec<u8> {
5223 let objects = [
5224 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5225 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
5226 b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5227 b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Hello PDF) Tj ET\nendstream\nendobj\n".as_slice(),
5228 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5229 ];
5230 let mut pdf = b"%PDF-1.4\n".to_vec();
5231 let mut offsets = Vec::new();
5232 for object in objects {
5233 offsets.push(pdf.len());
5234 pdf.extend_from_slice(object);
5235 }
5236 let xref = pdf.len();
5237 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5238 pdf.extend_from_slice(b"0000000000 65535 f \n");
5239 for offset in offsets {
5240 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5241 }
5242 pdf.extend_from_slice(
5243 format!(
5244 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5245 objects.len() + 1
5246 )
5247 .as_bytes(),
5248 );
5249 pdf
5250 }
5251
5252 pub(super) fn multi_page_pdf() -> Vec<u8> {
5253 let objects = [
5254 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5255 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>\nendobj\n".as_slice(),
5256 b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5257 b"4 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page One) Tj ET\nendstream\nendobj\n".as_slice(),
5258 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5259 b"6 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5260 b"7 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page Two) Tj ET\nendstream\nendobj\n".as_slice(),
5261 ];
5262 let mut pdf = b"%PDF-1.4\n".to_vec();
5263 let mut offsets = Vec::new();
5264 for object in objects {
5265 offsets.push(pdf.len());
5266 pdf.extend_from_slice(object);
5267 }
5268 let xref = pdf.len();
5269 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5270 pdf.extend_from_slice(b"0000000000 65535 f \n");
5271 for offset in offsets {
5272 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5273 }
5274 pdf.extend_from_slice(
5275 format!(
5276 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5277 objects.len() + 1
5278 )
5279 .as_bytes(),
5280 );
5281 pdf
5282 }
5283
5284 fn encrypted_pdf() -> Vec<u8> {
5285 let objects = [
5286 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5287 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
5288 b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5289 b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Secret PDF) Tj ET\nendstream\nendobj\n".as_slice(),
5290 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5291 b"6 0 obj\n<< /Filter /Standard /V 1 /R 2 /Length 40 /O <0000000000000000000000000000000000000000000000000000000000000000> /U <0000000000000000000000000000000000000000000000000000000000000000> /P -4 >>\nendobj\n".as_slice(),
5292 ];
5293 let mut pdf = b"%PDF-1.4\n".to_vec();
5294 let mut offsets = Vec::new();
5295 for object in objects {
5296 offsets.push(pdf.len());
5297 pdf.extend_from_slice(object);
5298 }
5299 let xref = pdf.len();
5300 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5301 pdf.extend_from_slice(b"0000000000 65535 f \n");
5302 for offset in offsets {
5303 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5304 }
5305 pdf.extend_from_slice(
5306 format!(
5307 "trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5308 objects.len() + 1
5309 )
5310 .as_bytes(),
5311 );
5312 pdf
5313 }
5314
5315 #[test]
5316 fn pdf_extracts_page_text_with_exact_page_locator() {
5317 let facts =
5318 extract_document_text_controlled(&minimal_pdf(), "guide.pdf", Some("pdf"), &control())
5319 .expect("valid PDF");
5320 assert!(facts.text.contains("Hello PDF"));
5321 assert!(matches!(
5322 facts
5323 .facts
5324 .iter()
5325 .find(|fact| fact.text.contains("Hello PDF")),
5326 Some(DocumentFact {
5327 locator: DocumentLocator::Pdf { page: 1, .. },
5328 ..
5329 })
5330 ));
5331 }
5332
5333 #[test]
5334 fn pdf_page_locators_are_page_local_for_multi_page_documents() {
5335 let facts = extract_document_text_controlled(
5336 &multi_page_pdf(),
5337 "guide.pdf",
5338 Some("pdf"),
5339 &control(),
5340 )
5341 .expect("valid multi-page PDF");
5342 assert_eq!(facts.facts.len(), 2);
5343 assert!(matches!(
5344 facts.facts[1].locator,
5345 DocumentLocator::Pdf {
5346 page: 2,
5347 text_start: 0,
5348 text_end: 8
5349 }
5350 ));
5351 }
5352
5353 #[test]
5354 fn pdf_host_limits_discard_all_page_evidence() {
5355 let work = control();
5356 let stage = IndexWorkStage::SymbolParsing;
5357 let exact = pdf_pages_to_facts(&[(1, "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES))], &work, stage)
5358 .expect("exact output limit remains complete");
5359 assert_eq!(exact.completeness, DocumentCompleteness::Complete);
5360 assert_eq!(exact.text.len(), MAX_DOCUMENT_OUTPUT_BYTES);
5361 assert_eq!(exact.facts.len(), 1);
5362 let exact_facts =
5363 pdf_pages_to_facts(&[(1, "x\n".repeat(MAX_DOCUMENT_FACTS))], &work, stage)
5364 .expect("exact fact limit remains complete");
5365 assert_eq!(exact_facts.completeness, DocumentCompleteness::Complete);
5366 assert_eq!(exact_facts.facts.len(), MAX_DOCUMENT_FACTS);
5367
5368 let over_output = pdf_pages_to_facts(
5369 &[
5370 (1, "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES - 1)),
5371 (2, "y".to_owned()),
5372 ],
5373 &work,
5374 stage,
5375 )
5376 .expect("host output exhaustion is document-local");
5377 let assert_empty_gap = |facts: DocumentFacts, limit| {
5378 assert!(facts.text.is_empty());
5379 assert!(facts.facts.is_empty());
5380 assert!(facts.symbols.is_empty());
5381 assert_eq!(facts.format, DocumentFormat::Pdf);
5382 assert_eq!(facts.provenance, DocumentParserProvenance::PdfExtract);
5383 assert_eq!(
5384 facts.completeness,
5385 DocumentCompleteness::Partial {
5386 gaps: vec![DocumentCoverageGap::ResourceLimit(limit)]
5387 }
5388 );
5389 };
5390 assert_empty_gap(over_output, DocumentLimit::OutputBytes);
5391
5392 let over_facts =
5393 pdf_pages_to_facts(&[(1, "x\n".repeat(MAX_DOCUMENT_FACTS + 1))], &work, stage)
5394 .expect("host fact exhaustion is document-local");
5395 assert_empty_gap(over_facts, DocumentLimit::FactCount);
5396 }
5397
5398 #[test]
5399 fn pdf_missing_or_unsupported_page_stream_is_not_silently_omitted() {
5400 for missing in [true, false] {
5401 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
5402 if missing {
5403 document.objects.remove(&(7, 0));
5404 } else {
5405 document
5406 .objects
5407 .get_mut(&(7, 0))
5408 .expect("second page stream")
5409 .as_stream_mut()
5410 .expect("stream object")
5411 .dict
5412 .set("Filter", "UnsupportedDecode");
5413 }
5414 let mut bytes = Vec::new();
5415 document.save_to(&mut bytes).expect("fixture serialization");
5416 assert!(
5417 matches!(
5418 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5419 Err(DocumentExtractionError::Malformed {
5420 format: DocumentFormat::Pdf,
5421 ..
5422 })
5423 ),
5424 "missing={missing} must fail instead of publishing incomplete text"
5425 );
5426 }
5427 }
5428
5429 #[test]
5430 fn empty_pdf_publishes_complete_text_without_facts() {
5431 let mut document = lopdf::Document::new();
5432 let mut pages = lopdf::Dictionary::new();
5433 pages.set("Type", "Pages");
5434 pages.set("Kids", Vec::<lopdf::Object>::new());
5435 pages.set("Count", 0);
5436 let pages = document.add_object(pages);
5437 let mut catalog = lopdf::Dictionary::new();
5438 catalog.set("Type", "Catalog");
5439 catalog.set("Pages", pages);
5440 let catalog = document.add_object(catalog);
5441 document.trailer.set("Root", catalog);
5442 let mut bytes = Vec::new();
5443 document.save_to(&mut bytes).expect("empty fixture");
5444 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5445 .expect("empty page tree must pass the embedded guest and host");
5446 assert_eq!(result.completeness, DocumentCompleteness::Complete);
5447 assert!(result.text.is_empty());
5448 assert!(result.facts.is_empty());
5449 assert!(
5450 result
5451 .symbol_graph("guide.pdf", Some("pdf"))
5452 .symbols
5453 .is_empty()
5454 );
5455 }
5456
5457 #[test]
5458 fn pdf_indirect_page_tree_fields_preserve_exact_facts() {
5459 let original = multi_page_pdf();
5460 let expected = extract_document_text_controlled(&original, "guide.pdf", None, &control())
5461 .expect("direct page tree");
5462 let mut document = lopdf::Document::load_mem(&original).expect("fixture PDF");
5463 let children = document
5464 .get_dictionary((2, 0))
5465 .expect("page tree")
5466 .get(b"Kids")
5467 .expect("children")
5468 .clone();
5469 let children = document.add_object(children);
5470 let alias = document.add_object(lopdf::Object::Reference(children));
5471 let count = document.add_object(2);
5472 let node = document.get_dictionary_mut((2, 0)).expect("page tree");
5473 node.set("Kids", alias);
5474 node.set("Count", count);
5475 for (id, name) in [((2, 0), "Pages"), ((3, 0), "Page"), ((6, 0), "Page")] {
5476 let name = document.add_object(lopdf::Object::Name(name.as_bytes().to_vec()));
5477 document
5478 .get_dictionary_mut(id)
5479 .expect("page-tree node")
5480 .set("Type", name);
5481 }
5482 let mut bytes = Vec::new();
5483 document.save_to(&mut bytes).expect("fixture serialization");
5484 let actual = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5485 .expect("indirect page-tree fields");
5486 assert_eq!(actual, expected);
5487
5488 let cycle = document.new_object_id();
5489 document
5490 .objects
5491 .insert(cycle, lopdf::Object::Reference(cycle));
5492 let missing = document.new_object_id();
5493 for invalid in [lopdf::Object::Null, 0.into(), cycle.into(), missing.into()] {
5494 document
5495 .get_dictionary_mut((2, 0))
5496 .expect("page tree")
5497 .set("Kids", invalid);
5498 let mut bytes = Vec::new();
5499 document.save_to(&mut bytes).expect("fixture serialization");
5500 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5501 assert!(
5502 matches!(
5503 result,
5504 Err(DocumentExtractionError::Malformed {
5505 format: DocumentFormat::Pdf,
5506 ..
5507 })
5508 ),
5509 "{result:?}"
5510 );
5511 }
5512 }
5513
5514 #[test]
5515 fn pdf_missing_page_tree_child_never_publishes_a_complete_prefix() {
5516 for declared_count in [1, 2] {
5517 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
5518 document.objects.remove(&(6, 0));
5519 document
5520 .get_object_mut((2, 0))
5521 .expect("page tree")
5522 .as_dict_mut()
5523 .expect("page tree dictionary")
5524 .set("Count", declared_count);
5525 let mut bytes = Vec::new();
5526 document.save_to(&mut bytes).expect("fixture serialization");
5527 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5528 assert!(
5529 matches!(
5530 result,
5531 Err(DocumentExtractionError::Malformed {
5532 format: DocumentFormat::Pdf,
5533 ..
5534 })
5535 ),
5536 "declared_count={declared_count}: {result:?}"
5537 );
5538 }
5539 }
5540
5541 #[test]
5542 fn pdf_form_content_keeps_text_and_page_evidence() {
5543 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5544 let mut form = lopdf::Dictionary::new();
5545 form.set("Type", "XObject");
5546 let subtype = document.add_object(lopdf::Object::Name(b"Form".to_vec()));
5547 form.set("Subtype", subtype);
5548 form.set(
5549 "Matrix",
5550 vec![1.into(), 0.into(), 0.into(), 1.into(), 0.into(), 600.into()],
5551 );
5552 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
5553 let resources = document
5554 .get_dictionary((3, 0))
5555 .expect("page")
5556 .get(b"Resources")
5557 .expect("resources")
5558 .clone();
5559 form.set("Resources", resources);
5560 let form_id = document.add_object(lopdf::Stream::new(
5561 form,
5562 b"BT /F1 12 Tf 72 0 Td (Form Text Marker) Tj ET".to_vec(),
5563 ));
5564 let mut xobjects = lopdf::Dictionary::new();
5565 xobjects.set("Fm1", form_id);
5566 document
5567 .get_object_mut((3, 0))
5568 .expect("page")
5569 .as_dict_mut()
5570 .expect("page dictionary")
5571 .get_mut(b"Resources")
5572 .expect("resources")
5573 .as_dict_mut()
5574 .expect("resource dictionary")
5575 .set("XObject", xobjects);
5576 let stream = document
5577 .get_object_mut((4, 0))
5578 .expect("page stream")
5579 .as_stream_mut()
5580 .expect("stream");
5581 let mut content = stream.content.clone();
5582 content
5583 .extend_from_slice(b"\nq 1 0 0 1 0 100 cm /Fm1 Do Q\nq 1 0 0 1 0 -100 cm /Fm1 Do Q\n");
5584 stream.set_content(content);
5585 let mut bytes = Vec::new();
5586 document.save_to(&mut bytes).expect("fixture serialization");
5587 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5588 .expect("valid Form content");
5589 assert!(facts.text.contains("Hello PDF"));
5590 assert_eq!(facts.facts.len(), 3, "{}", facts.text);
5591 assert_eq!(
5592 facts
5593 .facts
5594 .iter()
5595 .filter(|fact| fact.text == "Form Text Marker")
5596 .count(),
5597 2,
5598 "{}",
5599 facts.text
5600 );
5601 assert!(
5602 facts
5603 .facts
5604 .iter()
5605 .any(|fact| fact.text.contains("Form Text Marker")
5606 && matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }))
5607 );
5608 }
5609
5610 #[test]
5611 fn pdf_rotation_and_quote_operators_preserve_text_locators() {
5612 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5613 for (rotation, content) in [
5614 (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 -20 Td (Next) Tj ET".as_slice()),
5615 (90, b"BT /F1 12 Tf 0 1 -1 0 112 72 Tm (First) Tj 0 1 -1 0 112 180 Tm (Second) Tj 0 1 -1 0 132 72 Tm (Next) Tj ET"),
5616 (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 0 Td (Next) ' ET"),
5617 (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 0 Td 0 0 (Next) \" ET"),
5618 ] {
5619 document.get_object_mut((2, 0)).expect("page tree").as_dict_mut()
5620 .expect("page tree dictionary").set("Rotate", rotation);
5621 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5622 .expect("stream").set_content(content.to_vec());
5623 let mut bytes = Vec::new();
5624 document.save_to(&mut bytes).expect("fixture serialization");
5625 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5626 .expect("valid positioned text");
5627 assert_eq!(facts.text, "First Second\nNext", "rotation={rotation}");
5628 assert_eq!(facts.facts.len(), 2);
5629 for (index, fact) in facts.facts.iter().enumerate() {
5630 assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
5631 assert_eq!(fact.line_start, index + 1);
5632 assert_eq!(fact.line_end, index + 1);
5633 }
5634 }
5635 for rotation in [90, 180, 270] {
5636 document
5637 .get_object_mut((2, 0))
5638 .expect("page tree")
5639 .as_dict_mut()
5640 .expect("page tree dictionary")
5641 .set("Rotate", rotation);
5642 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5643 .expect("stream").set_content(b"BT /F1 12 Tf 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 -100 Td (Next) Tj ET".to_vec());
5644 let mut bytes = Vec::new();
5645 document.save_to(&mut bytes).expect("fixture serialization");
5646 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5647 .expect("ordinary rotated text");
5648 assert_eq!(facts.text, "First Second\nNext");
5649 assert_eq!(facts.facts.len(), 2);
5650 for (index, fact) in facts.facts.iter().enumerate() {
5651 assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
5652 assert_eq!((fact.line_start, fact.line_end), (index + 1, index + 1));
5653 }
5654 }
5655 }
5656
5657 #[test]
5658 fn pdf_text_state_and_missing_width_preserve_block_text() {
5659 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5660 let descriptor = lopdf::dictionary! {
5661 "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 32,
5662 "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
5663 "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200, "CapHeight" => 700,
5664 "StemV" => 80, "MissingWidth" => 600
5665 };
5666 document.objects.insert(
5667 (5, 0),
5668 lopdf::dictionary! {
5669 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Fixture",
5670 "Encoding" => "WinAnsiEncoding", "FontDescriptor" => descriptor,
5671 "FirstChar" => 65, "LastChar" => 65, "Widths" => vec![600.into()]
5672 }
5673 .into(),
5674 );
5675 document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
5676 .set_content(b"BT /F1 12 Tf 20 TL 72 500 Td q 100 -100 Td (A) Tj Q T* (StateB) Tj 1 0 0 1 115.2 480 Tm (C) Tj ET".to_vec());
5677 let mut bytes = Vec::new();
5678 document.save_to(&mut bytes).expect("fixture serialization");
5679 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5680 .expect("scoped text with descriptor widths");
5681 assert_eq!(facts.text, "A\nStateBC");
5682 assert_eq!(facts.facts.len(), 2);
5683 assert_eq!(facts.facts[1].text, "StateBC");
5684 assert_eq!((facts.facts[1].line_start, facts.facts[1].line_end), (2, 2));
5685 assert!(matches!(
5686 facts.facts[1].locator,
5687 DocumentLocator::Pdf { page: 1, .. }
5688 ));
5689 }
5690
5691 #[test]
5692 fn pdf_font_matrix_and_color_aliases_preserve_text() {
5693 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5694 let glyph = document.add_object(lopdf::Stream::new(
5695 lopdf::Dictionary::new(),
5696 b"600 0 d0".to_vec(),
5697 ));
5698 document.objects.insert((5, 0), lopdf::dictionary! {
5699 "Type" => "Font", "Subtype" => "Type3",
5700 "FontBBox" => vec![0.into(), 0.into(), 600.into(), 600.into()],
5701 "FontMatrix" => vec![0.002.into(), 0.into(), 0.into(), 0.002.into(), 0.into(), 0.into()],
5702 "CharProcs" => lopdf::dictionary! { "A" => glyph, "B" => glyph },
5703 "Encoding" => lopdf::dictionary! { "Differences" => vec![65.into(), "A".into(), "B".into()] },
5704 "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()]
5705 }.into());
5706 document
5707 .get_dictionary_mut((3, 0))
5708 .expect("page")
5709 .get_mut(b"Resources")
5710 .expect("resources")
5711 .as_dict_mut()
5712 .expect("dictionary")
5713 .set("ColorSpace", lopdf::dictionary! { "CS1" => "DeviceCMYK",
5714 "IndexedAlias" => vec!["Indexed".into(), "DeviceRGB".into(), 1.into(),
5715 lopdf::Object::String(vec![0, 0, 0, 255, 255, 255], lopdf::StringFormat::Hexadecimal)] });
5716 for color in [
5717 "",
5718 "/CS1 cs 0 0 0 1 sc /CS1 CS 0 0 0 1 SC",
5719 "/IndexedAlias cs 1 sc /IndexedAlias CS 1 SC",
5720 ] {
5721 document
5722 .get_object_mut((4, 0))
5723 .expect("content")
5724 .as_stream_mut()
5725 .expect("stream")
5726 .set_content(
5727 format!("{color} BT /F1 12 Tf 72 500 Td (A) Tj 14.4 0 Td (B) Tj ET")
5728 .into_bytes(),
5729 );
5730 let mut bytes = Vec::new();
5731 document.save_to(&mut bytes).expect("fixture serialization");
5732 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5733 .expect("Type 3 text and visual-only color aliases");
5734 assert_eq!(facts.text, "AB");
5735 assert_eq!(facts.facts.len(), 1);
5736 assert!(matches!(
5737 facts.facts[0].locator,
5738 DocumentLocator::Pdf {
5739 page: 1,
5740 text_start: 0,
5741 text_end: 2
5742 }
5743 ));
5744 }
5745 document
5746 .get_dictionary_mut((5, 0))
5747 .expect("font")
5748 .set("FontMatrix", vec![lopdf::Object::Real(0.002)]);
5749 let mut bytes = Vec::new();
5750 document.save_to(&mut bytes).expect("fixture serialization");
5751 assert!(matches!(
5752 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5753 Err(DocumentExtractionError::Malformed {
5754 format: DocumentFormat::Pdf,
5755 ..
5756 })
5757 ));
5758 }
5759
5760 #[test]
5761 fn pdf_implicit_encoding_preserves_exact_text_and_refuses_undefined_glyphs() {
5762 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5763 document
5764 .get_dictionary_mut((5, 0))
5765 .expect("font")
5766 .set("Encoding", lopdf::Dictionary::new());
5767 document
5768 .get_object_mut((4, 0))
5769 .expect("content")
5770 .as_stream_mut()
5771 .expect("stream")
5772 .set_content(b"BT /F1 12 Tf 72 500 Td <27> Tj ET".to_vec());
5773 let mut bytes = Vec::new();
5774 document.save_to(&mut bytes).expect("fixture serialization");
5775 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5776 .expect("implicit standard font encoding");
5777 assert_eq!(facts.text, "\u{2019}");
5778 assert_eq!(facts.facts.len(), 1);
5779 assert!(matches!(
5780 facts.facts[0].locator,
5781 DocumentLocator::Pdf {
5782 page: 1,
5783 text_start: 0,
5784 text_end: 3
5785 }
5786 ));
5787 document.get_dictionary_mut((5, 0)).expect("font").set(
5788 "Encoding",
5789 lopdf::dictionary! { "Differences" => vec![39.into(), ".notdef".into()] },
5790 );
5791 let mut bytes = Vec::new();
5792 document.save_to(&mut bytes).expect("fixture serialization");
5793 assert!(matches!(
5794 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5795 Err(DocumentExtractionError::UnsupportedPdfInput)
5796 ));
5797 }
5798
5799 #[test]
5800 fn pdf_partial_unicode_uses_builtin_font_or_refuses() {
5801 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5802 let cmap = document.add_object(lopdf::Stream::new(
5803 lopdf::Dictionary::new(),
5804 br"/CIDInit /ProcSet findresource begin
580512 dict begin begincmap
5806/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
5807/CMapName /Fixture def /CMapType 2 def
58081 begincodespacerange <00> <FF> endcodespacerange
58091 beginbfchar <41> <005A> endbfchar
5810endcmap CMapName currentdict /CMap defineresource pop end end"
5811 .to_vec(),
5812 ));
5813 document
5814 .get_object_mut((4, 0))
5815 .expect("content")
5816 .as_stream_mut()
5817 .expect("stream")
5818 .set_content(b"BT /F1 12 Tf 72 500 Td (AB) Tj ET".to_vec());
5819 for (base, expected) in [("Symbol", Some("Z\u{0392}")), ("Fixture", None)] {
5820 document.objects.insert(
5821 (5, 0),
5822 lopdf::dictionary! {
5823 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => base,
5824 "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()],
5825 "ToUnicode" => cmap
5826 }
5827 .into(),
5828 );
5829 let mut bytes = Vec::new();
5830 document.save_to(&mut bytes).expect("fixture serialization");
5831 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5832 if let Some(expected) = expected {
5833 assert_eq!(result.expect("known built-in encoding").text, expected);
5834 } else {
5835 assert!(
5836 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5837 "{result:?}"
5838 );
5839 }
5840 }
5841 }
5842
5843 #[test]
5844 fn pdf_calibrated_color_dictionaries_preserve_text_or_refuse() {
5845 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5846 let parameters = document.add_object(lopdf::dictionary! {
5847 "WhitePoint" => vec![1.into(), 1.into(), 1.into()]
5848 });
5849 let wrong_type = document.add_object(42);
5850 document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
5851 .set_content(b"BT /F1 12 Tf 72 500 Td (Prefix) Tj ET /Calibrated cs /Calibrated CS BT /F1 12 Tf 72 480 Td (Visible) Tj ET".to_vec());
5852 for space in ["CalGray", "CalRGB", "Lab"] {
5853 for (reference, valid) in [(parameters, true), (wrong_type, false), ((999, 0), false)] {
5854 document.get_dictionary_mut((3, 0)).expect("page")
5855 .get_mut(b"Resources").expect("resources").as_dict_mut().expect("dictionary")
5856 .set("ColorSpace", lopdf::dictionary! {
5857 "Calibrated" => vec![lopdf::Object::Name(space.as_bytes().to_vec()), reference.into()]
5858 });
5859 let mut bytes = Vec::new();
5860 document.save_to(&mut bytes).expect("fixture serialization");
5861 let result =
5862 extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5863 if valid {
5864 let facts = result.expect("indirect calibrated dictionary");
5865 assert_eq!(
5866 facts.text.split_whitespace().collect::<Vec<_>>(),
5867 ["Prefix", "Visible"]
5868 );
5869 assert_eq!(facts.facts.len(), 2);
5870 for (fact, (text_start, text_end)) in facts.facts.iter().zip([(0, 6), (7, 14)])
5871 {
5872 assert_eq!(
5873 fact.locator,
5874 DocumentLocator::Pdf {
5875 page: 1,
5876 text_start,
5877 text_end
5878 }
5879 );
5880 }
5881 } else {
5882 assert!(
5883 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
5884 "{space}: {result:?}"
5885 );
5886 }
5887 }
5888 }
5889 }
5890
5891 #[test]
5892 fn pdf_extended_graphics_state_selects_text_font() {
5893 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5894 let state_type = document.add_object(lopdf::Object::Name(b"ExtGState".to_vec()));
5895 let mut state = lopdf::Dictionary::new();
5896 state.set("Type", state_type);
5897 state.set("Font", vec![lopdf::Object::Reference((5, 0)), 12.into()]);
5898 let mut states = lopdf::Dictionary::new();
5899 states.set("GS", state);
5900 document
5901 .get_object_mut((3, 0))
5902 .expect("page")
5903 .as_dict_mut()
5904 .expect("page dictionary")
5905 .get_mut(b"Resources")
5906 .expect("resources")
5907 .as_dict_mut()
5908 .expect("resource dictionary")
5909 .set("ExtGState", states);
5910 document
5911 .get_object_mut((4, 0))
5912 .expect("page stream")
5913 .as_stream_mut()
5914 .expect("stream")
5915 .set_content(b"/GS gs BT 10 Tw 2 Tc 72 500 Td (Graphics ) Tj (Font) Tj ET".to_vec());
5916 let mut bytes = Vec::new();
5917 document.save_to(&mut bytes).expect("fixture serialization");
5918 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5919 .expect("ExtGState font is usable without Tf");
5920 assert_eq!(facts.text.trim(), "Graphics Font");
5921 assert_eq!(facts.facts.len(), 1);
5922 assert!(matches!(
5923 facts.facts[0].locator,
5924 DocumentLocator::Pdf { page: 1, .. }
5925 ));
5926 }
5927
5928 #[test]
5929 fn pdf_actual_text_refuses_partial_text_publication() {
5930 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5931 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5932 .expect("stream").set_content(b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /ActualText (replacement) >> BDC (glyph) Tj EMC ET".to_vec());
5933 let mut bytes = Vec::new();
5934 document.save_to(&mut bytes).expect("fixture serialization");
5935 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5936 assert!(
5937 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5938 "{result:?}"
5939 );
5940 }
5941
5942 #[test]
5943 fn pdf_structure_replacements_refuse_partial_text_publication() {
5944 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5945 document
5946 .get_object_mut((4, 0))
5947 .expect("page stream")
5948 .as_stream_mut()
5949 .expect("stream")
5950 .set_content(
5951 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /MCID 0 >> BDC (glyph) Tj EMC ET"
5952 .to_vec(),
5953 );
5954 let root = document.new_object_id();
5955 let mut element = lopdf::Dictionary::new();
5956 element.set("Type", "StructElem");
5957 element.set("S", "Span");
5958 element.set("P", root);
5959 element.set("Pg", (3, 0));
5960 element.set("K", 0);
5961 let element = document.add_object(element);
5962 let mut parents = lopdf::Dictionary::new();
5963 parents.set(
5964 "Nums",
5965 vec![0.into(), lopdf::Object::Array(vec![element.into()])],
5966 );
5967 let parents = document.add_object(parents);
5968 let mut structure = lopdf::Dictionary::new();
5969 structure.set("Type", "StructTreeRoot");
5970 structure.set("K", element);
5971 structure.set("ParentTree", parents);
5972 document.objects.insert(root, structure.into());
5973 document
5974 .get_dictionary_mut((3, 0))
5975 .expect("page")
5976 .set("StructParents", 0);
5977 document
5978 .get_dictionary_mut((1, 0))
5979 .expect("catalog")
5980 .set("StructTreeRoot", root);
5981 for replacement in [false, true] {
5982 if replacement {
5983 document
5984 .get_dictionary_mut(element)
5985 .expect("structure element")
5986 .set("ActualText", lopdf::Object::string_literal("replacement"));
5987 }
5988 let mut bytes = Vec::new();
5989 document.save_to(&mut bytes).expect("fixture serialization");
5990 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5991 if replacement {
5992 assert!(
5993 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5994 "{result:?}"
5995 );
5996 } else {
5997 let extracted = result.expect("ordinary tagged content remains supported");
5998 assert_eq!(extracted.text.trim(), "Prefixglyph");
5999 assert!(matches!(
6000 extracted.facts[0].locator,
6001 DocumentLocator::Pdf { page: 1, .. }
6002 ));
6003 }
6004 }
6005 }
6006
6007 #[test]
6008 fn pdf_reversed_chars_refuses_partial_text_publication() {
6009 for content in [
6010 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars BMC (desrever) Tj EMC ET".as_slice(),
6011 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars << /MCID 0 >> BDC (desrever) Tj EMC ET",
6012 ] {
6013 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6014 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
6015 .expect("stream").set_content(content.to_vec());
6016 let mut bytes = Vec::new();
6017 document.save_to(&mut bytes).expect("fixture serialization");
6018 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6019 assert!(matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)), "{result:?}");
6020 }
6021 }
6022
6023 fn pdf_with_xobject(xobject: lopdf::Stream) -> Vec<u8> {
6024 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6025 let object = document.add_object(xobject);
6026 let mut resources = lopdf::Dictionary::new();
6027 resources.set("Object1", object);
6028 document
6029 .get_object_mut((3, 0))
6030 .expect("page")
6031 .as_dict_mut()
6032 .expect("page dictionary")
6033 .get_mut(b"Resources")
6034 .expect("resources")
6035 .as_dict_mut()
6036 .expect("resource dictionary")
6037 .set("XObject", resources);
6038 let stream = document
6039 .get_object_mut((4, 0))
6040 .expect("page content")
6041 .as_stream_mut()
6042 .expect("content stream");
6043 let mut content = stream.content.clone();
6044 content.extend_from_slice(b"\n/Object1 Do\n");
6045 stream.set_content(content);
6046 let mut bytes = Vec::new();
6047 document.save_to(&mut bytes).expect("fixture serialization");
6048 bytes
6049 }
6050
6051 #[test]
6052 fn recursive_pdf_form_stops_without_publishing_page_text() {
6053 let mut form = lopdf::Dictionary::new();
6054 form.set("Type", "XObject");
6055 form.set("Subtype", "Form");
6056 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
6057 let bytes = pdf_with_xobject(lopdf::Stream::new(form, b"/Object1 Do".to_vec()));
6058 let mut document = lopdf::Document::load_mem(&bytes).expect("fixture PDF");
6059 let resources = document
6060 .get_dictionary((3, 0))
6061 .expect("page dictionary")
6062 .get(b"Resources")
6063 .expect("page resources")
6064 .clone();
6065 let id = resources
6066 .as_dict()
6067 .expect("resource dictionary")
6068 .get(b"XObject")
6069 .expect("XObject resources")
6070 .as_dict()
6071 .expect("XObject dictionary")
6072 .get(b"Object1")
6073 .expect("Form reference")
6074 .as_reference()
6075 .expect("indirect Form");
6076 document
6077 .get_object_mut(id)
6078 .expect("Form object")
6079 .as_stream_mut()
6080 .expect("Form stream")
6081 .dict
6082 .set("Resources", resources);
6083 let mut bytes = Vec::new();
6084 document.save_to(&mut bytes).expect("fixture serialization");
6085 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6086 assert!(
6087 matches!(
6088 result,
6089 Err(DocumentExtractionError::Malformed {
6090 format: DocumentFormat::Pdf,
6091 ..
6092 })
6093 ),
6094 "{result:?}"
6095 );
6096 }
6097
6098 #[test]
6099 fn pdf_closed_paths_preserve_text_and_missing_current_points_refuse() {
6100 for (path, valid) in [
6101 ("10 20 m 30 40 l h 50 60 70 80 v S", true),
6102 ("10 20 30 40 re 50 60 70 80 v S", true),
6103 ("50 60 70 80 v", false),
6104 ("10 20 m s 50 60 70 80 v", false),
6105 ("10 20 m f* 50 60 70 80 v", false),
6106 ("10 20 m B 50 60 70 80 v", false),
6107 ("10 20 m B* 50 60 70 80 v", false),
6108 ("10 20 m b 50 60 70 80 v", false),
6109 ("10 20 m b* 50 60 70 80 v", false),
6110 ("10 20 m S 50 60 70 80 v", false),
6111 ("10 20 m f 50 60 70 80 v", false),
6112 ("10 20 m F 50 60 70 80 v", false),
6113 ("10 20 m n 50 60 70 80 v", false),
6114 (
6115 "10 20 30 40 re s 10 20 30 40 re f* 10 20 30 40 re B 10 20 30 40 re B* 10 20 30 40 re b 10 20 30 40 re b*",
6116 true,
6117 ),
6118 ] {
6119 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6120 let stream = document
6121 .get_object_mut((4, 0))
6122 .expect("page content")
6123 .as_stream_mut()
6124 .expect("content stream");
6125 let mut content = format!("{path}\n").into_bytes();
6126 content.extend_from_slice(&stream.content);
6127 stream.set_content(content);
6128 let mut bytes = Vec::new();
6129 document.save_to(&mut bytes).expect("fixture serialization");
6130 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6131 if valid {
6132 let facts = result.expect("closed path and displayed PDF text");
6133 assert_eq!(facts.text, "Hello PDF");
6134 assert_eq!(facts.facts.len(), 1);
6135 assert!(matches!(
6136 facts.facts[0].locator,
6137 DocumentLocator::Pdf {
6138 page: 1,
6139 text_start: 0,
6140 text_end: 9
6141 }
6142 ));
6143 } else {
6144 assert!(
6145 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
6146 "{result:?}"
6147 );
6148 }
6149 }
6150 }
6151
6152 #[test]
6153 fn pdf_postscript_xobjects_preserve_displayed_text_and_exact_locator() {
6154 for subtype in ["PS", "Form", "Unknown"] {
6155 let mut object = lopdf::Dictionary::new();
6156 object.set("Type", "XObject");
6157 object.set("Subtype", subtype);
6158 if subtype != "PS" {
6159 object.set("Subtype2", "PS");
6160 }
6161 let bytes = pdf_with_xobject(lopdf::Stream::new(
6162 object,
6163 b"/Helvetica findfont 12 scalefont setfont (Print only) show".to_vec(),
6164 ));
6165 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6166 if subtype == "Unknown" {
6167 assert!(
6168 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
6169 "{result:?}"
6170 );
6171 } else {
6172 let facts = result.expect("displayed PDF text");
6173 assert_eq!(facts.text, "Hello PDF");
6174 assert_eq!(facts.facts.len(), 1);
6175 assert!(matches!(
6176 facts.facts[0].locator,
6177 DocumentLocator::Pdf {
6178 page: 1,
6179 text_start: 0,
6180 text_end: 9
6181 }
6182 ));
6183 }
6184 }
6185 }
6186
6187 #[test]
6188 fn pdf_image_pixels_do_not_replace_or_invent_page_text() {
6189 let mut image = lopdf::Dictionary::new();
6190 image.set("Type", "XObject");
6191 image.set("Subtype", "Image");
6192 image.set("Width", 1);
6193 image.set("Height", 1);
6194 image.set("ColorSpace", "DeviceRGB");
6195 image.set("BitsPerComponent", 8);
6196 let bytes = pdf_with_xobject(lopdf::Stream::new(image, vec![255, 0, 0]));
6197 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
6198 .expect("text and image PDF");
6199 assert_eq!(facts.text, "Hello PDF");
6200 let mut document = lopdf::Document::load_mem(&bytes).expect("image fixture");
6201 let subtype = document.add_object(lopdf::Object::Name(b"Image".to_vec()));
6202 for object in document.objects.values_mut() {
6203 if let lopdf::Object::Stream(stream) = object
6204 && stream
6205 .dict
6206 .get(b"Subtype")
6207 .and_then(lopdf::Object::as_name)
6208 .ok()
6209 == Some(b"Image".as_slice())
6210 {
6211 stream.dict.set("Subtype", subtype);
6212 stream.dict.set("Filter", "DCTDecode");
6213 }
6214 }
6215 let mut indirect = Vec::new();
6216 document
6217 .save_to(&mut indirect)
6218 .expect("fixture serialization");
6219 let actual = extract_document_text_controlled(&indirect, "guide.pdf", None, &control())
6220 .expect("indirect image subtype keeps opaque pixels outside decoding");
6221 assert_eq!(actual, facts);
6222 }
6223
6224 #[test]
6225 fn pdf_form_font_names_do_not_reuse_page_font_encodings() {
6226 let mut encoding = lopdf::Dictionary::new();
6227 encoding.set("Type", "Encoding");
6228 encoding.set("BaseEncoding", "WinAnsiEncoding");
6229 encoding.set(
6230 "Differences",
6231 vec![65.into(), lopdf::Object::Name(b"Z".to_vec())],
6232 );
6233 let mut font = lopdf::Dictionary::new();
6234 font.set("Type", "Font");
6235 font.set("Subtype", "Type1");
6236 font.set("BaseFont", "Helvetica");
6237 font.set("Encoding", encoding);
6238 let mut fonts = lopdf::Dictionary::new();
6239 fonts.set("F1", font);
6240 let mut resources = lopdf::Dictionary::new();
6241 resources.set("Font", fonts);
6242 let mut form = lopdf::Dictionary::new();
6243 form.set("Type", "XObject");
6244 form.set("Subtype", "Form");
6245 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
6246 form.set("Resources", resources);
6247 let bytes = pdf_with_xobject(lopdf::Stream::new(
6248 form,
6249 b"BT /F1 12 Tf 72 700 Td (A) Tj ET".to_vec(),
6250 ));
6251 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
6252 .expect("scoped Form font");
6253 assert!(facts.text.contains("Hello PDF"));
6254 assert!(facts.text.contains('Z'), "{}", facts.text);
6255 assert!(!facts.text.contains('A'), "{}", facts.text);
6256 }
6257
6258 #[test]
6259 fn pdf_cid_text_requires_every_character_to_decode() {
6260 for (encoding, codes, expected, default_width) in [
6261 ("Identity-H", "0001", Some("Z")),
6262 (
6263 "Identity-H",
6264 "0001> Tj 3 0 Td <0001> Tj 8 0 Td <0001",
6265 Some("ZZ Z"),
6266 ),
6267 ("Identity-H", "00010002", None),
6268 ("Identity-H", "000100", None),
6269 ("Identity-V", "0001", None),
6270 ("custom", "0001", None),
6271 ]
6272 .into_iter()
6273 .map(|(encoding, codes, expected)| (encoding, codes, expected, None))
6274 .chain([(
6275 "Identity-H",
6276 "0001> Tj 18.006 0 Td <0001",
6277 Some("ZZ"),
6278 Some(1500.5_f32),
6279 )]) {
6280 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6281 let cmap = document.add_object(lopdf::Stream::new(
6282 lopdf::Dictionary::new(),
6283 br"/CIDInit /ProcSet findresource begin
628412 dict begin begincmap
6285/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
6286/CMapName /Fixture def /CMapType 2 def
62871 begincodespacerange <0000> <FFFF> endcodespacerange
62881 beginbfchar <0001> <005A> endbfchar
6289endcmap CMapName currentdict /CMap defineresource pop end end"
6290 .to_vec(),
6291 ));
6292 let mut system = lopdf::Dictionary::new();
6293 system.set("Registry", lopdf::Object::string_literal("Adobe"));
6294 system.set("Ordering", lopdf::Object::string_literal("Identity"));
6295 system.set("Supplement", 0);
6296 let mut descendant = lopdf::Dictionary::new();
6297 descendant.set("Type", "Font");
6298 descendant.set("Subtype", "CIDFontType2");
6299 descendant.set("BaseFont", "Fixture");
6300 if let Some(width) = default_width {
6301 document.version = "2.0".to_owned();
6302 descendant.set("DW", lopdf::Object::Real(width));
6303 } else {
6304 descendant.set("W", vec![lopdf::Object::Integer(1), 1.into(), 200.into()]);
6305 }
6306 descendant.set("CIDSystemInfo", system);
6307 let descriptor = document.add_object(lopdf::dictionary! {
6308 "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 4,
6309 "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
6310 "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200,
6311 "CapHeight" => 700, "StemV" => 80
6312 });
6313 descendant.set("FontDescriptor", descriptor);
6314 let descendant = document.add_object(descendant);
6315 let mut font = lopdf::Dictionary::new();
6316 font.set("Type", "Font");
6317 font.set("Subtype", "Type0");
6318 font.set("BaseFont", "Fixture");
6319 if encoding == "custom" {
6320 let encoding = document.add_object(lopdf::Stream::new(
6321 lopdf::Dictionary::new(),
6322 b"/CIDInit /ProcSet findresource begin
632312 dict begin begincmap
6324/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
6325/CMapName /FixtureEncoding def /CMapType 1 def /WMode 0 def
63261 begincodespacerange <0000> <FFFF> endcodespacerange
63271 begincidrange <0000> <FFFF> 0 endcidrange
6328endcmap CMapName currentdict /CMap defineresource pop end end"
6329 .to_vec(),
6330 ));
6331 font.set("Encoding", encoding);
6332 } else {
6333 font.set("Encoding", encoding);
6334 }
6335 font.set(
6336 "DescendantFonts",
6337 vec![lopdf::Object::Reference(descendant)],
6338 );
6339 font.set("ToUnicode", cmap);
6340 document.objects.insert((5, 0), font.into());
6341 document
6342 .get_object_mut((4, 0))
6343 .expect("content")
6344 .as_stream_mut()
6345 .expect("stream")
6346 .set_content(format!("BT /F1 12 Tf 72 720 Td <{codes}> Tj ET").into_bytes());
6347 let mut bytes = Vec::new();
6348 document.save_to(&mut bytes).expect("fixture serialization");
6349 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6350 if let Some(expected) = expected {
6351 assert_eq!(result.expect("mapped horizontal CID").text, expected);
6352 } else if encoding != "Identity-H" || codes == "00010002" {
6353 assert!(
6354 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
6355 "{result:?}"
6356 );
6357 } else {
6358 assert!(
6359 matches!(
6360 result,
6361 Err(DocumentExtractionError::Malformed {
6362 format: DocumentFormat::Pdf,
6363 ..
6364 })
6365 ),
6366 "{codes}: {result:?}"
6367 );
6368 }
6369 }
6370 }
6371
6372 #[test]
6373 fn pdf_late_malformed_page_never_publishes_a_complete_prefix() {
6374 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
6375 document.objects.insert(
6376 (7, 0),
6377 lopdf::Stream::new(lopdf::Dictionary::new(), b"BT (unterminated".to_vec()).into(),
6378 );
6379 let mut bytes = Vec::new();
6380 document.save_to(&mut bytes).expect("fixture serialization");
6381 assert!(matches!(
6382 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
6383 Err(DocumentExtractionError::Malformed {
6384 format: DocumentFormat::Pdf,
6385 ..
6386 })
6387 ));
6388 }
6389
6390 #[test]
6391 fn pdf_decompression_bomb_stops_at_the_first_host_resource_limit() {
6392 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6393 let mut stream = lopdf::Stream::new(
6394 lopdf::Dictionary::new(),
6395 vec![b' '; MAX_DOCUMENT_EXPANDED_BYTES + 1],
6396 );
6397 stream.compress().expect("fixture compression");
6398 document.objects.insert((4, 0), stream.into());
6399 let mut bytes = Vec::new();
6400 document.save_to(&mut bytes).expect("fixture serialization");
6401 assert!(bytes.len() < MAX_DOCUMENT_COMPRESSED_BYTES);
6402 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6403 assert!(
6404 matches!(
6405 &result,
6406 Err(DocumentExtractionError::ResourceLimit {
6407 limit: DocumentLimit::MemoryBytes
6408 | DocumentLimit::ExpandedBytes
6409 | DocumentLimit::ExecutionFuel,
6410 ..
6411 } | DocumentExtractionError::Work(
6412 projectatlas_core::IndexWorkFailure::DeadlineExceeded { .. }
6413 ))
6414 ),
6415 "actual refusal: {result:?}"
6416 );
6417 }
6418
6419 #[test]
6420 fn encrypted_or_password_protected_pdf_is_rejected_without_credentials() {
6421 let error = extract_document_text_controlled(
6422 &encrypted_pdf(),
6423 "secret.pdf",
6424 Some("pdf"),
6425 &control(),
6426 )
6427 .expect_err("password-protected PDFs must never be decrypted by indexing");
6428 assert!(matches!(error, DocumentExtractionError::EncryptedPdf));
6429 }
6430
6431 #[test]
6432 fn unsafe_or_duplicate_docx_parts_are_rejected() {
6433 let mut bytes = Vec::new();
6434 {
6435 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6436 writer
6437 .start_file("../word/document.xml", FileOptions::default())
6438 .expect("fixture entry");
6439 writer.write_all(b"<w:document/>").expect("fixture XML");
6440 writer.finish().expect("fixture archive");
6441 }
6442 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6443 .expect_err("path traversal must fail closed");
6444 assert!(matches!(
6445 error,
6446 DocumentExtractionError::InvalidDocxPackage { .. }
6447 ));
6448 }
6449
6450 #[test]
6451 fn stored_and_deflated_docx_parts_are_admitted() {
6452 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>bounded</w:t></w:r></w:p></w:body></w:document>"#;
6453 for method in [CompressionMethod::Stored, CompressionMethod::Deflated] {
6454 let facts = extract_document_text_controlled(
6455 &docx_archive(xml, method),
6456 "guide.docx",
6457 None,
6458 &control(),
6459 )
6460 .expect("admitted DOCX compression");
6461 assert_eq!(facts.text, "bounded");
6462 }
6463 }
6464
6465 #[test]
6466 fn unsupported_docx_compression_is_rejected_before_text_read() {
6467 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
6468 let mut bytes = docx_archive(xml, CompressionMethod::Stored);
6469 rewrite_zip_method(&mut bytes, 12);
6470 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6471 .expect_err("unsupported compression must fail closed");
6472 assert!(matches!(
6473 error,
6474 DocumentExtractionError::UnsupportedDocxInput { .. }
6475 ));
6476 }
6477
6478 #[test]
6479 fn encrypted_docx_is_rejected_before_text_publication() {
6480 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
6481 let mut bytes = docx_archive(xml, CompressionMethod::Stored);
6482 mark_zip_encrypted(&mut bytes);
6483 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6484 .expect_err("encrypted packages must fail closed");
6485 assert!(matches!(
6486 error,
6487 DocumentExtractionError::UnsupportedDocxInput { .. }
6488 ));
6489 }
6490
6491 #[test]
6492 fn duplicate_docx_parts_are_rejected_before_document_read() {
6493 let mut bytes = Vec::new();
6494 {
6495 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6496 for (name, value) in [
6497 (DOCX_DOCUMENT_PART, b"first".as_slice()),
6498 ("word/duplicat.xml", b"second".as_slice()),
6499 ] {
6500 writer
6501 .start_file(name, FileOptions::default())
6502 .expect("fixture entry");
6503 writer.write_all(value).expect("fixture XML");
6504 }
6505 writer.finish().expect("fixture archive");
6506 }
6507 rename_zip_fixture_part(&mut bytes, "word/duplicat.xml", DOCX_DOCUMENT_PART);
6508 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6509 .expect_err("duplicate package parts must fail closed");
6510 assert!(matches!(
6511 error,
6512 DocumentExtractionError::InvalidDocxPackage { message }
6513 if message.contains("duplicate package part")
6514 ));
6515 }
6516
6517 fn rename_zip_fixture_part(bytes: &mut [u8], from: &str, to: &str) {
6519 assert_eq!(from.len(), to.len());
6520 let positions: Vec<_> = bytes
6521 .windows(from.len())
6522 .enumerate()
6523 .filter_map(|(index, value)| (value == from.as_bytes()).then_some(index))
6524 .collect();
6525 assert_eq!(positions.len(), 2, "local and central fixture names");
6526 for index in positions {
6527 bytes[index..index + from.len()].copy_from_slice(to.as_bytes());
6528 }
6529 }
6530
6531 #[test]
6532 fn duplicate_unreferenced_docx_parts_are_rejected_at_every_position() {
6533 let names = ["word/first.bin", "word/other.bin", "word/third.bin"];
6534 for name in names {
6535 let mut bytes = docx_archive_with_parts(&[
6536 (DOCX_DOCUMENT_PART, b"not reached"),
6537 (names[0], b"first"),
6538 (names[1], b"middle"),
6539 (names[2], b"last"),
6540 ("word/spare.bin", b"duplicate"),
6541 ]);
6542 rename_zip_fixture_part(&mut bytes, "word/spare.bin", name);
6543 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6544 .expect_err("unreferenced duplicate parts must fail before XML reads");
6545 assert!(matches!(
6546 error,
6547 DocumentExtractionError::InvalidDocxPackage { message }
6548 if message.contains("duplicate package part")
6549 ));
6550 }
6551 }
6552
6553 #[test]
6554 fn docx_entry_budget_counts_records_hidden_by_name_deduplication() {
6555 let mut bytes = Vec::new();
6556 {
6557 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6558 for index in 0..=MAX_DOCUMENT_ENTRIES {
6559 writer
6560 .start_file(format!("part-{index:03}.bin"), FileOptions::default())
6561 .expect("fixture entry");
6562 }
6563 writer.finish().expect("fixture archive");
6564 }
6565 rename_zip_fixture_part(
6566 &mut bytes,
6567 &format!("part-{MAX_DOCUMENT_ENTRIES:03}.bin"),
6568 &format!("part-{:03}.bin", MAX_DOCUMENT_ENTRIES - 1),
6569 );
6570 let archive = ZipArchive::new(Cursor::new(&bytes)).expect("valid duplicate ZIP");
6571 assert_eq!(archive.len(), MAX_DOCUMENT_ENTRIES);
6572 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6573 .expect_err("all central records count toward the entry limit");
6574 assert!(matches!(
6575 error,
6576 DocumentExtractionError::ResourceLimit {
6577 limit: DocumentLimit::EntryCount,
6578 observed,
6579 maximum: MAX_DOCUMENT_ENTRIES,
6580 } if observed == MAX_DOCUMENT_ENTRIES + 1
6581 ));
6582 }
6583
6584 #[test]
6585 fn unreferenced_embedded_parts_do_not_trigger_recursive_parsing() {
6586 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
6587 let bytes = docx_archive_with_parts(&[
6588 (DOCX_DOCUMENT_PART, main),
6589 ("word/embeddings/oleObject1.bin", b"OLE"),
6590 ("word/embeddings/nested.docx", b"PK\x03\x04"),
6591 ]);
6592 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6593 .expect("unreferenced embedded parts remain inert");
6594 assert_eq!(facts.text, "Visible");
6595 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
6596 }
6597
6598 #[test]
6599 fn non_utf8_docx_part_metadata_is_rejected() {
6600 let mut bytes = Vec::new();
6601 {
6602 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6603 writer
6604 .start_file("word/document.xml", FileOptions::default())
6605 .expect("fixture entry");
6606 writer
6607 .write_all(b"<w:document xmlns:w=\"urn:w\"><w:body/></w:document>")
6608 .expect("fixture XML");
6609 writer.finish().expect("fixture archive");
6610 }
6611 let name = b"word/document.xml";
6612 let positions = bytes
6613 .windows(name.len())
6614 .enumerate()
6615 .filter_map(|(index, candidate)| (candidate == name).then_some(index))
6616 .collect::<Vec<_>>();
6617 for index in positions {
6618 bytes[index] = 0xff;
6619 }
6620 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6621 .expect_err("non-UTF-8 package metadata must fail closed");
6622 assert!(matches!(
6623 error,
6624 DocumentExtractionError::InvalidDocxPackage { .. }
6625 ));
6626 }
6627
6628 #[test]
6629 fn docx_entry_count_is_bounded_before_archive_reads() {
6630 let mut bytes = Vec::new();
6631 {
6632 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6633 for index in 0..=MAX_DOCUMENT_ENTRIES {
6634 writer
6635 .start_file(format!("parts/{index}.xml"), FileOptions::default())
6636 .expect("fixture entry");
6637 }
6638 writer.finish().expect("fixture archive");
6639 }
6640 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6641 .expect_err("entry count must be bounded before archive reads");
6642 assert!(matches!(
6643 error,
6644 DocumentExtractionError::ResourceLimit {
6645 limit: DocumentLimit::EntryCount,
6646 observed,
6647 maximum: MAX_DOCUMENT_ENTRIES
6648 } if observed == MAX_DOCUMENT_ENTRIES + 1
6649 ));
6650 }
6651
6652 #[test]
6653 fn docx_part_name_fanout_is_bounded_before_locator_cloning() {
6654 let long_part = format!("word/{}.xml", "x".repeat(MAX_DOCX_PART_NAME_BYTES));
6655 let archive = docx_archive_with_parts(&[
6656 (DOCX_DOCUMENT_PART, b"<w:document/>"),
6657 (&long_part, b"<w:hdr/>"),
6658 ]);
6659 let error = extract_document_text_controlled(&archive, "long-name.docx", None, &control())
6660 .expect_err("large part names cannot multiply through thousands of locators");
6661 assert!(matches!(
6662 error,
6663 DocumentExtractionError::InvalidDocxPackage { message }
6664 if message.contains("part name exceeds")
6665 ));
6666 }
6667
6668 #[test]
6669 fn docx_expanded_size_is_bounded_before_decompression() {
6670 let mut bytes = Vec::new();
6671 {
6672 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6673 writer
6674 .start_file(
6675 DOCX_DOCUMENT_PART,
6676 FileOptions::default().compression_method(CompressionMethod::Deflated),
6677 )
6678 .expect("fixture entry");
6679 writer
6680 .write_all(&vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 1])
6681 .expect("fixture payload");
6682 writer.finish().expect("fixture archive");
6683 }
6684 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6685 .expect_err("expanded ZIP size must be bounded before decompression");
6686 assert!(matches!(
6687 error,
6688 DocumentExtractionError::ResourceLimit {
6689 limit: DocumentLimit::ExpandedBytes,
6690 observed,
6691 maximum: MAX_DOCUMENT_EXPANDED_BYTES
6692 } if observed == MAX_DOCUMENT_EXPANDED_BYTES + 1
6693 ));
6694 }
6695
6696 #[test]
6697 fn docx_forged_catalog_size_is_rejected_before_text_publication() {
6698 let mut bytes = docx_archive(
6699 &vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 8192],
6700 CompressionMethod::Deflated,
6701 );
6702 for index in 0..bytes.len().saturating_sub(4) {
6703 let size_offset = match &bytes[index..index + 4] {
6704 b"PK\x03\x04" => index + 22,
6705 b"PK\x01\x02" => index + 24,
6706 _ => continue,
6707 };
6708 bytes[size_offset..size_offset + 4].copy_from_slice(&1_u32.to_le_bytes());
6709 }
6710 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6711 .expect_err("actual decompression must be bounded independently of ZIP metadata");
6712 assert!(matches!(
6713 error,
6714 DocumentExtractionError::InvalidDocxPackage { message }
6715 if message.contains("declared uncompressed size")
6716 ));
6717 }
6718
6719 #[test]
6720 fn docx_repeat_reads_charge_actual_decompression_work() {
6721 let filler = vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES / 3 + 1];
6722 let bytes = docx_archive_with_parts(&[
6723 (DOCX_DOCUMENT_PART, br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#),
6724 ("word/repeated.bin", &filler),
6725 ]);
6726 let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).expect("fixture ZIP");
6727 let mut budget = DocxReadBudget::default();
6728 for _ in 0..5 {
6729 read_docx_package_part(
6730 &mut archive,
6731 "word/repeated.bin",
6732 &mut budget,
6733 &control(),
6734 IndexWorkStage::TextIndex,
6735 )
6736 .expect("bounded repeat read");
6737 }
6738 assert!(matches!(
6739 read_docx_package_part(
6740 &mut archive,
6741 "word/repeated.bin",
6742 &mut budget,
6743 &control(),
6744 IndexWorkStage::TextIndex,
6745 ),
6746 Err(DocumentExtractionError::ResourceLimit {
6747 limit: DocumentLimit::ParserWorkBytes,
6748 ..
6749 }),
6750 ));
6751 }
6752
6753 #[test]
6754 fn docx_namespace_identity_is_independent_of_prefix() {
6755 let canonical = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A</w:t><w:tab/><w:t>B</w:t></w:r></w:p></w:body></w:document>"#;
6756 let expected = parse_docx(
6757 canonical.as_bytes(),
6758 &control(),
6759 IndexWorkStage::SymbolParsing,
6760 )
6761 .expect("canonical WordprocessingML");
6762 for xml in [
6763 canonical.replace("w:", "x:").replace("xmlns:w", "xmlns:x"),
6764 canonical.replace("w:", "").replace("xmlns:w", "xmlns"),
6765 canonical.replace(
6766 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
6767 "http://purl.oclc.org/ooxml/wordprocessingml/main",
6768 ),
6769 ] {
6770 assert_eq!(
6771 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
6772 .expect("equivalent namespace identity"),
6773 expected,
6774 );
6775 }
6776 let foreign_text = canonical.replace(
6777 "<w:t>A</w:t>",
6778 "<foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t>",
6779 );
6780 assert!(matches!(
6781 parse_docx(
6782 foreign_text.as_bytes(),
6783 &control(),
6784 IndexWorkStage::SymbolParsing
6785 ),
6786 Err(DocumentExtractionError::UnsupportedDocxInput { .. }),
6787 ));
6788 for xml in [
6789 canonical.replace(
6790 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
6791 "urn:foreign",
6792 ),
6793 canonical.replace(
6794 "<w:t>A</w:t>",
6795 "<w:t><foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t></w:t>",
6796 ),
6797 canonical.replace("<w:t>A</w:t>", "<unknown:t>A</unknown:t>"),
6798 canonical.replace("</w:r></w:p>", "</w:p></w:r>"),
6799 ] {
6800 assert!(matches!(
6801 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing),
6802 Err(DocumentExtractionError::Malformed {
6803 format: DocumentFormat::Docx,
6804 ..
6805 }),
6806 ));
6807 }
6808 }
6809
6810 #[test]
6811 fn docx_ruby_annotations_are_typed_unsupported() {
6812 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Prefix</w:t><w:ruby><w:rt><w:r><w:t>Reading</w:t></w:r></w:rt><w:rubyBase><w:r><w:t>Base</w:t></w:r></w:rubyBase></w:ruby></w:r></w:p></w:body></w:document>"#;
6813 assert!(matches!(
6814 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
6815 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
6816 ));
6817 let deleted = xml
6818 .replace("<w:ruby>", "<w:del><w:ruby>")
6819 .replace("</w:ruby>", "</w:ruby></w:del>");
6820 assert_eq!(
6821 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6822 .expect("deleted ruby stays excluded")
6823 .text,
6824 "Prefix"
6825 );
6826 }
6827
6828 #[test]
6829 fn docx_alternate_format_chunk_is_local_incomplete_coverage() {
6830 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Prefix</w:t></w:r></w:p><w:altChunk r:id="html"/></w:body></w:document>"#;
6831 let direct = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
6832 .expect("unexamined import retains verified surrounding text");
6833 assert_eq!(direct.text, "Prefix");
6834 assert_eq!(
6835 direct.completeness,
6836 DocumentCompleteness::Partial {
6837 gaps: vec![DocumentCoverageGap::UnexaminedStory],
6838 }
6839 );
6840 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="html" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="import.html"/></Relationships>"#;
6841 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="html" ContentType="text/html"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
6842 let imported = b"<!doctype html><html><body><p>Imported</p></body></html>";
6843 let archive = docx_archive_with_parts(&[
6844 ("[Content_Types].xml", manifest),
6845 (DOCX_DOCUMENT_PART, xml.as_bytes()),
6846 ("word/_rels/document.xml.rels", rels),
6847 ("word/import.html", imported),
6848 ]);
6849 let packaged = extract_document_text_controlled(&archive, "import.docx", None, &control())
6850 .expect("safe imported part is local incomplete coverage");
6851 assert_eq!(packaged.completeness, direct.completeness);
6852 let sdt = xml.replace(
6853 "<w:altChunk r:id=\"html\"/>",
6854 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr w:val=\"false\"/></w:sdtPr><w:sdtContent><w:tbl><w:tr><w:tc><w:altChunk r:id=\"html\"/></w:tc></w:tr></w:tbl></w:sdtContent></w:sdt>",
6855 );
6856 let sdt_archive = docx_archive_with_parts(&[
6857 ("[Content_Types].xml", manifest),
6858 (DOCX_DOCUMENT_PART, sdt.as_bytes()),
6859 ("word/_rels/document.xml.rels", rels),
6860 ("word/import.html", imported),
6861 ]);
6862 let sdt_facts =
6863 extract_document_text_controlled(&sdt_archive, "import.docx", None, &control())
6864 .expect("actual alternate-format content must not load a glossary placeholder");
6865 assert_eq!(sdt_facts.text, "Prefix");
6866 assert_eq!(sdt_facts.completeness, direct.completeness);
6867 let text_box = xml.replace(
6868 "<w:altChunk r:id=\"html\"/>",
6869 &format!(
6870 "<w:p><w:r>{}</w:r></w:p>",
6871 docx_drawing_text_box("<w:altChunk r:id=\"html\"/>", 1)
6872 ),
6873 );
6874 let text_box_archive = docx_archive_with_parts(&[
6875 ("[Content_Types].xml", manifest),
6876 (
6877 "_rels/.rels",
6878 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
6879 ),
6880 (DOCX_DOCUMENT_PART, text_box.as_bytes()),
6881 ("word/_rels/document.xml.rels", rels),
6882 ("word/import.html", imported),
6883 ]);
6884 let text_box_facts = extract_document_text_controlled(
6885 &text_box_archive,
6886 "text-box-import.docx",
6887 None,
6888 &control(),
6889 )
6890 .expect("rendered text-box import retains local incomplete coverage");
6891 assert_eq!(text_box_facts.text, "Prefix\n");
6892 assert_eq!(text_box_facts.completeness, direct.completeness);
6893 let rels = std::str::from_utf8(rels).expect("ASCII fixture");
6894 for broken in [
6895 rels.replace("relationships/aFChunk", "relationships/header"),
6896 rels.replace("Target=\"import.html\"", "Target=\"../escape.html\""),
6897 rels.replace(
6898 "Target=\"import.html\"",
6899 "Target=\"https://example.invalid/import.html\" TargetMode=\"External\"",
6900 ),
6901 ] {
6902 let archive = docx_archive_with_parts(&[
6903 ("[Content_Types].xml", manifest),
6904 (DOCX_DOCUMENT_PART, xml.as_bytes()),
6905 ("word/_rels/document.xml.rels", broken.as_bytes()),
6906 ("word/import.html", imported),
6907 ]);
6908 assert!(matches!(
6909 extract_document_text_controlled(&archive, "broken.docx", None, &control()),
6910 Err(DocumentExtractionError::InvalidDocxPackage { .. }),
6911 ));
6912 }
6913 let deleted = xml.replace(
6914 "<w:altChunk r:id=\"html\"/>",
6915 "<w:del><w:altChunk r:id=\"html\"/></w:del>",
6916 );
6917 assert_eq!(
6918 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6919 .expect("deleted content stays excluded")
6920 .text,
6921 "Prefix"
6922 );
6923 }
6924
6925 #[test]
6926 fn docx_subdocuments_refuse_incomplete_text() {
6927 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Prefix</w:t></w:r><w:subDoc r:id="child"/></w:p></w:body></w:document>"#;
6928 for reference in [
6929 r#"<w:subDoc r:id="child"/>"#,
6930 r#"<w:subDoc r:id="child"></w:subDoc>"#,
6931 ] {
6932 let xml = xml.replace(r#"<w:subDoc r:id="child"/>"#, reference);
6933 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
6934 let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
6935 assert!(
6936 matches!(
6937 result,
6938 Err(DocumentExtractionError::InvalidDocxPackage { .. })
6939 ),
6940 "{result:?}"
6941 );
6942 let deleted = xml.replace(reference, &format!("<w:del>{reference}</w:del>"));
6943 assert_eq!(
6944 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6945 .expect("deleted subdocument remains excluded")
6946 .text,
6947 "Prefix"
6948 );
6949 }
6950 }
6951
6952 #[test]
6953 fn docx_story_anchors_in_field_instructions_do_not_select_parts() {
6954 for anchor in [
6955 r#"<w:altChunk r:id="missing"/>"#,
6956 r#"<w:p><w:subDoc r:id="missing"/></w:p>"#,
6957 ] {
6958 let xml = format!(
6959 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r></w:p>{anchor}<w:p><w:r><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#
6960 );
6961 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
6962 let result = extract_document_text_controlled(&bytes, "field.docx", None, &control())
6963 .expect("non-rendered instruction anchor needs no relationship");
6964 assert_eq!(result.text, "Result");
6965 assert_eq!(result.completeness, DocumentCompleteness::Complete);
6966 }
6967 }
6968
6969 #[test]
6970 fn docx_comment_ranges_require_content_parents_and_references() {
6971 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="comments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
6972 let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>CommentFour</w:t><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:comment><w:comment w:id="5"><w:p><w:r><w:t>CommentFive</w:t><w:sym w:font="Symbol" w:char="F062"/></w:r></w:p></w:comment></w:comments>"#;
6973 for valid in [
6974 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:commentRangeStart w:id="4"/><w:r><w:t>Visible</w:t></w:r><w:commentRangeEnd w:id="4"/></w:p></w:body></w:document>"#,
6975 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><mc:AlternateContent><mc:Choice Requires="w"><w:commentRangeStart w:id="4"/></mc:Choice><mc:Fallback/></mc:AlternateContent><w:r><w:t>Visible</w:t></w:r><w:commentRangeEnd w:id="4"/></w:p></w:body></w:document>"#,
6976 ] {
6977 let archive = docx_archive_with_parts(&[
6978 (DOCX_DOCUMENT_PART, valid.as_bytes()),
6979 ("word/_rels/document.xml.rels", rels),
6980 ("word/comments.xml", comments),
6981 ]);
6982 let rendered =
6983 extract_document_text_controlled(&archive, "content.docx", None, &control())
6984 .expect("an unreferenced content-level range is ignored");
6985 assert_eq!(rendered.text, "Visible");
6986 assert!(rendered.symbols.is_empty());
6987 assert_eq!(rendered.completeness, DocumentCompleteness::Complete);
6988 for (reference_id, selected, excluded, code) in [
6989 (4, "CommentFour", "CommentFive", 0xF061),
6990 (5, "CommentFive", "CommentFour", 0xF062),
6991 ] {
6992 let referenced = valid.replace(
6993 "<w:commentRangeEnd w:id=\"4\"/>",
6994 &format!(
6995 "<w:commentRangeEnd w:id=\"4\"/><w:r><w:commentReference w:id=\"{reference_id}\"/></w:r>"
6996 ),
6997 );
6998 let archive = docx_archive_with_parts(&[
6999 (DOCX_DOCUMENT_PART, referenced.as_bytes()),
7000 ("word/_rels/document.xml.rels", rels),
7001 ("word/comments.xml", comments),
7002 ]);
7003 let rendered =
7004 extract_document_text_controlled(&archive, "content.docx", None, &control())
7005 .expect("only the rendered reference selects its comment story");
7006 assert!(rendered.text.contains(selected));
7007 assert!(!rendered.text.contains(excluded));
7008 assert_eq!(rendered.symbols.len(), 1);
7009 assert_eq!(rendered.symbols[0].code, Some(code));
7010 }
7011 }
7012 for marker in ["commentRangeStart", "commentRangeEnd"] {
7013 let main = format!(
7014 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:{marker} w:id="4"/></w:pPr><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#
7015 );
7016 let archive = docx_archive_with_parts(&[
7017 (DOCX_DOCUMENT_PART, main.as_bytes()),
7018 ("word/_rels/document.xml.rels", rels),
7019 ("word/comments.xml", comments),
7020 ]);
7021 assert!(matches!(
7022 extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7023 Err(DocumentExtractionError::Malformed { message, .. })
7024 if message.contains("comment range")
7025 ));
7026 }
7027 }
7028
7029 #[test]
7030 fn docx_utf16_xml_is_decoded_before_wordprocessing_parsing() {
7031 let xml = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7032 for little_endian in [true, false] {
7033 for bom in [true, false] {
7034 let mut bytes = Vec::new();
7035 for unit in bom.then_some(0xfeff).into_iter().chain(xml.encode_utf16()) {
7036 bytes.extend_from_slice(&if little_endian {
7037 unit.to_le_bytes()
7038 } else {
7039 unit.to_be_bytes()
7040 });
7041 }
7042 assert_eq!(
7043 parse_docx(&bytes, &control(), IndexWorkStage::TextIndex)
7044 .expect("valid UTF-16 DOCX XML")
7045 .text,
7046 "Text"
7047 );
7048 }
7049 }
7050 let leading_whitespace = xml.split_once("?>").expect("fixture declaration").1;
7051 for little_endian in [true, false] {
7052 let mut bytes = Vec::new();
7053 for unit in format!(" \t\n{leading_whitespace}").encode_utf16() {
7054 bytes.extend_from_slice(&if little_endian {
7055 unit.to_le_bytes()
7056 } else {
7057 unit.to_be_bytes()
7058 });
7059 }
7060 assert_eq!(
7061 parse_docx(&bytes, &control(), IndexWorkStage::TextIndex)
7062 .expect("BOM-less UTF-16 accepts leading XML whitespace")
7063 .text,
7064 "Text"
7065 );
7066 }
7067 assert!(matches!(
7068 parse_docx(
7069 xml.replace("UTF-16", "ISO-8859-1").as_bytes(),
7070 &control(),
7071 IndexWorkStage::TextIndex
7072 ),
7073 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7074 ));
7075 for encoding in ["UTF-8", "US-ASCII"] {
7076 assert!(
7077 parse_docx(
7078 xml.replace("UTF-16", encoding).as_bytes(),
7079 &control(),
7080 IndexWorkStage::TextIndex
7081 )
7082 .is_ok()
7083 );
7084 }
7085 }
7086
7087 #[test]
7088 fn docx_xml_declarations_must_match_story_and_package_bytes() {
7089 let story = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7090 assert!(matches!(
7091 parse_docx(story.as_bytes(), &control(), IndexWorkStage::TextIndex),
7092 Err(DocumentExtractionError::Malformed { .. })
7093 ));
7094 for (little_endian, wrong_encoding) in
7095 [(true, "UTF-16BE"), (false, "UTF-16LE"), (true, "UTF-8")]
7096 {
7097 let mut encoded = vec![];
7098 for unit in story.replace("UTF-16", wrong_encoding).encode_utf16() {
7099 encoded.extend_from_slice(&if little_endian {
7100 unit.to_le_bytes()
7101 } else {
7102 unit.to_be_bytes()
7103 });
7104 }
7105 assert!(matches!(
7106 parse_docx(&encoded, &control(), IndexWorkStage::TextIndex),
7107 Err(DocumentExtractionError::Malformed { .. })
7108 ));
7109 }
7110 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7111 let manifest = br#"<?xml version="1.0" encoding="ISO-8859-1"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
7112 let archive = docx_archive_with_parts(&[
7113 ("[Content_Types].xml", manifest),
7114 (DOCX_DOCUMENT_PART, main),
7115 ]);
7116 assert!(matches!(
7117 extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7118 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7119 ));
7120 let root_rels = br#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
7121 let archive =
7122 docx_archive_with_parts(&[("_rels/.rels", root_rels), (DOCX_DOCUMENT_PART, main)]);
7123 assert!(matches!(
7124 extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7125 Err(DocumentExtractionError::Malformed { .. })
7126 ));
7127 let late_story = format!(" <!--before-->{story}");
7128 assert!(matches!(
7129 parse_docx(late_story.as_bytes(), &control(), IndexWorkStage::TextIndex),
7130 Err(DocumentExtractionError::Malformed { .. })
7131 ));
7132 let late_manifest = format!(" <!--before-->{}", String::from_utf8_lossy(manifest));
7133 let archive = docx_archive_with_parts(&[
7134 ("[Content_Types].xml", late_manifest.as_bytes()),
7135 (DOCX_DOCUMENT_PART, main),
7136 ]);
7137 assert!(
7138 extract_document_text_controlled(&archive, "metadata.docx", None, &control()).is_err()
7139 );
7140 let settings_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7141 let late_settings = br#" <!--before--><?xml version="1.0" encoding="UTF-16"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#;
7142 let archive = docx_archive_with_parts(&[
7143 (DOCX_DOCUMENT_PART, main),
7144 ("word/_rels/document.xml.rels", settings_rels),
7145 ("word/settings.xml", late_settings),
7146 ]);
7147 assert!(matches!(
7148 extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7149 Err(DocumentExtractionError::Malformed { .. })
7150 ));
7151 }
7152
7153 #[test]
7154 fn docx_direct_hidden_runs_do_not_emit_text_symbols_or_references() {
7155 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:t>Secret</w:t><w:sym w:font="Wingdings" w:char="F03A"/><w:footnoteReference w:id="1"/></w:r><w:r><w:rPr><w:vanish w:val="false"/></w:rPr><w:sym w:font="Symbol" w:char="F061"/></w:r><w:r><w:rPr><w:webHidden/></w:rPr><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7156 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
7157 .expect("directly hidden runs do not contribute rendered payload");
7158 assert_eq!(facts.text, "αVisible");
7159 assert_eq!(facts.symbols.len(), 1);
7160 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7161 assert!(matches!(
7162 &facts.symbols[0].locator,
7163 DocumentLocator::Docx { run: 2, .. }
7164 ));
7165 let invalid = String::from_utf8_lossy(xml).replace("w:val=\"false\"", "w:val=\"maybe\"");
7166 assert!(matches!(
7167 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7168 Err(DocumentExtractionError::Malformed { .. })
7169 ));
7170 for (value, visible) in [
7171 ("", false),
7172 (" w:val=\"true\"", false),
7173 (" w:val=\"1\"", false),
7174 (" w:val=\"on\"", false),
7175 (" w:val=\"false\"", true),
7176 (" w:val=\"0\"", true),
7177 (" w:val=\"off\"", true),
7178 ] {
7179 let xml = format!(
7180 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr><w:vanish{value}/></w:rPr><w:t>Text</w:t></w:r></w:p></w:body></w:document>"
7181 );
7182 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7183 .expect("valid on/off run visibility");
7184 assert_eq!(facts.text == "Text", visible, "{value}");
7185 }
7186 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="note" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
7187 let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
7188 let archive = docx_archive_with_parts(&[
7189 (DOCX_DOCUMENT_PART, xml),
7190 ("word/_rels/document.xml.rels", rels),
7191 ("word/footnotes.xml", notes),
7192 ]);
7193 let facts =
7194 extract_document_text_controlled(&archive, "hidden-note.docx", None, &control())
7195 .expect("a hidden note reference does not reach the note story");
7196 assert_eq!(facts.text, "αVisible");
7197 assert_eq!(facts.symbols.len(), 1);
7198 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7199 }
7200
7201 #[test]
7202 fn docx_hidden_outer_run_does_not_publish_nested_text_box() {
7203 let main = format!(
7204 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr>{}</w:r><w:r><w:t>Visible</w:t></w:r></w:p><w:p><w:r><w:t>Following</w:t></w:r></w:p></w:body></w:document>",
7205 docx_drawing_text_box(
7206 "<w:p><w:r><w:t>Hidden</w:t><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/><w:footnoteReference w:id=\"1\"/></w:r></w:p>",
7207 1
7208 )
7209 );
7210 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="note" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
7211 let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
7212 let archive = docx_archive_with_parts(&[
7213 (DOCX_DOCUMENT_PART, main.as_bytes()),
7214 ("word/_rels/document.xml.rels", rels),
7215 ("word/footnotes.xml", notes),
7216 ]);
7217 let facts = extract_document_text_controlled(&archive, "hidden-box.docx", None, &control())
7218 .expect("a hidden outer run hides its text box and note references");
7219 assert_eq!(facts.text, "Visible\nFollowing");
7220 assert!(facts.symbols.is_empty());
7221 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7222 assert!(matches!(
7223 &facts.facts[1].locator,
7224 DocumentLocator::Docx { paragraph: 3, .. }
7225 ));
7226 }
7227
7228 #[test]
7229 fn docx_hidden_field_markers_keep_cached_result_state() {
7230 let template = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:instrText>PAGE</w:instrText><w:t>INSTRUCTION</w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="separate"/></w:r>CACHE<w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7231 for (cached, expected, partial) in [("<w:r><w:t>7</w:t></w:r>", "7", false), ("", "", true)]
7232 {
7233 let xml = template.replace("CACHE", cached);
7234 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7235 .expect("hidden field markers preserve field structure");
7236 assert_eq!(facts.text, expected);
7237 assert_eq!(
7238 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField)),
7239 partial
7240 );
7241 }
7242 }
7243
7244 #[test]
7245 fn docx_style_dependent_visibility_is_partial() {
7246 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:rStyle w:val="Secret"/></w:rPr><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:body></w:document>"#;
7247 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="styles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
7248 let styles = br#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="character" w:styleId="Secret"><w:rPr><w:vanish/></w:rPr></w:style></w:styles>"#;
7249 let archive = docx_archive_with_parts(&[
7250 (DOCX_DOCUMENT_PART, main),
7251 ("word/_rels/document.xml.rels", rels),
7252 ("word/styles.xml", styles),
7253 ]);
7254 let facts = extract_document_text_controlled(&archive, "styled.docx", None, &control())
7255 .expect("unresolved style visibility is not a package error");
7256 assert!(
7257 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnresolvedVisibility))
7258 );
7259 }
7260
7261 #[test]
7262 fn docx_spec_vanish_without_direct_vanish_is_renderer_dependent() {
7263 for (properties, text, partial) in [
7264 ("<w:specVanish/>", "Text", true),
7265 ("<w:specVanish/><w:vanish/>", "", false),
7266 ("<w:vanish/><w:specVanish/>", "", false),
7267 ] {
7268 let xml = format!(
7269 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr>{properties}</w:rPr><w:t>Text</w:t></w:r></w:p></w:body></w:document>"
7270 );
7271 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7272 .expect("specVanish is not a package error");
7273 assert_eq!(facts.text, text);
7274 assert_eq!(
7275 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)),
7276 partial
7277 );
7278 }
7279 }
7280
7281 #[test]
7282 fn docx_foreign_text_is_unsupported_without_hiding_word_text_boxes() {
7283 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMath><m:r><m:t>CONTENT</m:t></m:r></m:oMath></w:p></w:body></w:document>"#;
7284 for content in ["x", "<![CDATA[x]]>", "x"] {
7285 assert!(matches!(
7286 parse_docx(
7287 xml.replace("CONTENT", content).as_bytes(),
7288 &control(),
7289 IndexWorkStage::TextIndex
7290 ),
7291 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7292 ));
7293 }
7294 assert!(matches!(
7295 parse_docx(
7296 xml.replace("CONTENT", "&unknown;").as_bytes(),
7297 &control(),
7298 IndexWorkStage::TextIndex
7299 ),
7300 Err(DocumentExtractionError::Malformed { .. })
7301 ));
7302 assert!(
7303 parse_docx(
7304 xml.replace("CONTENT", " ").as_bytes(),
7305 &control(),
7306 IndexWorkStage::TextIndex
7307 )
7308 .is_ok()
7309 );
7310 for content in [
7311 "<w:sym w:font=\"Symbol\" w:char=\"F061\"/>",
7312 "<w:t>Secret</w:t>",
7313 "<w:txbxContent><w:p><w:r><w:t>Secret</w:t></w:r></w:p></w:txbxContent>",
7314 ] {
7315 let opaque = format!(
7316 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:x=\"urn:opaque\"><w:body><w:p><w:r><x:opaque>{content}</x:opaque></w:r></w:p></w:body></w:document>"
7317 );
7318 assert!(matches!(
7319 parse_docx(opaque.as_bytes(), &control(), IndexWorkStage::TextIndex),
7320 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7321 ));
7322 }
7323 for (word, drawing, inline) in [
7324 (
7325 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
7326 "http://schemas.openxmlformats.org/drawingml/2006/main",
7327 "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
7328 ),
7329 (
7330 "http://purl.oclc.org/ooxml/wordprocessingml/main",
7331 "http://purl.oclc.org/ooxml/drawingml/main",
7332 "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing",
7333 ),
7334 ] {
7335 let xml = format!(
7336 "<w:document xmlns:w=\"{word}\" xmlns:a=\"{drawing}\" xmlns:wp=\"{inline}\"><w:body><w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><w:txbxContent><w:p><w:r><w:t>Box</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"
7337 );
7338 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7339 .expect("Word text inside recognized drawing wrappers");
7340 assert_eq!(facts.text, "Boxα\n");
7341 assert_eq!(facts.symbols.len(), 1);
7342 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7343 }
7344 }
7345
7346 #[test]
7347 fn docx_deleted_revisions_do_not_publish_run_content() {
7348 for revision in ["del", "moveFrom"] {
7349 let xml = format!(
7350 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t></w:r><w:{revision}><w:r><w:delText>Removed</w:delText><w:fldChar w:fldCharType=\"begin\"/><w:sym w:font=\"Wingdings\" w:char=\"F020\"/><w:noBreakHyphen/><w:softHyphen/><w:tab/><w:ptab/><w:br/><w:cr/><w:lastRenderedPageBreak/></w:r><w:del><w:r><w:t>Nested</w:t></w:r></w:del></w:{revision}><w:r><w:t>After</w:t></w:r></w:p></w:body></w:document>"
7351 );
7352 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7353 .expect("tracked revision content is valid XML");
7354 assert!(matches!(
7355 parse_docx(
7356 xml.replace("Removed", "&unknown;").as_bytes(),
7357 &control(),
7358 IndexWorkStage::TextIndex
7359 ),
7360 Err(DocumentExtractionError::Malformed { .. })
7361 ));
7362 assert_eq!(parsed.text, "BeforeAfter", "{revision}");
7363 assert_eq!(parsed.facts.len(), 2);
7364 assert_eq!(parsed.facts[1].line_start, 1);
7365 assert_eq!(parsed.facts[1].line_end, 1);
7366 assert_eq!(
7367 parsed.facts[1].locator,
7368 DocumentLocator::Docx {
7369 part: DOCX_DOCUMENT_PART.to_owned(),
7370 paragraph: 1,
7371 run: 4,
7372 text_start: 0,
7373 text_end: 5,
7374 }
7375 );
7376 }
7377 }
7378
7379 #[test]
7380 fn docx_dynamic_text_blocks_refuse_without_evaluation() {
7381 let template = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><w:body><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>7</w:t></w:r></w:fldSimple>BLOCK</w:p></w:body></w:document>"#;
7382 assert_eq!(
7383 parse_docx(
7384 template.replace("BLOCK", "").as_bytes(),
7385 &control(),
7386 IndexWorkStage::TextIndex
7387 )
7388 .expect("cached field result remains literal text")
7389 .text,
7390 "7"
7391 );
7392 for name in [
7393 "pgNum",
7394 "dayShort",
7395 "dayLong",
7396 "monthShort",
7397 "monthLong",
7398 "yearShort",
7399 "yearLong",
7400 "footnoteReference",
7401 "endnoteReference",
7402 ] {
7403 let attributes = if matches!(name, "footnoteReference" | "endnoteReference") {
7404 " w:id=\"1\""
7405 } else {
7406 ""
7407 };
7408 for element in [
7409 format!("<w:{name}{attributes}/>"),
7410 format!("<w:{name}{attributes}></w:{name}>"),
7411 ] {
7412 let run = format!("<w:r>{element}</w:r>");
7413 let facts = parse_docx(
7414 template.replace("BLOCK", &run).as_bytes(),
7415 &control(),
7416 IndexWorkStage::TextIndex,
7417 )
7418 .expect("dynamic marker retains surrounding text with partial coverage");
7419 assert!(facts.text.contains('\u{fffc}'), "{name}");
7420 assert!(
7421 matches!(facts.completeness, DocumentCompleteness::Partial { .. }),
7422 "{name}"
7423 );
7424 for discarded in [
7425 format!("<w:del>{run}</w:del>"),
7426 format!("<w:moveFrom>{run}</w:moveFrom>"),
7427 format!(
7428 r#"<mc:AlternateContent><mc:Choice Requires="future">{run}</mc:Choice><mc:Fallback/></mc:AlternateContent>"#
7429 ),
7430 ] {
7431 assert_eq!(
7432 parse_docx(
7433 template.replace("BLOCK", &discarded).as_bytes(),
7434 &control(),
7435 IndexWorkStage::TextIndex
7436 )
7437 .expect("discarded dynamic text does not require evaluation")
7438 .text,
7439 "7",
7440 "{name}"
7441 );
7442 }
7443 }
7444 }
7445 }
7446
7447 #[test]
7448 fn docx_field_carriers_retain_only_literal_and_cached_text() {
7449 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t xml:space="preserve">Page </w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE & <![CDATA[ignored]]></w:instrText><w:t>NOT RENDERED</w:t></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:instrText xml:space="preserve"> Literal</w:instrText></w:r><w:r><w:delInstrText>Deleted code</w:delInstrText><w:delText><![CDATA[Deleted text]]></w:delText></w:r></w:p></w:body></w:document>"#;
7450 let parsed = extract_document_text_controlled(
7451 &docx_archive(xml, CompressionMethod::Deflated),
7452 "fields.docx",
7453 None,
7454 &control(),
7455 )
7456 .expect("field instructions and deleted carriers are valid bounded XML");
7457 assert_eq!(parsed.text, "Page 7 Literal");
7458 assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
7459 let escaped_markers = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>7</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7460 let escaped = parse_docx(escaped_markers, &control(), IndexWorkStage::TextIndex)
7461 .expect("field marker values may contain XML character references");
7462 assert_eq!(escaped.text, "7");
7463 assert_eq!(escaped.completeness, DocumentCompleteness::Complete);
7464 let spaced_field = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>DATE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t xml:space="preserve"> </w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7465 let spaced = parse_docx(spaced_field, &control(), IndexWorkStage::TextIndex)
7466 .expect("retained whitespace is a cached field result");
7467 assert_eq!(spaced.text, " ");
7468 assert_eq!(spaced.completeness, DocumentCompleteness::Complete);
7469 for field in [
7470 "<w:fldSimple w:instr=\"PAGE\"/>",
7471 "<w:r><w:fldChar w:fldCharType=\"begin\"/></w:r><w:r><w:instrText> DATE </w:instrText></w:r><w:r><w:fldChar w:fldCharType=\"separate\"/></w:r><w:r><w:fldChar w:fldCharType=\"end\"/></w:r>",
7472 ] {
7473 let xml = format!(
7474 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t></w:r>{field}<w:r><w:t>After</w:t></w:r></w:p></w:body></w:document>"
7475 );
7476 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7477 .expect("live field without cached text retains surrounding text");
7478 assert_eq!(parsed.text, "BeforeAfter");
7479 assert!(
7480 matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField)),
7481 "{field}"
7482 );
7483 }
7484 assert_eq!(parsed.facts.len(), 3);
7485 for (fact, (run, text)) in
7486 parsed
7487 .facts
7488 .iter()
7489 .zip([(1, "Page "), (5, "7"), (7, " Literal")])
7490 {
7491 assert_eq!(fact.text, text);
7492 assert_eq!(
7493 fact.locator,
7494 DocumentLocator::Docx {
7495 part: DOCX_DOCUMENT_PART.to_owned(),
7496 paragraph: 1,
7497 run,
7498 text_start: 0,
7499 text_end: text.len(),
7500 }
7501 );
7502 }
7503 let nested = format!(
7504 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:fldChar w:fldCharType=\"begin\"/><w:instrText>OUTER</w:instrText>{}<w:fldChar w:fldCharType=\"begin\"/><w:instrText>INNER</w:instrText><w:fldChar w:fldCharType=\"separate\"/><w:instrText>Outer code remains ignored</w:instrText><w:fldChar w:fldCharType=\"end\"/><w:fldChar w:fldCharType=\"separate\"/><w:t>Result</w:t><w:fldChar w:fldCharType=\"end\"/></w:r></w:p></w:body></w:document>",
7505 docx_drawing_text_box("<w:p><w:r><w:instrText>Box</w:instrText></w:r></w:p>", 1)
7506 );
7507 let nested = parse_docx(nested.as_bytes(), &control(), IndexWorkStage::TextIndex)
7508 .expect("text boxes inside field instructions do not publish code text");
7509 assert_eq!(nested.text, "Result");
7510 assert_eq!(nested.facts.len(), 1);
7511 assert_eq!(nested.completeness, DocumentCompleteness::Complete);
7512 let simple_in_instruction = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r><w:fldSimple w:instr="PAGE"/><w:r><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7513 let simple = parse_docx(simple_in_instruction, &control(), IndexWorkStage::TextIndex)
7514 .expect("simple fields inside an outer instruction cannot render");
7515 assert_eq!(simple.text, "Result");
7516 assert_eq!(simple.completeness, DocumentCompleteness::Complete);
7517 let selected_simple = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><mc:AlternateContent><mc:Choice Requires="w"><w:fldSimple w:instr="PAGE"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:p></w:body></w:document>"#;
7518 let selected = parse_docx(selected_simple, &control(), IndexWorkStage::TextIndex)
7519 .expect("selected compatibility content preserves valid paragraph fields");
7520 assert!(
7521 matches!(selected.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField))
7522 );
7523 let symbol_field = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:sym w:font="Wingdings" w:char="F03A"/><w:fldChar w:fldCharType="separate"/><w:sym w:font="Symbol" w:char="F061"/><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7524 let symbol_field = parse_docx(symbol_field, &control(), IndexWorkStage::TextIndex)
7525 .expect("only the cached field result is rendered");
7526 assert_eq!(symbol_field.text, "α");
7527 assert_eq!(symbol_field.symbols.len(), 1);
7528 assert_eq!(symbol_field.completeness, DocumentCompleteness::Complete);
7529 let instruction_refs = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>FIELD</w:instrText><w:footnoteReference w:id="2"/><w:endnoteReference w:id="3"/><w:commentReference w:id="4"/><w:commentRangeStart w:id="4"/><w:commentRangeEnd w:id="4"/><w:pgNum/><w:tab/><w:fldChar w:fldCharType="separate"/><w:t>Visible</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7530 let mut references = DocxStoryReferences::default();
7531 let instruction_refs = parse_docx_part(
7532 instruction_refs,
7533 DOCX_DOCUMENT_PART,
7534 "document",
7535 None,
7536 None,
7537 &mut references,
7538 &control(),
7539 IndexWorkStage::TextIndex,
7540 )
7541 .expect("field instructions cannot publish referenced stories or dynamic text");
7542 assert_eq!(instruction_refs.text, "Visible");
7543 assert!(instruction_refs.symbols.is_empty());
7544 assert!(references.items.is_empty());
7545 assert_eq!(
7546 instruction_refs.completeness,
7547 DocumentCompleteness::Complete
7548 );
7549 let text = std::str::from_utf8(xml).expect("UTF-8 fixture");
7550 for invalid in [
7551 text.replace("PAGE & <![CDATA[ignored]]>", "<w:t>nested</w:t>"),
7552 text.replace("PAGE & <![CDATA[ignored]]>", "&unknown;"),
7553 text.replace("<w:p>", "<w:p><w:instrText>outside run</w:instrText>"),
7554 ] {
7555 assert!(matches!(
7556 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7557 Err(DocumentExtractionError::Malformed { .. })
7558 ));
7559 }
7560 for invalid in [
7561 text.replace("<w:body>", "<w:body><w:fldSimple w:instr=\"PAGE\"/>"),
7562 text.replace(
7563 "<w:p>",
7564 "<w:p><w:pPr><w:fldSimple w:instr=\"PAGE\"/></w:pPr>",
7565 ),
7566 ] {
7567 assert!(matches!(
7568 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7569 Err(DocumentExtractionError::Malformed { .. })
7570 ));
7571 assert!(matches!(
7572 extract_document_text_controlled(
7573 &docx_archive(invalid.as_bytes(), CompressionMethod::Deflated),
7574 "invalid-field.docx",
7575 None,
7576 &control(),
7577 ),
7578 Err(DocumentExtractionError::Malformed { .. })
7579 ));
7580 }
7581 let deep = format!(
7582 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}</w:r></w:p></w:body></w:document>",
7583 "<w:fldChar w:fldCharType=\"begin\"/>".repeat(MAX_DOCX_XML_DEPTH + 1),
7584 );
7585 assert!(matches!(
7586 parse_docx(deep.as_bytes(), &control(), IndexWorkStage::TextIndex),
7587 Err(DocumentExtractionError::ResourceLimit {
7588 limit: DocumentLimit::NestingDepth,
7589 ..
7590 })
7591 ));
7592 }
7593
7594 #[test]
7595 fn docx_field_result_in_text_box_is_cached_only_when_content_is_retained() {
7596 for (content, retained) in [
7597 ("<w:t>7</w:t>", true),
7598 ("<w:sym w:font=\"Symbol\" w:char=\"F061\"/>", true),
7599 ("", false),
7600 ] {
7601 let xml = format!(
7602 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:fldChar w:fldCharType=\"begin\"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType=\"separate\"/>{}<w:fldChar w:fldCharType=\"end\"/></w:r></w:p></w:body></w:document>",
7603 docx_drawing_text_box(&format!("<w:p><w:r>{content}</w:r></w:p>"), 1)
7604 );
7605 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7606 .expect("a text box can contain a field's cached result");
7607 assert_eq!(
7608 parsed.completeness == DocumentCompleteness::Complete,
7609 retained,
7610 "{content}"
7611 );
7612 assert_eq!(parsed.facts.is_empty(), !retained, "{content}");
7613 }
7614 }
7615
7616 #[test]
7617 fn docx_root_ignorable_policy_preserves_visible_text_and_refuses_other_policies() {
7618 use std::fmt::Write as _;
7619 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future" mc:Ignorable="future"><w:body><future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7620 for source in [
7621 xml.to_owned(),
7622 xml.replace("<future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper>", ""),
7623 xml.replace("mc:Ignorable=\"future\"", &format!("mc:Ignorable=\"{}\"", "future ".repeat(128))),
7624 xml.replace("<future:wrapper>", "<mc:AlternateContent><mc:Choice Requires=\"future\" mc:ProcessContent=\"future:wrapper\"><future:wrapper>").replace("</future:wrapper>", "</future:wrapper></mc:Choice><mc:Fallback/></mc:AlternateContent>"),
7625 xml.replace(
7626 "<future:wrapper>",
7627 "<alias:wrapper xmlns:alias=\"urn:future\">",
7628 )
7629 .replace("</future:wrapper>", "</alias:wrapper>"),
7630 xml.replace(
7631 "mc:Ignorable=\"future\"",
7632 "mc:Ignorable=\"future future w\"",
7633 ),
7634 xml.replace(
7635 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
7636 "http://purl.oclc.org/ooxml/wordprocessingml/main",
7637 ),
7638 xml.replace("mc:", "compat:")
7639 .replace("xmlns:mc=", "xmlns:compat="),
7640 ] {
7641 let parsed = parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex)
7642 .expect("ignore only unknown root-policy namespaces");
7643 assert_eq!(parsed.text, "Visible");
7644 assert_eq!(parsed.facts.len(), 1);
7645 assert!(matches!(
7646 parsed.facts[0].locator,
7647 DocumentLocator::Docx {
7648 paragraph: 1,
7649 run: 1,
7650 text_start: 0,
7651 text_end: 7,
7652 ..
7653 }
7654 ));
7655 }
7656 let deleted = xml
7657 .replace("<future:wrapper>", "<w:del mc:Ignorable=\"future\">")
7658 .replace("</future:wrapper>", "</w:del>");
7659 let parsed = parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
7660 .expect("deleted policies do not affect live text");
7661 assert_eq!(parsed.text, "Visible");
7662 assert!(matches!(
7663 parsed.facts[0].locator,
7664 DocumentLocator::Docx {
7665 paragraph: 2,
7666 run: 1,
7667 text_start: 0,
7668 text_end: 7,
7669 ..
7670 }
7671 ));
7672 for source in [
7673 xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"\""),
7674 xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"w\""),
7675 xml.replace(
7676 "<future:wrapper>",
7677 "<future:wrapper xmlns:future=\"urn:different\">",
7678 ),
7679 xml.replace("mc:Ignorable=\"future\"", "Ignorable=\"future\""),
7680 ] {
7681 assert!(matches!(
7682 parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex),
7683 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7684 ));
7685 }
7686 for source in [
7687 xml.replace(
7688 "mc:Ignorable=\"future\"",
7689 "mc:ProcessContent=\"future:wrapper\"",
7690 ),
7691 xml.replace("<future:wrapper>", "<future:wrapper mc:ProcessContent=\"future:child\">"),
7692 xml.replace("<future:wrapper>", "<future:wrapper mc:MustUnderstand=\"future\">"),
7693 xml.replace("<future:wrapper>", "<future:wrapper mc:Ignorable=\"future\">"),
7694 xml.replace("<w:body>", "<w:body><mc:AlternateContent><mc:Choice Requires=\"w\" mc:ProcessContent=\"future:wrapper\">").replace("</w:body>", "</mc:Choice></mc:AlternateContent></w:body>"),
7695 xml.replace("mc:Ignorable=\"future\"", "mc:MustUnderstand=\"future\""),
7696 xml.replace(" mc:Ignorable=\"future\"", "")
7697 .replace("<w:body>", "<w:body mc:Ignorable=\"future\">"),
7698 ] {
7699 assert!(matches!(
7700 parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex),
7701 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7702 ));
7703 }
7704 for policy in ["missing", "future:wrapper", "future\u{a0}w"] {
7705 assert!(matches!(
7706 parse_docx(
7707 xml.replace(
7708 "mc:Ignorable=\"future\"",
7709 &format!("mc:Ignorable=\"{policy}\"")
7710 )
7711 .as_bytes(),
7712 &control(),
7713 IndexWorkStage::TextIndex
7714 ),
7715 Err(DocumentExtractionError::Malformed { .. })
7716 ));
7717 }
7718 for count in [
7719 MAX_DOCX_IGNORABLE_NAMESPACES,
7720 MAX_DOCX_IGNORABLE_NAMESPACES + 1,
7721 ] {
7722 let mut declarations = String::new();
7723 let mut prefixes = String::new();
7724 for index in 0..count {
7725 write!(declarations, "xmlns:n{index}=\"urn:{index}\" ")
7726 .expect("namespace declaration");
7727 write!(prefixes, "n{index} ").expect("namespace prefix");
7728 }
7729 let source = xml
7730 .replace(
7731 "<future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper>",
7732 "",
7733 )
7734 .replace(
7735 "mc:Ignorable=\"future\"",
7736 &format!("{declarations} mc:Ignorable=\"{prefixes}\""),
7737 );
7738 let bytes = docx_archive(source.as_bytes(), CompressionMethod::Deflated);
7739 let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
7740 if count == MAX_DOCX_IGNORABLE_NAMESPACES {
7741 assert_eq!(result.expect("bounded distinct namespaces").text, "Visible");
7742 } else {
7743 assert!(matches!(
7744 result,
7745 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7746 ));
7747 }
7748 }
7749 }
7750
7751 #[test]
7752 fn docx_compatibility_selects_one_understood_branch() {
7753 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><w:body><mc:AlternateContent><mc:Choice Requires="future"><w:p><w:r><w:t>Unsupported</w:t></w:r></w:p></mc:Choice><mc:Choice Requires="w"><w:p><w:r><w:t>Chosen</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>"#;
7754 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7755 .expect("uncertain earlier choice is not guessed");
7756 assert!(parsed.text.is_empty());
7757 assert!(
7758 matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7759 );
7760 let fallback = xml.replace("Requires=\"w\"", "Requires=\"future\"");
7761 let parsed = parse_docx(fallback.as_bytes(), &control(), IndexWorkStage::TextIndex)
7762 .expect("fallback when no choice is understood");
7763 assert!(parsed.text.is_empty());
7764 assert!(
7765 matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7766 );
7767 let nested = xml.replace("<w:t>Chosen</w:t>", "<mc:AlternateContent><mc:Choice Requires=\"future\"><w:instrText>Discarded</w:instrText></mc:Choice><mc:Fallback><w:t>Nested</w:t></mc:Fallback></mc:AlternateContent>");
7768 let parsed = parse_docx(nested.as_bytes(), &control(), IndexWorkStage::TextIndex)
7769 .expect("nested alternatives preserve the containing run");
7770 assert!(parsed.text.is_empty());
7771 for first in [
7772 xml.replace("Requires=\"future\"", "Requires=\"w\""),
7773 xml.replace(
7774 "xmlns:future=\"urn:future\"",
7775 "xmlns:future=\"http://purl.oclc.org/ooxml/wordprocessingml/main\"",
7776 ),
7777 ] {
7778 let parsed = parse_docx(first.as_bytes(), &control(), IndexWorkStage::TextIndex)
7779 .expect("first understood branch and namespace aliases");
7780 assert_eq!(parsed.text, "Unsupported");
7781 assert_eq!(parsed.facts.len(), 1);
7782 }
7783 for invalid in [
7784 xml.replace("Requires=\"future\"", "Requires=\"\""),
7785 xml.replace("Requires=\"w\"", "Requires=\"w\u{a0}w\""),
7786 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><w:p/>"),
7787 xml.replace("</mc:AlternateContent>", "<mc:Fallback/></mc:AlternateContent>"),
7788 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Fallback/>"),
7789 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Choice Requires=\"future\"><w:r><w:t>&unknown;</w:t></w:r></mc:Choice>"),
7790 ] {
7791 assert!(matches!(parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7792 Err(DocumentExtractionError::Malformed { .. })));
7793 }
7794 }
7795
7796 #[test]
7797 fn docx_skipped_compatibility_branch_validates_attribute_namespaces() {
7798 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><mc:AlternateContent><mc:Choice Requires="w"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7799 assert_eq!(
7800 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7801 .expect("valid fallback remains unselected")
7802 .text,
7803 "Selected"
7804 );
7805 for malformed in [
7806 xml.replace("<mc:Fallback>", "<mc:Fallback x:extra=\"1\">"),
7807 xml.replace("<w:t>Ignored", "<w:t x:extra=\"1\">Ignored"),
7808 ] {
7809 assert!(matches!(
7810 parse_docx(malformed.as_bytes(), &control(), IndexWorkStage::TextIndex),
7811 Err(DocumentExtractionError::Malformed { message, .. })
7812 if message.contains("undeclared namespace prefix")
7813 ));
7814 }
7815 }
7816
7817 #[test]
7818 fn docx_ignored_xml_rejects_invalid_entities() {
7819 let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><mc:AlternateContent><mc:Choice Requires="w"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7820 let escaped = xml.replace("<w:body>", "<w:body w:extra=\"A&B\">");
7821 assert_eq!(
7822 parse_docx(escaped.as_bytes(), &control(), IndexWorkStage::TextIndex)
7823 .expect("defined entity in an ignored attribute is valid")
7824 .text,
7825 "Selected"
7826 );
7827 for malformed in [
7828 xml.replace("<w:body>", "<w:body w:extra=\"&undefined;\">"),
7829 xml.replace("<w:body>", "<w:body w:extra=\"�\">"),
7830 xml.replace("<mc:Fallback>", "<mc:Fallback w:extra=\"&undefined;\">"),
7831 xml.replace("<w:t>Ignored", "<w:t w:extra=\"&undefined;\">Ignored"),
7832 ] {
7833 assert!(matches!(
7834 parse_docx(malformed.as_bytes(), &control(), IndexWorkStage::TextIndex),
7835 Err(DocumentExtractionError::Malformed { .. })
7836 ));
7837 }
7838 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
7839 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7840 let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:zoom w:extra="&undefined;"/></w:settings>"#;
7841 let archive = docx_archive_with_parts(&[
7842 (DOCX_DOCUMENT_PART, main),
7843 ("word/_rels/document.xml.rels", rels),
7844 ("word/settings.xml", settings),
7845 ]);
7846 assert!(matches!(
7847 extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7848 Err(DocumentExtractionError::Malformed { .. })
7849 ));
7850 let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:compat>&undefined;</w:compat></w:settings>"#;
7851 let archive = docx_archive_with_parts(&[
7852 (DOCX_DOCUMENT_PART, main),
7853 ("word/_rels/document.xml.rels", rels),
7854 ("word/settings.xml", settings),
7855 ]);
7856 assert!(matches!(
7857 extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7858 Err(DocumentExtractionError::Malformed { .. })
7859 ));
7860 let valid_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:compat>&</w:compat></w:settings>"#;
7861 let archive = docx_archive_with_parts(&[
7862 (DOCX_DOCUMENT_PART, main),
7863 ("word/_rels/document.xml.rels", rels),
7864 ("word/settings.xml", valid_settings),
7865 ]);
7866 extract_document_text_controlled(&archive, "settings.docx", None, &control())
7867 .expect("predefined entity in ignored settings text is valid");
7868 let relationships = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships" extra="&undefined;"/>"#;
7869 assert!(matches!(
7870 parse_docx_relationships(relationships, &control(), IndexWorkStage::TextIndex),
7871 Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7872 ));
7873 let content_types = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types" extra="&undefined;"/>"#;
7874 assert!(matches!(
7875 parse_docx_content_types(content_types, &control(), IndexWorkStage::TextIndex),
7876 Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7877 ));
7878 let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></w:footnote><w:footnote w:id="2" w:extra="&undefined;"/></w:footnotes>"#;
7879 let selected = HashSet::from(["1".to_owned()]);
7880 assert!(matches!(
7881 parse_docx_part(
7882 notes,
7883 "word/footnotes.xml",
7884 "footnotes",
7885 Some(&selected),
7886 None,
7887 &mut DocxStoryReferences::default(),
7888 &control(),
7889 IndexWorkStage::TextIndex,
7890 ),
7891 Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7892 ));
7893 }
7894
7895 #[test]
7896 fn docx_settings_reject_content_outside_its_root() {
7897 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7898 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7899 let settings = "<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:evenAndOddHeaders/></w:settings>";
7900 let archive = |settings: &str| {
7901 docx_archive_with_parts(&[
7902 (DOCX_DOCUMENT_PART, main),
7903 ("word/_rels/document.xml.rels", rels),
7904 ("word/settings.xml", settings.as_bytes()),
7905 ])
7906 };
7907 let internal = settings.replace("</w:settings>", " <![CDATA[ ]]></w:settings>");
7908 extract_document_text_controlled(&archive(&internal), "settings.docx", None, &control())
7909 .expect("structural XML whitespace inside settings remains valid");
7910 for invalid in [
7911 format!("junk{settings}"),
7912 format!("{settings}junk"),
7913 format!(" {settings}"),
7914 format!("{settings} "),
7915 format!("<![CDATA[ ]]>{settings}"),
7916 format!("{settings}<![CDATA[ ]]>"),
7917 ] {
7918 assert!(
7919 matches!(
7920 extract_document_text_controlled(
7921 &archive(&invalid),
7922 "settings.docx",
7923 None,
7924 &control()
7925 ),
7926 Err(DocumentExtractionError::Malformed { .. })
7927 ),
7928 "{invalid}"
7929 );
7930 }
7931 }
7932
7933 #[test]
7934 fn docx_selected_compatibility_run_properties_hide_payload() {
7935 for properties in [
7936 "<mc:AlternateContent><mc:Choice Requires=\"w\"><w:rPr><w:vanish/></w:rPr></mc:Choice><mc:Fallback><w:rPr/></mc:Fallback></mc:AlternateContent>",
7937 "<w:rPr><mc:AlternateContent><mc:Choice Requires=\"w\"><w:vanish/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:rPr>",
7938 ] {
7939 let xml = format!(
7940 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body><w:p><w:r>{properties}<w:t>Secret</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/><w:footnoteReference w:id=\"1\"/></w:r><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"
7941 );
7942 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7943 .expect("selected compatibility formatting hides its run");
7944 assert_eq!(facts.text, "Visible");
7945 assert!(facts.symbols.is_empty());
7946 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7947 }
7948 }
7949
7950 #[test]
7951 fn docx_unknown_compatibility_choice_never_claims_complete_symbol_coverage() {
7952 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><mc:AlternateContent><mc:Choice Requires="wps"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></mc:Choice><mc:Choice Requires="w"><w:p><w:r><w:sym w:font="Symbol" w:char="F063"/></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:sym w:font="Symbol" w:char="F062"/></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7953 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
7954 .expect("fallback remains bounded evidence");
7955 assert!(facts.text.is_empty());
7956 assert!(facts.symbols.is_empty());
7957 assert!(
7958 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7959 );
7960 }
7961
7962 #[test]
7963 fn docx_nested_text_boxes_preserve_run_order_and_locators() {
7964 let xml = format!(
7965 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t>{}<w:t>After</w:t></w:r></w:p><w:p><w:r><w:t>Following</w:t></w:r></w:p></w:body></w:document>",
7966 docx_drawing_text_box("<w:p><w:r><w:t>Inside</w:t></w:r></w:p>", 1)
7967 );
7968 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Stored);
7969 let parsed = extract_document_text_controlled(&bytes, "text-box.docx", None, &control())
7970 .expect("nested text container is valid WordprocessingML");
7971 assert_eq!(parsed.text, "Before\nInside\nAfter\nFollowing");
7972 assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
7973 assert_eq!(parsed.facts.len(), 4);
7974 for (fact, (text, paragraph, start, end, line)) in parsed.facts.iter().zip([
7975 ("Before", 1, 0, 6, 1),
7976 ("Inside", 2, 0, 6, 2),
7977 ("After", 1, 6, 11, 3),
7978 ("Following", 3, 0, 9, 4),
7979 ]) {
7980 assert_eq!(fact.text, text);
7981 assert_eq!((fact.line_start, fact.line_end), (line, line));
7982 assert_eq!(
7983 fact.locator,
7984 DocumentLocator::Docx {
7985 part: DOCX_DOCUMENT_PART.to_owned(),
7986 paragraph,
7987 run: 1,
7988 text_start: start,
7989 text_end: end,
7990 }
7991 );
7992 }
7993 }
7994
7995 #[test]
7996 fn docx_separate_text_boxes_have_distinct_symbol_locators() {
7997 let content = "<w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>";
7998 let xml = format!(
7999 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}{}</w:r></w:p></w:body></w:document>",
8000 docx_drawing_text_box(content, 1),
8001 docx_drawing_text_box(content, 2)
8002 );
8003 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
8004 .expect("both text boxes have rendered symbols");
8005 assert_eq!(facts.symbols.len(), 2);
8006 assert_ne!(facts.symbols[0].locator, facts.symbols[1].locator);
8007 }
8008
8009 #[test]
8010 fn docx_note_item_locators_stay_distinct_across_incremental_parses() {
8011 let xml = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote></w:footnotes>"#;
8012 let parse = |id: &str| {
8013 parse_docx_part(
8014 xml,
8015 "word/footnotes.xml",
8016 "footnotes",
8017 Some(&HashSet::from([id.to_owned()])),
8018 None,
8019 &mut DocxStoryReferences::default(),
8020 &control(),
8021 IndexWorkStage::TextIndex,
8022 )
8023 };
8024 let first = parse("1").expect("first note");
8025 let second = parse("2").expect("second note");
8026 assert_ne!(first.symbols[0].locator, second.symbols[0].locator);
8027 }
8028
8029 #[test]
8030 fn docx_story_ids_match_decimal_value_and_reject_numeric_duplicates() {
8031 for (kind, reference, root, item) in [
8032 ("footnotes", "footnoteReference", "footnotes", "footnote"),
8033 ("endnotes", "endnoteReference", "endnotes", "endnote"),
8034 ("comments", "commentReference", "comments", "comment"),
8035 ] {
8036 let marker = if kind == "comments" {
8037 "<w:commentRangeStart w:id=\"01\"/><w:r><w:commentReference w:id=\"+01\"/></w:r>"
8038 .to_owned()
8039 } else {
8040 format!("<w:r><w:{reference} w:id=\"+01\"/></w:r>")
8041 };
8042 let main = format!(
8043 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p>{marker}</w:p></w:body></w:document>"
8044 );
8045 let rels = format!(
8046 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"story\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}\" Target=\"{kind}.xml\"/></Relationships>"
8047 );
8048 let story = format!(
8049 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\"><w:p><w:r><w:t>Matched</w:t></w:r></w:p></w:{item}></w:{root}>"
8050 );
8051 let part = format!("word/{kind}.xml");
8052 let package = |story: &str| {
8053 docx_archive_with_parts(&[
8054 (DOCX_DOCUMENT_PART, main.as_bytes()),
8055 ("word/_rels/document.xml.rels", rels.as_bytes()),
8056 (&part, story.as_bytes()),
8057 ])
8058 };
8059 let facts = extract_document_text_controlled(
8060 &package(&story),
8061 "numeric-ids.docx",
8062 None,
8063 &control(),
8064 )
8065 .expect("decimal-equivalent story reference resolves");
8066 assert!(facts.text.contains("Matched"), "missing {kind} item");
8067 let duplicate = story.replace(
8068 &format!("</w:{root}>"),
8069 &format!("<w:{item} w:id=\"001\"/></w:{root}>"),
8070 );
8071 assert!(matches!(
8072 extract_document_text_controlled(
8073 &package(&duplicate),
8074 "duplicate-ids.docx",
8075 None,
8076 &control(),
8077 ),
8078 Err(DocumentExtractionError::Malformed { .. })
8079 ));
8080 }
8081 for (raw, canonical) in [("+0001", "1"), ("-00", "0"), (" -001 ", "-1")] {
8082 assert_eq!(
8083 canonical_docx_story_id(raw).expect("valid XML decimal story ID"),
8084 canonical
8085 );
8086 }
8087 for raw in ["", "+", "1.0", "1e0", "1 0"] {
8088 assert!(matches!(
8089 canonical_docx_story_id(raw),
8090 Err(DocumentExtractionError::Malformed { .. })
8091 ));
8092 }
8093 }
8094
8095 #[test]
8096 fn docx_compatibility_wrapped_story_items_follow_selected_ids() {
8097 for (root, item) in [
8098 ("footnotes", "footnote"),
8099 ("endnotes", "endnote"),
8100 ("comments", "comment"),
8101 ] {
8102 let xml = format!(
8103 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:AlternateContent><mc:Choice Requires=\"w\"><w:{item} w:id=\"1\"><w:p><w:r><w:t>Wrapped</w:t></w:r></w:p></w:{item}></mc:Choice><mc:Fallback><w:{item} w:id=\"1\"><w:p><w:r><w:t>Unselected</w:t></w:r></w:p></w:{item}></mc:Fallback></mc:AlternateContent><w:{item} w:id=\"2\"><w:p><w:r><w:t>Ordinary</w:t></w:r></w:p></w:{item}></w:{root}>"
8104 );
8105 let parse = |id: &str| {
8106 parse_docx_part(
8107 xml.as_bytes(),
8108 "word/story.xml",
8109 root,
8110 Some(&HashSet::from([id.to_owned()])),
8111 None,
8112 &mut DocxStoryReferences::default(),
8113 &control(),
8114 IndexWorkStage::TextIndex,
8115 )
8116 };
8117 let selected = parse("1").expect("wrapped selected story item is found");
8118 assert_eq!(selected.text, "Wrapped", "{root}");
8119 let ordinary = parse("2").expect("unreferenced wrapped item is skipped");
8120 assert_eq!(ordinary.text, "Ordinary", "{root}");
8121 }
8122 }
8123
8124 #[test]
8125 fn docx_story_roots_reject_non_item_content() {
8126 for (root, item) in [
8127 ("footnotes", "footnote"),
8128 ("endnotes", "endnote"),
8129 ("comments", "comment"),
8130 ] {
8131 for wrapped in [false, true] {
8132 let rogue = "<w:p><w:r><w:t>Rogue</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>";
8133 let rogue = if wrapped {
8134 format!(
8135 "<mc:AlternateContent><mc:Choice Requires=\"w\">{rogue}</mc:Choice><mc:Fallback/></mc:AlternateContent>"
8136 )
8137 } else {
8138 rogue.to_owned()
8139 };
8140 let xml = format!(
8141 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\">{rogue}<w:{item} w:id=\"1\"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></w:{item}></w:{root}>"
8142 );
8143 assert!(
8144 matches!(
8145 parse_docx_part(
8146 xml.as_bytes(),
8147 "word/story.xml",
8148 root,
8149 Some(&HashSet::from(["1".to_owned()])),
8150 None,
8151 &mut DocxStoryReferences::default(),
8152 &control(),
8153 IndexWorkStage::TextIndex,
8154 ),
8155 Err(DocumentExtractionError::Malformed { .. })
8156 ),
8157 "{root}, wrapped={wrapped}"
8158 );
8159 }
8160 }
8161 }
8162
8163 #[test]
8164 fn docx_story_items_reject_nested_items() {
8165 for (root, item) in [
8166 ("footnotes", "footnote"),
8167 ("endnotes", "endnote"),
8168 ("comments", "comment"),
8169 ] {
8170 for nested in ["footnote", "endnote", "comment"] {
8171 let xml = format!(
8172 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\"><w:{nested} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{nested}></w:{item}></w:{root}>"
8173 );
8174 assert!(
8175 matches!(
8176 parse_docx_part(
8177 xml.as_bytes(),
8178 "word/story.xml",
8179 root,
8180 Some(&HashSet::from(["1".to_owned()])),
8181 None,
8182 &mut DocxStoryReferences::default(),
8183 &control(),
8184 IndexWorkStage::TextIndex,
8185 ),
8186 Err(DocumentExtractionError::Malformed { .. })
8187 ),
8188 "{root}/{nested}"
8189 );
8190 }
8191 }
8192 for item in ["footnote", "endnote", "comment"] {
8193 let xml = format!(
8194 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:{item} w:id=\"1\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}></w:body></w:document>"
8195 );
8196 assert!(
8197 matches!(
8198 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8199 Err(DocumentExtractionError::Malformed { .. })
8200 ),
8201 "document/{item}"
8202 );
8203 }
8204 }
8205
8206 #[test]
8207 fn docx_wrapped_continuation_note_does_not_claim_later_text() {
8208 for (root, item) in [("footnotes", "footnote"), ("endnotes", "endnote")] {
8209 let xml = format!(
8210 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:AlternateContent><mc:Choice Requires=\"w\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p/></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:t>Normal</w:t></w:r></w:p></w:{item}></mc:Choice><mc:Fallback/></mc:AlternateContent></w:{root}>"
8211 );
8212 let facts = parse_docx_part(
8213 xml.as_bytes(),
8214 "word/notes.xml",
8215 root,
8216 Some(&HashSet::from(["2".to_owned()])),
8217 Some(&HashSet::from(["1".to_owned()])),
8218 &mut DocxStoryReferences::default(),
8219 &control(),
8220 IndexWorkStage::TextIndex,
8221 )
8222 .expect("empty continuation note cannot taint a later note");
8223 assert_eq!(facts.text, "Normal");
8224 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
8225 }
8226 }
8227
8228 #[test]
8229 fn docx_note_special_items_are_included_once_and_conditional_items_are_partial() {
8230 for (root, item) in [("footnotes", "footnote"), ("endnotes", "endnote")] {
8231 let xml = format!(
8232 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"0\" w:type=\"separator\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"-1\" w:type=\"continuationNotice\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"3\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"99\"><w:p><w:r><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/></w:r></w:p></w:{item}></w:{root}>"
8233 );
8234 let special_ids = HashSet::from(["0".to_owned(), "1".to_owned(), "-1".to_owned()]);
8235 let parse = |id: &str, selected_special_ids: Option<&HashSet<String>>| {
8236 parse_docx_part(
8237 xml.as_bytes(),
8238 "word/notes.xml",
8239 root,
8240 Some(&HashSet::from([id.to_owned()])),
8241 selected_special_ids,
8242 &mut DocxStoryReferences::default(),
8243 &control(),
8244 IndexWorkStage::TextIndex,
8245 )
8246 };
8247 let first = parse("2", Some(&special_ids)).expect("referenced note and special items");
8248 assert!(matches!(
8249 parse("0", Some(&special_ids)),
8250 Err(DocumentExtractionError::InvalidDocxPackage { .. })
8251 ));
8252 assert!(matches!(
8253 parse("2", Some(&HashSet::from(["777".to_owned()]))),
8254 Err(DocumentExtractionError::InvalidDocxPackage { .. })
8255 ));
8256 assert_eq!(first.symbols.len(), 4);
8257 assert!(
8258 first
8259 .symbols
8260 .iter()
8261 .all(|symbol| symbol.unicode == Some('α'))
8262 );
8263 assert!(
8264 matches!(first.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory))
8265 );
8266 let later = parse("3", None).expect("new note without duplicate special items");
8267 assert_eq!(later.symbols.len(), 1);
8268 assert_eq!(later.completeness, DocumentCompleteness::Complete);
8269 let no_text = format!(
8270 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}></w:{root}>"
8271 );
8272 let no_text = parse_docx_part(
8273 no_text.as_bytes(),
8274 "word/notes.xml",
8275 root,
8276 Some(&HashSet::from(["2".to_owned()])),
8277 Some(&HashSet::from(["1".to_owned()])),
8278 &mut DocxStoryReferences::default(),
8279 &control(),
8280 IndexWorkStage::TextIndex,
8281 )
8282 .expect("non-text continuation marker does not hide a symbol");
8283 assert_eq!(no_text.symbols.len(), 1);
8284 assert_eq!(no_text.completeness, DocumentCompleteness::Complete);
8285 let conditional_comment = format!(
8286 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:commentReference w:id=\"4\"/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:t>Normal</w:t></w:r></w:p></w:{item}></w:{root}>"
8287 );
8288 let mut references = DocxStoryReferences::default();
8289 let conditional_comment = parse_docx_part(
8290 conditional_comment.as_bytes(),
8291 "word/notes.xml",
8292 root,
8293 Some(&HashSet::from(["2".to_owned()])),
8294 Some(&HashSet::from(["1".to_owned()])),
8295 &mut references,
8296 &control(),
8297 IndexWorkStage::TextIndex,
8298 )
8299 .expect("comment reference in a continuation item remains conditional");
8300 assert_eq!(conditional_comment.text, "\u{fffc}\nNormal");
8301 assert_eq!(
8302 references.items,
8303 vec![(DocxStoryKind::Comments, "4".to_owned())]
8304 );
8305 assert!(matches!(
8306 conditional_comment.completeness,
8307 DocumentCompleteness::Partial { ref gaps }
8308 if gaps.contains(&DocumentCoverageGap::ConditionalStory)
8309 ));
8310 for (id, item_type, special) in [("2", "", false), ("0", " w:type=\"separator\"", true)]
8311 {
8312 let duplicate = format!(
8313 "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"{id}\"{item_type}><w:p><w:r><w:t>First</w:t></w:r></w:p></w:{item}><w:{item} w:id=\"{id}\"{item_type}><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:{item}></w:{root}>"
8314 );
8315 let selected_ids = HashSet::from(["2".to_owned()]);
8316 let special_ids = HashSet::from(["0".to_owned()]);
8317 assert!(matches!(
8318 parse_docx_part(
8319 duplicate.as_bytes(),
8320 "word/notes.xml",
8321 root,
8322 Some(&selected_ids),
8323 special.then_some(&special_ids),
8324 &mut DocxStoryReferences::default(),
8325 &control(),
8326 IndexWorkStage::TextIndex,
8327 ),
8328 Err(DocumentExtractionError::Malformed { .. })
8329 ));
8330 }
8331 }
8332 let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>First</w:t></w:r></w:p></w:comment><w:comment w:id="4"><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:comment></w:comments>"#;
8333 assert!(matches!(
8334 parse_docx_part(
8335 comments,
8336 "word/comments.xml",
8337 "comments",
8338 Some(&HashSet::from(["4".to_owned()])),
8339 None,
8340 &mut DocxStoryReferences::default(),
8341 &control(),
8342 IndexWorkStage::TextIndex,
8343 ),
8344 Err(DocumentExtractionError::Malformed { .. })
8345 ));
8346 }
8347
8348 #[test]
8349 fn docx_utf16_normalization_checks_peak_memory_before_allocation() {
8350 let mut xml = vec![0_u8; MAX_DOCUMENT_EXPANDED_BYTES];
8351 xml[..2].copy_from_slice(&[0xff, 0xfe]);
8352 for pair in xml[2..].as_chunks_mut::<2>().0 {
8353 pair.copy_from_slice(&0x0800_u16.to_le_bytes());
8354 }
8355 assert!(matches!(
8356 decode_docx_xml(xml, &control(), IndexWorkStage::TextIndex),
8357 Err(DocumentExtractionError::ResourceLimit {
8358 limit: DocumentLimit::MemoryBytes,
8359 ..
8360 })
8361 ));
8362 }
8363
8364 #[test]
8365 fn docx_namespace_storage_is_charged_before_parser_allocation() {
8366 let xml = format!(
8367 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:unused=\"{}\"><w:body/></w:document>",
8368 "x".repeat(24 * 1024 * 1024),
8369 );
8370 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
8371 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8372 .expect("safe package memory ceiling is document-local incomplete coverage");
8373 assert_eq!(
8374 facts.completeness,
8375 DocumentCompleteness::Partial {
8376 gaps: vec![DocumentCoverageGap::ResourceLimit(
8377 DocumentLimit::MemoryBytes
8378 )],
8379 }
8380 );
8381 }
8382
8383 #[test]
8384 fn docx_empty_elements_preserve_paragraph_and_run_ordinals() {
8385 let xml = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p/><w:p><w:r/><w:r><w:t>A</w:t><w:tab/><w:t/><w:t>B</w:t></w:r></w:p></w:body></w:document>";
8386 let facts = parse_docx(xml, &control(), IndexWorkStage::SymbolParsing)
8387 .expect("empty elements are valid document structure");
8388 assert_eq!(facts.text, "A\tB");
8389 assert_eq!(facts.facts.len(), 1);
8390 assert!(matches!(
8391 facts.facts[0].locator,
8392 DocumentLocator::Docx {
8393 paragraph: 2,
8394 run: 2,
8395 text_start: 0,
8396 text_end: 3,
8397 ..
8398 }
8399 ));
8400 let expanded = String::from_utf8(xml.to_vec())
8401 .expect("UTF-8 fixture")
8402 .replace("<w:p/>", "<w:p></w:p>")
8403 .replace("<w:r/>", "<w:r></w:r>")
8404 .replace("<w:t/>", "<w:t></w:t>")
8405 .replace("<w:tab/>", "<w:tab></w:tab>");
8406 let expanded_facts = parse_docx(
8407 expanded.as_bytes(),
8408 &control(),
8409 IndexWorkStage::SymbolParsing,
8410 )
8411 .expect("equivalent explicit empty elements");
8412 assert_eq!(facts, expanded_facts);
8413 }
8414
8415 #[test]
8416 fn docx_xml_nesting_is_bounded() {
8417 let xml = format!(
8418 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{}<w:body/>{}</w:document>",
8419 "<w:container>".repeat(MAX_DOCX_XML_DEPTH),
8420 "</w:container>".repeat(MAX_DOCX_XML_DEPTH),
8421 );
8422 let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
8423 .expect_err("XML nesting must be bounded");
8424 assert!(matches!(
8425 error,
8426 DocumentExtractionError::ResourceLimit {
8427 limit: DocumentLimit::NestingDepth,
8428 observed,
8429 maximum: MAX_DOCX_XML_DEPTH,
8430 } if observed == MAX_DOCX_XML_DEPTH + 1
8431 ));
8432 }
8433
8434 #[test]
8435 fn docx_aggregate_output_is_bounded_before_reading_remaining_xml() {
8436 let run = "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES / 2 + 1);
8437 let xml = format!(
8438 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>{run}</w:t></w:r><w:r><w:t>{run}</w:t></w:r><malformed"
8439 );
8440 let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
8441 .expect_err("aggregate output must fail before the later malformed XML");
8442 assert!(matches!(
8443 error,
8444 DocumentExtractionError::ResourceLimit {
8445 limit: DocumentLimit::OutputBytes,
8446 observed,
8447 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
8448 } if observed == MAX_DOCUMENT_OUTPUT_BYTES + 2
8449 ));
8450 }
8451
8452 #[test]
8453 fn direct_xml_extracts_body_and_table_locators() {
8454 let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
8455 <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
8456 <w:body><w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t xml:space="preserve"> world</w:t></w:r></w:p>
8457 <w:tbl><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body>
8458 </w:document>"#;
8459 let bytes = docx_archive(xml, CompressionMethod::Stored);
8460 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8461 .expect("valid DOCX");
8462 assert_eq!(facts.text, "Hello world\nCell");
8463 assert_eq!(facts.facts.len(), 3);
8464 assert!(matches!(
8465 &facts.facts[2].locator,
8466 DocumentLocator::Docx {
8467 part,
8468 paragraph: 2,
8469 run: 1,
8470 text_start: 0,
8471 text_end: 4
8472 } if part == DOCX_DOCUMENT_PART
8473 ));
8474 }
8475
8476 #[test]
8477 fn direct_xml_rejects_truncated_document_part() {
8478 let bytes = docx_archive(
8479 b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>truncated",
8480 CompressionMethod::Stored,
8481 );
8482 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8483 .expect_err("truncated XML must fail closed");
8484 assert!(matches!(
8485 error,
8486 DocumentExtractionError::Malformed {
8487 format: DocumentFormat::Docx,
8488 ..
8489 }
8490 ));
8491 }
8492
8493 #[test]
8494 fn docx_text_whitespace_follows_inherited_xml_space() {
8495 for (attributes, leaves, expected) in [
8496 ("", "<w:t> A </w:t><w:t> B </w:t>", "AB"),
8497 (
8498 "",
8499 "<w:t xml:space=\"preserve\"> A </w:t><w:t> B </w:t>",
8500 " A B",
8501 ),
8502 (
8503 "xml:space=\"preserve\"",
8504 "<w:t> A </w:t><w:t xml:space=\"default\"> B </w:t><w:t> C </w:t>",
8505 " A B C ",
8506 ),
8507 (
8508 "",
8509 "<w:t> \tA <![CDATA[ B ]]>& C </w:t>",
8510 "A B & C",
8511 ),
8512 (
8513 "",
8514 "<w:t>  A  </w:t><w:t> \t </w:t>",
8515 "\u{a0}A\u{a0}",
8516 ),
8517 ] {
8518 let xml = format!(
8519 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" {attributes}><w:body><w:p><w:r>{leaves}</w:r></w:p></w:body></w:document>"
8520 );
8521 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
8522 .expect("Word text whitespace");
8523 assert_eq!(facts.text, expected);
8524 assert_eq!(facts.facts.len(), 1);
8525 assert_eq!(facts.facts[0].text, expected);
8526 assert!(
8527 matches!(facts.facts[0].locator, DocumentLocator::Docx { paragraph: 1, run: 1, text_start: 0, text_end, .. } if text_end == expected.len())
8528 );
8529 }
8530 }
8531
8532 #[test]
8533 fn docx_invalid_whitespace_mode_refuses_publication() {
8534 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Prefix</w:t><w:t xml:space="invalid">suffix</w:t></w:r></w:p></w:body></w:document>"#;
8535 assert!(matches!(
8536 extract_document_text_controlled(
8537 &docx_archive(xml, CompressionMethod::Deflated),
8538 "guide.docx",
8539 None,
8540 &control()
8541 ),
8542 Err(DocumentExtractionError::Malformed {
8543 format: DocumentFormat::Docx,
8544 ..
8545 })
8546 ));
8547 }
8548
8549 #[test]
8550 fn direct_xml_preserves_entities_tabs_and_breaks() {
8551 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A & B</w:t><w:tab/><w:br/><w:t>C</w:t><w:noBreakHyphen/><w:t>D</w:t><w:softHyphen/><w:t>E</w:t><w:ptab w:alignment="left" w:relativeTo="margin" w:leader="none"/><w:t>F</w:t><w:lastRenderedPageBreak/><w:t>G</w:t></w:r></w:p></w:body></w:document>"#;
8552 let bytes = docx_archive(xml, CompressionMethod::Stored);
8553 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8554 .expect("valid DOCX");
8555 assert_eq!(facts.text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
8556 assert_eq!(facts.facts[0].text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
8557 assert_eq!(facts.facts[0].line_start, 1);
8558 assert_eq!(facts.facts[0].line_end, 3);
8559 assert_eq!(
8560 facts.facts[0].locator.to_string(),
8561 "docx:part=word/document.xml;paragraph=1;run=1;text-span=0..19"
8562 );
8563 }
8564
8565 #[test]
8566 fn docx_font_specific_symbols_preserve_identity_without_inventing_text() {
8567 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Before</w:t><w:sym w:font="Wingdings" w:char="F03A"/><w:t>After</w:t></w:r></w:p></w:body></w:document>"#;
8568 let facts = extract_document_text_controlled(
8569 &docx_archive(xml, CompressionMethod::Deflated),
8570 "symbol.docx",
8571 None,
8572 &control(),
8573 )
8574 .expect("rendered symbol must not block source indexing");
8575 assert!(facts.text.contains("Before"));
8576 assert!(facts.text.contains("After"));
8577 assert!(!facts.text.contains("F03A"));
8578 assert_eq!(
8579 facts.completeness,
8580 DocumentCompleteness::Partial {
8581 gaps: vec![DocumentCoverageGap::UnknownSymbolMapping],
8582 }
8583 );
8584 let graph = facts.symbol_graph("symbol.docx", None);
8585 assert_eq!(graph.language.as_deref(), Some("docx"));
8586 assert!(
8587 graph
8588 .symbols
8589 .iter()
8590 .all(|symbol| symbol.language.as_deref() == Some("docx"))
8591 );
8592 assert_eq!(
8593 facts.symbol_graph("symbol.docx", Some("DOCX")).language,
8594 graph.language
8595 );
8596 assert!(graph.symbols.iter().any(|symbol| {
8597 symbol
8598 .signature
8599 .contains("font=Some(\"Wingdings\");code=F03A")
8600 && symbol.signature.contains("docx:part=word/document.xml")
8601 }));
8602 }
8603
8604 #[test]
8605 fn docx_symbols_with_absent_identity_remain_partial() {
8606 for (attributes, font, code) in [
8607 ("w:char=\"F03A\"", None, Some(0xF03A)),
8608 ("w:font=\"Wingdings\"", Some("Wingdings"), None),
8609 ("", None, None),
8610 ] {
8611 let xml = format!(
8612 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t><w:sym {attributes}/><w:t>After</w:t></w:r></w:p></w:body></w:document>"
8613 );
8614 let facts = extract_document_text_controlled(
8615 &docx_archive(xml.as_bytes(), CompressionMethod::Deflated),
8616 "symbol.docx",
8617 None,
8618 &control(),
8619 )
8620 .expect("omitted symbol attributes are valid but unresolved");
8621 assert!(facts.text.contains("Before\u{fffc}After"));
8622 assert_eq!(facts.symbols.len(), 1);
8623 assert_eq!(facts.symbols[0].font.as_deref(), font);
8624 assert_eq!(facts.symbols[0].code, code);
8625 assert_eq!(facts.symbols[0].unicode, None);
8626 let symbol = facts
8627 .symbol_graph("symbol.docx", None)
8628 .symbols
8629 .into_iter()
8630 .find(|symbol| symbol.name == "document-symbol-1")
8631 .expect("symbol remains queryable");
8632 let code = code.map_or_else(|| "missing".to_owned(), |code| format!("{code:04X}"));
8633 assert!(
8634 symbol
8635 .signature
8636 .contains(&format!("font={font:?};code={code}"))
8637 );
8638 assert_eq!(
8639 facts.completeness,
8640 DocumentCompleteness::Partial {
8641 gaps: vec![DocumentCoverageGap::UnknownSymbolMapping],
8642 }
8643 );
8644 }
8645 }
8646
8647 #[test]
8648 fn docx_symbol_identity_rejects_namespace_equivalent_duplicates() {
8649 for attributes in [
8650 "w:font=\"Symbol\" x:font=\"Wingdings\" w:char=\"F061\"",
8651 "x:font=\"Wingdings\" w:font=\"Symbol\" w:char=\"F061\"",
8652 "w:font=\"Symbol\" w:char=\"F061\" x:char=\"F062\"",
8653 "w:font=\"Symbol\" x:char=\"F062\" w:char=\"F061\"",
8654 ] {
8655 let xml = format!(
8656 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:sym {attributes}/></w:r></w:p></w:body></w:document>"
8657 );
8658 assert!(matches!(
8659 extract_document_text_controlled(
8660 &docx_archive(xml.as_bytes(), CompressionMethod::Deflated),
8661 "symbol.docx",
8662 None,
8663 &control(),
8664 ),
8665 Err(DocumentExtractionError::Malformed { message, .. })
8666 if message.contains("attribute is duplicated")
8667 ));
8668 }
8669 let aliased = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:sym x:font="Symbol" x:char="F061"/></w:r></w:p></w:body></w:document>"#;
8670 let facts = extract_document_text_controlled(
8671 &docx_archive(aliased, CompressionMethod::Deflated),
8672 "symbol.docx",
8673 None,
8674 &control(),
8675 )
8676 .expect("namespace alias remains valid");
8677 assert_eq!(facts.symbols[0].font.as_deref(), Some("Symbol"));
8678 assert_eq!(facts.symbols[0].code, Some(0xF061));
8679 assert_eq!(facts.symbols[0].unicode, Some('α'));
8680 }
8681
8682 #[test]
8683 fn docx_run_payload_requires_logical_direct_child() {
8684 for payload in [
8685 "<w:rPr><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:rPr>",
8686 "<w:rPr><w:t>Secret</w:t></w:rPr>",
8687 "<w:wrapper><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:wrapper>",
8688 "<w:rPr><w:tab/></w:rPr>",
8689 "<w:rPr><w:footnoteReference w:id=\"1\"/></w:rPr>",
8690 ] {
8691 let xml = format!(
8692 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8693 );
8694 assert!(matches!(
8695 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8696 Err(DocumentExtractionError::Malformed { message, .. })
8697 if message.contains("not a direct child")
8698 ));
8699 }
8700 let selected = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><w:r><mc:AlternateContent><mc:Choice Requires="w"><w:sym w:font="Symbol" w:char="F061"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:r></w:p></w:body></w:document>"#;
8701 let facts = parse_docx(selected, &control(), IndexWorkStage::TextIndex)
8702 .expect("selected compatibility branch preserves direct run payload");
8703 assert_eq!(facts.text, "α");
8704 assert_eq!(facts.symbols.len(), 1);
8705 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
8706 }
8707
8708 #[test]
8709 fn docx_run_metadata_cannot_publish_text_box_or_story_symbols() {
8710 for payload in [
8711 "<w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent>",
8712 "<w:rPr><w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></w:rPr>",
8713 "<w:rPr><a:graphic><a:graphicData><w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></a:graphicData></a:graphic></w:rPr>",
8714 ] {
8715 let xml = format!(
8716 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8717 );
8718 assert!(matches!(
8719 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8720 Err(DocumentExtractionError::Malformed { message, .. })
8721 if message.contains("no rendered drawing owner")
8722 || message.contains("no supported drawing parent")
8723 ));
8724 }
8725 let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:comment></w:comments>"#;
8726 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
8727 for (payload, kind, target, part, story) in [
8728 (
8729 "<w:commentRangeStart w:id=\"4\"/>",
8730 "comments",
8731 "comments.xml",
8732 "word/comments.xml",
8733 comments.as_slice(),
8734 ),
8735 (
8736 "<w:rPr><w:commentRangeStart w:id=\"4\"/></w:rPr>",
8737 "comments",
8738 "comments.xml",
8739 "word/comments.xml",
8740 comments.as_slice(),
8741 ),
8742 (
8743 "<w:rPr><w:sectPr><w:headerReference r:id=\"header\"/></w:sectPr></w:rPr>",
8744 "header",
8745 "header.xml",
8746 "word/header.xml",
8747 header.as_slice(),
8748 ),
8749 ] {
8750 let main = format!(
8751 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8752 );
8753 let rels = format!(
8754 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"header\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}\" Target=\"{target}\"/></Relationships>"
8755 );
8756 let archive = docx_archive_with_parts(&[
8757 (DOCX_DOCUMENT_PART, main.as_bytes()),
8758 ("word/_rels/document.xml.rels", rels.as_bytes()),
8759 (part, story),
8760 ]);
8761 assert!(matches!(
8762 extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
8763 Err(DocumentExtractionError::Malformed { .. })
8764 ));
8765 }
8766 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="header" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
8767 for nested in [
8768 "<w:r><w:headerReference r:id=\"header\"/></w:r>",
8769 "<w:wrapper><w:headerReference r:id=\"header\"/></w:wrapper>",
8770 "<w:r><w:titlePg/></w:r>",
8771 ] {
8772 let main = format!(
8773 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:pPr><w:sectPr>{nested}</w:sectPr></w:pPr></w:p></w:body></w:document>"
8774 );
8775 let archive = docx_archive_with_parts(&[
8776 (DOCX_DOCUMENT_PART, main.as_bytes()),
8777 ("word/_rels/document.xml.rels", rels),
8778 ("word/header.xml", header),
8779 ]);
8780 assert!(matches!(
8781 extract_document_text_controlled(&archive, "section.docx", None, &control()),
8782 Err(DocumentExtractionError::Malformed { .. })
8783 ));
8784 }
8785 let selected = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:sectPr><mc:AlternateContent><mc:Choice Requires="w"><w:headerReference r:id="header"/><w:titlePg w:val="false"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sectPr></w:body></w:document>"#;
8786 let archive = docx_archive_with_parts(&[
8787 (DOCX_DOCUMENT_PART, selected),
8788 ("word/_rels/document.xml.rels", rels),
8789 ("word/header.xml", header),
8790 ]);
8791 let facts = extract_document_text_controlled(&archive, "section.docx", None, &control())
8792 .expect("selected section metadata remains direct after compatibility wrappers");
8793 assert_eq!(facts.symbols.len(), 1);
8794 }
8795
8796 #[test]
8797 fn docx_font_identity_has_a_bounded_retained_size() {
8798 let package = |font: &str| {
8799 let xml = format!(
8800 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:sym w:font=\"{font}\" w:char=\"F03A\"/></w:r></w:p></w:body></w:document>"
8801 );
8802 docx_archive(xml.as_bytes(), CompressionMethod::Deflated)
8803 };
8804 let retained = extract_document_text_controlled(
8805 &package(&"A".repeat(MAX_DOCX_FONT_NAME_BYTES)),
8806 "font.docx",
8807 None,
8808 &control(),
8809 )
8810 .expect("bounded exact font identity");
8811 assert_eq!(retained.symbols.len(), 1);
8812 let over_limit = extract_document_text_controlled(
8813 &package(&"A".repeat(MAX_DOCX_FONT_NAME_BYTES + 1)),
8814 "font.docx",
8815 None,
8816 &control(),
8817 )
8818 .expect("oversized font becomes local incomplete coverage");
8819 assert_eq!(
8820 over_limit.completeness,
8821 DocumentCompleteness::Partial {
8822 gaps: vec![DocumentCoverageGap::ResourceLimit(
8823 DocumentLimit::MemoryBytes
8824 )],
8825 }
8826 );
8827 assert!(over_limit.symbols.is_empty());
8828 }
8829
8830 #[test]
8831 fn docx_rendered_story_parts_retain_symbols_and_skip_unreferenced_content() {
8832 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"/><Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"/><Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/></Types>"#;
8833 let package_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rMain" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
8834 let document_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rHeader" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Internal"/><Relationship Id="rFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rFootnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rEndnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/><Relationship Id="rSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
8835 let main = format!(
8836 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:r><w:t>Main</w:t><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/>{}</w:r></w:p><w:p><w:pPr><w:framePr/></w:pPr><w:r><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/></w:r></w:p><w:p><w:r><w:footnoteReference w:id=\"2\"/><w:endnoteReference w:id=\"3\"/><w:commentReference w:id=\"4\"/></w:r></w:p><w:sectPr><w:headerReference r:id=\"rHeader\" w:type=\"default\"/><w:footerReference r:id=\"rFooter\" w:type=\"default\"/></w:sectPr></w:body></w:document>",
8837 docx_drawing_text_box(
8838 "<w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>",
8839 1
8840 )
8841 );
8842 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>Header</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
8843 let footer = br#"<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>Footer</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:ftr>"#;
8844 let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="0" w:type="separator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="1" w:type="continuationSeparator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:t>Footnote</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote><w:footnote w:id="99"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
8845 let endnotes = br#"<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:endnote w:id="0" w:type="separator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:endnote><w:endnote w:id="3"><w:p><w:r><w:t>Endnote</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:endnote></w:endnotes>"#;
8846 let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>Comment</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:comment></w:comments>"#;
8847 let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnotePr><w:footnote w:id="+00"/><w:footnote w:id="01"/></w:footnotePr><w:endnotePr><w:endnote w:id="0"/></w:endnotePr></w:settings>"#;
8848 let glossary = br#"<w:glossaryDocument xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docParts><w:docPart><w:docPartBody><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:docPartBody></w:docPart></w:docParts></w:glossaryDocument>"#;
8849 let archive = |settings: &[u8]| {
8850 docx_archive_with_parts(&[
8851 ("[Content_Types].xml", manifest),
8852 ("_rels/.rels", package_rels),
8853 (DOCX_DOCUMENT_PART, main.as_bytes()),
8854 ("word/_rels/document.xml.rels", document_rels),
8855 ("word/header1.xml", header),
8856 ("word/footer1.xml", footer),
8857 ("word/footnotes.xml", footnotes),
8858 ("word/endnotes.xml", endnotes),
8859 ("word/comments.xml", comments),
8860 ("word/settings.xml", settings),
8861 ("word/glossary/document.xml", glossary),
8862 ])
8863 };
8864 let facts =
8865 extract_document_text_controlled(&archive(settings), "stories.docx", None, &control())
8866 .expect("every referenced rendered story is admitted");
8867 assert_eq!(facts.symbols.len(), 11);
8868 assert!(
8869 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory))
8870 );
8871 for (part, expected) in [("word/footnotes.xml", 3), ("word/endnotes.xml", 2)] {
8872 assert_eq!(facts.symbols.iter().filter(|symbol| {
8873 matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8874 }).count(), expected, "missing or repeated note separator in {part}");
8875 }
8876 for part in [
8877 "word/document.xml",
8878 "word/header1.xml",
8879 "word/footer1.xml",
8880 "word/footnotes.xml",
8881 "word/endnotes.xml",
8882 "word/comments.xml",
8883 ] {
8884 assert!(facts.symbols.iter().any(|symbol| {
8885 matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8886 }), "missing symbol in {part}");
8887 }
8888 assert!(facts.symbols.iter().all(|symbol| {
8889 !matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part.contains("glossary"))
8890 }));
8891 assert!(facts.text.contains('α'));
8892 assert!(facts.text.contains("Header"));
8893 assert!(facts.text.contains("Comment"));
8894 let unlisted = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnotePr/><w:endnotePr/></w:settings>"#;
8895 let unlisted = extract_document_text_controlled(
8896 &archive(unlisted),
8897 "unlisted-separators.docx",
8898 None,
8899 &control(),
8900 )
8901 .expect("unlisted special notes are not loaded");
8902 assert_eq!(unlisted.symbols.len(), 8);
8903 assert!(
8904 matches!(unlisted.completeness, DocumentCompleteness::Partial { ref gaps } if !gaps.contains(&DocumentCoverageGap::ConditionalStory))
8905 );
8906 for part in ["word/footnotes.xml", "word/endnotes.xml"] {
8907 assert_eq!(unlisted.symbols.iter().filter(|symbol| {
8908 matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8909 }).count(), 1, "unlisted note separator was emitted from {part}");
8910 }
8911 let compatible_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="w"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8912 let compatible = extract_document_text_controlled(
8913 &archive(compatible_settings),
8914 "compatible-settings.docx",
8915 None,
8916 &control(),
8917 )
8918 .expect("selected compatibility settings load their special note");
8919 assert_eq!(compatible.symbols.len(), 9);
8920 assert!(
8921 !matches!(compatible.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8922 );
8923 for malformed in [
8924 std::str::from_utf8(compatible_settings)
8925 .expect("settings fixture")
8926 .replace("<mc:Fallback/>", "<mc:Fallback x:extra=\"1\"/>"),
8927 std::str::from_utf8(compatible_settings)
8928 .expect("settings fixture")
8929 .replace(
8930 "<mc:Fallback/>",
8931 "<mc:Fallback><w:evenAndOddHeaders x:extra=\"1\"/></mc:Fallback>",
8932 ),
8933 ] {
8934 assert!(matches!(
8935 extract_document_text_controlled(
8936 &archive(malformed.as_bytes()),
8937 "invalid-skipped-settings.docx",
8938 None,
8939 &control()
8940 ),
8941 Err(DocumentExtractionError::Malformed { message, .. })
8942 if message.contains("undeclared namespace prefix")
8943 ));
8944 }
8945 let nested = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="w"><mc:AlternateContent><mc:Choice Requires="w"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8946 let nested = extract_document_text_controlled(
8947 &archive(nested),
8948 "nested-settings.docx",
8949 None,
8950 &control(),
8951 )
8952 .expect("nested selected settings load their special note");
8953 assert_eq!(nested.symbols.len(), 9);
8954 assert!(
8955 !matches!(nested.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8956 );
8957 let uncertain = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8958 let uncertain = extract_document_text_controlled(
8959 &archive(uncertain),
8960 "uncertain-settings.docx",
8961 None,
8962 &control(),
8963 )
8964 .expect("unknown compatibility settings remain file-local incomplete");
8965 assert!(
8966 matches!(uncertain.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8967 );
8968 let nested_uncertain = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="w"><mc:AlternateContent><mc:Choice Requires="future"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8969 let nested_uncertain = extract_document_text_controlled(
8970 &archive(nested_uncertain),
8971 "nested-uncertain-settings.docx",
8972 None,
8973 &control(),
8974 )
8975 .expect("nested unknown branch remains incomplete");
8976 assert!(
8977 matches!(nested_uncertain.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8978 );
8979 let undeclared = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="missing"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8980 assert!(matches!(
8981 extract_document_text_controlled(
8982 &archive(undeclared),
8983 "undeclared-settings.docx",
8984 None,
8985 &control()
8986 ),
8987 Err(DocumentExtractionError::Malformed { .. })
8988 ));
8989 let invalid_token = std::str::from_utf8(undeclared)
8990 .expect("UTF-8 settings fixture")
8991 .replace("Requires=\"missing\"", "Requires=\"w:future\"");
8992 assert!(matches!(
8993 extract_document_text_controlled(
8994 &archive(invalid_token.as_bytes()),
8995 "invalid-prefix-settings.docx",
8996 None,
8997 &control()
8998 ),
8999 Err(DocumentExtractionError::Malformed { .. })
9000 ));
9001 let mixed = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future missing"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
9002 for xml in [
9003 std::str::from_utf8(mixed)
9004 .expect("UTF-8 settings fixture")
9005 .to_owned(),
9006 std::str::from_utf8(mixed)
9007 .expect("UTF-8 settings fixture")
9008 .replace("future missing", "missing future"),
9009 ] {
9010 assert!(matches!(
9011 extract_document_text_controlled(
9012 &archive(xml.as_bytes()),
9013 "mixed-prefixes.docx",
9014 None,
9015 &control()
9016 ),
9017 Err(DocumentExtractionError::Malformed { .. })
9018 ));
9019 }
9020 for property in ["footnotePr", "endnotePr"] {
9021 let closing = format!("</w:{property}>");
9022 let duplicate_properties = std::str::from_utf8(settings)
9023 .expect("UTF-8 settings fixture")
9024 .replace(&closing, &format!("{closing}<w:{property}/>"));
9025 assert!(matches!(
9026 extract_document_text_controlled(
9027 &archive(duplicate_properties.as_bytes()),
9028 "duplicate-note-properties.docx",
9029 None,
9030 &control(),
9031 ),
9032 Err(DocumentExtractionError::Malformed { .. })
9033 ));
9034 }
9035 }
9036
9037 #[test]
9038 fn docx_story_relationships_refuse_external_escape_missing_and_wrong_type() {
9039 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Keep old publication</w:t></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9040 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9041 let escaped_external = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="External"/></Relationships>"#;
9042 let parsed =
9043 parse_docx_relationships(escaped_external, &control(), IndexWorkStage::TextIndex)
9044 .expect("escaped TargetMode retains external classification");
9045 assert_eq!(
9046 parsed.get("h").map(|(_, _, external)| *external),
9047 Some(true)
9048 );
9049 for (relationship, include_header) in [
9050 (
9051 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="External""#,
9052 true,
9053 ),
9054 (
9055 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="External""#,
9056 true,
9057 ),
9058 (
9059 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="../../outside.xml""#,
9060 true,
9061 ),
9062 (
9063 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="missing.xml""#,
9064 false,
9065 ),
9066 (
9067 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="header1.xml""#,
9068 true,
9069 ),
9070 (
9071 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Unexpected""#,
9072 true,
9073 ),
9074 (
9075 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Unexpected""#,
9076 true,
9077 ),
9078 (
9079 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%2Fone.xml""#,
9080 true,
9081 ),
9082 (
9083 r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%GGone.xml""#,
9084 true,
9085 ),
9086 ] {
9087 let rels = format!(
9088 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"h\" {relationship}/></Relationships>"
9089 );
9090 let mut parts = vec![
9091 (DOCX_DOCUMENT_PART, main.as_slice()),
9092 ("word/_rels/document.xml.rels", rels.as_bytes()),
9093 ];
9094 if include_header {
9095 parts.push(("word/header1.xml", header.as_slice()));
9096 }
9097 let archive = docx_archive_with_parts(&parts);
9098 assert!(
9099 matches!(
9100 extract_document_text_controlled(&archive, "unsafe.docx", None, &control()),
9101 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9102 ),
9103 "{relationship}"
9104 );
9105 }
9106 }
9107
9108 #[test]
9109 fn docx_relationships_reject_undeclared_attribute_prefixes() {
9110 for xml in [
9111 r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships" x:extra="1"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
9112 r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml" x:extra="1"/></Relationships>"#,
9113 ] {
9114 assert!(matches!(
9115 parse_docx_relationships(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9116 Err(DocumentExtractionError::Malformed { .. })
9117 ));
9118 }
9119 }
9120
9121 #[test]
9122 fn docx_package_metadata_rejects_non_xml_structural_whitespace() {
9123 let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\u{a0}</Types>";
9124 assert!(matches!(
9125 parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex),
9126 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9127 ));
9128 let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\u{2003}</Relationships>";
9129 assert!(matches!(
9130 parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex),
9131 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9132 ));
9133 }
9134
9135 #[test]
9136 fn docx_package_metadata_preserves_ordinary_processing_instructions() {
9137 let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><?producer marker?></Types>";
9138 assert!(
9139 parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex)
9140 .is_ok()
9141 );
9142 let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><?producer marker?></Relationships>";
9143 assert!(
9144 parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex)
9145 .is_ok()
9146 );
9147 for invalid in [
9148 types.replace("<?producer marker?>", "<?XML marker?>"),
9149 rels.replace("<?producer marker?>", "<?XML marker?>"),
9150 ] {
9151 let result = if invalid.starts_with("<Types") {
9152 parse_docx_content_types(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9153 .map(|_| ())
9154 } else {
9155 parse_docx_relationships(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9156 .map(|_| ())
9157 };
9158 assert!(matches!(
9159 result,
9160 Err(DocumentExtractionError::Malformed { .. })
9161 ));
9162 }
9163 }
9164
9165 #[test]
9166 fn docx_package_metadata_accepts_only_internal_whitespace_references() {
9167 let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"> 	<![CDATA[\n ]]></Types>";
9168 assert!(
9169 parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex)
9170 .is_ok()
9171 );
9172 let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">
<![CDATA[ ]]></Relationships>";
9173 assert!(
9174 parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex)
9175 .is_ok()
9176 );
9177 for invalid in [
9178 types.replace(" 	", "A"),
9179 types.replace(" 	", "&missing;"),
9180 types.replace("<![CDATA[\n ]]>", "<![CDATA[Injected]]>"),
9181 format!(" {types}"),
9182 format!("<![CDATA[ ]]> {types}"),
9183 rels.replace("
", "A"),
9184 rels.replace("
", "&missing;"),
9185 rels.replace("<![CDATA[ ]]>", "<![CDATA[Injected]]>"),
9186 format!("{rels} "),
9187 format!("{rels}<![CDATA[ ]]>"),
9188 ] {
9189 let result = if invalid.contains("<Types") {
9190 parse_docx_content_types(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9191 .map(|_| ())
9192 } else {
9193 parse_docx_relationships(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9194 .map(|_| ())
9195 };
9196 assert!(result.is_err(), "{invalid}");
9197 }
9198 }
9199
9200 #[test]
9201 fn docx_safe_percent_encoded_story_target_retains_symbols() {
9202 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header%20one.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9203 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9204 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%20one.xml" TargetMode="Internal"/></Relationships>"#;
9205 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9206 let archive = docx_archive_with_parts(&[
9207 ("[Content_Types].xml", manifest),
9208 (DOCX_DOCUMENT_PART, main),
9209 ("word/_rels/document.xml.rels", rels),
9210 ("word/header%20one.xml", header),
9211 ]);
9212 let facts = extract_document_text_controlled(&archive, "encoded.docx", None, &control())
9213 .expect("safe encoded target resolves to its package part");
9214 assert!(facts.symbols.iter().any(|symbol| {
9215 matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/header%20one.xml")
9216 }));
9217 }
9218
9219 #[test]
9220 fn docx_relative_dot_and_unreserved_uri_escapes_resolve_inside_package() {
9221 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9222 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="./header%31.xml"/></Relationships>"#;
9223 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9224 let archive = docx_archive_with_parts(&[
9225 (DOCX_DOCUMENT_PART, main),
9226 ("word/_rels/document.xml.rels", rels),
9227 ("word/header1.xml", header),
9228 ]);
9229 let facts = extract_document_text_controlled(&archive, "normalized.docx", None, &control())
9230 .expect("relative target is normalized within the package");
9231 assert!(facts.symbols.iter().any(|symbol| {
9232 matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/header1.xml")
9233 }));
9234 }
9235
9236 #[test]
9237 fn docx_package_metadata_tables_are_bounded_before_map_fanout() {
9238 let oversized = format!(
9239 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}</Relationships>",
9240 " ".repeat(MAX_DOCX_METADATA_BYTES)
9241 );
9242 assert!(matches!(
9243 parse_docx_relationships(oversized.as_bytes(), &control(), IndexWorkStage::TextIndex),
9244 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9245 ));
9246 let mut records = String::new();
9247 for index in 0..=MAX_DOCX_METADATA_RECORDS {
9248 write!(records, "<Relationship Id=\"r{index}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header\" Target=\"header.xml\"/>")
9249 .expect("fixture relationship");
9250 }
9251 let xml = format!(
9252 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{records}</Relationships>"
9253 );
9254 assert!(matches!(
9255 parse_docx_relationships(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9256 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9257 ));
9258 }
9259
9260 #[test]
9261 fn docx_referenced_unexamined_subdocument_is_partial() {
9262 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Visible</w:t></w:r><w:subDoc r:id="child"/></w:p></w:body></w:document>"#;
9263 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="child" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/subDocument" Target="child.xml"/></Relationships>"#;
9264 let child = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:body></w:document>"#;
9265 let archive = docx_archive_with_parts(&[
9266 (DOCX_DOCUMENT_PART, main),
9267 ("word/_rels/document.xml.rels", rels),
9268 ("word/child.xml", child),
9269 ]);
9270 let facts =
9271 extract_document_text_controlled(&archive, "subdocument.docx", None, &control())
9272 .expect("in-package subdocument is valid but not recursively interpreted");
9273 assert!(facts.text.contains("Visible"));
9274 assert!(facts.symbols.is_empty());
9275 assert_eq!(
9276 facts.completeness,
9277 DocumentCompleteness::Partial {
9278 gaps: vec![DocumentCoverageGap::UnexaminedStory],
9279 }
9280 );
9281 let sdt_main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:subDoc r:id="child"/></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9282 let mut references = DocxStoryReferences::default();
9283 let sdt = parse_docx_part(
9284 sdt_main,
9285 DOCX_DOCUMENT_PART,
9286 "document",
9287 None,
9288 None,
9289 &mut references,
9290 &control(),
9291 IndexWorkStage::TextIndex,
9292 )
9293 .expect("subdocument anchor is actual SDT content");
9294 assert_eq!(
9295 references.linked_parts,
9296 vec![(DocxStoryKind::Subdocument, "child".to_owned())]
9297 );
9298 assert!(!references.glossary_placeholder);
9299 assert_eq!(sdt.completeness, facts.completeness);
9300 }
9301
9302 #[test]
9303 fn docx_glossary_placeholder_is_partial_only_when_rendered() {
9304 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/></Types>"#;
9305 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="glossary" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" Target="glossary/document.xml"/></Relationships>"#;
9306 let glossary = br#"<w:glossaryDocument xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docParts><w:docPart><w:docPartPr><w:name w:val="Hint"/><w:types><w:type w:val="bbPlcHdr"/></w:types></w:docPartPr><w:docPartBody><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:docPartBody></w:docPart></w:docParts></w:glossaryDocument>"#;
9307 for (content, incomplete) in [
9308 ("<w:p><w:r><w:t>Literal</w:t></w:r></w:p>", false),
9309 (
9310 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p/></w:sdtContent></w:sdt>",
9311 true,
9312 ),
9313 (
9314 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9315 false,
9316 ),
9317 (
9318 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9319 true,
9320 ),
9321 (
9322 "<w:sdt><mc:AlternateContent><mc:Choice Requires=\"w\"><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr/></w:sdtPr></mc:Choice><mc:Fallback/></mc:AlternateContent><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9323 true,
9324 ),
9325 (
9326 "<w:sdt><w:sdtPr><w:placeholder><mc:AlternateContent><mc:Choice Requires=\"w\"><w:docPart w:val=\"Hint\"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:placeholder><w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9327 true,
9328 ),
9329 (
9330 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><mc:AlternateContent><mc:Choice Requires=\"w\"><w:showingPlcHdr/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9331 true,
9332 ),
9333 (
9334 "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><mc:AlternateContent><mc:Choice Requires=\"w\"><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sdt>",
9335 false,
9336 ),
9337 ] {
9338 let main = format!(
9339 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body>{content}</w:body></w:document>"
9340 );
9341 let archive = docx_archive_with_parts(&[
9342 ("[Content_Types].xml", manifest),
9343 (DOCX_DOCUMENT_PART, main.as_bytes()),
9344 ("word/_rels/document.xml.rels", rels),
9345 ("word/glossary/document.xml", glossary),
9346 ]);
9347 let facts =
9348 extract_document_text_controlled(&archive, "glossary.docx", None, &control())
9349 .expect("valid in-package glossary relationship");
9350 assert_eq!(
9351 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9352 incomplete,
9353 "{content}"
9354 );
9355 assert!(
9356 facts.symbols.is_empty(),
9357 "unexamined glossary symbol was invented"
9358 );
9359 }
9360 let hidden = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder><w:showingPlcHdr w:val="false"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9361 let facts = extract_document_text_controlled(
9362 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, hidden)]),
9363 "hidden-placeholder.docx",
9364 None,
9365 &control(),
9366 )
9367 .expect("a false placeholder flag does not require a glossary relationship");
9368 assert_eq!(facts.text, "Cached");
9369 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9370 let preserved_space = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:t xml:space="preserve"> </w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9371 let facts = extract_document_text_controlled(
9372 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, preserved_space)]),
9373 "preserved-space.docx",
9374 None,
9375 &control(),
9376 )
9377 .expect("retained whitespace replaces a glossary placeholder");
9378 assert_eq!(facts.text, " ");
9379 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9380 let hidden_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9381 let facts = extract_document_text_controlled(
9382 &docx_archive_with_parts(&[
9383 ("[Content_Types].xml", manifest),
9384 (DOCX_DOCUMENT_PART, hidden_only),
9385 ("word/_rels/document.xml.rels", rels),
9386 ("word/glossary/document.xml", glossary),
9387 ]),
9388 "hidden-placeholder.docx",
9389 None,
9390 &control(),
9391 )
9392 .expect("hidden content does not satisfy a visible glossary placeholder");
9393 assert!(facts.symbols.is_empty());
9394 assert!(
9395 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory) && !gaps.contains(&DocumentCoverageGap::UnknownSymbolMapping))
9396 );
9397 for control_name in [
9398 "noBreakHyphen",
9399 "softHyphen",
9400 "cr",
9401 "ptab",
9402 "lastRenderedPageBreak",
9403 "pgNum",
9404 ] {
9405 let main = format!(
9406 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:{control_name}/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"
9407 );
9408 let facts = extract_document_text_controlled(
9409 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9410 "visible-control.docx",
9411 None,
9412 &control(),
9413 )
9414 .expect("visible controls do not load an unrendered glossary placeholder");
9415 assert!(!facts.text.is_empty(), "{control_name}");
9416 assert!(
9417 !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9418 "{control_name}"
9419 );
9420 }
9421 for content in [
9422 "<w:object xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\"><v:shape/><o:OLEObject/></w:object>",
9423 "<w:object><w:control/></w:object>",
9424 ] {
9425 let main = format!(
9426 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r>{content}</w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"
9427 );
9428 let facts = extract_document_text_controlled(
9429 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9430 "embedded-control.docx",
9431 None,
9432 &control(),
9433 )
9434 .expect("rendered objects do not load an unrendered glossary placeholder");
9435 assert!(
9436 !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9437 "{content}"
9438 );
9439 }
9440 let content_part = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:contentPart r:id="xml"/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9441 let content_part_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/></Relationships>"#;
9442 let content_part_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
9443 let facts = extract_document_text_controlled(
9444 &docx_archive_with_parts(&[
9445 ("[Content_Types].xml", content_part_manifest),
9446 (DOCX_DOCUMENT_PART, content_part),
9447 ("word/_rels/document.xml.rels", content_part_rels),
9448 ("customXml/item1.xml", b"<equation>opaque</equation>"),
9449 ]),
9450 "alternate-xml-control.docx",
9451 None,
9452 &control(),
9453 )
9454 .expect("unsupported content part does not trigger glossary lookup");
9455 assert!(
9456 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9457 );
9458 for relationship in [
9459 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#.as_slice(),
9460 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="https://example.invalid/item.xml" TargetMode="External"/></Relationships>"#,
9461 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="../customXml/item1.xml"/></Relationships>"#,
9462 br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../../../outside.xml"/></Relationships>"#,
9463 ] {
9464 assert!(matches!(
9465 extract_document_text_controlled(
9466 &docx_archive_with_parts(&[
9467 ("[Content_Types].xml", content_part_manifest),
9468 (DOCX_DOCUMENT_PART, content_part),
9469 ("word/_rels/document.xml.rels", relationship),
9470 ("customXml/item1.xml", b"<equation>opaque</equation>"),
9471 ]),
9472 "invalid-content-part.docx",
9473 None,
9474 &control(),
9475 ),
9476 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9477 ));
9478 }
9479 let no_id = std::str::from_utf8(content_part)
9480 .expect("fixture is UTF-8")
9481 .replace(" r:id=\"xml\"", "");
9482 assert!(matches!(
9483 extract_document_text_controlled(
9484 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, no_id.as_bytes())]),
9485 "missing-content-part-id.docx",
9486 None,
9487 &control(),
9488 ),
9489 Err(DocumentExtractionError::Malformed { .. })
9490 ));
9491 let note_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"/><Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"/><Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/></Types>"#;
9492 let note_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="c" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
9493 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:footnoteReference w:id="2"/><w:endnoteReference w:id="3"/><w:commentReference w:id="4"/></w:r></w:p></w:body></w:document>"#;
9494 let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="2"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:footnoteRef/></w:r></w:p></w:sdtContent></w:sdt></w:footnote></w:footnotes>"#;
9495 let endnotes = br#"<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:endnote w:id="3"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:endnoteRef/></w:r></w:p></w:sdtContent></w:sdt></w:endnote></w:endnotes>"#;
9496 let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:annotationRef/></w:r></w:p></w:sdtContent></w:sdt></w:comment></w:comments>"#;
9497 let facts = extract_document_text_controlled(
9498 &docx_archive_with_parts(&[
9499 ("[Content_Types].xml", note_manifest),
9500 (DOCX_DOCUMENT_PART, main),
9501 ("word/_rels/document.xml.rels", note_rels),
9502 ("word/footnotes.xml", footnotes),
9503 ("word/endnotes.xml", endnotes),
9504 ("word/comments.xml", comments),
9505 ]),
9506 "story-reference-mark.docx",
9507 None,
9508 &control(),
9509 )
9510 .expect("story reference marks replace placeholders without a glossary relationship");
9511 assert_eq!(facts.text.chars().filter(|ch| *ch == '\u{fffc}').count(), 6);
9512 assert!(
9513 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField) && !gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9514 );
9515 let header_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/></Types>"#;
9516 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9517 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p/></w:sdtContent></w:sdt></w:hdr>"#;
9518 let main_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="glossary" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" Target="glossary/document.xml"/></Relationships>"#;
9519 let archive = docx_archive_with_parts(&[
9520 ("[Content_Types].xml", header_manifest),
9521 (DOCX_DOCUMENT_PART, main),
9522 ("word/_rels/document.xml.rels", main_rels),
9523 ("word/header1.xml", header),
9524 ("word/glossary/document.xml", glossary),
9525 ]);
9526 let facts =
9527 extract_document_text_controlled(&archive, "header-glossary.docx", None, &control())
9528 .expect("header placeholder uses the main document glossary relationship");
9529 assert!(
9530 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9531 );
9532 }
9533
9534 #[test]
9535 fn docx_glossary_placeholder_inside_field_instruction_is_not_rendered() {
9536 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent/></w:sdt><w:r><w:fldChar w:fldCharType="separate"/><w:t>Cached</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
9537 let facts = extract_document_text_controlled(
9538 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main)]),
9539 "instruction-placeholder.docx",
9540 None,
9541 &control(),
9542 )
9543 .expect("non-rendered field instruction cannot require a glossary story");
9544 assert_eq!(facts.text, "Cached");
9545 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9546 }
9547
9548 #[test]
9549 fn docx_special_note_settings_share_the_fact_ceiling() {
9550 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
9551 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
9552 for count in [MAX_DOCUMENT_FACTS, MAX_DOCUMENT_FACTS + 1] {
9553 let mut footnotes = String::new();
9554 for id in 0..MAX_DOCUMENT_FACTS / 2 {
9555 write!(footnotes, "<w:footnote w:id=\"{id}\"/>").expect("footnote fixture");
9556 }
9557 let mut endnotes = String::new();
9558 for id in MAX_DOCUMENT_FACTS / 2..count {
9559 write!(endnotes, "<w:endnote w:id=\"{id}\"/>").expect("endnote fixture");
9560 }
9561 let settings = format!(
9562 "<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:footnotePr>{footnotes}</w:footnotePr><w:endnotePr>{endnotes}</w:endnotePr></w:settings>"
9563 );
9564 let facts = extract_document_text_controlled(
9565 &docx_archive_with_parts(&[
9566 (DOCX_DOCUMENT_PART, main),
9567 ("word/_rels/document.xml.rels", rels),
9568 ("word/settings.xml", settings.as_bytes()),
9569 ]),
9570 "special-notes.docx",
9571 None,
9572 &control(),
9573 )
9574 .expect("bounded settings keep verified main text");
9575 assert_eq!(facts.text, "Visible");
9576 assert_eq!(
9577 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount))),
9578 count > MAX_DOCUMENT_FACTS
9579 );
9580 }
9581 }
9582
9583 #[test]
9584 fn docx_story_content_type_must_match_relationship_kind() {
9585 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>"#;
9586 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9587 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9588 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p/></w:hdr>"#;
9589 let archive = docx_archive_with_parts(&[
9590 ("[Content_Types].xml", manifest),
9591 (DOCX_DOCUMENT_PART, main),
9592 ("word/_rels/document.xml.rels", rels),
9593 ("word/header1.xml", header),
9594 ]);
9595 assert!(matches!(
9596 extract_document_text_controlled(&archive, "wrong-type.docx", None, &control()),
9597 Err(DocumentExtractionError::InvalidDocxPackage { .. }),
9598 ));
9599 }
9600
9601 #[test]
9602 fn docx_content_type_default_covers_main_and_story_override_takes_precedence() {
9603 let manifest = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9604 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9605 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9606 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9607 let archive = |manifest: &str| {
9608 docx_archive_with_parts(&[
9609 ("[Content_Types].xml", manifest.as_bytes()),
9610 (DOCX_DOCUMENT_PART, main),
9611 ("word/_rels/document.xml.rels", rels),
9612 ("word/header1.xml", header),
9613 ])
9614 };
9615 let facts =
9616 extract_document_text_controlled(&archive(manifest), "defaults.docx", None, &control())
9617 .expect("default-covered main and overridden header are valid");
9618 assert_eq!(facts.symbols.len(), 2);
9619 let wrong = manifest.replace("wordprocessingml.header+xml", "wordprocessingml.footer+xml");
9620 assert!(matches!(
9621 extract_document_text_controlled(&archive(&wrong), "defaults.docx", None, &control()),
9622 Err(DocumentExtractionError::InvalidDocxPackage { .. })
9623 ));
9624 }
9625
9626 #[test]
9627 fn docx_content_types_reject_undeclared_attribute_prefixes() {
9628 let content_type =
9629 "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
9630 let rels = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
9631 let records = [
9632 format!(
9633 "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\" x:extra=\"1\">{rels}<Override PartName=\"/word/document.xml\" ContentType=\"{content_type}\"/></Types>"
9634 ),
9635 format!(
9636 "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">{rels}<Override PartName=\"/word/document.xml\" ContentType=\"{content_type}\" x:extra=\"1\"/></Types>"
9637 ),
9638 format!(
9639 "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">{rels}<Default Extension=\"xml\" ContentType=\"{content_type}\" x:extra=\"1\"/></Types>"
9640 ),
9641 ];
9642 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Keep old publication</w:t></w:r></w:p></w:body></w:document>"#;
9643 let valid = records[2].replace(" x:extra=\"1\"", "");
9644 let archive = docx_archive_with_parts(&[
9645 ("[Content_Types].xml", valid.as_bytes()),
9646 (DOCX_DOCUMENT_PART, main),
9647 ]);
9648 assert_eq!(
9649 extract_document_text_controlled(&archive, "valid-types.docx", None, &control())
9650 .expect("valid content-type metadata retains rendered text")
9651 .text,
9652 "Keep old publication"
9653 );
9654 for manifest in records {
9655 let archive = docx_archive_with_parts(&[
9656 ("[Content_Types].xml", manifest.as_bytes()),
9657 (DOCX_DOCUMENT_PART, main),
9658 ]);
9659 assert!(matches!(
9660 extract_document_text_controlled(&archive, "invalid-types.docx", None, &control()),
9661 Err(DocumentExtractionError::Malformed { message, .. })
9662 if message.contains("undeclared namespace prefix")
9663 ));
9664 }
9665 }
9666
9667 #[test]
9668 fn docx_utf16_package_metadata_and_stories_preserve_symbols() {
9669 let manifest = r#"<?xml version="1.0" encoding="UTF-16"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9670 let root_rels = r#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
9671 let main = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9672 let rels = r#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9673 let header = r#"<?xml version="1.0" encoding="UTF-16"?><w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9674 for little_endian in [true, false] {
9675 let encode = |xml: &str| {
9676 let mut bytes = if little_endian {
9677 vec![0xff, 0xfe]
9678 } else {
9679 vec![0xfe, 0xff]
9680 };
9681 for unit in xml.encode_utf16() {
9682 bytes.extend_from_slice(&if little_endian {
9683 unit.to_le_bytes()
9684 } else {
9685 unit.to_be_bytes()
9686 });
9687 }
9688 bytes
9689 };
9690 let manifest = encode(manifest);
9691 let root_rels = encode(root_rels);
9692 let main = encode(main);
9693 let rels = encode(rels);
9694 let header = encode(header);
9695 let archive = docx_archive_with_parts(&[
9696 ("[Content_Types].xml", &manifest),
9697 ("_rels/.rels", &root_rels),
9698 (DOCX_DOCUMENT_PART, &main),
9699 ("word/_rels/document.xml.rels", &rels),
9700 ("word/header1.xml", &header),
9701 ]);
9702 let facts = extract_document_text_controlled(&archive, "utf16.docx", None, &control())
9703 .expect("UTF-16 package parts are valid");
9704 assert_eq!(facts.symbols.len(), 2);
9705 }
9706 }
9707
9708 #[test]
9709 fn docx_header_note_uses_main_document_relationship() {
9710 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9711 let main_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="n" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
9712 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:footnoteReference w:id="2"/></w:r></w:p></w:hdr>"#;
9713 let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="2"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
9714 let archive = docx_archive_with_parts(&[
9715 (DOCX_DOCUMENT_PART, main),
9716 ("word/_rels/document.xml.rels", main_rels),
9717 ("word/header1.xml", header),
9718 ("word/footnotes.xml", footnotes),
9719 ]);
9720 let facts = extract_document_text_controlled(&archive, "nested.docx", None, &control())
9721 .expect("reachable nested story is valid");
9722 assert!(facts.symbols.iter().any(|symbol| {
9723 matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/footnotes.xml")
9724 }));
9725 }
9726
9727 #[test]
9728 fn docx_header_note_fanout_batches_ids_before_large_note_part() {
9729 let mut main = String::from(
9730 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>",
9731 );
9732 let mut rels = String::from(
9733 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"notes\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes\" Target=\"footnotes.xml\"/>",
9734 );
9735 let mut notes = String::from(
9736 "<w:footnotes xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><!--",
9737 );
9738 notes.push_str(&"x".repeat(4 * 1024 * 1024));
9739 notes.push_str("-->");
9740 let mut parts = Vec::new();
9741 for index in 0..18 {
9742 write!(&mut main, "<w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h{index}\"/></w:sectPr></w:pPr></w:p>").expect("section fixture");
9743 write!(&mut rels, "<Relationship Id=\"h{index}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header\" Target=\"header{index}.xml\"/>").expect("relationship fixture");
9744 write!(&mut notes, "<w:footnote w:id=\"{index}\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:footnote>").expect("note fixture");
9745 parts.push((format!("word/header{index}.xml"), format!("<w:hdr xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:p><w:r><w:footnoteReference w:id=\"{index}\"/></w:r></w:p></w:hdr>")));
9746 }
9747 main.push_str("</w:body></w:document>");
9748 rels.push_str("</Relationships>");
9749 notes.push_str("</w:footnotes>");
9750 parts.push((DOCX_DOCUMENT_PART.to_owned(), main));
9751 parts.push(("word/_rels/document.xml.rels".to_owned(), rels));
9752 parts.push(("word/footnotes.xml".to_owned(), notes));
9753 let borrowed = parts
9754 .iter()
9755 .map(|(name, xml)| (name.as_str(), xml.as_bytes()))
9756 .collect::<Vec<_>>();
9757 let archive = docx_archive_with_parts(&borrowed);
9758 let facts = extract_document_text_controlled(&archive, "batched.docx", None, &control())
9759 .expect("all referenced notes fit the unique package bounds");
9760 assert_eq!(facts.symbols.len(), 18);
9761 assert!(
9762 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps }
9763 if gaps.contains(&DocumentCoverageGap::UnevaluatedField)
9764 && !gaps.iter().any(|gap| matches!(gap, DocumentCoverageGap::ResourceLimit(_))))
9765 );
9766 }
9767
9768 #[test]
9769 fn docx_inactive_header_variants_do_not_publish_symbols() {
9770 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="d"/><w:headerReference w:type="first" r:id="f"/><w:headerReference w:type="even" r:id="e"/></w:sectPr></w:body></w:document>"#;
9771 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="default.xml"/><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="even.xml"/></Relationships>"#;
9772 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9773 let archive = docx_archive_with_parts(&[
9774 (DOCX_DOCUMENT_PART, main),
9775 ("word/_rels/document.xml.rels", rels),
9776 ("word/default.xml", header),
9777 ("word/first.xml", header),
9778 ("word/even.xml", header),
9779 ]);
9780 let facts = extract_document_text_controlled(&archive, "inactive.docx", None, &control())
9781 .expect("inactive variants are safe to skip");
9782 assert_eq!(facts.symbols.len(), 1);
9783 assert!(
9784 matches!(&facts.symbols[0].locator, DocumentLocator::Docx { part, .. } if part == "word/default.xml")
9785 );
9786 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9787 }
9788
9789 #[test]
9790 fn docx_first_and_default_headers_need_pagination_coverage() {
9791 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="default.xml"/><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/></Relationships>"#;
9792 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9793 for (prior, title, first, symbols, conditional) in [
9794 (
9795 "",
9796 "<w:titlePg/>",
9797 "<w:headerReference w:type=\"first\" r:id=\"f\"/>",
9798 2,
9799 true,
9800 ),
9801 ("", "<w:titlePg/>", "", 1, true),
9802 (
9803 "<w:p><w:pPr><w:sectPr><w:headerReference w:type=\"default\" r:id=\"d\"/></w:sectPr></w:pPr></w:p>",
9804 "<w:titlePg/>",
9805 "",
9806 1,
9807 true,
9808 ),
9809 (
9810 "",
9811 "<w:titlePg w:val=\"false\"/>",
9812 "<w:headerReference w:type=\"first\" r:id=\"f\"/>",
9813 1,
9814 false,
9815 ),
9816 ] {
9817 let default = if prior.is_empty() {
9818 "<w:headerReference w:type=\"default\" r:id=\"d\"/>"
9819 } else {
9820 ""
9821 };
9822 let main = format!(
9823 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>{prior}<w:sectPr>{default}{first}{title}</w:sectPr></w:body></w:document>"
9824 );
9825 let archive = docx_archive_with_parts(&[
9826 (DOCX_DOCUMENT_PART, main.as_bytes()),
9827 ("word/_rels/document.xml.rels", rels),
9828 ("word/default.xml", header),
9829 ("word/first.xml", header),
9830 ]);
9831 let facts =
9832 extract_document_text_controlled(&archive, "headers.docx", None, &control())
9833 .expect("reachable header variants are valid");
9834 assert_eq!(facts.symbols.len(), symbols);
9835 assert_eq!(
9836 matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory)),
9837 conditional
9838 );
9839 }
9840 }
9841
9842 #[test]
9843 fn docx_duplicate_section_story_variant_is_malformed() {
9844 for kind in ["header", "footer"] {
9845 for variant in ["default", "first", "even"] {
9846 let main = format!(
9847 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:sectPr><w:{kind}Reference w:type=\"{variant}\" r:id=\"a\"/><w:{kind}Reference w:type=\"{variant}\" r:id=\"b\"/></w:sectPr></w:body></w:document>"
9848 );
9849 assert!(
9850 matches!(
9851 extract_document_text_controlled(
9852 &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9853 "duplicate-section-variant.docx",
9854 None,
9855 &control(),
9856 ),
9857 Err(DocumentExtractionError::Malformed { .. })
9858 ),
9859 "{kind} {variant}"
9860 );
9861 }
9862 }
9863 }
9864
9865 #[test]
9866 fn docx_section_properties_require_document_ancestry() {
9867 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
9868 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9869 for (invalid, in_body) in [
9870 (
9871 "<w:p><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:p>",
9872 true,
9873 ),
9874 (
9875 "<w:wrapper><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:wrapper>",
9876 true,
9877 ),
9878 (
9879 "<w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr>",
9880 true,
9881 ),
9882 (
9883 "<w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><wps:wsp><wps:txbx><w:txbxContent><w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></w:p></w:txbxContent></wps:txbx></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>",
9884 true,
9885 ),
9886 (
9887 "<w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></w:p>",
9888 false,
9889 ),
9890 ] {
9891 let content = if in_body {
9892 format!("<w:body>{invalid}</w:body>")
9893 } else {
9894 invalid.to_owned()
9895 };
9896 let main = format!(
9897 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">{content}</w:document>"
9898 );
9899 let archive = docx_archive_with_parts(&[
9900 (DOCX_DOCUMENT_PART, main.as_bytes()),
9901 ("word/_rels/document.xml.rels", rels),
9902 ("word/header.xml", header),
9903 ]);
9904 assert!(
9905 matches!(
9906 extract_document_text_controlled(
9907 &archive,
9908 "invalid-section.docx",
9909 None,
9910 &control()
9911 ),
9912 Err(DocumentExtractionError::Malformed { .. })
9913 ),
9914 "{invalid}"
9915 );
9916 }
9917 for valid in [
9918 "<mc:AlternateContent><mc:Choice Requires=\"w\"><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></mc:Choice><mc:Fallback/></mc:AlternateContent>",
9919 "<w:p><mc:AlternateContent><mc:Choice Requires=\"w\"><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:p>",
9920 ] {
9921 let main = format!(
9922 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body>{valid}</w:body></w:document>"
9923 );
9924 let archive = docx_archive_with_parts(&[
9925 (DOCX_DOCUMENT_PART, main.as_bytes()),
9926 ("word/_rels/document.xml.rels", rels),
9927 ("word/header.xml", header),
9928 ]);
9929 let facts =
9930 extract_document_text_controlled(&archive, "valid-section.docx", None, &control())
9931 .expect("selected compatibility wrapper preserves valid section ancestry");
9932 assert_eq!(facts.symbols.len(), 1, "{valid}");
9933 }
9934 }
9935
9936 #[test]
9937 fn docx_run_payload_requires_rendered_paragraph_ancestry() {
9938 for invalid in [
9939 "<w:pPr><w:r><w:t>Injected</w:t></w:r></w:pPr>",
9940 "<w:pPr><w:hyperlink><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:hyperlink></w:pPr>",
9941 "<w:pPr><w:p><w:r><w:t>Injected</w:t></w:r></w:p></w:pPr>",
9942 ] {
9943 let xml = format!(
9944 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p>{invalid}</w:p></w:body></w:document>"
9945 );
9946 assert!(
9947 matches!(
9948 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9949 Err(DocumentExtractionError::Malformed { .. })
9950 ),
9951 "{invalid}"
9952 );
9953 }
9954 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:p><w:r><w:t>Injected</w:t></w:r></w:p></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Content</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9955 assert!(matches!(
9956 parse_docx(xml, &control(), IndexWorkStage::TextIndex),
9957 Err(DocumentExtractionError::Malformed { .. })
9958 ));
9959 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:hyperlink><w:r><w:t>Linked</w:t></w:r></w:hyperlink><w:sdt><w:sdtContent><w:r><w:t>Content</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
9960 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
9961 .expect("supported paragraph-content containers retain runs");
9962 assert_eq!(facts.text, "LinkedContent");
9963 }
9964
9965 #[test]
9966 fn docx_story_anchors_require_rendered_content_parents() {
9967 for invalid in [
9968 "<w:p><w:pPr><w:altChunk r:id=\"chunk\"/></w:pPr></w:p>",
9969 "<w:p><w:pPr><w:subDoc r:id=\"child\"/></w:pPr></w:p>",
9970 "<w:p><w:sdt><w:sdtPr><w:subDoc r:id=\"child\"/></w:sdtPr><w:sdtContent/></w:sdt></w:p>",
9971 "<w:p><w:r><w:drawing><w:txbxContent><w:altChunk r:id=\"chunk\"/></w:txbxContent></w:drawing></w:r></w:p>",
9972 ] {
9973 let xml = format!(
9974 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>{invalid}</w:body></w:document>"
9975 );
9976 assert!(
9977 matches!(
9978 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9979 Err(DocumentExtractionError::Malformed { .. })
9980 ),
9981 "{invalid}"
9982 );
9983 }
9984 }
9985
9986 #[test]
9987 fn docx_main_document_requires_one_direct_body() {
9988 for content in [
9989 "",
9990 "<w:body/><w:body><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:body>",
9991 "<w:body><w:p><w:body/></w:p></w:body>",
9992 ] {
9993 let xml = format!(
9994 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{content}</w:document>"
9995 );
9996 assert!(
9997 matches!(
9998 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9999 Err(DocumentExtractionError::Malformed { .. })
10000 ),
10001 "{content}"
10002 );
10003 }
10004 }
10005
10006 #[test]
10007 fn docx_top_level_compatibility_body_preserves_coverage() {
10008 let uncertain = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:body><w:p><w:r><w:t>Unknown</w:t></w:r></w:p></w:body></mc:Choice><mc:Fallback><w:body><w:p><w:r><w:t>Fallback</w:t></w:r></w:p></w:body></mc:Fallback></mc:AlternateContent></w:document>"#;
10009 let parsed = extract_document_text_controlled(
10010 &docx_archive(uncertain, CompressionMethod::Stored),
10011 "uncertain-body.docx",
10012 None,
10013 &control(),
10014 )
10015 .expect("an unexamined top-level alternative does not prove a missing body");
10016 assert!(parsed.text.is_empty());
10017 assert!(parsed.symbols.is_empty());
10018 assert!(
10019 matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
10020 );
10021
10022 let selected = std::str::from_utf8(uncertain)
10023 .expect("ASCII fixture")
10024 .replace("Requires=\"future\"", "Requires=\"w\"");
10025 let parsed = extract_document_text_controlled(
10026 &docx_archive(selected.as_bytes(), CompressionMethod::Stored),
10027 "selected-body.docx",
10028 None,
10029 &control(),
10030 )
10031 .expect("the supported direct-root choice selects its body");
10032 assert_eq!(parsed.text, "Unknown");
10033 assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
10034
10035 let absent = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:p/></mc:Choice><mc:Fallback><w:p/></mc:Fallback></mc:AlternateContent></w:document>"#;
10036 assert!(matches!(
10037 parse_docx(absent, &control(), IndexWorkStage::TextIndex),
10038 Err(DocumentExtractionError::Malformed { .. })
10039 ));
10040 }
10041
10042 #[test]
10043 fn docx_character_reference_whitespace_between_elements_is_accepted() {
10044 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>First</w:t></w:r></w:p>
 <w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#;
10045 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10046 .expect("XML character-reference whitespace is structural whitespace");
10047 assert_eq!(facts.text, "First\nSecond");
10048 }
10049
10050 #[test]
10051 fn docx_structural_cdata_accepts_only_xml_whitespace_inside_the_root() {
10052 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>First</w:t></w:r></w:p><![CDATA[
10053 ]]><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#;
10054 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10055 .expect("whitespace CDATA inside a story is structural whitespace");
10056 assert_eq!(facts.text, "First\nSecond");
10057 for xml in [
10058 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><![CDATA[Injected]]></w:body></w:document>"#.as_slice(),
10059 br#"<![CDATA[ ]]><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#.as_slice(),
10060 ] {
10061 assert!(matches!(
10062 parse_docx(xml, &control(), IndexWorkStage::TextIndex),
10063 Err(DocumentExtractionError::Malformed { .. })
10064 ));
10065 }
10066 }
10067
10068 #[test]
10069 fn docx_xml_characters_and_root_reference_boundaries_are_validated() {
10070 let valid = "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>";
10071 for invalid in [
10072 format!(" {valid}"),
10073 format!("{valid} "),
10074 valid.replace("<w:body>", "<w:body>\u{b}"),
10075 valid.replace("<w:t>Visible", "<w:t>\u{c}Visible"),
10076 valid.replace("<w:t>Visible", "<w:t>Visible"),
10077 valid.replace("<w:body>", "<w:body w:extra=\"\">"),
10078 ] {
10079 assert!(
10080 matches!(
10081 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
10082 Err(DocumentExtractionError::Malformed { .. })
10083 ),
10084 "{invalid:?}"
10085 );
10086 }
10087 }
10088
10089 #[test]
10090 fn docx_xml_declarations_and_comments_follow_xml_grammar() {
10091 let valid = "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>";
10092 for invalid in [
10093 format!("<?xml encoding=\"UTF-8\"?>{valid}"),
10094 format!("<?xml version=\"1.0\"?>{valid}"),
10095 format!("<?xml version=\"1.0\" version=\"1.0\"?>{valid}"),
10096 format!("<?xml version=\"1.0\" standalone=\"maybe\"?>{valid}"),
10097 format!("<?XML version=\"1.0\"?>{valid}"),
10098 valid.replace("<w:body>", "<w:body><!--bad--comment-->"),
10099 ] {
10100 assert!(
10101 matches!(
10102 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
10103 Err(DocumentExtractionError::Malformed { .. })
10104 ),
10105 "{invalid}"
10106 );
10107 }
10108 let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><!--bad--comment--></Types>";
10109 assert!(matches!(
10110 parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex),
10111 Err(DocumentExtractionError::Malformed { .. })
10112 ));
10113 }
10114
10115 #[test]
10116 fn docx_literal_xml_line_endings_normalize_before_publication() {
10117 let xml = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>First\r\nSecond<![CDATA[\rThird]]></w:t></w:r></w:p></w:body></w:document>";
10118 let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10119 .expect("literal source line endings remain valid XML text");
10120 assert_eq!(facts.text, "First\nSecond\nThird");
10121 let referenced = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>First
Second</w:t></w:r></w:p></w:body></w:document>";
10122 let facts = parse_docx(referenced, &control(), IndexWorkStage::TextIndex)
10123 .expect("character references are expanded after literal EOL normalization");
10124 assert_eq!(facts.text, "First\rSecond");
10125 }
10126
10127 #[test]
10128 fn docx_table_block_wrappers_preserve_cell_paragraphs() {
10129 for table in [
10130 "<w:tbl><w:sdt><w:sdtContent><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:sdtContent></w:sdt></w:tbl>",
10131 "<w:tbl><w:customXml w:element=\"row\"><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:customXml></w:tbl>",
10132 "<w:tbl><w:tr><w:sdt><w:sdtContent><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:sdtContent></w:sdt></w:tr></w:tbl>",
10133 "<w:tbl><w:tr><w:customXml w:element=\"cell\"><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:customXml></w:tr></w:tbl>",
10134 ] {
10135 let xml = format!(
10136 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>{table}</w:body></w:document>"
10137 );
10138 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
10139 .expect("table-level wrappers retain rendered cell text");
10140 assert_eq!(facts.text, "Cell", "{table}");
10141 }
10142 }
10143
10144 #[test]
10145 fn docx_historical_section_properties_do_not_select_old_headers() {
10146 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="current"/><w:sectPrChange><w:sectPr><w:headerReference w:type="default" r:id="old"/></w:sectPr></w:sectPrChange></w:sectPr></w:body></w:document>"#;
10147 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="current" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
10148 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10149 let archive = docx_archive_with_parts(&[
10150 (DOCX_DOCUMENT_PART, main),
10151 ("word/_rels/document.xml.rels", rels),
10152 ("word/header.xml", header),
10153 ]);
10154 let facts = extract_document_text_controlled(&archive, "revision.docx", None, &control())
10155 .expect("tracked old section must not replace current header");
10156 assert_eq!(facts.symbols.len(), 1);
10157 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
10158 }
10159
10160 #[test]
10161 fn docx_historical_section_properties_validate_suppressed_attributes() {
10162 for history in [
10163 r#"<w:sectPrChange x:extra="1"><w:sectPr/></w:sectPrChange>"#,
10164 r#"<w:sectPrChange><w:sectPr x:extra="1"/></w:sectPrChange>"#,
10165 ] {
10166 let main = format!(
10167 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sectPr>{history}</w:sectPr></w:body></w:document>"
10168 );
10169 let archive = docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]);
10170 assert!(matches!(
10171 extract_document_text_controlled(&archive, "revision.docx", None, &control()),
10172 Err(DocumentExtractionError::Malformed { .. })
10173 ));
10174 }
10175 }
10176
10177 #[test]
10178 fn docx_later_title_page_inherits_prior_first_header() {
10179 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:pPr><w:sectPr><w:headerReference w:type="first" r:id="prior"/></w:sectPr></w:pPr></w:p><w:sectPr><w:titlePg/></w:sectPr></w:body></w:document>"#;
10180 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="prior" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="prior.xml"/></Relationships>"#;
10181 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10182 let archive = docx_archive_with_parts(&[
10183 (DOCX_DOCUMENT_PART, main),
10184 ("word/_rels/document.xml.rels", rels),
10185 ("word/prior.xml", header),
10186 ]);
10187 let facts = extract_document_text_controlled(&archive, "inherit.docx", None, &control())
10188 .expect("later title page inherits prior first header");
10189 assert_eq!(facts.symbols.len(), 1);
10190 assert_eq!(facts.completeness, DocumentCompleteness::Complete);
10191 let conflicting = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="prior"/><w:titlePg w:val="false"/><w:titlePg w:val="true"/></w:sectPr></w:body></w:document>"#;
10192 let archive = docx_archive_with_parts(&[
10193 (DOCX_DOCUMENT_PART, conflicting),
10194 ("word/_rels/document.xml.rels", rels),
10195 ("word/prior.xml", header),
10196 ]);
10197 assert!(matches!(
10198 extract_document_text_controlled(
10199 &archive,
10200 "duplicate-title-page.docx",
10201 None,
10202 &control()
10203 ),
10204 Err(DocumentExtractionError::Malformed { .. })
10205 ));
10206 }
10207
10208 #[test]
10209 fn docx_root_relationship_selects_nonconventional_main_part() {
10210 let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/header.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
10211 let root_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="./%64ocument.xml"/></Relationships>"#;
10212 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
10213 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
10214 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10215 let archive = docx_archive_with_parts(&[
10216 ("[Content_Types].xml", manifest),
10217 ("_rels/.rels", root_rels),
10218 ("document.xml", main),
10219 ("_rels/document.xml.rels", rels),
10220 ("header.xml", header),
10221 ]);
10222 let facts = extract_document_text_controlled(&archive, "root.docx", None, &control())
10223 .expect("OPC root relationship selects the main part");
10224 assert_eq!(facts.symbols.len(), 2);
10225 assert!(facts.symbols.iter().any(|symbol| {
10226 matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "document.xml")
10227 }));
10228 assert!(facts.symbols.iter().any(|symbol| {
10229 matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "header.xml")
10230 }));
10231 }
10232
10233 #[test]
10234 fn docx_missing_opc_metadata_never_publishes_complete_text() {
10235 let mut bytes = Vec::new();
10236 {
10237 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
10238 writer
10239 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
10240 .expect("fixture part");
10241 writer
10242 .write_all(br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#)
10243 .expect("fixture XML");
10244 writer.finish().expect("fixture package");
10245 }
10246 assert!(matches!(
10247 extract_document_text_controlled(&bytes, "truncated.docx", None, &control()),
10248 Err(DocumentExtractionError::InvalidDocxPackage { .. }),
10249 ));
10250 }
10251
10252 #[test]
10253 fn docx_enabled_header_variants_preserve_symbols_with_conditional_coverage() {
10254 let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="f"/><w:headerReference w:type="even" r:id="e"/><w:titlePg/></w:sectPr></w:body></w:document>"#;
10255 let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="even.xml"/><Relationship Id="s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
10256 let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10257 let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:evenAndOddHeaders/></w:settings>"#;
10258 let archive = docx_archive_with_parts(&[
10259 (DOCX_DOCUMENT_PART, main),
10260 ("word/_rels/document.xml.rels", rels),
10261 ("word/first.xml", header),
10262 ("word/even.xml", header),
10263 ("word/settings.xml", settings),
10264 ]);
10265 let facts = extract_document_text_controlled(&archive, "enabled.docx", None, &control())
10266 .expect("enabled variants are reachable");
10267 assert_eq!(facts.symbols.len(), 2);
10268 assert_eq!(
10269 facts.completeness,
10270 DocumentCompleteness::Partial {
10271 gaps: vec![DocumentCoverageGap::ConditionalStory],
10272 }
10273 );
10274 let first_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="f"/><w:titlePg/></w:sectPr></w:body></w:document>"#;
10275 let first_only_archive = docx_archive_with_parts(&[
10276 (DOCX_DOCUMENT_PART, first_only),
10277 ("word/_rels/document.xml.rels", rels),
10278 ("word/first.xml", header),
10279 ("word/even.xml", header),
10280 ("word/settings.xml", settings),
10281 ]);
10282 let first_only_facts = extract_document_text_controlled(
10283 &first_only_archive,
10284 "first-only.docx",
10285 None,
10286 &control(),
10287 )
10288 .expect("first-page header does not depend on page parity");
10289 assert_eq!(first_only_facts.symbols.len(), 1);
10290 assert_eq!(
10291 first_only_facts.completeness,
10292 DocumentCompleteness::Complete
10293 );
10294 let default_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="f"/></w:sectPr></w:body></w:document>"#;
10295 let default_only_archive = docx_archive_with_parts(&[
10296 (DOCX_DOCUMENT_PART, default_only),
10297 ("word/_rels/document.xml.rels", rels),
10298 ("word/first.xml", header),
10299 ("word/settings.xml", settings),
10300 ]);
10301 let default_only_facts = extract_document_text_controlled(
10302 &default_only_archive,
10303 "default-only.docx",
10304 None,
10305 &control(),
10306 )
10307 .expect("default header remains parity-dependent");
10308 assert_eq!(default_only_facts.symbols.len(), 1);
10309 assert_eq!(
10310 default_only_facts.completeness,
10311 DocumentCompleteness::Partial {
10312 gaps: vec![DocumentCoverageGap::ConditionalStory],
10313 }
10314 );
10315 let undeclared_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><x:evenAndOddHeaders/></w:settings>"#;
10316 let malformed = docx_archive_with_parts(&[
10317 (DOCX_DOCUMENT_PART, main),
10318 ("word/_rels/document.xml.rels", rels),
10319 ("word/first.xml", header),
10320 ("word/even.xml", header),
10321 ("word/settings.xml", undeclared_settings),
10322 ]);
10323 assert!(matches!(
10324 extract_document_text_controlled(&malformed, "undeclared-settings.docx", None, &control()),
10325 Err(DocumentExtractionError::Malformed { message, .. })
10326 if message.contains("undeclared namespace prefix")
10327 ));
10328 for settings in [
10329 br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:evenAndOddHeaders x:val="false"/></w:settings>"#.as_slice(),
10330 br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:ignored="true"><w:evenAndOddHeaders/></w:settings>"#.as_slice(),
10331 ] {
10332 let malformed = docx_archive_with_parts(&[
10333 (DOCX_DOCUMENT_PART, main),
10334 ("word/_rels/document.xml.rels", rels),
10335 ("word/first.xml", header),
10336 ("word/even.xml", header),
10337 ("word/settings.xml", settings),
10338 ]);
10339 assert!(matches!(
10340 extract_document_text_controlled(&malformed, "undeclared-settings.docx", None, &control()),
10341 Err(DocumentExtractionError::Malformed { message, .. })
10342 if message.contains("undeclared namespace prefix")
10343 ));
10344 }
10345 }
10346
10347 #[test]
10348 fn docx_post_admission_fact_limit_is_local_incomplete_coverage() {
10349 for count in [
10350 MAX_DOCUMENT_FACTS - 1,
10351 MAX_DOCUMENT_FACTS,
10352 MAX_DOCUMENT_FACTS + 1,
10353 ] {
10354 let xml = format!(
10355 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}</w:r></w:p></w:body></w:document>",
10356 "<w:sym w:font=\"Wingdings\" w:char=\"F03A\"/>".repeat(count),
10357 );
10358 let archive = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
10359 let facts =
10360 extract_document_text_controlled(&archive, "limited.docx", None, &control())
10361 .expect("safe post-admission fact ceiling is file-local");
10362 if count < MAX_DOCUMENT_FACTS {
10363 assert_eq!(facts.symbols.len(), count);
10364 assert_eq!(facts.facts.len(), 1);
10365 assert!(
10366 !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount)))
10367 );
10368 } else {
10369 assert!(facts.symbols.is_empty());
10370 assert_eq!(
10371 facts.completeness,
10372 DocumentCompleteness::Partial {
10373 gaps: vec![DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount)],
10374 }
10375 );
10376 }
10377 }
10378 }
10379
10380 #[test]
10381 fn direct_xml_rejects_external_doctype_declarations() {
10382 let xml = br#"<!DOCTYPE w:document SYSTEM "https://example.invalid/document.dtd"><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
10383 let bytes = docx_archive(xml, CompressionMethod::Stored);
10384 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
10385 .expect_err("external declarations must never enter the parser boundary");
10386 assert!(matches!(
10387 error,
10388 DocumentExtractionError::Malformed {
10389 format: DocumentFormat::Docx,
10390 ..
10391 }
10392 ));
10393 }
10394
10395 #[test]
10396 fn direct_xml_rejects_non_whitespace_outside_document_root() {
10397 let xml = br#"prefix<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#;
10398 let bytes = docx_archive(xml, CompressionMethod::Stored);
10399 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
10400 .expect_err("non-whitespace outside the root must fail closed");
10401 assert!(matches!(
10402 error,
10403 DocumentExtractionError::Malformed {
10404 format: DocumentFormat::Docx,
10405 ..
10406 }
10407 ));
10408 }
10409}