use crate::PdfError;
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
use std::path::Path;
mod content_geometry;
mod content_mask;
mod content_resources;
mod content_scan;
use content_scan::ContentCounts;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdfType {
TextBased,
Scanned,
ImageBased,
Mixed,
}
#[derive(Debug, Clone)]
pub enum ScanStrategy {
EarlyExit,
Full,
Sample(u32),
Pages(Vec<u32>),
}
#[derive(Debug)]
pub struct PdfTypeResult {
pub pdf_type: PdfType,
pub page_count: u32,
pub pages_sampled: u32,
pub pages_with_text: u32,
pub confidence: f32,
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub creation_date: Option<String>,
pub mod_date: Option<String>,
pub ocr_recommended: bool,
pub pages_needing_ocr: Vec<u32>,
pub ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct DetectionConfig {
pub strategy: ScanStrategy,
pub min_text_ops_per_page: u32,
pub text_page_ratio_threshold: f32,
}
impl Default for DetectionConfig {
fn default() -> Self {
Self {
strategy: ScanStrategy::Sample(8),
min_text_ops_per_page: 3,
text_page_ratio_threshold: 0.6,
}
}
}
pub fn detect_pdf_type<P: AsRef<Path>>(path: P) -> Result<PdfTypeResult, PdfError> {
detect_pdf_type_with_config(path, DetectionConfig::default())
}
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)
}
pub fn detect_pdf_type_mem(buffer: &[u8]) -> Result<PdfTypeResult, PdfError> {
detect_pdf_type_mem_with_config(buffer, DetectionConfig::default())
}
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)
}
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'%'
)
}
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;
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;
let mut analysis_cache: HashMap<u32, PageAnalysis> = HashMap::new();
let mut pages_actually_sampled = 0u32;
let mut pages_past_form_budget = 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={} executed_text_ops={} hidden_text_ops={} images={} image_count={} template={} covering_image={} form_bytes={} form_bytes_exceeded={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
page_num, analysis.text_operator_count, analysis.executed_text_operator_count,
analysis.invisible_text_operator_count,
analysis.has_images, analysis.image_count, analysis.has_template_image,
analysis.has_covering_image,
analysis.executed_form_bytes, analysis.form_bytes_exceeded,
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
);
if analysis.form_bytes_exceeded {
pages_past_form_budget += 1;
log::debug!(
"page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
page_num, analysis.executed_form_bytes
);
}
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
&& !analysis.has_invisible_text_layer
{
pages_with_text += 1;
}
if analysis.has_images {
pages_with_images += 1;
}
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))
|| analysis.has_invisible_text_layer
{
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());
if allow_early_exit
&& (analysis.text_operator_count < config.min_text_ops_per_page
|| is_image_dominated
|| analysis.unique_text_chars < 5
|| analysis.has_invisible_text_layer)
&& (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
};
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
};
let ocr_recommended: bool;
let (pdf_type, confidence) = if has_template_images && pages_with_text > 0 {
ocr_recommended = true;
(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) {
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))
};
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
};
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) {
let a = analyze_page_content(doc, page_id);
analysis_cache.insert(page_num, a.clone());
a
} else {
continue;
};
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;
let sparse_text_over_scan = analysis.has_template_image
&& analysis.text_operator_count < config.min_text_ops_per_page.max(10);
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| analysis.has_invisible_text_layer
|| sparse_text_over_scan
|| (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
}
};
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);
}
}
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);
analysis_cache.insert(page_num, analysis);
}
}
}
}
pages_needing_ocr.sort();
pages_needing_ocr.dedup();
let mut ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>> =
std::collections::BTreeMap::new();
for &page_num in &pages_needing_ocr {
let reasons = match analysis_cache.get(&page_num) {
Some(analysis) => page_ocr_reasons(analysis),
None => match pages.get(&page_num) {
Some(&page_id) => {
let executed = content_scan::page_executed_content(doc, page_id);
if executed.form_bytes_exceeded {
pages_past_form_budget += 1;
log::debug!(
"page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
page_num, executed.form_bytes
);
}
if executed.shows_only_a_hidden_text_layer {
vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
} else {
vec![crate::OCR_REASON_SCANNED]
}
}
None => vec![crate::OCR_REASON_SCANNED],
},
};
ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
}
if pages_past_form_budget > 0 {
log::debug!(
"{} page(s) ran past the scan's byte budget for form content; their evidence is incomplete and their classification unchanged",
pages_past_form_budget
);
}
let DocumentInfo {
title,
author,
subject,
keywords,
creator,
producer,
creation_date,
mod_date,
} = read_document_info(doc);
Ok(PdfTypeResult {
pdf_type,
page_count,
pages_sampled,
pages_with_text,
confidence,
title,
author,
subject,
keywords,
creator,
producer,
creation_date,
mod_date,
ocr_recommended,
pages_needing_ocr,
ocr_reasons_by_page,
})
}
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
}
#[derive(Clone, Default)]
struct PageAnalysis {
text_operator_count: u32,
executed_text_operator_count: u32,
invisible_text_operator_count: u32,
has_images: bool,
has_template_image: bool,
has_covering_image: bool,
has_invisible_text_layer: bool,
executed_form_bytes: usize,
form_bytes_exceeded: bool,
#[allow(dead_code)]
total_image_area: u64,
image_count: u32,
unique_text_chars: u32,
unique_alphanum_chars: u32,
#[allow(dead_code)]
path_op_count: u32,
has_vector_text: bool,
has_identity_h_no_tounicode: bool,
has_only_type3_fonts: bool,
font_change_count: u32,
has_decodable_text_fonts: bool,
}
fn page_ocr_reasons(a: &PageAnalysis) -> Vec<&'static str> {
let mut reasons = Vec::new();
if a.has_invisible_text_layer {
reasons.push(crate::OCR_REASON_INVISIBLE_TEXT_LAYER);
}
if a.has_identity_h_no_tounicode || a.has_only_type3_fonts {
reasons.push(crate::OCR_REASON_SUSPECTED_GARBLED_TEXT);
}
if a.has_vector_text {
reasons.push(crate::OCR_REASON_VECTOR_TEXT);
}
if reasons.is_empty() {
let has_extractable_text = a.text_operator_count > 0 && a.unique_text_chars > 0;
if !has_extractable_text && !a.has_images && !a.has_template_image {
reasons.push(crate::OCR_REASON_NO_TEXT);
} else {
reasons.push(crate::OCR_REASON_SCANNED);
}
}
reasons
}
#[derive(Clone, Debug)]
struct FontInfo {
subtype: Option<Vec<u8>>,
encoding: Option<Vec<u8>>,
has_tounicode: bool,
dict: lopdf::Dictionary,
}
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() {
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(),
},
);
}
}
}
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);
}
}
}
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
}
}
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 {
if let Some(rd) = own_resources {
if let Some(id) = lookup_font_id(doc, rd, name) {
used_font_ids.insert(id);
continue 'name;
}
}
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;
}
}
}
}
}
fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
let mut counts = ContentCounts::default();
let mut all_unique_chars: HashSet<u8> = HashSet::new();
let mut used_font_ids: HashSet<ObjectId> = HashSet::new();
let mut font_map: HashMap<ObjectId, FontInfo> = HashMap::new();
let page_resources = doc.get_page_resources(page_id).ok();
let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
let (executed, page_counts) =
content_scan::scan_page_content(doc, page_id, &mut all_unique_chars, &mut page_font_names);
counts.add(page_counts);
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,
);
}
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);
counts.add(scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
));
}
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);
counts.add(scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
));
}
}
}
let text_ops = counts.text_ops;
let image_count = counts.image_count;
let path_ops = counts.path_ops;
let font_changes = counts.font_changes;
let (found_images, total_image_area, has_template_image) = analyze_page_images(doc, page_id);
let has_images = image_count > 0 || found_images || executed.draws_image;
let has_covering_image = executed.covers_page;
let executed_text_ops = executed.text_ops;
let hidden_text_ops = executed.hidden_text_ops;
let has_invisible_text_layer = executed.shows_only_a_hidden_text_layer;
let executed_form_bytes = executed.form_bytes;
let form_bytes_exceeded = executed.form_bytes_exceeded;
let unique_alphanum_chars = all_unique_chars
.iter()
.filter(|b| b.is_ascii_alphanumeric())
.count() as u32;
let mut has_vector_text = path_ops >= 1000
&& path_ops > text_ops.saturating_mul(200)
&& (unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS;
if has_vector_text {
let decoded = decoded_text_counts(doc, page_id);
has_vector_text = !(decoded.through_cid_cmap && decoded.is_page_text(path_ops));
}
let has_identity_h_no_tounicode =
text_ops > 0 && used_fonts_have_identity_h_no_tounicode(&used_font_ids, &font_map, doc);
let has_only_type3_fonts = text_ops > 0 && used_fonts_are_only_type3(&used_font_ids, &font_map);
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,
executed_text_operator_count: executed_text_ops,
invisible_text_operator_count: hidden_text_ops,
has_images,
has_template_image,
has_covering_image,
has_invisible_text_layer,
executed_form_bytes,
form_bytes_exceeded,
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,
}
}
const VECTOR_TEXT_MIN_ALPHANUMERICS: usize = 30;
const VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER: u32 = 100;
const DECODED_TEXT_MAX_FORMS: usize = 1_000;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct DecodedTextCounts {
distinct_alphanumerics: usize,
alphanumerics: usize,
through_cid_cmap: bool,
}
impl DecodedTextCounts {
fn is_page_text(self, path_ops: u32) -> bool {
self.distinct_alphanumerics >= VECTOR_TEXT_MIN_ALPHANUMERICS
&& (self.alphanumerics as u64) * u64::from(VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER)
>= u64::from(path_ops)
}
}
enum FontDecoder {
CMap {
cmap: crate::tounicode::ToUnicodeCMap,
cid: bool,
},
Bytes,
}
fn font_decoder(doc: &Document, font: &lopdf::Dictionary) -> Option<FontDecoder> {
let subtype = font.get(b"Subtype").ok().and_then(|s| s.as_name().ok());
let to_unicode = match font.get(b"ToUnicode") {
Ok(Object::Stream(stream)) => Some(stream),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Stream(stream)) => Some(stream),
_ => None,
},
_ => None,
};
if let Some(stream) = to_unicode {
let data = match stream
.decompressed_content_with_limit(crate::MAX_STREAM_DECOMPRESSED_BYTES)
{
Ok(data) => data,
Err(lopdf::Error::Decompress(lopdf::DecompressError::MemoryLimitExceeded {
..
})) => return None,
Err(_) if stream.content.len() > crate::MAX_STREAM_DECOMPRESSED_BYTES => return None,
Err(_) => stream.content.clone(),
};
if let Some(mut cmap) = crate::tounicode::ToUnicodeCMap::parse(&data) {
let cid = subtype == Some(b"Type0");
if subtype.is_some_and(|name| name != b"Type0") && cmap.code_byte_length != 1 {
cmap.code_byte_length = 1;
cmap.refresh_gap_fills();
}
return Some(FontDecoder::CMap { cmap, cid });
}
}
match subtype {
Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => Some(FontDecoder::Bytes),
_ => None,
}
}
fn lookup_font<'a>(
doc: &'a Document,
resources: &'a lopdf::Dictionary,
name: &[u8],
) -> Option<(Option<ObjectId>, &'a lopdf::Dictionary)> {
let fonts = match resources.get(b"Font").ok()? {
Object::Dictionary(dict) => dict,
Object::Reference(id) => doc.get_dictionary(*id).ok()?,
_ => return None,
};
match fonts.get(name).ok()? {
Object::Reference(id) => Some((Some(*id), doc.get_dictionary(*id).ok()?)),
Object::Dictionary(dict) => Some((None, dict)),
_ => None,
}
}
fn lookup_form(doc: &Document, resources: &lopdf::Dictionary, name: &[u8]) -> Option<ObjectId> {
let xobjects = match resources.get(b"XObject").ok()? {
Object::Dictionary(dict) => dict,
Object::Reference(id) => doc.get_dictionary(*id).ok()?,
_ => return None,
};
let id = xobjects.get(name).ok()?.as_reference().ok()?;
let Ok(Object::Stream(stream)) = doc.get_object(id) else {
return None;
};
(stream
.dict
.get(b"Subtype")
.ok()
.and_then(|s| s.as_name().ok())
== Some(b"Form"))
.then_some(id)
}
fn decoded_text_counts(doc: &Document, page_id: ObjectId) -> DecodedTextCounts {
decoded_text_counts_within(
doc,
page_id,
crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES,
crate::extractor::content_decode::MAX_PAGE_OPERATIONS,
)
}
fn decoded_text_counts_within(
doc: &Document,
page_id: ObjectId,
max_bytes: usize,
max_operations: usize,
) -> DecodedTextCounts {
use std::rc::Rc;
let mut page_scope: Vec<&lopdf::Dictionary> = Vec::new();
if let Ok((own, ancestors)) = doc.get_page_resources(page_id) {
page_scope.extend(own);
page_scope.extend(
ancestors
.iter()
.filter_map(|id| doc.get_dictionary(*id).ok()),
);
}
let Ok(page_content) = doc.get_page_content_with_limit(page_id, max_bytes) else {
return DecodedTextCounts::default();
};
let mut bytes_left = max_bytes.saturating_sub(page_content.len());
let mut operations_left = max_operations;
enum Pending {
Page(Vec<u8>),
Form(ObjectId),
}
struct Invocation<'a> {
source: Pending,
scope: Vec<&'a lopdf::Dictionary>,
font: Option<Rc<FontDecoder>>,
}
let mut pending = std::collections::VecDeque::from([Invocation {
source: Pending::Page(page_content),
scope: page_scope,
font: None,
}]);
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut counts = DecodedTextCounts::default();
let mut seen: HashSet<char> = HashSet::new();
let mut decoders: HashMap<ObjectId, Option<Rc<FontDecoder>>> = HashMap::new();
while let Some(Invocation {
source,
scope: invoking_scope,
font: inherited,
}) = pending.pop_front()
{
let (content, scope) = match source {
Pending::Page(content) => (content, invoking_scope),
Pending::Form(id) => {
let Ok(Object::Stream(stream)) = doc.get_object(id) else {
continue;
};
let mut scope: Vec<&lopdf::Dictionary> = match stream.dict.get(b"Resources") {
Ok(Object::Dictionary(dict)) => vec![dict],
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().into_iter().collect(),
_ => Vec::new(),
};
scope.extend(invoking_scope);
let content = match stream.decompressed_content_with_limit(bytes_left) {
Ok(content) => content,
Err(lopdf::Error::Decompress(
lopdf::DecompressError::MemoryLimitExceeded { .. },
)) => break,
Err(_) if stream.content.len() > bytes_left => break,
Err(_) => stream.content.clone(),
};
bytes_left = bytes_left.saturating_sub(content.len());
(content, scope)
}
};
let ops = match crate::extractor::content_decode::decode_content_bounded(
&content,
operations_left,
) {
Ok(Some(ops)) => ops,
Ok(None) => break,
Err(_) => continue,
};
operations_left = operations_left.saturating_sub(ops.operations.len());
let mut current: Option<Rc<FontDecoder>> = inherited;
let mut saved: Vec<Option<Rc<FontDecoder>>> = Vec::new();
for op in &ops.operations {
match op.operator.as_str() {
"q" => saved.push(current.clone()),
"Q" => {
if let Some(restored) = saved.pop() {
current = restored;
}
}
"Do" => {
if visited.len() >= DECODED_TEXT_MAX_FORMS {
continue;
}
let form = op
.operands
.first()
.and_then(|name| name.as_name().ok())
.and_then(|name| {
scope
.iter()
.find_map(|resources| lookup_form(doc, resources, name))
});
if let Some(id) = form {
if visited.insert(id) {
pending.push_back(Invocation {
source: Pending::Form(id),
scope: scope.clone(),
font: current.clone(),
});
}
}
}
"Tf" => {
current = op
.operands
.first()
.and_then(|name| name.as_name().ok())
.and_then(|name| {
scope
.iter()
.find_map(|resources| lookup_font(doc, resources, name))
})
.and_then(|(id, font)| match id {
Some(id) => decoders
.entry(id)
.or_insert_with(|| font_decoder(doc, font).map(Rc::new))
.clone(),
None => font_decoder(doc, font).map(Rc::new),
});
}
"Tj" | "'" | "\"" | "TJ" => {
let Some(decoder) = current.as_deref() else {
continue;
};
let mut shown: Vec<&[u8]> = Vec::new();
if op.operator == "TJ" {
if let Some(Ok(array)) = op.operands.first().map(|array| array.as_array()) {
shown.extend(array.iter().filter_map(|element| match element {
Object::String(bytes, _) => Some(bytes.as_slice()),
_ => None,
}));
}
} else if let Some(Object::String(bytes, _)) = op.operands.last() {
shown.push(bytes);
}
for bytes in shown {
let decoded: Vec<char> = match decoder {
FontDecoder::CMap { cmap, cid } => {
counts.through_cid_cmap |= *cid && !bytes.is_empty();
cmap.decode_cids(bytes)
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
FontDecoder::Bytes => bytes
.iter()
.map(|&b| b as char)
.filter(|c| c.is_alphanumeric())
.collect(),
};
counts.alphanumerics += decoded.len();
seen.extend(decoded);
}
}
_ => {}
}
}
}
counts.distinct_alphanumerics = seen.len();
counts
}
#[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 {
has_other_decodable_font = true;
continue;
}
if font_dict.get(b"ToUnicode").is_ok() {
has_other_decodable_font = true;
continue;
}
if identity_h_font_has_fallback(font_dict, doc) {
has_other_decodable_font = true;
continue;
}
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") => {
}
_ => {
has_other_decodable_font = true;
}
}
}
has_undecodable_identity_h && !has_other_decodable_font
}
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,
};
if crate::tounicode::cid_values_look_like_unicode(cid_font_dict) {
return true;
}
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;
}
if crate::mac_glyph_order::cid_to_gid_is_identity(cid_font_dict, doc)
&& doc
.get_object(ff_ref)
.and_then(Object::as_stream)
.ok()
.and_then(|stream| stream.decompressed_content().ok())
.is_some_and(|data| crate::mac_glyph_order::font_file_follows_mac_order(&data))
{
return true;
}
}
}
false
}
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,
};
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
}
#[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") {
if font_dict.get(b"ToUnicode").is_ok() {
return false;
}
has_type3 = true;
} else {
return false;
}
}
if has_type3 {
log::debug!("page has only Type3 fonts without ToUnicode — text is undecodable");
}
has_type3
}
#[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() {
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") => {
return true;
}
Some(b"Type0") => {
if identity_h_font_has_fallback(font_dict, doc) {
return true;
}
}
_ => {}
}
}
false
}
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") => {
}
_ => {
has_other_decodable_font = true;
}
}
}
has_undecodable_identity_h && !has_other_decodable_font
}
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
}
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>,
) -> ContentCounts {
let mut counts = ContentCounts::default();
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());
let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
let own_resources: Vec<&lopdf::Dictionary> =
content_resources::stream_resources(doc, stream)
.into_iter()
.collect();
counts.add(content_scan::scan_content_stream_alone(
doc,
&content,
unique_chars,
&mut xobj_font_names,
&own_resources,
));
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_to_ids(doc, res, &xobj_font_names, used_font_ids);
collect_fonts_from_resource_dict(doc, res, font_map);
counts.add(scan_xobjects_in_resources(
doc,
res,
visited,
unique_chars,
used_font_ids,
font_map,
));
}
}
Some(b"Image") => {
counts.image_count += 1;
}
_ => {}
}
}
}
counts
}
#[cfg(test)]
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 doc = Document::new();
let counts =
content_scan::scan_content_stream_alone(&doc, content, unique_chars, used_font_names, &[]);
(
counts.text_ops,
counts.image_count,
counts.path_ops,
counts.font_changes,
)
}
fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool {
let mut j = op_pos;
while j > floor {
j -= 1;
if !is_pdf_whitespace(content[j]) {
return matches!(content[j], b')' | b'>' | b']');
}
}
false
}
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> Option<Vec<u8>> {
let mut j = tf_pos;
while j > floor && is_pdf_whitespace(content[j - 1]) {
j -= 1;
}
while j > floor
&& (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
{
j -= 1;
}
while j > floor && is_pdf_whitespace(content[j - 1]) {
j -= 1;
}
let name_end = j;
while j > floor && content[j - 1] != b'/' {
if is_pdf_whitespace(content[j - 1]) || content[j - 1] == b'(' || content[j - 1] == b')' {
return None;
}
j -= 1;
}
if j <= floor || content[j - 1] != b'/' {
return None;
}
if j < name_end {
Some(content_mask::decode_name_escapes(&content[j..name_end]))
} else {
None
}
}
fn collect_text_chars_before(
content: &[u8],
op_pos: usize,
unique_chars: &mut HashSet<u8>,
floor: usize,
) {
let mut j = op_pos;
while j > floor {
j -= 1;
if !is_pdf_whitespace(content[j]) {
break;
}
}
if j == floor {
return;
}
let closing = content[j];
if closing == b')' {
let mut depth = 1i32;
let mut k = j;
while k > floor && 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,
_ => {}
}
}
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'>' {
let mut k = j;
while k > floor {
k -= 1;
if content[k] == b'<' {
break;
}
}
if content[k] == b'<' && k + 1 < j {
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']' {
let mut k = j;
while k > floor {
k -= 1;
if content[k] == b'[' {
break;
}
}
if content[k] == b'[' {
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;
}
}
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;
}
}
}
}
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,
}
}
pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000;
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,
);
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,
);
}
}
}
}
}
}
}
}
if !has_template_image && total_area >= TEMPLATE_IMAGE_THRESHOLD * 4 {
has_template_image = true;
}
(has_images, total_area, has_template_image)
}
pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> PageOcrSignals {
let analysis = analyze_page_content(doc, page_id);
let needs_ocr_for_template_image = if !analysis.has_template_image {
false
} else {
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;
let insufficient_text =
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
looks_like_scan || insufficient_text
};
PageOcrSignals {
template_image_needs_ocr: needs_ocr_for_template_image,
has_vector_text: analysis.has_vector_text,
has_invisible_text_layer: analysis.has_invisible_text_layer,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct PageOcrSignals {
pub(crate) template_image_needs_ocr: bool,
pub(crate) has_vector_text: bool,
pub(crate) has_invisible_text_layer: bool,
}
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" {
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,
);
}
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DocumentInfo {
pub(crate) title: Option<String>,
pub(crate) author: Option<String>,
pub(crate) subject: Option<String>,
pub(crate) keywords: Option<String>,
pub(crate) creator: Option<String>,
pub(crate) producer: Option<String>,
pub(crate) creation_date: Option<String>,
pub(crate) mod_date: Option<String>,
}
pub(crate) fn read_document_info(doc: &Document) -> DocumentInfo {
let info = match doc.trailer.get(b"Info") {
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
Ok(Object::Dictionary(dict)) => Some(dict),
_ => None,
};
let Some(info) = info else {
return DocumentInfo::default();
};
let entry = |key: &[u8]| -> Option<String> {
let value = match info.get(key).ok()? {
Object::Reference(id) => doc.get_object(*id).ok()?,
value => value,
};
match value {
Object::String(bytes, _) => Some(crate::text_utils::decode_pdf_text_string(bytes)),
_ => None,
}
};
DocumentInfo {
title: entry(b"Title"),
author: entry(b"Author"),
subject: entry(b"Subject"),
keywords: entry(b"Keywords"),
creator: entry(b"Creator"),
producer: entry(b"Producer"),
creation_date: entry(b"CreationDate"),
mod_date: entry(b"ModDate"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_info_entries_are_read_and_decoded() {
use lopdf::{dictionary, StringFormat};
let utf16 = |text: &str| {
let mut bytes = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
Object::String(bytes, StringFormat::Hexadecimal)
};
let mut doc = Document::with_version("1.7");
let keywords = doc.add_object(Object::string_literal("annual, colour"));
let info = doc.add_object(dictionary! {
"Title" => utf16("Größe – Überblick"),
"Author" => Object::string_literal(b"Jos\xE9 Mart\xEDnez \x84 Acme\x92".to_vec()),
"Subject" => Object::string_literal("Quarterly results"),
"Keywords" => Object::Reference(keywords),
"Creator" => Object::string_literal("Writer"),
"Producer" => utf16("PDF Library 1.0"),
"CreationDate" => Object::string_literal("D:20240115103000+01'00'"),
"ModDate" => Object::Name(b"NotAString".to_vec()),
});
doc.trailer.set("Info", Object::Reference(info));
assert_eq!(
read_document_info(&doc),
DocumentInfo {
title: Some("Größe – Überblick".into()),
author: Some("José MartÃnez — Acmeâ„¢".into()),
subject: Some("Quarterly results".into()),
keywords: Some("annual, colour".into()),
creator: Some("Writer".into()),
producer: Some("PDF Library 1.0".into()),
creation_date: Some("D:20240115103000+01'00'".into()),
mod_date: None,
}
);
doc.trailer.set(
"Info",
dictionary! { "Producer" => Object::string_literal("Inline") },
);
let inline = read_document_info(&doc);
assert_eq!(inline.producer.as_deref(), Some("Inline"));
assert_eq!(inline.title, None);
doc.trailer.remove(b"Info");
assert_eq!(read_document_info(&doc), DocumentInfo::default());
}
#[test]
fn page_ocr_reasons_classify() {
let scanned = PageAnalysis {
has_template_image: true,
..Default::default()
};
assert_eq!(page_ocr_reasons(&scanned), vec![crate::OCR_REASON_SCANNED]);
let image_only = PageAnalysis {
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&image_only),
vec![crate::OCR_REASON_SCANNED]
);
let blank = PageAnalysis::default();
assert_eq!(page_ocr_reasons(&blank), vec![crate::OCR_REASON_NO_TEXT]);
let vector = PageAnalysis {
has_vector_text: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&vector),
vec![crate::OCR_REASON_VECTOR_TEXT]
);
let garbled = PageAnalysis {
has_identity_h_no_tounicode: true,
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&garbled),
vec![crate::OCR_REASON_SUSPECTED_GARBLED_TEXT]
);
let text_with_image = PageAnalysis {
text_operator_count: 40,
unique_text_chars: 120,
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&text_with_image),
vec![crate::OCR_REASON_SCANNED]
);
let invisible_layer = PageAnalysis {
text_operator_count: 300,
executed_text_operator_count: 300,
invisible_text_operator_count: 300,
unique_text_chars: 40,
has_images: true,
has_covering_image: true,
has_invisible_text_layer: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&invisible_layer),
vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
);
let garbled_vector_invisible_layer = PageAnalysis {
has_identity_h_no_tounicode: true,
has_vector_text: true,
..invisible_layer
};
assert_eq!(
page_ocr_reasons(&garbled_vector_invisible_layer),
vec![
crate::OCR_REASON_INVISIBLE_TEXT_LAYER,
crate::OCR_REASON_SUSPECTED_GARBLED_TEXT,
crate::OCR_REASON_VECTOR_TEXT
]
);
}
#[test]
fn test_scan_content_operators() {
let mut uchars = HashSet::new();
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);
assert!(uchars.len() >= 7);
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);
assert!(uchars.len() >= 4);
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_scan_content_successive_tj_collects_each_operand() {
let content = b"[(Hello)] TJ [(World)] TJ (More) Tj";
let mut uchars = HashSet::new();
let (ops, _, _, _) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 3);
for &ch in b"HeloWrdM" {
assert!(uchars.contains(&ch), "missing char {}", ch as char);
}
}
#[test]
fn test_scan_content_tj_inside_literal_is_not_an_operator() {
let content = b"BT (Hello Tj World) Tj ET";
let mut uchars = HashSet::new();
let (ops, _, _, _) =
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 1);
for &ch in b"HeloTjWrd" {
assert!(uchars.contains(&ch), "missing char {}", ch as char);
}
}
#[test]
fn test_scan_content_malformed_tj_lookback_stays_linear() {
let n = 30_000usize;
let mut content = Vec::with_capacity(n * 5);
for _ in 0..n {
content.extend_from_slice(b"] TJ\n");
}
let mut uchars = HashSet::new();
let (ops, _, _, _) =
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
assert_eq!(ops, 0);
assert!(uchars.is_empty());
}
#[test]
fn test_image_dominated_detection() {
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); 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); assert!(uchars.len() >= 5);
}
#[test]
fn test_path_heavy_detection() {
let mut content = Vec::new();
content.extend_from_slice(b"BT (Header) Tj ET\n");
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);
assert!(paths >= 2000, "expected >= 2000 path ops, got {paths}");
let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
assert!(has_vector_text);
}
#[test]
fn test_normal_paths_not_vector_text() {
let mut content = Vec::new();
for _ in 0..20 {
content.extend_from_slice(b"BT (Some text content here) Tj ET\n");
}
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}");
let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
assert!(!has_vector_text);
}
#[test]
fn test_epever_vector_text_detection() {
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() {
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() {
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() {
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() {
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 cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
"W" => Object::Array(vec![
Object::Integer(0x41), Object::Array(vec![
Object::Integer(600), Object::Integer(600), Object::Integer(600),
]),
Object::Integer(0x61), 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() {
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 cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"CIDFontType2".to_vec()),
"W" => Object::Array(vec![
Object::Integer(3), Object::Array(vec![
Object::Integer(600), Object::Integer(600), Object::Integer(600),
Object::Integer(600), Object::Integer(600),
]),
Object::Integer(10), 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() {
let mut uchars = HashSet::new();
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);
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);
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);
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() {
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);
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);
let text_ops = 300u32;
let font_changes = 50u32;
assert!(text_ops < 1500);
}
#[test]
fn test_looks_like_scan_requires_all_conditions() {
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"
);
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"
);
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"
);
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"
);
}
#[test]
fn test_has_vector_text_real_text_with_decorations_not_flagged() {
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() {
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"
);
}
#[test]
fn test_identity_h_with_supplementary_decodable_font_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();
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 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() {
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"
);
}
#[test]
fn test_page_has_decodable_text_fonts_type1() {
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() {
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() {
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]
fn test_looks_like_scan_overridden_by_decodable_cid_text() {
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() {
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() {
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"
);
}
#[test]
fn test_extract_font_name_basic() {
let content = b"/F1 12 Tf";
let name = extract_font_name_before_tf(content, 6, 0); 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, 0);
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);
}
#[test]
fn test_p1_unused_decodable_font_does_not_save_undecodable_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()),
});
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"
);
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() {
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()),
});
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"
);
}
fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
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 image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Image".to_vec()),
"Width" => Object::Integer(1500),
"Height" => Object::Integer(2383),
},
Vec::new(),
)));
let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"XObject" => dictionary! {
"Im0" => Object::Reference(image_id),
},
},
},
b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
)));
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
for line in masthead_lines {
content.extend_from_slice(format!("({line}) Tj ").as_bytes());
}
content.extend_from_slice(b"ET");
let content_id =
doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
"Resources" => dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
"XObject" => dictionary! { "Fm0" => Object::Reference(form_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),
}),
);
(doc, page_id)
}
#[test]
fn test_masthead_over_form_wrapped_scan_needs_ocr() {
let (doc, page_id) = masthead_scan_page(&[
"18",
"FINANCIAL EXPRESS",
"WWW.FINANCIALEXPRESS.COM",
"FRIDAY, DECEMBER 13, 2024",
]);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: full-page image inside the form must be found"
);
assert!(
analysis.unique_alphanum_chars >= 10,
"sanity: masthead text is diverse, alphanum_low cannot fire"
);
let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
assert!(
needs_ocr,
"template image + text below the pages_with_text floor is a scan"
);
}
#[test]
fn test_text_page_over_background_image_stays_native() {
let lines: Vec<String> = (0..12)
.map(|i| format!("Paragraph line {i} with ordinary body text"))
.collect();
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let (doc, page_id) = masthead_scan_page(&refs);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: background image found"
);
assert!(
analysis.text_operator_count >= 10,
"sanity: body text clears the floor"
);
let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
assert!(
!needs_ocr,
"a text page with a background image must stay native"
);
}
#[test]
fn test_p2_decodable_font_in_xobject_unflagged() {
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()),
});
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));
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() {
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 good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
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 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));
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() {
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 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"
);
}
#[test]
fn test_p1_name_collision_xobject_decodable_page_undecodable() {
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+BadFont".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()),
});
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));
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() {
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 good_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
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()),
});
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));
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"
);
}
#[test]
fn test_p2_indirect_xobject_resources() {
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"Helvetica".to_vec()),
});
let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
"Font" => dictionary! {
"F1" => Object::Reference(font_id),
},
}));
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"
);
}
#[test]
fn test_p1_p2_combined_indirect_resources_with_name_collision() {
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+BadFont".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"TimesNewRoman".to_vec()),
});
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"
);
}
#[test]
fn test_p3_page_overrides_parent_font_undecodable_shadows_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();
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 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() {
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 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),
},
}));
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() {
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 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(),
)));
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"
);
}
const CID_TEXT_LINES: [&str; 3] = [
"The quick brown fox jumps over the lazy dog",
"Pack my box with five dozen liquor jugs 0123456789",
"Sphinx of black quartz judge my vow",
];
#[derive(Clone, Copy, PartialEq)]
enum CidTextLayout {
Page,
InlineFont,
Form,
FormInheritingFonts,
AfterRestoredFont,
UninvokedForm,
FormInheritingFontState,
FormThenLargerForm,
MalformedFormThenForm,
}
fn cid_text_over_vector_art(
lines: &[&str],
paths: usize,
broken_cmap: bool,
layout: CidTextLayout,
) -> (Document, ObjectId) {
use lopdf::{dictionary, Stream};
let mut alphabet: Vec<char> = lines.iter().flat_map(|line| line.chars()).collect();
alphabet.sort_unstable();
alphabet.dedup();
let code_of = |c: char| alphabet.iter().position(|&a| a == c).unwrap() as u16 + 1;
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
cmap.push_str(&format!("{} beginbfchar\n", alphabet.len()));
for &c in &alphabet {
let unicode = if broken_cmap { 'x' as u32 } else { c as u32 };
cmap.push_str(&format!("<{:04X}> <{:04X}>\n", code_of(c), unicode));
}
cmap.push_str(
"endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n",
);
let glyphs: Vec<(bool, u16)> = alphabet.iter().map(|_| (true, 600)).collect();
let codes: Vec<u8> = alphabet.iter().map(|&c| c as u8).collect();
let font_file =
crate::extractor::fonts::blank_glyph_tests::synthetic_truetype(&glyphs, &codes);
let mut doc = Document::with_version("1.5");
let font_file_id = doc.add_object(Object::Stream(Stream::new(
dictionary! { "Length1" => font_file.len() as i64 },
font_file,
)));
let descriptor_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => "AAAAAA+Subset",
"Flags" => 4,
"FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
"ItalicAngle" => 0,
"Ascent" => 700,
"Descent" => 0,
"CapHeight" => 700,
"StemV" => 80,
"FontFile2" => Object::Reference(font_file_id),
});
let cid_font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"BaseFont" => "AAAAAA+Subset",
"CIDSystemInfo" => dictionary! {
"Registry" => Object::string_literal("Adobe"),
"Ordering" => Object::string_literal("Identity"),
"Supplement" => 0,
},
"FontDescriptor" => Object::Reference(descriptor_id),
"DW" => 600,
"CIDToGIDMap" => "Identity",
});
let cmap_stream = Stream::new(dictionary! {}, cmap.into_bytes());
let font = |to_unicode: Object| {
dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => "AAAAAA+Subset",
"Encoding" => "Identity-H",
"DescendantFonts" => vec![Object::Reference(cid_font_id)],
"ToUnicode" => to_unicode,
}
};
let font_entry = if layout == CidTextLayout::InlineFont {
Object::Dictionary(font(Object::Stream(cmap_stream)))
} else {
let cmap_id = doc.add_object(Object::Stream(cmap_stream));
Object::Reference(doc.add_object(font(Object::Reference(cmap_id))))
};
let helvetica_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let mut art = String::new();
for i in 0..paths {
let (x, y) = (50 + (i % 40) * 12, 100 + (i / 40) * 20);
art.push_str(&format!(
"{x} {y} m {} {} l {} {y} l h f\n",
x + 5,
y + 8,
x + 10
));
}
let mut text = String::new();
for (index, line) in lines.iter().enumerate() {
let hex: String = line
.chars()
.map(|c| format!("{:04X}", code_of(c)))
.collect();
text.push_str(&format!(
"BT /F1 12 Tf 72 {} Td <{hex}> Tj ET\n",
700 - 20 * index
));
}
let fonts = dictionary! {
"F1" => font_entry,
"F2" => Object::Reference(helvetica_id),
};
let (page_content, page_resources) = match layout {
CidTextLayout::Page | CidTextLayout::InlineFont => {
(format!("{art}{text}"), dictionary! { "Font" => fonts })
}
CidTextLayout::AfterRestoredFont => (
format!(
"{art}BT /F1 12 Tf ET q BT /F2 12 Tf 72 40 Td (x) Tj ET Q\n{}",
text.replace("/F1 12 Tf ", "")
),
dictionary! { "Font" => fonts },
),
CidTextLayout::Form
| CidTextLayout::FormInheritingFonts
| CidTextLayout::UninvokedForm
| CidTextLayout::FormInheritingFontState
| CidTextLayout::FormThenLargerForm
| CidTextLayout::MalformedFormThenForm => {
let form_dict = || {
dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
}
};
let mut text_form_dict = form_dict();
if matches!(
layout,
CidTextLayout::Form
| CidTextLayout::FormThenLargerForm
| CidTextLayout::MalformedFormThenForm
) {
text_form_dict.set("Resources", dictionary! { "Font" => fonts.clone() });
}
let form_text = if layout == CidTextLayout::FormInheritingFontState {
text.replace("/F1 12 Tf ", "")
} else {
text
};
let form_id = doc.add_object(Object::Stream(Stream::new(
text_form_dict,
form_text.into_bytes(),
)));
let mut xobjects = dictionary! { "Fm1" => Object::Reference(form_id) };
let invoke = match layout {
CidTextLayout::UninvokedForm => String::new(),
CidTextLayout::FormInheritingFontState => {
"BT /F1 12 Tf ET q /Fm1 Do Q\n".to_string()
}
CidTextLayout::FormThenLargerForm => {
let larger = doc.add_object(Object::Stream(Stream::new(
form_dict(),
art.clone().into_bytes(),
)));
xobjects.set("Fm2", Object::Reference(larger));
"q /Fm1 Do Q q /Fm2 Do Q\n".to_string()
}
CidTextLayout::MalformedFormThenForm => {
let malformed = doc.add_object(Object::Stream(Stream::new(
form_dict(),
b"BT /F2 12 Tf 72 40 Td (broken".to_vec(),
)));
xobjects.set("Fm0", Object::Reference(malformed));
"q /Fm0 Do Q q /Fm1 Do Q\n".to_string()
}
_ => "q /Fm1 Do Q\n".to_string(),
};
(
format!("{art}{invoke}"),
dictionary! {
"Font" => fonts,
"XObject" => xobjects,
},
)
}
};
let content_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {},
page_content.into_bytes(),
)));
let pages_id = doc.new_object_id();
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => page_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),
}),
);
(doc, page_id)
}
#[test]
fn cid_text_with_a_working_tounicode_is_not_vector_text() {
for layout in [
CidTextLayout::Page,
CidTextLayout::InlineFont,
CidTextLayout::Form,
CidTextLayout::FormInheritingFonts,
CidTextLayout::AfterRestoredFont,
CidTextLayout::FormInheritingFontState,
CidTextLayout::FormThenLargerForm,
CidTextLayout::MalformedFormThenForm,
] {
let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
let analysis = analyze_page_content(&doc, page_id);
assert!(analysis.path_op_count >= 1000, "{}", analysis.path_op_count);
assert!(
(analysis.unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS,
"the byte count is blind to the codes: {}",
analysis.unique_alphanum_chars
);
assert!(!analysis.has_vector_text, "layout {}", layout as u8);
assert_eq!(page_ocr_signals(&doc, page_id), PageOcrSignals::default());
}
}
#[test]
fn little_or_undecodable_cid_text_over_vector_art_stays_vector_text() {
let (doc, page_id) = cid_text_over_vector_art(&["Fig 3"], 400, false, CidTextLayout::Page);
assert!(analyze_page_content(&doc, page_id).has_vector_text);
let (doc, page_id) =
cid_text_over_vector_art(&CID_TEXT_LINES, 400, true, CidTextLayout::Page);
assert!(analyze_page_content(&doc, page_id).has_vector_text);
let (doc, page_id) =
cid_text_over_vector_art(&CID_TEXT_LINES, 8_000, false, CidTextLayout::Page);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.path_op_count >= 40_000,
"{}",
analysis.path_op_count
);
assert!(analysis.has_vector_text);
let (doc, page_id) =
cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::UninvokedForm);
assert!(analyze_page_content(&doc, page_id).has_vector_text);
assert!(!decoded_text_counts(&doc, page_id).through_cid_cmap);
}
#[test]
fn a_simple_fonts_cmap_declared_two_bytes_wide_decodes_one_byte_per_code() {
use lopdf::dictionary;
let cmap = b"1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
3 beginbfchar\n<0A> <006E>\n<82> <0063>\n<0020> <0020>\nendbfchar\n";
let mut doc = Document::with_version("1.5");
let stream = doc.add_object(lopdf::Stream::new(dictionary! {}, cmap.to_vec()));
let simple = dictionary! {
"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "ToUnicode" => stream,
};
let composite = dictionary! {
"Type" => "Font", "Subtype" => "Type0", "BaseFont" => "Composite",
"Encoding" => "Identity-H", "ToUnicode" => stream,
};
let decode = |font: &lopdf::Dictionary| match font_decoder(&doc, font) {
Some(FontDecoder::CMap { cmap, .. }) => cmap.decode_cids(&[0x0A, 0x82]),
_ => panic!("a CMap decoder"),
};
assert_eq!(decode(&simple), "nc");
assert_ne!(decode(&composite), "nc");
let unknown = dictionary! {
"Type" => "Font", "BaseFont" => "NoSubtype", "ToUnicode" => stream,
};
assert_ne!(decode(&unknown), "nc");
}
#[test]
fn decoded_text_counts_cover_the_whole_text() {
for layout in [
CidTextLayout::Page,
CidTextLayout::InlineFont,
CidTextLayout::FormInheritingFonts,
CidTextLayout::AfterRestoredFont,
CidTextLayout::FormInheritingFontState,
CidTextLayout::FormThenLargerForm,
CidTextLayout::MalformedFormThenForm,
] {
let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
let counts = decoded_text_counts(&doc, page_id);
assert_eq!(
(counts.distinct_alphanumerics, counts.through_cid_cmap),
(39, true),
"layout {}",
layout as u8
);
let extra = usize::from(layout == CidTextLayout::AfterRestoredFont);
assert_eq!(counts.alphanumerics, 106 + extra);
let at_the_bar = counts.alphanumerics as u32 * VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER;
assert!(counts.is_page_text(at_the_bar));
assert!(!counts.is_page_text(at_the_bar + 1));
}
let sparse = DecodedTextCounts {
distinct_alphanumerics: 29,
alphanumerics: 1_000,
through_cid_cmap: true,
};
assert!(!sparse.is_page_text(1_000));
}
#[test]
fn decoded_text_counts_stay_within_the_page_budgets() {
let (doc, page_id) =
cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Form);
let page_content = doc.get_page_content(page_id);
let page_operations = lopdf::content::Content::decode(&page_content)
.unwrap()
.operations
.len();
let form = doc
.objects
.values()
.find_map(|object| match object {
Object::Stream(stream)
if stream.dict.get(b"Subtype").ok()
== Some(&Object::Name(b"Form".to_vec())) =>
{
Some(stream)
}
_ => None,
})
.unwrap();
let form_operations = lopdf::content::Content::decode(&form.content)
.unwrap()
.operations
.len();
let full = DecodedTextCounts {
distinct_alphanumerics: 39,
alphanumerics: 106,
through_cid_cmap: true,
};
let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
let operations = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
assert_eq!(
decoded_text_counts_within(&doc, page_id, bytes, page_operations),
DecodedTextCounts::default()
);
assert_eq!(
decoded_text_counts_within(&doc, page_id, bytes, page_operations + form_operations),
full
);
assert_eq!(
decoded_text_counts_within(&doc, page_id, page_content.len(), operations),
DecodedTextCounts::default()
);
assert_eq!(
decoded_text_counts_within(
&doc,
page_id,
page_content.len() + form.content.len(),
operations
),
full
);
assert_eq!(
decoded_text_counts_within(&doc, page_id, page_content.len() / 2, operations),
DecodedTextCounts::default()
);
assert_eq!(decoded_text_counts(&doc, page_id), full);
}
fn form_streams(doc: &Document) -> Vec<&lopdf::Stream> {
doc.objects
.values()
.filter_map(|object| match object {
Object::Stream(stream)
if stream.dict.get(b"Subtype").ok()
== Some(&Object::Name(b"Form".to_vec())) =>
{
Some(stream)
}
_ => None,
})
.collect()
}
#[test]
fn forms_are_read_in_invocation_order_within_the_budgets() {
let full = DecodedTextCounts {
distinct_alphanumerics: 39,
alphanumerics: 106,
through_cid_cmap: true,
};
let operations = |content: &[u8]| {
lopdf::content::Content::decode(content)
.unwrap()
.operations
.len()
};
let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
let ops = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
let (doc, page_id) = cid_text_over_vector_art(
&CID_TEXT_LINES,
400,
false,
CidTextLayout::FormThenLargerForm,
);
let page_content = doc.get_page_content(page_id);
let text_form = form_streams(&doc)
.into_iter()
.find(|stream| stream.content.windows(3).any(|w| w == &b" Tj"[..]))
.unwrap();
let just_enough_operations = operations(&page_content) + operations(&text_form.content);
let just_enough_bytes = page_content.len() + text_form.content.len();
assert_eq!(
decoded_text_counts_within(&doc, page_id, bytes, just_enough_operations),
full
);
assert_eq!(
decoded_text_counts_within(&doc, page_id, just_enough_bytes, ops),
full
);
assert_eq!(decoded_text_counts(&doc, page_id), full);
let (doc, page_id) = cid_text_over_vector_art(
&CID_TEXT_LINES,
400,
false,
CidTextLayout::MalformedFormThenForm,
);
assert_eq!(decoded_text_counts(&doc, page_id), full);
assert!(!analyze_page_content(&doc, page_id).has_vector_text);
}
#[test]
fn a_tounicode_cmap_over_the_stream_bound_is_not_read() {
fn pad_to(doc: &mut Document, id: ObjectId, total: usize) {
let Some(Object::Stream(stream)) = doc.objects.get_mut(&id) else {
unreachable!()
};
stream.content.resize(total, b' ');
}
let (mut doc, page_id) =
cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Page);
let cmap_id = doc
.objects
.iter()
.find_map(|(id, object)| match object {
Object::Stream(stream)
if stream.content.windows(9).any(|w| w == &b"begincmap"[..]) =>
{
Some(*id)
}
_ => None,
})
.unwrap();
let bound = crate::MAX_STREAM_DECOMPRESSED_BYTES;
pad_to(&mut doc, cmap_id, bound);
assert!(!analyze_page_content(&doc, page_id).has_vector_text);
pad_to(&mut doc, cmap_id, bound + 1);
assert!(analyze_page_content(&doc, page_id).has_vector_text);
assert_eq!(
decoded_text_counts(&doc, page_id),
DecodedTextCounts::default()
);
}
}