Skip to main content

projectatlas_symbols/
documents.rs

1//! Bounded, in-process extraction for the supported repository document formats.
2
3use 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
22/// The exact audited PDF object parser version used by this boundary.
23pub const LOPDF_VERSION: &str = "0.44.0";
24/// The upstream text parser version with the contained guest's documented patches.
25pub const PDF_EXTRACT_VERSION: &str = "0.12.0+projectatlas";
26/// The exact audited XML parser version used by the DOCX boundary.
27pub const QUICK_XML_VERSION: &str = "0.42.0";
28/// Maximum compressed bytes admitted to one document extraction.
29pub const MAX_DOCUMENT_COMPRESSED_BYTES: usize = limits::INPUT_LIMIT;
30/// Maximum expanded package bytes admitted to one document extraction.
31pub const MAX_DOCUMENT_EXPANDED_BYTES: usize = limits::EXPANDED_LIMIT;
32/// Maximum extracted UTF-8 bytes retained from one document.
33pub const MAX_DOCUMENT_OUTPUT_BYTES: usize = limits::OUTPUT_LIMIT;
34/// Maximum source, parser staging, and retained-output envelope for one extraction.
35pub const MAX_DOCUMENT_MEMORY_BYTES: usize = 96 * 1024 * 1024;
36/// Maximum ZIP entries inspected in one DOCX package.
37pub const MAX_DOCUMENT_ENTRIES: usize = 256;
38/// Maximum document-container depth; embedded documents are rejected.
39pub const MAX_DOCUMENT_RECURSION_DEPTH: usize = 1;
40/// Maximum XML element nesting admitted within the document part.
41const MAX_DOCX_XML_DEPTH: usize = 64;
42/// Maximum distinct ignorable namespace URIs in the supported root policy.
43const MAX_DOCX_IGNORABLE_NAMESPACES: usize = 64;
44/// Maximum evidence facts retained from one document.
45pub const MAX_DOCUMENT_FACTS: usize = limits::FACT_LIMIT;
46/// The only DOCX package part admitted to the parser.
47pub const DOCX_DOCUMENT_PART: &str = "word/document.xml";
48
49/// Keep heavyweight document parser envelopes from multiplying by source workers.
50/// ponytail: one parser per process; add bounded parallel admission only if measured throughput requires it.
51static DOCUMENT_EXECUTION: Mutex<()> = Mutex::new(());
52
53/// Admit one process-local document parser without hiding cancellation while queued.
54fn 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            // The lock owns no mutable parser state; each call owns and drops its parser.
63            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/// A repository document format supported by the bounded extraction boundary.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum DocumentFormat {
74    /// Portable Document Format, parsed by the fixed contained text extractor.
75    Pdf,
76    /// Office Open XML Word document, parsed by the direct `quick-xml` boundary.
77    Docx,
78}
79
80impl DocumentFormat {
81    /// Return the canonical language identifier used by the registry.
82    #[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/// Completeness of one bounded extraction result.
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99pub enum DocumentCompleteness {
100    /// Every supported content item in the admitted format was extracted.
101    Complete,
102}
103
104/// Exact location of one extracted document text span.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub enum DocumentLocator {
107    /// A text span in one one-based PDF page.
108    Pdf {
109        /// One-based page number.
110        page: usize,
111        /// Inclusive byte offset within the parser's page text.
112        text_start: usize,
113        /// Exclusive byte offset within the parser's page text.
114        text_end: usize,
115    },
116    /// A text span in the admitted DOCX document part.
117    Docx {
118        /// Package part containing the source text.
119        part: &'static str,
120        /// One-based paragraph ordinal in the admitted document body.
121        paragraph: usize,
122        /// One-based run ordinal within the paragraph.
123        run: usize,
124        /// Inclusive UTF-8 byte offset within the run text.
125        text_start: usize,
126        /// Exclusive UTF-8 byte offset within the run text.
127        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/// Parser provenance attached to every bounded document result.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum DocumentParserProvenance {
159    /// The pinned `pdf-extract` parser with documented guest-local patches.
160    PdfExtract,
161    /// The pinned direct `quick-xml` parser after strict package admission.
162    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/// One exact text fact retained from a supported document.
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub struct DocumentFact {
177    /// Extracted UTF-8 text for this fact.
178    pub text: String,
179    /// Exact parser-relative locator for the text.
180    pub locator: DocumentLocator,
181    /// One-based first line in the emitted document text.
182    pub line_start: usize,
183    /// One-based last occupied line in the emitted document text.
184    pub line_end: usize,
185}
186
187/// Complete bounded text and provenance extracted from one document.
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct DocumentFacts {
190    /// Format admitted by magic and language/extension checks.
191    pub format: DocumentFormat,
192    /// Extracted text with preserved paragraph and run layout for the text index.
193    pub text: String,
194    /// Exact text facts used to build graph evidence.
195    pub facts: Vec<DocumentFact>,
196    /// Whether the supported part was fully examined.
197    pub completeness: DocumentCompleteness,
198    /// Parser provenance for audit and graph evidence.
199    pub provenance: DocumentParserProvenance,
200}
201
202impl DocumentFacts {
203    /// Project exact document facts into the existing sparse symbol graph.
204    #[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/// Resource ceiling applied by the document boundary.
243#[derive(Clone, Copy, Debug, Eq, PartialEq)]
244pub enum DocumentLimit {
245    /// Raw input bytes for PDF or compressed package bytes for DOCX.
246    InputBytes,
247    /// Total compressed ZIP member bytes.
248    CompressedBytes,
249    /// Total uncompressed ZIP member bytes.
250    ExpandedBytes,
251    /// Retained extracted UTF-8 bytes.
252    OutputBytes,
253    /// Source and parser staging envelope.
254    MemoryBytes,
255    /// Number of ZIP entries.
256    EntryCount,
257    /// Number of retained evidence facts.
258    FactCount,
259    /// Nested XML elements in a document part.
260    NestingDepth,
261    /// Interpreter work consumed by the contained PDF parser.
262    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/// Typed failure at the PDF/DOCX trust boundary.
282#[derive(Debug, Error)]
283pub enum DocumentExtractionError {
284    /// The file was not admitted as one of the supported formats.
285    #[error("unsupported document format for language {language}")]
286    UnsupportedFormat {
287        /// Language requested by the caller.
288        language: String,
289    },
290    /// The path/registry admission and file magic disagree.
291    #[error("document magic does not match expected {expected} format; found {found}")]
292    MismatchedMagic {
293        /// Format expected from the path or language registry.
294        expected: DocumentFormat,
295        /// Format identified by the leading bytes.
296        found: &'static str,
297    },
298    /// A document exceeded one explicit bounded resource.
299    #[error("document exceeded {limit}: observed {observed}, limit {maximum}")]
300    ResourceLimit {
301        /// Resource that exceeded its ceiling.
302        limit: DocumentLimit,
303        /// First observed value beyond the ceiling.
304        observed: usize,
305        /// Configured maximum.
306        maximum: usize,
307    },
308    /// An encrypted PDF was rejected because credentials are never accepted here.
309    #[error("encrypted PDF documents are unsupported")]
310    EncryptedPdf,
311    /// PDF text semantics require a feature outside the admitted parser subset.
312    #[error("unsupported PDF text semantics")]
313    UnsupportedPdfInput,
314    /// A malformed or unreadable parser input was rejected.
315    #[error("malformed {format} document: {message}")]
316    Malformed {
317        /// Format whose parser reported the error.
318        format: DocumentFormat,
319        /// Bounded parser diagnostic.
320        message: String,
321    },
322    /// ZIP package structure is unsafe or not a supported DOCX package.
323    #[error("invalid DOCX package: {message}")]
324    InvalidDocxPackage {
325        /// Bounded package diagnostic.
326        message: String,
327    },
328    /// DOCX input requires a document or package feature outside the admitted set.
329    #[error("unsupported DOCX package input: {message}")]
330    UnsupportedDocxInput {
331        /// Bounded package diagnostic.
332        message: String,
333    },
334    /// Shared indexing cancellation or deadline stopped extraction.
335    #[error(transparent)]
336    Work(#[from] IndexWorkFailure),
337}
338
339/// Honor a supplied language; infer from the extension only when no language is supplied.
340#[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
350/// Extract bounded text and exact facts for one admitted document.
351///
352/// # Errors
353///
354/// Returns a typed admission, parser, resource, cancellation, or deadline
355/// failure without returning a partial result.
356pub 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
371/// Extract a sparse graph with exact document locator evidence.
372///
373/// # Errors
374///
375/// Returns a typed admission, parser, resource, cancellation, or deadline
376/// failure without returning a partial graph.
377pub 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
389/// Extract document text and exact facts for symbol publication and content summaries.
390///
391/// # Errors
392///
393/// Returns a typed admission, parser, resource, cancellation, or deadline failure
394/// without returning partial facts.
395pub 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
410/// Extract one document while applying the caller-selected work stage.
411fn 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
440/// Extract PDF page text after magic, encryption, page-content, and memory checks.
441fn 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
518/// Append one ASCII separator while enforcing the retained-output ceiling.
519fn 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
531/// Enforce the source and parser-staging memory envelope.
532fn 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
543/// Validate a DOCX ZIP and parse only its declared document part with quick-xml.
544fn 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    // Retain XML, parser staging, namespace resolver and deduplicated root policy
696    // copies, plus geometric string growth in the run/output/facts and the fact vector.
697    // Archive metadata has already been dropped before these allocations overlap.
698    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/// Decoded run text with Word text-leaf whitespace policy applied before publication.
719#[derive(Default)]
720struct RawDocxRun {
721    /// Decoded logical run text.
722    text: String,
723    /// Decoded run bytes already emitted before a nested text container.
724    text_start: usize,
725}
726
727/// Paragraph, run, and field ownership within one `WordprocessingML` text container.
728#[derive(Default)]
729struct DocxTextContext {
730    /// Document-order paragraph ordinal, including empty paragraphs.
731    number: usize,
732    /// Run ordinal within this paragraph, including empty runs.
733    run_number: usize,
734    /// Whether the paragraph element is still open.
735    open: bool,
736    /// Unpublished fragment of the active run.
737    run: Option<RawDocxRun>,
738    /// Nested complex-field phases in this text container.
739    fields: Vec<DocxFieldPhase>,
740}
741
742/// Whether a complex field is still in its instruction or cached-result region.
743#[derive(Clone, Copy, PartialEq, Eq)]
744enum DocxFieldPhase {
745    /// Field instructions are data and are never executed or rendered.
746    Instruction,
747    /// Existing cached result text may be retained.
748    Result,
749}
750
751/// The closed text leaves admitted from the Word document part.
752#[derive(Clone, Copy, PartialEq, Eq)]
753enum DocxTextCarrier {
754    /// Literal document text retained with a locator.
755    Rendered,
756    /// Field instructions and deleted text are validated without publication.
757    Ignored,
758}
759
760/// Recognize either supported Word namespace independently of its chosen prefix.
761fn 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
769/// Selection state for one bounded Markup Compatibility alternative.
770struct DocxAlternative {
771    /// XML depth of the enclosing `AlternateContent` element.
772    depth: usize,
773    /// Whether an earlier branch was selected.
774    selected: bool,
775    /// Whether the required first Choice has appeared.
776    choice_seen: bool,
777    /// Whether the final optional Fallback has appeared.
778    fallback_seen: bool,
779}
780
781/// Parse body paragraphs and table paragraphs from the admitted XML part.
782fn 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                            // Trim the complete leaf, not individual XML text/entity/CDATA events.
1365                            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
1463/// Emit a run fragment before leaving its container, preserving its original byte locator.
1464fn 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    // A terminal newline does not create another occupied slice line.
1494    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
1513/// Append decoded XML text while bounding one retained run before publication.
1514fn 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
1533/// Decode only XML predefined and character references; external entities are never resolved.
1534fn 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]]>", "&#120;"] {
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 &amp; <![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 &amp; <![CDATA[ignored]]>", "<w:t>nested</w:t>"),
3466            text.replace("PAGE &amp; <![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&#x20;<![CDATA[ B ]]>&amp; C&#x20;</w:t>",
3850                "A  B & C",
3851            ),
3852            (
3853                "",
3854                "<w:t> &#xA0;A&#xA0; </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 &amp; 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}