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::{BytesRef, Event};
8use quick_xml::name::{QName, ResolveResult};
9use std::collections::HashSet;
10use std::fmt;
11use std::io::{Cursor, Read};
12use std::path::Path;
13use std::sync::{Mutex, MutexGuard, TryLockError};
14use std::time::Duration;
15use thiserror::Error;
16use zip::{CompressionMethod, ZipArchive};
17
18#[path = "../../../packaging/pdf-parser/limits.rs"]
19mod limits;
20mod pdf_runtime;
21
22pub const LOPDF_VERSION: &str = "0.44.0";
24pub const PDF_EXTRACT_VERSION: &str = "0.12.0+projectatlas";
26pub const QUICK_XML_VERSION: &str = "0.42.0";
28pub const MAX_DOCUMENT_COMPRESSED_BYTES: usize = limits::INPUT_LIMIT;
30pub const MAX_DOCUMENT_EXPANDED_BYTES: usize = limits::EXPANDED_LIMIT;
32pub const MAX_DOCUMENT_OUTPUT_BYTES: usize = limits::OUTPUT_LIMIT;
34pub const MAX_DOCUMENT_MEMORY_BYTES: usize = 96 * 1024 * 1024;
36pub const MAX_DOCUMENT_ENTRIES: usize = 256;
38pub const MAX_DOCUMENT_RECURSION_DEPTH: usize = 1;
40const MAX_DOCX_XML_DEPTH: usize = 64;
42const MAX_DOCX_IGNORABLE_NAMESPACES: usize = 64;
44pub const MAX_DOCUMENT_FACTS: usize = limits::FACT_LIMIT;
46pub const DOCX_DOCUMENT_PART: &str = "word/document.xml";
48
49static DOCUMENT_EXECUTION: Mutex<()> = Mutex::new(());
52
53fn lock_document_execution(
55 control: &IndexWorkControl,
56 stage: IndexWorkStage,
57) -> Result<MutexGuard<'static, ()>, DocumentExtractionError> {
58 loop {
59 control.check(stage)?;
60 match DOCUMENT_EXECUTION.try_lock() {
61 Ok(guard) => return Ok(guard),
62 Err(TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
64 Err(TryLockError::WouldBlock) => {
65 std::thread::park_timeout(Duration::from_millis(10));
66 }
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum DocumentFormat {
74 Pdf,
76 Docx,
78}
79
80impl DocumentFormat {
81 #[must_use]
83 pub const fn language(self) -> &'static str {
84 match self {
85 Self::Pdf => "pdf",
86 Self::Docx => "docx",
87 }
88 }
89}
90
91impl fmt::Display for DocumentFormat {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 formatter.write_str(self.language())
94 }
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99pub enum DocumentCompleteness {
100 Complete,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
106pub enum DocumentLocator {
107 Pdf {
109 page: usize,
111 text_start: usize,
113 text_end: usize,
115 },
116 Docx {
118 part: &'static str,
120 paragraph: usize,
122 run: usize,
124 text_start: usize,
126 text_end: usize,
128 },
129}
130
131impl fmt::Display for DocumentLocator {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Pdf {
135 page,
136 text_start,
137 text_end,
138 } => write!(
139 formatter,
140 "pdf:page={page};text-span={text_start}..{text_end}"
141 ),
142 Self::Docx {
143 part,
144 paragraph,
145 run,
146 text_start,
147 text_end,
148 } => write!(
149 formatter,
150 "docx:part={part};paragraph={paragraph};run={run};text-span={text_start}..{text_end}"
151 ),
152 }
153 }
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum DocumentParserProvenance {
159 PdfExtract,
161 QuickXml,
163}
164
165impl fmt::Display for DocumentParserProvenance {
166 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167 formatter.write_str(match self {
168 Self::PdfExtract => "pdf-extract-0.12.0+projectatlas",
169 Self::QuickXml => "quick-xml-0.42.0",
170 })
171 }
172}
173
174#[derive(Clone, Debug, Eq, PartialEq)]
176pub struct DocumentFact {
177 pub text: String,
179 pub locator: DocumentLocator,
181 pub line_start: usize,
183 pub line_end: usize,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct DocumentFacts {
190 pub format: DocumentFormat,
192 pub text: String,
194 pub facts: Vec<DocumentFact>,
196 pub completeness: DocumentCompleteness,
198 pub provenance: DocumentParserProvenance,
200}
201
202impl DocumentFacts {
203 #[must_use]
205 pub fn symbol_graph(&self, path: &str, language: Option<&str>) -> SymbolGraph {
206 let symbols = self
207 .facts
208 .iter()
209 .enumerate()
210 .map(|(index, fact)| CodeSymbol {
211 path: path.to_owned(),
212 language: language.map(str::to_owned),
213 name: format!("document-block-{}", index + 1),
214 kind: SymbolKind::Value,
215 signature: fact.locator.to_string(),
216 exported: false,
217 documentation: None,
218 line_start: fact.line_start,
219 line_end: fact.line_end,
220 source_selector: None,
221 parent: None,
222 parser: ParserKind::Structural,
223 detail: Some(format!(
224 "format={};provenance={};completeness={:?};text-bytes={}",
225 self.format,
226 self.provenance,
227 self.completeness,
228 fact.text.len()
229 )),
230 })
231 .collect();
232 SymbolGraph {
233 path: path.to_owned(),
234 language: language.map(str::to_owned),
235 parser: ParserKind::Structural,
236 symbols,
237 relations: Vec::new(),
238 }
239 }
240}
241
242#[derive(Clone, Copy, Debug, Eq, PartialEq)]
244pub enum DocumentLimit {
245 InputBytes,
247 CompressedBytes,
249 ExpandedBytes,
251 OutputBytes,
253 MemoryBytes,
255 EntryCount,
257 FactCount,
259 NestingDepth,
261 ExecutionFuel,
263}
264
265impl fmt::Display for DocumentLimit {
266 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267 formatter.write_str(match self {
268 Self::InputBytes => "input_bytes",
269 Self::CompressedBytes => "compressed_bytes",
270 Self::ExpandedBytes => "expanded_bytes",
271 Self::OutputBytes => "output_bytes",
272 Self::MemoryBytes => "memory_bytes",
273 Self::EntryCount => "entry_count",
274 Self::FactCount => "fact_count",
275 Self::NestingDepth => "nesting_depth",
276 Self::ExecutionFuel => "execution_fuel",
277 })
278 }
279}
280
281#[derive(Debug, Error)]
283pub enum DocumentExtractionError {
284 #[error("unsupported document format for language {language}")]
286 UnsupportedFormat {
287 language: String,
289 },
290 #[error("document magic does not match expected {expected} format; found {found}")]
292 MismatchedMagic {
293 expected: DocumentFormat,
295 found: &'static str,
297 },
298 #[error("document exceeded {limit}: observed {observed}, limit {maximum}")]
300 ResourceLimit {
301 limit: DocumentLimit,
303 observed: usize,
305 maximum: usize,
307 },
308 #[error("encrypted PDF documents are unsupported")]
310 EncryptedPdf,
311 #[error("unsupported PDF text semantics")]
313 UnsupportedPdfInput,
314 #[error("malformed {format} document: {message}")]
316 Malformed {
317 format: DocumentFormat,
319 message: String,
321 },
322 #[error("invalid DOCX package: {message}")]
324 InvalidDocxPackage {
325 message: String,
327 },
328 #[error("unsupported DOCX package input: {message}")]
330 UnsupportedDocxInput {
331 message: String,
333 },
334 #[error(transparent)]
336 Work(#[from] IndexWorkFailure),
337}
338
339#[must_use]
341pub fn document_format_for_path(path: &str, language: Option<&str>) -> Option<DocumentFormat> {
342 let language = language.or_else(|| Path::new(path).extension()?.to_str())?;
343 match language.to_ascii_lowercase().as_str() {
344 "pdf" => Some(DocumentFormat::Pdf),
345 "docx" => Some(DocumentFormat::Docx),
346 _ => None,
347 }
348}
349
350pub fn extract_document_text_controlled(
357 bytes: &[u8],
358 path: &str,
359 language: Option<&str>,
360 control: &IndexWorkControl,
361) -> Result<DocumentFacts, DocumentExtractionError> {
362 extract_document_controlled_with_stage(
363 bytes,
364 path,
365 language,
366 control,
367 IndexWorkStage::TextIndex,
368 )
369}
370
371pub fn extract_document_graph_controlled(
378 bytes: &[u8],
379 path: &str,
380 language: Option<&str>,
381 control: &IndexWorkControl,
382) -> Result<SymbolGraph, DocumentExtractionError> {
383 Ok(
384 extract_document_symbol_facts_controlled(bytes, path, language, control)?
385 .symbol_graph(path, language),
386 )
387}
388
389pub fn extract_document_symbol_facts_controlled(
396 bytes: &[u8],
397 path: &str,
398 language: Option<&str>,
399 control: &IndexWorkControl,
400) -> Result<DocumentFacts, DocumentExtractionError> {
401 extract_document_controlled_with_stage(
402 bytes,
403 path,
404 language,
405 control,
406 IndexWorkStage::SymbolParsing,
407 )
408}
409
410fn extract_document_controlled_with_stage(
412 bytes: &[u8],
413 path: &str,
414 language: Option<&str>,
415 control: &IndexWorkControl,
416 stage: IndexWorkStage,
417) -> Result<DocumentFacts, DocumentExtractionError> {
418 control.check(stage)?;
419 if bytes.len() > MAX_DOCUMENT_COMPRESSED_BYTES {
420 return Err(DocumentExtractionError::ResourceLimit {
421 limit: DocumentLimit::InputBytes,
422 observed: bytes.len(),
423 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
424 });
425 }
426 let format = document_format_for_path(path, language).ok_or_else(|| {
427 DocumentExtractionError::UnsupportedFormat {
428 language: language.unwrap_or("unknown").to_owned(),
429 }
430 })?;
431 let _execution = lock_document_execution(control, stage)?;
432 let facts = match format {
433 DocumentFormat::Pdf => extract_pdf(bytes, control, stage)?,
434 DocumentFormat::Docx => extract_docx(bytes, control, stage)?,
435 };
436 control.check(stage)?;
437 Ok(facts)
438}
439
440fn extract_pdf(
442 bytes: &[u8],
443 control: &IndexWorkControl,
444 stage: IndexWorkStage,
445) -> Result<DocumentFacts, DocumentExtractionError> {
446 if !bytes.starts_with(b"%PDF-") {
447 return Err(DocumentExtractionError::MismatchedMagic {
448 expected: DocumentFormat::Pdf,
449 found: if bytes.starts_with(b"PK\x03\x04") {
450 "docx"
451 } else {
452 "unknown"
453 },
454 });
455 }
456 let pages = pdf_runtime::extract_pages(bytes, control, stage)?;
457 let mut text = String::new();
458 let mut facts = Vec::new();
459 for (page_number, page) in &pages {
460 control.check(stage)?;
461 let mut page_offset = 0usize;
462 for line in page.split('\n') {
463 let source_start = page_offset;
464 page_offset = page_offset.saturating_add(line.len().saturating_add(1));
465 if line.trim().is_empty() {
466 continue;
467 }
468 if facts.len() >= MAX_DOCUMENT_FACTS {
469 return Err(DocumentExtractionError::ResourceLimit {
470 limit: DocumentLimit::FactCount,
471 observed: facts.len().saturating_add(1),
472 maximum: MAX_DOCUMENT_FACTS,
473 });
474 }
475 let required = text
476 .len()
477 .saturating_add(usize::from(!text.is_empty()))
478 .saturating_add(line.len());
479 if required > MAX_DOCUMENT_OUTPUT_BYTES {
480 return Err(DocumentExtractionError::ResourceLimit {
481 limit: DocumentLimit::OutputBytes,
482 observed: required,
483 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
484 });
485 }
486 if !text.is_empty() {
487 push_output_byte(&mut text, b'\n')?;
488 }
489 text.push_str(line);
490 let end = text.len();
491 facts.push(DocumentFact {
492 line_start: facts.len() + 1,
493 line_end: facts.len() + 1,
494 text: line.to_owned(),
495 locator: DocumentLocator::Pdf {
496 page: usize::try_from(*page_number).map_err(|_error| {
497 DocumentExtractionError::Malformed {
498 format: DocumentFormat::Pdf,
499 message: "PDF page number exceeds host range".to_owned(),
500 }
501 })?,
502 text_start: source_start,
503 text_end: source_start.saturating_add(line.len()),
504 },
505 });
506 debug_assert!(end <= MAX_DOCUMENT_OUTPUT_BYTES);
507 }
508 }
509 Ok(DocumentFacts {
510 format: DocumentFormat::Pdf,
511 text,
512 facts,
513 completeness: DocumentCompleteness::Complete,
514 provenance: DocumentParserProvenance::PdfExtract,
515 })
516}
517
518fn push_output_byte(text: &mut String, byte: u8) -> Result<(), DocumentExtractionError> {
520 if text.len().saturating_add(1) > MAX_DOCUMENT_OUTPUT_BYTES {
521 return Err(DocumentExtractionError::ResourceLimit {
522 limit: DocumentLimit::OutputBytes,
523 observed: text.len().saturating_add(1),
524 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
525 });
526 }
527 text.push(char::from(byte));
528 Ok(())
529}
530
531fn check_memory_budget(observed: usize) -> Result<(), DocumentExtractionError> {
533 if observed > MAX_DOCUMENT_MEMORY_BYTES {
534 return Err(DocumentExtractionError::ResourceLimit {
535 limit: DocumentLimit::MemoryBytes,
536 observed,
537 maximum: MAX_DOCUMENT_MEMORY_BYTES,
538 });
539 }
540 Ok(())
541}
542
543fn extract_docx(
545 bytes: &[u8],
546 control: &IndexWorkControl,
547 stage: IndexWorkStage,
548) -> Result<DocumentFacts, DocumentExtractionError> {
549 if !bytes.starts_with(b"PK\x03\x04") {
550 return Err(DocumentExtractionError::MismatchedMagic {
551 expected: DocumentFormat::Docx,
552 found: if bytes.starts_with(b"%PDF-") {
553 "pdf"
554 } else {
555 "unknown"
556 },
557 });
558 }
559 let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|error| {
560 DocumentExtractionError::InvalidDocxPackage {
561 message: error.to_string(),
562 }
563 })?;
564 if archive.len() > MAX_DOCUMENT_ENTRIES {
565 return Err(DocumentExtractionError::ResourceLimit {
566 limit: DocumentLimit::EntryCount,
567 observed: archive.len(),
568 maximum: MAX_DOCUMENT_ENTRIES,
569 });
570 }
571 let mut compressed_bytes = 0usize;
572 let mut expanded_bytes = 0usize;
573 let mut names = HashSet::new();
574 for index in 0..archive.len() {
575 check_parser_iteration(index, &mut || control.check(stage))?;
576 let entry = archive.by_index_raw(index).map_err(|error| {
577 DocumentExtractionError::InvalidDocxPackage {
578 message: error.to_string(),
579 }
580 })?;
581 if std::str::from_utf8(entry.name_raw()).is_err() {
582 return Err(DocumentExtractionError::InvalidDocxPackage {
583 message: "DOCX package part metadata is not UTF-8".to_owned(),
584 });
585 }
586 let name = entry.name().to_owned();
587 let enclosed = entry.enclosed_name().is_some();
588 let compression = entry.compression();
589 let compressed_size = entry.compressed_size();
590 let expanded_size = entry.size();
591 if !matches!(
592 compression,
593 CompressionMethod::Stored | CompressionMethod::Deflated
594 ) {
595 return Err(DocumentExtractionError::UnsupportedDocxInput {
596 message: format!("unsupported compression for package part {name}"),
597 });
598 }
599 drop(entry);
600 archive.by_index(index).map_err(|error| match error {
601 zip::result::ZipError::UnsupportedArchive(message) => {
602 DocumentExtractionError::UnsupportedDocxInput {
603 message: message.to_owned(),
604 }
605 }
606 error => DocumentExtractionError::InvalidDocxPackage {
607 message: error.to_string(),
608 },
609 })?;
610 if !enclosed || name.contains('\\') || name.starts_with('/') || !names.insert(name.clone())
611 {
612 return Err(DocumentExtractionError::InvalidDocxPackage {
613 message: format!("unsafe or duplicate package part {name}"),
614 });
615 }
616 if name.ends_with('/') {
617 continue;
618 }
619 let compressed = usize::try_from(compressed_size).map_err(|_error| {
620 DocumentExtractionError::ResourceLimit {
621 limit: DocumentLimit::CompressedBytes,
622 observed: usize::MAX,
623 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
624 }
625 })?;
626 let expanded = usize::try_from(expanded_size).map_err(|_error| {
627 DocumentExtractionError::ResourceLimit {
628 limit: DocumentLimit::ExpandedBytes,
629 observed: usize::MAX,
630 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
631 }
632 })?;
633 compressed_bytes = compressed_bytes.saturating_add(compressed);
634 expanded_bytes = expanded_bytes.saturating_add(expanded);
635 if compressed_bytes > MAX_DOCUMENT_COMPRESSED_BYTES {
636 return Err(DocumentExtractionError::ResourceLimit {
637 limit: DocumentLimit::CompressedBytes,
638 observed: compressed_bytes,
639 maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
640 });
641 }
642 if expanded_bytes > MAX_DOCUMENT_EXPANDED_BYTES {
643 return Err(DocumentExtractionError::ResourceLimit {
644 limit: DocumentLimit::ExpandedBytes,
645 observed: expanded_bytes,
646 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
647 });
648 }
649 let lower_name = name.to_ascii_lowercase();
650 if lower_name.starts_with("word/embeddings/")
651 || Path::new(&lower_name)
652 .extension()
653 .is_some_and(|extension| extension.eq_ignore_ascii_case("docx"))
654 || Path::new(&lower_name)
655 .extension()
656 .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
657 {
658 return Err(DocumentExtractionError::InvalidDocxPackage {
659 message: format!("embedded document part {name} is unsupported"),
660 });
661 }
662 }
663 let mut xml = Vec::new();
664 {
665 let mut document_part = archive.by_name(DOCX_DOCUMENT_PART).map_err(|_error| {
666 DocumentExtractionError::InvalidDocxPackage {
667 message: format!("required part {DOCX_DOCUMENT_PART} is missing"),
668 }
669 })?;
670 let mut chunk = [0_u8; 8192];
671 loop {
672 control.check(stage)?;
673 let read = document_part.read(&mut chunk).map_err(|error| {
674 DocumentExtractionError::InvalidDocxPackage {
675 message: error.to_string(),
676 }
677 })?;
678 if read == 0 {
679 break;
680 }
681 let observed = xml.len().saturating_add(read);
682 if observed > MAX_DOCUMENT_EXPANDED_BYTES {
683 return Err(DocumentExtractionError::ResourceLimit {
684 limit: DocumentLimit::ExpandedBytes,
685 observed,
686 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
687 });
688 }
689 xml.extend_from_slice(&chunk[..read]);
690 }
691 }
692 drop(archive);
693 drop(names);
694 control.check(stage)?;
695 check_memory_budget(
699 bytes
700 .len()
701 .saturating_add(xml.capacity().saturating_mul(4))
702 .saturating_add(MAX_DOCX_IGNORABLE_NAMESPACES * std::mem::size_of::<String>())
703 .saturating_add(MAX_DOCUMENT_OUTPUT_BYTES.saturating_mul(4))
704 .saturating_add(MAX_DOCX_XML_DEPTH.saturating_mul(
705 std::mem::size_of::<(DocxTextContext, usize)>()
706 + MAX_DOCX_XML_DEPTH * std::mem::size_of::<DocxFieldPhase>()
707 + std::mem::size_of::<DocxAlternative>(),
708 ))
709 .saturating_add(
710 MAX_DOCUMENT_FACTS
711 .saturating_mul(std::mem::size_of::<DocumentFact>())
712 .saturating_mul(2),
713 ),
714 )?;
715 parse_docx(&xml, control, stage)
716}
717
718#[derive(Default)]
720struct RawDocxRun {
721 text: String,
723 text_start: usize,
725}
726
727#[derive(Default)]
729struct DocxTextContext {
730 number: usize,
732 run_number: usize,
734 open: bool,
736 run: Option<RawDocxRun>,
738 fields: Vec<DocxFieldPhase>,
740}
741
742#[derive(Clone, Copy, PartialEq, Eq)]
744enum DocxFieldPhase {
745 Instruction,
747 Result,
749}
750
751#[derive(Clone, Copy, PartialEq, Eq)]
753enum DocxTextCarrier {
754 Rendered,
756 Ignored,
758}
759
760fn wordprocessing_namespace(namespace: &str) -> bool {
762 matches!(
763 namespace,
764 "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
765 | "http://purl.oclc.org/ooxml/wordprocessingml/main"
766 )
767}
768
769struct DocxAlternative {
771 depth: usize,
773 selected: bool,
775 choice_seen: bool,
777 fallback_seen: bool,
779}
780
781fn parse_docx(
783 xml: &[u8],
784 control: &IndexWorkControl,
785 stage: IndexWorkStage,
786) -> Result<DocumentFacts, DocumentExtractionError> {
787 if xml.starts_with(&[0xff, 0xfe])
788 || xml.starts_with(&[0xfe, 0xff])
789 || xml.starts_with(&[0, b'<', 0, b'?'])
790 || xml.starts_with(&[b'<', 0, b'?', 0])
791 {
792 return Err(DocumentExtractionError::UnsupportedDocxInput {
793 message: "DOCX XML encoding is not supported; UTF-8 is required".to_owned(),
794 });
795 }
796 let mut reader = NsReader::from_reader(xml);
797 reader.config_mut().trim_text(false);
798 reader.config_mut().expand_empty_elements = true;
799 let mut output = String::new();
800 let mut output_line = 1usize;
801 let mut facts = Vec::new();
802 let mut paragraph_number = 0usize;
803 let mut paragraph = DocxTextContext::default();
804 let mut text_boxes = Vec::new();
805 let mut text_carrier = None;
806 let mut text_start = 0;
807 let mut preserve_space = [false; MAX_DOCX_XML_DEPTH + 1];
808 let mut alternatives: Vec<DocxAlternative> = Vec::new();
809 let mut ignorable_namespaces: Vec<String> = Vec::new();
810 let mut skipped_branch_depth = None;
811 let mut deleted_depth = None;
812 let mut foreign_depth = None;
813 let mut element_depth = 0usize;
814 let mut root_seen = false;
815 let mut root_closed = false;
816 let mut event_index = 0usize;
817 loop {
818 check_parser_iteration(event_index, &mut || control.check(stage))?;
819 event_index = event_index.saturating_add(1);
820 let (namespace, event) =
821 reader
822 .read_resolved_event()
823 .map_err(|error| DocumentExtractionError::Malformed {
824 format: DocumentFormat::Docx,
825 message: error.to_string(),
826 })?;
827 let compatibility = matches!(&namespace, ResolveResult::Bound(namespace)
828 if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006");
829 let wordprocessing = match &namespace {
830 ResolveResult::Bound(namespace) => wordprocessing_namespace(namespace.as_ref()),
831 ResolveResult::Unbound => false,
832 ResolveResult::Unknown(prefix) => {
833 return Err(DocumentExtractionError::Malformed {
834 format: DocumentFormat::Docx,
835 message: format!("DOCX XML contained an undeclared namespace prefix: {prefix}"),
836 });
837 }
838 };
839 if foreign_depth.is_some() && text_carrier.is_none() {
840 let has_text = match &event {
841 Event::Text(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
842 Event::CData(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
843 Event::GeneralRef(reference) => Some(
844 decode_docx_reference(reference)?
845 .chars()
846 .any(|c| !c.is_ascii_whitespace()),
847 ),
848 _ => None,
849 };
850 if let Some(has_text) = has_text {
851 if has_text && deleted_depth.is_none() && skipped_branch_depth.is_none() {
852 return Err(DocumentExtractionError::UnsupportedDocxInput {
853 message: "foreign-namespace text requires unsupported semantic decoding"
854 .to_owned(),
855 });
856 }
857 continue;
858 }
859 }
860 match event {
861 Event::Start(event) => {
862 if text_carrier.is_some() {
863 return Err(DocumentExtractionError::Malformed {
864 format: DocumentFormat::Docx,
865 message: "DOCX text elements cannot contain nested markup".to_owned(),
866 });
867 }
868 let name = event.local_name();
869 if element_depth == 0 {
870 if root_seen || !wordprocessing || name.as_ref() != "document" {
871 return Err(DocumentExtractionError::Malformed {
872 format: DocumentFormat::Docx,
873 message: "DOCX XML must contain one WordprocessingML document root"
874 .to_owned(),
875 });
876 }
877 root_seen = true;
878 }
879 element_depth = element_depth.saturating_add(1);
880 if element_depth > MAX_DOCX_XML_DEPTH {
881 return Err(DocumentExtractionError::ResourceLimit {
882 limit: DocumentLimit::NestingDepth,
883 observed: element_depth,
884 maximum: MAX_DOCX_XML_DEPTH,
885 });
886 }
887 preserve_space[element_depth] = preserve_space[element_depth - 1];
888 if skipped_branch_depth.is_some() {
889 continue;
890 }
891 let ignorable = !wordprocessing
892 && !compatibility
893 && matches!(&namespace, ResolveResult::Bound(namespace)
894 if ignorable_namespaces.iter().any(|ignored| ignored == namespace.as_ref()));
895 if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
896 let alternative = alternatives
897 .last_mut()
898 .filter(|alternative| {
899 alternative.depth + 1 == element_depth && !alternative.fallback_seen
900 })
901 .ok_or_else(|| DocumentExtractionError::Malformed {
902 format: DocumentFormat::Docx,
903 message: "DOCX compatibility branch has invalid placement".to_owned(),
904 })?;
905 let supported = if name.as_ref() == "Choice" {
906 alternative.choice_seen = true;
907 let requires = event
908 .try_get_attribute("Requires")
909 .map_err(|error| DocumentExtractionError::Malformed {
910 format: DocumentFormat::Docx,
911 message: error.to_string(),
912 })?
913 .ok_or_else(|| DocumentExtractionError::Malformed {
914 format: DocumentFormat::Docx,
915 message: "DOCX compatibility choice requires namespace prefixes"
916 .to_owned(),
917 })?;
918 let requires =
919 quick_xml::escape::unescape(&requires.value).map_err(|error| {
920 DocumentExtractionError::Malformed {
921 format: DocumentFormat::Docx,
922 message: error.to_string(),
923 }
924 })?;
925 if requires.split_whitespace().next().is_none() {
926 return Err(DocumentExtractionError::Malformed {
927 format: DocumentFormat::Docx,
928 message: "DOCX compatibility choice requires namespace prefixes"
929 .to_owned(),
930 });
931 }
932 let mut supported = true;
933 for (index, prefix) in requires.split_whitespace().enumerate() {
934 check_parser_iteration(index, &mut || control.check(stage))?;
935 let qualified = format!("{prefix}:choice");
936 let (namespace, _) =
937 reader.resolver().resolve_element(QName(&qualified));
938 if !matches!(namespace, ResolveResult::Bound(namespace)
939 if wordprocessing_namespace(namespace.as_ref()))
940 {
941 supported = false;
942 break;
943 }
944 }
945 supported
946 } else {
947 if !alternative.choice_seen {
948 return Err(DocumentExtractionError::Malformed {
949 format: DocumentFormat::Docx,
950 message: "DOCX compatibility fallback requires a preceding choice"
951 .to_owned(),
952 });
953 }
954 alternative.fallback_seen = true;
955 true
956 };
957 if !alternative.selected && supported {
958 alternative.selected = true;
959 } else {
960 skipped_branch_depth = Some(element_depth);
961 continue;
962 }
963 }
964 if deleted_depth.is_none()
965 && !(wordprocessing && matches!(name.as_ref(), "del" | "moveFrom"))
966 {
967 for (index, attribute) in event.attributes().enumerate() {
968 check_parser_iteration(index, &mut || control.check(stage))?;
969 let attribute =
970 attribute.map_err(|error| DocumentExtractionError::Malformed {
971 format: DocumentFormat::Docx,
972 message: error.to_string(),
973 })?;
974 let (attribute_namespace, attribute_name) =
975 reader.resolver().resolve_attribute(attribute.key);
976 if let ResolveResult::Unknown(prefix) = &attribute_namespace {
977 return Err(DocumentExtractionError::Malformed {
978 format: DocumentFormat::Docx,
979 message: format!(
980 "DOCX XML attribute has an undeclared namespace prefix: {prefix}"
981 ),
982 });
983 }
984 if attribute_name.as_ref() == "space"
985 && matches!(&attribute_namespace, ResolveResult::Bound(namespace)
986 if namespace.as_ref() == "http://www.w3.org/XML/1998/namespace")
987 {
988 let value =
989 quick_xml::escape::unescape(&attribute.value).map_err(|error| {
990 DocumentExtractionError::Malformed {
991 format: DocumentFormat::Docx,
992 message: error.to_string(),
993 }
994 })?;
995 preserve_space[element_depth] = match value.as_ref() {
996 "default" => false,
997 "preserve" => true,
998 _ => {
999 return Err(DocumentExtractionError::Malformed {
1000 format: DocumentFormat::Docx,
1001 message: "DOCX xml:space must be default or preserve"
1002 .to_owned(),
1003 });
1004 }
1005 };
1006 }
1007 if !matches!(attribute_namespace, ResolveResult::Bound(namespace)
1008 if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006")
1009 || !matches!(
1010 attribute_name.as_ref(),
1011 "Ignorable" | "ProcessContent" | "MustUnderstand"
1012 )
1013 {
1014 continue;
1015 }
1016 let value =
1017 quick_xml::escape::unescape(&attribute.value).map_err(|error| {
1018 DocumentExtractionError::Malformed {
1019 format: DocumentFormat::Docx,
1020 message: error.to_string(),
1021 }
1022 })?;
1023 if value.split_whitespace().next().is_none() {
1024 continue;
1025 }
1026 if attribute_name.as_ref() != "Ignorable" || element_depth != 1 {
1027 return Err(DocumentExtractionError::UnsupportedDocxInput {
1028 message: "DOCX compatibility policy supports only root Ignorable namespaces".to_owned(),
1029 });
1030 }
1031 for (index, prefix) in value.split_whitespace().enumerate() {
1032 check_parser_iteration(index, &mut || control.check(stage))?;
1033 if prefix.contains(':') {
1034 return Err(DocumentExtractionError::Malformed {
1035 format: DocumentFormat::Docx,
1036 message: "DOCX Ignorable policy requires namespace prefixes, not qualified names".to_owned(),
1037 });
1038 }
1039 let qualified = format!("{prefix}:ignored");
1040 let (resolved, _) =
1041 reader.resolver().resolve_element(QName(&qualified));
1042 let ResolveResult::Bound(namespace) = resolved else {
1043 return Err(DocumentExtractionError::Malformed {
1044 format: DocumentFormat::Docx,
1045 message: "DOCX Ignorable policy names an undeclared namespace"
1046 .to_owned(),
1047 });
1048 };
1049 if ignorable_namespaces
1050 .iter()
1051 .any(|ignored| ignored == namespace.as_ref())
1052 {
1053 continue;
1054 }
1055 if ignorable_namespaces.len() == MAX_DOCX_IGNORABLE_NAMESPACES {
1056 return Err(DocumentExtractionError::UnsupportedDocxInput {
1057 message: "DOCX root Ignorable policy exceeds the supported namespace count".to_owned(),
1058 });
1059 }
1060 ignorable_namespaces.push(namespace.as_ref().to_owned());
1061 }
1062 }
1063 }
1064 if ignorable {
1065 skipped_branch_depth = Some(element_depth);
1066 continue;
1067 }
1068 if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
1069 continue;
1070 }
1071 if alternatives
1072 .last()
1073 .is_some_and(|alternative| alternative.depth + 1 == element_depth)
1074 {
1075 return Err(DocumentExtractionError::Malformed {
1076 format: DocumentFormat::Docx,
1077 message: "DOCX compatibility alternatives must contain choices and an optional fallback".to_owned(),
1078 });
1079 }
1080 if compatibility && name.as_ref() == "AlternateContent" {
1081 alternatives.push(DocxAlternative {
1082 depth: element_depth,
1083 selected: false,
1084 choice_seen: false,
1085 fallback_seen: false,
1086 });
1087 continue;
1088 }
1089 if !wordprocessing && !compatibility && foreign_depth.is_none() {
1090 foreign_depth = Some(element_depth);
1091 }
1092 match if wordprocessing { name.as_ref() } else { "" } {
1093 "subDoc" if deleted_depth.is_none() => {
1094 return Err(DocumentExtractionError::UnsupportedDocxInput {
1095 message:
1096 "referenced DOCX subdocuments require unsupported external content"
1097 .to_owned(),
1098 });
1099 }
1100 "altChunk" if deleted_depth.is_none() => {
1101 return Err(DocumentExtractionError::UnsupportedDocxInput {
1102 message:
1103 "alternate-format DOCX chunks require unsupported part decoding"
1104 .to_owned(),
1105 });
1106 }
1107 "ruby" => {
1108 if deleted_depth.is_some() {
1109 skipped_branch_depth = Some(element_depth);
1110 } else {
1111 return Err(DocumentExtractionError::UnsupportedDocxInput {
1112 message: "ruby annotations require unsupported nested run decoding"
1113 .to_owned(),
1114 });
1115 }
1116 }
1117 "del" | "moveFrom" if deleted_depth.is_none() => {
1118 deleted_depth = Some(element_depth);
1119 }
1120 "txbxContent" => {
1121 if let Some(run) = paragraph.run.as_mut() {
1122 publish_docx_run_fragment(
1123 run,
1124 paragraph.number,
1125 paragraph.run_number,
1126 &mut output,
1127 &mut output_line,
1128 &mut facts,
1129 )?;
1130 }
1131 text_boxes.push((std::mem::take(&mut paragraph), output.len()));
1132 }
1133 "p" if !paragraph.open => {
1134 paragraph.open = true;
1135 paragraph_number += 1;
1136 paragraph.number = paragraph_number;
1137 paragraph.run_number = 0;
1138 if !output.is_empty() && deleted_depth.is_none() {
1139 push_output_byte(&mut output, b'\n')?;
1140 output_line += 1;
1141 }
1142 }
1143 "r" if paragraph.open && paragraph.run.is_none() => {
1144 paragraph.run_number += 1;
1145 paragraph.run = Some(RawDocxRun::default());
1146 }
1147 "t" | "instrText" | "delText" | "delInstrText" if paragraph.run.is_some() => {
1148 text_start = paragraph.run.as_ref().map_or(0, |run| run.text.len());
1149 let ignored = deleted_depth.is_some()
1150 || matches!(name.as_ref(), "delText" | "delInstrText")
1151 || (name.as_ref() == "instrText"
1152 && paragraph.fields.contains(&DocxFieldPhase::Instruction));
1153 text_carrier = Some(if ignored {
1154 DocxTextCarrier::Ignored
1155 } else {
1156 DocxTextCarrier::Rendered
1157 });
1158 }
1159 "fldChar" if paragraph.run.is_some() && deleted_depth.is_some() => {}
1160 "fldChar" if paragraph.run.is_some() => {
1161 let mut field_type = None;
1162 for attribute in event.attributes() {
1163 let attribute =
1164 attribute.map_err(|error| DocumentExtractionError::Malformed {
1165 format: DocumentFormat::Docx,
1166 message: error.to_string(),
1167 })?;
1168 let (namespace, local) =
1169 reader.resolver().resolve_attribute(attribute.key);
1170 if local.as_ref() == "fldCharType"
1171 && matches!(namespace, ResolveResult::Bound(namespace)
1172 if wordprocessing_namespace(namespace.as_ref()))
1173 {
1174 field_type = Some(attribute.value.into_owned());
1175 }
1176 }
1177 match field_type.as_deref() {
1178 Some("begin") => {
1179 if paragraph.fields.len() >= MAX_DOCX_XML_DEPTH {
1180 return Err(DocumentExtractionError::ResourceLimit {
1181 limit: DocumentLimit::NestingDepth,
1182 observed: paragraph.fields.len() + 1,
1183 maximum: MAX_DOCX_XML_DEPTH,
1184 });
1185 }
1186 paragraph.fields.push(DocxFieldPhase::Instruction);
1187 }
1188 Some("separate") if !paragraph.fields.is_empty() => {
1189 if let Some(phase) = paragraph.fields.last_mut() {
1190 *phase = DocxFieldPhase::Result;
1191 }
1192 }
1193 Some("end") if !paragraph.fields.is_empty() => {
1194 paragraph.fields.pop();
1195 }
1196 _ => {
1197 return Err(DocumentExtractionError::Malformed {
1198 format: DocumentFormat::Docx,
1199 message: "DOCX field marker has invalid type or nesting"
1200 .to_owned(),
1201 });
1202 }
1203 }
1204 }
1205 "tab"
1206 | "ptab"
1207 | "br"
1208 | "cr"
1209 | "lastRenderedPageBreak"
1210 | "noBreakHyphen"
1211 | "softHyphen"
1212 if deleted_depth.is_none() =>
1213 {
1214 if let Some(run) = paragraph.run.as_mut() {
1215 append_docx_run_text(
1216 run,
1217 match name.as_ref() {
1218 "tab" | "ptab" => "\t",
1219 "noBreakHyphen" => "\u{2011}",
1220 "softHyphen" => "\u{00ad}",
1221 _ => "\n",
1222 },
1223 output.len(),
1224 )?;
1225 }
1226 }
1227 "pgNum" | "dayShort" | "dayLong" | "monthShort" | "monthLong" | "yearShort"
1228 | "yearLong" | "footnoteReference" | "endnoteReference"
1229 if paragraph.run.is_some() && deleted_depth.is_none() =>
1230 {
1231 return Err(DocumentExtractionError::UnsupportedDocxInput {
1232 message: "dynamic DOCX text blocks require unsupported text evaluation"
1233 .to_owned(),
1234 });
1235 }
1236 "sym" if paragraph.run.is_some() && deleted_depth.is_none() => {
1237 return Err(DocumentExtractionError::UnsupportedDocxInput {
1238 message: "font-specific symbols require unsupported font decoding"
1239 .to_owned(),
1240 });
1241 }
1242 "p" | "r" | "t" | "instrText" | "delText" | "delInstrText" | "fldChar" => {
1243 return Err(DocumentExtractionError::Malformed {
1244 format: DocumentFormat::Docx,
1245 message: "DOCX paragraph, run, or text nesting is invalid".to_owned(),
1246 });
1247 }
1248 _ => {}
1249 }
1250 }
1251 Event::Text(event) => {
1252 if skipped_branch_depth.is_some() {
1253 continue;
1254 }
1255 if text_carrier.is_some() {
1256 let Some(run) = paragraph.run.as_mut() else {
1257 return Err(DocumentExtractionError::Malformed {
1258 format: DocumentFormat::Docx,
1259 message: "text appeared outside a run".to_owned(),
1260 });
1261 };
1262 if text_carrier == Some(DocxTextCarrier::Rendered) {
1263 append_docx_run_text(run, event.as_ref(), output.len())?;
1264 }
1265 } else if !event
1266 .as_ref()
1267 .chars()
1268 .all(|character| character.is_ascii_whitespace())
1269 {
1270 return Err(DocumentExtractionError::Malformed {
1271 format: DocumentFormat::Docx,
1272 message: "DOCX XML contained text outside its document root".to_owned(),
1273 });
1274 }
1275 }
1276 Event::CData(event) => {
1277 if skipped_branch_depth.is_some() {
1278 continue;
1279 }
1280 if text_carrier.is_none() {
1281 return Err(DocumentExtractionError::Malformed {
1282 format: DocumentFormat::Docx,
1283 message: "CDATA appeared outside a run".to_owned(),
1284 });
1285 }
1286 let Some(run) = paragraph.run.as_mut() else {
1287 return Err(DocumentExtractionError::Malformed {
1288 format: DocumentFormat::Docx,
1289 message: "CDATA appeared outside a run".to_owned(),
1290 });
1291 };
1292 if text_carrier == Some(DocxTextCarrier::Rendered) {
1293 append_docx_run_text(run, event.as_ref(), output.len())?;
1294 }
1295 }
1296 Event::GeneralRef(reference) => {
1297 if skipped_branch_depth.is_some() {
1298 decode_docx_reference(&reference)?;
1299 continue;
1300 }
1301 if text_carrier.is_none() {
1302 return Err(DocumentExtractionError::Malformed {
1303 format: DocumentFormat::Docx,
1304 message: "entity appeared outside a text run".to_owned(),
1305 });
1306 }
1307 let Some(run) = paragraph.run.as_mut() else {
1308 return Err(DocumentExtractionError::Malformed {
1309 format: DocumentFormat::Docx,
1310 message: "entity appeared outside a run".to_owned(),
1311 });
1312 };
1313 let text = decode_docx_reference(&reference)?;
1314 if text_carrier == Some(DocxTextCarrier::Rendered) {
1315 append_docx_run_text(run, &text, output.len())?;
1316 }
1317 }
1318 Event::DocType(_) => {
1319 return Err(DocumentExtractionError::Malformed {
1320 format: DocumentFormat::Docx,
1321 message: "DOCX XML DOCTYPE and external declarations are unsupported"
1322 .to_owned(),
1323 });
1324 }
1325 Event::End(event) => {
1326 if element_depth == 0 {
1327 return Err(DocumentExtractionError::Malformed {
1328 format: DocumentFormat::Docx,
1329 message: "DOCX XML contained an unmatched closing element".to_owned(),
1330 });
1331 }
1332 if foreign_depth == Some(element_depth) {
1333 foreign_depth = None;
1334 }
1335 if let Some(depth) = skipped_branch_depth {
1336 if element_depth == depth {
1337 skipped_branch_depth = None;
1338 }
1339 element_depth -= 1;
1340 continue;
1341 }
1342 if compatibility && event.local_name().as_ref() == "AlternateContent" {
1343 let alternative = alternatives.pop().filter(|alternative| {
1344 alternative.depth == element_depth && alternative.choice_seen
1345 });
1346 if alternative.is_none() {
1347 return Err(DocumentExtractionError::Malformed {
1348 format: DocumentFormat::Docx,
1349 message: "DOCX compatibility alternatives require at least one choice"
1350 .to_owned(),
1351 });
1352 }
1353 }
1354 let name = event.local_name();
1355 if deleted_depth == Some(element_depth) {
1356 deleted_depth = None;
1357 }
1358 match if wordprocessing { name.as_ref() } else { "" } {
1359 "t" | "instrText" | "delText" | "delInstrText" => {
1360 if text_carrier == Some(DocxTextCarrier::Rendered)
1361 && !preserve_space[element_depth]
1362 && let Some(run) = paragraph.run.as_mut()
1363 {
1364 let text = &run.text[text_start..];
1366 let trimmed = text.trim_matches([' ', '\t', '\r', '\n']);
1367 let leading =
1368 text.len() - text.trim_start_matches([' ', '\t', '\r', '\n']).len();
1369 let end = text_start + leading + trimmed.len();
1370 run.text.truncate(end);
1371 run.text.drain(text_start..text_start + leading);
1372 }
1373 text_carrier = None;
1374 }
1375 "r" => {
1376 if let Some(mut run) = paragraph.run.take() {
1377 publish_docx_run_fragment(
1378 &mut run,
1379 paragraph.number,
1380 paragraph.run_number,
1381 &mut output,
1382 &mut output_line,
1383 &mut facts,
1384 )?;
1385 }
1386 }
1387 "txbxContent" => {
1388 if !paragraph.fields.is_empty() {
1389 return Err(DocumentExtractionError::Malformed {
1390 format: DocumentFormat::Docx,
1391 message: "DOCX text box ended inside an incomplete field"
1392 .to_owned(),
1393 });
1394 }
1395 let Some((outer, previous_bytes)) = text_boxes.pop() else {
1396 return Err(DocumentExtractionError::Malformed {
1397 format: DocumentFormat::Docx,
1398 message: "DOCX text box had no matching container".to_owned(),
1399 });
1400 };
1401 paragraph = outer;
1402 if output.len() > previous_bytes && !output.ends_with('\n') {
1403 push_output_byte(&mut output, b'\n')?;
1404 output_line += 1;
1405 }
1406 }
1407 "p" => paragraph.open = false,
1408 _ => {}
1409 }
1410 element_depth -= 1;
1411 if element_depth == 0 {
1412 root_closed = true;
1413 }
1414 }
1415 Event::Decl(declaration) => {
1416 if let Some(encoding) = declaration.encoding() {
1417 let encoding =
1418 encoding.map_err(|error| DocumentExtractionError::Malformed {
1419 format: DocumentFormat::Docx,
1420 message: error.to_string(),
1421 })?;
1422 if !encoding.eq_ignore_ascii_case("UTF-8")
1423 && !encoding.eq_ignore_ascii_case("US-ASCII")
1424 {
1425 return Err(DocumentExtractionError::UnsupportedDocxInput {
1426 message: "DOCX XML encoding is not supported; UTF-8 is required"
1427 .to_owned(),
1428 });
1429 }
1430 }
1431 }
1432 Event::Eof => break,
1433 _ => {}
1434 }
1435 }
1436 if !root_seen
1437 || !root_closed
1438 || element_depth != 0
1439 || paragraph.open
1440 || !text_boxes.is_empty()
1441 || !alternatives.is_empty()
1442 || skipped_branch_depth.is_some()
1443 || deleted_depth.is_some()
1444 || foreign_depth.is_some()
1445 || paragraph.run.is_some()
1446 || text_carrier.is_some()
1447 || !paragraph.fields.is_empty()
1448 {
1449 return Err(DocumentExtractionError::Malformed {
1450 format: DocumentFormat::Docx,
1451 message: "DOCX XML ended before all elements were closed".to_owned(),
1452 });
1453 }
1454 Ok(DocumentFacts {
1455 format: DocumentFormat::Docx,
1456 text: output,
1457 facts,
1458 completeness: DocumentCompleteness::Complete,
1459 provenance: DocumentParserProvenance::QuickXml,
1460 })
1461}
1462
1463fn publish_docx_run_fragment(
1465 run: &mut RawDocxRun,
1466 paragraph: usize,
1467 run_number: usize,
1468 output: &mut String,
1469 output_line: &mut usize,
1470 facts: &mut Vec<DocumentFact>,
1471) -> Result<(), DocumentExtractionError> {
1472 if run.text.is_empty() {
1473 return Ok(());
1474 }
1475 if facts.len() >= MAX_DOCUMENT_FACTS {
1476 return Err(DocumentExtractionError::ResourceLimit {
1477 limit: DocumentLimit::FactCount,
1478 observed: facts.len() + 1,
1479 maximum: MAX_DOCUMENT_FACTS,
1480 });
1481 }
1482 let required = output.len().saturating_add(run.text.len());
1483 if required > MAX_DOCUMENT_OUTPUT_BYTES {
1484 return Err(DocumentExtractionError::ResourceLimit {
1485 limit: DocumentLimit::OutputBytes,
1486 observed: required,
1487 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
1488 });
1489 }
1490 let text = std::mem::take(&mut run.text);
1491 let line_start = *output_line;
1492 *output_line += text.bytes().filter(|byte| *byte == b'\n').count();
1493 let line_end = *output_line - usize::from(text.ends_with('\n'));
1495 output.push_str(&text);
1496 let text_end = run.text_start + text.len();
1497 facts.push(DocumentFact {
1498 line_start,
1499 line_end,
1500 text,
1501 locator: DocumentLocator::Docx {
1502 part: DOCX_DOCUMENT_PART,
1503 paragraph,
1504 run: run_number,
1505 text_start: run.text_start,
1506 text_end,
1507 },
1508 });
1509 run.text_start = text_end;
1510 Ok(())
1511}
1512
1513fn append_docx_run_text(
1515 run: &mut RawDocxRun,
1516 text: &str,
1517 published_bytes: usize,
1518) -> Result<(), DocumentExtractionError> {
1519 let required = published_bytes
1520 .saturating_add(run.text.len())
1521 .saturating_add(text.len());
1522 if required > MAX_DOCUMENT_OUTPUT_BYTES {
1523 return Err(DocumentExtractionError::ResourceLimit {
1524 limit: DocumentLimit::OutputBytes,
1525 observed: required,
1526 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
1527 });
1528 }
1529 run.text.push_str(text);
1530 Ok(())
1531}
1532
1533fn decode_docx_reference(reference: &BytesRef<'_>) -> Result<String, DocumentExtractionError> {
1535 if let Some(character) =
1536 reference
1537 .resolve_char_ref()
1538 .map_err(|error| DocumentExtractionError::Malformed {
1539 format: DocumentFormat::Docx,
1540 message: error.to_string(),
1541 })?
1542 {
1543 let valid = matches!(character, '\u{9}' | '\u{a}' | '\u{d}') || character >= '\u{20}';
1544 if !valid {
1545 return Err(DocumentExtractionError::Malformed {
1546 format: DocumentFormat::Docx,
1547 message: "DOCX XML contained an invalid character reference".to_owned(),
1548 });
1549 }
1550 return Ok(character.to_string());
1551 }
1552 match reference.as_ref() {
1553 "amp" => Ok("&".to_owned()),
1554 "apos" => Ok("'".to_owned()),
1555 "gt" => Ok(">".to_owned()),
1556 "lt" => Ok("<".to_owned()),
1557 "quot" => Ok("\"".to_owned()),
1558 _ => Err(DocumentExtractionError::Malformed {
1559 format: DocumentFormat::Docx,
1560 message: "DOCX XML contained an unsupported entity reference".to_owned(),
1561 }),
1562 }
1563}
1564
1565#[cfg(test)]
1566#[allow(clippy::expect_used)]
1567mod tests {
1568 use super::*;
1569 use projectatlas_core::{IndexCancellation, IndexWorkControl};
1570 use std::io::Write;
1571 use std::time::{Duration, Instant};
1572 use zip::CompressionMethod;
1573 use zip::ZipWriter;
1574 use zip::write::FileOptions;
1575
1576 fn control() -> IndexWorkControl {
1577 IndexWorkControl::new(IndexCancellation::new(), None)
1578 }
1579
1580 fn docx_archive(xml: &[u8], method: CompressionMethod) -> Vec<u8> {
1581 let mut bytes = Vec::new();
1582 {
1583 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
1584 writer
1585 .start_file(
1586 DOCX_DOCUMENT_PART,
1587 FileOptions::default().compression_method(method),
1588 )
1589 .expect("fixture entry");
1590 writer.write_all(xml).expect("fixture XML");
1591 writer.finish().expect("fixture archive");
1592 }
1593 bytes
1594 }
1595
1596 fn rewrite_zip_method(bytes: &mut [u8], method: u16) {
1597 for index in 0..bytes.len().saturating_sub(4) {
1598 match &bytes[index..index + 4] {
1599 b"PK\x03\x04" => {
1600 bytes[index + 8..index + 10].copy_from_slice(&method.to_le_bytes());
1601 }
1602 b"PK\x01\x02" => {
1603 bytes[index + 10..index + 12].copy_from_slice(&method.to_le_bytes());
1604 }
1605 _ => {}
1606 }
1607 }
1608 }
1609
1610 #[test]
1611 fn document_admission_observes_queued_deadline_and_cancellation() {
1612 let lease = lock_document_execution(&control(), IndexWorkStage::TextIndex)
1613 .expect("initial document lease");
1614 let docx = docx_archive(
1615 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#,
1616 CompressionMethod::Stored,
1617 );
1618 for (path, bytes) in [
1619 ("guide.docx", docx.as_slice()),
1620 ("guide.pdf", minimal_pdf().as_slice()),
1621 ] {
1622 let waiting =
1623 IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_millis(100)));
1624 assert!(
1625 matches!(
1626 extract_document_text_controlled(bytes, path, None, &waiting),
1627 Err(DocumentExtractionError::Work(
1628 IndexWorkFailure::DeadlineExceeded {
1629 stage: IndexWorkStage::TextIndex
1630 }
1631 ))
1632 ),
1633 "{path} must wait for the shared document lease"
1634 );
1635 }
1636 let cancellation = IndexCancellation::new();
1637 let waiting = IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(5)));
1638 let cancel = std::thread::spawn(move || {
1639 std::thread::sleep(Duration::from_millis(20));
1640 cancellation.cancel();
1641 });
1642 let result = extract_document_text_controlled(&docx, "guide.docx", None, &waiting);
1643 cancel.join().expect("cancellation thread");
1644 assert!(matches!(
1645 result,
1646 Err(DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
1647 stage: IndexWorkStage::TextIndex
1648 }))
1649 ));
1650 drop(lease);
1651 assert!(extract_document_text_controlled(&docx, "guide.docx", None, &control()).is_ok());
1652 }
1653
1654 fn mark_zip_encrypted(bytes: &mut [u8]) {
1655 for index in 0..bytes.len().saturating_sub(4) {
1656 let flags = match &bytes[index..index + 4] {
1657 b"PK\x03\x04" => &mut bytes[index + 6..index + 8],
1658 b"PK\x01\x02" => &mut bytes[index + 8..index + 10],
1659 _ => continue,
1660 };
1661 let value = u16::from_le_bytes([flags[0], flags[1]]) | 1;
1662 flags.copy_from_slice(&value.to_le_bytes());
1663 }
1664 }
1665
1666 #[test]
1667 fn path_admission_is_case_insensitive_but_not_speculative() {
1668 assert_eq!(
1669 document_format_for_path("docs/guide.PDF", None),
1670 Some(DocumentFormat::Pdf)
1671 );
1672 assert_eq!(
1673 document_format_for_path("docs/guide.docx", Some("pdf")),
1674 Some(DocumentFormat::Pdf)
1675 );
1676 assert_eq!(document_format_for_path("docs/guide.doc", None), None);
1677 for language in ["text", "rust", "markdown", ""] {
1678 for path in ["docs/guide.pdf", "docs/guide.docx"] {
1679 assert_eq!(document_format_for_path(path, Some(language)), None);
1680 }
1681 }
1682 }
1683
1684 #[test]
1685 fn mismatched_magic_fails_closed_before_parser_work() {
1686 let error = extract_document_text_controlled(b"PK\x03\x04", "guide.pdf", None, &control())
1687 .expect_err("DOCX bytes must not enter the PDF parser");
1688 assert!(matches!(
1689 error,
1690 DocumentExtractionError::MismatchedMagic {
1691 expected: DocumentFormat::Pdf,
1692 found: "docx"
1693 }
1694 ));
1695 }
1696
1697 #[test]
1698 fn cancellation_is_observed_before_parser_work() {
1699 let cancellation = IndexCancellation::new();
1700 cancellation.cancel();
1701 let control = IndexWorkControl::new(cancellation, None);
1702 let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
1703 .expect_err("canceled work must not parse");
1704 assert!(matches!(
1705 error,
1706 DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
1707 stage: IndexWorkStage::TextIndex
1708 })
1709 ));
1710 }
1711
1712 #[test]
1713 fn expired_document_deadline_is_observed_before_parser_work() {
1714 let control = IndexWorkControl::with_deadline(
1715 IndexCancellation::new(),
1716 Instant::now()
1717 .checked_sub(Duration::from_secs(1))
1718 .expect("current instant supports one-second subtraction"),
1719 );
1720 let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
1721 .expect_err("expired work must not parse");
1722 assert!(matches!(
1723 error,
1724 DocumentExtractionError::Work(IndexWorkFailure::DeadlineExceeded {
1725 stage: IndexWorkStage::TextIndex
1726 })
1727 ));
1728 }
1729
1730 #[test]
1731 fn document_input_bytes_are_bounded_before_parser_work() {
1732 let mut bytes = vec![b'x'; MAX_DOCUMENT_COMPRESSED_BYTES + 1];
1733 bytes[..5].copy_from_slice(b"%PDF-");
1734 let error = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
1735 .expect_err("oversized input must be rejected before parsing");
1736 assert!(matches!(
1737 error,
1738 DocumentExtractionError::ResourceLimit {
1739 limit: DocumentLimit::InputBytes,
1740 observed,
1741 maximum: MAX_DOCUMENT_COMPRESSED_BYTES
1742 } if observed == MAX_DOCUMENT_COMPRESSED_BYTES + 1
1743 ));
1744 }
1745
1746 pub(super) fn minimal_pdf() -> Vec<u8> {
1747 let objects = [
1748 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
1749 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
1750 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(),
1751 b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Hello PDF) Tj ET\nendstream\nendobj\n".as_slice(),
1752 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
1753 ];
1754 let mut pdf = b"%PDF-1.4\n".to_vec();
1755 let mut offsets = Vec::new();
1756 for object in objects {
1757 offsets.push(pdf.len());
1758 pdf.extend_from_slice(object);
1759 }
1760 let xref = pdf.len();
1761 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1762 pdf.extend_from_slice(b"0000000000 65535 f \n");
1763 for offset in offsets {
1764 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1765 }
1766 pdf.extend_from_slice(
1767 format!(
1768 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
1769 objects.len() + 1
1770 )
1771 .as_bytes(),
1772 );
1773 pdf
1774 }
1775
1776 fn multi_page_pdf() -> Vec<u8> {
1777 let objects = [
1778 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
1779 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>\nendobj\n".as_slice(),
1780 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(),
1781 b"4 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page One) Tj ET\nendstream\nendobj\n".as_slice(),
1782 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
1783 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(),
1784 b"7 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page Two) Tj ET\nendstream\nendobj\n".as_slice(),
1785 ];
1786 let mut pdf = b"%PDF-1.4\n".to_vec();
1787 let mut offsets = Vec::new();
1788 for object in objects {
1789 offsets.push(pdf.len());
1790 pdf.extend_from_slice(object);
1791 }
1792 let xref = pdf.len();
1793 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1794 pdf.extend_from_slice(b"0000000000 65535 f \n");
1795 for offset in offsets {
1796 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1797 }
1798 pdf.extend_from_slice(
1799 format!(
1800 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
1801 objects.len() + 1
1802 )
1803 .as_bytes(),
1804 );
1805 pdf
1806 }
1807
1808 fn encrypted_pdf() -> Vec<u8> {
1809 let objects = [
1810 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
1811 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
1812 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(),
1813 b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Secret PDF) Tj ET\nendstream\nendobj\n".as_slice(),
1814 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
1815 b"6 0 obj\n<< /Filter /Standard /V 1 /R 2 /Length 40 /O <0000000000000000000000000000000000000000000000000000000000000000> /U <0000000000000000000000000000000000000000000000000000000000000000> /P -4 >>\nendobj\n".as_slice(),
1816 ];
1817 let mut pdf = b"%PDF-1.4\n".to_vec();
1818 let mut offsets = Vec::new();
1819 for object in objects {
1820 offsets.push(pdf.len());
1821 pdf.extend_from_slice(object);
1822 }
1823 let xref = pdf.len();
1824 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
1825 pdf.extend_from_slice(b"0000000000 65535 f \n");
1826 for offset in offsets {
1827 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1828 }
1829 pdf.extend_from_slice(
1830 format!(
1831 "trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
1832 objects.len() + 1
1833 )
1834 .as_bytes(),
1835 );
1836 pdf
1837 }
1838
1839 #[test]
1840 fn pdf_extracts_page_text_with_exact_page_locator() {
1841 let facts =
1842 extract_document_text_controlled(&minimal_pdf(), "guide.pdf", Some("pdf"), &control())
1843 .expect("valid PDF");
1844 assert!(facts.text.contains("Hello PDF"));
1845 assert!(matches!(
1846 facts
1847 .facts
1848 .iter()
1849 .find(|fact| fact.text.contains("Hello PDF")),
1850 Some(DocumentFact {
1851 locator: DocumentLocator::Pdf { page: 1, .. },
1852 ..
1853 })
1854 ));
1855 }
1856
1857 #[test]
1858 fn pdf_page_locators_are_page_local_for_multi_page_documents() {
1859 let facts = extract_document_text_controlled(
1860 &multi_page_pdf(),
1861 "guide.pdf",
1862 Some("pdf"),
1863 &control(),
1864 )
1865 .expect("valid multi-page PDF");
1866 assert_eq!(facts.facts.len(), 2);
1867 assert!(matches!(
1868 facts.facts[1].locator,
1869 DocumentLocator::Pdf {
1870 page: 2,
1871 text_start: 0,
1872 text_end: 8
1873 }
1874 ));
1875 }
1876
1877 #[test]
1878 fn pdf_missing_or_unsupported_page_stream_is_not_silently_omitted() {
1879 for missing in [true, false] {
1880 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
1881 if missing {
1882 document.objects.remove(&(7, 0));
1883 } else {
1884 document
1885 .objects
1886 .get_mut(&(7, 0))
1887 .expect("second page stream")
1888 .as_stream_mut()
1889 .expect("stream object")
1890 .dict
1891 .set("Filter", "UnsupportedDecode");
1892 }
1893 let mut bytes = Vec::new();
1894 document.save_to(&mut bytes).expect("fixture serialization");
1895 assert!(
1896 matches!(
1897 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
1898 Err(DocumentExtractionError::Malformed {
1899 format: DocumentFormat::Pdf,
1900 ..
1901 })
1902 ),
1903 "missing={missing} must fail instead of publishing incomplete text"
1904 );
1905 }
1906 }
1907
1908 #[test]
1909 fn empty_pdf_publishes_complete_text_without_facts() {
1910 let mut document = lopdf::Document::new();
1911 let mut pages = lopdf::Dictionary::new();
1912 pages.set("Type", "Pages");
1913 pages.set("Kids", Vec::<lopdf::Object>::new());
1914 pages.set("Count", 0);
1915 let pages = document.add_object(pages);
1916 let mut catalog = lopdf::Dictionary::new();
1917 catalog.set("Type", "Catalog");
1918 catalog.set("Pages", pages);
1919 let catalog = document.add_object(catalog);
1920 document.trailer.set("Root", catalog);
1921 let mut bytes = Vec::new();
1922 document.save_to(&mut bytes).expect("empty fixture");
1923 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
1924 .expect("empty page tree must pass the embedded guest and host");
1925 assert_eq!(result.completeness, DocumentCompleteness::Complete);
1926 assert!(result.text.is_empty());
1927 assert!(result.facts.is_empty());
1928 assert!(
1929 result
1930 .symbol_graph("guide.pdf", Some("pdf"))
1931 .symbols
1932 .is_empty()
1933 );
1934 }
1935
1936 #[test]
1937 fn pdf_indirect_page_tree_fields_preserve_exact_facts() {
1938 let original = multi_page_pdf();
1939 let expected = extract_document_text_controlled(&original, "guide.pdf", None, &control())
1940 .expect("direct page tree");
1941 let mut document = lopdf::Document::load_mem(&original).expect("fixture PDF");
1942 let children = document
1943 .get_dictionary((2, 0))
1944 .expect("page tree")
1945 .get(b"Kids")
1946 .expect("children")
1947 .clone();
1948 let children = document.add_object(children);
1949 let alias = document.add_object(lopdf::Object::Reference(children));
1950 let count = document.add_object(2);
1951 let node = document.get_dictionary_mut((2, 0)).expect("page tree");
1952 node.set("Kids", alias);
1953 node.set("Count", count);
1954 for (id, name) in [((2, 0), "Pages"), ((3, 0), "Page"), ((6, 0), "Page")] {
1955 let name = document.add_object(lopdf::Object::Name(name.as_bytes().to_vec()));
1956 document
1957 .get_dictionary_mut(id)
1958 .expect("page-tree node")
1959 .set("Type", name);
1960 }
1961 let mut bytes = Vec::new();
1962 document.save_to(&mut bytes).expect("fixture serialization");
1963 let actual = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
1964 .expect("indirect page-tree fields");
1965 assert_eq!(actual, expected);
1966
1967 let cycle = document.new_object_id();
1968 document
1969 .objects
1970 .insert(cycle, lopdf::Object::Reference(cycle));
1971 let missing = document.new_object_id();
1972 for invalid in [lopdf::Object::Null, 0.into(), cycle.into(), missing.into()] {
1973 document
1974 .get_dictionary_mut((2, 0))
1975 .expect("page tree")
1976 .set("Kids", invalid);
1977 let mut bytes = Vec::new();
1978 document.save_to(&mut bytes).expect("fixture serialization");
1979 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
1980 assert!(
1981 matches!(
1982 result,
1983 Err(DocumentExtractionError::Malformed {
1984 format: DocumentFormat::Pdf,
1985 ..
1986 })
1987 ),
1988 "{result:?}"
1989 );
1990 }
1991 }
1992
1993 #[test]
1994 fn pdf_missing_page_tree_child_never_publishes_a_complete_prefix() {
1995 for declared_count in [1, 2] {
1996 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
1997 document.objects.remove(&(6, 0));
1998 document
1999 .get_object_mut((2, 0))
2000 .expect("page tree")
2001 .as_dict_mut()
2002 .expect("page tree dictionary")
2003 .set("Count", declared_count);
2004 let mut bytes = Vec::new();
2005 document.save_to(&mut bytes).expect("fixture serialization");
2006 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2007 assert!(
2008 matches!(
2009 result,
2010 Err(DocumentExtractionError::Malformed {
2011 format: DocumentFormat::Pdf,
2012 ..
2013 })
2014 ),
2015 "declared_count={declared_count}: {result:?}"
2016 );
2017 }
2018 }
2019
2020 #[test]
2021 fn pdf_form_content_keeps_text_and_page_evidence() {
2022 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2023 let mut form = lopdf::Dictionary::new();
2024 form.set("Type", "XObject");
2025 let subtype = document.add_object(lopdf::Object::Name(b"Form".to_vec()));
2026 form.set("Subtype", subtype);
2027 form.set(
2028 "Matrix",
2029 vec![1.into(), 0.into(), 0.into(), 1.into(), 0.into(), 600.into()],
2030 );
2031 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
2032 let resources = document
2033 .get_dictionary((3, 0))
2034 .expect("page")
2035 .get(b"Resources")
2036 .expect("resources")
2037 .clone();
2038 form.set("Resources", resources);
2039 let form_id = document.add_object(lopdf::Stream::new(
2040 form,
2041 b"BT /F1 12 Tf 72 0 Td (Form Text Marker) Tj ET".to_vec(),
2042 ));
2043 let mut xobjects = lopdf::Dictionary::new();
2044 xobjects.set("Fm1", form_id);
2045 document
2046 .get_object_mut((3, 0))
2047 .expect("page")
2048 .as_dict_mut()
2049 .expect("page dictionary")
2050 .get_mut(b"Resources")
2051 .expect("resources")
2052 .as_dict_mut()
2053 .expect("resource dictionary")
2054 .set("XObject", xobjects);
2055 let stream = document
2056 .get_object_mut((4, 0))
2057 .expect("page stream")
2058 .as_stream_mut()
2059 .expect("stream");
2060 let mut content = stream.content.clone();
2061 content
2062 .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");
2063 stream.set_content(content);
2064 let mut bytes = Vec::new();
2065 document.save_to(&mut bytes).expect("fixture serialization");
2066 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2067 .expect("valid Form content");
2068 assert!(facts.text.contains("Hello PDF"));
2069 assert_eq!(facts.facts.len(), 3, "{}", facts.text);
2070 assert_eq!(
2071 facts
2072 .facts
2073 .iter()
2074 .filter(|fact| fact.text == "Form Text Marker")
2075 .count(),
2076 2,
2077 "{}",
2078 facts.text
2079 );
2080 assert!(
2081 facts
2082 .facts
2083 .iter()
2084 .any(|fact| fact.text.contains("Form Text Marker")
2085 && matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }))
2086 );
2087 }
2088
2089 #[test]
2090 fn pdf_rotation_and_quote_operators_preserve_text_locators() {
2091 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2092 for (rotation, content) in [
2093 (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()),
2094 (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"),
2095 (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 0 Td (Next) ' ET"),
2096 (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"),
2097 ] {
2098 document.get_object_mut((2, 0)).expect("page tree").as_dict_mut()
2099 .expect("page tree dictionary").set("Rotate", rotation);
2100 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
2101 .expect("stream").set_content(content.to_vec());
2102 let mut bytes = Vec::new();
2103 document.save_to(&mut bytes).expect("fixture serialization");
2104 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2105 .expect("valid positioned text");
2106 assert_eq!(facts.text, "First Second\nNext", "rotation={rotation}");
2107 assert_eq!(facts.facts.len(), 2);
2108 for (index, fact) in facts.facts.iter().enumerate() {
2109 assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
2110 assert_eq!(fact.line_start, index + 1);
2111 assert_eq!(fact.line_end, index + 1);
2112 }
2113 }
2114 for rotation in [90, 180, 270] {
2115 document
2116 .get_object_mut((2, 0))
2117 .expect("page tree")
2118 .as_dict_mut()
2119 .expect("page tree dictionary")
2120 .set("Rotate", rotation);
2121 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
2122 .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());
2123 let mut bytes = Vec::new();
2124 document.save_to(&mut bytes).expect("fixture serialization");
2125 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2126 .expect("ordinary rotated text");
2127 assert_eq!(facts.text, "First Second\nNext");
2128 assert_eq!(facts.facts.len(), 2);
2129 for (index, fact) in facts.facts.iter().enumerate() {
2130 assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
2131 assert_eq!((fact.line_start, fact.line_end), (index + 1, index + 1));
2132 }
2133 }
2134 }
2135
2136 #[test]
2137 fn pdf_text_state_and_missing_width_preserve_block_text() {
2138 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2139 let descriptor = lopdf::dictionary! {
2140 "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 32,
2141 "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
2142 "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200, "CapHeight" => 700,
2143 "StemV" => 80, "MissingWidth" => 600
2144 };
2145 document.objects.insert(
2146 (5, 0),
2147 lopdf::dictionary! {
2148 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Fixture",
2149 "Encoding" => "WinAnsiEncoding", "FontDescriptor" => descriptor,
2150 "FirstChar" => 65, "LastChar" => 65, "Widths" => vec![600.into()]
2151 }
2152 .into(),
2153 );
2154 document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
2155 .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());
2156 let mut bytes = Vec::new();
2157 document.save_to(&mut bytes).expect("fixture serialization");
2158 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2159 .expect("scoped text with descriptor widths");
2160 assert_eq!(facts.text, "A\nStateBC");
2161 assert_eq!(facts.facts.len(), 2);
2162 assert_eq!(facts.facts[1].text, "StateBC");
2163 assert_eq!((facts.facts[1].line_start, facts.facts[1].line_end), (2, 2));
2164 assert!(matches!(
2165 facts.facts[1].locator,
2166 DocumentLocator::Pdf { page: 1, .. }
2167 ));
2168 }
2169
2170 #[test]
2171 fn pdf_font_matrix_and_color_aliases_preserve_text() {
2172 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2173 let glyph = document.add_object(lopdf::Stream::new(
2174 lopdf::Dictionary::new(),
2175 b"600 0 d0".to_vec(),
2176 ));
2177 document.objects.insert((5, 0), lopdf::dictionary! {
2178 "Type" => "Font", "Subtype" => "Type3",
2179 "FontBBox" => vec![0.into(), 0.into(), 600.into(), 600.into()],
2180 "FontMatrix" => vec![0.002.into(), 0.into(), 0.into(), 0.002.into(), 0.into(), 0.into()],
2181 "CharProcs" => lopdf::dictionary! { "A" => glyph, "B" => glyph },
2182 "Encoding" => lopdf::dictionary! { "Differences" => vec![65.into(), "A".into(), "B".into()] },
2183 "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()]
2184 }.into());
2185 document
2186 .get_dictionary_mut((3, 0))
2187 .expect("page")
2188 .get_mut(b"Resources")
2189 .expect("resources")
2190 .as_dict_mut()
2191 .expect("dictionary")
2192 .set("ColorSpace", lopdf::dictionary! { "CS1" => "DeviceCMYK",
2193 "IndexedAlias" => vec!["Indexed".into(), "DeviceRGB".into(), 1.into(),
2194 lopdf::Object::String(vec![0, 0, 0, 255, 255, 255], lopdf::StringFormat::Hexadecimal)] });
2195 for color in [
2196 "",
2197 "/CS1 cs 0 0 0 1 sc /CS1 CS 0 0 0 1 SC",
2198 "/IndexedAlias cs 1 sc /IndexedAlias CS 1 SC",
2199 ] {
2200 document
2201 .get_object_mut((4, 0))
2202 .expect("content")
2203 .as_stream_mut()
2204 .expect("stream")
2205 .set_content(
2206 format!("{color} BT /F1 12 Tf 72 500 Td (A) Tj 14.4 0 Td (B) Tj ET")
2207 .into_bytes(),
2208 );
2209 let mut bytes = Vec::new();
2210 document.save_to(&mut bytes).expect("fixture serialization");
2211 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2212 .expect("Type 3 text and visual-only color aliases");
2213 assert_eq!(facts.text, "AB");
2214 assert_eq!(facts.facts.len(), 1);
2215 assert!(matches!(
2216 facts.facts[0].locator,
2217 DocumentLocator::Pdf {
2218 page: 1,
2219 text_start: 0,
2220 text_end: 2
2221 }
2222 ));
2223 }
2224 document
2225 .get_dictionary_mut((5, 0))
2226 .expect("font")
2227 .set("FontMatrix", vec![lopdf::Object::Real(0.002)]);
2228 let mut bytes = Vec::new();
2229 document.save_to(&mut bytes).expect("fixture serialization");
2230 assert!(matches!(
2231 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
2232 Err(DocumentExtractionError::Malformed {
2233 format: DocumentFormat::Pdf,
2234 ..
2235 })
2236 ));
2237 }
2238
2239 #[test]
2240 fn pdf_implicit_encoding_preserves_exact_text_and_refuses_undefined_glyphs() {
2241 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2242 document
2243 .get_dictionary_mut((5, 0))
2244 .expect("font")
2245 .set("Encoding", lopdf::Dictionary::new());
2246 document
2247 .get_object_mut((4, 0))
2248 .expect("content")
2249 .as_stream_mut()
2250 .expect("stream")
2251 .set_content(b"BT /F1 12 Tf 72 500 Td <27> Tj ET".to_vec());
2252 let mut bytes = Vec::new();
2253 document.save_to(&mut bytes).expect("fixture serialization");
2254 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2255 .expect("implicit standard font encoding");
2256 assert_eq!(facts.text, "\u{2019}");
2257 assert_eq!(facts.facts.len(), 1);
2258 assert!(matches!(
2259 facts.facts[0].locator,
2260 DocumentLocator::Pdf {
2261 page: 1,
2262 text_start: 0,
2263 text_end: 3
2264 }
2265 ));
2266 document.get_dictionary_mut((5, 0)).expect("font").set(
2267 "Encoding",
2268 lopdf::dictionary! { "Differences" => vec![39.into(), ".notdef".into()] },
2269 );
2270 let mut bytes = Vec::new();
2271 document.save_to(&mut bytes).expect("fixture serialization");
2272 assert!(matches!(
2273 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
2274 Err(DocumentExtractionError::UnsupportedPdfInput)
2275 ));
2276 }
2277
2278 #[test]
2279 fn pdf_partial_unicode_uses_builtin_font_or_refuses() {
2280 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2281 let cmap = document.add_object(lopdf::Stream::new(
2282 lopdf::Dictionary::new(),
2283 br"/CIDInit /ProcSet findresource begin
228412 dict begin begincmap
2285/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
2286/CMapName /Fixture def /CMapType 2 def
22871 begincodespacerange <00> <FF> endcodespacerange
22881 beginbfchar <41> <005A> endbfchar
2289endcmap CMapName currentdict /CMap defineresource pop end end"
2290 .to_vec(),
2291 ));
2292 document
2293 .get_object_mut((4, 0))
2294 .expect("content")
2295 .as_stream_mut()
2296 .expect("stream")
2297 .set_content(b"BT /F1 12 Tf 72 500 Td (AB) Tj ET".to_vec());
2298 for (base, expected) in [("Symbol", Some("Z\u{0392}")), ("Fixture", None)] {
2299 document.objects.insert(
2300 (5, 0),
2301 lopdf::dictionary! {
2302 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => base,
2303 "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()],
2304 "ToUnicode" => cmap
2305 }
2306 .into(),
2307 );
2308 let mut bytes = Vec::new();
2309 document.save_to(&mut bytes).expect("fixture serialization");
2310 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2311 if let Some(expected) = expected {
2312 assert_eq!(result.expect("known built-in encoding").text, expected);
2313 } else {
2314 assert!(
2315 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
2316 "{result:?}"
2317 );
2318 }
2319 }
2320 }
2321
2322 #[test]
2323 fn pdf_calibrated_color_dictionaries_preserve_text_or_refuse() {
2324 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2325 let parameters = document.add_object(lopdf::dictionary! {
2326 "WhitePoint" => vec![1.into(), 1.into(), 1.into()]
2327 });
2328 let wrong_type = document.add_object(42);
2329 document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
2330 .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());
2331 for space in ["CalGray", "CalRGB", "Lab"] {
2332 for (reference, valid) in [(parameters, true), (wrong_type, false), ((999, 0), false)] {
2333 document.get_dictionary_mut((3, 0)).expect("page")
2334 .get_mut(b"Resources").expect("resources").as_dict_mut().expect("dictionary")
2335 .set("ColorSpace", lopdf::dictionary! {
2336 "Calibrated" => vec![lopdf::Object::Name(space.as_bytes().to_vec()), reference.into()]
2337 });
2338 let mut bytes = Vec::new();
2339 document.save_to(&mut bytes).expect("fixture serialization");
2340 let result =
2341 extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2342 if valid {
2343 let facts = result.expect("indirect calibrated dictionary");
2344 assert_eq!(
2345 facts.text.split_whitespace().collect::<Vec<_>>(),
2346 ["Prefix", "Visible"]
2347 );
2348 assert_eq!(facts.facts.len(), 2);
2349 for (fact, (text_start, text_end)) in facts.facts.iter().zip([(0, 6), (7, 14)])
2350 {
2351 assert_eq!(
2352 fact.locator,
2353 DocumentLocator::Pdf {
2354 page: 1,
2355 text_start,
2356 text_end
2357 }
2358 );
2359 }
2360 } else {
2361 assert!(
2362 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
2363 "{space}: {result:?}"
2364 );
2365 }
2366 }
2367 }
2368 }
2369
2370 #[test]
2371 fn pdf_extended_graphics_state_selects_text_font() {
2372 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2373 let state_type = document.add_object(lopdf::Object::Name(b"ExtGState".to_vec()));
2374 let mut state = lopdf::Dictionary::new();
2375 state.set("Type", state_type);
2376 state.set("Font", vec![lopdf::Object::Reference((5, 0)), 12.into()]);
2377 let mut states = lopdf::Dictionary::new();
2378 states.set("GS", state);
2379 document
2380 .get_object_mut((3, 0))
2381 .expect("page")
2382 .as_dict_mut()
2383 .expect("page dictionary")
2384 .get_mut(b"Resources")
2385 .expect("resources")
2386 .as_dict_mut()
2387 .expect("resource dictionary")
2388 .set("ExtGState", states);
2389 document
2390 .get_object_mut((4, 0))
2391 .expect("page stream")
2392 .as_stream_mut()
2393 .expect("stream")
2394 .set_content(b"/GS gs BT 10 Tw 2 Tc 72 500 Td (Graphics ) Tj (Font) Tj ET".to_vec());
2395 let mut bytes = Vec::new();
2396 document.save_to(&mut bytes).expect("fixture serialization");
2397 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2398 .expect("ExtGState font is usable without Tf");
2399 assert_eq!(facts.text.trim(), "Graphics Font");
2400 assert_eq!(facts.facts.len(), 1);
2401 assert!(matches!(
2402 facts.facts[0].locator,
2403 DocumentLocator::Pdf { page: 1, .. }
2404 ));
2405 }
2406
2407 #[test]
2408 fn pdf_actual_text_refuses_partial_text_publication() {
2409 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2410 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
2411 .expect("stream").set_content(b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /ActualText (replacement) >> BDC (glyph) Tj EMC ET".to_vec());
2412 let mut bytes = Vec::new();
2413 document.save_to(&mut bytes).expect("fixture serialization");
2414 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2415 assert!(
2416 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
2417 "{result:?}"
2418 );
2419 }
2420
2421 #[test]
2422 fn pdf_structure_replacements_refuse_partial_text_publication() {
2423 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2424 document
2425 .get_object_mut((4, 0))
2426 .expect("page stream")
2427 .as_stream_mut()
2428 .expect("stream")
2429 .set_content(
2430 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /MCID 0 >> BDC (glyph) Tj EMC ET"
2431 .to_vec(),
2432 );
2433 let root = document.new_object_id();
2434 let mut element = lopdf::Dictionary::new();
2435 element.set("Type", "StructElem");
2436 element.set("S", "Span");
2437 element.set("P", root);
2438 element.set("Pg", (3, 0));
2439 element.set("K", 0);
2440 let element = document.add_object(element);
2441 let mut parents = lopdf::Dictionary::new();
2442 parents.set(
2443 "Nums",
2444 vec![0.into(), lopdf::Object::Array(vec![element.into()])],
2445 );
2446 let parents = document.add_object(parents);
2447 let mut structure = lopdf::Dictionary::new();
2448 structure.set("Type", "StructTreeRoot");
2449 structure.set("K", element);
2450 structure.set("ParentTree", parents);
2451 document.objects.insert(root, structure.into());
2452 document
2453 .get_dictionary_mut((3, 0))
2454 .expect("page")
2455 .set("StructParents", 0);
2456 document
2457 .get_dictionary_mut((1, 0))
2458 .expect("catalog")
2459 .set("StructTreeRoot", root);
2460 for replacement in [false, true] {
2461 if replacement {
2462 document
2463 .get_dictionary_mut(element)
2464 .expect("structure element")
2465 .set("ActualText", lopdf::Object::string_literal("replacement"));
2466 }
2467 let mut bytes = Vec::new();
2468 document.save_to(&mut bytes).expect("fixture serialization");
2469 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2470 if replacement {
2471 assert!(
2472 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
2473 "{result:?}"
2474 );
2475 } else {
2476 let extracted = result.expect("ordinary tagged content remains supported");
2477 assert_eq!(extracted.text.trim(), "Prefixglyph");
2478 assert!(matches!(
2479 extracted.facts[0].locator,
2480 DocumentLocator::Pdf { page: 1, .. }
2481 ));
2482 }
2483 }
2484 }
2485
2486 #[test]
2487 fn pdf_reversed_chars_refuses_partial_text_publication() {
2488 for content in [
2489 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars BMC (desrever) Tj EMC ET".as_slice(),
2490 b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars << /MCID 0 >> BDC (desrever) Tj EMC ET",
2491 ] {
2492 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2493 document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
2494 .expect("stream").set_content(content.to_vec());
2495 let mut bytes = Vec::new();
2496 document.save_to(&mut bytes).expect("fixture serialization");
2497 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2498 assert!(matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)), "{result:?}");
2499 }
2500 }
2501
2502 fn pdf_with_xobject(xobject: lopdf::Stream) -> Vec<u8> {
2503 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2504 let object = document.add_object(xobject);
2505 let mut resources = lopdf::Dictionary::new();
2506 resources.set("Object1", object);
2507 document
2508 .get_object_mut((3, 0))
2509 .expect("page")
2510 .as_dict_mut()
2511 .expect("page dictionary")
2512 .get_mut(b"Resources")
2513 .expect("resources")
2514 .as_dict_mut()
2515 .expect("resource dictionary")
2516 .set("XObject", resources);
2517 let stream = document
2518 .get_object_mut((4, 0))
2519 .expect("page content")
2520 .as_stream_mut()
2521 .expect("content stream");
2522 let mut content = stream.content.clone();
2523 content.extend_from_slice(b"\n/Object1 Do\n");
2524 stream.set_content(content);
2525 let mut bytes = Vec::new();
2526 document.save_to(&mut bytes).expect("fixture serialization");
2527 bytes
2528 }
2529
2530 #[test]
2531 fn recursive_pdf_form_stops_without_publishing_page_text() {
2532 let mut form = lopdf::Dictionary::new();
2533 form.set("Type", "XObject");
2534 form.set("Subtype", "Form");
2535 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
2536 let bytes = pdf_with_xobject(lopdf::Stream::new(form, b"/Object1 Do".to_vec()));
2537 let mut document = lopdf::Document::load_mem(&bytes).expect("fixture PDF");
2538 let resources = document
2539 .get_dictionary((3, 0))
2540 .expect("page dictionary")
2541 .get(b"Resources")
2542 .expect("page resources")
2543 .clone();
2544 let id = resources
2545 .as_dict()
2546 .expect("resource dictionary")
2547 .get(b"XObject")
2548 .expect("XObject resources")
2549 .as_dict()
2550 .expect("XObject dictionary")
2551 .get(b"Object1")
2552 .expect("Form reference")
2553 .as_reference()
2554 .expect("indirect Form");
2555 document
2556 .get_object_mut(id)
2557 .expect("Form object")
2558 .as_stream_mut()
2559 .expect("Form stream")
2560 .dict
2561 .set("Resources", resources);
2562 let mut bytes = Vec::new();
2563 document.save_to(&mut bytes).expect("fixture serialization");
2564 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2565 assert!(
2566 matches!(
2567 result,
2568 Err(DocumentExtractionError::ResourceLimit {
2569 limit: DocumentLimit::MemoryBytes | DocumentLimit::ExecutionFuel,
2570 ..
2571 } | DocumentExtractionError::Work(
2572 projectatlas_core::IndexWorkFailure::DeadlineExceeded { .. }
2573 ))
2574 ),
2575 "{result:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn pdf_closed_paths_preserve_text_and_missing_current_points_refuse() {
2581 for (path, valid) in [
2582 ("10 20 m 30 40 l h 50 60 70 80 v S", true),
2583 ("10 20 30 40 re 50 60 70 80 v S", true),
2584 ("50 60 70 80 v", false),
2585 ("10 20 m s 50 60 70 80 v", false),
2586 ("10 20 m f* 50 60 70 80 v", false),
2587 ("10 20 m B 50 60 70 80 v", false),
2588 ("10 20 m B* 50 60 70 80 v", false),
2589 ("10 20 m b 50 60 70 80 v", false),
2590 ("10 20 m b* 50 60 70 80 v", false),
2591 ("10 20 m S 50 60 70 80 v", false),
2592 ("10 20 m f 50 60 70 80 v", false),
2593 ("10 20 m F 50 60 70 80 v", false),
2594 ("10 20 m n 50 60 70 80 v", false),
2595 (
2596 "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*",
2597 true,
2598 ),
2599 ] {
2600 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2601 let stream = document
2602 .get_object_mut((4, 0))
2603 .expect("page content")
2604 .as_stream_mut()
2605 .expect("content stream");
2606 let mut content = format!("{path}\n").into_bytes();
2607 content.extend_from_slice(&stream.content);
2608 stream.set_content(content);
2609 let mut bytes = Vec::new();
2610 document.save_to(&mut bytes).expect("fixture serialization");
2611 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2612 if valid {
2613 let facts = result.expect("closed path and displayed PDF text");
2614 assert_eq!(facts.text, "Hello PDF");
2615 assert_eq!(facts.facts.len(), 1);
2616 assert!(matches!(
2617 facts.facts[0].locator,
2618 DocumentLocator::Pdf {
2619 page: 1,
2620 text_start: 0,
2621 text_end: 9
2622 }
2623 ));
2624 } else {
2625 assert!(
2626 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
2627 "{result:?}"
2628 );
2629 }
2630 }
2631 }
2632
2633 #[test]
2634 fn pdf_postscript_xobjects_preserve_displayed_text_and_exact_locator() {
2635 for subtype in ["PS", "Form", "Unknown"] {
2636 let mut object = lopdf::Dictionary::new();
2637 object.set("Type", "XObject");
2638 object.set("Subtype", subtype);
2639 if subtype != "PS" {
2640 object.set("Subtype2", "PS");
2641 }
2642 let bytes = pdf_with_xobject(lopdf::Stream::new(
2643 object,
2644 b"/Helvetica findfont 12 scalefont setfont (Print only) show".to_vec(),
2645 ));
2646 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2647 if subtype == "Unknown" {
2648 assert!(
2649 matches!(result, Err(DocumentExtractionError::Malformed { .. })),
2650 "{result:?}"
2651 );
2652 } else {
2653 let facts = result.expect("displayed PDF text");
2654 assert_eq!(facts.text, "Hello PDF");
2655 assert_eq!(facts.facts.len(), 1);
2656 assert!(matches!(
2657 facts.facts[0].locator,
2658 DocumentLocator::Pdf {
2659 page: 1,
2660 text_start: 0,
2661 text_end: 9
2662 }
2663 ));
2664 }
2665 }
2666 }
2667
2668 #[test]
2669 fn pdf_image_pixels_do_not_replace_or_invent_page_text() {
2670 let mut image = lopdf::Dictionary::new();
2671 image.set("Type", "XObject");
2672 image.set("Subtype", "Image");
2673 image.set("Width", 1);
2674 image.set("Height", 1);
2675 image.set("ColorSpace", "DeviceRGB");
2676 image.set("BitsPerComponent", 8);
2677 let bytes = pdf_with_xobject(lopdf::Stream::new(image, vec![255, 0, 0]));
2678 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2679 .expect("text and image PDF");
2680 assert_eq!(facts.text, "Hello PDF");
2681 let mut document = lopdf::Document::load_mem(&bytes).expect("image fixture");
2682 let subtype = document.add_object(lopdf::Object::Name(b"Image".to_vec()));
2683 for object in document.objects.values_mut() {
2684 if let lopdf::Object::Stream(stream) = object
2685 && stream
2686 .dict
2687 .get(b"Subtype")
2688 .and_then(lopdf::Object::as_name)
2689 .ok()
2690 == Some(b"Image".as_slice())
2691 {
2692 stream.dict.set("Subtype", subtype);
2693 stream.dict.set("Filter", "DCTDecode");
2694 }
2695 }
2696 let mut indirect = Vec::new();
2697 document
2698 .save_to(&mut indirect)
2699 .expect("fixture serialization");
2700 let actual = extract_document_text_controlled(&indirect, "guide.pdf", None, &control())
2701 .expect("indirect image subtype keeps opaque pixels outside decoding");
2702 assert_eq!(actual, facts);
2703 }
2704
2705 #[test]
2706 fn pdf_form_font_names_do_not_reuse_page_font_encodings() {
2707 let mut encoding = lopdf::Dictionary::new();
2708 encoding.set("Type", "Encoding");
2709 encoding.set("BaseEncoding", "WinAnsiEncoding");
2710 encoding.set(
2711 "Differences",
2712 vec![65.into(), lopdf::Object::Name(b"Z".to_vec())],
2713 );
2714 let mut font = lopdf::Dictionary::new();
2715 font.set("Type", "Font");
2716 font.set("Subtype", "Type1");
2717 font.set("BaseFont", "Helvetica");
2718 font.set("Encoding", encoding);
2719 let mut fonts = lopdf::Dictionary::new();
2720 fonts.set("F1", font);
2721 let mut resources = lopdf::Dictionary::new();
2722 resources.set("Font", fonts);
2723 let mut form = lopdf::Dictionary::new();
2724 form.set("Type", "XObject");
2725 form.set("Subtype", "Form");
2726 form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
2727 form.set("Resources", resources);
2728 let bytes = pdf_with_xobject(lopdf::Stream::new(
2729 form,
2730 b"BT /F1 12 Tf 72 700 Td (A) Tj ET".to_vec(),
2731 ));
2732 let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
2733 .expect("scoped Form font");
2734 assert!(facts.text.contains("Hello PDF"));
2735 assert!(facts.text.contains('Z'), "{}", facts.text);
2736 assert!(!facts.text.contains('A'), "{}", facts.text);
2737 }
2738
2739 #[test]
2740 fn pdf_cid_text_requires_every_character_to_decode() {
2741 for (encoding, codes, expected, default_width) in [
2742 ("Identity-H", "0001", Some("Z")),
2743 (
2744 "Identity-H",
2745 "0001> Tj 3 0 Td <0001> Tj 8 0 Td <0001",
2746 Some("ZZ Z"),
2747 ),
2748 ("Identity-H", "00010002", None),
2749 ("Identity-H", "000100", None),
2750 ("Identity-V", "0001", None),
2751 ("custom", "0001", None),
2752 ]
2753 .into_iter()
2754 .map(|(encoding, codes, expected)| (encoding, codes, expected, None))
2755 .chain([(
2756 "Identity-H",
2757 "0001> Tj 18.006 0 Td <0001",
2758 Some("ZZ"),
2759 Some(1500.5_f32),
2760 )]) {
2761 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2762 let cmap = document.add_object(lopdf::Stream::new(
2763 lopdf::Dictionary::new(),
2764 br"/CIDInit /ProcSet findresource begin
276512 dict begin begincmap
2766/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
2767/CMapName /Fixture def /CMapType 2 def
27681 begincodespacerange <0000> <FFFF> endcodespacerange
27691 beginbfchar <0001> <005A> endbfchar
2770endcmap CMapName currentdict /CMap defineresource pop end end"
2771 .to_vec(),
2772 ));
2773 let mut system = lopdf::Dictionary::new();
2774 system.set("Registry", lopdf::Object::string_literal("Adobe"));
2775 system.set("Ordering", lopdf::Object::string_literal("Identity"));
2776 system.set("Supplement", 0);
2777 let mut descendant = lopdf::Dictionary::new();
2778 descendant.set("Type", "Font");
2779 descendant.set("Subtype", "CIDFontType2");
2780 descendant.set("BaseFont", "Fixture");
2781 if let Some(width) = default_width {
2782 document.version = "2.0".to_owned();
2783 descendant.set("DW", lopdf::Object::Real(width));
2784 } else {
2785 descendant.set("W", vec![lopdf::Object::Integer(1), 1.into(), 200.into()]);
2786 }
2787 descendant.set("CIDSystemInfo", system);
2788 let descriptor = document.add_object(lopdf::dictionary! {
2789 "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 4,
2790 "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
2791 "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200,
2792 "CapHeight" => 700, "StemV" => 80
2793 });
2794 descendant.set("FontDescriptor", descriptor);
2795 let descendant = document.add_object(descendant);
2796 let mut font = lopdf::Dictionary::new();
2797 font.set("Type", "Font");
2798 font.set("Subtype", "Type0");
2799 font.set("BaseFont", "Fixture");
2800 if encoding == "custom" {
2801 let encoding = document.add_object(lopdf::Stream::new(
2802 lopdf::Dictionary::new(),
2803 b"/CIDInit /ProcSet findresource begin
280412 dict begin begincmap
2805/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
2806/CMapName /FixtureEncoding def /CMapType 1 def /WMode 0 def
28071 begincodespacerange <0000> <FFFF> endcodespacerange
28081 begincidrange <0000> <FFFF> 0 endcidrange
2809endcmap CMapName currentdict /CMap defineresource pop end end"
2810 .to_vec(),
2811 ));
2812 font.set("Encoding", encoding);
2813 } else {
2814 font.set("Encoding", encoding);
2815 }
2816 font.set(
2817 "DescendantFonts",
2818 vec![lopdf::Object::Reference(descendant)],
2819 );
2820 font.set("ToUnicode", cmap);
2821 document.objects.insert((5, 0), font.into());
2822 document
2823 .get_object_mut((4, 0))
2824 .expect("content")
2825 .as_stream_mut()
2826 .expect("stream")
2827 .set_content(format!("BT /F1 12 Tf 72 720 Td <{codes}> Tj ET").into_bytes());
2828 let mut bytes = Vec::new();
2829 document.save_to(&mut bytes).expect("fixture serialization");
2830 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2831 if let Some(expected) = expected {
2832 assert_eq!(result.expect("mapped horizontal CID").text, expected);
2833 } else if encoding != "Identity-H" || codes == "00010002" {
2834 assert!(
2835 matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
2836 "{result:?}"
2837 );
2838 } else {
2839 assert!(
2840 matches!(
2841 result,
2842 Err(DocumentExtractionError::Malformed {
2843 format: DocumentFormat::Pdf,
2844 ..
2845 })
2846 ),
2847 "{codes}: {result:?}"
2848 );
2849 }
2850 }
2851 }
2852
2853 #[test]
2854 fn pdf_late_malformed_page_never_publishes_a_complete_prefix() {
2855 let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
2856 document.objects.insert(
2857 (7, 0),
2858 lopdf::Stream::new(lopdf::Dictionary::new(), b"BT (unterminated".to_vec()).into(),
2859 );
2860 let mut bytes = Vec::new();
2861 document.save_to(&mut bytes).expect("fixture serialization");
2862 assert!(matches!(
2863 extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
2864 Err(DocumentExtractionError::Malformed {
2865 format: DocumentFormat::Pdf,
2866 ..
2867 })
2868 ));
2869 }
2870
2871 #[test]
2872 fn pdf_decompression_bomb_stops_at_the_first_host_resource_limit() {
2873 let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
2874 let mut stream = lopdf::Stream::new(
2875 lopdf::Dictionary::new(),
2876 vec![b' '; MAX_DOCUMENT_EXPANDED_BYTES + 1],
2877 );
2878 stream.compress().expect("fixture compression");
2879 document.objects.insert((4, 0), stream.into());
2880 let mut bytes = Vec::new();
2881 document.save_to(&mut bytes).expect("fixture serialization");
2882 assert!(bytes.len() < MAX_DOCUMENT_COMPRESSED_BYTES);
2883 let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
2884 assert!(
2885 matches!(
2886 &result,
2887 Err(DocumentExtractionError::ResourceLimit {
2888 limit: DocumentLimit::MemoryBytes
2889 | DocumentLimit::ExpandedBytes
2890 | DocumentLimit::ExecutionFuel,
2891 ..
2892 } | DocumentExtractionError::Work(
2893 projectatlas_core::IndexWorkFailure::DeadlineExceeded { .. }
2894 ))
2895 ),
2896 "actual refusal: {result:?}"
2897 );
2898 }
2899
2900 #[test]
2901 fn encrypted_or_password_protected_pdf_is_rejected_without_credentials() {
2902 let error = extract_document_text_controlled(
2903 &encrypted_pdf(),
2904 "secret.pdf",
2905 Some("pdf"),
2906 &control(),
2907 )
2908 .expect_err("password-protected PDFs must never be decrypted by indexing");
2909 assert!(matches!(error, DocumentExtractionError::EncryptedPdf));
2910 }
2911
2912 #[test]
2913 fn unsafe_or_duplicate_docx_parts_are_rejected() {
2914 let mut bytes = Vec::new();
2915 {
2916 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
2917 writer
2918 .start_file("../word/document.xml", FileOptions::default())
2919 .expect("fixture entry");
2920 writer.write_all(b"<w:document/>").expect("fixture XML");
2921 writer.finish().expect("fixture archive");
2922 }
2923 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
2924 .expect_err("path traversal must fail closed");
2925 assert!(matches!(
2926 error,
2927 DocumentExtractionError::InvalidDocxPackage { .. }
2928 ));
2929 }
2930
2931 #[test]
2932 fn stored_and_deflated_docx_parts_are_admitted() {
2933 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>"#;
2934 for method in [CompressionMethod::Stored, CompressionMethod::Deflated] {
2935 let facts = extract_document_text_controlled(
2936 &docx_archive(xml, method),
2937 "guide.docx",
2938 None,
2939 &control(),
2940 )
2941 .expect("admitted DOCX compression");
2942 assert_eq!(facts.text, "bounded");
2943 }
2944 }
2945
2946 #[test]
2947 fn unsupported_docx_compression_is_rejected_before_text_read() {
2948 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
2949 let mut bytes = docx_archive(xml, CompressionMethod::Stored);
2950 rewrite_zip_method(&mut bytes, 12);
2951 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
2952 .expect_err("unsupported compression must fail closed");
2953 assert!(matches!(
2954 error,
2955 DocumentExtractionError::UnsupportedDocxInput { .. }
2956 ));
2957 }
2958
2959 #[test]
2960 fn encrypted_docx_is_rejected_before_text_publication() {
2961 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
2962 let mut bytes = docx_archive(xml, CompressionMethod::Stored);
2963 mark_zip_encrypted(&mut bytes);
2964 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
2965 .expect_err("encrypted packages must fail closed");
2966 assert!(matches!(
2967 error,
2968 DocumentExtractionError::UnsupportedDocxInput { .. }
2969 ));
2970 }
2971
2972 #[test]
2973 fn duplicate_docx_parts_are_rejected_before_document_read() {
2974 let mut bytes = Vec::new();
2975 {
2976 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
2977 for value in [b"first".as_slice(), b"second".as_slice()] {
2978 writer
2979 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
2980 .expect("fixture entry");
2981 writer.write_all(value).expect("fixture XML");
2982 }
2983 writer.finish().expect("fixture archive");
2984 }
2985 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
2986 .expect_err("duplicate package parts must fail closed");
2987 assert!(matches!(
2988 error,
2989 DocumentExtractionError::InvalidDocxPackage { .. }
2990 ));
2991 }
2992
2993 #[test]
2994 fn embedded_document_parts_are_rejected_without_recursive_parsing() {
2995 let mut bytes = Vec::new();
2996 {
2997 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
2998 writer
2999 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3000 .expect("fixture entry");
3001 writer
3002 .write_all(b"<w:document xmlns:w=\"urn:w\"><w:body/></w:document>")
3003 .expect("fixture XML");
3004 writer
3005 .start_file("word/embeddings/nested.docx", FileOptions::default())
3006 .expect("embedded fixture entry");
3007 writer.write_all(b"PK\x03\x04").expect("embedded fixture");
3008 writer.finish().expect("fixture archive");
3009 }
3010 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3011 .expect_err("embedded documents must not recurse");
3012 assert!(matches!(
3013 error,
3014 DocumentExtractionError::InvalidDocxPackage { .. }
3015 ));
3016 }
3017
3018 #[test]
3019 fn non_utf8_docx_part_metadata_is_rejected() {
3020 let mut bytes = Vec::new();
3021 {
3022 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3023 writer
3024 .start_file("word/document.xml", FileOptions::default())
3025 .expect("fixture entry");
3026 writer
3027 .write_all(b"<w:document xmlns:w=\"urn:w\"><w:body/></w:document>")
3028 .expect("fixture XML");
3029 writer.finish().expect("fixture archive");
3030 }
3031 let name = b"word/document.xml";
3032 let positions = bytes
3033 .windows(name.len())
3034 .enumerate()
3035 .filter_map(|(index, candidate)| (candidate == name).then_some(index))
3036 .collect::<Vec<_>>();
3037 for index in positions {
3038 bytes[index] = 0xff;
3039 }
3040 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3041 .expect_err("non-UTF-8 package metadata must fail closed");
3042 assert!(matches!(
3043 error,
3044 DocumentExtractionError::InvalidDocxPackage { .. }
3045 ));
3046 }
3047
3048 #[test]
3049 fn docx_entry_count_is_bounded_before_archive_reads() {
3050 let mut bytes = Vec::new();
3051 {
3052 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3053 for index in 0..=MAX_DOCUMENT_ENTRIES {
3054 writer
3055 .start_file(format!("parts/{index}.xml"), FileOptions::default())
3056 .expect("fixture entry");
3057 }
3058 writer.finish().expect("fixture archive");
3059 }
3060 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3061 .expect_err("entry count must be bounded before archive reads");
3062 assert!(matches!(
3063 error,
3064 DocumentExtractionError::ResourceLimit {
3065 limit: DocumentLimit::EntryCount,
3066 observed,
3067 maximum: MAX_DOCUMENT_ENTRIES
3068 } if observed == MAX_DOCUMENT_ENTRIES + 1
3069 ));
3070 }
3071
3072 #[test]
3073 fn docx_expanded_size_is_bounded_before_decompression() {
3074 let mut bytes = Vec::new();
3075 {
3076 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3077 writer
3078 .start_file(
3079 DOCX_DOCUMENT_PART,
3080 FileOptions::default().compression_method(CompressionMethod::Deflated),
3081 )
3082 .expect("fixture entry");
3083 writer
3084 .write_all(&vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 1])
3085 .expect("fixture payload");
3086 writer.finish().expect("fixture archive");
3087 }
3088 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3089 .expect_err("expanded ZIP size must be bounded before decompression");
3090 assert!(matches!(
3091 error,
3092 DocumentExtractionError::ResourceLimit {
3093 limit: DocumentLimit::ExpandedBytes,
3094 observed,
3095 maximum: MAX_DOCUMENT_EXPANDED_BYTES
3096 } if observed == MAX_DOCUMENT_EXPANDED_BYTES + 1
3097 ));
3098 }
3099
3100 #[test]
3101 fn docx_actual_expansion_is_bounded_despite_forged_catalog_size() {
3102 let mut bytes = docx_archive(
3103 &vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 8192],
3104 CompressionMethod::Deflated,
3105 );
3106 for index in 0..bytes.len().saturating_sub(4) {
3107 let size_offset = match &bytes[index..index + 4] {
3108 b"PK\x03\x04" => index + 22,
3109 b"PK\x01\x02" => index + 24,
3110 _ => continue,
3111 };
3112 bytes[size_offset..size_offset + 4].copy_from_slice(&1_u32.to_le_bytes());
3113 }
3114 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3115 .expect_err("actual decompression must be bounded independently of ZIP metadata");
3116 assert!(matches!(
3117 error,
3118 DocumentExtractionError::ResourceLimit {
3119 limit: DocumentLimit::ExpandedBytes,
3120 observed,
3121 maximum: MAX_DOCUMENT_EXPANDED_BYTES,
3122 } if observed > MAX_DOCUMENT_EXPANDED_BYTES
3123 ));
3124 }
3125
3126 #[test]
3127 fn docx_namespace_identity_is_independent_of_prefix() {
3128 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>"#;
3129 let expected = parse_docx(
3130 canonical.as_bytes(),
3131 &control(),
3132 IndexWorkStage::SymbolParsing,
3133 )
3134 .expect("canonical WordprocessingML");
3135 for xml in [
3136 canonical.replace("w:", "x:").replace("xmlns:w", "xmlns:x"),
3137 canonical.replace("w:", "").replace("xmlns:w", "xmlns"),
3138 canonical.replace(
3139 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
3140 "http://purl.oclc.org/ooxml/wordprocessingml/main",
3141 ),
3142 ] {
3143 assert_eq!(
3144 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
3145 .expect("equivalent namespace identity"),
3146 expected,
3147 );
3148 }
3149 let foreign_text = canonical.replace(
3150 "<w:t>A</w:t>",
3151 "<foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t>",
3152 );
3153 assert!(matches!(
3154 parse_docx(
3155 foreign_text.as_bytes(),
3156 &control(),
3157 IndexWorkStage::SymbolParsing
3158 ),
3159 Err(DocumentExtractionError::UnsupportedDocxInput { .. }),
3160 ));
3161 for xml in [
3162 canonical.replace(
3163 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
3164 "urn:foreign",
3165 ),
3166 canonical.replace(
3167 "<w:t>A</w:t>",
3168 "<w:t><foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t></w:t>",
3169 ),
3170 canonical.replace("<w:t>A</w:t>", "<unknown:t>A</unknown:t>"),
3171 canonical.replace("</w:r></w:p>", "</w:p></w:r>"),
3172 ] {
3173 assert!(matches!(
3174 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing),
3175 Err(DocumentExtractionError::Malformed {
3176 format: DocumentFormat::Docx,
3177 ..
3178 }),
3179 ));
3180 }
3181 }
3182
3183 #[test]
3184 fn docx_ruby_annotations_are_typed_unsupported() {
3185 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>"#;
3186 assert!(matches!(
3187 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
3188 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3189 ));
3190 let deleted = xml
3191 .replace("<w:ruby>", "<w:del><w:ruby>")
3192 .replace("</w:ruby>", "</w:ruby></w:del>");
3193 assert_eq!(
3194 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
3195 .expect("deleted ruby stays excluded")
3196 .text,
3197 "Prefix"
3198 );
3199 }
3200
3201 #[test]
3202 fn docx_alternate_format_chunk_refuses_incomplete_publication() {
3203 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>"#;
3204 assert!(matches!(
3205 parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
3206 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3207 ));
3208 let deleted = xml.replace(
3209 "<w:altChunk r:id=\"html\"/>",
3210 "<w:del><w:altChunk r:id=\"html\"/></w:del>",
3211 );
3212 assert_eq!(
3213 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
3214 .expect("deleted content stays excluded")
3215 .text,
3216 "Prefix"
3217 );
3218 }
3219
3220 #[test]
3221 fn docx_subdocuments_refuse_incomplete_text() {
3222 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>"#;
3223 for reference in [
3224 r#"<w:subDoc r:id="child"/>"#,
3225 r#"<w:subDoc r:id="child"></w:subDoc>"#,
3226 ] {
3227 let xml = xml.replace(r#"<w:subDoc r:id="child"/>"#, reference);
3228 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
3229 let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
3230 assert!(
3231 matches!(
3232 result,
3233 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3234 ),
3235 "{result:?}"
3236 );
3237 let deleted = xml.replace(reference, &format!("<w:del>{reference}</w:del>"));
3238 assert_eq!(
3239 parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
3240 .expect("deleted subdocument remains excluded")
3241 .text,
3242 "Prefix"
3243 );
3244 }
3245 }
3246
3247 #[test]
3248 fn docx_unsupported_xml_encoding_is_not_malformed() {
3249 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>"#;
3250 for little_endian in [true, false] {
3251 for bom in [true, false] {
3252 let mut bytes = Vec::new();
3253 for unit in bom.then_some(0xfeff).into_iter().chain(xml.encode_utf16()) {
3254 bytes.extend_from_slice(&if little_endian {
3255 unit.to_le_bytes()
3256 } else {
3257 unit.to_be_bytes()
3258 });
3259 }
3260 assert!(matches!(
3261 parse_docx(&bytes, &control(), IndexWorkStage::TextIndex),
3262 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3263 ));
3264 }
3265 }
3266 for encoding in ["UTF-16", "ISO-8859-1"] {
3267 assert!(matches!(
3268 parse_docx(
3269 xml.replace("UTF-16", encoding).as_bytes(),
3270 &control(),
3271 IndexWorkStage::TextIndex
3272 ),
3273 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3274 ));
3275 }
3276 for encoding in ["UTF-8", "US-ASCII"] {
3277 assert!(
3278 parse_docx(
3279 xml.replace("UTF-16", encoding).as_bytes(),
3280 &control(),
3281 IndexWorkStage::TextIndex
3282 )
3283 .is_ok()
3284 );
3285 }
3286 }
3287
3288 #[test]
3289 fn docx_foreign_text_is_unsupported_without_hiding_word_text_boxes() {
3290 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>"#;
3291 for content in ["x", "<![CDATA[x]]>", "x"] {
3292 assert!(matches!(
3293 parse_docx(
3294 xml.replace("CONTENT", content).as_bytes(),
3295 &control(),
3296 IndexWorkStage::TextIndex
3297 ),
3298 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3299 ));
3300 }
3301 assert!(matches!(
3302 parse_docx(
3303 xml.replace("CONTENT", "&unknown;").as_bytes(),
3304 &control(),
3305 IndexWorkStage::TextIndex
3306 ),
3307 Err(DocumentExtractionError::Malformed { .. })
3308 ));
3309 assert!(
3310 parse_docx(
3311 xml.replace("CONTENT", " ").as_bytes(),
3312 &control(),
3313 IndexWorkStage::TextIndex
3314 )
3315 .is_ok()
3316 );
3317 let drawing = br#"<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><w:drawing><a:graphic><a:graphicData><w:txbxContent><w:p><w:r><w:t>Box</w:t></w:r></w:p></w:txbxContent></a:graphicData></a:graphic></w:drawing></w:r></w:p></w:body></w:document>"#;
3318 assert_eq!(
3319 parse_docx(drawing, &control(), IndexWorkStage::TextIndex)
3320 .expect("Word text inside opaque drawing wrappers")
3321 .text,
3322 "Box\n"
3323 );
3324 }
3325
3326 #[test]
3327 fn docx_deleted_revisions_do_not_publish_run_content() {
3328 for revision in ["del", "moveFrom"] {
3329 let xml = format!(
3330 "<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>"
3331 );
3332 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
3333 .expect("tracked revision content is valid XML");
3334 assert!(matches!(
3335 parse_docx(
3336 xml.replace("Removed", "&unknown;").as_bytes(),
3337 &control(),
3338 IndexWorkStage::TextIndex
3339 ),
3340 Err(DocumentExtractionError::Malformed { .. })
3341 ));
3342 assert_eq!(parsed.text, "BeforeAfter", "{revision}");
3343 assert_eq!(parsed.facts.len(), 2);
3344 assert_eq!(parsed.facts[1].line_start, 1);
3345 assert_eq!(parsed.facts[1].line_end, 1);
3346 assert_eq!(
3347 parsed.facts[1].locator,
3348 DocumentLocator::Docx {
3349 part: DOCX_DOCUMENT_PART,
3350 paragraph: 1,
3351 run: 4,
3352 text_start: 0,
3353 text_end: 5,
3354 }
3355 );
3356 }
3357 }
3358
3359 #[test]
3360 fn docx_dynamic_text_blocks_refuse_without_evaluation() {
3361 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>"#;
3362 assert_eq!(
3363 parse_docx(
3364 template.replace("BLOCK", "").as_bytes(),
3365 &control(),
3366 IndexWorkStage::TextIndex
3367 )
3368 .expect("cached field result remains literal text")
3369 .text,
3370 "7"
3371 );
3372 for name in [
3373 "pgNum",
3374 "dayShort",
3375 "dayLong",
3376 "monthShort",
3377 "monthLong",
3378 "yearShort",
3379 "yearLong",
3380 "footnoteReference",
3381 "endnoteReference",
3382 ] {
3383 let attributes = if matches!(name, "footnoteReference" | "endnoteReference") {
3384 " w:id=\"1\""
3385 } else {
3386 ""
3387 };
3388 for element in [
3389 format!("<w:{name}{attributes}/>"),
3390 format!("<w:{name}{attributes}></w:{name}>"),
3391 ] {
3392 let run = format!("<w:r>{element}</w:r>");
3393 assert!(
3394 matches!(
3395 parse_docx(
3396 template.replace("BLOCK", &run).as_bytes(),
3397 &control(),
3398 IndexWorkStage::TextIndex
3399 ),
3400 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3401 ),
3402 "{name}"
3403 );
3404 for discarded in [
3405 format!("<w:del>{run}</w:del>"),
3406 format!("<w:moveFrom>{run}</w:moveFrom>"),
3407 format!(
3408 r#"<mc:AlternateContent><mc:Choice Requires="future">{run}</mc:Choice><mc:Fallback/></mc:AlternateContent>"#
3409 ),
3410 ] {
3411 assert_eq!(
3412 parse_docx(
3413 template.replace("BLOCK", &discarded).as_bytes(),
3414 &control(),
3415 IndexWorkStage::TextIndex
3416 )
3417 .expect("discarded dynamic text does not require evaluation")
3418 .text,
3419 "7",
3420 "{name}"
3421 );
3422 }
3423 }
3424 }
3425 }
3426
3427 #[test]
3428 fn docx_field_carriers_retain_only_literal_and_cached_text() {
3429 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: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>"#;
3430 let parsed = extract_document_text_controlled(
3431 &docx_archive(xml, CompressionMethod::Deflated),
3432 "fields.docx",
3433 None,
3434 &control(),
3435 )
3436 .expect("field instructions and deleted carriers are valid bounded XML");
3437 assert_eq!(parsed.text, "Page 7 Literal");
3438 assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
3439 assert_eq!(parsed.facts.len(), 3);
3440 for (fact, (run, text)) in
3441 parsed
3442 .facts
3443 .iter()
3444 .zip([(1, "Page "), (5, "7"), (7, " Literal")])
3445 {
3446 assert_eq!(fact.text, text);
3447 assert_eq!(
3448 fact.locator,
3449 DocumentLocator::Docx {
3450 part: DOCX_DOCUMENT_PART,
3451 paragraph: 1,
3452 run,
3453 text_start: 0,
3454 text_end: text.len(),
3455 }
3456 );
3457 }
3458 let nested = 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>OUTER</w:instrText><w:drawing><w:txbxContent><w:p><w:r><w:instrText>Box</w:instrText></w:r></w:p></w:txbxContent></w:drawing><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>"#;
3459 let nested = parse_docx(nested, &control(), IndexWorkStage::TextIndex)
3460 .expect("nested fields and text boxes keep independent state");
3461 assert_eq!(nested.text, "Box\nResult");
3462 assert_eq!(nested.facts.len(), 2);
3463 let text = std::str::from_utf8(xml).expect("UTF-8 fixture");
3464 for invalid in [
3465 text.replace("PAGE & <![CDATA[ignored]]>", "<w:t>nested</w:t>"),
3466 text.replace("PAGE & <![CDATA[ignored]]>", "&unknown;"),
3467 text.replace("<w:p>", "<w:p><w:instrText>outside run</w:instrText>"),
3468 ] {
3469 assert!(matches!(
3470 parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
3471 Err(DocumentExtractionError::Malformed { .. })
3472 ));
3473 }
3474 let deep = format!(
3475 "<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>",
3476 "<w:fldChar w:fldCharType=\"begin\"/>".repeat(MAX_DOCX_XML_DEPTH + 1),
3477 );
3478 assert!(matches!(
3479 parse_docx(deep.as_bytes(), &control(), IndexWorkStage::TextIndex),
3480 Err(DocumentExtractionError::ResourceLimit {
3481 limit: DocumentLimit::NestingDepth,
3482 ..
3483 })
3484 ));
3485 }
3486
3487 #[test]
3488 fn docx_root_ignorable_policy_preserves_visible_text_and_refuses_other_policies() {
3489 use std::fmt::Write as _;
3490 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>"#;
3491 for source in [
3492 xml.to_owned(),
3493 xml.replace("<future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper>", ""),
3494 xml.replace("mc:Ignorable=\"future\"", &format!("mc:Ignorable=\"{}\"", "future ".repeat(128))),
3495 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>"),
3496 xml.replace(
3497 "<future:wrapper>",
3498 "<alias:wrapper xmlns:alias=\"urn:future\">",
3499 )
3500 .replace("</future:wrapper>", "</alias:wrapper>"),
3501 xml.replace(
3502 "mc:Ignorable=\"future\"",
3503 "mc:Ignorable=\"future future w\"",
3504 ),
3505 xml.replace(
3506 "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
3507 "http://purl.oclc.org/ooxml/wordprocessingml/main",
3508 ),
3509 xml.replace("mc:", "compat:")
3510 .replace("xmlns:mc=", "xmlns:compat="),
3511 ] {
3512 let parsed = parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex)
3513 .expect("ignore only unknown root-policy namespaces");
3514 assert_eq!(parsed.text, "Visible");
3515 assert_eq!(parsed.facts.len(), 1);
3516 assert!(matches!(
3517 parsed.facts[0].locator,
3518 DocumentLocator::Docx {
3519 paragraph: 1,
3520 run: 1,
3521 text_start: 0,
3522 text_end: 7,
3523 ..
3524 }
3525 ));
3526 }
3527 let deleted = xml
3528 .replace("<future:wrapper>", "<w:del mc:Ignorable=\"future\">")
3529 .replace("</future:wrapper>", "</w:del>");
3530 let parsed = parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
3531 .expect("deleted policies do not affect live text");
3532 assert_eq!(parsed.text, "Visible");
3533 assert!(matches!(
3534 parsed.facts[0].locator,
3535 DocumentLocator::Docx {
3536 paragraph: 2,
3537 run: 1,
3538 text_start: 0,
3539 text_end: 7,
3540 ..
3541 }
3542 ));
3543 for source in [
3544 xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"\""),
3545 xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"w\""),
3546 xml.replace(
3547 "<future:wrapper>",
3548 "<future:wrapper xmlns:future=\"urn:different\">",
3549 ),
3550 xml.replace("mc:Ignorable=\"future\"", "Ignorable=\"future\""),
3551 ] {
3552 assert_eq!(
3553 parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex)
3554 .expect("unlisted wrappers retain existing behavior")
3555 .text,
3556 "Ignored\nVisible"
3557 );
3558 }
3559 for source in [
3560 xml.replace(
3561 "mc:Ignorable=\"future\"",
3562 "mc:ProcessContent=\"future:wrapper\"",
3563 ),
3564 xml.replace("<future:wrapper>", "<future:wrapper mc:ProcessContent=\"future:child\">"),
3565 xml.replace("<future:wrapper>", "<future:wrapper mc:MustUnderstand=\"future\">"),
3566 xml.replace("<future:wrapper>", "<future:wrapper mc:Ignorable=\"future\">"),
3567 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>"),
3568 xml.replace("mc:Ignorable=\"future\"", "mc:MustUnderstand=\"future\""),
3569 xml.replace(" mc:Ignorable=\"future\"", "")
3570 .replace("<w:body>", "<w:body mc:Ignorable=\"future\">"),
3571 ] {
3572 assert!(matches!(
3573 parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex),
3574 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3575 ));
3576 }
3577 for policy in ["missing", "future:wrapper"] {
3578 assert!(matches!(
3579 parse_docx(
3580 xml.replace(
3581 "mc:Ignorable=\"future\"",
3582 &format!("mc:Ignorable=\"{policy}\"")
3583 )
3584 .as_bytes(),
3585 &control(),
3586 IndexWorkStage::TextIndex
3587 ),
3588 Err(DocumentExtractionError::Malformed { .. })
3589 ));
3590 }
3591 for count in [
3592 MAX_DOCX_IGNORABLE_NAMESPACES,
3593 MAX_DOCX_IGNORABLE_NAMESPACES + 1,
3594 ] {
3595 let mut declarations = String::new();
3596 let mut prefixes = String::new();
3597 for index in 0..count {
3598 write!(declarations, "xmlns:n{index}=\"urn:{index}\" ")
3599 .expect("namespace declaration");
3600 write!(prefixes, "n{index} ").expect("namespace prefix");
3601 }
3602 let source = xml.replace(
3603 "mc:Ignorable=\"future\"",
3604 &format!("{declarations} mc:Ignorable=\"{prefixes}\""),
3605 );
3606 let bytes = docx_archive(source.as_bytes(), CompressionMethod::Deflated);
3607 let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
3608 if count == MAX_DOCX_IGNORABLE_NAMESPACES {
3609 assert_eq!(
3610 result.expect("bounded distinct namespaces").text,
3611 "Ignored\nVisible"
3612 );
3613 } else {
3614 assert!(matches!(
3615 result,
3616 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3617 ));
3618 }
3619 }
3620 }
3621
3622 #[test]
3623 fn docx_compatibility_selects_one_understood_branch() {
3624 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>"#;
3625 let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
3626 .expect("one understood choice");
3627 assert_eq!(parsed.text, "Chosen");
3628 assert_eq!(parsed.facts.len(), 1);
3629 let fallback = xml.replace("Requires=\"w\"", "Requires=\"future\"");
3630 let parsed = parse_docx(fallback.as_bytes(), &control(), IndexWorkStage::TextIndex)
3631 .expect("fallback when no choice is understood");
3632 assert_eq!(parsed.text, "Fallback");
3633 assert_eq!(parsed.facts.len(), 1);
3634 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>");
3635 let parsed = parse_docx(nested.as_bytes(), &control(), IndexWorkStage::TextIndex)
3636 .expect("nested alternatives preserve the containing run");
3637 assert_eq!(parsed.text, "Nested");
3638 for first in [
3639 xml.replace("Requires=\"future\"", "Requires=\"w\""),
3640 xml.replace(
3641 "xmlns:future=\"urn:future\"",
3642 "xmlns:future=\"http://purl.oclc.org/ooxml/wordprocessingml/main\"",
3643 ),
3644 ] {
3645 let parsed = parse_docx(first.as_bytes(), &control(), IndexWorkStage::TextIndex)
3646 .expect("first understood branch and namespace aliases");
3647 assert_eq!(parsed.text, "Unsupported");
3648 assert_eq!(parsed.facts.len(), 1);
3649 }
3650 for invalid in [
3651 xml.replace("Requires=\"future\"", "Requires=\"\""),
3652 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><w:p/>"),
3653 xml.replace("</mc:AlternateContent>", "<mc:Fallback/></mc:AlternateContent>"),
3654 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Fallback/>"),
3655 xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Choice Requires=\"future\"><w:r><w:t>&unknown;</w:t></w:r></mc:Choice>"),
3656 ] {
3657 assert!(matches!(parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
3658 Err(DocumentExtractionError::Malformed { .. })));
3659 }
3660 }
3661
3662 #[test]
3663 fn docx_nested_text_boxes_preserve_run_order_and_locators() {
3664 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:drawing><w:txbxContent><w:p><w:r><w:t>Inside</w:t></w:r></w:p></w:txbxContent></w:drawing><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>"#;
3665 let bytes = docx_archive(xml, CompressionMethod::Stored);
3666 let parsed = extract_document_text_controlled(&bytes, "text-box.docx", None, &control())
3667 .expect("nested text container is valid WordprocessingML");
3668 assert_eq!(parsed.text, "Before\nInside\nAfter\nFollowing");
3669 assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
3670 assert_eq!(parsed.facts.len(), 4);
3671 for (fact, (text, paragraph, start, end, line)) in parsed.facts.iter().zip([
3672 ("Before", 1, 0, 6, 1),
3673 ("Inside", 2, 0, 6, 2),
3674 ("After", 1, 6, 11, 3),
3675 ("Following", 3, 0, 9, 4),
3676 ]) {
3677 assert_eq!(fact.text, text);
3678 assert_eq!((fact.line_start, fact.line_end), (line, line));
3679 assert_eq!(
3680 fact.locator,
3681 DocumentLocator::Docx {
3682 part: DOCX_DOCUMENT_PART,
3683 paragraph,
3684 run: 1,
3685 text_start: start,
3686 text_end: end,
3687 }
3688 );
3689 }
3690 }
3691
3692 #[test]
3693 fn docx_namespace_storage_is_charged_before_parser_allocation() {
3694 let xml = format!(
3695 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:unused=\"{}\"><w:body/></w:document>",
3696 "x".repeat(24 * 1024 * 1024),
3697 );
3698 let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
3699 assert!(matches!(
3700 extract_document_text_controlled(&bytes, "guide.docx", None, &control()),
3701 Err(DocumentExtractionError::ResourceLimit {
3702 limit: DocumentLimit::MemoryBytes,
3703 ..
3704 }),
3705 ));
3706 }
3707
3708 #[test]
3709 fn docx_empty_elements_preserve_paragraph_and_run_ordinals() {
3710 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>";
3711 let facts = parse_docx(xml, &control(), IndexWorkStage::SymbolParsing)
3712 .expect("empty elements are valid document structure");
3713 assert_eq!(facts.text, "A\tB");
3714 assert_eq!(facts.facts.len(), 1);
3715 assert!(matches!(
3716 facts.facts[0].locator,
3717 DocumentLocator::Docx {
3718 paragraph: 2,
3719 run: 2,
3720 text_start: 0,
3721 text_end: 3,
3722 ..
3723 }
3724 ));
3725 let expanded = String::from_utf8(xml.to_vec())
3726 .expect("UTF-8 fixture")
3727 .replace("<w:p/>", "<w:p></w:p>")
3728 .replace("<w:r/>", "<w:r></w:r>")
3729 .replace("<w:t/>", "<w:t></w:t>")
3730 .replace("<w:tab/>", "<w:tab></w:tab>");
3731 let expanded_facts = parse_docx(
3732 expanded.as_bytes(),
3733 &control(),
3734 IndexWorkStage::SymbolParsing,
3735 )
3736 .expect("equivalent explicit empty elements");
3737 assert_eq!(facts, expanded_facts);
3738 }
3739
3740 #[test]
3741 fn docx_xml_nesting_is_bounded() {
3742 let xml = format!(
3743 "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{}<w:body/>{}</w:document>",
3744 "<w:container>".repeat(MAX_DOCX_XML_DEPTH),
3745 "</w:container>".repeat(MAX_DOCX_XML_DEPTH),
3746 );
3747 let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
3748 .expect_err("XML nesting must be bounded");
3749 assert!(matches!(
3750 error,
3751 DocumentExtractionError::ResourceLimit {
3752 limit: DocumentLimit::NestingDepth,
3753 observed,
3754 maximum: MAX_DOCX_XML_DEPTH,
3755 } if observed == MAX_DOCX_XML_DEPTH + 1
3756 ));
3757 }
3758
3759 #[test]
3760 fn docx_aggregate_output_is_bounded_before_reading_remaining_xml() {
3761 let run = "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES / 2 + 1);
3762 let xml = format!(
3763 "<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"
3764 );
3765 let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
3766 .expect_err("aggregate output must fail before the later malformed XML");
3767 assert!(matches!(
3768 error,
3769 DocumentExtractionError::ResourceLimit {
3770 limit: DocumentLimit::OutputBytes,
3771 observed,
3772 maximum: MAX_DOCUMENT_OUTPUT_BYTES,
3773 } if observed == MAX_DOCUMENT_OUTPUT_BYTES + 2
3774 ));
3775 }
3776
3777 #[test]
3778 fn direct_xml_extracts_body_and_table_locators() {
3779 let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3780 <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3781 <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>
3782 <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>
3783 </w:document>"#;
3784 let mut bytes = Vec::new();
3785 {
3786 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3787 writer
3788 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3789 .expect("fixture entry");
3790 writer.write_all(xml).expect("fixture XML");
3791 writer.finish().expect("fixture archive");
3792 }
3793 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3794 .expect("valid DOCX");
3795 assert_eq!(facts.text, "Hello world\nCell");
3796 assert_eq!(facts.facts.len(), 3);
3797 assert!(matches!(
3798 facts.facts[2].locator,
3799 DocumentLocator::Docx {
3800 part: DOCX_DOCUMENT_PART,
3801 paragraph: 2,
3802 run: 1,
3803 text_start: 0,
3804 text_end: 4
3805 }
3806 ));
3807 }
3808
3809 #[test]
3810 fn direct_xml_rejects_truncated_document_part() {
3811 let mut bytes = Vec::new();
3812 {
3813 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3814 writer
3815 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3816 .expect("fixture entry");
3817 writer
3818 .write_all(b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>truncated")
3819 .expect("fixture XML");
3820 writer.finish().expect("fixture archive");
3821 }
3822 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3823 .expect_err("truncated XML must fail closed");
3824 assert!(matches!(
3825 error,
3826 DocumentExtractionError::Malformed {
3827 format: DocumentFormat::Docx,
3828 ..
3829 }
3830 ));
3831 }
3832
3833 #[test]
3834 fn docx_text_whitespace_follows_inherited_xml_space() {
3835 for (attributes, leaves, expected) in [
3836 ("", "<w:t> A </w:t><w:t> B </w:t>", "AB"),
3837 (
3838 "",
3839 "<w:t xml:space=\"preserve\"> A </w:t><w:t> B </w:t>",
3840 " A B",
3841 ),
3842 (
3843 "xml:space=\"preserve\"",
3844 "<w:t> A </w:t><w:t xml:space=\"default\"> B </w:t><w:t> C </w:t>",
3845 " A B C ",
3846 ),
3847 (
3848 "",
3849 "<w:t> \tA <![CDATA[ B ]]>& C </w:t>",
3850 "A B & C",
3851 ),
3852 (
3853 "",
3854 "<w:t>  A  </w:t><w:t> \t </w:t>",
3855 "\u{a0}A\u{a0}",
3856 ),
3857 ] {
3858 let xml = format!(
3859 "<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>"
3860 );
3861 let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
3862 .expect("Word text whitespace");
3863 assert_eq!(facts.text, expected);
3864 assert_eq!(facts.facts.len(), 1);
3865 assert_eq!(facts.facts[0].text, expected);
3866 assert!(
3867 matches!(facts.facts[0].locator, DocumentLocator::Docx { paragraph: 1, run: 1, text_start: 0, text_end, .. } if text_end == expected.len())
3868 );
3869 }
3870 }
3871
3872 #[test]
3873 fn docx_invalid_whitespace_mode_refuses_publication() {
3874 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>"#;
3875 assert!(matches!(
3876 extract_document_text_controlled(
3877 &docx_archive(xml, CompressionMethod::Deflated),
3878 "guide.docx",
3879 None,
3880 &control()
3881 ),
3882 Err(DocumentExtractionError::Malformed {
3883 format: DocumentFormat::Docx,
3884 ..
3885 })
3886 ));
3887 }
3888
3889 #[test]
3890 fn direct_xml_preserves_entities_tabs_and_breaks() {
3891 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>"#;
3892 let mut bytes = Vec::new();
3893 {
3894 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3895 writer
3896 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3897 .expect("fixture entry");
3898 writer.write_all(xml).expect("fixture XML");
3899 writer.finish().expect("fixture archive");
3900 }
3901 let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3902 .expect("valid DOCX");
3903 assert_eq!(facts.text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
3904 assert_eq!(facts.facts[0].text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
3905 assert_eq!(facts.facts[0].line_start, 1);
3906 assert_eq!(facts.facts[0].line_end, 3);
3907 assert_eq!(
3908 facts.facts[0].locator.to_string(),
3909 "docx:part=word/document.xml;paragraph=1;run=1;text-span=0..19"
3910 );
3911 }
3912
3913 #[test]
3914 fn docx_font_specific_symbols_refuse_instead_of_inventing_text() {
3915 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>"#;
3916 assert!(matches!(
3917 extract_document_text_controlled(
3918 &docx_archive(xml, CompressionMethod::Deflated),
3919 "symbol.docx",
3920 None,
3921 &control()
3922 ),
3923 Err(DocumentExtractionError::UnsupportedDocxInput { .. })
3924 ));
3925 }
3926
3927 #[test]
3928 fn direct_xml_rejects_external_doctype_declarations() {
3929 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>"#;
3930 let mut bytes = Vec::new();
3931 {
3932 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3933 writer
3934 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3935 .expect("fixture entry");
3936 writer.write_all(xml).expect("fixture XML");
3937 writer.finish().expect("fixture archive");
3938 }
3939 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3940 .expect_err("external declarations must never enter the parser boundary");
3941 assert!(matches!(
3942 error,
3943 DocumentExtractionError::Malformed {
3944 format: DocumentFormat::Docx,
3945 ..
3946 }
3947 ));
3948 }
3949
3950 #[test]
3951 fn direct_xml_rejects_non_whitespace_outside_document_root() {
3952 let xml = br#"prefix<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#;
3953 let mut bytes = Vec::new();
3954 {
3955 let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
3956 writer
3957 .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
3958 .expect("fixture entry");
3959 writer.write_all(xml).expect("fixture XML");
3960 writer.finish().expect("fixture archive");
3961 }
3962 let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
3963 .expect_err("non-whitespace outside the root must fail closed");
3964 assert!(matches!(
3965 error,
3966 DocumentExtractionError::Malformed {
3967 format: DocumentFormat::Docx,
3968 ..
3969 }
3970 ));
3971 }
3972}