Skip to main content

pdf_inspector/
detector.rs

1//! Smart PDF type detection without full document load
2//!
3//! This module detects whether a PDF is text-based, scanned, or image-based
4//! by sampling content streams for text operators (Tj/TJ) without loading
5//! all objects.
6
7use crate::PdfError;
8use lopdf::{Document, Object, ObjectId};
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12mod content_geometry;
13mod content_mask;
14mod content_resources;
15mod content_scan;
16use content_scan::ContentCounts;
17
18/// PDF type classification
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PdfType {
21    /// PDF has extractable text (Tj/TJ operators found)
22    TextBased,
23    /// PDF appears to be scanned (images only, no text operators)
24    Scanned,
25    /// PDF contains mostly images with minimal/no text
26    ImageBased,
27    /// PDF has mix of text and image-heavy pages
28    Mixed,
29}
30
31/// Strategy for which pages to scan during detection
32#[derive(Debug, Clone)]
33pub enum ScanStrategy {
34    /// Scan all pages, stop on first non-text page (current default).
35    /// Best for pipelines that route TextBased PDFs to fast extraction.
36    EarlyExit,
37    /// Scan all pages, no early exit.
38    /// Best when you need accurate Mixed vs Scanned classification.
39    Full,
40    /// Sample up to N evenly distributed pages (first, last, middle).
41    /// Best for very large PDFs where speed matters more than precision.
42    Sample(u32),
43    /// Only scan these specific 1-indexed page numbers.
44    /// Best when the caller knows which pages to check.
45    Pages(Vec<u32>),
46}
47
48/// Result of PDF type detection
49#[derive(Debug)]
50pub struct PdfTypeResult {
51    /// Detected PDF type
52    pub pdf_type: PdfType,
53    /// Number of pages in the document
54    pub page_count: u32,
55    /// Number of pages sampled for detection
56    pub pages_sampled: u32,
57    /// Number of pages with text operators found
58    pub pages_with_text: u32,
59    /// Confidence score (0.0 - 1.0)
60    pub confidence: f32,
61    /// The `/Title` of the document information dictionary (the trailer's
62    /// `/Info`), decoded as a PDF text string: UTF-16BE after the byte order
63    /// mark `FE FF`, UTF-8 after `EF BB BF`, PDFDocEncoding otherwise — with
64    /// UTF-16LE after `FF FE` and valid UTF-8 written without a mark read as
65    /// such, language escapes and trailing NULs dropped. `None` when the
66    /// document has no such dictionary, the entry is missing or its value is
67    /// not a string. The other entries below are read the same way; XMP
68    /// metadata is not read.
69    pub title: Option<String>,
70    /// The document information dictionary's `/Author`, read like `title`.
71    pub author: Option<String>,
72    /// The document information dictionary's `/Subject`, read like `title`.
73    pub subject: Option<String>,
74    /// The document information dictionary's `/Keywords`, read like `title`.
75    pub keywords: Option<String>,
76    /// The document information dictionary's `/Creator` (the application
77    /// the document was authored in), read like `title`.
78    pub creator: Option<String>,
79    /// The document information dictionary's `/Producer` (the application
80    /// that wrote the PDF), read like `title`.
81    pub producer: Option<String>,
82    /// The document information dictionary's `/CreationDate`, read like
83    /// `title` and returned as written: a PDF date string such as
84    /// `D:20240115103000+01'00'`, neither validated nor converted.
85    pub creation_date: Option<String>,
86    /// The document information dictionary's `/ModDate`, returned as
87    /// written like `creation_date`.
88    pub mod_date: Option<String>,
89    /// Whether OCR is recommended for better extraction
90    /// True when images provide essential context (e.g., template-based PDFs)
91    pub ocr_recommended: bool,
92    /// 1-indexed page numbers that need OCR (image-only or insufficient text).
93    /// Empty for TextBased. All pages for Scanned/ImageBased. Specific pages for Mixed.
94    pub pages_needing_ocr: Vec<u32>,
95    /// Per-page explanation for `pages_needing_ocr`: 1-indexed page → reason
96    /// codes (`scanned`, `no_text`, `vector_text`, `invisible_text_layer`,
97    /// `suspected_garbled_text`). Only contains pages that need OCR.
98    pub ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>>,
99}
100
101/// Configuration for PDF type detection
102#[derive(Debug, Clone)]
103pub struct DetectionConfig {
104    /// Strategy for which pages to scan
105    pub strategy: ScanStrategy,
106    /// Minimum text operator count per page to consider as text-based
107    pub min_text_ops_per_page: u32,
108    /// Threshold ratio of text pages to total pages for classification
109    pub text_page_ratio_threshold: f32,
110}
111
112impl Default for DetectionConfig {
113    fn default() -> Self {
114        Self {
115            // EarlyExit is too aggressive for PDFs with an image-only cover
116            // followed by text-heavy pages (e.g., annual reports).
117            strategy: ScanStrategy::Sample(8),
118            min_text_ops_per_page: 3,
119            text_page_ratio_threshold: 0.6,
120        }
121    }
122}
123
124/// Detect PDF type from file path
125pub fn detect_pdf_type<P: AsRef<Path>>(path: P) -> Result<PdfTypeResult, PdfError> {
126    detect_pdf_type_with_config(path, DetectionConfig::default())
127}
128
129/// Detect PDF type from file path with custom configuration
130pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
131    path: P,
132    config: DetectionConfig,
133) -> Result<PdfTypeResult, PdfError> {
134    crate::validate_pdf_file(&path)?;
135
136    let (doc, page_count) = crate::load_document_from_path(&path)?;
137
138    detect_from_document(&doc, page_count, &config)
139}
140
141/// Detect PDF type from memory buffer
142pub fn detect_pdf_type_mem(buffer: &[u8]) -> Result<PdfTypeResult, PdfError> {
143    detect_pdf_type_mem_with_config(buffer, DetectionConfig::default())
144}
145
146/// Detect PDF type from memory buffer with custom configuration
147pub fn detect_pdf_type_mem_with_config(
148    buffer: &[u8],
149    config: DetectionConfig,
150) -> Result<PdfTypeResult, PdfError> {
151    crate::validate_pdf_bytes(buffer)?;
152
153    let (doc, page_count) = crate::load_document_from_mem(buffer)?;
154
155    detect_from_document(&doc, page_count, &config)
156}
157
158/// Heuristic page-count fallback for malformed PDFs that cannot be parsed.
159///
160/// This scans raw bytes for page dictionaries (`/Type /Page`) while excluding
161/// the page tree node (`/Type /Pages`). It is intended as a low-confidence hint
162/// for diagnostics; parsed page-tree counts remain authoritative.
163pub fn estimate_page_count_from_bytes(buffer: &[u8]) -> u32 {
164    let mut count = 0u32;
165    let mut pos = 0usize;
166
167    while let Some(rel_idx) = find_bytes(&buffer[pos..], b"/Type") {
168        let mut value_pos = pos + rel_idx + b"/Type".len();
169        value_pos = skip_pdf_whitespace(buffer, value_pos);
170
171        if buffer.get(value_pos) == Some(&b'/') {
172            let name_start = value_pos + 1;
173            let name_end = name_start + b"Page".len();
174            if name_end <= buffer.len()
175                && &buffer[name_start..name_end] == b"Page"
176                && buffer
177                    .get(name_end)
178                    .is_none_or(|b| is_pdf_name_delimiter(*b))
179            {
180                count += 1;
181            }
182        }
183
184        pos += rel_idx + b"/Type".len();
185    }
186
187    count
188}
189
190fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
191    haystack.windows(needle.len()).position(|w| w == needle)
192}
193
194fn skip_pdf_whitespace(buffer: &[u8], mut pos: usize) -> usize {
195    while pos < buffer.len() && is_pdf_whitespace(buffer[pos]) {
196        pos += 1;
197    }
198    pos
199}
200
201fn is_pdf_whitespace(byte: u8) -> bool {
202    matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
203}
204
205fn is_pdf_name_delimiter(byte: u8) -> bool {
206    is_pdf_whitespace(byte)
207        || matches!(
208            byte,
209            b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
210        )
211}
212
213/// Detection logic on a pre-loaded document.
214///
215/// `page_count` should come from `Document::load_metadata()`.
216pub(crate) fn detect_from_document(
217    doc: &Document,
218    page_count: u32,
219    config: &DetectionConfig,
220) -> Result<PdfTypeResult, PdfError> {
221    let pages = doc.get_pages();
222    let total_pages = pages.len() as u32;
223
224    // Select pages to scan based on strategy
225    let (sample_indices, allow_early_exit) = match &config.strategy {
226        ScanStrategy::EarlyExit => ((1..=total_pages).collect::<Vec<_>>(), true),
227        ScanStrategy::Full => ((1..=total_pages).collect::<Vec<_>>(), false),
228        ScanStrategy::Sample(max_pages) => {
229            let n = (*max_pages).min(total_pages);
230            (distribute_pages(n, total_pages), false)
231        }
232        ScanStrategy::Pages(pages) => {
233            let mut valid: Vec<u32> = pages
234                .iter()
235                .copied()
236                .filter(|&p| p >= 1 && p <= total_pages)
237                .collect();
238            valid.sort();
239            valid.dedup();
240            (valid, false)
241        }
242    };
243
244    let mut pages_with_text = 0u32;
245    let mut pages_with_images = 0u32;
246    let mut pages_with_template_images = 0u32;
247    let mut pages_with_vector_text = 0u32;
248    let mut total_text_ops = 0u32;
249    // Cache Phase 1 results to avoid re-analyzing sampled pages in Phase 2
250    let mut analysis_cache: HashMap<u32, PageAnalysis> = HashMap::new();
251    let mut pages_actually_sampled = 0u32;
252    // Pages read whose form content ran past the scan's byte budget.
253    let mut pages_past_form_budget = 0u32;
254
255    for page_num in &sample_indices {
256        if let Some(&page_id) = pages.get(page_num) {
257            let analysis = analyze_page_content(doc, page_id);
258            pages_actually_sampled += 1;
259            log::debug!(
260                "page {}: text_ops={} executed_text_ops={} hidden_text_ops={} images={} image_count={} template={} covering_image={} form_bytes={} form_bytes_exceeded={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
261                page_num, analysis.text_operator_count, analysis.executed_text_operator_count,
262                analysis.invisible_text_operator_count,
263                analysis.has_images, analysis.image_count, analysis.has_template_image,
264                analysis.has_covering_image,
265                analysis.executed_form_bytes, analysis.form_bytes_exceeded,
266                analysis.unique_text_chars, analysis.unique_alphanum_chars,
267                analysis.path_op_count, analysis.has_vector_text,
268                analysis.total_image_area, analysis.has_identity_h_no_tounicode,
269                analysis.has_only_type3_fonts, analysis.font_change_count,
270                analysis.has_decodable_text_fonts
271            );
272            if analysis.form_bytes_exceeded {
273                pages_past_form_budget += 1;
274                log::debug!(
275                    "page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
276                    page_num, analysis.executed_form_bytes
277                );
278            }
279            let is_image_dominated = analysis.image_count > 10
280                && analysis.image_count > analysis.text_operator_count * 3;
281            let effective_min_ops = if analysis.has_images || analysis.image_count > 0 {
282                config.min_text_ops_per_page.max(10)
283            } else {
284                config.min_text_ops_per_page
285            };
286            if analysis.text_operator_count >= effective_min_ops
287                && !is_image_dominated
288                && analysis.unique_text_chars >= 5
289                && !analysis.has_vector_text
290                && !analysis.has_only_type3_fonts
291                && !analysis.has_invisible_text_layer
292            {
293                pages_with_text += 1;
294            }
295            if analysis.has_images {
296                pages_with_images += 1;
297            }
298            // Only count as a template-image page if it looks like a scan
299            // (single full-page image) rather than a text page with figures.
300            // Scanned-with-OCR PDFs have 1 large image per page + OCR text overlay;
301            // text PDFs with figures have multiple smaller images alongside real text.
302            //
303            // Exception: CID-encoded fonts with ToUnicode produce low
304            // unique_alphanum_chars in raw bytes but are fully decodable.
305            // When a page has decodable fonts and enough text ops, treat it
306            // as having real text regardless of raw byte diversity.
307            let alphanum_ok = analysis.unique_alphanum_chars < 10
308                && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
309            // A page whose text is all invisible under an image covering
310            // the page is a scan however many operators the layer has:
311            // the page shows its raster, and the layer only describes it.
312            if (analysis.has_template_image
313                && (analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_ok))
314                || analysis.has_invisible_text_layer
315            {
316                pages_with_template_images += 1;
317            }
318            if analysis.has_vector_text {
319                pages_with_vector_text += 1;
320            }
321            total_text_ops += analysis.text_operator_count;
322            analysis_cache.insert(*page_num, analysis.clone());
323
324            // Early exit: if this page is non-text (insufficient meaningful text
325            // but has images), this PDF won't be purely TextBased.
326            if allow_early_exit
327                && (analysis.text_operator_count < config.min_text_ops_per_page
328                    || is_image_dominated
329                    || analysis.unique_text_chars < 5
330                    || analysis.has_invisible_text_layer)
331                && (analysis.has_images || analysis.has_template_image)
332            {
333                break;
334            }
335        }
336    }
337
338    let pages_sampled = pages_actually_sampled;
339    let text_ratio = if pages_sampled > 0 {
340        pages_with_text as f32 / pages_sampled as f32
341    } else {
342        0.0
343    };
344
345    // Check if this is a template-based PDF (images provide essential context)
346    // Template PDFs have text AND large background images on most pages
347    let has_template_images = pages_with_template_images > 0;
348    let template_ratio = if pages_sampled > 0 {
349        pages_with_template_images as f32 / pages_sampled as f32
350    } else {
351        0.0
352    };
353
354    // OCR is recommended when:
355    // 1. Template images are present (text alone is insufficient), OR
356    // 2. PDF is scanned/image-based
357    let ocr_recommended: bool;
358
359    // Classification logic
360    let (pdf_type, confidence) = if has_template_images && pages_with_text > 0 {
361        ocr_recommended = true;
362        // Template-based PDF: has text but images provide essential context
363        (PdfType::Mixed, 0.5 + (0.3 * (1.0 - template_ratio)))
364    } else if text_ratio >= config.text_page_ratio_threshold {
365        ocr_recommended = false;
366        (PdfType::TextBased, text_ratio)
367    } else if pages_with_text == 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
368        // No extractable text but has images or vector-outlined text
369        ocr_recommended = true;
370        if total_text_ops == 0 && pages_with_vector_text == 0 {
371            (PdfType::Scanned, 0.95)
372        } else {
373            (PdfType::ImageBased, 0.8)
374        }
375    } else if pages_with_text > 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
376        ocr_recommended = true;
377        (PdfType::Mixed, 0.7)
378    } else if total_text_ops == 0 {
379        ocr_recommended = true;
380        (PdfType::Scanned, 0.9)
381    } else {
382        ocr_recommended = false;
383        (PdfType::TextBased, text_ratio.max(0.5))
384    };
385
386    // Phase 1b: Newspaper-style layout detection.
387    // Dense multi-column newspapers (WSJ, NYT) have extractable text but produce
388    // poor output due to complex interleaved article layouts. Detect via consistently
389    // high text density combined with moderate font switches and a low Tf/Tj ratio.
390    //
391    // The Tf/Tj ratio distinguishes newspapers from styled legal/business documents:
392    // - Newspapers: ratio 0.02-0.06 (dense prose with occasional font switches)
393    // - Rich-styled docs (DPA, contracts): ratio 0.25-0.35 (per-character styling)
394    //
395    // Thresholds calibrated against:
396    // - WSJ 50-page newspaper: text_ops 1500-3800, font_changes 50-194, ratio 0.02-0.06
397    // - DPA/contracts: text_ops 1300-2260, font_changes 327-630, ratio 0.25-0.32
398    // - SEC filings: text_ops 1-1800, font_changes 1-65 (only 1-2 dense pages)
399    // - Normal docs: text_ops < 700, font_changes < 55
400    let ocr_recommended = if pdf_type == PdfType::TextBased && pages_sampled >= 3 {
401        let mut newspaper_pages = 0u32;
402        for analysis in analysis_cache.values() {
403            let ratio = if analysis.text_operator_count > 0 {
404                analysis.font_change_count as f32 / analysis.text_operator_count as f32
405            } else {
406                1.0
407            };
408            if analysis.text_operator_count >= 1500
409                && analysis.font_change_count >= 50
410                && ratio < 0.15
411            {
412                newspaper_pages += 1;
413            }
414        }
415        let newspaper_ratio = newspaper_pages as f32 / pages_sampled as f32;
416        if newspaper_ratio >= 0.5 {
417            log::debug!(
418                "newspaper layout detected: {}/{} pages with high text_ops + font_changes → OCR recommended",
419                newspaper_pages, pages_sampled
420            );
421            true
422        } else {
423            ocr_recommended
424        }
425    } else {
426        ocr_recommended
427    };
428
429    // Phase 2: Build per-page OCR list
430    let mut pages_needing_ocr = match pdf_type {
431        PdfType::TextBased => Vec::new(),
432        PdfType::Scanned | PdfType::ImageBased => (1..=total_pages).collect(),
433        PdfType::Mixed => {
434            let mut ocr_pages = Vec::new();
435            for page_num in 1..=total_pages {
436                let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
437                    cached.clone()
438                } else if let Some(&page_id) = pages.get(&page_num) {
439                    // Cache the fresh analysis so the reason-classification pass
440                    // below sees the real signals (vector_text, etc.) instead of
441                    // defaulting to "scanned".
442                    let a = analyze_page_content(doc, page_id);
443                    analysis_cache.insert(page_num, a.clone());
444                    a
445                } else {
446                    continue;
447                };
448                // Template images only need OCR when it looks like a scan
449                // (single full-page image) rather than figures alongside text.
450                // CID-encoded fonts with ToUnicode produce low unique_alphanum_chars
451                // in raw bytes but are fully decodable — don't treat as scan.
452                let alphanum_low = analysis.unique_alphanum_chars < 10
453                    && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
454                let looks_like_scan =
455                    analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
456                // A template-image page below the `pages_with_text` floor is
457                // a scan with incidental chrome (masthead, stamp, date line)
458                // even when that chrome is diverse, decodable text — keep
459                // this in sync with `page_ocr_signals`.
460                let sparse_text_over_scan = analysis.has_template_image
461                    && analysis.text_operator_count < config.min_text_ops_per_page.max(10);
462                if (analysis.has_template_image && looks_like_scan)
463                    || analysis.has_vector_text
464                    || analysis.has_invisible_text_layer
465                    || sparse_text_over_scan
466                    || (analysis.text_operator_count < config.min_text_ops_per_page
467                        && analysis.has_images)
468                {
469                    ocr_pages.push(page_num);
470                }
471            }
472            ocr_pages.sort();
473            ocr_pages.dedup();
474            ocr_pages
475        }
476    };
477
478    // Phase 3: Flag pages with undecodable fonts for OCR.
479    // - Identity-H/V without ToUnicode: raw CID values can't map to Unicode
480    // - Type3-only without ToUnicode: glyph bitmaps can't map to Unicode
481    for (&page_num, analysis) in &analysis_cache {
482        if (analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts)
483            && !pages_needing_ocr.contains(&page_num)
484        {
485            pages_needing_ocr.push(page_num);
486        }
487    }
488    // Check uncached pages too (when not all pages were sampled).
489    // Use analyze_page_content to get usage-based font checks (P1 + P2 fix).
490    if pages_needing_ocr.len() < total_pages as usize {
491        for page_num in 1..=total_pages {
492            if analysis_cache.contains_key(&page_num) || pages_needing_ocr.contains(&page_num) {
493                continue;
494            }
495            if let Some(&page_id) = pages.get(&page_num) {
496                let analysis = analyze_page_content(doc, page_id);
497                if analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts {
498                    pages_needing_ocr.push(page_num);
499                    // Cache so the reason pass reports suspected_garbled_text
500                    // rather than defaulting to "scanned".
501                    analysis_cache.insert(page_num, analysis);
502                }
503            }
504        }
505    }
506    pages_needing_ocr.sort();
507    pages_needing_ocr.dedup();
508
509    // Explain each OCR-flagged page. Pages we analyzed get a signal-derived
510    // reason. Pages flagged only by whole-document classification — the
511    // pages a sample left out of a Scanned/ImageBased document — are still
512    // read for the one signal the byte scan alone gives, so a text layer
513    // nobody sees is named wherever it is; the font and path signals come
514    // from the full analysis and stay with the sampled pages, so such a
515    // page otherwise defaults to `scanned`.
516    let mut ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>> =
517        std::collections::BTreeMap::new();
518    for &page_num in &pages_needing_ocr {
519        let reasons = match analysis_cache.get(&page_num) {
520            Some(analysis) => page_ocr_reasons(analysis),
521            None => match pages.get(&page_num) {
522                Some(&page_id) => {
523                    let executed = content_scan::page_executed_content(doc, page_id);
524                    if executed.form_bytes_exceeded {
525                        pages_past_form_budget += 1;
526                        log::debug!(
527                            "page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
528                            page_num, executed.form_bytes
529                        );
530                    }
531                    if executed.shows_only_a_hidden_text_layer {
532                        vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
533                    } else {
534                        vec![crate::OCR_REASON_SCANNED]
535                    }
536                }
537                None => vec![crate::OCR_REASON_SCANNED],
538            },
539        };
540        ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
541    }
542    if pages_past_form_budget > 0 {
543        log::debug!(
544            "{} page(s) ran past the scan's byte budget for form content; their evidence is incomplete and their classification unchanged",
545            pages_past_form_budget
546        );
547    }
548
549    // The document information dictionary's entries, when it has them.
550    let DocumentInfo {
551        title,
552        author,
553        subject,
554        keywords,
555        creator,
556        producer,
557        creation_date,
558        mod_date,
559    } = read_document_info(doc);
560
561    Ok(PdfTypeResult {
562        pdf_type,
563        page_count,
564        pages_sampled,
565        pages_with_text,
566        confidence,
567        title,
568        author,
569        subject,
570        keywords,
571        creator,
572        producer,
573        creation_date,
574        mod_date,
575        ocr_recommended,
576        pages_needing_ocr,
577        ocr_reasons_by_page,
578    })
579}
580
581/// Distribute `n` page indices evenly across `total` pages (1-indexed).
582///
583/// Always includes the first and last page, with remaining pages
584/// spaced evenly in between.
585fn distribute_pages(n: u32, total: u32) -> Vec<u32> {
586    if n == 0 {
587        return Vec::new();
588    }
589    if n >= total {
590        return (1..=total).collect();
591    }
592
593    let mut indices = Vec::with_capacity(n as usize);
594    indices.push(1);
595
596    if n > 1 {
597        indices.push(total);
598    }
599
600    let remaining = n.saturating_sub(2);
601    if remaining > 0 && total > 2 {
602        let step = (total - 2) / (remaining + 1);
603        for i in 1..=remaining {
604            let idx = 1 + (step * i);
605            if idx > 1 && idx < total && !indices.contains(&idx) {
606                indices.push(idx);
607            }
608        }
609    }
610
611    indices.sort();
612    indices.dedup();
613    indices
614}
615
616/// Page content analysis result
617#[derive(Clone, Default)]
618struct PageAnalysis {
619    text_operator_count: u32,
620    /// Text-showing operators the page executes: in its own content and in
621    /// the Form XObjects that content invokes, each once — unlike
622    /// `text_operator_count`, which also counts forms merely bound.
623    executed_text_operator_count: u32,
624    /// Those of `executed_text_operator_count` that left nothing to see:
625    /// run under text render mode 3 (invisible), or under mode 7 (clip
626    /// only) with nothing painted through the clip.
627    invisible_text_operator_count: u32,
628    has_images: bool,
629    /// Whether page has a large background/template image (>50% coverage)
630    has_template_image: bool,
631    /// Whether the images the page's content draws — by `Do`, in its own
632    /// content and in the forms it invokes, each draw clipped to the
633    /// visible page box — cover at least half of the page area, whatever
634    /// their pixel size. Images bound in resources but never drawn do not
635    /// count.
636    has_covering_image: bool,
637    /// Whether every text-showing operator the page executes is invisible
638    /// while images cover the page: a scan carrying a text layer nobody
639    /// sees. What the layer says is not what the page shows, so the page
640    /// is read from its raster.
641    has_invisible_text_layer: bool,
642    /// Bytes of Form XObject and pattern-cell content the page's content
643    /// executed, and whether a form went unread because the page's budget
644    /// of them ran out — the page then keeps the classification it had
645    /// before the executed content was followed, never
646    /// `has_invisible_text_layer`.
647    executed_form_bytes: usize,
648    form_bytes_exceeded: bool,
649    /// Total image area in pixels (reserved for future use)
650    #[allow(dead_code)]
651    total_image_area: u64,
652    /// Number of Do (XObject invocation) operators in content streams
653    image_count: u32,
654    /// Number of unique non-whitespace text characters found in string operands
655    unique_text_chars: u32,
656    /// Number of unique ASCII alphanumeric bytes (letters + digits) in string operands
657    unique_alphanum_chars: u32,
658    /// Number of path construction/painting ops (m, l, c, h, f, re, etc.)
659    #[allow(dead_code)]
660    path_op_count: u32,
661    /// Whether the page has vector-outlined text (massive path ops, minimal text ops)
662    has_vector_text: bool,
663    /// Whether the page has Type0 fonts with Identity-H/V encoding but no ToUnicode CMap.
664    /// These fonts produce garbage text because CID values can't be mapped to Unicode.
665    has_identity_h_no_tounicode: bool,
666    /// Whether the page uses only Type3 fonts (no normal text fonts).
667    /// Type3 fonts render each glyph as a custom drawing/bitmap — without a
668    /// ToUnicode CMap, the character codes can't be mapped to Unicode.
669    has_only_type3_fonts: bool,
670    /// Number of Tf (set font) operators — high count indicates many font switches
671    font_change_count: u32,
672    /// Whether the page has fonts that can produce decodable text (ToUnicode,
673    /// standard encoding, Type1/TrueType with known encoding).
674    /// CID-encoded text with ToUnicode produces low unique_alphanum_chars in raw
675    /// bytes but is fully decodable — this flag prevents misclassifying it as a scan.
676    has_decodable_text_fonts: bool,
677}
678
679/// Explain *why* a page needs OCR, from its content analysis. Priority: a
680/// text layer nobody sees under a covering image (`invisible_text_layer`)
681/// comes first — such a page is a scan whatever its fonts are — then
682/// undecodable fonts (`suspected_garbled_text`) and vector-outlined text
683/// (`vector_text`), which persist even when a text layer is present;
684/// otherwise a page with no extractable text is `scanned` when an image
685/// backs it or `no_text` when nothing does. `extract_pages_markdown_mem`
686/// puts `invisible_text_layer` first as well; the reasons after it keep
687/// each surface's own order (there, `scanned` can stand beside the font
688/// and path reasons, which here pre-empt it).
689fn page_ocr_reasons(a: &PageAnalysis) -> Vec<&'static str> {
690    let mut reasons = Vec::new();
691    if a.has_invisible_text_layer {
692        reasons.push(crate::OCR_REASON_INVISIBLE_TEXT_LAYER);
693    }
694    if a.has_identity_h_no_tounicode || a.has_only_type3_fonts {
695        reasons.push(crate::OCR_REASON_SUSPECTED_GARBLED_TEXT);
696    }
697    if a.has_vector_text {
698        reasons.push(crate::OCR_REASON_VECTOR_TEXT);
699    }
700    if reasons.is_empty() {
701        let has_extractable_text = a.text_operator_count > 0 && a.unique_text_chars > 0;
702        if !has_extractable_text && !a.has_images && !a.has_template_image {
703            reasons.push(crate::OCR_REASON_NO_TEXT);
704        } else {
705            // Image-backed with no usable text, or too little text to trust.
706            reasons.push(crate::OCR_REASON_SCANNED);
707        }
708    }
709    reasons
710}
711
712/// Extracted font information from a Resource dictionary entry.
713/// Stores the properties needed for decodability/identity-h checks
714/// without holding a reference to the document.
715#[derive(Clone, Debug)]
716struct FontInfo {
717    subtype: Option<Vec<u8>>,
718    encoding: Option<Vec<u8>>,
719    has_tounicode: bool,
720    /// The raw font dictionary as an owned lopdf Dictionary.
721    /// Needed for fallback checks (DescendantFonts → W array, embedded cmap).
722    dict: lopdf::Dictionary,
723}
724
725/// Collect font entries from a Resources/Font dictionary into the font map.
726/// Each entry maps font ObjectId → FontInfo. Using ObjectId as the key
727/// avoids name collisions: different resource dictionaries can legally define
728/// `/F1` pointing to different font objects, and ObjectId uniquely identifies
729/// the underlying font regardless of the name used to reference it.
730///
731/// Inline font dictionaries (rare — fonts are almost always indirect refs)
732/// are skipped because they have no ObjectId.
733fn collect_fonts_from_resource_dict(
734    doc: &Document,
735    resources: &lopdf::Dictionary,
736    font_map: &mut HashMap<ObjectId, FontInfo>,
737) {
738    let font_obj = match resources.get(b"Font").ok() {
739        Some(obj) => obj,
740        None => return,
741    };
742    let font_dict = match font_obj {
743        Object::Dictionary(d) => Some(d),
744        Object::Reference(r) => doc.get_dictionary(*r).ok(),
745        _ => None,
746    };
747    let Some(font_dict) = font_dict else {
748        return;
749    };
750    for (_name, value) in font_dict.iter() {
751        // Only indirect references have a stable ObjectId.
752        // Inline font dicts are extremely rare and have no ObjectId — skip them.
753        let font_obj_id = match value {
754            Object::Reference(r) => *r,
755            _ => continue,
756        };
757        if font_map.contains_key(&font_obj_id) {
758            continue;
759        }
760        let resolved = doc.get_dictionary(font_obj_id).ok();
761        if let Some(fd) = resolved {
762            let subtype = fd
763                .get(b"Subtype")
764                .ok()
765                .and_then(|o| o.as_name().ok())
766                .map(|n| n.to_vec());
767            let encoding = fd
768                .get(b"Encoding")
769                .ok()
770                .and_then(|o| o.as_name().ok())
771                .map(|n| n.to_vec());
772            let has_tounicode = fd.get(b"ToUnicode").is_ok();
773            font_map.insert(
774                font_obj_id,
775                FontInfo {
776                    subtype,
777                    encoding,
778                    has_tounicode,
779                    dict: fd.clone(),
780                },
781            );
782        }
783    }
784}
785
786/// Resolve font names (collected from a content stream) to ObjectIds using the
787/// given resource dictionary. This is how we scope font name resolution correctly:
788/// each content stream (page-level or Form XObject) resolves `/FontName` against
789/// its own Resources/Font dictionary, yielding the correct underlying font object.
790fn resolve_font_names_to_ids(
791    doc: &Document,
792    resources: &lopdf::Dictionary,
793    font_names: &HashSet<Vec<u8>>,
794    used_font_ids: &mut HashSet<ObjectId>,
795) {
796    let font_obj = match resources.get(b"Font").ok() {
797        Some(obj) => obj,
798        None => return,
799    };
800    let font_dict = match font_obj {
801        Object::Dictionary(d) => Some(d),
802        Object::Reference(r) => doc.get_dictionary(*r).ok(),
803        _ => None,
804    };
805    let Some(font_dict) = font_dict else {
806        return;
807    };
808    for name in font_names {
809        if let Ok(Object::Reference(r)) = font_dict.get(name) {
810            used_font_ids.insert(*r);
811        }
812    }
813}
814
815/// Look up a single font name in a resource dictionary, returning its indirect
816/// ObjectId if present.
817fn lookup_font_id(
818    doc: &Document,
819    resources: &lopdf::Dictionary,
820    font_name: &[u8],
821) -> Option<ObjectId> {
822    let font_obj = resources.get(b"Font").ok()?;
823    let font_dict = match font_obj {
824        Object::Dictionary(d) => Some(d),
825        Object::Reference(r) => doc.get_dictionary(*r).ok(),
826        _ => None,
827    }?;
828    if let Ok(Object::Reference(r)) = font_dict.get(font_name) {
829        Some(*r)
830    } else {
831        None
832    }
833}
834
835/// Resolve page-level font names with PDF resource inheritance shadowing.
836///
837/// PDF spec (ISO 32000-1, 7.7.3.4): a page inherits /Resources from its
838/// parent /Pages nodes, but a definition in a more-specific scope shadows
839/// the same name from an ancestor. lopdf's `get_page_resources` returns
840/// ancestors in most-specific-first order (page → parent → grandparent),
841/// so the first dictionary that defines a given font name wins.
842fn resolve_with_shadowing(
843    doc: &Document,
844    own_resources: Option<&lopdf::Dictionary>,
845    ancestor_resource_ids: &[ObjectId],
846    names: &HashSet<Vec<u8>>,
847    used_font_ids: &mut HashSet<ObjectId>,
848) {
849    'name: for name in names {
850        // Check page's own inline /Resources first (most specific scope)
851        if let Some(rd) = own_resources {
852            if let Some(id) = lookup_font_id(doc, rd, name) {
853                used_font_ids.insert(id);
854                continue 'name;
855            }
856        }
857        // Walk inherited resource dicts (most-specific to root); first hit wins
858        for ancestor_id in ancestor_resource_ids {
859            if let Ok(rd) = doc.get_dictionary(*ancestor_id) {
860                if let Some(id) = lookup_font_id(doc, rd, name) {
861                    used_font_ids.insert(id);
862                    continue 'name;
863                }
864            }
865        }
866    }
867}
868
869/// Analyze a page's content stream for text operators and images
870fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
871    let mut counts = ContentCounts::default();
872    let mut all_unique_chars: HashSet<u8> = HashSet::new();
873    // Collect font ObjectIds (not names) to avoid cross-scope name collisions.
874    // Each content stream resolves its Tf font names against its own resource
875    // dictionary, producing the correct underlying font ObjectId.
876    let mut used_font_ids: HashSet<ObjectId> = HashSet::new();
877
878    // Build font map keyed by ObjectId: collects FontInfo for all fonts from
879    // page-level Resources + Form XObject Resources.
880    let mut font_map: HashMap<ObjectId, FontInfo> = HashMap::new();
881
882    // We need the page's resource dict to resolve font names from page content.
883    // get_page_resources returns (Option<&Dictionary>, Vec<ObjectId>) for
884    // inline and indirect resource dicts respectively.
885    let page_resources = doc.get_page_resources(page_id).ok();
886
887    // The page's content streams, read as one and followed through `Do`
888    // (see `content_scan`), with the raw font names they use.
889    let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
890    let (executed, page_counts) =
891        content_scan::scan_page_content(doc, page_id, &mut all_unique_chars, &mut page_font_names);
892    counts.add(page_counts);
893
894    // Resolve font names against the page's resource dictionaries,
895    // respecting PDF resource inheritance shadowing: the most-specific
896    // scope (page's own /Resources) wins over inherited ancestors.
897    if let Some((ref resource_dict, ref resource_ids)) = page_resources {
898        resolve_with_shadowing(
899            doc,
900            *resource_dict,
901            resource_ids,
902            &page_font_names,
903            &mut used_font_ids,
904        );
905    }
906
907    // Scan XObject Form contents for text operators, collect their fonts,
908    // and resolve font names per-XObject scope.
909    if let Some((resource_dict, resource_ids)) = page_resources {
910        let mut visited = HashSet::new();
911        if let Some(resources) = resource_dict {
912            collect_fonts_from_resource_dict(doc, resources, &mut font_map);
913            counts.add(scan_xobjects_in_resources(
914                doc,
915                resources,
916                &mut visited,
917                &mut all_unique_chars,
918                &mut used_font_ids,
919                &mut font_map,
920            ));
921        }
922        for resource_id in resource_ids {
923            if let Ok(resources) = doc.get_dictionary(resource_id) {
924                collect_fonts_from_resource_dict(doc, resources, &mut font_map);
925                counts.add(scan_xobjects_in_resources(
926                    doc,
927                    resources,
928                    &mut visited,
929                    &mut all_unique_chars,
930                    &mut used_font_ids,
931                    &mut font_map,
932                ));
933            }
934        }
935    }
936    let text_ops = counts.text_ops;
937    let image_count = counts.image_count;
938    let path_ops = counts.path_ops;
939    let font_changes = counts.font_changes;
940
941    // Check for XObject images and calculate coverage. An image the
942    // content drew — an inline image, or one a pattern's cell draws — is
943    // an image of the page too, whatever its resources bind.
944    let (found_images, total_image_area, has_template_image) = analyze_page_images(doc, page_id);
945    let has_images = image_count > 0 || found_images || executed.draws_image;
946
947    // The images the page's content drew — in its own streams and in the
948    // forms they invoke, each draw clipped to the page — cover the page
949    // when their boxes on it add up to at least half of it, whatever their
950    // pixel size. Images the resources merely bind, and forms never
951    // invoked, are not content: `has_template_image` judges the pixels of
952    // whatever is bound and has no say here.
953    let has_covering_image = executed.covers_page;
954
955    // A page whose every executed text-showing operator left nothing to
956    // see while images cover it shows the raster alone; the text layer
957    // describes the raster rather than being the page's content.
958    let executed_text_ops = executed.text_ops;
959    let hidden_text_ops = executed.hidden_text_ops;
960    let has_invisible_text_layer = executed.shows_only_a_hidden_text_layer;
961    let executed_form_bytes = executed.form_bytes;
962    let form_bytes_exceeded = executed.form_bytes_exceeded;
963
964    let unique_alphanum_chars = all_unique_chars
965        .iter()
966        .filter(|b| b.is_ascii_alphanumeric())
967        .count() as u32;
968
969    // Vector-outlined text: massive path ops with minimal text ops.
970    // Each outlined glyph needs ~10-30 path commands, so a page of
971    // outlined text produces thousands of path ops.
972    //
973    // Also require few unique alphanum chars: real outlined-text pages have
974    // very few because each glyph is a path, not a Tj/TJ text op. Pages with
975    // real selectable text plus decorative paths (column borders, dividers)
976    // have many unique alphanum chars — these are NOT vector-outlined text.
977    //
978    // The byte count says nothing about text shown through a CID-keyed
979    // font: its two-byte codes are glyph indices whose bytes are rarely
980    // ASCII letters or digits, however much text they carry. A page the
981    // byte count would flag is therefore judged on its decoded text when
982    // any of it came through such a font's ToUnicode CMap (see
983    // `DecodedTextCounts::is_page_text`); decoding walks the content
984    // streams again, so it is not done for the other pages.
985    let mut has_vector_text = path_ops >= 1000
986        && path_ops > text_ops.saturating_mul(200)
987        && (unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS;
988    if has_vector_text {
989        let decoded = decoded_text_counts(doc, page_id);
990        has_vector_text = !(decoded.through_cid_cmap && decoded.is_page_text(path_ops));
991    }
992
993    // Check for Identity-H/V fonts without ToUnicode — these produce garbage text.
994    // Only consider fonts actually USED by Tf operators in content streams (P1 fix),
995    // and include fonts from Form XObject Resources (P2 fix).
996    let has_identity_h_no_tounicode =
997        text_ops > 0 && used_fonts_have_identity_h_no_tounicode(&used_font_ids, &font_map, doc);
998
999    // Check for Type3-only fonts — glyph bitmaps without Unicode mapping.
1000    // Uses the usage-based font set for accuracy.
1001    let has_only_type3_fonts = text_ops > 0 && used_fonts_are_only_type3(&used_font_ids, &font_map);
1002
1003    // Check if the page has fonts that can decode text to Unicode.
1004    // CID-encoded fonts with ToUnicode produce low unique_alphanum_chars in raw
1005    // bytes but are fully decodable — we need this to avoid false scan detection.
1006    // Only considers fonts actually USED via Tf operators (P1 + P2 fix).
1007    let has_decodable_text_fonts =
1008        text_ops > 0 && used_fonts_have_decodable_text(&used_font_ids, &font_map, doc);
1009
1010    PageAnalysis {
1011        text_operator_count: text_ops,
1012        executed_text_operator_count: executed_text_ops,
1013        invisible_text_operator_count: hidden_text_ops,
1014        has_images,
1015        has_template_image,
1016        has_covering_image,
1017        has_invisible_text_layer,
1018        executed_form_bytes,
1019        form_bytes_exceeded,
1020        total_image_area,
1021        image_count,
1022        unique_text_chars: all_unique_chars.len() as u32,
1023        unique_alphanum_chars,
1024        path_op_count: path_ops,
1025        has_vector_text,
1026        has_identity_h_no_tounicode,
1027        has_only_type3_fonts,
1028        font_change_count: font_changes,
1029        has_decodable_text_fonts,
1030    }
1031}
1032
1033/// Distinct alphanumeric characters a page must show for its text to count
1034/// as real text next to a mass of path operators — below it, the paths are
1035/// taken for outlined glyphs and the page for vector text. Applied to the
1036/// bytes of string operands, and to the decoded text of pages that show
1037/// text through a CID-keyed font with a ToUnicode CMap.
1038const VECTOR_TEXT_MIN_ALPHANUMERICS: usize = 30;
1039
1040/// Path operators per decoded alphanumeric character beyond which a page's
1041/// text is a header or footer next to a drawing rather than the page's
1042/// content: outlined body text with a live title and address line runs into
1043/// the hundreds, a title over an illustration or a paragraph beside a chart
1044/// stays well under.
1045const VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER: u32 = 100;
1046
1047/// Form XObjects read at most when counting a page's decoded text; a page
1048/// invoking more is counted as far as that. The page and its forms
1049/// together are also held to the extractor's page budgets of decompressed
1050/// bytes and of operations.
1051const DECODED_TEXT_MAX_FORMS: usize = 1_000;
1052
1053/// What a page's text amounts to once every string is decoded.
1054#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1055struct DecodedTextCounts {
1056    /// Distinct alphanumeric characters.
1057    distinct_alphanumerics: usize,
1058    /// Alphanumeric characters, repeats included.
1059    alphanumerics: usize,
1060    /// Whether any string was decoded through the ToUnicode CMap of a
1061    /// CID-keyed (Type0) font — the strings whose bytes the byte count
1062    /// cannot read.
1063    through_cid_cmap: bool,
1064}
1065
1066impl DecodedTextCounts {
1067    /// Whether text this diverse and this plentiful is the content of a
1068    /// page carrying `path_ops` path operators, rather than the caption,
1069    /// title or footer of a drawing: as many distinct letters and digits as
1070    /// the byte count asks of a simple font, and at most
1071    /// [`VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER`] path operators per
1072    /// character.
1073    fn is_page_text(self, path_ops: u32) -> bool {
1074        self.distinct_alphanumerics >= VECTOR_TEXT_MIN_ALPHANUMERICS
1075            && (self.alphanumerics as u64) * u64::from(VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER)
1076                >= u64::from(path_ops)
1077    }
1078}
1079
1080/// How a font's string operands are read for the decoded text counts.
1081enum FontDecoder {
1082    /// Codes mapped through the font's ToUnicode CMap; `cid` when the font
1083    /// is CID-keyed.
1084    CMap {
1085        cmap: crate::tounicode::ToUnicodeCMap,
1086        cid: bool,
1087    },
1088    /// A simple font without a usable CMap: one character per byte.
1089    Bytes,
1090}
1091
1092/// The decoder for the font dictionary `font`: its ToUnicode CMap when it
1093/// has one that parses, direct or referenced; the bytes themselves for a
1094/// simple font without one; `None` for a font whose text cannot be decoded
1095/// here (a CID-keyed font without a CMap, a Type3 font), whose strings
1096/// count for nothing.
1097fn font_decoder(doc: &Document, font: &lopdf::Dictionary) -> Option<FontDecoder> {
1098    let subtype = font.get(b"Subtype").ok().and_then(|s| s.as_name().ok());
1099    let to_unicode = match font.get(b"ToUnicode") {
1100        Ok(Object::Stream(stream)) => Some(stream),
1101        Ok(Object::Reference(id)) => match doc.get_object(*id) {
1102            Ok(Object::Stream(stream)) => Some(stream),
1103            _ => None,
1104        },
1105        _ => None,
1106    };
1107    if let Some(stream) = to_unicode {
1108        // Decompressed within the loader's per-stream bound, which the
1109        // largest real CMaps come nowhere near: a stream inflating past it
1110        // is not read, and the font goes undecoded rather than costing
1111        // more memory than the document's own streams may.
1112        let data = match stream
1113            .decompressed_content_with_limit(crate::MAX_STREAM_DECOMPRESSED_BYTES)
1114        {
1115            Ok(data) => data,
1116            Err(lopdf::Error::Decompress(lopdf::DecompressError::MemoryLimitExceeded {
1117                ..
1118            })) => return None,
1119            Err(_) if stream.content.len() > crate::MAX_STREAM_DECOMPRESSED_BYTES => return None,
1120            Err(_) => stream.content.clone(),
1121        };
1122        if let Some(mut cmap) = crate::tounicode::ToUnicodeCMap::parse(&data) {
1123            let cid = subtype == Some(b"Type0");
1124            // A simple font's codes are one byte, whatever width its CMap
1125            // declares: read its strings byte by byte, as extraction does.
1126            // A font whose subtype is not a name keeps the CMap's width, in
1127            // both (see `PageFontKinds`).
1128            if subtype.is_some_and(|name| name != b"Type0") && cmap.code_byte_length != 1 {
1129                cmap.code_byte_length = 1;
1130                cmap.refresh_gap_fills();
1131            }
1132            return Some(FontDecoder::CMap { cmap, cid });
1133        }
1134    }
1135    match subtype {
1136        Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => Some(FontDecoder::Bytes),
1137        _ => None,
1138    }
1139}
1140
1141/// The font dictionary `name` resolves to in `resources`, with its object
1142/// id when it is an indirect object; an inline dictionary has none.
1143fn lookup_font<'a>(
1144    doc: &'a Document,
1145    resources: &'a lopdf::Dictionary,
1146    name: &[u8],
1147) -> Option<(Option<ObjectId>, &'a lopdf::Dictionary)> {
1148    let fonts = match resources.get(b"Font").ok()? {
1149        Object::Dictionary(dict) => dict,
1150        Object::Reference(id) => doc.get_dictionary(*id).ok()?,
1151        _ => return None,
1152    };
1153    match fonts.get(name).ok()? {
1154        Object::Reference(id) => Some((Some(*id), doc.get_dictionary(*id).ok()?)),
1155        Object::Dictionary(dict) => Some((None, dict)),
1156        _ => None,
1157    }
1158}
1159
1160/// The Form XObject `name` resolves to in `resources`, when it is one.
1161fn lookup_form(doc: &Document, resources: &lopdf::Dictionary, name: &[u8]) -> Option<ObjectId> {
1162    let xobjects = match resources.get(b"XObject").ok()? {
1163        Object::Dictionary(dict) => dict,
1164        Object::Reference(id) => doc.get_dictionary(*id).ok()?,
1165        _ => return None,
1166    };
1167    let id = xobjects.get(name).ok()?.as_reference().ok()?;
1168    let Ok(Object::Stream(stream)) = doc.get_object(id) else {
1169        return None;
1170    };
1171    (stream
1172        .dict
1173        .get(b"Subtype")
1174        .ok()
1175        .and_then(|s| s.as_name().ok())
1176        == Some(b"Form"))
1177    .then_some(id)
1178}
1179
1180/// The alphanumeric characters in the text the page shows — in its own
1181/// content and in the Form XObjects its content invokes with `Do`, each
1182/// once — once every string is decoded through the font in force: its
1183/// ToUnicode CMap where it has one, the bytes themselves for a simple font
1184/// without one, nothing for a font that cannot be decoded here. Font names
1185/// resolve against the stream's own resources, then those of the stream
1186/// that invoked it, up to the page's and its ancestors'; the font in force
1187/// follows `q`/`Q` like the rest of the graphics state, and a form starts
1188/// with the font its invoker had selected, as it does the rest of the text
1189/// state. A form is decompressed when its turn comes, so one form's
1190/// content is held at a time. Replacement characters, which a CMap that
1191/// does not cover its codes produces, are not alphanumeric and do not
1192/// count. The page and its forms together are read within the extractor's
1193/// page budgets of decompressed bytes and of operations, in the order
1194/// invoked — the page, then the forms it invokes, then theirs — so the
1195/// budgets go to the text drawn first; what lies beyond them is not
1196/// counted, as the extractor would not read it either. A stream that does
1197/// not parse is skipped, as the extractor skips it.
1198fn decoded_text_counts(doc: &Document, page_id: ObjectId) -> DecodedTextCounts {
1199    decoded_text_counts_within(
1200        doc,
1201        page_id,
1202        crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES,
1203        crate::extractor::content_decode::MAX_PAGE_OPERATIONS,
1204    )
1205}
1206
1207/// [`decoded_text_counts`] within the budgets given: at most `max_bytes`
1208/// of decompressed content and `max_operations` operators, over the page
1209/// and the forms it invokes together.
1210fn decoded_text_counts_within(
1211    doc: &Document,
1212    page_id: ObjectId,
1213    max_bytes: usize,
1214    max_operations: usize,
1215) -> DecodedTextCounts {
1216    use std::rc::Rc;
1217
1218    let mut page_scope: Vec<&lopdf::Dictionary> = Vec::new();
1219    if let Ok((own, ancestors)) = doc.get_page_resources(page_id) {
1220        page_scope.extend(own);
1221        page_scope.extend(
1222            ancestors
1223                .iter()
1224                .filter_map(|id| doc.get_dictionary(*id).ok()),
1225        );
1226    }
1227    // A page whose content alone is over the budget is not read here, as
1228    // the extractor does not read it.
1229    let Ok(page_content) = doc.get_page_content_with_limit(page_id, max_bytes) else {
1230        return DecodedTextCounts::default();
1231    };
1232    let mut bytes_left = max_bytes.saturating_sub(page_content.len());
1233    let mut operations_left = max_operations;
1234
1235    enum Pending {
1236        Page(Vec<u8>),
1237        Form(ObjectId),
1238    }
1239    /// A stream waiting to be read: the page, or a form with the resource
1240    /// scope and the font in force where it was invoked.
1241    struct Invocation<'a> {
1242        source: Pending,
1243        scope: Vec<&'a lopdf::Dictionary>,
1244        font: Option<Rc<FontDecoder>>,
1245    }
1246    let mut pending = std::collections::VecDeque::from([Invocation {
1247        source: Pending::Page(page_content),
1248        scope: page_scope,
1249        font: None,
1250    }]);
1251    let mut visited: HashSet<ObjectId> = HashSet::new();
1252    let mut counts = DecodedTextCounts::default();
1253    let mut seen: HashSet<char> = HashSet::new();
1254    let mut decoders: HashMap<ObjectId, Option<Rc<FontDecoder>>> = HashMap::new();
1255    while let Some(Invocation {
1256        source,
1257        scope: invoking_scope,
1258        font: inherited,
1259    }) = pending.pop_front()
1260    {
1261        let (content, scope) = match source {
1262            Pending::Page(content) => (content, invoking_scope),
1263            Pending::Form(id) => {
1264                let Ok(Object::Stream(stream)) = doc.get_object(id) else {
1265                    continue;
1266                };
1267                let mut scope: Vec<&lopdf::Dictionary> = match stream.dict.get(b"Resources") {
1268                    Ok(Object::Dictionary(dict)) => vec![dict],
1269                    Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().into_iter().collect(),
1270                    _ => Vec::new(),
1271                };
1272                scope.extend(invoking_scope);
1273                // Decompressed within what is left of the page's byte
1274                // budget; a stream that fails to decode for another reason
1275                // is read raw, as the page content is, if that fits too.
1276                let content = match stream.decompressed_content_with_limit(bytes_left) {
1277                    Ok(content) => content,
1278                    Err(lopdf::Error::Decompress(
1279                        lopdf::DecompressError::MemoryLimitExceeded { .. },
1280                    )) => break,
1281                    Err(_) if stream.content.len() > bytes_left => break,
1282                    Err(_) => stream.content.clone(),
1283                };
1284                bytes_left = bytes_left.saturating_sub(content.len());
1285                (content, scope)
1286            }
1287        };
1288        let ops = match crate::extractor::content_decode::decode_content_bounded(
1289            &content,
1290            operations_left,
1291        ) {
1292            Ok(Some(ops)) => ops,
1293            // The operation budget is spent: nothing further is read.
1294            Ok(None) => break,
1295            // A stream that does not parse is skipped, as the extractor
1296            // skips it; the streams after it are still read.
1297            Err(_) => continue,
1298        };
1299        operations_left = operations_left.saturating_sub(ops.operations.len());
1300        let mut current: Option<Rc<FontDecoder>> = inherited;
1301        let mut saved: Vec<Option<Rc<FontDecoder>>> = Vec::new();
1302        for op in &ops.operations {
1303            match op.operator.as_str() {
1304                "q" => saved.push(current.clone()),
1305                "Q" => {
1306                    if let Some(restored) = saved.pop() {
1307                        current = restored;
1308                    }
1309                }
1310                "Do" => {
1311                    if visited.len() >= DECODED_TEXT_MAX_FORMS {
1312                        continue;
1313                    }
1314                    let form = op
1315                        .operands
1316                        .first()
1317                        .and_then(|name| name.as_name().ok())
1318                        .and_then(|name| {
1319                            scope
1320                                .iter()
1321                                .find_map(|resources| lookup_form(doc, resources, name))
1322                        });
1323                    if let Some(id) = form {
1324                        if visited.insert(id) {
1325                            pending.push_back(Invocation {
1326                                source: Pending::Form(id),
1327                                scope: scope.clone(),
1328                                font: current.clone(),
1329                            });
1330                        }
1331                    }
1332                }
1333                "Tf" => {
1334                    current = op
1335                        .operands
1336                        .first()
1337                        .and_then(|name| name.as_name().ok())
1338                        .and_then(|name| {
1339                            scope
1340                                .iter()
1341                                .find_map(|resources| lookup_font(doc, resources, name))
1342                        })
1343                        .and_then(|(id, font)| match id {
1344                            Some(id) => decoders
1345                                .entry(id)
1346                                .or_insert_with(|| font_decoder(doc, font).map(Rc::new))
1347                                .clone(),
1348                            None => font_decoder(doc, font).map(Rc::new),
1349                        });
1350                }
1351                "Tj" | "'" | "\"" | "TJ" => {
1352                    let Some(decoder) = current.as_deref() else {
1353                        continue;
1354                    };
1355                    let mut shown: Vec<&[u8]> = Vec::new();
1356                    if op.operator == "TJ" {
1357                        if let Some(Ok(array)) = op.operands.first().map(|array| array.as_array()) {
1358                            shown.extend(array.iter().filter_map(|element| match element {
1359                                Object::String(bytes, _) => Some(bytes.as_slice()),
1360                                _ => None,
1361                            }));
1362                        }
1363                    } else if let Some(Object::String(bytes, _)) = op.operands.last() {
1364                        shown.push(bytes);
1365                    }
1366                    for bytes in shown {
1367                        let decoded: Vec<char> = match decoder {
1368                            FontDecoder::CMap { cmap, cid } => {
1369                                counts.through_cid_cmap |= *cid && !bytes.is_empty();
1370                                cmap.decode_cids(bytes)
1371                                    .chars()
1372                                    .filter(|c| c.is_alphanumeric())
1373                                    .collect()
1374                            }
1375                            FontDecoder::Bytes => bytes
1376                                .iter()
1377                                .map(|&b| b as char)
1378                                .filter(|c| c.is_alphanumeric())
1379                                .collect(),
1380                        };
1381                        counts.alphanumerics += decoded.len();
1382                        seen.extend(decoded);
1383                    }
1384                }
1385                _ => {}
1386            }
1387        }
1388    }
1389    counts.distinct_alphanumerics = seen.len();
1390    counts
1391}
1392
1393/// Check if a page has Type0 fonts with Identity-H/V encoding and no ToUnicode CMap.
1394/// These fonts encode text as raw CID values that can't be mapped to Unicode without
1395/// a ToUnicode CMap, producing garbage output for non-Latin scripts (e.g. Cyrillic).
1396///
1397/// Returns false when the page also has other decodable text fonts (Type1, TrueType,
1398/// or Type0 with ToUnicode/fallback). In that case the undecodable Identity-H font
1399/// is supplementary and the page has enough good text for extraction.
1400///
1401/// NOTE: This is a resource-based check (examines ALL fonts in Resources/Font, not just
1402/// those used by Tf operators). Superseded by `used_fonts_have_identity_h_no_tounicode`
1403/// in production code. Kept for unit tests that validate font-level classification.
1404#[cfg(test)]
1405fn page_has_identity_h_no_tounicode(doc: &Document, page_id: ObjectId) -> bool {
1406    let fonts = match doc.get_page_fonts(page_id) {
1407        Ok(f) => f,
1408        Err(_) => return false,
1409    };
1410
1411    let mut has_undecodable_identity_h = false;
1412    let mut has_other_decodable_font = false;
1413
1414    for font_dict in fonts.values() {
1415        let subtype = font_dict
1416            .get(b"Subtype")
1417            .ok()
1418            .and_then(|o| o.as_name().ok());
1419
1420        match subtype {
1421            Some(b"Type0") => {
1422                let encoding = font_dict
1423                    .get(b"Encoding")
1424                    .ok()
1425                    .and_then(|o| o.as_name().ok());
1426                let is_identity = matches!(encoding, Some(b"Identity-H") | Some(b"Identity-V"));
1427
1428                if !is_identity {
1429                    // Type0 with non-Identity encoding (e.g. a named CMap) — decodable
1430                    has_other_decodable_font = true;
1431                    continue;
1432                }
1433                if font_dict.get(b"ToUnicode").is_ok() {
1434                    // Has ToUnicode — decodable
1435                    has_other_decodable_font = true;
1436                    continue;
1437                }
1438                if identity_h_font_has_fallback(font_dict, doc) {
1439                    // Fallback decoding path works — decodable
1440                    has_other_decodable_font = true;
1441                    continue;
1442                }
1443
1444                // Identity-H/V without ToUnicode and no fallback — undecodable
1445                log::debug!(
1446                    "page has Identity-H/V font without ToUnicode: {:?}",
1447                    font_dict
1448                        .get(b"BaseFont")
1449                        .ok()
1450                        .and_then(|o| o.as_name().ok())
1451                        .map(|n| String::from_utf8_lossy(n).to_string())
1452                );
1453                has_undecodable_identity_h = true;
1454            }
1455            Some(b"Type3") => {
1456                // Type3 fonts are handled separately by page_has_only_type3_fonts;
1457                // don't count them as decodable here.
1458            }
1459            _ => {
1460                // Type1, TrueType, MMType1, CIDFontType0/2 — these are generally
1461                // decodable via standard encoding, ToUnicode, or glyph name lookup.
1462                has_other_decodable_font = true;
1463            }
1464        }
1465    }
1466
1467    // Only flag when there are undecodable Identity-H fonts AND no other
1468    // decodable fonts on the page. If the page has other text fonts, the
1469    // Identity-H font is supplementary and the page still extracts well.
1470    has_undecodable_identity_h && !has_other_decodable_font
1471}
1472
1473/// Check whether an Identity-H font without ToUnicode can still be decoded
1474/// via one of the extraction pipeline's fallback paths.
1475fn identity_h_font_has_fallback(font_dict: &lopdf::Dictionary, doc: &Document) -> bool {
1476    let desc_fonts_obj = match font_dict.get(b"DescendantFonts").ok() {
1477        Some(obj) => obj,
1478        None => return false,
1479    };
1480    let desc_fonts = match desc_fonts_obj {
1481        Object::Array(arr) => arr,
1482        Object::Reference(r) => match doc.get_object(*r) {
1483            Ok(Object::Array(arr)) => arr,
1484            _ => return false,
1485        },
1486        _ => return false,
1487    };
1488    if desc_fonts.is_empty() {
1489        return false;
1490    }
1491    let cid_font_dict = match &desc_fonts[0] {
1492        Object::Reference(r) => match doc.get_dictionary(*r) {
1493            Ok(d) => d,
1494            _ => return false,
1495        },
1496        Object::Dictionary(d) => d,
1497        _ => return false,
1498    };
1499
1500    // Fallback 1: W array CIDs look like Unicode codepoints → passthrough works.
1501    // Many PDF generators (Chromium, wkhtmltopdf) use Identity-H where CID = Unicode.
1502    if crate::tounicode::cid_values_look_like_unicode(cid_font_dict) {
1503        return true;
1504    }
1505
1506    // Fallback 2: Embedded TrueType/OpenType font has a usable cmap table.
1507    if let Some(font_descriptor) = cid_font_dict
1508        .get(b"FontDescriptor")
1509        .ok()
1510        .and_then(|o| match o {
1511            Object::Reference(r) => doc.get_dictionary(*r).ok(),
1512            Object::Dictionary(d) => Some(d),
1513            _ => None,
1514        })
1515    {
1516        let font_file_ref = font_descriptor
1517            .get(b"FontFile2")
1518            .ok()
1519            .and_then(|o| o.as_reference().ok())
1520            .or_else(|| {
1521                font_descriptor
1522                    .get(b"FontFile3")
1523                    .ok()
1524                    .and_then(|o| o.as_reference().ok())
1525            });
1526        if let Some(ff_ref) = font_file_ref {
1527            if embedded_font_has_cmap(doc, ff_ref) {
1528                return true;
1529            }
1530            // Fallback 3: no cmap, but the glyph order is the standard
1531            // Macintosh one and the metrics corroborate it (see
1532            // `mac_glyph_order`); extraction decodes it.
1533            if crate::mac_glyph_order::cid_to_gid_is_identity(cid_font_dict, doc)
1534                && doc
1535                    .get_object(ff_ref)
1536                    .and_then(Object::as_stream)
1537                    .ok()
1538                    .and_then(|stream| stream.decompressed_content().ok())
1539                    .is_some_and(|data| crate::mac_glyph_order::font_file_follows_mac_order(&data))
1540            {
1541                return true;
1542            }
1543        }
1544    }
1545
1546    false
1547}
1548
1549/// Quick check whether an embedded TrueType/OpenType font has a cmap table
1550/// that can map GIDs to Unicode codepoints.
1551fn embedded_font_has_cmap(doc: &Document, font_ref: lopdf::ObjectId) -> bool {
1552    let stream = match doc.get_object(font_ref).and_then(Object::as_stream) {
1553        Ok(s) => s,
1554        Err(_) => return false,
1555    };
1556    let data = match stream.decompressed_content() {
1557        Ok(d) => d,
1558        Err(_) => return false,
1559    };
1560    let face = match ttf_parser::Face::parse(&data, 0) {
1561        Ok(f) => f,
1562        Err(_) => return false,
1563    };
1564    // Check that the font has a cmap table with at least some Unicode mappings
1565    if let Some(cmap) = face.tables().cmap {
1566        for subtable in cmap.subtables {
1567            if subtable.is_unicode()
1568                || (subtable.platform_id == ttf_parser::PlatformId::Windows
1569                    && subtable.encoding_id == 0)
1570            {
1571                let mut count = 0u32;
1572                subtable.codepoints(|_| count += 1);
1573                if count > 0 {
1574                    return true;
1575                }
1576            }
1577        }
1578    }
1579    false
1580}
1581
1582/// Returns true if every font on the page is Type3 (no normal text fonts).
1583/// Type3 fonts render glyphs as custom drawings/bitmaps. Without a ToUnicode
1584/// CMap, character codes can't be mapped to Unicode — the page needs OCR.
1585///
1586/// NOTE: Resource-based check. Superseded by `used_fonts_are_only_type3`.
1587/// Kept for existing unit tests.
1588#[cfg(test)]
1589fn page_has_only_type3_fonts(doc: &Document, page_id: ObjectId) -> bool {
1590    let fonts = match doc.get_page_fonts(page_id) {
1591        Ok(f) => f,
1592        Err(_) => return false,
1593    };
1594    if fonts.is_empty() {
1595        return false;
1596    }
1597    let mut has_type3 = false;
1598    for font_dict in fonts.values() {
1599        let subtype = font_dict
1600            .get(b"Subtype")
1601            .ok()
1602            .and_then(|o| o.as_name().ok());
1603        if subtype == Some(b"Type3") {
1604            // Type3 with a ToUnicode CMap can still produce usable text
1605            if font_dict.get(b"ToUnicode").is_ok() {
1606                return false;
1607            }
1608            has_type3 = true;
1609        } else {
1610            // Has a non-Type3 font — page has real text fonts
1611            return false;
1612        }
1613    }
1614    if has_type3 {
1615        log::debug!("page has only Type3 fonts without ToUnicode — text is undecodable");
1616    }
1617    has_type3
1618}
1619
1620/// Check if the page has at least one font that can produce decodable Unicode text.
1621///
1622/// Returns true when any font on the page has:
1623/// - A /ToUnicode CMap (works for all font types including CID fonts), OR
1624/// - A standard /Encoding (WinAnsiEncoding, MacRomanEncoding, etc.) for Type1/TrueType, OR
1625/// - Is a Type1 or TrueType font (these use glyph names → Adobe Glyph List fallback)
1626///
1627/// This distinguishes pages with CID-encoded text that IS decodable (via ToUnicode)
1628/// from scanned pages that happen to have a few decorative text ops. CID text produces
1629/// low unique_alphanum_chars in raw bytes but can map to full Unicode through ToUnicode.
1630///
1631/// NOTE: Resource-based check. Superseded by `used_fonts_have_decodable_text`.
1632/// Kept for existing unit tests.
1633#[cfg(test)]
1634fn page_has_decodable_text_fonts(doc: &Document, page_id: ObjectId) -> bool {
1635    let fonts = match doc.get_page_fonts(page_id) {
1636        Ok(f) => f,
1637        Err(_) => return false,
1638    };
1639    for font_dict in fonts.values() {
1640        // Any font with ToUnicode is decodable
1641        if font_dict.get(b"ToUnicode").is_ok() {
1642            return true;
1643        }
1644        let subtype = font_dict
1645            .get(b"Subtype")
1646            .ok()
1647            .and_then(|o| o.as_name().ok());
1648        match subtype {
1649            Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
1650                // Type1/TrueType with a named encoding or glyph names are decodable
1651                // via the Adobe Glyph List or encoding vectors.
1652                return true;
1653            }
1654            Some(b"Type0") => {
1655                // Type0 (CID) without ToUnicode — check if it has a fallback path
1656                if identity_h_font_has_fallback(font_dict, doc) {
1657                    return true;
1658                }
1659            }
1660            _ => {}
1661        }
1662    }
1663    false
1664}
1665
1666/// Usage-based check: do the USED fonts include an undecodable Identity-H/V font
1667/// without any other decodable font to compensate?
1668///
1669/// Unlike `page_has_identity_h_no_tounicode`, this only considers fonts actually
1670/// referenced by Tf operators in content streams (P1 fix) and includes fonts from
1671/// Form XObject Resources (P2 fix).
1672fn used_fonts_have_identity_h_no_tounicode(
1673    used_font_ids: &HashSet<ObjectId>,
1674    font_map: &HashMap<ObjectId, FontInfo>,
1675    doc: &Document,
1676) -> bool {
1677    let mut has_undecodable_identity_h = false;
1678    let mut has_other_decodable_font = false;
1679
1680    for id in used_font_ids {
1681        let Some(info) = font_map.get(id) else {
1682            continue;
1683        };
1684        match info.subtype.as_deref() {
1685            Some(b"Type0") => {
1686                let is_identity = matches!(
1687                    info.encoding.as_deref(),
1688                    Some(b"Identity-H") | Some(b"Identity-V")
1689                );
1690                if !is_identity {
1691                    has_other_decodable_font = true;
1692                    continue;
1693                }
1694                if info.has_tounicode {
1695                    has_other_decodable_font = true;
1696                    continue;
1697                }
1698                if identity_h_font_has_fallback(&info.dict, doc) {
1699                    has_other_decodable_font = true;
1700                    continue;
1701                }
1702                has_undecodable_identity_h = true;
1703            }
1704            Some(b"Type3") => {
1705                // Handled separately by used_fonts_are_only_type3
1706            }
1707            _ => {
1708                // Type1, TrueType, MMType1, etc. — generally decodable
1709                has_other_decodable_font = true;
1710            }
1711        }
1712    }
1713
1714    has_undecodable_identity_h && !has_other_decodable_font
1715}
1716
1717/// Usage-based check: are ALL used fonts Type3 without ToUnicode?
1718///
1719/// Unlike `page_has_only_type3_fonts`, this only considers fonts actually referenced
1720/// by Tf operators (P1 fix) and includes Form XObject fonts (P2 fix).
1721fn used_fonts_are_only_type3(
1722    used_font_ids: &HashSet<ObjectId>,
1723    font_map: &HashMap<ObjectId, FontInfo>,
1724) -> bool {
1725    if used_font_ids.is_empty() {
1726        return false;
1727    }
1728    let mut has_type3 = false;
1729    for id in used_font_ids {
1730        let Some(info) = font_map.get(id) else {
1731            continue;
1732        };
1733        if info.subtype.as_deref() == Some(b"Type3") {
1734            if info.has_tounicode {
1735                return false;
1736            }
1737            has_type3 = true;
1738        } else {
1739            return false;
1740        }
1741    }
1742    has_type3
1743}
1744
1745/// Usage-based check: do the USED fonts include at least one that can produce
1746/// decodable Unicode text?
1747///
1748/// Unlike `page_has_decodable_text_fonts`, this only considers fonts actually
1749/// referenced by Tf operators (P1 fix) and includes Form XObject fonts (P2 fix).
1750fn used_fonts_have_decodable_text(
1751    used_font_ids: &HashSet<ObjectId>,
1752    font_map: &HashMap<ObjectId, FontInfo>,
1753    doc: &Document,
1754) -> bool {
1755    for id in used_font_ids {
1756        let Some(info) = font_map.get(id) else {
1757            continue;
1758        };
1759        if info.has_tounicode {
1760            return true;
1761        }
1762        match info.subtype.as_deref() {
1763            Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
1764                return true;
1765            }
1766            Some(b"Type0") => {
1767                if identity_h_font_has_fallback(&info.dict, doc) {
1768                    return true;
1769                }
1770            }
1771            _ => {}
1772        }
1773    }
1774    false
1775}
1776
1777fn scan_xobjects_in_resources(
1778    doc: &Document,
1779    resources: &lopdf::Dictionary,
1780    visited: &mut HashSet<ObjectId>,
1781    unique_chars: &mut HashSet<u8>,
1782    used_font_ids: &mut HashSet<ObjectId>,
1783    font_map: &mut HashMap<ObjectId, FontInfo>,
1784) -> ContentCounts {
1785    let mut counts = ContentCounts::default();
1786
1787    let xobjects = match resources.get(b"XObject").ok() {
1788        Some(Object::Dictionary(d)) => Some(d.clone()),
1789        Some(Object::Reference(r)) => doc.get_dictionary(*r).ok().cloned(),
1790        _ => None,
1791    };
1792
1793    if let Some(xobj_dict) = xobjects {
1794        for (_, obj) in xobj_dict.iter() {
1795            let Some(obj_id) = obj.as_reference().ok() else {
1796                continue;
1797            };
1798            if !visited.insert(obj_id) {
1799                continue;
1800            }
1801            let Ok(Object::Stream(stream)) = doc.get_object(obj_id) else {
1802                continue;
1803            };
1804            let subtype = stream
1805                .dict
1806                .get(b"Subtype")
1807                .ok()
1808                .and_then(|o| o.as_name().ok());
1809            match subtype {
1810                Some(b"Form") => {
1811                    let content = stream
1812                        .decompressed_content()
1813                        .unwrap_or_else(|_| stream.content.clone());
1814                    // Collect raw font names from this XObject's content stream.
1815                    // Every form bound is counted here, invoked or not, as
1816                    // the text and font tallies always have been; what the
1817                    // page executes is followed from its own content, so
1818                    // this scan follows no `Do`.
1819                    let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
1820                    let own_resources: Vec<&lopdf::Dictionary> =
1821                        content_resources::stream_resources(doc, stream)
1822                            .into_iter()
1823                            .collect();
1824                    counts.add(content_scan::scan_content_stream_alone(
1825                        doc,
1826                        &content,
1827                        unique_chars,
1828                        &mut xobj_font_names,
1829                        &own_resources,
1830                    ));
1831
1832                    // Resolve the Form XObject's /Resources — handle both inline
1833                    // dicts and indirect references (P2 fix: indirect refs were
1834                    // previously skipped by as_dict()).
1835                    let xobj_res_owned;
1836                    let xobj_res = match stream.dict.get(b"Resources").ok() {
1837                        Some(Object::Dictionary(d)) => Some(d),
1838                        Some(Object::Reference(r)) => {
1839                            xobj_res_owned = doc.get_dictionary(*r).ok();
1840                            xobj_res_owned
1841                        }
1842                        _ => None,
1843                    };
1844
1845                    if let Some(res) = xobj_res {
1846                        // Resolve font names against the XObject's own resource dict
1847                        // (P1 fix: scoped resolution, not global name-based lookup)
1848                        resolve_font_names_to_ids(doc, res, &xobj_font_names, used_font_ids);
1849                        // Collect font definitions from this scope
1850                        collect_fonts_from_resource_dict(doc, res, font_map);
1851                        // Recurse into nested XObjects
1852                        counts.add(scan_xobjects_in_resources(
1853                            doc,
1854                            res,
1855                            visited,
1856                            unique_chars,
1857                            used_font_ids,
1858                            font_map,
1859                        ));
1860                    }
1861                }
1862                Some(b"Image") => {
1863                    counts.image_count += 1;
1864                }
1865                _ => {}
1866            }
1867        }
1868    }
1869
1870    counts
1871}
1872
1873/// [`content_scan::scan_content_stream_alone`] as the counts alone:
1874/// `(text_ops, image_count, path_ops, font_changes)`.
1875#[cfg(test)]
1876fn scan_content_for_text_operators(
1877    content: &[u8],
1878    unique_chars: &mut HashSet<u8>,
1879    used_font_names: &mut HashSet<Vec<u8>>,
1880) -> (u32, u32, u32, u32) {
1881    let doc = Document::new();
1882    let counts =
1883        content_scan::scan_content_stream_alone(&doc, content, unique_chars, used_font_names, &[]);
1884    (
1885        counts.text_ops,
1886        counts.image_count,
1887        counts.path_ops,
1888        counts.font_changes,
1889    )
1890}
1891
1892/// True when the token before `op_pos` (skipping whitespace, not crossing
1893/// `floor`) is a string/array closer. Used so `Tj` inside `(Hello Tj World)`
1894/// is not treated as an operator.
1895fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool {
1896    let mut j = op_pos;
1897    while j > floor {
1898        j -= 1;
1899        if !is_pdf_whitespace(content[j]) {
1900            return matches!(content[j], b')' | b'>' | b']');
1901        }
1902    }
1903    false
1904}
1905
1906/// Extract the font name operand from content stream bytes preceding a Tf operator.
1907///
1908/// The Tf operator syntax is: `/FontName size Tf`
1909/// We scan backward from the position of 'T' in 'Tf' past the size number and
1910/// whitespace to find the `/Name` token.
1911///
1912/// Returns the font name bytes (without the leading `/`, its `#xx` escapes
1913/// decoded as the resource dictionary's keys are), e.g. `b"F1"` for `/F1`
1914/// or `/F#31`. `floor` is the start of the previous text/font operator (or
1915/// 0); lookback must not cross it. Whitespace is the file format's, NUL
1916/// included.
1917fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> Option<Vec<u8>> {
1918    // Scan backward past whitespace before "Tf"
1919    let mut j = tf_pos;
1920    while j > floor && is_pdf_whitespace(content[j - 1]) {
1921        j -= 1;
1922    }
1923    // Scan backward past the size number (digits, '.', '-')
1924    while j > floor
1925        && (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
1926    {
1927        j -= 1;
1928    }
1929    // Scan backward past whitespace between font name and size
1930    while j > floor && is_pdf_whitespace(content[j - 1]) {
1931        j -= 1;
1932    }
1933    // Now j should point just after the font name. Scan backward to find '/'.
1934    let name_end = j;
1935    while j > floor && content[j - 1] != b'/' {
1936        // Font names consist of regular characters (not whitespace, not delimiters)
1937        if is_pdf_whitespace(content[j - 1]) || content[j - 1] == b'(' || content[j - 1] == b')' {
1938            return None;
1939        }
1940        j -= 1;
1941    }
1942    if j <= floor || content[j - 1] != b'/' {
1943        return None;
1944    }
1945    // j-1 is the '/', font name is content[j..name_end]
1946    if j < name_end {
1947        Some(content_mask::decode_name_escapes(&content[j..name_end]))
1948    } else {
1949        None
1950    }
1951}
1952
1953/// Scan backward from a Tj/TJ operator to find the preceding string operand
1954/// and collect unique non-whitespace bytes from it.
1955///
1956/// Handles both literal strings `(...)` and hex strings `<...>`.
1957/// `floor` is the start of the previous text/font operator (or 0); lookback
1958/// must not cross it, or a missing `[` before `TJ` rescans the whole prefix.
1959fn collect_text_chars_before(
1960    content: &[u8],
1961    op_pos: usize,
1962    unique_chars: &mut HashSet<u8>,
1963    floor: usize,
1964) {
1965    // Walk backward past whitespace (the file format's, NUL included) to
1966    // find the closing delimiter
1967    let mut j = op_pos;
1968    while j > floor {
1969        j -= 1;
1970        if !is_pdf_whitespace(content[j]) {
1971            break;
1972        }
1973    }
1974    // All whitespace, or we landed on the previous operator token.
1975    if j == floor {
1976        return;
1977    }
1978
1979    let closing = content[j];
1980
1981    if closing == b')' {
1982        // Literal string: scan backward for matching '('
1983        let mut depth = 1i32;
1984        let mut k = j;
1985        while k > floor && depth > 0 {
1986            k -= 1;
1987            match content[k] {
1988                b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
1989                b'(' if k == 0 || content[k - 1] != b'\\' => depth -= 1,
1990                _ => {}
1991            }
1992        }
1993        // k now points at '('; collect bytes between (k+1..j)
1994        if depth == 0 && k + 1 < j {
1995            for &ch in &content[k + 1..j] {
1996                if !ch.is_ascii_whitespace() {
1997                    unique_chars.insert(ch);
1998                }
1999            }
2000        }
2001    } else if closing == b'>' {
2002        // Hex string: scan backward for '<'
2003        let mut k = j;
2004        while k > floor {
2005            k -= 1;
2006            if content[k] == b'<' {
2007                break;
2008            }
2009        }
2010        if content[k] == b'<' && k + 1 < j {
2011            // Decode hex pairs and collect unique non-whitespace bytes
2012            let hex_slice = &content[k + 1..j];
2013            let hex_clean: Vec<u8> = hex_slice
2014                .iter()
2015                .copied()
2016                .filter(|b| !b.is_ascii_whitespace())
2017                .collect();
2018            for pair in hex_clean.chunks(2) {
2019                if pair.len() == 2 {
2020                    let high = hex_val(pair[0]);
2021                    let low = hex_val(pair[1]);
2022                    if let (Some(h), Some(l)) = (high, low) {
2023                        let byte = (h << 4) | l;
2024                        if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
2025                            unique_chars.insert(byte);
2026                        }
2027                    }
2028                }
2029            }
2030        }
2031    } else if closing == b']' {
2032        // TJ array: scan backward for '[' and collect from all strings inside
2033        let mut k = j;
2034        while k > floor {
2035            k -= 1;
2036            if content[k] == b'[' {
2037                break;
2038            }
2039        }
2040        if content[k] == b'[' {
2041            // Scan forward through the array collecting string contents
2042            let mut m = k + 1;
2043            while m < j {
2044                if content[m] == b'(' {
2045                    let start = m + 1;
2046                    let mut depth = 1i32;
2047                    m += 1;
2048                    while m < j && depth > 0 {
2049                        match content[m] {
2050                            b')' if content[m - 1] != b'\\' => depth -= 1,
2051                            b'(' if content[m - 1] != b'\\' => depth += 1,
2052                            _ => {}
2053                        }
2054                        if depth > 0 {
2055                            m += 1;
2056                        }
2057                    }
2058                    // collect bytes from start..m
2059                    for &ch in &content[start..m] {
2060                        if !ch.is_ascii_whitespace() {
2061                            unique_chars.insert(ch);
2062                        }
2063                    }
2064                } else if content[m] == b'<' {
2065                    let hex_start = m + 1;
2066                    m += 1;
2067                    while m < j && content[m] != b'>' {
2068                        m += 1;
2069                    }
2070                    let hex_slice = &content[hex_start..m];
2071                    let hex_clean: Vec<u8> = hex_slice
2072                        .iter()
2073                        .copied()
2074                        .filter(|b| !b.is_ascii_whitespace())
2075                        .collect();
2076                    for pair in hex_clean.chunks(2) {
2077                        if pair.len() == 2 {
2078                            let high = hex_val(pair[0]);
2079                            let low = hex_val(pair[1]);
2080                            if let (Some(h), Some(l)) = (high, low) {
2081                                let byte = (h << 4) | l;
2082                                if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
2083                                    unique_chars.insert(byte);
2084                                }
2085                            }
2086                        }
2087                    }
2088                }
2089                m += 1;
2090            }
2091        }
2092    }
2093}
2094
2095/// Convert a hex ASCII character to its numeric value (0-15)
2096fn hex_val(b: u8) -> Option<u8> {
2097    match b {
2098        b'0'..=b'9' => Some(b - b'0'),
2099        b'a'..=b'f' => Some(b - b'a' + 10),
2100        b'A'..=b'F' => Some(b - b'A' + 10),
2101        _ => None,
2102    }
2103}
2104
2105/// Analyze page images: returns (has_images, total_area, has_template_image)
2106///
2107/// A template image is one that covers >50% of a standard page area.
2108/// Standard page: 612x792 points (US Letter) = ~485,000 sq points
2109/// At 2x resolution that's ~1.9M pixels, so we use 250K pixels as threshold
2110/// (accounting for varying DPI and page sizes)
2111/// Returns `(has_images, total_image_area, has_template_image)` for a page.
2112/// `has_template_image` means a single large (>50% page coverage)
2113/// background image — the signal `classify_pdf`/`detect_pdf_type` uses to
2114/// route a page to OCR regardless of any incidental native text drawn over
2115/// it. Exposed at crate visibility so extraction-side per-page `needs_ocr`
2116/// computation (`extract_pages_markdown_mem`) can consult the same signal
2117/// instead of maintaining its own, independent notion of "needs OCR" that
2118/// can silently disagree with detection — see #227.
2119pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
2120    // Threshold: image covering roughly half a page at 150+ DPI
2121    // 612 * 792 / 2 * (150/72)^2 ≈ 1M pixels, but we'll be conservative
2122    const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000; // 500K pixels
2123
2124    let mut has_images = false;
2125    let mut total_area: u64 = 0;
2126    let mut has_template_image = false;
2127    let mut visited: HashSet<ObjectId> = HashSet::new();
2128
2129    if let Ok(page_dict) = doc.get_dictionary(page_id) {
2130        let resources = match page_dict.get(b"Resources") {
2131            Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
2132            Ok(Object::Dictionary(dict)) => Some(dict),
2133            _ => None,
2134        };
2135
2136        if let Some(resources) = resources {
2137            collect_images_from_resources(
2138                doc,
2139                resources,
2140                &mut has_images,
2141                &mut total_area,
2142                &mut has_template_image,
2143                TEMPLATE_IMAGE_THRESHOLD,
2144                &mut visited,
2145            );
2146
2147            // Also check Pattern resources: tiling patterns can contain
2148            // XObject images (e.g., screenshots pasted into PDFs via
2149            // Chrome "Save as PDF").
2150            if let Ok(pattern_obj) = resources.get(b"Pattern") {
2151                let pattern_dict = match pattern_obj {
2152                    Object::Reference(id) => doc.get_dictionary(*id).ok(),
2153                    Object::Dictionary(dict) => Some(dict),
2154                    _ => None,
2155                };
2156                if let Some(pattern_dict) = pattern_dict {
2157                    for (_, value) in pattern_dict.iter() {
2158                        let pat_ref = match value.as_reference() {
2159                            Ok(r) => r,
2160                            _ => continue,
2161                        };
2162                        if !visited.insert(pat_ref) {
2163                            continue;
2164                        }
2165                        if let Ok(Object::Stream(stream)) = doc.get_object(pat_ref) {
2166                            if let Ok(pat_resources) = stream.dict.get(b"Resources") {
2167                                let pat_res_dict = match pat_resources {
2168                                    Object::Reference(id) => doc.get_dictionary(*id).ok(),
2169                                    Object::Dictionary(dict) => Some(dict),
2170                                    _ => None,
2171                                };
2172                                if let Some(pat_res) = pat_res_dict {
2173                                    collect_images_from_resources(
2174                                        doc,
2175                                        pat_res,
2176                                        &mut has_images,
2177                                        &mut total_area,
2178                                        &mut has_template_image,
2179                                        TEMPLATE_IMAGE_THRESHOLD,
2180                                        &mut visited,
2181                                    );
2182                                }
2183                            }
2184                        }
2185                    }
2186                }
2187            }
2188        }
2189    }
2190
2191    // Tiled scans: many small image tiles (e.g., JBIG2 strips) that together
2192    // cover the full page. No individual tile triggers the template threshold,
2193    // but the aggregate area clearly indicates a scanned/image-backed page.
2194    if !has_template_image && total_area >= TEMPLATE_IMAGE_THRESHOLD * 4 {
2195        has_template_image = true;
2196    }
2197
2198    (has_images, total_area, has_template_image)
2199}
2200
2201/// Computes `template_image_needs_ocr`, `has_vector_text` and
2202/// `has_invisible_text_layer` (see [`PageOcrSignals`]) for a page from a
2203/// single shared `analyze_page_content` pass — that call
2204/// decompresses and scans every content stream (page + XObjects) plus
2205/// image coverage, so `extract_pages_markdown_mem` must not invoke it
2206/// twice per page (once per signal) the way `detect_from_document` avoids
2207/// by caching its per-page `PageAnalysis`.
2208///
2209/// `needs_ocr_for_template_image` is true when a page's template image
2210/// should be treated as a scan needing OCR — a single full-page background
2211/// image with little/no real text — rather than a text page that happens
2212/// to carry a watermark, letterhead, or figure. Mirrors the two distinct
2213/// signals classification uses to route a template-image page to OCR:
2214///
2215/// 1. `looks_like_scan`: image_count <= 1, few text operators (<50), and
2216///    low alphanumeric diversity in raw string operands (unless decodable
2217///    CID/ToUnicode fonts explain that away) — the gate used for
2218///    `pages_with_template_images` and Mixed-type per-page routing.
2219/// 2. Insufficient real text volume, using the same `effective_min_ops`
2220///    floor (`min_text_ops_per_page.max(10)`) that `pages_with_text`
2221///    applies to image-bearing pages. That floor is a per-page judgment,
2222///    not part of the cross-page aggregate: classification counts a
2223///    template-image page with fewer ops as textless and routes it to OCR,
2224///    so this function must agree. The lower bare threshold (3) let a
2225///    full-page scan carrying a small native masthead — a newspaper
2226///    header, stamp, or date line of ~4 diverse, decodable text ops —
2227///    extract as "a text page" here while whole-document classification
2228///    called the same page scanned, silently dropping the page body from
2229///    OCR routing. `alphanum_low` can't catch that case: masthead chrome
2230///    is real text, so its byte diversity is high.
2231///
2232/// This function always evaluates against `DetectionConfig::default()` —
2233/// it has no config parameter, and the per-page extraction path that calls
2234/// it never carries one. A caller passing a custom `min_text_ops_per_page`
2235/// to `detect_from_document` affects whole-document detection only; the
2236/// two paths agree under the default configuration.
2237///
2238/// `has_vector_text` is true when a page has vector-outlined text (glyphs
2239/// drawn as paths rather than shown via text-showing operators) —
2240/// `detect_from_document`'s Mixed-type per-page routing always sends
2241/// these pages to OCR, independent of any template-image check, since
2242/// outlined glyphs can't be extracted as text at all.
2243///
2244/// Exposed at crate visibility so `extract_pages_markdown_mem` can apply
2245/// the same gates classification needs elsewhere instead of treating the
2246/// raw signals alone as sufficient — see #227/#231.
2247pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> PageOcrSignals {
2248    let analysis = analyze_page_content(doc, page_id);
2249
2250    let needs_ocr_for_template_image = if !analysis.has_template_image {
2251        false
2252    } else {
2253        let alphanum_low = analysis.unique_alphanum_chars < 10
2254            && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
2255        let looks_like_scan =
2256            analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
2257        let insufficient_text =
2258            analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
2259        looks_like_scan || insufficient_text
2260    };
2261
2262    PageOcrSignals {
2263        template_image_needs_ocr: needs_ocr_for_template_image,
2264        has_vector_text: analysis.has_vector_text,
2265        has_invisible_text_layer: analysis.has_invisible_text_layer,
2266    }
2267}
2268
2269/// The per-page signals [`page_ocr_signals`] shares between classification
2270/// and per-page extraction.
2271#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2272pub(crate) struct PageOcrSignals {
2273    /// The page's template image is a scan needing OCR rather than a
2274    /// watermark, letterhead or figure under a text page.
2275    pub(crate) template_image_needs_ocr: bool,
2276    /// The page's text is drawn as vector outlines.
2277    pub(crate) has_vector_text: bool,
2278    /// Every text-showing operator on the page is invisible while an image
2279    /// covers the page.
2280    pub(crate) has_invisible_text_layer: bool,
2281}
2282
2283/// Recursively collect image dimensions from XObject resources,
2284/// including images nested inside Form XObjects.
2285fn collect_images_from_resources(
2286    doc: &Document,
2287    resources: &lopdf::Dictionary,
2288    has_images: &mut bool,
2289    total_area: &mut u64,
2290    has_template_image: &mut bool,
2291    threshold: u64,
2292    visited: &mut HashSet<ObjectId>,
2293) {
2294    let xobject = match resources.get(b"XObject") {
2295        Ok(obj) => obj,
2296        _ => return,
2297    };
2298    let xobject_dict = match xobject {
2299        Object::Reference(id) => doc.get_dictionary(*id).ok(),
2300        Object::Dictionary(dict) => Some(dict),
2301        _ => None,
2302    };
2303    let Some(xobject_dict) = xobject_dict else {
2304        return;
2305    };
2306
2307    for (_, value) in xobject_dict.iter() {
2308        let xobj_ref = match value.as_reference() {
2309            Ok(r) => r,
2310            _ => continue,
2311        };
2312        if !visited.insert(xobj_ref) {
2313            continue;
2314        }
2315        let xobj = match doc.get_object(xobj_ref) {
2316            Ok(o) => o,
2317            _ => continue,
2318        };
2319        let stream = match xobj.as_stream() {
2320            Ok(s) => s,
2321            _ => continue,
2322        };
2323        let subtype = match stream.dict.get(b"Subtype") {
2324            Ok(s) => s,
2325            _ => continue,
2326        };
2327        let name = match subtype.as_name() {
2328            Ok(n) => n,
2329            _ => continue,
2330        };
2331
2332        if name == b"Image" {
2333            *has_images = true;
2334            let width = stream
2335                .dict
2336                .get(b"Width")
2337                .ok()
2338                .and_then(|w| w.as_i64().ok())
2339                .unwrap_or(0) as u64;
2340            let height = stream
2341                .dict
2342                .get(b"Height")
2343                .ok()
2344                .and_then(|h| h.as_i64().ok())
2345                .unwrap_or(0) as u64;
2346            let area = width * height;
2347            *total_area += area;
2348            if area >= threshold {
2349                *has_template_image = true;
2350            }
2351        } else if name == b"Form" {
2352            // Recurse into Form XObject's own Resources
2353            if let Ok(form_resources) = stream.dict.get(b"Resources") {
2354                let form_res_dict = match form_resources {
2355                    Object::Reference(id) => doc.get_dictionary(*id).ok(),
2356                    Object::Dictionary(dict) => Some(dict),
2357                    _ => None,
2358                };
2359                if let Some(form_res) = form_res_dict {
2360                    collect_images_from_resources(
2361                        doc,
2362                        form_res,
2363                        has_images,
2364                        total_area,
2365                        has_template_image,
2366                        threshold,
2367                        visited,
2368                    );
2369                }
2370            }
2371        }
2372    }
2373}
2374
2375/// The text entries of a document information dictionary, each decoded as
2376/// a PDF text string (see [`PdfTypeResult::title`]).
2377#[derive(Debug, Clone, Default, PartialEq, Eq)]
2378pub(crate) struct DocumentInfo {
2379    pub(crate) title: Option<String>,
2380    pub(crate) author: Option<String>,
2381    pub(crate) subject: Option<String>,
2382    pub(crate) keywords: Option<String>,
2383    pub(crate) creator: Option<String>,
2384    pub(crate) producer: Option<String>,
2385    pub(crate) creation_date: Option<String>,
2386    pub(crate) mod_date: Option<String>,
2387}
2388
2389/// Read the document information dictionary the trailer's `/Info` names,
2390/// directly or by reference. An entry that is missing, or whose value
2391/// (followed through a reference) is not a string, reads as `None`.
2392pub(crate) fn read_document_info(doc: &Document) -> DocumentInfo {
2393    let info = match doc.trailer.get(b"Info") {
2394        Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
2395        Ok(Object::Dictionary(dict)) => Some(dict),
2396        _ => None,
2397    };
2398    let Some(info) = info else {
2399        return DocumentInfo::default();
2400    };
2401    let entry = |key: &[u8]| -> Option<String> {
2402        let value = match info.get(key).ok()? {
2403            Object::Reference(id) => doc.get_object(*id).ok()?,
2404            value => value,
2405        };
2406        match value {
2407            Object::String(bytes, _) => Some(crate::text_utils::decode_pdf_text_string(bytes)),
2408            _ => None,
2409        }
2410    };
2411    DocumentInfo {
2412        title: entry(b"Title"),
2413        author: entry(b"Author"),
2414        subject: entry(b"Subject"),
2415        keywords: entry(b"Keywords"),
2416        creator: entry(b"Creator"),
2417        producer: entry(b"Producer"),
2418        creation_date: entry(b"CreationDate"),
2419        mod_date: entry(b"ModDate"),
2420    }
2421}
2422
2423#[cfg(test)]
2424mod tests {
2425    use super::*;
2426
2427    #[test]
2428    fn document_info_entries_are_read_and_decoded() {
2429        use lopdf::{dictionary, StringFormat};
2430
2431        let utf16 = |text: &str| {
2432            let mut bytes = vec![0xFE, 0xFF];
2433            for unit in text.encode_utf16() {
2434                bytes.extend_from_slice(&unit.to_be_bytes());
2435            }
2436            Object::String(bytes, StringFormat::Hexadecimal)
2437        };
2438        let mut doc = Document::with_version("1.7");
2439        let keywords = doc.add_object(Object::string_literal("annual, colour"));
2440        let info = doc.add_object(dictionary! {
2441            "Title" => utf16("Größe – Überblick"),
2442            "Author" => Object::string_literal(b"Jos\xE9 Mart\xEDnez \x84 Acme\x92".to_vec()),
2443            "Subject" => Object::string_literal("Quarterly results"),
2444            "Keywords" => Object::Reference(keywords),
2445            "Creator" => Object::string_literal("Writer"),
2446            "Producer" => utf16("PDF Library 1.0"),
2447            "CreationDate" => Object::string_literal("D:20240115103000+01'00'"),
2448            "ModDate" => Object::Name(b"NotAString".to_vec()),
2449        });
2450        doc.trailer.set("Info", Object::Reference(info));
2451        assert_eq!(
2452            read_document_info(&doc),
2453            DocumentInfo {
2454                title: Some("Größe – Überblick".into()),
2455                author: Some("José Martínez — Acme™".into()),
2456                subject: Some("Quarterly results".into()),
2457                keywords: Some("annual, colour".into()),
2458                creator: Some("Writer".into()),
2459                producer: Some("PDF Library 1.0".into()),
2460                creation_date: Some("D:20240115103000+01'00'".into()),
2461                mod_date: None,
2462            }
2463        );
2464
2465        // An information dictionary written in place of a reference reads
2466        // the same; a document without one reads nothing.
2467        doc.trailer.set(
2468            "Info",
2469            dictionary! { "Producer" => Object::string_literal("Inline") },
2470        );
2471        let inline = read_document_info(&doc);
2472        assert_eq!(inline.producer.as_deref(), Some("Inline"));
2473        assert_eq!(inline.title, None);
2474        doc.trailer.remove(b"Info");
2475        assert_eq!(read_document_info(&doc), DocumentInfo::default());
2476    }
2477
2478    #[test]
2479    fn page_ocr_reasons_classify() {
2480        // Scanned: no text, full-page image.
2481        let scanned = PageAnalysis {
2482            has_template_image: true,
2483            ..Default::default()
2484        };
2485        assert_eq!(page_ocr_reasons(&scanned), vec![crate::OCR_REASON_SCANNED]);
2486
2487        // Image-only page (no template flag, but has an image).
2488        let image_only = PageAnalysis {
2489            has_images: true,
2490            ..Default::default()
2491        };
2492        assert_eq!(
2493            page_ocr_reasons(&image_only),
2494            vec![crate::OCR_REASON_SCANNED]
2495        );
2496
2497        // No text, no image → no_text.
2498        let blank = PageAnalysis::default();
2499        assert_eq!(page_ocr_reasons(&blank), vec![crate::OCR_REASON_NO_TEXT]);
2500
2501        // Vector-outlined text.
2502        let vector = PageAnalysis {
2503            has_vector_text: true,
2504            ..Default::default()
2505        };
2506        assert_eq!(
2507            page_ocr_reasons(&vector),
2508            vec![crate::OCR_REASON_VECTOR_TEXT]
2509        );
2510
2511        // Undecodable fonts → garbled, and it wins over the fall-through.
2512        let garbled = PageAnalysis {
2513            has_identity_h_no_tounicode: true,
2514            has_images: true,
2515            ..Default::default()
2516        };
2517        assert_eq!(
2518            page_ocr_reasons(&garbled),
2519            vec![crate::OCR_REASON_SUSPECTED_GARBLED_TEXT]
2520        );
2521
2522        // A page with real extractable text and an image is not flagged here
2523        // as scanned/no_text (only reached for pages already needing OCR).
2524        let text_with_image = PageAnalysis {
2525            text_operator_count: 40,
2526            unique_text_chars: 120,
2527            has_images: true,
2528            ..Default::default()
2529        };
2530        assert_eq!(
2531            page_ocr_reasons(&text_with_image),
2532            vec![crate::OCR_REASON_SCANNED]
2533        );
2534
2535        // A text layer nobody sees under a covering image: the specific
2536        // reason, not the `scanned` fall-through, and ahead of garbled
2537        // fonts and vector text — the page is a scan whatever its fonts.
2538        let invisible_layer = PageAnalysis {
2539            text_operator_count: 300,
2540            executed_text_operator_count: 300,
2541            invisible_text_operator_count: 300,
2542            unique_text_chars: 40,
2543            has_images: true,
2544            has_covering_image: true,
2545            has_invisible_text_layer: true,
2546            ..Default::default()
2547        };
2548        assert_eq!(
2549            page_ocr_reasons(&invisible_layer),
2550            vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
2551        );
2552        let garbled_vector_invisible_layer = PageAnalysis {
2553            has_identity_h_no_tounicode: true,
2554            has_vector_text: true,
2555            ..invisible_layer
2556        };
2557        assert_eq!(
2558            page_ocr_reasons(&garbled_vector_invisible_layer),
2559            vec![
2560                crate::OCR_REASON_INVISIBLE_TEXT_LAYER,
2561                crate::OCR_REASON_SUSPECTED_GARBLED_TEXT,
2562                crate::OCR_REASON_VECTOR_TEXT
2563            ]
2564        );
2565    }
2566
2567    #[test]
2568    fn test_scan_content_operators() {
2569        let mut uchars = HashSet::new();
2570
2571        // Sample PDF content stream with text operators
2572        let content = b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET";
2573        let (ops, imgs, _, _) =
2574            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2575        assert_eq!(ops, 1);
2576        assert_eq!(imgs, 0);
2577        // "Hello World" without space: H, e, l, o, W, r, d = 7 unique
2578        assert!(uchars.len() >= 7);
2579
2580        // Content with TJ array
2581        uchars.clear();
2582        let content2 = b"BT /F1 12 Tf 100 700 Td [(H) 10 (ello)] TJ ET";
2583        let (ops2, _, _, _) =
2584            scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
2585        assert_eq!(ops2, 1);
2586        // H, e, l, o = 4 unique
2587        assert!(uchars.len() >= 4);
2588
2589        // Content with Do (XObject invocation — not counted as image here;
2590        // actual image detection is handled by scan_xobjects_in_resources)
2591        uchars.clear();
2592        let content3 = b"q 100 0 0 100 50 700 cm /Img1 Do Q";
2593        let (ops3, imgs3, _, _) =
2594            scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
2595        assert_eq!(ops3, 0);
2596        assert_eq!(imgs3, 0);
2597    }
2598
2599    #[test]
2600    fn test_scan_content_successive_tj_collects_each_operand() {
2601        // Lookback is floored at the previous Tj/TJ/Tf so later operators must
2602        // still see their own operands.
2603        let content = b"[(Hello)] TJ [(World)] TJ (More) Tj";
2604        let mut uchars = HashSet::new();
2605        let (ops, _, _, _) =
2606            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2607        assert_eq!(ops, 3);
2608        for &ch in b"HeloWrdM" {
2609            assert!(uchars.contains(&ch), "missing char {}", ch as char);
2610        }
2611    }
2612
2613    #[test]
2614    fn test_scan_content_tj_inside_literal_is_not_an_operator() {
2615        // `Tj` followed by space inside a literal must not count as an operator
2616        // or pin the lookback floor; the real `Tj` still collects the string.
2617        let content = b"BT (Hello Tj World) Tj ET";
2618        let mut uchars = HashSet::new();
2619        let (ops, _, _, _) =
2620            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2621        assert_eq!(ops, 1);
2622        for &ch in b"HeloTjWrd" {
2623            assert!(uchars.contains(&ch), "missing char {}", ch as char);
2624        }
2625    }
2626
2627    #[test]
2628    fn test_scan_content_malformed_tj_lookback_stays_linear() {
2629        // `] TJ` with no `[` used to walk the entire prefix for every operator
2630        // (quadratic). 30k repeats is enough that a prefix rescan would dominate
2631        // the test runtime; with the floor it is a single linear pass. Such an
2632        // operator has no string to show, so none of them is counted.
2633        let n = 30_000usize;
2634        let mut content = Vec::with_capacity(n * 5);
2635        for _ in 0..n {
2636            content.extend_from_slice(b"] TJ\n");
2637        }
2638        let mut uchars = HashSet::new();
2639        let (ops, _, _, _) =
2640            scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2641        assert_eq!(ops, 0);
2642        assert!(uchars.is_empty());
2643    }
2644
2645    #[test]
2646    fn test_image_dominated_detection() {
2647        // Do operators are no longer counted as images by scan_content_for_text_operators.
2648        // Image-dominated detection now relies on scan_xobjects_in_resources which
2649        // checks XObject Subtype. Here we verify that Do operators don't inflate image_count.
2650        let mut content = Vec::new();
2651        for i in 0..50 {
2652            content.extend_from_slice(format!("/Im{i} Do\n").as_bytes());
2653        }
2654        content.extend_from_slice(b"BT (x) Tj ET\n");
2655        content.extend_from_slice(b"BT (x) Tj ET\n");
2656        content.extend_from_slice(b"BT (x) Tj ET\n");
2657
2658        let mut uchars = HashSet::new();
2659        let (ops, imgs, _, _) =
2660            scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2661        assert_eq!(ops, 3);
2662        assert_eq!(imgs, 0); // Do operators are not counted here
2663        assert_eq!(uchars.len(), 1);
2664    }
2665
2666    #[test]
2667    fn test_normal_text_not_image_dominated() {
2668        let content = b"BT /F1 12 Tf (The quick brown fox jumps over the lazy dog) Tj ET\n\
2669                         /Img1 Do\n/Img2 Do\n";
2670        let mut uchars = HashSet::new();
2671        let (ops, imgs, _, _) =
2672            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2673        assert_eq!(ops, 1);
2674        assert_eq!(imgs, 0); // Do operators not counted here
2675                             // Many unique chars from the sentence
2676        assert!(uchars.len() >= 5);
2677    }
2678
2679    #[test]
2680    fn test_path_heavy_detection() {
2681        // Simulate vector-outlined text: many path ops, few text ops
2682        let mut content = Vec::new();
2683        // Add a couple text ops
2684        content.extend_from_slice(b"BT (Header) Tj ET\n");
2685        // Add 2000 path ops (simulating outlined glyphs)
2686        for _ in 0..500 {
2687            content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h\n");
2688        }
2689        content.extend_from_slice(b"f\n");
2690
2691        let mut uchars = HashSet::new();
2692        let (text, imgs, paths, _) =
2693            scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2694        assert_eq!(text, 1);
2695        assert_eq!(imgs, 0);
2696        // 500 * (m + l + c + h) + 1 f = 2001
2697        assert!(paths >= 2000, "expected >= 2000 path ops, got {paths}");
2698
2699        // Should trigger vector text detection: paths >= 1000 && paths > text * 200
2700        let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
2701        assert!(has_vector_text);
2702    }
2703
2704    #[test]
2705    fn test_normal_paths_not_vector_text() {
2706        // Normal page: text with some decorative paths (charts, borders)
2707        let mut content = Vec::new();
2708        // 20 text ops
2709        for _ in 0..20 {
2710            content.extend_from_slice(b"BT (Some text content here) Tj ET\n");
2711        }
2712        // 50 path ops (a chart or border)
2713        for _ in 0..10 {
2714            content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h f\n");
2715        }
2716
2717        let mut uchars = HashSet::new();
2718        let (text, _, paths, _) =
2719            scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2720        assert_eq!(text, 20);
2721        assert!(paths >= 40, "expected >= 40 path ops, got {paths}");
2722
2723        // Should NOT trigger: paths < 1000
2724        let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
2725        assert!(!has_vector_text);
2726    }
2727
2728    #[test]
2729    fn test_epever_vector_text_detection() {
2730        // Integration test: EPEVER PDF should be Mixed with page 2 needing OCR
2731        let path = std::path::Path::new("./tests/fixtures/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf");
2732        let path = if path.exists() {
2733            path.to_path_buf()
2734        } else {
2735            let alt = std::path::PathBuf::from(
2736                "../pdf-evals/pdfs/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf",
2737            );
2738            if !alt.exists() {
2739                // PDF not available, skip test
2740                return;
2741            }
2742            alt
2743        };
2744
2745        let config = DetectionConfig {
2746            strategy: ScanStrategy::Full,
2747            ..DetectionConfig::default()
2748        };
2749        let result = detect_pdf_type_with_config(&path, config).unwrap();
2750        assert_eq!(
2751            result.pdf_type,
2752            PdfType::Mixed,
2753            "EPEVER should be Mixed (page 2 has vector-outlined text)"
2754        );
2755        assert!(
2756            result.pages_needing_ocr.contains(&2),
2757            "Page 2 should need OCR, got: {:?}",
2758            result.pages_needing_ocr
2759        );
2760        assert!(result.ocr_recommended);
2761    }
2762
2763    #[test]
2764    fn test_page_has_identity_h_no_tounicode_positive() {
2765        // Build a minimal PDF with a Type0 Identity-H font and no ToUnicode.
2766        use lopdf::dictionary;
2767        let mut doc = Document::with_version("1.4");
2768        let pages_id = doc.new_object_id();
2769        let page_id = doc.new_object_id();
2770        let font_id = doc.add_object(dictionary! {
2771            "Type" => "Font",
2772            "Subtype" => Object::Name(b"Type0".to_vec()),
2773            "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2774            "Encoding" => Object::Name(b"Identity-H".to_vec()),
2775        });
2776        let resources = dictionary! {
2777            "Font" => dictionary! {
2778                "F1" => Object::Reference(font_id),
2779            },
2780        };
2781        doc.objects.insert(
2782            page_id,
2783            Object::Dictionary(dictionary! {
2784                "Type" => "Page",
2785                "Parent" => Object::Reference(pages_id),
2786                "Resources" => resources,
2787            }),
2788        );
2789        doc.objects.insert(
2790            pages_id,
2791            Object::Dictionary(dictionary! {
2792                "Type" => "Pages",
2793                "Kids" => vec![Object::Reference(page_id)],
2794                "Count" => Object::Integer(1),
2795            }),
2796        );
2797        assert!(page_has_identity_h_no_tounicode(&doc, page_id));
2798    }
2799
2800    #[test]
2801    fn test_page_has_identity_h_with_tounicode_negative() {
2802        // Type0 Identity-H font WITH ToUnicode — should NOT flag.
2803        use lopdf::dictionary;
2804        let mut doc = Document::with_version("1.4");
2805        let pages_id = doc.new_object_id();
2806        let page_id = doc.new_object_id();
2807        let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
2808            dictionary! {},
2809            b"fake cmap".to_vec(),
2810        )));
2811        let font_id = doc.add_object(dictionary! {
2812            "Type" => "Font",
2813            "Subtype" => Object::Name(b"Type0".to_vec()),
2814            "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2815            "Encoding" => Object::Name(b"Identity-H".to_vec()),
2816            "ToUnicode" => Object::Reference(cmap_id),
2817        });
2818        let resources = dictionary! {
2819            "Font" => dictionary! {
2820                "F1" => Object::Reference(font_id),
2821            },
2822        };
2823        doc.objects.insert(
2824            page_id,
2825            Object::Dictionary(dictionary! {
2826                "Type" => "Page",
2827                "Parent" => Object::Reference(pages_id),
2828                "Resources" => resources,
2829            }),
2830        );
2831        doc.objects.insert(
2832            pages_id,
2833            Object::Dictionary(dictionary! {
2834                "Type" => "Pages",
2835                "Kids" => vec![Object::Reference(page_id)],
2836                "Count" => Object::Integer(1),
2837            }),
2838        );
2839        assert!(!page_has_identity_h_no_tounicode(&doc, page_id));
2840    }
2841
2842    #[test]
2843    fn test_identity_h_with_unicode_cids_not_flagged() {
2844        // Type0 Identity-H font without ToUnicode but with W array CIDs
2845        // that look like Unicode codepoints (e.g. from Chromium/wkhtmltopdf).
2846        // The CID-as-Unicode passthrough can decode these — don't flag.
2847        use lopdf::dictionary;
2848        let mut doc = Document::with_version("1.4");
2849        let pages_id = doc.new_object_id();
2850        let page_id = doc.new_object_id();
2851        // CIDFont with W array containing Unicode-range CIDs (>= 0x41)
2852        let cid_font_id = doc.add_object(dictionary! {
2853            "Type" => "Font",
2854            "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
2855            "W" => Object::Array(vec![
2856                Object::Integer(0x41),  // CID 65 = 'A'
2857                Object::Array(vec![
2858                    Object::Integer(600), Object::Integer(600), Object::Integer(600),
2859                ]),
2860                Object::Integer(0x61),  // CID 97 = 'a'
2861                Object::Array(vec![
2862                    Object::Integer(500), Object::Integer(500), Object::Integer(500),
2863                ]),
2864            ]),
2865        });
2866        let font_id = doc.add_object(dictionary! {
2867            "Type" => "Font",
2868            "Subtype" => Object::Name(b"Type0".to_vec()),
2869            "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2870            "Encoding" => Object::Name(b"Identity-H".to_vec()),
2871            "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
2872        });
2873        let resources = dictionary! {
2874            "Font" => dictionary! {
2875                "F1" => Object::Reference(font_id),
2876            },
2877        };
2878        doc.objects.insert(
2879            page_id,
2880            Object::Dictionary(dictionary! {
2881                "Type" => "Page",
2882                "Parent" => Object::Reference(pages_id),
2883                "Resources" => resources,
2884            }),
2885        );
2886        doc.objects.insert(
2887            pages_id,
2888            Object::Dictionary(dictionary! {
2889                "Type" => "Pages",
2890                "Kids" => vec![Object::Reference(page_id)],
2891                "Count" => Object::Integer(1),
2892            }),
2893        );
2894        assert!(
2895            !page_has_identity_h_no_tounicode(&doc, page_id),
2896            "Should NOT flag: W array CIDs look like Unicode, passthrough works"
2897        );
2898    }
2899
2900    #[test]
2901    fn test_identity_h_with_low_gid_cids_still_flagged() {
2902        // Type0 Identity-H font without ToUnicode and W array CIDs
2903        // that are low GID values (subset font, no cmap). These can't
2904        // be decoded — should still be flagged.
2905        use lopdf::dictionary;
2906        let mut doc = Document::with_version("1.4");
2907        let pages_id = doc.new_object_id();
2908        let page_id = doc.new_object_id();
2909        // CIDFont with W array containing low GID values (< 0x41)
2910        let cid_font_id = doc.add_object(dictionary! {
2911            "Type" => "Font",
2912            "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
2913            "W" => Object::Array(vec![
2914                Object::Integer(3),  // Low GID
2915                Object::Array(vec![
2916                    Object::Integer(600), Object::Integer(600), Object::Integer(600),
2917                    Object::Integer(600), Object::Integer(600),
2918                ]),
2919                Object::Integer(10),  // Still low
2920                Object::Array(vec![
2921                    Object::Integer(500), Object::Integer(500), Object::Integer(500),
2922                ]),
2923            ]),
2924        });
2925        let font_id = doc.add_object(dictionary! {
2926            "Type" => "Font",
2927            "Subtype" => Object::Name(b"Type0".to_vec()),
2928            "BaseFont" => Object::Name(b"GPBCHP+TimesNewRoman".to_vec()),
2929            "Encoding" => Object::Name(b"Identity-H".to_vec()),
2930            "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
2931        });
2932        let resources = dictionary! {
2933            "Font" => dictionary! {
2934                "F1" => Object::Reference(font_id),
2935            },
2936        };
2937        doc.objects.insert(
2938            page_id,
2939            Object::Dictionary(dictionary! {
2940                "Type" => "Page",
2941                "Parent" => Object::Reference(pages_id),
2942                "Resources" => resources,
2943            }),
2944        );
2945        doc.objects.insert(
2946            pages_id,
2947            Object::Dictionary(dictionary! {
2948                "Type" => "Pages",
2949                "Kids" => vec![Object::Reference(page_id)],
2950                "Count" => Object::Integer(1),
2951            }),
2952        );
2953        assert!(
2954            page_has_identity_h_no_tounicode(&doc, page_id),
2955            "Should flag: low GID CIDs, no cmap, no passthrough"
2956        );
2957    }
2958
2959    #[test]
2960    fn test_scan_content_counts_tf_operators() {
2961        let mut uchars = HashSet::new();
2962        let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
2963        let (ops, _, _, fonts) =
2964            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2965        assert_eq!(ops, 2);
2966        assert_eq!(fonts, 2);
2967    }
2968
2969    #[test]
2970    fn test_tf_without_trailing_whitespace() {
2971        // Some PDFs concatenate Tf directly with the next operator's operand,
2972        // e.g. "25 Tf[<01>..." or "25 Tf(<text>..."
2973        let mut uchars = HashSet::new();
2974
2975        // Tf followed by '[' (TJ array start)
2976        let content = b"BT /F1 25 Tf[<01>1<02>-1] TJ ET";
2977        let (ops, _, _, fonts) =
2978            scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2979        assert_eq!(fonts, 1, "Tf followed by '[' should be counted");
2980        assert_eq!(ops, 1);
2981
2982        // Tf followed by '(' (literal string)
2983        uchars.clear();
2984        let content2 = b"BT /F1 12 Tf(Hello) Tj ET";
2985        let (ops2, _, _, fonts2) =
2986            scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
2987        assert_eq!(fonts2, 1, "Tf followed by '(' should be counted");
2988        assert_eq!(ops2, 1);
2989
2990        // Tf followed by '<' (hex string)
2991        uchars.clear();
2992        let content3 = b"BT /F1 12 Tf<0102> Tj ET";
2993        let (ops3, _, _, fonts3) =
2994            scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
2995        assert_eq!(fonts3, 1, "Tf followed by '<' should be counted");
2996        assert_eq!(ops3, 1);
2997
2998        // Tf followed by '/' (next font name)
2999        uchars.clear();
3000        let content4 = b"BT /F1 12 Tf/F2 10 Tf (x) Tj ET";
3001        let (_, _, _, fonts4) =
3002            scan_content_for_text_operators(content4, &mut uchars, &mut HashSet::new());
3003        assert_eq!(fonts4, 2, "Tf followed by '/' should be counted");
3004    }
3005
3006    #[test]
3007    fn test_newspaper_heuristic_thresholds() {
3008        // Newspaper page: high text ops, moderate font changes, low ratio
3009        let text_ops = 3500u32;
3010        let font_changes = 150u32;
3011        let ratio = font_changes as f32 / text_ops as f32;
3012        assert!(text_ops >= 1500);
3013        assert!(font_changes >= 50);
3014        assert!(ratio < 0.15); // 0.043
3015
3016        // Dense styled doc (DPA/contract): high text ops, very high font changes, high ratio
3017        let text_ops = 1800u32;
3018        let font_changes = 540u32;
3019        let ratio = font_changes as f32 / text_ops as f32;
3020        assert!(text_ops >= 1500);
3021        assert!(font_changes >= 50);
3022        assert!(ratio >= 0.15); // 0.30 — should NOT trigger newspaper heuristic
3023
3024        // Normal doc: low text ops — doesn't qualify at all
3025        let text_ops = 300u32;
3026        let font_changes = 50u32;
3027        assert!(text_ops < 1500);
3028    }
3029
3030    #[test]
3031    fn test_looks_like_scan_requires_all_conditions() {
3032        // The looks_like_scan heuristic requires ALL three conditions (AND):
3033        // 1. image_count <= 1
3034        // 2. text_operator_count < 50
3035        // 3. unique_alphanum_chars < 10
3036
3037        // A text page with one figure: has text ops and alphanum chars
3038        // Should NOT look like a scan
3039        let image_count = 1u32;
3040        let text_operator_count = 135u32;
3041        let unique_alphanum_chars = 58u32;
3042        let looks_like_scan =
3043            image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3044        assert!(
3045            !looks_like_scan,
3046            "text page with one figure should not be flagged as scan"
3047        );
3048
3049        // A genuine scan: single image, no real text
3050        let image_count = 1u32;
3051        let text_operator_count = 3u32;
3052        let unique_alphanum_chars = 2u32;
3053        let looks_like_scan =
3054            image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3055        assert!(
3056            looks_like_scan,
3057            "single image with no real text should be flagged as scan"
3058        );
3059
3060        // OCR overlay page: single image but has OCR text operators and chars
3061        // Should NOT look like a scan (OCR text is sufficient)
3062        let image_count = 1u32;
3063        let text_operator_count = 200u32;
3064        let unique_alphanum_chars = 40u32;
3065        let looks_like_scan =
3066            image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3067        assert!(
3068            !looks_like_scan,
3069            "OCR overlay page should not be flagged as scan"
3070        );
3071
3072        // Multiple images but low text: still not a scan (multiple figures page)
3073        let image_count = 4u32;
3074        let text_operator_count = 25u32;
3075        let unique_alphanum_chars = 1u32;
3076        let looks_like_scan =
3077            image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3078        assert!(
3079            !looks_like_scan,
3080            "multiple images page should not match single-image scan pattern"
3081        );
3082    }
3083
3084    // ---------- Tests for has_vector_text alphanum guard ----------
3085
3086    #[test]
3087    fn test_has_vector_text_real_text_with_decorations_not_flagged() {
3088        // Newspaper-style page: high path_ops (column borders/dividers/decorations)
3089        // BUT also lots of selectable real text → high unique_alphanum_chars.
3090        // Should NOT trigger has_vector_text — the paths are decorations, not glyphs.
3091        let path_ops = 8354u32;
3092        let text_ops = 41u32;
3093        let unique_alphanum_chars = 53u32;
3094        let has_vector_text = path_ops >= 1000
3095            && path_ops > text_ops.saturating_mul(200)
3096            && unique_alphanum_chars < 30;
3097        assert!(
3098            !has_vector_text,
3099            "page with real selectable text alongside decorative paths should not be vector_text"
3100        );
3101    }
3102
3103    #[test]
3104    fn test_has_vector_text_outlined_glyphs_still_flagged() {
3105        // True outlined-text page: massive path_ops, very few unique alphanum chars
3106        // (each char is a path, not a Tj op). MUST still flag as vector_text.
3107        let path_ops = 8000u32;
3108        let text_ops = 5u32;
3109        let unique_alphanum_chars = 4u32;
3110        let has_vector_text = path_ops >= 1000
3111            && path_ops > text_ops.saturating_mul(200)
3112            && unique_alphanum_chars < 30;
3113        assert!(
3114            has_vector_text,
3115            "true outlined-text page should still be flagged as vector_text"
3116        );
3117    }
3118
3119    // ---------- Tests for page_has_identity_h_no_tounicode supplementary-font handling ----------
3120
3121    #[test]
3122    fn test_identity_h_with_supplementary_decodable_font_not_flagged() {
3123        // Page has TWO fonts: an undecodable Identity-H Type0 (supplementary,
3124        // e.g. a decorative font for headers) AND a Type1 font with ToUnicode
3125        // (carries the body text). Should NOT flag — body text is decodable.
3126        use lopdf::dictionary;
3127        let mut doc = Document::with_version("1.4");
3128        let pages_id = doc.new_object_id();
3129        let page_id = doc.new_object_id();
3130
3131        // Undecodable Identity-H: no ToUnicode, no W array → no fallback.
3132        let bad_font_id = doc.add_object(dictionary! {
3133            "Type" => "Font",
3134            "Subtype" => Object::Name(b"Type0".to_vec()),
3135            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3136            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3137        });
3138
3139        // Decodable Type1 with ToUnicode: typical body-text font.
3140        let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3141            dictionary! {},
3142            b"fake cmap".to_vec(),
3143        )));
3144        let good_font_id = doc.add_object(dictionary! {
3145            "Type" => "Font",
3146            "Subtype" => Object::Name(b"Type1".to_vec()),
3147            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3148            "ToUnicode" => Object::Reference(cmap_id),
3149        });
3150
3151        let resources = dictionary! {
3152            "Font" => dictionary! {
3153                "F1" => Object::Reference(bad_font_id),
3154                "F2" => Object::Reference(good_font_id),
3155            },
3156        };
3157        doc.objects.insert(
3158            page_id,
3159            Object::Dictionary(dictionary! {
3160                "Type" => "Page",
3161                "Parent" => Object::Reference(pages_id),
3162                "Resources" => resources,
3163            }),
3164        );
3165        doc.objects.insert(
3166            pages_id,
3167            Object::Dictionary(dictionary! {
3168                "Type" => "Pages",
3169                "Kids" => vec![Object::Reference(page_id)],
3170                "Count" => Object::Integer(1),
3171            }),
3172        );
3173
3174        assert!(
3175            !page_has_identity_h_no_tounicode(&doc, page_id),
3176            "page with supplementary undecodable Identity-H but decodable Type1 should not flag"
3177        );
3178    }
3179
3180    #[test]
3181    fn test_identity_h_with_no_other_fonts_still_flagged() {
3182        // Regression check: page with ONLY the undecodable Identity-H font
3183        // (no other decodable text font) MUST still flag for OCR.
3184        use lopdf::dictionary;
3185        let mut doc = Document::with_version("1.4");
3186        let pages_id = doc.new_object_id();
3187        let page_id = doc.new_object_id();
3188        let bad_font_id = doc.add_object(dictionary! {
3189            "Type" => "Font",
3190            "Subtype" => Object::Name(b"Type0".to_vec()),
3191            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3192            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3193        });
3194        let resources = dictionary! {
3195            "Font" => dictionary! {
3196                "F1" => Object::Reference(bad_font_id),
3197            },
3198        };
3199        doc.objects.insert(
3200            page_id,
3201            Object::Dictionary(dictionary! {
3202                "Type" => "Page",
3203                "Parent" => Object::Reference(pages_id),
3204                "Resources" => resources,
3205            }),
3206        );
3207        doc.objects.insert(
3208            pages_id,
3209            Object::Dictionary(dictionary! {
3210                "Type" => "Pages",
3211                "Kids" => vec![Object::Reference(page_id)],
3212                "Count" => Object::Integer(1),
3213            }),
3214        );
3215        assert!(
3216            page_has_identity_h_no_tounicode(&doc, page_id),
3217            "page with only undecodable Identity-H must still be flagged"
3218        );
3219    }
3220
3221    // ---------- Tests for page_has_decodable_text_fonts ----------
3222
3223    #[test]
3224    fn test_page_has_decodable_text_fonts_type1() {
3225        // Type1 font (no ToUnicode required — uses Adobe Glyph List)
3226        use lopdf::dictionary;
3227        let mut doc = Document::with_version("1.4");
3228        let pages_id = doc.new_object_id();
3229        let page_id = doc.new_object_id();
3230        let font_id = doc.add_object(dictionary! {
3231            "Type" => "Font",
3232            "Subtype" => Object::Name(b"Type1".to_vec()),
3233            "BaseFont" => Object::Name(b"Times-Roman".to_vec()),
3234        });
3235        let resources = dictionary! {
3236            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3237        };
3238        doc.objects.insert(
3239            page_id,
3240            Object::Dictionary(dictionary! {
3241                "Type" => "Page",
3242                "Parent" => Object::Reference(pages_id),
3243                "Resources" => resources,
3244            }),
3245        );
3246        doc.objects.insert(
3247            pages_id,
3248            Object::Dictionary(dictionary! {
3249                "Type" => "Pages",
3250                "Kids" => vec![Object::Reference(page_id)],
3251                "Count" => Object::Integer(1),
3252            }),
3253        );
3254        assert!(page_has_decodable_text_fonts(&doc, page_id));
3255    }
3256
3257    #[test]
3258    fn test_page_has_decodable_text_fonts_type0_with_tounicode() {
3259        // Type0/Identity-H font with ToUnicode: CID-encoded text but decodable.
3260        // This is the bank-annual-report pattern.
3261        use lopdf::dictionary;
3262        let mut doc = Document::with_version("1.4");
3263        let pages_id = doc.new_object_id();
3264        let page_id = doc.new_object_id();
3265        let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3266            dictionary! {},
3267            b"fake cmap".to_vec(),
3268        )));
3269        let font_id = doc.add_object(dictionary! {
3270            "Type" => "Font",
3271            "Subtype" => Object::Name(b"Type0".to_vec()),
3272            "BaseFont" => Object::Name(b"BentonSans-Bold".to_vec()),
3273            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3274            "ToUnicode" => Object::Reference(cmap_id),
3275        });
3276        let resources = dictionary! {
3277            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3278        };
3279        doc.objects.insert(
3280            page_id,
3281            Object::Dictionary(dictionary! {
3282                "Type" => "Page",
3283                "Parent" => Object::Reference(pages_id),
3284                "Resources" => resources,
3285            }),
3286        );
3287        doc.objects.insert(
3288            pages_id,
3289            Object::Dictionary(dictionary! {
3290                "Type" => "Pages",
3291                "Kids" => vec![Object::Reference(page_id)],
3292                "Count" => Object::Integer(1),
3293            }),
3294        );
3295        assert!(page_has_decodable_text_fonts(&doc, page_id));
3296    }
3297
3298    #[test]
3299    fn test_page_has_decodable_text_fonts_undecodable_only_returns_false() {
3300        // ONLY undecodable Identity-H (no ToUnicode, no fallback).
3301        // Should return false — no path to recover this text.
3302        use lopdf::dictionary;
3303        let mut doc = Document::with_version("1.4");
3304        let pages_id = doc.new_object_id();
3305        let page_id = doc.new_object_id();
3306        let font_id = doc.add_object(dictionary! {
3307            "Type" => "Font",
3308            "Subtype" => Object::Name(b"Type0".to_vec()),
3309            "BaseFont" => Object::Name(b"ABCDEF+UnknownFont".to_vec()),
3310            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3311        });
3312        let resources = dictionary! {
3313            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3314        };
3315        doc.objects.insert(
3316            page_id,
3317            Object::Dictionary(dictionary! {
3318                "Type" => "Page",
3319                "Parent" => Object::Reference(pages_id),
3320                "Resources" => resources,
3321            }),
3322        );
3323        doc.objects.insert(
3324            pages_id,
3325            Object::Dictionary(dictionary! {
3326                "Type" => "Pages",
3327                "Kids" => vec![Object::Reference(page_id)],
3328                "Count" => Object::Integer(1),
3329            }),
3330        );
3331        assert!(!page_has_decodable_text_fonts(&doc, page_id));
3332    }
3333
3334    // ---------- Test for the CID-aware looks_like_scan override ----------
3335
3336    #[test]
3337    fn test_looks_like_scan_overridden_by_decodable_cid_text() {
3338        // CID-encoded text page (Type0 with ToUnicode) has:
3339        //   image_count = 1 (template image)
3340        //   text_operator_count = 36 (real Tj/TJ ops emitting CID values)
3341        //   unique_alphanum_chars = 8 (raw bytes are CIDs, not ASCII)
3342        //   has_decodable_text_fonts = true
3343        // Old check: looks_like_scan = (image<=1 && text<50 && alphanum<10) → TRUE (incorrect).
3344        // New check: alphanum < 10 is overridden when decodable fonts present + text_ops >= 10
3345        //           → looks_like_scan = false (correct — text IS decodable).
3346        let image_count = 1u32;
3347        let text_operator_count = 36u32;
3348        let unique_alphanum_chars = 8u32;
3349        let has_decodable_text_fonts = true;
3350
3351        let alphanum_low =
3352            unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3353        let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3354        assert!(
3355            !looks_like_scan,
3356            "CID-encoded decodable text page should not be flagged as scan"
3357        );
3358    }
3359
3360    #[test]
3361    fn test_looks_like_scan_keeps_flag_when_no_decodable_fonts() {
3362        // Same metrics as above but no decodable fonts → genuinely could be a scan.
3363        // Override should NOT kick in — looks_like_scan remains true.
3364        let image_count = 1u32;
3365        let text_operator_count = 36u32;
3366        let unique_alphanum_chars = 8u32;
3367        let has_decodable_text_fonts = false;
3368
3369        let alphanum_low =
3370            unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3371        let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3372        assert!(
3373            looks_like_scan,
3374            "page with no decodable fonts should remain flagged as scan"
3375        );
3376    }
3377
3378    #[test]
3379    fn test_looks_like_scan_keeps_flag_with_few_text_ops_even_if_decodable() {
3380        // Truly scanned page with a small overlay (1-5 text_ops, e.g. page number).
3381        // Has a decodable font (the page number font) but text_ops too low to
3382        // override. MUST still flag as scan.
3383        let image_count = 1u32;
3384        let text_operator_count = 4u32;
3385        let unique_alphanum_chars = 2u32;
3386        let has_decodable_text_fonts = true;
3387
3388        let alphanum_low =
3389            unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3390        let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3391        assert!(
3392            looks_like_scan,
3393            "scanned page with small text overlay (page number) should still flag"
3394        );
3395    }
3396
3397    // ---------- Tests for extract_font_name_before_tf ----------
3398
3399    #[test]
3400    fn test_extract_font_name_basic() {
3401        // Standard pattern: /F1 12 Tf
3402        let content = b"/F1 12 Tf";
3403        let name = extract_font_name_before_tf(content, 6, 0); // 'T' is at index 6
3404        assert_eq!(name, Some(b"F1".to_vec()));
3405    }
3406
3407    #[test]
3408    fn test_extract_font_name_long_name() {
3409        let content = b"/ArialMT-Bold 9.5 Tf";
3410        let name = extract_font_name_before_tf(content, 18, 0);
3411        assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
3412    }
3413
3414    #[test]
3415    fn test_scan_content_collects_used_font_names() {
3416        let mut uchars = HashSet::new();
3417        let mut fonts = HashSet::new();
3418        let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
3419        scan_content_for_text_operators(content, &mut uchars, &mut fonts);
3420        assert!(fonts.contains(&b"F1".to_vec()), "should collect F1");
3421        assert!(fonts.contains(&b"F2".to_vec()), "should collect F2");
3422        assert_eq!(fonts.len(), 2);
3423    }
3424
3425    // ---------- P1 tests: usage-based font filtering ----------
3426
3427    #[test]
3428    fn test_p1_unused_decodable_font_does_not_save_undecodable_page() {
3429        // P1 bug scenario: page Resources has TWO fonts:
3430        // - /F1: undecodable Identity-H (used in content stream)
3431        // - /F2: decodable Type1 (NOT used in content stream — leftover/inherited)
3432        //
3433        // Old resource-based check: sees F2 decodable → wrongly unflagged.
3434        // New usage-based check: only F1 is used → correctly flagged.
3435        use lopdf::dictionary;
3436        let mut doc = Document::with_version("1.4");
3437        let pages_id = doc.new_object_id();
3438        let page_id = doc.new_object_id();
3439
3440        // F1: undecodable Identity-H (no ToUnicode, no fallback)
3441        let bad_font_id = doc.add_object(dictionary! {
3442            "Type" => "Font",
3443            "Subtype" => Object::Name(b"Type0".to_vec()),
3444            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3445            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3446        });
3447        // F2: decodable Type1 (unused — leftover in Resources)
3448        let good_font_id = doc.add_object(dictionary! {
3449            "Type" => "Font",
3450            "Subtype" => Object::Name(b"Type1".to_vec()),
3451            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3452        });
3453        // Content stream only uses /F1
3454        let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
3455        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3456            dictionary! {},
3457            content_data.to_vec(),
3458        )));
3459        let resources = dictionary! {
3460            "Font" => dictionary! {
3461                "F1" => Object::Reference(bad_font_id),
3462                "F2" => Object::Reference(good_font_id),
3463            },
3464        };
3465        doc.objects.insert(
3466            page_id,
3467            Object::Dictionary(dictionary! {
3468                "Type" => "Page",
3469                "Parent" => Object::Reference(pages_id),
3470                "Resources" => resources,
3471                "Contents" => Object::Reference(content_id),
3472            }),
3473        );
3474        doc.objects.insert(
3475            pages_id,
3476            Object::Dictionary(dictionary! {
3477                "Type" => "Pages",
3478                "Kids" => vec![Object::Reference(page_id)],
3479                "Count" => Object::Integer(1),
3480            }),
3481        );
3482
3483        let analysis = analyze_page_content(&doc, page_id);
3484        assert!(
3485            analysis.has_identity_h_no_tounicode,
3486            "P1: page using only undecodable Identity-H should be flagged, even though \
3487             Resources also contains unused decodable Type1"
3488        );
3489        // Verify the old resource-based check would have been WRONG (the bug we're fixing)
3490        assert!(
3491            !page_has_identity_h_no_tounicode(&doc, page_id),
3492            "sanity: old resource-based check incorrectly sees unused decodable font"
3493        );
3494    }
3495
3496    #[test]
3497    fn test_p1_used_decodable_font_still_unflagged() {
3498        // Counterpart: both fonts ARE used in content → decodable font saves the page.
3499        use lopdf::dictionary;
3500        let mut doc = Document::with_version("1.4");
3501        let pages_id = doc.new_object_id();
3502        let page_id = doc.new_object_id();
3503
3504        let bad_font_id = doc.add_object(dictionary! {
3505            "Type" => "Font",
3506            "Subtype" => Object::Name(b"Type0".to_vec()),
3507            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3508            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3509        });
3510        let good_font_id = doc.add_object(dictionary! {
3511            "Type" => "Font",
3512            "Subtype" => Object::Name(b"Type1".to_vec()),
3513            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3514        });
3515        // Content stream uses BOTH /F1 and /F2
3516        let content_data = b"BT /F1 12 Tf <0102> Tj /F2 10 Tf (Hello world) Tj ET";
3517        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3518            dictionary! {},
3519            content_data.to_vec(),
3520        )));
3521        let resources = dictionary! {
3522            "Font" => dictionary! {
3523                "F1" => Object::Reference(bad_font_id),
3524                "F2" => Object::Reference(good_font_id),
3525            },
3526        };
3527        doc.objects.insert(
3528            page_id,
3529            Object::Dictionary(dictionary! {
3530                "Type" => "Page",
3531                "Parent" => Object::Reference(pages_id),
3532                "Resources" => resources,
3533                "Contents" => Object::Reference(content_id),
3534            }),
3535        );
3536        doc.objects.insert(
3537            pages_id,
3538            Object::Dictionary(dictionary! {
3539                "Type" => "Pages",
3540                "Kids" => vec![Object::Reference(page_id)],
3541                "Count" => Object::Integer(1),
3542            }),
3543        );
3544
3545        let analysis = analyze_page_content(&doc, page_id);
3546        assert!(
3547            !analysis.has_identity_h_no_tounicode,
3548            "page using both undecodable and decodable fonts should NOT be flagged"
3549        );
3550    }
3551
3552    // ---------- masthead-over-scan tests: template image + sparse chrome ----------
3553
3554    /// Builds a page whose only image is a full-page scan inside a Form
3555    /// XObject, plus `masthead_ops` native text-show ops of diverse,
3556    /// decodable chrome (newspaper masthead / date line style).
3557    fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
3558        use lopdf::dictionary;
3559        let mut doc = Document::with_version("1.4");
3560        let pages_id = doc.new_object_id();
3561        let page_id = doc.new_object_id();
3562
3563        let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3564            dictionary! {
3565                "Type" => "XObject",
3566                "Subtype" => Object::Name(b"Image".to_vec()),
3567                "Width" => Object::Integer(1500),
3568                "Height" => Object::Integer(2383),
3569            },
3570            Vec::new(),
3571        )));
3572        let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3573            dictionary! {
3574                "Type" => "XObject",
3575                "Subtype" => Object::Name(b"Form".to_vec()),
3576                "Resources" => dictionary! {
3577                    "XObject" => dictionary! {
3578                        "Im0" => Object::Reference(image_id),
3579                    },
3580                },
3581            },
3582            b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
3583        )));
3584        let font_id = doc.add_object(dictionary! {
3585            "Type" => "Font",
3586            "Subtype" => Object::Name(b"Type1".to_vec()),
3587            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3588        });
3589
3590        let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
3591        for line in masthead_lines {
3592            content.extend_from_slice(format!("({line}) Tj ").as_bytes());
3593        }
3594        content.extend_from_slice(b"ET");
3595        let content_id =
3596            doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
3597
3598        doc.objects.insert(
3599            page_id,
3600            Object::Dictionary(dictionary! {
3601                "Type" => "Page",
3602                "Parent" => Object::Reference(pages_id),
3603                "MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
3604                "Resources" => dictionary! {
3605                    "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3606                    "XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
3607                },
3608                "Contents" => Object::Reference(content_id),
3609            }),
3610        );
3611        doc.objects.insert(
3612            pages_id,
3613            Object::Dictionary(dictionary! {
3614                "Type" => "Pages",
3615                "Kids" => vec![Object::Reference(page_id)],
3616                "Count" => Object::Integer(1),
3617            }),
3618        );
3619        (doc, page_id)
3620    }
3621
3622    #[test]
3623    fn test_masthead_over_form_wrapped_scan_needs_ocr() {
3624        // A full-page scan wrapped in a Form XObject with ~4 ops of real,
3625        // diverse masthead text. `alphanum_low` can't flag it (the chrome is
3626        // genuine text), so the sparse-text floor must: without OCR the page
3627        // body is silently lost while classification calls the page scanned.
3628        let (doc, page_id) = masthead_scan_page(&[
3629            "18",
3630            "FINANCIAL EXPRESS",
3631            "WWW.FINANCIALEXPRESS.COM",
3632            "FRIDAY, DECEMBER 13, 2024",
3633        ]);
3634        let analysis = analyze_page_content(&doc, page_id);
3635        assert!(
3636            analysis.has_template_image,
3637            "sanity: full-page image inside the form must be found"
3638        );
3639        assert!(
3640            analysis.unique_alphanum_chars >= 10,
3641            "sanity: masthead text is diverse, alphanum_low cannot fire"
3642        );
3643        let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
3644        assert!(
3645            needs_ocr,
3646            "template image + text below the pages_with_text floor is a scan"
3647        );
3648    }
3649
3650    #[test]
3651    fn test_text_page_over_background_image_stays_native() {
3652        // Counterpart: a real text page over a full-page background image
3653        // (letterhead/watermark) has enough text ops to clear the
3654        // `pages_with_text` floor and must NOT be routed to OCR.
3655        let lines: Vec<String> = (0..12)
3656            .map(|i| format!("Paragraph line {i} with ordinary body text"))
3657            .collect();
3658        let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
3659        let (doc, page_id) = masthead_scan_page(&refs);
3660        let analysis = analyze_page_content(&doc, page_id);
3661        assert!(
3662            analysis.has_template_image,
3663            "sanity: background image found"
3664        );
3665        assert!(
3666            analysis.text_operator_count >= 10,
3667            "sanity: body text clears the floor"
3668        );
3669        let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
3670        assert!(
3671            !needs_ocr,
3672            "a text page with a background image must stay native"
3673        );
3674    }
3675
3676    // ---------- P2 tests: Form XObject font traversal ----------
3677
3678    #[test]
3679    fn test_p2_decodable_font_in_xobject_unflagged() {
3680        // P2 scenario: page-level Resources has only undecodable Identity-H (/F1),
3681        // but a Form XObject's Resources has a decodable Type1 font (/F2).
3682        // Content stream uses /F1 at page level, and the XObject uses /F2.
3683        // The page should NOT be flagged because text IS decodable (in XObject).
3684        use lopdf::dictionary;
3685        let mut doc = Document::with_version("1.4");
3686        let pages_id = doc.new_object_id();
3687        let page_id = doc.new_object_id();
3688
3689        // F1: undecodable Identity-H at page level
3690        let bad_font_id = doc.add_object(dictionary! {
3691            "Type" => "Font",
3692            "Subtype" => Object::Name(b"Type0".to_vec()),
3693            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3694            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3695        });
3696        // F2: decodable Type1 in XObject
3697        let good_font_id = doc.add_object(dictionary! {
3698            "Type" => "Font",
3699            "Subtype" => Object::Name(b"Type1".to_vec()),
3700            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3701        });
3702
3703        // Form XObject: uses /F2 for decodable text
3704        let xobj_content = b"BT /F2 10 Tf (Hello from XObject) Tj ET";
3705        let xobj_stream = lopdf::Stream::new(
3706            dictionary! {
3707                "Type" => "XObject",
3708                "Subtype" => Object::Name(b"Form".to_vec()),
3709                "Resources" => dictionary! {
3710                    "Font" => dictionary! {
3711                        "F2" => Object::Reference(good_font_id),
3712                    },
3713                },
3714            },
3715            xobj_content.to_vec(),
3716        );
3717        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3718
3719        // Page content: uses /F1 and invokes the XObject
3720        let content_data = b"BT /F1 12 Tf <0102> Tj ET /XF1 Do";
3721        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3722            dictionary! {},
3723            content_data.to_vec(),
3724        )));
3725        let resources = dictionary! {
3726            "Font" => dictionary! {
3727                "F1" => Object::Reference(bad_font_id),
3728            },
3729            "XObject" => dictionary! {
3730                "XF1" => Object::Reference(xobj_id),
3731            },
3732        };
3733        doc.objects.insert(
3734            page_id,
3735            Object::Dictionary(dictionary! {
3736                "Type" => "Page",
3737                "Parent" => Object::Reference(pages_id),
3738                "Resources" => resources,
3739                "Contents" => Object::Reference(content_id),
3740            }),
3741        );
3742        doc.objects.insert(
3743            pages_id,
3744            Object::Dictionary(dictionary! {
3745                "Type" => "Pages",
3746                "Kids" => vec![Object::Reference(page_id)],
3747                "Count" => Object::Integer(1),
3748            }),
3749        );
3750
3751        let analysis = analyze_page_content(&doc, page_id);
3752        assert!(
3753            !analysis.has_identity_h_no_tounicode,
3754            "P2: page with decodable font in Form XObject should NOT be flagged — \
3755             the XObject has decodable text"
3756        );
3757        assert!(
3758            analysis.has_decodable_text_fonts,
3759            "P2: should detect decodable fonts from Form XObject Resources"
3760        );
3761    }
3762
3763    #[test]
3764    fn test_p2_undecodable_font_only_in_xobject_flagged() {
3765        // P2 negative test: page-level Resources has decodable Type1 (/F1),
3766        // but only the Form XObject uses text (with undecodable Identity-H /F2).
3767        // Content stream uses ONLY /F2 (via XObject). Should flag.
3768        use lopdf::dictionary;
3769        let mut doc = Document::with_version("1.4");
3770        let pages_id = doc.new_object_id();
3771        let page_id = doc.new_object_id();
3772
3773        // F1: decodable Type1 at page level (NOT used by content)
3774        let good_font_id = doc.add_object(dictionary! {
3775            "Type" => "Font",
3776            "Subtype" => Object::Name(b"Type1".to_vec()),
3777            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3778        });
3779        // F2: undecodable Identity-H in XObject
3780        let bad_font_id = doc.add_object(dictionary! {
3781            "Type" => "Font",
3782            "Subtype" => Object::Name(b"Type0".to_vec()),
3783            "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3784            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3785        });
3786
3787        // Form XObject: uses /F2 (undecodable)
3788        let xobj_content = b"BT /F2 10 Tf <0102030405> Tj ET";
3789        let xobj_stream = lopdf::Stream::new(
3790            dictionary! {
3791                "Type" => "XObject",
3792                "Subtype" => Object::Name(b"Form".to_vec()),
3793                "Resources" => dictionary! {
3794                    "Font" => dictionary! {
3795                        "F2" => Object::Reference(bad_font_id),
3796                    },
3797                },
3798            },
3799            xobj_content.to_vec(),
3800        );
3801        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3802
3803        // Page content: only invokes XObject (no direct Tf at page level)
3804        let content_data = b"/XF1 Do";
3805        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3806            dictionary! {},
3807            content_data.to_vec(),
3808        )));
3809        let resources = dictionary! {
3810            "Font" => dictionary! {
3811                "F1" => Object::Reference(good_font_id),
3812            },
3813            "XObject" => dictionary! {
3814                "XF1" => Object::Reference(xobj_id),
3815            },
3816        };
3817        doc.objects.insert(
3818            page_id,
3819            Object::Dictionary(dictionary! {
3820                "Type" => "Page",
3821                "Parent" => Object::Reference(pages_id),
3822                "Resources" => resources,
3823                "Contents" => Object::Reference(content_id),
3824            }),
3825        );
3826        doc.objects.insert(
3827            pages_id,
3828            Object::Dictionary(dictionary! {
3829                "Type" => "Pages",
3830                "Kids" => vec![Object::Reference(page_id)],
3831                "Count" => Object::Integer(1),
3832            }),
3833        );
3834
3835        let analysis = analyze_page_content(&doc, page_id);
3836        assert!(
3837            analysis.has_identity_h_no_tounicode,
3838            "P2 negative: only used font is undecodable (in XObject) — must flag, \
3839             even though page-level Resources has an unused decodable Type1"
3840        );
3841    }
3842
3843    #[test]
3844    fn test_p2_decodable_fonts_detected_from_xobject() {
3845        // P2: has_decodable_text_fonts should be true when the only decodable font
3846        // is inside a Form XObject's Resources (not at page level).
3847        use lopdf::dictionary;
3848        let mut doc = Document::with_version("1.4");
3849        let pages_id = doc.new_object_id();
3850        let page_id = doc.new_object_id();
3851
3852        // F1: decodable Type1, only in XObject
3853        let good_font_id = doc.add_object(dictionary! {
3854            "Type" => "Font",
3855            "Subtype" => Object::Name(b"Type1".to_vec()),
3856            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3857        });
3858
3859        let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
3860        let xobj_stream = lopdf::Stream::new(
3861            dictionary! {
3862                "Type" => "XObject",
3863                "Subtype" => Object::Name(b"Form".to_vec()),
3864                "Resources" => dictionary! {
3865                    "Font" => dictionary! {
3866                        "F1" => Object::Reference(good_font_id),
3867                    },
3868                },
3869            },
3870            xobj_content.to_vec(),
3871        );
3872        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3873
3874        let content_data = b"/XF1 Do";
3875        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3876            dictionary! {},
3877            content_data.to_vec(),
3878        )));
3879        let resources = dictionary! {
3880            "XObject" => dictionary! {
3881                "XF1" => Object::Reference(xobj_id),
3882            },
3883        };
3884        doc.objects.insert(
3885            page_id,
3886            Object::Dictionary(dictionary! {
3887                "Type" => "Page",
3888                "Parent" => Object::Reference(pages_id),
3889                "Resources" => resources,
3890                "Contents" => Object::Reference(content_id),
3891            }),
3892        );
3893        doc.objects.insert(
3894            pages_id,
3895            Object::Dictionary(dictionary! {
3896                "Type" => "Pages",
3897                "Kids" => vec![Object::Reference(page_id)],
3898                "Count" => Object::Integer(1),
3899            }),
3900        );
3901
3902        let analysis = analyze_page_content(&doc, page_id);
3903        assert!(
3904            analysis.has_decodable_text_fonts,
3905            "P2: decodable font from Form XObject should be detected"
3906        );
3907        assert!(
3908            !analysis.has_identity_h_no_tounicode,
3909            "P2: no Identity-H font used, should not flag"
3910        );
3911    }
3912
3913    // ---------- P1 regression: font name collisions across resource scopes ----------
3914
3915    #[test]
3916    fn test_p1_name_collision_xobject_decodable_page_undecodable() {
3917        // P1 bug scenario: Page Resources has /F1 -> undecodable Identity-H.
3918        // Form XObject Resources has /F1 -> decodable Type1. DIFFERENT font, same name.
3919        // Only the XObject's content uses /F1.
3920        //
3921        // Without fix: global name-keyed font_map has page's undecodable /F1;
3922        //   XObject's /F1 is skipped (contains_key). Lookup resolves to the WRONG font
3923        //   -> page wrongly flagged.
3924        // With fix (ObjectId-based): XObject's Tf resolves /F1 against XObject's own
3925        //   Resources, yielding the decodable Type1's ObjectId -> correctly NOT flagged.
3926        use lopdf::dictionary;
3927        let mut doc = Document::with_version("1.4");
3928        let pages_id = doc.new_object_id();
3929        let page_id = doc.new_object_id();
3930
3931        // Page-level /F1: undecodable Identity-H (no ToUnicode, no fallback)
3932        let bad_font_id = doc.add_object(dictionary! {
3933            "Type" => "Font",
3934            "Subtype" => Object::Name(b"Type0".to_vec()),
3935            "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
3936            "Encoding" => Object::Name(b"Identity-H".to_vec()),
3937        });
3938
3939        // XObject-level /F1: decodable Type1 — DIFFERENT underlying font, same /F1 name
3940        let good_font_id = doc.add_object(dictionary! {
3941            "Type" => "Font",
3942            "Subtype" => Object::Name(b"Type1".to_vec()),
3943            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3944        });
3945
3946        // Form XObject: its own Resources define /F1 -> good_font_id
3947        let xobj_content = b"BT /F1 10 Tf (Hello from XObject) Tj ET";
3948        let xobj_stream = lopdf::Stream::new(
3949            dictionary! {
3950                "Type" => "XObject",
3951                "Subtype" => Object::Name(b"Form".to_vec()),
3952                "Resources" => dictionary! {
3953                    "Font" => dictionary! {
3954                        "F1" => Object::Reference(good_font_id),
3955                    },
3956                },
3957            },
3958            xobj_content.to_vec(),
3959        );
3960        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3961
3962        // Page content: only invokes the XObject, no direct text
3963        let content_data = b"/XF1 Do";
3964        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3965            dictionary! {},
3966            content_data.to_vec(),
3967        )));
3968        let resources = dictionary! {
3969            "Font" => dictionary! {
3970                "F1" => Object::Reference(bad_font_id),
3971            },
3972            "XObject" => dictionary! {
3973                "XF1" => Object::Reference(xobj_id),
3974            },
3975        };
3976        doc.objects.insert(
3977            page_id,
3978            Object::Dictionary(dictionary! {
3979                "Type" => "Page",
3980                "Parent" => Object::Reference(pages_id),
3981                "Resources" => resources,
3982                "Contents" => Object::Reference(content_id),
3983            }),
3984        );
3985        doc.objects.insert(
3986            pages_id,
3987            Object::Dictionary(dictionary! {
3988                "Type" => "Pages",
3989                "Kids" => vec![Object::Reference(page_id)],
3990                "Count" => Object::Integer(1),
3991            }),
3992        );
3993
3994        let analysis = analyze_page_content(&doc, page_id);
3995        assert!(
3996            !analysis.has_identity_h_no_tounicode,
3997            "P1 name collision: XObject uses /F1 which resolves to decodable Type1 \
3998             in XObject scope — should NOT flag even though page's /F1 is undecodable"
3999        );
4000        assert!(
4001            analysis.has_decodable_text_fonts,
4002            "P1 name collision: XObject's /F1 is decodable Type1"
4003        );
4004    }
4005
4006    #[test]
4007    fn test_p1_name_collision_xobject_undecodable_page_decodable() {
4008        // Inverse P1 scenario: Page Resources has /F1 -> decodable Type1.
4009        // Form XObject Resources has /F1 -> undecodable Identity-H.
4010        // Only the XObject's content uses /F1.
4011        //
4012        // Without fix: global font_map has page's decodable /F1; XObject's /F1
4013        //   skipped. Lookup resolves to page's decodable font -> wrongly unflagged.
4014        // With fix: XObject's Tf resolves /F1 against XObject Resources, gets the
4015        //   undecodable Identity-H ObjectId -> correctly flagged.
4016        use lopdf::dictionary;
4017        let mut doc = Document::with_version("1.4");
4018        let pages_id = doc.new_object_id();
4019        let page_id = doc.new_object_id();
4020
4021        // Page-level /F1: decodable Type1
4022        let good_font_id = doc.add_object(dictionary! {
4023            "Type" => "Font",
4024            "Subtype" => Object::Name(b"Type1".to_vec()),
4025            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4026        });
4027
4028        // XObject-level /F1: undecodable Identity-H — DIFFERENT font, same name
4029        let bad_font_id = doc.add_object(dictionary! {
4030            "Type" => "Font",
4031            "Subtype" => Object::Name(b"Type0".to_vec()),
4032            "BaseFont" => Object::Name(b"XYZDEF+BadCIDFont".to_vec()),
4033            "Encoding" => Object::Name(b"Identity-H".to_vec()),
4034        });
4035
4036        // Form XObject: its own Resources define /F1 -> bad_font_id
4037        let xobj_content = b"BT /F1 10 Tf <0102030405> Tj ET";
4038        let xobj_stream = lopdf::Stream::new(
4039            dictionary! {
4040                "Type" => "XObject",
4041                "Subtype" => Object::Name(b"Form".to_vec()),
4042                "Resources" => dictionary! {
4043                    "Font" => dictionary! {
4044                        "F1" => Object::Reference(bad_font_id),
4045                    },
4046                },
4047            },
4048            xobj_content.to_vec(),
4049        );
4050        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4051
4052        // Page content: only invokes the XObject
4053        let content_data = b"/XF1 Do";
4054        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4055            dictionary! {},
4056            content_data.to_vec(),
4057        )));
4058        let resources = dictionary! {
4059            "Font" => dictionary! {
4060                "F1" => Object::Reference(good_font_id),
4061            },
4062            "XObject" => dictionary! {
4063                "XF1" => Object::Reference(xobj_id),
4064            },
4065        };
4066        doc.objects.insert(
4067            page_id,
4068            Object::Dictionary(dictionary! {
4069                "Type" => "Page",
4070                "Parent" => Object::Reference(pages_id),
4071                "Resources" => resources,
4072                "Contents" => Object::Reference(content_id),
4073            }),
4074        );
4075        doc.objects.insert(
4076            pages_id,
4077            Object::Dictionary(dictionary! {
4078                "Type" => "Pages",
4079                "Kids" => vec![Object::Reference(page_id)],
4080                "Count" => Object::Integer(1),
4081            }),
4082        );
4083
4084        let analysis = analyze_page_content(&doc, page_id);
4085        assert!(
4086            analysis.has_identity_h_no_tounicode,
4087            "P1 inverse: XObject uses /F1 which resolves to undecodable Identity-H \
4088             in XObject scope — MUST flag even though page's /F1 is decodable Type1"
4089        );
4090    }
4091
4092    // ---------- P2 regression: indirect Form XObject Resources ----------
4093
4094    #[test]
4095    fn test_p2_indirect_xobject_resources() {
4096        // P2 bug: Form XObject's /Resources stored as an indirect reference (X 0 R)
4097        // instead of an inline dictionary. The old code used as_dict() which returns
4098        // None for indirect refs, causing the entire Resources branch to be skipped.
4099        //
4100        // With fix: we also handle Object::Reference by resolving it.
4101        use lopdf::dictionary;
4102        let mut doc = Document::with_version("1.4");
4103        let pages_id = doc.new_object_id();
4104        let page_id = doc.new_object_id();
4105
4106        // Font inside the XObject — decodable Type1
4107        let font_id = doc.add_object(dictionary! {
4108            "Type" => "Font",
4109            "Subtype" => Object::Name(b"Type1".to_vec()),
4110            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4111        });
4112
4113        // Store the XObject's Resources as a separate indirect object
4114        let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4115            "Font" => dictionary! {
4116                "F1" => Object::Reference(font_id),
4117            },
4118        }));
4119
4120        // Form XObject: /Resources is an indirect reference (the bug trigger)
4121        let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
4122        let xobj_stream = lopdf::Stream::new(
4123            dictionary! {
4124                "Type" => "XObject",
4125                "Subtype" => Object::Name(b"Form".to_vec()),
4126                "Resources" => Object::Reference(xobj_resources_id),
4127            },
4128            xobj_content.to_vec(),
4129        );
4130        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4131
4132        let content_data = b"/XF1 Do";
4133        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4134            dictionary! {},
4135            content_data.to_vec(),
4136        )));
4137        let resources = dictionary! {
4138            "XObject" => dictionary! {
4139                "XF1" => Object::Reference(xobj_id),
4140            },
4141        };
4142        doc.objects.insert(
4143            page_id,
4144            Object::Dictionary(dictionary! {
4145                "Type" => "Page",
4146                "Parent" => Object::Reference(pages_id),
4147                "Resources" => resources,
4148                "Contents" => Object::Reference(content_id),
4149            }),
4150        );
4151        doc.objects.insert(
4152            pages_id,
4153            Object::Dictionary(dictionary! {
4154                "Type" => "Pages",
4155                "Kids" => vec![Object::Reference(page_id)],
4156                "Count" => Object::Integer(1),
4157            }),
4158        );
4159
4160        let analysis = analyze_page_content(&doc, page_id);
4161        assert!(
4162            analysis.has_decodable_text_fonts,
4163            "P2 indirect: decodable font behind indirect /Resources must be discovered"
4164        );
4165        assert_eq!(
4166            analysis.text_operator_count, 1,
4167            "P2 indirect: text ops from XObject content should be counted"
4168        );
4169    }
4170
4171    // ---------- P1 + P2 combined: indirect Resources with name collisions ----------
4172
4173    #[test]
4174    fn test_p1_p2_combined_indirect_resources_with_name_collision() {
4175        // Combined scenario: Page has /F1 -> undecodable Identity-H.
4176        // Form XObject has /F1 -> decodable Type1 stored via INDIRECT /Resources.
4177        // XObject content uses /F1 which should resolve to the decodable one.
4178        //
4179        // This tests both bugs simultaneously:
4180        // P1: name collision (/F1 means different fonts in different scopes)
4181        // P2: XObject Resources is an indirect reference
4182        use lopdf::dictionary;
4183        let mut doc = Document::with_version("1.4");
4184        let pages_id = doc.new_object_id();
4185        let page_id = doc.new_object_id();
4186
4187        // Page-level /F1: undecodable
4188        let bad_font_id = doc.add_object(dictionary! {
4189            "Type" => "Font",
4190            "Subtype" => Object::Name(b"Type0".to_vec()),
4191            "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4192            "Encoding" => Object::Name(b"Identity-H".to_vec()),
4193        });
4194
4195        // XObject-level /F1: decodable Type1 — different underlying font
4196        let good_font_id = doc.add_object(dictionary! {
4197            "Type" => "Font",
4198            "Subtype" => Object::Name(b"Type1".to_vec()),
4199            "BaseFont" => Object::Name(b"TimesNewRoman".to_vec()),
4200        });
4201
4202        // XObject Resources as an indirect reference (P2)
4203        let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4204            "Font" => dictionary! {
4205                "F1" => Object::Reference(good_font_id),
4206            },
4207        }));
4208
4209        let xobj_content = b"BT /F1 12 Tf (Decodable text in XObject) Tj ET";
4210        let xobj_stream = lopdf::Stream::new(
4211            dictionary! {
4212                "Type" => "XObject",
4213                "Subtype" => Object::Name(b"Form".to_vec()),
4214                "Resources" => Object::Reference(xobj_resources_id),
4215            },
4216            xobj_content.to_vec(),
4217        );
4218        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4219
4220        let content_data = b"/XF1 Do";
4221        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4222            dictionary! {},
4223            content_data.to_vec(),
4224        )));
4225        let resources = dictionary! {
4226            "Font" => dictionary! {
4227                "F1" => Object::Reference(bad_font_id),
4228            },
4229            "XObject" => dictionary! {
4230                "XF1" => Object::Reference(xobj_id),
4231            },
4232        };
4233        doc.objects.insert(
4234            page_id,
4235            Object::Dictionary(dictionary! {
4236                "Type" => "Page",
4237                "Parent" => Object::Reference(pages_id),
4238                "Resources" => resources,
4239                "Contents" => Object::Reference(content_id),
4240            }),
4241        );
4242        doc.objects.insert(
4243            pages_id,
4244            Object::Dictionary(dictionary! {
4245                "Type" => "Pages",
4246                "Kids" => vec![Object::Reference(page_id)],
4247                "Count" => Object::Integer(1),
4248            }),
4249        );
4250
4251        let analysis = analyze_page_content(&doc, page_id);
4252        assert!(
4253            !analysis.has_identity_h_no_tounicode,
4254            "P1+P2 combined: XObject /F1 resolves to decodable Type1 via indirect \
4255             Resources — should NOT flag despite page /F1 being undecodable"
4256        );
4257        assert!(
4258            analysis.has_decodable_text_fonts,
4259            "P1+P2 combined: should detect decodable font from indirect XObject Resources"
4260        );
4261    }
4262
4263    // ---------- P3 tests: resource inheritance shadowing ----------
4264
4265    #[test]
4266    fn test_p3_page_overrides_parent_font_undecodable_shadows_decodable() {
4267        // Page tree: /Pages root has /Resources with /F1 → decodable Type1.
4268        // Page itself has /Resources with /F1 → undecodable Identity-H.
4269        // Content uses /F1. The page's /F1 shadows the parent's /F1.
4270        // Expectation: MUST be flagged (only the undecodable font is "used").
4271        use lopdf::dictionary;
4272        let mut doc = Document::with_version("1.4");
4273        let pages_id = doc.new_object_id();
4274        let page_id = doc.new_object_id();
4275
4276        // Parent's /F1: decodable Type1 (SHADOWED — should NOT be in used set)
4277        let parent_font_id = doc.add_object(dictionary! {
4278            "Type" => "Font",
4279            "Subtype" => Object::Name(b"Type1".to_vec()),
4280            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4281        });
4282        let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4283            "Font" => dictionary! {
4284                "F1" => Object::Reference(parent_font_id),
4285            },
4286        }));
4287
4288        // Page's /F1: undecodable Identity-H (no ToUnicode, no fallback)
4289        let page_font_id = doc.add_object(dictionary! {
4290            "Type" => "Font",
4291            "Subtype" => Object::Name(b"Type0".to_vec()),
4292            "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4293            "Encoding" => Object::Name(b"Identity-H".to_vec()),
4294        });
4295
4296        let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
4297        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4298            dictionary! {},
4299            content_data.to_vec(),
4300        )));
4301
4302        doc.objects.insert(
4303            page_id,
4304            Object::Dictionary(dictionary! {
4305                "Type" => "Page",
4306                "Parent" => Object::Reference(pages_id),
4307                "Resources" => dictionary! {
4308                    "Font" => dictionary! {
4309                        "F1" => Object::Reference(page_font_id),
4310                    },
4311                },
4312                "Contents" => Object::Reference(content_id),
4313            }),
4314        );
4315        doc.objects.insert(
4316            pages_id,
4317            Object::Dictionary(dictionary! {
4318                "Type" => "Pages",
4319                "Kids" => vec![Object::Reference(page_id)],
4320                "Count" => Object::Integer(1),
4321                "Resources" => Object::Reference(parent_resources_id),
4322            }),
4323        );
4324
4325        let analysis = analyze_page_content(&doc, page_id);
4326        assert!(
4327            analysis.has_identity_h_no_tounicode,
4328            "P3: page /F1 (undecodable) shadows parent /F1 (decodable) — \
4329             must be flagged for OCR"
4330        );
4331    }
4332
4333    #[test]
4334    fn test_p3_page_overrides_parent_font_decodable_shadows_undecodable() {
4335        // Inverse: page /F1 → decodable Type1, parent /F1 → undecodable Identity-H.
4336        // Content uses /F1. The page's decodable font shadows the parent's bad one.
4337        // Expectation: MUST NOT be flagged.
4338        use lopdf::dictionary;
4339        let mut doc = Document::with_version("1.4");
4340        let pages_id = doc.new_object_id();
4341        let page_id = doc.new_object_id();
4342
4343        // Parent's /F1: undecodable Identity-H (SHADOWED — should NOT be in used set)
4344        let parent_font_id = doc.add_object(dictionary! {
4345            "Type" => "Font",
4346            "Subtype" => Object::Name(b"Type0".to_vec()),
4347            "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4348            "Encoding" => Object::Name(b"Identity-H".to_vec()),
4349        });
4350        let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4351            "Font" => dictionary! {
4352                "F1" => Object::Reference(parent_font_id),
4353            },
4354        }));
4355
4356        // Page's /F1: decodable Type1
4357        let page_font_id = doc.add_object(dictionary! {
4358            "Type" => "Font",
4359            "Subtype" => Object::Name(b"Type1".to_vec()),
4360            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4361        });
4362
4363        let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
4364        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4365            dictionary! {},
4366            content_data.to_vec(),
4367        )));
4368
4369        doc.objects.insert(
4370            page_id,
4371            Object::Dictionary(dictionary! {
4372                "Type" => "Page",
4373                "Parent" => Object::Reference(pages_id),
4374                "Resources" => dictionary! {
4375                    "Font" => dictionary! {
4376                        "F1" => Object::Reference(page_font_id),
4377                    },
4378                },
4379                "Contents" => Object::Reference(content_id),
4380            }),
4381        );
4382        doc.objects.insert(
4383            pages_id,
4384            Object::Dictionary(dictionary! {
4385                "Type" => "Pages",
4386                "Kids" => vec![Object::Reference(page_id)],
4387                "Count" => Object::Integer(1),
4388                "Resources" => Object::Reference(parent_resources_id),
4389            }),
4390        );
4391
4392        let analysis = analyze_page_content(&doc, page_id);
4393        assert!(
4394            !analysis.has_identity_h_no_tounicode,
4395            "P3: page /F1 (decodable) shadows parent /F1 (undecodable) — \
4396             must NOT be flagged for OCR"
4397        );
4398        assert!(
4399            analysis.has_decodable_text_fonts,
4400            "P3: page's decodable font should be detected as used"
4401        );
4402    }
4403
4404    #[test]
4405    fn test_p3_inheritance_without_override_uses_parent_font() {
4406        // Page has NO /F1 in its own /Resources. Parent has /F1 → decodable.
4407        // Content uses /F1. Should inherit the parent's font.
4408        // Expectation: MUST NOT be flagged.
4409        use lopdf::dictionary;
4410        let mut doc = Document::with_version("1.4");
4411        let pages_id = doc.new_object_id();
4412        let page_id = doc.new_object_id();
4413
4414        // Parent's /F1: decodable Type1
4415        let parent_font_id = doc.add_object(dictionary! {
4416            "Type" => "Font",
4417            "Subtype" => Object::Name(b"Type1".to_vec()),
4418            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4419        });
4420        let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4421            "Font" => dictionary! {
4422                "F1" => Object::Reference(parent_font_id),
4423            },
4424        }));
4425
4426        let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
4427        let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4428            dictionary! {},
4429            content_data.to_vec(),
4430        )));
4431
4432        // Page has NO own /Resources — inherits everything from parent
4433        doc.objects.insert(
4434            page_id,
4435            Object::Dictionary(dictionary! {
4436                "Type" => "Page",
4437                "Parent" => Object::Reference(pages_id),
4438                "Contents" => Object::Reference(content_id),
4439            }),
4440        );
4441        doc.objects.insert(
4442            pages_id,
4443            Object::Dictionary(dictionary! {
4444                "Type" => "Pages",
4445                "Kids" => vec![Object::Reference(page_id)],
4446                "Count" => Object::Integer(1),
4447                "Resources" => Object::Reference(parent_resources_id),
4448            }),
4449        );
4450
4451        let analysis = analyze_page_content(&doc, page_id);
4452        assert!(
4453            !analysis.has_identity_h_no_tounicode,
4454            "P3: page inherits parent's decodable /F1 — must NOT be flagged"
4455        );
4456        assert!(
4457            analysis.has_decodable_text_fonts,
4458            "P3: inherited decodable font should be detected as used"
4459        );
4460    }
4461
4462    /// Three lines that together show forty distinct letters, digits and
4463    /// spaces.
4464    const CID_TEXT_LINES: [&str; 3] = [
4465        "The quick brown fox jumps over the lazy dog",
4466        "Pack my box with five dozen liquor jugs 0123456789",
4467        "Sphinx of black quartz judge my vow",
4468    ];
4469
4470    /// Where a page's CID text is shown and how its font is written.
4471    #[derive(Clone, Copy, PartialEq)]
4472    enum CidTextLayout {
4473        /// In the page content, the font an indirect object with a
4474        /// referenced ToUnicode stream.
4475        Page,
4476        /// In the page content, the font dictionary and its ToUnicode
4477        /// stream written inline in the resources.
4478        InlineFont,
4479        /// In a Form XObject with its own font resources.
4480        Form,
4481        /// In a Form XObject without resources, using the page's font.
4482        FormInheritingFonts,
4483        /// In the page content after a `q`/`Q` that selected another font
4484        /// inside the saved state.
4485        AfterRestoredFont,
4486        /// In a Form XObject the page's resources name but its content
4487        /// never invokes.
4488        UninvokedForm,
4489        /// In a Form XObject without resources or a `Tf` of its own, shown
4490        /// in the font the page selected before invoking it.
4491        FormInheritingFontState,
4492        /// In a Form XObject with its own font resources, invoked before a
4493        /// second, larger form of paths.
4494        FormThenLargerForm,
4495        /// In a Form XObject with its own font resources, invoked after a
4496        /// form whose content breaks off inside a string.
4497        MalformedFormThenForm,
4498    }
4499
4500    /// A page of `paths` filled triangles and `lines` of text shown as
4501    /// two-byte codes through a Type0 font: an embedded TrueType subset
4502    /// under Identity-H whose glyph `i` is the `i`th distinct character of
4503    /// the lines, with a ToUnicode CMap saying so — or, when `broken_cmap`,
4504    /// mapping every code to the same letter. `layout` says where the text
4505    /// is shown and how the font is written.
4506    fn cid_text_over_vector_art(
4507        lines: &[&str],
4508        paths: usize,
4509        broken_cmap: bool,
4510        layout: CidTextLayout,
4511    ) -> (Document, ObjectId) {
4512        use lopdf::{dictionary, Stream};
4513
4514        let mut alphabet: Vec<char> = lines.iter().flat_map(|line| line.chars()).collect();
4515        alphabet.sort_unstable();
4516        alphabet.dedup();
4517        let code_of = |c: char| alphabet.iter().position(|&a| a == c).unwrap() as u16 + 1;
4518
4519        let mut cmap = String::from(
4520            "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
4521             /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
4522             /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
4523             1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
4524        );
4525        cmap.push_str(&format!("{} beginbfchar\n", alphabet.len()));
4526        for &c in &alphabet {
4527            let unicode = if broken_cmap { 'x' as u32 } else { c as u32 };
4528            cmap.push_str(&format!("<{:04X}> <{:04X}>\n", code_of(c), unicode));
4529        }
4530        cmap.push_str(
4531            "endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n",
4532        );
4533
4534        let glyphs: Vec<(bool, u16)> = alphabet.iter().map(|_| (true, 600)).collect();
4535        let codes: Vec<u8> = alphabet.iter().map(|&c| c as u8).collect();
4536        let font_file =
4537            crate::extractor::fonts::blank_glyph_tests::synthetic_truetype(&glyphs, &codes);
4538
4539        let mut doc = Document::with_version("1.5");
4540        let font_file_id = doc.add_object(Object::Stream(Stream::new(
4541            dictionary! { "Length1" => font_file.len() as i64 },
4542            font_file,
4543        )));
4544        let descriptor_id = doc.add_object(dictionary! {
4545            "Type" => "FontDescriptor",
4546            "FontName" => "AAAAAA+Subset",
4547            "Flags" => 4,
4548            "FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
4549            "ItalicAngle" => 0,
4550            "Ascent" => 700,
4551            "Descent" => 0,
4552            "CapHeight" => 700,
4553            "StemV" => 80,
4554            "FontFile2" => Object::Reference(font_file_id),
4555        });
4556        let cid_font_id = doc.add_object(dictionary! {
4557            "Type" => "Font",
4558            "Subtype" => "CIDFontType2",
4559            "BaseFont" => "AAAAAA+Subset",
4560            "CIDSystemInfo" => dictionary! {
4561                "Registry" => Object::string_literal("Adobe"),
4562                "Ordering" => Object::string_literal("Identity"),
4563                "Supplement" => 0,
4564            },
4565            "FontDescriptor" => Object::Reference(descriptor_id),
4566            "DW" => 600,
4567            "CIDToGIDMap" => "Identity",
4568        });
4569        let cmap_stream = Stream::new(dictionary! {}, cmap.into_bytes());
4570        let font = |to_unicode: Object| {
4571            dictionary! {
4572                "Type" => "Font",
4573                "Subtype" => "Type0",
4574                "BaseFont" => "AAAAAA+Subset",
4575                "Encoding" => "Identity-H",
4576                "DescendantFonts" => vec![Object::Reference(cid_font_id)],
4577                "ToUnicode" => to_unicode,
4578            }
4579        };
4580        let font_entry = if layout == CidTextLayout::InlineFont {
4581            Object::Dictionary(font(Object::Stream(cmap_stream)))
4582        } else {
4583            let cmap_id = doc.add_object(Object::Stream(cmap_stream));
4584            Object::Reference(doc.add_object(font(Object::Reference(cmap_id))))
4585        };
4586        let helvetica_id = doc.add_object(dictionary! {
4587            "Type" => "Font",
4588            "Subtype" => "Type1",
4589            "BaseFont" => "Helvetica",
4590        });
4591
4592        let mut art = String::new();
4593        for i in 0..paths {
4594            let (x, y) = (50 + (i % 40) * 12, 100 + (i / 40) * 20);
4595            art.push_str(&format!(
4596                "{x} {y} m {} {} l {} {y} l h f\n",
4597                x + 5,
4598                y + 8,
4599                x + 10
4600            ));
4601        }
4602        let mut text = String::new();
4603        for (index, line) in lines.iter().enumerate() {
4604            let hex: String = line
4605                .chars()
4606                .map(|c| format!("{:04X}", code_of(c)))
4607                .collect();
4608            text.push_str(&format!(
4609                "BT /F1 12 Tf 72 {} Td <{hex}> Tj ET\n",
4610                700 - 20 * index
4611            ));
4612        }
4613        let fonts = dictionary! {
4614            "F1" => font_entry,
4615            "F2" => Object::Reference(helvetica_id),
4616        };
4617        let (page_content, page_resources) = match layout {
4618            CidTextLayout::Page | CidTextLayout::InlineFont => {
4619                (format!("{art}{text}"), dictionary! { "Font" => fonts })
4620            }
4621            CidTextLayout::AfterRestoredFont => (
4622                format!(
4623                    "{art}BT /F1 12 Tf ET q BT /F2 12 Tf 72 40 Td (x) Tj ET Q\n{}",
4624                    text.replace("/F1 12 Tf ", "")
4625                ),
4626                dictionary! { "Font" => fonts },
4627            ),
4628            CidTextLayout::Form
4629            | CidTextLayout::FormInheritingFonts
4630            | CidTextLayout::UninvokedForm
4631            | CidTextLayout::FormInheritingFontState
4632            | CidTextLayout::FormThenLargerForm
4633            | CidTextLayout::MalformedFormThenForm => {
4634                let form_dict = || {
4635                    dictionary! {
4636                        "Type" => "XObject",
4637                        "Subtype" => "Form",
4638                        "BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4639                    }
4640                };
4641                let mut text_form_dict = form_dict();
4642                if matches!(
4643                    layout,
4644                    CidTextLayout::Form
4645                        | CidTextLayout::FormThenLargerForm
4646                        | CidTextLayout::MalformedFormThenForm
4647                ) {
4648                    text_form_dict.set("Resources", dictionary! { "Font" => fonts.clone() });
4649                }
4650                let form_text = if layout == CidTextLayout::FormInheritingFontState {
4651                    text.replace("/F1 12 Tf ", "")
4652                } else {
4653                    text
4654                };
4655                let form_id = doc.add_object(Object::Stream(Stream::new(
4656                    text_form_dict,
4657                    form_text.into_bytes(),
4658                )));
4659                let mut xobjects = dictionary! { "Fm1" => Object::Reference(form_id) };
4660                let invoke = match layout {
4661                    CidTextLayout::UninvokedForm => String::new(),
4662                    CidTextLayout::FormInheritingFontState => {
4663                        "BT /F1 12 Tf ET q /Fm1 Do Q\n".to_string()
4664                    }
4665                    CidTextLayout::FormThenLargerForm => {
4666                        // A second form of paths, invoked after the text.
4667                        let larger = doc.add_object(Object::Stream(Stream::new(
4668                            form_dict(),
4669                            art.clone().into_bytes(),
4670                        )));
4671                        xobjects.set("Fm2", Object::Reference(larger));
4672                        "q /Fm1 Do Q q /Fm2 Do Q\n".to_string()
4673                    }
4674                    CidTextLayout::MalformedFormThenForm => {
4675                        // A form whose content breaks off inside a string,
4676                        // invoked before the text.
4677                        let malformed = doc.add_object(Object::Stream(Stream::new(
4678                            form_dict(),
4679                            b"BT /F2 12 Tf 72 40 Td (broken".to_vec(),
4680                        )));
4681                        xobjects.set("Fm0", Object::Reference(malformed));
4682                        "q /Fm0 Do Q q /Fm1 Do Q\n".to_string()
4683                    }
4684                    _ => "q /Fm1 Do Q\n".to_string(),
4685                };
4686                (
4687                    format!("{art}{invoke}"),
4688                    dictionary! {
4689                        "Font" => fonts,
4690                        "XObject" => xobjects,
4691                    },
4692                )
4693            }
4694        };
4695        let content_id = doc.add_object(Object::Stream(Stream::new(
4696            dictionary! {},
4697            page_content.into_bytes(),
4698        )));
4699        let pages_id = doc.new_object_id();
4700        let page_id = doc.add_object(dictionary! {
4701            "Type" => "Page",
4702            "Parent" => Object::Reference(pages_id),
4703            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4704            "Resources" => page_resources,
4705            "Contents" => Object::Reference(content_id),
4706        });
4707        doc.objects.insert(
4708            pages_id,
4709            Object::Dictionary(dictionary! {
4710                "Type" => "Pages",
4711                "Kids" => vec![Object::Reference(page_id)],
4712                "Count" => Object::Integer(1),
4713            }),
4714        );
4715        (doc, page_id)
4716    }
4717
4718    /// Text shown through a CID-keyed font with a ToUnicode CMap counts by
4719    /// its decoded characters: three lines of prose next to two thousand
4720    /// path operators (a drawing of about seventeen operators per
4721    /// character) are text, although their two-byte codes hold no ASCII
4722    /// letter or digit.
4723    #[test]
4724    fn cid_text_with_a_working_tounicode_is_not_vector_text() {
4725        for layout in [
4726            CidTextLayout::Page,
4727            CidTextLayout::InlineFont,
4728            CidTextLayout::Form,
4729            CidTextLayout::FormInheritingFonts,
4730            CidTextLayout::AfterRestoredFont,
4731            CidTextLayout::FormInheritingFontState,
4732            CidTextLayout::FormThenLargerForm,
4733            CidTextLayout::MalformedFormThenForm,
4734        ] {
4735            let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
4736            let analysis = analyze_page_content(&doc, page_id);
4737            assert!(analysis.path_op_count >= 1000, "{}", analysis.path_op_count);
4738            assert!(
4739                (analysis.unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS,
4740                "the byte count is blind to the codes: {}",
4741                analysis.unique_alphanum_chars
4742            );
4743            assert!(!analysis.has_vector_text, "layout {}", layout as u8);
4744            assert_eq!(page_ocr_signals(&doc, page_id), PageOcrSignals::default());
4745        }
4746    }
4747
4748    /// A caption's worth of decoded text does not make a page of paths a
4749    /// text page; neither does a CMap that maps every code alike, nor a
4750    /// title and address line, diverse as they are, over forty thousand
4751    /// path operators of outlined body text, nor text in a form the page
4752    /// never invokes.
4753    #[test]
4754    fn little_or_undecodable_cid_text_over_vector_art_stays_vector_text() {
4755        let (doc, page_id) = cid_text_over_vector_art(&["Fig 3"], 400, false, CidTextLayout::Page);
4756        assert!(analyze_page_content(&doc, page_id).has_vector_text);
4757        let (doc, page_id) =
4758            cid_text_over_vector_art(&CID_TEXT_LINES, 400, true, CidTextLayout::Page);
4759        assert!(analyze_page_content(&doc, page_id).has_vector_text);
4760        let (doc, page_id) =
4761            cid_text_over_vector_art(&CID_TEXT_LINES, 8_000, false, CidTextLayout::Page);
4762        let analysis = analyze_page_content(&doc, page_id);
4763        assert!(
4764            analysis.path_op_count >= 40_000,
4765            "{}",
4766            analysis.path_op_count
4767        );
4768        assert!(analysis.has_vector_text);
4769        // Text in a form the page never invokes is not shown.
4770        let (doc, page_id) =
4771            cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::UninvokedForm);
4772        assert!(analyze_page_content(&doc, page_id).has_vector_text);
4773        assert!(!decoded_text_counts(&doc, page_id).through_cid_cmap);
4774    }
4775
4776    /// A simple font's ToUnicode CMap declared two bytes wide over one-byte
4777    /// entries decodes one byte per code, as extraction reads it; a
4778    /// composite font keeps the CMap's two-byte codes.
4779    #[test]
4780    fn a_simple_fonts_cmap_declared_two_bytes_wide_decodes_one_byte_per_code() {
4781        use lopdf::dictionary;
4782
4783        let cmap = b"1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
4784            3 beginbfchar\n<0A> <006E>\n<82> <0063>\n<0020> <0020>\nendbfchar\n";
4785        let mut doc = Document::with_version("1.5");
4786        let stream = doc.add_object(lopdf::Stream::new(dictionary! {}, cmap.to_vec()));
4787        let simple = dictionary! {
4788            "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "ToUnicode" => stream,
4789        };
4790        let composite = dictionary! {
4791            "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "Composite",
4792            "Encoding" => "Identity-H", "ToUnicode" => stream,
4793        };
4794        let decode = |font: &lopdf::Dictionary| match font_decoder(&doc, font) {
4795            Some(FontDecoder::CMap { cmap, .. }) => cmap.decode_cids(&[0x0A, 0x82]),
4796            _ => panic!("a CMap decoder"),
4797        };
4798        assert_eq!(decode(&simple), "nc");
4799        assert_ne!(decode(&composite), "nc");
4800        // A font without a subtype keeps the CMap's width, as extraction
4801        // keeps it.
4802        let unknown = dictionary! {
4803            "Type" => "Font", "BaseFont" => "NoSubtype", "ToUnicode" => stream,
4804        };
4805        assert_ne!(decode(&unknown), "nc");
4806    }
4807
4808    /// The decoded counts cover the whole text: the three lines show
4809    /// thirty-nine distinct letters and digits, one hundred and six in all,
4810    /// through the CID font's CMap. They are the page's text next to a
4811    /// drawing of up to a hundred path operators per character, and a
4812    /// header next to a larger one.
4813    #[test]
4814    fn decoded_text_counts_cover_the_whole_text() {
4815        for layout in [
4816            CidTextLayout::Page,
4817            CidTextLayout::InlineFont,
4818            CidTextLayout::FormInheritingFonts,
4819            CidTextLayout::AfterRestoredFont,
4820            CidTextLayout::FormInheritingFontState,
4821            CidTextLayout::FormThenLargerForm,
4822            CidTextLayout::MalformedFormThenForm,
4823        ] {
4824            let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
4825            let counts = decoded_text_counts(&doc, page_id);
4826            assert_eq!(
4827                (counts.distinct_alphanumerics, counts.through_cid_cmap),
4828                (39, true),
4829                "layout {}",
4830                layout as u8
4831            );
4832            // The restored-font layout paints one more glyph in the
4833            // simple font before the CID text.
4834            let extra = usize::from(layout == CidTextLayout::AfterRestoredFont);
4835            assert_eq!(counts.alphanumerics, 106 + extra);
4836            let at_the_bar = counts.alphanumerics as u32 * VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER;
4837            assert!(counts.is_page_text(at_the_bar));
4838            assert!(!counts.is_page_text(at_the_bar + 1));
4839        }
4840        let sparse = DecodedTextCounts {
4841            distinct_alphanumerics: 29,
4842            alphanumerics: 1_000,
4843            through_cid_cmap: true,
4844        };
4845        assert!(!sparse.is_page_text(1_000));
4846    }
4847
4848    /// The page and the forms it invokes are read together within the
4849    /// page's budgets: with the operations, or the bytes, spent on the
4850    /// page's own content, the form's text goes uncounted; with room for
4851    /// the form, it is counted in full; a page over the byte budget by
4852    /// itself counts nothing.
4853    #[test]
4854    fn decoded_text_counts_stay_within_the_page_budgets() {
4855        let (doc, page_id) =
4856            cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Form);
4857        let page_content = doc.get_page_content(page_id);
4858        let page_operations = lopdf::content::Content::decode(&page_content)
4859            .unwrap()
4860            .operations
4861            .len();
4862        let form = doc
4863            .objects
4864            .values()
4865            .find_map(|object| match object {
4866                Object::Stream(stream)
4867                    if stream.dict.get(b"Subtype").ok()
4868                        == Some(&Object::Name(b"Form".to_vec())) =>
4869                {
4870                    Some(stream)
4871                }
4872                _ => None,
4873            })
4874            .unwrap();
4875        let form_operations = lopdf::content::Content::decode(&form.content)
4876            .unwrap()
4877            .operations
4878            .len();
4879        let full = DecodedTextCounts {
4880            distinct_alphanumerics: 39,
4881            alphanumerics: 106,
4882            through_cid_cmap: true,
4883        };
4884        let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
4885        let operations = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
4886
4887        assert_eq!(
4888            decoded_text_counts_within(&doc, page_id, bytes, page_operations),
4889            DecodedTextCounts::default()
4890        );
4891        assert_eq!(
4892            decoded_text_counts_within(&doc, page_id, bytes, page_operations + form_operations),
4893            full
4894        );
4895        assert_eq!(
4896            decoded_text_counts_within(&doc, page_id, page_content.len(), operations),
4897            DecodedTextCounts::default()
4898        );
4899        assert_eq!(
4900            decoded_text_counts_within(
4901                &doc,
4902                page_id,
4903                page_content.len() + form.content.len(),
4904                operations
4905            ),
4906            full
4907        );
4908        assert_eq!(
4909            decoded_text_counts_within(&doc, page_id, page_content.len() / 2, operations),
4910            DecodedTextCounts::default()
4911        );
4912        assert_eq!(decoded_text_counts(&doc, page_id), full);
4913    }
4914
4915    /// The Form XObject streams of `doc`, in no particular order.
4916    fn form_streams(doc: &Document) -> Vec<&lopdf::Stream> {
4917        doc.objects
4918            .values()
4919            .filter_map(|object| match object {
4920                Object::Stream(stream)
4921                    if stream.dict.get(b"Subtype").ok()
4922                        == Some(&Object::Name(b"Form".to_vec())) =>
4923                {
4924                    Some(stream)
4925                }
4926                _ => None,
4927            })
4928            .collect()
4929    }
4930
4931    /// The forms a page invokes are read in the order invoked, so the
4932    /// budgets go to the text drawn first: with room for the page and the
4933    /// text form only, a larger form invoked after it goes unread and the
4934    /// text is counted in full. A form whose content breaks off is read up
4935    /// to the fault, and the forms invoked after it are still read.
4936    #[test]
4937    fn forms_are_read_in_invocation_order_within_the_budgets() {
4938        let full = DecodedTextCounts {
4939            distinct_alphanumerics: 39,
4940            alphanumerics: 106,
4941            through_cid_cmap: true,
4942        };
4943        let operations = |content: &[u8]| {
4944            lopdf::content::Content::decode(content)
4945                .unwrap()
4946                .operations
4947                .len()
4948        };
4949        let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
4950        let ops = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
4951
4952        let (doc, page_id) = cid_text_over_vector_art(
4953            &CID_TEXT_LINES,
4954            400,
4955            false,
4956            CidTextLayout::FormThenLargerForm,
4957        );
4958        let page_content = doc.get_page_content(page_id);
4959        let text_form = form_streams(&doc)
4960            .into_iter()
4961            .find(|stream| stream.content.windows(3).any(|w| w == &b" Tj"[..]))
4962            .unwrap();
4963        let just_enough_operations = operations(&page_content) + operations(&text_form.content);
4964        let just_enough_bytes = page_content.len() + text_form.content.len();
4965        assert_eq!(
4966            decoded_text_counts_within(&doc, page_id, bytes, just_enough_operations),
4967            full
4968        );
4969        assert_eq!(
4970            decoded_text_counts_within(&doc, page_id, just_enough_bytes, ops),
4971            full
4972        );
4973        assert_eq!(decoded_text_counts(&doc, page_id), full);
4974
4975        let (doc, page_id) = cid_text_over_vector_art(
4976            &CID_TEXT_LINES,
4977            400,
4978            false,
4979            CidTextLayout::MalformedFormThenForm,
4980        );
4981        assert_eq!(decoded_text_counts(&doc, page_id), full);
4982        assert!(!analyze_page_content(&doc, page_id).has_vector_text);
4983    }
4984
4985    /// A ToUnicode stream is read within the loader's per-stream bound: at
4986    /// the bound the CMap is read and the text through it counted; one byte
4987    /// over it the stream is not read, the font goes undecoded and the page
4988    /// stays vector text.
4989    #[test]
4990    fn a_tounicode_cmap_over_the_stream_bound_is_not_read() {
4991        fn pad_to(doc: &mut Document, id: ObjectId, total: usize) {
4992            let Some(Object::Stream(stream)) = doc.objects.get_mut(&id) else {
4993                unreachable!()
4994            };
4995            stream.content.resize(total, b' ');
4996        }
4997        let (mut doc, page_id) =
4998            cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Page);
4999        let cmap_id = doc
5000            .objects
5001            .iter()
5002            .find_map(|(id, object)| match object {
5003                Object::Stream(stream)
5004                    if stream.content.windows(9).any(|w| w == &b"begincmap"[..]) =>
5005                {
5006                    Some(*id)
5007                }
5008                _ => None,
5009            })
5010            .unwrap();
5011        let bound = crate::MAX_STREAM_DECOMPRESSED_BYTES;
5012        pad_to(&mut doc, cmap_id, bound);
5013        assert!(!analyze_page_content(&doc, page_id).has_vector_text);
5014        pad_to(&mut doc, cmap_id, bound + 1);
5015        assert!(analyze_page_content(&doc, page_id).has_vector_text);
5016        assert_eq!(
5017            decoded_text_counts(&doc, page_id),
5018            DecodedTextCounts::default()
5019        );
5020    }
5021}