use crate::PdfError;
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[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 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;
for page_num in &sample_indices {
if let Some(&page_id) = pages.get(page_num) {
let analysis = analyze_page_content(doc, page_id);
pages_actually_sampled += 1;
log::debug!(
"page {}: text_ops={} images={} image_count={} template={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
page_num, analysis.text_operator_count, analysis.has_images,
analysis.image_count, analysis.has_template_image,
analysis.unique_text_chars, analysis.unique_alphanum_chars,
analysis.path_op_count, analysis.has_vector_text,
analysis.total_image_area, analysis.has_identity_h_no_tounicode,
analysis.has_only_type3_fonts, analysis.font_change_count,
analysis.has_decodable_text_fonts
);
let is_image_dominated = analysis.image_count > 10
&& analysis.image_count > analysis.text_operator_count * 3;
let effective_min_ops = if analysis.has_images || analysis.image_count > 0 {
config.min_text_ops_per_page.max(10)
} else {
config.min_text_ops_per_page
};
if analysis.text_operator_count >= effective_min_ops
&& !is_image_dominated
&& analysis.unique_text_chars >= 5
&& !analysis.has_vector_text
&& !analysis.has_only_type3_fonts
{
pages_with_text += 1;
}
if analysis.has_images {
pages_with_images += 1;
}
let alphanum_ok = analysis.unique_alphanum_chars < 10
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
if analysis.has_template_image
&& (analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_ok)
{
pages_with_template_images += 1;
}
if analysis.has_vector_text {
pages_with_vector_text += 1;
}
total_text_ops += analysis.text_operator_count;
analysis_cache.insert(*page_num, analysis.clone());
if allow_early_exit
&& (analysis.text_operator_count < config.min_text_ops_per_page
|| is_image_dominated
|| analysis.unique_text_chars < 5)
&& (analysis.has_images || analysis.has_template_image)
{
break;
}
}
}
let pages_sampled = pages_actually_sampled;
let text_ratio = if pages_sampled > 0 {
pages_with_text as f32 / pages_sampled as f32
} else {
0.0
};
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;
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| (analysis.text_operator_count < config.min_text_ops_per_page
&& analysis.has_images)
{
ocr_pages.push(page_num);
}
}
ocr_pages.sort();
ocr_pages.dedup();
ocr_pages
}
};
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 => vec![crate::OCR_REASON_SCANNED],
};
ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
}
let title = get_document_title(doc);
Ok(PdfTypeResult {
pdf_type,
page_count,
pages_sampled,
pages_with_text,
confidence,
title,
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,
has_images: bool,
has_template_image: 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_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 text_ops = 0u32;
let mut has_images = false;
let mut image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
let mut all_unique_chars: HashSet<u8> = HashSet::new();
let mut used_font_ids: HashSet<ObjectId> = HashSet::new();
let mut font_map: HashMap<ObjectId, FontInfo> = HashMap::new();
let content_streams = doc.get_page_contents(page_id);
let page_resources = doc.get_page_resources(page_id).ok();
for content_id in content_streams {
if let Ok(Object::Stream(stream)) = doc.get_object(content_id) {
let content = match stream.decompressed_content() {
Ok(data) => data,
Err(_) => stream.content.clone(),
};
let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
&content,
&mut all_unique_chars,
&mut page_font_names,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
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);
let (ops, imgs, paths, fonts) = scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
}
for resource_id in resource_ids {
if let Ok(resources) = doc.get_dictionary(resource_id) {
collect_fonts_from_resource_dict(doc, resources, &mut font_map);
let (ops, imgs, paths, fonts) = scan_xobjects_in_resources(
doc,
resources,
&mut visited,
&mut all_unique_chars,
&mut used_font_ids,
&mut font_map,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
has_images = has_images || imgs > 0;
}
}
}
let (found_images, total_image_area, has_template_image) = analyze_page_images(doc, page_id);
if found_images {
has_images = true;
}
let unique_alphanum_chars = all_unique_chars
.iter()
.filter(|b| b.is_ascii_alphanumeric())
.count() as u32;
let has_vector_text =
path_ops >= 1000 && path_ops > text_ops.saturating_mul(200) && unique_alphanum_chars < 30;
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,
has_images,
has_template_image,
total_image_area,
image_count,
unique_text_chars: all_unique_chars.len() as u32,
unique_alphanum_chars,
path_op_count: path_ops,
has_vector_text,
has_identity_h_no_tounicode,
has_only_type3_fonts,
font_change_count: font_changes,
has_decodable_text_fonts,
}
}
#[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;
}
}
}
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>,
) -> (u32, u32, u32, u32) {
let mut text_ops = 0u32;
let mut image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
let xobjects = match resources.get(b"XObject").ok() {
Some(Object::Dictionary(d)) => Some(d.clone()),
Some(Object::Reference(r)) => doc.get_dictionary(*r).ok().cloned(),
_ => None,
};
if let Some(xobj_dict) = xobjects {
for (_, obj) in xobj_dict.iter() {
let Some(obj_id) = obj.as_reference().ok() else {
continue;
};
if !visited.insert(obj_id) {
continue;
}
let Ok(Object::Stream(stream)) = doc.get_object(obj_id) else {
continue;
};
let subtype = stream
.dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok());
match subtype {
Some(b"Form") => {
let content = stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone());
let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
let (ops, imgs, paths, fonts) = scan_content_for_text_operators(
&content,
unique_chars,
&mut xobj_font_names,
);
text_ops += ops;
image_count += imgs;
path_ops += paths;
font_changes += fonts;
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);
let (ops2, imgs2, paths2, fonts2) = scan_xobjects_in_resources(
doc,
res,
visited,
unique_chars,
used_font_ids,
font_map,
);
text_ops += ops2;
image_count += imgs2;
path_ops += paths2;
font_changes += fonts2;
}
}
Some(b"Image") => {
image_count += 1;
}
_ => {}
}
}
}
(text_ops, image_count, path_ops, font_changes)
}
fn scan_content_for_text_operators(
content: &[u8],
unique_chars: &mut HashSet<u8>,
used_font_names: &mut HashSet<Vec<u8>>,
) -> (u32, u32, u32, u32) {
let mut text_ops = 0u32;
let image_count = 0u32;
let mut path_ops = 0u32;
let mut font_changes = 0u32;
let is_word_start = |pos: usize| -> bool { pos == 0 || content[pos - 1].is_ascii_whitespace() };
let is_word_end =
|pos: usize| -> bool { pos + 1 >= content.len() || content[pos + 1].is_ascii_whitespace() };
let mut i = 0;
while i < content.len() {
let b = content[i];
if b == b'T' && i + 1 < content.len() {
let next = content[i + 1];
if next == b'j' || next == b'J' {
if i + 2 >= content.len()
|| content[i + 2].is_ascii_whitespace()
|| content[i + 2] == b'\n'
|| content[i + 2] == b'\r'
{
text_ops += 1;
collect_text_chars_before(content, i, unique_chars);
}
} else if next == b'f' {
if i + 2 >= content.len()
|| content[i + 2].is_ascii_whitespace()
|| content[i + 2] == b'\n'
|| content[i + 2] == b'\r'
|| content[i + 2] == b'['
|| content[i + 2] == b'('
|| content[i + 2] == b'<'
|| content[i + 2] == b'/'
{
font_changes += 1;
if let Some(name) = extract_font_name_before_tf(content, i) {
used_font_names.insert(name);
}
}
}
}
match b {
b'm' | b'l' | b'c' | b'h' | b'f' | b'S' | b's' | b'B' | b'F'
if is_word_start(i) && is_word_end(i) =>
{
path_ops += 1;
}
b'r' if i + 1 < content.len()
&& content[i + 1] == b'e'
&& is_word_start(i)
&& (i + 2 >= content.len() || content[i + 2].is_ascii_whitespace()) =>
{
path_ops += 1;
}
b'f' if i + 1 < content.len()
&& content[i + 1] == b'*'
&& is_word_start(i)
&& (i + 2 >= content.len() || content[i + 2].is_ascii_whitespace()) =>
{
path_ops += 1;
}
_ => {}
}
i += 1;
}
(text_ops, image_count, path_ops, font_changes)
}
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>> {
let mut j = tf_pos;
while j > 0 && content[j - 1].is_ascii_whitespace() {
j -= 1;
}
while j > 0
&& (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
{
j -= 1;
}
while j > 0 && content[j - 1].is_ascii_whitespace() {
j -= 1;
}
let name_end = j;
while j > 0 && content[j - 1] != b'/' {
if content[j - 1].is_ascii_whitespace() || content[j - 1] == b'(' || content[j - 1] == b')'
{
return None;
}
j -= 1;
}
if j == 0 || content[j - 1] != b'/' {
return None;
}
if j < name_end {
Some(content[j..name_end].to_vec())
} else {
None
}
}
fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut HashSet<u8>) {
let mut j = op_pos;
while j > 0 {
j -= 1;
if !content[j].is_ascii_whitespace() {
break;
}
}
if j == 0 {
return;
}
let closing = content[j];
if closing == b')' {
let mut depth = 1i32;
let mut k = j;
while k > 0 && depth > 0 {
k -= 1;
match content[k] {
b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
b'(' if k == 0 || content[k - 1] != b'\\' => depth -= 1,
_ => {}
}
}
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 > 0 {
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 > 0 {
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,
}
}
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)
}
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,
);
}
}
}
}
}
fn get_document_title(doc: &Document) -> Option<String> {
let info_ref = doc.trailer.get(b"Info").ok()?.as_reference().ok()?;
let info = doc.get_dictionary(info_ref).ok()?;
let title_obj = info.get(b"Title").ok()?;
match title_obj {
Object::String(bytes, _) => {
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let utf16: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
Some(String::from_utf16_lossy(&utf16))
} else {
Some(String::from_utf8_lossy(bytes).to_string())
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn 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]
);
}
#[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_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); assert_eq!(name, Some(b"F1".to_vec()));
}
#[test]
fn test_extract_font_name_long_name() {
let content = b"/ArialMT-Bold 9.5 Tf";
let name = extract_font_name_before_tf(content, 18);
assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
}
#[test]
fn test_scan_content_collects_used_font_names() {
let mut uchars = HashSet::new();
let mut fonts = HashSet::new();
let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
scan_content_for_text_operators(content, &mut uchars, &mut fonts);
assert!(fonts.contains(&b"F1".to_vec()), "should collect F1");
assert!(fonts.contains(&b"F2".to_vec()), "should collect F2");
assert_eq!(fonts.len(), 2);
}
#[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"
);
}
#[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"
);
}
}