1use crate::error::{EngineError, Result};
11use crate::geometry::{self, PageGeometry};
12use crate::limits::{LimitError, ProcessingLimits};
13use std::sync::{Arc, Mutex};
14
15type LimitSlot = Arc<Mutex<Option<(u64, u64)>>>;
20use crate::blocks::{Block, ClipMargins, ExtractOptions, TatrOptions};
21use crate::markdown_images::{self, Figure};
22use crate::render::{self, ColorMode, RenderConfig, RenderOptions, RenderedPage};
23use crate::text::{GeometryMode, TextBlock, TextExtractionDevice};
24use crate::thumbnail::ThumbnailOptions;
25use crate::words::Word;
26
27use kurbo::Rect;
28use pdf_forms::parse::parse_acroform;
29use pdf_forms::tree::{FieldType, FieldValue};
30use pdf_render::pdf_interpret as InkDeviceTypes;
31use pdf_render::pdf_interpret::PageExt;
32use pdf_render::pdf_interpret::{
33 interpret_page, Cache, Context, InterpreterSettings, InterpreterWarning,
34};
35use pdf_render::pdf_syntax::object::dict::keys::{
36 A, D, DEST, F, FIRST, NEXT, NM, OUTLINES, RECT, S, SUBTYPE, TITLE, URI,
37};
38use pdf_render::pdf_syntax::object::{Array, Dict, Name, ObjRef, Object, ObjectIdentifier};
39use pdf_render::pdf_syntax::page::Page;
40use pdf_render::pdf_syntax::{Pdf, PdfLoadLimits};
41#[cfg(feature = "parallel")]
42use rayon::prelude::*;
43use std::collections::BTreeSet;
44
45#[derive(Debug, Clone, Default)]
47pub struct DocumentInfo {
48 pub title: Option<String>,
50 pub author: Option<String>,
52 pub subject: Option<String>,
54 pub keywords: Option<String>,
56 pub creator: Option<String>,
58 pub producer: Option<String>,
60}
61
62#[derive(Debug, Clone)]
64pub struct BookmarkItem {
65 pub title: String,
67 pub page: Option<usize>,
69 pub children: Vec<BookmarkItem>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum LinkTarget {
77 Uri(String),
79 Page(usize),
81 Named(String),
84 Unknown,
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct PageLink {
92 pub target: LinkTarget,
94 pub rect: [f64; 4],
96 pub xref: u32,
98 pub id: String,
100}
101
102pub struct PdfDocument {
104 pdf: Pdf,
105 settings: InterpreterSettings,
106 keep_ligatures: bool,
109}
110
111impl PdfDocument {
112 pub fn open(data: impl Into<pdf_render::pdf_syntax::PdfData>) -> Result<Self> {
114 let pdf = Pdf::new(data).map_err(|e| match e {
115 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
116 EngineError::Encrypted(format!("{d:?}"))
117 }
118 _ => EngineError::InvalidPdf(format!("{e:?}")),
119 })?;
120 let settings = InterpreterSettings {
121 shared_cache: Some(Cache::new()),
122 ..InterpreterSettings::default()
123 };
124 Ok(Self {
125 pdf,
126 settings,
127 keep_ligatures: false,
128 })
129 }
130
131 pub fn open_with_processing_limits(
133 data: impl Into<pdf_render::pdf_syntax::PdfData>,
134 limits: ProcessingLimits,
135 ) -> Result<Self> {
136 let syntax_limits = PdfLoadLimits::new()
137 .max_object_depth(limits.max_object_depth)
138 .max_image_pixels(limits.max_image_pixels)
139 .max_stream_bytes(limits.max_stream_bytes);
140 let pdf = Pdf::new_with_limits(data, syntax_limits).map_err(|e| match e {
141 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
142 EngineError::Encrypted(format!("{d:?}"))
143 }
144 _ => EngineError::InvalidPdf(format!("{e:?}")),
145 })?;
146 let settings = InterpreterSettings {
147 max_operator_count: Some(limits.max_operator_count),
148 shared_cache: Some(Cache::new()),
149 ..InterpreterSettings::default()
150 };
151 Ok(Self {
152 pdf,
153 settings,
154 keep_ligatures: false,
155 })
156 }
157
158 pub fn open_with_password(
160 data: impl Into<pdf_render::pdf_syntax::PdfData>,
161 password: &str,
162 ) -> Result<Self> {
163 let pdf = Pdf::new_with_password(data, password).map_err(|e| match e {
164 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
165 EngineError::Encrypted(format!("{d:?}"))
166 }
167 _ => EngineError::InvalidPdf(format!("{e:?}")),
168 })?;
169 let settings = InterpreterSettings {
170 shared_cache: Some(Cache::new()),
171 ..InterpreterSettings::default()
172 };
173 Ok(Self {
174 pdf,
175 settings,
176 keep_ligatures: false,
177 })
178 }
179
180 pub fn open_with_password_and_processing_limits(
182 data: impl Into<pdf_render::pdf_syntax::PdfData>,
183 password: &str,
184 limits: ProcessingLimits,
185 ) -> Result<Self> {
186 let syntax_limits = PdfLoadLimits::new()
187 .max_object_depth(limits.max_object_depth)
188 .max_image_pixels(limits.max_image_pixels)
189 .max_stream_bytes(limits.max_stream_bytes);
190 let pdf = Pdf::new_with_password_and_limits(data, password, syntax_limits).map_err(
191 |e| match e {
192 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
193 EngineError::Encrypted(format!("{d:?}"))
194 }
195 _ => EngineError::InvalidPdf(format!("{e:?}")),
196 },
197 )?;
198 let settings = InterpreterSettings {
199 max_operator_count: Some(limits.max_operator_count),
200 shared_cache: Some(Cache::new()),
201 ..InterpreterSettings::default()
202 };
203 Ok(Self {
204 pdf,
205 settings,
206 keep_ligatures: false,
207 })
208 }
209
210 pub fn pdf(&self) -> &Pdf {
212 &self.pdf
213 }
214
215 pub fn load_recovery(&self) -> pdf_render::pdf_syntax::LoadRecovery {
218 self.pdf.load_recovery()
219 }
220
221 #[cfg(feature = "ocr-cli")]
226 pub(crate) fn adopt_settings(&mut self, other: &Self) {
227 self.settings = other.settings.clone();
228 self.keep_ligatures = other.keep_ligatures;
229 }
230
231 pub fn set_settings(&mut self, settings: InterpreterSettings) {
233 self.settings = settings;
234 }
235
236 pub fn set_keep_ligatures(&mut self, keep: bool) {
251 self.keep_ligatures = keep;
252 }
253
254 pub fn set_warning_sink(&mut self, sink: pdf_render::pdf_interpret::WarningSinkFn) {
261 self.settings.warning_sink = sink;
262 }
263
264 pub fn page_count(&self) -> usize {
266 self.pdf.pages().len()
267 }
268
269 pub fn page_geometry(&self, index: usize) -> Result<PageGeometry> {
271 let page = self.get_page(index)?;
272 Ok(geometry::extract_geometry(page))
273 }
274
275 pub fn render_page(&self, index: usize, options: &RenderOptions) -> Result<RenderedPage> {
283 #[cfg(feature = "xfa")]
284 if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
285 return flat_doc.render_page(index, options);
286 }
287 let page = self.get_page(index)?;
288 let (w, h) = page.render_dimensions();
292 if w <= 0.0 || h <= 0.0 {
293 return Err(EngineError::InvalidPageGeometry {
294 width: w,
295 height: h,
296 reason: "page has zero or negative dimensions".into(),
297 });
298 }
299 const MIN_PAGE_PT: f32 = 1.0;
302 if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
303 return Err(EngineError::InvalidPageGeometry {
304 width: w,
305 height: h,
306 reason: "page too small to render (< 1pt)".into(),
307 });
308 }
309 let (settings, slot) = Self::with_limit_collector(&self.settings);
310 let rendered = render::render_page(page, options, &settings);
311 Self::check_limit_slot(&slot)?;
312 Ok(rendered)
313 }
314
315 pub fn render_page_with_config(
320 &self,
321 index: usize,
322 config: &RenderConfig,
323 ) -> Result<RenderedPage> {
324 #[cfg(feature = "xfa")]
325 if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
326 return flat_doc.render_page_with_config(index, config);
327 }
328 let page = self.get_page(index)?;
329 let (w, h) = page.render_dimensions();
330 if w <= 0.0 || h <= 0.0 {
331 return Err(EngineError::InvalidPageGeometry {
332 width: w,
333 height: h,
334 reason: "page has zero or negative dimensions".into(),
335 });
336 }
337 const MIN_PAGE_PT: f32 = 1.0;
338 if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
339 return Err(EngineError::InvalidPageGeometry {
340 width: w,
341 height: h,
342 reason: "page too small to render (< 1pt)".into(),
343 });
344 }
345 let (settings, slot) = Self::with_limit_collector(&self.settings);
346 let rendered = render::render_page_with_config(page, config, &settings);
347 Self::check_limit_slot(&slot)?;
348 Ok(rendered)
349 }
350
351 pub fn render_page_cmyk(&self, index: usize, dpi: u32) -> Result<RenderedPage> {
353 self.render_page_with_config(
354 index,
355 &RenderConfig {
356 color_mode: ColorMode::PreserveCmyk,
357 dpi,
358 },
359 )
360 }
361
362 pub fn render_all(&self, options: &RenderOptions) -> Vec<RenderedPage> {
364 let pages = self.pdf.pages();
365 #[cfg(feature = "parallel")]
366 return (0..pages.len())
367 .into_par_iter()
368 .map(|i| render::render_page(&pages[i], options, &self.settings))
369 .collect();
370 #[cfg(not(feature = "parallel"))]
371 (0..pages.len())
372 .map(|i| render::render_page(&pages[i], options, &self.settings))
373 .collect()
374 }
375
376 pub fn render_all_with_config(&self, config: &RenderConfig) -> Vec<RenderedPage> {
378 let pages = self.pdf.pages();
379 #[cfg(feature = "parallel")]
380 return (0..pages.len())
381 .into_par_iter()
382 .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
383 .collect();
384 #[cfg(not(feature = "parallel"))]
385 (0..pages.len())
386 .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
387 .collect()
388 }
389
390 pub fn thumbnail(&self, index: usize, options: &ThumbnailOptions) -> Result<RenderedPage> {
392 let page = self.get_page(index)?;
393 Ok(render::render_thumbnail(
394 page,
395 options.max_dimension,
396 &self.settings,
397 ))
398 }
399
400 pub fn thumbnails_all(&self, options: &ThumbnailOptions) -> Vec<RenderedPage> {
402 let pages = self.pdf.pages();
403 #[cfg(feature = "parallel")]
404 return (0..pages.len())
405 .into_par_iter()
406 .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
407 .collect();
408 #[cfg(not(feature = "parallel"))]
409 (0..pages.len())
410 .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
411 .collect()
412 }
413
414 pub fn extract_text(&self, index: usize) -> Result<String> {
416 let page = self.get_page(index)?;
417 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
418 let mut device = TextExtractionDevice::new().keep_ligatures(self.keep_ligatures);
419 let mut ctx = Self::create_context_with_settings(page, settings);
420 interpret_page(page, &mut ctx, &mut device);
421 Self::check_limit_slot(&slot)?;
422 Ok(device.into_text())
423 }
424
425 #[doc(hidden)]
427 pub fn extract_text_pages_reusing_settings<I>(&self, indices: I) -> Result<Vec<String>>
428 where
429 I: IntoIterator<Item = usize>,
430 {
431 let pages = self.pdf.pages();
432 let mut settings = self.text_extraction_settings();
433 let indices = indices.into_iter();
434 let (lower_bound, upper_bound) = indices.size_hint();
435 let mut texts = Vec::with_capacity(upper_bound.unwrap_or(lower_bound));
436
437 for index in indices {
438 let page = pages.get(index).ok_or(EngineError::PageOutOfRange {
439 index,
440 count: pages.len(),
441 })?;
442 let (text, next_settings) =
443 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
444 settings = next_settings;
445 texts.push(text);
446 }
447
448 Ok(texts)
449 }
450
451 pub fn extract_text_blocks(&self, index: usize) -> Result<Vec<TextBlock>> {
453 let page = self.get_page(index)?;
454 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
455 let mut device = TextExtractionDevice::new().keep_ligatures(self.keep_ligatures);
456 let mut ctx = Self::create_context_with_settings(page, settings);
457 interpret_page(page, &mut ctx, &mut device);
458 Self::check_limit_slot(&slot)?;
459 Ok(device.into_blocks())
460 }
461
462 pub fn extract_text_blocks_rich(&self, index: usize) -> Result<Vec<TextBlock>> {
469 let page = self.get_page(index)?;
470 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
471 let mut device = TextExtractionDevice::with_mode(GeometryMode::RichGeometry)
472 .keep_ligatures(self.keep_ligatures);
473 let mut ctx = Self::create_context_with_settings(page, settings);
474 interpret_page(page, &mut ctx, &mut device);
475 Self::check_limit_slot(&slot)?;
476 Ok(device.into_blocks())
477 }
478
479 pub fn extract_all_text_blocks(&self) -> Vec<Vec<TextBlock>> {
481 let pages = self.pdf.pages();
482 let mut settings = self.text_extraction_settings();
483 let mut blocks = Vec::with_capacity(pages.len());
484
485 for page in pages.iter() {
486 let (page_blocks, next_settings) =
487 Self::extract_text_blocks_with_settings(page, settings, self.keep_ligatures);
488 settings = next_settings;
489 blocks.push(page_blocks);
490 }
491
492 blocks
493 }
494
495 pub fn extract_page_blocks(&self, index: usize, first_index: usize) -> Result<Vec<Block>> {
512 let page = self.get_page(index)?;
513 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
514 let mut device = TextExtractionDevice::new();
515 let mut ctx = Self::create_context_with_settings(page, settings);
516 interpret_page(page, &mut ctx, &mut device);
517 Self::check_limit_slot(&slot)?;
518 let mut blocks = device.into_paragraph_blocks(index, first_index);
519 crate::headings::classify(&mut blocks);
523 let blocks = crate::text::join_double_spaced_runs(blocks, first_index);
527 let mut blocks = crate::reading_order::arrange(blocks, first_index);
528 if !self.keep_ligatures {
531 for block in &mut blocks {
532 crate::ligatures::fold_block(block);
533 }
534 }
535 Ok(blocks)
536 }
537
538 pub fn extract_blocks(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
565 options.validate(self.page_count())?;
566 if let Some(copy) = self.ocr_copy_if_requested(options)? {
570 let mut inner = options.clone();
571 inner.ocr = crate::OcrMode::Never;
572 return copy.extract_blocks(&inner);
573 }
574 if options.raw_order {
581 return self.extract_blocks_in_raw_order(options);
582 }
583 let (blocks, ruled, lattices, images, graphics) = self
584 .blocks_rules_and_rulings_before_learning(
585 options.table_strategy,
586 options.document_font_names,
587 options.cell_path_words,
588 options.actual_text,
589 options.clip_margins,
590 )?;
591 let sizes: Vec<(f64, f64)> = (0..self.page_count())
596 .map(|page| {
597 self.page_geometry(page)
598 .map(|g| g.effective_dimensions())
599 .unwrap_or((0.0, 0.0))
600 })
601 .collect();
602 let tatr = self.tatr_context(options.tatr())?;
603 let blocks = if options.learned {
604 crate::learned::refine_with_tatr(
605 blocks,
606 &sizes,
607 &ruled,
608 &lattices,
609 &images,
610 mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
611 tatr.as_ref(),
612 options.cell_path_words,
613 )
614 } else {
615 blocks
616 };
617 let mut blocks = blocks;
618 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
619 crate::blocks::peel_table_edges(
620 &mut blocks,
621 crate::learned::SWITCHES.table_runs_on_figures,
622 );
623 crate::blocks::tables_across_pages(&mut blocks);
624 Self::demote_fragment_tables(&mut blocks);
625 crate::blocks::join_heading_subtitles(&mut blocks);
626 crate::blocks::join_heading_lines(&mut blocks);
627 crate::blocks::split_paragraphs_on_steps(&mut blocks);
628 if options.use_tags {
629 blocks = crate::tagged_evidence::apply(
630 &self.pdf,
631 &self.text_extraction_settings(),
632 blocks,
633 &sizes,
634 options.cell_path_words,
635 );
636 }
637 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
638 let mut blocks = crate::blocks::window(blocks, options);
639 if !self.keep_ligatures {
642 for block in &mut blocks {
643 crate::ligatures::fold_block(block);
644 }
645 }
646 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
647 Ok(blocks)
648 }
649
650 #[doc(hidden)]
658 pub fn extract_blocks_with_switches(
659 &self,
660 options: &ExtractOptions,
661 switches: crate::learned::Switches,
662 ) -> Result<Vec<Block>> {
663 options.validate(self.page_count())?;
664 if let Some(copy) = self.ocr_copy_if_requested(options)? {
666 let mut inner = options.clone();
667 inner.ocr = crate::OcrMode::Never;
668 return copy.extract_blocks_with_switches(&inner, switches);
669 }
670 let (blocks, ruled, lattices, images, graphics) = self
671 .blocks_rules_and_rulings_before_learning(
672 options.table_strategy,
673 options.document_font_names,
674 options.cell_path_words,
675 options.actual_text,
676 options.clip_margins,
677 )?;
678 let sizes: Vec<(f64, f64)> = (0..self.page_count())
679 .map(|page| {
680 self.page_geometry(page)
681 .map(|g| g.effective_dimensions())
682 .unwrap_or((0.0, 0.0))
683 })
684 .collect();
685 let tatr = self.tatr_context(options.tatr())?;
686 let blocks = if options.learned {
687 crate::learned::refine_with_tatr(
688 blocks,
689 &sizes,
690 &ruled,
691 &lattices,
692 &images,
693 mask_table_switches(switches, options.table_strategy),
694 tatr.as_ref(),
695 options.cell_path_words,
696 )
697 } else {
698 blocks
699 };
700 let mut blocks = blocks;
701 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
702 crate::blocks::peel_table_edges(&mut blocks, switches.table_runs_on_figures);
703 crate::blocks::tables_across_pages(&mut blocks);
704 Self::demote_fragment_tables(&mut blocks);
705 crate::blocks::join_heading_subtitles(&mut blocks);
706 crate::blocks::join_heading_lines(&mut blocks);
707 if options.use_tags {
708 blocks = crate::tagged_evidence::apply(
709 &self.pdf,
710 &self.text_extraction_settings(),
711 blocks,
712 &sizes,
713 options.cell_path_words,
714 );
715 }
716 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
717 let mut blocks = crate::blocks::window(blocks, options);
718 if !self.keep_ligatures {
720 for block in &mut blocks {
721 crate::ligatures::fold_block(block);
722 }
723 }
724 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
725 Ok(blocks)
726 }
727
728 pub fn extract_words(&self, options: &ExtractOptions) -> Result<Vec<Word>> {
753 let blocks = self.extract_blocks(options)?;
754 Ok(crate::words::words_from_blocks(&blocks))
755 }
756
757 #[doc(hidden)]
769 pub fn extract_blocks_and_rules(
770 &self,
771 options: &ExtractOptions,
772 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
773 let (blocks, ruled, _) = self.extract_blocks_rules_and_withdrawn(options)?;
774 Ok((blocks, ruled))
775 }
776
777 pub(crate) fn extract_blocks_rules_and_withdrawn(
780 &self,
781 options: &ExtractOptions,
782 ) -> Result<BlocksRulesWithdrawn> {
783 options.validate(self.page_count())?;
784 if let Some(copy) = self.ocr_copy_if_requested(options)? {
786 let mut inner = options.clone();
787 inner.ocr = crate::OcrMode::Never;
788 return copy.extract_blocks_rules_and_withdrawn(&inner);
789 }
790 let (blocks, ruled, lattices, images, graphics) = self
791 .blocks_rules_and_rulings_before_learning(
792 options.table_strategy,
793 options.document_font_names,
794 options.cell_path_words,
795 options.actual_text,
796 options.clip_margins,
797 )?;
798 let sizes: Vec<(f64, f64)> = (0..self.page_count())
799 .map(|page| {
800 self.page_geometry(page)
801 .map(|g| g.effective_dimensions())
802 .unwrap_or((0.0, 0.0))
803 })
804 .collect();
805 let tatr = self.tatr_context(options.tatr())?;
806 let blocks = if options.learned {
807 crate::learned::refine_with_tatr(
808 blocks,
809 &sizes,
810 &ruled,
811 &lattices,
812 &images,
813 mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
814 tatr.as_ref(),
815 options.cell_path_words,
816 )
817 } else {
818 blocks
819 };
820 let mut blocks = blocks;
821 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
822 crate::blocks::peel_table_edges(
823 &mut blocks,
824 crate::learned::SWITCHES.table_runs_on_figures,
825 );
826 crate::blocks::tables_across_pages(&mut blocks);
827 Self::demote_fragment_tables(&mut blocks);
828 crate::blocks::join_heading_subtitles(&mut blocks);
829 crate::blocks::join_heading_lines(&mut blocks);
830 crate::blocks::split_paragraphs_on_steps(&mut blocks);
831 if options.use_tags {
832 blocks = crate::tagged_evidence::apply(
833 &self.pdf,
834 &self.text_extraction_settings(),
835 blocks,
836 &sizes,
837 options.cell_path_words,
838 );
839 }
840 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
841 let mut blocks = crate::blocks::window(blocks, options);
842 if !self.keep_ligatures {
844 for block in &mut blocks {
845 crate::ligatures::fold_block(block);
846 }
847 }
848 let withdrawn = crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
849 Ok((blocks, ruled, withdrawn))
850 }
851
852 #[doc(hidden)]
859 pub fn extract_blocks_with_signals(
860 &self,
861 ) -> Result<(
862 Vec<Block>,
863 Vec<Option<crate::learned::tables::PageTableSignals>>,
864 )> {
865 let (blocks, ruled, lattices, images, graphics) = self
866 .blocks_rules_and_rulings_before_learning(
867 crate::TableStrategy::Both,
868 false,
869 false,
870 false,
871 None,
872 )?;
873 let sizes: Vec<(f64, f64)> = (0..self.page_count())
874 .map(|page| {
875 self.page_geometry(page)
876 .map(|g| g.effective_dimensions())
877 .unwrap_or((0.0, 0.0))
878 })
879 .collect();
880 let (blocks, signals) = crate::learned::refine_with_images_and_signals_with_cell_path_words(
881 blocks,
882 &sizes,
883 &ruled,
884 &lattices,
885 &images,
886 crate::learned::SWITCHES,
887 false,
889 );
890 let mut blocks = blocks;
891 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
892 crate::blocks::peel_table_edges(
893 &mut blocks,
894 crate::learned::SWITCHES.table_runs_on_figures,
895 );
896 crate::blocks::tables_across_pages(&mut blocks);
897 Self::demote_fragment_tables(&mut blocks);
898 crate::blocks::join_heading_subtitles(&mut blocks);
899 crate::blocks::join_heading_lines(&mut blocks);
900 crate::blocks::split_paragraphs_on_steps(&mut blocks);
901 let mut blocks = Self::finish_blocks_with_figures(
904 blocks,
905 &images,
906 &graphics,
907 &ExtractOptions::default(),
908 );
909 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
910 Ok((blocks, signals))
911 }
912
913 #[doc(hidden)]
920 pub fn table_signals(&self) -> Result<Vec<Option<crate::learned::tables::PageTableSignals>>> {
921 Ok(self.extract_blocks_with_signals()?.1)
922 }
923
924 pub(crate) fn tatr_context(
932 &self,
933 tatr: Option<&TatrOptions>,
934 ) -> Result<Option<crate::learned::tables::TatrContext<'_>>> {
935 let Some(options) = tatr else {
936 return Ok(None);
937 };
938 if options.test_hook.is_some() {
939 return Ok(Some(crate::learned::tables::TatrContext::new(
940 self,
941 options.clone(),
942 )));
943 }
944 #[cfg(feature = "ort-tables")]
945 {
946 let config = options.runtime_config();
947 pdf_ocr::runtime::session(&crate::learned::tables_tatr::TATR_SPEC, &config)
948 .map_err(std::io::Error::from)?;
949 Ok(Some(crate::learned::tables::TatrContext::new(
950 self,
951 options.clone(),
952 )))
953 }
954 #[cfg(not(feature = "ort-tables"))]
955 {
956 Err(EngineError::Io(std::io::Error::new(
959 std::io::ErrorKind::InvalidInput,
960 crate::error::OptionsError::TatrNeedsOrtTables,
961 )))
962 }
963 }
964}
965
966struct FragmentGrid {
968 sparse: bool,
970 short: bool,
972 median: usize,
974 continued_hits: usize,
976 continued_in_first_row: bool,
978 has_word: bool,
980 bbox: [f64; 4],
982 em: f64,
984}
985
986type BlocksRulesWithdrawn = (
988 Vec<Block>,
989 Vec<Vec<crate::text::HorizontalRule>>,
990 Vec<crate::table_trace::WithdrawnTable>,
991);
992
993struct RegionTableInputs {
998 page_blocks: Vec<Block>,
1000 features: crate::learned::features::PageFeatures,
1002 lines: Vec<crate::learned::judge::Line>,
1004 held: Vec<crate::text::Ruling>,
1006 region_lines: Vec<usize>,
1008}
1009
1010impl PdfDocument {
1011 #[doc(hidden)]
1029 pub fn table_in_region(&self, page: usize, bbox: [f64; 4]) -> Result<Vec<Block>> {
1030 self.table_in_region_inner(page, bbox, None)
1031 }
1032
1033 #[doc(hidden)]
1044 pub fn table_in_region_with_tatr(
1045 &self,
1046 page: usize,
1047 bbox: [f64; 4],
1048 tatr: &TatrOptions,
1049 ) -> Result<Vec<Block>> {
1050 let tatr = self.tatr_context(Some(tatr))?;
1051 self.table_in_region_inner(page, bbox, tatr.as_ref())
1052 }
1053
1054 fn table_in_region_inner(
1056 &self,
1057 page: usize,
1058 bbox: [f64; 4],
1059 tatr: Option<&crate::learned::tables::TatrContext>,
1060 ) -> Result<Vec<Block>> {
1061 let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1062 return Ok(Vec::new());
1063 };
1064 let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1065 let region_tatr = tatr.map(|context| crate::learned::tables::TatrRegion::of(context, true));
1067 let mut out = crate::learned::tables::rebuild_region(
1068 &refs,
1069 &inputs.lines,
1070 &inputs.features.words,
1071 &inputs.held,
1072 &inputs.region_lines,
1073 None,
1074 crate::learned::SWITCHES,
1075 region_tatr.as_ref(),
1076 false,
1078 );
1079 if Self::region_fragment_veto(&out, &inputs.page_blocks) {
1084 crate::table_trace::emit_run(
1085 page,
1086 &out,
1087 &inputs.page_blocks,
1088 Some(crate::table_trace::PageCtx {
1089 rulings: &inputs.held,
1090 graphics: &[],
1091 }),
1092 );
1093 return Ok(Vec::new());
1094 }
1095 for block in out.iter_mut() {
1096 if block.table_origin == crate::table_trace::TableOrigin::Other {
1099 block.table_origin = crate::table_trace::TableOrigin::Region;
1100 }
1101 }
1102 for (i, block) in out.iter_mut().enumerate() {
1103 block.index = i;
1104 }
1105 if !self.keep_ligatures {
1106 for block in out.iter_mut() {
1107 crate::ligatures::fold_block(block);
1108 }
1109 }
1110 Ok(out)
1111 }
1112
1113 #[cfg(feature = "ort-layout")]
1134 pub(crate) fn tatr_cells_in_region(
1135 &self,
1136 page: usize,
1137 bbox: [f64; 4],
1138 tatr: &crate::learned::tables::TatrContext,
1139 ) -> Result<Option<Vec<Block>>> {
1140 let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1141 return Ok(None);
1142 };
1143 let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1144 let valid: Vec<usize> = inputs
1145 .region_lines
1146 .iter()
1147 .copied()
1148 .filter(|&i| i < inputs.lines.len())
1149 .collect();
1150 if valid.is_empty() {
1151 return Ok(None);
1152 }
1153 let (pair_words, _) = crate::learned::tables::words_of_region(
1154 &refs,
1155 &inputs.lines,
1156 &inputs.features.words,
1157 &valid,
1158 );
1159 if pair_words.is_empty() {
1160 return Ok(None);
1161 }
1162 let page_number = valid
1165 .iter()
1166 .filter_map(|&i| inputs.lines.get(i))
1167 .filter_map(|line| refs.get(line.block))
1168 .map(|block| block.page)
1169 .next()
1170 .unwrap_or(0);
1171 let region = crate::learned::tables::TatrRegion::of(tatr, true);
1172 match crate::learned::tables::tatr_for_region_with_cell_path_words(
1173 Some(®ion),
1174 &refs,
1175 &inputs.lines,
1176 &inputs.features.words,
1177 &inputs.region_lines,
1178 &pair_words,
1179 page_number,
1180 false,
1182 ) {
1183 crate::learned::tables::TatrVerdict::Cells(blocks) => {
1184 let mut cells: Vec<Block> = blocks
1185 .into_iter()
1186 .filter(|b| matches!(b.kind, crate::BlockKind::TableCell { .. }))
1187 .collect();
1188 if cells.is_empty() {
1189 return Ok(None);
1190 }
1191 for block in cells.iter_mut() {
1192 block.table_origin = crate::table_trace::TableOrigin::Tatr;
1193 }
1194 if Self::region_fragment_veto(&cells, &inputs.page_blocks) {
1197 crate::table_trace::emit_run(
1198 page,
1199 &cells,
1200 &inputs.page_blocks,
1201 Some(crate::table_trace::PageCtx {
1202 rulings: &inputs.held,
1203 graphics: &[],
1204 }),
1205 );
1206 return Ok(None);
1207 }
1208 for (i, block) in cells.iter_mut().enumerate() {
1209 block.index = i;
1210 }
1211 if !self.keep_ligatures {
1212 for block in cells.iter_mut() {
1213 crate::ligatures::fold_block(block);
1214 }
1215 }
1216 Ok(Some(cells))
1217 }
1218 crate::learned::tables::TatrVerdict::Fallback(_) => Ok(None),
1219 }
1220 }
1221
1222 fn table_in_region_inputs(
1228 &self,
1229 page: usize,
1230 bbox: [f64; 4],
1231 ) -> Result<Option<RegionTableInputs>> {
1232 let count = self.page_count();
1233 if page >= count {
1234 return Err(EngineError::PageOutOfRange { index: page, count });
1235 }
1236 let (blocks, _, rulings, _, _) = self.blocks_rules_and_rulings_before_learning(
1237 crate::TableStrategy::Both,
1238 false,
1239 false,
1240 false,
1241 None,
1242 )?;
1243
1244 let page_blocks: Vec<Block> = blocks.into_iter().filter(|b| b.page == page).collect();
1245 if page_blocks.is_empty() {
1246 return Ok(None);
1247 }
1248 let (width, height) = self.page_geometry(page)?.effective_dimensions();
1249 let refs: Vec<&Block> = page_blocks.iter().collect();
1250 let features = crate::learned::features::page_features(&refs, width, height);
1251 let lines = crate::learned::judge::lines_of_page(&refs, &features.words, &[]);
1252 let mut heights: Vec<f64> = lines
1253 .iter()
1254 .map(|line| line.bbox[3] - line.bbox[1])
1255 .filter(|h| h.is_finite() && *h > 0.0)
1256 .collect();
1257 heights.sort_by(f64::total_cmp);
1258 let median = if heights.is_empty() {
1259 0.0
1260 } else if heights.len() % 2 == 1 {
1261 heights[heights.len() / 2]
1262 } else {
1263 (heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
1264 };
1265 let pad = crate::learned::tables::REGION_PAD_LINES * median;
1266 let left = bbox[0].min(bbox[2]) - pad;
1267 let bottom = bbox[1].min(bbox[3]) - pad;
1268 let right = bbox[0].max(bbox[2]) + pad;
1269 let top = bbox[1].max(bbox[3]) + pad;
1270 let padded = [left, bottom, right, top];
1271 let region_lines = crate::learned::judge::held_by(padded, &lines);
1272 if region_lines.len() < 2 {
1273 return Ok(None);
1274 }
1275 let page_rulings: &[crate::text::Ruling] =
1281 rulings.get(page).map(Vec::as_slice).unwrap_or(&[]);
1282 let held: Vec<crate::text::Ruling> = {
1283 let lattices = crate::learned::lattice::lattices(page_rulings);
1284 let touched: Vec<[f64; 4]> = lattices
1285 .iter()
1286 .map(|lattice| lattice.bbox())
1287 .filter(|bbox| {
1288 bbox[0] <= padded[2]
1289 && bbox[2] >= padded[0]
1290 && bbox[1] <= padded[3]
1291 && bbox[3] >= padded[1]
1292 })
1293 .collect();
1294 if touched.is_empty() {
1295 page_rulings
1296 .iter()
1297 .copied()
1298 .filter(|r| {
1299 r.x0 <= padded[2]
1300 && r.x1 >= padded[0]
1301 && r.y0 <= padded[3]
1302 && r.y1 >= padded[1]
1303 })
1304 .collect()
1305 } else {
1306 page_rulings
1307 .iter()
1308 .copied()
1309 .filter(|r| {
1310 touched.iter().any(|bbox| {
1311 r.x0 <= bbox[2] + 2.0
1316 && r.x1 >= bbox[0] - 2.0
1317 && r.y0 <= bbox[3] + 2.0
1318 && r.y1 >= bbox[1] - 2.0
1319 })
1320 })
1321 .collect()
1322 }
1323 };
1324 Ok(Some(RegionTableInputs {
1325 page_blocks,
1326 features,
1327 lines,
1328 held,
1329 region_lines,
1330 }))
1331 }
1332
1333 fn region_fragment_veto(cells: &[Block], page: &[Block]) -> bool {
1354 let Some(grid) = Self::fragment_grid_stats(cells) else {
1355 return false;
1356 };
1357 if grid.continued_hits >= 2
1358 && grid.continued_in_first_row
1359 && (grid.sparse || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN)
1360 {
1361 return true;
1362 }
1363 if !grid.short || grid.has_word {
1364 return false;
1365 }
1366 Self::caption_near_grid(
1367 &grid,
1368 page.iter().map(|block| (block.text.as_str(), block.bbox)),
1369 )
1370 }
1371
1372 fn fragment_grid_stats(cells: &[Block]) -> Option<FragmentGrid> {
1375 struct RegionCell<'a> {
1376 row: u16,
1377 column: u16,
1378 row_span: u16,
1379 column_span: u16,
1380 text: &'a str,
1381 bbox: [f64; 4],
1382 }
1383 let grid: Vec<RegionCell<'_>> = cells
1384 .iter()
1385 .filter_map(|block| match block.kind {
1386 crate::BlockKind::TableCell {
1387 row,
1388 column,
1389 row_span,
1390 column_span,
1391 ..
1392 } => Some(RegionCell {
1393 row,
1394 column,
1395 row_span,
1396 column_span,
1397 text: block.text.as_str(),
1398 bbox: block.bbox,
1399 }),
1400 _ => None,
1401 })
1402 .collect();
1403 if grid.is_empty() {
1404 return None;
1405 }
1406 let rows = grid
1407 .iter()
1408 .map(|cell| cell.row as usize + cell.row_span.max(1) as usize)
1409 .max()
1410 .unwrap_or(0);
1411 let columns = grid
1412 .iter()
1413 .map(|cell| cell.column as usize + cell.column_span as usize)
1414 .max()
1415 .unwrap_or(0);
1416 if rows == 0 || columns == 0 {
1417 return None;
1418 }
1419 let positions = rows * columns;
1420 let filled: usize = grid
1421 .iter()
1422 .map(|cell| cell.column_span as usize * cell.row_span.max(1) as usize)
1423 .sum();
1424 let sparse = positions > 0 && filled < positions && (positions - filled) * 3 > positions;
1425 let mut lens: Vec<usize> = grid
1426 .iter()
1427 .map(|cell| cell.text.chars().filter(|c| c.is_alphanumeric()).count())
1428 .collect();
1429 lens.sort_unstable();
1430 let mid = lens.len() / 2;
1431 let short = if lens.len() % 2 == 1 {
1433 lens[mid] <= crate::tables::FRAGMENT_CELL_MEDIAN
1434 } else {
1435 lens[mid - 1] + lens[mid] <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN
1436 };
1437 let median = lens[mid];
1438 let has_word = grid.iter().any(|cell| {
1439 let mut run = 0usize;
1440 for c in cell.text.chars() {
1441 if c.is_alphabetic() {
1442 run += 1;
1443 if run >= 4 {
1444 return true;
1445 }
1446 } else {
1447 run = 0;
1448 }
1449 }
1450 false
1451 });
1452 let continued_hits: usize = grid
1453 .iter()
1454 .map(|cell| cell.text.to_ascii_lowercase().matches("continu").count())
1455 .sum();
1456 let continued_in_first_row = grid
1457 .iter()
1458 .all(|cell| !cell.text.to_ascii_lowercase().contains("continu") || cell.row == 0);
1459 let mut bbox = [
1460 f64::INFINITY,
1461 f64::INFINITY,
1462 f64::NEG_INFINITY,
1463 f64::NEG_INFINITY,
1464 ];
1465 let mut heights: Vec<f64> = Vec::with_capacity(grid.len());
1466 for cell in &grid {
1467 bbox[0] = bbox[0].min(cell.bbox[0]);
1468 bbox[2] = bbox[2].max(cell.bbox[2]);
1469 bbox[1] = bbox[1].min(cell.bbox[1]);
1470 bbox[3] = bbox[3].max(cell.bbox[3]);
1471 heights.push(cell.bbox[3] - cell.bbox[1]);
1472 }
1473 if !(bbox[0].is_finite() && bbox[2] > bbox[0] && bbox[3] >= bbox[1]) {
1474 return None;
1475 }
1476 heights.sort_by(f64::total_cmp);
1477 let em = heights[heights.len() / 2];
1478 if !(em.is_finite() && em > 0.0) {
1479 return None;
1480 }
1481 Some(FragmentGrid {
1482 sparse,
1483 short,
1484 median,
1485 continued_hits,
1486 continued_in_first_row,
1487 has_word,
1488 bbox,
1489 em,
1490 })
1491 }
1492
1493 fn caption_near_grid<'a>(
1496 grid: &FragmentGrid,
1497 mut page: impl Iterator<Item = (&'a str, [f64; 4])>,
1498 ) -> bool {
1499 let near = crate::tables::FIGURE_CAPTION_NEAR_EM * grid.em;
1500 page.any(|(text, bbox)| {
1501 if !crate::tables::is_figure_caption(text) {
1502 return false;
1503 }
1504 let [left, lo, right, hi] = bbox;
1505 if !(left.is_finite() && right > left && hi >= lo) {
1506 return false;
1507 }
1508 if right <= grid.bbox[0] || left >= grid.bbox[2] {
1509 return false;
1510 }
1511 let gap = if hi < grid.bbox[1] {
1512 grid.bbox[1] - hi
1513 } else if lo > grid.bbox[3] {
1514 lo - grid.bbox[3]
1515 } else {
1516 0.0
1517 };
1518 gap <= near
1519 })
1520 }
1521
1522 fn demote_fragment_tables(blocks: &mut [Block]) {
1531 let mut start: Option<usize> = None;
1532 let mut runs: Vec<(usize, usize)> = Vec::new();
1533 for (i, block) in blocks.iter().enumerate() {
1534 if matches!(block.kind, crate::BlockKind::TableCell { .. }) {
1535 start.get_or_insert(i);
1536 } else if let Some(first) = start.take() {
1537 runs.push((first, i));
1538 }
1539 }
1540 if let Some(first) = start.take() {
1541 runs.push((first, blocks.len()));
1542 }
1543 for (first, end) in runs {
1544 let mut page_first = first;
1545 while page_first < end {
1546 let page = blocks[page_first].page;
1547 let mut page_end = page_first + 1;
1548 while page_end < end && blocks[page_end].page == page {
1549 page_end += 1;
1550 }
1551 let veto = match Self::fragment_grid_stats(&blocks[page_first..page_end]) {
1552 Some(grid) => {
1553 (grid.continued_hits >= 2
1554 && grid.continued_in_first_row
1555 && (grid.sparse
1556 || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN))
1557 || (grid.sparse
1558 && grid.short
1559 && !grid.has_word
1560 && Self::caption_near_grid(
1561 &grid,
1562 blocks
1563 .iter()
1564 .filter(|block| block.page == page)
1565 .map(|block| (block.text.as_str(), block.bbox)),
1566 ))
1567 }
1568 None => false,
1569 };
1570 if veto {
1571 crate::table_trace::emit_run(page, &blocks[page_first..page_end], blocks, None);
1575 for block in &mut blocks[page_first..page_end] {
1576 block.kind = crate::BlockKind::Paragraph;
1577 block.grid = None;
1578 block.confidence = None;
1579 block.continues_previous = false;
1580 }
1581 }
1582 page_first = page_end;
1583 }
1584 }
1585 }
1586
1587 pub fn page_needs_ocr(&self, index: usize) -> crate::ocr_need::OcrNeed {
1594 let pages = self.pdf.pages();
1595 let page = match pages.get(index) {
1596 Some(page) => page,
1597 None => return crate::ocr_need::OcrNeed::None,
1598 };
1599 let (width, height) = page.render_dimensions();
1600 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1601 let mut device = crate::ocr_need::OcrSurveyDevice::new(f64::from(width), f64::from(height));
1602 let mut ctx = Self::create_context_with_settings(page, settings);
1603 interpret_page(page, &mut ctx, &mut device);
1604 if Self::check_limit_slot(&slot).is_err() {
1605 return crate::ocr_need::OcrNeed::None;
1606 }
1607 device.decide()
1608 }
1609
1610 pub fn vision_advice(&self) -> Result<Vec<VisionAdvice>> {
1624 let pages = self.pdf.pages();
1625 let mut out: Vec<VisionAdvice> = Vec::with_capacity(pages.len());
1626 for (index, page) in pages.iter().enumerate() {
1627 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1628 let mut device = InkDevice::default();
1629 let mut ctx = Self::create_context_with_settings(page, settings);
1630 interpret_page(page, &mut ctx, &mut device);
1631 Self::check_limit_slot(&slot)?;
1632 let text = self.extract_page_blocks(index, 0).map(|blocks| {
1633 blocks
1634 .iter()
1635 .map(|b| b.text.trim().chars().count())
1636 .sum::<usize>()
1637 })?;
1638 out.push(VisionAdvice {
1639 no_text_layer: text < VISION_MIN_CHARACTERS && device.drew_anything(),
1640 ..VisionAdvice::default()
1641 });
1642 }
1643 Ok(out)
1644 }
1645
1646 #[doc(hidden)]
1655 pub fn extract_blocks_before_learning(&self) -> Result<Vec<Block>> {
1656 let (blocks, _, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1657 crate::TableStrategy::Both,
1658 false,
1659 false,
1660 false,
1661 None,
1662 )?;
1663 Ok(blocks)
1664 }
1665
1666 #[doc(hidden)]
1676 pub fn extract_blocks_and_rules_before_learning(
1677 &self,
1678 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1679 self.blocks_and_rules_before_learning()
1680 }
1681
1682 #[cfg(feature = "ort-layout")]
1686 pub(crate) fn page_rulings_and_graphics(
1687 &self,
1688 page: usize,
1689 ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1690 let count = self.page_count();
1691 let pages = self.pdf.pages();
1692 let Some(source) = pages.get(page) else {
1693 return Err(EngineError::PageOutOfRange { index: page, count });
1694 };
1695 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1696 let mut device = TextExtractionDevice::new();
1697 let mut ctx = Self::create_context_with_settings(source, settings);
1698 interpret_page(source, &mut ctx, &mut device);
1699 Self::check_limit_slot(&slot)?;
1700 let rulings = device.take_rulings();
1701 let (width, height) = source.render_dimensions();
1702 let graphics = device.take_graphics(f64::from(width), f64::from(height));
1703 Ok((rulings, graphics))
1704 }
1705
1706 #[doc(hidden)]
1717 pub fn extract_image_boxes(&self) -> Result<Vec<Vec<[f64; 4]>>> {
1718 let mut out = Vec::new();
1719 for (number, page) in self.pdf.pages().iter().enumerate() {
1720 if number >= markdown_images::MAX_GRAPHICS_SCAN_PAGES {
1721 out.push(Vec::new());
1722 continue;
1723 }
1724 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1725 let mut device = TextExtractionDevice::new();
1726 let mut ctx = Self::create_context_with_settings(page, settings);
1727 interpret_page(page, &mut ctx, &mut device);
1728 Self::check_limit_slot(&slot)?;
1729 let (w, h) = page.render_dimensions();
1730 let mut boxes = device.take_images();
1731 boxes.extend(device.take_graphics(f64::from(w), f64::from(h)));
1732 out.push(boxes);
1733 }
1734 Ok(out)
1735 }
1736
1737 #[doc(hidden)]
1743 pub fn page_image_boxes(&self, index: usize) -> Result<Vec<[f64; 4]>> {
1744 let pages = self.pdf.pages();
1745 let Some(page) = pages.get(index) else {
1746 return Ok(Vec::new());
1747 };
1748 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1749 let mut device = TextExtractionDevice::new();
1750 let mut ctx = Self::create_context_with_settings(page, settings);
1751 interpret_page(page, &mut ctx, &mut device);
1752 Self::check_limit_slot(&slot)?;
1753 Ok(device.take_images())
1754 }
1755
1756 pub fn extract_figures(&self, options: &ExtractOptions) -> Result<Vec<Figure>> {
1780 use std::collections::BTreeMap;
1781 let blocks = self.extract_blocks(options)?;
1782 let mut pages: BTreeMap<usize, Vec<[f64; 4]>> = BTreeMap::new();
1783 for block in &blocks {
1784 if matches!(block.kind, crate::BlockKind::Figure) {
1785 pages.entry(block.page).or_default().push(block.bbox);
1786 }
1787 }
1788 let mut out = Vec::new();
1789 for (page, boxes) in &pages {
1790 let renders = markdown_images::render_crops(self, *page, boxes)?;
1791 for (ordinal, (bbox, png)) in boxes.iter().zip(renders).enumerate() {
1792 out.push(Figure {
1793 page: *page,
1794 bbox: *bbox,
1795 name: markdown_images::figure_name(*page, ordinal),
1796 png,
1797 });
1798 }
1799 }
1800 Ok(out)
1801 }
1802
1803 fn blocks_and_rules_before_learning(
1805 &self,
1806 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1807 let (blocks, ruled, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1808 crate::TableStrategy::Both,
1809 false,
1810 false,
1811 false,
1812 None,
1813 )?;
1814 Ok((blocks, ruled))
1815 }
1816
1817 #[doc(hidden)]
1820 pub fn page_rulings(&self, index: usize) -> Result<Vec<crate::text::Ruling>> {
1821 let pages = self.pdf.pages();
1822 let Some(page) = pages.get(index) else {
1823 return Ok(Vec::new());
1824 };
1825 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1826 let mut device = TextExtractionDevice::new();
1827 let mut ctx = Self::create_context_with_settings(page, settings);
1828 interpret_page(page, &mut ctx, &mut device);
1829 Self::check_limit_slot(&slot)?;
1830 Ok(device.take_rulings())
1831 }
1832
1833 #[doc(hidden)]
1837 #[allow(clippy::type_complexity)]
1838 pub fn page_rulings_and_fills(
1839 &self,
1840 index: usize,
1841 ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1842 let pages = self.pdf.pages();
1843 let Some(page) = pages.get(index) else {
1844 return Ok((Vec::new(), Vec::new()));
1845 };
1846 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1847 let mut device = TextExtractionDevice::new();
1848 let mut ctx = Self::create_context_with_settings(page, settings);
1849 interpret_page(page, &mut ctx, &mut device);
1850 Self::check_limit_slot(&slot)?;
1851 Ok((device.take_rulings(), device.take_fills()))
1852 }
1853
1854 fn clip_rect(margins: &ClipMargins, width: f64, height: f64) -> [f64; 4] {
1858 let [left, top, right, bottom] = margins.as_array();
1859 [left, bottom, width - right, height - top]
1860 }
1861
1862 fn extract_blocks_in_raw_order(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
1865 let pages = self.pdf.pages();
1866 let mut blocks: Vec<Block> = Vec::new();
1867 for (index, page) in pages.iter().enumerate() {
1868 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1869 let mut device = TextExtractionDevice::new()
1870 .raw_runs(true)
1871 .actual_text(options.actual_text);
1872 let mut ctx = Self::create_context_with_settings(page, settings);
1873 interpret_page(page, &mut ctx, &mut device);
1874 Self::check_limit_slot(&slot)?;
1875 if let Some(margins) = &options.clip_margins {
1876 let (width, height) = page.render_dimensions();
1877 device.clip_spans(Self::clip_rect(
1878 margins,
1879 f64::from(width),
1880 f64::from(height),
1881 ));
1882 }
1883 blocks.extend(device.into_raw_blocks(index, blocks.len()));
1884 }
1885 let mut blocks = crate::blocks::window(blocks, options);
1886 if !self.keep_ligatures {
1888 for block in &mut blocks {
1889 crate::ligatures::fold_block(block);
1890 }
1891 }
1892 Ok(blocks)
1893 }
1894
1895 #[allow(clippy::type_complexity)]
1899 fn blocks_rules_and_rulings_before_learning(
1900 &self,
1901 table_strategy: crate::TableStrategy,
1902 document_font_names: bool,
1905 cell_path_words: bool,
1908 actual_text: bool,
1911 clip_margins: Option<ClipMargins>,
1914 ) -> Result<(
1915 Vec<Block>,
1916 Vec<Vec<crate::text::HorizontalRule>>,
1917 Vec<Vec<crate::text::Ruling>>,
1918 Vec<Vec<[f64; 4]>>,
1919 Vec<Vec<[f64; 4]>>,
1920 )> {
1921 let pages = self.pdf.pages();
1922 let mut images: Vec<Vec<[f64; 4]>> = Vec::new();
1923 let mut graphics: Vec<Vec<[f64; 4]>> = Vec::new();
1924 let mut blocks: Vec<Block> = Vec::new();
1925 let mut ruled: Vec<Vec<crate::text::HorizontalRule>> = Vec::new();
1926 let mut lattices: Vec<Vec<crate::text::Ruling>> = Vec::new();
1927
1928 for (index, page) in pages.iter().enumerate() {
1929 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1930 let mut device = TextExtractionDevice::new()
1931 .document_font_names(document_font_names)
1932 .actual_text(actual_text);
1933 let mut ctx = Self::create_context_with_settings(page, settings);
1934 interpret_page(page, &mut ctx, &mut device);
1935 Self::check_limit_slot(&slot)?;
1936 lattices.push(device.take_rulings());
1937 let figures = device.take_images();
1938 images.push(figures);
1939 let (width, height) = page.render_dimensions();
1940 graphics.push(device.take_graphics(f64::from(width), f64::from(height)));
1941 if let Some(margins) = &clip_margins {
1945 device.clip_spans(Self::clip_rect(
1946 margins,
1947 f64::from(width),
1948 f64::from(height),
1949 ));
1950 }
1951 let (page_blocks, page_rules) = device.into_paragraph_blocks_and_rules_with_strategy(
1952 index,
1953 blocks.len(),
1954 table_strategy,
1955 cell_path_words,
1956 );
1957 blocks.extend(page_blocks);
1958 ruled.push(page_rules);
1959 }
1960
1961 crate::furniture::mark(&mut blocks);
1965 crate::headings::classify(&mut blocks);
1970 let blocks = crate::text::join_double_spaced_runs(blocks, 0);
1974 let blocks = crate::reading_order::arrange(blocks, 0);
1978 Ok((blocks, ruled, lattices, images, graphics))
1979 }
1980
1981 fn finish_blocks_with_figures(
1988 blocks: Vec<Block>,
1989 images: &[Vec<[f64; 4]>],
1990 graphics: &[Vec<[f64; 4]>],
1991 options: &ExtractOptions,
1992 ) -> Vec<Block> {
1993 let pages = images.len().max(graphics.len());
1994 let mut page_has_text = vec![false; pages];
1995 for block in &blocks {
1996 if let Some(slot) = page_has_text.get_mut(block.page) {
1997 *slot = true;
1998 }
1999 }
2000 let blocks = if options.drop_text_over_images {
2001 markdown_images::drop_text_over_images(blocks, images)
2002 } else {
2003 blocks
2004 };
2005 markdown_images::merge_figures(blocks, images, graphics, &page_has_text)
2006 }
2007
2008 pub fn extract_markdown(&self, options: &ExtractOptions) -> Result<String> {
2044 if matches!(options.quality, crate::Quality::Balanced) {
2045 return crate::model::balanced_markdown_for(self, options);
2046 }
2047 let mut blocks = self.extract_blocks(options)?;
2048 if options.include_figures && (options.embed_figures || options.figure_image_dir.is_some())
2052 {
2053 markdown_images::resolve_figure_texts(self, &mut blocks, options)?;
2054 }
2055 let markdown = crate::markdown::MarkdownOptions::from(options);
2056 Ok(crate::markdown::to_markdown_with_options(
2057 &blocks, &markdown,
2058 ))
2059 }
2060
2061 pub fn extract_docling(&self, name: &str, options: &ExtractOptions) -> Result<String> {
2071 let blocks = self.extract_blocks(options)?;
2072 let mut pages = Vec::new();
2073 for page in (0..self.page_count()).filter(|page| options.holds(*page)) {
2074 let (width, height) = self.page_geometry(page)?.effective_dimensions();
2077 pages.push(crate::docling::DoclingPage {
2078 page,
2079 width,
2080 height,
2081 });
2082 }
2083 Ok(crate::docling::to_docling_json(name, &pages, &blocks))
2084 }
2085
2086 pub fn extract_acroform_text(&self) -> String {
2092 let Some(tree) = parse_acroform(&self.pdf) else {
2093 return String::new();
2094 };
2095 let mut parts: Vec<String> = Vec::new();
2096 for id in tree.all_ids() {
2097 let node = tree.get(id);
2098 if node.children.is_empty() {
2099 let value_str = match &node.value {
2101 Some(FieldValue::Text(s)) if !s.is_empty() => Some(s.clone()),
2102 Some(FieldValue::StringArray(arr)) => {
2103 let joined = arr
2104 .iter()
2105 .filter(|s| !s.is_empty())
2106 .cloned()
2107 .collect::<Vec<_>>()
2108 .join(", ");
2109 if joined.is_empty() {
2110 None
2111 } else {
2112 Some(joined)
2113 }
2114 }
2115 _ => None,
2116 };
2117 let button_caption =
2118 value_str.is_none() && tree.effective_field_type(id) == Some(FieldType::Button);
2119 let extracted = value_str.or_else(|| {
2120 button_caption.then(|| {
2121 node.mk
2122 .as_ref()
2123 .and_then(|mk| mk.caption.as_ref())
2124 .filter(|caption| !caption.is_empty())
2125 .cloned()
2126 })?
2127 });
2128 if let Some(s) = extracted {
2129 parts.push(s);
2130 }
2131 }
2132 }
2133 let joined = parts.join("\n");
2134 if self.keep_ligatures {
2135 return joined;
2136 }
2137 crate::ligatures::fold(&joined).into_owned()
2143 }
2144
2145 pub fn extract_all_text(&self) -> String {
2148 let pages = self.pdf.pages();
2149 let mut settings = self.text_extraction_settings();
2150 let mut page_texts = Vec::with_capacity(pages.len());
2151 for page in pages.iter() {
2152 let (page_text, next_settings) =
2153 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2154 settings = next_settings;
2155 page_texts.push(page_text);
2156 }
2157
2158 let acroform = self.extract_acroform_text();
2163 if !acroform.is_empty() {
2164 match page_texts.last_mut() {
2165 Some(last) => {
2166 if !last.is_empty() && !last.ends_with('\n') {
2167 last.push('\n');
2168 }
2169 last.push_str(&acroform);
2170 }
2171 None => return acroform,
2172 }
2173 }
2174
2175 join_page_texts(page_texts.iter().map(String::as_str))
2176 }
2177
2178 pub fn search_text(&self, query: &str) -> Vec<usize> {
2185 let pages = self.pdf.pages();
2186 let query_lower = query.to_lowercase();
2187 let query_lower = if self.keep_ligatures {
2192 query_lower
2193 } else {
2194 crate::ligatures::fold(&query_lower).into_owned()
2195 };
2196 #[cfg(feature = "parallel")]
2197 let page_contains = |i: usize| -> Option<usize> {
2198 let page = &pages[i];
2199 let (text, _) = Self::extract_text_with_settings(
2200 page,
2201 self.text_extraction_settings(),
2202 self.keep_ligatures,
2203 );
2204 if text.to_lowercase().contains(&query_lower) {
2205 Some(i)
2206 } else {
2207 None
2208 }
2209 };
2210 #[cfg(feature = "parallel")]
2211 return (0..pages.len())
2212 .into_par_iter()
2213 .filter_map(page_contains)
2214 .collect();
2215 #[cfg(not(feature = "parallel"))]
2216 {
2217 let mut settings = self.text_extraction_settings();
2218 let mut hits = Vec::new();
2219 for (i, page) in pages.iter().enumerate() {
2220 let (text, next_settings) =
2221 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2222 settings = next_settings;
2223 if text.to_lowercase().contains(&query_lower) {
2224 hits.push(i);
2225 }
2226 }
2227 hits
2228 }
2229 }
2230
2231 pub fn info(&self) -> DocumentInfo {
2233 let meta = self.pdf.metadata();
2234 DocumentInfo {
2235 title: meta.title.as_ref().map(|b| bytes_to_string(b)),
2236 author: meta.author.as_ref().map(|b| bytes_to_string(b)),
2237 subject: meta.subject.as_ref().map(|b| bytes_to_string(b)),
2238 keywords: meta.keywords.as_ref().map(|b| bytes_to_string(b)),
2239 creator: meta.creator.as_ref().map(|b| bytes_to_string(b)),
2240 producer: meta.producer.as_ref().map(|b| bytes_to_string(b)),
2241 }
2242 }
2243
2244 pub fn bookmarks(&self) -> Vec<BookmarkItem> {
2246 let xref = self.pdf.xref();
2247 let root_id = xref.root_id();
2248 let catalog: Dict<'_> = match xref.get(root_id) {
2249 Some(d) => d,
2250 None => return Vec::new(),
2251 };
2252
2253 let outlines: Dict<'_> = match catalog.get(OUTLINES) {
2254 Some(d) => d,
2255 None => return Vec::new(),
2256 };
2257
2258 let first: Dict<'_> = match outlines.get(FIRST) {
2259 Some(d) => d,
2260 None => return Vec::new(),
2261 };
2262
2263 let mut visited = BTreeSet::new();
2267 let pages = self.page_object_ids();
2268 parse_outline_items(&first, 0, &mut visited, &pages)
2269 }
2270
2271 pub fn page_links(&self, index: usize) -> Result<Vec<PageLink>> {
2277 let page = self.get_page(index)?;
2278 let pages = self.page_object_ids();
2279 let mut links = Vec::new();
2280 for annot in page.annots() {
2281 if annot.get::<Name>(SUBTYPE).as_deref() != Some(b"Link".as_slice()) {
2282 continue;
2283 }
2284 let rect = match annot.get::<pdf_render::pdf_syntax::object::Rect>(RECT) {
2285 Some(rect) => [rect.x0, rect.y0, rect.x1, rect.y1],
2286 None => continue,
2287 };
2288 let id = annot
2289 .get::<pdf_render::pdf_syntax::object::String>(NM)
2290 .map(|s| bytes_to_string(s.as_bytes()))
2291 .unwrap_or_default();
2292 let xref = annot
2293 .obj_id()
2294 .and_then(|id| u32::try_from(id.obj_number).ok())
2295 .unwrap_or(0);
2296 links.push(PageLink {
2297 target: link_target(&annot, &pages),
2298 rect,
2299 xref,
2300 id,
2301 });
2302 }
2303 Ok(links)
2304 }
2305
2306 fn page_object_ids(&self) -> Vec<Option<ObjectIdentifier>> {
2313 self.pdf.pages().iter().map(|p| p.raw().obj_id()).collect()
2314 }
2315
2316 pub fn ocr_page(
2335 &self,
2336 index: usize,
2337 backend: &dyn crate::ocr::OcrBackend,
2338 dpi: f64,
2339 ) -> crate::error::Result<crate::ocr::OcrResult> {
2340 let opts = crate::render::RenderOptions {
2341 dpi,
2342 ..Default::default()
2343 };
2344 let rendered = self.render_page(index, &opts)?;
2345
2346 let mut rgb = Vec::with_capacity((rendered.width * rendered.height * 3) as usize);
2348 for chunk in rendered.pixels.chunks(4) {
2349 rgb.push(chunk[0]);
2350 rgb.push(chunk[1]);
2351 rgb.push(chunk[2]);
2352 }
2353
2354 backend
2355 .recognize(&rgb, rendered.width, rendered.height)
2356 .map_err(|e| crate::error::EngineError::RenderError(e.to_string()))
2357 }
2358
2359 fn with_limit_collector(settings: &InterpreterSettings) -> (InterpreterSettings, LimitSlot) {
2366 let slot: LimitSlot = Arc::new(Mutex::new(None));
2367 let slot_clone = Arc::clone(&slot);
2368 let prev_sink = settings.warning_sink.clone();
2369 let mut new_settings = settings.clone();
2370 new_settings.warning_sink = Arc::new(move |w: InterpreterWarning| {
2371 if let InterpreterWarning::StreamTooLarge { observed, limit } = w {
2372 let mut guard = slot_clone.lock().unwrap_or_else(|e| e.into_inner());
2373 if guard.is_none() {
2374 *guard = Some((observed, limit));
2375 }
2376 }
2377 prev_sink(w);
2378 });
2379 (new_settings, slot)
2380 }
2381
2382 fn check_limit_slot(slot: &LimitSlot) -> Result<()> {
2387 if let Some((observed, limit)) = *slot.lock().unwrap_or_else(|e| e.into_inner()) {
2388 return Err(EngineError::LimitExceeded(LimitError::StreamTooLarge {
2389 actual_bytes: observed,
2390 limit_bytes: limit,
2391 }));
2392 }
2393 Ok(())
2394 }
2395
2396 fn get_page(&self, index: usize) -> Result<&Page<'_>> {
2397 let pages = self.pdf.pages();
2398 if index >= pages.len() {
2399 return Err(EngineError::PageOutOfRange {
2400 index,
2401 count: pages.len(),
2402 });
2403 }
2404 Ok(&pages[index])
2405 }
2406
2407 fn text_extraction_settings(&self) -> InterpreterSettings {
2408 let mut settings = self.settings.clone();
2409 settings.skip_signature_widgets = false;
2412 settings
2413 }
2414
2415 fn create_context_with_settings<'a>(
2416 page: &Page<'a>,
2417 settings: InterpreterSettings,
2418 ) -> Context<'a> {
2419 let (w, h) = page.render_dimensions();
2420 Context::new(
2421 page.initial_transform(false),
2422 Rect::new(0.0, 0.0, w as f64, h as f64),
2423 page.xref(),
2424 settings,
2425 )
2426 }
2427
2428 fn extract_text_with_settings<'a>(
2429 page: &Page<'a>,
2430 settings: InterpreterSettings,
2431 keep_ligatures: bool,
2432 ) -> (String, InterpreterSettings) {
2433 let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2434 let mut ctx = Self::create_context_with_settings(page, settings);
2435 interpret_page(page, &mut ctx, &mut device);
2436 let settings = ctx.into_settings();
2437 (device.into_text(), settings)
2438 }
2439
2440 fn extract_text_blocks_with_settings<'a>(
2441 page: &Page<'a>,
2442 settings: InterpreterSettings,
2443 keep_ligatures: bool,
2444 ) -> (Vec<TextBlock>, InterpreterSettings) {
2445 let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2446 let mut ctx = Self::create_context_with_settings(page, settings);
2447 interpret_page(page, &mut ctx, &mut device);
2448 let settings = ctx.into_settings();
2449 (device.into_blocks(), settings)
2450 }
2451
2452 #[cfg(feature = "xfa")]
2453 fn open_flattened_xfa_for_render(&self) -> Option<Self> {
2454 if !crate::xfa::has_xfa(self) {
2455 return None;
2456 }
2457
2458 let flat_bytes = crate::xfa::flatten(self).ok()?;
2459 let mut flat_doc = Self::open(flat_bytes).ok()?;
2460 flat_doc.settings = self.settings.clone();
2461 Some(flat_doc)
2462 }
2463}
2464
2465pub fn gray_page(doc: &PdfDocument, page: usize, dpi: u32) -> Result<(u32, u32, Vec<u8>)> {
2473 let options = RenderOptions {
2474 dpi: f64::from(dpi),
2475 ..Default::default()
2476 };
2477 let rendered = doc.render_page(page, &options)?;
2478 let mut data = Vec::with_capacity(rendered.width as usize * rendered.height as usize);
2479 for px in rendered.pixels.chunks_exact(4) {
2480 let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
2481 let alpha = f32::from(a) / 255.0;
2484 let blend = |c: u8| (f32::from(c) * alpha + 255.0 * (1.0 - alpha)).round() as u8;
2485 let (r, g, b) = (blend(r), blend(g), blend(b));
2486 let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
2487 data.push(luma.round().clamp(0.0, 255.0) as u8);
2488 }
2489 Ok((rendered.width, rendered.height, data))
2490}
2491
2492pub fn join_page_texts<I>(page_texts: I) -> String
2504where
2505 I: IntoIterator,
2506 I::Item: AsRef<str>,
2507{
2508 let mut text = String::new();
2509
2510 for page_text in page_texts {
2511 text.push_str(page_text.as_ref());
2512 while !text.is_empty() && !text.ends_with("\n\n") {
2513 text.push('\n');
2514 }
2515 text.push('\u{000C}');
2516 }
2517
2518 text
2519}
2520
2521fn mask_table_switches(
2530 switches: crate::learned::Switches,
2531 strategy: crate::TableStrategy,
2532) -> crate::learned::Switches {
2533 let mut switches = switches;
2534 match strategy {
2535 crate::TableStrategy::Lines => {
2536 switches.table_region = false;
2537 switches.table_structure = false;
2538 switches.table_judge = false;
2539 }
2540 crate::TableStrategy::Text => {
2541 switches.table_lattice = false;
2542 }
2543 crate::TableStrategy::Both => {}
2544 }
2545 switches
2546}
2547
2548#[cfg(test)]
2549mod extract_all_text_tests {
2550 use super::join_page_texts;
2551
2552 #[test]
2553 fn terminates_every_page_like_pdftotext() {
2554 assert_eq!(
2555 join_page_texts(["Page 1", "Page 2"]),
2556 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2557 );
2558 }
2559
2560 #[test]
2561 fn a_page_carries_a_separator_even_when_it_has_no_text() {
2562 assert_eq!(
2564 join_page_texts(["", "Page 2"]),
2565 "\u{000C}Page 2\n\n\u{000C}"
2566 );
2567 }
2568
2569 #[test]
2570 fn reuses_existing_blank_line_before_form_feed() {
2571 assert_eq!(
2572 join_page_texts(["Page 1\n\n", "Page 2"]),
2573 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2574 );
2575 }
2576
2577 #[test]
2578 fn as_many_separators_as_pages() {
2579 let text = join_page_texts(["a", "b", "c"]);
2580 assert_eq!(text.matches('\u{000C}').count(), 3);
2581 assert_eq!(text.split('\u{000C}').count(), 4);
2583 }
2584}
2585
2586const MAX_OUTLINE_DEPTH: usize = 100;
2589
2590fn parse_outline_items(
2597 item_dict: &Dict<'_>,
2598 depth: usize,
2599 visited: &mut BTreeSet<ObjectIdentifier>,
2600 pages: &[Option<ObjectIdentifier>],
2601) -> Vec<BookmarkItem> {
2602 let mut items = Vec::new();
2603 if depth >= MAX_OUTLINE_DEPTH {
2604 return items;
2605 }
2606 let mut current: Option<Dict<'_>> = Some(item_dict.clone());
2607
2608 while let Some(dict) = current {
2609 if let Some(id) = dict.obj_id() {
2612 if !visited.insert(id) {
2613 break;
2614 }
2615 }
2616
2617 let title = dict
2618 .get::<pdf_render::pdf_syntax::object::String>(TITLE)
2619 .map(|s| bytes_to_string(s.as_bytes()))
2620 .unwrap_or_default();
2621
2622 let children = match dict.get::<Dict<'_>>(FIRST) {
2623 Some(child_dict) => parse_outline_items(&child_dict, depth + 1, visited, pages),
2624 None => Vec::new(),
2625 };
2626
2627 items.push(BookmarkItem {
2628 title,
2629 page: outline_destination_page(&dict, pages),
2630 children,
2631 });
2632
2633 current = dict.get::<Dict<'_>>(NEXT);
2634 }
2635
2636 items
2637}
2638
2639fn outline_destination_page(item: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> Option<usize> {
2648 if let Some(page) = explicit_dest_page(item.get::<Array<'_>>(DEST), pages) {
2649 return Some(page);
2650 }
2651 let action = item.get::<Dict<'_>>(A)?;
2652 if action.get::<Name>(S).as_deref() != Some(b"GoTo".as_slice()) {
2653 return None;
2654 }
2655 if action.contains_key(F) {
2656 return None;
2658 }
2659 explicit_dest_page(action.get::<Array<'_>>(D), pages)
2660}
2661
2662fn explicit_dest_page(
2665 dest: Option<Array<'_>>,
2666 pages: &[Option<ObjectIdentifier>],
2667) -> Option<usize> {
2668 let first = dest?.raw_iter().next()?;
2669 let target = ObjectIdentifier::from(ObjRef::try_from(first).ok()?);
2670 pages.iter().position(|id| *id == Some(target))
2671}
2672
2673fn link_target(annot: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2680 if let Some(action) = annot.get::<Dict<'_>>(A) {
2681 if action.get::<Name>(S).as_deref() == Some(b"URI".as_slice()) {
2682 let uri = action
2683 .get::<pdf_render::pdf_syntax::object::String>(URI)
2684 .map(|s| bytes_to_string(s.as_bytes()))
2685 .unwrap_or_default();
2686 return LinkTarget::Uri(uri);
2687 }
2688 if action.get::<Name>(S).as_deref() == Some(b"GoTo".as_slice()) && !action.contains_key(F) {
2689 return link_destination(action.get::<Object<'_>>(D), pages);
2690 }
2691 return LinkTarget::Unknown;
2692 }
2693 link_destination(annot.get::<Object<'_>>(DEST), pages)
2694}
2695
2696fn link_destination(dest: Option<Object<'_>>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2700 match dest {
2701 Some(Object::Array(array)) => explicit_dest_page(Some(array), pages)
2702 .map(LinkTarget::Page)
2703 .unwrap_or(LinkTarget::Unknown),
2704 Some(Object::Name(name)) => LinkTarget::Named(name.as_str().to_string()),
2705 Some(Object::String(text)) => LinkTarget::Named(bytes_to_string(text.as_bytes())),
2706 _ => LinkTarget::Unknown,
2707 }
2708}
2709
2710fn bytes_to_string(bytes: &[u8]) -> String {
2712 if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
2714 let chars: Vec<u16> = bytes[2..]
2715 .chunks(2)
2716 .filter_map(|c| {
2717 if c.len() == 2 {
2718 Some(u16::from_be_bytes([c[0], c[1]]))
2719 } else {
2720 None
2721 }
2722 })
2723 .collect();
2724 return String::from_utf16_lossy(&chars);
2725 }
2726
2727 match std::str::from_utf8(bytes) {
2729 Ok(s) => s.to_string(),
2730 Err(_) => bytes.iter().map(|&b| b as char).collect(),
2731 }
2732}
2733
2734#[cfg(test)]
2735mod tests {
2736 use super::*;
2737 use crate::render::{ColorMode, PixelFormat, RenderConfig, RenderOptions};
2738 use lopdf::{Document as LoDocument, Object};
2739 use std::path::PathBuf;
2740
2741 fn corpus_path(name: &str) -> PathBuf {
2742 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2743 .join("../../corpus")
2744 .join(name)
2745 }
2746
2747 #[test]
2750 fn cyclic_outline_terminates_and_is_bounded() {
2751 fn cyclic_outline_pdf() -> Vec<u8> {
2752 let objs: [&[u8]; 6] = [
2753 b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
2754 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
2755 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2756 b"<< /Type /Outlines /First 5 0 R >>",
2757 b"<< /Title (A) /Next 6 0 R >>",
2758 b"<< /Title (B) /Next 5 0 R >>", ];
2760 let mut buf = Vec::new();
2761 let mut offsets = [0usize; 7];
2762 buf.extend_from_slice(b"%PDF-1.7\n");
2763 for (i, body) in objs.iter().enumerate() {
2764 offsets[i + 1] = buf.len();
2765 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2766 buf.extend_from_slice(body);
2767 buf.extend_from_slice(b"\nendobj\n");
2768 }
2769 let xref_off = buf.len();
2770 buf.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
2771 for o in &offsets[1..7] {
2772 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2773 }
2774 buf.extend_from_slice(
2775 format!("trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2776 .as_bytes(),
2777 );
2778 buf
2779 }
2780
2781 fn count(b: &BookmarkItem) -> usize {
2782 1 + b.children.iter().map(count).sum::<usize>()
2783 }
2784
2785 let doc = PdfDocument::open(cyclic_outline_pdf()).expect("open cyclic-outline PDF");
2786 let bookmarks = doc.bookmarks();
2787 let total: usize = bookmarks.iter().map(count).sum();
2788 assert!(
2789 total <= 2,
2790 "cyclic /Next outline must not loop forever; got {total} items"
2791 );
2792 }
2793
2794 #[test]
2797 fn outline_destinations_resolve_to_their_pages() {
2798 fn outlined_pdf() -> Vec<u8> {
2799 let objs: [&[u8]; 9] = [
2800 b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>",
2801 b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
2802 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2803 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2804 b"<< /Type /Outlines /First 6 0 R /Count 3 >>",
2805 b"<< /Title (A) /Parent 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] >>",
2806 b"<< /Title (B) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /First 9 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>",
2807 b"<< /Title (C) /Parent 5 0 R /Prev 7 0 R /Dest (chapter-three) >>",
2808 b"<< /Title (B.1) /Parent 7 0 R /Dest [4 0 R /Fit] >>",
2809 ];
2810 let mut buf = Vec::new();
2811 let mut offsets = [0usize; 10];
2812 buf.extend_from_slice(b"%PDF-1.7\n");
2813 for (i, body) in objs.iter().enumerate() {
2814 offsets[i + 1] = buf.len();
2815 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2816 buf.extend_from_slice(body);
2817 buf.extend_from_slice(b"\nendobj\n");
2818 }
2819 let xref_off = buf.len();
2820 buf.extend_from_slice(b"xref\n0 10\n0000000000 65535 f \n");
2821 for o in &offsets[1..10] {
2822 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2823 }
2824 buf.extend_from_slice(
2825 format!("trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2826 .as_bytes(),
2827 );
2828 buf
2829 }
2830
2831 let doc = PdfDocument::open(outlined_pdf()).expect("open outlined PDF");
2832 let bookmarks = doc.bookmarks();
2833 assert_eq!(bookmarks.len(), 3);
2834 assert_eq!(
2835 (bookmarks[0].title.as_str(), bookmarks[0].page),
2836 ("A", Some(0))
2837 );
2838 assert_eq!(
2839 (bookmarks[1].title.as_str(), bookmarks[1].page),
2840 ("B", Some(1))
2841 );
2842 assert_eq!(bookmarks[1].children.len(), 1);
2843 assert_eq!(
2844 (
2845 bookmarks[1].children[0].title.as_str(),
2846 bookmarks[1].children[0].page
2847 ),
2848 ("B.1", Some(1))
2849 );
2850 assert_eq!(
2851 (bookmarks[2].title.as_str(), bookmarks[2].page),
2852 ("C", None)
2853 );
2854 }
2855
2856 fn repo_fixture(name: &str) -> Option<PathBuf> {
2864 let dir = std::env::var("PDFLUENT_TEST_FIXTURES").ok().map_or_else(
2865 || {
2866 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2867 .join("..")
2868 .join("..")
2869 .join("tests")
2870 },
2871 PathBuf::from,
2872 );
2873 let path = dir.join(name);
2874 path.exists().then_some(path)
2875 }
2876
2877 #[test]
2878 fn shared_image_cache_is_render_neutral() {
2879 let Some(path) = repo_fixture("corpus-mini/scanned.pdf") else {
2880 eprintln!("skipping: repository fixture tree not present");
2881 return;
2882 };
2883 let data = std::fs::read(&path).expect("read scanned.pdf fixture");
2884 let cfg = RenderConfig::default();
2885
2886 let doc_on = PdfDocument::open(data.clone()).expect("open cache-on");
2889 let miss = doc_on
2890 .render_page_with_config(0, &cfg)
2891 .expect("cold render (cache miss)");
2892 let hit = doc_on
2893 .render_page_with_config(0, &cfg)
2894 .expect("warm render (cache hit)");
2895
2896 let mut doc_off = PdfDocument::open(data).expect("open cache-off");
2899 doc_off.set_settings(InterpreterSettings::default());
2900 let uncached = doc_off
2901 .render_page_with_config(0, &cfg)
2902 .expect("render with cache disabled");
2903
2904 assert_eq!(
2905 (miss.width, miss.height),
2906 (uncached.width, uncached.height),
2907 "render dimensions must match"
2908 );
2909 assert_eq!(
2910 miss.pixels, hit.pixels,
2911 "a cache hit must return exactly the freshly-decoded render"
2912 );
2913 assert_eq!(
2914 miss.pixels, uncached.pixels,
2915 "cache-enabled render must equal cache-disabled render"
2916 );
2917 }
2918
2919 fn normalize_text(text: &str) -> String {
2920 text.split_whitespace().collect::<Vec<_>>().join(" ")
2921 }
2922
2923 fn strip_type0_tounicode(data: &[u8]) -> (Vec<u8>, usize) {
2924 fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<Vec<u8>> {
2925 match dict.get(key).ok()? {
2926 Object::Name(name) => Some(name.clone()),
2927 _ => None,
2928 }
2929 }
2930
2931 fn descendant_is_cidfont_type2(doc: &LoDocument, type0: &lopdf::Dictionary) -> bool {
2932 let Some(Object::Array(descendants)) = type0.get(b"DescendantFonts").ok() else {
2933 return false;
2934 };
2935 let Some(Object::Reference(desc_id)) = descendants.first() else {
2936 return false;
2937 };
2938 let Ok(Object::Dictionary(descendant)) = doc.get_object(*desc_id) else {
2939 return false;
2940 };
2941 matches!(
2942 descendant.get(b"Subtype").ok(),
2943 Some(Object::Name(name)) if name.as_slice() == b"CIDFontType2"
2944 )
2945 }
2946
2947 let mut doc = LoDocument::load_mem(data).expect("load stripped-to-unicode fixture");
2948 let ids: Vec<_> = doc.objects.keys().copied().collect();
2949 let mut removed = 0usize;
2950
2951 for id in ids {
2952 let Some(Object::Dictionary(dict)) = doc.objects.get(&id) else {
2953 continue;
2954 };
2955 if !matches!(
2956 dict.get(b"Subtype").ok(),
2957 Some(Object::Name(name)) if name.as_slice() == b"Type0"
2958 ) {
2959 continue;
2960 }
2961 if !matches!(
2962 get_name(dict, b"Encoding").as_deref(),
2963 Some(b"Identity-H") | Some(b"Identity-V")
2964 ) {
2965 continue;
2966 }
2967 if !descendant_is_cidfont_type2(&doc, dict) {
2968 continue;
2969 }
2970
2971 if let Some(Object::Dictionary(type0)) = doc.objects.get_mut(&id) {
2972 if type0.has(b"ToUnicode") {
2973 type0.remove(b"ToUnicode");
2974 removed += 1;
2975 }
2976 }
2977 }
2978
2979 let mut out = Vec::new();
2980 doc.save_to(&mut out)
2981 .expect("save stripped-to-unicode fixture");
2982 (out, removed)
2983 }
2984
2985 fn solid_fill_pdf_bytes(color_operator: &str) -> Vec<u8> {
2986 use lopdf::{dictionary, Document, Object, Stream};
2987
2988 let mut doc = Document::with_version("1.4");
2989
2990 let pages_id = doc.new_object_id();
2991 let page_id = doc.new_object_id();
2992 let content = format!("{color_operator}\n0 0 72 72 re f\n");
2993 let content_id = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
2994
2995 doc.objects.insert(
2996 page_id,
2997 Object::Dictionary(dictionary! {
2998 "Type" => Object::Name(b"Page".to_vec()),
2999 "Parent" => Object::Reference(pages_id),
3000 "MediaBox" => Object::Array(vec![
3001 Object::Integer(0),
3002 Object::Integer(0),
3003 Object::Integer(72),
3004 Object::Integer(72),
3005 ]),
3006 "Contents" => Object::Reference(content_id),
3007 }),
3008 );
3009
3010 doc.objects.insert(
3011 pages_id,
3012 Object::Dictionary(dictionary! {
3013 "Type" => Object::Name(b"Pages".to_vec()),
3014 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3015 "Count" => Object::Integer(1),
3016 }),
3017 );
3018
3019 let catalog_id = doc.new_object_id();
3020 doc.objects.insert(
3021 catalog_id,
3022 Object::Dictionary(dictionary! {
3023 "Type" => Object::Name(b"Catalog".to_vec()),
3024 "Pages" => Object::Reference(pages_id),
3025 }),
3026 );
3027
3028 doc.trailer.set("Root", Object::Reference(catalog_id));
3029
3030 let mut bytes = Vec::new();
3031 doc.save_to(&mut bytes).expect("save solid fill fixture");
3032 bytes
3033 }
3034
3035 fn mixed_rgb_cmyk_pdf_bytes() -> Vec<u8> {
3036 use lopdf::{dictionary, Document, Object, Stream};
3037
3038 let mut doc = Document::with_version("1.4");
3039 let pages_id = doc.new_object_id();
3040 let page_id = doc.new_object_id();
3041 let content = b"1 0 0 rg\n0 0 36 72 re f\n1 0 0 0 k\n36 0 36 72 re f\n".to_vec();
3042 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3043
3044 doc.objects.insert(
3045 page_id,
3046 Object::Dictionary(dictionary! {
3047 "Type" => "Page",
3048 "Parent" => Object::Reference(pages_id),
3049 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3050 "Contents" => Object::Reference(content_id),
3051 }),
3052 );
3053 doc.objects.insert(
3054 pages_id,
3055 Object::Dictionary(dictionary! {
3056 "Type" => "Pages",
3057 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3058 "Count" => Object::Integer(1),
3059 }),
3060 );
3061 let catalog_id = doc.new_object_id();
3062 doc.objects.insert(
3063 catalog_id,
3064 Object::Dictionary(dictionary! {
3065 "Type" => "Catalog",
3066 "Pages" => Object::Reference(pages_id),
3067 }),
3068 );
3069 doc.trailer.set("Root", Object::Reference(catalog_id));
3070
3071 let mut bytes = Vec::new();
3072 doc.save_to(&mut bytes)
3073 .expect("save mixed rgb/cmyk fixture");
3074 bytes
3075 }
3076
3077 fn transparent_cmyk_pdf_bytes() -> Vec<u8> {
3078 use lopdf::{dictionary, Document, Object, Stream};
3079
3080 let mut doc = Document::with_version("1.4");
3081 let pages_id = doc.new_object_id();
3082 let page_id = doc.new_object_id();
3083 let gs_id = doc.add_object(Object::Dictionary(dictionary! {
3084 "Type" => "ExtGState",
3085 "ca" => Object::Real(0.5),
3086 }));
3087 let content = b"/GS1 gs\n1 0 0 0 k\n0 0 72 72 re f\n".to_vec();
3088 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3089
3090 doc.objects.insert(
3091 page_id,
3092 Object::Dictionary(dictionary! {
3093 "Type" => "Page",
3094 "Parent" => Object::Reference(pages_id),
3095 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3096 "Resources" => dictionary! {
3097 "ExtGState" => dictionary! {
3098 "GS1" => Object::Reference(gs_id),
3099 },
3100 },
3101 "Contents" => Object::Reference(content_id),
3102 }),
3103 );
3104 doc.objects.insert(
3105 pages_id,
3106 Object::Dictionary(dictionary! {
3107 "Type" => "Pages",
3108 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3109 "Count" => Object::Integer(1),
3110 }),
3111 );
3112 let catalog_id = doc.new_object_id();
3113 doc.objects.insert(
3114 catalog_id,
3115 Object::Dictionary(dictionary! {
3116 "Type" => "Catalog",
3117 "Pages" => Object::Reference(pages_id),
3118 }),
3119 );
3120 doc.trailer.set("Root", Object::Reference(catalog_id));
3121
3122 let mut bytes = Vec::new();
3123 doc.save_to(&mut bytes)
3124 .expect("save transparent cmyk fixture");
3125 bytes
3126 }
3127
3128 fn cmyk_image_pdf_bytes() -> Vec<u8> {
3129 use lopdf::{dictionary, Document, Object, Stream};
3130
3131 let mut doc = Document::with_version("1.4");
3132 let pages_id = doc.new_object_id();
3133 let page_id = doc.new_object_id();
3134 let image_id = doc.add_object(Stream::new(
3135 dictionary! {
3136 "Type" => "XObject",
3137 "Subtype" => "Image",
3138 "Width" => Object::Integer(2),
3139 "Height" => Object::Integer(1),
3140 "BitsPerComponent" => Object::Integer(8),
3141 "ColorSpace" => "DeviceCMYK",
3142 },
3143 vec![255, 0, 0, 0, 0, 255, 0, 0],
3144 ));
3145 let content = b"q\n2 0 0 1 0 0 cm\n/Im1 Do\nQ\n".to_vec();
3146 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3147
3148 doc.objects.insert(
3149 page_id,
3150 Object::Dictionary(dictionary! {
3151 "Type" => "Page",
3152 "Parent" => Object::Reference(pages_id),
3153 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 2.into(), 1.into()]),
3154 "Resources" => dictionary! {
3155 "XObject" => dictionary! {
3156 "Im1" => Object::Reference(image_id),
3157 },
3158 },
3159 "Contents" => Object::Reference(content_id),
3160 }),
3161 );
3162 doc.objects.insert(
3163 pages_id,
3164 Object::Dictionary(dictionary! {
3165 "Type" => "Pages",
3166 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3167 "Count" => Object::Integer(1),
3168 }),
3169 );
3170 let catalog_id = doc.new_object_id();
3171 doc.objects.insert(
3172 catalog_id,
3173 Object::Dictionary(dictionary! {
3174 "Type" => "Catalog",
3175 "Pages" => Object::Reference(pages_id),
3176 }),
3177 );
3178 doc.trailer.set("Root", Object::Reference(catalog_id));
3179
3180 let mut bytes = Vec::new();
3181 doc.save_to(&mut bytes).expect("save cmyk image fixture");
3182 bytes
3183 }
3184
3185 fn pixel_at(rendered: &RenderedPage, x: u32, y: u32) -> [u8; 4] {
3186 let idx = ((y * rendered.width + x) * 4) as usize;
3187 [
3188 rendered.pixels[idx],
3189 rendered.pixels[idx + 1],
3190 rendered.pixels[idx + 2],
3191 rendered.pixels[idx + 3],
3192 ]
3193 }
3194
3195 fn non_embedded_truetype_pdf_bytes(
3200 base_font: &[u8],
3201 encoding: &[u8],
3202 text_bytes: &[u8],
3203 ) -> Vec<u8> {
3204 use lopdf::{dictionary, Document, Object, Stream};
3205
3206 let mut doc = Document::with_version("1.4");
3207
3208 let font_id = doc.add_object(Object::Dictionary(dictionary! {
3209 "Type" => "Font",
3210 "Subtype" => "TrueType",
3211 "Name" => Object::Name(b"F0".to_vec()),
3212 "BaseFont" => Object::Name(base_font.to_vec()),
3213 "Encoding" => Object::Name(encoding.to_vec()),
3214 }));
3215
3216 let resources_id = doc.add_object(Object::Dictionary(dictionary! {
3217 "Font" => dictionary! { "F0" => Object::Reference(font_id) },
3218 }));
3219
3220 let mut content = Vec::new();
3221 content.extend_from_slice(b"BT\n/F0 12 Tf\n100 700 Td\n(");
3222 for &b in text_bytes {
3223 match b {
3224 b'(' | b')' | b'\\' => {
3225 content.push(b'\\');
3226 content.push(b);
3227 }
3228 _ => content.push(b),
3229 }
3230 }
3231 content.extend_from_slice(b") Tj\nET\n");
3232 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3233
3234 let pages_id = doc.new_object_id();
3235 let page_id = doc.add_object(Object::Dictionary(dictionary! {
3236 "Type" => "Page",
3237 "Parent" => Object::Reference(pages_id),
3238 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3239 "Resources" => Object::Reference(resources_id),
3240 "Contents" => Object::Reference(content_id),
3241 }));
3242 doc.objects.insert(
3243 pages_id,
3244 Object::Dictionary(dictionary! {
3245 "Type" => "Pages",
3246 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3247 "Count" => Object::Integer(1),
3248 }),
3249 );
3250 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
3251 "Type" => "Catalog",
3252 "Pages" => Object::Reference(pages_id),
3253 }));
3254 doc.trailer.set("Root", Object::Reference(catalog_id));
3255
3256 let mut bytes = Vec::new();
3257 doc.save_to(&mut bytes).expect("save non-embedded fixture");
3258 bytes
3259 }
3260
3261 fn push_button_caption_pdf_bytes(caption: &[u8]) -> Vec<u8> {
3264 use lopdf::{dictionary, Document, Object, Stream, StringFormat};
3265
3266 let mut doc = Document::with_version("1.4");
3267
3268 let catalog_id = doc.new_object_id();
3269 let pages_id = doc.new_object_id();
3270 let page_id = doc.new_object_id();
3271 let acroform_id = doc.new_object_id();
3272 let content_id = doc.new_object_id();
3273 let widget_id = doc.new_object_id();
3274
3275 doc.objects.insert(
3276 content_id,
3277 Object::Stream(Stream::new(dictionary! {}, Vec::new())),
3278 );
3279 doc.objects.insert(
3280 widget_id,
3281 Object::Dictionary(dictionary! {
3282 "Type" => "Annot",
3283 "Subtype" => "Widget",
3284 "FT" => "Btn",
3285 "Ff" => Object::Integer(1 << 16),
3286 "T" => Object::String(b"button".to_vec(), StringFormat::Literal),
3287 "MK" => dictionary! {
3288 "CA" => Object::String(caption.to_vec(), StringFormat::Literal),
3289 },
3290 "Rect" => Object::Array(vec![100.into(), 700.into(), 260.into(), 730.into()]),
3291 "P" => Object::Reference(page_id),
3292 }),
3293 );
3294 doc.objects.insert(
3295 page_id,
3296 Object::Dictionary(dictionary! {
3297 "Type" => "Page",
3298 "Parent" => Object::Reference(pages_id),
3299 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3300 "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
3301 "Contents" => Object::Reference(content_id),
3302 }),
3303 );
3304 doc.objects.insert(
3305 pages_id,
3306 Object::Dictionary(dictionary! {
3307 "Type" => "Pages",
3308 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3309 "Count" => Object::Integer(1),
3310 }),
3311 );
3312 doc.objects.insert(
3313 acroform_id,
3314 Object::Dictionary(dictionary! {
3315 "Fields" => Object::Array(vec![Object::Reference(widget_id)]),
3316 }),
3317 );
3318 doc.objects.insert(
3319 catalog_id,
3320 Object::Dictionary(dictionary! {
3321 "Type" => "Catalog",
3322 "Pages" => Object::Reference(pages_id),
3323 "AcroForm" => Object::Reference(acroform_id),
3324 }),
3325 );
3326 doc.trailer.set("Root", Object::Reference(catalog_id));
3327
3328 let mut bytes = Vec::new();
3329 doc.save_to(&mut bytes)
3330 .expect("save push-button caption fixture");
3331 bytes
3332 }
3333
3334 #[test]
3335 fn extract_text_non_embedded_truetype_alias_resolves_via_winansi() {
3336 let bytes = non_embedded_truetype_pdf_bytes(
3342 b"TimesNewRoman",
3343 b"WinAnsiEncoding",
3344 b"UNITED STATES DISTRICT COURT",
3345 );
3346 let text = PdfDocument::open(bytes)
3347 .expect("open non-embedded TrueType fixture")
3348 .extract_text(0)
3349 .expect("extract non-embedded TrueType text");
3350 let norm = normalize_text(&text);
3351 assert!(
3352 norm.contains("UNITED STATES DISTRICT COURT"),
3353 "expected WinAnsi-decoded text, got: {norm:?}"
3354 );
3355 }
3356
3357 #[test]
3358 fn extract_text_non_embedded_truetype_unknown_name_still_decodes() {
3359 let bytes = non_embedded_truetype_pdf_bytes(
3366 b"OpaqueCustomXYZ",
3367 b"WinAnsiEncoding",
3368 b"Hello, world!",
3369 );
3370 let text = PdfDocument::open(bytes)
3371 .expect("open custom non-embedded fixture")
3372 .extract_text(0)
3373 .expect("extract custom non-embedded text");
3374 let norm = normalize_text(&text);
3375 assert!(
3376 norm.contains("Hello, world!"),
3377 "expected WinAnsi-decoded text, got: {norm:?}"
3378 );
3379 }
3380
3381 #[test]
3382 fn extract_acroform_text_includes_push_button_mk_caption() {
3383 let bytes = push_button_caption_pdf_bytes(b"Don't cry over spilt milk");
3384 let doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3385
3386 let page_text = doc.extract_text(0).expect("extract page text");
3387 assert!(
3388 normalize_text(&page_text).is_empty(),
3389 "expected empty page content stream, got: {page_text:?}"
3390 );
3391
3392 let acroform_text = doc.extract_acroform_text();
3393 assert_eq!(normalize_text(&acroform_text), "Don't cry over spilt milk");
3394
3395 let all_text = doc.extract_all_text();
3396 assert_eq!(normalize_text(&all_text), "Don't cry over spilt milk");
3397 }
3398
3399 #[test]
3404 fn acroform_values_fold_their_ligatures() {
3405 let mut caption = vec![0xFE_u8, 0xFF_u8];
3409 for unit in "o\u{FB03}ce hours".encode_utf16() {
3410 caption.extend_from_slice(&unit.to_be_bytes());
3411 }
3412 let bytes = push_button_caption_pdf_bytes(&caption);
3413 let mut doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3414
3415 assert_eq!(normalize_text(&doc.extract_acroform_text()), "office hours");
3416 assert_eq!(normalize_text(&doc.extract_all_text()), "office hours");
3417
3418 doc.set_keep_ligatures(true);
3419 assert_eq!(
3420 normalize_text(&doc.extract_acroform_text()),
3421 "o\u{FB03}ce hours",
3422 "keep_ligatures did not reach the field values"
3423 );
3424 }
3425
3426 #[test]
3427 fn bytes_to_string_utf8() {
3428 assert_eq!(bytes_to_string(b"hello"), "hello");
3429 }
3430
3431 #[test]
3432 fn bytes_to_string_latin1() {
3433 let bytes = &[0xC4, 0xD6, 0xDC]; let s = bytes_to_string(bytes);
3435 assert_eq!(s, "ÄÖÜ");
3436 }
3437
3438 #[test]
3439 fn bytes_to_string_utf16() {
3440 let bytes = &[0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]; assert_eq!(bytes_to_string(bytes), "Hi");
3442 }
3443
3444 #[test]
3445 fn document_info_default() {
3446 let info = DocumentInfo::default();
3447 assert!(info.title.is_none());
3448 assert!(info.author.is_none());
3449 }
3450
3451 #[test]
3452 fn bookmark_item_children() {
3453 let item = BookmarkItem {
3454 title: "Root".into(),
3455 page: None,
3456 children: vec![BookmarkItem {
3457 title: "Child".into(),
3458 page: Some(0),
3459 children: Vec::new(),
3460 }],
3461 };
3462 assert_eq!(item.children.len(), 1);
3463 assert_eq!(item.children[0].title, "Child");
3464 }
3465
3466 #[test]
3467 fn extract_text_type0_without_tounicode_uses_font_program_fallback() {
3468 let original = std::fs::read(corpus_path("sf181.pdf")).expect("read sf181 fixture");
3469 let expected = PdfDocument::open(original.clone())
3470 .expect("open original sf181")
3471 .extract_text(0)
3472 .expect("extract original sf181 text");
3473 assert!(
3474 expected.contains("Guide to Personnel Data Standards"),
3475 "unexpected baseline extraction: {expected}"
3476 );
3477
3478 let (stripped, removed) = strip_type0_tounicode(&original);
3479 assert!(
3480 removed > 0,
3481 "expected to strip at least one Type0 ToUnicode"
3482 );
3483
3484 let actual = PdfDocument::open(stripped)
3485 .expect("open stripped sf181")
3486 .extract_text(0)
3487 .expect("extract stripped sf181 text");
3488
3489 let actual_norm = normalize_text(&actual);
3490 let expected_norm = normalize_text(&expected);
3491
3492 assert!(
3493 actual_norm.contains("Guide to Personnel Data Standards"),
3494 "missing main heading after stripping ToUnicode: {actual_norm}"
3495 );
3496 assert!(
3497 actual_norm.contains("Privacy Act Statement"),
3498 "missing body text after stripping ToUnicode: {actual_norm}"
3499 );
3500 assert!(
3501 actual_norm.len() + 32 >= expected_norm.len(),
3502 "too much text lost after stripping ToUnicode: expected {} chars, got {}",
3503 expected_norm.len(),
3504 actual_norm.len()
3505 );
3506 }
3507
3508 #[test]
3509 fn extract_text_identity_h_bogus_tounicode_recovers_via_identity_fallback() {
3510 let bytes =
3518 std::fs::read(corpus_path("PDFBOX-4322-3.pdf")).expect("read PDFBOX-4322-3 fixture");
3519 let doc = PdfDocument::open(bytes).expect("open PDFBOX-4322-3");
3520 let text = doc.extract_all_text();
3521
3522 let norm = normalize_text(&text);
3523 assert!(
3524 norm.contains("Transatlantic Council"),
3525 "expected Identity-H codes to resolve as Unicode: {norm}"
3526 );
3527 assert!(
3528 norm.contains("Boy Scouts of America"),
3529 "expected body text to be recovered: {norm}"
3530 );
3531 }
3532
3533 #[test]
3534 fn render_max_pixels_none_is_unchanged_default_behavior() {
3535 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3537 let baseline = doc
3538 .render_page(
3539 0,
3540 &RenderOptions {
3541 dpi: 144.0,
3542 ..Default::default()
3543 },
3544 )
3545 .expect("baseline render");
3546 let explicit_none = doc
3547 .render_page(
3548 0,
3549 &RenderOptions {
3550 dpi: 144.0,
3551 max_pixels: None,
3552 ..Default::default()
3553 },
3554 )
3555 .expect("explicit-none render");
3556 assert_eq!(baseline.width, explicit_none.width);
3557 assert_eq!(baseline.height, explicit_none.height);
3558 assert_eq!(baseline.pixels, explicit_none.pixels);
3559 }
3560
3561 #[test]
3562 fn render_max_pixels_budget_clamps_resolution() {
3563 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3564 let full = doc
3565 .render_page(
3566 0,
3567 &RenderOptions {
3568 dpi: 288.0,
3569 ..Default::default()
3570 },
3571 )
3572 .expect("full render");
3573 let full_px = full.width * full.height;
3574 let budget = full_px / 4;
3576 let capped = doc
3577 .render_page(
3578 0,
3579 &RenderOptions {
3580 dpi: 288.0,
3581 max_pixels: Some(budget),
3582 ..Default::default()
3583 },
3584 )
3585 .expect("capped render");
3586 assert!(
3587 capped.width * capped.height <= full_px,
3588 "capped output must not exceed full output"
3589 );
3590 assert!(
3591 capped.width < full.width || capped.height < full.height,
3592 "budget below full pixel count must shrink at least one dimension"
3593 );
3594 }
3595
3596 #[test]
3597 fn render_max_pixels_large_budget_no_clamp() {
3598 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3600 let baseline = doc
3601 .render_page(
3602 0,
3603 &RenderOptions {
3604 dpi: 72.0,
3605 ..Default::default()
3606 },
3607 )
3608 .expect("baseline");
3609 let huge = doc
3610 .render_page(
3611 0,
3612 &RenderOptions {
3613 dpi: 72.0,
3614 max_pixels: Some(100_000_000),
3615 ..Default::default()
3616 },
3617 )
3618 .expect("huge-budget render");
3619 assert_eq!(baseline.width, huge.width);
3620 assert_eq!(baseline.height, huge.height);
3621 assert_eq!(baseline.pixels, huge.pixels);
3622 }
3623
3624 #[test]
3625 fn render_page_with_config_srgb_matches_legacy_render_page() {
3626 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open rgb fixture");
3627 let legacy = doc
3628 .render_page(
3629 0,
3630 &RenderOptions {
3631 dpi: 72.0,
3632 ..Default::default()
3633 },
3634 )
3635 .expect("legacy render succeeds");
3636 let configured = doc
3637 .render_page_with_config(
3638 0,
3639 &RenderConfig {
3640 color_mode: ColorMode::Srgb,
3641 dpi: 72,
3642 },
3643 )
3644 .expect("configured render succeeds");
3645
3646 assert_eq!(legacy.width, configured.width);
3647 assert_eq!(legacy.height, configured.height);
3648 assert_eq!(legacy.pixel_format, PixelFormat::Rgba8);
3649 assert_eq!(configured.pixel_format, PixelFormat::Rgba8);
3650 assert_eq!(legacy.pixels, configured.pixels);
3651 }
3652
3653 #[test]
3654 fn render_page_with_config_preserve_cmyk_returns_cmyk_buffer() {
3655 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3656 let rendered = doc
3657 .render_page_with_config(
3658 0,
3659 &RenderConfig {
3660 color_mode: ColorMode::PreserveCmyk,
3661 dpi: 72,
3662 },
3663 )
3664 .expect("cmyk render succeeds");
3665
3666 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3667 assert_eq!(
3668 rendered.pixels.len(),
3669 rendered.width as usize * rendered.height as usize * 4
3670 );
3671 assert_eq!(
3672 pixel_at(&rendered, rendered.width / 2, rendered.height / 2),
3673 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3674 );
3675 }
3676
3677 #[test]
3678 fn render_page_with_config_simulate_cmyk_does_not_panic_on_cmyk_pdf() {
3679 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3680 let rendered = doc
3681 .render_page_with_config(
3682 0,
3683 &RenderConfig {
3684 color_mode: ColorMode::SimulateCmyk,
3685 dpi: 72,
3686 },
3687 )
3688 .expect("simulate cmyk render succeeds");
3689
3690 assert_eq!(rendered.pixel_format, PixelFormat::Rgba8);
3691 assert!(!rendered.pixels.is_empty());
3692 }
3693
3694 #[test]
3695 fn render_page_with_config_preserve_cmyk_mixed_page_preserves_only_cmyk_region() {
3696 let doc = PdfDocument::open(mixed_rgb_cmyk_pdf_bytes()).expect("open mixed fixture");
3697 let rendered = doc
3698 .render_page_with_config(
3699 0,
3700 &RenderConfig {
3701 color_mode: ColorMode::PreserveCmyk,
3702 dpi: 72,
3703 },
3704 )
3705 .expect("mixed render succeeds");
3706
3707 assert_eq!(
3708 pixel_at(&rendered, 54, 36),
3709 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3710 );
3711 assert_ne!(
3712 pixel_at(&rendered, 18, 36),
3713 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3714 );
3715 }
3716
3717 #[test]
3718 fn render_page_with_config_preserve_cmyk_transparent_page_does_not_crash() {
3719 let doc =
3720 PdfDocument::open(transparent_cmyk_pdf_bytes()).expect("open transparent cmyk fixture");
3721 let rendered = doc
3722 .render_page_with_config(
3723 0,
3724 &RenderConfig {
3725 color_mode: ColorMode::PreserveCmyk,
3726 dpi: 72,
3727 },
3728 )
3729 .expect("transparent cmyk render succeeds");
3730
3731 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3732 assert_eq!(
3733 rendered.pixels.len(),
3734 rendered.width as usize * rendered.height as usize * 4
3735 );
3736 }
3737
3738 #[test]
3739 fn render_page_with_config_preserve_cmyk_keeps_device_cmyk_image_bytes() {
3740 let doc = PdfDocument::open(cmyk_image_pdf_bytes()).expect("open cmyk image fixture");
3741 let rendered = doc
3742 .render_page_with_config(
3743 0,
3744 &RenderConfig {
3745 color_mode: ColorMode::PreserveCmyk,
3746 dpi: 72,
3747 },
3748 )
3749 .expect("cmyk image render succeeds");
3750
3751 assert_eq!(rendered.width, 2);
3752 assert_eq!(rendered.height, 1);
3753 assert_eq!(pixel_at(&rendered, 0, 0), [255, 0, 0, 0]);
3754 assert_eq!(pixel_at(&rendered, 1, 0), [0, 255, 0, 0]);
3755 }
3756}
3757
3758#[cfg(test)]
3759mod load_recovery_tests {
3760 use super::PdfDocument;
3761
3762 #[test]
3763 fn broken_xref_sets_xref_rebuilt() {
3764 let body: &[u8] = b"%PDF-1.7\n\
37671 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
37682 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
37693 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n\
3770trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n999999\n%%EOF";
3771 let doc = PdfDocument::open(body.to_vec()).expect("recovers via xref rebuild");
3772 assert!(doc.load_recovery().xref_rebuilt, "xref_rebuilt must be set");
3773 }
3774
3775 #[test]
3776 fn clean_document_reports_no_recovery() {
3777 let objs: [&[u8]; 3] = [
3779 b"<< /Type /Catalog /Pages 2 0 R >>",
3780 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
3781 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
3782 ];
3783 let mut buf = Vec::new();
3784 let mut off = [0usize; 4];
3785 buf.extend_from_slice(b"%PDF-1.7\n");
3786 for (i, body) in objs.iter().enumerate() {
3787 off[i + 1] = buf.len();
3788 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
3789 buf.extend_from_slice(body);
3790 buf.extend_from_slice(b"\nendobj\n");
3791 }
3792 let xref_off = buf.len();
3793 buf.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
3794 for o in &off[1..4] {
3795 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
3796 }
3797 buf.extend_from_slice(
3798 format!("trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n{xref_off}\n%%EOF").as_bytes(),
3799 );
3800 let doc = PdfDocument::open(buf).expect("clean doc opens");
3801 let r = doc.load_recovery();
3802 assert!(
3803 !r.xref_rebuilt && !r.page_tree_rebuilt,
3804 "a clean document must report no recovery; got {r:?}"
3805 );
3806 }
3807}
3808
3809#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3816pub struct VisionAdvice {
3817 pub no_text_layer: bool,
3825 pub low_confidence_table: bool,
3833 pub figure: bool,
3841}
3842
3843impl VisionAdvice {
3844 pub fn needs_vision(&self) -> bool {
3846 self.no_text_layer || self.low_confidence_table || self.figure
3847 }
3848}
3849
3850const VISION_MIN_CHARACTERS: usize = 40;
3859
3860#[derive(Default)]
3862struct InkDevice {
3863 glyphs: usize,
3864 images: usize,
3865 paths: usize,
3866}
3867
3868impl InkDevice {
3869 fn drew_anything(&self) -> bool {
3870 self.glyphs > 0 || self.images > 0 || self.paths > 0
3871 }
3872}
3873
3874impl<'a> InkDeviceTypes::Device<'a> for InkDevice {
3875 fn set_soft_mask(&mut self, _: Option<InkDeviceTypes::SoftMask<'a>>) {}
3876 fn set_blend_mode(&mut self, _: InkDeviceTypes::BlendMode) {}
3877 fn draw_path(
3878 &mut self,
3879 _: &kurbo::BezPath,
3880 _: kurbo::Affine,
3881 _: &InkDeviceTypes::Paint<'_>,
3882 _: &InkDeviceTypes::PathDrawMode,
3883 ) {
3884 self.paths += 1;
3885 }
3886 fn push_clip_path(&mut self, _: &InkDeviceTypes::ClipPath) {}
3887 fn push_transparency_group(
3888 &mut self,
3889 _: f32,
3890 _: Option<InkDeviceTypes::SoftMask<'_>>,
3891 _: InkDeviceTypes::BlendMode,
3892 ) {
3893 }
3894 fn draw_glyph(
3895 &mut self,
3896 _: &pdf_render::pdf_interpret::font::Glyph<'_>,
3897 _: kurbo::Affine,
3898 _: kurbo::Affine,
3899 _: &InkDeviceTypes::Paint<'_>,
3900 mode: &InkDeviceTypes::GlyphDrawMode,
3901 ) {
3902 if !matches!(mode, InkDeviceTypes::GlyphDrawMode::Invisible) {
3916 self.glyphs += 1;
3917 }
3918 }
3919 fn draw_image(&mut self, _: InkDeviceTypes::Image<'a, '_>, _: kurbo::Affine) {
3920 self.images += 1;
3921 }
3922 fn pop_clip_path(&mut self) {}
3923 fn pop_transparency_group(&mut self) {}
3924}
3925
3926#[cfg(test)]
3927mod last_glyph_record_tests {
3928 use super::*;
3934 use crate::text::last_glyph_is_held;
3935 use lopdf::{dictionary, Document as LoDocument, Object, Stream};
3936 use pdf_render::pdf_interpret::font::{Glyph, GlyphPlace};
3937 use pdf_render::pdf_interpret::{
3938 BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
3939 };
3940
3941 fn one_line() -> Vec<u8> {
3943 let mut doc = LoDocument::with_version("1.7");
3944 let pages_id = doc.new_object_id();
3945 let font_id = doc.add_object(dictionary! {
3946 "Type" => "Font",
3947 "Subtype" => "Type1",
3948 "BaseFont" => "Helvetica",
3949 });
3950 let content = b"BT /F1 12 Tf 72 700 Td (abc) Tj ET".to_vec();
3951 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3952 let page_id = doc.add_object(dictionary! {
3953 "Type" => "Page",
3954 "Parent" => pages_id,
3955 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3956 "Contents" => content_id,
3957 "Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
3958 });
3959 doc.objects.insert(
3960 pages_id,
3961 Object::Dictionary(dictionary! {
3962 "Type" => "Pages",
3963 "Kids" => vec![Object::Reference(page_id)],
3964 "Count" => 1_i64,
3965 }),
3966 );
3967 let catalog_id = doc.add_object(dictionary! {
3968 "Type" => "Catalog",
3969 "Pages" => pages_id,
3970 });
3971 doc.trailer.set("Root", catalog_id);
3972 let mut bytes = Vec::new();
3973 doc.save_to(&mut bytes).expect("fixture saves");
3974 bytes
3975 }
3976
3977 struct PanicsAtTheSecondGlyph {
3980 inner: TextExtractionDevice,
3981 glyphs: usize,
3982 }
3983
3984 impl<'a> Device<'a> for PanicsAtTheSecondGlyph {
3985 fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
3986 self.inner.set_soft_mask(mask);
3987 }
3988 fn set_blend_mode(&mut self, blend_mode: BlendMode) {
3989 self.inner.set_blend_mode(blend_mode);
3990 }
3991 fn draw_path(
3992 &mut self,
3993 path: &kurbo::BezPath,
3994 transform: kurbo::Affine,
3995 paint: &Paint<'a>,
3996 draw_mode: &PathDrawMode,
3997 ) {
3998 self.inner.draw_path(path, transform, paint, draw_mode);
3999 }
4000 fn push_clip_path(&mut self, clip_path: &ClipPath) {
4001 self.inner.push_clip_path(clip_path);
4002 }
4003 fn push_transparency_group(
4004 &mut self,
4005 opacity: f32,
4006 mask: Option<SoftMask<'a>>,
4007 blend_mode: BlendMode,
4008 ) {
4009 self.inner
4010 .push_transparency_group(opacity, mask, blend_mode);
4011 }
4012 fn draw_glyph(
4013 &mut self,
4014 glyph: &Glyph<'a>,
4015 transform: kurbo::Affine,
4016 glyph_transform: kurbo::Affine,
4017 paint: &Paint<'a>,
4018 draw_mode: &GlyphDrawMode,
4019 ) {
4020 self.glyphs += 1;
4021 if self.glyphs == 2 {
4022 assert!(last_glyph_is_held(), "the first glyph is remembered");
4023 panic!("inside extraction");
4024 }
4025 self.inner
4026 .draw_glyph(glyph, transform, glyph_transform, paint, draw_mode);
4027 }
4028 fn draw_image(&mut self, image: Image<'a, '_>, transform: kurbo::Affine) {
4029 self.inner.draw_image(image, transform);
4030 }
4031 fn pop_clip_path(&mut self) {
4032 self.inner.pop_clip_path();
4033 }
4034 fn pop_transparency_group(&mut self) {
4035 self.inner.pop_transparency_group();
4036 }
4037 }
4038
4039 #[test]
4040 fn the_last_glyph_record_is_empty_after_a_page_is_extracted() {
4041 let doc = PdfDocument::open(one_line()).expect("fixture opens");
4042 let text = doc.extract_text(0).expect("text");
4043 assert!(text.contains("abc"), "{text:?}");
4044 assert!(!last_glyph_is_held());
4045 }
4046
4047 #[test]
4048 fn the_last_glyph_record_is_empty_after_a_panic_inside_extraction() {
4049 let doc = PdfDocument::open(one_line()).expect("fixture opens");
4050 let page = doc.get_page(0).expect("page");
4051 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4052 let mut device = PanicsAtTheSecondGlyph {
4053 inner: TextExtractionDevice::new(),
4054 glyphs: 0,
4055 };
4056 let mut ctx =
4057 PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4058 interpret_page(page, &mut ctx, &mut device);
4059 }));
4060 assert!(outcome.is_err(), "the device panicked");
4061 assert!(!last_glyph_is_held());
4062 }
4063
4064 fn c_then_circle() -> Vec<u8> {
4068 let mut doc = LoDocument::with_version("1.7");
4069 let pages_id = doc.new_object_id();
4070 let helvetica = doc.add_object(dictionary! {
4071 "Type" => "Font",
4072 "Subtype" => "Type1",
4073 "BaseFont" => "Helvetica",
4074 });
4075 let circle = doc.add_object(dictionary! {
4076 "Type" => "Font",
4077 "Subtype" => "Type1",
4078 "BaseFont" => "CMSY10",
4079 "FirstChar" => 65_i64,
4080 "LastChar" => 65_i64,
4081 "Widths" => vec![Object::Integer(1000)],
4082 "Encoding" => dictionary! {
4083 "Type" => "Encoding",
4084 "Differences" => vec![Object::Integer(65), Object::Name(b"circlecopyrt".to_vec())],
4085 },
4086 });
4087 let content = b"BT /F1 10 Tf 1 0 0 1 102.8 700 Tm (c) Tj \
4088 /F2 10 Tf 1 0 0 1 100 700 Tm (A) Tj ET"
4089 .to_vec();
4090 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
4091 let page_id = doc.add_object(dictionary! {
4092 "Type" => "Page",
4093 "Parent" => pages_id,
4094 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4095 "Contents" => content_id,
4096 "Resources" => dictionary! {
4097 "Font" => dictionary! { "F1" => helvetica, "F2" => circle },
4098 },
4099 });
4100 doc.objects.insert(
4101 pages_id,
4102 Object::Dictionary(dictionary! {
4103 "Type" => "Pages",
4104 "Kids" => vec![Object::Reference(page_id)],
4105 "Count" => 1_i64,
4106 }),
4107 );
4108 let catalog_id = doc.add_object(dictionary! {
4109 "Type" => "Catalog",
4110 "Pages" => pages_id,
4111 });
4112 doc.trailer.set("Root", catalog_id);
4113 let mut bytes = Vec::new();
4114 doc.save_to(&mut bytes).expect("fixture saves");
4115 bytes
4116 }
4117
4118 #[derive(Default)]
4121 struct AsksAtTheSecondGlyph {
4122 places: Vec<Option<GlyphPlace>>,
4123 foreign: Option<GlyphPlace>,
4125 answered: Vec<(&'static str, bool)>,
4127 }
4128
4129 impl<'a> Device<'a> for AsksAtTheSecondGlyph {
4130 fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
4131 fn set_blend_mode(&mut self, _: BlendMode) {}
4132 fn draw_path(
4133 &mut self,
4134 _: &kurbo::BezPath,
4135 _: kurbo::Affine,
4136 _: &Paint<'a>,
4137 _: &PathDrawMode,
4138 ) {
4139 }
4140 fn push_clip_path(&mut self, _: &ClipPath) {}
4141 fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
4142 fn draw_glyph(
4143 &mut self,
4144 glyph: &Glyph<'a>,
4145 _: kurbo::Affine,
4146 _: kurbo::Affine,
4147 _: &Paint<'a>,
4148 _: &GlyphDrawMode,
4149 ) {
4150 let Glyph::Outline(outline) = glyph else {
4151 return;
4152 };
4153 if let [Some(before)] = self.places[..] {
4154 if let Some(foreign) = self.foreign {
4155 let read = outline.reread_previous(foreign).is_some();
4156 self.answered
4157 .push(("a place from another interpretation", read));
4158 }
4159 let own = outline.place().expect("drawn in an interpretation");
4160 let read = outline.reread_previous(own).is_some();
4161 self.answered.push(("its own place", read));
4162 let read = outline.reread_previous(before).is_some();
4164 self.answered.push(("the glyph directly before", read));
4165 }
4166 self.places.push(outline.place());
4167 }
4168 fn draw_image(&mut self, _: Image<'a, '_>, _: kurbo::Affine) {}
4169 fn pop_clip_path(&mut self) {}
4170 fn pop_transparency_group(&mut self) {}
4171 }
4172
4173 fn asked_on_a_new_thread(foreign: Option<GlyphPlace>) -> AsksAtTheSecondGlyph {
4176 std::thread::spawn(move || {
4177 let doc = PdfDocument::open(c_then_circle()).expect("fixture opens");
4178 let page = doc.get_page(0).expect("page");
4179 let mut device = AsksAtTheSecondGlyph {
4180 foreign,
4181 ..Default::default()
4182 };
4183 let mut ctx =
4184 PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4185 interpret_page(page, &mut ctx, &mut device);
4186 device
4187 })
4188 .join()
4189 .expect("the thread finishes")
4190 }
4191
4192 #[test]
4197 fn a_place_from_elsewhere_than_the_last_glyph_gives_no_reread() {
4198 let first = asked_on_a_new_thread(None);
4199 let foreign = first.places[0].expect("the c has a place");
4200 let second = asked_on_a_new_thread(Some(foreign));
4201 assert_eq!(
4202 second.answered,
4203 [
4204 ("a place from another interpretation", false),
4205 ("its own place", false),
4206 ("the glyph directly before", true),
4207 ]
4208 );
4209 }
4210}