1use crate::PdfError;
8use lopdf::{Document, Object, ObjectId};
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12mod content_geometry;
13mod content_mask;
14mod content_resources;
15mod content_scan;
16use content_scan::ContentCounts;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PdfType {
21 TextBased,
23 Scanned,
25 ImageBased,
27 Mixed,
29}
30
31#[derive(Debug, Clone)]
33pub enum ScanStrategy {
34 EarlyExit,
37 Full,
40 Sample(u32),
43 Pages(Vec<u32>),
46}
47
48#[derive(Debug)]
50pub struct PdfTypeResult {
51 pub pdf_type: PdfType,
53 pub page_count: u32,
55 pub pages_sampled: u32,
57 pub pages_with_text: u32,
59 pub confidence: f32,
61 pub title: Option<String>,
70 pub author: Option<String>,
72 pub subject: Option<String>,
74 pub keywords: Option<String>,
76 pub creator: Option<String>,
79 pub producer: Option<String>,
82 pub creation_date: Option<String>,
86 pub mod_date: Option<String>,
89 pub ocr_recommended: bool,
92 pub pages_needing_ocr: Vec<u32>,
95 pub ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>>,
99}
100
101#[derive(Debug, Clone)]
103pub struct DetectionConfig {
104 pub strategy: ScanStrategy,
106 pub min_text_ops_per_page: u32,
108 pub text_page_ratio_threshold: f32,
110}
111
112impl Default for DetectionConfig {
113 fn default() -> Self {
114 Self {
115 strategy: ScanStrategy::Sample(8),
118 min_text_ops_per_page: 3,
119 text_page_ratio_threshold: 0.6,
120 }
121 }
122}
123
124pub fn detect_pdf_type<P: AsRef<Path>>(path: P) -> Result<PdfTypeResult, PdfError> {
126 detect_pdf_type_with_config(path, DetectionConfig::default())
127}
128
129pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
131 path: P,
132 config: DetectionConfig,
133) -> Result<PdfTypeResult, PdfError> {
134 crate::validate_pdf_file(&path)?;
135
136 let (doc, page_count) = crate::load_document_from_path(&path)?;
137
138 detect_from_document(&doc, page_count, &config)
139}
140
141pub fn detect_pdf_type_mem(buffer: &[u8]) -> Result<PdfTypeResult, PdfError> {
143 detect_pdf_type_mem_with_config(buffer, DetectionConfig::default())
144}
145
146pub fn detect_pdf_type_mem_with_config(
148 buffer: &[u8],
149 config: DetectionConfig,
150) -> Result<PdfTypeResult, PdfError> {
151 crate::validate_pdf_bytes(buffer)?;
152
153 let (doc, page_count) = crate::load_document_from_mem(buffer)?;
154
155 detect_from_document(&doc, page_count, &config)
156}
157
158pub fn estimate_page_count_from_bytes(buffer: &[u8]) -> u32 {
164 let mut count = 0u32;
165 let mut pos = 0usize;
166
167 while let Some(rel_idx) = find_bytes(&buffer[pos..], b"/Type") {
168 let mut value_pos = pos + rel_idx + b"/Type".len();
169 value_pos = skip_pdf_whitespace(buffer, value_pos);
170
171 if buffer.get(value_pos) == Some(&b'/') {
172 let name_start = value_pos + 1;
173 let name_end = name_start + b"Page".len();
174 if name_end <= buffer.len()
175 && &buffer[name_start..name_end] == b"Page"
176 && buffer
177 .get(name_end)
178 .is_none_or(|b| is_pdf_name_delimiter(*b))
179 {
180 count += 1;
181 }
182 }
183
184 pos += rel_idx + b"/Type".len();
185 }
186
187 count
188}
189
190fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
191 haystack.windows(needle.len()).position(|w| w == needle)
192}
193
194fn skip_pdf_whitespace(buffer: &[u8], mut pos: usize) -> usize {
195 while pos < buffer.len() && is_pdf_whitespace(buffer[pos]) {
196 pos += 1;
197 }
198 pos
199}
200
201fn is_pdf_whitespace(byte: u8) -> bool {
202 matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
203}
204
205fn is_pdf_name_delimiter(byte: u8) -> bool {
206 is_pdf_whitespace(byte)
207 || matches!(
208 byte,
209 b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
210 )
211}
212
213pub(crate) fn detect_from_document(
217 doc: &Document,
218 page_count: u32,
219 config: &DetectionConfig,
220) -> Result<PdfTypeResult, PdfError> {
221 let pages = doc.get_pages();
222 let total_pages = pages.len() as u32;
223
224 let (sample_indices, allow_early_exit) = match &config.strategy {
226 ScanStrategy::EarlyExit => ((1..=total_pages).collect::<Vec<_>>(), true),
227 ScanStrategy::Full => ((1..=total_pages).collect::<Vec<_>>(), false),
228 ScanStrategy::Sample(max_pages) => {
229 let n = (*max_pages).min(total_pages);
230 (distribute_pages(n, total_pages), false)
231 }
232 ScanStrategy::Pages(pages) => {
233 let mut valid: Vec<u32> = pages
234 .iter()
235 .copied()
236 .filter(|&p| p >= 1 && p <= total_pages)
237 .collect();
238 valid.sort();
239 valid.dedup();
240 (valid, false)
241 }
242 };
243
244 let mut pages_with_text = 0u32;
245 let mut pages_with_images = 0u32;
246 let mut pages_with_template_images = 0u32;
247 let mut pages_with_vector_text = 0u32;
248 let mut total_text_ops = 0u32;
249 let mut analysis_cache: HashMap<u32, PageAnalysis> = HashMap::new();
251 let mut pages_actually_sampled = 0u32;
252 let mut pages_past_form_budget = 0u32;
254
255 for page_num in &sample_indices {
256 if let Some(&page_id) = pages.get(page_num) {
257 let analysis = analyze_page_content(doc, page_id);
258 pages_actually_sampled += 1;
259 log::debug!(
260 "page {}: text_ops={} executed_text_ops={} hidden_text_ops={} images={} image_count={} template={} covering_image={} form_bytes={} form_bytes_exceeded={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
261 page_num, analysis.text_operator_count, analysis.executed_text_operator_count,
262 analysis.invisible_text_operator_count,
263 analysis.has_images, analysis.image_count, analysis.has_template_image,
264 analysis.has_covering_image,
265 analysis.executed_form_bytes, analysis.form_bytes_exceeded,
266 analysis.unique_text_chars, analysis.unique_alphanum_chars,
267 analysis.path_op_count, analysis.has_vector_text,
268 analysis.total_image_area, analysis.has_identity_h_no_tounicode,
269 analysis.has_only_type3_fonts, analysis.font_change_count,
270 analysis.has_decodable_text_fonts
271 );
272 if analysis.form_bytes_exceeded {
273 pages_past_form_budget += 1;
274 log::debug!(
275 "page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
276 page_num, analysis.executed_form_bytes
277 );
278 }
279 let is_image_dominated = analysis.image_count > 10
280 && analysis.image_count > analysis.text_operator_count * 3;
281 let effective_min_ops = if analysis.has_images || analysis.image_count > 0 {
282 config.min_text_ops_per_page.max(10)
283 } else {
284 config.min_text_ops_per_page
285 };
286 if analysis.text_operator_count >= effective_min_ops
287 && !is_image_dominated
288 && analysis.unique_text_chars >= 5
289 && !analysis.has_vector_text
290 && !analysis.has_only_type3_fonts
291 && !analysis.has_invisible_text_layer
292 {
293 pages_with_text += 1;
294 }
295 if analysis.has_images {
296 pages_with_images += 1;
297 }
298 let alphanum_ok = analysis.unique_alphanum_chars < 10
308 && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
309 if (analysis.has_template_image
313 && (analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_ok))
314 || analysis.has_invisible_text_layer
315 {
316 pages_with_template_images += 1;
317 }
318 if analysis.has_vector_text {
319 pages_with_vector_text += 1;
320 }
321 total_text_ops += analysis.text_operator_count;
322 analysis_cache.insert(*page_num, analysis.clone());
323
324 if allow_early_exit
327 && (analysis.text_operator_count < config.min_text_ops_per_page
328 || is_image_dominated
329 || analysis.unique_text_chars < 5
330 || analysis.has_invisible_text_layer)
331 && (analysis.has_images || analysis.has_template_image)
332 {
333 break;
334 }
335 }
336 }
337
338 let pages_sampled = pages_actually_sampled;
339 let text_ratio = if pages_sampled > 0 {
340 pages_with_text as f32 / pages_sampled as f32
341 } else {
342 0.0
343 };
344
345 let has_template_images = pages_with_template_images > 0;
348 let template_ratio = if pages_sampled > 0 {
349 pages_with_template_images as f32 / pages_sampled as f32
350 } else {
351 0.0
352 };
353
354 let ocr_recommended: bool;
358
359 let (pdf_type, confidence) = if has_template_images && pages_with_text > 0 {
361 ocr_recommended = true;
362 (PdfType::Mixed, 0.5 + (0.3 * (1.0 - template_ratio)))
364 } else if text_ratio >= config.text_page_ratio_threshold {
365 ocr_recommended = false;
366 (PdfType::TextBased, text_ratio)
367 } else if pages_with_text == 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
368 ocr_recommended = true;
370 if total_text_ops == 0 && pages_with_vector_text == 0 {
371 (PdfType::Scanned, 0.95)
372 } else {
373 (PdfType::ImageBased, 0.8)
374 }
375 } else if pages_with_text > 0 && (pages_with_images > 0 || pages_with_vector_text > 0) {
376 ocr_recommended = true;
377 (PdfType::Mixed, 0.7)
378 } else if total_text_ops == 0 {
379 ocr_recommended = true;
380 (PdfType::Scanned, 0.9)
381 } else {
382 ocr_recommended = false;
383 (PdfType::TextBased, text_ratio.max(0.5))
384 };
385
386 let ocr_recommended = if pdf_type == PdfType::TextBased && pages_sampled >= 3 {
401 let mut newspaper_pages = 0u32;
402 for analysis in analysis_cache.values() {
403 let ratio = if analysis.text_operator_count > 0 {
404 analysis.font_change_count as f32 / analysis.text_operator_count as f32
405 } else {
406 1.0
407 };
408 if analysis.text_operator_count >= 1500
409 && analysis.font_change_count >= 50
410 && ratio < 0.15
411 {
412 newspaper_pages += 1;
413 }
414 }
415 let newspaper_ratio = newspaper_pages as f32 / pages_sampled as f32;
416 if newspaper_ratio >= 0.5 {
417 log::debug!(
418 "newspaper layout detected: {}/{} pages with high text_ops + font_changes → OCR recommended",
419 newspaper_pages, pages_sampled
420 );
421 true
422 } else {
423 ocr_recommended
424 }
425 } else {
426 ocr_recommended
427 };
428
429 let mut pages_needing_ocr = match pdf_type {
431 PdfType::TextBased => Vec::new(),
432 PdfType::Scanned | PdfType::ImageBased => (1..=total_pages).collect(),
433 PdfType::Mixed => {
434 let mut ocr_pages = Vec::new();
435 for page_num in 1..=total_pages {
436 let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
437 cached.clone()
438 } else if let Some(&page_id) = pages.get(&page_num) {
439 let a = analyze_page_content(doc, page_id);
443 analysis_cache.insert(page_num, a.clone());
444 a
445 } else {
446 continue;
447 };
448 let alphanum_low = analysis.unique_alphanum_chars < 10
453 && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
454 let looks_like_scan =
455 analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
456 let sparse_text_over_scan = analysis.has_template_image
461 && analysis.text_operator_count < config.min_text_ops_per_page.max(10);
462 if (analysis.has_template_image && looks_like_scan)
463 || analysis.has_vector_text
464 || analysis.has_invisible_text_layer
465 || sparse_text_over_scan
466 || (analysis.text_operator_count < config.min_text_ops_per_page
467 && analysis.has_images)
468 {
469 ocr_pages.push(page_num);
470 }
471 }
472 ocr_pages.sort();
473 ocr_pages.dedup();
474 ocr_pages
475 }
476 };
477
478 for (&page_num, analysis) in &analysis_cache {
482 if (analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts)
483 && !pages_needing_ocr.contains(&page_num)
484 {
485 pages_needing_ocr.push(page_num);
486 }
487 }
488 if pages_needing_ocr.len() < total_pages as usize {
491 for page_num in 1..=total_pages {
492 if analysis_cache.contains_key(&page_num) || pages_needing_ocr.contains(&page_num) {
493 continue;
494 }
495 if let Some(&page_id) = pages.get(&page_num) {
496 let analysis = analyze_page_content(doc, page_id);
497 if analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts {
498 pages_needing_ocr.push(page_num);
499 analysis_cache.insert(page_num, analysis);
502 }
503 }
504 }
505 }
506 pages_needing_ocr.sort();
507 pages_needing_ocr.dedup();
508
509 let mut ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>> =
517 std::collections::BTreeMap::new();
518 for &page_num in &pages_needing_ocr {
519 let reasons = match analysis_cache.get(&page_num) {
520 Some(analysis) => page_ocr_reasons(analysis),
521 None => match pages.get(&page_num) {
522 Some(&page_id) => {
523 let executed = content_scan::page_executed_content(doc, page_id);
524 if executed.form_bytes_exceeded {
525 pages_past_form_budget += 1;
526 log::debug!(
527 "page {}: form content past the scan's byte budget after {} bytes; the rest went unread and the page keeps its classification",
528 page_num, executed.form_bytes
529 );
530 }
531 if executed.shows_only_a_hidden_text_layer {
532 vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
533 } else {
534 vec![crate::OCR_REASON_SCANNED]
535 }
536 }
537 None => vec![crate::OCR_REASON_SCANNED],
538 },
539 };
540 ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
541 }
542 if pages_past_form_budget > 0 {
543 log::debug!(
544 "{} page(s) ran past the scan's byte budget for form content; their evidence is incomplete and their classification unchanged",
545 pages_past_form_budget
546 );
547 }
548
549 let DocumentInfo {
551 title,
552 author,
553 subject,
554 keywords,
555 creator,
556 producer,
557 creation_date,
558 mod_date,
559 } = read_document_info(doc);
560
561 Ok(PdfTypeResult {
562 pdf_type,
563 page_count,
564 pages_sampled,
565 pages_with_text,
566 confidence,
567 title,
568 author,
569 subject,
570 keywords,
571 creator,
572 producer,
573 creation_date,
574 mod_date,
575 ocr_recommended,
576 pages_needing_ocr,
577 ocr_reasons_by_page,
578 })
579}
580
581fn distribute_pages(n: u32, total: u32) -> Vec<u32> {
586 if n == 0 {
587 return Vec::new();
588 }
589 if n >= total {
590 return (1..=total).collect();
591 }
592
593 let mut indices = Vec::with_capacity(n as usize);
594 indices.push(1);
595
596 if n > 1 {
597 indices.push(total);
598 }
599
600 let remaining = n.saturating_sub(2);
601 if remaining > 0 && total > 2 {
602 let step = (total - 2) / (remaining + 1);
603 for i in 1..=remaining {
604 let idx = 1 + (step * i);
605 if idx > 1 && idx < total && !indices.contains(&idx) {
606 indices.push(idx);
607 }
608 }
609 }
610
611 indices.sort();
612 indices.dedup();
613 indices
614}
615
616#[derive(Clone, Default)]
618struct PageAnalysis {
619 text_operator_count: u32,
620 executed_text_operator_count: u32,
624 invisible_text_operator_count: u32,
628 has_images: bool,
629 has_template_image: bool,
631 has_covering_image: bool,
637 has_invisible_text_layer: bool,
642 executed_form_bytes: usize,
648 form_bytes_exceeded: bool,
649 #[allow(dead_code)]
651 total_image_area: u64,
652 image_count: u32,
654 unique_text_chars: u32,
656 unique_alphanum_chars: u32,
658 #[allow(dead_code)]
660 path_op_count: u32,
661 has_vector_text: bool,
663 has_identity_h_no_tounicode: bool,
666 has_only_type3_fonts: bool,
670 font_change_count: u32,
672 has_decodable_text_fonts: bool,
677}
678
679fn page_ocr_reasons(a: &PageAnalysis) -> Vec<&'static str> {
690 let mut reasons = Vec::new();
691 if a.has_invisible_text_layer {
692 reasons.push(crate::OCR_REASON_INVISIBLE_TEXT_LAYER);
693 }
694 if a.has_identity_h_no_tounicode || a.has_only_type3_fonts {
695 reasons.push(crate::OCR_REASON_SUSPECTED_GARBLED_TEXT);
696 }
697 if a.has_vector_text {
698 reasons.push(crate::OCR_REASON_VECTOR_TEXT);
699 }
700 if reasons.is_empty() {
701 let has_extractable_text = a.text_operator_count > 0 && a.unique_text_chars > 0;
702 if !has_extractable_text && !a.has_images && !a.has_template_image {
703 reasons.push(crate::OCR_REASON_NO_TEXT);
704 } else {
705 reasons.push(crate::OCR_REASON_SCANNED);
707 }
708 }
709 reasons
710}
711
712#[derive(Clone, Debug)]
716struct FontInfo {
717 subtype: Option<Vec<u8>>,
718 encoding: Option<Vec<u8>>,
719 has_tounicode: bool,
720 dict: lopdf::Dictionary,
723}
724
725fn collect_fonts_from_resource_dict(
734 doc: &Document,
735 resources: &lopdf::Dictionary,
736 font_map: &mut HashMap<ObjectId, FontInfo>,
737) {
738 let font_obj = match resources.get(b"Font").ok() {
739 Some(obj) => obj,
740 None => return,
741 };
742 let font_dict = match font_obj {
743 Object::Dictionary(d) => Some(d),
744 Object::Reference(r) => doc.get_dictionary(*r).ok(),
745 _ => None,
746 };
747 let Some(font_dict) = font_dict else {
748 return;
749 };
750 for (_name, value) in font_dict.iter() {
751 let font_obj_id = match value {
754 Object::Reference(r) => *r,
755 _ => continue,
756 };
757 if font_map.contains_key(&font_obj_id) {
758 continue;
759 }
760 let resolved = doc.get_dictionary(font_obj_id).ok();
761 if let Some(fd) = resolved {
762 let subtype = fd
763 .get(b"Subtype")
764 .ok()
765 .and_then(|o| o.as_name().ok())
766 .map(|n| n.to_vec());
767 let encoding = fd
768 .get(b"Encoding")
769 .ok()
770 .and_then(|o| o.as_name().ok())
771 .map(|n| n.to_vec());
772 let has_tounicode = fd.get(b"ToUnicode").is_ok();
773 font_map.insert(
774 font_obj_id,
775 FontInfo {
776 subtype,
777 encoding,
778 has_tounicode,
779 dict: fd.clone(),
780 },
781 );
782 }
783 }
784}
785
786fn resolve_font_names_to_ids(
791 doc: &Document,
792 resources: &lopdf::Dictionary,
793 font_names: &HashSet<Vec<u8>>,
794 used_font_ids: &mut HashSet<ObjectId>,
795) {
796 let font_obj = match resources.get(b"Font").ok() {
797 Some(obj) => obj,
798 None => return,
799 };
800 let font_dict = match font_obj {
801 Object::Dictionary(d) => Some(d),
802 Object::Reference(r) => doc.get_dictionary(*r).ok(),
803 _ => None,
804 };
805 let Some(font_dict) = font_dict else {
806 return;
807 };
808 for name in font_names {
809 if let Ok(Object::Reference(r)) = font_dict.get(name) {
810 used_font_ids.insert(*r);
811 }
812 }
813}
814
815fn lookup_font_id(
818 doc: &Document,
819 resources: &lopdf::Dictionary,
820 font_name: &[u8],
821) -> Option<ObjectId> {
822 let font_obj = resources.get(b"Font").ok()?;
823 let font_dict = match font_obj {
824 Object::Dictionary(d) => Some(d),
825 Object::Reference(r) => doc.get_dictionary(*r).ok(),
826 _ => None,
827 }?;
828 if let Ok(Object::Reference(r)) = font_dict.get(font_name) {
829 Some(*r)
830 } else {
831 None
832 }
833}
834
835fn resolve_with_shadowing(
843 doc: &Document,
844 own_resources: Option<&lopdf::Dictionary>,
845 ancestor_resource_ids: &[ObjectId],
846 names: &HashSet<Vec<u8>>,
847 used_font_ids: &mut HashSet<ObjectId>,
848) {
849 'name: for name in names {
850 if let Some(rd) = own_resources {
852 if let Some(id) = lookup_font_id(doc, rd, name) {
853 used_font_ids.insert(id);
854 continue 'name;
855 }
856 }
857 for ancestor_id in ancestor_resource_ids {
859 if let Ok(rd) = doc.get_dictionary(*ancestor_id) {
860 if let Some(id) = lookup_font_id(doc, rd, name) {
861 used_font_ids.insert(id);
862 continue 'name;
863 }
864 }
865 }
866 }
867}
868
869fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
871 let mut counts = ContentCounts::default();
872 let mut all_unique_chars: HashSet<u8> = HashSet::new();
873 let mut used_font_ids: HashSet<ObjectId> = HashSet::new();
877
878 let mut font_map: HashMap<ObjectId, FontInfo> = HashMap::new();
881
882 let page_resources = doc.get_page_resources(page_id).ok();
886
887 let mut page_font_names: HashSet<Vec<u8>> = HashSet::new();
890 let (executed, page_counts) =
891 content_scan::scan_page_content(doc, page_id, &mut all_unique_chars, &mut page_font_names);
892 counts.add(page_counts);
893
894 if let Some((ref resource_dict, ref resource_ids)) = page_resources {
898 resolve_with_shadowing(
899 doc,
900 *resource_dict,
901 resource_ids,
902 &page_font_names,
903 &mut used_font_ids,
904 );
905 }
906
907 if let Some((resource_dict, resource_ids)) = page_resources {
910 let mut visited = HashSet::new();
911 if let Some(resources) = resource_dict {
912 collect_fonts_from_resource_dict(doc, resources, &mut font_map);
913 counts.add(scan_xobjects_in_resources(
914 doc,
915 resources,
916 &mut visited,
917 &mut all_unique_chars,
918 &mut used_font_ids,
919 &mut font_map,
920 ));
921 }
922 for resource_id in resource_ids {
923 if let Ok(resources) = doc.get_dictionary(resource_id) {
924 collect_fonts_from_resource_dict(doc, resources, &mut font_map);
925 counts.add(scan_xobjects_in_resources(
926 doc,
927 resources,
928 &mut visited,
929 &mut all_unique_chars,
930 &mut used_font_ids,
931 &mut font_map,
932 ));
933 }
934 }
935 }
936 let text_ops = counts.text_ops;
937 let image_count = counts.image_count;
938 let path_ops = counts.path_ops;
939 let font_changes = counts.font_changes;
940
941 let (found_images, total_image_area, has_template_image) = analyze_page_images(doc, page_id);
945 let has_images = image_count > 0 || found_images || executed.draws_image;
946
947 let has_covering_image = executed.covers_page;
954
955 let executed_text_ops = executed.text_ops;
959 let hidden_text_ops = executed.hidden_text_ops;
960 let has_invisible_text_layer = executed.shows_only_a_hidden_text_layer;
961 let executed_form_bytes = executed.form_bytes;
962 let form_bytes_exceeded = executed.form_bytes_exceeded;
963
964 let unique_alphanum_chars = all_unique_chars
965 .iter()
966 .filter(|b| b.is_ascii_alphanumeric())
967 .count() as u32;
968
969 let mut has_vector_text = path_ops >= 1000
986 && path_ops > text_ops.saturating_mul(200)
987 && (unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS;
988 if has_vector_text {
989 let decoded = decoded_text_counts(doc, page_id);
990 has_vector_text = !(decoded.through_cid_cmap && decoded.is_page_text(path_ops));
991 }
992
993 let has_identity_h_no_tounicode =
997 text_ops > 0 && used_fonts_have_identity_h_no_tounicode(&used_font_ids, &font_map, doc);
998
999 let has_only_type3_fonts = text_ops > 0 && used_fonts_are_only_type3(&used_font_ids, &font_map);
1002
1003 let has_decodable_text_fonts =
1008 text_ops > 0 && used_fonts_have_decodable_text(&used_font_ids, &font_map, doc);
1009
1010 PageAnalysis {
1011 text_operator_count: text_ops,
1012 executed_text_operator_count: executed_text_ops,
1013 invisible_text_operator_count: hidden_text_ops,
1014 has_images,
1015 has_template_image,
1016 has_covering_image,
1017 has_invisible_text_layer,
1018 executed_form_bytes,
1019 form_bytes_exceeded,
1020 total_image_area,
1021 image_count,
1022 unique_text_chars: all_unique_chars.len() as u32,
1023 unique_alphanum_chars,
1024 path_op_count: path_ops,
1025 has_vector_text,
1026 has_identity_h_no_tounicode,
1027 has_only_type3_fonts,
1028 font_change_count: font_changes,
1029 has_decodable_text_fonts,
1030 }
1031}
1032
1033const VECTOR_TEXT_MIN_ALPHANUMERICS: usize = 30;
1039
1040const VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER: u32 = 100;
1046
1047const DECODED_TEXT_MAX_FORMS: usize = 1_000;
1052
1053#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1055struct DecodedTextCounts {
1056 distinct_alphanumerics: usize,
1058 alphanumerics: usize,
1060 through_cid_cmap: bool,
1064}
1065
1066impl DecodedTextCounts {
1067 fn is_page_text(self, path_ops: u32) -> bool {
1074 self.distinct_alphanumerics >= VECTOR_TEXT_MIN_ALPHANUMERICS
1075 && (self.alphanumerics as u64) * u64::from(VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER)
1076 >= u64::from(path_ops)
1077 }
1078}
1079
1080enum FontDecoder {
1082 CMap {
1085 cmap: crate::tounicode::ToUnicodeCMap,
1086 cid: bool,
1087 },
1088 Bytes,
1090}
1091
1092fn font_decoder(doc: &Document, font: &lopdf::Dictionary) -> Option<FontDecoder> {
1098 let subtype = font.get(b"Subtype").ok().and_then(|s| s.as_name().ok());
1099 let to_unicode = match font.get(b"ToUnicode") {
1100 Ok(Object::Stream(stream)) => Some(stream),
1101 Ok(Object::Reference(id)) => match doc.get_object(*id) {
1102 Ok(Object::Stream(stream)) => Some(stream),
1103 _ => None,
1104 },
1105 _ => None,
1106 };
1107 if let Some(stream) = to_unicode {
1108 let data = match stream
1113 .decompressed_content_with_limit(crate::MAX_STREAM_DECOMPRESSED_BYTES)
1114 {
1115 Ok(data) => data,
1116 Err(lopdf::Error::Decompress(lopdf::DecompressError::MemoryLimitExceeded {
1117 ..
1118 })) => return None,
1119 Err(_) if stream.content.len() > crate::MAX_STREAM_DECOMPRESSED_BYTES => return None,
1120 Err(_) => stream.content.clone(),
1121 };
1122 if let Some(mut cmap) = crate::tounicode::ToUnicodeCMap::parse(&data) {
1123 let cid = subtype == Some(b"Type0");
1124 if subtype.is_some_and(|name| name != b"Type0") && cmap.code_byte_length != 1 {
1129 cmap.code_byte_length = 1;
1130 cmap.refresh_gap_fills();
1131 }
1132 return Some(FontDecoder::CMap { cmap, cid });
1133 }
1134 }
1135 match subtype {
1136 Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => Some(FontDecoder::Bytes),
1137 _ => None,
1138 }
1139}
1140
1141fn lookup_font<'a>(
1144 doc: &'a Document,
1145 resources: &'a lopdf::Dictionary,
1146 name: &[u8],
1147) -> Option<(Option<ObjectId>, &'a lopdf::Dictionary)> {
1148 let fonts = match resources.get(b"Font").ok()? {
1149 Object::Dictionary(dict) => dict,
1150 Object::Reference(id) => doc.get_dictionary(*id).ok()?,
1151 _ => return None,
1152 };
1153 match fonts.get(name).ok()? {
1154 Object::Reference(id) => Some((Some(*id), doc.get_dictionary(*id).ok()?)),
1155 Object::Dictionary(dict) => Some((None, dict)),
1156 _ => None,
1157 }
1158}
1159
1160fn lookup_form(doc: &Document, resources: &lopdf::Dictionary, name: &[u8]) -> Option<ObjectId> {
1162 let xobjects = match resources.get(b"XObject").ok()? {
1163 Object::Dictionary(dict) => dict,
1164 Object::Reference(id) => doc.get_dictionary(*id).ok()?,
1165 _ => return None,
1166 };
1167 let id = xobjects.get(name).ok()?.as_reference().ok()?;
1168 let Ok(Object::Stream(stream)) = doc.get_object(id) else {
1169 return None;
1170 };
1171 (stream
1172 .dict
1173 .get(b"Subtype")
1174 .ok()
1175 .and_then(|s| s.as_name().ok())
1176 == Some(b"Form"))
1177 .then_some(id)
1178}
1179
1180fn decoded_text_counts(doc: &Document, page_id: ObjectId) -> DecodedTextCounts {
1199 decoded_text_counts_within(
1200 doc,
1201 page_id,
1202 crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES,
1203 crate::extractor::content_decode::MAX_PAGE_OPERATIONS,
1204 )
1205}
1206
1207fn decoded_text_counts_within(
1211 doc: &Document,
1212 page_id: ObjectId,
1213 max_bytes: usize,
1214 max_operations: usize,
1215) -> DecodedTextCounts {
1216 use std::rc::Rc;
1217
1218 let mut page_scope: Vec<&lopdf::Dictionary> = Vec::new();
1219 if let Ok((own, ancestors)) = doc.get_page_resources(page_id) {
1220 page_scope.extend(own);
1221 page_scope.extend(
1222 ancestors
1223 .iter()
1224 .filter_map(|id| doc.get_dictionary(*id).ok()),
1225 );
1226 }
1227 let Ok(page_content) = doc.get_page_content_with_limit(page_id, max_bytes) else {
1230 return DecodedTextCounts::default();
1231 };
1232 let mut bytes_left = max_bytes.saturating_sub(page_content.len());
1233 let mut operations_left = max_operations;
1234
1235 enum Pending {
1236 Page(Vec<u8>),
1237 Form(ObjectId),
1238 }
1239 struct Invocation<'a> {
1242 source: Pending,
1243 scope: Vec<&'a lopdf::Dictionary>,
1244 font: Option<Rc<FontDecoder>>,
1245 }
1246 let mut pending = std::collections::VecDeque::from([Invocation {
1247 source: Pending::Page(page_content),
1248 scope: page_scope,
1249 font: None,
1250 }]);
1251 let mut visited: HashSet<ObjectId> = HashSet::new();
1252 let mut counts = DecodedTextCounts::default();
1253 let mut seen: HashSet<char> = HashSet::new();
1254 let mut decoders: HashMap<ObjectId, Option<Rc<FontDecoder>>> = HashMap::new();
1255 while let Some(Invocation {
1256 source,
1257 scope: invoking_scope,
1258 font: inherited,
1259 }) = pending.pop_front()
1260 {
1261 let (content, scope) = match source {
1262 Pending::Page(content) => (content, invoking_scope),
1263 Pending::Form(id) => {
1264 let Ok(Object::Stream(stream)) = doc.get_object(id) else {
1265 continue;
1266 };
1267 let mut scope: Vec<&lopdf::Dictionary> = match stream.dict.get(b"Resources") {
1268 Ok(Object::Dictionary(dict)) => vec![dict],
1269 Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok().into_iter().collect(),
1270 _ => Vec::new(),
1271 };
1272 scope.extend(invoking_scope);
1273 let content = match stream.decompressed_content_with_limit(bytes_left) {
1277 Ok(content) => content,
1278 Err(lopdf::Error::Decompress(
1279 lopdf::DecompressError::MemoryLimitExceeded { .. },
1280 )) => break,
1281 Err(_) if stream.content.len() > bytes_left => break,
1282 Err(_) => stream.content.clone(),
1283 };
1284 bytes_left = bytes_left.saturating_sub(content.len());
1285 (content, scope)
1286 }
1287 };
1288 let ops = match crate::extractor::content_decode::decode_content_bounded(
1289 &content,
1290 operations_left,
1291 ) {
1292 Ok(Some(ops)) => ops,
1293 Ok(None) => break,
1295 Err(_) => continue,
1298 };
1299 operations_left = operations_left.saturating_sub(ops.operations.len());
1300 let mut current: Option<Rc<FontDecoder>> = inherited;
1301 let mut saved: Vec<Option<Rc<FontDecoder>>> = Vec::new();
1302 for op in &ops.operations {
1303 match op.operator.as_str() {
1304 "q" => saved.push(current.clone()),
1305 "Q" => {
1306 if let Some(restored) = saved.pop() {
1307 current = restored;
1308 }
1309 }
1310 "Do" => {
1311 if visited.len() >= DECODED_TEXT_MAX_FORMS {
1312 continue;
1313 }
1314 let form = op
1315 .operands
1316 .first()
1317 .and_then(|name| name.as_name().ok())
1318 .and_then(|name| {
1319 scope
1320 .iter()
1321 .find_map(|resources| lookup_form(doc, resources, name))
1322 });
1323 if let Some(id) = form {
1324 if visited.insert(id) {
1325 pending.push_back(Invocation {
1326 source: Pending::Form(id),
1327 scope: scope.clone(),
1328 font: current.clone(),
1329 });
1330 }
1331 }
1332 }
1333 "Tf" => {
1334 current = op
1335 .operands
1336 .first()
1337 .and_then(|name| name.as_name().ok())
1338 .and_then(|name| {
1339 scope
1340 .iter()
1341 .find_map(|resources| lookup_font(doc, resources, name))
1342 })
1343 .and_then(|(id, font)| match id {
1344 Some(id) => decoders
1345 .entry(id)
1346 .or_insert_with(|| font_decoder(doc, font).map(Rc::new))
1347 .clone(),
1348 None => font_decoder(doc, font).map(Rc::new),
1349 });
1350 }
1351 "Tj" | "'" | "\"" | "TJ" => {
1352 let Some(decoder) = current.as_deref() else {
1353 continue;
1354 };
1355 let mut shown: Vec<&[u8]> = Vec::new();
1356 if op.operator == "TJ" {
1357 if let Some(Ok(array)) = op.operands.first().map(|array| array.as_array()) {
1358 shown.extend(array.iter().filter_map(|element| match element {
1359 Object::String(bytes, _) => Some(bytes.as_slice()),
1360 _ => None,
1361 }));
1362 }
1363 } else if let Some(Object::String(bytes, _)) = op.operands.last() {
1364 shown.push(bytes);
1365 }
1366 for bytes in shown {
1367 let decoded: Vec<char> = match decoder {
1368 FontDecoder::CMap { cmap, cid } => {
1369 counts.through_cid_cmap |= *cid && !bytes.is_empty();
1370 cmap.decode_cids(bytes)
1371 .chars()
1372 .filter(|c| c.is_alphanumeric())
1373 .collect()
1374 }
1375 FontDecoder::Bytes => bytes
1376 .iter()
1377 .map(|&b| b as char)
1378 .filter(|c| c.is_alphanumeric())
1379 .collect(),
1380 };
1381 counts.alphanumerics += decoded.len();
1382 seen.extend(decoded);
1383 }
1384 }
1385 _ => {}
1386 }
1387 }
1388 }
1389 counts.distinct_alphanumerics = seen.len();
1390 counts
1391}
1392
1393#[cfg(test)]
1405fn page_has_identity_h_no_tounicode(doc: &Document, page_id: ObjectId) -> bool {
1406 let fonts = match doc.get_page_fonts(page_id) {
1407 Ok(f) => f,
1408 Err(_) => return false,
1409 };
1410
1411 let mut has_undecodable_identity_h = false;
1412 let mut has_other_decodable_font = false;
1413
1414 for font_dict in fonts.values() {
1415 let subtype = font_dict
1416 .get(b"Subtype")
1417 .ok()
1418 .and_then(|o| o.as_name().ok());
1419
1420 match subtype {
1421 Some(b"Type0") => {
1422 let encoding = font_dict
1423 .get(b"Encoding")
1424 .ok()
1425 .and_then(|o| o.as_name().ok());
1426 let is_identity = matches!(encoding, Some(b"Identity-H") | Some(b"Identity-V"));
1427
1428 if !is_identity {
1429 has_other_decodable_font = true;
1431 continue;
1432 }
1433 if font_dict.get(b"ToUnicode").is_ok() {
1434 has_other_decodable_font = true;
1436 continue;
1437 }
1438 if identity_h_font_has_fallback(font_dict, doc) {
1439 has_other_decodable_font = true;
1441 continue;
1442 }
1443
1444 log::debug!(
1446 "page has Identity-H/V font without ToUnicode: {:?}",
1447 font_dict
1448 .get(b"BaseFont")
1449 .ok()
1450 .and_then(|o| o.as_name().ok())
1451 .map(|n| String::from_utf8_lossy(n).to_string())
1452 );
1453 has_undecodable_identity_h = true;
1454 }
1455 Some(b"Type3") => {
1456 }
1459 _ => {
1460 has_other_decodable_font = true;
1463 }
1464 }
1465 }
1466
1467 has_undecodable_identity_h && !has_other_decodable_font
1471}
1472
1473fn identity_h_font_has_fallback(font_dict: &lopdf::Dictionary, doc: &Document) -> bool {
1476 let desc_fonts_obj = match font_dict.get(b"DescendantFonts").ok() {
1477 Some(obj) => obj,
1478 None => return false,
1479 };
1480 let desc_fonts = match desc_fonts_obj {
1481 Object::Array(arr) => arr,
1482 Object::Reference(r) => match doc.get_object(*r) {
1483 Ok(Object::Array(arr)) => arr,
1484 _ => return false,
1485 },
1486 _ => return false,
1487 };
1488 if desc_fonts.is_empty() {
1489 return false;
1490 }
1491 let cid_font_dict = match &desc_fonts[0] {
1492 Object::Reference(r) => match doc.get_dictionary(*r) {
1493 Ok(d) => d,
1494 _ => return false,
1495 },
1496 Object::Dictionary(d) => d,
1497 _ => return false,
1498 };
1499
1500 if crate::tounicode::cid_values_look_like_unicode(cid_font_dict) {
1503 return true;
1504 }
1505
1506 if let Some(font_descriptor) = cid_font_dict
1508 .get(b"FontDescriptor")
1509 .ok()
1510 .and_then(|o| match o {
1511 Object::Reference(r) => doc.get_dictionary(*r).ok(),
1512 Object::Dictionary(d) => Some(d),
1513 _ => None,
1514 })
1515 {
1516 let font_file_ref = font_descriptor
1517 .get(b"FontFile2")
1518 .ok()
1519 .and_then(|o| o.as_reference().ok())
1520 .or_else(|| {
1521 font_descriptor
1522 .get(b"FontFile3")
1523 .ok()
1524 .and_then(|o| o.as_reference().ok())
1525 });
1526 if let Some(ff_ref) = font_file_ref {
1527 if embedded_font_has_cmap(doc, ff_ref) {
1528 return true;
1529 }
1530 if crate::mac_glyph_order::cid_to_gid_is_identity(cid_font_dict, doc)
1534 && doc
1535 .get_object(ff_ref)
1536 .and_then(Object::as_stream)
1537 .ok()
1538 .and_then(|stream| stream.decompressed_content().ok())
1539 .is_some_and(|data| crate::mac_glyph_order::font_file_follows_mac_order(&data))
1540 {
1541 return true;
1542 }
1543 }
1544 }
1545
1546 false
1547}
1548
1549fn embedded_font_has_cmap(doc: &Document, font_ref: lopdf::ObjectId) -> bool {
1552 let stream = match doc.get_object(font_ref).and_then(Object::as_stream) {
1553 Ok(s) => s,
1554 Err(_) => return false,
1555 };
1556 let data = match stream.decompressed_content() {
1557 Ok(d) => d,
1558 Err(_) => return false,
1559 };
1560 let face = match ttf_parser::Face::parse(&data, 0) {
1561 Ok(f) => f,
1562 Err(_) => return false,
1563 };
1564 if let Some(cmap) = face.tables().cmap {
1566 for subtable in cmap.subtables {
1567 if subtable.is_unicode()
1568 || (subtable.platform_id == ttf_parser::PlatformId::Windows
1569 && subtable.encoding_id == 0)
1570 {
1571 let mut count = 0u32;
1572 subtable.codepoints(|_| count += 1);
1573 if count > 0 {
1574 return true;
1575 }
1576 }
1577 }
1578 }
1579 false
1580}
1581
1582#[cfg(test)]
1589fn page_has_only_type3_fonts(doc: &Document, page_id: ObjectId) -> bool {
1590 let fonts = match doc.get_page_fonts(page_id) {
1591 Ok(f) => f,
1592 Err(_) => return false,
1593 };
1594 if fonts.is_empty() {
1595 return false;
1596 }
1597 let mut has_type3 = false;
1598 for font_dict in fonts.values() {
1599 let subtype = font_dict
1600 .get(b"Subtype")
1601 .ok()
1602 .and_then(|o| o.as_name().ok());
1603 if subtype == Some(b"Type3") {
1604 if font_dict.get(b"ToUnicode").is_ok() {
1606 return false;
1607 }
1608 has_type3 = true;
1609 } else {
1610 return false;
1612 }
1613 }
1614 if has_type3 {
1615 log::debug!("page has only Type3 fonts without ToUnicode — text is undecodable");
1616 }
1617 has_type3
1618}
1619
1620#[cfg(test)]
1634fn page_has_decodable_text_fonts(doc: &Document, page_id: ObjectId) -> bool {
1635 let fonts = match doc.get_page_fonts(page_id) {
1636 Ok(f) => f,
1637 Err(_) => return false,
1638 };
1639 for font_dict in fonts.values() {
1640 if font_dict.get(b"ToUnicode").is_ok() {
1642 return true;
1643 }
1644 let subtype = font_dict
1645 .get(b"Subtype")
1646 .ok()
1647 .and_then(|o| o.as_name().ok());
1648 match subtype {
1649 Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
1650 return true;
1653 }
1654 Some(b"Type0") => {
1655 if identity_h_font_has_fallback(font_dict, doc) {
1657 return true;
1658 }
1659 }
1660 _ => {}
1661 }
1662 }
1663 false
1664}
1665
1666fn used_fonts_have_identity_h_no_tounicode(
1673 used_font_ids: &HashSet<ObjectId>,
1674 font_map: &HashMap<ObjectId, FontInfo>,
1675 doc: &Document,
1676) -> bool {
1677 let mut has_undecodable_identity_h = false;
1678 let mut has_other_decodable_font = false;
1679
1680 for id in used_font_ids {
1681 let Some(info) = font_map.get(id) else {
1682 continue;
1683 };
1684 match info.subtype.as_deref() {
1685 Some(b"Type0") => {
1686 let is_identity = matches!(
1687 info.encoding.as_deref(),
1688 Some(b"Identity-H") | Some(b"Identity-V")
1689 );
1690 if !is_identity {
1691 has_other_decodable_font = true;
1692 continue;
1693 }
1694 if info.has_tounicode {
1695 has_other_decodable_font = true;
1696 continue;
1697 }
1698 if identity_h_font_has_fallback(&info.dict, doc) {
1699 has_other_decodable_font = true;
1700 continue;
1701 }
1702 has_undecodable_identity_h = true;
1703 }
1704 Some(b"Type3") => {
1705 }
1707 _ => {
1708 has_other_decodable_font = true;
1710 }
1711 }
1712 }
1713
1714 has_undecodable_identity_h && !has_other_decodable_font
1715}
1716
1717fn used_fonts_are_only_type3(
1722 used_font_ids: &HashSet<ObjectId>,
1723 font_map: &HashMap<ObjectId, FontInfo>,
1724) -> bool {
1725 if used_font_ids.is_empty() {
1726 return false;
1727 }
1728 let mut has_type3 = false;
1729 for id in used_font_ids {
1730 let Some(info) = font_map.get(id) else {
1731 continue;
1732 };
1733 if info.subtype.as_deref() == Some(b"Type3") {
1734 if info.has_tounicode {
1735 return false;
1736 }
1737 has_type3 = true;
1738 } else {
1739 return false;
1740 }
1741 }
1742 has_type3
1743}
1744
1745fn used_fonts_have_decodable_text(
1751 used_font_ids: &HashSet<ObjectId>,
1752 font_map: &HashMap<ObjectId, FontInfo>,
1753 doc: &Document,
1754) -> bool {
1755 for id in used_font_ids {
1756 let Some(info) = font_map.get(id) else {
1757 continue;
1758 };
1759 if info.has_tounicode {
1760 return true;
1761 }
1762 match info.subtype.as_deref() {
1763 Some(b"Type1") | Some(b"TrueType") | Some(b"MMType1") => {
1764 return true;
1765 }
1766 Some(b"Type0") => {
1767 if identity_h_font_has_fallback(&info.dict, doc) {
1768 return true;
1769 }
1770 }
1771 _ => {}
1772 }
1773 }
1774 false
1775}
1776
1777fn scan_xobjects_in_resources(
1778 doc: &Document,
1779 resources: &lopdf::Dictionary,
1780 visited: &mut HashSet<ObjectId>,
1781 unique_chars: &mut HashSet<u8>,
1782 used_font_ids: &mut HashSet<ObjectId>,
1783 font_map: &mut HashMap<ObjectId, FontInfo>,
1784) -> ContentCounts {
1785 let mut counts = ContentCounts::default();
1786
1787 let xobjects = match resources.get(b"XObject").ok() {
1788 Some(Object::Dictionary(d)) => Some(d.clone()),
1789 Some(Object::Reference(r)) => doc.get_dictionary(*r).ok().cloned(),
1790 _ => None,
1791 };
1792
1793 if let Some(xobj_dict) = xobjects {
1794 for (_, obj) in xobj_dict.iter() {
1795 let Some(obj_id) = obj.as_reference().ok() else {
1796 continue;
1797 };
1798 if !visited.insert(obj_id) {
1799 continue;
1800 }
1801 let Ok(Object::Stream(stream)) = doc.get_object(obj_id) else {
1802 continue;
1803 };
1804 let subtype = stream
1805 .dict
1806 .get(b"Subtype")
1807 .ok()
1808 .and_then(|o| o.as_name().ok());
1809 match subtype {
1810 Some(b"Form") => {
1811 let content = stream
1812 .decompressed_content()
1813 .unwrap_or_else(|_| stream.content.clone());
1814 let mut xobj_font_names: HashSet<Vec<u8>> = HashSet::new();
1820 let own_resources: Vec<&lopdf::Dictionary> =
1821 content_resources::stream_resources(doc, stream)
1822 .into_iter()
1823 .collect();
1824 counts.add(content_scan::scan_content_stream_alone(
1825 doc,
1826 &content,
1827 unique_chars,
1828 &mut xobj_font_names,
1829 &own_resources,
1830 ));
1831
1832 let xobj_res_owned;
1836 let xobj_res = match stream.dict.get(b"Resources").ok() {
1837 Some(Object::Dictionary(d)) => Some(d),
1838 Some(Object::Reference(r)) => {
1839 xobj_res_owned = doc.get_dictionary(*r).ok();
1840 xobj_res_owned
1841 }
1842 _ => None,
1843 };
1844
1845 if let Some(res) = xobj_res {
1846 resolve_font_names_to_ids(doc, res, &xobj_font_names, used_font_ids);
1849 collect_fonts_from_resource_dict(doc, res, font_map);
1851 counts.add(scan_xobjects_in_resources(
1853 doc,
1854 res,
1855 visited,
1856 unique_chars,
1857 used_font_ids,
1858 font_map,
1859 ));
1860 }
1861 }
1862 Some(b"Image") => {
1863 counts.image_count += 1;
1864 }
1865 _ => {}
1866 }
1867 }
1868 }
1869
1870 counts
1871}
1872
1873#[cfg(test)]
1876fn scan_content_for_text_operators(
1877 content: &[u8],
1878 unique_chars: &mut HashSet<u8>,
1879 used_font_names: &mut HashSet<Vec<u8>>,
1880) -> (u32, u32, u32, u32) {
1881 let doc = Document::new();
1882 let counts =
1883 content_scan::scan_content_stream_alone(&doc, content, unique_chars, used_font_names, &[]);
1884 (
1885 counts.text_ops,
1886 counts.image_count,
1887 counts.path_ops,
1888 counts.font_changes,
1889 )
1890}
1891
1892fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool {
1896 let mut j = op_pos;
1897 while j > floor {
1898 j -= 1;
1899 if !is_pdf_whitespace(content[j]) {
1900 return matches!(content[j], b')' | b'>' | b']');
1901 }
1902 }
1903 false
1904}
1905
1906fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> Option<Vec<u8>> {
1918 let mut j = tf_pos;
1920 while j > floor && is_pdf_whitespace(content[j - 1]) {
1921 j -= 1;
1922 }
1923 while j > floor
1925 && (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
1926 {
1927 j -= 1;
1928 }
1929 while j > floor && is_pdf_whitespace(content[j - 1]) {
1931 j -= 1;
1932 }
1933 let name_end = j;
1935 while j > floor && content[j - 1] != b'/' {
1936 if is_pdf_whitespace(content[j - 1]) || content[j - 1] == b'(' || content[j - 1] == b')' {
1938 return None;
1939 }
1940 j -= 1;
1941 }
1942 if j <= floor || content[j - 1] != b'/' {
1943 return None;
1944 }
1945 if j < name_end {
1947 Some(content_mask::decode_name_escapes(&content[j..name_end]))
1948 } else {
1949 None
1950 }
1951}
1952
1953fn collect_text_chars_before(
1960 content: &[u8],
1961 op_pos: usize,
1962 unique_chars: &mut HashSet<u8>,
1963 floor: usize,
1964) {
1965 let mut j = op_pos;
1968 while j > floor {
1969 j -= 1;
1970 if !is_pdf_whitespace(content[j]) {
1971 break;
1972 }
1973 }
1974 if j == floor {
1976 return;
1977 }
1978
1979 let closing = content[j];
1980
1981 if closing == b')' {
1982 let mut depth = 1i32;
1984 let mut k = j;
1985 while k > floor && depth > 0 {
1986 k -= 1;
1987 match content[k] {
1988 b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
1989 b'(' if k == 0 || content[k - 1] != b'\\' => depth -= 1,
1990 _ => {}
1991 }
1992 }
1993 if depth == 0 && k + 1 < j {
1995 for &ch in &content[k + 1..j] {
1996 if !ch.is_ascii_whitespace() {
1997 unique_chars.insert(ch);
1998 }
1999 }
2000 }
2001 } else if closing == b'>' {
2002 let mut k = j;
2004 while k > floor {
2005 k -= 1;
2006 if content[k] == b'<' {
2007 break;
2008 }
2009 }
2010 if content[k] == b'<' && k + 1 < j {
2011 let hex_slice = &content[k + 1..j];
2013 let hex_clean: Vec<u8> = hex_slice
2014 .iter()
2015 .copied()
2016 .filter(|b| !b.is_ascii_whitespace())
2017 .collect();
2018 for pair in hex_clean.chunks(2) {
2019 if pair.len() == 2 {
2020 let high = hex_val(pair[0]);
2021 let low = hex_val(pair[1]);
2022 if let (Some(h), Some(l)) = (high, low) {
2023 let byte = (h << 4) | l;
2024 if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
2025 unique_chars.insert(byte);
2026 }
2027 }
2028 }
2029 }
2030 }
2031 } else if closing == b']' {
2032 let mut k = j;
2034 while k > floor {
2035 k -= 1;
2036 if content[k] == b'[' {
2037 break;
2038 }
2039 }
2040 if content[k] == b'[' {
2041 let mut m = k + 1;
2043 while m < j {
2044 if content[m] == b'(' {
2045 let start = m + 1;
2046 let mut depth = 1i32;
2047 m += 1;
2048 while m < j && depth > 0 {
2049 match content[m] {
2050 b')' if content[m - 1] != b'\\' => depth -= 1,
2051 b'(' if content[m - 1] != b'\\' => depth += 1,
2052 _ => {}
2053 }
2054 if depth > 0 {
2055 m += 1;
2056 }
2057 }
2058 for &ch in &content[start..m] {
2060 if !ch.is_ascii_whitespace() {
2061 unique_chars.insert(ch);
2062 }
2063 }
2064 } else if content[m] == b'<' {
2065 let hex_start = m + 1;
2066 m += 1;
2067 while m < j && content[m] != b'>' {
2068 m += 1;
2069 }
2070 let hex_slice = &content[hex_start..m];
2071 let hex_clean: Vec<u8> = hex_slice
2072 .iter()
2073 .copied()
2074 .filter(|b| !b.is_ascii_whitespace())
2075 .collect();
2076 for pair in hex_clean.chunks(2) {
2077 if pair.len() == 2 {
2078 let high = hex_val(pair[0]);
2079 let low = hex_val(pair[1]);
2080 if let (Some(h), Some(l)) = (high, low) {
2081 let byte = (h << 4) | l;
2082 if byte != 0 && byte != b' ' && byte != b'\t' && byte != b'\n' {
2083 unique_chars.insert(byte);
2084 }
2085 }
2086 }
2087 }
2088 }
2089 m += 1;
2090 }
2091 }
2092 }
2093}
2094
2095fn hex_val(b: u8) -> Option<u8> {
2097 match b {
2098 b'0'..=b'9' => Some(b - b'0'),
2099 b'a'..=b'f' => Some(b - b'a' + 10),
2100 b'A'..=b'F' => Some(b - b'A' + 10),
2101 _ => None,
2102 }
2103}
2104
2105pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
2120 const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000; let mut has_images = false;
2125 let mut total_area: u64 = 0;
2126 let mut has_template_image = false;
2127 let mut visited: HashSet<ObjectId> = HashSet::new();
2128
2129 if let Ok(page_dict) = doc.get_dictionary(page_id) {
2130 let resources = match page_dict.get(b"Resources") {
2131 Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
2132 Ok(Object::Dictionary(dict)) => Some(dict),
2133 _ => None,
2134 };
2135
2136 if let Some(resources) = resources {
2137 collect_images_from_resources(
2138 doc,
2139 resources,
2140 &mut has_images,
2141 &mut total_area,
2142 &mut has_template_image,
2143 TEMPLATE_IMAGE_THRESHOLD,
2144 &mut visited,
2145 );
2146
2147 if let Ok(pattern_obj) = resources.get(b"Pattern") {
2151 let pattern_dict = match pattern_obj {
2152 Object::Reference(id) => doc.get_dictionary(*id).ok(),
2153 Object::Dictionary(dict) => Some(dict),
2154 _ => None,
2155 };
2156 if let Some(pattern_dict) = pattern_dict {
2157 for (_, value) in pattern_dict.iter() {
2158 let pat_ref = match value.as_reference() {
2159 Ok(r) => r,
2160 _ => continue,
2161 };
2162 if !visited.insert(pat_ref) {
2163 continue;
2164 }
2165 if let Ok(Object::Stream(stream)) = doc.get_object(pat_ref) {
2166 if let Ok(pat_resources) = stream.dict.get(b"Resources") {
2167 let pat_res_dict = match pat_resources {
2168 Object::Reference(id) => doc.get_dictionary(*id).ok(),
2169 Object::Dictionary(dict) => Some(dict),
2170 _ => None,
2171 };
2172 if let Some(pat_res) = pat_res_dict {
2173 collect_images_from_resources(
2174 doc,
2175 pat_res,
2176 &mut has_images,
2177 &mut total_area,
2178 &mut has_template_image,
2179 TEMPLATE_IMAGE_THRESHOLD,
2180 &mut visited,
2181 );
2182 }
2183 }
2184 }
2185 }
2186 }
2187 }
2188 }
2189 }
2190
2191 if !has_template_image && total_area >= TEMPLATE_IMAGE_THRESHOLD * 4 {
2195 has_template_image = true;
2196 }
2197
2198 (has_images, total_area, has_template_image)
2199}
2200
2201pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> PageOcrSignals {
2248 let analysis = analyze_page_content(doc, page_id);
2249
2250 let needs_ocr_for_template_image = if !analysis.has_template_image {
2251 false
2252 } else {
2253 let alphanum_low = analysis.unique_alphanum_chars < 10
2254 && !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
2255 let looks_like_scan =
2256 analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
2257 let insufficient_text =
2258 analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
2259 looks_like_scan || insufficient_text
2260 };
2261
2262 PageOcrSignals {
2263 template_image_needs_ocr: needs_ocr_for_template_image,
2264 has_vector_text: analysis.has_vector_text,
2265 has_invisible_text_layer: analysis.has_invisible_text_layer,
2266 }
2267}
2268
2269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2272pub(crate) struct PageOcrSignals {
2273 pub(crate) template_image_needs_ocr: bool,
2276 pub(crate) has_vector_text: bool,
2278 pub(crate) has_invisible_text_layer: bool,
2281}
2282
2283fn collect_images_from_resources(
2286 doc: &Document,
2287 resources: &lopdf::Dictionary,
2288 has_images: &mut bool,
2289 total_area: &mut u64,
2290 has_template_image: &mut bool,
2291 threshold: u64,
2292 visited: &mut HashSet<ObjectId>,
2293) {
2294 let xobject = match resources.get(b"XObject") {
2295 Ok(obj) => obj,
2296 _ => return,
2297 };
2298 let xobject_dict = match xobject {
2299 Object::Reference(id) => doc.get_dictionary(*id).ok(),
2300 Object::Dictionary(dict) => Some(dict),
2301 _ => None,
2302 };
2303 let Some(xobject_dict) = xobject_dict else {
2304 return;
2305 };
2306
2307 for (_, value) in xobject_dict.iter() {
2308 let xobj_ref = match value.as_reference() {
2309 Ok(r) => r,
2310 _ => continue,
2311 };
2312 if !visited.insert(xobj_ref) {
2313 continue;
2314 }
2315 let xobj = match doc.get_object(xobj_ref) {
2316 Ok(o) => o,
2317 _ => continue,
2318 };
2319 let stream = match xobj.as_stream() {
2320 Ok(s) => s,
2321 _ => continue,
2322 };
2323 let subtype = match stream.dict.get(b"Subtype") {
2324 Ok(s) => s,
2325 _ => continue,
2326 };
2327 let name = match subtype.as_name() {
2328 Ok(n) => n,
2329 _ => continue,
2330 };
2331
2332 if name == b"Image" {
2333 *has_images = true;
2334 let width = stream
2335 .dict
2336 .get(b"Width")
2337 .ok()
2338 .and_then(|w| w.as_i64().ok())
2339 .unwrap_or(0) as u64;
2340 let height = stream
2341 .dict
2342 .get(b"Height")
2343 .ok()
2344 .and_then(|h| h.as_i64().ok())
2345 .unwrap_or(0) as u64;
2346 let area = width * height;
2347 *total_area += area;
2348 if area >= threshold {
2349 *has_template_image = true;
2350 }
2351 } else if name == b"Form" {
2352 if let Ok(form_resources) = stream.dict.get(b"Resources") {
2354 let form_res_dict = match form_resources {
2355 Object::Reference(id) => doc.get_dictionary(*id).ok(),
2356 Object::Dictionary(dict) => Some(dict),
2357 _ => None,
2358 };
2359 if let Some(form_res) = form_res_dict {
2360 collect_images_from_resources(
2361 doc,
2362 form_res,
2363 has_images,
2364 total_area,
2365 has_template_image,
2366 threshold,
2367 visited,
2368 );
2369 }
2370 }
2371 }
2372 }
2373}
2374
2375#[derive(Debug, Clone, Default, PartialEq, Eq)]
2378pub(crate) struct DocumentInfo {
2379 pub(crate) title: Option<String>,
2380 pub(crate) author: Option<String>,
2381 pub(crate) subject: Option<String>,
2382 pub(crate) keywords: Option<String>,
2383 pub(crate) creator: Option<String>,
2384 pub(crate) producer: Option<String>,
2385 pub(crate) creation_date: Option<String>,
2386 pub(crate) mod_date: Option<String>,
2387}
2388
2389pub(crate) fn read_document_info(doc: &Document) -> DocumentInfo {
2393 let info = match doc.trailer.get(b"Info") {
2394 Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
2395 Ok(Object::Dictionary(dict)) => Some(dict),
2396 _ => None,
2397 };
2398 let Some(info) = info else {
2399 return DocumentInfo::default();
2400 };
2401 let entry = |key: &[u8]| -> Option<String> {
2402 let value = match info.get(key).ok()? {
2403 Object::Reference(id) => doc.get_object(*id).ok()?,
2404 value => value,
2405 };
2406 match value {
2407 Object::String(bytes, _) => Some(crate::text_utils::decode_pdf_text_string(bytes)),
2408 _ => None,
2409 }
2410 };
2411 DocumentInfo {
2412 title: entry(b"Title"),
2413 author: entry(b"Author"),
2414 subject: entry(b"Subject"),
2415 keywords: entry(b"Keywords"),
2416 creator: entry(b"Creator"),
2417 producer: entry(b"Producer"),
2418 creation_date: entry(b"CreationDate"),
2419 mod_date: entry(b"ModDate"),
2420 }
2421}
2422
2423#[cfg(test)]
2424mod tests {
2425 use super::*;
2426
2427 #[test]
2428 fn document_info_entries_are_read_and_decoded() {
2429 use lopdf::{dictionary, StringFormat};
2430
2431 let utf16 = |text: &str| {
2432 let mut bytes = vec![0xFE, 0xFF];
2433 for unit in text.encode_utf16() {
2434 bytes.extend_from_slice(&unit.to_be_bytes());
2435 }
2436 Object::String(bytes, StringFormat::Hexadecimal)
2437 };
2438 let mut doc = Document::with_version("1.7");
2439 let keywords = doc.add_object(Object::string_literal("annual, colour"));
2440 let info = doc.add_object(dictionary! {
2441 "Title" => utf16("Größe – Überblick"),
2442 "Author" => Object::string_literal(b"Jos\xE9 Mart\xEDnez \x84 Acme\x92".to_vec()),
2443 "Subject" => Object::string_literal("Quarterly results"),
2444 "Keywords" => Object::Reference(keywords),
2445 "Creator" => Object::string_literal("Writer"),
2446 "Producer" => utf16("PDF Library 1.0"),
2447 "CreationDate" => Object::string_literal("D:20240115103000+01'00'"),
2448 "ModDate" => Object::Name(b"NotAString".to_vec()),
2449 });
2450 doc.trailer.set("Info", Object::Reference(info));
2451 assert_eq!(
2452 read_document_info(&doc),
2453 DocumentInfo {
2454 title: Some("Größe – Überblick".into()),
2455 author: Some("José Martínez — Acme™".into()),
2456 subject: Some("Quarterly results".into()),
2457 keywords: Some("annual, colour".into()),
2458 creator: Some("Writer".into()),
2459 producer: Some("PDF Library 1.0".into()),
2460 creation_date: Some("D:20240115103000+01'00'".into()),
2461 mod_date: None,
2462 }
2463 );
2464
2465 doc.trailer.set(
2468 "Info",
2469 dictionary! { "Producer" => Object::string_literal("Inline") },
2470 );
2471 let inline = read_document_info(&doc);
2472 assert_eq!(inline.producer.as_deref(), Some("Inline"));
2473 assert_eq!(inline.title, None);
2474 doc.trailer.remove(b"Info");
2475 assert_eq!(read_document_info(&doc), DocumentInfo::default());
2476 }
2477
2478 #[test]
2479 fn page_ocr_reasons_classify() {
2480 let scanned = PageAnalysis {
2482 has_template_image: true,
2483 ..Default::default()
2484 };
2485 assert_eq!(page_ocr_reasons(&scanned), vec![crate::OCR_REASON_SCANNED]);
2486
2487 let image_only = PageAnalysis {
2489 has_images: true,
2490 ..Default::default()
2491 };
2492 assert_eq!(
2493 page_ocr_reasons(&image_only),
2494 vec![crate::OCR_REASON_SCANNED]
2495 );
2496
2497 let blank = PageAnalysis::default();
2499 assert_eq!(page_ocr_reasons(&blank), vec![crate::OCR_REASON_NO_TEXT]);
2500
2501 let vector = PageAnalysis {
2503 has_vector_text: true,
2504 ..Default::default()
2505 };
2506 assert_eq!(
2507 page_ocr_reasons(&vector),
2508 vec![crate::OCR_REASON_VECTOR_TEXT]
2509 );
2510
2511 let garbled = PageAnalysis {
2513 has_identity_h_no_tounicode: true,
2514 has_images: true,
2515 ..Default::default()
2516 };
2517 assert_eq!(
2518 page_ocr_reasons(&garbled),
2519 vec![crate::OCR_REASON_SUSPECTED_GARBLED_TEXT]
2520 );
2521
2522 let text_with_image = PageAnalysis {
2525 text_operator_count: 40,
2526 unique_text_chars: 120,
2527 has_images: true,
2528 ..Default::default()
2529 };
2530 assert_eq!(
2531 page_ocr_reasons(&text_with_image),
2532 vec![crate::OCR_REASON_SCANNED]
2533 );
2534
2535 let invisible_layer = PageAnalysis {
2539 text_operator_count: 300,
2540 executed_text_operator_count: 300,
2541 invisible_text_operator_count: 300,
2542 unique_text_chars: 40,
2543 has_images: true,
2544 has_covering_image: true,
2545 has_invisible_text_layer: true,
2546 ..Default::default()
2547 };
2548 assert_eq!(
2549 page_ocr_reasons(&invisible_layer),
2550 vec![crate::OCR_REASON_INVISIBLE_TEXT_LAYER]
2551 );
2552 let garbled_vector_invisible_layer = PageAnalysis {
2553 has_identity_h_no_tounicode: true,
2554 has_vector_text: true,
2555 ..invisible_layer
2556 };
2557 assert_eq!(
2558 page_ocr_reasons(&garbled_vector_invisible_layer),
2559 vec![
2560 crate::OCR_REASON_INVISIBLE_TEXT_LAYER,
2561 crate::OCR_REASON_SUSPECTED_GARBLED_TEXT,
2562 crate::OCR_REASON_VECTOR_TEXT
2563 ]
2564 );
2565 }
2566
2567 #[test]
2568 fn test_scan_content_operators() {
2569 let mut uchars = HashSet::new();
2570
2571 let content = b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET";
2573 let (ops, imgs, _, _) =
2574 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2575 assert_eq!(ops, 1);
2576 assert_eq!(imgs, 0);
2577 assert!(uchars.len() >= 7);
2579
2580 uchars.clear();
2582 let content2 = b"BT /F1 12 Tf 100 700 Td [(H) 10 (ello)] TJ ET";
2583 let (ops2, _, _, _) =
2584 scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
2585 assert_eq!(ops2, 1);
2586 assert!(uchars.len() >= 4);
2588
2589 uchars.clear();
2592 let content3 = b"q 100 0 0 100 50 700 cm /Img1 Do Q";
2593 let (ops3, imgs3, _, _) =
2594 scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
2595 assert_eq!(ops3, 0);
2596 assert_eq!(imgs3, 0);
2597 }
2598
2599 #[test]
2600 fn test_scan_content_successive_tj_collects_each_operand() {
2601 let content = b"[(Hello)] TJ [(World)] TJ (More) Tj";
2604 let mut uchars = HashSet::new();
2605 let (ops, _, _, _) =
2606 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2607 assert_eq!(ops, 3);
2608 for &ch in b"HeloWrdM" {
2609 assert!(uchars.contains(&ch), "missing char {}", ch as char);
2610 }
2611 }
2612
2613 #[test]
2614 fn test_scan_content_tj_inside_literal_is_not_an_operator() {
2615 let content = b"BT (Hello Tj World) Tj ET";
2618 let mut uchars = HashSet::new();
2619 let (ops, _, _, _) =
2620 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2621 assert_eq!(ops, 1);
2622 for &ch in b"HeloTjWrd" {
2623 assert!(uchars.contains(&ch), "missing char {}", ch as char);
2624 }
2625 }
2626
2627 #[test]
2628 fn test_scan_content_malformed_tj_lookback_stays_linear() {
2629 let n = 30_000usize;
2634 let mut content = Vec::with_capacity(n * 5);
2635 for _ in 0..n {
2636 content.extend_from_slice(b"] TJ\n");
2637 }
2638 let mut uchars = HashSet::new();
2639 let (ops, _, _, _) =
2640 scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2641 assert_eq!(ops, 0);
2642 assert!(uchars.is_empty());
2643 }
2644
2645 #[test]
2646 fn test_image_dominated_detection() {
2647 let mut content = Vec::new();
2651 for i in 0..50 {
2652 content.extend_from_slice(format!("/Im{i} Do\n").as_bytes());
2653 }
2654 content.extend_from_slice(b"BT (x) Tj ET\n");
2655 content.extend_from_slice(b"BT (x) Tj ET\n");
2656 content.extend_from_slice(b"BT (x) Tj ET\n");
2657
2658 let mut uchars = HashSet::new();
2659 let (ops, imgs, _, _) =
2660 scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2661 assert_eq!(ops, 3);
2662 assert_eq!(imgs, 0); assert_eq!(uchars.len(), 1);
2664 }
2665
2666 #[test]
2667 fn test_normal_text_not_image_dominated() {
2668 let content = b"BT /F1 12 Tf (The quick brown fox jumps over the lazy dog) Tj ET\n\
2669 /Img1 Do\n/Img2 Do\n";
2670 let mut uchars = HashSet::new();
2671 let (ops, imgs, _, _) =
2672 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2673 assert_eq!(ops, 1);
2674 assert_eq!(imgs, 0); assert!(uchars.len() >= 5);
2677 }
2678
2679 #[test]
2680 fn test_path_heavy_detection() {
2681 let mut content = Vec::new();
2683 content.extend_from_slice(b"BT (Header) Tj ET\n");
2685 for _ in 0..500 {
2687 content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h\n");
2688 }
2689 content.extend_from_slice(b"f\n");
2690
2691 let mut uchars = HashSet::new();
2692 let (text, imgs, paths, _) =
2693 scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2694 assert_eq!(text, 1);
2695 assert_eq!(imgs, 0);
2696 assert!(paths >= 2000, "expected >= 2000 path ops, got {paths}");
2698
2699 let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
2701 assert!(has_vector_text);
2702 }
2703
2704 #[test]
2705 fn test_normal_paths_not_vector_text() {
2706 let mut content = Vec::new();
2708 for _ in 0..20 {
2710 content.extend_from_slice(b"BT (Some text content here) Tj ET\n");
2711 }
2712 for _ in 0..10 {
2714 content.extend_from_slice(b"100 200 m 150 250 l 200 200 c h f\n");
2715 }
2716
2717 let mut uchars = HashSet::new();
2718 let (text, _, paths, _) =
2719 scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
2720 assert_eq!(text, 20);
2721 assert!(paths >= 40, "expected >= 40 path ops, got {paths}");
2722
2723 let has_vector_text = paths >= 1000 && paths > text.saturating_mul(200);
2725 assert!(!has_vector_text);
2726 }
2727
2728 #[test]
2729 fn test_epever_vector_text_detection() {
2730 let path = std::path::Path::new("./tests/fixtures/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf");
2732 let path = if path.exists() {
2733 path.to_path_buf()
2734 } else {
2735 let alt = std::path::PathBuf::from(
2736 "../pdf-evals/pdfs/EPEVER-DataSheet-XTRA-N-G3-Series-3.pdf",
2737 );
2738 if !alt.exists() {
2739 return;
2741 }
2742 alt
2743 };
2744
2745 let config = DetectionConfig {
2746 strategy: ScanStrategy::Full,
2747 ..DetectionConfig::default()
2748 };
2749 let result = detect_pdf_type_with_config(&path, config).unwrap();
2750 assert_eq!(
2751 result.pdf_type,
2752 PdfType::Mixed,
2753 "EPEVER should be Mixed (page 2 has vector-outlined text)"
2754 );
2755 assert!(
2756 result.pages_needing_ocr.contains(&2),
2757 "Page 2 should need OCR, got: {:?}",
2758 result.pages_needing_ocr
2759 );
2760 assert!(result.ocr_recommended);
2761 }
2762
2763 #[test]
2764 fn test_page_has_identity_h_no_tounicode_positive() {
2765 use lopdf::dictionary;
2767 let mut doc = Document::with_version("1.4");
2768 let pages_id = doc.new_object_id();
2769 let page_id = doc.new_object_id();
2770 let font_id = doc.add_object(dictionary! {
2771 "Type" => "Font",
2772 "Subtype" => Object::Name(b"Type0".to_vec()),
2773 "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2774 "Encoding" => Object::Name(b"Identity-H".to_vec()),
2775 });
2776 let resources = dictionary! {
2777 "Font" => dictionary! {
2778 "F1" => Object::Reference(font_id),
2779 },
2780 };
2781 doc.objects.insert(
2782 page_id,
2783 Object::Dictionary(dictionary! {
2784 "Type" => "Page",
2785 "Parent" => Object::Reference(pages_id),
2786 "Resources" => resources,
2787 }),
2788 );
2789 doc.objects.insert(
2790 pages_id,
2791 Object::Dictionary(dictionary! {
2792 "Type" => "Pages",
2793 "Kids" => vec![Object::Reference(page_id)],
2794 "Count" => Object::Integer(1),
2795 }),
2796 );
2797 assert!(page_has_identity_h_no_tounicode(&doc, page_id));
2798 }
2799
2800 #[test]
2801 fn test_page_has_identity_h_with_tounicode_negative() {
2802 use lopdf::dictionary;
2804 let mut doc = Document::with_version("1.4");
2805 let pages_id = doc.new_object_id();
2806 let page_id = doc.new_object_id();
2807 let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
2808 dictionary! {},
2809 b"fake cmap".to_vec(),
2810 )));
2811 let font_id = doc.add_object(dictionary! {
2812 "Type" => "Font",
2813 "Subtype" => Object::Name(b"Type0".to_vec()),
2814 "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2815 "Encoding" => Object::Name(b"Identity-H".to_vec()),
2816 "ToUnicode" => Object::Reference(cmap_id),
2817 });
2818 let resources = dictionary! {
2819 "Font" => dictionary! {
2820 "F1" => Object::Reference(font_id),
2821 },
2822 };
2823 doc.objects.insert(
2824 page_id,
2825 Object::Dictionary(dictionary! {
2826 "Type" => "Page",
2827 "Parent" => Object::Reference(pages_id),
2828 "Resources" => resources,
2829 }),
2830 );
2831 doc.objects.insert(
2832 pages_id,
2833 Object::Dictionary(dictionary! {
2834 "Type" => "Pages",
2835 "Kids" => vec![Object::Reference(page_id)],
2836 "Count" => Object::Integer(1),
2837 }),
2838 );
2839 assert!(!page_has_identity_h_no_tounicode(&doc, page_id));
2840 }
2841
2842 #[test]
2843 fn test_identity_h_with_unicode_cids_not_flagged() {
2844 use lopdf::dictionary;
2848 let mut doc = Document::with_version("1.4");
2849 let pages_id = doc.new_object_id();
2850 let page_id = doc.new_object_id();
2851 let cid_font_id = doc.add_object(dictionary! {
2853 "Type" => "Font",
2854 "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
2855 "W" => Object::Array(vec![
2856 Object::Integer(0x41), Object::Array(vec![
2858 Object::Integer(600), Object::Integer(600), Object::Integer(600),
2859 ]),
2860 Object::Integer(0x61), Object::Array(vec![
2862 Object::Integer(500), Object::Integer(500), Object::Integer(500),
2863 ]),
2864 ]),
2865 });
2866 let font_id = doc.add_object(dictionary! {
2867 "Type" => "Font",
2868 "Subtype" => Object::Name(b"Type0".to_vec()),
2869 "BaseFont" => Object::Name(b"ABCDEF+ArialMT".to_vec()),
2870 "Encoding" => Object::Name(b"Identity-H".to_vec()),
2871 "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
2872 });
2873 let resources = dictionary! {
2874 "Font" => dictionary! {
2875 "F1" => Object::Reference(font_id),
2876 },
2877 };
2878 doc.objects.insert(
2879 page_id,
2880 Object::Dictionary(dictionary! {
2881 "Type" => "Page",
2882 "Parent" => Object::Reference(pages_id),
2883 "Resources" => resources,
2884 }),
2885 );
2886 doc.objects.insert(
2887 pages_id,
2888 Object::Dictionary(dictionary! {
2889 "Type" => "Pages",
2890 "Kids" => vec![Object::Reference(page_id)],
2891 "Count" => Object::Integer(1),
2892 }),
2893 );
2894 assert!(
2895 !page_has_identity_h_no_tounicode(&doc, page_id),
2896 "Should NOT flag: W array CIDs look like Unicode, passthrough works"
2897 );
2898 }
2899
2900 #[test]
2901 fn test_identity_h_with_low_gid_cids_still_flagged() {
2902 use lopdf::dictionary;
2906 let mut doc = Document::with_version("1.4");
2907 let pages_id = doc.new_object_id();
2908 let page_id = doc.new_object_id();
2909 let cid_font_id = doc.add_object(dictionary! {
2911 "Type" => "Font",
2912 "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
2913 "W" => Object::Array(vec![
2914 Object::Integer(3), Object::Array(vec![
2916 Object::Integer(600), Object::Integer(600), Object::Integer(600),
2917 Object::Integer(600), Object::Integer(600),
2918 ]),
2919 Object::Integer(10), Object::Array(vec![
2921 Object::Integer(500), Object::Integer(500), Object::Integer(500),
2922 ]),
2923 ]),
2924 });
2925 let font_id = doc.add_object(dictionary! {
2926 "Type" => "Font",
2927 "Subtype" => Object::Name(b"Type0".to_vec()),
2928 "BaseFont" => Object::Name(b"GPBCHP+TimesNewRoman".to_vec()),
2929 "Encoding" => Object::Name(b"Identity-H".to_vec()),
2930 "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
2931 });
2932 let resources = dictionary! {
2933 "Font" => dictionary! {
2934 "F1" => Object::Reference(font_id),
2935 },
2936 };
2937 doc.objects.insert(
2938 page_id,
2939 Object::Dictionary(dictionary! {
2940 "Type" => "Page",
2941 "Parent" => Object::Reference(pages_id),
2942 "Resources" => resources,
2943 }),
2944 );
2945 doc.objects.insert(
2946 pages_id,
2947 Object::Dictionary(dictionary! {
2948 "Type" => "Pages",
2949 "Kids" => vec![Object::Reference(page_id)],
2950 "Count" => Object::Integer(1),
2951 }),
2952 );
2953 assert!(
2954 page_has_identity_h_no_tounicode(&doc, page_id),
2955 "Should flag: low GID CIDs, no cmap, no passthrough"
2956 );
2957 }
2958
2959 #[test]
2960 fn test_scan_content_counts_tf_operators() {
2961 let mut uchars = HashSet::new();
2962 let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
2963 let (ops, _, _, fonts) =
2964 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2965 assert_eq!(ops, 2);
2966 assert_eq!(fonts, 2);
2967 }
2968
2969 #[test]
2970 fn test_tf_without_trailing_whitespace() {
2971 let mut uchars = HashSet::new();
2974
2975 let content = b"BT /F1 25 Tf[<01>1<02>-1] TJ ET";
2977 let (ops, _, _, fonts) =
2978 scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
2979 assert_eq!(fonts, 1, "Tf followed by '[' should be counted");
2980 assert_eq!(ops, 1);
2981
2982 uchars.clear();
2984 let content2 = b"BT /F1 12 Tf(Hello) Tj ET";
2985 let (ops2, _, _, fonts2) =
2986 scan_content_for_text_operators(content2, &mut uchars, &mut HashSet::new());
2987 assert_eq!(fonts2, 1, "Tf followed by '(' should be counted");
2988 assert_eq!(ops2, 1);
2989
2990 uchars.clear();
2992 let content3 = b"BT /F1 12 Tf<0102> Tj ET";
2993 let (ops3, _, _, fonts3) =
2994 scan_content_for_text_operators(content3, &mut uchars, &mut HashSet::new());
2995 assert_eq!(fonts3, 1, "Tf followed by '<' should be counted");
2996 assert_eq!(ops3, 1);
2997
2998 uchars.clear();
3000 let content4 = b"BT /F1 12 Tf/F2 10 Tf (x) Tj ET";
3001 let (_, _, _, fonts4) =
3002 scan_content_for_text_operators(content4, &mut uchars, &mut HashSet::new());
3003 assert_eq!(fonts4, 2, "Tf followed by '/' should be counted");
3004 }
3005
3006 #[test]
3007 fn test_newspaper_heuristic_thresholds() {
3008 let text_ops = 3500u32;
3010 let font_changes = 150u32;
3011 let ratio = font_changes as f32 / text_ops as f32;
3012 assert!(text_ops >= 1500);
3013 assert!(font_changes >= 50);
3014 assert!(ratio < 0.15); let text_ops = 1800u32;
3018 let font_changes = 540u32;
3019 let ratio = font_changes as f32 / text_ops as f32;
3020 assert!(text_ops >= 1500);
3021 assert!(font_changes >= 50);
3022 assert!(ratio >= 0.15); let text_ops = 300u32;
3026 let font_changes = 50u32;
3027 assert!(text_ops < 1500);
3028 }
3029
3030 #[test]
3031 fn test_looks_like_scan_requires_all_conditions() {
3032 let image_count = 1u32;
3040 let text_operator_count = 135u32;
3041 let unique_alphanum_chars = 58u32;
3042 let looks_like_scan =
3043 image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3044 assert!(
3045 !looks_like_scan,
3046 "text page with one figure should not be flagged as scan"
3047 );
3048
3049 let image_count = 1u32;
3051 let text_operator_count = 3u32;
3052 let unique_alphanum_chars = 2u32;
3053 let looks_like_scan =
3054 image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3055 assert!(
3056 looks_like_scan,
3057 "single image with no real text should be flagged as scan"
3058 );
3059
3060 let image_count = 1u32;
3063 let text_operator_count = 200u32;
3064 let unique_alphanum_chars = 40u32;
3065 let looks_like_scan =
3066 image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3067 assert!(
3068 !looks_like_scan,
3069 "OCR overlay page should not be flagged as scan"
3070 );
3071
3072 let image_count = 4u32;
3074 let text_operator_count = 25u32;
3075 let unique_alphanum_chars = 1u32;
3076 let looks_like_scan =
3077 image_count <= 1 && text_operator_count < 50 && unique_alphanum_chars < 10;
3078 assert!(
3079 !looks_like_scan,
3080 "multiple images page should not match single-image scan pattern"
3081 );
3082 }
3083
3084 #[test]
3087 fn test_has_vector_text_real_text_with_decorations_not_flagged() {
3088 let path_ops = 8354u32;
3092 let text_ops = 41u32;
3093 let unique_alphanum_chars = 53u32;
3094 let has_vector_text = path_ops >= 1000
3095 && path_ops > text_ops.saturating_mul(200)
3096 && unique_alphanum_chars < 30;
3097 assert!(
3098 !has_vector_text,
3099 "page with real selectable text alongside decorative paths should not be vector_text"
3100 );
3101 }
3102
3103 #[test]
3104 fn test_has_vector_text_outlined_glyphs_still_flagged() {
3105 let path_ops = 8000u32;
3108 let text_ops = 5u32;
3109 let unique_alphanum_chars = 4u32;
3110 let has_vector_text = path_ops >= 1000
3111 && path_ops > text_ops.saturating_mul(200)
3112 && unique_alphanum_chars < 30;
3113 assert!(
3114 has_vector_text,
3115 "true outlined-text page should still be flagged as vector_text"
3116 );
3117 }
3118
3119 #[test]
3122 fn test_identity_h_with_supplementary_decodable_font_not_flagged() {
3123 use lopdf::dictionary;
3127 let mut doc = Document::with_version("1.4");
3128 let pages_id = doc.new_object_id();
3129 let page_id = doc.new_object_id();
3130
3131 let bad_font_id = doc.add_object(dictionary! {
3133 "Type" => "Font",
3134 "Subtype" => Object::Name(b"Type0".to_vec()),
3135 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3136 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3137 });
3138
3139 let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3141 dictionary! {},
3142 b"fake cmap".to_vec(),
3143 )));
3144 let good_font_id = doc.add_object(dictionary! {
3145 "Type" => "Font",
3146 "Subtype" => Object::Name(b"Type1".to_vec()),
3147 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3148 "ToUnicode" => Object::Reference(cmap_id),
3149 });
3150
3151 let resources = dictionary! {
3152 "Font" => dictionary! {
3153 "F1" => Object::Reference(bad_font_id),
3154 "F2" => Object::Reference(good_font_id),
3155 },
3156 };
3157 doc.objects.insert(
3158 page_id,
3159 Object::Dictionary(dictionary! {
3160 "Type" => "Page",
3161 "Parent" => Object::Reference(pages_id),
3162 "Resources" => resources,
3163 }),
3164 );
3165 doc.objects.insert(
3166 pages_id,
3167 Object::Dictionary(dictionary! {
3168 "Type" => "Pages",
3169 "Kids" => vec![Object::Reference(page_id)],
3170 "Count" => Object::Integer(1),
3171 }),
3172 );
3173
3174 assert!(
3175 !page_has_identity_h_no_tounicode(&doc, page_id),
3176 "page with supplementary undecodable Identity-H but decodable Type1 should not flag"
3177 );
3178 }
3179
3180 #[test]
3181 fn test_identity_h_with_no_other_fonts_still_flagged() {
3182 use lopdf::dictionary;
3185 let mut doc = Document::with_version("1.4");
3186 let pages_id = doc.new_object_id();
3187 let page_id = doc.new_object_id();
3188 let bad_font_id = doc.add_object(dictionary! {
3189 "Type" => "Font",
3190 "Subtype" => Object::Name(b"Type0".to_vec()),
3191 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3192 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3193 });
3194 let resources = dictionary! {
3195 "Font" => dictionary! {
3196 "F1" => Object::Reference(bad_font_id),
3197 },
3198 };
3199 doc.objects.insert(
3200 page_id,
3201 Object::Dictionary(dictionary! {
3202 "Type" => "Page",
3203 "Parent" => Object::Reference(pages_id),
3204 "Resources" => resources,
3205 }),
3206 );
3207 doc.objects.insert(
3208 pages_id,
3209 Object::Dictionary(dictionary! {
3210 "Type" => "Pages",
3211 "Kids" => vec![Object::Reference(page_id)],
3212 "Count" => Object::Integer(1),
3213 }),
3214 );
3215 assert!(
3216 page_has_identity_h_no_tounicode(&doc, page_id),
3217 "page with only undecodable Identity-H must still be flagged"
3218 );
3219 }
3220
3221 #[test]
3224 fn test_page_has_decodable_text_fonts_type1() {
3225 use lopdf::dictionary;
3227 let mut doc = Document::with_version("1.4");
3228 let pages_id = doc.new_object_id();
3229 let page_id = doc.new_object_id();
3230 let font_id = doc.add_object(dictionary! {
3231 "Type" => "Font",
3232 "Subtype" => Object::Name(b"Type1".to_vec()),
3233 "BaseFont" => Object::Name(b"Times-Roman".to_vec()),
3234 });
3235 let resources = dictionary! {
3236 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3237 };
3238 doc.objects.insert(
3239 page_id,
3240 Object::Dictionary(dictionary! {
3241 "Type" => "Page",
3242 "Parent" => Object::Reference(pages_id),
3243 "Resources" => resources,
3244 }),
3245 );
3246 doc.objects.insert(
3247 pages_id,
3248 Object::Dictionary(dictionary! {
3249 "Type" => "Pages",
3250 "Kids" => vec![Object::Reference(page_id)],
3251 "Count" => Object::Integer(1),
3252 }),
3253 );
3254 assert!(page_has_decodable_text_fonts(&doc, page_id));
3255 }
3256
3257 #[test]
3258 fn test_page_has_decodable_text_fonts_type0_with_tounicode() {
3259 use lopdf::dictionary;
3262 let mut doc = Document::with_version("1.4");
3263 let pages_id = doc.new_object_id();
3264 let page_id = doc.new_object_id();
3265 let cmap_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3266 dictionary! {},
3267 b"fake cmap".to_vec(),
3268 )));
3269 let font_id = doc.add_object(dictionary! {
3270 "Type" => "Font",
3271 "Subtype" => Object::Name(b"Type0".to_vec()),
3272 "BaseFont" => Object::Name(b"BentonSans-Bold".to_vec()),
3273 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3274 "ToUnicode" => Object::Reference(cmap_id),
3275 });
3276 let resources = dictionary! {
3277 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3278 };
3279 doc.objects.insert(
3280 page_id,
3281 Object::Dictionary(dictionary! {
3282 "Type" => "Page",
3283 "Parent" => Object::Reference(pages_id),
3284 "Resources" => resources,
3285 }),
3286 );
3287 doc.objects.insert(
3288 pages_id,
3289 Object::Dictionary(dictionary! {
3290 "Type" => "Pages",
3291 "Kids" => vec![Object::Reference(page_id)],
3292 "Count" => Object::Integer(1),
3293 }),
3294 );
3295 assert!(page_has_decodable_text_fonts(&doc, page_id));
3296 }
3297
3298 #[test]
3299 fn test_page_has_decodable_text_fonts_undecodable_only_returns_false() {
3300 use lopdf::dictionary;
3303 let mut doc = Document::with_version("1.4");
3304 let pages_id = doc.new_object_id();
3305 let page_id = doc.new_object_id();
3306 let font_id = doc.add_object(dictionary! {
3307 "Type" => "Font",
3308 "Subtype" => Object::Name(b"Type0".to_vec()),
3309 "BaseFont" => Object::Name(b"ABCDEF+UnknownFont".to_vec()),
3310 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3311 });
3312 let resources = dictionary! {
3313 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3314 };
3315 doc.objects.insert(
3316 page_id,
3317 Object::Dictionary(dictionary! {
3318 "Type" => "Page",
3319 "Parent" => Object::Reference(pages_id),
3320 "Resources" => resources,
3321 }),
3322 );
3323 doc.objects.insert(
3324 pages_id,
3325 Object::Dictionary(dictionary! {
3326 "Type" => "Pages",
3327 "Kids" => vec![Object::Reference(page_id)],
3328 "Count" => Object::Integer(1),
3329 }),
3330 );
3331 assert!(!page_has_decodable_text_fonts(&doc, page_id));
3332 }
3333
3334 #[test]
3337 fn test_looks_like_scan_overridden_by_decodable_cid_text() {
3338 let image_count = 1u32;
3347 let text_operator_count = 36u32;
3348 let unique_alphanum_chars = 8u32;
3349 let has_decodable_text_fonts = true;
3350
3351 let alphanum_low =
3352 unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3353 let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3354 assert!(
3355 !looks_like_scan,
3356 "CID-encoded decodable text page should not be flagged as scan"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_looks_like_scan_keeps_flag_when_no_decodable_fonts() {
3362 let image_count = 1u32;
3365 let text_operator_count = 36u32;
3366 let unique_alphanum_chars = 8u32;
3367 let has_decodable_text_fonts = false;
3368
3369 let alphanum_low =
3370 unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3371 let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3372 assert!(
3373 looks_like_scan,
3374 "page with no decodable fonts should remain flagged as scan"
3375 );
3376 }
3377
3378 #[test]
3379 fn test_looks_like_scan_keeps_flag_with_few_text_ops_even_if_decodable() {
3380 let image_count = 1u32;
3384 let text_operator_count = 4u32;
3385 let unique_alphanum_chars = 2u32;
3386 let has_decodable_text_fonts = true;
3387
3388 let alphanum_low =
3389 unique_alphanum_chars < 10 && !(has_decodable_text_fonts && text_operator_count >= 10);
3390 let looks_like_scan = image_count <= 1 && text_operator_count < 50 && alphanum_low;
3391 assert!(
3392 looks_like_scan,
3393 "scanned page with small text overlay (page number) should still flag"
3394 );
3395 }
3396
3397 #[test]
3400 fn test_extract_font_name_basic() {
3401 let content = b"/F1 12 Tf";
3403 let name = extract_font_name_before_tf(content, 6, 0); assert_eq!(name, Some(b"F1".to_vec()));
3405 }
3406
3407 #[test]
3408 fn test_extract_font_name_long_name() {
3409 let content = b"/ArialMT-Bold 9.5 Tf";
3410 let name = extract_font_name_before_tf(content, 18, 0);
3411 assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
3412 }
3413
3414 #[test]
3415 fn test_scan_content_collects_used_font_names() {
3416 let mut uchars = HashSet::new();
3417 let mut fonts = HashSet::new();
3418 let content = b"BT /F1 12 Tf (Hello) Tj /F2 10 Tf (World) Tj ET";
3419 scan_content_for_text_operators(content, &mut uchars, &mut fonts);
3420 assert!(fonts.contains(&b"F1".to_vec()), "should collect F1");
3421 assert!(fonts.contains(&b"F2".to_vec()), "should collect F2");
3422 assert_eq!(fonts.len(), 2);
3423 }
3424
3425 #[test]
3428 fn test_p1_unused_decodable_font_does_not_save_undecodable_page() {
3429 use lopdf::dictionary;
3436 let mut doc = Document::with_version("1.4");
3437 let pages_id = doc.new_object_id();
3438 let page_id = doc.new_object_id();
3439
3440 let bad_font_id = doc.add_object(dictionary! {
3442 "Type" => "Font",
3443 "Subtype" => Object::Name(b"Type0".to_vec()),
3444 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3445 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3446 });
3447 let good_font_id = doc.add_object(dictionary! {
3449 "Type" => "Font",
3450 "Subtype" => Object::Name(b"Type1".to_vec()),
3451 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3452 });
3453 let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
3455 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3456 dictionary! {},
3457 content_data.to_vec(),
3458 )));
3459 let resources = dictionary! {
3460 "Font" => dictionary! {
3461 "F1" => Object::Reference(bad_font_id),
3462 "F2" => Object::Reference(good_font_id),
3463 },
3464 };
3465 doc.objects.insert(
3466 page_id,
3467 Object::Dictionary(dictionary! {
3468 "Type" => "Page",
3469 "Parent" => Object::Reference(pages_id),
3470 "Resources" => resources,
3471 "Contents" => Object::Reference(content_id),
3472 }),
3473 );
3474 doc.objects.insert(
3475 pages_id,
3476 Object::Dictionary(dictionary! {
3477 "Type" => "Pages",
3478 "Kids" => vec![Object::Reference(page_id)],
3479 "Count" => Object::Integer(1),
3480 }),
3481 );
3482
3483 let analysis = analyze_page_content(&doc, page_id);
3484 assert!(
3485 analysis.has_identity_h_no_tounicode,
3486 "P1: page using only undecodable Identity-H should be flagged, even though \
3487 Resources also contains unused decodable Type1"
3488 );
3489 assert!(
3491 !page_has_identity_h_no_tounicode(&doc, page_id),
3492 "sanity: old resource-based check incorrectly sees unused decodable font"
3493 );
3494 }
3495
3496 #[test]
3497 fn test_p1_used_decodable_font_still_unflagged() {
3498 use lopdf::dictionary;
3500 let mut doc = Document::with_version("1.4");
3501 let pages_id = doc.new_object_id();
3502 let page_id = doc.new_object_id();
3503
3504 let bad_font_id = doc.add_object(dictionary! {
3505 "Type" => "Font",
3506 "Subtype" => Object::Name(b"Type0".to_vec()),
3507 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3508 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3509 });
3510 let good_font_id = doc.add_object(dictionary! {
3511 "Type" => "Font",
3512 "Subtype" => Object::Name(b"Type1".to_vec()),
3513 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3514 });
3515 let content_data = b"BT /F1 12 Tf <0102> Tj /F2 10 Tf (Hello world) Tj ET";
3517 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3518 dictionary! {},
3519 content_data.to_vec(),
3520 )));
3521 let resources = dictionary! {
3522 "Font" => dictionary! {
3523 "F1" => Object::Reference(bad_font_id),
3524 "F2" => Object::Reference(good_font_id),
3525 },
3526 };
3527 doc.objects.insert(
3528 page_id,
3529 Object::Dictionary(dictionary! {
3530 "Type" => "Page",
3531 "Parent" => Object::Reference(pages_id),
3532 "Resources" => resources,
3533 "Contents" => Object::Reference(content_id),
3534 }),
3535 );
3536 doc.objects.insert(
3537 pages_id,
3538 Object::Dictionary(dictionary! {
3539 "Type" => "Pages",
3540 "Kids" => vec![Object::Reference(page_id)],
3541 "Count" => Object::Integer(1),
3542 }),
3543 );
3544
3545 let analysis = analyze_page_content(&doc, page_id);
3546 assert!(
3547 !analysis.has_identity_h_no_tounicode,
3548 "page using both undecodable and decodable fonts should NOT be flagged"
3549 );
3550 }
3551
3552 fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
3558 use lopdf::dictionary;
3559 let mut doc = Document::with_version("1.4");
3560 let pages_id = doc.new_object_id();
3561 let page_id = doc.new_object_id();
3562
3563 let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3564 dictionary! {
3565 "Type" => "XObject",
3566 "Subtype" => Object::Name(b"Image".to_vec()),
3567 "Width" => Object::Integer(1500),
3568 "Height" => Object::Integer(2383),
3569 },
3570 Vec::new(),
3571 )));
3572 let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3573 dictionary! {
3574 "Type" => "XObject",
3575 "Subtype" => Object::Name(b"Form".to_vec()),
3576 "Resources" => dictionary! {
3577 "XObject" => dictionary! {
3578 "Im0" => Object::Reference(image_id),
3579 },
3580 },
3581 },
3582 b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
3583 )));
3584 let font_id = doc.add_object(dictionary! {
3585 "Type" => "Font",
3586 "Subtype" => Object::Name(b"Type1".to_vec()),
3587 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3588 });
3589
3590 let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
3591 for line in masthead_lines {
3592 content.extend_from_slice(format!("({line}) Tj ").as_bytes());
3593 }
3594 content.extend_from_slice(b"ET");
3595 let content_id =
3596 doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
3597
3598 doc.objects.insert(
3599 page_id,
3600 Object::Dictionary(dictionary! {
3601 "Type" => "Page",
3602 "Parent" => Object::Reference(pages_id),
3603 "MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
3604 "Resources" => dictionary! {
3605 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3606 "XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
3607 },
3608 "Contents" => Object::Reference(content_id),
3609 }),
3610 );
3611 doc.objects.insert(
3612 pages_id,
3613 Object::Dictionary(dictionary! {
3614 "Type" => "Pages",
3615 "Kids" => vec![Object::Reference(page_id)],
3616 "Count" => Object::Integer(1),
3617 }),
3618 );
3619 (doc, page_id)
3620 }
3621
3622 #[test]
3623 fn test_masthead_over_form_wrapped_scan_needs_ocr() {
3624 let (doc, page_id) = masthead_scan_page(&[
3629 "18",
3630 "FINANCIAL EXPRESS",
3631 "WWW.FINANCIALEXPRESS.COM",
3632 "FRIDAY, DECEMBER 13, 2024",
3633 ]);
3634 let analysis = analyze_page_content(&doc, page_id);
3635 assert!(
3636 analysis.has_template_image,
3637 "sanity: full-page image inside the form must be found"
3638 );
3639 assert!(
3640 analysis.unique_alphanum_chars >= 10,
3641 "sanity: masthead text is diverse, alphanum_low cannot fire"
3642 );
3643 let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
3644 assert!(
3645 needs_ocr,
3646 "template image + text below the pages_with_text floor is a scan"
3647 );
3648 }
3649
3650 #[test]
3651 fn test_text_page_over_background_image_stays_native() {
3652 let lines: Vec<String> = (0..12)
3656 .map(|i| format!("Paragraph line {i} with ordinary body text"))
3657 .collect();
3658 let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
3659 let (doc, page_id) = masthead_scan_page(&refs);
3660 let analysis = analyze_page_content(&doc, page_id);
3661 assert!(
3662 analysis.has_template_image,
3663 "sanity: background image found"
3664 );
3665 assert!(
3666 analysis.text_operator_count >= 10,
3667 "sanity: body text clears the floor"
3668 );
3669 let needs_ocr = page_ocr_signals(&doc, page_id).template_image_needs_ocr;
3670 assert!(
3671 !needs_ocr,
3672 "a text page with a background image must stay native"
3673 );
3674 }
3675
3676 #[test]
3679 fn test_p2_decodable_font_in_xobject_unflagged() {
3680 use lopdf::dictionary;
3685 let mut doc = Document::with_version("1.4");
3686 let pages_id = doc.new_object_id();
3687 let page_id = doc.new_object_id();
3688
3689 let bad_font_id = doc.add_object(dictionary! {
3691 "Type" => "Font",
3692 "Subtype" => Object::Name(b"Type0".to_vec()),
3693 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3694 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3695 });
3696 let good_font_id = doc.add_object(dictionary! {
3698 "Type" => "Font",
3699 "Subtype" => Object::Name(b"Type1".to_vec()),
3700 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3701 });
3702
3703 let xobj_content = b"BT /F2 10 Tf (Hello from XObject) Tj ET";
3705 let xobj_stream = lopdf::Stream::new(
3706 dictionary! {
3707 "Type" => "XObject",
3708 "Subtype" => Object::Name(b"Form".to_vec()),
3709 "Resources" => dictionary! {
3710 "Font" => dictionary! {
3711 "F2" => Object::Reference(good_font_id),
3712 },
3713 },
3714 },
3715 xobj_content.to_vec(),
3716 );
3717 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3718
3719 let content_data = b"BT /F1 12 Tf <0102> Tj ET /XF1 Do";
3721 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3722 dictionary! {},
3723 content_data.to_vec(),
3724 )));
3725 let resources = dictionary! {
3726 "Font" => dictionary! {
3727 "F1" => Object::Reference(bad_font_id),
3728 },
3729 "XObject" => dictionary! {
3730 "XF1" => Object::Reference(xobj_id),
3731 },
3732 };
3733 doc.objects.insert(
3734 page_id,
3735 Object::Dictionary(dictionary! {
3736 "Type" => "Page",
3737 "Parent" => Object::Reference(pages_id),
3738 "Resources" => resources,
3739 "Contents" => Object::Reference(content_id),
3740 }),
3741 );
3742 doc.objects.insert(
3743 pages_id,
3744 Object::Dictionary(dictionary! {
3745 "Type" => "Pages",
3746 "Kids" => vec![Object::Reference(page_id)],
3747 "Count" => Object::Integer(1),
3748 }),
3749 );
3750
3751 let analysis = analyze_page_content(&doc, page_id);
3752 assert!(
3753 !analysis.has_identity_h_no_tounicode,
3754 "P2: page with decodable font in Form XObject should NOT be flagged — \
3755 the XObject has decodable text"
3756 );
3757 assert!(
3758 analysis.has_decodable_text_fonts,
3759 "P2: should detect decodable fonts from Form XObject Resources"
3760 );
3761 }
3762
3763 #[test]
3764 fn test_p2_undecodable_font_only_in_xobject_flagged() {
3765 use lopdf::dictionary;
3769 let mut doc = Document::with_version("1.4");
3770 let pages_id = doc.new_object_id();
3771 let page_id = doc.new_object_id();
3772
3773 let good_font_id = doc.add_object(dictionary! {
3775 "Type" => "Font",
3776 "Subtype" => Object::Name(b"Type1".to_vec()),
3777 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3778 });
3779 let bad_font_id = doc.add_object(dictionary! {
3781 "Type" => "Font",
3782 "Subtype" => Object::Name(b"Type0".to_vec()),
3783 "BaseFont" => Object::Name(b"ABCDEF+Cosmos-Medium".to_vec()),
3784 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3785 });
3786
3787 let xobj_content = b"BT /F2 10 Tf <0102030405> Tj ET";
3789 let xobj_stream = lopdf::Stream::new(
3790 dictionary! {
3791 "Type" => "XObject",
3792 "Subtype" => Object::Name(b"Form".to_vec()),
3793 "Resources" => dictionary! {
3794 "Font" => dictionary! {
3795 "F2" => Object::Reference(bad_font_id),
3796 },
3797 },
3798 },
3799 xobj_content.to_vec(),
3800 );
3801 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3802
3803 let content_data = b"/XF1 Do";
3805 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3806 dictionary! {},
3807 content_data.to_vec(),
3808 )));
3809 let resources = dictionary! {
3810 "Font" => dictionary! {
3811 "F1" => Object::Reference(good_font_id),
3812 },
3813 "XObject" => dictionary! {
3814 "XF1" => Object::Reference(xobj_id),
3815 },
3816 };
3817 doc.objects.insert(
3818 page_id,
3819 Object::Dictionary(dictionary! {
3820 "Type" => "Page",
3821 "Parent" => Object::Reference(pages_id),
3822 "Resources" => resources,
3823 "Contents" => Object::Reference(content_id),
3824 }),
3825 );
3826 doc.objects.insert(
3827 pages_id,
3828 Object::Dictionary(dictionary! {
3829 "Type" => "Pages",
3830 "Kids" => vec![Object::Reference(page_id)],
3831 "Count" => Object::Integer(1),
3832 }),
3833 );
3834
3835 let analysis = analyze_page_content(&doc, page_id);
3836 assert!(
3837 analysis.has_identity_h_no_tounicode,
3838 "P2 negative: only used font is undecodable (in XObject) — must flag, \
3839 even though page-level Resources has an unused decodable Type1"
3840 );
3841 }
3842
3843 #[test]
3844 fn test_p2_decodable_fonts_detected_from_xobject() {
3845 use lopdf::dictionary;
3848 let mut doc = Document::with_version("1.4");
3849 let pages_id = doc.new_object_id();
3850 let page_id = doc.new_object_id();
3851
3852 let good_font_id = doc.add_object(dictionary! {
3854 "Type" => "Font",
3855 "Subtype" => Object::Name(b"Type1".to_vec()),
3856 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3857 });
3858
3859 let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
3860 let xobj_stream = lopdf::Stream::new(
3861 dictionary! {
3862 "Type" => "XObject",
3863 "Subtype" => Object::Name(b"Form".to_vec()),
3864 "Resources" => dictionary! {
3865 "Font" => dictionary! {
3866 "F1" => Object::Reference(good_font_id),
3867 },
3868 },
3869 },
3870 xobj_content.to_vec(),
3871 );
3872 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3873
3874 let content_data = b"/XF1 Do";
3875 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3876 dictionary! {},
3877 content_data.to_vec(),
3878 )));
3879 let resources = dictionary! {
3880 "XObject" => dictionary! {
3881 "XF1" => Object::Reference(xobj_id),
3882 },
3883 };
3884 doc.objects.insert(
3885 page_id,
3886 Object::Dictionary(dictionary! {
3887 "Type" => "Page",
3888 "Parent" => Object::Reference(pages_id),
3889 "Resources" => resources,
3890 "Contents" => Object::Reference(content_id),
3891 }),
3892 );
3893 doc.objects.insert(
3894 pages_id,
3895 Object::Dictionary(dictionary! {
3896 "Type" => "Pages",
3897 "Kids" => vec![Object::Reference(page_id)],
3898 "Count" => Object::Integer(1),
3899 }),
3900 );
3901
3902 let analysis = analyze_page_content(&doc, page_id);
3903 assert!(
3904 analysis.has_decodable_text_fonts,
3905 "P2: decodable font from Form XObject should be detected"
3906 );
3907 assert!(
3908 !analysis.has_identity_h_no_tounicode,
3909 "P2: no Identity-H font used, should not flag"
3910 );
3911 }
3912
3913 #[test]
3916 fn test_p1_name_collision_xobject_decodable_page_undecodable() {
3917 use lopdf::dictionary;
3927 let mut doc = Document::with_version("1.4");
3928 let pages_id = doc.new_object_id();
3929 let page_id = doc.new_object_id();
3930
3931 let bad_font_id = doc.add_object(dictionary! {
3933 "Type" => "Font",
3934 "Subtype" => Object::Name(b"Type0".to_vec()),
3935 "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
3936 "Encoding" => Object::Name(b"Identity-H".to_vec()),
3937 });
3938
3939 let good_font_id = doc.add_object(dictionary! {
3941 "Type" => "Font",
3942 "Subtype" => Object::Name(b"Type1".to_vec()),
3943 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3944 });
3945
3946 let xobj_content = b"BT /F1 10 Tf (Hello from XObject) Tj ET";
3948 let xobj_stream = lopdf::Stream::new(
3949 dictionary! {
3950 "Type" => "XObject",
3951 "Subtype" => Object::Name(b"Form".to_vec()),
3952 "Resources" => dictionary! {
3953 "Font" => dictionary! {
3954 "F1" => Object::Reference(good_font_id),
3955 },
3956 },
3957 },
3958 xobj_content.to_vec(),
3959 );
3960 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3961
3962 let content_data = b"/XF1 Do";
3964 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
3965 dictionary! {},
3966 content_data.to_vec(),
3967 )));
3968 let resources = dictionary! {
3969 "Font" => dictionary! {
3970 "F1" => Object::Reference(bad_font_id),
3971 },
3972 "XObject" => dictionary! {
3973 "XF1" => Object::Reference(xobj_id),
3974 },
3975 };
3976 doc.objects.insert(
3977 page_id,
3978 Object::Dictionary(dictionary! {
3979 "Type" => "Page",
3980 "Parent" => Object::Reference(pages_id),
3981 "Resources" => resources,
3982 "Contents" => Object::Reference(content_id),
3983 }),
3984 );
3985 doc.objects.insert(
3986 pages_id,
3987 Object::Dictionary(dictionary! {
3988 "Type" => "Pages",
3989 "Kids" => vec![Object::Reference(page_id)],
3990 "Count" => Object::Integer(1),
3991 }),
3992 );
3993
3994 let analysis = analyze_page_content(&doc, page_id);
3995 assert!(
3996 !analysis.has_identity_h_no_tounicode,
3997 "P1 name collision: XObject uses /F1 which resolves to decodable Type1 \
3998 in XObject scope — should NOT flag even though page's /F1 is undecodable"
3999 );
4000 assert!(
4001 analysis.has_decodable_text_fonts,
4002 "P1 name collision: XObject's /F1 is decodable Type1"
4003 );
4004 }
4005
4006 #[test]
4007 fn test_p1_name_collision_xobject_undecodable_page_decodable() {
4008 use lopdf::dictionary;
4017 let mut doc = Document::with_version("1.4");
4018 let pages_id = doc.new_object_id();
4019 let page_id = doc.new_object_id();
4020
4021 let good_font_id = doc.add_object(dictionary! {
4023 "Type" => "Font",
4024 "Subtype" => Object::Name(b"Type1".to_vec()),
4025 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4026 });
4027
4028 let bad_font_id = doc.add_object(dictionary! {
4030 "Type" => "Font",
4031 "Subtype" => Object::Name(b"Type0".to_vec()),
4032 "BaseFont" => Object::Name(b"XYZDEF+BadCIDFont".to_vec()),
4033 "Encoding" => Object::Name(b"Identity-H".to_vec()),
4034 });
4035
4036 let xobj_content = b"BT /F1 10 Tf <0102030405> Tj ET";
4038 let xobj_stream = lopdf::Stream::new(
4039 dictionary! {
4040 "Type" => "XObject",
4041 "Subtype" => Object::Name(b"Form".to_vec()),
4042 "Resources" => dictionary! {
4043 "Font" => dictionary! {
4044 "F1" => Object::Reference(bad_font_id),
4045 },
4046 },
4047 },
4048 xobj_content.to_vec(),
4049 );
4050 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4051
4052 let content_data = b"/XF1 Do";
4054 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4055 dictionary! {},
4056 content_data.to_vec(),
4057 )));
4058 let resources = dictionary! {
4059 "Font" => dictionary! {
4060 "F1" => Object::Reference(good_font_id),
4061 },
4062 "XObject" => dictionary! {
4063 "XF1" => Object::Reference(xobj_id),
4064 },
4065 };
4066 doc.objects.insert(
4067 page_id,
4068 Object::Dictionary(dictionary! {
4069 "Type" => "Page",
4070 "Parent" => Object::Reference(pages_id),
4071 "Resources" => resources,
4072 "Contents" => Object::Reference(content_id),
4073 }),
4074 );
4075 doc.objects.insert(
4076 pages_id,
4077 Object::Dictionary(dictionary! {
4078 "Type" => "Pages",
4079 "Kids" => vec![Object::Reference(page_id)],
4080 "Count" => Object::Integer(1),
4081 }),
4082 );
4083
4084 let analysis = analyze_page_content(&doc, page_id);
4085 assert!(
4086 analysis.has_identity_h_no_tounicode,
4087 "P1 inverse: XObject uses /F1 which resolves to undecodable Identity-H \
4088 in XObject scope — MUST flag even though page's /F1 is decodable Type1"
4089 );
4090 }
4091
4092 #[test]
4095 fn test_p2_indirect_xobject_resources() {
4096 use lopdf::dictionary;
4102 let mut doc = Document::with_version("1.4");
4103 let pages_id = doc.new_object_id();
4104 let page_id = doc.new_object_id();
4105
4106 let font_id = doc.add_object(dictionary! {
4108 "Type" => "Font",
4109 "Subtype" => Object::Name(b"Type1".to_vec()),
4110 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4111 });
4112
4113 let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4115 "Font" => dictionary! {
4116 "F1" => Object::Reference(font_id),
4117 },
4118 }));
4119
4120 let xobj_content = b"BT /F1 10 Tf (Hello) Tj ET";
4122 let xobj_stream = lopdf::Stream::new(
4123 dictionary! {
4124 "Type" => "XObject",
4125 "Subtype" => Object::Name(b"Form".to_vec()),
4126 "Resources" => Object::Reference(xobj_resources_id),
4127 },
4128 xobj_content.to_vec(),
4129 );
4130 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4131
4132 let content_data = b"/XF1 Do";
4133 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4134 dictionary! {},
4135 content_data.to_vec(),
4136 )));
4137 let resources = dictionary! {
4138 "XObject" => dictionary! {
4139 "XF1" => Object::Reference(xobj_id),
4140 },
4141 };
4142 doc.objects.insert(
4143 page_id,
4144 Object::Dictionary(dictionary! {
4145 "Type" => "Page",
4146 "Parent" => Object::Reference(pages_id),
4147 "Resources" => resources,
4148 "Contents" => Object::Reference(content_id),
4149 }),
4150 );
4151 doc.objects.insert(
4152 pages_id,
4153 Object::Dictionary(dictionary! {
4154 "Type" => "Pages",
4155 "Kids" => vec![Object::Reference(page_id)],
4156 "Count" => Object::Integer(1),
4157 }),
4158 );
4159
4160 let analysis = analyze_page_content(&doc, page_id);
4161 assert!(
4162 analysis.has_decodable_text_fonts,
4163 "P2 indirect: decodable font behind indirect /Resources must be discovered"
4164 );
4165 assert_eq!(
4166 analysis.text_operator_count, 1,
4167 "P2 indirect: text ops from XObject content should be counted"
4168 );
4169 }
4170
4171 #[test]
4174 fn test_p1_p2_combined_indirect_resources_with_name_collision() {
4175 use lopdf::dictionary;
4183 let mut doc = Document::with_version("1.4");
4184 let pages_id = doc.new_object_id();
4185 let page_id = doc.new_object_id();
4186
4187 let bad_font_id = doc.add_object(dictionary! {
4189 "Type" => "Font",
4190 "Subtype" => Object::Name(b"Type0".to_vec()),
4191 "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4192 "Encoding" => Object::Name(b"Identity-H".to_vec()),
4193 });
4194
4195 let good_font_id = doc.add_object(dictionary! {
4197 "Type" => "Font",
4198 "Subtype" => Object::Name(b"Type1".to_vec()),
4199 "BaseFont" => Object::Name(b"TimesNewRoman".to_vec()),
4200 });
4201
4202 let xobj_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4204 "Font" => dictionary! {
4205 "F1" => Object::Reference(good_font_id),
4206 },
4207 }));
4208
4209 let xobj_content = b"BT /F1 12 Tf (Decodable text in XObject) Tj ET";
4210 let xobj_stream = lopdf::Stream::new(
4211 dictionary! {
4212 "Type" => "XObject",
4213 "Subtype" => Object::Name(b"Form".to_vec()),
4214 "Resources" => Object::Reference(xobj_resources_id),
4215 },
4216 xobj_content.to_vec(),
4217 );
4218 let xobj_id = doc.add_object(Object::Stream(xobj_stream));
4219
4220 let content_data = b"/XF1 Do";
4221 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4222 dictionary! {},
4223 content_data.to_vec(),
4224 )));
4225 let resources = dictionary! {
4226 "Font" => dictionary! {
4227 "F1" => Object::Reference(bad_font_id),
4228 },
4229 "XObject" => dictionary! {
4230 "XF1" => Object::Reference(xobj_id),
4231 },
4232 };
4233 doc.objects.insert(
4234 page_id,
4235 Object::Dictionary(dictionary! {
4236 "Type" => "Page",
4237 "Parent" => Object::Reference(pages_id),
4238 "Resources" => resources,
4239 "Contents" => Object::Reference(content_id),
4240 }),
4241 );
4242 doc.objects.insert(
4243 pages_id,
4244 Object::Dictionary(dictionary! {
4245 "Type" => "Pages",
4246 "Kids" => vec![Object::Reference(page_id)],
4247 "Count" => Object::Integer(1),
4248 }),
4249 );
4250
4251 let analysis = analyze_page_content(&doc, page_id);
4252 assert!(
4253 !analysis.has_identity_h_no_tounicode,
4254 "P1+P2 combined: XObject /F1 resolves to decodable Type1 via indirect \
4255 Resources — should NOT flag despite page /F1 being undecodable"
4256 );
4257 assert!(
4258 analysis.has_decodable_text_fonts,
4259 "P1+P2 combined: should detect decodable font from indirect XObject Resources"
4260 );
4261 }
4262
4263 #[test]
4266 fn test_p3_page_overrides_parent_font_undecodable_shadows_decodable() {
4267 use lopdf::dictionary;
4272 let mut doc = Document::with_version("1.4");
4273 let pages_id = doc.new_object_id();
4274 let page_id = doc.new_object_id();
4275
4276 let parent_font_id = doc.add_object(dictionary! {
4278 "Type" => "Font",
4279 "Subtype" => Object::Name(b"Type1".to_vec()),
4280 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4281 });
4282 let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4283 "Font" => dictionary! {
4284 "F1" => Object::Reference(parent_font_id),
4285 },
4286 }));
4287
4288 let page_font_id = doc.add_object(dictionary! {
4290 "Type" => "Font",
4291 "Subtype" => Object::Name(b"Type0".to_vec()),
4292 "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4293 "Encoding" => Object::Name(b"Identity-H".to_vec()),
4294 });
4295
4296 let content_data = b"BT /F1 12 Tf <0102030405> Tj ET";
4297 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4298 dictionary! {},
4299 content_data.to_vec(),
4300 )));
4301
4302 doc.objects.insert(
4303 page_id,
4304 Object::Dictionary(dictionary! {
4305 "Type" => "Page",
4306 "Parent" => Object::Reference(pages_id),
4307 "Resources" => dictionary! {
4308 "Font" => dictionary! {
4309 "F1" => Object::Reference(page_font_id),
4310 },
4311 },
4312 "Contents" => Object::Reference(content_id),
4313 }),
4314 );
4315 doc.objects.insert(
4316 pages_id,
4317 Object::Dictionary(dictionary! {
4318 "Type" => "Pages",
4319 "Kids" => vec![Object::Reference(page_id)],
4320 "Count" => Object::Integer(1),
4321 "Resources" => Object::Reference(parent_resources_id),
4322 }),
4323 );
4324
4325 let analysis = analyze_page_content(&doc, page_id);
4326 assert!(
4327 analysis.has_identity_h_no_tounicode,
4328 "P3: page /F1 (undecodable) shadows parent /F1 (decodable) — \
4329 must be flagged for OCR"
4330 );
4331 }
4332
4333 #[test]
4334 fn test_p3_page_overrides_parent_font_decodable_shadows_undecodable() {
4335 use lopdf::dictionary;
4339 let mut doc = Document::with_version("1.4");
4340 let pages_id = doc.new_object_id();
4341 let page_id = doc.new_object_id();
4342
4343 let parent_font_id = doc.add_object(dictionary! {
4345 "Type" => "Font",
4346 "Subtype" => Object::Name(b"Type0".to_vec()),
4347 "BaseFont" => Object::Name(b"ABCDEF+BadFont".to_vec()),
4348 "Encoding" => Object::Name(b"Identity-H".to_vec()),
4349 });
4350 let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4351 "Font" => dictionary! {
4352 "F1" => Object::Reference(parent_font_id),
4353 },
4354 }));
4355
4356 let page_font_id = doc.add_object(dictionary! {
4358 "Type" => "Font",
4359 "Subtype" => Object::Name(b"Type1".to_vec()),
4360 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4361 });
4362
4363 let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
4364 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4365 dictionary! {},
4366 content_data.to_vec(),
4367 )));
4368
4369 doc.objects.insert(
4370 page_id,
4371 Object::Dictionary(dictionary! {
4372 "Type" => "Page",
4373 "Parent" => Object::Reference(pages_id),
4374 "Resources" => dictionary! {
4375 "Font" => dictionary! {
4376 "F1" => Object::Reference(page_font_id),
4377 },
4378 },
4379 "Contents" => Object::Reference(content_id),
4380 }),
4381 );
4382 doc.objects.insert(
4383 pages_id,
4384 Object::Dictionary(dictionary! {
4385 "Type" => "Pages",
4386 "Kids" => vec![Object::Reference(page_id)],
4387 "Count" => Object::Integer(1),
4388 "Resources" => Object::Reference(parent_resources_id),
4389 }),
4390 );
4391
4392 let analysis = analyze_page_content(&doc, page_id);
4393 assert!(
4394 !analysis.has_identity_h_no_tounicode,
4395 "P3: page /F1 (decodable) shadows parent /F1 (undecodable) — \
4396 must NOT be flagged for OCR"
4397 );
4398 assert!(
4399 analysis.has_decodable_text_fonts,
4400 "P3: page's decodable font should be detected as used"
4401 );
4402 }
4403
4404 #[test]
4405 fn test_p3_inheritance_without_override_uses_parent_font() {
4406 use lopdf::dictionary;
4410 let mut doc = Document::with_version("1.4");
4411 let pages_id = doc.new_object_id();
4412 let page_id = doc.new_object_id();
4413
4414 let parent_font_id = doc.add_object(dictionary! {
4416 "Type" => "Font",
4417 "Subtype" => Object::Name(b"Type1".to_vec()),
4418 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
4419 });
4420 let parent_resources_id = doc.add_object(Object::Dictionary(dictionary! {
4421 "Font" => dictionary! {
4422 "F1" => Object::Reference(parent_font_id),
4423 },
4424 }));
4425
4426 let content_data = b"BT /F1 12 Tf (Hello world) Tj ET";
4427 let content_id = doc.add_object(Object::Stream(lopdf::Stream::new(
4428 dictionary! {},
4429 content_data.to_vec(),
4430 )));
4431
4432 doc.objects.insert(
4434 page_id,
4435 Object::Dictionary(dictionary! {
4436 "Type" => "Page",
4437 "Parent" => Object::Reference(pages_id),
4438 "Contents" => Object::Reference(content_id),
4439 }),
4440 );
4441 doc.objects.insert(
4442 pages_id,
4443 Object::Dictionary(dictionary! {
4444 "Type" => "Pages",
4445 "Kids" => vec![Object::Reference(page_id)],
4446 "Count" => Object::Integer(1),
4447 "Resources" => Object::Reference(parent_resources_id),
4448 }),
4449 );
4450
4451 let analysis = analyze_page_content(&doc, page_id);
4452 assert!(
4453 !analysis.has_identity_h_no_tounicode,
4454 "P3: page inherits parent's decodable /F1 — must NOT be flagged"
4455 );
4456 assert!(
4457 analysis.has_decodable_text_fonts,
4458 "P3: inherited decodable font should be detected as used"
4459 );
4460 }
4461
4462 const CID_TEXT_LINES: [&str; 3] = [
4465 "The quick brown fox jumps over the lazy dog",
4466 "Pack my box with five dozen liquor jugs 0123456789",
4467 "Sphinx of black quartz judge my vow",
4468 ];
4469
4470 #[derive(Clone, Copy, PartialEq)]
4472 enum CidTextLayout {
4473 Page,
4476 InlineFont,
4479 Form,
4481 FormInheritingFonts,
4483 AfterRestoredFont,
4486 UninvokedForm,
4489 FormInheritingFontState,
4492 FormThenLargerForm,
4495 MalformedFormThenForm,
4498 }
4499
4500 fn cid_text_over_vector_art(
4507 lines: &[&str],
4508 paths: usize,
4509 broken_cmap: bool,
4510 layout: CidTextLayout,
4511 ) -> (Document, ObjectId) {
4512 use lopdf::{dictionary, Stream};
4513
4514 let mut alphabet: Vec<char> = lines.iter().flat_map(|line| line.chars()).collect();
4515 alphabet.sort_unstable();
4516 alphabet.dedup();
4517 let code_of = |c: char| alphabet.iter().position(|&a| a == c).unwrap() as u16 + 1;
4518
4519 let mut cmap = String::from(
4520 "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
4521 /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
4522 /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
4523 1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
4524 );
4525 cmap.push_str(&format!("{} beginbfchar\n", alphabet.len()));
4526 for &c in &alphabet {
4527 let unicode = if broken_cmap { 'x' as u32 } else { c as u32 };
4528 cmap.push_str(&format!("<{:04X}> <{:04X}>\n", code_of(c), unicode));
4529 }
4530 cmap.push_str(
4531 "endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n",
4532 );
4533
4534 let glyphs: Vec<(bool, u16)> = alphabet.iter().map(|_| (true, 600)).collect();
4535 let codes: Vec<u8> = alphabet.iter().map(|&c| c as u8).collect();
4536 let font_file =
4537 crate::extractor::fonts::blank_glyph_tests::synthetic_truetype(&glyphs, &codes);
4538
4539 let mut doc = Document::with_version("1.5");
4540 let font_file_id = doc.add_object(Object::Stream(Stream::new(
4541 dictionary! { "Length1" => font_file.len() as i64 },
4542 font_file,
4543 )));
4544 let descriptor_id = doc.add_object(dictionary! {
4545 "Type" => "FontDescriptor",
4546 "FontName" => "AAAAAA+Subset",
4547 "Flags" => 4,
4548 "FontBBox" => vec![0.into(), 0.into(), 600.into(), 700.into()],
4549 "ItalicAngle" => 0,
4550 "Ascent" => 700,
4551 "Descent" => 0,
4552 "CapHeight" => 700,
4553 "StemV" => 80,
4554 "FontFile2" => Object::Reference(font_file_id),
4555 });
4556 let cid_font_id = doc.add_object(dictionary! {
4557 "Type" => "Font",
4558 "Subtype" => "CIDFontType2",
4559 "BaseFont" => "AAAAAA+Subset",
4560 "CIDSystemInfo" => dictionary! {
4561 "Registry" => Object::string_literal("Adobe"),
4562 "Ordering" => Object::string_literal("Identity"),
4563 "Supplement" => 0,
4564 },
4565 "FontDescriptor" => Object::Reference(descriptor_id),
4566 "DW" => 600,
4567 "CIDToGIDMap" => "Identity",
4568 });
4569 let cmap_stream = Stream::new(dictionary! {}, cmap.into_bytes());
4570 let font = |to_unicode: Object| {
4571 dictionary! {
4572 "Type" => "Font",
4573 "Subtype" => "Type0",
4574 "BaseFont" => "AAAAAA+Subset",
4575 "Encoding" => "Identity-H",
4576 "DescendantFonts" => vec![Object::Reference(cid_font_id)],
4577 "ToUnicode" => to_unicode,
4578 }
4579 };
4580 let font_entry = if layout == CidTextLayout::InlineFont {
4581 Object::Dictionary(font(Object::Stream(cmap_stream)))
4582 } else {
4583 let cmap_id = doc.add_object(Object::Stream(cmap_stream));
4584 Object::Reference(doc.add_object(font(Object::Reference(cmap_id))))
4585 };
4586 let helvetica_id = doc.add_object(dictionary! {
4587 "Type" => "Font",
4588 "Subtype" => "Type1",
4589 "BaseFont" => "Helvetica",
4590 });
4591
4592 let mut art = String::new();
4593 for i in 0..paths {
4594 let (x, y) = (50 + (i % 40) * 12, 100 + (i / 40) * 20);
4595 art.push_str(&format!(
4596 "{x} {y} m {} {} l {} {y} l h f\n",
4597 x + 5,
4598 y + 8,
4599 x + 10
4600 ));
4601 }
4602 let mut text = String::new();
4603 for (index, line) in lines.iter().enumerate() {
4604 let hex: String = line
4605 .chars()
4606 .map(|c| format!("{:04X}", code_of(c)))
4607 .collect();
4608 text.push_str(&format!(
4609 "BT /F1 12 Tf 72 {} Td <{hex}> Tj ET\n",
4610 700 - 20 * index
4611 ));
4612 }
4613 let fonts = dictionary! {
4614 "F1" => font_entry,
4615 "F2" => Object::Reference(helvetica_id),
4616 };
4617 let (page_content, page_resources) = match layout {
4618 CidTextLayout::Page | CidTextLayout::InlineFont => {
4619 (format!("{art}{text}"), dictionary! { "Font" => fonts })
4620 }
4621 CidTextLayout::AfterRestoredFont => (
4622 format!(
4623 "{art}BT /F1 12 Tf ET q BT /F2 12 Tf 72 40 Td (x) Tj ET Q\n{}",
4624 text.replace("/F1 12 Tf ", "")
4625 ),
4626 dictionary! { "Font" => fonts },
4627 ),
4628 CidTextLayout::Form
4629 | CidTextLayout::FormInheritingFonts
4630 | CidTextLayout::UninvokedForm
4631 | CidTextLayout::FormInheritingFontState
4632 | CidTextLayout::FormThenLargerForm
4633 | CidTextLayout::MalformedFormThenForm => {
4634 let form_dict = || {
4635 dictionary! {
4636 "Type" => "XObject",
4637 "Subtype" => "Form",
4638 "BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4639 }
4640 };
4641 let mut text_form_dict = form_dict();
4642 if matches!(
4643 layout,
4644 CidTextLayout::Form
4645 | CidTextLayout::FormThenLargerForm
4646 | CidTextLayout::MalformedFormThenForm
4647 ) {
4648 text_form_dict.set("Resources", dictionary! { "Font" => fonts.clone() });
4649 }
4650 let form_text = if layout == CidTextLayout::FormInheritingFontState {
4651 text.replace("/F1 12 Tf ", "")
4652 } else {
4653 text
4654 };
4655 let form_id = doc.add_object(Object::Stream(Stream::new(
4656 text_form_dict,
4657 form_text.into_bytes(),
4658 )));
4659 let mut xobjects = dictionary! { "Fm1" => Object::Reference(form_id) };
4660 let invoke = match layout {
4661 CidTextLayout::UninvokedForm => String::new(),
4662 CidTextLayout::FormInheritingFontState => {
4663 "BT /F1 12 Tf ET q /Fm1 Do Q\n".to_string()
4664 }
4665 CidTextLayout::FormThenLargerForm => {
4666 let larger = doc.add_object(Object::Stream(Stream::new(
4668 form_dict(),
4669 art.clone().into_bytes(),
4670 )));
4671 xobjects.set("Fm2", Object::Reference(larger));
4672 "q /Fm1 Do Q q /Fm2 Do Q\n".to_string()
4673 }
4674 CidTextLayout::MalformedFormThenForm => {
4675 let malformed = doc.add_object(Object::Stream(Stream::new(
4678 form_dict(),
4679 b"BT /F2 12 Tf 72 40 Td (broken".to_vec(),
4680 )));
4681 xobjects.set("Fm0", Object::Reference(malformed));
4682 "q /Fm0 Do Q q /Fm1 Do Q\n".to_string()
4683 }
4684 _ => "q /Fm1 Do Q\n".to_string(),
4685 };
4686 (
4687 format!("{art}{invoke}"),
4688 dictionary! {
4689 "Font" => fonts,
4690 "XObject" => xobjects,
4691 },
4692 )
4693 }
4694 };
4695 let content_id = doc.add_object(Object::Stream(Stream::new(
4696 dictionary! {},
4697 page_content.into_bytes(),
4698 )));
4699 let pages_id = doc.new_object_id();
4700 let page_id = doc.add_object(dictionary! {
4701 "Type" => "Page",
4702 "Parent" => Object::Reference(pages_id),
4703 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4704 "Resources" => page_resources,
4705 "Contents" => Object::Reference(content_id),
4706 });
4707 doc.objects.insert(
4708 pages_id,
4709 Object::Dictionary(dictionary! {
4710 "Type" => "Pages",
4711 "Kids" => vec![Object::Reference(page_id)],
4712 "Count" => Object::Integer(1),
4713 }),
4714 );
4715 (doc, page_id)
4716 }
4717
4718 #[test]
4724 fn cid_text_with_a_working_tounicode_is_not_vector_text() {
4725 for layout in [
4726 CidTextLayout::Page,
4727 CidTextLayout::InlineFont,
4728 CidTextLayout::Form,
4729 CidTextLayout::FormInheritingFonts,
4730 CidTextLayout::AfterRestoredFont,
4731 CidTextLayout::FormInheritingFontState,
4732 CidTextLayout::FormThenLargerForm,
4733 CidTextLayout::MalformedFormThenForm,
4734 ] {
4735 let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
4736 let analysis = analyze_page_content(&doc, page_id);
4737 assert!(analysis.path_op_count >= 1000, "{}", analysis.path_op_count);
4738 assert!(
4739 (analysis.unique_alphanum_chars as usize) < VECTOR_TEXT_MIN_ALPHANUMERICS,
4740 "the byte count is blind to the codes: {}",
4741 analysis.unique_alphanum_chars
4742 );
4743 assert!(!analysis.has_vector_text, "layout {}", layout as u8);
4744 assert_eq!(page_ocr_signals(&doc, page_id), PageOcrSignals::default());
4745 }
4746 }
4747
4748 #[test]
4754 fn little_or_undecodable_cid_text_over_vector_art_stays_vector_text() {
4755 let (doc, page_id) = cid_text_over_vector_art(&["Fig 3"], 400, false, CidTextLayout::Page);
4756 assert!(analyze_page_content(&doc, page_id).has_vector_text);
4757 let (doc, page_id) =
4758 cid_text_over_vector_art(&CID_TEXT_LINES, 400, true, CidTextLayout::Page);
4759 assert!(analyze_page_content(&doc, page_id).has_vector_text);
4760 let (doc, page_id) =
4761 cid_text_over_vector_art(&CID_TEXT_LINES, 8_000, false, CidTextLayout::Page);
4762 let analysis = analyze_page_content(&doc, page_id);
4763 assert!(
4764 analysis.path_op_count >= 40_000,
4765 "{}",
4766 analysis.path_op_count
4767 );
4768 assert!(analysis.has_vector_text);
4769 let (doc, page_id) =
4771 cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::UninvokedForm);
4772 assert!(analyze_page_content(&doc, page_id).has_vector_text);
4773 assert!(!decoded_text_counts(&doc, page_id).through_cid_cmap);
4774 }
4775
4776 #[test]
4780 fn a_simple_fonts_cmap_declared_two_bytes_wide_decodes_one_byte_per_code() {
4781 use lopdf::dictionary;
4782
4783 let cmap = b"1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
4784 3 beginbfchar\n<0A> <006E>\n<82> <0063>\n<0020> <0020>\nendbfchar\n";
4785 let mut doc = Document::with_version("1.5");
4786 let stream = doc.add_object(lopdf::Stream::new(dictionary! {}, cmap.to_vec()));
4787 let simple = dictionary! {
4788 "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "ToUnicode" => stream,
4789 };
4790 let composite = dictionary! {
4791 "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "Composite",
4792 "Encoding" => "Identity-H", "ToUnicode" => stream,
4793 };
4794 let decode = |font: &lopdf::Dictionary| match font_decoder(&doc, font) {
4795 Some(FontDecoder::CMap { cmap, .. }) => cmap.decode_cids(&[0x0A, 0x82]),
4796 _ => panic!("a CMap decoder"),
4797 };
4798 assert_eq!(decode(&simple), "nc");
4799 assert_ne!(decode(&composite), "nc");
4800 let unknown = dictionary! {
4803 "Type" => "Font", "BaseFont" => "NoSubtype", "ToUnicode" => stream,
4804 };
4805 assert_ne!(decode(&unknown), "nc");
4806 }
4807
4808 #[test]
4814 fn decoded_text_counts_cover_the_whole_text() {
4815 for layout in [
4816 CidTextLayout::Page,
4817 CidTextLayout::InlineFont,
4818 CidTextLayout::FormInheritingFonts,
4819 CidTextLayout::AfterRestoredFont,
4820 CidTextLayout::FormInheritingFontState,
4821 CidTextLayout::FormThenLargerForm,
4822 CidTextLayout::MalformedFormThenForm,
4823 ] {
4824 let (doc, page_id) = cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, layout);
4825 let counts = decoded_text_counts(&doc, page_id);
4826 assert_eq!(
4827 (counts.distinct_alphanumerics, counts.through_cid_cmap),
4828 (39, true),
4829 "layout {}",
4830 layout as u8
4831 );
4832 let extra = usize::from(layout == CidTextLayout::AfterRestoredFont);
4835 assert_eq!(counts.alphanumerics, 106 + extra);
4836 let at_the_bar = counts.alphanumerics as u32 * VECTOR_TEXT_MAX_PATH_OPS_PER_CHARACTER;
4837 assert!(counts.is_page_text(at_the_bar));
4838 assert!(!counts.is_page_text(at_the_bar + 1));
4839 }
4840 let sparse = DecodedTextCounts {
4841 distinct_alphanumerics: 29,
4842 alphanumerics: 1_000,
4843 through_cid_cmap: true,
4844 };
4845 assert!(!sparse.is_page_text(1_000));
4846 }
4847
4848 #[test]
4854 fn decoded_text_counts_stay_within_the_page_budgets() {
4855 let (doc, page_id) =
4856 cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Form);
4857 let page_content = doc.get_page_content(page_id);
4858 let page_operations = lopdf::content::Content::decode(&page_content)
4859 .unwrap()
4860 .operations
4861 .len();
4862 let form = doc
4863 .objects
4864 .values()
4865 .find_map(|object| match object {
4866 Object::Stream(stream)
4867 if stream.dict.get(b"Subtype").ok()
4868 == Some(&Object::Name(b"Form".to_vec())) =>
4869 {
4870 Some(stream)
4871 }
4872 _ => None,
4873 })
4874 .unwrap();
4875 let form_operations = lopdf::content::Content::decode(&form.content)
4876 .unwrap()
4877 .operations
4878 .len();
4879 let full = DecodedTextCounts {
4880 distinct_alphanumerics: 39,
4881 alphanumerics: 106,
4882 through_cid_cmap: true,
4883 };
4884 let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
4885 let operations = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
4886
4887 assert_eq!(
4888 decoded_text_counts_within(&doc, page_id, bytes, page_operations),
4889 DecodedTextCounts::default()
4890 );
4891 assert_eq!(
4892 decoded_text_counts_within(&doc, page_id, bytes, page_operations + form_operations),
4893 full
4894 );
4895 assert_eq!(
4896 decoded_text_counts_within(&doc, page_id, page_content.len(), operations),
4897 DecodedTextCounts::default()
4898 );
4899 assert_eq!(
4900 decoded_text_counts_within(
4901 &doc,
4902 page_id,
4903 page_content.len() + form.content.len(),
4904 operations
4905 ),
4906 full
4907 );
4908 assert_eq!(
4909 decoded_text_counts_within(&doc, page_id, page_content.len() / 2, operations),
4910 DecodedTextCounts::default()
4911 );
4912 assert_eq!(decoded_text_counts(&doc, page_id), full);
4913 }
4914
4915 fn form_streams(doc: &Document) -> Vec<&lopdf::Stream> {
4917 doc.objects
4918 .values()
4919 .filter_map(|object| match object {
4920 Object::Stream(stream)
4921 if stream.dict.get(b"Subtype").ok()
4922 == Some(&Object::Name(b"Form".to_vec())) =>
4923 {
4924 Some(stream)
4925 }
4926 _ => None,
4927 })
4928 .collect()
4929 }
4930
4931 #[test]
4937 fn forms_are_read_in_invocation_order_within_the_budgets() {
4938 let full = DecodedTextCounts {
4939 distinct_alphanumerics: 39,
4940 alphanumerics: 106,
4941 through_cid_cmap: true,
4942 };
4943 let operations = |content: &[u8]| {
4944 lopdf::content::Content::decode(content)
4945 .unwrap()
4946 .operations
4947 .len()
4948 };
4949 let bytes = crate::extractor::content_decode::MAX_PAGE_CONTENT_BYTES;
4950 let ops = crate::extractor::content_decode::MAX_PAGE_OPERATIONS;
4951
4952 let (doc, page_id) = cid_text_over_vector_art(
4953 &CID_TEXT_LINES,
4954 400,
4955 false,
4956 CidTextLayout::FormThenLargerForm,
4957 );
4958 let page_content = doc.get_page_content(page_id);
4959 let text_form = form_streams(&doc)
4960 .into_iter()
4961 .find(|stream| stream.content.windows(3).any(|w| w == &b" Tj"[..]))
4962 .unwrap();
4963 let just_enough_operations = operations(&page_content) + operations(&text_form.content);
4964 let just_enough_bytes = page_content.len() + text_form.content.len();
4965 assert_eq!(
4966 decoded_text_counts_within(&doc, page_id, bytes, just_enough_operations),
4967 full
4968 );
4969 assert_eq!(
4970 decoded_text_counts_within(&doc, page_id, just_enough_bytes, ops),
4971 full
4972 );
4973 assert_eq!(decoded_text_counts(&doc, page_id), full);
4974
4975 let (doc, page_id) = cid_text_over_vector_art(
4976 &CID_TEXT_LINES,
4977 400,
4978 false,
4979 CidTextLayout::MalformedFormThenForm,
4980 );
4981 assert_eq!(decoded_text_counts(&doc, page_id), full);
4982 assert!(!analyze_page_content(&doc, page_id).has_vector_text);
4983 }
4984
4985 #[test]
4990 fn a_tounicode_cmap_over_the_stream_bound_is_not_read() {
4991 fn pad_to(doc: &mut Document, id: ObjectId, total: usize) {
4992 let Some(Object::Stream(stream)) = doc.objects.get_mut(&id) else {
4993 unreachable!()
4994 };
4995 stream.content.resize(total, b' ');
4996 }
4997 let (mut doc, page_id) =
4998 cid_text_over_vector_art(&CID_TEXT_LINES, 400, false, CidTextLayout::Page);
4999 let cmap_id = doc
5000 .objects
5001 .iter()
5002 .find_map(|(id, object)| match object {
5003 Object::Stream(stream)
5004 if stream.content.windows(9).any(|w| w == &b"begincmap"[..]) =>
5005 {
5006 Some(*id)
5007 }
5008 _ => None,
5009 })
5010 .unwrap();
5011 let bound = crate::MAX_STREAM_DECOMPRESSED_BYTES;
5012 pad_to(&mut doc, cmap_id, bound);
5013 assert!(!analyze_page_content(&doc, page_id).has_vector_text);
5014 pad_to(&mut doc, cmap_id, bound + 1);
5015 assert!(analyze_page_content(&doc, page_id).has_vector_text);
5016 assert_eq!(
5017 decoded_text_counts(&doc, page_id),
5018 DecodedTextCounts::default()
5019 );
5020 }
5021}