Files
pdf-inspector/src/detector.rs
T

3537 lines
133 KiB
Rust

//! Smart PDF type detection without full document load
//!
//! This module detects whether a PDF is text-based, scanned, or image-based
//! by sampling content streams for text operators (Tj/TJ) without loading
//! all objects.
use crate::PdfError;
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
use std::path::Path;
/// PDF type classification
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdfType {
/// PDF has extractable text (Tj/TJ operators found)
TextBased,
/// PDF appears to be scanned (images only, no text operators)
Scanned,
/// PDF contains mostly images with minimal/no text
ImageBased,
/// PDF has mix of text and image-heavy pages
Mixed,
}
/// Strategy for which pages to scan during detection
#[derive(Debug, Clone)]
pub enum ScanStrategy {
/// Scan all pages, stop on first non-text page (current default).
/// Best for pipelines that route TextBased PDFs to fast extraction.
EarlyExit,
/// Scan all pages, no early exit.
/// Best when you need accurate Mixed vs Scanned classification.
Full,
/// Sample up to N evenly distributed pages (first, last, middle).
/// Best for very large PDFs where speed matters more than precision.
Sample(u32),
/// Only scan these specific 1-indexed page numbers.
/// Best when the caller knows which pages to check.
Pages(Vec<u32>),
}
/// Result of PDF type detection
#[derive(Debug)]
pub struct PdfTypeResult {
/// Detected PDF type
pub pdf_type: PdfType,
/// Number of pages in the document
pub page_count: u32,
/// Number of pages sampled for detection
pub pages_sampled: u32,
/// Number of pages with text operators found
pub pages_with_text: u32,
/// Confidence score (0.0 - 1.0)
pub confidence: f32,
/// Title from metadata (if available)
pub title: Option<String>,
/// Whether OCR is recommended for better extraction
/// True when images provide essential context (e.g., template-based PDFs)
pub ocr_recommended: bool,
/// 1-indexed page numbers that need OCR (image-only or insufficient text).
/// Empty for TextBased. All pages for Scanned/ImageBased. Specific pages for Mixed.
pub pages_needing_ocr: Vec<u32>,
}
/// Configuration for PDF type detection
#[derive(Debug, Clone)]
pub struct DetectionConfig {
/// Strategy for which pages to scan
pub strategy: ScanStrategy,
/// Minimum text operator count per page to consider as text-based
pub min_text_ops_per_page: u32,
/// Threshold ratio of text pages to total pages for classification
pub text_page_ratio_threshold: f32,
}
impl Default for DetectionConfig {
fn default() -> Self {
Self {
// EarlyExit is too aggressive for PDFs with an image-only cover
// followed by text-heavy pages (e.g., annual reports).
strategy: ScanStrategy::Sample(8),
min_text_ops_per_page: 3,
text_page_ratio_threshold: 0.6,
}
}
}
/// Detect PDF type from file path
pub fn detect_pdf_type<P: AsRef<Path>>(path: P) -> Result<PdfTypeResult, PdfError> {
detect_pdf_type_with_config(path, DetectionConfig::default())
}
/// Detect PDF type from file path with custom configuration
pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
path: P,
config: DetectionConfig,
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_file(&path)?;
let (doc, page_count) = crate::load_document_from_path(&path)?;
detect_from_document(&doc, page_count, &config)
}
/// Detect PDF type from memory buffer
pub fn detect_pdf_type_mem(buffer: &[u8]) -> Result<PdfTypeResult, PdfError> {
detect_pdf_type_mem_with_config(buffer, DetectionConfig::default())
}
/// Detect PDF type from memory buffer with custom configuration
pub fn detect_pdf_type_mem_with_config(
buffer: &[u8],
config: DetectionConfig,
) -> Result<PdfTypeResult, PdfError> {
crate::validate_pdf_bytes(buffer)?;
let (doc, page_count) = crate::load_document_from_mem(buffer)?;
detect_from_document(&doc, page_count, &config)
}
/// Heuristic page-count fallback for malformed PDFs that cannot be parsed.
///
/// This scans raw bytes for page dictionaries (`/Type /Page`) while excluding
/// the page tree node (`/Type /Pages`). It is intended as a low-confidence hint
/// for diagnostics; parsed page-tree counts remain authoritative.
pub fn estimate_page_count_from_bytes(buffer: &[u8]) -> u32 {
let mut count = 0u32;
let mut pos = 0usize;
while let Some(rel_idx) = find_bytes(&buffer[pos..], b"/Type") {
let mut value_pos = pos + rel_idx + b"/Type".len();
value_pos = skip_pdf_whitespace(buffer, value_pos);
if buffer.get(value_pos) == Some(&b'/') {
let name_start = value_pos + 1;
let name_end = name_start + b"Page".len();
if name_end <= buffer.len()
&& &buffer[name_start..name_end] == b"Page"
&& buffer
.get(name_end)
.is_none_or(|b| is_pdf_name_delimiter(*b))
{
count += 1;
}
}
pos += rel_idx + b"/Type".len();
}
count
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn skip_pdf_whitespace(buffer: &[u8], mut pos: usize) -> usize {
while pos < buffer.len() && is_pdf_whitespace(buffer[pos]) {
pos += 1;
}
pos
}
fn is_pdf_whitespace(byte: u8) -> bool {
matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn is_pdf_name_delimiter(byte: u8) -> bool {
is_pdf_whitespace(byte)
|| matches!(
byte,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
/// Detection logic on a pre-loaded document.
///
/// `page_count` should come from `Document::load_metadata()`.
pub(crate) fn detect_from_document(
doc: &Document,
page_count: u32,
config: &DetectionConfig,
) -> Result<PdfTypeResult, PdfError> {
let pages = doc.get_pages();
let total_pages = pages.len() as u32;
// Select pages to scan based on strategy
let (sample_indices, allow_early_exit) = match &config.strategy {
ScanStrategy::EarlyExit => ((1..=total_pages).collect::<Vec<_>>(), true),
ScanStrategy::Full => ((1..=total_pages).collect::<Vec<_>>(), false),
ScanStrategy::Sample(max_pages) => {
let n = (*max_pages).min(total_pages);
(distribute_pages(n, total_pages), false)
}
ScanStrategy::Pages(pages) => {
let mut valid: Vec<u32> = pages
.iter()
.copied()
.filter(|&p| p >= 1 && p <= total_pages)
.collect();
valid.sort();
valid.dedup();
(valid, false)
}
};
let mut pages_with_text = 0u32;
let mut pages_with_images = 0u32;
let mut pages_with_template_images = 0u32;
let mut pages_with_vector_text = 0u32;
let mut total_text_ops = 0u32;
// Cache Phase 1 results to avoid re-analyzing sampled pages in Phase 2
let mut analysis_cache: HashMap<u32, PageAnalysis> = HashMap::new();
let mut pages_actually_sampled = 0u32;
for page_num in &sample_indices {
if let Some(&page_id) = pages.get(page_num) {
let analysis = analyze_page_content(doc, page_id);
pages_actually_sampled += 1;
log::debug!(
"page {}: text_ops={} images={} image_count={} template={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
page_num, analysis.text_operator_count, analysis.has_images,
analysis.image_count, analysis.has_template_image,
analysis.unique_text_chars, analysis.unique_alphanum_chars,
analysis.path_op_count, analysis.has_vector_text,
analysis.total_image_area, analysis.has_identity_h_no_tounicode,
analysis.has_only_type3_fonts, analysis.font_change_count,
analysis.has_decodable_text_fonts
);
let is_image_dominated = analysis.image_count > 10
&& analysis.image_count > analysis.text_operator_count * 3;
let effective_min_ops = if analysis.has_images || analysis.image_count > 0 {
config.min_text_ops_per_page.max(10)
} else {
config.min_text_ops_per_page
};
if analysis.text_operator_count >= effective_min_ops
&& !is_image_dominated
&& analysis.unique_text_chars >= 5
&& !analysis.has_vector_text
&& !analysis.has_only_type3_fonts
{
pages_with_text += 1;
}
if analysis.has_images {
pages_with_images += 1;
}
// Only count as a template-image page if it looks like a scan
// (single full-page image) rather than a text page with figures.
// Scanned-with-OCR PDFs have 1 large image per page + OCR text overlay;
// text PDFs with figures have multiple smaller images alongside real text.
//
// Exception: CID-encoded fonts with ToUnicode produce low
// unique_alphanum_chars in raw bytes but are fully decodable.
// When a page has decodable fonts and enough text ops, treat it
// as having real text regardless of raw byte diversity.
let alphanum_ok = analysis.unique_alphanum_chars < 10
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
if analysis.has_template_image
&& (analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_ok)
{
pages_with_template_images += 1;
}
if analysis.has_vector_text {
pages_with_vector_text += 1;
}
total_text_ops += analysis.text_operator_count;
analysis_cache.insert(*page_num, analysis.clone());
// Early exit: if this page is non-text (insufficient meaningful text
// but has images), this PDF won't be purely TextBased.
if allow_early_exit
&& (analysis.text_operator_count < config.min_text_ops_per_page
|| is_image_dominated
|| analysis.unique_text_chars < 5)
&& (analysis.has_images || analysis.has_template_image)
{
break;
}
}
}
let pages_sampled = pages_actually_sampled;
let text_ratio = if pages_sampled > 0 {
pages_with_text as f32 / pages_sampled as f32
} else {
0.0
};
// Check if this is a template-based PDF (images provide essential context)
// Template PDFs have text AND large background images on most pages
let has_template_images = pages_with_template_images > 0;
let template_ratio = if pages_sampled > 0 {
pages_with_template_images as f32 / pages_sampled as f32
} else {
0.0
};
// OCR is recommended when:
// 1. Template images are present (text alone is insufficient), OR
// 2. PDF is scanned/image-based
let ocr_recommended: bool;
// Classification logic
let (pdf_type, confidence) = if has_template_images && pages_with_text > 0 {
ocr_recommended = true;
// Template-based PDF: has text but images provide essential context
(PdfType::Mixed, 0.5 + (0.3 * (1.0 - template_ratio)))
} else if text_ratio >= config.text_page_ratio_threshold {
ocr_recommended = false;
(PdfType::TextBased, text_ratio)
} else if pages_with_text == 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
// No extractable text but has images or vector-outlined text
ocr_recommended = true;
if total_text_ops == 0 && pages_with_vector_text == 0 {
(PdfType::Scanned, 0.95)
} else {
(PdfType::ImageBased, 0.8)
}
} else if pages_with_text > 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
ocr_recommended = true;
(PdfType::Mixed, 0.7)
} else if total_text_ops == 0 {
ocr_recommended = true;
(PdfType::Scanned, 0.9)
} else {
ocr_recommended = false;
(PdfType::TextBased, text_ratio.max(0.5))
};
// Phase 1b: Newspaper-style layout detection.
// Dense multi-column newspapers (WSJ, NYT) have extractable text but produce
// poor output due to complex interleaved article layouts. Detect via consistently
// high text density combined with moderate font switches and a low Tf/Tj ratio.
//
// The Tf/Tj ratio distinguishes newspapers from styled legal/business documents:
// - Newspapers: ratio 0.02-0.06 (dense prose with occasional font switches)
// - Rich-styled docs (DPA, contracts): ratio 0.25-0.35 (per-character styling)
//
// Thresholds calibrated against:
// - WSJ 50-page newspaper: text_ops 1500-3800, font_changes 50-194, ratio 0.02-0.06
// - DPA/contracts: text_ops 1300-2260, font_changes 327-630, ratio 0.25-0.32
// - SEC filings: text_ops 1-1800, font_changes 1-65 (only 1-2 dense pages)
// - Normal docs: text_ops < 700, font_changes < 55
let ocr_recommended = if pdf_type == PdfType::TextBased && pages_sampled >= 3 {
let mut newspaper_pages = 0u32;
for analysis in analysis_cache.values() {
let ratio = if analysis.text_operator_count > 0 {
analysis.font_change_count as f32 / analysis.text_operator_count as f32
} else {
1.0
};
if analysis.text_operator_count >= 1500
&& analysis.font_change_count >= 50
&& ratio < 0.15
{
newspaper_pages += 1;
}
}
let newspaper_ratio = newspaper_pages as f32 / pages_sampled as f32;
if newspaper_ratio >= 0.5 {
log::debug!(
"newspaper layout detected: {}/{} pages with high text_ops + font_changes → OCR recommended",
newspaper_pages, pages_sampled
);
true
} else {
ocr_recommended
}
} else {
ocr_recommended
};
// Phase 2: Build per-page OCR list
let mut pages_needing_ocr = match pdf_type {
PdfType::TextBased => Vec::new(),
PdfType::Scanned | PdfType::ImageBased => (1..=total_pages).collect(),
PdfType::Mixed => {
let mut ocr_pages = Vec::new();
for page_num in 1..=total_pages {
let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
cached.clone()
} else if let Some(&page_id) = pages.get(&page_num) {
analyze_page_content(doc, page_id)
} else {
continue;
};
// Template images only need OCR when it looks like a scan
// (single full-page image) rather than figures alongside text.
// CID-encoded fonts with ToUnicode produce low unique_alphanum_chars
// in raw bytes but are fully decodable — don't treat as scan.
let alphanum_low = analysis.unique_alphanum_chars < 10
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| (analysis.text_operator_count < config.min_text_ops_per_page
&& analysis.has_images)
{
ocr_pages.push(page_num);
}
}
ocr_pages.sort();
ocr_pages.dedup();
ocr_pages
}
};
// Phase 3: Flag pages with undecodable fonts for OCR.
// - Identity-H/V without ToUnicode: raw CID values can't map to Unicode
// - Type3-only without ToUnicode: glyph bitmaps can't map to Unicode
for (&page_num, analysis) in &analysis_cache {
if (analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts)
&& !pages_needing_ocr.contains(&page_num)
{
pages_needing_ocr.push(page_num);
}
}
// Check uncached pages too (when not all pages were sampled).
// Use analyze_page_content to get usage-based font checks (P1 + P2 fix).
if pages_needing_ocr.len() < total_pages as usize {
for page_num in 1..=total_pages {
if analysis_cache.contains_key(&page_num) || pages_needing_ocr.contains(&page_num) {
continue;
}
if let Some(&page_id) = pages.get(&page_num) {
let analysis = analyze_page_content(doc, page_id);
if analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts {
pages_needing_ocr.push(page_num);
}
}
}
}
pages_needing_ocr.sort();
pages_needing_ocr.dedup();
// Try to get title from metadata
let title = get_document_title(doc);
Ok(PdfTypeResult {
pdf_type,
page_count,
pages_sampled,
pages_with_text,
confidence,
title,
ocr_recommended,
pages_needing_ocr,
})
}
/// Distribute `n` page indices evenly across `total` pages (1-indexed).
///
/// Always includes the first and last page, with remaining pages
/// spaced evenly in between.
fn distribute_pages(n: u32, total: u32) -> Vec<u32> {
if n == 0 {
return Vec::new();
}
if n >= total {
return (1..=total).collect();
}
let mut indices = Vec::with_capacity(n as usize);
indices.push(1);
if n > 1 {
indices.push(total);
}
let remaining = n.saturating_sub(2);
if remaining > 0 && total > 2 {
let step = (total - 2) / (remaining + 1);
for i in 1..=remaining {
let idx = 1 + (step * i);
if idx > 1 && idx < total && !indices.contains(&idx) {
indices.push(idx);
}
}
}
indices.sort();
indices.dedup();
indices
}
/// Page content analysis result
#[derive(Clone)]
struct PageAnalysis {
text_operator_count: u32,
has_images: bool,
/// Whether page has a large background/template image (>50% coverage)
has_template_image: bool,
/// Total image area in pixels (reserved for future use)
#[allow(dead_code)]
total_image_area: u64,
/// Number of Do (XObject invocation) operators in content streams
image_count: u32,
/// Number of unique non-whitespace text characters found in string operands
unique_text_chars: u32,
/// Number of unique ASCII alphanumeric bytes (letters + digits) in string operands
unique_alphanum_chars: u32,
/// Number of path construction/painting ops (m, l, c, h, f, re, etc.)
#[allow(dead_code)]
path_op_count: u32,
/// Whether the page has vector-outlined text (massive path ops, minimal text ops)
has_vector_text: bool,
/// Whether the page has Type0 fonts with Identity-H/V encoding but no ToUnicode CMap.
/// These fonts produce garbage text because CID values can't be mapped to Unicode.
has_identity_h_no_tounicode: bool,
/// Whether the page uses only Type3 fonts (no normal text fonts).
/// Type3 fonts render each glyph as a custom drawing/bitmap — without a
/// ToUnicode CMap, the character codes can't be mapped to Unicode.
has_only_type3_fonts: bool,
/// Number of Tf (set font) operators — high count indicates many font switches
font_change_count: u32,
/// Whether the page has fonts that can produce decodable text (ToUnicode,
/// standard encoding, Type1/TrueType with known encoding).
/// CID-encoded text with ToUnicode produces low unique_alphanum_chars in raw
/// bytes but is fully decodable — this flag prevents misclassifying it as a scan.
has_decodable_text_fonts: bool,
}
/// Extracted font information from a Resource dictionary entry.
/// Stores the properties needed for decodability/identity-h checks
/// without holding a reference to the document.
#[derive(Clone, Debug)]
struct FontInfo {
subtype: Option<Vec<u8>>,
encoding: Option<Vec<u8>>,
has_tounicode: bool,
/// The raw font dictionary as an owned lopdf Dictionary.
/// Needed for fallback checks (DescendantFonts → W array, embedded cmap).
dict: lopdf::Dictionary,
}
/// Collect font entries from a Resources/Font dictionary into the font map.
/// Each entry maps font ObjectId → FontInfo. Using ObjectId as the key
/// avoids name collisions: different resource dictionaries can legally define
/// `/F1` pointing to different font objects, and ObjectId uniquely identifies
/// the underlying font regardless of the name used to reference it.
///
/// Inline font dictionaries (rare — fonts are almost always indirect refs)
/// are skipped because they have no ObjectId.
fn collect_fonts_from_resource_dict(
doc: &Document,
resources: &lopdf::Dictionary,
font_map: &mut HashMap<ObjectId, FontInfo>,
) {
let font_obj = match resources.get(b"Font").ok() {
Some(obj) => obj,
None => return,
};
let font_dict = match font_obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
};
let Some(font_dict) = font_dict else {
return;
};
for (_name, value) in font_dict.iter() {
// Only indirect references have a stable ObjectId.
// Inline font dicts are extremely rare and have no ObjectId — skip them.
let font_obj_id = match value {
Object::Reference(r) => *r,
_ => continue,
};
if font_map.contains_key(&font_obj_id) {
continue;
}
let resolved = doc.get_dictionary(font_obj_id).ok();
if let Some(fd) = resolved {
let subtype = fd
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| n.to_vec());
let encoding = fd
.get(b"Encoding")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| n.to_vec());
let has_tounicode = fd.get(b"ToUnicode").is_ok();
font_map.insert(
font_obj_id,
FontInfo {
subtype,
encoding,
has_tounicode,
dict: fd.clone(),
},
);
}
}
}
/// Resolve font names (collected from a content stream) to ObjectIds using the
/// given resource dictionary. This is how we scope font name resolution correctly:
/// each content stream (page-level or Form XObject) resolves `/FontName` against
/// its own Resources/Font dictionary, yielding the correct underlying font object.
fn resolve_font_names_to_ids(
doc: &Document,
resources: &lopdf::Dictionary,
font_names: &HashSet<Vec<u8>>,
used_font_ids: &mut HashSet<ObjectId>,
) {
let font_obj = match resources.get(b"Font").ok() {
Some(obj) => obj,
None => return,
};
let font_dict = match font_obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
};
let Some(font_dict) = font_dict else {
return;
};
for name in font_names {
if let Ok(Object::Reference(r)) = font_dict.get(name) {
used_font_ids.insert(*r);
}
}
}
/// Look up a single font name in a resource dictionary, returning its indirect
/// ObjectId if present.
fn lookup_font_id(
doc: &Document,
resources: &lopdf::Dictionary,
font_name: &[u8],
) -> Option<ObjectId> {
let font_obj = resources.get(b"Font").ok()?;
let font_dict = match font_obj {
Object::Dictionary(d) => Some(d),
Object::Reference(r) => doc.get_dictionary(*r).ok(),
_ => None,
}?;
if let Ok(Object::Reference(r)) = font_dict.get(font_name) {
Some(*r)
} else {
None
}
}
/// Resolve page-level font names with PDF resource inheritance shadowing.
///
/// PDF spec (ISO 32000-1, 7.7.3.4): a page inherits /Resources from its
/// parent /Pages nodes, but a definition in a more-specific scope shadows
/// the same name from an ancestor. lopdf's `get_page_resources` returns
/// ancestors in most-specific-first order (page → parent → grandparent),
/// so the first dictionary that defines a given font name wins.
fn resolve_with_shadowing(
doc: &Document,
own_resources: Option<&lopdf::Dictionary>,
ancestor_resource_ids: &[ObjectId],
names: &HashSet<Vec<u8>>,
used_font_ids: &mut HashSet<ObjectId>,
) {
'name: for name in names {
// Check page's own inline /Resources first (most specific scope)
if let Some(rd) = own_resources {
if let Some(id) = lookup_font_id(doc, rd, name) {
used_font_ids.insert(id);
continue 'name;
}
}
// Walk inherited resource dicts (most-specific to root); first hit wins
for ancestor_id in ancestor_resource_ids {
if let Ok(rd) = doc.get_dictionary(*ancestor_id) {
if let Some(id) = lookup_font_id(doc, rd, name) {
used_font_ids.insert(id);
continue 'name;
}
}
}
}
}
/// Analyze a page's content stream for text operators and images
fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
let mut text_ops = 0u32;
let mut has_images = false;
let mut image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
let mut all_unique_chars: HashSet<u8> = HashSet::new();
// Collect font ObjectIds (not names) to avoid cross-scope name collisions.
// Each content stream resolves its Tf font names against its own resource
// dictionary, producing the correct underlying font ObjectId.
let mut used_font_ids: HashSet<ObjectId> = HashSet::new();
// Build font map keyed by ObjectId: collects FontInfo for all fonts from
// page-level Resources + Form XObject Resources.
let mut font_map: HashMap<ObjectId, FontInfo> = HashMap::new();
// Get content streams for this page — these use the page's resource dict
let content_streams = doc.get_page_contents(page_id);
// We need the page's resource dict to resolve font names from page content.
// get_page_resources returns (Option<&Dictionary>, Vec<ObjectId>) for
// inline and indirect resource dicts respectively.
let page_resources = doc.get_page_resources(page_id).ok();
for content_id in content_streams {
if let Ok(Object::Stream(stream)) = doc.get_object(content_id) {
let content = match stream.decompressed_content() {
Ok(data) => data,
Err(_) => stream.content.clone(),
};
// Scan for text operators, collecting raw font names
let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
&content,
&mut all_unique_chars,
&mut page_font_names,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
// Resolve font names against the page's resource dictionaries,
// respecting PDF resource inheritance shadowing: the most-specific
// scope (page's own /Resources) wins over inherited ancestors.
if let Some((ref resource_dict, ref resource_ids)) = page_resources {
resolve_with_shadowing(
doc,
*resource_dict,
resource_ids,
&page_font_names,
&mut used_font_ids,
);
}
}
}
// Scan XObject Form contents for text operators, collect their fonts,
// and resolve font names per-XObject scope.
if let Some((resource_dict, resource_ids)) = page_resources {
let mut visited = HashSet::new();
if let Some(resources) = resource_dict {
collect_fonts_from_resource_dict(doc, resources, &mut font_map);
let (ops, imgs, paths, fonts) = scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
}
for resource_id in resource_ids {
if let Ok(resources) = doc.get_dictionary(resource_id) {
collect_fonts_from_resource_dict(doc, resources, &mut font_map);
let (ops, imgs, paths, fonts) = scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
}
}
}
// Check for XObject images and calculate coverage
let (found_images, total_image_area, has_template_image) = analyze_page_images(doc, page_id);
if found_images {
has_images = true;
}
let unique_alphanum_chars = all_unique_chars
.iter()
.filter(|b| b.is_ascii_alphanumeric())
.count() as u32;
// Vector-outlined text: massive path ops with minimal text ops.
// Each outlined glyph needs ~10-30 path commands, so a page of
// outlined text produces thousands of path ops.
//
// Also require few unique alphanum chars: real outlined-text pages have
// very few because each glyph is a path, not a Tj/TJ text op. Pages with
// real selectable text plus decorative paths (column borders, dividers)
// have many unique alphanum chars — these are NOT vector-outlined text.
let has_vector_text =
path_ops >= 1000 && path_ops > text_ops.saturating_mul(200) && unique_alphanum_chars < 30;
// Check for Identity-H/V fonts without ToUnicode — these produce garbage text.
// Only consider fonts actually USED by Tf operators in content streams (P1 fix),
// and include fonts from Form XObject Resources (P2 fix).
let has_identity_h_no_tounicode =
text_ops > 0 && used_fonts_have_identity_h_no_tounicode(&used_font_ids, &font_map, doc);
// Check for Type3-only fonts — glyph bitmaps without Unicode mapping.
// Uses the usage-based font set for accuracy.
let has_only_type3_fonts = text_ops > 0 && used_fonts_are_only_type3(&used_font_ids, &font_map);
// Check if the page has fonts that can decode text to Unicode.
// CID-encoded fonts with ToUnicode produce low unique_alphanum_chars in raw
// bytes but are fully decodable — we need this to avoid false scan detection.
// Only considers fonts actually USED via Tf operators (P1 + P2 fix).
let has_decodable_text_fonts =
text_ops > 0 && used_fonts_have_decodable_text(&used_font_ids, &font_map, doc);
PageAnalysis {
text_operator_count: text_ops,
has_images,
has_template_image,
total_image_area,
image_count,
unique_text_chars: all_unique_chars.len() as u32,
unique_alphanum_chars,
path_op_count: path_ops,
has_vector_text,
has_identity_h_no_tounicode,
has_only_type3_fonts,
font_change_count: font_changes,
has_decodable_text_fonts,
}
}
/// Check if a page has Type0 fonts with Identity-H/V encoding and no ToUnicode CMap.
/// These fonts encode text as raw CID values that can't be mapped to Unicode without
/// a ToUnicode CMap, producing garbage output for non-Latin scripts (e.g. Cyrillic).
///
/// Returns false when the page also has other decodable text fonts (Type1, TrueType,
/// or Type0 with ToUnicode/fallback). In that case the undecodable Identity-H font
/// is supplementary and the page has enough good text for extraction.
///
/// NOTE: This is a resource-based check (examines ALL fonts in Resources/Font, not just
/// those used by Tf operators). Superseded by `used_fonts_have_identity_h_no_tounicode`
/// in production code. Kept for unit tests that validate font-level classification.
#[cfg(test)]
fn page_has_identity_h_no_tounicode(doc: &Document, page_id: ObjectId) -> bool {
let fonts = match doc.get_page_fonts(page_id) {
Ok(f) => f,
Err(_) => return false,
};
let mut has_undecodable_identity_h = false;
let mut has_other_decodable_font = false;
for font_dict in fonts.values() {
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
match subtype {
Some(b"Type0") => {
let encoding = font_dict
.get(b"Encoding")
.ok()
.and_then(|o| o.as_name().ok());
let is_identity = matches!(encoding, Some(b"Identity-H") | Some(b"Identity-V"));
if !is_identity {
// Type0 with non-Identity encoding (e.g. a named CMap) — decodable
has_other_decodable_font = true;
continue;
}
if font_dict.get(b"ToUnicode").is_ok() {
// Has ToUnicode — decodable
has_other_decodable_font = true;
continue;
}
if identity_h_font_has_fallback(font_dict, doc) {
// Fallback decoding path works — decodable
has_other_decodable_font = true;
continue;
}
// Identity-H/V without ToUnicode and no fallback — undecodable
log::debug!(
"page has Identity-H/V font without ToUnicode: {:?}",
font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
);
has_undecodable_identity_h = true;
}
Some(b"Type3") => {
// Type3 fonts are handled separately by page_has_only_type3_fonts;
// don't count them as decodable here.
}
_ => {
// Type1, TrueType, MMType1, CIDFontType0/2 — these are generally
// decodable via standard encoding, ToUnicode, or glyph name lookup.
has_other_decodable_font = true;
}
}
}
// Only flag when there are undecodable Identity-H fonts AND no other
// decodable fonts on the page. If the page has other text fonts, the
// Identity-H font is supplementary and the page still extracts well.
has_undecodable_identity_h && !has_other_decodable_font
}
/// Check whether an Identity-H font without ToUnicode can still be decoded
/// via one of the extraction pipeline's fallback paths.
fn identity_h_font_has_fallback(font_dict: &lopdf::Dictionary, doc: &Document) -> bool {
let desc_fonts_obj = match font_dict.get(b"DescendantFonts").ok() {
Some(obj) => obj,
None => return false,
};
let desc_fonts = match desc_fonts_obj {
Object::Array(arr) => arr,
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(arr)) => arr,
_ => return false,
},
_ => return false,
};
if desc_fonts.is_empty() {
return false;
}
let cid_font_dict = match &desc_fonts[0] {
Object::Reference(r) => match doc.get_dictionary(*r) {
Ok(d) => d,
_ => return false,
},
Object::Dictionary(d) => d,
_ => return false,
};
// Fallback 1: W array CIDs look like Unicode codepoints → passthrough works.
// Many PDF generators (Chromium, wkhtmltopdf) use Identity-H where CID = Unicode.
if crate::tounicode::cid_values_look_like_unicode(cid_font_dict) {
return true;
}
// Fallback 2: Embedded TrueType/OpenType font has a usable cmap table.
if let Some(font_descriptor) = cid_font_dict
.get(b"FontDescriptor")
.ok()
.and_then(|o| match o {
Object::Reference(r) => doc.get_dictionary(*r).ok(),
Object::Dictionary(d) => Some(d),
_ => None,
})
{
let font_file_ref = font_descriptor
.get(b"FontFile2")
.ok()
.and_then(|o| o.as_reference().ok())
.or_else(|| {
font_descriptor
.get(b"FontFile3")
.ok()
.and_then(|o| o.as_reference().ok())
});
if let Some(ff_ref) = font_file_ref {
if embedded_font_has_cmap(doc, ff_ref) {
return true;
}
}
}
false
}
/// Quick check whether an embedded TrueType/OpenType font has a cmap table
/// that can map GIDs to Unicode codepoints.
fn embedded_font_has_cmap(doc: &Document, font_ref: lopdf::ObjectId) -> bool {
let stream = match doc.get_object(font_ref).and_then(Object::as_stream) {
Ok(s) => s,
Err(_) => return false,
};
let data = match stream.decompressed_content() {
Ok(d) => d,
Err(_) => return false,
};
let face = match ttf_parser::Face::parse(&data, 0) {
Ok(f) => f,
Err(_) => return false,
};
// Check that the font has a cmap table with at least some Unicode mappings
if let Some(cmap) = face.tables().cmap {
for subtable in cmap.subtables {
if subtable.is_unicode()
|| (subtable.platform_id == ttf_parser::PlatformId::Windows
&& subtable.encoding_id == 0)
{
let mut count = 0u32;
subtable.codepoints(|_| count += 1);
if count > 0 {
return true;
}
}
}
}
false
}
/// Returns true if every font on the page is Type3 (no normal text fonts).
/// Type3 fonts render glyphs as custom drawings/bitmaps. Without a ToUnicode
/// CMap, character codes can't be mapped to Unicode — the page needs OCR.
///
/// NOTE: Resource-based check. Superseded by `used_fonts_are_only_type3`.
/// Kept for existing unit tests.
#[cfg(test)]
fn page_has_only_type3_fonts(doc: &Document, page_id: ObjectId) -> bool {
let fonts = match doc.get_page_fonts(page_id) {
Ok(f) => f,
Err(_) => return false,
};
if fonts.is_empty() {
return false;
}
let mut has_type3 = false;
for font_dict in fonts.values() {
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
if subtype == Some(b"Type3") {
// Type3 with a ToUnicode CMap can still produce usable text
if font_dict.get(b"ToUnicode").is_ok() {
return false;
}
has_type3 = true;
} else {
// Has a non-Type3 font — page has real text fonts
return false;
}
}
if has_type3 {
log::debug!("page has only Type3 fonts without ToUnicode — text is undecodable");
}
has_type3
}
/// Check if the page has at least one font that can produce decodable Unicode text.
///
/// Returns true when any font on the page has:
/// - A /ToUnicode CMap (works for all font types including CID fonts), OR
/// - A standard /Encoding (WinAnsiEncoding, MacRomanEncoding, etc.) for Type1/TrueType, OR
/// - Is a Type1 or TrueType font (these use glyph names → Adobe Glyph List fallback)
///
/// This distinguishes pages with CID-encoded text that IS decodable (via ToUnicode)
/// from scanned pages that happen to have a few decorative text ops. CID text produces
/// low unique_alphanum_chars in raw bytes but can map to full Unicode through ToUnicode.
///
/// NOTE: Resource-based check. Superseded by `used_fonts_have_decodable_text`.
/// Kept for existing unit tests.
#[cfg(test)]
fn page_has_decodable_text_fonts(doc: &Document, page_id: ObjectId) -> bool {
let fonts = match doc.get_page_fonts(page_id) {
Ok(f) => f,
Err(_) => return false,
};
for font_dict in fonts.values() {
// Any font with ToUnicode is decodable
if font_dict.get(b"ToUnicode").is_ok() {
return true;
}
let subtype = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
match subtype {
Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
// Type1/TrueType with a named encoding or glyph names are decodable
// via the Adobe Glyph List or encoding vectors.
return true;
}
Some(b"Type0") => {
// Type0 (CID) without ToUnicode — check if it has a fallback path
if identity_h_font_has_fallback(font_dict, doc) {
return true;
}
}
_ => {}
}
}
false
}
/// Usage-based check: do the USED fonts include an undecodable Identity-H/V font
/// without any other decodable font to compensate?
///
/// Unlike `page_has_identity_h_no_tounicode`, this only considers fonts actually
/// referenced by Tf operators in content streams (P1 fix) and includes fonts from
/// Form XObject Resources (P2 fix).
fn used_fonts_have_identity_h_no_tounicode(
used_font_ids: &HashSet<ObjectId>,
font_map: &HashMap<ObjectId, FontInfo>,
doc: &Document,
) -> bool {
let mut has_undecodable_identity_h = false;
let mut has_other_decodable_font = false;
for id in used_font_ids {
let Some(info) = font_map.get(id) else {
continue;
};
match info.subtype.as_deref() {
Some(b"Type0") => {
let is_identity = matches!(
info.encoding.as_deref(),
Some(b"Identity-H") | Some(b"Identity-V")
);
if !is_identity {
has_other_decodable_font = true;
continue;
}
if info.has_tounicode {
has_other_decodable_font = true;
continue;
}
if identity_h_font_has_fallback(&info.dict, doc) {
has_other_decodable_font = true;
continue;
}
has_undecodable_identity_h = true;
}
Some(b"Type3") => {
// Handled separately by used_fonts_are_only_type3
}
_ => {
// Type1, TrueType, MMType1, etc. — generally decodable
has_other_decodable_font = true;
}
}
}
has_undecodable_identity_h && !has_other_decodable_font
}
/// Usage-based check: are ALL used fonts Type3 without ToUnicode?
///
/// Unlike `page_has_only_type3_fonts`, this only considers fonts actually referenced
/// by Tf operators (P1 fix) and includes Form XObject fonts (P2 fix).
fn used_fonts_are_only_type3(
used_font_ids: &HashSet<ObjectId>,
font_map: &HashMap<ObjectId, FontInfo>,
) -> bool {
if used_font_ids.is_empty() {
return false;
}
let mut has_type3 = false;
for id in used_font_ids {
let Some(info) = font_map.get(id) else {
continue;
};
if info.subtype.as_deref() == Some(b"Type3") {
if info.has_tounicode {
return false;
}
has_type3 = true;
} else {
return false;
}
}
has_type3
}
/// Usage-based check: do the USED fonts include at least one that can produce
/// decodable Unicode text?
///
/// Unlike `page_has_decodable_text_fonts`, this only considers fonts actually
/// referenced by Tf operators (P1 fix) and includes Form XObject fonts (P2 fix).
fn used_fonts_have_decodable_text(
used_font_ids: &HashSet<ObjectId>,
font_map: &HashMap<ObjectId, FontInfo>,
doc: &Document,
) -> bool {
for id in used_font_ids {
let Some(info) = font_map.get(id) else {
continue;
};
if info.has_tounicode {
return true;
}
match info.subtype.as_deref() {
Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
return true;
}
Some(b"Type0") => {
if identity_h_font_has_fallback(&info.dict, doc) {
return true;
}
}
_ => {}
}
}
false
}
fn scan_xobjects_in_resources(
doc: &Document,
resources: &lopdf::Dictionary,
visited: &mut HashSet<ObjectId>,
unique_chars: &mut HashSet<u8>,
used_font_ids: &mut HashSet<ObjectId>,
font_map: &mut HashMap<ObjectId, FontInfo>,
) -> (u32, u32, u32, u32) {
let mut text_ops = 0u32;
let mut image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
let xobjects = match resources.get(b"XObject").ok() {
Some(Object::Dictionary(d)) => Some(d.clone()),
Some(Object::Reference(r)) => doc.get_dictionary(*r).ok().cloned(),
_ => None,
};
if let Some(xobj_dict) = xobjects {
for (_, obj) in xobj_dict.iter() {
let Some(obj_id) = obj.as_reference().ok() else {
continue;
};
if !visited.insert(obj_id) {
continue;
}
let Ok(Object::Stream(stream)) = doc.get_object(obj_id) else {
continue;
};
let subtype = stream
.dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
match subtype {
Some(b"Form") => {
let content = stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone());
// Collect raw font names from this XObject's content stream
let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
&content,
unique_chars,
&mut xobj_font_names,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
// Resolve the Form XObject's /Resources — handle both inline
// dicts and indirect references (P2 fix: indirect refs were
// previously skipped by as_dict()).
let xobj_res_owned;
let xobj_res = match stream.dict.get(b"Resources").ok() {
Some(Object::Dictionary(d)) => Some(d),
Some(Object::Reference(r)) => {
xobj_res_owned = doc.get_dictionary(*r).ok();
xobj_res_owned
}
_ => None,
};
if let Some(res) = xobj_res {
// Resolve font names against the XObject's own resource dict
// (P1 fix: scoped resolution, not global name-based lookup)
resolve_font_names_to_ids(doc, res, &xobj_font_names, used_font_ids);
// Collect font definitions from this scope
collect_fonts_from_resource_dict(doc, res, font_map);
// Recurse into nested XObjects
let (ops2, imgs2, paths2, fonts2) = scan_xobjects_in_resources(
doc,
res,
visited,
unique_chars,
used_font_ids,
font_map,
);
text_ops += ops2;
image_count += imgs2;
path_ops += paths2;
font_changes += fonts2;
}
}
Some(b"Image") => {
image_count += 1;
}
_ => {}
}
}
}
(text_ops, image_count, path_ops, font_changes)
}
/// Fast scan of content stream bytes for text operators
///
/// This is a fast heuristic scan that looks for:
/// - "Tj" - show text string
/// - "TJ" - show text with individual glyph positioning
/// - "'" - move to next line and show text
/// - "\"" - set word/char spacing, move to next line, show text
///
/// Returns (text_op_count, image_count, path_op_count, font_change_count).
/// Unique non-whitespace text characters are collected into `unique_chars`.
fn scan_content_for_text_operators(
content: &[u8],
unique_chars: &mut HashSet<u8>,
used_font_names: &mut HashSet<Vec<u8>>,
) -> (u32, u32, u32, u32) {
let mut text_ops = 0u32;
let image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
// Helper: check if position is a word boundary (start of content or preceded by whitespace)
let is_word_start = |pos: usize| -> bool { pos == 0 || content[pos - 1].is_ascii_whitespace() };
// Helper: check if position is at end or followed by whitespace
let is_word_end =
|pos: usize| -> bool { pos + 1 >= content.len() || content[pos + 1].is_ascii_whitespace() };
// Simple state machine to find operators
let mut i = 0;
while i < content.len() {
let b = content[i];
// Look for 'T' followed by 'j', 'J', or 'f'
if b == b'T' && i + 1 < content.len() {
let next = content[i + 1];
if next == b'j' || next == b'J' {
// Verify it's an operator (followed by whitespace or newline)
if i + 2 >= content.len()
|| content[i + 2].is_ascii_whitespace()
|| content[i + 2] == b'\n'
|| content[i + 2] == b'\r'
{
text_ops += 1;
// Scan backward for text string operand to collect unique chars
collect_text_chars_before(content, i, unique_chars);
}
} else if next == b'f' {
// Tf = set font operator
// Some PDFs concatenate Tf with the next operator without
// whitespace (e.g. "25 Tf[<01>..." or "25 Tf(<text>..."),
// so also accept '[', '(', '<', '/' as valid followers.
if i + 2 >= content.len()
|| content[i + 2].is_ascii_whitespace()
|| content[i + 2] == b'\n'
|| content[i + 2] == b'\r'
|| content[i + 2] == b'['
|| content[i + 2] == b'('
|| content[i + 2] == b'<'
|| content[i + 2] == b'/'
{
font_changes += 1;
// Extract the font name operand preceding the size + Tf.
// Pattern: /FontName <size> Tf
// Scan backward past the size number and whitespace to find /Name.
if let Some(name) = extract_font_name_before_tf(content, i) {
used_font_names.insert(name);
}
}
}
}
// Note: We do NOT count 'Do' operators here because Do invokes any
// XObject — including Form XObjects that contain text. Actual image
// detection is handled by scan_xobjects_in_resources (checks Subtype)
// and analyze_page_images (measures pixel area).
// Count path construction/painting operators.
// Single-byte: m (moveto), l (lineto), c (curveto), h (closepath),
// f (fill), S (stroke), s (close+stroke), B (fill+stroke),
// F (fill, variant)
// These are the high-volume operators in vector-outlined text.
match b {
b'm' | b'l' | b'c' | b'h' | b'f' | b'S' | b's' | b'B' | b'F'
if is_word_start(i) && is_word_end(i) =>
{
path_ops += 1;
}
// Two-byte: re (rect), f* (fill even-odd)
b'r' if i + 1 < content.len()
&& content[i + 1] == b'e'
&& is_word_start(i)
&& (i + 2 >= content.len() || content[i + 2].is_ascii_whitespace()) =>
{
path_ops += 1;
}
b'f' if i + 1 < content.len()
&& content[i + 1] == b'*'
&& is_word_start(i)
&& (i + 2 >= content.len() || content[i + 2].is_ascii_whitespace()) =>
{
path_ops += 1;
}
_ => {}
}
i += 1;
}
(text_ops, image_count, path_ops, font_changes)
}
/// Extract the font name operand from content stream bytes preceding a Tf operator.
///
/// The Tf operator syntax is: `/FontName size Tf`
/// We scan backward from the position of 'T' in 'Tf' past the size number and
/// whitespace to find the `/Name` token.
///
/// Returns the font name bytes (without the leading `/`), e.g. `b"F1"` for `/F1`.
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>> {
// Scan backward past whitespace before "Tf"
let mut j = tf_pos;
while j > 0 && content[j - 1].is_ascii_whitespace() {
j -= 1;
}
// Scan backward past the size number (digits, '.', '-')
while j > 0
&& (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
{
j -= 1;
}
// Scan backward past whitespace between font name and size
while j > 0 && content[j - 1].is_ascii_whitespace() {
j -= 1;
}
// Now j should point just after the font name. Scan backward to find '/'.
let name_end = j;
while j > 0 && content[j - 1] != b'/' {
// Font names consist of regular characters (not whitespace, not delimiters)
if content[j - 1].is_ascii_whitespace() || content[j - 1] == b'(' || content[j - 1] == b')'
{
return None;
}
j -= 1;
}
if j == 0 || content[j - 1] != b'/' {
return None;
}
// j-1 is the '/', font name is content[j..name_end]
if j < name_end {
Some(content[j..name_end].to_vec())
} else {
None
}
}
/// Scan backward from a Tj/TJ operator to find the preceding string operand
/// and collect unique non-whitespace bytes from it.
///
/// Handles both literal strings `(...)` and hex strings `<...>`.
fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut HashSet<u8>) {
// Walk backward past whitespace to find the closing delimiter
let mut j = op_pos;
while j > 0 {
j -= 1;
if !content[j].is_ascii_whitespace() {
break;
}
}
if j == 0 {
return;
}
let closing = content[j];
if closing == b')' {
// Literal string: scan backward for matching '('
let mut depth = 1i32;
let mut k = j;
while k > 0 && depth > 0 {
k -= 1;
match content[k] {
b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
b'(' if k == 0 || content[k - 1] != b'\\' => depth -= 1,
_ => {}
}
}
// k now points at '('; collect bytes between (k+1..j)
if depth == 0 && k + 1 < j {
for &ch in &content[k + 1..j] {
if !ch.is_ascii_whitespace() {
unique_chars.insert(ch);
}
}
}
} else if closing == b'>' {
// Hex string: scan backward for '<'
let mut k = j;
while k > 0 {
k -= 1;
if content[k] == b'<' {
break;
}
}
if content[k] == b'<' && k + 1 < j {
// Decode hex pairs and collect unique non-whitespace bytes
let hex_slice = &content[k + 1..j];
let hex_clean: Vec<u8> = hex_slice
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
for pair in hex_clean.chunks(2) {
if pair.len() == 2 {
let high = hex_val(pair[0]);
let low = hex_val(pair[1]);
if let (Some(h), Some(l)) = (high, low) {
let byte = (h << 4) | l;
if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
unique_chars.insert(byte);
}
}
}
}
}
} else if closing == b']' {
// TJ array: scan backward for '[' and collect from all strings inside
let mut k = j;
while k > 0 {
k -= 1;
if content[k] == b'[' {
break;
}
}
if content[k] == b'[' {
// Scan forward through the array collecting string contents
let mut m = k + 1;
while m < j {
if content[m] == b'(' {
let start = m + 1;
let mut depth = 1i32;
m += 1;
while m < j && depth > 0 {
match content[m] {
b')' if content[m - 1] != b'\\' => depth -= 1,
b'(' if content[m - 1] != b'\\' => depth += 1,
_ => {}
}
if depth > 0 {
m += 1;
}
}
// collect bytes from start..m
for &ch in &content[start..m] {
if !ch.is_ascii_whitespace() {
unique_chars.insert(ch);
}
}
} else if content[m] == b'<' {
let hex_start = m + 1;
m += 1;
while m < j && content[m] != b'>' {
m += 1;
}
let hex_slice = &content[hex_start..m];
let hex_clean: Vec<u8> = hex_slice
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
for pair in hex_clean.chunks(2) {
if pair.len() == 2 {
let high = hex_val(pair[0]);
let low = hex_val(pair[1]);
if let (Some(h), Some(l)) = (high, low) {
let byte = (h << 4) | l;
if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
unique_chars.insert(byte);
}
}
}
}
}
m += 1;
}
}
}
}
/// Convert a hex ASCII character to its numeric value (0-15)
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// Analyze page images: returns (has_images, total_area, has_template_image)
///
/// A template image is one that covers >50% of a standard page area.
/// Standard page: 612x792 points (US Letter) = ~485,000 sq points
/// At 2x resolution that's ~1.9M pixels, so we use 250K pixels as threshold
/// (accounting for varying DPI and page sizes)
fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
// Threshold: image covering roughly half a page at 150+ DPI
// 612 * 792 / 2 * (150/72)^2 ≈ 1M pixels, but we'll be conservative
const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000; // 500K pixels
let mut has_images = false;
let mut total_area: u64 = 0;
let mut has_template_image = false;
let mut visited: HashSet<ObjectId> = HashSet::new();
if let Ok(page_dict) = doc.get_dictionary(page_id) {
let resources = match page_dict.get(b"Resources") {
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
Ok(Object::Dictionary(dict)) => Some(dict),
_ => None,
};
if let Some(resources) = resources {
collect_images_from_resources(
doc,
resources,
&mut has_images,
&mut total_area,
&mut has_template_image,
TEMPLATE_IMAGE_THRESHOLD,
&mut visited,
);
// Also check Pattern resources: tiling patterns can contain
// XObject images (e.g., screenshots pasted into PDFs via
// Chrome "Save as PDF").
if let Ok(pattern_obj) = resources.get(b"Pattern") {
let pattern_dict = match pattern_obj {
Object::Reference(id) => doc.get_dictionary(*id).ok(),
Object::Dictionary(dict) => Some(dict),
_ => None,
};
if let Some(pattern_dict) = pattern_dict {
for (_, value) in pattern_dict.iter() {
let pat_ref = match value.as_reference() {
Ok(r) => r,
_ => continue,
};
if !visited.insert(pat_ref) {
continue;
}
if let Ok(Object::Stream(stream)) = doc.get_object(pat_ref) {
if let Ok(pat_resources) = stream.dict.get(b"Resources") {
let pat_res_dict = match pat_resources {
Object::Reference(id) => doc.get_dictionary(*id).ok(),
Object::Dictionary(dict) => Some(dict),
_ => None,
};
if let Some(pat_res) = pat_res_dict {
collect_images_from_resources(
doc,
pat_res,
&mut has_images,
&mut total_area,
&mut has_template_image,
TEMPLATE_IMAGE_THRESHOLD,
&mut visited,
);
}
}
}
}
}
}
}
}
// Tiled scans: many small image tiles (e.g., JBIG2 strips) that together
// cover the full page. No individual tile triggers the template threshold,
// but the aggregate area clearly indicates a scanned/image-backed page.
if !has_template_image && total_area >= TEMPLATE_IMAGE_THRESHOLD * 4 {
has_template_image = true;
}
(has_images, total_area, has_template_image)
}
/// Recursively collect image dimensions from XObject resources,
/// including images nested inside Form XObjects.
fn collect_images_from_resources(
doc: &Document,
resources: &lopdf::Dictionary,
has_images: &mut bool,
total_area: &mut u64,
has_template_image: &mut bool,
threshold: u64,
visited: &mut HashSet<ObjectId>,
) {
let xobject = match resources.get(b"XObject") {
Ok(obj) => obj,
_ => return,
};
let xobject_dict = match xobject {
Object::Reference(id) => doc.get_dictionary(*id).ok(),
Object::Dictionary(dict) => Some(dict),
_ => None,
};
let Some(xobject_dict) = xobject_dict else {
return;
};
for (_, value) in xobject_dict.iter() {
let xobj_ref = match value.as_reference() {
Ok(r) => r,
_ => continue,
};
if !visited.insert(xobj_ref) {
continue;
}
let xobj = match doc.get_object(xobj_ref) {
Ok(o) => o,
_ => continue,
};
let stream = match xobj.as_stream() {
Ok(s) => s,
_ => continue,
};
let subtype = match stream.dict.get(b"Subtype") {
Ok(s) => s,
_ => continue,
};
let name = match subtype.as_name() {
Ok(n) => n,
_ => continue,
};
if name == b"Image" {
*has_images = true;
let width = stream
.dict
.get(b"Width")
.ok()
.and_then(|w| w.as_i64().ok())
.unwrap_or(0) as u64;
let height = stream
.dict
.get(b"Height")
.ok()
.and_then(|h| h.as_i64().ok())
.unwrap_or(0) as u64;
let area = width * height;
*total_area += area;
if area >= threshold {
*has_template_image = true;
}
} else if name == b"Form" {
// Recurse into Form XObject's own Resources
if let Ok(form_resources) = stream.dict.get(b"Resources") {
let form_res_dict = match form_resources {
Object::Reference(id) => doc.get_dictionary(*id).ok(),
Object::Dictionary(dict) => Some(dict),
_ => None,
};
if let Some(form_res) = form_res_dict {
collect_images_from_resources(
doc,
form_res,
has_images,
total_area,
has_template_image,
threshold,
visited,
);
}
}
}
}
}
/// Get document title from Info dictionary
fn get_document_title(doc: &Document) -> Option<String> {
let info_ref = doc.trailer.get(b"Info").ok()?.as_reference().ok()?;
let info = doc.get_dictionary(info_ref).ok()?;
let title_obj = info.get(b"Title").ok()?;
match title_obj {
Object::String(bytes, _) => {
// Handle UTF-16BE encoding (BOM: 0xFE 0xFF)
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(bytes).to_string())
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_content_operators() {
let mut uchars = HashSet::new();
// Sample PDF content stream with text operators
let content = b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET";
let (ops, imgs, _, _) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 1);
assert_eq!(imgs, 0);
// "Hello World" without space: H, e, l, o, W, r, d = 7 unique
assert!(uchars.len() >= 7);
// Content with TJ array
uchars.clear();
let content2 = b"BT /F1 12 Tf 100 700 Td [(H) 10 (ello)] TJ ET";
let (ops2, _, _, _) =
scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
assert_eq!(ops2, 1);
// H, e, l, o = 4 unique
assert!(uchars.len() >= 4);
// Content with Do (XObject invocation — not counted as image here;
// actual image detection is handled by scan_xobjects_in_resources)
uchars.clear();
let content3 = b"q 100 0 0 100 50 700 cm /Img1 Do Q";
let (ops3, imgs3, _, _) =
scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
assert_eq!(ops3, 0);
assert_eq!(imgs3, 0);
}
#[test]
fn test_image_dominated_detection() {
// Do operators are no longer counted as images by scan_content_for_text_operators.
// Image-dominated detection now relies on scan_xobjects_in_resources which
// checks XObject Subtype. Here we verify that Do operators don't inflate image_count.
let mut content = Vec::new();
for i in 0..50 {
content.extend_from_slice(format!("/Im{i} Do\n").as_bytes());
}
content.extend_from_slice(b"BT (x) Tj ET\n");
content.extend_from_slice(b"BT (x) Tj ET\n");
content.extend_from_slice(b"BT (x) Tj ET\n");
let mut uchars = HashSet::new();
let (ops, imgs, _, _) =
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 3);
assert_eq!(imgs, 0); // Do operators are not counted here
assert_eq!(uchars.len(), 1);
}
#[test]
fn test_normal_text_not_image_dominated() {
let content = b"BT /F1 12 Tf (The quick brown fox jumps over the lazy dog) Tj ET\n\
/Img1 Do\n/Img2 Do\n";
let mut uchars = HashSet::new();
let (ops, imgs, _, _) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 1);
assert_eq!(imgs, 0); // Do operators not counted here
// Many unique chars from the sentence
assert!(uchars.len() >= 5);
}
#[test]
fn test_path_heavy_detection() {
// Simulate vector-outlined text: many path ops, few text ops
let mut content = Vec::new();
// Add a couple text ops
content.extend_from_slice(b"BT (Header) Tj ET\n");
// Add 2000 path ops (simulating outlined glyphs)
for _ in 0..500 {
content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h\n");
}
content.extend_from_slice(b"f\n");
let mut uchars = HashSet::new();
let (text, imgs, paths, _) =
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
assert_eq!(text, 1);
assert_eq!(imgs, 0);
// 500 * (m + l + c + h) + 1 f = 2001
assert!(paths >= 2000, "expected >= 2000 path ops, got {paths}");
// Should trigger vector text detection: paths >= 1000 && paths > text * 200
let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
assert!(has_vector_text);
}
#[test]
fn test_normal_paths_not_vector_text() {
// Normal page: text with some decorative paths (charts, borders)
let mut content = Vec::new();
// 20 text ops
for _ in 0..20 {
content.extend_from_slice(b"BT (Some text content here) Tj ET\n");
}
// 50 path ops (a chart or border)
for _ in 0..10 {
content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h f\n");
}
let mut uchars = HashSet::new();
let (text, _, paths, _) =
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
assert_eq!(text, 20);
assert!(paths >= 40, "expected >= 40 path ops, got {paths}");
// Should NOT trigger: paths < 1000
let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
assert!(!has_vector_text);
}
#[test]
fn test_epever_vector_text_detection() {
// Integration test: EPEVER PDF should be Mixed with page 2 needing OCR
let path = std::path::Path::new("./tests/fixtures/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf");
let path = if path.exists() {
path.to_path_buf()
} else {
let alt = std::path::PathBuf::from(
"../pdf-evals/pdfs/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf",
);
if !alt.exists() {
// PDF not available, skip test
return;
}
alt
};
let config = DetectionConfig {
strategy: ScanStrategy::Full,
..DetectionConfig::default()
};
let result = detect_pdf_type_with_config(&path, config).unwrap();
assert_eq!(
result.pdf_type,
PdfType::Mixed,
"EPEVER should be Mixed (page 2 has vector-outlined text)"
);
assert!(
result.pages_needing_ocr.contains(&2),
"Page 2 should need OCR, got: {:?}",
result.pages_needing_ocr
);
assert!(result.ocr_recommended);
}
#[test]
fn test_page_has_identity_h_no_tounicode_positive() {
// Build a minimal PDF with a Type0 Identity-H font and no ToUnicode.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(page_has_identity_h_no_tounicode(&doc, page_id));
}
#[test]
fn test_page_has_identity_h_with_tounicode_negative() {
// Type0 Identity-H font WITH ToUnicode — should NOT flag.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
b"fake cmap".to_vec(),
)));
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
"ToUnicode" => Object::Reference(cmap_id),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(!page_has_identity_h_no_tounicode(&doc, page_id));
}
#[test]
fn test_identity_h_with_unicode_cids_not_flagged() {
// Type0 Identity-H font without ToUnicode but with W array CIDs
// that look like Unicode codepoints (e.g. from Chromium/wkhtmltopdf).
// The CID-as-Unicode passthrough can decode these — don't flag.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// CIDFont with W array containing Unicode-range CIDs (>= 0x41)
let cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
"W" => Object::Array(vec![
Object::Integer(0x41), // CID 65 = 'A'
Object::Array(vec![
Object::Integer(600), Object::Integer(600), Object::Integer(600),
]),
Object::Integer(0x61), // CID 97 = 'a'
Object::Array(vec![
Object::Integer(500), Object::Integer(500), Object::Integer(500),
]),
]),
});
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
"DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(
!page_has_identity_h_no_tounicode(&doc, page_id),
"Should NOT flag: W array CIDs look like Unicode, passthrough works"
);
}
#[test]
fn test_identity_h_with_low_gid_cids_still_flagged() {
// Type0 Identity-H font without ToUnicode and W array CIDs
// that are low GID values (subset font, no cmap). These can't
// be decoded — should still be flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// CIDFont with W array containing low GID values (< 0x41)
let cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
"W" => Object::Array(vec![
Object::Integer(3), // Low GID
Object::Array(vec![
Object::Integer(600), Object::Integer(600), Object::Integer(600),
Object::Integer(600), Object::Integer(600),
]),
Object::Integer(10), // Still low
Object::Array(vec![
Object::Integer(500), Object::Integer(500), Object::Integer(500),
]),
]),
});
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"GPBCHP+TimesNewRoman".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
"DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(
page_has_identity_h_no_tounicode(&doc, page_id),
"Should flag: low GID CIDs, no cmap, no passthrough"
);
}
#[test]
fn test_scan_content_counts_tf_operators() {
let mut uchars = HashSet::new();
let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
let (ops, _, _, fonts) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 2);
assert_eq!(fonts, 2);
}
#[test]
fn test_tf_without_trailing_whitespace() {
// Some PDFs concatenate Tf directly with the next operator's operand,
// e.g. "25 Tf[<01>..." or "25 Tf(<text>..."
let mut uchars = HashSet::new();
// Tf followed by '[' (TJ array start)
let content = b"BT /F1 25 Tf[<01>1<02>-1] TJ ET";
let (ops, _, _, fonts) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(fonts, 1, "Tf followed by '[' should be counted");
assert_eq!(ops, 1);
// Tf followed by '(' (literal string)
uchars.clear();
let content2 = b"BT /F1 12 Tf(Hello) Tj ET";
let (ops2, _, _, fonts2) =
scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
assert_eq!(fonts2, 1, "Tf followed by '(' should be counted");
assert_eq!(ops2, 1);
// Tf followed by '<' (hex string)
uchars.clear();
let content3 = b"BT /F1 12 Tf<0102> Tj ET";
let (ops3, _, _, fonts3) =
scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
assert_eq!(fonts3, 1, "Tf followed by '<' should be counted");
assert_eq!(ops3, 1);
// Tf followed by '/' (next font name)
uchars.clear();
let content4 = b"BT /F1 12 Tf/F2 10 Tf (x) Tj ET";
let (_, _, _, fonts4) =
scan_content_for_text_operators(content4, &mut uchars, &mut HashSet::new());
assert_eq!(fonts4, 2, "Tf followed by '/' should be counted");
}
#[test]
fn test_newspaper_heuristic_thresholds() {
// Newspaper page: high text ops, moderate font changes, low ratio
let text_ops = 3500u32;
let font_changes = 150u32;
let ratio = font_changes as f32 / text_ops as f32;
assert!(text_ops >= 1500);
assert!(font_changes >= 50);
assert!(ratio < 0.15); // 0.043
// Dense styled doc (DPA/contract): high text ops, very high font changes, high ratio
let text_ops = 1800u32;
let font_changes = 540u32;
let ratio = font_changes as f32 / text_ops as f32;
assert!(text_ops >= 1500);
assert!(font_changes >= 50);
assert!(ratio >= 0.15); // 0.30 — should NOT trigger newspaper heuristic
// Normal doc: low text ops — doesn't qualify at all
let text_ops = 300u32;
let font_changes = 50u32;
assert!(text_ops < 1500);
}
#[test]
fn test_looks_like_scan_requires_all_conditions() {
// The looks_like_scan heuristic requires ALL three conditions (AND):
// 1. image_count <= 1
// 2. text_operator_count < 50
// 3. unique_alphanum_chars < 10
// A text page with one figure: has text ops and alphanum chars
// Should NOT look like a scan
let image_count = 1u32;
let text_operator_count = 135u32;
let unique_alphanum_chars = 58u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"text page with one figure should not be flagged as scan"
);
// A genuine scan: single image, no real text
let image_count = 1u32;
let text_operator_count = 3u32;
let unique_alphanum_chars = 2u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
looks_like_scan,
"single image with no real text should be flagged as scan"
);
// OCR overlay page: single image but has OCR text operators and chars
// Should NOT look like a scan (OCR text is sufficient)
let image_count = 1u32;
let text_operator_count = 200u32;
let unique_alphanum_chars = 40u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"OCR overlay page should not be flagged as scan"
);
// Multiple images but low text: still not a scan (multiple figures page)
let image_count = 4u32;
let text_operator_count = 25u32;
let unique_alphanum_chars = 1u32;
let looks_like_scan =
image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
assert!(
!looks_like_scan,
"multiple images page should not match single-image scan pattern"
);
}
// ---------- Tests for has_vector_text alphanum guard ----------
#[test]
fn test_has_vector_text_real_text_with_decorations_not_flagged() {
// Newspaper-style page: high path_ops (column borders/dividers/decorations)
// BUT also lots of selectable real text → high unique_alphanum_chars.
// Should NOT trigger has_vector_text — the paths are decorations, not glyphs.
let path_ops = 8354u32;
let text_ops = 41u32;
let unique_alphanum_chars = 53u32;
let has_vector_text = path_ops >= 1000
&& path_ops > text_ops.saturating_mul(200)
&& unique_alphanum_chars < 30;
assert!(
!has_vector_text,
"page with real selectable text alongside decorative paths should not be vector_text"
);
}
#[test]
fn test_has_vector_text_outlined_glyphs_still_flagged() {
// True outlined-text page: massive path_ops, very few unique alphanum chars
// (each char is a path, not a Tj op). MUST still flag as vector_text.
let path_ops = 8000u32;
let text_ops = 5u32;
let unique_alphanum_chars = 4u32;
let has_vector_text = path_ops >= 1000
&& path_ops > text_ops.saturating_mul(200)
&& unique_alphanum_chars < 30;
assert!(
has_vector_text,
"true outlined-text page should still be flagged as vector_text"
);
}
// ---------- Tests for page_has_identity_h_no_tounicode supplementary-font handling ----------
#[test]
fn test_identity_h_with_supplementary_decodable_font_not_flagged() {
// Page has TWO fonts: an undecodable Identity-H Type0 (supplementary,
// e.g. a decorative font for headers) AND a Type1 font with ToUnicode
// (carries the body text). Should NOT flag — body text is decodable.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Undecodable Identity-H: no ToUnicode, no W array → no fallback.
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// Decodable Type1 with ToUnicode: typical body-text font.
let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
b"fake cmap".to_vec(),
)));
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
"ToUnicode" => Object::Reference(cmap_id),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
"F2" => Object::Reference(good_font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(
!page_has_identity_h_no_tounicode(&doc, page_id),
"page with supplementary undecodable Identity-H but decodable Type1 should not flag"
);
}
#[test]
fn test_identity_h_with_no_other_fonts_still_flagged() {
// Regression check: page with ONLY the undecodable Identity-H font
// (no other decodable text font) MUST still flag for OCR.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(
page_has_identity_h_no_tounicode(&doc, page_id),
"page with only undecodable Identity-H must still be flagged"
);
}
// ---------- Tests for page_has_decodable_text_fonts ----------
#[test]
fn test_page_has_decodable_text_fonts_type1() {
// Type1 font (no ToUnicode required — uses Adobe Glyph List)
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Times-Roman".to_vec()),
});
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(page_has_decodable_text_fonts(&doc, page_id));
}
#[test]
fn test_page_has_decodable_text_fonts_type0_with_tounicode() {
// Type0/Identity-H font with ToUnicode: CID-encoded text but decodable.
// This is the bank-annual-report pattern.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
b"fake cmap".to_vec(),
)));
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"BentonSans-Bold".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
"ToUnicode" => Object::Reference(cmap_id),
});
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(page_has_decodable_text_fonts(&doc, page_id));
}
#[test]
fn test_page_has_decodable_text_fonts_undecodable_only_returns_false() {
// ONLY undecodable Identity-H (no ToUnicode, no fallback).
// Should return false — no path to recover this text.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+UnknownFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let resources = dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
assert!(!page_has_decodable_text_fonts(&doc, page_id));
}
// ---------- Test for the CID-aware looks_like_scan override ----------
#[test]
fn test_looks_like_scan_overridden_by_decodable_cid_text() {
// CID-encoded text page (Type0 with ToUnicode) has:
// image_count = 1 (template image)
// text_operator_count = 36 (real Tj/TJ ops emitting CID values)
// unique_alphanum_chars = 8 (raw bytes are CIDs, not ASCII)
// has_decodable_text_fonts = true
// Old check: looks_like_scan = (image<=1 && text<50 && alphanum<10) → TRUE (incorrect).
// New check: alphanum < 10 is overridden when decodable fonts present + text_ops >= 10
// → looks_like_scan = false (correct — text IS decodable).
let image_count = 1u32;
let text_operator_count = 36u32;
let unique_alphanum_chars = 8u32;
let has_decodable_text_fonts = true;
let alphanum_low =
unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
assert!(
!looks_like_scan,
"CID-encoded decodable text page should not be flagged as scan"
);
}
#[test]
fn test_looks_like_scan_keeps_flag_when_no_decodable_fonts() {
// Same metrics as above but no decodable fonts → genuinely could be a scan.
// Override should NOT kick in — looks_like_scan remains true.
let image_count = 1u32;
let text_operator_count = 36u32;
let unique_alphanum_chars = 8u32;
let has_decodable_text_fonts = false;
let alphanum_low =
unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
assert!(
looks_like_scan,
"page with no decodable fonts should remain flagged as scan"
);
}
#[test]
fn test_looks_like_scan_keeps_flag_with_few_text_ops_even_if_decodable() {
// Truly scanned page with a small overlay (1-5 text_ops, e.g. page number).
// Has a decodable font (the page number font) but text_ops too low to
// override. MUST still flag as scan.
let image_count = 1u32;
let text_operator_count = 4u32;
let unique_alphanum_chars = 2u32;
let has_decodable_text_fonts = true;
let alphanum_low =
unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
assert!(
looks_like_scan,
"scanned page with small text overlay (page number) should still flag"
);
}
// ---------- Tests for extract_font_name_before_tf ----------
#[test]
fn test_extract_font_name_basic() {
// Standard pattern: /F1 12 Tf
let content = b"/F1 12 Tf";
let name = extract_font_name_before_tf(content, 6); // 'T' is at index 6
assert_eq!(name, Some(b"F1".to_vec()));
}
#[test]
fn test_extract_font_name_long_name() {
let content = b"/ArialMT-Bold 9.5 Tf";
let name = extract_font_name_before_tf(content, 18);
assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
}
#[test]
fn test_scan_content_collects_used_font_names() {
let mut uchars = HashSet::new();
let mut fonts = HashSet::new();
let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
scan_content_for_text_operators(content, &mut uchars, &mut fonts);
assert!(fonts.contains(&b"F1".to_vec()), "should collect F1");
assert!(fonts.contains(&b"F2".to_vec()), "should collect F2");
assert_eq!(fonts.len(), 2);
}
// ---------- P1 tests: usage-based font filtering ----------
#[test]
fn test_p1_unused_decodable_font_does_not_save_undecodable_page() {
// P1 bug scenario: page Resources has TWO fonts:
// - /F1: undecodable Identity-H (used in content stream)
// - /F2: decodable Type1 (NOT used in content stream — leftover/inherited)
//
// Old resource-based check: sees F2 decodable → wrongly unflagged.
// New usage-based check: only F1 is used → correctly flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// F1: undecodable Identity-H (no ToUnicode, no fallback)
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// F2: decodable Type1 (unused — leftover in Resources)
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// Content stream only uses /F1
let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
"F2" => Object::Reference(good_font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_identity_h_no_tounicode,
"P1: page using only undecodable Identity-H should be flagged, even though \
Resources also contains unused decodable Type1"
);
// Verify the old resource-based check would have been WRONG (the bug we're fixing)
assert!(
!page_has_identity_h_no_tounicode(&doc, page_id),
"sanity: old resource-based check incorrectly sees unused decodable font"
);
}
#[test]
fn test_p1_used_decodable_font_still_unflagged() {
// Counterpart: both fonts ARE used in content → decodable font saves the page.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// Content stream uses BOTH /F1 and /F2
let content_data = b"BT /F1 12 Tf <0102> Tj /F2 10 Tf (Hello world) Tj ET";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
"F2" => Object::Reference(good_font_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"page using both undecodable and decodable fonts should NOT be flagged"
);
}
// ---------- P2 tests: Form XObject font traversal ----------
#[test]
fn test_p2_decodable_font_in_xobject_unflagged() {
// P2 scenario: page-level Resources has only undecodable Identity-H (/F1),
// but a Form XObject's Resources has a decodable Type1 font (/F2).
// Content stream uses /F1 at page level, and the XObject uses /F2.
// The page should NOT be flagged because text IS decodable (in XObject).
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// F1: undecodable Identity-H at page level
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// F2: decodable Type1 in XObject
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// Form XObject: uses /F2 for decodable text
let xobj_content = b"BT /F2 10 Tf (Hello from XObject) Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"Font" => dictionary! {
"F2" => Object::Reference(good_font_id),
},
},
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
// Page content: uses /F1 and invokes the XObject
let content_data = b"BT /F1 12 Tf <0102> Tj ET /XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
},
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"P2: page with decodable font in Form XObject should NOT be flagged — \
the XObject has decodable text"
);
assert!(
analysis.has_decodable_text_fonts,
"P2: should detect decodable fonts from Form XObject Resources"
);
}
#[test]
fn test_p2_undecodable_font_only_in_xobject_flagged() {
// P2 negative test: page-level Resources has decodable Type1 (/F1),
// but only the Form XObject uses text (with undecodable Identity-H /F2).
// Content stream uses ONLY /F2 (via XObject). Should flag.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// F1: decodable Type1 at page level (NOT used by content)
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// F2: undecodable Identity-H in XObject
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// Form XObject: uses /F2 (undecodable)
let xobj_content = b"BT /F2 10 Tf <0102030405> Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"Font" => dictionary! {
"F2" => Object::Reference(bad_font_id),
},
},
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
// Page content: only invokes XObject (no direct Tf at page level)
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(good_font_id),
},
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_identity_h_no_tounicode,
"P2 negative: only used font is undecodable (in XObject) — must flag, \
even though page-level Resources has an unused decodable Type1"
);
}
#[test]
fn test_p2_decodable_fonts_detected_from_xobject() {
// P2: has_decodable_text_fonts should be true when the only decodable font
// is inside a Form XObject's Resources (not at page level).
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// F1: decodable Type1, only in XObject
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(good_font_id),
},
},
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_decodable_text_fonts,
"P2: decodable font from Form XObject should be detected"
);
assert!(
!analysis.has_identity_h_no_tounicode,
"P2: no Identity-H font used, should not flag"
);
}
// ---------- P1 regression: font name collisions across resource scopes ----------
#[test]
fn test_p1_name_collision_xobject_decodable_page_undecodable() {
// P1 bug scenario: Page Resources has /F1 -> undecodable Identity-H.
// Form XObject Resources has /F1 -> decodable Type1. DIFFERENT font, same name.
// Only the XObject's content uses /F1.
//
// Without fix: global name-keyed font_map has page's undecodable /F1;
// XObject's /F1 is skipped (contains_key). Lookup resolves to the WRONG font
// -> page wrongly flagged.
// With fix (ObjectId-based): XObject's Tf resolves /F1 against XObject's own
// Resources, yielding the decodable Type1's ObjectId -> correctly NOT flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Page-level /F1: undecodable Identity-H (no ToUnicode, no fallback)
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// XObject-level /F1: decodable Type1 — DIFFERENT underlying font, same /F1 name
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// Form XObject: its own Resources define /F1 -> good_font_id
let xobj_content = b"BT /F1 10 Tf (Hello from XObject) Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(good_font_id),
},
},
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
// Page content: only invokes the XObject, no direct text
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
},
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"P1 name collision: XObject uses /F1 which resolves to decodable Type1 \
in XObject scope — should NOT flag even though page's /F1 is undecodable"
);
assert!(
analysis.has_decodable_text_fonts,
"P1 name collision: XObject's /F1 is decodable Type1"
);
}
#[test]
fn test_p1_name_collision_xobject_undecodable_page_decodable() {
// Inverse P1 scenario: Page Resources has /F1 -> decodable Type1.
// Form XObject Resources has /F1 -> undecodable Identity-H.
// Only the XObject's content uses /F1.
//
// Without fix: global font_map has page's decodable /F1; XObject's /F1
// skipped. Lookup resolves to page's decodable font -> wrongly unflagged.
// With fix: XObject's Tf resolves /F1 against XObject Resources, gets the
// undecodable Identity-H ObjectId -> correctly flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Page-level /F1: decodable Type1
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// XObject-level /F1: undecodable Identity-H — DIFFERENT font, same name
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"XYZDEF+BadCIDFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// Form XObject: its own Resources define /F1 -> bad_font_id
let xobj_content = b"BT /F1 10 Tf <0102030405> Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
},
},
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
// Page content: only invokes the XObject
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(good_font_id),
},
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_identity_h_no_tounicode,
"P1 inverse: XObject uses /F1 which resolves to undecodable Identity-H \
in XObject scope — MUST flag even though page's /F1 is decodable Type1"
);
}
// ---------- P2 regression: indirect Form XObject Resources ----------
#[test]
fn test_p2_indirect_xobject_resources() {
// P2 bug: Form XObject's /Resources stored as an indirect reference (X 0 R)
// instead of an inline dictionary. The old code used as_dict() which returns
// None for indirect refs, causing the entire Resources branch to be skipped.
//
// With fix: we also handle Object::Reference by resolving it.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Font inside the XObject — decodable Type1
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
// Store the XObject's Resources as a separate indirect object
let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
}));
// Form XObject: /Resources is an indirect reference (the bug trigger)
let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => Object::Reference(xobj_resources_id),
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_decodable_text_fonts,
"P2 indirect: decodable font behind indirect /Resources must be discovered"
);
assert_eq!(
analysis.text_operator_count, 1,
"P2 indirect: text ops from XObject content should be counted"
);
}
// ---------- P1 + P2 combined: indirect Resources with name collisions ----------
#[test]
fn test_p1_p2_combined_indirect_resources_with_name_collision() {
// Combined scenario: Page has /F1 -> undecodable Identity-H.
// Form XObject has /F1 -> decodable Type1 stored via INDIRECT /Resources.
// XObject content uses /F1 which should resolve to the decodable one.
//
// This tests both bugs simultaneously:
// P1: name collision (/F1 means different fonts in different scopes)
// P2: XObject Resources is an indirect reference
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Page-level /F1: undecodable
let bad_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
// XObject-level /F1: decodable Type1 — different underlying font
let good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"TimesNewRoman".to_vec()),
});
// XObject Resources as an indirect reference (P2)
let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(good_font_id),
},
}));
let xobj_content = b"BT /F1 12 Tf (Decodable text in XObject) Tj ET";
let xobj_stream = lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => Object::Reference(xobj_resources_id),
},
xobj_content.to_vec(),
);
let xobj_id = doc.add_object(Object::Stream(xobj_stream));
let content_data = b"/XF1 Do";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
let resources = dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(bad_font_id),
},
"XObject" => dictionary! {
"XF1" => Object::Reference(xobj_id),
},
};
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => resources,
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"P1+P2 combined: XObject /F1 resolves to decodable Type1 via indirect \
Resources — should NOT flag despite page /F1 being undecodable"
);
assert!(
analysis.has_decodable_text_fonts,
"P1+P2 combined: should detect decodable font from indirect XObject Resources"
);
}
// ---------- P3 tests: resource inheritance shadowing ----------
#[test]
fn test_p3_page_overrides_parent_font_undecodable_shadows_decodable() {
// Page tree: /Pages root has /Resources with /F1 → decodable Type1.
// Page itself has /Resources with /F1 → undecodable Identity-H.
// Content uses /F1. The page's /F1 shadows the parent's /F1.
// Expectation: MUST be flagged (only the undecodable font is "used").
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Parent's /F1: decodable Type1 (SHADOWED — should NOT be in used set)
let parent_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(parent_font_id),
},
}));
// Page's /F1: undecodable Identity-H (no ToUnicode, no fallback)
let page_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(page_font_id),
},
},
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
"Resources" => Object::Reference(parent_resources_id),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_identity_h_no_tounicode,
"P3: page /F1 (undecodable) shadows parent /F1 (decodable) — \
must be flagged for OCR"
);
}
#[test]
fn test_p3_page_overrides_parent_font_decodable_shadows_undecodable() {
// Inverse: page /F1 → decodable Type1, parent /F1 → undecodable Identity-H.
// Content uses /F1. The page's decodable font shadows the parent's bad one.
// Expectation: MUST NOT be flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Parent's /F1: undecodable Identity-H (SHADOWED — should NOT be in used set)
let parent_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type0".to_vec()),
"BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
"Encoding" => Object::Name(b"Identity-H".to_vec()),
});
let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(parent_font_id),
},
}));
// Page's /F1: decodable Type1
let page_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Resources" => dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(page_font_id),
},
},
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
"Resources" => Object::Reference(parent_resources_id),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"P3: page /F1 (decodable) shadows parent /F1 (undecodable) — \
must NOT be flagged for OCR"
);
assert!(
analysis.has_decodable_text_fonts,
"P3: page's decodable font should be detected as used"
);
}
#[test]
fn test_p3_inheritance_without_override_uses_parent_font() {
// Page has NO /F1 in its own /Resources. Parent has /F1 → decodable.
// Content uses /F1. Should inherit the parent's font.
// Expectation: MUST NOT be flagged.
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
// Parent's /F1: decodable Type1
let parent_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(parent_font_id),
},
}));
let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {},
content_data.to_vec(),
)));
// Page has NO own /Resources — inherits everything from parent
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
"Resources" => Object::Reference(parent_resources_id),
}),
);
let analysis = analyze_page_content(&doc, page_id);
assert!(
!analysis.has_identity_h_no_tounicode,
"P3: page inherits parent's decodable /F1 — must NOT be flagged"
);
assert!(
analysis.has_decodable_text_fonts,
"P3: inherited decodable font should be detected as used"
);
}
}