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::BytesStart;
8use quick_xml::events::{BytesRef, Event};
9use quick_xml::name::{QName, ResolveResult};
10use std::collections::{BTreeMap, HashMap, HashSet};
11use std::fmt;
12use std::io::{Cursor, Read};
13use std::path::Path;
14use std::sync::{Mutex, MutexGuard, TryLockError};
15use std::time::Duration;
16use thiserror::Error;
17use zip::read::{ZipFile, ZipFileEntry};
18use zip::result::{ZipError, ZipResult};
19use zip::unstable::stream::{ZipStreamReader, ZipStreamVisitor};
20use zip::{CompressionMethod, ZipArchive};
21
22#[path = "../../../packaging/pdf-parser/limits.rs"]
23mod limits;
24mod pdf_runtime;
25
26/// The exact audited PDF object parser version used by this boundary.
27pub const LOPDF_VERSION: &str = "0.44.0";
28/// The upstream text parser version with the contained guest's documented patches.
29pub const PDF_EXTRACT_VERSION: &str = "0.12.0+projectatlas";
30/// The exact audited XML parser version used by the DOCX boundary.
31pub const QUICK_XML_VERSION: &str = "0.42.0";
32/// Maximum compressed bytes admitted to one document extraction.
33pub const MAX_DOCUMENT_COMPRESSED_BYTES: usize = limits::INPUT_LIMIT;
34/// Maximum expanded package bytes admitted to one document extraction.
35pub const MAX_DOCUMENT_EXPANDED_BYTES: usize = limits::EXPANDED_LIMIT;
36/// Maximum extracted UTF-8 bytes retained from one document.
37pub const MAX_DOCUMENT_OUTPUT_BYTES: usize = limits::OUTPUT_LIMIT;
38/// Maximum source, parser staging, and retained-output envelope for one extraction.
39pub const MAX_DOCUMENT_MEMORY_BYTES: usize = 96 * 1024 * 1024;
40/// Maximum ZIP entries inspected in one DOCX package.
41pub const MAX_DOCUMENT_ENTRIES: usize = 256;
42/// Maximum document-container depth; embedded documents are rejected.
43pub const MAX_DOCUMENT_RECURSION_DEPTH: usize = 1;
44/// Maximum XML element nesting admitted within the document part.
45const MAX_DOCX_XML_DEPTH: usize = 64;
46/// Maximum ZIP part-name bytes before locator fanout can exceed the document envelope.
47const MAX_DOCX_PART_NAME_BYTES: usize = 1024;
48/// XML reference identities are bounded before aggregate set fanout.
49const MAX_DOCX_REFERENCE_ID_BYTES: usize = 256;
50/// Exact font identity retained on each symbol without multiplying unbounded XML attributes.
51const MAX_DOCX_FONT_NAME_BYTES: usize = 256;
52/// Maximum one relationship or content-type XML table admitted for bounded metadata maps.
53const MAX_DOCX_METADATA_BYTES: usize = 2 * 1024 * 1024;
54/// Maximum retained package metadata records from one XML table.
55const MAX_DOCX_METADATA_RECORDS: usize = 4096;
56/// Repeated decompression is parser work, not additional ZIP admission.
57const MAX_DOCX_DECOMPRESSION_WORK_BYTES: usize = MAX_DOCUMENT_EXPANDED_BYTES * 2;
58/// Maximum distinct ignorable namespace URIs in the supported root policy.
59const MAX_DOCX_IGNORABLE_NAMESPACES: usize = 64;
60/// Maximum evidence facts retained from one document.
61pub const MAX_DOCUMENT_FACTS: usize = limits::FACT_LIMIT;
62/// Main DOCX document part selected by the package relationship.
63pub const DOCX_DOCUMENT_PART: &str = "word/document.xml";
64/// Reserved derived graph fact carrying document-local text coverage to publication.
65pub const DOCUMENT_COVERAGE_SYMBOL: &str = "document-text-coverage";
66
67/// Keep heavyweight document parser envelopes from multiplying by source workers.
68/// ponytail: one parser per process; add bounded parallel admission only if measured throughput requires it.
69static DOCUMENT_EXECUTION: Mutex<()> = Mutex::new(());
70
71/// Admit one process-local document parser without hiding cancellation while queued.
72fn lock_document_execution(
73    control: &IndexWorkControl,
74    stage: IndexWorkStage,
75) -> Result<MutexGuard<'static, ()>, DocumentExtractionError> {
76    loop {
77        control.check(stage)?;
78        match DOCUMENT_EXECUTION.try_lock() {
79            Ok(guard) => return Ok(guard),
80            // The lock owns no mutable parser state; each call owns and drops its parser.
81            Err(TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
82            Err(TryLockError::WouldBlock) => {
83                std::thread::park_timeout(Duration::from_millis(10));
84            }
85        }
86    }
87}
88
89/// A repository document format supported by the bounded extraction boundary.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum DocumentFormat {
92    /// Portable Document Format, parsed by the fixed contained text extractor.
93    Pdf,
94    /// Office Open XML Word document, parsed by the direct `quick-xml` boundary.
95    Docx,
96}
97
98impl DocumentFormat {
99    /// Return the canonical language identifier used by the registry.
100    #[must_use]
101    pub const fn language(self) -> &'static str {
102        match self {
103            Self::Pdf => "pdf",
104            Self::Docx => "docx",
105        }
106    }
107}
108
109impl fmt::Display for DocumentFormat {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter.write_str(self.language())
112    }
113}
114
115/// Completeness of one bounded extraction result.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub enum DocumentCompleteness {
118    /// Every supported content item in the admitted format was extracted.
119    Complete,
120    /// Exact retained evidence has one or more explicitly unresolved text regions.
121    Partial {
122        /// Distinct reasons why the extracted text is not the complete rendering.
123        gaps: Vec<DocumentCoverageGap>,
124    },
125}
126
127/// A bounded reason that prevents document text from claiming full coverage.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum DocumentCoverageGap {
130    /// A font-specific glyph has no verified Unicode mapping.
131    UnknownSymbolMapping,
132    /// A live field or reference marker requires layout-time evaluation.
133    UnevaluatedField,
134    /// A referenced story could not be safely interpreted as rendered text.
135    UnexaminedStory,
136    /// A configured story may not appear without pagination evidence.
137    ConditionalStory,
138    /// Style inheritance or renderer policy may change which runs are visible.
139    UnresolvedVisibility,
140    /// Parsing stopped at an accepted post-admission resource ceiling.
141    ResourceLimit(DocumentLimit),
142}
143
144impl DocumentCoverageGap {
145    /// Stable persisted spelling for one document-local incomplete reason.
146    #[must_use]
147    pub const fn as_str(self) -> &'static str {
148        match self {
149            Self::UnknownSymbolMapping => "unknown_symbol_mapping",
150            Self::UnevaluatedField => "unevaluated_field",
151            Self::UnexaminedStory => "unexamined_story",
152            Self::ConditionalStory => "conditional_story",
153            Self::UnresolvedVisibility => "unresolved_visibility",
154            Self::ResourceLimit(DocumentLimit::OutputBytes) => "resource_limit:output_bytes",
155            Self::ResourceLimit(DocumentLimit::FactCount) => "resource_limit:fact_count",
156            Self::ResourceLimit(DocumentLimit::MemoryBytes) => "resource_limit:memory_bytes",
157            Self::ResourceLimit(DocumentLimit::ExpandedBytes) => "resource_limit:expanded_bytes",
158            Self::ResourceLimit(DocumentLimit::ParserWorkBytes) => {
159                "resource_limit:parser_work_bytes"
160            }
161            Self::ResourceLimit(DocumentLimit::ExecutionFuel) => "resource_limit:execution_fuel",
162            Self::ResourceLimit(_) => "resource_limit:other",
163        }
164    }
165}
166
167/// Exact location of one extracted document text span.
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub enum DocumentLocator {
170    /// A text span in one one-based PDF page.
171    Pdf {
172        /// One-based page number.
173        page: usize,
174        /// Inclusive byte offset within the parser's page text.
175        text_start: usize,
176        /// Exclusive byte offset within the parser's page text.
177        text_end: usize,
178    },
179    /// A text span in one admitted DOCX story part.
180    Docx {
181        /// Package part containing the source text.
182        part: String,
183        /// One-based paragraph ordinal in the admitted document body.
184        paragraph: usize,
185        /// One-based run ordinal within the paragraph.
186        run: usize,
187        /// Inclusive UTF-8 byte offset within the run text.
188        text_start: usize,
189        /// Exclusive UTF-8 byte offset within the run text.
190        text_end: usize,
191    },
192}
193
194impl fmt::Display for DocumentLocator {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::Pdf {
198                page,
199                text_start,
200                text_end,
201            } => write!(
202                formatter,
203                "pdf:page={page};text-span={text_start}..{text_end}"
204            ),
205            Self::Docx {
206                part,
207                paragraph,
208                run,
209                text_start,
210                text_end,
211            } => write!(
212                formatter,
213                "docx:part={part};paragraph={paragraph};run={run};text-span={text_start}..{text_end}"
214            ),
215        }
216    }
217}
218
219/// Parser provenance attached to every bounded document result.
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub enum DocumentParserProvenance {
222    /// The pinned `pdf-extract` parser with documented guest-local patches.
223    PdfExtract,
224    /// The pinned direct `quick-xml` parser after strict package admission.
225    QuickXml,
226}
227
228impl fmt::Display for DocumentParserProvenance {
229    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230        formatter.write_str(match self {
231            Self::PdfExtract => "pdf-extract-0.12.0+projectatlas",
232            Self::QuickXml => "quick-xml-0.42.0",
233        })
234    }
235}
236
237/// One exact text fact retained from a supported document.
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct DocumentFact {
240    /// Extracted UTF-8 text for this fact.
241    pub text: String,
242    /// Exact parser-relative locator for the text.
243    pub locator: DocumentLocator,
244    /// One-based first line in the emitted document text.
245    pub line_start: usize,
246    /// One-based last occupied line in the emitted document text.
247    pub line_end: usize,
248}
249
250/// One rendered DOCX font glyph with exact source identity and optional verified text.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct DocumentSymbol {
253    /// Font named by `w:font`, if supplied.
254    pub font: Option<String>,
255    /// Numeric `w:char` value, if supplied.
256    pub code: Option<u16>,
257    /// Exact package part, paragraph, run, and in-run occurrence span.
258    pub locator: DocumentLocator,
259    /// One-based line containing the glyph or unresolved placeholder.
260    pub line: usize,
261    /// Unicode text only when the font/code mapping is verified.
262    pub unicode: Option<char>,
263}
264
265/// Complete bounded text and provenance extracted from one document.
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct DocumentFacts {
268    /// Format admitted by magic and language/extension checks.
269    pub format: DocumentFormat,
270    /// Extracted text with preserved paragraph and run layout for the text index.
271    pub text: String,
272    /// Exact text facts used to build graph evidence.
273    pub facts: Vec<DocumentFact>,
274    /// Rendered font glyphs, including ones with unresolved Unicode text.
275    pub symbols: Vec<DocumentSymbol>,
276    /// Whether the supported part was fully examined.
277    pub completeness: DocumentCompleteness,
278    /// Parser provenance for audit and graph evidence.
279    pub provenance: DocumentParserProvenance,
280}
281
282impl DocumentFacts {
283    /// Project exact document facts into the existing sparse symbol graph.
284    /// The admitted format, not a caller's optional language hint, owns the graph language.
285    #[must_use]
286    pub fn symbol_graph(&self, path: &str, _language: Option<&str>) -> SymbolGraph {
287        let language = Some(self.format.language().to_owned());
288        let mut symbols: Vec<CodeSymbol> = self
289            .facts
290            .iter()
291            .enumerate()
292            .map(|(index, fact)| CodeSymbol {
293                path: path.to_owned(),
294                language: language.clone(),
295                name: format!("document-block-{}", index + 1),
296                kind: SymbolKind::Value,
297                signature: fact.locator.to_string(),
298                exported: false,
299                documentation: None,
300                line_start: fact.line_start,
301                line_end: fact.line_end,
302                source_selector: None,
303                parent: None,
304                parser: ParserKind::Structural,
305                detail: Some(format!(
306                    "format={};provenance={};completeness={:?};text-bytes={}",
307                    self.format,
308                    self.provenance,
309                    self.completeness,
310                    fact.text.len()
311                )),
312            })
313            .collect();
314        symbols.extend(self.symbols.iter().enumerate().map(|(index, symbol)| {
315            let code = symbol
316                .code
317                .map_or_else(|| "missing".to_owned(), |code| format!("{code:04X}"));
318            CodeSymbol {
319                path: path.to_owned(),
320                language: language.clone(),
321                name: format!("document-symbol-{}", index + 1),
322                kind: SymbolKind::Value,
323                signature: format!("{};font={:?};code={code}", symbol.locator, symbol.font),
324                exported: false,
325                documentation: None,
326                line_start: symbol.line,
327                line_end: symbol.line,
328                source_selector: None,
329                parent: None,
330                parser: ParserKind::Structural,
331                detail: Some(format!(
332                    "unicode={:?};completeness={:?}",
333                    symbol.unicode, self.completeness
334                )),
335            }
336        }));
337        if let DocumentCompleteness::Partial { gaps } = &self.completeness {
338            symbols.push(CodeSymbol {
339                path: path.to_owned(),
340                language: language.clone(),
341                name: DOCUMENT_COVERAGE_SYMBOL.to_owned(),
342                kind: SymbolKind::Unknown,
343                signature: gaps
344                    .iter()
345                    .map(|gap| gap.as_str())
346                    .collect::<Vec<_>>()
347                    .join(","),
348                exported: false,
349                documentation: None,
350                line_start: 1,
351                line_end: 1,
352                source_selector: None,
353                parent: None,
354                parser: ParserKind::Structural,
355                detail: Some("document text coverage is incomplete".to_owned()),
356            });
357        }
358        SymbolGraph {
359            path: path.to_owned(),
360            language,
361            parser: ParserKind::Structural,
362            symbols,
363            relations: Vec::new(),
364        }
365    }
366}
367
368/// Resource ceiling applied by the document boundary.
369#[derive(Clone, Copy, Debug, Eq, PartialEq)]
370pub enum DocumentLimit {
371    /// Raw input bytes for PDF or compressed package bytes for DOCX.
372    InputBytes,
373    /// Total compressed ZIP member bytes.
374    CompressedBytes,
375    /// Total uncompressed ZIP member bytes.
376    ExpandedBytes,
377    /// Total repeated DOCX decompression work after safe package admission.
378    ParserWorkBytes,
379    /// Retained extracted UTF-8 bytes.
380    OutputBytes,
381    /// Source and parser staging envelope.
382    MemoryBytes,
383    /// Number of ZIP entries.
384    EntryCount,
385    /// Number of retained evidence facts.
386    FactCount,
387    /// Nested XML elements in a document part.
388    NestingDepth,
389    /// Interpreter work consumed by the contained PDF parser.
390    ExecutionFuel,
391}
392
393impl fmt::Display for DocumentLimit {
394    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
395        formatter.write_str(match self {
396            Self::InputBytes => "input_bytes",
397            Self::CompressedBytes => "compressed_bytes",
398            Self::ExpandedBytes => "expanded_bytes",
399            Self::ParserWorkBytes => "parser_work_bytes",
400            Self::OutputBytes => "output_bytes",
401            Self::MemoryBytes => "memory_bytes",
402            Self::EntryCount => "entry_count",
403            Self::FactCount => "fact_count",
404            Self::NestingDepth => "nesting_depth",
405            Self::ExecutionFuel => "execution_fuel",
406        })
407    }
408}
409
410/// Typed failure at the PDF/DOCX trust boundary.
411#[derive(Debug, Error)]
412pub enum DocumentExtractionError {
413    /// The file was not admitted as one of the supported formats.
414    #[error("unsupported document format for language {language}")]
415    UnsupportedFormat {
416        /// Language requested by the caller.
417        language: String,
418    },
419    /// The path/registry admission and file magic disagree.
420    #[error("document magic does not match expected {expected} format; found {found}")]
421    MismatchedMagic {
422        /// Format expected from the path or language registry.
423        expected: DocumentFormat,
424        /// Format identified by the leading bytes.
425        found: &'static str,
426    },
427    /// A document exceeded one explicit bounded resource.
428    #[error("document exceeded {limit}: observed {observed}, limit {maximum}")]
429    ResourceLimit {
430        /// Resource that exceeded its ceiling.
431        limit: DocumentLimit,
432        /// First observed value beyond the ceiling.
433        observed: usize,
434        /// Configured maximum.
435        maximum: usize,
436    },
437    /// An encrypted PDF was rejected because credentials are never accepted here.
438    #[error("encrypted PDF documents are unsupported")]
439    EncryptedPdf,
440    /// PDF text semantics require a feature outside the admitted parser subset.
441    #[error("unsupported PDF text semantics")]
442    UnsupportedPdfInput,
443    /// A malformed or unreadable parser input was rejected.
444    #[error("malformed {format} document: {message}")]
445    Malformed {
446        /// Format whose parser reported the error.
447        format: DocumentFormat,
448        /// Bounded parser diagnostic.
449        message: String,
450    },
451    /// ZIP package structure is unsafe or not a supported DOCX package.
452    #[error("invalid DOCX package: {message}")]
453    InvalidDocxPackage {
454        /// Bounded package diagnostic.
455        message: String,
456    },
457    /// DOCX input requires a document or package feature outside the admitted set.
458    #[error("unsupported DOCX package input: {message}")]
459    UnsupportedDocxInput {
460        /// Bounded package diagnostic.
461        message: String,
462    },
463    /// Shared indexing cancellation or deadline stopped extraction.
464    #[error(transparent)]
465    Work(#[from] IndexWorkFailure),
466}
467
468/// Honor a supplied language; infer from the extension only when no language is supplied.
469#[must_use]
470pub fn document_format_for_path(path: &str, language: Option<&str>) -> Option<DocumentFormat> {
471    let language = language.or_else(|| Path::new(path).extension()?.to_str())?;
472    match language.to_ascii_lowercase().as_str() {
473        "pdf" => Some(DocumentFormat::Pdf),
474        "docx" => Some(DocumentFormat::Docx),
475        _ => None,
476    }
477}
478
479/// Extract bounded text and exact facts for one admitted document.
480///
481/// # Errors
482///
483/// Returns a typed admission, parser, resource, cancellation, or deadline
484/// failure without returning a partial result.
485pub fn extract_document_text_controlled(
486    bytes: &[u8],
487    path: &str,
488    language: Option<&str>,
489    control: &IndexWorkControl,
490) -> Result<DocumentFacts, DocumentExtractionError> {
491    extract_document_controlled_with_stage(
492        bytes,
493        path,
494        language,
495        control,
496        IndexWorkStage::TextIndex,
497    )
498}
499
500/// Extract a sparse graph with exact document locator evidence.
501///
502/// # Errors
503///
504/// Returns a typed admission, parser, resource, cancellation, or deadline
505/// failure without returning a partial graph.
506pub fn extract_document_graph_controlled(
507    bytes: &[u8],
508    path: &str,
509    language: Option<&str>,
510    control: &IndexWorkControl,
511) -> Result<SymbolGraph, DocumentExtractionError> {
512    Ok(
513        extract_document_symbol_facts_controlled(bytes, path, language, control)?
514            .symbol_graph(path, language),
515    )
516}
517
518/// Extract document text and exact facts for symbol publication and content summaries.
519///
520/// # Errors
521///
522/// Returns a typed admission, parser, resource, cancellation, or deadline failure
523/// without returning partial facts.
524pub fn extract_document_symbol_facts_controlled(
525    bytes: &[u8],
526    path: &str,
527    language: Option<&str>,
528    control: &IndexWorkControl,
529) -> Result<DocumentFacts, DocumentExtractionError> {
530    extract_document_controlled_with_stage(
531        bytes,
532        path,
533        language,
534        control,
535        IndexWorkStage::SymbolParsing,
536    )
537}
538
539/// Extract one document while applying the caller-selected work stage.
540fn extract_document_controlled_with_stage(
541    bytes: &[u8],
542    path: &str,
543    language: Option<&str>,
544    control: &IndexWorkControl,
545    stage: IndexWorkStage,
546) -> Result<DocumentFacts, DocumentExtractionError> {
547    control.check(stage)?;
548    if bytes.len() > MAX_DOCUMENT_COMPRESSED_BYTES {
549        return Err(DocumentExtractionError::ResourceLimit {
550            limit: DocumentLimit::InputBytes,
551            observed: bytes.len(),
552            maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
553        });
554    }
555    let format = document_format_for_path(path, language).ok_or_else(|| {
556        DocumentExtractionError::UnsupportedFormat {
557            language: language.unwrap_or("unknown").to_owned(),
558        }
559    })?;
560    let _execution = lock_document_execution(control, stage)?;
561    let facts = match format {
562        DocumentFormat::Pdf => extract_pdf(bytes, control, stage)?,
563        DocumentFormat::Docx => extract_docx(bytes, control, stage)?,
564    };
565    control.check(stage)?;
566    Ok(facts)
567}
568
569/// Extract PDF page text after magic, encryption, page-content, and memory checks.
570fn extract_pdf(
571    bytes: &[u8],
572    control: &IndexWorkControl,
573    stage: IndexWorkStage,
574) -> Result<DocumentFacts, DocumentExtractionError> {
575    if !bytes.starts_with(b"%PDF-") {
576        return Err(DocumentExtractionError::MismatchedMagic {
577            expected: DocumentFormat::Pdf,
578            found: if bytes.starts_with(b"PK\x03\x04") {
579                "docx"
580            } else {
581                "unknown"
582            },
583        });
584    }
585    let pages = match pdf_runtime::extract_pages(bytes, control, stage) {
586        Ok(pdf_runtime::PdfPages::Complete(pages)) => pages,
587        Ok(pdf_runtime::PdfPages::Incomplete(limit)) => {
588            return Ok(empty_partial_document(
589                DocumentFormat::Pdf,
590                DocumentParserProvenance::PdfExtract,
591                DocumentCoverageGap::ResourceLimit(limit),
592            ));
593        }
594        Err(error) => return Err(error),
595    };
596    pdf_pages_to_facts(&pages, control, stage)
597}
598
599/// Assemble admitted PDF pages without changing their page-local source offsets.
600fn pdf_pages_to_facts(
601    pages: &[(u32, String)],
602    control: &IndexWorkControl,
603    stage: IndexWorkStage,
604) -> Result<DocumentFacts, DocumentExtractionError> {
605    let mut text = String::new();
606    let mut facts = Vec::new();
607    for (page_number, page) in pages {
608        control.check(stage)?;
609        let mut page_offset = 0usize;
610        for line in page.split('\n') {
611            let source_start = page_offset;
612            page_offset = page_offset.saturating_add(line.len().saturating_add(1));
613            if line.trim().is_empty() {
614                continue;
615            }
616            if facts.len() >= MAX_DOCUMENT_FACTS {
617                return Ok(empty_partial_document(
618                    DocumentFormat::Pdf,
619                    DocumentParserProvenance::PdfExtract,
620                    DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount),
621                ));
622            }
623            let required = text
624                .len()
625                .saturating_add(usize::from(!text.is_empty()))
626                .saturating_add(line.len());
627            if required > MAX_DOCUMENT_OUTPUT_BYTES {
628                return Ok(empty_partial_document(
629                    DocumentFormat::Pdf,
630                    DocumentParserProvenance::PdfExtract,
631                    DocumentCoverageGap::ResourceLimit(DocumentLimit::OutputBytes),
632                ));
633            }
634            if !text.is_empty() {
635                push_output_byte(&mut text, b'\n')?;
636            }
637            text.push_str(line);
638            let end = text.len();
639            facts.push(DocumentFact {
640                line_start: facts.len() + 1,
641                line_end: facts.len() + 1,
642                text: line.to_owned(),
643                locator: DocumentLocator::Pdf {
644                    page: usize::try_from(*page_number).map_err(|_error| {
645                        DocumentExtractionError::Malformed {
646                            format: DocumentFormat::Pdf,
647                            message: "PDF page number exceeds host range".to_owned(),
648                        }
649                    })?,
650                    text_start: source_start,
651                    text_end: source_start.saturating_add(line.len()),
652                },
653            });
654            debug_assert!(end <= MAX_DOCUMENT_OUTPUT_BYTES);
655        }
656    }
657    Ok(DocumentFacts {
658        format: DocumentFormat::Pdf,
659        text,
660        facts,
661        symbols: Vec::new(),
662        completeness: DocumentCompleteness::Complete,
663        provenance: DocumentParserProvenance::PdfExtract,
664    })
665}
666
667/// Append one ASCII separator while enforcing the retained-output ceiling.
668fn push_output_byte(text: &mut String, byte: u8) -> Result<(), DocumentExtractionError> {
669    if text.len().saturating_add(1) > MAX_DOCUMENT_OUTPUT_BYTES {
670        return Err(DocumentExtractionError::ResourceLimit {
671            limit: DocumentLimit::OutputBytes,
672            observed: text.len().saturating_add(1),
673            maximum: MAX_DOCUMENT_OUTPUT_BYTES,
674        });
675    }
676    text.push(char::from(byte));
677    Ok(())
678}
679
680/// Enforce the source and parser-staging memory envelope.
681fn check_memory_budget(observed: usize) -> Result<(), DocumentExtractionError> {
682    if observed > MAX_DOCUMENT_MEMORY_BYTES {
683        return Err(DocumentExtractionError::ResourceLimit {
684            limit: DocumentLimit::MemoryBytes,
685            observed,
686            maximum: MAX_DOCUMENT_MEMORY_BYTES,
687        });
688    }
689    Ok(())
690}
691
692/// Admit original central-directory records, including names hidden by the indexed reader.
693struct DocxArchiveAdmission<'a> {
694    /// Validated unique part names reused by package relationship admission.
695    names: HashSet<String>,
696    /// Original central-directory records seen, including duplicate names.
697    entries: usize,
698    /// Request cancellation and deadline governing metadata admission.
699    control: &'a IndexWorkControl,
700    /// Owning indexing stage reported by cancellation and deadline failures.
701    stage: IndexWorkStage,
702    /// Typed admission failure retained across the ZIP visitor's fixed error type.
703    failure: Option<DocumentExtractionError>,
704}
705
706impl DocxArchiveAdmission<'_> {
707    /// Count and validate one original part without reading its payload.
708    fn admit_part(&mut self, metadata: &ZipFileEntry<'_>) -> Result<(), DocumentExtractionError> {
709        self.control.check(self.stage)?;
710        self.entries =
711            self.entries
712                .checked_add(1)
713                .ok_or(DocumentExtractionError::ResourceLimit {
714                    limit: DocumentLimit::EntryCount,
715                    observed: usize::MAX,
716                    maximum: MAX_DOCUMENT_ENTRIES,
717                })?;
718        if self.entries > MAX_DOCUMENT_ENTRIES {
719            return Err(DocumentExtractionError::ResourceLimit {
720                limit: DocumentLimit::EntryCount,
721                observed: self.entries,
722                maximum: MAX_DOCUMENT_ENTRIES,
723            });
724        }
725        if std::str::from_utf8(metadata.name_raw()).is_err() {
726            return Err(DocumentExtractionError::InvalidDocxPackage {
727                message: "DOCX package part metadata is not UTF-8".to_owned(),
728            });
729        }
730        let name =
731            metadata
732                .name()
733                .map_err(|error| DocumentExtractionError::InvalidDocxPackage {
734                    message: error.to_string(),
735                })?;
736        if name.len() > MAX_DOCX_PART_NAME_BYTES {
737            return Err(DocumentExtractionError::InvalidDocxPackage {
738                message: format!("DOCX package part name exceeds {MAX_DOCX_PART_NAME_BYTES} bytes"),
739            });
740        }
741        if metadata.enclosed_name().is_none()
742            || name.contains('\\')
743            || name.starts_with('/')
744            || !self.names.insert(name.to_string())
745        {
746            return Err(DocumentExtractionError::InvalidDocxPackage {
747                message: format!("unsafe or duplicate package part {name}"),
748            });
749        }
750        Ok(())
751    }
752}
753
754impl ZipStreamVisitor for DocxArchiveAdmission<'_> {
755    fn visit_file<R: Read>(&mut self, _file: &mut ZipFile<'_, R>) -> ZipResult<()> {
756        Err(ZipError::InvalidArchive(
757            "DOCX metadata traversal reached a local entry".into(),
758        ))
759    }
760
761    fn visit_additional_metadata(&mut self, metadata: &ZipFileEntry<'_>) -> ZipResult<()> {
762        self.admit_part(metadata).map_err(|error| {
763            self.failure = Some(error);
764            ZipError::InvalidArchive("DOCX metadata admission failed".into())
765        })
766    }
767}
768
769/// Validate a DOCX ZIP and parse only its declared document part with quick-xml.
770fn extract_docx(
771    bytes: &[u8],
772    control: &IndexWorkControl,
773    stage: IndexWorkStage,
774) -> Result<DocumentFacts, DocumentExtractionError> {
775    if !bytes.starts_with(b"PK\x03\x04") {
776        return Err(DocumentExtractionError::MismatchedMagic {
777            expected: DocumentFormat::Docx,
778            found: if bytes.starts_with(b"%PDF-") {
779                "pdf"
780            } else {
781                "unknown"
782            },
783        });
784    }
785    let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|error| {
786        DocumentExtractionError::InvalidDocxPackage {
787            message: error.to_string(),
788        }
789    })?;
790    if archive.len() > MAX_DOCUMENT_ENTRIES {
791        return Err(DocumentExtractionError::ResourceLimit {
792            limit: DocumentLimit::EntryCount,
793            observed: archive.len(),
794            maximum: MAX_DOCUMENT_ENTRIES,
795        });
796    }
797    let mut admission = DocxArchiveAdmission {
798        names: HashSet::new(),
799        entries: 0,
800        control,
801        stage,
802        failure: None,
803    };
804    let mut metadata = Cursor::new(bytes);
805    metadata.set_position(archive.central_directory_start());
806    // The stream visitor stops at the first central parse error. The indexed reader above
807    // already validated these same immutable records; this pass only restores their identity/count.
808    let result = ZipStreamReader::new(metadata).visit(&mut admission);
809    if let Some(error) = admission.failure {
810        return Err(error);
811    }
812    result.map_err(|error| DocumentExtractionError::InvalidDocxPackage {
813        message: error.to_string(),
814    })?;
815    if admission.entries != archive.len() {
816        return Err(DocumentExtractionError::InvalidDocxPackage {
817            message: "DOCX metadata traversal is incomplete".to_owned(),
818        });
819    }
820    let names = admission.names;
821    let mut compressed_bytes = 0usize;
822    let mut expanded_bytes = 0usize;
823    for index in 0..archive.len() {
824        check_parser_iteration(index, &mut || control.check(stage))?;
825        let entry = archive.by_index_raw(index).map_err(|error| {
826            DocumentExtractionError::InvalidDocxPackage {
827                message: error.to_string(),
828            }
829        })?;
830        let name = entry
831            .name()
832            .map_err(|error| DocumentExtractionError::InvalidDocxPackage {
833                message: error.to_string(),
834            })?;
835        let compression = entry.compression();
836        let compressed_size = entry.compressed_size();
837        let expanded_size = entry.size();
838        if !matches!(
839            compression,
840            CompressionMethod::Stored | CompressionMethod::Deflated
841        ) {
842            return Err(DocumentExtractionError::UnsupportedDocxInput {
843                message: format!("unsupported compression for package part {name}"),
844            });
845        }
846        let name = name.into_owned();
847        drop(entry);
848        archive.by_index(index).map_err(|error| match error {
849            zip::result::ZipError::UnsupportedArchive(message) => {
850                DocumentExtractionError::UnsupportedDocxInput {
851                    message: message.to_owned(),
852                }
853            }
854            error => DocumentExtractionError::InvalidDocxPackage {
855                message: error.to_string(),
856            },
857        })?;
858        if name.ends_with('/') {
859            continue;
860        }
861        let compressed = usize::try_from(compressed_size).map_err(|_error| {
862            DocumentExtractionError::ResourceLimit {
863                limit: DocumentLimit::CompressedBytes,
864                observed: usize::MAX,
865                maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
866            }
867        })?;
868        let expanded = usize::try_from(expanded_size).map_err(|_error| {
869            DocumentExtractionError::ResourceLimit {
870                limit: DocumentLimit::ExpandedBytes,
871                observed: usize::MAX,
872                maximum: MAX_DOCUMENT_EXPANDED_BYTES,
873            }
874        })?;
875        compressed_bytes = compressed_bytes.saturating_add(compressed);
876        expanded_bytes = expanded_bytes.saturating_add(expanded);
877        if compressed_bytes > MAX_DOCUMENT_COMPRESSED_BYTES {
878            return Err(DocumentExtractionError::ResourceLimit {
879                limit: DocumentLimit::CompressedBytes,
880                observed: compressed_bytes,
881                maximum: MAX_DOCUMENT_COMPRESSED_BYTES,
882            });
883        }
884        if expanded_bytes > MAX_DOCUMENT_EXPANDED_BYTES {
885            return Err(DocumentExtractionError::ResourceLimit {
886                limit: DocumentLimit::ExpandedBytes,
887                observed: expanded_bytes,
888                maximum: MAX_DOCUMENT_EXPANDED_BYTES,
889            });
890        }
891    }
892    let mut read_budget = DocxReadBudget::default();
893    if !names.contains("[Content_Types].xml") || !names.contains("_rels/.rels") {
894        return Err(DocumentExtractionError::InvalidDocxPackage {
895            message: "DOCX package is missing content types or root relationships".to_owned(),
896        });
897    }
898    let content_types = if names.contains("[Content_Types].xml") {
899        let manifest = read_docx_package_part(
900            &mut archive,
901            "[Content_Types].xml",
902            &mut read_budget,
903            control,
904            stage,
905        )?;
906        Some(parse_docx_content_types(&manifest, control, stage)?)
907    } else {
908        None
909    };
910    let main_part = if names.contains("_rels/.rels") {
911        let package_rels = read_docx_package_part(
912            &mut archive,
913            "_rels/.rels",
914            &mut read_budget,
915            control,
916            stage,
917        )?;
918        let relationships = parse_docx_relationships(&package_rels, control, stage)?;
919        let mut main = relationships
920            .values()
921            .filter(|(kind, _, _)| kind == "officeDocument");
922        let Some((_, target, external)) = main.next() else {
923            return Err(DocumentExtractionError::InvalidDocxPackage {
924                message: "DOCX package has no unique internal main-document relationship"
925                    .to_owned(),
926            });
927        };
928        if main.next().is_some() {
929            return Err(DocumentExtractionError::InvalidDocxPackage {
930                message: "DOCX package has no unique internal main-document relationship"
931                    .to_owned(),
932            });
933        }
934        if *external {
935            return Err(DocumentExtractionError::InvalidDocxPackage {
936                message: "DOCX main-document relationship is external".to_owned(),
937            });
938        }
939        resolve_docx_story_target(target, "", &names)?
940    } else {
941        DOCX_DOCUMENT_PART.to_owned()
942    };
943    if let Some(types) = &content_types {
944        validate_docx_content_type(&main_part, "document.main", types)?;
945    }
946    let xml =
947        match read_docx_package_part(&mut archive, &main_part, &mut read_budget, control, stage) {
948            Ok(xml) => xml,
949            Err(error) => {
950                if let Some(limit) = accepted_docx_limit(&error) {
951                    return Ok(empty_partial_document(
952                        DocumentFormat::Docx,
953                        DocumentParserProvenance::QuickXml,
954                        DocumentCoverageGap::ResourceLimit(limit),
955                    ));
956                }
957                return Err(error);
958            }
959        };
960    control.check(stage)?;
961    // Bound live source, one expanded story, parser staging, retained text and facts.
962    let memory_check = check_docx_story_memory(bytes.len(), xml.capacity(), 0);
963    if let Err(error) = memory_check {
964        if let Some(limit) = accepted_docx_limit(&error) {
965            return Ok(empty_partial_document(
966                DocumentFormat::Docx,
967                DocumentParserProvenance::QuickXml,
968                DocumentCoverageGap::ResourceLimit(limit),
969            ));
970        }
971        return Err(error);
972    }
973    let mut references = DocxStoryReferences::default();
974    let mut document = match parse_docx_part(
975        &xml,
976        &main_part,
977        "document",
978        None,
979        None,
980        &mut references,
981        control,
982        stage,
983    ) {
984        Ok(document) => document,
985        Err(error) => {
986            if let Some(limit) = accepted_docx_limit(&error) {
987                return Ok(empty_partial_document(
988                    DocumentFormat::Docx,
989                    DocumentParserProvenance::QuickXml,
990                    DocumentCoverageGap::ResourceLimit(limit),
991                ));
992            }
993            return Err(error);
994        }
995    };
996    drop(xml);
997    let settings = match docx_settings(
998        &mut archive,
999        &main_part,
1000        &names,
1001        content_types.as_ref(),
1002        &mut read_budget,
1003        control,
1004        stage,
1005    ) {
1006        Ok(enabled) => enabled,
1007        Err(error) => {
1008            if let Some(limit) = accepted_docx_limit(&error) {
1009                mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1010                return Ok(document);
1011            }
1012            return Err(error);
1013        }
1014    };
1015    if settings.unexamined_compatibility {
1016        mark_docx_gap(&mut document, DocumentCoverageGap::UnexaminedStory);
1017    }
1018    if settings.even_odd_headers && references.has_parity_variant() {
1019        mark_docx_gap(&mut document, DocumentCoverageGap::ConditionalStory);
1020    }
1021    let mut total_references = references.count();
1022    let mut document_newlines = document.text.bytes().filter(|byte| *byte == b'\n').count();
1023    let main_relationship_part = docx_relationship_part(&main_part);
1024    let main_relationships = if names.contains(&main_relationship_part) {
1025        let xml = read_docx_package_part(
1026            &mut archive,
1027            &main_relationship_part,
1028            &mut read_budget,
1029            control,
1030            stage,
1031        )?;
1032        Some(parse_docx_relationships(&xml, control, stage)?)
1033    } else {
1034        None
1035    };
1036    if main_relationships
1037        .as_ref()
1038        .is_some_and(|relationships| relationships.values().any(|(kind, _, _)| kind == "styles"))
1039    {
1040        mark_docx_gap(&mut document, DocumentCoverageGap::UnresolvedVisibility);
1041    }
1042    // Drain linked stories first so their note/comment IDs are batched before item-part inflation.
1043    let mut selected: BTreeMap<(u8, String), (DocxStoryKind, HashSet<String>)> = BTreeMap::new();
1044    if let Err(error) = queue_docx_story_references(
1045        &mut archive,
1046        &main_part,
1047        &references,
1048        &names,
1049        content_types.as_ref(),
1050        &main_part,
1051        main_relationships.as_ref(),
1052        settings.even_odd_headers,
1053        &mut read_budget,
1054        &mut selected,
1055        control,
1056        stage,
1057    ) {
1058        if let Some(limit) = accepted_docx_limit(&error) {
1059            mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1060            return Ok(document);
1061        }
1062        return Err(error);
1063    }
1064    let mut parsed: HashMap<String, (DocxStoryKind, HashSet<String>)> = HashMap::new();
1065    while let Some(((_, part), (kind, ids))) = selected.pop_first() {
1066        control.check(stage)?;
1067        let previous = parsed
1068            .entry(part.clone())
1069            .or_insert_with(|| (kind, HashSet::new()));
1070        if previous.0 != kind {
1071            return Err(DocumentExtractionError::InvalidDocxPackage {
1072                message: "DOCX story part has conflicting relationship types".to_owned(),
1073            });
1074        }
1075        let previous = &mut previous.1;
1076        let special_ids = if previous.is_empty() {
1077            match kind {
1078                DocxStoryKind::Footnotes => Some(&settings.footnote_special_ids),
1079                DocxStoryKind::Endnotes => Some(&settings.endnote_special_ids),
1080                _ => None,
1081            }
1082        } else {
1083            None
1084        };
1085        let pending_ids = if kind.item_name().is_some() {
1086            ids.difference(previous).cloned().collect::<HashSet<_>>()
1087        } else if previous.is_empty() {
1088            HashSet::new()
1089        } else {
1090            continue;
1091        };
1092        if kind.item_name().is_some() && pending_ids.is_empty() {
1093            continue;
1094        }
1095        if kind.item_name().is_some() {
1096            previous.extend(pending_ids.iter().cloned());
1097        } else {
1098            previous.insert(String::new());
1099        }
1100        let story_xml =
1101            match read_docx_package_part(&mut archive, &part, &mut read_budget, control, stage) {
1102                Ok(xml) => xml,
1103                Err(error) => {
1104                    if let Some(limit) = accepted_docx_limit(&error) {
1105                        mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1106                        break;
1107                    }
1108                    return Err(error);
1109                }
1110            };
1111        if let Err(error) =
1112            check_docx_story_memory(bytes.len(), story_xml.capacity(), document.text.capacity())
1113        {
1114            if let Some(limit) = accepted_docx_limit(&error) {
1115                mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1116                break;
1117            }
1118            return Err(error);
1119        }
1120        let mut child_references = DocxStoryReferences::default();
1121        let story = match parse_docx_part(
1122            &story_xml,
1123            &part,
1124            kind.root_name(),
1125            kind.item_name().map(|_| &pending_ids),
1126            special_ids,
1127            &mut child_references,
1128            control,
1129            stage,
1130        ) {
1131            Ok(story) => story,
1132            Err(error) => {
1133                if let Some(limit) = accepted_docx_limit(&error) {
1134                    mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1135                    break;
1136                }
1137                return Err(error);
1138            }
1139        };
1140        if let Err(error) = append_docx_story(&mut document, story, &mut document_newlines) {
1141            if let Some(limit) = accepted_docx_limit(&error) {
1142                mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1143                break;
1144            }
1145            return Err(error);
1146        }
1147        total_references = total_references.saturating_add(child_references.count());
1148        if total_references > MAX_DOCUMENT_FACTS {
1149            mark_docx_gap(
1150                &mut document,
1151                DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount),
1152            );
1153            break;
1154        }
1155        if settings.even_odd_headers && child_references.has_parity_variant() {
1156            mark_docx_gap(&mut document, DocumentCoverageGap::ConditionalStory);
1157        }
1158        if let Err(error) = queue_docx_story_references(
1159            &mut archive,
1160            &part,
1161            &child_references,
1162            &names,
1163            content_types.as_ref(),
1164            &main_part,
1165            main_relationships.as_ref(),
1166            settings.even_odd_headers,
1167            &mut read_budget,
1168            &mut selected,
1169            control,
1170            stage,
1171        ) {
1172            if let Some(limit) = accepted_docx_limit(&error) {
1173                mark_docx_gap(&mut document, DocumentCoverageGap::ResourceLimit(limit));
1174                break;
1175            }
1176            return Err(error);
1177        }
1178    }
1179    Ok(document)
1180}
1181
1182/// Classify safe post-admission parser ceilings that may publish local incomplete coverage.
1183fn accepted_docx_limit(error: &DocumentExtractionError) -> Option<DocumentLimit> {
1184    match error {
1185        DocumentExtractionError::ResourceLimit { limit, .. }
1186            if matches!(
1187                limit,
1188                DocumentLimit::OutputBytes
1189                    | DocumentLimit::FactCount
1190                    | DocumentLimit::MemoryBytes
1191                    | DocumentLimit::ParserWorkBytes
1192            ) =>
1193        {
1194            Some(*limit)
1195        }
1196        _ => None,
1197    }
1198}
1199
1200/// Charge one live story, parser staging, and retained output to the document envelope.
1201fn check_docx_story_memory(
1202    source_bytes: usize,
1203    story_capacity: usize,
1204    retained_text_capacity: usize,
1205) -> Result<(), DocumentExtractionError> {
1206    check_memory_budget(
1207        source_bytes
1208            .saturating_add(story_capacity.saturating_mul(4))
1209            .saturating_add(retained_text_capacity.saturating_mul(2))
1210            .saturating_add(MAX_DOCUMENT_ENTRIES.saturating_mul(MAX_DOCX_PART_NAME_BYTES))
1211            .saturating_add(MAX_DOCUMENT_FACTS.saturating_mul(MAX_DOCX_PART_NAME_BYTES * 2))
1212            .saturating_add(MAX_DOCUMENT_FACTS.saturating_mul(MAX_DOCX_REFERENCE_ID_BYTES * 4))
1213            .saturating_add(MAX_DOCX_METADATA_BYTES.saturating_mul(8))
1214            .saturating_add(MAX_DOCX_IGNORABLE_NAMESPACES * std::mem::size_of::<String>())
1215            .saturating_add(MAX_DOCUMENT_OUTPUT_BYTES.saturating_mul(4))
1216            .saturating_add(MAX_DOCX_XML_DEPTH.saturating_mul(
1217                std::mem::size_of::<(DocxTextContext, usize)>()
1218                    + MAX_DOCX_XML_DEPTH * std::mem::size_of::<DocxFieldPhase>()
1219                    + std::mem::size_of::<DocxSimpleField>()
1220                    + std::mem::size_of::<DocxSdtContext>()
1221                    + std::mem::size_of::<DocxSdtTag>()
1222                    + std::mem::size_of::<DocxAlternative>(),
1223            ))
1224            .saturating_add(
1225                MAX_DOCUMENT_FACTS
1226                    .saturating_mul(std::mem::size_of::<DocumentFact>())
1227                    .saturating_mul(2),
1228            ),
1229    )
1230}
1231
1232/// Preserve a typed coverage fact when no main-story evidence fits the retained ceiling.
1233fn empty_partial_document(
1234    format: DocumentFormat,
1235    provenance: DocumentParserProvenance,
1236    gap: DocumentCoverageGap,
1237) -> DocumentFacts {
1238    DocumentFacts {
1239        format,
1240        text: String::new(),
1241        facts: Vec::new(),
1242        symbols: Vec::new(),
1243        completeness: DocumentCompleteness::Partial { gaps: vec![gap] },
1244        provenance,
1245    }
1246}
1247
1248/// Add one distinct reason without discarding already verified text or symbols.
1249fn mark_docx_gap(document: &mut DocumentFacts, gap: DocumentCoverageGap) {
1250    if let DocumentCompleteness::Complete = document.completeness {
1251        document.completeness = DocumentCompleteness::Partial { gaps: Vec::new() };
1252    }
1253    if let DocumentCompleteness::Partial { gaps } = &mut document.completeness
1254        && !gaps.contains(&gap)
1255    {
1256        gaps.push(gap);
1257    }
1258}
1259
1260/// OPC relationship-part name adjacent to its source part.
1261fn docx_relationship_part(source_part: &str) -> String {
1262    match source_part.rsplit_once('/') {
1263        Some((directory, filename)) => format!("{directory}/_rels/{filename}.rels"),
1264        None => format!("_rels/{source_part}.rels"),
1265    }
1266}
1267
1268#[allow(clippy::too_many_arguments)]
1269/// Resolve story links from their source part and note items from the main part.
1270fn queue_docx_story_references(
1271    archive: &mut ZipArchive<Cursor<&[u8]>>,
1272    origin_part: &str,
1273    references: &DocxStoryReferences,
1274    names: &HashSet<String>,
1275    content_types: Option<&DocxContentTypes>,
1276    main_part: &str,
1277    main_relationships: Option<&HashMap<String, (String, String, bool)>>,
1278    even_odd_headers: bool,
1279    read_budget: &mut DocxReadBudget,
1280    selected: &mut BTreeMap<(u8, String), (DocxStoryKind, HashSet<String>)>,
1281    control: &IndexWorkControl,
1282    stage: IndexWorkStage,
1283) -> Result<(), DocumentExtractionError> {
1284    let has_story_links = !references.linked_parts.is_empty()
1285        || !references.imported_parts.is_empty()
1286        || !references.content_parts.is_empty()
1287        || (even_odd_headers && !references.even_parts.is_empty());
1288    if !has_story_links && references.items.is_empty() && !references.glossary_placeholder {
1289        return Ok(());
1290    }
1291    let relationship_origin = if has_story_links {
1292        origin_part
1293    } else {
1294        main_part
1295    };
1296    let relationship_part = docx_relationship_part(relationship_origin);
1297    let source_relationships = if relationship_origin == main_part {
1298        None
1299    } else {
1300        let xml = read_docx_package_part(archive, &relationship_part, read_budget, control, stage)?;
1301        Some(parse_docx_relationships(&xml, control, stage)?)
1302    };
1303    let missing_main = || DocumentExtractionError::InvalidDocxPackage {
1304        message: format!(
1305            "required part {} is missing",
1306            docx_relationship_part(main_part)
1307        ),
1308    };
1309    let relationships = source_relationships
1310        .as_ref()
1311        .or(main_relationships)
1312        .ok_or_else(missing_main)?;
1313    let item_relationships = if references.items.is_empty() && !references.glossary_placeholder {
1314        relationships
1315    } else {
1316        main_relationships.ok_or_else(missing_main)?
1317    };
1318    let mut relationship_by_kind = HashMap::new();
1319    for (index, (relationship_kind, target, external)) in item_relationships.values().enumerate() {
1320        check_parser_iteration(index, &mut || control.check(stage))?;
1321        relationship_by_kind
1322            .entry(relationship_kind.as_str())
1323            .and_modify(|unique| *unique = None)
1324            .or_insert(Some((target.as_str(), *external)));
1325    }
1326    for (kind, id) in references
1327        .linked_parts
1328        .iter()
1329        .chain(references.even_parts.iter().filter(|_| even_odd_headers))
1330    {
1331        let (relationship_kind, target, external) =
1332            relationships
1333                .get(id)
1334                .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1335                    message: format!("referenced DOCX relationship {id} is missing"),
1336                })?;
1337        if relationship_kind != kind.relationship_name() || *external {
1338            return Err(DocumentExtractionError::InvalidDocxPackage {
1339                message: format!("DOCX relationship {id} has an unsafe or wrong story type"),
1340            });
1341        }
1342        let part = resolve_docx_story_target(target, origin_part, names)?;
1343        if let Some(types) = content_types {
1344            validate_docx_content_type(&part, kind.content_type(), types)?;
1345        }
1346        if *kind == DocxStoryKind::Subdocument {
1347            continue;
1348        }
1349        let story = selected
1350            .entry((u8::from(kind.item_name().is_some()), part))
1351            .or_insert_with(|| (*kind, HashSet::new()));
1352        if story.0 != *kind {
1353            return Err(DocumentExtractionError::InvalidDocxPackage {
1354                message: "DOCX story part has conflicting relationship types".to_owned(),
1355            });
1356        }
1357    }
1358    for id in &references.imported_parts {
1359        let (kind, target, external) =
1360            relationships
1361                .get(id)
1362                .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1363                    message: format!("referenced DOCX import relationship {id} is missing"),
1364                })?;
1365        if !matches!(kind.as_str(), "aFChunk" | "afChunk") || *external {
1366            return Err(DocumentExtractionError::InvalidDocxPackage {
1367                message: format!("DOCX import relationship {id} has an unsafe or wrong type"),
1368            });
1369        }
1370        resolve_docx_story_target(target, origin_part, names)?;
1371    }
1372    for id in &references.content_parts {
1373        let (kind, target, external) =
1374            relationships
1375                .get(id)
1376                .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1377                    message: format!("referenced DOCX content part relationship {id} is missing"),
1378                })?;
1379        if kind != "customXml" || *external {
1380            return Err(DocumentExtractionError::InvalidDocxPackage {
1381                message: format!("DOCX content part relationship {id} is unsafe or wrong type"),
1382            });
1383        }
1384        resolve_docx_story_target(target, origin_part, names)?;
1385    }
1386    for (kind, id) in &references.items {
1387        let (target, external) = match relationship_by_kind.get(kind.relationship_name()) {
1388            None => {
1389                return Err(DocumentExtractionError::InvalidDocxPackage {
1390                    message: format!(
1391                        "referenced DOCX {} part is missing",
1392                        kind.relationship_name()
1393                    ),
1394                });
1395            }
1396            Some(None) => {
1397                return Err(DocumentExtractionError::InvalidDocxPackage {
1398                    message: format!(
1399                        "DOCX {} relationship is ambiguous",
1400                        kind.relationship_name()
1401                    ),
1402                });
1403            }
1404            Some(Some(relationship)) => *relationship,
1405        };
1406        if external {
1407            return Err(DocumentExtractionError::InvalidDocxPackage {
1408                message: format!("DOCX {} relationship is external", kind.relationship_name()),
1409            });
1410        }
1411        let part = resolve_docx_story_target(target, main_part, names)?;
1412        if let Some(types) = content_types {
1413            validate_docx_content_type(&part, kind.content_type(), types)?;
1414        }
1415        let story = selected
1416            .entry((1, part))
1417            .or_insert_with(|| (*kind, HashSet::new()));
1418        if story.0 != *kind {
1419            return Err(DocumentExtractionError::InvalidDocxPackage {
1420                message: "DOCX story part has conflicting relationship types".to_owned(),
1421            });
1422        }
1423        story.1.insert(id.clone());
1424    }
1425    if references.glossary_placeholder {
1426        let mut glossary = item_relationships
1427            .values()
1428            .filter(|(kind, _, _)| kind == "glossaryDocument");
1429        let (_, target, external) =
1430            glossary
1431                .next()
1432                .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
1433                    message: "referenced DOCX glossary relationship is missing".to_owned(),
1434                })?;
1435        if glossary.next().is_some() {
1436            return Err(DocumentExtractionError::InvalidDocxPackage {
1437                message: "referenced DOCX glossary relationship is ambiguous".to_owned(),
1438            });
1439        }
1440        if *external {
1441            return Err(DocumentExtractionError::InvalidDocxPackage {
1442                message: "referenced DOCX glossary relationship is external".to_owned(),
1443            });
1444        }
1445        let part = resolve_docx_story_target(target, main_part, names)?;
1446        if let Some(types) = content_types {
1447            validate_docx_content_type(&part, "document.glossary", types)?;
1448        }
1449    }
1450    Ok(())
1451}
1452
1453/// Bounded document-wide settings that affect reachable story content.
1454#[derive(Default)]
1455struct DocxSettings {
1456    /// Whether even-page header and footer variants are active.
1457    even_odd_headers: bool,
1458    /// Special footnote IDs loaded by the document.
1459    footnote_special_ids: HashSet<String>,
1460    /// Special endnote IDs loaded by the document.
1461    endnote_special_ids: HashSet<String>,
1462    /// A compatibility branch in settings may select unexamined rendered stories.
1463    unexamined_compatibility: bool,
1464}
1465
1466/// Read validated document-wide settings once before linked-story extraction.
1467fn docx_settings(
1468    archive: &mut ZipArchive<Cursor<&[u8]>>,
1469    main_part: &str,
1470    names: &HashSet<String>,
1471    content_types: Option<&DocxContentTypes>,
1472    read_budget: &mut DocxReadBudget,
1473    control: &IndexWorkControl,
1474    stage: IndexWorkStage,
1475) -> Result<DocxSettings, DocumentExtractionError> {
1476    let relationship_part = docx_relationship_part(main_part);
1477    if !names.contains(&relationship_part) {
1478        return Ok(DocxSettings::default());
1479    }
1480    let rel_xml = read_docx_package_part(archive, &relationship_part, read_budget, control, stage)?;
1481    let relationships = parse_docx_relationships(&rel_xml, control, stage)?;
1482    let mut settings_links = relationships
1483        .values()
1484        .filter(|(kind, _, _)| kind == "settings");
1485    let Some((_, target, external)) = settings_links.next() else {
1486        return Ok(DocxSettings::default());
1487    };
1488    if settings_links.next().is_some() || *external {
1489        return Err(DocumentExtractionError::InvalidDocxPackage {
1490            message: "DOCX settings relationship is ambiguous or external".to_owned(),
1491        });
1492    }
1493    let part = resolve_docx_story_target(target, main_part, names)?;
1494    if let Some(types) = content_types {
1495        validate_docx_content_type(&part, "settings", types)?;
1496    }
1497    let xml = read_docx_package_part(archive, &part, read_budget, control, stage)?;
1498    if xml.len() > MAX_DOCX_METADATA_BYTES {
1499        return Err(DocumentExtractionError::InvalidDocxPackage {
1500            message: "DOCX settings exceed the package metadata bound".to_owned(),
1501        });
1502    }
1503    let mut reader = NsReader::from_reader(xml.as_slice());
1504    reader.config_mut().expand_empty_elements = true;
1505    reader.config_mut().check_comments = true;
1506    let mut depth = 0usize;
1507    let mut seen_root = false;
1508    let mut seen_switch = false;
1509    let mut seen_footnote_properties = false;
1510    let mut seen_endnote_properties = false;
1511    let mut settings = DocxSettings::default();
1512    let mut note_kind = None;
1513    let mut alternatives: Vec<DocxAlternative> = Vec::new();
1514    let mut selected_branches: Vec<usize> = Vec::new();
1515    let mut skipped_branch_depth = None;
1516    let mut event_index = 0usize;
1517    loop {
1518        check_parser_iteration(event_index, &mut || control.check(stage))?;
1519        event_index = event_index.saturating_add(1);
1520        let (namespace, event) =
1521            reader
1522                .read_resolved_event()
1523                .map_err(|error| DocumentExtractionError::Malformed {
1524                    format: DocumentFormat::Docx,
1525                    message: error.to_string(),
1526                })?;
1527        let wordprocessing = match &namespace {
1528            ResolveResult::Bound(namespace) => wordprocessing_namespace(namespace.as_ref()),
1529            ResolveResult::Unbound => false,
1530            ResolveResult::Unknown(prefix) => {
1531                return Err(DocumentExtractionError::Malformed {
1532                    format: DocumentFormat::Docx,
1533                    message: format!("DOCX XML contained an undeclared namespace prefix: {prefix}"),
1534                });
1535            }
1536        };
1537        let compatibility = matches!(&namespace, ResolveResult::Bound(namespace)
1538            if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006");
1539        if matches!(event, Event::Decl(_)) && event_index != 1 {
1540            return Err(DocumentExtractionError::Malformed {
1541                format: DocumentFormat::Docx,
1542                message: "DOCX XML declaration must be the first XML event".to_owned(),
1543            });
1544        }
1545        match event {
1546            Event::Start(event) => {
1547                depth += 1;
1548                if depth > MAX_DOCX_XML_DEPTH {
1549                    return Err(DocumentExtractionError::ResourceLimit {
1550                        limit: DocumentLimit::NestingDepth,
1551                        observed: depth,
1552                        maximum: MAX_DOCX_XML_DEPTH,
1553                    });
1554                }
1555                validate_docx_attributes(&event, &reader, control, stage)?;
1556                if skipped_branch_depth.is_some() {
1557                    continue;
1558                }
1559                let name = event.local_name();
1560                if depth == 1 && (seen_root || !wordprocessing || name.as_ref() != "settings") {
1561                    return Err(DocumentExtractionError::Malformed {
1562                        format: DocumentFormat::Docx,
1563                        message: "DOCX settings have an invalid root".to_owned(),
1564                    });
1565                }
1566                if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
1567                    let alternative = alternatives
1568                        .last_mut()
1569                        .filter(|item| item.depth + 1 == depth && !item.fallback_seen)
1570                        .ok_or_else(|| DocumentExtractionError::Malformed {
1571                            format: DocumentFormat::Docx,
1572                            message: "DOCX settings compatibility branch has invalid placement"
1573                                .to_owned(),
1574                        })?;
1575                    let supported = if name.as_ref() == "Choice" {
1576                        alternative.choice_seen = true;
1577                        docx_choice_supported(&reader, &event, control, stage)?
1578                    } else {
1579                        if !alternative.choice_seen {
1580                            return Err(DocumentExtractionError::Malformed {
1581                                format: DocumentFormat::Docx,
1582                                message: "DOCX settings compatibility fallback requires a choice"
1583                                    .to_owned(),
1584                            });
1585                        }
1586                        alternative.fallback_seen = true;
1587                        true
1588                    };
1589                    if !supported && alternative.selection == DocxAlternativeSelection::Unselected {
1590                        settings.unexamined_compatibility = true;
1591                        alternative.selection = DocxAlternativeSelection::Uncertain;
1592                    }
1593                    if alternative.selection == DocxAlternativeSelection::Unselected && supported {
1594                        alternative.selection = DocxAlternativeSelection::Selected;
1595                        selected_branches.push(depth);
1596                    } else {
1597                        skipped_branch_depth = Some(depth);
1598                    }
1599                    continue;
1600                }
1601                if alternatives
1602                    .last()
1603                    .is_some_and(|item| item.depth + 1 == depth)
1604                {
1605                    return Err(DocumentExtractionError::Malformed {
1606                        format: DocumentFormat::Docx,
1607                        message: "DOCX settings alternatives must contain choices or fallback"
1608                            .to_owned(),
1609                    });
1610                }
1611                if compatibility && name.as_ref() == "AlternateContent" {
1612                    alternatives.push(DocxAlternative {
1613                        depth,
1614                        selection: DocxAlternativeSelection::Unselected,
1615                        choice_seen: false,
1616                        fallback_seen: false,
1617                    });
1618                    continue;
1619                }
1620                let logical_parent = depth - 1 - 2 * selected_branches.len();
1621                if logical_parent == 0 {
1622                    seen_root = true;
1623                } else if logical_parent == 1
1624                    && wordprocessing
1625                    && event.local_name().as_ref() == "evenAndOddHeaders"
1626                {
1627                    if seen_switch {
1628                        return Err(DocumentExtractionError::Malformed {
1629                            format: DocumentFormat::Docx,
1630                            message: "DOCX even/odd header setting is duplicated".to_owned(),
1631                        });
1632                    }
1633                    seen_switch = true;
1634                    settings.even_odd_headers = docx_on_off(&event, &reader)?;
1635                } else if logical_parent == 1 && wordprocessing {
1636                    note_kind = match event.local_name().as_ref() {
1637                        "footnotePr" if !seen_footnote_properties => {
1638                            seen_footnote_properties = true;
1639                            Some(DocxStoryKind::Footnotes)
1640                        }
1641                        "endnotePr" if !seen_endnote_properties => {
1642                            seen_endnote_properties = true;
1643                            Some(DocxStoryKind::Endnotes)
1644                        }
1645                        "footnotePr" | "endnotePr" => {
1646                            return Err(DocumentExtractionError::Malformed {
1647                                format: DocumentFormat::Docx,
1648                                message: "DOCX document-wide note properties are duplicated"
1649                                    .to_owned(),
1650                            });
1651                        }
1652                        _ => None,
1653                    };
1654                } else if logical_parent == 2
1655                    && wordprocessing
1656                    && let Some(kind) = note_kind
1657                    && event.local_name().as_ref()
1658                        == if kind == DocxStoryKind::Footnotes {
1659                            "footnote"
1660                        } else {
1661                            "endnote"
1662                        }
1663                {
1664                    let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
1665                        .ok_or_else(|| DocumentExtractionError::Malformed {
1666                            format: DocumentFormat::Docx,
1667                            message: "DOCX special note setting is missing its id".to_owned(),
1668                        })?;
1669                    let id = canonical_docx_story_id(&id)?;
1670                    let inserted = match kind {
1671                        DocxStoryKind::Footnotes => settings.footnote_special_ids.insert(id),
1672                        DocxStoryKind::Endnotes => settings.endnote_special_ids.insert(id),
1673                        _ => unreachable!(),
1674                    };
1675                    if inserted {
1676                        let observed = settings
1677                            .footnote_special_ids
1678                            .len()
1679                            .saturating_add(settings.endnote_special_ids.len());
1680                        if observed > MAX_DOCUMENT_FACTS {
1681                            return Err(DocumentExtractionError::ResourceLimit {
1682                                limit: DocumentLimit::FactCount,
1683                                observed,
1684                                maximum: MAX_DOCUMENT_FACTS,
1685                            });
1686                        }
1687                    }
1688                }
1689            }
1690            Event::End(event) => {
1691                if depth == 0 {
1692                    return Err(DocumentExtractionError::Malformed {
1693                        format: DocumentFormat::Docx,
1694                        message: "DOCX settings have an unmatched closing element".to_owned(),
1695                    });
1696                }
1697                if let Some(skipped) = skipped_branch_depth {
1698                    if depth == skipped {
1699                        skipped_branch_depth = None;
1700                    }
1701                    depth -= 1;
1702                    continue;
1703                }
1704                if compatibility
1705                    && event.local_name().as_ref() == "AlternateContent"
1706                    && alternatives
1707                        .pop()
1708                        .is_none_or(|item| item.depth != depth || !item.choice_seen)
1709                {
1710                    return Err(DocumentExtractionError::Malformed {
1711                        format: DocumentFormat::Docx,
1712                        message: "DOCX settings alternatives require a choice".to_owned(),
1713                    });
1714                }
1715                if compatibility
1716                    && matches!(event.local_name().as_ref(), "Choice" | "Fallback")
1717                    && selected_branches.last() == Some(&depth)
1718                {
1719                    selected_branches.pop();
1720                }
1721                if wordprocessing
1722                    && depth - 2 * selected_branches.len() == 2
1723                    && matches!(event.local_name().as_ref(), "footnotePr" | "endnotePr")
1724                {
1725                    note_kind = None;
1726                }
1727                depth -= 1;
1728            }
1729            Event::PI(instruction) if instruction.target().eq_ignore_ascii_case("xml") => {
1730                return Err(DocumentExtractionError::Malformed {
1731                    format: DocumentFormat::Docx,
1732                    message: "DOCX XML processing instruction uses the reserved XML target"
1733                        .to_owned(),
1734                });
1735            }
1736            Event::DocType(_) => {
1737                return Err(DocumentExtractionError::Malformed {
1738                    format: DocumentFormat::Docx,
1739                    message: "DOCX settings DOCTYPE is unsupported".to_owned(),
1740                });
1741            }
1742            Event::Text(text) if depth == 0 && !text.as_ref().chars().all(xml_whitespace) => {
1743                return Err(DocumentExtractionError::Malformed {
1744                    format: DocumentFormat::Docx,
1745                    message: "DOCX settings contain text outside the root".to_owned(),
1746                });
1747            }
1748            Event::CData(_) if depth == 0 => {
1749                return Err(DocumentExtractionError::Malformed {
1750                    format: DocumentFormat::Docx,
1751                    message: "DOCX settings contain CDATA outside the root".to_owned(),
1752                });
1753            }
1754            Event::GeneralRef(reference) => {
1755                decode_docx_reference(&reference)?;
1756                if depth == 0 {
1757                    return Err(DocumentExtractionError::Malformed {
1758                        format: DocumentFormat::Docx,
1759                        message: "DOCX settings contain a reference outside the root".to_owned(),
1760                    });
1761                }
1762            }
1763            Event::Eof => break,
1764            _ => {}
1765        }
1766    }
1767    if !seen_root
1768        || depth != 0
1769        || !alternatives.is_empty()
1770        || !selected_branches.is_empty()
1771        || skipped_branch_depth.is_some()
1772    {
1773        return Err(DocumentExtractionError::Malformed {
1774            format: DocumentFormat::Docx,
1775            message: "DOCX settings ended before the root closed".to_owned(),
1776        });
1777    }
1778    Ok(settings)
1779}
1780
1781#[derive(Default)]
1782/// Bound actual decompression work, including repeated reads of one package part.
1783struct DocxReadBudget {
1784    /// Distinct ZIP-member expansion used for fatal package-safety admission.
1785    unique_expanded: usize,
1786    /// Total actual inflation work, including repeat reads.
1787    work_expanded: usize,
1788    /// Parts already charged to unique ZIP expansion.
1789    read_parts: HashSet<String>,
1790}
1791
1792/// Bounded OPC content types, with exact part overrides taking precedence.
1793#[derive(Default)]
1794struct DocxContentTypes {
1795    /// Types assigned to individual package parts.
1796    overrides: HashMap<String, String>,
1797    /// Types assigned by case-insensitive filename extension.
1798    defaults: HashMap<String, String>,
1799}
1800
1801/// Read one previously admitted ZIP member and verify its actual expansion.
1802fn read_docx_package_part(
1803    archive: &mut ZipArchive<Cursor<&[u8]>>,
1804    name: &str,
1805    read_budget: &mut DocxReadBudget,
1806    control: &IndexWorkControl,
1807    stage: IndexWorkStage,
1808) -> Result<Vec<u8>, DocumentExtractionError> {
1809    let mut entry =
1810        archive
1811            .by_name(name)
1812            .map_err(|_error| DocumentExtractionError::InvalidDocxPackage {
1813                message: format!("required part {name} is missing"),
1814            })?;
1815    let mut xml = Vec::new();
1816    let mut chunk = [0_u8; 8192];
1817    loop {
1818        control.check(stage)?;
1819        let read = entry.read(&mut chunk).map_err(|error| {
1820            DocumentExtractionError::InvalidDocxPackage {
1821                message: error.to_string(),
1822            }
1823        })?;
1824        if read == 0 {
1825            break;
1826        }
1827        let observed = read_budget
1828            .unique_expanded
1829            .saturating_add(xml.len())
1830            .saturating_add(read);
1831        if !read_budget.read_parts.contains(name) && observed > MAX_DOCUMENT_EXPANDED_BYTES {
1832            return Err(DocumentExtractionError::ResourceLimit {
1833                limit: DocumentLimit::ExpandedBytes,
1834                observed,
1835                maximum: MAX_DOCUMENT_EXPANDED_BYTES,
1836            });
1837        }
1838        let work = read_budget
1839            .work_expanded
1840            .saturating_add(xml.len())
1841            .saturating_add(read);
1842        if work > MAX_DOCX_DECOMPRESSION_WORK_BYTES {
1843            return Err(DocumentExtractionError::ResourceLimit {
1844                limit: DocumentLimit::ParserWorkBytes,
1845                observed: work,
1846                maximum: MAX_DOCX_DECOMPRESSION_WORK_BYTES,
1847            });
1848        }
1849        xml.extend_from_slice(&chunk[..read]);
1850    }
1851    if read_budget.read_parts.insert(name.to_owned()) {
1852        read_budget.unique_expanded = read_budget.unique_expanded.saturating_add(xml.len());
1853    }
1854    read_budget.work_expanded = read_budget.work_expanded.saturating_add(xml.len());
1855    decode_docx_xml(xml, control, stage)
1856}
1857
1858/// Normalize bounded UTF-16 package XML before the UTF-8 XML reader sees it.
1859fn decode_docx_xml(
1860    xml: Vec<u8>,
1861    control: &IndexWorkControl,
1862    stage: IndexWorkStage,
1863) -> Result<Vec<u8>, DocumentExtractionError> {
1864    let encoding = if xml.starts_with(&[0xff, 0xfe]) {
1865        Some((true, 2))
1866    } else if xml.starts_with(&[0xfe, 0xff]) {
1867        Some((false, 2))
1868    } else if xml.len() >= 2 && xml[1] == 0 && matches!(xml[0], b'<' | b' ' | b'\t' | b'\r' | b'\n')
1869    {
1870        Some((true, 0))
1871    } else if xml.len() >= 2 && xml[0] == 0 && matches!(xml[1], b'<' | b' ' | b'\t' | b'\r' | b'\n')
1872    {
1873        Some((false, 0))
1874    } else {
1875        None
1876    };
1877    let Some((little_endian, bom_bytes)) = encoding else {
1878        validate_docx_xml_encoding(&xml, None)?;
1879        validate_docx_xml_characters(&xml, control, stage)?;
1880        return Ok(xml);
1881    };
1882    let (chunks, remainder) = xml[bom_bytes..].as_chunks::<2>();
1883    if !remainder.is_empty() {
1884        return Err(DocumentExtractionError::Malformed {
1885            format: DocumentFormat::Docx,
1886            message: "DOCX UTF-16 XML has an incomplete code unit".to_owned(),
1887        });
1888    }
1889    let units = || {
1890        chunks.iter().map(|pair| {
1891            if little_endian {
1892                u16::from_le_bytes([pair[0], pair[1]])
1893            } else {
1894                u16::from_be_bytes([pair[0], pair[1]])
1895            }
1896        })
1897    };
1898    let decoded_characters = || {
1899        char::decode_utf16(units()).map(|character| {
1900            character.map_err(|_error| DocumentExtractionError::Malformed {
1901                format: DocumentFormat::Docx,
1902                message: "DOCX UTF-16 XML contains an invalid surrogate".to_owned(),
1903            })
1904        })
1905    };
1906    let mut decoded_bytes = 0usize;
1907    for (index, character) in decoded_characters().enumerate() {
1908        check_parser_iteration(index, &mut || control.check(stage))?;
1909        decoded_bytes = decoded_bytes.saturating_add(character?.len_utf8());
1910        if decoded_bytes > MAX_DOCUMENT_EXPANDED_BYTES {
1911            return Err(DocumentExtractionError::ResourceLimit {
1912                limit: DocumentLimit::MemoryBytes,
1913                observed: decoded_bytes,
1914                maximum: MAX_DOCUMENT_EXPANDED_BYTES,
1915            });
1916        }
1917    }
1918    // Hold source, raw XML, normalized XML, retained output, and bounded metadata/facts at once.
1919    check_memory_budget(
1920        MAX_DOCUMENT_COMPRESSED_BYTES
1921            .saturating_add(xml.capacity())
1922            .saturating_add(decoded_bytes)
1923            .saturating_add(MAX_DOCUMENT_OUTPUT_BYTES)
1924            .saturating_add(MAX_DOCX_METADATA_BYTES)
1925            .saturating_add(MAX_DOCUMENT_FACTS * 256 * 4),
1926    )?;
1927    let mut decoded = String::with_capacity(decoded_bytes);
1928    for (index, character) in decoded_characters().enumerate() {
1929        check_parser_iteration(index, &mut || control.check(stage))?;
1930        decoded.push(character?);
1931    }
1932    let decoded = decoded.into_bytes();
1933    validate_docx_xml_encoding(&decoded, Some(little_endian))?;
1934    validate_docx_xml_characters(&decoded, control, stage)?;
1935    Ok(decoded)
1936}
1937
1938/// Reject non-XML characters in every selected package part before parsing any branch.
1939fn validate_docx_xml_characters(
1940    xml: &[u8],
1941    control: &IndexWorkControl,
1942    stage: IndexWorkStage,
1943) -> Result<(), DocumentExtractionError> {
1944    let text = std::str::from_utf8(xml).map_err(|error| DocumentExtractionError::Malformed {
1945        format: DocumentFormat::Docx,
1946        message: error.to_string(),
1947    })?;
1948    for (index, character) in text.chars().enumerate() {
1949        check_parser_iteration(index, &mut || control.check(stage))?;
1950        if !xml10_character(character) {
1951            return Err(DocumentExtractionError::Malformed {
1952                format: DocumentFormat::Docx,
1953                message: "DOCX XML contains an invalid character".to_owned(),
1954            });
1955        }
1956    }
1957    Ok(())
1958}
1959
1960/// XML 1.0 `Char`, including its exact permitted controls and scalar ranges.
1961fn xml10_character(character: char) -> bool {
1962    matches!(
1963        character,
1964        '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'
1965    )
1966}
1967
1968/// XML `S` is narrower than Rust's Unicode and ASCII whitespace classes.
1969fn xml_whitespace(character: char) -> bool {
1970    matches!(character, ' ' | '\t' | '\r' | '\n')
1971}
1972
1973/// Split namespace prefix lists using only XML `S` separators.
1974fn xml_whitespace_tokens(value: &str) -> impl Iterator<Item = &str> {
1975    value
1976        .split(xml_whitespace)
1977        .filter(|token| !token.is_empty())
1978}
1979
1980/// Only whitespace character content inside an open XML root is structural.
1981fn internal_xml_whitespace(depth: usize, value: &str) -> bool {
1982    depth > 0 && value.chars().all(xml_whitespace)
1983}
1984
1985/// Check the original byte encoding before every package XML reader sees normalized text.
1986fn validate_docx_xml_encoding(
1987    xml: &[u8],
1988    utf16_little_endian: Option<bool>,
1989) -> Result<(), DocumentExtractionError> {
1990    let mut reader = NsReader::from_reader(xml.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(xml));
1991    reader.config_mut().check_comments = true;
1992    let Event::Decl(declaration) =
1993        reader
1994            .read_event()
1995            .map_err(|error| DocumentExtractionError::Malformed {
1996                format: DocumentFormat::Docx,
1997                message: error.to_string(),
1998            })?
1999    else {
2000        return Ok(());
2001    };
2002    let attributes = BytesStart::from_content(declaration.as_ref(), 3);
2003    let mut encoding = None;
2004    let mut fields = 0usize;
2005    for attribute in attributes.attributes() {
2006        let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2007            format: DocumentFormat::Docx,
2008            message: error.to_string(),
2009        })?;
2010        let value = attribute.value.as_ref();
2011        match (fields, attribute.key.as_ref()) {
2012            (0, "version") if value == "1.0" => {}
2013            (1, "encoding") => encoding = Some(value.to_owned()),
2014            (1 | 2, "standalone")
2015                if matches!(value, "yes" | "no") && (fields == 1 || encoding.is_some()) => {}
2016            _ => {
2017                return Err(DocumentExtractionError::Malformed {
2018                    format: DocumentFormat::Docx,
2019                    message: "DOCX XML declaration has invalid or duplicate fields".to_owned(),
2020                });
2021            }
2022        }
2023        fields += 1;
2024    }
2025    if fields == 0 {
2026        return Err(DocumentExtractionError::Malformed {
2027            format: DocumentFormat::Docx,
2028            message: "DOCX XML declaration is missing version 1.0".to_owned(),
2029        });
2030    }
2031    let Some(encoding) = encoding else {
2032        return Ok(());
2033    };
2034    let encoding = encoding.as_str();
2035    let utf8 = encoding.eq_ignore_ascii_case("UTF-8");
2036    let ascii = encoding.eq_ignore_ascii_case("US-ASCII");
2037    let utf16 = encoding.eq_ignore_ascii_case("UTF-16");
2038    let utf16_le = encoding.eq_ignore_ascii_case("UTF-16LE");
2039    let utf16_be = encoding.eq_ignore_ascii_case("UTF-16BE");
2040    if !(utf8 || ascii || utf16 || utf16_le || utf16_be) {
2041        return Err(DocumentExtractionError::UnsupportedDocxInput {
2042            message: "DOCX XML encoding is not supported; UTF-8 or UTF-16 is required".to_owned(),
2043        });
2044    }
2045    let matches_bytes = match utf16_little_endian {
2046        None => utf8 || (ascii && xml.is_ascii()),
2047        Some(true) => utf16 || utf16_le,
2048        Some(false) => utf16 || utf16_be,
2049    };
2050    if !matches_bytes {
2051        return Err(DocumentExtractionError::Malformed {
2052            format: DocumentFormat::Docx,
2053            message: "DOCX XML encoding declaration does not match its bytes".to_owned(),
2054        });
2055    }
2056    Ok(())
2057}
2058
2059/// Extract package content-type overrides and extension defaults.
2060fn parse_docx_content_types(
2061    xml: &[u8],
2062    control: &IndexWorkControl,
2063    stage: IndexWorkStage,
2064) -> Result<DocxContentTypes, DocumentExtractionError> {
2065    const CONTENT_TYPES_NS: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
2066    if xml.len() > MAX_DOCX_METADATA_BYTES {
2067        return Err(DocumentExtractionError::InvalidDocxPackage {
2068            message: "DOCX content types exceed the package metadata bound".to_owned(),
2069        });
2070    }
2071    let mut reader = NsReader::from_reader(xml);
2072    reader.config_mut().expand_empty_elements = true;
2073    reader.config_mut().check_comments = true;
2074    let mut types = DocxContentTypes::default();
2075    let mut depth = 0usize;
2076    let mut closed = false;
2077    let mut events = 0usize;
2078    loop {
2079        check_parser_iteration(events, &mut || control.check(stage))?;
2080        events = events.saturating_add(1);
2081        let (namespace, event) =
2082            reader
2083                .read_resolved_event()
2084                .map_err(|error| DocumentExtractionError::Malformed {
2085                    format: DocumentFormat::Docx,
2086                    message: error.to_string(),
2087                })?;
2088        let valid_namespace = matches!(namespace, ResolveResult::Bound(namespace)
2089            if namespace.as_ref() == CONTENT_TYPES_NS);
2090        match event {
2091            Event::Start(event) => {
2092                depth += 1;
2093                let name = event.local_name();
2094                if !valid_namespace
2095                    || !matches!(
2096                        (depth, name.as_ref()),
2097                        (1, "Types") | (2, "Override" | "Default")
2098                    )
2099                    || closed
2100                {
2101                    return Err(DocumentExtractionError::InvalidDocxPackage {
2102                        message: "DOCX content types have an invalid XML structure".to_owned(),
2103                    });
2104                }
2105                validate_docx_attributes(&event, &reader, control, stage)?;
2106                if depth == 2 {
2107                    if types.overrides.len() + types.defaults.len() == MAX_DOCX_METADATA_RECORDS {
2108                        return Err(DocumentExtractionError::InvalidDocxPackage {
2109                            message:
2110                                "DOCX content type overrides exceed the package metadata bound"
2111                                    .to_owned(),
2112                        });
2113                    }
2114                    let attribute = |name| -> Result<String, DocumentExtractionError> {
2115                        let value = event
2116                            .try_get_attribute(name)
2117                            .map_err(|error| DocumentExtractionError::Malformed {
2118                                format: DocumentFormat::Docx,
2119                                message: error.to_string(),
2120                            })?
2121                            .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
2122                                message: format!("DOCX content type override is missing {name}"),
2123                            })?;
2124                        Ok(quick_xml::escape::unescape(&value.value)
2125                            .map_err(|error| DocumentExtractionError::Malformed {
2126                                format: DocumentFormat::Docx,
2127                                message: error.to_string(),
2128                            })?
2129                            .into_owned())
2130                    };
2131                    let kind = attribute("ContentType")?;
2132                    if kind.is_empty() {
2133                        return Err(DocumentExtractionError::InvalidDocxPackage {
2134                            message: "DOCX content type is empty".to_owned(),
2135                        });
2136                    }
2137                    if name.as_ref() == "Override" {
2138                        let part = attribute("PartName")?;
2139                        if !part.starts_with('/')
2140                            || part.contains(['\\', '?', '#', ':'])
2141                            || part[1..].split('/').any(|component| {
2142                                component.is_empty()
2143                                    || matches!(component, "." | "..")
2144                                    || !safe_docx_uri_component(component)
2145                            })
2146                            || types.overrides.insert(part.clone(), kind).is_some()
2147                        {
2148                            return Err(DocumentExtractionError::InvalidDocxPackage {
2149                                message: format!("unsafe or duplicate DOCX content type {part}"),
2150                            });
2151                        }
2152                    } else {
2153                        let extension = attribute("Extension")?.to_ascii_lowercase();
2154                        if extension.is_empty()
2155                            || !extension.bytes().all(|byte| byte.is_ascii_alphanumeric())
2156                            || types.defaults.insert(extension.clone(), kind).is_some()
2157                        {
2158                            return Err(DocumentExtractionError::InvalidDocxPackage {
2159                                message: format!("unsafe or duplicate DOCX extension {extension}"),
2160                            });
2161                        }
2162                    }
2163                }
2164            }
2165            Event::End(_) => {
2166                if depth == 0 {
2167                    return Err(DocumentExtractionError::InvalidDocxPackage {
2168                        message: "DOCX content types XML has an unmatched end".to_owned(),
2169                    });
2170                }
2171                depth -= 1;
2172                if depth == 0 {
2173                    closed = true;
2174                }
2175            }
2176            Event::Text(text) if text.as_ref().chars().all(xml_whitespace) => {}
2177            Event::CData(text) if internal_xml_whitespace(depth, text.as_ref()) => {}
2178            Event::GeneralRef(reference) => {
2179                let text = decode_docx_reference(&reference)?;
2180                if !internal_xml_whitespace(depth, &text) {
2181                    return Err(DocumentExtractionError::InvalidDocxPackage {
2182                        message: "DOCX content types contain non-structural XML text".to_owned(),
2183                    });
2184                }
2185            }
2186            Event::Decl(_) if events == 1 => {}
2187            Event::Decl(_) => {
2188                return Err(DocumentExtractionError::Malformed {
2189                    format: DocumentFormat::Docx,
2190                    message: "DOCX XML declaration must be the first XML event".to_owned(),
2191                });
2192            }
2193            Event::Comment(_) => {}
2194            Event::PI(instruction) if !instruction.target().eq_ignore_ascii_case("xml") => {}
2195            Event::PI(_) => {
2196                return Err(DocumentExtractionError::Malformed {
2197                    format: DocumentFormat::Docx,
2198                    message: "DOCX XML processing instruction uses the reserved XML target"
2199                        .to_owned(),
2200                });
2201            }
2202            Event::Eof => break,
2203            _ => {
2204                return Err(DocumentExtractionError::InvalidDocxPackage {
2205                    message: "DOCX content types contain unsupported XML".to_owned(),
2206                });
2207            }
2208        }
2209    }
2210    if !closed || depth != 0 {
2211        return Err(DocumentExtractionError::InvalidDocxPackage {
2212            message: "DOCX content types XML is incomplete".to_owned(),
2213        });
2214    }
2215    Ok(types)
2216}
2217
2218/// Reject a referenced story whose package type contradicts its relationship.
2219fn validate_docx_content_type(
2220    part: &str,
2221    expected_suffix: &str,
2222    types: &DocxContentTypes,
2223) -> Result<(), DocumentExtractionError> {
2224    let expected = format!(
2225        "application/vnd.openxmlformats-officedocument.wordprocessingml.{expected_suffix}+xml"
2226    );
2227    let actual = types.overrides.get(&format!("/{part}")).or_else(|| {
2228        part.rsplit_once('.')
2229            .and_then(|(_, extension)| types.defaults.get(&extension.to_ascii_lowercase()))
2230    });
2231    if actual.is_none_or(|actual| actual != &expected) {
2232        return Err(DocumentExtractionError::InvalidDocxPackage {
2233            message: format!("DOCX story part {part} has a missing or wrong content type"),
2234        });
2235    }
2236    Ok(())
2237}
2238
2239/// Read typed internal/external relationship identities from one package part.
2240fn parse_docx_relationships(
2241    xml: &[u8],
2242    control: &IndexWorkControl,
2243    stage: IndexWorkStage,
2244) -> Result<HashMap<String, (String, String, bool)>, DocumentExtractionError> {
2245    const RELATIONSHIPS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
2246    const OFFICE_RELATIONSHIP_PREFIX: &str =
2247        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
2248    if xml.len() > MAX_DOCX_METADATA_BYTES {
2249        return Err(DocumentExtractionError::InvalidDocxPackage {
2250            message: "DOCX relationships exceed the package metadata bound".to_owned(),
2251        });
2252    }
2253    let mut reader = NsReader::from_reader(xml);
2254    reader.config_mut().expand_empty_elements = true;
2255    reader.config_mut().check_comments = true;
2256    let mut result = HashMap::new();
2257    let mut depth = 0usize;
2258    let mut closed = false;
2259    let mut events = 0usize;
2260    loop {
2261        check_parser_iteration(events, &mut || control.check(stage))?;
2262        events = events.saturating_add(1);
2263        let (namespace, event) =
2264            reader
2265                .read_resolved_event()
2266                .map_err(|error| DocumentExtractionError::Malformed {
2267                    format: DocumentFormat::Docx,
2268                    message: error.to_string(),
2269                })?;
2270        let valid_namespace = matches!(namespace, ResolveResult::Bound(namespace)
2271            if namespace.as_ref() == RELATIONSHIPS_NS);
2272        match event {
2273            Event::Start(event) => {
2274                depth += 1;
2275                let name = event.local_name();
2276                if !valid_namespace
2277                    || !matches!(
2278                        (depth, name.as_ref()),
2279                        (1, "Relationships") | (2, "Relationship")
2280                    )
2281                    || closed
2282                {
2283                    return Err(DocumentExtractionError::InvalidDocxPackage {
2284                        message: "DOCX relationships have an invalid XML structure".to_owned(),
2285                    });
2286                }
2287                validate_docx_attributes(&event, &reader, control, stage)?;
2288                if depth == 2 {
2289                    if result.len() == MAX_DOCX_METADATA_RECORDS {
2290                        return Err(DocumentExtractionError::InvalidDocxPackage {
2291                            message: "DOCX relationships exceed the package metadata record bound"
2292                                .to_owned(),
2293                        });
2294                    }
2295                    let attribute = |name| -> Result<String, DocumentExtractionError> {
2296                        let value = event
2297                            .try_get_attribute(name)
2298                            .map_err(|error| DocumentExtractionError::Malformed {
2299                                format: DocumentFormat::Docx,
2300                                message: error.to_string(),
2301                            })?
2302                            .ok_or_else(|| DocumentExtractionError::InvalidDocxPackage {
2303                                message: format!("DOCX relationship is missing {name}"),
2304                            })?;
2305                        Ok(quick_xml::escape::unescape(&value.value)
2306                            .map_err(|error| DocumentExtractionError::Malformed {
2307                                format: DocumentFormat::Docx,
2308                                message: error.to_string(),
2309                            })?
2310                            .into_owned())
2311                    };
2312                    let id = attribute("Id")?;
2313                    let kind = attribute("Type")?;
2314                    let target = attribute("Target")?;
2315                    let target_mode = event.try_get_attribute("TargetMode").map_err(|error| {
2316                        DocumentExtractionError::Malformed {
2317                            format: DocumentFormat::Docx,
2318                            message: error.to_string(),
2319                        }
2320                    })?;
2321                    let target_mode = target_mode
2322                        .as_ref()
2323                        .map(|value| quick_xml::escape::unescape(&value.value))
2324                        .transpose()
2325                        .map_err(|error| DocumentExtractionError::Malformed {
2326                            format: DocumentFormat::Docx,
2327                            message: error.to_string(),
2328                        })?;
2329                    let external = match target_mode.as_deref() {
2330                        None | Some("Internal") => false,
2331                        Some("External") => true,
2332                        Some(_) => {
2333                            return Err(DocumentExtractionError::InvalidDocxPackage {
2334                                message: "DOCX relationship has an invalid TargetMode".to_owned(),
2335                            });
2336                        }
2337                    };
2338                    let kind = kind
2339                        .strip_prefix(OFFICE_RELATIONSHIP_PREFIX)
2340                        .or_else(|| {
2341                            kind.strip_prefix(
2342                                "http://purl.oclc.org/ooxml/officeDocument/relationships/",
2343                            )
2344                        })
2345                        .unwrap_or("")
2346                        .to_owned();
2347                    if result
2348                        .insert(id.clone(), (kind, target, external))
2349                        .is_some()
2350                    {
2351                        return Err(DocumentExtractionError::InvalidDocxPackage {
2352                            message: format!("duplicate DOCX relationship {id}"),
2353                        });
2354                    }
2355                }
2356            }
2357            Event::End(_) => {
2358                if depth == 0 {
2359                    return Err(DocumentExtractionError::InvalidDocxPackage {
2360                        message: "DOCX relationship XML has an unmatched end".to_owned(),
2361                    });
2362                }
2363                depth -= 1;
2364                if depth == 0 {
2365                    closed = true;
2366                }
2367            }
2368            Event::Text(text) if text.as_ref().chars().all(xml_whitespace) => {}
2369            Event::CData(text) if internal_xml_whitespace(depth, text.as_ref()) => {}
2370            Event::GeneralRef(reference) => {
2371                let text = decode_docx_reference(&reference)?;
2372                if !internal_xml_whitespace(depth, &text) {
2373                    return Err(DocumentExtractionError::InvalidDocxPackage {
2374                        message: "DOCX relationships contain non-structural XML text".to_owned(),
2375                    });
2376                }
2377            }
2378            Event::Decl(_) if events == 1 => {}
2379            Event::Decl(_) => {
2380                return Err(DocumentExtractionError::Malformed {
2381                    format: DocumentFormat::Docx,
2382                    message: "DOCX XML declaration must be the first XML event".to_owned(),
2383                });
2384            }
2385            Event::Comment(_) => {}
2386            Event::PI(instruction) if !instruction.target().eq_ignore_ascii_case("xml") => {}
2387            Event::PI(_) => {
2388                return Err(DocumentExtractionError::Malformed {
2389                    format: DocumentFormat::Docx,
2390                    message: "DOCX XML processing instruction uses the reserved XML target"
2391                        .to_owned(),
2392                });
2393            }
2394            Event::Eof => break,
2395            _ => {
2396                return Err(DocumentExtractionError::InvalidDocxPackage {
2397                    message: "DOCX relationships contain unsupported XML".to_owned(),
2398                });
2399            }
2400        }
2401    }
2402    if !closed || depth != 0 {
2403        return Err(DocumentExtractionError::InvalidDocxPackage {
2404            message: "DOCX relationships XML is incomplete".to_owned(),
2405        });
2406    }
2407    Ok(result)
2408}
2409
2410/// Resolve a story-relative target while refusing unsafe URI syntax and package escape.
2411fn resolve_docx_story_target(
2412    target: &str,
2413    origin_part: &str,
2414    names: &HashSet<String>,
2415) -> Result<String, DocumentExtractionError> {
2416    let mut components = if origin_part.is_empty() {
2417        Vec::new()
2418    } else {
2419        let mut components = origin_part
2420            .split('/')
2421            .map(str::to_owned)
2422            .collect::<Vec<_>>();
2423        components.pop();
2424        components
2425    };
2426    let target = if let Some(absolute) = target.strip_prefix('/') {
2427        components.clear();
2428        absolute
2429    } else {
2430        target
2431    };
2432    if target.is_empty() || target.contains(['\\', '?', '#', ':']) {
2433        return Err(DocumentExtractionError::InvalidDocxPackage {
2434            message: format!("unsafe DOCX story target {target}"),
2435        });
2436    }
2437    for component in target.split('/') {
2438        let normalized = normalize_docx_target_component(component).ok_or_else(|| {
2439            DocumentExtractionError::InvalidDocxPackage {
2440                message: format!("unsafe DOCX story target {target}"),
2441            }
2442        })?;
2443        match normalized.as_str() {
2444            "" => {
2445                return Err(DocumentExtractionError::InvalidDocxPackage {
2446                    message: format!("unsafe DOCX story target {target}"),
2447                });
2448            }
2449            "." => {}
2450            ".." => {
2451                if components.pop().is_none() {
2452                    return Err(DocumentExtractionError::InvalidDocxPackage {
2453                        message: format!("unsafe DOCX story target {target}"),
2454                    });
2455                }
2456            }
2457            _ => components.push(normalized),
2458        }
2459    }
2460    let part = components.join("/");
2461    if !names.contains(&part) {
2462        return Err(DocumentExtractionError::InvalidDocxPackage {
2463            message: format!("referenced DOCX story part {part} is missing"),
2464        });
2465    }
2466    Ok(part)
2467}
2468
2469/// Admit canonical part-name segments without encoded separators or unreserved aliases.
2470fn safe_docx_uri_component(component: &str) -> bool {
2471    normalize_docx_target_component(component).is_some_and(|normalized| normalized == component)
2472}
2473
2474/// Normalize URI unreserved escapes while preserving encoded package-name bytes.
2475fn normalize_docx_target_component(component: &str) -> Option<String> {
2476    let bytes = component.as_bytes();
2477    let mut normalized = Vec::with_capacity(bytes.len());
2478    let mut index = 0;
2479    while index < bytes.len() {
2480        if bytes[index] != b'%' {
2481            normalized.push(bytes[index]);
2482            index += 1;
2483            continue;
2484        }
2485        if index + 2 >= bytes.len()
2486            || !bytes[index + 1].is_ascii_hexdigit()
2487            || !bytes[index + 2].is_ascii_hexdigit()
2488        {
2489            return None;
2490        }
2491        let encoded = u8::from_str_radix(&component[index + 1..index + 3], 16).ok()?;
2492        if matches!(encoded, b'/' | b'\\') {
2493            return None;
2494        }
2495        if encoded.is_ascii_alphanumeric() || b"-._~".contains(&encoded) {
2496            normalized.push(encoded);
2497        } else {
2498            normalized.extend_from_slice(&bytes[index..index + 3]);
2499        }
2500        index += 3;
2501    }
2502    String::from_utf8(normalized).ok()
2503}
2504
2505/// Append exact story facts and adjust their aggregate text line positions.
2506fn append_docx_story(
2507    document: &mut DocumentFacts,
2508    story: DocumentFacts,
2509    document_newlines: &mut usize,
2510) -> Result<(), DocumentExtractionError> {
2511    let DocumentFacts {
2512        text,
2513        facts,
2514        symbols,
2515        completeness,
2516        ..
2517    } = story;
2518    let separator = usize::from(!document.text.is_empty() && !text.is_empty());
2519    let total = document
2520        .text
2521        .len()
2522        .saturating_add(separator)
2523        .saturating_add(text.len());
2524    if total > MAX_DOCUMENT_OUTPUT_BYTES {
2525        return Err(DocumentExtractionError::ResourceLimit {
2526            limit: DocumentLimit::OutputBytes,
2527            observed: total,
2528            maximum: MAX_DOCUMENT_OUTPUT_BYTES,
2529        });
2530    }
2531    let fact_count = document
2532        .facts
2533        .len()
2534        .saturating_add(document.symbols.len())
2535        .saturating_add(facts.len())
2536        .saturating_add(symbols.len());
2537    if fact_count > MAX_DOCUMENT_FACTS {
2538        return Err(DocumentExtractionError::ResourceLimit {
2539            limit: DocumentLimit::FactCount,
2540            observed: fact_count,
2541            maximum: MAX_DOCUMENT_FACTS,
2542        });
2543    }
2544    let line_offset = if document.text.is_empty() {
2545        0
2546    } else {
2547        *document_newlines + separator
2548    };
2549    *document_newlines += text.bytes().filter(|byte| *byte == b'\n').count() + separator;
2550    if separator != 0 {
2551        document.text.push('\n');
2552    }
2553    document.text.push_str(&text);
2554    document.facts.extend(facts.into_iter().map(|mut fact| {
2555        fact.line_start += line_offset;
2556        fact.line_end += line_offset;
2557        fact
2558    }));
2559    document
2560        .symbols
2561        .extend(symbols.into_iter().map(|mut symbol| {
2562            symbol.line += line_offset;
2563            symbol
2564        }));
2565    if let DocumentCompleteness::Partial { gaps } = completeness {
2566        if let DocumentCompleteness::Complete = document.completeness {
2567            document.completeness = DocumentCompleteness::Partial { gaps: Vec::new() };
2568        }
2569        if let DocumentCompleteness::Partial { gaps: existing } = &mut document.completeness {
2570            for gap in gaps {
2571                if !existing.contains(&gap) {
2572                    existing.push(gap);
2573                }
2574            }
2575        }
2576    }
2577    Ok(())
2578}
2579
2580/// Decoded run text with Word text-leaf whitespace policy applied before publication.
2581#[derive(Default)]
2582struct RawDocxRun {
2583    /// Logical depth of this run, excluding selected compatibility wrappers.
2584    depth: usize,
2585    /// Direct run properties cannot follow already-consumed content.
2586    content_seen: bool,
2587    /// Logical depth of active direct run properties.
2588    properties_depth: Option<usize>,
2589    /// XML depth of the drawing, picture, or object owning a text box.
2590    drawing_depth: Option<usize>,
2591    /// Direct run formatting excludes payload without discarding field transitions.
2592    hidden: bool,
2593    /// Renderer-specific hiding needs a coverage gap unless direct vanish resolves it.
2594    spec_vanish: bool,
2595    /// Decoded logical run text.
2596    text: String,
2597    /// Decoded run bytes already emitted before a nested text container.
2598    text_start: usize,
2599}
2600
2601/// Paragraph, run, and field ownership within one `WordprocessingML` text container.
2602#[derive(Default)]
2603struct DocxTextContext {
2604    /// Document-order paragraph ordinal, including empty paragraphs.
2605    number: usize,
2606    /// Run ordinal within this paragraph, including empty runs.
2607    run_number: usize,
2608    /// Whether the paragraph element is still open.
2609    open: bool,
2610    /// Unpublished fragment of the active run.
2611    run: Option<RawDocxRun>,
2612    /// Nested complex-field phases in this text container.
2613    fields: Vec<DocxFieldPhase>,
2614}
2615
2616/// Whether a complex field is still in its instruction or cached-result region.
2617#[derive(Clone, Copy, PartialEq, Eq)]
2618enum DocxFieldPhase {
2619    /// Field instructions are data and are never executed or rendered.
2620    Instruction,
2621    /// Existing cached result text may be retained.
2622    Result {
2623        /// Whether at least one literal cached result character was retained.
2624        has_cached_text: bool,
2625    },
2626}
2627
2628/// One simple field whose child runs may contain cached literal result text.
2629struct DocxSimpleField {
2630    /// XML depth of the enclosing field element.
2631    depth: usize,
2632    /// Whether a literal result was retained from the field's children.
2633    has_cached_text: bool,
2634    /// A field inside enclosing code cannot render or require evaluation.
2635    in_instruction: bool,
2636}
2637
2638/// Mark only actual retained field-result content, never field instructions.
2639fn mark_docx_cached_result(fields: &mut [DocxFieldPhase], simple: &mut [DocxSimpleField]) {
2640    for field in fields {
2641        if let DocxFieldPhase::Result { has_cached_text } = field {
2642            *has_cached_text = true;
2643        }
2644    }
2645    for field in simple {
2646        field.has_cached_text = true;
2647    }
2648}
2649
2650/// The closed text leaves admitted from the Word document part.
2651#[derive(Clone, Copy, PartialEq, Eq)]
2652enum DocxTextCarrier {
2653    /// Literal document text retained with a locator.
2654    Rendered,
2655    /// Field instructions and deleted text are validated without publication.
2656    Ignored,
2657}
2658
2659/// Recognize either supported Word namespace independently of its chosen prefix.
2660fn wordprocessing_namespace(namespace: &str) -> bool {
2661    matches!(
2662        namespace,
2663        "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2664            | "http://purl.oclc.org/ooxml/wordprocessingml/main"
2665    )
2666}
2667
2668/// Resolve the namespace prefixes required by a Markup Compatibility choice.
2669fn docx_choice_supported(
2670    reader: &NsReader<&[u8]>,
2671    event: &BytesStart<'_>,
2672    control: &IndexWorkControl,
2673    stage: IndexWorkStage,
2674) -> Result<bool, DocumentExtractionError> {
2675    let requires = event
2676        .try_get_attribute("Requires")
2677        .map_err(|error| DocumentExtractionError::Malformed {
2678            format: DocumentFormat::Docx,
2679            message: error.to_string(),
2680        })?
2681        .ok_or_else(|| DocumentExtractionError::Malformed {
2682            format: DocumentFormat::Docx,
2683            message: "DOCX compatibility choice requires namespace prefixes".to_owned(),
2684        })?;
2685    let requires = quick_xml::escape::unescape(&requires.value).map_err(|error| {
2686        DocumentExtractionError::Malformed {
2687            format: DocumentFormat::Docx,
2688            message: error.to_string(),
2689        }
2690    })?;
2691    if xml_whitespace_tokens(&requires).next().is_none() {
2692        return Err(DocumentExtractionError::Malformed {
2693            format: DocumentFormat::Docx,
2694            message: "DOCX compatibility choice requires namespace prefixes".to_owned(),
2695        });
2696    }
2697    let mut supported = true;
2698    for (index, prefix) in xml_whitespace_tokens(&requires).enumerate() {
2699        check_parser_iteration(index, &mut || control.check(stage))?;
2700        if prefix.contains(':') {
2701            return Err(DocumentExtractionError::Malformed {
2702                format: DocumentFormat::Docx,
2703                message: format!("DOCX compatibility choice has invalid prefix {prefix}"),
2704            });
2705        }
2706        let qualified = format!("{prefix}:choice");
2707        let (namespace, _) = reader.resolver().resolve_element(QName(&qualified));
2708        match namespace {
2709            ResolveResult::Bound(namespace) if wordprocessing_namespace(namespace.as_ref()) => {}
2710            ResolveResult::Bound(_) => supported = false,
2711            ResolveResult::Unbound | ResolveResult::Unknown(_) => {
2712                return Err(DocumentExtractionError::Malformed {
2713                    format: DocumentFormat::Docx,
2714                    message: format!("DOCX compatibility choice has undeclared prefix {prefix}"),
2715                });
2716            }
2717        }
2718    }
2719    Ok(supported)
2720}
2721
2722/// Selection state for one bounded Markup Compatibility alternative.
2723struct DocxAlternative {
2724    /// XML depth of the enclosing `AlternateContent` element.
2725    depth: usize,
2726    /// Whether a branch is selected or an earlier branch may render in Word.
2727    selection: DocxAlternativeSelection,
2728    /// Whether the required first Choice has appeared.
2729    choice_seen: bool,
2730    /// Whether the final optional Fallback has appeared.
2731    fallback_seen: bool,
2732}
2733
2734/// Parser confidence about which compatibility branch Word may render.
2735#[derive(Clone, Copy, Eq, PartialEq)]
2736enum DocxAlternativeSelection {
2737    /// No earlier branch can render.
2738    Unselected,
2739    /// An understood branch was selected.
2740    Selected,
2741    /// An earlier unsupported branch may render.
2742    Uncertain,
2743}
2744
2745/// Closed package stories that can be reached from rendered `WordprocessingML`.
2746#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2747enum DocxStoryKind {
2748    /// Header selected by a section reference.
2749    Header,
2750    /// Footer selected by a section reference.
2751    Footer,
2752    /// Numbered footnote selected by a rendered marker.
2753    Footnotes,
2754    /// Numbered endnote selected by a rendered marker.
2755    Endnotes,
2756    /// Comment selected by a rendered marker or range.
2757    Comments,
2758    /// Referenced but intentionally unparsed subdocument.
2759    Subdocument,
2760}
2761
2762impl DocxStoryKind {
2763    /// Expected package content-type suffix for this story.
2764    fn content_type(self) -> &'static str {
2765        match self {
2766            Self::Header => "header",
2767            Self::Footer => "footer",
2768            Self::Footnotes => "footnotes",
2769            Self::Endnotes => "endnotes",
2770            Self::Comments => "comments",
2771            Self::Subdocument => "document.main",
2772        }
2773    }
2774
2775    /// Relationship-type suffix for this story.
2776    fn relationship_name(self) -> &'static str {
2777        match self {
2778            Self::Header => "header",
2779            Self::Footer => "footer",
2780            Self::Footnotes => "footnotes",
2781            Self::Endnotes => "endnotes",
2782            Self::Comments => "comments",
2783            Self::Subdocument => "subDocument",
2784        }
2785    }
2786
2787    /// Required root element of this story part.
2788    fn root_name(self) -> &'static str {
2789        match self {
2790            Self::Header => "hdr",
2791            Self::Footer => "ftr",
2792            Self::Footnotes => "footnotes",
2793            Self::Endnotes => "endnotes",
2794            Self::Comments => "comments",
2795            Self::Subdocument => "document",
2796        }
2797    }
2798
2799    /// Item element name for selected note/comment IDs, if applicable.
2800    fn item_name(self) -> Option<&'static str> {
2801        match self {
2802            Self::Footnotes => Some("footnote"),
2803            Self::Endnotes => Some("endnote"),
2804            Self::Comments => Some("comment"),
2805            Self::Header | Self::Footer | Self::Subdocument => None,
2806        }
2807    }
2808}
2809
2810#[derive(Default)]
2811/// Package references observed in rendered XML of one story part.
2812struct DocxStoryReferences {
2813    /// Explicit `r:id` references to related parts.
2814    linked_parts: Vec<(DocxStoryKind, String)>,
2815    /// A default header or footer may render differently on odd and even pages.
2816    has_default_header_footer: bool,
2817    /// Even-page stories are active only when the document setting enables them.
2818    even_parts: Vec<(DocxStoryKind, String)>,
2819    /// Imported-content anchors validated without decoding their target.
2820    imported_parts: Vec<String>,
2821    /// Alternate XML content anchors validated without decoding their target.
2822    content_parts: Vec<String>,
2823    /// Numbered note/comment items whose marker is rendered in this part.
2824    items: Vec<(DocxStoryKind, String)>,
2825    /// A rendered structured-document placeholder names glossary content we do not parse.
2826    glossary_placeholder: bool,
2827}
2828
2829impl DocxStoryReferences {
2830    /// Count retained references before cross-story set fanout.
2831    fn count(&self) -> usize {
2832        self.linked_parts
2833            .len()
2834            .saturating_add(self.even_parts.len())
2835            .saturating_add(self.imported_parts.len())
2836            .saturating_add(self.content_parts.len())
2837            .saturating_add(self.items.len())
2838            .saturating_add(usize::from(self.glossary_placeholder))
2839    }
2840
2841    /// First-page-only stories do not depend on page parity.
2842    fn has_parity_variant(&self) -> bool {
2843        self.has_default_header_footer || !self.even_parts.is_empty()
2844    }
2845}
2846
2847#[derive(Clone, Copy, Eq, PartialEq)]
2848/// Configured section page variant for a header or footer reference.
2849enum DocxHeaderVariant {
2850    /// Ordinary pages.
2851    Default,
2852    /// First page when title-page mode is enabled.
2853    First,
2854    /// Even pages when the document-wide setting is enabled.
2855    Even,
2856}
2857
2858/// Deferred section links, resolved after its title-page switch is known.
2859struct DocxHeaderSection {
2860    /// XML depth of the current section-properties element.
2861    depth: usize,
2862    /// Depth excluding selected compatibility wrappers, for direct children.
2863    logical_depth: usize,
2864    /// Whether its first-page stories are displayed.
2865    title_page: Option<bool>,
2866    /// Explicit links and their page variants.
2867    links: Vec<(DocxStoryKind, String, DocxHeaderVariant)>,
2868}
2869
2870/// Logical ancestors that may own section properties after compatibility selection.
2871#[derive(Clone, Copy, Default, Eq, PartialEq)]
2872enum DocxSectionAncestor {
2873    #[default]
2874    /// No section-bearing structural role.
2875    Other,
2876    /// Root document element.
2877    Document,
2878    /// Main document body directly under the root.
2879    Body,
2880    /// Paragraph within the main document body.
2881    Paragraph,
2882    /// Properties directly under that paragraph.
2883    ParagraphProperties,
2884}
2885
2886/// Rendered block-content ancestry for paragraph admission.
2887#[derive(Clone, Copy, Default, Eq, PartialEq)]
2888enum DocxBlockAncestor {
2889    #[default]
2890    /// Metadata or any unsupported block path.
2891    Other,
2892    /// A container that may directly hold paragraphs.
2893    Content,
2894    /// A table awaiting rows.
2895    Table,
2896    /// A row awaiting cells.
2897    Row,
2898    /// A block structured-document tag awaiting its content.
2899    SdtContent,
2900    /// A row structured-document tag awaiting its content.
2901    SdtTable,
2902    /// A cell structured-document tag awaiting its content.
2903    SdtRow,
2904}
2905
2906/// Parent tags needed to distinguish rendered placeholders from `docPartObj` metadata.
2907#[derive(Clone, Copy, Default, Eq, PartialEq)]
2908enum DocxSdtTag {
2909    #[default]
2910    /// Any non-SDT element.
2911    Other,
2912    /// Structured document tag container.
2913    Sdt,
2914    /// SDT properties.
2915    Properties,
2916    /// Placeholder properties.
2917    Placeholder,
2918    /// Content where a placeholder could be displayed.
2919    Content,
2920}
2921
2922/// Bounded structured-document state for deciding whether a glossary placeholder can render.
2923struct DocxSdtContext {
2924    /// XML depth of the containing SDT.
2925    depth: usize,
2926    /// Whether a glossary placeholder was named.
2927    placeholder: bool,
2928    /// Whether cached placeholder display was requested.
2929    showing: bool,
2930    /// XML depth of the optional content element.
2931    content_depth: Option<usize>,
2932    /// Whether the content contains visible data.
2933    content_nonempty: bool,
2934}
2935
2936/// Recognize either admitted Office relationship namespace for `r:id`.
2937fn office_relationship_namespace(namespace: &str) -> bool {
2938    matches!(
2939        namespace,
2940        "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2941            | "http://purl.oclc.org/ooxml/officeDocument/relationships"
2942    )
2943}
2944
2945/// Validate XML attribute names and entity references even when semantically ignored.
2946fn validate_docx_attributes(
2947    event: &BytesStart<'_>,
2948    reader: &NsReader<&[u8]>,
2949    control: &IndexWorkControl,
2950    stage: IndexWorkStage,
2951) -> Result<(), DocumentExtractionError> {
2952    for (index, attribute) in event.attributes().enumerate() {
2953        check_parser_iteration(index, &mut || control.check(stage))?;
2954        let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2955            format: DocumentFormat::Docx,
2956            message: error.to_string(),
2957        })?;
2958        if let (ResolveResult::Unknown(prefix), _) =
2959            reader.resolver().resolve_attribute(attribute.key)
2960        {
2961            return Err(DocumentExtractionError::Malformed {
2962                format: DocumentFormat::Docx,
2963                message: format!("DOCX XML attribute has an undeclared namespace prefix: {prefix}"),
2964            });
2965        }
2966        let value = quick_xml::escape::unescape(&attribute.value).map_err(|error| {
2967            DocumentExtractionError::Malformed {
2968                format: DocumentFormat::Docx,
2969                message: error.to_string(),
2970            }
2971        })?;
2972        if !value.chars().all(xml10_character) {
2973            return Err(DocumentExtractionError::Malformed {
2974                format: DocumentFormat::Docx,
2975                message: "DOCX XML attribute contains an invalid character".to_owned(),
2976            });
2977        }
2978    }
2979    Ok(())
2980}
2981
2982/// Read one exact namespaced attribute without accepting spoofed local names.
2983fn docx_attribute(
2984    event: &BytesStart<'_>,
2985    reader: &NsReader<&[u8]>,
2986    local_name: &str,
2987    namespace_matches: fn(&str) -> bool,
2988) -> Result<Option<String>, DocumentExtractionError> {
2989    let mut found = None;
2990    for attribute in event.attributes() {
2991        let attribute = attribute.map_err(|error| DocumentExtractionError::Malformed {
2992            format: DocumentFormat::Docx,
2993            message: error.to_string(),
2994        })?;
2995        let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
2996        if let ResolveResult::Unknown(prefix) = &namespace {
2997            return Err(DocumentExtractionError::Malformed {
2998                format: DocumentFormat::Docx,
2999                message: format!("DOCX XML attribute has an undeclared namespace prefix: {prefix}"),
3000            });
3001        }
3002        if local.as_ref() == local_name
3003            && matches!(namespace, ResolveResult::Bound(namespace)
3004                if namespace_matches(namespace.as_ref()))
3005        {
3006            if found.is_some() {
3007                return Err(DocumentExtractionError::Malformed {
3008                    format: DocumentFormat::Docx,
3009                    message: format!("DOCX {local_name} attribute is duplicated"),
3010                });
3011            }
3012            let value = quick_xml::escape::unescape(&attribute.value).map_err(|error| {
3013                DocumentExtractionError::Malformed {
3014                    format: DocumentFormat::Docx,
3015                    message: error.to_string(),
3016                }
3017            })?;
3018            if local_name == "id" && value.len() > MAX_DOCX_REFERENCE_ID_BYTES {
3019                return Err(DocumentExtractionError::ResourceLimit {
3020                    limit: DocumentLimit::MemoryBytes,
3021                    observed: value.len(),
3022                    maximum: MAX_DOCX_REFERENCE_ID_BYTES,
3023                });
3024            }
3025            found = Some(value.into_owned());
3026        }
3027    }
3028    Ok(found)
3029}
3030
3031/// Match bounded Word story IDs by their XML Schema integer value, not spelling.
3032fn canonical_docx_story_id(id: &str) -> Result<String, DocumentExtractionError> {
3033    let id = id.trim_matches([' ', '\t', '\r', '\n']);
3034    let (negative, digits) = match id.strip_prefix('-') {
3035        Some(digits) => (true, digits),
3036        None => (false, id.strip_prefix('+').unwrap_or(id)),
3037    };
3038    if digits.is_empty() || !digits.bytes().all(|digit| digit.is_ascii_digit()) {
3039        return Err(DocumentExtractionError::Malformed {
3040            format: DocumentFormat::Docx,
3041            message: "DOCX story item ID is not a decimal integer".to_owned(),
3042        });
3043    }
3044    let digits = digits.trim_start_matches('0');
3045    if digits.is_empty() {
3046        return Ok("0".to_owned());
3047    }
3048    Ok(if negative {
3049        format!("-{digits}")
3050    } else {
3051        digits.to_owned()
3052    })
3053}
3054
3055/// Read a `WordprocessingML` on/off element, whose absent value means true.
3056fn docx_on_off(
3057    event: &BytesStart<'_>,
3058    reader: &NsReader<&[u8]>,
3059) -> Result<bool, DocumentExtractionError> {
3060    match docx_attribute(event, reader, "val", wordprocessing_namespace)?.as_deref() {
3061        None | Some("true" | "1" | "on") => Ok(true),
3062        Some("false" | "0" | "off") => Ok(false),
3063        Some(value) => Err(DocumentExtractionError::Malformed {
3064            format: DocumentFormat::Docx,
3065            message: format!("DOCX on/off value {value:?} is invalid"),
3066        }),
3067    }
3068}
3069
3070/// Parse body paragraphs and table paragraphs from the admitted XML part.
3071#[cfg(test)]
3072fn parse_docx(
3073    xml: &[u8],
3074    control: &IndexWorkControl,
3075    stage: IndexWorkStage,
3076) -> Result<DocumentFacts, DocumentExtractionError> {
3077    let xml = decode_docx_xml(xml.to_vec(), control, stage)?;
3078    parse_docx_part(
3079        &xml,
3080        DOCX_DOCUMENT_PART,
3081        "document",
3082        None,
3083        None,
3084        &mut DocxStoryReferences::default(),
3085        control,
3086        stage,
3087    )
3088}
3089
3090/// Parse one validated `WordprocessingML` story without borrowing package state.
3091fn parse_docx_part(
3092    xml: &[u8],
3093    part: &str,
3094    root_name: &str,
3095    selected_ids: Option<&HashSet<String>>,
3096    selected_special_ids: Option<&HashSet<String>>,
3097    references: &mut DocxStoryReferences,
3098    control: &IndexWorkControl,
3099    stage: IndexWorkStage,
3100) -> Result<DocumentFacts, DocumentExtractionError> {
3101    let mut reader = NsReader::from_reader(xml);
3102    reader.config_mut().trim_text(false);
3103    reader.config_mut().expand_empty_elements = true;
3104    reader.config_mut().check_comments = true;
3105    let mut output = String::new();
3106    let mut output_line = 1usize;
3107    let mut facts = Vec::new();
3108    let mut symbols = Vec::new();
3109    let mut gaps = Vec::new();
3110    let mut paragraph_number = 0usize;
3111    let mut paragraph = DocxTextContext::default();
3112    let mut text_boxes = Vec::new();
3113    let mut text_carrier = None;
3114    let mut text_start = 0;
3115    let mut preserve_space = [false; MAX_DOCX_XML_DEPTH + 1];
3116    let mut sdt_tags = [DocxSdtTag::Other; MAX_DOCX_XML_DEPTH + 1];
3117    let mut section_ancestors = [DocxSectionAncestor::Other; MAX_DOCX_XML_DEPTH + 1];
3118    let mut inside_main_body = [false; MAX_DOCX_XML_DEPTH + 1];
3119    let mut comment_range_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3120    let mut block_ancestors = [DocxBlockAncestor::Other; MAX_DOCX_XML_DEPTH + 1];
3121    let mut rendered_run_ancestry = [false; MAX_DOCX_XML_DEPTH + 1];
3122    let mut direct_run_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3123    let mut import_anchor_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3124    let mut text_box_content_parents = [false; MAX_DOCX_XML_DEPTH + 1];
3125    let mut sdt_stack: Vec<DocxSdtContext> = Vec::new();
3126    let mut simple_fields: Vec<DocxSimpleField> = Vec::new();
3127    let mut header_section: Option<DocxHeaderSection> = None;
3128    let mut inherited_first: [Option<String>; 2] = [None, None];
3129    let mut inherited_default = [false; 2];
3130    let mut alternatives: Vec<DocxAlternative> = Vec::new();
3131    let mut selected_branches: Vec<usize> = Vec::new();
3132    let mut ignorable_namespaces: Vec<String> = Vec::new();
3133    let mut skipped_branch_depth = None;
3134    let mut skipped_text_box = false;
3135    let mut conditional_note_start = None;
3136    let mut deleted_depth = None;
3137    let mut foreign_depth = None;
3138    let mut foreign_opaque_depth = None;
3139    let mut element_depth = 0usize;
3140    let mut root_seen = false;
3141    let mut root_closed = false;
3142    let mut main_body_seen = false;
3143    let mut main_body_unexamined = false;
3144    let mut event_index = 0usize;
3145    let mut seen_ids = HashSet::new();
3146    let mut seen_special_ids = HashSet::new();
3147    loop {
3148        check_parser_iteration(event_index, &mut || control.check(stage))?;
3149        event_index = event_index.saturating_add(1);
3150        let (namespace, event) =
3151            reader
3152                .read_resolved_event()
3153                .map_err(|error| DocumentExtractionError::Malformed {
3154                    format: DocumentFormat::Docx,
3155                    message: error.to_string(),
3156                })?;
3157        let compatibility = matches!(&namespace, ResolveResult::Bound(namespace)
3158            if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006");
3159        let wordprocessing = match &namespace {
3160            ResolveResult::Bound(namespace) => wordprocessing_namespace(namespace.as_ref()),
3161            ResolveResult::Unbound => false,
3162            ResolveResult::Unknown(prefix) => {
3163                return Err(DocumentExtractionError::Malformed {
3164                    format: DocumentFormat::Docx,
3165                    message: format!("DOCX XML contained an undeclared namespace prefix: {prefix}"),
3166                });
3167            }
3168        };
3169        let recognized_drawing = matches!(&namespace, ResolveResult::Bound(namespace)
3170            if matches!(namespace.as_ref(),
3171                "http://schemas.openxmlformats.org/drawingml/2006/main"
3172                    | "http://purl.oclc.org/ooxml/drawingml/main"
3173                    | "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
3174                    | "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing"
3175                    | "http://schemas.openxmlformats.org/drawingml/2006/picture"
3176                    | "http://purl.oclc.org/ooxml/drawingml/picture"
3177                    | "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
3178                    | "urn:schemas-microsoft-com:vml"));
3179        let text_box_content_parent = match (&namespace, &event) {
3180            (ResolveResult::Bound(namespace), Event::Start(element) | Event::Empty(element)) => {
3181                matches!(
3182                    (namespace.as_ref(), element.local_name().as_ref()),
3183                    (
3184                        "http://schemas.openxmlformats.org/drawingml/2006/main"
3185                            | "http://purl.oclc.org/ooxml/drawingml/main",
3186                        "graphicData",
3187                    ) | ("urn:schemas-microsoft-com:vml", "textbox")
3188                        | (
3189                            "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
3190                            "txbx",
3191                        )
3192                )
3193            }
3194            _ => false,
3195        };
3196        if matches!(event, Event::Decl(_)) && event_index != 1 {
3197            return Err(DocumentExtractionError::Malformed {
3198                format: DocumentFormat::Docx,
3199                message: "DOCX XML declaration must be the first XML event".to_owned(),
3200            });
3201        }
3202        if foreign_depth.is_some() && text_carrier.is_none() {
3203            let has_text = match &event {
3204                Event::Text(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
3205                Event::CData(text) => Some(text.as_ref().chars().any(|c| !c.is_ascii_whitespace())),
3206                Event::GeneralRef(reference) => Some(
3207                    decode_docx_reference(reference)?
3208                        .chars()
3209                        .any(|c| !c.is_ascii_whitespace()),
3210                ),
3211                _ => None,
3212            };
3213            if let Some(has_text) = has_text {
3214                if has_text && deleted_depth.is_none() && skipped_branch_depth.is_none() {
3215                    return Err(DocumentExtractionError::UnsupportedDocxInput {
3216                        message: "foreign-namespace text requires unsupported semantic decoding"
3217                            .to_owned(),
3218                    });
3219                }
3220                continue;
3221            }
3222        }
3223        match event {
3224            Event::Start(event) => {
3225                if text_carrier.is_some() {
3226                    return Err(DocumentExtractionError::Malformed {
3227                        format: DocumentFormat::Docx,
3228                        message: "DOCX text elements cannot contain nested markup".to_owned(),
3229                    });
3230                }
3231                let name = event.local_name();
3232                if element_depth == 0 {
3233                    if root_seen || !wordprocessing || name.as_ref() != root_name {
3234                        return Err(DocumentExtractionError::Malformed {
3235                            format: DocumentFormat::Docx,
3236                            message: "DOCX XML must contain one WordprocessingML document root"
3237                                .to_owned(),
3238                        });
3239                    }
3240                    root_seen = true;
3241                }
3242                element_depth = element_depth.saturating_add(1);
3243                if element_depth > MAX_DOCX_XML_DEPTH {
3244                    return Err(DocumentExtractionError::ResourceLimit {
3245                        limit: DocumentLimit::NestingDepth,
3246                        observed: element_depth,
3247                        maximum: MAX_DOCX_XML_DEPTH,
3248                    });
3249                }
3250                preserve_space[element_depth] = preserve_space[element_depth - 1];
3251                sdt_tags[element_depth] = DocxSdtTag::Other;
3252                // Source-part ordinal stays stable when different note IDs are parsed later.
3253                if wordprocessing
3254                    && name.as_ref() == "p"
3255                    && (selected_ids.is_some()
3256                        || skipped_branch_depth.is_none()
3257                        || skipped_text_box)
3258                {
3259                    paragraph_number += 1;
3260                }
3261                if let Some(branch_depth) = skipped_branch_depth {
3262                    validate_docx_attributes(&event, &reader, control, stage)?;
3263                    if root_name == "document"
3264                        && wordprocessing
3265                        && name.as_ref() == "body"
3266                        && element_depth == branch_depth + 1
3267                        && alternatives.last().is_some_and(|alternative| {
3268                            alternative.selection == DocxAlternativeSelection::Uncertain
3269                                && alternative.depth + 1 == branch_depth
3270                                && alternative.depth == 2 + 2 * selected_branches.len()
3271                        })
3272                    {
3273                        main_body_unexamined = true;
3274                    }
3275                    continue;
3276                }
3277                if wordprocessing
3278                    && (foreign_opaque_depth.is_some()
3279                        || (foreign_depth.is_some()
3280                            && text_boxes.is_empty()
3281                            && name.as_ref() != "txbxContent"))
3282                {
3283                    return Err(DocumentExtractionError::UnsupportedDocxInput {
3284                        message: "Word content inside an opaque foreign wrapper cannot be verified as rendered".to_owned(),
3285                    });
3286                }
3287                if wordprocessing
3288                    && deleted_depth.is_none()
3289                    && matches!(name.as_ref(), "rStyle" | "pStyle")
3290                    && !gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)
3291                {
3292                    gaps.push(DocumentCoverageGap::UnresolvedVisibility);
3293                }
3294                let ignorable = !wordprocessing
3295                    && !compatibility
3296                    && matches!(&namespace, ResolveResult::Bound(namespace)
3297                        if ignorable_namespaces.iter().any(|ignored| ignored == namespace.as_ref()));
3298                if wordprocessing
3299                    && matches!(name.as_ref(), "footnote" | "endnote" | "comment")
3300                    && (!matches!(
3301                        (root_name, name.as_ref()),
3302                        ("footnotes", "footnote")
3303                            | ("endnotes", "endnote")
3304                            | ("comments", "comment")
3305                    ) || element_depth != 2 + 2 * selected_branches.len())
3306                {
3307                    return Err(DocumentExtractionError::Malformed {
3308                        format: DocumentFormat::Docx,
3309                        message: "DOCX story item is outside its collection root".to_owned(),
3310                    });
3311                }
3312                if element_depth == 2 + 2 * selected_branches.len()
3313                    && let Some(selected_ids) = selected_ids
3314                    && matches!(root_name, "footnotes" | "endnotes" | "comments")
3315                {
3316                    let expected = match root_name {
3317                        "footnotes" => "footnote",
3318                        "endnotes" => "endnote",
3319                        _ => "comment",
3320                    };
3321                    if wordprocessing && name.as_ref() != expected {
3322                        return Err(DocumentExtractionError::Malformed {
3323                            format: DocumentFormat::Docx,
3324                            message: "DOCX story root contains a non-item child".to_owned(),
3325                        });
3326                    }
3327                    if wordprocessing {
3328                        let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
3329                            .ok_or_else(|| DocumentExtractionError::Malformed {
3330                            format: DocumentFormat::Docx,
3331                            message: "DOCX story item is missing its id".to_owned(),
3332                        })?;
3333                        let id = canonical_docx_story_id(&id)?;
3334                        let note_type = if matches!(root_name, "footnotes" | "endnotes") {
3335                            docx_attribute(&event, &reader, "type", wordprocessing_namespace)?
3336                        } else {
3337                            None
3338                        };
3339                        let selected = selected_ids.contains(&id);
3340                        if selected && note_type.as_deref().is_some_and(|kind| kind != "normal") {
3341                            return Err(DocumentExtractionError::InvalidDocxPackage {
3342                                message: "DOCX note reference targets a non-normal item".to_owned(),
3343                            });
3344                        }
3345                        let special = selected_special_ids.is_some_and(|ids| ids.contains(&id))
3346                            && matches!(
3347                                note_type.as_deref(),
3348                                Some("separator" | "continuationSeparator" | "continuationNotice")
3349                            );
3350                        if !selected && !special {
3351                            validate_docx_attributes(&event, &reader, control, stage)?;
3352                            skipped_branch_depth = Some(element_depth);
3353                            continue;
3354                        }
3355                        let first_occurrence = if selected {
3356                            seen_ids.insert(id)
3357                        } else {
3358                            seen_special_ids.insert(id)
3359                        };
3360                        if !first_occurrence {
3361                            return Err(DocumentExtractionError::Malformed {
3362                                format: DocumentFormat::Docx,
3363                                message: "DOCX selected story item ID is duplicated".to_owned(),
3364                            });
3365                        }
3366                        if matches!(
3367                            note_type.as_deref(),
3368                            Some("continuationSeparator" | "continuationNotice")
3369                        ) {
3370                            conditional_note_start =
3371                                Some((facts.len(), symbols.len(), references.count()));
3372                        }
3373                    }
3374                }
3375                if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
3376                    let alternative = alternatives
3377                        .last_mut()
3378                        .filter(|alternative| {
3379                            alternative.depth + 1 == element_depth && !alternative.fallback_seen
3380                        })
3381                        .ok_or_else(|| DocumentExtractionError::Malformed {
3382                            format: DocumentFormat::Docx,
3383                            message: "DOCX compatibility branch has invalid placement".to_owned(),
3384                        })?;
3385                    let supported = if name.as_ref() == "Choice" {
3386                        alternative.choice_seen = true;
3387                        docx_choice_supported(&reader, &event, control, stage)?
3388                    } else {
3389                        if !alternative.choice_seen {
3390                            return Err(DocumentExtractionError::Malformed {
3391                                format: DocumentFormat::Docx,
3392                                message: "DOCX compatibility fallback requires a preceding choice"
3393                                    .to_owned(),
3394                            });
3395                        }
3396                        alternative.fallback_seen = true;
3397                        true
3398                    };
3399                    if name.as_ref() == "Choice"
3400                        && !supported
3401                        && alternative.selection == DocxAlternativeSelection::Unselected
3402                        && !gaps.contains(&DocumentCoverageGap::UnexaminedStory)
3403                    {
3404                        gaps.push(DocumentCoverageGap::UnexaminedStory);
3405                    }
3406                    if name.as_ref() == "Choice"
3407                        && !supported
3408                        && alternative.selection == DocxAlternativeSelection::Unselected
3409                    {
3410                        alternative.selection = DocxAlternativeSelection::Uncertain;
3411                    }
3412                    if alternative.selection == DocxAlternativeSelection::Unselected && supported {
3413                        alternative.selection = DocxAlternativeSelection::Selected;
3414                    } else {
3415                        validate_docx_attributes(&event, &reader, control, stage)?;
3416                        skipped_branch_depth = Some(element_depth);
3417                        continue;
3418                    }
3419                }
3420                {
3421                    let suppressed = deleted_depth.is_some()
3422                        || (wordprocessing
3423                            && matches!(name.as_ref(), "del" | "moveFrom" | "sectPrChange"));
3424                    for (index, attribute) in event.attributes().enumerate() {
3425                        check_parser_iteration(index, &mut || control.check(stage))?;
3426                        let attribute =
3427                            attribute.map_err(|error| DocumentExtractionError::Malformed {
3428                                format: DocumentFormat::Docx,
3429                                message: error.to_string(),
3430                            })?;
3431                        let (attribute_namespace, attribute_name) =
3432                            reader.resolver().resolve_attribute(attribute.key);
3433                        if let ResolveResult::Unknown(prefix) = &attribute_namespace {
3434                            return Err(DocumentExtractionError::Malformed {
3435                                format: DocumentFormat::Docx,
3436                                message: format!(
3437                                    "DOCX XML attribute has an undeclared namespace prefix: {prefix}"
3438                                ),
3439                            });
3440                        }
3441                        let value =
3442                            quick_xml::escape::unescape(&attribute.value).map_err(|error| {
3443                                DocumentExtractionError::Malformed {
3444                                    format: DocumentFormat::Docx,
3445                                    message: error.to_string(),
3446                                }
3447                            })?;
3448                        if !value.chars().all(xml10_character) {
3449                            return Err(DocumentExtractionError::Malformed {
3450                                format: DocumentFormat::Docx,
3451                                message: "DOCX XML attribute contains an invalid character"
3452                                    .to_owned(),
3453                            });
3454                        }
3455                        if suppressed {
3456                            continue;
3457                        }
3458                        if attribute_name.as_ref() == "space"
3459                            && matches!(&attribute_namespace, ResolveResult::Bound(namespace)
3460                                if namespace.as_ref() == "http://www.w3.org/XML/1998/namespace")
3461                        {
3462                            preserve_space[element_depth] = match value.as_ref() {
3463                                "default" => false,
3464                                "preserve" => true,
3465                                _ => {
3466                                    return Err(DocumentExtractionError::Malformed {
3467                                        format: DocumentFormat::Docx,
3468                                        message: "DOCX xml:space must be default or preserve"
3469                                            .to_owned(),
3470                                    });
3471                                }
3472                            };
3473                        }
3474                        if !matches!(attribute_namespace, ResolveResult::Bound(namespace)
3475                            if namespace.as_ref() == "http://schemas.openxmlformats.org/markup-compatibility/2006")
3476                            || !matches!(
3477                                attribute_name.as_ref(),
3478                                "Ignorable" | "ProcessContent" | "MustUnderstand"
3479                            )
3480                        {
3481                            continue;
3482                        }
3483                        if xml_whitespace_tokens(&value).next().is_none() {
3484                            continue;
3485                        }
3486                        if attribute_name.as_ref() != "Ignorable" || element_depth != 1 {
3487                            return Err(DocumentExtractionError::UnsupportedDocxInput {
3488                                message: "DOCX compatibility policy supports only root Ignorable namespaces".to_owned(),
3489                            });
3490                        }
3491                        for (index, prefix) in xml_whitespace_tokens(&value).enumerate() {
3492                            check_parser_iteration(index, &mut || control.check(stage))?;
3493                            if prefix.contains(':') {
3494                                return Err(DocumentExtractionError::Malformed {
3495                                    format: DocumentFormat::Docx,
3496                                    message: "DOCX Ignorable policy requires namespace prefixes, not qualified names".to_owned(),
3497                                });
3498                            }
3499                            let qualified = format!("{prefix}:ignored");
3500                            let (resolved, _) =
3501                                reader.resolver().resolve_element(QName(&qualified));
3502                            let ResolveResult::Bound(namespace) = resolved else {
3503                                return Err(DocumentExtractionError::Malformed {
3504                                    format: DocumentFormat::Docx,
3505                                    message: "DOCX Ignorable policy names an undeclared namespace"
3506                                        .to_owned(),
3507                                });
3508                            };
3509                            if ignorable_namespaces
3510                                .iter()
3511                                .any(|ignored| ignored == namespace.as_ref())
3512                            {
3513                                continue;
3514                            }
3515                            if ignorable_namespaces.len() == MAX_DOCX_IGNORABLE_NAMESPACES {
3516                                return Err(DocumentExtractionError::UnsupportedDocxInput {
3517                                    message: "DOCX root Ignorable policy exceeds the supported namespace count".to_owned(),
3518                                });
3519                            }
3520                            ignorable_namespaces.push(namespace.as_ref().to_owned());
3521                        }
3522                    }
3523                }
3524                if ignorable {
3525                    skipped_branch_depth = Some(element_depth);
3526                    continue;
3527                }
3528                if compatibility && matches!(name.as_ref(), "Choice" | "Fallback") {
3529                    selected_branches.push(element_depth);
3530                    continue;
3531                }
3532                if alternatives
3533                    .last()
3534                    .is_some_and(|alternative| alternative.depth + 1 == element_depth)
3535                {
3536                    return Err(DocumentExtractionError::Malformed {
3537                        format: DocumentFormat::Docx,
3538                        message: "DOCX compatibility alternatives must contain choices and an optional fallback".to_owned(),
3539                    });
3540                }
3541                if compatibility && name.as_ref() == "AlternateContent" {
3542                    alternatives.push(DocxAlternative {
3543                        depth: element_depth,
3544                        selection: DocxAlternativeSelection::Unselected,
3545                        choice_seen: false,
3546                        fallback_seen: false,
3547                    });
3548                    continue;
3549                }
3550                if !wordprocessing && !compatibility {
3551                    if foreign_depth.is_none() {
3552                        foreign_depth = Some(element_depth);
3553                    }
3554                    if !recognized_drawing && foreign_opaque_depth.is_none() {
3555                        foreign_opaque_depth = Some(element_depth);
3556                    }
3557                }
3558                let mut parent_depth = element_depth - 1;
3559                for &branch_depth in selected_branches.iter().rev() {
3560                    if branch_depth == parent_depth {
3561                        parent_depth -= 2;
3562                    }
3563                }
3564                text_box_content_parents[element_depth] = text_box_content_parent;
3565                section_ancestors[element_depth] = if wordprocessing {
3566                    match (name.as_ref(), section_ancestors[parent_depth]) {
3567                        ("document", _) if element_depth == 1 => DocxSectionAncestor::Document,
3568                        ("body", DocxSectionAncestor::Document) => DocxSectionAncestor::Body,
3569                        ("p", _) if inside_main_body[parent_depth] => {
3570                            DocxSectionAncestor::Paragraph
3571                        }
3572                        ("pPr", DocxSectionAncestor::Paragraph) => {
3573                            DocxSectionAncestor::ParagraphProperties
3574                        }
3575                        _ => DocxSectionAncestor::Other,
3576                    }
3577                } else {
3578                    DocxSectionAncestor::Other
3579                };
3580                if root_name == "document" && wordprocessing && name.as_ref() == "body" {
3581                    if section_ancestors[element_depth] != DocxSectionAncestor::Body
3582                        || main_body_seen
3583                    {
3584                        return Err(DocumentExtractionError::Malformed {
3585                            format: DocumentFormat::Docx,
3586                            message: "DOCX main document requires one direct body".to_owned(),
3587                        });
3588                    }
3589                    main_body_seen = true;
3590                }
3591                inside_main_body[element_depth] = if wordprocessing && name.as_ref() == "body" {
3592                    section_ancestors[element_depth] == DocxSectionAncestor::Body
3593                } else if wordprocessing
3594                    && matches!(
3595                        name.as_ref(),
3596                        "document"
3597                            | "hdr"
3598                            | "ftr"
3599                            | "footnotes"
3600                            | "endnotes"
3601                            | "comments"
3602                            | "docPartBody"
3603                            | "txbxContent"
3604                    )
3605                {
3606                    false
3607                } else {
3608                    inside_main_body[parent_depth]
3609                        || section_ancestors[element_depth] == DocxSectionAncestor::Body
3610                };
3611                block_ancestors[element_depth] = if wordprocessing {
3612                    match (name.as_ref(), block_ancestors[parent_depth]) {
3613                        ("hdr" | "ftr", _) if element_depth == 1 && name.as_ref() == root_name => {
3614                            DocxBlockAncestor::Content
3615                        }
3616                        ("body", _)
3617                            if section_ancestors[element_depth] == DocxSectionAncestor::Body =>
3618                        {
3619                            DocxBlockAncestor::Content
3620                        }
3621                        ("footnote" | "endnote" | "comment", _)
3622                            if parent_depth == 1
3623                                && matches!(
3624                                    (root_name, name.as_ref()),
3625                                    ("footnotes", "footnote")
3626                                        | ("endnotes", "endnote")
3627                                        | ("comments", "comment")
3628                                ) =>
3629                        {
3630                            DocxBlockAncestor::Content
3631                        }
3632                        ("tbl", DocxBlockAncestor::Content)
3633                        | ("sdtContent", DocxBlockAncestor::SdtTable)
3634                        | ("customXml", DocxBlockAncestor::Table) => DocxBlockAncestor::Table,
3635                        ("tr", DocxBlockAncestor::Table)
3636                        | ("sdtContent", DocxBlockAncestor::SdtRow)
3637                        | ("customXml", DocxBlockAncestor::Row) => DocxBlockAncestor::Row,
3638                        ("sdt", DocxBlockAncestor::Content) => DocxBlockAncestor::SdtContent,
3639                        ("sdt", DocxBlockAncestor::Table) => DocxBlockAncestor::SdtTable,
3640                        ("sdt", DocxBlockAncestor::Row) => DocxBlockAncestor::SdtRow,
3641                        ("txbxContent", _)
3642                        | ("tc", DocxBlockAncestor::Row)
3643                        | ("sdtContent", DocxBlockAncestor::SdtContent)
3644                        | (
3645                            "customXml" | "ins" | "moveTo" | "del" | "moveFrom",
3646                            DocxBlockAncestor::Content,
3647                        ) => DocxBlockAncestor::Content,
3648                        _ => DocxBlockAncestor::Other,
3649                    }
3650                } else {
3651                    DocxBlockAncestor::Other
3652                };
3653                rendered_run_ancestry[element_depth] = wordprocessing
3654                    && (name.as_ref() == "p"
3655                        || (rendered_run_ancestry[parent_depth]
3656                            && matches!(
3657                                name.as_ref(),
3658                                "sdt"
3659                                    | "sdtContent"
3660                                    | "hyperlink"
3661                                    | "fldSimple"
3662                                    | "customXml"
3663                                    | "smartTag"
3664                                    | "ins"
3665                                    | "moveTo"
3666                                    | "del"
3667                                    | "moveFrom"
3668                                    | "bdo"
3669                                    | "dir"
3670                            )));
3671                direct_run_parents[element_depth] =
3672                    rendered_run_ancestry[element_depth] && name.as_ref() != "sdt";
3673                import_anchor_parents[element_depth] = wordprocessing
3674                    && block_ancestors[element_depth] == DocxBlockAncestor::Content
3675                    && matches!(
3676                        name.as_ref(),
3677                        "body"
3678                            | "hdr"
3679                            | "ftr"
3680                            | "footnote"
3681                            | "endnote"
3682                            | "comment"
3683                            | "tc"
3684                            | "txbxContent"
3685                    );
3686                // Comment anchors belong to content, never property metadata.
3687                comment_range_parents[element_depth] = wordprocessing
3688                    && matches!(
3689                        name.as_ref(),
3690                        "bdo"
3691                            | "body"
3692                            | "comment"
3693                            | "customXml"
3694                            | "dir"
3695                            | "docPartBody"
3696                            | "endnote"
3697                            | "fldSimple"
3698                            | "footnote"
3699                            | "ftr"
3700                            | "hdr"
3701                            | "hyperlink"
3702                            | "ins"
3703                            | "moveTo"
3704                            | "p"
3705                            | "rt"
3706                            | "rubyBase"
3707                            | "sdt"
3708                            | "sdtContent"
3709                            | "tbl"
3710                            | "tc"
3711                            | "tr"
3712                            | "txbxContent"
3713                    );
3714                if wordprocessing && deleted_depth.is_none() {
3715                    sdt_tags[element_depth] = match (name.as_ref(), sdt_tags[parent_depth]) {
3716                        ("sdt", _) => DocxSdtTag::Sdt,
3717                        ("sdtPr", DocxSdtTag::Sdt) => DocxSdtTag::Properties,
3718                        ("placeholder", DocxSdtTag::Properties) => DocxSdtTag::Placeholder,
3719                        ("sdtContent", DocxSdtTag::Sdt) => DocxSdtTag::Content,
3720                        _ => DocxSdtTag::Other,
3721                    };
3722                    if !paragraph.fields.contains(&DocxFieldPhase::Instruction)
3723                        && (matches!(name.as_ref(), "altChunk" | "subDoc")
3724                            || (paragraph.run.as_ref().is_some_and(|run| !run.hidden)
3725                                && matches!(
3726                                    name.as_ref(),
3727                                    "sym"
3728                                        | "drawing"
3729                                        | "pict"
3730                                        | "object"
3731                                        | "annotationRef"
3732                                        | "contentPart"
3733                                        | "tab"
3734                                        | "ptab"
3735                                        | "br"
3736                                        | "cr"
3737                                        | "lastRenderedPageBreak"
3738                                        | "noBreakHyphen"
3739                                        | "softHyphen"
3740                                        | "pgNum"
3741                                        | "dayShort"
3742                                        | "dayLong"
3743                                        | "monthShort"
3744                                        | "monthLong"
3745                                        | "yearShort"
3746                                        | "yearLong"
3747                                        | "footnoteReference"
3748                                        | "endnoteReference"
3749                                        | "commentReference"
3750                                        | "footnoteRef"
3751                                        | "endnoteRef"
3752                                        | "separator"
3753                                        | "continuationSeparator"
3754                                )))
3755                    {
3756                        for context in &mut sdt_stack {
3757                            if context
3758                                .content_depth
3759                                .is_some_and(|depth| element_depth > depth)
3760                            {
3761                                context.content_nonempty = true;
3762                            }
3763                        }
3764                    }
3765                }
3766                let logical_depth = element_depth - 2 * selected_branches.len();
3767                if wordprocessing
3768                    && matches!(
3769                        name.as_ref(),
3770                        "t" | "instrText"
3771                            | "delText"
3772                            | "delInstrText"
3773                            | "fldChar"
3774                            | "sym"
3775                            | "drawing"
3776                            | "pict"
3777                            | "object"
3778                            | "contentPart"
3779                            | "tab"
3780                            | "ptab"
3781                            | "br"
3782                            | "cr"
3783                            | "lastRenderedPageBreak"
3784                            | "noBreakHyphen"
3785                            | "softHyphen"
3786                            | "pgNum"
3787                            | "dayShort"
3788                            | "dayLong"
3789                            | "monthShort"
3790                            | "monthLong"
3791                            | "yearShort"
3792                            | "yearLong"
3793                            | "footnoteReference"
3794                            | "endnoteReference"
3795                            | "commentReference"
3796                            | "footnoteRef"
3797                            | "endnoteRef"
3798                            | "annotationRef"
3799                            | "separator"
3800                            | "continuationSeparator"
3801                    )
3802                    && paragraph
3803                        .run
3804                        .as_ref()
3805                        .is_some_and(|run| logical_depth != run.depth + 1)
3806                {
3807                    return Err(DocumentExtractionError::Malformed {
3808                        format: DocumentFormat::Docx,
3809                        message: "DOCX run content is not a direct child of its run".to_owned(),
3810                    });
3811                }
3812                if wordprocessing
3813                    && paragraph.run.is_some()
3814                    && matches!(name.as_ref(), "sectPr" | "altChunk" | "subDoc")
3815                {
3816                    return Err(DocumentExtractionError::Malformed {
3817                        format: DocumentFormat::Docx,
3818                        message: "DOCX non-run content appeared inside a run".to_owned(),
3819                    });
3820                }
3821                if wordprocessing
3822                    && matches!(name.as_ref(), "commentRangeStart" | "commentRangeEnd")
3823                    && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
3824                    && deleted_depth.is_none()
3825                    && !paragraph.fields.contains(&DocxFieldPhase::Instruction)
3826                {
3827                    return Err(DocumentExtractionError::Malformed {
3828                        format: DocumentFormat::Docx,
3829                        message: "DOCX comment range appeared inside a rendered run".to_owned(),
3830                    });
3831                }
3832                if wordprocessing && let Some(run) = paragraph.run.as_mut() {
3833                    if logical_depth == run.depth + 1 {
3834                        if name.as_ref() == "rPr" {
3835                            if run.content_seen {
3836                                return Err(DocumentExtractionError::Malformed {
3837                                    format: DocumentFormat::Docx,
3838                                    message: "DOCX run properties follow run content".to_owned(),
3839                                });
3840                            }
3841                            run.properties_depth = Some(logical_depth);
3842                        } else {
3843                            run.content_seen = true;
3844                            if matches!(name.as_ref(), "drawing" | "pict" | "object") {
3845                                run.drawing_depth = Some(element_depth);
3846                            }
3847                        }
3848                    }
3849                    if name.as_ref() == "vanish"
3850                        && run.properties_depth == logical_depth.checked_sub(1)
3851                        && deleted_depth.is_none()
3852                    {
3853                        run.hidden |= docx_on_off(&event, &reader)?;
3854                    }
3855                    if name.as_ref() == "specVanish"
3856                        && run.properties_depth == logical_depth.checked_sub(1)
3857                        && deleted_depth.is_none()
3858                    {
3859                        run.spec_vanish |= docx_on_off(&event, &reader)?;
3860                    }
3861                }
3862                match if wordprocessing { name.as_ref() } else { "" } {
3863                    "sectPr" if deleted_depth.is_none() => {
3864                        if root_name != "document"
3865                            || !matches!(
3866                                section_ancestors[parent_depth],
3867                                DocxSectionAncestor::Body
3868                                    | DocxSectionAncestor::ParagraphProperties
3869                            )
3870                        {
3871                            return Err(DocumentExtractionError::Malformed {
3872                                format: DocumentFormat::Docx,
3873                                message: "DOCX section properties have invalid ancestry".to_owned(),
3874                            });
3875                        }
3876                        if header_section.is_some() {
3877                            return Err(DocumentExtractionError::Malformed {
3878                                format: DocumentFormat::Docx,
3879                                message: "DOCX section properties cannot nest".to_owned(),
3880                            });
3881                        }
3882                        header_section = Some(DocxHeaderSection {
3883                            depth: element_depth,
3884                            logical_depth,
3885                            title_page: None,
3886                            links: Vec::new(),
3887                        });
3888                    }
3889                    "titlePg" if deleted_depth.is_none() => {
3890                        let section = header_section
3891                            .as_mut()
3892                            .filter(|section| logical_depth == section.logical_depth + 1)
3893                            .ok_or_else(|| DocumentExtractionError::Malformed {
3894                                format: DocumentFormat::Docx,
3895                                message: "DOCX title-page setting is outside section properties"
3896                                    .to_owned(),
3897                            })?;
3898                        if section
3899                            .title_page
3900                            .replace(docx_on_off(&event, &reader)?)
3901                            .is_some()
3902                        {
3903                            return Err(DocumentExtractionError::Malformed {
3904                                format: DocumentFormat::Docx,
3905                                message: "DOCX section has duplicate title-page settings"
3906                                    .to_owned(),
3907                            });
3908                        }
3909                    }
3910                    "fldSimple" if deleted_depth.is_none() => {
3911                        if !paragraph.open || !direct_run_parents[parent_depth] {
3912                            return Err(DocumentExtractionError::Malformed {
3913                                format: DocumentFormat::Docx,
3914                                message: "DOCX simple field is outside rendered paragraph content"
3915                                    .to_owned(),
3916                            });
3917                        }
3918                        simple_fields.push(DocxSimpleField {
3919                            depth: element_depth,
3920                            has_cached_text: false,
3921                            in_instruction: paragraph.fields.contains(&DocxFieldPhase::Instruction),
3922                        });
3923                    }
3924                    "sdt" if deleted_depth.is_none() => sdt_stack.push(DocxSdtContext {
3925                        depth: element_depth,
3926                        placeholder: false,
3927                        showing: false,
3928                        content_depth: None,
3929                        content_nonempty: false,
3930                    }),
3931                    "docPart" if sdt_tags[parent_depth] == DocxSdtTag::Placeholder => {
3932                        let name =
3933                            docx_attribute(&event, &reader, "val", wordprocessing_namespace)?
3934                                .ok_or_else(|| DocumentExtractionError::Malformed {
3935                                    format: DocumentFormat::Docx,
3936                                    message: "DOCX placeholder docPart is missing w:val".to_owned(),
3937                                })?;
3938                        if name.is_empty() {
3939                            return Err(DocumentExtractionError::Malformed {
3940                                format: DocumentFormat::Docx,
3941                                message: "DOCX placeholder docPart name is empty".to_owned(),
3942                            });
3943                        }
3944                        if let Some(context) = sdt_stack.last_mut() {
3945                            context.placeholder = true;
3946                        }
3947                    }
3948                    "showingPlcHdr" if sdt_tags[parent_depth] == DocxSdtTag::Properties => {
3949                        if let Some(context) = sdt_stack.last_mut() {
3950                            context.showing = docx_on_off(&event, &reader)?;
3951                        }
3952                    }
3953                    "sdtContent" if sdt_tags[element_depth] == DocxSdtTag::Content => {
3954                        if let Some(context) = sdt_stack.last_mut() {
3955                            context.content_depth = Some(element_depth);
3956                        }
3957                    }
3958                    "headerReference" | "footerReference" if deleted_depth.is_none() => {
3959                        let kind = if name.as_ref() == "headerReference" {
3960                            DocxStoryKind::Header
3961                        } else {
3962                            DocxStoryKind::Footer
3963                        };
3964                        let id =
3965                            docx_attribute(&event, &reader, "id", office_relationship_namespace)?
3966                                .ok_or_else(|| DocumentExtractionError::Malformed {
3967                                format: DocumentFormat::Docx,
3968                                message: "DOCX header/footer reference is missing r:id".to_owned(),
3969                            })?;
3970                        let variant = match docx_attribute(
3971                            &event,
3972                            &reader,
3973                            "type",
3974                            wordprocessing_namespace,
3975                        )?
3976                        .as_deref()
3977                        {
3978                            None | Some("default") => DocxHeaderVariant::Default,
3979                            Some("first") => DocxHeaderVariant::First,
3980                            Some("even") => DocxHeaderVariant::Even,
3981                            Some(value) => {
3982                                return Err(DocumentExtractionError::Malformed {
3983                                    format: DocumentFormat::Docx,
3984                                    message: format!(
3985                                        "DOCX header/footer type {value:?} is invalid"
3986                                    ),
3987                                });
3988                            }
3989                        };
3990                        let section = header_section
3991                            .as_mut()
3992                            .filter(|section| logical_depth == section.logical_depth + 1)
3993                            .ok_or_else(|| DocumentExtractionError::Malformed {
3994                                format: DocumentFormat::Docx,
3995                                message:
3996                                    "DOCX header/footer reference is not a direct section child"
3997                                        .to_owned(),
3998                            })?;
3999                        if section
4000                            .links
4001                            .iter()
4002                            .any(|(existing_kind, _, existing_variant)| {
4003                                *existing_kind == kind && *existing_variant == variant
4004                            })
4005                        {
4006                            return Err(DocumentExtractionError::Malformed {
4007                                format: DocumentFormat::Docx,
4008                                message: "DOCX section has a duplicate header/footer variant"
4009                                    .to_owned(),
4010                            });
4011                        }
4012                        section.links.push((kind, id, variant));
4013                    }
4014                    "subDoc"
4015                        if deleted_depth.is_none()
4016                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4017                    {
4018                        if !direct_run_parents[parent_depth] {
4019                            return Err(DocumentExtractionError::Malformed {
4020                                format: DocumentFormat::Docx,
4021                                message:
4022                                    "DOCX subdocument anchor is outside rendered paragraph content"
4023                                        .to_owned(),
4024                            });
4025                        }
4026                        let id =
4027                            docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4028                                .ok_or_else(|| DocumentExtractionError::Malformed {
4029                                format: DocumentFormat::Docx,
4030                                message: "DOCX subdocument reference is missing r:id".to_owned(),
4031                            })?;
4032                        references
4033                            .linked_parts
4034                            .push((DocxStoryKind::Subdocument, id));
4035                        if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4036                            gaps.push(DocumentCoverageGap::UnexaminedStory);
4037                        }
4038                    }
4039                    "altChunk"
4040                        if deleted_depth.is_none()
4041                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4042                    {
4043                        if !import_anchor_parents[parent_depth] {
4044                            return Err(DocumentExtractionError::Malformed {
4045                                format: DocumentFormat::Docx,
4046                                message:
4047                                    "DOCX alternate-format import is outside rendered block content"
4048                                        .to_owned(),
4049                            });
4050                        }
4051                        let id =
4052                            docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4053                                .ok_or_else(|| DocumentExtractionError::Malformed {
4054                                format: DocumentFormat::Docx,
4055                                message: "DOCX alternate-format import is missing r:id".to_owned(),
4056                            })?;
4057                        references.imported_parts.push(id);
4058                        if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4059                            gaps.push(DocumentCoverageGap::UnexaminedStory);
4060                        }
4061                    }
4062                    "ruby" => {
4063                        if deleted_depth.is_some() {
4064                            skipped_branch_depth = Some(element_depth);
4065                        } else {
4066                            return Err(DocumentExtractionError::UnsupportedDocxInput {
4067                                message: "ruby annotations require unsupported nested run decoding"
4068                                    .to_owned(),
4069                            });
4070                        }
4071                    }
4072                    "del" | "moveFrom" | "sectPrChange" if deleted_depth.is_none() => {
4073                        deleted_depth = Some(element_depth);
4074                    }
4075                    "txbxContent" => {
4076                        if !text_box_content_parents[parent_depth] {
4077                            return Err(DocumentExtractionError::Malformed {
4078                                format: DocumentFormat::Docx,
4079                                message: "DOCX text box has no supported drawing parent".to_owned(),
4080                            });
4081                        }
4082                        if !paragraph.run.as_ref().is_some_and(|run| {
4083                            run.drawing_depth.is_some() && run.properties_depth.is_none()
4084                        }) {
4085                            return Err(DocumentExtractionError::Malformed {
4086                                format: DocumentFormat::Docx,
4087                                message: "DOCX text box has no rendered drawing owner".to_owned(),
4088                            });
4089                        }
4090                        if paragraph.run.as_ref().is_some_and(|run| run.hidden)
4091                            || paragraph.fields.contains(&DocxFieldPhase::Instruction)
4092                        {
4093                            skipped_branch_depth = Some(element_depth);
4094                            skipped_text_box = true;
4095                            continue;
4096                        }
4097                        if let Some(run) = paragraph.run.as_mut() {
4098                            publish_docx_run_fragment(
4099                                run,
4100                                part,
4101                                paragraph.number,
4102                                paragraph.run_number,
4103                                &mut output,
4104                                &mut output_line,
4105                                &mut facts,
4106                                symbols.len(),
4107                            )?;
4108                        }
4109                        text_boxes.push((
4110                            std::mem::take(&mut paragraph),
4111                            output.len(),
4112                            facts.len(),
4113                        ));
4114                    }
4115                    "p" if !paragraph.open => {
4116                        if block_ancestors[parent_depth] != DocxBlockAncestor::Content {
4117                            return Err(DocumentExtractionError::Malformed {
4118                                format: DocumentFormat::Docx,
4119                                message: "DOCX paragraph is outside rendered block content"
4120                                    .to_owned(),
4121                            });
4122                        }
4123                        paragraph.open = true;
4124                        paragraph.number = paragraph_number;
4125                        paragraph.run_number = 0;
4126                        if !output.is_empty() && deleted_depth.is_none() {
4127                            push_output_byte(&mut output, b'\n')?;
4128                            output_line += 1;
4129                        }
4130                    }
4131                    "r" if paragraph.open && paragraph.run.is_none() => {
4132                        if !direct_run_parents[parent_depth] {
4133                            return Err(DocumentExtractionError::Malformed {
4134                                format: DocumentFormat::Docx,
4135                                message: "DOCX run is outside rendered paragraph content"
4136                                    .to_owned(),
4137                            });
4138                        }
4139                        paragraph.run_number += 1;
4140                        paragraph.run = Some(RawDocxRun {
4141                            depth: logical_depth,
4142                            ..RawDocxRun::default()
4143                        });
4144                    }
4145                    "t" | "instrText" | "delText" | "delInstrText" if paragraph.run.is_some() => {
4146                        text_start = paragraph.run.as_ref().map_or(0, |run| run.text.len());
4147                        let ignored = deleted_depth.is_some()
4148                            || paragraph.run.as_ref().is_some_and(|run| run.hidden)
4149                            || matches!(name.as_ref(), "delText" | "delInstrText")
4150                            || paragraph.fields.contains(&DocxFieldPhase::Instruction);
4151                        text_carrier = Some(if ignored {
4152                            DocxTextCarrier::Ignored
4153                        } else {
4154                            DocxTextCarrier::Rendered
4155                        });
4156                    }
4157                    "fldChar" if paragraph.run.is_some() && deleted_depth.is_some() => {}
4158                    "fldChar" if paragraph.run.is_some() => {
4159                        let field_type = docx_attribute(
4160                            &event,
4161                            &reader,
4162                            "fldCharType",
4163                            wordprocessing_namespace,
4164                        )?;
4165                        match field_type.as_deref() {
4166                            Some("begin") => {
4167                                if paragraph.fields.len() >= MAX_DOCX_XML_DEPTH {
4168                                    return Err(DocumentExtractionError::ResourceLimit {
4169                                        limit: DocumentLimit::NestingDepth,
4170                                        observed: paragraph.fields.len() + 1,
4171                                        maximum: MAX_DOCX_XML_DEPTH,
4172                                    });
4173                                }
4174                                paragraph.fields.push(DocxFieldPhase::Instruction);
4175                            }
4176                            Some("separate") if !paragraph.fields.is_empty() => {
4177                                if let Some(phase) = paragraph.fields.last_mut() {
4178                                    *phase = DocxFieldPhase::Result {
4179                                        has_cached_text: false,
4180                                    };
4181                                }
4182                            }
4183                            Some("end") if !paragraph.fields.is_empty() => {
4184                                if paragraph.fields.pop()
4185                                    != Some(DocxFieldPhase::Result {
4186                                        has_cached_text: true,
4187                                    })
4188                                    && !paragraph.fields.contains(&DocxFieldPhase::Instruction)
4189                                    && !gaps.contains(&DocumentCoverageGap::UnevaluatedField)
4190                                {
4191                                    gaps.push(DocumentCoverageGap::UnevaluatedField);
4192                                }
4193                            }
4194                            _ => {
4195                                return Err(DocumentExtractionError::Malformed {
4196                                    format: DocumentFormat::Docx,
4197                                    message: "DOCX field marker has invalid type or nesting"
4198                                        .to_owned(),
4199                                });
4200                            }
4201                        }
4202                    }
4203                    "tab"
4204                    | "ptab"
4205                    | "br"
4206                    | "cr"
4207                    | "lastRenderedPageBreak"
4208                    | "noBreakHyphen"
4209                    | "softHyphen"
4210                        if deleted_depth.is_none()
4211                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4212                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4213                    {
4214                        if let Some(run) = paragraph.run.as_mut() {
4215                            mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4216                            append_docx_run_text(
4217                                run,
4218                                match name.as_ref() {
4219                                    "tab" | "ptab" => "\t",
4220                                    "noBreakHyphen" => "\u{2011}",
4221                                    "softHyphen" => "\u{00ad}",
4222                                    _ => "\n",
4223                                },
4224                                output.len(),
4225                            )?;
4226                        }
4227                    }
4228                    "pgNum" | "dayShort" | "dayLong" | "monthShort" | "monthLong" | "yearShort"
4229                    | "yearLong"
4230                        if paragraph.run.is_some()
4231                            && deleted_depth.is_none()
4232                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4233                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4234                    {
4235                        if let Some(run) = paragraph.run.as_mut() {
4236                            append_docx_run_text(run, "\u{fffc}", output.len())?;
4237                        }
4238                        if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4239                            gaps.push(DocumentCoverageGap::UnevaluatedField);
4240                        }
4241                    }
4242                    "contentPart"
4243                        if paragraph.run.is_some()
4244                            && deleted_depth.is_none()
4245                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4246                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4247                    {
4248                        let id =
4249                            docx_attribute(&event, &reader, "id", office_relationship_namespace)?
4250                                .ok_or_else(|| DocumentExtractionError::Malformed {
4251                                format: DocumentFormat::Docx,
4252                                message: "DOCX content part is missing r:id".to_owned(),
4253                            })?;
4254                        references.content_parts.push(id);
4255                        if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4256                            gaps.push(DocumentCoverageGap::UnexaminedStory);
4257                        }
4258                    }
4259                    "footnoteRef" | "endnoteRef" | "annotationRef"
4260                        if paragraph.run.is_some()
4261                            && deleted_depth.is_none()
4262                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4263                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4264                    {
4265                        if (name.as_ref() == "footnoteRef" && root_name != "footnotes")
4266                            || (name.as_ref() == "endnoteRef" && root_name != "endnotes")
4267                            || (name.as_ref() == "annotationRef" && root_name != "comments")
4268                        {
4269                            return Err(DocumentExtractionError::Malformed {
4270                                format: DocumentFormat::Docx,
4271                                message: "DOCX story reference mark is outside its story"
4272                                    .to_owned(),
4273                            });
4274                        }
4275                        if let Some(run) = paragraph.run.as_mut() {
4276                            append_docx_run_text(run, "\u{fffc}", output.len())?;
4277                        }
4278                        if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4279                            gaps.push(DocumentCoverageGap::UnevaluatedField);
4280                        }
4281                    }
4282                    "footnoteReference" | "endnoteReference" | "commentReference"
4283                        if paragraph.run.is_some()
4284                            && deleted_depth.is_none()
4285                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4286                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4287                    {
4288                        let kind = match name.as_ref() {
4289                            "footnoteReference" => DocxStoryKind::Footnotes,
4290                            "endnoteReference" => DocxStoryKind::Endnotes,
4291                            _ => DocxStoryKind::Comments,
4292                        };
4293                        let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
4294                            .ok_or_else(|| DocumentExtractionError::Malformed {
4295                            format: DocumentFormat::Docx,
4296                            message: "DOCX note/comment reference is missing w:id".to_owned(),
4297                        })?;
4298                        let id = canonical_docx_story_id(&id)?;
4299                        references.items.push((kind, id));
4300                        if let Some(run) = paragraph.run.as_mut() {
4301                            append_docx_run_text(run, "\u{fffc}", output.len())?;
4302                        }
4303                        if !gaps.contains(&DocumentCoverageGap::UnevaluatedField) {
4304                            gaps.push(DocumentCoverageGap::UnevaluatedField);
4305                        }
4306                    }
4307                    "commentRangeStart" | "commentRangeEnd"
4308                        if deleted_depth.is_none()
4309                            && !paragraph.run.as_ref().is_some_and(|run| run.hidden)
4310                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4311                    {
4312                        if !comment_range_parents[parent_depth] {
4313                            return Err(DocumentExtractionError::Malformed {
4314                                format: DocumentFormat::Docx,
4315                                message: "DOCX comment range is outside rendered content"
4316                                    .to_owned(),
4317                            });
4318                        }
4319                        let id = docx_attribute(&event, &reader, "id", wordprocessing_namespace)?
4320                            .ok_or_else(|| DocumentExtractionError::Malformed {
4321                            format: DocumentFormat::Docx,
4322                            message: "DOCX comment range is missing w:id".to_owned(),
4323                        })?;
4324                        // The range is only an anchor; commentReference selects the story.
4325                        canonical_docx_story_id(&id)?;
4326                    }
4327                    "sym"
4328                        if paragraph.run.is_some()
4329                            && deleted_depth.is_none()
4330                            && paragraph.run.as_ref().is_some_and(|run| !run.hidden)
4331                            && !paragraph.fields.contains(&DocxFieldPhase::Instruction) =>
4332                    {
4333                        let mut font = None;
4334                        let mut code = None;
4335                        for (index, attribute) in event.attributes().enumerate() {
4336                            check_parser_iteration(index, &mut || control.check(stage))?;
4337                            let attribute =
4338                                attribute.map_err(|error| DocumentExtractionError::Malformed {
4339                                    format: DocumentFormat::Docx,
4340                                    message: error.to_string(),
4341                                })?;
4342                            let (namespace, local) =
4343                                reader.resolver().resolve_attribute(attribute.key);
4344                            if !matches!(namespace, ResolveResult::Bound(namespace)
4345                                if wordprocessing_namespace(namespace.as_ref()))
4346                            {
4347                                continue;
4348                            }
4349                            let value =
4350                                quick_xml::escape::unescape(&attribute.value).map_err(|error| {
4351                                    DocumentExtractionError::Malformed {
4352                                        format: DocumentFormat::Docx,
4353                                        message: error.to_string(),
4354                                    }
4355                                })?;
4356                            match local.as_ref() {
4357                                "font" => {
4358                                    if font.is_some() {
4359                                        return Err(DocumentExtractionError::Malformed {
4360                                            format: DocumentFormat::Docx,
4361                                            message: "DOCX font attribute is duplicated".to_owned(),
4362                                        });
4363                                    }
4364                                    if value.len() > MAX_DOCX_FONT_NAME_BYTES {
4365                                        return Err(DocumentExtractionError::ResourceLimit {
4366                                            limit: DocumentLimit::MemoryBytes,
4367                                            observed: value.len(),
4368                                            maximum: MAX_DOCX_FONT_NAME_BYTES,
4369                                        });
4370                                    }
4371                                    font = Some(value.into_owned());
4372                                }
4373                                "char" => {
4374                                    if code.is_some() {
4375                                        return Err(DocumentExtractionError::Malformed {
4376                                            format: DocumentFormat::Docx,
4377                                            message: "DOCX char attribute is duplicated".to_owned(),
4378                                        });
4379                                    }
4380                                    if value.len() != 4
4381                                        || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
4382                                    {
4383                                        return Err(DocumentExtractionError::Malformed {
4384                                            format: DocumentFormat::Docx,
4385                                            message: "DOCX symbol code must be four hex digits"
4386                                                .to_owned(),
4387                                        });
4388                                    }
4389                                    code =
4390                                        Some(u16::from_str_radix(&value, 16).map_err(|error| {
4391                                            DocumentExtractionError::Malformed {
4392                                                format: DocumentFormat::Docx,
4393                                                message: error.to_string(),
4394                                            }
4395                                        })?);
4396                                }
4397                                _ => {}
4398                            }
4399                        }
4400                        if facts.len().saturating_add(symbols.len()) >= MAX_DOCUMENT_FACTS {
4401                            return Err(DocumentExtractionError::ResourceLimit {
4402                                limit: DocumentLimit::FactCount,
4403                                observed: facts.len().saturating_add(symbols.len()) + 1,
4404                                maximum: MAX_DOCUMENT_FACTS,
4405                            });
4406                        }
4407                        // Adobe's Symbol encoding maps byte 0x61 to Greek alpha;
4408                        // arbitrary font-private codes have no portable Unicode meaning.
4409                        let unicode = match (font.as_deref(), code) {
4410                            (Some(name), Some(0x0061 | 0xF061))
4411                                if name.eq_ignore_ascii_case("Symbol") =>
4412                            {
4413                                Some('α')
4414                            }
4415                            _ => None,
4416                        };
4417                        let Some(run) = paragraph.run.as_mut() else {
4418                            return Err(DocumentExtractionError::Malformed {
4419                                format: DocumentFormat::Docx,
4420                                message: "DOCX symbol appeared outside an active run".to_owned(),
4421                            });
4422                        };
4423                        let line =
4424                            output_line + run.text.bytes().filter(|byte| *byte == b'\n').count();
4425                        let text_start = run.text_start + run.text.len();
4426                        let rendered = unicode.unwrap_or('\u{fffc}');
4427                        mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4428                        append_docx_run_text(run, &rendered.to_string(), output.len())?;
4429                        symbols.push(DocumentSymbol {
4430                            font,
4431                            code,
4432                            locator: DocumentLocator::Docx {
4433                                part: part.to_owned(),
4434                                paragraph: paragraph.number,
4435                                run: paragraph.run_number,
4436                                text_start,
4437                                text_end: text_start + rendered.len_utf8(),
4438                            },
4439                            line,
4440                            unicode,
4441                        });
4442                        if unicode.is_none()
4443                            && !gaps.contains(&DocumentCoverageGap::UnknownSymbolMapping)
4444                        {
4445                            gaps.push(DocumentCoverageGap::UnknownSymbolMapping);
4446                        }
4447                    }
4448                    "p" | "r" | "t" | "instrText" | "delText" | "delInstrText" | "fldChar" => {
4449                        return Err(DocumentExtractionError::Malformed {
4450                            format: DocumentFormat::Docx,
4451                            message: "DOCX paragraph, run, or text nesting is invalid".to_owned(),
4452                        });
4453                    }
4454                    _ => {}
4455                }
4456                let reference_count = references.count().saturating_add(
4457                    header_section
4458                        .as_ref()
4459                        .map_or(0, |section| section.links.len()),
4460                );
4461                if reference_count > MAX_DOCUMENT_FACTS {
4462                    return Err(DocumentExtractionError::ResourceLimit {
4463                        limit: DocumentLimit::FactCount,
4464                        observed: reference_count,
4465                        maximum: MAX_DOCUMENT_FACTS,
4466                    });
4467                }
4468            }
4469            Event::Text(event) => {
4470                if skipped_branch_depth.is_some() {
4471                    continue;
4472                }
4473                if text_carrier.is_some() {
4474                    let Some(run) = paragraph.run.as_mut() else {
4475                        return Err(DocumentExtractionError::Malformed {
4476                            format: DocumentFormat::Docx,
4477                            message: "text appeared outside a run".to_owned(),
4478                        });
4479                    };
4480                    if text_carrier == Some(DocxTextCarrier::Rendered) {
4481                        append_docx_run_text(run, event.xml10_content().as_ref(), output.len())?;
4482                    }
4483                } else if !event.as_ref().chars().all(xml_whitespace) {
4484                    return Err(DocumentExtractionError::Malformed {
4485                        format: DocumentFormat::Docx,
4486                        message: "DOCX XML contained text outside its document root".to_owned(),
4487                    });
4488                }
4489            }
4490            Event::CData(event) => {
4491                if skipped_branch_depth.is_some() {
4492                    continue;
4493                }
4494                if text_carrier.is_none() {
4495                    if root_seen && !root_closed && event.as_ref().chars().all(xml_whitespace) {
4496                        continue;
4497                    }
4498                    return Err(DocumentExtractionError::Malformed {
4499                        format: DocumentFormat::Docx,
4500                        message: "CDATA appeared outside a run".to_owned(),
4501                    });
4502                }
4503                let Some(run) = paragraph.run.as_mut() else {
4504                    return Err(DocumentExtractionError::Malformed {
4505                        format: DocumentFormat::Docx,
4506                        message: "CDATA appeared outside a run".to_owned(),
4507                    });
4508                };
4509                if text_carrier == Some(DocxTextCarrier::Rendered) {
4510                    append_docx_run_text(run, event.xml10_content().as_ref(), output.len())?;
4511                }
4512            }
4513            Event::GeneralRef(reference) => {
4514                let text = decode_docx_reference(&reference)?;
4515                if skipped_branch_depth.is_some() {
4516                    continue;
4517                }
4518                if text_carrier.is_none() {
4519                    if root_seen && !root_closed && text.chars().all(xml_whitespace) {
4520                        continue;
4521                    }
4522                    return Err(DocumentExtractionError::Malformed {
4523                        format: DocumentFormat::Docx,
4524                        message: "entity appeared outside a text run".to_owned(),
4525                    });
4526                }
4527                let Some(run) = paragraph.run.as_mut() else {
4528                    return Err(DocumentExtractionError::Malformed {
4529                        format: DocumentFormat::Docx,
4530                        message: "entity appeared outside a run".to_owned(),
4531                    });
4532                };
4533                if text_carrier == Some(DocxTextCarrier::Rendered) {
4534                    append_docx_run_text(run, &text, output.len())?;
4535                }
4536            }
4537            Event::PI(instruction) if instruction.target().eq_ignore_ascii_case("xml") => {
4538                return Err(DocumentExtractionError::Malformed {
4539                    format: DocumentFormat::Docx,
4540                    message: "DOCX XML processing instruction uses the reserved XML target"
4541                        .to_owned(),
4542                });
4543            }
4544            Event::DocType(_) => {
4545                return Err(DocumentExtractionError::Malformed {
4546                    format: DocumentFormat::Docx,
4547                    message: "DOCX XML DOCTYPE and external declarations are unsupported"
4548                        .to_owned(),
4549                });
4550            }
4551            Event::End(event) => {
4552                if element_depth == 0 {
4553                    return Err(DocumentExtractionError::Malformed {
4554                        format: DocumentFormat::Docx,
4555                        message: "DOCX XML contained an unmatched closing element".to_owned(),
4556                    });
4557                }
4558                if foreign_opaque_depth == Some(element_depth) {
4559                    foreign_opaque_depth = None;
4560                }
4561                if foreign_depth == Some(element_depth) {
4562                    foreign_depth = None;
4563                }
4564                if let Some(depth) = skipped_branch_depth {
4565                    if element_depth == depth {
4566                        skipped_branch_depth = None;
4567                        skipped_text_box = false;
4568                    }
4569                    sdt_tags[element_depth] = DocxSdtTag::Other;
4570                    element_depth -= 1;
4571                    continue;
4572                }
4573                if compatibility && event.local_name().as_ref() == "AlternateContent" {
4574                    let alternative = alternatives.pop().filter(|alternative| {
4575                        alternative.depth == element_depth && alternative.choice_seen
4576                    });
4577                    if alternative.is_none() {
4578                        return Err(DocumentExtractionError::Malformed {
4579                            format: DocumentFormat::Docx,
4580                            message: "DOCX compatibility alternatives require at least one choice"
4581                                .to_owned(),
4582                        });
4583                    }
4584                }
4585                if compatibility
4586                    && matches!(event.local_name().as_ref(), "Choice" | "Fallback")
4587                    && selected_branches.last() == Some(&element_depth)
4588                {
4589                    selected_branches.pop();
4590                }
4591                let name = event.local_name();
4592                if wordprocessing
4593                    && name.as_ref() == "fldSimple"
4594                    && simple_fields
4595                        .last()
4596                        .is_some_and(|field| field.depth == element_depth)
4597                    && let Some(field) = simple_fields.pop()
4598                    && !field.has_cached_text
4599                    && !field.in_instruction
4600                    && !gaps.contains(&DocumentCoverageGap::UnevaluatedField)
4601                {
4602                    gaps.push(DocumentCoverageGap::UnevaluatedField);
4603                }
4604                if wordprocessing
4605                    && name.as_ref() == "sdt"
4606                    && sdt_stack
4607                        .last()
4608                        .is_some_and(|context| context.depth == element_depth)
4609                {
4610                    let Some(context) = sdt_stack.pop() else {
4611                        return Err(DocumentExtractionError::Malformed {
4612                            format: DocumentFormat::Docx,
4613                            message: "DOCX structured tag state is incomplete".to_owned(),
4614                        });
4615                    };
4616                    if !paragraph.fields.contains(&DocxFieldPhase::Instruction)
4617                        && context.placeholder
4618                        && (context.showing || !context.content_nonempty)
4619                    {
4620                        references.glossary_placeholder = true;
4621                        if !gaps.contains(&DocumentCoverageGap::UnexaminedStory) {
4622                            gaps.push(DocumentCoverageGap::UnexaminedStory);
4623                        }
4624                    }
4625                }
4626                if deleted_depth == Some(element_depth) {
4627                    deleted_depth = None;
4628                }
4629                if wordprocessing
4630                    && name.as_ref() == "rPr"
4631                    && let Some(run) = paragraph.run.as_mut()
4632                    && run.properties_depth == Some(element_depth - 2 * selected_branches.len())
4633                {
4634                    run.properties_depth = None;
4635                    if run.spec_vanish
4636                        && !run.hidden
4637                        && !gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)
4638                    {
4639                        gaps.push(DocumentCoverageGap::UnresolvedVisibility);
4640                    }
4641                }
4642                if wordprocessing
4643                    && matches!(name.as_ref(), "drawing" | "pict" | "object")
4644                    && let Some(run) = paragraph.run.as_mut()
4645                    && run.drawing_depth == Some(element_depth)
4646                {
4647                    run.drawing_depth = None;
4648                }
4649                if wordprocessing
4650                    && name.as_ref() == "sectPr"
4651                    && header_section
4652                        .as_ref()
4653                        .is_some_and(|section| section.depth == element_depth)
4654                {
4655                    let Some(section) = header_section.take() else {
4656                        return Err(DocumentExtractionError::Malformed {
4657                            format: DocumentFormat::Docx,
4658                            message: "DOCX section state is incomplete".to_owned(),
4659                        });
4660                    };
4661                    for (kind, id, variant) in section.links {
4662                        let index = usize::from(kind == DocxStoryKind::Footer);
4663                        match variant {
4664                            DocxHeaderVariant::Default => {
4665                                inherited_default[index] = true;
4666                                references.has_default_header_footer = true;
4667                                references.linked_parts.push((kind, id));
4668                            }
4669                            DocxHeaderVariant::First => {
4670                                inherited_first[index] = Some(id);
4671                            }
4672                            DocxHeaderVariant::Even => references.even_parts.push((kind, id)),
4673                        }
4674                    }
4675                    if section.title_page.unwrap_or(false) {
4676                        if inherited_default.contains(&true)
4677                            && !gaps.contains(&DocumentCoverageGap::ConditionalStory)
4678                        {
4679                            gaps.push(DocumentCoverageGap::ConditionalStory);
4680                        }
4681                        for (index, id) in inherited_first.iter().enumerate() {
4682                            if let Some(id) = id {
4683                                let kind = if index == 0 {
4684                                    DocxStoryKind::Header
4685                                } else {
4686                                    DocxStoryKind::Footer
4687                                };
4688                                references.linked_parts.push((kind, id.clone()));
4689                            }
4690                        }
4691                    }
4692                }
4693                match if wordprocessing { name.as_ref() } else { "" } {
4694                    "t" | "instrText" | "delText" | "delInstrText" => {
4695                        if text_carrier == Some(DocxTextCarrier::Rendered)
4696                            && !preserve_space[element_depth]
4697                            && let Some(run) = paragraph.run.as_mut()
4698                        {
4699                            // Trim the complete leaf, not individual XML text/entity/CDATA events.
4700                            let text = &run.text[text_start..];
4701                            let trimmed = text.trim_matches([' ', '\t', '\r', '\n']);
4702                            let leading =
4703                                text.len() - text.trim_start_matches([' ', '\t', '\r', '\n']).len();
4704                            let end = text_start + leading + trimmed.len();
4705                            run.text.truncate(end);
4706                            run.text.drain(text_start..text_start + leading);
4707                        }
4708                        if text_carrier == Some(DocxTextCarrier::Rendered)
4709                            && paragraph
4710                                .run
4711                                .as_ref()
4712                                .is_some_and(|run| run.text.len() > text_start)
4713                        {
4714                            mark_docx_cached_result(&mut paragraph.fields, &mut simple_fields);
4715                            for context in &mut sdt_stack {
4716                                if context.content_depth.is_some() {
4717                                    context.content_nonempty = true;
4718                                }
4719                            }
4720                        }
4721                        text_carrier = None;
4722                    }
4723                    "r" => {
4724                        if let Some(mut run) = paragraph.run.take() {
4725                            publish_docx_run_fragment(
4726                                &mut run,
4727                                part,
4728                                paragraph.number,
4729                                paragraph.run_number,
4730                                &mut output,
4731                                &mut output_line,
4732                                &mut facts,
4733                                symbols.len(),
4734                            )?;
4735                        }
4736                    }
4737                    "txbxContent" => {
4738                        if !paragraph.fields.is_empty() {
4739                            return Err(DocumentExtractionError::Malformed {
4740                                format: DocumentFormat::Docx,
4741                                message: "DOCX text box ended inside an incomplete field"
4742                                    .to_owned(),
4743                            });
4744                        }
4745                        let Some((mut outer, previous_bytes, previous_facts)) = text_boxes.pop()
4746                        else {
4747                            return Err(DocumentExtractionError::Malformed {
4748                                format: DocumentFormat::Docx,
4749                                message: "DOCX text box had no matching container".to_owned(),
4750                            });
4751                        };
4752                        if facts.len() > previous_facts {
4753                            mark_docx_cached_result(&mut outer.fields, &mut simple_fields);
4754                        }
4755                        paragraph = outer;
4756                        if output.len() > previous_bytes && !output.ends_with('\n') {
4757                            push_output_byte(&mut output, b'\n')?;
4758                            output_line += 1;
4759                        }
4760                    }
4761                    "p" => paragraph.open = false,
4762                    _ => {}
4763                }
4764                if wordprocessing
4765                    && matches!(name.as_ref(), "footnote" | "endnote")
4766                    && element_depth == 2 + 2 * selected_branches.len()
4767                    && let Some((fact_count, symbol_count, reference_count)) =
4768                        conditional_note_start.take()
4769                    && (facts.len() > fact_count
4770                        || symbols.len() > symbol_count
4771                        || references.count() > reference_count)
4772                    && !gaps.contains(&DocumentCoverageGap::ConditionalStory)
4773                {
4774                    gaps.push(DocumentCoverageGap::ConditionalStory);
4775                }
4776                sdt_tags[element_depth] = DocxSdtTag::Other;
4777                element_depth -= 1;
4778                if element_depth == 0 {
4779                    root_closed = true;
4780                }
4781            }
4782            Event::Eof => break,
4783            _ => {}
4784        }
4785    }
4786    if !root_seen
4787        || !root_closed
4788        || (root_name == "document" && !main_body_seen && !main_body_unexamined)
4789        || element_depth != 0
4790        || paragraph.open
4791        || !text_boxes.is_empty()
4792        || !alternatives.is_empty()
4793        || !selected_branches.is_empty()
4794        || skipped_branch_depth.is_some()
4795        || conditional_note_start.is_some()
4796        || deleted_depth.is_some()
4797        || foreign_depth.is_some()
4798        || foreign_opaque_depth.is_some()
4799        || paragraph.run.is_some()
4800        || text_carrier.is_some()
4801        || !paragraph.fields.is_empty()
4802        || !simple_fields.is_empty()
4803        || !sdt_stack.is_empty()
4804        || header_section.is_some()
4805    {
4806        return Err(DocumentExtractionError::Malformed {
4807            format: DocumentFormat::Docx,
4808            message: "DOCX XML ended before all elements were closed".to_owned(),
4809        });
4810    }
4811    if let Some(selected_ids) = selected_ids
4812        && !selected_ids.is_subset(&seen_ids)
4813    {
4814        return Err(DocumentExtractionError::InvalidDocxPackage {
4815            message: format!("referenced DOCX story item is absent from {part}"),
4816        });
4817    }
4818    if let Some(selected_special_ids) = selected_special_ids
4819        && !selected_special_ids.is_subset(&seen_special_ids)
4820    {
4821        return Err(DocumentExtractionError::InvalidDocxPackage {
4822            message: format!("listed DOCX special note is absent from {part}"),
4823        });
4824    }
4825    Ok(DocumentFacts {
4826        format: DocumentFormat::Docx,
4827        text: output,
4828        facts,
4829        symbols,
4830        completeness: if gaps.is_empty() {
4831            DocumentCompleteness::Complete
4832        } else {
4833            DocumentCompleteness::Partial { gaps }
4834        },
4835        provenance: DocumentParserProvenance::QuickXml,
4836    })
4837}
4838
4839/// Emit a run fragment before leaving its container, preserving its original byte locator.
4840fn publish_docx_run_fragment(
4841    run: &mut RawDocxRun,
4842    part: &str,
4843    paragraph: usize,
4844    run_number: usize,
4845    output: &mut String,
4846    output_line: &mut usize,
4847    facts: &mut Vec<DocumentFact>,
4848    symbol_count: usize,
4849) -> Result<(), DocumentExtractionError> {
4850    if run.text.is_empty() {
4851        return Ok(());
4852    }
4853    let observed = facts.len().saturating_add(symbol_count).saturating_add(1);
4854    if observed > MAX_DOCUMENT_FACTS {
4855        return Err(DocumentExtractionError::ResourceLimit {
4856            limit: DocumentLimit::FactCount,
4857            observed,
4858            maximum: MAX_DOCUMENT_FACTS,
4859        });
4860    }
4861    let required = output.len().saturating_add(run.text.len());
4862    if required > MAX_DOCUMENT_OUTPUT_BYTES {
4863        return Err(DocumentExtractionError::ResourceLimit {
4864            limit: DocumentLimit::OutputBytes,
4865            observed: required,
4866            maximum: MAX_DOCUMENT_OUTPUT_BYTES,
4867        });
4868    }
4869    let text = std::mem::take(&mut run.text);
4870    let line_start = *output_line;
4871    *output_line += text.bytes().filter(|byte| *byte == b'\n').count();
4872    // A terminal newline does not create another occupied slice line.
4873    let line_end = *output_line - usize::from(text.ends_with('\n'));
4874    output.push_str(&text);
4875    let text_end = run.text_start + text.len();
4876    facts.push(DocumentFact {
4877        line_start,
4878        line_end,
4879        text,
4880        locator: DocumentLocator::Docx {
4881            part: part.to_owned(),
4882            paragraph,
4883            run: run_number,
4884            text_start: run.text_start,
4885            text_end,
4886        },
4887    });
4888    run.text_start = text_end;
4889    Ok(())
4890}
4891
4892/// Append decoded XML text while bounding one retained run before publication.
4893fn append_docx_run_text(
4894    run: &mut RawDocxRun,
4895    text: &str,
4896    published_bytes: usize,
4897) -> Result<(), DocumentExtractionError> {
4898    let required = published_bytes
4899        .saturating_add(run.text.len())
4900        .saturating_add(text.len());
4901    if required > MAX_DOCUMENT_OUTPUT_BYTES {
4902        return Err(DocumentExtractionError::ResourceLimit {
4903            limit: DocumentLimit::OutputBytes,
4904            observed: required,
4905            maximum: MAX_DOCUMENT_OUTPUT_BYTES,
4906        });
4907    }
4908    run.text.push_str(text);
4909    Ok(())
4910}
4911
4912/// Decode only XML predefined and character references; external entities are never resolved.
4913fn decode_docx_reference(reference: &BytesRef<'_>) -> Result<String, DocumentExtractionError> {
4914    if let Some(character) =
4915        reference
4916            .resolve_char_ref()
4917            .map_err(|error| DocumentExtractionError::Malformed {
4918                format: DocumentFormat::Docx,
4919                message: error.to_string(),
4920            })?
4921    {
4922        if !xml10_character(character) {
4923            return Err(DocumentExtractionError::Malformed {
4924                format: DocumentFormat::Docx,
4925                message: "DOCX XML contained an invalid character reference".to_owned(),
4926            });
4927        }
4928        return Ok(character.to_string());
4929    }
4930    match reference.as_ref() {
4931        "amp" => Ok("&".to_owned()),
4932        "apos" => Ok("'".to_owned()),
4933        "gt" => Ok(">".to_owned()),
4934        "lt" => Ok("<".to_owned()),
4935        "quot" => Ok("\"".to_owned()),
4936        _ => Err(DocumentExtractionError::Malformed {
4937            format: DocumentFormat::Docx,
4938            message: "DOCX XML contained an unsupported entity reference".to_owned(),
4939        }),
4940    }
4941}
4942
4943#[cfg(test)]
4944#[allow(clippy::expect_used)]
4945mod tests {
4946    use super::*;
4947    use projectatlas_core::{IndexCancellation, IndexWorkControl};
4948    use std::fmt::Write as _;
4949    use std::io::Write;
4950    use std::time::{Duration, Instant};
4951    use zip::CompressionMethod;
4952    use zip::ZipWriter;
4953    use zip::write::SimpleFileOptions as FileOptions;
4954
4955    fn control() -> IndexWorkControl {
4956        IndexWorkControl::new(IndexCancellation::new(), None)
4957    }
4958
4959    fn docx_drawing_text_box(content: &str, id: u32) -> String {
4960        format!(
4961            concat!(
4962                "<w:drawing><wp:inline xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\">",
4963                "<wp:extent cx=\"914400\" cy=\"914400\"/><wp:docPr id=\"{id}\" name=\"Text box {id}\"/>",
4964                "<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\">",
4965                "<a:graphicData uri=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">",
4966                "<wps:wsp xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">",
4967                "<wps:cNvSpPr txBox=\"1\"/><wps:spPr><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></wps:spPr><wps:txbx><w:txbxContent>{content}</w:txbxContent></wps:txbx>",
4968                "<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing>"
4969            ),
4970            id = id,
4971            content = content
4972        )
4973    }
4974
4975    fn docx_archive(xml: &[u8], method: CompressionMethod) -> Vec<u8> {
4976        docx_archive_with_parts_and_method(&[(DOCX_DOCUMENT_PART, xml)], method)
4977    }
4978
4979    fn docx_archive_with_parts(parts: &[(&str, &[u8])]) -> Vec<u8> {
4980        docx_archive_with_parts_and_method(parts, CompressionMethod::Deflated)
4981    }
4982
4983    fn docx_archive_with_parts_and_method(
4984        parts: &[(&str, &[u8])],
4985        method: CompressionMethod,
4986    ) -> Vec<u8> {
4987        let mut bytes = Vec::new();
4988        {
4989            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
4990            let main = if parts.iter().any(|(name, _)| *name == DOCX_DOCUMENT_PART) {
4991                Some(DOCX_DOCUMENT_PART)
4992            } else if parts.iter().any(|(name, _)| *name == "document.xml") {
4993                Some("document.xml")
4994            } else {
4995                None
4996            };
4997            if !parts.iter().any(|(name, _)| *name == "[Content_Types].xml") {
4998                let mut manifest = String::from(
4999                    "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
5000                );
5001                for (name, _) in parts {
5002                    if !Path::new(name)
5003                        .extension()
5004                        .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
5005                    {
5006                        continue;
5007                    }
5008                    let suffix = if Some(*name) == main || name.ends_with("/child.xml") {
5009                        Some("document.main")
5010                    } else if name.contains("glossary") {
5011                        Some("document.glossary")
5012                    } else if name.contains("header")
5013                        || name.contains("first")
5014                        || name.contains("even")
5015                        || name.contains("default")
5016                        || name.contains("prior")
5017                    {
5018                        Some("header")
5019                    } else if name.contains("footer") {
5020                        Some("footer")
5021                    } else if name.contains("footnotes") {
5022                        Some("footnotes")
5023                    } else if name.contains("endnotes") {
5024                        Some("endnotes")
5025                    } else if name.contains("comments") {
5026                        Some("comments")
5027                    } else if name.contains("settings") {
5028                        Some("settings")
5029                    } else {
5030                        None
5031                    };
5032                    if let Some(suffix) = suffix {
5033                        write!(
5034                            &mut manifest,
5035                            "<Override PartName=\"/{name}\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.{suffix}+xml\"/>"
5036                        )
5037                        .expect("fixture manifest override");
5038                    }
5039                }
5040                manifest.push_str("</Types>");
5041                writer
5042                    .start_file("[Content_Types].xml", FileOptions::default())
5043                    .expect("fixture manifest");
5044                writer
5045                    .write_all(manifest.as_bytes())
5046                    .expect("fixture manifest XML");
5047            }
5048            if !parts.iter().any(|(name, _)| *name == "_rels/.rels")
5049                && let Some(main) = main
5050            {
5051                let rels = format!(
5052                    "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"main\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"{main}\"/></Relationships>"
5053                );
5054                writer
5055                    .start_file("_rels/.rels", FileOptions::default())
5056                    .expect("fixture root relationships");
5057                writer
5058                    .write_all(rels.as_bytes())
5059                    .expect("fixture root relationship XML");
5060            }
5061            for (name, xml) in parts {
5062                writer
5063                    .start_file(*name, FileOptions::default().compression_method(method))
5064                    .expect("fixture part");
5065                writer.write_all(xml).expect("fixture XML");
5066            }
5067            writer.finish().expect("fixture archive");
5068        }
5069        bytes
5070    }
5071
5072    fn rewrite_zip_method(bytes: &mut [u8], method: u16) {
5073        for index in 0..bytes.len().saturating_sub(4) {
5074            match &bytes[index..index + 4] {
5075                b"PK\x03\x04" => {
5076                    bytes[index + 8..index + 10].copy_from_slice(&method.to_le_bytes());
5077                }
5078                b"PK\x01\x02" => {
5079                    bytes[index + 10..index + 12].copy_from_slice(&method.to_le_bytes());
5080                }
5081                _ => {}
5082            }
5083        }
5084    }
5085
5086    #[test]
5087    fn document_admission_observes_queued_deadline_and_cancellation() {
5088        let lease = lock_document_execution(&control(), IndexWorkStage::TextIndex)
5089            .expect("initial document lease");
5090        let docx = docx_archive(
5091            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#,
5092            CompressionMethod::Stored,
5093        );
5094        for (path, bytes) in [
5095            ("guide.docx", docx.as_slice()),
5096            ("guide.pdf", minimal_pdf().as_slice()),
5097        ] {
5098            let waiting =
5099                IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_millis(100)));
5100            assert!(
5101                matches!(
5102                    extract_document_text_controlled(bytes, path, None, &waiting),
5103                    Err(DocumentExtractionError::Work(
5104                        IndexWorkFailure::DeadlineExceeded {
5105                            stage: IndexWorkStage::TextIndex
5106                        }
5107                    ))
5108                ),
5109                "{path} must wait for the shared document lease"
5110            );
5111        }
5112        let cancellation = IndexCancellation::new();
5113        let waiting = IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(5)));
5114        let cancel = std::thread::spawn(move || {
5115            std::thread::sleep(Duration::from_millis(20));
5116            cancellation.cancel();
5117        });
5118        let result = extract_document_text_controlled(&docx, "guide.docx", None, &waiting);
5119        cancel.join().expect("cancellation thread");
5120        assert!(matches!(
5121            result,
5122            Err(DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
5123                stage: IndexWorkStage::TextIndex
5124            }))
5125        ));
5126        drop(lease);
5127        assert!(extract_document_text_controlled(&docx, "guide.docx", None, &control()).is_ok());
5128    }
5129
5130    fn mark_zip_encrypted(bytes: &mut [u8]) {
5131        for index in 0..bytes.len().saturating_sub(4) {
5132            let flags = match &bytes[index..index + 4] {
5133                b"PK\x03\x04" => &mut bytes[index + 6..index + 8],
5134                b"PK\x01\x02" => &mut bytes[index + 8..index + 10],
5135                _ => continue,
5136            };
5137            let value = u16::from_le_bytes([flags[0], flags[1]]) | 1;
5138            flags.copy_from_slice(&value.to_le_bytes());
5139        }
5140    }
5141
5142    #[test]
5143    fn path_admission_is_case_insensitive_but_not_speculative() {
5144        assert_eq!(
5145            document_format_for_path("docs/guide.PDF", None),
5146            Some(DocumentFormat::Pdf)
5147        );
5148        assert_eq!(
5149            document_format_for_path("docs/guide.docx", Some("pdf")),
5150            Some(DocumentFormat::Pdf)
5151        );
5152        assert_eq!(document_format_for_path("docs/guide.doc", None), None);
5153        for language in ["text", "rust", "markdown", ""] {
5154            for path in ["docs/guide.pdf", "docs/guide.docx"] {
5155                assert_eq!(document_format_for_path(path, Some(language)), None);
5156            }
5157        }
5158    }
5159
5160    #[test]
5161    fn mismatched_magic_fails_closed_before_parser_work() {
5162        let error = extract_document_text_controlled(b"PK\x03\x04", "guide.pdf", None, &control())
5163            .expect_err("DOCX bytes must not enter the PDF parser");
5164        assert!(matches!(
5165            error,
5166            DocumentExtractionError::MismatchedMagic {
5167                expected: DocumentFormat::Pdf,
5168                found: "docx"
5169            }
5170        ));
5171    }
5172
5173    #[test]
5174    fn cancellation_is_observed_before_parser_work() {
5175        let cancellation = IndexCancellation::new();
5176        cancellation.cancel();
5177        let control = IndexWorkControl::new(cancellation, None);
5178        let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
5179            .expect_err("canceled work must not parse");
5180        assert!(matches!(
5181            error,
5182            DocumentExtractionError::Work(IndexWorkFailure::Cancelled {
5183                stage: IndexWorkStage::TextIndex
5184            })
5185        ));
5186    }
5187
5188    #[test]
5189    fn expired_document_deadline_is_observed_before_parser_work() {
5190        let control = IndexWorkControl::with_deadline(
5191            IndexCancellation::new(),
5192            Instant::now()
5193                .checked_sub(Duration::from_secs(1))
5194                .expect("current instant supports one-second subtraction"),
5195        );
5196        let error = extract_document_text_controlled(b"%PDF-", "guide.pdf", None, &control)
5197            .expect_err("expired work must not parse");
5198        assert!(matches!(
5199            error,
5200            DocumentExtractionError::Work(IndexWorkFailure::DeadlineExceeded {
5201                stage: IndexWorkStage::TextIndex
5202            })
5203        ));
5204    }
5205
5206    #[test]
5207    fn document_input_bytes_are_bounded_before_parser_work() {
5208        let mut bytes = vec![b'x'; MAX_DOCUMENT_COMPRESSED_BYTES + 1];
5209        bytes[..5].copy_from_slice(b"%PDF-");
5210        let error = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5211            .expect_err("oversized input must be rejected before parsing");
5212        assert!(matches!(
5213            error,
5214            DocumentExtractionError::ResourceLimit {
5215                limit: DocumentLimit::InputBytes,
5216                observed,
5217                maximum: MAX_DOCUMENT_COMPRESSED_BYTES
5218            } if observed == MAX_DOCUMENT_COMPRESSED_BYTES + 1
5219        ));
5220    }
5221
5222    pub(super) fn minimal_pdf() -> Vec<u8> {
5223        let objects = [
5224            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5225            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
5226            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5227            b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Hello PDF) Tj ET\nendstream\nendobj\n".as_slice(),
5228            b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5229        ];
5230        let mut pdf = b"%PDF-1.4\n".to_vec();
5231        let mut offsets = Vec::new();
5232        for object in objects {
5233            offsets.push(pdf.len());
5234            pdf.extend_from_slice(object);
5235        }
5236        let xref = pdf.len();
5237        pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5238        pdf.extend_from_slice(b"0000000000 65535 f \n");
5239        for offset in offsets {
5240            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5241        }
5242        pdf.extend_from_slice(
5243            format!(
5244                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5245                objects.len() + 1
5246            )
5247            .as_bytes(),
5248        );
5249        pdf
5250    }
5251
5252    pub(super) fn multi_page_pdf() -> Vec<u8> {
5253        let objects = [
5254            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5255            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>\nendobj\n".as_slice(),
5256            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5257            b"4 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page One) Tj ET\nendstream\nendobj\n".as_slice(),
5258            b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5259            b"6 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5260            b"7 0 obj\n<< /Length 39 >>\nstream\nBT /F1 12 Tf 72 720 Td (Page Two) Tj ET\nendstream\nendobj\n".as_slice(),
5261        ];
5262        let mut pdf = b"%PDF-1.4\n".to_vec();
5263        let mut offsets = Vec::new();
5264        for object in objects {
5265            offsets.push(pdf.len());
5266            pdf.extend_from_slice(object);
5267        }
5268        let xref = pdf.len();
5269        pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5270        pdf.extend_from_slice(b"0000000000 65535 f \n");
5271        for offset in offsets {
5272            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5273        }
5274        pdf.extend_from_slice(
5275            format!(
5276                "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5277                objects.len() + 1
5278            )
5279            .as_bytes(),
5280        );
5281        pdf
5282    }
5283
5284    fn encrypted_pdf() -> Vec<u8> {
5285        let objects = [
5286            b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
5287            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
5288            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
5289            b"4 0 obj\n<< /Length 40 >>\nstream\nBT /F1 12 Tf 72 720 Td (Secret PDF) Tj ET\nendstream\nendobj\n".as_slice(),
5290            b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
5291            b"6 0 obj\n<< /Filter /Standard /V 1 /R 2 /Length 40 /O <0000000000000000000000000000000000000000000000000000000000000000> /U <0000000000000000000000000000000000000000000000000000000000000000> /P -4 >>\nendobj\n".as_slice(),
5292        ];
5293        let mut pdf = b"%PDF-1.4\n".to_vec();
5294        let mut offsets = Vec::new();
5295        for object in objects {
5296            offsets.push(pdf.len());
5297            pdf.extend_from_slice(object);
5298        }
5299        let xref = pdf.len();
5300        pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
5301        pdf.extend_from_slice(b"0000000000 65535 f \n");
5302        for offset in offsets {
5303            pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
5304        }
5305        pdf.extend_from_slice(
5306            format!(
5307                "trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
5308                objects.len() + 1
5309            )
5310            .as_bytes(),
5311        );
5312        pdf
5313    }
5314
5315    #[test]
5316    fn pdf_extracts_page_text_with_exact_page_locator() {
5317        let facts =
5318            extract_document_text_controlled(&minimal_pdf(), "guide.pdf", Some("pdf"), &control())
5319                .expect("valid PDF");
5320        assert!(facts.text.contains("Hello PDF"));
5321        assert!(matches!(
5322            facts
5323                .facts
5324                .iter()
5325                .find(|fact| fact.text.contains("Hello PDF")),
5326            Some(DocumentFact {
5327                locator: DocumentLocator::Pdf { page: 1, .. },
5328                ..
5329            })
5330        ));
5331    }
5332
5333    #[test]
5334    fn pdf_page_locators_are_page_local_for_multi_page_documents() {
5335        let facts = extract_document_text_controlled(
5336            &multi_page_pdf(),
5337            "guide.pdf",
5338            Some("pdf"),
5339            &control(),
5340        )
5341        .expect("valid multi-page PDF");
5342        assert_eq!(facts.facts.len(), 2);
5343        assert!(matches!(
5344            facts.facts[1].locator,
5345            DocumentLocator::Pdf {
5346                page: 2,
5347                text_start: 0,
5348                text_end: 8
5349            }
5350        ));
5351    }
5352
5353    #[test]
5354    fn pdf_host_limits_discard_all_page_evidence() {
5355        let work = control();
5356        let stage = IndexWorkStage::SymbolParsing;
5357        let exact = pdf_pages_to_facts(&[(1, "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES))], &work, stage)
5358            .expect("exact output limit remains complete");
5359        assert_eq!(exact.completeness, DocumentCompleteness::Complete);
5360        assert_eq!(exact.text.len(), MAX_DOCUMENT_OUTPUT_BYTES);
5361        assert_eq!(exact.facts.len(), 1);
5362        let exact_facts =
5363            pdf_pages_to_facts(&[(1, "x\n".repeat(MAX_DOCUMENT_FACTS))], &work, stage)
5364                .expect("exact fact limit remains complete");
5365        assert_eq!(exact_facts.completeness, DocumentCompleteness::Complete);
5366        assert_eq!(exact_facts.facts.len(), MAX_DOCUMENT_FACTS);
5367
5368        let over_output = pdf_pages_to_facts(
5369            &[
5370                (1, "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES - 1)),
5371                (2, "y".to_owned()),
5372            ],
5373            &work,
5374            stage,
5375        )
5376        .expect("host output exhaustion is document-local");
5377        let assert_empty_gap = |facts: DocumentFacts, limit| {
5378            assert!(facts.text.is_empty());
5379            assert!(facts.facts.is_empty());
5380            assert!(facts.symbols.is_empty());
5381            assert_eq!(facts.format, DocumentFormat::Pdf);
5382            assert_eq!(facts.provenance, DocumentParserProvenance::PdfExtract);
5383            assert_eq!(
5384                facts.completeness,
5385                DocumentCompleteness::Partial {
5386                    gaps: vec![DocumentCoverageGap::ResourceLimit(limit)]
5387                }
5388            );
5389        };
5390        assert_empty_gap(over_output, DocumentLimit::OutputBytes);
5391
5392        let over_facts =
5393            pdf_pages_to_facts(&[(1, "x\n".repeat(MAX_DOCUMENT_FACTS + 1))], &work, stage)
5394                .expect("host fact exhaustion is document-local");
5395        assert_empty_gap(over_facts, DocumentLimit::FactCount);
5396    }
5397
5398    #[test]
5399    fn pdf_missing_or_unsupported_page_stream_is_not_silently_omitted() {
5400        for missing in [true, false] {
5401            let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
5402            if missing {
5403                document.objects.remove(&(7, 0));
5404            } else {
5405                document
5406                    .objects
5407                    .get_mut(&(7, 0))
5408                    .expect("second page stream")
5409                    .as_stream_mut()
5410                    .expect("stream object")
5411                    .dict
5412                    .set("Filter", "UnsupportedDecode");
5413            }
5414            let mut bytes = Vec::new();
5415            document.save_to(&mut bytes).expect("fixture serialization");
5416            assert!(
5417                matches!(
5418                    extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5419                    Err(DocumentExtractionError::Malformed {
5420                        format: DocumentFormat::Pdf,
5421                        ..
5422                    })
5423                ),
5424                "missing={missing} must fail instead of publishing incomplete text"
5425            );
5426        }
5427    }
5428
5429    #[test]
5430    fn empty_pdf_publishes_complete_text_without_facts() {
5431        let mut document = lopdf::Document::new();
5432        let mut pages = lopdf::Dictionary::new();
5433        pages.set("Type", "Pages");
5434        pages.set("Kids", Vec::<lopdf::Object>::new());
5435        pages.set("Count", 0);
5436        let pages = document.add_object(pages);
5437        let mut catalog = lopdf::Dictionary::new();
5438        catalog.set("Type", "Catalog");
5439        catalog.set("Pages", pages);
5440        let catalog = document.add_object(catalog);
5441        document.trailer.set("Root", catalog);
5442        let mut bytes = Vec::new();
5443        document.save_to(&mut bytes).expect("empty fixture");
5444        let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5445            .expect("empty page tree must pass the embedded guest and host");
5446        assert_eq!(result.completeness, DocumentCompleteness::Complete);
5447        assert!(result.text.is_empty());
5448        assert!(result.facts.is_empty());
5449        assert!(
5450            result
5451                .symbol_graph("guide.pdf", Some("pdf"))
5452                .symbols
5453                .is_empty()
5454        );
5455    }
5456
5457    #[test]
5458    fn pdf_indirect_page_tree_fields_preserve_exact_facts() {
5459        let original = multi_page_pdf();
5460        let expected = extract_document_text_controlled(&original, "guide.pdf", None, &control())
5461            .expect("direct page tree");
5462        let mut document = lopdf::Document::load_mem(&original).expect("fixture PDF");
5463        let children = document
5464            .get_dictionary((2, 0))
5465            .expect("page tree")
5466            .get(b"Kids")
5467            .expect("children")
5468            .clone();
5469        let children = document.add_object(children);
5470        let alias = document.add_object(lopdf::Object::Reference(children));
5471        let count = document.add_object(2);
5472        let node = document.get_dictionary_mut((2, 0)).expect("page tree");
5473        node.set("Kids", alias);
5474        node.set("Count", count);
5475        for (id, name) in [((2, 0), "Pages"), ((3, 0), "Page"), ((6, 0), "Page")] {
5476            let name = document.add_object(lopdf::Object::Name(name.as_bytes().to_vec()));
5477            document
5478                .get_dictionary_mut(id)
5479                .expect("page-tree node")
5480                .set("Type", name);
5481        }
5482        let mut bytes = Vec::new();
5483        document.save_to(&mut bytes).expect("fixture serialization");
5484        let actual = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5485            .expect("indirect page-tree fields");
5486        assert_eq!(actual, expected);
5487
5488        let cycle = document.new_object_id();
5489        document
5490            .objects
5491            .insert(cycle, lopdf::Object::Reference(cycle));
5492        let missing = document.new_object_id();
5493        for invalid in [lopdf::Object::Null, 0.into(), cycle.into(), missing.into()] {
5494            document
5495                .get_dictionary_mut((2, 0))
5496                .expect("page tree")
5497                .set("Kids", invalid);
5498            let mut bytes = Vec::new();
5499            document.save_to(&mut bytes).expect("fixture serialization");
5500            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5501            assert!(
5502                matches!(
5503                    result,
5504                    Err(DocumentExtractionError::Malformed {
5505                        format: DocumentFormat::Pdf,
5506                        ..
5507                    })
5508                ),
5509                "{result:?}"
5510            );
5511        }
5512    }
5513
5514    #[test]
5515    fn pdf_missing_page_tree_child_never_publishes_a_complete_prefix() {
5516        for declared_count in [1, 2] {
5517            let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
5518            document.objects.remove(&(6, 0));
5519            document
5520                .get_object_mut((2, 0))
5521                .expect("page tree")
5522                .as_dict_mut()
5523                .expect("page tree dictionary")
5524                .set("Count", declared_count);
5525            let mut bytes = Vec::new();
5526            document.save_to(&mut bytes).expect("fixture serialization");
5527            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5528            assert!(
5529                matches!(
5530                    result,
5531                    Err(DocumentExtractionError::Malformed {
5532                        format: DocumentFormat::Pdf,
5533                        ..
5534                    })
5535                ),
5536                "declared_count={declared_count}: {result:?}"
5537            );
5538        }
5539    }
5540
5541    #[test]
5542    fn pdf_form_content_keeps_text_and_page_evidence() {
5543        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5544        let mut form = lopdf::Dictionary::new();
5545        form.set("Type", "XObject");
5546        let subtype = document.add_object(lopdf::Object::Name(b"Form".to_vec()));
5547        form.set("Subtype", subtype);
5548        form.set(
5549            "Matrix",
5550            vec![1.into(), 0.into(), 0.into(), 1.into(), 0.into(), 600.into()],
5551        );
5552        form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
5553        let resources = document
5554            .get_dictionary((3, 0))
5555            .expect("page")
5556            .get(b"Resources")
5557            .expect("resources")
5558            .clone();
5559        form.set("Resources", resources);
5560        let form_id = document.add_object(lopdf::Stream::new(
5561            form,
5562            b"BT /F1 12 Tf 72 0 Td (Form Text Marker) Tj ET".to_vec(),
5563        ));
5564        let mut xobjects = lopdf::Dictionary::new();
5565        xobjects.set("Fm1", form_id);
5566        document
5567            .get_object_mut((3, 0))
5568            .expect("page")
5569            .as_dict_mut()
5570            .expect("page dictionary")
5571            .get_mut(b"Resources")
5572            .expect("resources")
5573            .as_dict_mut()
5574            .expect("resource dictionary")
5575            .set("XObject", xobjects);
5576        let stream = document
5577            .get_object_mut((4, 0))
5578            .expect("page stream")
5579            .as_stream_mut()
5580            .expect("stream");
5581        let mut content = stream.content.clone();
5582        content
5583            .extend_from_slice(b"\nq 1 0 0 1 0 100 cm /Fm1 Do Q\nq 1 0 0 1 0 -100 cm /Fm1 Do Q\n");
5584        stream.set_content(content);
5585        let mut bytes = Vec::new();
5586        document.save_to(&mut bytes).expect("fixture serialization");
5587        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5588            .expect("valid Form content");
5589        assert!(facts.text.contains("Hello PDF"));
5590        assert_eq!(facts.facts.len(), 3, "{}", facts.text);
5591        assert_eq!(
5592            facts
5593                .facts
5594                .iter()
5595                .filter(|fact| fact.text == "Form Text Marker")
5596                .count(),
5597            2,
5598            "{}",
5599            facts.text
5600        );
5601        assert!(
5602            facts
5603                .facts
5604                .iter()
5605                .any(|fact| fact.text.contains("Form Text Marker")
5606                    && matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }))
5607        );
5608    }
5609
5610    #[test]
5611    fn pdf_rotation_and_quote_operators_preserve_text_locators() {
5612        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5613        for (rotation, content) in [
5614            (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 -20 Td (Next) Tj ET".as_slice()),
5615            (90, b"BT /F1 12 Tf 0 1 -1 0 112 72 Tm (First) Tj 0 1 -1 0 112 180 Tm (Second) Tj 0 1 -1 0 132 72 Tm (Next) Tj ET"),
5616            (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 0 Td (Next) ' ET"),
5617            (0, b"BT /F1 12 Tf 20 TL 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 0 Td 0 0 (Next) \" ET"),
5618        ] {
5619            document.get_object_mut((2, 0)).expect("page tree").as_dict_mut()
5620                .expect("page tree dictionary").set("Rotate", rotation);
5621            document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5622                .expect("stream").set_content(content.to_vec());
5623            let mut bytes = Vec::new();
5624            document.save_to(&mut bytes).expect("fixture serialization");
5625            let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5626                .expect("valid positioned text");
5627            assert_eq!(facts.text, "First Second\nNext", "rotation={rotation}");
5628            assert_eq!(facts.facts.len(), 2);
5629            for (index, fact) in facts.facts.iter().enumerate() {
5630                assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
5631                assert_eq!(fact.line_start, index + 1);
5632                assert_eq!(fact.line_end, index + 1);
5633            }
5634        }
5635        for rotation in [90, 180, 270] {
5636            document
5637                .get_object_mut((2, 0))
5638                .expect("page tree")
5639                .as_dict_mut()
5640                .expect("page tree dictionary")
5641                .set("Rotate", rotation);
5642            document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5643                .expect("stream").set_content(b"BT /F1 12 Tf 72 500 Td (First) Tj 108 0 Td (Second) Tj -108 -100 Td (Next) Tj ET".to_vec());
5644            let mut bytes = Vec::new();
5645            document.save_to(&mut bytes).expect("fixture serialization");
5646            let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5647                .expect("ordinary rotated text");
5648            assert_eq!(facts.text, "First Second\nNext");
5649            assert_eq!(facts.facts.len(), 2);
5650            for (index, fact) in facts.facts.iter().enumerate() {
5651                assert!(matches!(fact.locator, DocumentLocator::Pdf { page: 1, .. }));
5652                assert_eq!((fact.line_start, fact.line_end), (index + 1, index + 1));
5653            }
5654        }
5655    }
5656
5657    #[test]
5658    fn pdf_text_state_and_missing_width_preserve_block_text() {
5659        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5660        let descriptor = lopdf::dictionary! {
5661            "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 32,
5662            "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
5663            "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200, "CapHeight" => 700,
5664            "StemV" => 80, "MissingWidth" => 600
5665        };
5666        document.objects.insert(
5667            (5, 0),
5668            lopdf::dictionary! {
5669                "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Fixture",
5670                "Encoding" => "WinAnsiEncoding", "FontDescriptor" => descriptor,
5671                "FirstChar" => 65, "LastChar" => 65, "Widths" => vec![600.into()]
5672            }
5673            .into(),
5674        );
5675        document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
5676            .set_content(b"BT /F1 12 Tf 20 TL 72 500 Td q 100 -100 Td (A) Tj Q T* (StateB) Tj 1 0 0 1 115.2 480 Tm (C) Tj ET".to_vec());
5677        let mut bytes = Vec::new();
5678        document.save_to(&mut bytes).expect("fixture serialization");
5679        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5680            .expect("scoped text with descriptor widths");
5681        assert_eq!(facts.text, "A\nStateBC");
5682        assert_eq!(facts.facts.len(), 2);
5683        assert_eq!(facts.facts[1].text, "StateBC");
5684        assert_eq!((facts.facts[1].line_start, facts.facts[1].line_end), (2, 2));
5685        assert!(matches!(
5686            facts.facts[1].locator,
5687            DocumentLocator::Pdf { page: 1, .. }
5688        ));
5689    }
5690
5691    #[test]
5692    fn pdf_font_matrix_and_color_aliases_preserve_text() {
5693        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5694        let glyph = document.add_object(lopdf::Stream::new(
5695            lopdf::Dictionary::new(),
5696            b"600 0 d0".to_vec(),
5697        ));
5698        document.objects.insert((5, 0), lopdf::dictionary! {
5699            "Type" => "Font", "Subtype" => "Type3",
5700            "FontBBox" => vec![0.into(), 0.into(), 600.into(), 600.into()],
5701            "FontMatrix" => vec![0.002.into(), 0.into(), 0.into(), 0.002.into(), 0.into(), 0.into()],
5702            "CharProcs" => lopdf::dictionary! { "A" => glyph, "B" => glyph },
5703            "Encoding" => lopdf::dictionary! { "Differences" => vec![65.into(), "A".into(), "B".into()] },
5704            "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()]
5705        }.into());
5706        document
5707            .get_dictionary_mut((3, 0))
5708            .expect("page")
5709            .get_mut(b"Resources")
5710            .expect("resources")
5711            .as_dict_mut()
5712            .expect("dictionary")
5713            .set("ColorSpace", lopdf::dictionary! { "CS1" => "DeviceCMYK",
5714                "IndexedAlias" => vec!["Indexed".into(), "DeviceRGB".into(), 1.into(),
5715                    lopdf::Object::String(vec![0, 0, 0, 255, 255, 255], lopdf::StringFormat::Hexadecimal)] });
5716        for color in [
5717            "",
5718            "/CS1 cs 0 0 0 1 sc /CS1 CS 0 0 0 1 SC",
5719            "/IndexedAlias cs 1 sc /IndexedAlias CS 1 SC",
5720        ] {
5721            document
5722                .get_object_mut((4, 0))
5723                .expect("content")
5724                .as_stream_mut()
5725                .expect("stream")
5726                .set_content(
5727                    format!("{color} BT /F1 12 Tf 72 500 Td (A) Tj 14.4 0 Td (B) Tj ET")
5728                        .into_bytes(),
5729                );
5730            let mut bytes = Vec::new();
5731            document.save_to(&mut bytes).expect("fixture serialization");
5732            let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5733                .expect("Type 3 text and visual-only color aliases");
5734            assert_eq!(facts.text, "AB");
5735            assert_eq!(facts.facts.len(), 1);
5736            assert!(matches!(
5737                facts.facts[0].locator,
5738                DocumentLocator::Pdf {
5739                    page: 1,
5740                    text_start: 0,
5741                    text_end: 2
5742                }
5743            ));
5744        }
5745        document
5746            .get_dictionary_mut((5, 0))
5747            .expect("font")
5748            .set("FontMatrix", vec![lopdf::Object::Real(0.002)]);
5749        let mut bytes = Vec::new();
5750        document.save_to(&mut bytes).expect("fixture serialization");
5751        assert!(matches!(
5752            extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5753            Err(DocumentExtractionError::Malformed {
5754                format: DocumentFormat::Pdf,
5755                ..
5756            })
5757        ));
5758    }
5759
5760    #[test]
5761    fn pdf_implicit_encoding_preserves_exact_text_and_refuses_undefined_glyphs() {
5762        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5763        document
5764            .get_dictionary_mut((5, 0))
5765            .expect("font")
5766            .set("Encoding", lopdf::Dictionary::new());
5767        document
5768            .get_object_mut((4, 0))
5769            .expect("content")
5770            .as_stream_mut()
5771            .expect("stream")
5772            .set_content(b"BT /F1 12 Tf 72 500 Td <27> Tj ET".to_vec());
5773        let mut bytes = Vec::new();
5774        document.save_to(&mut bytes).expect("fixture serialization");
5775        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5776            .expect("implicit standard font encoding");
5777        assert_eq!(facts.text, "\u{2019}");
5778        assert_eq!(facts.facts.len(), 1);
5779        assert!(matches!(
5780            facts.facts[0].locator,
5781            DocumentLocator::Pdf {
5782                page: 1,
5783                text_start: 0,
5784                text_end: 3
5785            }
5786        ));
5787        document.get_dictionary_mut((5, 0)).expect("font").set(
5788            "Encoding",
5789            lopdf::dictionary! { "Differences" => vec![39.into(), ".notdef".into()] },
5790        );
5791        let mut bytes = Vec::new();
5792        document.save_to(&mut bytes).expect("fixture serialization");
5793        assert!(matches!(
5794            extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
5795            Err(DocumentExtractionError::UnsupportedPdfInput)
5796        ));
5797    }
5798
5799    #[test]
5800    fn pdf_partial_unicode_uses_builtin_font_or_refuses() {
5801        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5802        let cmap = document.add_object(lopdf::Stream::new(
5803            lopdf::Dictionary::new(),
5804            br"/CIDInit /ProcSet findresource begin
580512 dict begin begincmap
5806/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
5807/CMapName /Fixture def /CMapType 2 def
58081 begincodespacerange <00> <FF> endcodespacerange
58091 beginbfchar <41> <005A> endbfchar
5810endcmap CMapName currentdict /CMap defineresource pop end end"
5811                .to_vec(),
5812        ));
5813        document
5814            .get_object_mut((4, 0))
5815            .expect("content")
5816            .as_stream_mut()
5817            .expect("stream")
5818            .set_content(b"BT /F1 12 Tf 72 500 Td (AB) Tj ET".to_vec());
5819        for (base, expected) in [("Symbol", Some("Z\u{0392}")), ("Fixture", None)] {
5820            document.objects.insert(
5821                (5, 0),
5822                lopdf::dictionary! {
5823                    "Type" => "Font", "Subtype" => "Type1", "BaseFont" => base,
5824                    "FirstChar" => 65, "LastChar" => 66, "Widths" => vec![600.into(), 600.into()],
5825                    "ToUnicode" => cmap
5826                }
5827                .into(),
5828            );
5829            let mut bytes = Vec::new();
5830            document.save_to(&mut bytes).expect("fixture serialization");
5831            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5832            if let Some(expected) = expected {
5833                assert_eq!(result.expect("known built-in encoding").text, expected);
5834            } else {
5835                assert!(
5836                    matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5837                    "{result:?}"
5838                );
5839            }
5840        }
5841    }
5842
5843    #[test]
5844    fn pdf_calibrated_color_dictionaries_preserve_text_or_refuse() {
5845        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5846        let parameters = document.add_object(lopdf::dictionary! {
5847            "WhitePoint" => vec![1.into(), 1.into(), 1.into()]
5848        });
5849        let wrong_type = document.add_object(42);
5850        document.get_object_mut((4, 0)).expect("content").as_stream_mut().expect("stream")
5851            .set_content(b"BT /F1 12 Tf 72 500 Td (Prefix) Tj ET /Calibrated cs /Calibrated CS BT /F1 12 Tf 72 480 Td (Visible) Tj ET".to_vec());
5852        for space in ["CalGray", "CalRGB", "Lab"] {
5853            for (reference, valid) in [(parameters, true), (wrong_type, false), ((999, 0), false)] {
5854                document.get_dictionary_mut((3, 0)).expect("page")
5855                    .get_mut(b"Resources").expect("resources").as_dict_mut().expect("dictionary")
5856                    .set("ColorSpace", lopdf::dictionary! {
5857                        "Calibrated" => vec![lopdf::Object::Name(space.as_bytes().to_vec()), reference.into()]
5858                    });
5859                let mut bytes = Vec::new();
5860                document.save_to(&mut bytes).expect("fixture serialization");
5861                let result =
5862                    extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5863                if valid {
5864                    let facts = result.expect("indirect calibrated dictionary");
5865                    assert_eq!(
5866                        facts.text.split_whitespace().collect::<Vec<_>>(),
5867                        ["Prefix", "Visible"]
5868                    );
5869                    assert_eq!(facts.facts.len(), 2);
5870                    for (fact, (text_start, text_end)) in facts.facts.iter().zip([(0, 6), (7, 14)])
5871                    {
5872                        assert_eq!(
5873                            fact.locator,
5874                            DocumentLocator::Pdf {
5875                                page: 1,
5876                                text_start,
5877                                text_end
5878                            }
5879                        );
5880                    }
5881                } else {
5882                    assert!(
5883                        matches!(result, Err(DocumentExtractionError::Malformed { .. })),
5884                        "{space}: {result:?}"
5885                    );
5886                }
5887            }
5888        }
5889    }
5890
5891    #[test]
5892    fn pdf_extended_graphics_state_selects_text_font() {
5893        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5894        let state_type = document.add_object(lopdf::Object::Name(b"ExtGState".to_vec()));
5895        let mut state = lopdf::Dictionary::new();
5896        state.set("Type", state_type);
5897        state.set("Font", vec![lopdf::Object::Reference((5, 0)), 12.into()]);
5898        let mut states = lopdf::Dictionary::new();
5899        states.set("GS", state);
5900        document
5901            .get_object_mut((3, 0))
5902            .expect("page")
5903            .as_dict_mut()
5904            .expect("page dictionary")
5905            .get_mut(b"Resources")
5906            .expect("resources")
5907            .as_dict_mut()
5908            .expect("resource dictionary")
5909            .set("ExtGState", states);
5910        document
5911            .get_object_mut((4, 0))
5912            .expect("page stream")
5913            .as_stream_mut()
5914            .expect("stream")
5915            .set_content(b"/GS gs BT 10 Tw 2 Tc 72 500 Td (Graphics ) Tj (Font) Tj ET".to_vec());
5916        let mut bytes = Vec::new();
5917        document.save_to(&mut bytes).expect("fixture serialization");
5918        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
5919            .expect("ExtGState font is usable without Tf");
5920        assert_eq!(facts.text.trim(), "Graphics Font");
5921        assert_eq!(facts.facts.len(), 1);
5922        assert!(matches!(
5923            facts.facts[0].locator,
5924            DocumentLocator::Pdf { page: 1, .. }
5925        ));
5926    }
5927
5928    #[test]
5929    fn pdf_actual_text_refuses_partial_text_publication() {
5930        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5931        document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
5932            .expect("stream").set_content(b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /ActualText (replacement) >> BDC (glyph) Tj EMC ET".to_vec());
5933        let mut bytes = Vec::new();
5934        document.save_to(&mut bytes).expect("fixture serialization");
5935        let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5936        assert!(
5937            matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5938            "{result:?}"
5939        );
5940    }
5941
5942    #[test]
5943    fn pdf_structure_replacements_refuse_partial_text_publication() {
5944        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
5945        document
5946            .get_object_mut((4, 0))
5947            .expect("page stream")
5948            .as_stream_mut()
5949            .expect("stream")
5950            .set_content(
5951                b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /Span << /MCID 0 >> BDC (glyph) Tj EMC ET"
5952                    .to_vec(),
5953            );
5954        let root = document.new_object_id();
5955        let mut element = lopdf::Dictionary::new();
5956        element.set("Type", "StructElem");
5957        element.set("S", "Span");
5958        element.set("P", root);
5959        element.set("Pg", (3, 0));
5960        element.set("K", 0);
5961        let element = document.add_object(element);
5962        let mut parents = lopdf::Dictionary::new();
5963        parents.set(
5964            "Nums",
5965            vec![0.into(), lopdf::Object::Array(vec![element.into()])],
5966        );
5967        let parents = document.add_object(parents);
5968        let mut structure = lopdf::Dictionary::new();
5969        structure.set("Type", "StructTreeRoot");
5970        structure.set("K", element);
5971        structure.set("ParentTree", parents);
5972        document.objects.insert(root, structure.into());
5973        document
5974            .get_dictionary_mut((3, 0))
5975            .expect("page")
5976            .set("StructParents", 0);
5977        document
5978            .get_dictionary_mut((1, 0))
5979            .expect("catalog")
5980            .set("StructTreeRoot", root);
5981        for replacement in [false, true] {
5982            if replacement {
5983                document
5984                    .get_dictionary_mut(element)
5985                    .expect("structure element")
5986                    .set("ActualText", lopdf::Object::string_literal("replacement"));
5987            }
5988            let mut bytes = Vec::new();
5989            document.save_to(&mut bytes).expect("fixture serialization");
5990            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
5991            if replacement {
5992                assert!(
5993                    matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
5994                    "{result:?}"
5995                );
5996            } else {
5997                let extracted = result.expect("ordinary tagged content remains supported");
5998                assert_eq!(extracted.text.trim(), "Prefixglyph");
5999                assert!(matches!(
6000                    extracted.facts[0].locator,
6001                    DocumentLocator::Pdf { page: 1, .. }
6002                ));
6003            }
6004        }
6005    }
6006
6007    #[test]
6008    fn pdf_reversed_chars_refuses_partial_text_publication() {
6009        for content in [
6010            b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars BMC (desrever) Tj EMC ET".as_slice(),
6011            b"BT /F1 12 Tf 72 500 Td (Prefix) Tj /ReversedChars << /MCID 0 >> BDC (desrever) Tj EMC ET",
6012        ] {
6013            let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6014            document.get_object_mut((4, 0)).expect("page stream").as_stream_mut()
6015                .expect("stream").set_content(content.to_vec());
6016            let mut bytes = Vec::new();
6017            document.save_to(&mut bytes).expect("fixture serialization");
6018            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6019            assert!(matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)), "{result:?}");
6020        }
6021    }
6022
6023    fn pdf_with_xobject(xobject: lopdf::Stream) -> Vec<u8> {
6024        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6025        let object = document.add_object(xobject);
6026        let mut resources = lopdf::Dictionary::new();
6027        resources.set("Object1", object);
6028        document
6029            .get_object_mut((3, 0))
6030            .expect("page")
6031            .as_dict_mut()
6032            .expect("page dictionary")
6033            .get_mut(b"Resources")
6034            .expect("resources")
6035            .as_dict_mut()
6036            .expect("resource dictionary")
6037            .set("XObject", resources);
6038        let stream = document
6039            .get_object_mut((4, 0))
6040            .expect("page content")
6041            .as_stream_mut()
6042            .expect("content stream");
6043        let mut content = stream.content.clone();
6044        content.extend_from_slice(b"\n/Object1 Do\n");
6045        stream.set_content(content);
6046        let mut bytes = Vec::new();
6047        document.save_to(&mut bytes).expect("fixture serialization");
6048        bytes
6049    }
6050
6051    #[test]
6052    fn recursive_pdf_form_stops_without_publishing_page_text() {
6053        let mut form = lopdf::Dictionary::new();
6054        form.set("Type", "XObject");
6055        form.set("Subtype", "Form");
6056        form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
6057        let bytes = pdf_with_xobject(lopdf::Stream::new(form, b"/Object1 Do".to_vec()));
6058        let mut document = lopdf::Document::load_mem(&bytes).expect("fixture PDF");
6059        let resources = document
6060            .get_dictionary((3, 0))
6061            .expect("page dictionary")
6062            .get(b"Resources")
6063            .expect("page resources")
6064            .clone();
6065        let id = resources
6066            .as_dict()
6067            .expect("resource dictionary")
6068            .get(b"XObject")
6069            .expect("XObject resources")
6070            .as_dict()
6071            .expect("XObject dictionary")
6072            .get(b"Object1")
6073            .expect("Form reference")
6074            .as_reference()
6075            .expect("indirect Form");
6076        document
6077            .get_object_mut(id)
6078            .expect("Form object")
6079            .as_stream_mut()
6080            .expect("Form stream")
6081            .dict
6082            .set("Resources", resources);
6083        let mut bytes = Vec::new();
6084        document.save_to(&mut bytes).expect("fixture serialization");
6085        let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6086        assert!(
6087            matches!(
6088                result,
6089                Err(DocumentExtractionError::Malformed {
6090                    format: DocumentFormat::Pdf,
6091                    ..
6092                })
6093            ),
6094            "{result:?}"
6095        );
6096    }
6097
6098    #[test]
6099    fn pdf_closed_paths_preserve_text_and_missing_current_points_refuse() {
6100        for (path, valid) in [
6101            ("10 20 m 30 40 l h 50 60 70 80 v S", true),
6102            ("10 20 30 40 re 50 60 70 80 v S", true),
6103            ("50 60 70 80 v", false),
6104            ("10 20 m s 50 60 70 80 v", false),
6105            ("10 20 m f* 50 60 70 80 v", false),
6106            ("10 20 m B 50 60 70 80 v", false),
6107            ("10 20 m B* 50 60 70 80 v", false),
6108            ("10 20 m b 50 60 70 80 v", false),
6109            ("10 20 m b* 50 60 70 80 v", false),
6110            ("10 20 m S 50 60 70 80 v", false),
6111            ("10 20 m f 50 60 70 80 v", false),
6112            ("10 20 m F 50 60 70 80 v", false),
6113            ("10 20 m n 50 60 70 80 v", false),
6114            (
6115                "10 20 30 40 re s 10 20 30 40 re f* 10 20 30 40 re B 10 20 30 40 re B* 10 20 30 40 re b 10 20 30 40 re b*",
6116                true,
6117            ),
6118        ] {
6119            let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6120            let stream = document
6121                .get_object_mut((4, 0))
6122                .expect("page content")
6123                .as_stream_mut()
6124                .expect("content stream");
6125            let mut content = format!("{path}\n").into_bytes();
6126            content.extend_from_slice(&stream.content);
6127            stream.set_content(content);
6128            let mut bytes = Vec::new();
6129            document.save_to(&mut bytes).expect("fixture serialization");
6130            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6131            if valid {
6132                let facts = result.expect("closed path and displayed PDF text");
6133                assert_eq!(facts.text, "Hello PDF");
6134                assert_eq!(facts.facts.len(), 1);
6135                assert!(matches!(
6136                    facts.facts[0].locator,
6137                    DocumentLocator::Pdf {
6138                        page: 1,
6139                        text_start: 0,
6140                        text_end: 9
6141                    }
6142                ));
6143            } else {
6144                assert!(
6145                    matches!(result, Err(DocumentExtractionError::Malformed { .. })),
6146                    "{result:?}"
6147                );
6148            }
6149        }
6150    }
6151
6152    #[test]
6153    fn pdf_postscript_xobjects_preserve_displayed_text_and_exact_locator() {
6154        for subtype in ["PS", "Form", "Unknown"] {
6155            let mut object = lopdf::Dictionary::new();
6156            object.set("Type", "XObject");
6157            object.set("Subtype", subtype);
6158            if subtype != "PS" {
6159                object.set("Subtype2", "PS");
6160            }
6161            let bytes = pdf_with_xobject(lopdf::Stream::new(
6162                object,
6163                b"/Helvetica findfont 12 scalefont setfont (Print only) show".to_vec(),
6164            ));
6165            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6166            if subtype == "Unknown" {
6167                assert!(
6168                    matches!(result, Err(DocumentExtractionError::Malformed { .. })),
6169                    "{result:?}"
6170                );
6171            } else {
6172                let facts = result.expect("displayed PDF text");
6173                assert_eq!(facts.text, "Hello PDF");
6174                assert_eq!(facts.facts.len(), 1);
6175                assert!(matches!(
6176                    facts.facts[0].locator,
6177                    DocumentLocator::Pdf {
6178                        page: 1,
6179                        text_start: 0,
6180                        text_end: 9
6181                    }
6182                ));
6183            }
6184        }
6185    }
6186
6187    #[test]
6188    fn pdf_image_pixels_do_not_replace_or_invent_page_text() {
6189        let mut image = lopdf::Dictionary::new();
6190        image.set("Type", "XObject");
6191        image.set("Subtype", "Image");
6192        image.set("Width", 1);
6193        image.set("Height", 1);
6194        image.set("ColorSpace", "DeviceRGB");
6195        image.set("BitsPerComponent", 8);
6196        let bytes = pdf_with_xobject(lopdf::Stream::new(image, vec![255, 0, 0]));
6197        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
6198            .expect("text and image PDF");
6199        assert_eq!(facts.text, "Hello PDF");
6200        let mut document = lopdf::Document::load_mem(&bytes).expect("image fixture");
6201        let subtype = document.add_object(lopdf::Object::Name(b"Image".to_vec()));
6202        for object in document.objects.values_mut() {
6203            if let lopdf::Object::Stream(stream) = object
6204                && stream
6205                    .dict
6206                    .get(b"Subtype")
6207                    .and_then(lopdf::Object::as_name)
6208                    .ok()
6209                    == Some(b"Image".as_slice())
6210            {
6211                stream.dict.set("Subtype", subtype);
6212                stream.dict.set("Filter", "DCTDecode");
6213            }
6214        }
6215        let mut indirect = Vec::new();
6216        document
6217            .save_to(&mut indirect)
6218            .expect("fixture serialization");
6219        let actual = extract_document_text_controlled(&indirect, "guide.pdf", None, &control())
6220            .expect("indirect image subtype keeps opaque pixels outside decoding");
6221        assert_eq!(actual, facts);
6222    }
6223
6224    #[test]
6225    fn pdf_form_font_names_do_not_reuse_page_font_encodings() {
6226        let mut encoding = lopdf::Dictionary::new();
6227        encoding.set("Type", "Encoding");
6228        encoding.set("BaseEncoding", "WinAnsiEncoding");
6229        encoding.set(
6230            "Differences",
6231            vec![65.into(), lopdf::Object::Name(b"Z".to_vec())],
6232        );
6233        let mut font = lopdf::Dictionary::new();
6234        font.set("Type", "Font");
6235        font.set("Subtype", "Type1");
6236        font.set("BaseFont", "Helvetica");
6237        font.set("Encoding", encoding);
6238        let mut fonts = lopdf::Dictionary::new();
6239        fonts.set("F1", font);
6240        let mut resources = lopdf::Dictionary::new();
6241        resources.set("Font", fonts);
6242        let mut form = lopdf::Dictionary::new();
6243        form.set("Type", "XObject");
6244        form.set("Subtype", "Form");
6245        form.set("BBox", vec![0.into(), 0.into(), 612.into(), 792.into()]);
6246        form.set("Resources", resources);
6247        let bytes = pdf_with_xobject(lopdf::Stream::new(
6248            form,
6249            b"BT /F1 12 Tf 72 700 Td (A) Tj ET".to_vec(),
6250        ));
6251        let facts = extract_document_text_controlled(&bytes, "guide.pdf", None, &control())
6252            .expect("scoped Form font");
6253        assert!(facts.text.contains("Hello PDF"));
6254        assert!(facts.text.contains('Z'), "{}", facts.text);
6255        assert!(!facts.text.contains('A'), "{}", facts.text);
6256    }
6257
6258    #[test]
6259    fn pdf_cid_text_requires_every_character_to_decode() {
6260        for (encoding, codes, expected, default_width) in [
6261            ("Identity-H", "0001", Some("Z")),
6262            (
6263                "Identity-H",
6264                "0001> Tj 3 0 Td <0001> Tj 8 0 Td <0001",
6265                Some("ZZ Z"),
6266            ),
6267            ("Identity-H", "00010002", None),
6268            ("Identity-H", "000100", None),
6269            ("Identity-V", "0001", None),
6270            ("custom", "0001", None),
6271        ]
6272        .into_iter()
6273        .map(|(encoding, codes, expected)| (encoding, codes, expected, None))
6274        .chain([(
6275            "Identity-H",
6276            "0001> Tj 18.006 0 Td <0001",
6277            Some("ZZ"),
6278            Some(1500.5_f32),
6279        )]) {
6280            let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6281            let cmap = document.add_object(lopdf::Stream::new(
6282                lopdf::Dictionary::new(),
6283                br"/CIDInit /ProcSet findresource begin
628412 dict begin begincmap
6285/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
6286/CMapName /Fixture def /CMapType 2 def
62871 begincodespacerange <0000> <FFFF> endcodespacerange
62881 beginbfchar <0001> <005A> endbfchar
6289endcmap CMapName currentdict /CMap defineresource pop end end"
6290                    .to_vec(),
6291            ));
6292            let mut system = lopdf::Dictionary::new();
6293            system.set("Registry", lopdf::Object::string_literal("Adobe"));
6294            system.set("Ordering", lopdf::Object::string_literal("Identity"));
6295            system.set("Supplement", 0);
6296            let mut descendant = lopdf::Dictionary::new();
6297            descendant.set("Type", "Font");
6298            descendant.set("Subtype", "CIDFontType2");
6299            descendant.set("BaseFont", "Fixture");
6300            if let Some(width) = default_width {
6301                document.version = "2.0".to_owned();
6302                descendant.set("DW", lopdf::Object::Real(width));
6303            } else {
6304                descendant.set("W", vec![lopdf::Object::Integer(1), 1.into(), 200.into()]);
6305            }
6306            descendant.set("CIDSystemInfo", system);
6307            let descriptor = document.add_object(lopdf::dictionary! {
6308                "Type" => "FontDescriptor", "FontName" => "Fixture", "Flags" => 4,
6309                "FontBBox" => vec![0.into(), (-200).into(), 1000.into(), 1000.into()],
6310                "ItalicAngle" => 0, "Ascent" => 800, "Descent" => -200,
6311                "CapHeight" => 700, "StemV" => 80
6312            });
6313            descendant.set("FontDescriptor", descriptor);
6314            let descendant = document.add_object(descendant);
6315            let mut font = lopdf::Dictionary::new();
6316            font.set("Type", "Font");
6317            font.set("Subtype", "Type0");
6318            font.set("BaseFont", "Fixture");
6319            if encoding == "custom" {
6320                let encoding = document.add_object(lopdf::Stream::new(
6321                    lopdf::Dictionary::new(),
6322                    b"/CIDInit /ProcSet findresource begin
632312 dict begin begincmap
6324/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
6325/CMapName /FixtureEncoding def /CMapType 1 def /WMode 0 def
63261 begincodespacerange <0000> <FFFF> endcodespacerange
63271 begincidrange <0000> <FFFF> 0 endcidrange
6328endcmap CMapName currentdict /CMap defineresource pop end end"
6329                        .to_vec(),
6330                ));
6331                font.set("Encoding", encoding);
6332            } else {
6333                font.set("Encoding", encoding);
6334            }
6335            font.set(
6336                "DescendantFonts",
6337                vec![lopdf::Object::Reference(descendant)],
6338            );
6339            font.set("ToUnicode", cmap);
6340            document.objects.insert((5, 0), font.into());
6341            document
6342                .get_object_mut((4, 0))
6343                .expect("content")
6344                .as_stream_mut()
6345                .expect("stream")
6346                .set_content(format!("BT /F1 12 Tf 72 720 Td <{codes}> Tj ET").into_bytes());
6347            let mut bytes = Vec::new();
6348            document.save_to(&mut bytes).expect("fixture serialization");
6349            let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6350            if let Some(expected) = expected {
6351                assert_eq!(result.expect("mapped horizontal CID").text, expected);
6352            } else if encoding != "Identity-H" || codes == "00010002" {
6353                assert!(
6354                    matches!(result, Err(DocumentExtractionError::UnsupportedPdfInput)),
6355                    "{result:?}"
6356                );
6357            } else {
6358                assert!(
6359                    matches!(
6360                        result,
6361                        Err(DocumentExtractionError::Malformed {
6362                            format: DocumentFormat::Pdf,
6363                            ..
6364                        })
6365                    ),
6366                    "{codes}: {result:?}"
6367                );
6368            }
6369        }
6370    }
6371
6372    #[test]
6373    fn pdf_late_malformed_page_never_publishes_a_complete_prefix() {
6374        let mut document = lopdf::Document::load_mem(&multi_page_pdf()).expect("fixture PDF");
6375        document.objects.insert(
6376            (7, 0),
6377            lopdf::Stream::new(lopdf::Dictionary::new(), b"BT (unterminated".to_vec()).into(),
6378        );
6379        let mut bytes = Vec::new();
6380        document.save_to(&mut bytes).expect("fixture serialization");
6381        assert!(matches!(
6382            extract_document_text_controlled(&bytes, "guide.pdf", None, &control()),
6383            Err(DocumentExtractionError::Malformed {
6384                format: DocumentFormat::Pdf,
6385                ..
6386            })
6387        ));
6388    }
6389
6390    #[test]
6391    fn pdf_decompression_bomb_stops_at_the_first_host_resource_limit() {
6392        let mut document = lopdf::Document::load_mem(&minimal_pdf()).expect("fixture PDF");
6393        let mut stream = lopdf::Stream::new(
6394            lopdf::Dictionary::new(),
6395            vec![b' '; MAX_DOCUMENT_EXPANDED_BYTES + 1],
6396        );
6397        stream.compress().expect("fixture compression");
6398        document.objects.insert((4, 0), stream.into());
6399        let mut bytes = Vec::new();
6400        document.save_to(&mut bytes).expect("fixture serialization");
6401        assert!(bytes.len() < MAX_DOCUMENT_COMPRESSED_BYTES);
6402        let result = extract_document_text_controlled(&bytes, "guide.pdf", None, &control());
6403        assert!(
6404            matches!(
6405                &result,
6406                Err(DocumentExtractionError::ResourceLimit {
6407                    limit: DocumentLimit::MemoryBytes
6408                        | DocumentLimit::ExpandedBytes
6409                        | DocumentLimit::ExecutionFuel,
6410                    ..
6411                } | DocumentExtractionError::Work(
6412                    projectatlas_core::IndexWorkFailure::DeadlineExceeded { .. }
6413                ))
6414            ),
6415            "actual refusal: {result:?}"
6416        );
6417    }
6418
6419    #[test]
6420    fn encrypted_or_password_protected_pdf_is_rejected_without_credentials() {
6421        let error = extract_document_text_controlled(
6422            &encrypted_pdf(),
6423            "secret.pdf",
6424            Some("pdf"),
6425            &control(),
6426        )
6427        .expect_err("password-protected PDFs must never be decrypted by indexing");
6428        assert!(matches!(error, DocumentExtractionError::EncryptedPdf));
6429    }
6430
6431    #[test]
6432    fn unsafe_or_duplicate_docx_parts_are_rejected() {
6433        let mut bytes = Vec::new();
6434        {
6435            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6436            writer
6437                .start_file("../word/document.xml", FileOptions::default())
6438                .expect("fixture entry");
6439            writer.write_all(b"<w:document/>").expect("fixture XML");
6440            writer.finish().expect("fixture archive");
6441        }
6442        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6443            .expect_err("path traversal must fail closed");
6444        assert!(matches!(
6445            error,
6446            DocumentExtractionError::InvalidDocxPackage { .. }
6447        ));
6448    }
6449
6450    #[test]
6451    fn stored_and_deflated_docx_parts_are_admitted() {
6452        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>bounded</w:t></w:r></w:p></w:body></w:document>"#;
6453        for method in [CompressionMethod::Stored, CompressionMethod::Deflated] {
6454            let facts = extract_document_text_controlled(
6455                &docx_archive(xml, method),
6456                "guide.docx",
6457                None,
6458                &control(),
6459            )
6460            .expect("admitted DOCX compression");
6461            assert_eq!(facts.text, "bounded");
6462        }
6463    }
6464
6465    #[test]
6466    fn unsupported_docx_compression_is_rejected_before_text_read() {
6467        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
6468        let mut bytes = docx_archive(xml, CompressionMethod::Stored);
6469        rewrite_zip_method(&mut bytes, 12);
6470        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6471            .expect_err("unsupported compression must fail closed");
6472        assert!(matches!(
6473            error,
6474            DocumentExtractionError::UnsupportedDocxInput { .. }
6475        ));
6476    }
6477
6478    #[test]
6479    fn encrypted_docx_is_rejected_before_text_publication() {
6480        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
6481        let mut bytes = docx_archive(xml, CompressionMethod::Stored);
6482        mark_zip_encrypted(&mut bytes);
6483        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6484            .expect_err("encrypted packages must fail closed");
6485        assert!(matches!(
6486            error,
6487            DocumentExtractionError::UnsupportedDocxInput { .. }
6488        ));
6489    }
6490
6491    #[test]
6492    fn duplicate_docx_parts_are_rejected_before_document_read() {
6493        let mut bytes = Vec::new();
6494        {
6495            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6496            for (name, value) in [
6497                (DOCX_DOCUMENT_PART, b"first".as_slice()),
6498                ("word/duplicat.xml", b"second".as_slice()),
6499            ] {
6500                writer
6501                    .start_file(name, FileOptions::default())
6502                    .expect("fixture entry");
6503                writer.write_all(value).expect("fixture XML");
6504            }
6505            writer.finish().expect("fixture archive");
6506        }
6507        rename_zip_fixture_part(&mut bytes, "word/duplicat.xml", DOCX_DOCUMENT_PART);
6508        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6509            .expect_err("duplicate package parts must fail closed");
6510        assert!(matches!(
6511            error,
6512            DocumentExtractionError::InvalidDocxPackage { message }
6513                if message.contains("duplicate package part")
6514        ));
6515    }
6516
6517    /// Form malformed duplicate metadata without asking the ZIP writer to admit it.
6518    fn rename_zip_fixture_part(bytes: &mut [u8], from: &str, to: &str) {
6519        assert_eq!(from.len(), to.len());
6520        let positions: Vec<_> = bytes
6521            .windows(from.len())
6522            .enumerate()
6523            .filter_map(|(index, value)| (value == from.as_bytes()).then_some(index))
6524            .collect();
6525        assert_eq!(positions.len(), 2, "local and central fixture names");
6526        for index in positions {
6527            bytes[index..index + from.len()].copy_from_slice(to.as_bytes());
6528        }
6529    }
6530
6531    #[test]
6532    fn duplicate_unreferenced_docx_parts_are_rejected_at_every_position() {
6533        let names = ["word/first.bin", "word/other.bin", "word/third.bin"];
6534        for name in names {
6535            let mut bytes = docx_archive_with_parts(&[
6536                (DOCX_DOCUMENT_PART, b"not reached"),
6537                (names[0], b"first"),
6538                (names[1], b"middle"),
6539                (names[2], b"last"),
6540                ("word/spare.bin", b"duplicate"),
6541            ]);
6542            rename_zip_fixture_part(&mut bytes, "word/spare.bin", name);
6543            let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6544                .expect_err("unreferenced duplicate parts must fail before XML reads");
6545            assert!(matches!(
6546                error,
6547                DocumentExtractionError::InvalidDocxPackage { message }
6548                    if message.contains("duplicate package part")
6549            ));
6550        }
6551    }
6552
6553    #[test]
6554    fn docx_entry_budget_counts_records_hidden_by_name_deduplication() {
6555        let mut bytes = Vec::new();
6556        {
6557            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6558            for index in 0..=MAX_DOCUMENT_ENTRIES {
6559                writer
6560                    .start_file(format!("part-{index:03}.bin"), FileOptions::default())
6561                    .expect("fixture entry");
6562            }
6563            writer.finish().expect("fixture archive");
6564        }
6565        rename_zip_fixture_part(
6566            &mut bytes,
6567            &format!("part-{MAX_DOCUMENT_ENTRIES:03}.bin"),
6568            &format!("part-{:03}.bin", MAX_DOCUMENT_ENTRIES - 1),
6569        );
6570        let archive = ZipArchive::new(Cursor::new(&bytes)).expect("valid duplicate ZIP");
6571        assert_eq!(archive.len(), MAX_DOCUMENT_ENTRIES);
6572        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6573            .expect_err("all central records count toward the entry limit");
6574        assert!(matches!(
6575            error,
6576            DocumentExtractionError::ResourceLimit {
6577                limit: DocumentLimit::EntryCount,
6578                observed,
6579                maximum: MAX_DOCUMENT_ENTRIES,
6580            } if observed == MAX_DOCUMENT_ENTRIES + 1
6581        ));
6582    }
6583
6584    #[test]
6585    fn unreferenced_embedded_parts_do_not_trigger_recursive_parsing() {
6586        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
6587        let bytes = docx_archive_with_parts(&[
6588            (DOCX_DOCUMENT_PART, main),
6589            ("word/embeddings/oleObject1.bin", b"OLE"),
6590            ("word/embeddings/nested.docx", b"PK\x03\x04"),
6591        ]);
6592        let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6593            .expect("unreferenced embedded parts remain inert");
6594        assert_eq!(facts.text, "Visible");
6595        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
6596    }
6597
6598    #[test]
6599    fn non_utf8_docx_part_metadata_is_rejected() {
6600        let mut bytes = Vec::new();
6601        {
6602            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6603            writer
6604                .start_file("word/document.xml", FileOptions::default())
6605                .expect("fixture entry");
6606            writer
6607                .write_all(b"<w:document xmlns:w=\"urn:w\"><w:body/></w:document>")
6608                .expect("fixture XML");
6609            writer.finish().expect("fixture archive");
6610        }
6611        let name = b"word/document.xml";
6612        let positions = bytes
6613            .windows(name.len())
6614            .enumerate()
6615            .filter_map(|(index, candidate)| (candidate == name).then_some(index))
6616            .collect::<Vec<_>>();
6617        for index in positions {
6618            bytes[index] = 0xff;
6619        }
6620        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6621            .expect_err("non-UTF-8 package metadata must fail closed");
6622        assert!(matches!(
6623            error,
6624            DocumentExtractionError::InvalidDocxPackage { .. }
6625        ));
6626    }
6627
6628    #[test]
6629    fn docx_entry_count_is_bounded_before_archive_reads() {
6630        let mut bytes = Vec::new();
6631        {
6632            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6633            for index in 0..=MAX_DOCUMENT_ENTRIES {
6634                writer
6635                    .start_file(format!("parts/{index}.xml"), FileOptions::default())
6636                    .expect("fixture entry");
6637            }
6638            writer.finish().expect("fixture archive");
6639        }
6640        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6641            .expect_err("entry count must be bounded before archive reads");
6642        assert!(matches!(
6643            error,
6644            DocumentExtractionError::ResourceLimit {
6645                limit: DocumentLimit::EntryCount,
6646                observed,
6647                maximum: MAX_DOCUMENT_ENTRIES
6648            } if observed == MAX_DOCUMENT_ENTRIES + 1
6649        ));
6650    }
6651
6652    #[test]
6653    fn docx_part_name_fanout_is_bounded_before_locator_cloning() {
6654        let long_part = format!("word/{}.xml", "x".repeat(MAX_DOCX_PART_NAME_BYTES));
6655        let archive = docx_archive_with_parts(&[
6656            (DOCX_DOCUMENT_PART, b"<w:document/>"),
6657            (&long_part, b"<w:hdr/>"),
6658        ]);
6659        let error = extract_document_text_controlled(&archive, "long-name.docx", None, &control())
6660            .expect_err("large part names cannot multiply through thousands of locators");
6661        assert!(matches!(
6662            error,
6663            DocumentExtractionError::InvalidDocxPackage { message }
6664                if message.contains("part name exceeds")
6665        ));
6666    }
6667
6668    #[test]
6669    fn docx_expanded_size_is_bounded_before_decompression() {
6670        let mut bytes = Vec::new();
6671        {
6672            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
6673            writer
6674                .start_file(
6675                    DOCX_DOCUMENT_PART,
6676                    FileOptions::default().compression_method(CompressionMethod::Deflated),
6677                )
6678                .expect("fixture entry");
6679            writer
6680                .write_all(&vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 1])
6681                .expect("fixture payload");
6682            writer.finish().expect("fixture archive");
6683        }
6684        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6685            .expect_err("expanded ZIP size must be bounded before decompression");
6686        assert!(matches!(
6687            error,
6688            DocumentExtractionError::ResourceLimit {
6689                limit: DocumentLimit::ExpandedBytes,
6690                observed,
6691                maximum: MAX_DOCUMENT_EXPANDED_BYTES
6692            } if observed == MAX_DOCUMENT_EXPANDED_BYTES + 1
6693        ));
6694    }
6695
6696    #[test]
6697    fn docx_forged_catalog_size_is_rejected_before_text_publication() {
6698        let mut bytes = docx_archive(
6699            &vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES + 8192],
6700            CompressionMethod::Deflated,
6701        );
6702        for index in 0..bytes.len().saturating_sub(4) {
6703            let size_offset = match &bytes[index..index + 4] {
6704                b"PK\x03\x04" => index + 22,
6705                b"PK\x01\x02" => index + 24,
6706                _ => continue,
6707            };
6708            bytes[size_offset..size_offset + 4].copy_from_slice(&1_u32.to_le_bytes());
6709        }
6710        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
6711            .expect_err("actual decompression must be bounded independently of ZIP metadata");
6712        assert!(matches!(
6713            error,
6714            DocumentExtractionError::InvalidDocxPackage { message }
6715                if message.contains("declared uncompressed size")
6716        ));
6717    }
6718
6719    #[test]
6720    fn docx_repeat_reads_charge_actual_decompression_work() {
6721        let filler = vec![b'x'; MAX_DOCUMENT_EXPANDED_BYTES / 3 + 1];
6722        let bytes = docx_archive_with_parts(&[
6723            (DOCX_DOCUMENT_PART, br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#),
6724            ("word/repeated.bin", &filler),
6725        ]);
6726        let mut archive = ZipArchive::new(Cursor::new(bytes.as_slice())).expect("fixture ZIP");
6727        let mut budget = DocxReadBudget::default();
6728        for _ in 0..5 {
6729            read_docx_package_part(
6730                &mut archive,
6731                "word/repeated.bin",
6732                &mut budget,
6733                &control(),
6734                IndexWorkStage::TextIndex,
6735            )
6736            .expect("bounded repeat read");
6737        }
6738        assert!(matches!(
6739            read_docx_package_part(
6740                &mut archive,
6741                "word/repeated.bin",
6742                &mut budget,
6743                &control(),
6744                IndexWorkStage::TextIndex,
6745            ),
6746            Err(DocumentExtractionError::ResourceLimit {
6747                limit: DocumentLimit::ParserWorkBytes,
6748                ..
6749            }),
6750        ));
6751    }
6752
6753    #[test]
6754    fn docx_namespace_identity_is_independent_of_prefix() {
6755        let canonical = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A</w:t><w:tab/><w:t>B</w:t></w:r></w:p></w:body></w:document>"#;
6756        let expected = parse_docx(
6757            canonical.as_bytes(),
6758            &control(),
6759            IndexWorkStage::SymbolParsing,
6760        )
6761        .expect("canonical WordprocessingML");
6762        for xml in [
6763            canonical.replace("w:", "x:").replace("xmlns:w", "xmlns:x"),
6764            canonical.replace("w:", "").replace("xmlns:w", "xmlns"),
6765            canonical.replace(
6766                "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
6767                "http://purl.oclc.org/ooxml/wordprocessingml/main",
6768            ),
6769        ] {
6770            assert_eq!(
6771                parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
6772                    .expect("equivalent namespace identity"),
6773                expected,
6774            );
6775        }
6776        let foreign_text = canonical.replace(
6777            "<w:t>A</w:t>",
6778            "<foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t>",
6779        );
6780        assert!(matches!(
6781            parse_docx(
6782                foreign_text.as_bytes(),
6783                &control(),
6784                IndexWorkStage::SymbolParsing
6785            ),
6786            Err(DocumentExtractionError::UnsupportedDocxInput { .. }),
6787        ));
6788        for xml in [
6789            canonical.replace(
6790                "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
6791                "urn:foreign",
6792            ),
6793            canonical.replace(
6794                "<w:t>A</w:t>",
6795                "<w:t><foreign:t xmlns:foreign=\"urn:foreign\">A</foreign:t></w:t>",
6796            ),
6797            canonical.replace("<w:t>A</w:t>", "<unknown:t>A</unknown:t>"),
6798            canonical.replace("</w:r></w:p>", "</w:p></w:r>"),
6799        ] {
6800            assert!(matches!(
6801                parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing),
6802                Err(DocumentExtractionError::Malformed {
6803                    format: DocumentFormat::Docx,
6804                    ..
6805                }),
6806            ));
6807        }
6808    }
6809
6810    #[test]
6811    fn docx_ruby_annotations_are_typed_unsupported() {
6812        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Prefix</w:t><w:ruby><w:rt><w:r><w:t>Reading</w:t></w:r></w:rt><w:rubyBase><w:r><w:t>Base</w:t></w:r></w:rubyBase></w:ruby></w:r></w:p></w:body></w:document>"#;
6813        assert!(matches!(
6814            parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
6815            Err(DocumentExtractionError::UnsupportedDocxInput { .. })
6816        ));
6817        let deleted = xml
6818            .replace("<w:ruby>", "<w:del><w:ruby>")
6819            .replace("</w:ruby>", "</w:ruby></w:del>");
6820        assert_eq!(
6821            parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6822                .expect("deleted ruby stays excluded")
6823                .text,
6824            "Prefix"
6825        );
6826    }
6827
6828    #[test]
6829    fn docx_alternate_format_chunk_is_local_incomplete_coverage() {
6830        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Prefix</w:t></w:r></w:p><w:altChunk r:id="html"/></w:body></w:document>"#;
6831        let direct = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
6832            .expect("unexamined import retains verified surrounding text");
6833        assert_eq!(direct.text, "Prefix");
6834        assert_eq!(
6835            direct.completeness,
6836            DocumentCompleteness::Partial {
6837                gaps: vec![DocumentCoverageGap::UnexaminedStory],
6838            }
6839        );
6840        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="html" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="import.html"/></Relationships>"#;
6841        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="html" ContentType="text/html"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
6842        let imported = b"<!doctype html><html><body><p>Imported</p></body></html>";
6843        let archive = docx_archive_with_parts(&[
6844            ("[Content_Types].xml", manifest),
6845            (DOCX_DOCUMENT_PART, xml.as_bytes()),
6846            ("word/_rels/document.xml.rels", rels),
6847            ("word/import.html", imported),
6848        ]);
6849        let packaged = extract_document_text_controlled(&archive, "import.docx", None, &control())
6850            .expect("safe imported part is local incomplete coverage");
6851        assert_eq!(packaged.completeness, direct.completeness);
6852        let sdt = xml.replace(
6853            "<w:altChunk r:id=\"html\"/>",
6854            "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr w:val=\"false\"/></w:sdtPr><w:sdtContent><w:tbl><w:tr><w:tc><w:altChunk r:id=\"html\"/></w:tc></w:tr></w:tbl></w:sdtContent></w:sdt>",
6855        );
6856        let sdt_archive = docx_archive_with_parts(&[
6857            ("[Content_Types].xml", manifest),
6858            (DOCX_DOCUMENT_PART, sdt.as_bytes()),
6859            ("word/_rels/document.xml.rels", rels),
6860            ("word/import.html", imported),
6861        ]);
6862        let sdt_facts =
6863            extract_document_text_controlled(&sdt_archive, "import.docx", None, &control())
6864                .expect("actual alternate-format content must not load a glossary placeholder");
6865        assert_eq!(sdt_facts.text, "Prefix");
6866        assert_eq!(sdt_facts.completeness, direct.completeness);
6867        let text_box = xml.replace(
6868            "<w:altChunk r:id=\"html\"/>",
6869            &format!(
6870                "<w:p><w:r>{}</w:r></w:p>",
6871                docx_drawing_text_box("<w:altChunk r:id=\"html\"/>", 1)
6872            ),
6873        );
6874        let text_box_archive = docx_archive_with_parts(&[
6875            ("[Content_Types].xml", manifest),
6876            (
6877                "_rels/.rels",
6878                br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
6879            ),
6880            (DOCX_DOCUMENT_PART, text_box.as_bytes()),
6881            ("word/_rels/document.xml.rels", rels),
6882            ("word/import.html", imported),
6883        ]);
6884        let text_box_facts = extract_document_text_controlled(
6885            &text_box_archive,
6886            "text-box-import.docx",
6887            None,
6888            &control(),
6889        )
6890        .expect("rendered text-box import retains local incomplete coverage");
6891        assert_eq!(text_box_facts.text, "Prefix\n");
6892        assert_eq!(text_box_facts.completeness, direct.completeness);
6893        let rels = std::str::from_utf8(rels).expect("ASCII fixture");
6894        for broken in [
6895            rels.replace("relationships/aFChunk", "relationships/header"),
6896            rels.replace("Target=\"import.html\"", "Target=\"../escape.html\""),
6897            rels.replace(
6898                "Target=\"import.html\"",
6899                "Target=\"https://example.invalid/import.html\" TargetMode=\"External\"",
6900            ),
6901        ] {
6902            let archive = docx_archive_with_parts(&[
6903                ("[Content_Types].xml", manifest),
6904                (DOCX_DOCUMENT_PART, xml.as_bytes()),
6905                ("word/_rels/document.xml.rels", broken.as_bytes()),
6906                ("word/import.html", imported),
6907            ]);
6908            assert!(matches!(
6909                extract_document_text_controlled(&archive, "broken.docx", None, &control()),
6910                Err(DocumentExtractionError::InvalidDocxPackage { .. }),
6911            ));
6912        }
6913        let deleted = xml.replace(
6914            "<w:altChunk r:id=\"html\"/>",
6915            "<w:del><w:altChunk r:id=\"html\"/></w:del>",
6916        );
6917        assert_eq!(
6918            parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6919                .expect("deleted content stays excluded")
6920                .text,
6921            "Prefix"
6922        );
6923    }
6924
6925    #[test]
6926    fn docx_subdocuments_refuse_incomplete_text() {
6927        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Prefix</w:t></w:r><w:subDoc r:id="child"/></w:p></w:body></w:document>"#;
6928        for reference in [
6929            r#"<w:subDoc r:id="child"/>"#,
6930            r#"<w:subDoc r:id="child"></w:subDoc>"#,
6931        ] {
6932            let xml = xml.replace(r#"<w:subDoc r:id="child"/>"#, reference);
6933            let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
6934            let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
6935            assert!(
6936                matches!(
6937                    result,
6938                    Err(DocumentExtractionError::InvalidDocxPackage { .. })
6939                ),
6940                "{result:?}"
6941            );
6942            let deleted = xml.replace(reference, &format!("<w:del>{reference}</w:del>"));
6943            assert_eq!(
6944                parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
6945                    .expect("deleted subdocument remains excluded")
6946                    .text,
6947                "Prefix"
6948            );
6949        }
6950    }
6951
6952    #[test]
6953    fn docx_story_anchors_in_field_instructions_do_not_select_parts() {
6954        for anchor in [
6955            r#"<w:altChunk r:id="missing"/>"#,
6956            r#"<w:p><w:subDoc r:id="missing"/></w:p>"#,
6957        ] {
6958            let xml = format!(
6959                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r></w:p>{anchor}<w:p><w:r><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#
6960            );
6961            let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
6962            let result = extract_document_text_controlled(&bytes, "field.docx", None, &control())
6963                .expect("non-rendered instruction anchor needs no relationship");
6964            assert_eq!(result.text, "Result");
6965            assert_eq!(result.completeness, DocumentCompleteness::Complete);
6966        }
6967    }
6968
6969    #[test]
6970    fn docx_comment_ranges_require_content_parents_and_references() {
6971        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="comments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
6972        let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>CommentFour</w:t><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:comment><w:comment w:id="5"><w:p><w:r><w:t>CommentFive</w:t><w:sym w:font="Symbol" w:char="F062"/></w:r></w:p></w:comment></w:comments>"#;
6973        for valid in [
6974            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:commentRangeStart w:id="4"/><w:r><w:t>Visible</w:t></w:r><w:commentRangeEnd w:id="4"/></w:p></w:body></w:document>"#,
6975            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><mc:AlternateContent><mc:Choice Requires="w"><w:commentRangeStart w:id="4"/></mc:Choice><mc:Fallback/></mc:AlternateContent><w:r><w:t>Visible</w:t></w:r><w:commentRangeEnd w:id="4"/></w:p></w:body></w:document>"#,
6976        ] {
6977            let archive = docx_archive_with_parts(&[
6978                (DOCX_DOCUMENT_PART, valid.as_bytes()),
6979                ("word/_rels/document.xml.rels", rels),
6980                ("word/comments.xml", comments),
6981            ]);
6982            let rendered =
6983                extract_document_text_controlled(&archive, "content.docx", None, &control())
6984                    .expect("an unreferenced content-level range is ignored");
6985            assert_eq!(rendered.text, "Visible");
6986            assert!(rendered.symbols.is_empty());
6987            assert_eq!(rendered.completeness, DocumentCompleteness::Complete);
6988            for (reference_id, selected, excluded, code) in [
6989                (4, "CommentFour", "CommentFive", 0xF061),
6990                (5, "CommentFive", "CommentFour", 0xF062),
6991            ] {
6992                let referenced = valid.replace(
6993                    "<w:commentRangeEnd w:id=\"4\"/>",
6994                    &format!(
6995                        "<w:commentRangeEnd w:id=\"4\"/><w:r><w:commentReference w:id=\"{reference_id}\"/></w:r>"
6996                    ),
6997                );
6998                let archive = docx_archive_with_parts(&[
6999                    (DOCX_DOCUMENT_PART, referenced.as_bytes()),
7000                    ("word/_rels/document.xml.rels", rels),
7001                    ("word/comments.xml", comments),
7002                ]);
7003                let rendered =
7004                    extract_document_text_controlled(&archive, "content.docx", None, &control())
7005                        .expect("only the rendered reference selects its comment story");
7006                assert!(rendered.text.contains(selected));
7007                assert!(!rendered.text.contains(excluded));
7008                assert_eq!(rendered.symbols.len(), 1);
7009                assert_eq!(rendered.symbols[0].code, Some(code));
7010            }
7011        }
7012        for marker in ["commentRangeStart", "commentRangeEnd"] {
7013            let main = format!(
7014                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:{marker} w:id="4"/></w:pPr><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#
7015            );
7016            let archive = docx_archive_with_parts(&[
7017                (DOCX_DOCUMENT_PART, main.as_bytes()),
7018                ("word/_rels/document.xml.rels", rels),
7019                ("word/comments.xml", comments),
7020            ]);
7021            assert!(matches!(
7022                extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7023                Err(DocumentExtractionError::Malformed { message, .. })
7024                    if message.contains("comment range")
7025            ));
7026        }
7027    }
7028
7029    #[test]
7030    fn docx_utf16_xml_is_decoded_before_wordprocessing_parsing() {
7031        let xml = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7032        for little_endian in [true, false] {
7033            for bom in [true, false] {
7034                let mut bytes = Vec::new();
7035                for unit in bom.then_some(0xfeff).into_iter().chain(xml.encode_utf16()) {
7036                    bytes.extend_from_slice(&if little_endian {
7037                        unit.to_le_bytes()
7038                    } else {
7039                        unit.to_be_bytes()
7040                    });
7041                }
7042                assert_eq!(
7043                    parse_docx(&bytes, &control(), IndexWorkStage::TextIndex)
7044                        .expect("valid UTF-16 DOCX XML")
7045                        .text,
7046                    "Text"
7047                );
7048            }
7049        }
7050        let leading_whitespace = xml.split_once("?>").expect("fixture declaration").1;
7051        for little_endian in [true, false] {
7052            let mut bytes = Vec::new();
7053            for unit in format!(" \t\n{leading_whitespace}").encode_utf16() {
7054                bytes.extend_from_slice(&if little_endian {
7055                    unit.to_le_bytes()
7056                } else {
7057                    unit.to_be_bytes()
7058                });
7059            }
7060            assert_eq!(
7061                parse_docx(&bytes, &control(), IndexWorkStage::TextIndex)
7062                    .expect("BOM-less UTF-16 accepts leading XML whitespace")
7063                    .text,
7064                "Text"
7065            );
7066        }
7067        assert!(matches!(
7068            parse_docx(
7069                xml.replace("UTF-16", "ISO-8859-1").as_bytes(),
7070                &control(),
7071                IndexWorkStage::TextIndex
7072            ),
7073            Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7074        ));
7075        for encoding in ["UTF-8", "US-ASCII"] {
7076            assert!(
7077                parse_docx(
7078                    xml.replace("UTF-16", encoding).as_bytes(),
7079                    &control(),
7080                    IndexWorkStage::TextIndex
7081                )
7082                .is_ok()
7083            );
7084        }
7085    }
7086
7087    #[test]
7088    fn docx_xml_declarations_must_match_story_and_package_bytes() {
7089        let story = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7090        assert!(matches!(
7091            parse_docx(story.as_bytes(), &control(), IndexWorkStage::TextIndex),
7092            Err(DocumentExtractionError::Malformed { .. })
7093        ));
7094        for (little_endian, wrong_encoding) in
7095            [(true, "UTF-16BE"), (false, "UTF-16LE"), (true, "UTF-8")]
7096        {
7097            let mut encoded = vec![];
7098            for unit in story.replace("UTF-16", wrong_encoding).encode_utf16() {
7099                encoded.extend_from_slice(&if little_endian {
7100                    unit.to_le_bytes()
7101                } else {
7102                    unit.to_be_bytes()
7103                });
7104            }
7105            assert!(matches!(
7106                parse_docx(&encoded, &control(), IndexWorkStage::TextIndex),
7107                Err(DocumentExtractionError::Malformed { .. })
7108            ));
7109        }
7110        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#;
7111        let manifest = br#"<?xml version="1.0" encoding="ISO-8859-1"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
7112        let archive = docx_archive_with_parts(&[
7113            ("[Content_Types].xml", manifest),
7114            (DOCX_DOCUMENT_PART, main),
7115        ]);
7116        assert!(matches!(
7117            extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7118            Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7119        ));
7120        let root_rels = br#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
7121        let archive =
7122            docx_archive_with_parts(&[("_rels/.rels", root_rels), (DOCX_DOCUMENT_PART, main)]);
7123        assert!(matches!(
7124            extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
7125            Err(DocumentExtractionError::Malformed { .. })
7126        ));
7127        let late_story = format!(" <!--before-->{story}");
7128        assert!(matches!(
7129            parse_docx(late_story.as_bytes(), &control(), IndexWorkStage::TextIndex),
7130            Err(DocumentExtractionError::Malformed { .. })
7131        ));
7132        let late_manifest = format!(" <!--before-->{}", String::from_utf8_lossy(manifest));
7133        let archive = docx_archive_with_parts(&[
7134            ("[Content_Types].xml", late_manifest.as_bytes()),
7135            (DOCX_DOCUMENT_PART, main),
7136        ]);
7137        assert!(
7138            extract_document_text_controlled(&archive, "metadata.docx", None, &control()).is_err()
7139        );
7140        let settings_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7141        let late_settings = br#" <!--before--><?xml version="1.0" encoding="UTF-16"?><w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#;
7142        let archive = docx_archive_with_parts(&[
7143            (DOCX_DOCUMENT_PART, main),
7144            ("word/_rels/document.xml.rels", settings_rels),
7145            ("word/settings.xml", late_settings),
7146        ]);
7147        assert!(matches!(
7148            extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7149            Err(DocumentExtractionError::Malformed { .. })
7150        ));
7151    }
7152
7153    #[test]
7154    fn docx_direct_hidden_runs_do_not_emit_text_symbols_or_references() {
7155        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:t>Secret</w:t><w:sym w:font="Wingdings" w:char="F03A"/><w:footnoteReference w:id="1"/></w:r><w:r><w:rPr><w:vanish w:val="false"/></w:rPr><w:sym w:font="Symbol" w:char="F061"/></w:r><w:r><w:rPr><w:webHidden/></w:rPr><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7156        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
7157            .expect("directly hidden runs do not contribute rendered payload");
7158        assert_eq!(facts.text, "αVisible");
7159        assert_eq!(facts.symbols.len(), 1);
7160        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7161        assert!(matches!(
7162            &facts.symbols[0].locator,
7163            DocumentLocator::Docx { run: 2, .. }
7164        ));
7165        let invalid = String::from_utf8_lossy(xml).replace("w:val=\"false\"", "w:val=\"maybe\"");
7166        assert!(matches!(
7167            parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7168            Err(DocumentExtractionError::Malformed { .. })
7169        ));
7170        for (value, visible) in [
7171            ("", false),
7172            (" w:val=\"true\"", false),
7173            (" w:val=\"1\"", false),
7174            (" w:val=\"on\"", false),
7175            (" w:val=\"false\"", true),
7176            (" w:val=\"0\"", true),
7177            (" w:val=\"off\"", true),
7178        ] {
7179            let xml = format!(
7180                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr><w:vanish{value}/></w:rPr><w:t>Text</w:t></w:r></w:p></w:body></w:document>"
7181            );
7182            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7183                .expect("valid on/off run visibility");
7184            assert_eq!(facts.text == "Text", visible, "{value}");
7185        }
7186        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="note" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
7187        let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
7188        let archive = docx_archive_with_parts(&[
7189            (DOCX_DOCUMENT_PART, xml),
7190            ("word/_rels/document.xml.rels", rels),
7191            ("word/footnotes.xml", notes),
7192        ]);
7193        let facts =
7194            extract_document_text_controlled(&archive, "hidden-note.docx", None, &control())
7195                .expect("a hidden note reference does not reach the note story");
7196        assert_eq!(facts.text, "αVisible");
7197        assert_eq!(facts.symbols.len(), 1);
7198        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7199    }
7200
7201    #[test]
7202    fn docx_hidden_outer_run_does_not_publish_nested_text_box() {
7203        let main = format!(
7204            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr>{}</w:r><w:r><w:t>Visible</w:t></w:r></w:p><w:p><w:r><w:t>Following</w:t></w:r></w:p></w:body></w:document>",
7205            docx_drawing_text_box(
7206                "<w:p><w:r><w:t>Hidden</w:t><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/><w:footnoteReference w:id=\"1\"/></w:r></w:p>",
7207                1
7208            )
7209        );
7210        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="note" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
7211        let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
7212        let archive = docx_archive_with_parts(&[
7213            (DOCX_DOCUMENT_PART, main.as_bytes()),
7214            ("word/_rels/document.xml.rels", rels),
7215            ("word/footnotes.xml", notes),
7216        ]);
7217        let facts = extract_document_text_controlled(&archive, "hidden-box.docx", None, &control())
7218            .expect("a hidden outer run hides its text box and note references");
7219        assert_eq!(facts.text, "Visible\nFollowing");
7220        assert!(facts.symbols.is_empty());
7221        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7222        assert!(matches!(
7223            &facts.facts[1].locator,
7224            DocumentLocator::Docx { paragraph: 3, .. }
7225        ));
7226    }
7227
7228    #[test]
7229    fn docx_hidden_field_markers_keep_cached_result_state() {
7230        let template = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:instrText>PAGE</w:instrText><w:t>INSTRUCTION</w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="separate"/></w:r>CACHE<w:r><w:rPr><w:vanish/></w:rPr><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7231        for (cached, expected, partial) in [("<w:r><w:t>7</w:t></w:r>", "7", false), ("", "", true)]
7232        {
7233            let xml = template.replace("CACHE", cached);
7234            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7235                .expect("hidden field markers preserve field structure");
7236            assert_eq!(facts.text, expected);
7237            assert_eq!(
7238                matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField)),
7239                partial
7240            );
7241        }
7242    }
7243
7244    #[test]
7245    fn docx_style_dependent_visibility_is_partial() {
7246        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:rStyle w:val="Secret"/></w:rPr><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:body></w:document>"#;
7247        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="styles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
7248        let styles = br#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="character" w:styleId="Secret"><w:rPr><w:vanish/></w:rPr></w:style></w:styles>"#;
7249        let archive = docx_archive_with_parts(&[
7250            (DOCX_DOCUMENT_PART, main),
7251            ("word/_rels/document.xml.rels", rels),
7252            ("word/styles.xml", styles),
7253        ]);
7254        let facts = extract_document_text_controlled(&archive, "styled.docx", None, &control())
7255            .expect("unresolved style visibility is not a package error");
7256        assert!(
7257            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnresolvedVisibility))
7258        );
7259    }
7260
7261    #[test]
7262    fn docx_spec_vanish_without_direct_vanish_is_renderer_dependent() {
7263        for (properties, text, partial) in [
7264            ("<w:specVanish/>", "Text", true),
7265            ("<w:specVanish/><w:vanish/>", "", false),
7266            ("<w:vanish/><w:specVanish/>", "", false),
7267        ] {
7268            let xml = format!(
7269                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:rPr>{properties}</w:rPr><w:t>Text</w:t></w:r></w:p></w:body></w:document>"
7270            );
7271            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7272                .expect("specVanish is not a package error");
7273            assert_eq!(facts.text, text);
7274            assert_eq!(
7275                matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnresolvedVisibility)),
7276                partial
7277            );
7278        }
7279    }
7280
7281    #[test]
7282    fn docx_foreign_text_is_unsupported_without_hiding_word_text_boxes() {
7283        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMath><m:r><m:t>CONTENT</m:t></m:r></m:oMath></w:p></w:body></w:document>"#;
7284        for content in ["x", "<![CDATA[x]]>", "&#120;"] {
7285            assert!(matches!(
7286                parse_docx(
7287                    xml.replace("CONTENT", content).as_bytes(),
7288                    &control(),
7289                    IndexWorkStage::TextIndex
7290                ),
7291                Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7292            ));
7293        }
7294        assert!(matches!(
7295            parse_docx(
7296                xml.replace("CONTENT", "&unknown;").as_bytes(),
7297                &control(),
7298                IndexWorkStage::TextIndex
7299            ),
7300            Err(DocumentExtractionError::Malformed { .. })
7301        ));
7302        assert!(
7303            parse_docx(
7304                xml.replace("CONTENT", " ").as_bytes(),
7305                &control(),
7306                IndexWorkStage::TextIndex
7307            )
7308            .is_ok()
7309        );
7310        for content in [
7311            "<w:sym w:font=\"Symbol\" w:char=\"F061\"/>",
7312            "<w:t>Secret</w:t>",
7313            "<w:txbxContent><w:p><w:r><w:t>Secret</w:t></w:r></w:p></w:txbxContent>",
7314        ] {
7315            let opaque = format!(
7316                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:x=\"urn:opaque\"><w:body><w:p><w:r><x:opaque>{content}</x:opaque></w:r></w:p></w:body></w:document>"
7317            );
7318            assert!(matches!(
7319                parse_docx(opaque.as_bytes(), &control(), IndexWorkStage::TextIndex),
7320                Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7321            ));
7322        }
7323        for (word, drawing, inline) in [
7324            (
7325                "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
7326                "http://schemas.openxmlformats.org/drawingml/2006/main",
7327                "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
7328            ),
7329            (
7330                "http://purl.oclc.org/ooxml/wordprocessingml/main",
7331                "http://purl.oclc.org/ooxml/drawingml/main",
7332                "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing",
7333            ),
7334        ] {
7335            let xml = format!(
7336                "<w:document xmlns:w=\"{word}\" xmlns:a=\"{drawing}\" xmlns:wp=\"{inline}\"><w:body><w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><w:txbxContent><w:p><w:r><w:t>Box</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"
7337            );
7338            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7339                .expect("Word text inside recognized drawing wrappers");
7340            assert_eq!(facts.text, "Boxα\n");
7341            assert_eq!(facts.symbols.len(), 1);
7342            assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7343        }
7344    }
7345
7346    #[test]
7347    fn docx_deleted_revisions_do_not_publish_run_content() {
7348        for revision in ["del", "moveFrom"] {
7349            let xml = format!(
7350                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t></w:r><w:{revision}><w:r><w:delText>Removed</w:delText><w:fldChar w:fldCharType=\"begin\"/><w:sym w:font=\"Wingdings\" w:char=\"F020\"/><w:noBreakHyphen/><w:softHyphen/><w:tab/><w:ptab/><w:br/><w:cr/><w:lastRenderedPageBreak/></w:r><w:del><w:r><w:t>Nested</w:t></w:r></w:del></w:{revision}><w:r><w:t>After</w:t></w:r></w:p></w:body></w:document>"
7351            );
7352            let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7353                .expect("tracked revision content is valid XML");
7354            assert!(matches!(
7355                parse_docx(
7356                    xml.replace("Removed", "&unknown;").as_bytes(),
7357                    &control(),
7358                    IndexWorkStage::TextIndex
7359                ),
7360                Err(DocumentExtractionError::Malformed { .. })
7361            ));
7362            assert_eq!(parsed.text, "BeforeAfter", "{revision}");
7363            assert_eq!(parsed.facts.len(), 2);
7364            assert_eq!(parsed.facts[1].line_start, 1);
7365            assert_eq!(parsed.facts[1].line_end, 1);
7366            assert_eq!(
7367                parsed.facts[1].locator,
7368                DocumentLocator::Docx {
7369                    part: DOCX_DOCUMENT_PART.to_owned(),
7370                    paragraph: 1,
7371                    run: 4,
7372                    text_start: 0,
7373                    text_end: 5,
7374                }
7375            );
7376        }
7377    }
7378
7379    #[test]
7380    fn docx_dynamic_text_blocks_refuse_without_evaluation() {
7381        let template = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><w:body><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>7</w:t></w:r></w:fldSimple>BLOCK</w:p></w:body></w:document>"#;
7382        assert_eq!(
7383            parse_docx(
7384                template.replace("BLOCK", "").as_bytes(),
7385                &control(),
7386                IndexWorkStage::TextIndex
7387            )
7388            .expect("cached field result remains literal text")
7389            .text,
7390            "7"
7391        );
7392        for name in [
7393            "pgNum",
7394            "dayShort",
7395            "dayLong",
7396            "monthShort",
7397            "monthLong",
7398            "yearShort",
7399            "yearLong",
7400            "footnoteReference",
7401            "endnoteReference",
7402        ] {
7403            let attributes = if matches!(name, "footnoteReference" | "endnoteReference") {
7404                " w:id=\"1\""
7405            } else {
7406                ""
7407            };
7408            for element in [
7409                format!("<w:{name}{attributes}/>"),
7410                format!("<w:{name}{attributes}></w:{name}>"),
7411            ] {
7412                let run = format!("<w:r>{element}</w:r>");
7413                let facts = parse_docx(
7414                    template.replace("BLOCK", &run).as_bytes(),
7415                    &control(),
7416                    IndexWorkStage::TextIndex,
7417                )
7418                .expect("dynamic marker retains surrounding text with partial coverage");
7419                assert!(facts.text.contains('\u{fffc}'), "{name}");
7420                assert!(
7421                    matches!(facts.completeness, DocumentCompleteness::Partial { .. }),
7422                    "{name}"
7423                );
7424                for discarded in [
7425                    format!("<w:del>{run}</w:del>"),
7426                    format!("<w:moveFrom>{run}</w:moveFrom>"),
7427                    format!(
7428                        r#"<mc:AlternateContent><mc:Choice Requires="future">{run}</mc:Choice><mc:Fallback/></mc:AlternateContent>"#
7429                    ),
7430                ] {
7431                    assert_eq!(
7432                        parse_docx(
7433                            template.replace("BLOCK", &discarded).as_bytes(),
7434                            &control(),
7435                            IndexWorkStage::TextIndex
7436                        )
7437                        .expect("discarded dynamic text does not require evaluation")
7438                        .text,
7439                        "7",
7440                        "{name}"
7441                    );
7442                }
7443            }
7444        }
7445    }
7446
7447    #[test]
7448    fn docx_field_carriers_retain_only_literal_and_cached_text() {
7449        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t xml:space="preserve">Page </w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE &amp; <![CDATA[ignored]]></w:instrText><w:t>NOT RENDERED</w:t></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:instrText xml:space="preserve"> Literal</w:instrText></w:r><w:r><w:delInstrText>Deleted code</w:delInstrText><w:delText><![CDATA[Deleted text]]></w:delText></w:r></w:p></w:body></w:document>"#;
7450        let parsed = extract_document_text_controlled(
7451            &docx_archive(xml, CompressionMethod::Deflated),
7452            "fields.docx",
7453            None,
7454            &control(),
7455        )
7456        .expect("field instructions and deleted carriers are valid bounded XML");
7457        assert_eq!(parsed.text, "Page 7 Literal");
7458        assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
7459        let escaped_markers = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="b&#x65;gin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separ&#x61;te"/><w:t>7</w:t><w:fldChar w:fldCharType="e&#x6e;d"/></w:r></w:p></w:body></w:document>"#;
7460        let escaped = parse_docx(escaped_markers, &control(), IndexWorkStage::TextIndex)
7461            .expect("field marker values may contain XML character references");
7462        assert_eq!(escaped.text, "7");
7463        assert_eq!(escaped.completeness, DocumentCompleteness::Complete);
7464        let spaced_field = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>DATE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t xml:space="preserve"> </w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7465        let spaced = parse_docx(spaced_field, &control(), IndexWorkStage::TextIndex)
7466            .expect("retained whitespace is a cached field result");
7467        assert_eq!(spaced.text, " ");
7468        assert_eq!(spaced.completeness, DocumentCompleteness::Complete);
7469        for field in [
7470            "<w:fldSimple w:instr=\"PAGE\"/>",
7471            "<w:r><w:fldChar w:fldCharType=\"begin\"/></w:r><w:r><w:instrText> DATE </w:instrText></w:r><w:r><w:fldChar w:fldCharType=\"separate\"/></w:r><w:r><w:fldChar w:fldCharType=\"end\"/></w:r>",
7472        ] {
7473            let xml = format!(
7474                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t></w:r>{field}<w:r><w:t>After</w:t></w:r></w:p></w:body></w:document>"
7475            );
7476            let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7477                .expect("live field without cached text retains surrounding text");
7478            assert_eq!(parsed.text, "BeforeAfter");
7479            assert!(
7480                matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField)),
7481                "{field}"
7482            );
7483        }
7484        assert_eq!(parsed.facts.len(), 3);
7485        for (fact, (run, text)) in
7486            parsed
7487                .facts
7488                .iter()
7489                .zip([(1, "Page "), (5, "7"), (7, " Literal")])
7490        {
7491            assert_eq!(fact.text, text);
7492            assert_eq!(
7493                fact.locator,
7494                DocumentLocator::Docx {
7495                    part: DOCX_DOCUMENT_PART.to_owned(),
7496                    paragraph: 1,
7497                    run,
7498                    text_start: 0,
7499                    text_end: text.len(),
7500                }
7501            );
7502        }
7503        let nested = format!(
7504            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:fldChar w:fldCharType=\"begin\"/><w:instrText>OUTER</w:instrText>{}<w:fldChar w:fldCharType=\"begin\"/><w:instrText>INNER</w:instrText><w:fldChar w:fldCharType=\"separate\"/><w:instrText>Outer code remains ignored</w:instrText><w:fldChar w:fldCharType=\"end\"/><w:fldChar w:fldCharType=\"separate\"/><w:t>Result</w:t><w:fldChar w:fldCharType=\"end\"/></w:r></w:p></w:body></w:document>",
7505            docx_drawing_text_box("<w:p><w:r><w:instrText>Box</w:instrText></w:r></w:p>", 1)
7506        );
7507        let nested = parse_docx(nested.as_bytes(), &control(), IndexWorkStage::TextIndex)
7508            .expect("text boxes inside field instructions do not publish code text");
7509        assert_eq!(nested.text, "Result");
7510        assert_eq!(nested.facts.len(), 1);
7511        assert_eq!(nested.completeness, DocumentCompleteness::Complete);
7512        let simple_in_instruction = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r><w:fldSimple w:instr="PAGE"/><w:r><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7513        let simple = parse_docx(simple_in_instruction, &control(), IndexWorkStage::TextIndex)
7514            .expect("simple fields inside an outer instruction cannot render");
7515        assert_eq!(simple.text, "Result");
7516        assert_eq!(simple.completeness, DocumentCompleteness::Complete);
7517        let selected_simple = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><mc:AlternateContent><mc:Choice Requires="w"><w:fldSimple w:instr="PAGE"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:p></w:body></w:document>"#;
7518        let selected = parse_docx(selected_simple, &control(), IndexWorkStage::TextIndex)
7519            .expect("selected compatibility content preserves valid paragraph fields");
7520        assert!(
7521            matches!(selected.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField))
7522        );
7523        let symbol_field = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:sym w:font="Wingdings" w:char="F03A"/><w:fldChar w:fldCharType="separate"/><w:sym w:font="Symbol" w:char="F061"/><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7524        let symbol_field = parse_docx(symbol_field, &control(), IndexWorkStage::TextIndex)
7525            .expect("only the cached field result is rendered");
7526        assert_eq!(symbol_field.text, "α");
7527        assert_eq!(symbol_field.symbols.len(), 1);
7528        assert_eq!(symbol_field.completeness, DocumentCompleteness::Complete);
7529        let instruction_refs = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>FIELD</w:instrText><w:footnoteReference w:id="2"/><w:endnoteReference w:id="3"/><w:commentReference w:id="4"/><w:commentRangeStart w:id="4"/><w:commentRangeEnd w:id="4"/><w:pgNum/><w:tab/><w:fldChar w:fldCharType="separate"/><w:t>Visible</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
7530        let mut references = DocxStoryReferences::default();
7531        let instruction_refs = parse_docx_part(
7532            instruction_refs,
7533            DOCX_DOCUMENT_PART,
7534            "document",
7535            None,
7536            None,
7537            &mut references,
7538            &control(),
7539            IndexWorkStage::TextIndex,
7540        )
7541        .expect("field instructions cannot publish referenced stories or dynamic text");
7542        assert_eq!(instruction_refs.text, "Visible");
7543        assert!(instruction_refs.symbols.is_empty());
7544        assert!(references.items.is_empty());
7545        assert_eq!(
7546            instruction_refs.completeness,
7547            DocumentCompleteness::Complete
7548        );
7549        let text = std::str::from_utf8(xml).expect("UTF-8 fixture");
7550        for invalid in [
7551            text.replace("PAGE &amp; <![CDATA[ignored]]>", "<w:t>nested</w:t>"),
7552            text.replace("PAGE &amp; <![CDATA[ignored]]>", "&unknown;"),
7553            text.replace("<w:p>", "<w:p><w:instrText>outside run</w:instrText>"),
7554        ] {
7555            assert!(matches!(
7556                parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7557                Err(DocumentExtractionError::Malformed { .. })
7558            ));
7559        }
7560        for invalid in [
7561            text.replace("<w:body>", "<w:body><w:fldSimple w:instr=\"PAGE\"/>"),
7562            text.replace(
7563                "<w:p>",
7564                "<w:p><w:pPr><w:fldSimple w:instr=\"PAGE\"/></w:pPr>",
7565            ),
7566        ] {
7567            assert!(matches!(
7568                parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7569                Err(DocumentExtractionError::Malformed { .. })
7570            ));
7571            assert!(matches!(
7572                extract_document_text_controlled(
7573                    &docx_archive(invalid.as_bytes(), CompressionMethod::Deflated),
7574                    "invalid-field.docx",
7575                    None,
7576                    &control(),
7577                ),
7578                Err(DocumentExtractionError::Malformed { .. })
7579            ));
7580        }
7581        let deep = format!(
7582            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}</w:r></w:p></w:body></w:document>",
7583            "<w:fldChar w:fldCharType=\"begin\"/>".repeat(MAX_DOCX_XML_DEPTH + 1),
7584        );
7585        assert!(matches!(
7586            parse_docx(deep.as_bytes(), &control(), IndexWorkStage::TextIndex),
7587            Err(DocumentExtractionError::ResourceLimit {
7588                limit: DocumentLimit::NestingDepth,
7589                ..
7590            })
7591        ));
7592    }
7593
7594    #[test]
7595    fn docx_field_result_in_text_box_is_cached_only_when_content_is_retained() {
7596        for (content, retained) in [
7597            ("<w:t>7</w:t>", true),
7598            ("<w:sym w:font=\"Symbol\" w:char=\"F061\"/>", true),
7599            ("", false),
7600        ] {
7601            let xml = format!(
7602                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:fldChar w:fldCharType=\"begin\"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType=\"separate\"/>{}<w:fldChar w:fldCharType=\"end\"/></w:r></w:p></w:body></w:document>",
7603                docx_drawing_text_box(&format!("<w:p><w:r>{content}</w:r></w:p>"), 1)
7604            );
7605            let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7606                .expect("a text box can contain a field's cached result");
7607            assert_eq!(
7608                parsed.completeness == DocumentCompleteness::Complete,
7609                retained,
7610                "{content}"
7611            );
7612            assert_eq!(parsed.facts.is_empty(), !retained, "{content}");
7613        }
7614    }
7615
7616    #[test]
7617    fn docx_root_ignorable_policy_preserves_visible_text_and_refuses_other_policies() {
7618        use std::fmt::Write as _;
7619        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future" mc:Ignorable="future"><w:body><future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7620        for source in [
7621            xml.to_owned(),
7622            xml.replace("<future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper>", ""),
7623            xml.replace("mc:Ignorable=\"future\"", &format!("mc:Ignorable=\"{}\"", "future ".repeat(128))),
7624            xml.replace("<future:wrapper>", "<mc:AlternateContent><mc:Choice Requires=\"future\" mc:ProcessContent=\"future:wrapper\"><future:wrapper>").replace("</future:wrapper>", "</future:wrapper></mc:Choice><mc:Fallback/></mc:AlternateContent>"),
7625            xml.replace(
7626                "<future:wrapper>",
7627                "<alias:wrapper xmlns:alias=\"urn:future\">",
7628            )
7629            .replace("</future:wrapper>", "</alias:wrapper>"),
7630            xml.replace(
7631                "mc:Ignorable=\"future\"",
7632                "mc:Ignorable=\"future future w\"",
7633            ),
7634            xml.replace(
7635                "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
7636                "http://purl.oclc.org/ooxml/wordprocessingml/main",
7637            ),
7638            xml.replace("mc:", "compat:")
7639                .replace("xmlns:mc=", "xmlns:compat="),
7640        ] {
7641            let parsed = parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex)
7642                .expect("ignore only unknown root-policy namespaces");
7643            assert_eq!(parsed.text, "Visible");
7644            assert_eq!(parsed.facts.len(), 1);
7645            assert!(matches!(
7646                parsed.facts[0].locator,
7647                DocumentLocator::Docx {
7648                    paragraph: 1,
7649                    run: 1,
7650                    text_start: 0,
7651                    text_end: 7,
7652                    ..
7653                }
7654            ));
7655        }
7656        let deleted = xml
7657            .replace("<future:wrapper>", "<w:del mc:Ignorable=\"future\">")
7658            .replace("</future:wrapper>", "</w:del>");
7659        let parsed = parse_docx(deleted.as_bytes(), &control(), IndexWorkStage::TextIndex)
7660            .expect("deleted policies do not affect live text");
7661        assert_eq!(parsed.text, "Visible");
7662        assert!(matches!(
7663            parsed.facts[0].locator,
7664            DocumentLocator::Docx {
7665                paragraph: 2,
7666                run: 1,
7667                text_start: 0,
7668                text_end: 7,
7669                ..
7670            }
7671        ));
7672        for source in [
7673            xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"\""),
7674            xml.replace("mc:Ignorable=\"future\"", "mc:Ignorable=\"w\""),
7675            xml.replace(
7676                "<future:wrapper>",
7677                "<future:wrapper xmlns:future=\"urn:different\">",
7678            ),
7679            xml.replace("mc:Ignorable=\"future\"", "Ignorable=\"future\""),
7680        ] {
7681            assert!(matches!(
7682                parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex),
7683                Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7684            ));
7685        }
7686        for source in [
7687            xml.replace(
7688                "mc:Ignorable=\"future\"",
7689                "mc:ProcessContent=\"future:wrapper\"",
7690            ),
7691            xml.replace("<future:wrapper>", "<future:wrapper mc:ProcessContent=\"future:child\">"),
7692            xml.replace("<future:wrapper>", "<future:wrapper mc:MustUnderstand=\"future\">"),
7693            xml.replace("<future:wrapper>", "<future:wrapper mc:Ignorable=\"future\">"),
7694            xml.replace("<w:body>", "<w:body><mc:AlternateContent><mc:Choice Requires=\"w\" mc:ProcessContent=\"future:wrapper\">").replace("</w:body>", "</mc:Choice></mc:AlternateContent></w:body>"),
7695            xml.replace("mc:Ignorable=\"future\"", "mc:MustUnderstand=\"future\""),
7696            xml.replace(" mc:Ignorable=\"future\"", "")
7697                .replace("<w:body>", "<w:body mc:Ignorable=\"future\">"),
7698        ] {
7699            assert!(matches!(
7700                parse_docx(source.as_bytes(), &control(), IndexWorkStage::TextIndex),
7701                Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7702            ));
7703        }
7704        for policy in ["missing", "future:wrapper", "future\u{a0}w"] {
7705            assert!(matches!(
7706                parse_docx(
7707                    xml.replace(
7708                        "mc:Ignorable=\"future\"",
7709                        &format!("mc:Ignorable=\"{policy}\"")
7710                    )
7711                    .as_bytes(),
7712                    &control(),
7713                    IndexWorkStage::TextIndex
7714                ),
7715                Err(DocumentExtractionError::Malformed { .. })
7716            ));
7717        }
7718        for count in [
7719            MAX_DOCX_IGNORABLE_NAMESPACES,
7720            MAX_DOCX_IGNORABLE_NAMESPACES + 1,
7721        ] {
7722            let mut declarations = String::new();
7723            let mut prefixes = String::new();
7724            for index in 0..count {
7725                write!(declarations, "xmlns:n{index}=\"urn:{index}\" ")
7726                    .expect("namespace declaration");
7727                write!(prefixes, "n{index} ").expect("namespace prefix");
7728            }
7729            let source = xml
7730                .replace(
7731                    "<future:wrapper><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></future:wrapper>",
7732                    "",
7733                )
7734                .replace(
7735                    "mc:Ignorable=\"future\"",
7736                    &format!("{declarations} mc:Ignorable=\"{prefixes}\""),
7737                );
7738            let bytes = docx_archive(source.as_bytes(), CompressionMethod::Deflated);
7739            let result = extract_document_text_controlled(&bytes, "guide.docx", None, &control());
7740            if count == MAX_DOCX_IGNORABLE_NAMESPACES {
7741                assert_eq!(result.expect("bounded distinct namespaces").text, "Visible");
7742            } else {
7743                assert!(matches!(
7744                    result,
7745                    Err(DocumentExtractionError::UnsupportedDocxInput { .. })
7746                ));
7747            }
7748        }
7749    }
7750
7751    #[test]
7752    fn docx_compatibility_selects_one_understood_branch() {
7753        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><w:body><mc:AlternateContent><mc:Choice Requires="future"><w:p><w:r><w:t>Unsupported</w:t></w:r></w:p></mc:Choice><mc:Choice Requires="w"><w:p><w:r><w:t>Chosen</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>Fallback</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7754        let parsed = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7755            .expect("uncertain earlier choice is not guessed");
7756        assert!(parsed.text.is_empty());
7757        assert!(
7758            matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7759        );
7760        let fallback = xml.replace("Requires=\"w\"", "Requires=\"future\"");
7761        let parsed = parse_docx(fallback.as_bytes(), &control(), IndexWorkStage::TextIndex)
7762            .expect("fallback when no choice is understood");
7763        assert!(parsed.text.is_empty());
7764        assert!(
7765            matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7766        );
7767        let nested = xml.replace("<w:t>Chosen</w:t>", "<mc:AlternateContent><mc:Choice Requires=\"future\"><w:instrText>Discarded</w:instrText></mc:Choice><mc:Fallback><w:t>Nested</w:t></mc:Fallback></mc:AlternateContent>");
7768        let parsed = parse_docx(nested.as_bytes(), &control(), IndexWorkStage::TextIndex)
7769            .expect("nested alternatives preserve the containing run");
7770        assert!(parsed.text.is_empty());
7771        for first in [
7772            xml.replace("Requires=\"future\"", "Requires=\"w\""),
7773            xml.replace(
7774                "xmlns:future=\"urn:future\"",
7775                "xmlns:future=\"http://purl.oclc.org/ooxml/wordprocessingml/main\"",
7776            ),
7777        ] {
7778            let parsed = parse_docx(first.as_bytes(), &control(), IndexWorkStage::TextIndex)
7779                .expect("first understood branch and namespace aliases");
7780            assert_eq!(parsed.text, "Unsupported");
7781            assert_eq!(parsed.facts.len(), 1);
7782        }
7783        for invalid in [
7784            xml.replace("Requires=\"future\"", "Requires=\"\""),
7785            xml.replace("Requires=\"w\"", "Requires=\"w\u{a0}w\""),
7786            xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><w:p/>"),
7787            xml.replace("</mc:AlternateContent>", "<mc:Fallback/></mc:AlternateContent>"),
7788            xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Fallback/>"),
7789            xml.replace("<mc:AlternateContent>", "<mc:AlternateContent><mc:Choice Requires=\"future\"><w:r><w:t>&unknown;</w:t></w:r></mc:Choice>"),
7790        ] {
7791            assert!(matches!(parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
7792                Err(DocumentExtractionError::Malformed { .. })));
7793        }
7794    }
7795
7796    #[test]
7797    fn docx_skipped_compatibility_branch_validates_attribute_namespaces() {
7798        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><mc:AlternateContent><mc:Choice Requires="w"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7799        assert_eq!(
7800            parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7801                .expect("valid fallback remains unselected")
7802                .text,
7803            "Selected"
7804        );
7805        for malformed in [
7806            xml.replace("<mc:Fallback>", "<mc:Fallback x:extra=\"1\">"),
7807            xml.replace("<w:t>Ignored", "<w:t x:extra=\"1\">Ignored"),
7808        ] {
7809            assert!(matches!(
7810                parse_docx(malformed.as_bytes(), &control(), IndexWorkStage::TextIndex),
7811                Err(DocumentExtractionError::Malformed { message, .. })
7812                    if message.contains("undeclared namespace prefix")
7813            ));
7814        }
7815    }
7816
7817    #[test]
7818    fn docx_ignored_xml_rejects_invalid_entities() {
7819        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><mc:AlternateContent><mc:Choice Requires="w"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:t>Ignored</w:t></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7820        let escaped = xml.replace("<w:body>", "<w:body w:extra=\"A&amp;B\">");
7821        assert_eq!(
7822            parse_docx(escaped.as_bytes(), &control(), IndexWorkStage::TextIndex)
7823                .expect("defined entity in an ignored attribute is valid")
7824                .text,
7825            "Selected"
7826        );
7827        for malformed in [
7828            xml.replace("<w:body>", "<w:body w:extra=\"&undefined;\">"),
7829            xml.replace("<w:body>", "<w:body w:extra=\"&#0;\">"),
7830            xml.replace("<mc:Fallback>", "<mc:Fallback w:extra=\"&undefined;\">"),
7831            xml.replace("<w:t>Ignored", "<w:t w:extra=\"&undefined;\">Ignored"),
7832        ] {
7833            assert!(matches!(
7834                parse_docx(malformed.as_bytes(), &control(), IndexWorkStage::TextIndex),
7835                Err(DocumentExtractionError::Malformed { .. })
7836            ));
7837        }
7838        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
7839        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7840        let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:zoom w:extra="&undefined;"/></w:settings>"#;
7841        let archive = docx_archive_with_parts(&[
7842            (DOCX_DOCUMENT_PART, main),
7843            ("word/_rels/document.xml.rels", rels),
7844            ("word/settings.xml", settings),
7845        ]);
7846        assert!(matches!(
7847            extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7848            Err(DocumentExtractionError::Malformed { .. })
7849        ));
7850        let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:compat>&undefined;</w:compat></w:settings>"#;
7851        let archive = docx_archive_with_parts(&[
7852            (DOCX_DOCUMENT_PART, main),
7853            ("word/_rels/document.xml.rels", rels),
7854            ("word/settings.xml", settings),
7855        ]);
7856        assert!(matches!(
7857            extract_document_text_controlled(&archive, "settings.docx", None, &control()),
7858            Err(DocumentExtractionError::Malformed { .. })
7859        ));
7860        let valid_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:compat>&amp;</w:compat></w:settings>"#;
7861        let archive = docx_archive_with_parts(&[
7862            (DOCX_DOCUMENT_PART, main),
7863            ("word/_rels/document.xml.rels", rels),
7864            ("word/settings.xml", valid_settings),
7865        ]);
7866        extract_document_text_controlled(&archive, "settings.docx", None, &control())
7867            .expect("predefined entity in ignored settings text is valid");
7868        let relationships = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships" extra="&undefined;"/>"#;
7869        assert!(matches!(
7870            parse_docx_relationships(relationships, &control(), IndexWorkStage::TextIndex),
7871            Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7872        ));
7873        let content_types = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types" extra="&undefined;"/>"#;
7874        assert!(matches!(
7875            parse_docx_content_types(content_types, &control(), IndexWorkStage::TextIndex),
7876            Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7877        ));
7878        let notes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></w:footnote><w:footnote w:id="2" w:extra="&undefined;"/></w:footnotes>"#;
7879        let selected = HashSet::from(["1".to_owned()]);
7880        assert!(matches!(
7881            parse_docx_part(
7882                notes,
7883                "word/footnotes.xml",
7884                "footnotes",
7885                Some(&selected),
7886                None,
7887                &mut DocxStoryReferences::default(),
7888                &control(),
7889                IndexWorkStage::TextIndex,
7890            ),
7891            Err(DocumentExtractionError::Malformed { message, .. }) if message.contains("undefined")
7892        ));
7893    }
7894
7895    #[test]
7896    fn docx_settings_reject_content_outside_its_root() {
7897        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
7898        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
7899        let settings = "<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:evenAndOddHeaders/></w:settings>";
7900        let archive = |settings: &str| {
7901            docx_archive_with_parts(&[
7902                (DOCX_DOCUMENT_PART, main),
7903                ("word/_rels/document.xml.rels", rels),
7904                ("word/settings.xml", settings.as_bytes()),
7905            ])
7906        };
7907        let internal = settings.replace("</w:settings>", "&#x20;<![CDATA[ ]]></w:settings>");
7908        extract_document_text_controlled(&archive(&internal), "settings.docx", None, &control())
7909            .expect("structural XML whitespace inside settings remains valid");
7910        for invalid in [
7911            format!("junk{settings}"),
7912            format!("{settings}junk"),
7913            format!("&#x20;{settings}"),
7914            format!("{settings}&#x20;"),
7915            format!("<![CDATA[ ]]>{settings}"),
7916            format!("{settings}<![CDATA[ ]]>"),
7917        ] {
7918            assert!(
7919                matches!(
7920                    extract_document_text_controlled(
7921                        &archive(&invalid),
7922                        "settings.docx",
7923                        None,
7924                        &control()
7925                    ),
7926                    Err(DocumentExtractionError::Malformed { .. })
7927                ),
7928                "{invalid}"
7929            );
7930        }
7931    }
7932
7933    #[test]
7934    fn docx_selected_compatibility_run_properties_hide_payload() {
7935        for properties in [
7936            "<mc:AlternateContent><mc:Choice Requires=\"w\"><w:rPr><w:vanish/></w:rPr></mc:Choice><mc:Fallback><w:rPr/></mc:Fallback></mc:AlternateContent>",
7937            "<w:rPr><mc:AlternateContent><mc:Choice Requires=\"w\"><w:vanish/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:rPr>",
7938        ] {
7939            let xml = format!(
7940                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body><w:p><w:r>{properties}<w:t>Secret</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/><w:footnoteReference w:id=\"1\"/></w:r><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"
7941            );
7942            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
7943                .expect("selected compatibility formatting hides its run");
7944            assert_eq!(facts.text, "Visible");
7945            assert!(facts.symbols.is_empty());
7946            assert_eq!(facts.completeness, DocumentCompleteness::Complete);
7947        }
7948    }
7949
7950    #[test]
7951    fn docx_unknown_compatibility_choice_never_claims_complete_symbol_coverage() {
7952        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><mc:AlternateContent><mc:Choice Requires="wps"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></mc:Choice><mc:Choice Requires="w"><w:p><w:r><w:sym w:font="Symbol" w:char="F063"/></w:r></w:p></mc:Choice><mc:Fallback><w:p><w:r><w:sym w:font="Symbol" w:char="F062"/></w:r></w:p></mc:Fallback></mc:AlternateContent></w:body></w:document>"#;
7953        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
7954            .expect("fallback remains bounded evidence");
7955        assert!(facts.text.is_empty());
7956        assert!(facts.symbols.is_empty());
7957        assert!(
7958            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
7959        );
7960    }
7961
7962    #[test]
7963    fn docx_nested_text_boxes_preserve_run_order_and_locators() {
7964        let xml = format!(
7965            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t>{}<w:t>After</w:t></w:r></w:p><w:p><w:r><w:t>Following</w:t></w:r></w:p></w:body></w:document>",
7966            docx_drawing_text_box("<w:p><w:r><w:t>Inside</w:t></w:r></w:p>", 1)
7967        );
7968        let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Stored);
7969        let parsed = extract_document_text_controlled(&bytes, "text-box.docx", None, &control())
7970            .expect("nested text container is valid WordprocessingML");
7971        assert_eq!(parsed.text, "Before\nInside\nAfter\nFollowing");
7972        assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
7973        assert_eq!(parsed.facts.len(), 4);
7974        for (fact, (text, paragraph, start, end, line)) in parsed.facts.iter().zip([
7975            ("Before", 1, 0, 6, 1),
7976            ("Inside", 2, 0, 6, 2),
7977            ("After", 1, 6, 11, 3),
7978            ("Following", 3, 0, 9, 4),
7979        ]) {
7980            assert_eq!(fact.text, text);
7981            assert_eq!((fact.line_start, fact.line_end), (line, line));
7982            assert_eq!(
7983                fact.locator,
7984                DocumentLocator::Docx {
7985                    part: DOCX_DOCUMENT_PART.to_owned(),
7986                    paragraph,
7987                    run: 1,
7988                    text_start: start,
7989                    text_end: end,
7990                }
7991            );
7992        }
7993    }
7994
7995    #[test]
7996    fn docx_separate_text_boxes_have_distinct_symbol_locators() {
7997        let content = "<w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>";
7998        let xml = format!(
7999            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}{}</w:r></w:p></w:body></w:document>",
8000            docx_drawing_text_box(content, 1),
8001            docx_drawing_text_box(content, 2)
8002        );
8003        let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
8004            .expect("both text boxes have rendered symbols");
8005        assert_eq!(facts.symbols.len(), 2);
8006        assert_ne!(facts.symbols[0].locator, facts.symbols[1].locator);
8007    }
8008
8009    #[test]
8010    fn docx_note_item_locators_stay_distinct_across_incremental_parses() {
8011        let xml = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="1"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote></w:footnotes>"#;
8012        let parse = |id: &str| {
8013            parse_docx_part(
8014                xml,
8015                "word/footnotes.xml",
8016                "footnotes",
8017                Some(&HashSet::from([id.to_owned()])),
8018                None,
8019                &mut DocxStoryReferences::default(),
8020                &control(),
8021                IndexWorkStage::TextIndex,
8022            )
8023        };
8024        let first = parse("1").expect("first note");
8025        let second = parse("2").expect("second note");
8026        assert_ne!(first.symbols[0].locator, second.symbols[0].locator);
8027    }
8028
8029    #[test]
8030    fn docx_story_ids_match_decimal_value_and_reject_numeric_duplicates() {
8031        for (kind, reference, root, item) in [
8032            ("footnotes", "footnoteReference", "footnotes", "footnote"),
8033            ("endnotes", "endnoteReference", "endnotes", "endnote"),
8034            ("comments", "commentReference", "comments", "comment"),
8035        ] {
8036            let marker = if kind == "comments" {
8037                "<w:commentRangeStart w:id=\"01\"/><w:r><w:commentReference w:id=\"+01\"/></w:r>"
8038                    .to_owned()
8039            } else {
8040                format!("<w:r><w:{reference} w:id=\"+01\"/></w:r>")
8041            };
8042            let main = format!(
8043                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p>{marker}</w:p></w:body></w:document>"
8044            );
8045            let rels = format!(
8046                "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"story\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}\" Target=\"{kind}.xml\"/></Relationships>"
8047            );
8048            let story = format!(
8049                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\"><w:p><w:r><w:t>Matched</w:t></w:r></w:p></w:{item}></w:{root}>"
8050            );
8051            let part = format!("word/{kind}.xml");
8052            let package = |story: &str| {
8053                docx_archive_with_parts(&[
8054                    (DOCX_DOCUMENT_PART, main.as_bytes()),
8055                    ("word/_rels/document.xml.rels", rels.as_bytes()),
8056                    (&part, story.as_bytes()),
8057                ])
8058            };
8059            let facts = extract_document_text_controlled(
8060                &package(&story),
8061                "numeric-ids.docx",
8062                None,
8063                &control(),
8064            )
8065            .expect("decimal-equivalent story reference resolves");
8066            assert!(facts.text.contains("Matched"), "missing {kind} item");
8067            let duplicate = story.replace(
8068                &format!("</w:{root}>"),
8069                &format!("<w:{item} w:id=\"001\"/></w:{root}>"),
8070            );
8071            assert!(matches!(
8072                extract_document_text_controlled(
8073                    &package(&duplicate),
8074                    "duplicate-ids.docx",
8075                    None,
8076                    &control(),
8077                ),
8078                Err(DocumentExtractionError::Malformed { .. })
8079            ));
8080        }
8081        for (raw, canonical) in [("+0001", "1"), ("-00", "0"), (" -001 ", "-1")] {
8082            assert_eq!(
8083                canonical_docx_story_id(raw).expect("valid XML decimal story ID"),
8084                canonical
8085            );
8086        }
8087        for raw in ["", "+", "1.0", "1e0", "1 0"] {
8088            assert!(matches!(
8089                canonical_docx_story_id(raw),
8090                Err(DocumentExtractionError::Malformed { .. })
8091            ));
8092        }
8093    }
8094
8095    #[test]
8096    fn docx_compatibility_wrapped_story_items_follow_selected_ids() {
8097        for (root, item) in [
8098            ("footnotes", "footnote"),
8099            ("endnotes", "endnote"),
8100            ("comments", "comment"),
8101        ] {
8102            let xml = format!(
8103                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:AlternateContent><mc:Choice Requires=\"w\"><w:{item} w:id=\"1\"><w:p><w:r><w:t>Wrapped</w:t></w:r></w:p></w:{item}></mc:Choice><mc:Fallback><w:{item} w:id=\"1\"><w:p><w:r><w:t>Unselected</w:t></w:r></w:p></w:{item}></mc:Fallback></mc:AlternateContent><w:{item} w:id=\"2\"><w:p><w:r><w:t>Ordinary</w:t></w:r></w:p></w:{item}></w:{root}>"
8104            );
8105            let parse = |id: &str| {
8106                parse_docx_part(
8107                    xml.as_bytes(),
8108                    "word/story.xml",
8109                    root,
8110                    Some(&HashSet::from([id.to_owned()])),
8111                    None,
8112                    &mut DocxStoryReferences::default(),
8113                    &control(),
8114                    IndexWorkStage::TextIndex,
8115                )
8116            };
8117            let selected = parse("1").expect("wrapped selected story item is found");
8118            assert_eq!(selected.text, "Wrapped", "{root}");
8119            let ordinary = parse("2").expect("unreferenced wrapped item is skipped");
8120            assert_eq!(ordinary.text, "Ordinary", "{root}");
8121        }
8122    }
8123
8124    #[test]
8125    fn docx_story_roots_reject_non_item_content() {
8126        for (root, item) in [
8127            ("footnotes", "footnote"),
8128            ("endnotes", "endnote"),
8129            ("comments", "comment"),
8130        ] {
8131            for wrapped in [false, true] {
8132                let rogue = "<w:p><w:r><w:t>Rogue</w:t><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>";
8133                let rogue = if wrapped {
8134                    format!(
8135                        "<mc:AlternateContent><mc:Choice Requires=\"w\">{rogue}</mc:Choice><mc:Fallback/></mc:AlternateContent>"
8136                    )
8137                } else {
8138                    rogue.to_owned()
8139                };
8140                let xml = format!(
8141                    "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\">{rogue}<w:{item} w:id=\"1\"><w:p><w:r><w:t>Selected</w:t></w:r></w:p></w:{item}></w:{root}>"
8142                );
8143                assert!(
8144                    matches!(
8145                        parse_docx_part(
8146                            xml.as_bytes(),
8147                            "word/story.xml",
8148                            root,
8149                            Some(&HashSet::from(["1".to_owned()])),
8150                            None,
8151                            &mut DocxStoryReferences::default(),
8152                            &control(),
8153                            IndexWorkStage::TextIndex,
8154                        ),
8155                        Err(DocumentExtractionError::Malformed { .. })
8156                    ),
8157                    "{root}, wrapped={wrapped}"
8158                );
8159            }
8160        }
8161    }
8162
8163    #[test]
8164    fn docx_story_items_reject_nested_items() {
8165        for (root, item) in [
8166            ("footnotes", "footnote"),
8167            ("endnotes", "endnote"),
8168            ("comments", "comment"),
8169        ] {
8170            for nested in ["footnote", "endnote", "comment"] {
8171                let xml = format!(
8172                    "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\"><w:{nested} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{nested}></w:{item}></w:{root}>"
8173                );
8174                assert!(
8175                    matches!(
8176                        parse_docx_part(
8177                            xml.as_bytes(),
8178                            "word/story.xml",
8179                            root,
8180                            Some(&HashSet::from(["1".to_owned()])),
8181                            None,
8182                            &mut DocxStoryReferences::default(),
8183                            &control(),
8184                            IndexWorkStage::TextIndex,
8185                        ),
8186                        Err(DocumentExtractionError::Malformed { .. })
8187                    ),
8188                    "{root}/{nested}"
8189                );
8190            }
8191        }
8192        for item in ["footnote", "endnote", "comment"] {
8193            let xml = format!(
8194                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:{item} w:id=\"1\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}></w:body></w:document>"
8195            );
8196            assert!(
8197                matches!(
8198                    parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8199                    Err(DocumentExtractionError::Malformed { .. })
8200                ),
8201                "document/{item}"
8202            );
8203        }
8204    }
8205
8206    #[test]
8207    fn docx_wrapped_continuation_note_does_not_claim_later_text() {
8208        for (root, item) in [("footnotes", "footnote"), ("endnotes", "endnote")] {
8209            let xml = format!(
8210                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:AlternateContent><mc:Choice Requires=\"w\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p/></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:t>Normal</w:t></w:r></w:p></w:{item}></mc:Choice><mc:Fallback/></mc:AlternateContent></w:{root}>"
8211            );
8212            let facts = parse_docx_part(
8213                xml.as_bytes(),
8214                "word/notes.xml",
8215                root,
8216                Some(&HashSet::from(["2".to_owned()])),
8217                Some(&HashSet::from(["1".to_owned()])),
8218                &mut DocxStoryReferences::default(),
8219                &control(),
8220                IndexWorkStage::TextIndex,
8221            )
8222            .expect("empty continuation note cannot taint a later note");
8223            assert_eq!(facts.text, "Normal");
8224            assert_eq!(facts.completeness, DocumentCompleteness::Complete);
8225        }
8226    }
8227
8228    #[test]
8229    fn docx_note_special_items_are_included_once_and_conditional_items_are_partial() {
8230        for (root, item) in [("footnotes", "footnote"), ("endnotes", "endnote")] {
8231            let xml = format!(
8232                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"0\" w:type=\"separator\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"-1\" w:type=\"continuationNotice\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"3\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}><w:{item} w:id=\"99\"><w:p><w:r><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/></w:r></w:p></w:{item}></w:{root}>"
8233            );
8234            let special_ids = HashSet::from(["0".to_owned(), "1".to_owned(), "-1".to_owned()]);
8235            let parse = |id: &str, selected_special_ids: Option<&HashSet<String>>| {
8236                parse_docx_part(
8237                    xml.as_bytes(),
8238                    "word/notes.xml",
8239                    root,
8240                    Some(&HashSet::from([id.to_owned()])),
8241                    selected_special_ids,
8242                    &mut DocxStoryReferences::default(),
8243                    &control(),
8244                    IndexWorkStage::TextIndex,
8245                )
8246            };
8247            let first = parse("2", Some(&special_ids)).expect("referenced note and special items");
8248            assert!(matches!(
8249                parse("0", Some(&special_ids)),
8250                Err(DocumentExtractionError::InvalidDocxPackage { .. })
8251            ));
8252            assert!(matches!(
8253                parse("2", Some(&HashSet::from(["777".to_owned()]))),
8254                Err(DocumentExtractionError::InvalidDocxPackage { .. })
8255            ));
8256            assert_eq!(first.symbols.len(), 4);
8257            assert!(
8258                first
8259                    .symbols
8260                    .iter()
8261                    .all(|symbol| symbol.unicode == Some('α'))
8262            );
8263            assert!(
8264                matches!(first.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory))
8265            );
8266            let later = parse("3", None).expect("new note without duplicate special items");
8267            assert_eq!(later.symbols.len(), 1);
8268            assert_eq!(later.completeness, DocumentCompleteness::Complete);
8269            let no_text = format!(
8270                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:{item}></w:{root}>"
8271            );
8272            let no_text = parse_docx_part(
8273                no_text.as_bytes(),
8274                "word/notes.xml",
8275                root,
8276                Some(&HashSet::from(["2".to_owned()])),
8277                Some(&HashSet::from(["1".to_owned()])),
8278                &mut DocxStoryReferences::default(),
8279                &control(),
8280                IndexWorkStage::TextIndex,
8281            )
8282            .expect("non-text continuation marker does not hide a symbol");
8283            assert_eq!(no_text.symbols.len(), 1);
8284            assert_eq!(no_text.completeness, DocumentCompleteness::Complete);
8285            let conditional_comment = format!(
8286                "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"1\" w:type=\"continuationSeparator\"><w:p><w:r><w:commentReference w:id=\"4\"/></w:r></w:p></w:{item}><w:{item} w:id=\"2\"><w:p><w:r><w:t>Normal</w:t></w:r></w:p></w:{item}></w:{root}>"
8287            );
8288            let mut references = DocxStoryReferences::default();
8289            let conditional_comment = parse_docx_part(
8290                conditional_comment.as_bytes(),
8291                "word/notes.xml",
8292                root,
8293                Some(&HashSet::from(["2".to_owned()])),
8294                Some(&HashSet::from(["1".to_owned()])),
8295                &mut references,
8296                &control(),
8297                IndexWorkStage::TextIndex,
8298            )
8299            .expect("comment reference in a continuation item remains conditional");
8300            assert_eq!(conditional_comment.text, "\u{fffc}\nNormal");
8301            assert_eq!(
8302                references.items,
8303                vec![(DocxStoryKind::Comments, "4".to_owned())]
8304            );
8305            assert!(matches!(
8306                conditional_comment.completeness,
8307                DocumentCompleteness::Partial { ref gaps }
8308                    if gaps.contains(&DocumentCoverageGap::ConditionalStory)
8309            ));
8310            for (id, item_type, special) in [("2", "", false), ("0", " w:type=\"separator\"", true)]
8311            {
8312                let duplicate = format!(
8313                    "<w:{root} xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:{item} w:id=\"{id}\"{item_type}><w:p><w:r><w:t>First</w:t></w:r></w:p></w:{item}><w:{item} w:id=\"{id}\"{item_type}><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:{item}></w:{root}>"
8314                );
8315                let selected_ids = HashSet::from(["2".to_owned()]);
8316                let special_ids = HashSet::from(["0".to_owned()]);
8317                assert!(matches!(
8318                    parse_docx_part(
8319                        duplicate.as_bytes(),
8320                        "word/notes.xml",
8321                        root,
8322                        Some(&selected_ids),
8323                        special.then_some(&special_ids),
8324                        &mut DocxStoryReferences::default(),
8325                        &control(),
8326                        IndexWorkStage::TextIndex,
8327                    ),
8328                    Err(DocumentExtractionError::Malformed { .. })
8329                ));
8330            }
8331        }
8332        let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>First</w:t></w:r></w:p></w:comment><w:comment w:id="4"><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:comment></w:comments>"#;
8333        assert!(matches!(
8334            parse_docx_part(
8335                comments,
8336                "word/comments.xml",
8337                "comments",
8338                Some(&HashSet::from(["4".to_owned()])),
8339                None,
8340                &mut DocxStoryReferences::default(),
8341                &control(),
8342                IndexWorkStage::TextIndex,
8343            ),
8344            Err(DocumentExtractionError::Malformed { .. })
8345        ));
8346    }
8347
8348    #[test]
8349    fn docx_utf16_normalization_checks_peak_memory_before_allocation() {
8350        let mut xml = vec![0_u8; MAX_DOCUMENT_EXPANDED_BYTES];
8351        xml[..2].copy_from_slice(&[0xff, 0xfe]);
8352        for pair in xml[2..].as_chunks_mut::<2>().0 {
8353            pair.copy_from_slice(&0x0800_u16.to_le_bytes());
8354        }
8355        assert!(matches!(
8356            decode_docx_xml(xml, &control(), IndexWorkStage::TextIndex),
8357            Err(DocumentExtractionError::ResourceLimit {
8358                limit: DocumentLimit::MemoryBytes,
8359                ..
8360            })
8361        ));
8362    }
8363
8364    #[test]
8365    fn docx_namespace_storage_is_charged_before_parser_allocation() {
8366        let xml = format!(
8367            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:unused=\"{}\"><w:body/></w:document>",
8368            "x".repeat(24 * 1024 * 1024),
8369        );
8370        let bytes = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
8371        let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8372            .expect("safe package memory ceiling is document-local incomplete coverage");
8373        assert_eq!(
8374            facts.completeness,
8375            DocumentCompleteness::Partial {
8376                gaps: vec![DocumentCoverageGap::ResourceLimit(
8377                    DocumentLimit::MemoryBytes
8378                )],
8379            }
8380        );
8381    }
8382
8383    #[test]
8384    fn docx_empty_elements_preserve_paragraph_and_run_ordinals() {
8385        let xml = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p/><w:p><w:r/><w:r><w:t>A</w:t><w:tab/><w:t/><w:t>B</w:t></w:r></w:p></w:body></w:document>";
8386        let facts = parse_docx(xml, &control(), IndexWorkStage::SymbolParsing)
8387            .expect("empty elements are valid document structure");
8388        assert_eq!(facts.text, "A\tB");
8389        assert_eq!(facts.facts.len(), 1);
8390        assert!(matches!(
8391            facts.facts[0].locator,
8392            DocumentLocator::Docx {
8393                paragraph: 2,
8394                run: 2,
8395                text_start: 0,
8396                text_end: 3,
8397                ..
8398            }
8399        ));
8400        let expanded = String::from_utf8(xml.to_vec())
8401            .expect("UTF-8 fixture")
8402            .replace("<w:p/>", "<w:p></w:p>")
8403            .replace("<w:r/>", "<w:r></w:r>")
8404            .replace("<w:t/>", "<w:t></w:t>")
8405            .replace("<w:tab/>", "<w:tab></w:tab>");
8406        let expanded_facts = parse_docx(
8407            expanded.as_bytes(),
8408            &control(),
8409            IndexWorkStage::SymbolParsing,
8410        )
8411        .expect("equivalent explicit empty elements");
8412        assert_eq!(facts, expanded_facts);
8413    }
8414
8415    #[test]
8416    fn docx_xml_nesting_is_bounded() {
8417        let xml = format!(
8418            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{}<w:body/>{}</w:document>",
8419            "<w:container>".repeat(MAX_DOCX_XML_DEPTH),
8420            "</w:container>".repeat(MAX_DOCX_XML_DEPTH),
8421        );
8422        let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
8423            .expect_err("XML nesting must be bounded");
8424        assert!(matches!(
8425            error,
8426            DocumentExtractionError::ResourceLimit {
8427                limit: DocumentLimit::NestingDepth,
8428                observed,
8429                maximum: MAX_DOCX_XML_DEPTH,
8430            } if observed == MAX_DOCX_XML_DEPTH + 1
8431        ));
8432    }
8433
8434    #[test]
8435    fn docx_aggregate_output_is_bounded_before_reading_remaining_xml() {
8436        let run = "x".repeat(MAX_DOCUMENT_OUTPUT_BYTES / 2 + 1);
8437        let xml = format!(
8438            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>{run}</w:t></w:r><w:r><w:t>{run}</w:t></w:r><malformed"
8439        );
8440        let error = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::SymbolParsing)
8441            .expect_err("aggregate output must fail before the later malformed XML");
8442        assert!(matches!(
8443            error,
8444            DocumentExtractionError::ResourceLimit {
8445                limit: DocumentLimit::OutputBytes,
8446                observed,
8447                maximum: MAX_DOCUMENT_OUTPUT_BYTES,
8448            } if observed == MAX_DOCUMENT_OUTPUT_BYTES + 2
8449        ));
8450    }
8451
8452    #[test]
8453    fn direct_xml_extracts_body_and_table_locators() {
8454        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
8455            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
8456              <w:body><w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t xml:space="preserve"> world</w:t></w:r></w:p>
8457              <w:tbl><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body>
8458            </w:document>"#;
8459        let bytes = docx_archive(xml, CompressionMethod::Stored);
8460        let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8461            .expect("valid DOCX");
8462        assert_eq!(facts.text, "Hello world\nCell");
8463        assert_eq!(facts.facts.len(), 3);
8464        assert!(matches!(
8465            &facts.facts[2].locator,
8466            DocumentLocator::Docx {
8467                part,
8468                paragraph: 2,
8469                run: 1,
8470                text_start: 0,
8471                text_end: 4
8472            } if part == DOCX_DOCUMENT_PART
8473        ));
8474    }
8475
8476    #[test]
8477    fn direct_xml_rejects_truncated_document_part() {
8478        let bytes = docx_archive(
8479            b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>truncated",
8480            CompressionMethod::Stored,
8481        );
8482        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8483            .expect_err("truncated XML must fail closed");
8484        assert!(matches!(
8485            error,
8486            DocumentExtractionError::Malformed {
8487                format: DocumentFormat::Docx,
8488                ..
8489            }
8490        ));
8491    }
8492
8493    #[test]
8494    fn docx_text_whitespace_follows_inherited_xml_space() {
8495        for (attributes, leaves, expected) in [
8496            ("", "<w:t> A </w:t><w:t> B </w:t>", "AB"),
8497            (
8498                "",
8499                "<w:t xml:space=\"preserve\"> A </w:t><w:t> B </w:t>",
8500                " A B",
8501            ),
8502            (
8503                "xml:space=\"preserve\"",
8504                "<w:t> A </w:t><w:t xml:space=\"default\"> B </w:t><w:t> C </w:t>",
8505                " A B C ",
8506            ),
8507            (
8508                "",
8509                "<w:t> \tA&#x20;<![CDATA[ B ]]>&amp; C&#x20;</w:t>",
8510                "A  B & C",
8511            ),
8512            (
8513                "",
8514                "<w:t> &#xA0;A&#xA0; </w:t><w:t> \t </w:t>",
8515                "\u{a0}A\u{a0}",
8516            ),
8517        ] {
8518            let xml = format!(
8519                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" {attributes}><w:body><w:p><w:r>{leaves}</w:r></w:p></w:body></w:document>"
8520            );
8521            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
8522                .expect("Word text whitespace");
8523            assert_eq!(facts.text, expected);
8524            assert_eq!(facts.facts.len(), 1);
8525            assert_eq!(facts.facts[0].text, expected);
8526            assert!(
8527                matches!(facts.facts[0].locator, DocumentLocator::Docx { paragraph: 1, run: 1, text_start: 0, text_end, .. } if text_end == expected.len())
8528            );
8529        }
8530    }
8531
8532    #[test]
8533    fn docx_invalid_whitespace_mode_refuses_publication() {
8534        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Prefix</w:t><w:t xml:space="invalid">suffix</w:t></w:r></w:p></w:body></w:document>"#;
8535        assert!(matches!(
8536            extract_document_text_controlled(
8537                &docx_archive(xml, CompressionMethod::Deflated),
8538                "guide.docx",
8539                None,
8540                &control()
8541            ),
8542            Err(DocumentExtractionError::Malformed {
8543                format: DocumentFormat::Docx,
8544                ..
8545            })
8546        ));
8547    }
8548
8549    #[test]
8550    fn direct_xml_preserves_entities_tabs_and_breaks() {
8551        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A &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>"#;
8552        let bytes = docx_archive(xml, CompressionMethod::Stored);
8553        let facts = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
8554            .expect("valid DOCX");
8555        assert_eq!(facts.text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
8556        assert_eq!(facts.facts[0].text, "A & B\t\nC\u{2011}D\u{00ad}E\tF\nG");
8557        assert_eq!(facts.facts[0].line_start, 1);
8558        assert_eq!(facts.facts[0].line_end, 3);
8559        assert_eq!(
8560            facts.facts[0].locator.to_string(),
8561            "docx:part=word/document.xml;paragraph=1;run=1;text-span=0..19"
8562        );
8563    }
8564
8565    #[test]
8566    fn docx_font_specific_symbols_preserve_identity_without_inventing_text() {
8567        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Before</w:t><w:sym w:font="Wingdings" w:char="F03A"/><w:t>After</w:t></w:r></w:p></w:body></w:document>"#;
8568        let facts = extract_document_text_controlled(
8569            &docx_archive(xml, CompressionMethod::Deflated),
8570            "symbol.docx",
8571            None,
8572            &control(),
8573        )
8574        .expect("rendered symbol must not block source indexing");
8575        assert!(facts.text.contains("Before"));
8576        assert!(facts.text.contains("After"));
8577        assert!(!facts.text.contains("F03A"));
8578        assert_eq!(
8579            facts.completeness,
8580            DocumentCompleteness::Partial {
8581                gaps: vec![DocumentCoverageGap::UnknownSymbolMapping],
8582            }
8583        );
8584        let graph = facts.symbol_graph("symbol.docx", None);
8585        assert_eq!(graph.language.as_deref(), Some("docx"));
8586        assert!(
8587            graph
8588                .symbols
8589                .iter()
8590                .all(|symbol| symbol.language.as_deref() == Some("docx"))
8591        );
8592        assert_eq!(
8593            facts.symbol_graph("symbol.docx", Some("DOCX")).language,
8594            graph.language
8595        );
8596        assert!(graph.symbols.iter().any(|symbol| {
8597            symbol
8598                .signature
8599                .contains("font=Some(\"Wingdings\");code=F03A")
8600                && symbol.signature.contains("docx:part=word/document.xml")
8601        }));
8602    }
8603
8604    #[test]
8605    fn docx_symbols_with_absent_identity_remain_partial() {
8606        for (attributes, font, code) in [
8607            ("w:char=\"F03A\"", None, Some(0xF03A)),
8608            ("w:font=\"Wingdings\"", Some("Wingdings"), None),
8609            ("", None, None),
8610        ] {
8611            let xml = format!(
8612                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Before</w:t><w:sym {attributes}/><w:t>After</w:t></w:r></w:p></w:body></w:document>"
8613            );
8614            let facts = extract_document_text_controlled(
8615                &docx_archive(xml.as_bytes(), CompressionMethod::Deflated),
8616                "symbol.docx",
8617                None,
8618                &control(),
8619            )
8620            .expect("omitted symbol attributes are valid but unresolved");
8621            assert!(facts.text.contains("Before\u{fffc}After"));
8622            assert_eq!(facts.symbols.len(), 1);
8623            assert_eq!(facts.symbols[0].font.as_deref(), font);
8624            assert_eq!(facts.symbols[0].code, code);
8625            assert_eq!(facts.symbols[0].unicode, None);
8626            let symbol = facts
8627                .symbol_graph("symbol.docx", None)
8628                .symbols
8629                .into_iter()
8630                .find(|symbol| symbol.name == "document-symbol-1")
8631                .expect("symbol remains queryable");
8632            let code = code.map_or_else(|| "missing".to_owned(), |code| format!("{code:04X}"));
8633            assert!(
8634                symbol
8635                    .signature
8636                    .contains(&format!("font={font:?};code={code}"))
8637            );
8638            assert_eq!(
8639                facts.completeness,
8640                DocumentCompleteness::Partial {
8641                    gaps: vec![DocumentCoverageGap::UnknownSymbolMapping],
8642                }
8643            );
8644        }
8645    }
8646
8647    #[test]
8648    fn docx_symbol_identity_rejects_namespace_equivalent_duplicates() {
8649        for attributes in [
8650            "w:font=\"Symbol\" x:font=\"Wingdings\" w:char=\"F061\"",
8651            "x:font=\"Wingdings\" w:font=\"Symbol\" w:char=\"F061\"",
8652            "w:font=\"Symbol\" w:char=\"F061\" x:char=\"F062\"",
8653            "w:font=\"Symbol\" x:char=\"F062\" w:char=\"F061\"",
8654        ] {
8655            let xml = format!(
8656                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:sym {attributes}/></w:r></w:p></w:body></w:document>"
8657            );
8658            assert!(matches!(
8659                extract_document_text_controlled(
8660                    &docx_archive(xml.as_bytes(), CompressionMethod::Deflated),
8661                    "symbol.docx",
8662                    None,
8663                    &control(),
8664                ),
8665                Err(DocumentExtractionError::Malformed { message, .. })
8666                    if message.contains("attribute is duplicated")
8667            ));
8668        }
8669        let aliased = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:sym x:font="Symbol" x:char="F061"/></w:r></w:p></w:body></w:document>"#;
8670        let facts = extract_document_text_controlled(
8671            &docx_archive(aliased, CompressionMethod::Deflated),
8672            "symbol.docx",
8673            None,
8674            &control(),
8675        )
8676        .expect("namespace alias remains valid");
8677        assert_eq!(facts.symbols[0].font.as_deref(), Some("Symbol"));
8678        assert_eq!(facts.symbols[0].code, Some(0xF061));
8679        assert_eq!(facts.symbols[0].unicode, Some('α'));
8680    }
8681
8682    #[test]
8683    fn docx_run_payload_requires_logical_direct_child() {
8684        for payload in [
8685            "<w:rPr><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:rPr>",
8686            "<w:rPr><w:t>Secret</w:t></w:rPr>",
8687            "<w:wrapper><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:wrapper>",
8688            "<w:rPr><w:tab/></w:rPr>",
8689            "<w:rPr><w:footnoteReference w:id=\"1\"/></w:rPr>",
8690        ] {
8691            let xml = format!(
8692                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8693            );
8694            assert!(matches!(
8695                parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8696                Err(DocumentExtractionError::Malformed { message, .. })
8697                    if message.contains("not a direct child")
8698            ));
8699        }
8700        let selected = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:p><w:r><mc:AlternateContent><mc:Choice Requires="w"><w:sym w:font="Symbol" w:char="F061"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:r></w:p></w:body></w:document>"#;
8701        let facts = parse_docx(selected, &control(), IndexWorkStage::TextIndex)
8702            .expect("selected compatibility branch preserves direct run payload");
8703        assert_eq!(facts.text, "α");
8704        assert_eq!(facts.symbols.len(), 1);
8705        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
8706    }
8707
8708    #[test]
8709    fn docx_run_metadata_cannot_publish_text_box_or_story_symbols() {
8710        for payload in [
8711            "<w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent>",
8712            "<w:rPr><w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></w:rPr>",
8713            "<w:rPr><a:graphic><a:graphicData><w:txbxContent><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:txbxContent></a:graphicData></a:graphic></w:rPr>",
8714        ] {
8715            let xml = format!(
8716                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8717            );
8718            assert!(matches!(
8719                parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
8720                Err(DocumentExtractionError::Malformed { message, .. })
8721                    if message.contains("no rendered drawing owner")
8722                        || message.contains("no supported drawing parent")
8723            ));
8724        }
8725        let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:comment></w:comments>"#;
8726        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
8727        for (payload, kind, target, part, story) in [
8728            (
8729                "<w:commentRangeStart w:id=\"4\"/>",
8730                "comments",
8731                "comments.xml",
8732                "word/comments.xml",
8733                comments.as_slice(),
8734            ),
8735            (
8736                "<w:rPr><w:commentRangeStart w:id=\"4\"/></w:rPr>",
8737                "comments",
8738                "comments.xml",
8739                "word/comments.xml",
8740                comments.as_slice(),
8741            ),
8742            (
8743                "<w:rPr><w:sectPr><w:headerReference r:id=\"header\"/></w:sectPr></w:rPr>",
8744                "header",
8745                "header.xml",
8746                "word/header.xml",
8747                header.as_slice(),
8748            ),
8749        ] {
8750            let main = format!(
8751                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:r>{payload}</w:r></w:p></w:body></w:document>"
8752            );
8753            let rels = format!(
8754                "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"header\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}\" Target=\"{target}\"/></Relationships>"
8755            );
8756            let archive = docx_archive_with_parts(&[
8757                (DOCX_DOCUMENT_PART, main.as_bytes()),
8758                ("word/_rels/document.xml.rels", rels.as_bytes()),
8759                (part, story),
8760            ]);
8761            assert!(matches!(
8762                extract_document_text_controlled(&archive, "metadata.docx", None, &control()),
8763                Err(DocumentExtractionError::Malformed { .. })
8764            ));
8765        }
8766        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="header" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
8767        for nested in [
8768            "<w:r><w:headerReference r:id=\"header\"/></w:r>",
8769            "<w:wrapper><w:headerReference r:id=\"header\"/></w:wrapper>",
8770            "<w:r><w:titlePg/></w:r>",
8771        ] {
8772            let main = format!(
8773                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:pPr><w:sectPr>{nested}</w:sectPr></w:pPr></w:p></w:body></w:document>"
8774            );
8775            let archive = docx_archive_with_parts(&[
8776                (DOCX_DOCUMENT_PART, main.as_bytes()),
8777                ("word/_rels/document.xml.rels", rels),
8778                ("word/header.xml", header),
8779            ]);
8780            assert!(matches!(
8781                extract_document_text_controlled(&archive, "section.docx", None, &control()),
8782                Err(DocumentExtractionError::Malformed { .. })
8783            ));
8784        }
8785        let selected = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><w:body><w:sectPr><mc:AlternateContent><mc:Choice Requires="w"><w:headerReference r:id="header"/><w:titlePg w:val="false"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sectPr></w:body></w:document>"#;
8786        let archive = docx_archive_with_parts(&[
8787            (DOCX_DOCUMENT_PART, selected),
8788            ("word/_rels/document.xml.rels", rels),
8789            ("word/header.xml", header),
8790        ]);
8791        let facts = extract_document_text_controlled(&archive, "section.docx", None, &control())
8792            .expect("selected section metadata remains direct after compatibility wrappers");
8793        assert_eq!(facts.symbols.len(), 1);
8794    }
8795
8796    #[test]
8797    fn docx_font_identity_has_a_bounded_retained_size() {
8798        let package = |font: &str| {
8799            let xml = format!(
8800                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:sym w:font=\"{font}\" w:char=\"F03A\"/></w:r></w:p></w:body></w:document>"
8801            );
8802            docx_archive(xml.as_bytes(), CompressionMethod::Deflated)
8803        };
8804        let retained = extract_document_text_controlled(
8805            &package(&"A".repeat(MAX_DOCX_FONT_NAME_BYTES)),
8806            "font.docx",
8807            None,
8808            &control(),
8809        )
8810        .expect("bounded exact font identity");
8811        assert_eq!(retained.symbols.len(), 1);
8812        let over_limit = extract_document_text_controlled(
8813            &package(&"A".repeat(MAX_DOCX_FONT_NAME_BYTES + 1)),
8814            "font.docx",
8815            None,
8816            &control(),
8817        )
8818        .expect("oversized font becomes local incomplete coverage");
8819        assert_eq!(
8820            over_limit.completeness,
8821            DocumentCompleteness::Partial {
8822                gaps: vec![DocumentCoverageGap::ResourceLimit(
8823                    DocumentLimit::MemoryBytes
8824                )],
8825            }
8826        );
8827        assert!(over_limit.symbols.is_empty());
8828    }
8829
8830    #[test]
8831    fn docx_rendered_story_parts_retain_symbols_and_skip_unreferenced_content() {
8832        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"/><Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"/><Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/><Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/></Types>"#;
8833        let package_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rMain" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
8834        let document_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rHeader" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Internal"/><Relationship Id="rFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/><Relationship Id="rFootnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rEndnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/><Relationship Id="rSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
8835        let main = format!(
8836            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:p><w:r><w:t>Main</w:t><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/>{}</w:r></w:p><w:p><w:pPr><w:framePr/></w:pPr><w:r><w:sym w:font=\"Wingdings\" w:char=\"F03A\"/></w:r></w:p><w:p><w:r><w:footnoteReference w:id=\"2\"/><w:endnoteReference w:id=\"3\"/><w:commentReference w:id=\"4\"/></w:r></w:p><w:sectPr><w:headerReference r:id=\"rHeader\" w:type=\"default\"/><w:footerReference r:id=\"rFooter\" w:type=\"default\"/></w:sectPr></w:body></w:document>",
8837            docx_drawing_text_box(
8838                "<w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p>",
8839                1
8840            )
8841        );
8842        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>Header</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
8843        let footer = br#"<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:t>Footer</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:ftr>"#;
8844        let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="0" w:type="separator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="1" w:type="continuationSeparator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:t>Footnote</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote><w:footnote w:id="99"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
8845        let endnotes = br#"<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:endnote w:id="0" w:type="separator"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:endnote><w:endnote w:id="3"><w:p><w:r><w:t>Endnote</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:endnote></w:endnotes>"#;
8846        let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:p><w:r><w:t>Comment</w:t><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:comment></w:comments>"#;
8847        let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnotePr><w:footnote w:id="+00"/><w:footnote w:id="01"/></w:footnotePr><w:endnotePr><w:endnote w:id="0"/></w:endnotePr></w:settings>"#;
8848        let glossary = br#"<w:glossaryDocument xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docParts><w:docPart><w:docPartBody><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:docPartBody></w:docPart></w:docParts></w:glossaryDocument>"#;
8849        let archive = |settings: &[u8]| {
8850            docx_archive_with_parts(&[
8851                ("[Content_Types].xml", manifest),
8852                ("_rels/.rels", package_rels),
8853                (DOCX_DOCUMENT_PART, main.as_bytes()),
8854                ("word/_rels/document.xml.rels", document_rels),
8855                ("word/header1.xml", header),
8856                ("word/footer1.xml", footer),
8857                ("word/footnotes.xml", footnotes),
8858                ("word/endnotes.xml", endnotes),
8859                ("word/comments.xml", comments),
8860                ("word/settings.xml", settings),
8861                ("word/glossary/document.xml", glossary),
8862            ])
8863        };
8864        let facts =
8865            extract_document_text_controlled(&archive(settings), "stories.docx", None, &control())
8866                .expect("every referenced rendered story is admitted");
8867        assert_eq!(facts.symbols.len(), 11);
8868        assert!(
8869            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory))
8870        );
8871        for (part, expected) in [("word/footnotes.xml", 3), ("word/endnotes.xml", 2)] {
8872            assert_eq!(facts.symbols.iter().filter(|symbol| {
8873                matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8874            }).count(), expected, "missing or repeated note separator in {part}");
8875        }
8876        for part in [
8877            "word/document.xml",
8878            "word/header1.xml",
8879            "word/footer1.xml",
8880            "word/footnotes.xml",
8881            "word/endnotes.xml",
8882            "word/comments.xml",
8883        ] {
8884            assert!(facts.symbols.iter().any(|symbol| {
8885                matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8886            }), "missing symbol in {part}");
8887        }
8888        assert!(facts.symbols.iter().all(|symbol| {
8889            !matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part.contains("glossary"))
8890        }));
8891        assert!(facts.text.contains('α'));
8892        assert!(facts.text.contains("Header"));
8893        assert!(facts.text.contains("Comment"));
8894        let unlisted = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnotePr/><w:endnotePr/></w:settings>"#;
8895        let unlisted = extract_document_text_controlled(
8896            &archive(unlisted),
8897            "unlisted-separators.docx",
8898            None,
8899            &control(),
8900        )
8901        .expect("unlisted special notes are not loaded");
8902        assert_eq!(unlisted.symbols.len(), 8);
8903        assert!(
8904            matches!(unlisted.completeness, DocumentCompleteness::Partial { ref gaps } if !gaps.contains(&DocumentCoverageGap::ConditionalStory))
8905        );
8906        for part in ["word/footnotes.xml", "word/endnotes.xml"] {
8907            assert_eq!(unlisted.symbols.iter().filter(|symbol| {
8908                matches!(&symbol.locator, DocumentLocator::Docx { part: found, .. } if found == part)
8909            }).count(), 1, "unlisted note separator was emitted from {part}");
8910        }
8911        let compatible_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="w"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8912        let compatible = extract_document_text_controlled(
8913            &archive(compatible_settings),
8914            "compatible-settings.docx",
8915            None,
8916            &control(),
8917        )
8918        .expect("selected compatibility settings load their special note");
8919        assert_eq!(compatible.symbols.len(), 9);
8920        assert!(
8921            !matches!(compatible.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8922        );
8923        for malformed in [
8924            std::str::from_utf8(compatible_settings)
8925                .expect("settings fixture")
8926                .replace("<mc:Fallback/>", "<mc:Fallback x:extra=\"1\"/>"),
8927            std::str::from_utf8(compatible_settings)
8928                .expect("settings fixture")
8929                .replace(
8930                    "<mc:Fallback/>",
8931                    "<mc:Fallback><w:evenAndOddHeaders x:extra=\"1\"/></mc:Fallback>",
8932                ),
8933        ] {
8934            assert!(matches!(
8935                extract_document_text_controlled(
8936                    &archive(malformed.as_bytes()),
8937                    "invalid-skipped-settings.docx",
8938                    None,
8939                    &control()
8940                ),
8941                Err(DocumentExtractionError::Malformed { message, .. })
8942                    if message.contains("undeclared namespace prefix")
8943            ));
8944        }
8945        let nested = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="w"><mc:AlternateContent><mc:Choice Requires="w"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8946        let nested = extract_document_text_controlled(
8947            &archive(nested),
8948            "nested-settings.docx",
8949            None,
8950            &control(),
8951        )
8952        .expect("nested selected settings load their special note");
8953        assert_eq!(nested.symbols.len(), 9);
8954        assert!(
8955            !matches!(nested.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8956        );
8957        let uncertain = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8958        let uncertain = extract_document_text_controlled(
8959            &archive(uncertain),
8960            "uncertain-settings.docx",
8961            None,
8962            &control(),
8963        )
8964        .expect("unknown compatibility settings remain file-local incomplete");
8965        assert!(
8966            matches!(uncertain.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8967        );
8968        let nested_uncertain = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="w"><mc:AlternateContent><mc:Choice Requires="future"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8969        let nested_uncertain = extract_document_text_controlled(
8970            &archive(nested_uncertain),
8971            "nested-uncertain-settings.docx",
8972            None,
8973            &control(),
8974        )
8975        .expect("nested unknown branch remains incomplete");
8976        assert!(
8977            matches!(nested_uncertain.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
8978        );
8979        let undeclared = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Choice Requires="missing"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
8980        assert!(matches!(
8981            extract_document_text_controlled(
8982                &archive(undeclared),
8983                "undeclared-settings.docx",
8984                None,
8985                &control()
8986            ),
8987            Err(DocumentExtractionError::Malformed { .. })
8988        ));
8989        let invalid_token = std::str::from_utf8(undeclared)
8990            .expect("UTF-8 settings fixture")
8991            .replace("Requires=\"missing\"", "Requires=\"w:future\"");
8992        assert!(matches!(
8993            extract_document_text_controlled(
8994                &archive(invalid_token.as_bytes()),
8995                "invalid-prefix-settings.docx",
8996                None,
8997                &control()
8998            ),
8999            Err(DocumentExtractionError::Malformed { .. })
9000        ));
9001        let mixed = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future missing"><w:footnotePr><w:footnote w:id="0"/></w:footnotePr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:settings>"#;
9002        for xml in [
9003            std::str::from_utf8(mixed)
9004                .expect("UTF-8 settings fixture")
9005                .to_owned(),
9006            std::str::from_utf8(mixed)
9007                .expect("UTF-8 settings fixture")
9008                .replace("future missing", "missing future"),
9009        ] {
9010            assert!(matches!(
9011                extract_document_text_controlled(
9012                    &archive(xml.as_bytes()),
9013                    "mixed-prefixes.docx",
9014                    None,
9015                    &control()
9016                ),
9017                Err(DocumentExtractionError::Malformed { .. })
9018            ));
9019        }
9020        for property in ["footnotePr", "endnotePr"] {
9021            let closing = format!("</w:{property}>");
9022            let duplicate_properties = std::str::from_utf8(settings)
9023                .expect("UTF-8 settings fixture")
9024                .replace(&closing, &format!("{closing}<w:{property}/>"));
9025            assert!(matches!(
9026                extract_document_text_controlled(
9027                    &archive(duplicate_properties.as_bytes()),
9028                    "duplicate-note-properties.docx",
9029                    None,
9030                    &control(),
9031                ),
9032                Err(DocumentExtractionError::Malformed { .. })
9033            ));
9034        }
9035    }
9036
9037    #[test]
9038    fn docx_story_relationships_refuse_external_escape_missing_and_wrong_type() {
9039        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Keep old publication</w:t></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9040        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9041        let escaped_external = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="Ext&#x65;rnal"/></Relationships>"#;
9042        let parsed =
9043            parse_docx_relationships(escaped_external, &control(), IndexWorkStage::TextIndex)
9044                .expect("escaped TargetMode retains external classification");
9045        assert_eq!(
9046            parsed.get("h").map(|(_, _, external)| *external),
9047            Some(true)
9048        );
9049        for (relationship, include_header) in [
9050            (
9051                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="External""#,
9052                true,
9053            ),
9054            (
9055                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="https://example.invalid/a" TargetMode="Ext&#x65;rnal""#,
9056                true,
9057            ),
9058            (
9059                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="../../outside.xml""#,
9060                true,
9061            ),
9062            (
9063                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="missing.xml""#,
9064                false,
9065            ),
9066            (
9067                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="header1.xml""#,
9068                true,
9069            ),
9070            (
9071                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Unexpected""#,
9072                true,
9073            ),
9074            (
9075                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml" TargetMode="Unexp&#x65;cted""#,
9076                true,
9077            ),
9078            (
9079                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%2Fone.xml""#,
9080                true,
9081            ),
9082            (
9083                r#"Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%GGone.xml""#,
9084                true,
9085            ),
9086        ] {
9087            let rels = format!(
9088                "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"h\" {relationship}/></Relationships>"
9089            );
9090            let mut parts = vec![
9091                (DOCX_DOCUMENT_PART, main.as_slice()),
9092                ("word/_rels/document.xml.rels", rels.as_bytes()),
9093            ];
9094            if include_header {
9095                parts.push(("word/header1.xml", header.as_slice()));
9096            }
9097            let archive = docx_archive_with_parts(&parts);
9098            assert!(
9099                matches!(
9100                    extract_document_text_controlled(&archive, "unsafe.docx", None, &control()),
9101                    Err(DocumentExtractionError::InvalidDocxPackage { .. })
9102                ),
9103                "{relationship}"
9104            );
9105        }
9106    }
9107
9108    #[test]
9109    fn docx_relationships_reject_undeclared_attribute_prefixes() {
9110        for xml in [
9111            r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships" x:extra="1"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
9112            r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml" x:extra="1"/></Relationships>"#,
9113        ] {
9114            assert!(matches!(
9115                parse_docx_relationships(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9116                Err(DocumentExtractionError::Malformed { .. })
9117            ));
9118        }
9119    }
9120
9121    #[test]
9122    fn docx_package_metadata_rejects_non_xml_structural_whitespace() {
9123        let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\u{a0}</Types>";
9124        assert!(matches!(
9125            parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex),
9126            Err(DocumentExtractionError::InvalidDocxPackage { .. })
9127        ));
9128        let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\u{2003}</Relationships>";
9129        assert!(matches!(
9130            parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex),
9131            Err(DocumentExtractionError::InvalidDocxPackage { .. })
9132        ));
9133    }
9134
9135    #[test]
9136    fn docx_package_metadata_preserves_ordinary_processing_instructions() {
9137        let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><?producer marker?></Types>";
9138        assert!(
9139            parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex)
9140                .is_ok()
9141        );
9142        let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><?producer marker?></Relationships>";
9143        assert!(
9144            parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex)
9145                .is_ok()
9146        );
9147        for invalid in [
9148            types.replace("<?producer marker?>", "<?XML marker?>"),
9149            rels.replace("<?producer marker?>", "<?XML marker?>"),
9150        ] {
9151            let result = if invalid.starts_with("<Types") {
9152                parse_docx_content_types(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9153                    .map(|_| ())
9154            } else {
9155                parse_docx_relationships(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9156                    .map(|_| ())
9157            };
9158            assert!(matches!(
9159                result,
9160                Err(DocumentExtractionError::Malformed { .. })
9161            ));
9162        }
9163    }
9164
9165    #[test]
9166    fn docx_package_metadata_accepts_only_internal_whitespace_references() {
9167        let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">&#x20;&#x9;<![CDATA[\n ]]></Types>";
9168        assert!(
9169            parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex)
9170                .is_ok()
9171        );
9172        let rels = "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">&#xA;<![CDATA[ ]]></Relationships>";
9173        assert!(
9174            parse_docx_relationships(rels.as_bytes(), &control(), IndexWorkStage::TextIndex)
9175                .is_ok()
9176        );
9177        for invalid in [
9178            types.replace("&#x20;&#x9;", "&#x41;"),
9179            types.replace("&#x20;&#x9;", "&missing;"),
9180            types.replace("<![CDATA[\n ]]>", "<![CDATA[Injected]]>"),
9181            format!("&#x20;{types}"),
9182            format!("<![CDATA[ ]]> {types}"),
9183            rels.replace("&#xA;", "&#x41;"),
9184            rels.replace("&#xA;", "&missing;"),
9185            rels.replace("<![CDATA[ ]]>", "<![CDATA[Injected]]>"),
9186            format!("{rels}&#x20;"),
9187            format!("{rels}<![CDATA[ ]]>"),
9188        ] {
9189            let result = if invalid.contains("<Types") {
9190                parse_docx_content_types(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9191                    .map(|_| ())
9192            } else {
9193                parse_docx_relationships(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex)
9194                    .map(|_| ())
9195            };
9196            assert!(result.is_err(), "{invalid}");
9197        }
9198    }
9199
9200    #[test]
9201    fn docx_safe_percent_encoded_story_target_retains_symbols() {
9202        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header%20one.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9203        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9204        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header%20one.xml" TargetMode="Int&#x65;rnal"/></Relationships>"#;
9205        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9206        let archive = docx_archive_with_parts(&[
9207            ("[Content_Types].xml", manifest),
9208            (DOCX_DOCUMENT_PART, main),
9209            ("word/_rels/document.xml.rels", rels),
9210            ("word/header%20one.xml", header),
9211        ]);
9212        let facts = extract_document_text_controlled(&archive, "encoded.docx", None, &control())
9213            .expect("safe encoded target resolves to its package part");
9214        assert!(facts.symbols.iter().any(|symbol| {
9215            matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/header%20one.xml")
9216        }));
9217    }
9218
9219    #[test]
9220    fn docx_relative_dot_and_unreserved_uri_escapes_resolve_inside_package() {
9221        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9222        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="./header%31.xml"/></Relationships>"#;
9223        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:hdr>"#;
9224        let archive = docx_archive_with_parts(&[
9225            (DOCX_DOCUMENT_PART, main),
9226            ("word/_rels/document.xml.rels", rels),
9227            ("word/header1.xml", header),
9228        ]);
9229        let facts = extract_document_text_controlled(&archive, "normalized.docx", None, &control())
9230            .expect("relative target is normalized within the package");
9231        assert!(facts.symbols.iter().any(|symbol| {
9232            matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/header1.xml")
9233        }));
9234    }
9235
9236    #[test]
9237    fn docx_package_metadata_tables_are_bounded_before_map_fanout() {
9238        let oversized = format!(
9239            "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}</Relationships>",
9240            " ".repeat(MAX_DOCX_METADATA_BYTES)
9241        );
9242        assert!(matches!(
9243            parse_docx_relationships(oversized.as_bytes(), &control(), IndexWorkStage::TextIndex),
9244            Err(DocumentExtractionError::InvalidDocxPackage { .. })
9245        ));
9246        let mut records = String::new();
9247        for index in 0..=MAX_DOCX_METADATA_RECORDS {
9248            write!(records, "<Relationship Id=\"r{index}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header\" Target=\"header.xml\"/>")
9249                .expect("fixture relationship");
9250        }
9251        let xml = format!(
9252            "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{records}</Relationships>"
9253        );
9254        assert!(matches!(
9255            parse_docx_relationships(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9256            Err(DocumentExtractionError::InvalidDocxPackage { .. })
9257        ));
9258    }
9259
9260    #[test]
9261    fn docx_referenced_unexamined_subdocument_is_partial() {
9262        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Visible</w:t></w:r><w:subDoc r:id="child"/></w:p></w:body></w:document>"#;
9263        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="child" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/subDocument" Target="child.xml"/></Relationships>"#;
9264        let child = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:body></w:document>"#;
9265        let archive = docx_archive_with_parts(&[
9266            (DOCX_DOCUMENT_PART, main),
9267            ("word/_rels/document.xml.rels", rels),
9268            ("word/child.xml", child),
9269        ]);
9270        let facts =
9271            extract_document_text_controlled(&archive, "subdocument.docx", None, &control())
9272                .expect("in-package subdocument is valid but not recursively interpreted");
9273        assert!(facts.text.contains("Visible"));
9274        assert!(facts.symbols.is_empty());
9275        assert_eq!(
9276            facts.completeness,
9277            DocumentCompleteness::Partial {
9278                gaps: vec![DocumentCoverageGap::UnexaminedStory],
9279            }
9280        );
9281        let sdt_main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:subDoc r:id="child"/></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9282        let mut references = DocxStoryReferences::default();
9283        let sdt = parse_docx_part(
9284            sdt_main,
9285            DOCX_DOCUMENT_PART,
9286            "document",
9287            None,
9288            None,
9289            &mut references,
9290            &control(),
9291            IndexWorkStage::TextIndex,
9292        )
9293        .expect("subdocument anchor is actual SDT content");
9294        assert_eq!(
9295            references.linked_parts,
9296            vec![(DocxStoryKind::Subdocument, "child".to_owned())]
9297        );
9298        assert!(!references.glossary_placeholder);
9299        assert_eq!(sdt.completeness, facts.completeness);
9300    }
9301
9302    #[test]
9303    fn docx_glossary_placeholder_is_partial_only_when_rendered() {
9304        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/></Types>"#;
9305        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="glossary" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" Target="glossary/document.xml"/></Relationships>"#;
9306        let glossary = br#"<w:glossaryDocument xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docParts><w:docPart><w:docPartPr><w:name w:val="Hint"/><w:types><w:type w:val="bbPlcHdr"/></w:types></w:docPartPr><w:docPartBody><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:docPartBody></w:docPart></w:docParts></w:glossaryDocument>"#;
9307        for (content, incomplete) in [
9308            ("<w:p><w:r><w:t>Literal</w:t></w:r></w:p>", false),
9309            (
9310                "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p/></w:sdtContent></w:sdt>",
9311                true,
9312            ),
9313            (
9314                "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9315                false,
9316            ),
9317            (
9318                "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9319                true,
9320            ),
9321            (
9322                "<w:sdt><mc:AlternateContent><mc:Choice Requires=\"w\"><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><w:showingPlcHdr/></w:sdtPr></mc:Choice><mc:Fallback/></mc:AlternateContent><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9323                true,
9324            ),
9325            (
9326                "<w:sdt><w:sdtPr><w:placeholder><mc:AlternateContent><mc:Choice Requires=\"w\"><w:docPart w:val=\"Hint\"/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:placeholder><w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9327                true,
9328            ),
9329            (
9330                "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder><mc:AlternateContent><mc:Choice Requires=\"w\"><w:showingPlcHdr/></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt>",
9331                true,
9332            ),
9333            (
9334                "<w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><mc:AlternateContent><mc:Choice Requires=\"w\"><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></mc:Choice><mc:Fallback/></mc:AlternateContent></w:sdt>",
9335                false,
9336            ),
9337        ] {
9338            let main = format!(
9339                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body>{content}</w:body></w:document>"
9340            );
9341            let archive = docx_archive_with_parts(&[
9342                ("[Content_Types].xml", manifest),
9343                (DOCX_DOCUMENT_PART, main.as_bytes()),
9344                ("word/_rels/document.xml.rels", rels),
9345                ("word/glossary/document.xml", glossary),
9346            ]);
9347            let facts =
9348                extract_document_text_controlled(&archive, "glossary.docx", None, &control())
9349                    .expect("valid in-package glossary relationship");
9350            assert_eq!(
9351                matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9352                incomplete,
9353                "{content}"
9354            );
9355            assert!(
9356                facts.symbols.is_empty(),
9357                "unexamined glossary symbol was invented"
9358            );
9359        }
9360        let hidden = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder><w:showingPlcHdr w:val="false"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Cached</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9361        let facts = extract_document_text_controlled(
9362            &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, hidden)]),
9363            "hidden-placeholder.docx",
9364            None,
9365            &control(),
9366        )
9367        .expect("a false placeholder flag does not require a glossary relationship");
9368        assert_eq!(facts.text, "Cached");
9369        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9370        let preserved_space = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:t xml:space="preserve"> </w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9371        let facts = extract_document_text_controlled(
9372            &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, preserved_space)]),
9373            "preserved-space.docx",
9374            None,
9375            &control(),
9376        )
9377        .expect("retained whitespace replaces a glossary placeholder");
9378        assert_eq!(facts.text, " ");
9379        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9380        let hidden_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9381        let facts = extract_document_text_controlled(
9382            &docx_archive_with_parts(&[
9383                ("[Content_Types].xml", manifest),
9384                (DOCX_DOCUMENT_PART, hidden_only),
9385                ("word/_rels/document.xml.rels", rels),
9386                ("word/glossary/document.xml", glossary),
9387            ]),
9388            "hidden-placeholder.docx",
9389            None,
9390            &control(),
9391        )
9392        .expect("hidden content does not satisfy a visible glossary placeholder");
9393        assert!(facts.symbols.is_empty());
9394        assert!(
9395            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory) && !gaps.contains(&DocumentCoverageGap::UnknownSymbolMapping))
9396        );
9397        for control_name in [
9398            "noBreakHyphen",
9399            "softHyphen",
9400            "cr",
9401            "ptab",
9402            "lastRenderedPageBreak",
9403            "pgNum",
9404        ] {
9405            let main = format!(
9406                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:{control_name}/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"
9407            );
9408            let facts = extract_document_text_controlled(
9409                &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9410                "visible-control.docx",
9411                None,
9412                &control(),
9413            )
9414            .expect("visible controls do not load an unrendered glossary placeholder");
9415            assert!(!facts.text.is_empty(), "{control_name}");
9416            assert!(
9417                !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9418                "{control_name}"
9419            );
9420        }
9421        for content in [
9422            "<w:object xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\"><v:shape/><o:OLEObject/></w:object>",
9423            "<w:object><w:control/></w:object>",
9424        ] {
9425            let main = format!(
9426                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val=\"Hint\"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r>{content}</w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"
9427            );
9428            let facts = extract_document_text_controlled(
9429                &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9430                "embedded-control.docx",
9431                None,
9432                &control(),
9433            )
9434            .expect("rendered objects do not load an unrendered glossary placeholder");
9435            assert!(
9436                !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory)),
9437                "{content}"
9438            );
9439        }
9440        let content_part = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:contentPart r:id="xml"/></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9441        let content_part_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/></Relationships>"#;
9442        let content_part_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
9443        let facts = extract_document_text_controlled(
9444            &docx_archive_with_parts(&[
9445                ("[Content_Types].xml", content_part_manifest),
9446                (DOCX_DOCUMENT_PART, content_part),
9447                ("word/_rels/document.xml.rels", content_part_rels),
9448                ("customXml/item1.xml", b"<equation>opaque</equation>"),
9449            ]),
9450            "alternate-xml-control.docx",
9451            None,
9452            &control(),
9453        )
9454        .expect("unsupported content part does not trigger glossary lookup");
9455        assert!(
9456            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9457        );
9458        for relationship in [
9459            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#.as_slice(),
9460            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="https://example.invalid/item.xml" TargetMode="External"/></Relationships>"#,
9461            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="../customXml/item1.xml"/></Relationships>"#,
9462            br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../../../outside.xml"/></Relationships>"#,
9463        ] {
9464            assert!(matches!(
9465                extract_document_text_controlled(
9466                    &docx_archive_with_parts(&[
9467                        ("[Content_Types].xml", content_part_manifest),
9468                        (DOCX_DOCUMENT_PART, content_part),
9469                        ("word/_rels/document.xml.rels", relationship),
9470                        ("customXml/item1.xml", b"<equation>opaque</equation>"),
9471                    ]),
9472                    "invalid-content-part.docx",
9473                    None,
9474                    &control(),
9475                ),
9476                Err(DocumentExtractionError::InvalidDocxPackage { .. })
9477            ));
9478        }
9479        let no_id = std::str::from_utf8(content_part)
9480            .expect("fixture is UTF-8")
9481            .replace(" r:id=\"xml\"", "");
9482        assert!(matches!(
9483            extract_document_text_controlled(
9484                &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, no_id.as_bytes())]),
9485                "missing-content-part-id.docx",
9486                None,
9487                &control(),
9488            ),
9489            Err(DocumentExtractionError::Malformed { .. })
9490        ));
9491        let note_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"/><Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"/><Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/></Types>"#;
9492        let note_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="c" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
9493        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:footnoteReference w:id="2"/><w:endnoteReference w:id="3"/><w:commentReference w:id="4"/></w:r></w:p></w:body></w:document>"#;
9494        let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="2"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:footnoteRef/></w:r></w:p></w:sdtContent></w:sdt></w:footnote></w:footnotes>"#;
9495        let endnotes = br#"<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:endnote w:id="3"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:endnoteRef/></w:r></w:p></w:sdtContent></w:sdt></w:endnote></w:endnotes>"#;
9496        let comments = br#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:comment w:id="4"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p><w:r><w:annotationRef/></w:r></w:p></w:sdtContent></w:sdt></w:comment></w:comments>"#;
9497        let facts = extract_document_text_controlled(
9498            &docx_archive_with_parts(&[
9499                ("[Content_Types].xml", note_manifest),
9500                (DOCX_DOCUMENT_PART, main),
9501                ("word/_rels/document.xml.rels", note_rels),
9502                ("word/footnotes.xml", footnotes),
9503                ("word/endnotes.xml", endnotes),
9504                ("word/comments.xml", comments),
9505            ]),
9506            "story-reference-mark.docx",
9507            None,
9508            &control(),
9509        )
9510        .expect("story reference marks replace placeholders without a glossary relationship");
9511        assert_eq!(facts.text.chars().filter(|ch| *ch == '\u{fffc}').count(), 6);
9512        assert!(
9513            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnevaluatedField) && !gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9514        );
9515        let header_manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/></Types>"#;
9516        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9517        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent><w:p/></w:sdtContent></w:sdt></w:hdr>"#;
9518        let main_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="glossary" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument" Target="glossary/document.xml"/></Relationships>"#;
9519        let archive = docx_archive_with_parts(&[
9520            ("[Content_Types].xml", header_manifest),
9521            (DOCX_DOCUMENT_PART, main),
9522            ("word/_rels/document.xml.rels", main_rels),
9523            ("word/header1.xml", header),
9524            ("word/glossary/document.xml", glossary),
9525        ]);
9526        let facts =
9527            extract_document_text_controlled(&archive, "header-glossary.docx", None, &control())
9528                .expect("header placeholder uses the main document glossary relationship");
9529        assert!(
9530            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
9531        );
9532    }
9533
9534    #[test]
9535    fn docx_glossary_placeholder_inside_field_instruction_is_not_rendered() {
9536        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r><w:sdt><w:sdtPr><w:placeholder><w:docPart w:val="Hint"/></w:placeholder></w:sdtPr><w:sdtContent/></w:sdt><w:r><w:fldChar w:fldCharType="separate"/><w:t>Cached</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
9537        let facts = extract_document_text_controlled(
9538            &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main)]),
9539            "instruction-placeholder.docx",
9540            None,
9541            &control(),
9542        )
9543        .expect("non-rendered field instruction cannot require a glossary story");
9544        assert_eq!(facts.text, "Cached");
9545        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9546    }
9547
9548    #[test]
9549    fn docx_special_note_settings_share_the_fact_ceiling() {
9550        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>"#;
9551        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="settings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
9552        for count in [MAX_DOCUMENT_FACTS, MAX_DOCUMENT_FACTS + 1] {
9553            let mut footnotes = String::new();
9554            for id in 0..MAX_DOCUMENT_FACTS / 2 {
9555                write!(footnotes, "<w:footnote w:id=\"{id}\"/>").expect("footnote fixture");
9556            }
9557            let mut endnotes = String::new();
9558            for id in MAX_DOCUMENT_FACTS / 2..count {
9559                write!(endnotes, "<w:endnote w:id=\"{id}\"/>").expect("endnote fixture");
9560            }
9561            let settings = format!(
9562                "<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:footnotePr>{footnotes}</w:footnotePr><w:endnotePr>{endnotes}</w:endnotePr></w:settings>"
9563            );
9564            let facts = extract_document_text_controlled(
9565                &docx_archive_with_parts(&[
9566                    (DOCX_DOCUMENT_PART, main),
9567                    ("word/_rels/document.xml.rels", rels),
9568                    ("word/settings.xml", settings.as_bytes()),
9569                ]),
9570                "special-notes.docx",
9571                None,
9572                &control(),
9573            )
9574            .expect("bounded settings keep verified main text");
9575            assert_eq!(facts.text, "Visible");
9576            assert_eq!(
9577                matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount))),
9578                count > MAX_DOCUMENT_FACTS
9579            );
9580        }
9581    }
9582
9583    #[test]
9584    fn docx_story_content_type_must_match_relationship_kind() {
9585        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>"#;
9586        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9587        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9588        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p/></w:hdr>"#;
9589        let archive = docx_archive_with_parts(&[
9590            ("[Content_Types].xml", manifest),
9591            (DOCX_DOCUMENT_PART, main),
9592            ("word/_rels/document.xml.rels", rels),
9593            ("word/header1.xml", header),
9594        ]);
9595        assert!(matches!(
9596            extract_document_text_controlled(&archive, "wrong-type.docx", None, &control()),
9597            Err(DocumentExtractionError::InvalidDocxPackage { .. }),
9598        ));
9599    }
9600
9601    #[test]
9602    fn docx_content_type_default_covers_main_and_story_override_takes_precedence() {
9603        let manifest = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9604        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9605        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9606        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9607        let archive = |manifest: &str| {
9608            docx_archive_with_parts(&[
9609                ("[Content_Types].xml", manifest.as_bytes()),
9610                (DOCX_DOCUMENT_PART, main),
9611                ("word/_rels/document.xml.rels", rels),
9612                ("word/header1.xml", header),
9613            ])
9614        };
9615        let facts =
9616            extract_document_text_controlled(&archive(manifest), "defaults.docx", None, &control())
9617                .expect("default-covered main and overridden header are valid");
9618        assert_eq!(facts.symbols.len(), 2);
9619        let wrong = manifest.replace("wordprocessingml.header+xml", "wordprocessingml.footer+xml");
9620        assert!(matches!(
9621            extract_document_text_controlled(&archive(&wrong), "defaults.docx", None, &control()),
9622            Err(DocumentExtractionError::InvalidDocxPackage { .. })
9623        ));
9624    }
9625
9626    #[test]
9627    fn docx_content_types_reject_undeclared_attribute_prefixes() {
9628        let content_type =
9629            "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
9630        let rels = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
9631        let records = [
9632            format!(
9633                "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\" x:extra=\"1\">{rels}<Override PartName=\"/word/document.xml\" ContentType=\"{content_type}\"/></Types>"
9634            ),
9635            format!(
9636                "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">{rels}<Override PartName=\"/word/document.xml\" ContentType=\"{content_type}\" x:extra=\"1\"/></Types>"
9637            ),
9638            format!(
9639                "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">{rels}<Default Extension=\"xml\" ContentType=\"{content_type}\" x:extra=\"1\"/></Types>"
9640            ),
9641        ];
9642        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Keep old publication</w:t></w:r></w:p></w:body></w:document>"#;
9643        let valid = records[2].replace(" x:extra=\"1\"", "");
9644        let archive = docx_archive_with_parts(&[
9645            ("[Content_Types].xml", valid.as_bytes()),
9646            (DOCX_DOCUMENT_PART, main),
9647        ]);
9648        assert_eq!(
9649            extract_document_text_controlled(&archive, "valid-types.docx", None, &control())
9650                .expect("valid content-type metadata retains rendered text")
9651                .text,
9652            "Keep old publication"
9653        );
9654        for manifest in records {
9655            let archive = docx_archive_with_parts(&[
9656                ("[Content_Types].xml", manifest.as_bytes()),
9657                (DOCX_DOCUMENT_PART, main),
9658            ]);
9659            assert!(matches!(
9660                extract_document_text_controlled(&archive, "invalid-types.docx", None, &control()),
9661                Err(DocumentExtractionError::Malformed { message, .. })
9662                    if message.contains("undeclared namespace prefix")
9663            ));
9664        }
9665    }
9666
9667    #[test]
9668    fn docx_utf16_package_metadata_and_stories_preserve_symbols() {
9669        let manifest = r#"<?xml version="1.0" encoding="UTF-16"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
9670        let root_rels = r#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
9671        let main = r#"<?xml version="1.0" encoding="UTF-16"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9672        let rels = r#"<?xml version="1.0" encoding="UTF-16"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
9673        let header = r#"<?xml version="1.0" encoding="UTF-16"?><w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9674        for little_endian in [true, false] {
9675            let encode = |xml: &str| {
9676                let mut bytes = if little_endian {
9677                    vec![0xff, 0xfe]
9678                } else {
9679                    vec![0xfe, 0xff]
9680                };
9681                for unit in xml.encode_utf16() {
9682                    bytes.extend_from_slice(&if little_endian {
9683                        unit.to_le_bytes()
9684                    } else {
9685                        unit.to_be_bytes()
9686                    });
9687                }
9688                bytes
9689            };
9690            let manifest = encode(manifest);
9691            let root_rels = encode(root_rels);
9692            let main = encode(main);
9693            let rels = encode(rels);
9694            let header = encode(header);
9695            let archive = docx_archive_with_parts(&[
9696                ("[Content_Types].xml", &manifest),
9697                ("_rels/.rels", &root_rels),
9698                (DOCX_DOCUMENT_PART, &main),
9699                ("word/_rels/document.xml.rels", &rels),
9700                ("word/header1.xml", &header),
9701            ]);
9702            let facts = extract_document_text_controlled(&archive, "utf16.docx", None, &control())
9703                .expect("UTF-16 package parts are valid");
9704            assert_eq!(facts.symbols.len(), 2);
9705        }
9706    }
9707
9708    #[test]
9709    fn docx_header_note_uses_main_document_relationship() {
9710        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
9711        let main_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="n" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/></Relationships>"#;
9712        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:footnoteReference w:id="2"/></w:r></w:p></w:hdr>"#;
9713        let footnotes = br#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:footnote w:id="2"><w:p><w:r><w:sym w:font="Wingdings" w:char="F03A"/></w:r></w:p></w:footnote></w:footnotes>"#;
9714        let archive = docx_archive_with_parts(&[
9715            (DOCX_DOCUMENT_PART, main),
9716            ("word/_rels/document.xml.rels", main_rels),
9717            ("word/header1.xml", header),
9718            ("word/footnotes.xml", footnotes),
9719        ]);
9720        let facts = extract_document_text_controlled(&archive, "nested.docx", None, &control())
9721            .expect("reachable nested story is valid");
9722        assert!(facts.symbols.iter().any(|symbol| {
9723            matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "word/footnotes.xml")
9724        }));
9725    }
9726
9727    #[test]
9728    fn docx_header_note_fanout_batches_ids_before_large_note_part() {
9729        let mut main = String::from(
9730            "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>",
9731        );
9732        let mut rels = String::from(
9733            "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"notes\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes\" Target=\"footnotes.xml\"/>",
9734        );
9735        let mut notes = String::from(
9736            "<w:footnotes xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><!--",
9737        );
9738        notes.push_str(&"x".repeat(4 * 1024 * 1024));
9739        notes.push_str("-->");
9740        let mut parts = Vec::new();
9741        for index in 0..18 {
9742            write!(&mut main, "<w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h{index}\"/></w:sectPr></w:pPr></w:p>").expect("section fixture");
9743            write!(&mut rels, "<Relationship Id=\"h{index}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header\" Target=\"header{index}.xml\"/>").expect("relationship fixture");
9744            write!(&mut notes, "<w:footnote w:id=\"{index}\"><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:footnote>").expect("note fixture");
9745            parts.push((format!("word/header{index}.xml"), format!("<w:hdr xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:p><w:r><w:footnoteReference w:id=\"{index}\"/></w:r></w:p></w:hdr>")));
9746        }
9747        main.push_str("</w:body></w:document>");
9748        rels.push_str("</Relationships>");
9749        notes.push_str("</w:footnotes>");
9750        parts.push((DOCX_DOCUMENT_PART.to_owned(), main));
9751        parts.push(("word/_rels/document.xml.rels".to_owned(), rels));
9752        parts.push(("word/footnotes.xml".to_owned(), notes));
9753        let borrowed = parts
9754            .iter()
9755            .map(|(name, xml)| (name.as_str(), xml.as_bytes()))
9756            .collect::<Vec<_>>();
9757        let archive = docx_archive_with_parts(&borrowed);
9758        let facts = extract_document_text_controlled(&archive, "batched.docx", None, &control())
9759            .expect("all referenced notes fit the unique package bounds");
9760        assert_eq!(facts.symbols.len(), 18);
9761        assert!(
9762            matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps }
9763            if gaps.contains(&DocumentCoverageGap::UnevaluatedField)
9764                && !gaps.iter().any(|gap| matches!(gap, DocumentCoverageGap::ResourceLimit(_))))
9765        );
9766    }
9767
9768    #[test]
9769    fn docx_inactive_header_variants_do_not_publish_symbols() {
9770        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="d"/><w:headerReference w:type="first" r:id="f"/><w:headerReference w:type="even" r:id="e"/></w:sectPr></w:body></w:document>"#;
9771        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="default.xml"/><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="even.xml"/></Relationships>"#;
9772        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9773        let archive = docx_archive_with_parts(&[
9774            (DOCX_DOCUMENT_PART, main),
9775            ("word/_rels/document.xml.rels", rels),
9776            ("word/default.xml", header),
9777            ("word/first.xml", header),
9778            ("word/even.xml", header),
9779        ]);
9780        let facts = extract_document_text_controlled(&archive, "inactive.docx", None, &control())
9781            .expect("inactive variants are safe to skip");
9782        assert_eq!(facts.symbols.len(), 1);
9783        assert!(
9784            matches!(&facts.symbols[0].locator, DocumentLocator::Docx { part, .. } if part == "word/default.xml")
9785        );
9786        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
9787    }
9788
9789    #[test]
9790    fn docx_first_and_default_headers_need_pagination_coverage() {
9791        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="default.xml"/><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/></Relationships>"#;
9792        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9793        for (prior, title, first, symbols, conditional) in [
9794            (
9795                "",
9796                "<w:titlePg/>",
9797                "<w:headerReference w:type=\"first\" r:id=\"f\"/>",
9798                2,
9799                true,
9800            ),
9801            ("", "<w:titlePg/>", "", 1, true),
9802            (
9803                "<w:p><w:pPr><w:sectPr><w:headerReference w:type=\"default\" r:id=\"d\"/></w:sectPr></w:pPr></w:p>",
9804                "<w:titlePg/>",
9805                "",
9806                1,
9807                true,
9808            ),
9809            (
9810                "",
9811                "<w:titlePg w:val=\"false\"/>",
9812                "<w:headerReference w:type=\"first\" r:id=\"f\"/>",
9813                1,
9814                false,
9815            ),
9816        ] {
9817            let default = if prior.is_empty() {
9818                "<w:headerReference w:type=\"default\" r:id=\"d\"/>"
9819            } else {
9820                ""
9821            };
9822            let main = format!(
9823                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>{prior}<w:sectPr>{default}{first}{title}</w:sectPr></w:body></w:document>"
9824            );
9825            let archive = docx_archive_with_parts(&[
9826                (DOCX_DOCUMENT_PART, main.as_bytes()),
9827                ("word/_rels/document.xml.rels", rels),
9828                ("word/default.xml", header),
9829                ("word/first.xml", header),
9830            ]);
9831            let facts =
9832                extract_document_text_controlled(&archive, "headers.docx", None, &control())
9833                    .expect("reachable header variants are valid");
9834            assert_eq!(facts.symbols.len(), symbols);
9835            assert_eq!(
9836                matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ConditionalStory)),
9837                conditional
9838            );
9839        }
9840    }
9841
9842    #[test]
9843    fn docx_duplicate_section_story_variant_is_malformed() {
9844        for kind in ["header", "footer"] {
9845            for variant in ["default", "first", "even"] {
9846                let main = format!(
9847                    "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body><w:sectPr><w:{kind}Reference w:type=\"{variant}\" r:id=\"a\"/><w:{kind}Reference w:type=\"{variant}\" r:id=\"b\"/></w:sectPr></w:body></w:document>"
9848                );
9849                assert!(
9850                    matches!(
9851                        extract_document_text_controlled(
9852                            &docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]),
9853                            "duplicate-section-variant.docx",
9854                            None,
9855                            &control(),
9856                        ),
9857                        Err(DocumentExtractionError::Malformed { .. })
9858                    ),
9859                    "{kind} {variant}"
9860                );
9861            }
9862        }
9863    }
9864
9865    #[test]
9866    fn docx_section_properties_require_document_ancestry() {
9867        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
9868        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
9869        for (invalid, in_body) in [
9870            (
9871                "<w:p><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:p>",
9872                true,
9873            ),
9874            (
9875                "<w:wrapper><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:wrapper>",
9876                true,
9877            ),
9878            (
9879                "<w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr>",
9880                true,
9881            ),
9882            (
9883                "<w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><wps:wsp><wps:txbx><w:txbxContent><w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></w:p></w:txbxContent></wps:txbx></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>",
9884                true,
9885            ),
9886            (
9887                "<w:p><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></w:p>",
9888                false,
9889            ),
9890        ] {
9891            let content = if in_body {
9892                format!("<w:body>{invalid}</w:body>")
9893            } else {
9894                invalid.to_owned()
9895            };
9896            let main = format!(
9897                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">{content}</w:document>"
9898            );
9899            let archive = docx_archive_with_parts(&[
9900                (DOCX_DOCUMENT_PART, main.as_bytes()),
9901                ("word/_rels/document.xml.rels", rels),
9902                ("word/header.xml", header),
9903            ]);
9904            assert!(
9905                matches!(
9906                    extract_document_text_controlled(
9907                        &archive,
9908                        "invalid-section.docx",
9909                        None,
9910                        &control()
9911                    ),
9912                    Err(DocumentExtractionError::Malformed { .. })
9913                ),
9914                "{invalid}"
9915            );
9916        }
9917        for valid in [
9918            "<mc:AlternateContent><mc:Choice Requires=\"w\"><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></mc:Choice><mc:Fallback/></mc:AlternateContent>",
9919            "<w:p><mc:AlternateContent><mc:Choice Requires=\"w\"><w:pPr><w:sectPr><w:headerReference r:id=\"h\"/></w:sectPr></w:pPr></mc:Choice><mc:Fallback/></mc:AlternateContent></w:p>",
9920        ] {
9921            let main = format!(
9922                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><w:body>{valid}</w:body></w:document>"
9923            );
9924            let archive = docx_archive_with_parts(&[
9925                (DOCX_DOCUMENT_PART, main.as_bytes()),
9926                ("word/_rels/document.xml.rels", rels),
9927                ("word/header.xml", header),
9928            ]);
9929            let facts =
9930                extract_document_text_controlled(&archive, "valid-section.docx", None, &control())
9931                    .expect("selected compatibility wrapper preserves valid section ancestry");
9932            assert_eq!(facts.symbols.len(), 1, "{valid}");
9933        }
9934    }
9935
9936    #[test]
9937    fn docx_run_payload_requires_rendered_paragraph_ancestry() {
9938        for invalid in [
9939            "<w:pPr><w:r><w:t>Injected</w:t></w:r></w:pPr>",
9940            "<w:pPr><w:hyperlink><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:hyperlink></w:pPr>",
9941            "<w:pPr><w:p><w:r><w:t>Injected</w:t></w:r></w:p></w:pPr>",
9942        ] {
9943            let xml = format!(
9944                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p>{invalid}</w:p></w:body></w:document>"
9945            );
9946            assert!(
9947                matches!(
9948                    parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9949                    Err(DocumentExtractionError::Malformed { .. })
9950                ),
9951                "{invalid}"
9952            );
9953        }
9954        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:p><w:r><w:t>Injected</w:t></w:r></w:p></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Content</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#;
9955        assert!(matches!(
9956            parse_docx(xml, &control(), IndexWorkStage::TextIndex),
9957            Err(DocumentExtractionError::Malformed { .. })
9958        ));
9959        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:hyperlink><w:r><w:t>Linked</w:t></w:r></w:hyperlink><w:sdt><w:sdtContent><w:r><w:t>Content</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
9960        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
9961            .expect("supported paragraph-content containers retain runs");
9962        assert_eq!(facts.text, "LinkedContent");
9963    }
9964
9965    #[test]
9966    fn docx_story_anchors_require_rendered_content_parents() {
9967        for invalid in [
9968            "<w:p><w:pPr><w:altChunk r:id=\"chunk\"/></w:pPr></w:p>",
9969            "<w:p><w:pPr><w:subDoc r:id=\"child\"/></w:pPr></w:p>",
9970            "<w:p><w:sdt><w:sdtPr><w:subDoc r:id=\"child\"/></w:sdtPr><w:sdtContent/></w:sdt></w:p>",
9971            "<w:p><w:r><w:drawing><w:txbxContent><w:altChunk r:id=\"chunk\"/></w:txbxContent></w:drawing></w:r></w:p>",
9972        ] {
9973            let xml = format!(
9974                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><w:body>{invalid}</w:body></w:document>"
9975            );
9976            assert!(
9977                matches!(
9978                    parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9979                    Err(DocumentExtractionError::Malformed { .. })
9980                ),
9981                "{invalid}"
9982            );
9983        }
9984    }
9985
9986    #[test]
9987    fn docx_main_document_requires_one_direct_body() {
9988        for content in [
9989            "",
9990            "<w:body/><w:body><w:p><w:r><w:sym w:font=\"Symbol\" w:char=\"F061\"/></w:r></w:p></w:body>",
9991            "<w:body><w:p><w:body/></w:p></w:body>",
9992        ] {
9993            let xml = format!(
9994                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{content}</w:document>"
9995            );
9996            assert!(
9997                matches!(
9998                    parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex),
9999                    Err(DocumentExtractionError::Malformed { .. })
10000                ),
10001                "{content}"
10002            );
10003        }
10004    }
10005
10006    #[test]
10007    fn docx_top_level_compatibility_body_preserves_coverage() {
10008        let uncertain = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:body><w:p><w:r><w:t>Unknown</w:t></w:r></w:p></w:body></mc:Choice><mc:Fallback><w:body><w:p><w:r><w:t>Fallback</w:t></w:r></w:p></w:body></mc:Fallback></mc:AlternateContent></w:document>"#;
10009        let parsed = extract_document_text_controlled(
10010            &docx_archive(uncertain, CompressionMethod::Stored),
10011            "uncertain-body.docx",
10012            None,
10013            &control(),
10014        )
10015        .expect("an unexamined top-level alternative does not prove a missing body");
10016        assert!(parsed.text.is_empty());
10017        assert!(parsed.symbols.is_empty());
10018        assert!(
10019            matches!(parsed.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::UnexaminedStory))
10020        );
10021
10022        let selected = std::str::from_utf8(uncertain)
10023            .expect("ASCII fixture")
10024            .replace("Requires=\"future\"", "Requires=\"w\"");
10025        let parsed = extract_document_text_controlled(
10026            &docx_archive(selected.as_bytes(), CompressionMethod::Stored),
10027            "selected-body.docx",
10028            None,
10029            &control(),
10030        )
10031        .expect("the supported direct-root choice selects its body");
10032        assert_eq!(parsed.text, "Unknown");
10033        assert_eq!(parsed.completeness, DocumentCompleteness::Complete);
10034
10035        let absent = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:future="urn:future"><mc:AlternateContent><mc:Choice Requires="future"><w:p/></mc:Choice><mc:Fallback><w:p/></mc:Fallback></mc:AlternateContent></w:document>"#;
10036        assert!(matches!(
10037            parse_docx(absent, &control(), IndexWorkStage::TextIndex),
10038            Err(DocumentExtractionError::Malformed { .. })
10039        ));
10040    }
10041
10042    #[test]
10043    fn docx_character_reference_whitespace_between_elements_is_accepted() {
10044        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>First</w:t></w:r></w:p>&#xA;&#x20;<w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#;
10045        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10046            .expect("XML character-reference whitespace is structural whitespace");
10047        assert_eq!(facts.text, "First\nSecond");
10048    }
10049
10050    #[test]
10051    fn docx_structural_cdata_accepts_only_xml_whitespace_inside_the_root() {
10052        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>First</w:t></w:r></w:p><![CDATA[
10053 ]]><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#;
10054        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10055            .expect("whitespace CDATA inside a story is structural whitespace");
10056        assert_eq!(facts.text, "First\nSecond");
10057        for xml in [
10058            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><![CDATA[Injected]]></w:body></w:document>"#.as_slice(),
10059            br#"<![CDATA[ ]]><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#.as_slice(),
10060        ] {
10061            assert!(matches!(
10062                parse_docx(xml, &control(), IndexWorkStage::TextIndex),
10063                Err(DocumentExtractionError::Malformed { .. })
10064            ));
10065        }
10066    }
10067
10068    #[test]
10069    fn docx_xml_characters_and_root_reference_boundaries_are_validated() {
10070        let valid = "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>";
10071        for invalid in [
10072            format!("&#x20;{valid}"),
10073            format!("{valid}&#x20;"),
10074            valid.replace("<w:body>", "<w:body>\u{b}"),
10075            valid.replace("<w:t>Visible", "<w:t>\u{c}Visible"),
10076            valid.replace("<w:t>Visible", "<w:t>&#xFFFE;Visible"),
10077            valid.replace("<w:body>", "<w:body w:extra=\"&#xFFFE;\">"),
10078        ] {
10079            assert!(
10080                matches!(
10081                    parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
10082                    Err(DocumentExtractionError::Malformed { .. })
10083                ),
10084                "{invalid:?}"
10085            );
10086        }
10087    }
10088
10089    #[test]
10090    fn docx_xml_declarations_and_comments_follow_xml_grammar() {
10091        let valid = "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:body></w:document>";
10092        for invalid in [
10093            format!("<?xml encoding=\"UTF-8\"?>{valid}"),
10094            format!("<?xml version=\"1.0&#xFFFE;\"?>{valid}"),
10095            format!("<?xml version=\"1.0\" version=\"1.0\"?>{valid}"),
10096            format!("<?xml version=\"1.0\" standalone=\"maybe\"?>{valid}"),
10097            format!("<?XML version=\"1.0\"?>{valid}"),
10098            valid.replace("<w:body>", "<w:body><!--bad--comment-->"),
10099        ] {
10100            assert!(
10101                matches!(
10102                    parse_docx(invalid.as_bytes(), &control(), IndexWorkStage::TextIndex),
10103                    Err(DocumentExtractionError::Malformed { .. })
10104                ),
10105                "{invalid}"
10106            );
10107        }
10108        let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><!--bad--comment--></Types>";
10109        assert!(matches!(
10110            parse_docx_content_types(types.as_bytes(), &control(), IndexWorkStage::TextIndex),
10111            Err(DocumentExtractionError::Malformed { .. })
10112        ));
10113    }
10114
10115    #[test]
10116    fn docx_literal_xml_line_endings_normalize_before_publication() {
10117        let xml = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>First\r\nSecond<![CDATA[\rThird]]></w:t></w:r></w:p></w:body></w:document>";
10118        let facts = parse_docx(xml, &control(), IndexWorkStage::TextIndex)
10119            .expect("literal source line endings remain valid XML text");
10120        assert_eq!(facts.text, "First\nSecond\nThird");
10121        let referenced = b"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r><w:t>First&#xD;Second</w:t></w:r></w:p></w:body></w:document>";
10122        let facts = parse_docx(referenced, &control(), IndexWorkStage::TextIndex)
10123            .expect("character references are expanded after literal EOL normalization");
10124        assert_eq!(facts.text, "First\rSecond");
10125    }
10126
10127    #[test]
10128    fn docx_table_block_wrappers_preserve_cell_paragraphs() {
10129        for table in [
10130            "<w:tbl><w:sdt><w:sdtContent><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:sdtContent></w:sdt></w:tbl>",
10131            "<w:tbl><w:customXml w:element=\"row\"><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:customXml></w:tbl>",
10132            "<w:tbl><w:tr><w:sdt><w:sdtContent><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:sdtContent></w:sdt></w:tr></w:tbl>",
10133            "<w:tbl><w:tr><w:customXml w:element=\"cell\"><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:customXml></w:tr></w:tbl>",
10134        ] {
10135            let xml = format!(
10136                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>{table}</w:body></w:document>"
10137            );
10138            let facts = parse_docx(xml.as_bytes(), &control(), IndexWorkStage::TextIndex)
10139                .expect("table-level wrappers retain rendered cell text");
10140            assert_eq!(facts.text, "Cell", "{table}");
10141        }
10142    }
10143
10144    #[test]
10145    fn docx_historical_section_properties_do_not_select_old_headers() {
10146        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="current"/><w:sectPrChange><w:sectPr><w:headerReference w:type="default" r:id="old"/></w:sectPr></w:sectPrChange></w:sectPr></w:body></w:document>"#;
10147        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="current" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
10148        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10149        let archive = docx_archive_with_parts(&[
10150            (DOCX_DOCUMENT_PART, main),
10151            ("word/_rels/document.xml.rels", rels),
10152            ("word/header.xml", header),
10153        ]);
10154        let facts = extract_document_text_controlled(&archive, "revision.docx", None, &control())
10155            .expect("tracked old section must not replace current header");
10156        assert_eq!(facts.symbols.len(), 1);
10157        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
10158    }
10159
10160    #[test]
10161    fn docx_historical_section_properties_validate_suppressed_attributes() {
10162        for history in [
10163            r#"<w:sectPrChange x:extra="1"><w:sectPr/></w:sectPrChange>"#,
10164            r#"<w:sectPrChange><w:sectPr x:extra="1"/></w:sectPrChange>"#,
10165        ] {
10166            let main = format!(
10167                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:sectPr>{history}</w:sectPr></w:body></w:document>"
10168            );
10169            let archive = docx_archive_with_parts(&[(DOCX_DOCUMENT_PART, main.as_bytes())]);
10170            assert!(matches!(
10171                extract_document_text_controlled(&archive, "revision.docx", None, &control()),
10172                Err(DocumentExtractionError::Malformed { .. })
10173            ));
10174        }
10175    }
10176
10177    #[test]
10178    fn docx_later_title_page_inherits_prior_first_header() {
10179        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:pPr><w:sectPr><w:headerReference w:type="first" r:id="prior"/></w:sectPr></w:pPr></w:p><w:sectPr><w:titlePg/></w:sectPr></w:body></w:document>"#;
10180        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="prior" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="prior.xml"/></Relationships>"#;
10181        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10182        let archive = docx_archive_with_parts(&[
10183            (DOCX_DOCUMENT_PART, main),
10184            ("word/_rels/document.xml.rels", rels),
10185            ("word/prior.xml", header),
10186        ]);
10187        let facts = extract_document_text_controlled(&archive, "inherit.docx", None, &control())
10188            .expect("later title page inherits prior first header");
10189        assert_eq!(facts.symbols.len(), 1);
10190        assert_eq!(facts.completeness, DocumentCompleteness::Complete);
10191        let conflicting = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="prior"/><w:titlePg w:val="false"/><w:titlePg w:val="true"/></w:sectPr></w:body></w:document>"#;
10192        let archive = docx_archive_with_parts(&[
10193            (DOCX_DOCUMENT_PART, conflicting),
10194            ("word/_rels/document.xml.rels", rels),
10195            ("word/prior.xml", header),
10196        ]);
10197        assert!(matches!(
10198            extract_document_text_controlled(
10199                &archive,
10200                "duplicate-title-page.docx",
10201                None,
10202                &control()
10203            ),
10204            Err(DocumentExtractionError::Malformed { .. })
10205        ));
10206    }
10207
10208    #[test]
10209    fn docx_root_relationship_selects_nonconventional_main_part() {
10210        let manifest = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/header.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#;
10211        let root_rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="./%64ocument.xml"/></Relationships>"#;
10212        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p><w:sectPr><w:headerReference r:id="h"/></w:sectPr></w:body></w:document>"#;
10213        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="h" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header.xml"/></Relationships>"#;
10214        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10215        let archive = docx_archive_with_parts(&[
10216            ("[Content_Types].xml", manifest),
10217            ("_rels/.rels", root_rels),
10218            ("document.xml", main),
10219            ("_rels/document.xml.rels", rels),
10220            ("header.xml", header),
10221        ]);
10222        let facts = extract_document_text_controlled(&archive, "root.docx", None, &control())
10223            .expect("OPC root relationship selects the main part");
10224        assert_eq!(facts.symbols.len(), 2);
10225        assert!(facts.symbols.iter().any(|symbol| {
10226            matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "document.xml")
10227        }));
10228        assert!(facts.symbols.iter().any(|symbol| {
10229            matches!(&symbol.locator, DocumentLocator::Docx { part, .. } if part == "header.xml")
10230        }));
10231    }
10232
10233    #[test]
10234    fn docx_missing_opc_metadata_never_publishes_complete_text() {
10235        let mut bytes = Vec::new();
10236        {
10237            let mut writer = ZipWriter::new(Cursor::new(&mut bytes));
10238            writer
10239                .start_file(DOCX_DOCUMENT_PART, FileOptions::default())
10240                .expect("fixture part");
10241            writer
10242                .write_all(br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#)
10243                .expect("fixture XML");
10244            writer.finish().expect("fixture package");
10245        }
10246        assert!(matches!(
10247            extract_document_text_controlled(&bytes, "truncated.docx", None, &control()),
10248            Err(DocumentExtractionError::InvalidDocxPackage { .. }),
10249        ));
10250    }
10251
10252    #[test]
10253    fn docx_enabled_header_variants_preserve_symbols_with_conditional_coverage() {
10254        let main = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="f"/><w:headerReference w:type="even" r:id="e"/><w:titlePg/></w:sectPr></w:body></w:document>"#;
10255        let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="f" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="first.xml"/><Relationship Id="e" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="even.xml"/><Relationship Id="s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/></Relationships>"#;
10256        let header = br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:r><w:sym w:font="Symbol" w:char="F061"/></w:r></w:p></w:hdr>"#;
10257        let settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:evenAndOddHeaders/></w:settings>"#;
10258        let archive = docx_archive_with_parts(&[
10259            (DOCX_DOCUMENT_PART, main),
10260            ("word/_rels/document.xml.rels", rels),
10261            ("word/first.xml", header),
10262            ("word/even.xml", header),
10263            ("word/settings.xml", settings),
10264        ]);
10265        let facts = extract_document_text_controlled(&archive, "enabled.docx", None, &control())
10266            .expect("enabled variants are reachable");
10267        assert_eq!(facts.symbols.len(), 2);
10268        assert_eq!(
10269            facts.completeness,
10270            DocumentCompleteness::Partial {
10271                gaps: vec![DocumentCoverageGap::ConditionalStory],
10272            }
10273        );
10274        let first_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="first" r:id="f"/><w:titlePg/></w:sectPr></w:body></w:document>"#;
10275        let first_only_archive = docx_archive_with_parts(&[
10276            (DOCX_DOCUMENT_PART, first_only),
10277            ("word/_rels/document.xml.rels", rels),
10278            ("word/first.xml", header),
10279            ("word/even.xml", header),
10280            ("word/settings.xml", settings),
10281        ]);
10282        let first_only_facts = extract_document_text_controlled(
10283            &first_only_archive,
10284            "first-only.docx",
10285            None,
10286            &control(),
10287        )
10288        .expect("first-page header does not depend on page parity");
10289        assert_eq!(first_only_facts.symbols.len(), 1);
10290        assert_eq!(
10291            first_only_facts.completeness,
10292            DocumentCompleteness::Complete
10293        );
10294        let default_only = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:sectPr><w:headerReference w:type="default" r:id="f"/></w:sectPr></w:body></w:document>"#;
10295        let default_only_archive = docx_archive_with_parts(&[
10296            (DOCX_DOCUMENT_PART, default_only),
10297            ("word/_rels/document.xml.rels", rels),
10298            ("word/first.xml", header),
10299            ("word/settings.xml", settings),
10300        ]);
10301        let default_only_facts = extract_document_text_controlled(
10302            &default_only_archive,
10303            "default-only.docx",
10304            None,
10305            &control(),
10306        )
10307        .expect("default header remains parity-dependent");
10308        assert_eq!(default_only_facts.symbols.len(), 1);
10309        assert_eq!(
10310            default_only_facts.completeness,
10311            DocumentCompleteness::Partial {
10312                gaps: vec![DocumentCoverageGap::ConditionalStory],
10313            }
10314        );
10315        let undeclared_settings = br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><x:evenAndOddHeaders/></w:settings>"#;
10316        let malformed = docx_archive_with_parts(&[
10317            (DOCX_DOCUMENT_PART, main),
10318            ("word/_rels/document.xml.rels", rels),
10319            ("word/first.xml", header),
10320            ("word/even.xml", header),
10321            ("word/settings.xml", undeclared_settings),
10322        ]);
10323        assert!(matches!(
10324            extract_document_text_controlled(&malformed, "undeclared-settings.docx", None, &control()),
10325            Err(DocumentExtractionError::Malformed { message, .. })
10326                if message.contains("undeclared namespace prefix")
10327        ));
10328        for settings in [
10329            br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:evenAndOddHeaders x:val="false"/></w:settings>"#.as_slice(),
10330            br#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:ignored="true"><w:evenAndOddHeaders/></w:settings>"#.as_slice(),
10331        ] {
10332            let malformed = docx_archive_with_parts(&[
10333                (DOCX_DOCUMENT_PART, main),
10334                ("word/_rels/document.xml.rels", rels),
10335                ("word/first.xml", header),
10336                ("word/even.xml", header),
10337                ("word/settings.xml", settings),
10338            ]);
10339            assert!(matches!(
10340                extract_document_text_controlled(&malformed, "undeclared-settings.docx", None, &control()),
10341                Err(DocumentExtractionError::Malformed { message, .. })
10342                    if message.contains("undeclared namespace prefix")
10343            ));
10344        }
10345    }
10346
10347    #[test]
10348    fn docx_post_admission_fact_limit_is_local_incomplete_coverage() {
10349        for count in [
10350            MAX_DOCUMENT_FACTS - 1,
10351            MAX_DOCUMENT_FACTS,
10352            MAX_DOCUMENT_FACTS + 1,
10353        ] {
10354            let xml = format!(
10355                "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body><w:p><w:r>{}</w:r></w:p></w:body></w:document>",
10356                "<w:sym w:font=\"Wingdings\" w:char=\"F03A\"/>".repeat(count),
10357            );
10358            let archive = docx_archive(xml.as_bytes(), CompressionMethod::Deflated);
10359            let facts =
10360                extract_document_text_controlled(&archive, "limited.docx", None, &control())
10361                    .expect("safe post-admission fact ceiling is file-local");
10362            if count < MAX_DOCUMENT_FACTS {
10363                assert_eq!(facts.symbols.len(), count);
10364                assert_eq!(facts.facts.len(), 1);
10365                assert!(
10366                    !matches!(facts.completeness, DocumentCompleteness::Partial { ref gaps } if gaps.contains(&DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount)))
10367                );
10368            } else {
10369                assert!(facts.symbols.is_empty());
10370                assert_eq!(
10371                    facts.completeness,
10372                    DocumentCompleteness::Partial {
10373                        gaps: vec![DocumentCoverageGap::ResourceLimit(DocumentLimit::FactCount)],
10374                    }
10375                );
10376            }
10377        }
10378    }
10379
10380    #[test]
10381    fn direct_xml_rejects_external_doctype_declarations() {
10382        let xml = br#"<!DOCTYPE w:document SYSTEM "https://example.invalid/document.dtd"><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>"#;
10383        let bytes = docx_archive(xml, CompressionMethod::Stored);
10384        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
10385            .expect_err("external declarations must never enter the parser boundary");
10386        assert!(matches!(
10387            error,
10388            DocumentExtractionError::Malformed {
10389                format: DocumentFormat::Docx,
10390                ..
10391            }
10392        ));
10393    }
10394
10395    #[test]
10396    fn direct_xml_rejects_non_whitespace_outside_document_root() {
10397        let xml = br#"prefix<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#;
10398        let bytes = docx_archive(xml, CompressionMethod::Stored);
10399        let error = extract_document_text_controlled(&bytes, "guide.docx", None, &control())
10400            .expect_err("non-whitespace outside the root must fail closed");
10401        assert!(matches!(
10402            error,
10403            DocumentExtractionError::Malformed {
10404                format: DocumentFormat::Docx,
10405                ..
10406            }
10407        ));
10408    }
10409}