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 text_with_layout(&self) -> Vec<crate::text::LayoutBlock> {
500 let mut out = Vec::new();
501 for (idx, blocks) in self.extract_all_text_blocks().into_iter().enumerate() {
502 for block in blocks {
503 out.push(crate::text::LayoutBlock {
504 bbox: block.bbox(),
505 text: block.text(),
506 page: idx + 1,
507 });
508 }
509 }
510 out
511 }
512
513 pub fn extract_page_blocks(&self, index: usize, first_index: usize) -> Result<Vec<Block>> {
530 let page = self.get_page(index)?;
531 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
532 let mut device = TextExtractionDevice::new();
533 let mut ctx = Self::create_context_with_settings(page, settings);
534 interpret_page(page, &mut ctx, &mut device);
535 Self::check_limit_slot(&slot)?;
536 let mut blocks = device.into_paragraph_blocks(index, first_index);
537 crate::headings::classify(&mut blocks);
541 let blocks = crate::text::join_double_spaced_runs(blocks, first_index);
545 let mut blocks = crate::reading_order::arrange(blocks, first_index);
546 if !self.keep_ligatures {
549 for block in &mut blocks {
550 crate::ligatures::fold_block(block);
551 }
552 }
553 Ok(blocks)
554 }
555
556 pub fn extract_blocks(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
583 options.validate(self.page_count())?;
584 if let Some(copy) = self.ocr_copy_if_requested(options)? {
588 let mut inner = options.clone();
589 inner.ocr = crate::OcrMode::Never;
590 return copy.extract_blocks(&inner);
591 }
592 if options.raw_order {
599 return self.extract_blocks_in_raw_order(options);
600 }
601 let (blocks, ruled, lattices, images, graphics) = self
602 .blocks_rules_and_rulings_before_learning(
603 options.table_strategy,
604 options.document_font_names,
605 options.cell_path_words,
606 options.actual_text,
607 options.clip_margins,
608 )?;
609 let sizes: Vec<(f64, f64)> = (0..self.page_count())
614 .map(|page| {
615 self.page_geometry(page)
616 .map(|g| g.effective_dimensions())
617 .unwrap_or((0.0, 0.0))
618 })
619 .collect();
620 let tatr = self.tatr_context(options.tatr())?;
621 let blocks = if options.learned {
622 crate::learned::refine_with_tatr(
623 blocks,
624 &sizes,
625 &ruled,
626 &lattices,
627 &images,
628 mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
629 tatr.as_ref(),
630 options.cell_path_words,
631 )
632 } else {
633 blocks
634 };
635 let mut blocks = blocks;
636 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
637 crate::blocks::peel_table_edges(
638 &mut blocks,
639 crate::learned::SWITCHES.table_runs_on_figures,
640 );
641 crate::blocks::tables_across_pages(&mut blocks);
642 Self::demote_fragment_tables(&mut blocks);
643 crate::blocks::join_heading_subtitles(&mut blocks);
644 crate::blocks::join_heading_lines(&mut blocks);
645 crate::blocks::split_paragraphs_on_steps(&mut blocks);
646 crate::split_merged_rows::split_merged_rows(&mut blocks);
647 crate::empty_lines::drop_empty_lines(&mut blocks);
648 crate::continuation_rows::join_continuation_rows(&mut blocks);
649 crate::stub_wrap::join_wrapped_stubs(&mut blocks);
650 crate::trailing_sign::move_trailing_signs(&mut blocks);
651 crate::cell_tidy::tidy_cells(&mut blocks);
652 if options.use_tags {
653 blocks = crate::tagged_evidence::apply(
654 &self.pdf,
655 &self.text_extraction_settings(),
656 blocks,
657 &sizes,
658 options.cell_path_words,
659 );
660 }
661 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
662 let mut blocks = crate::blocks::window(blocks, options);
663 if !self.keep_ligatures {
666 for block in &mut blocks {
667 crate::ligatures::fold_block(block);
668 }
669 }
670 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
671 Ok(blocks)
672 }
673
674 #[doc(hidden)]
682 pub fn extract_blocks_with_switches(
683 &self,
684 options: &ExtractOptions,
685 switches: crate::learned::Switches,
686 ) -> Result<Vec<Block>> {
687 options.validate(self.page_count())?;
688 if let Some(copy) = self.ocr_copy_if_requested(options)? {
690 let mut inner = options.clone();
691 inner.ocr = crate::OcrMode::Never;
692 return copy.extract_blocks_with_switches(&inner, switches);
693 }
694 let (blocks, ruled, lattices, images, graphics) = self
695 .blocks_rules_and_rulings_before_learning(
696 options.table_strategy,
697 options.document_font_names,
698 options.cell_path_words,
699 options.actual_text,
700 options.clip_margins,
701 )?;
702 let sizes: Vec<(f64, f64)> = (0..self.page_count())
703 .map(|page| {
704 self.page_geometry(page)
705 .map(|g| g.effective_dimensions())
706 .unwrap_or((0.0, 0.0))
707 })
708 .collect();
709 let tatr = self.tatr_context(options.tatr())?;
710 let blocks = if options.learned {
711 crate::learned::refine_with_tatr(
712 blocks,
713 &sizes,
714 &ruled,
715 &lattices,
716 &images,
717 mask_table_switches(switches, options.table_strategy),
718 tatr.as_ref(),
719 options.cell_path_words,
720 )
721 } else {
722 blocks
723 };
724 let mut blocks = blocks;
725 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
726 crate::blocks::peel_table_edges(&mut blocks, switches.table_runs_on_figures);
727 crate::blocks::tables_across_pages(&mut blocks);
728 Self::demote_fragment_tables(&mut blocks);
729 crate::blocks::join_heading_subtitles(&mut blocks);
730 crate::blocks::join_heading_lines(&mut blocks);
731 crate::split_merged_rows::split_merged_rows(&mut blocks);
732 crate::empty_lines::drop_empty_lines(&mut blocks);
733 crate::continuation_rows::join_continuation_rows(&mut blocks);
734 crate::stub_wrap::join_wrapped_stubs(&mut blocks);
735 crate::trailing_sign::move_trailing_signs(&mut blocks);
736 crate::cell_tidy::tidy_cells(&mut blocks);
737 if options.use_tags {
738 blocks = crate::tagged_evidence::apply(
739 &self.pdf,
740 &self.text_extraction_settings(),
741 blocks,
742 &sizes,
743 options.cell_path_words,
744 );
745 }
746 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
747 let mut blocks = crate::blocks::window(blocks, options);
748 if !self.keep_ligatures {
750 for block in &mut blocks {
751 crate::ligatures::fold_block(block);
752 }
753 }
754 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
755 Ok(blocks)
756 }
757
758 pub fn extract_words(&self, options: &ExtractOptions) -> Result<Vec<Word>> {
783 let blocks = self.extract_blocks(options)?;
784 Ok(crate::words::words_from_blocks(&blocks))
785 }
786
787 #[doc(hidden)]
799 pub fn extract_blocks_and_rules(
800 &self,
801 options: &ExtractOptions,
802 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
803 let (blocks, ruled, _) = self.extract_blocks_rules_and_withdrawn(options)?;
804 Ok((blocks, ruled))
805 }
806
807 pub(crate) fn extract_blocks_rules_and_withdrawn(
810 &self,
811 options: &ExtractOptions,
812 ) -> Result<BlocksRulesWithdrawn> {
813 options.validate(self.page_count())?;
814 if let Some(copy) = self.ocr_copy_if_requested(options)? {
816 let mut inner = options.clone();
817 inner.ocr = crate::OcrMode::Never;
818 return copy.extract_blocks_rules_and_withdrawn(&inner);
819 }
820 let (blocks, ruled, lattices, images, graphics) = self
821 .blocks_rules_and_rulings_before_learning(
822 options.table_strategy,
823 options.document_font_names,
824 options.cell_path_words,
825 options.actual_text,
826 options.clip_margins,
827 )?;
828 let sizes: Vec<(f64, f64)> = (0..self.page_count())
829 .map(|page| {
830 self.page_geometry(page)
831 .map(|g| g.effective_dimensions())
832 .unwrap_or((0.0, 0.0))
833 })
834 .collect();
835 let tatr = self.tatr_context(options.tatr())?;
836 let blocks = if options.learned {
837 crate::learned::refine_with_tatr(
838 blocks,
839 &sizes,
840 &ruled,
841 &lattices,
842 &images,
843 mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
844 tatr.as_ref(),
845 options.cell_path_words,
846 )
847 } else {
848 blocks
849 };
850 let mut blocks = blocks;
851 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
852 crate::blocks::peel_table_edges(
853 &mut blocks,
854 crate::learned::SWITCHES.table_runs_on_figures,
855 );
856 crate::blocks::tables_across_pages(&mut blocks);
857 Self::demote_fragment_tables(&mut blocks);
858 crate::blocks::join_heading_subtitles(&mut blocks);
859 crate::blocks::join_heading_lines(&mut blocks);
860 crate::blocks::split_paragraphs_on_steps(&mut blocks);
861 crate::split_merged_rows::split_merged_rows(&mut blocks);
862 crate::empty_lines::drop_empty_lines(&mut blocks);
863 crate::continuation_rows::join_continuation_rows(&mut blocks);
864 crate::stub_wrap::join_wrapped_stubs(&mut blocks);
865 crate::trailing_sign::move_trailing_signs(&mut blocks);
866 crate::cell_tidy::tidy_cells(&mut blocks);
867 if options.use_tags {
868 blocks = crate::tagged_evidence::apply(
869 &self.pdf,
870 &self.text_extraction_settings(),
871 blocks,
872 &sizes,
873 options.cell_path_words,
874 );
875 }
876 let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
877 let mut blocks = crate::blocks::window(blocks, options);
878 if !self.keep_ligatures {
880 for block in &mut blocks {
881 crate::ligatures::fold_block(block);
882 }
883 }
884 let withdrawn = crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
885 Ok((blocks, ruled, withdrawn))
886 }
887
888 #[doc(hidden)]
895 pub fn extract_blocks_with_signals(
896 &self,
897 ) -> Result<(
898 Vec<Block>,
899 Vec<Option<crate::learned::tables::PageTableSignals>>,
900 )> {
901 let (blocks, ruled, lattices, images, graphics) = self
902 .blocks_rules_and_rulings_before_learning(
903 crate::TableStrategy::Both,
904 false,
905 false,
906 false,
907 None,
908 )?;
909 let sizes: Vec<(f64, f64)> = (0..self.page_count())
910 .map(|page| {
911 self.page_geometry(page)
912 .map(|g| g.effective_dimensions())
913 .unwrap_or((0.0, 0.0))
914 })
915 .collect();
916 let (blocks, signals) = crate::learned::refine_with_images_and_signals_with_cell_path_words(
917 blocks,
918 &sizes,
919 &ruled,
920 &lattices,
921 &images,
922 crate::learned::SWITCHES,
923 false,
925 );
926 let mut blocks = blocks;
927 crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
928 crate::blocks::peel_table_edges(
929 &mut blocks,
930 crate::learned::SWITCHES.table_runs_on_figures,
931 );
932 crate::blocks::tables_across_pages(&mut blocks);
933 Self::demote_fragment_tables(&mut blocks);
934 crate::blocks::join_heading_subtitles(&mut blocks);
935 crate::blocks::join_heading_lines(&mut blocks);
936 crate::blocks::split_paragraphs_on_steps(&mut blocks);
937 crate::split_merged_rows::split_merged_rows(&mut blocks);
938 crate::empty_lines::drop_empty_lines(&mut blocks);
939 crate::continuation_rows::join_continuation_rows(&mut blocks);
940 crate::stub_wrap::join_wrapped_stubs(&mut blocks);
941 crate::trailing_sign::move_trailing_signs(&mut blocks);
942 crate::cell_tidy::tidy_cells(&mut blocks);
943 let mut blocks = Self::finish_blocks_with_figures(
946 blocks,
947 &images,
948 &graphics,
949 &ExtractOptions::default(),
950 );
951 crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
952 Ok((blocks, signals))
953 }
954
955 #[doc(hidden)]
962 pub fn table_signals(&self) -> Result<Vec<Option<crate::learned::tables::PageTableSignals>>> {
963 Ok(self.extract_blocks_with_signals()?.1)
964 }
965
966 pub(crate) fn tatr_context(
974 &self,
975 tatr: Option<&TatrOptions>,
976 ) -> Result<Option<crate::learned::tables::TatrContext<'_>>> {
977 let Some(options) = tatr else {
978 return Ok(None);
979 };
980 if options.test_hook.is_some() {
981 return Ok(Some(crate::learned::tables::TatrContext::new(
982 self,
983 options.clone(),
984 )));
985 }
986 #[cfg(feature = "ort-tables")]
987 {
988 let config = options.runtime_config();
989 pdf_ocr::runtime::session(&crate::learned::tables_tatr::TATR_SPEC, &config)
990 .map_err(std::io::Error::from)?;
991 Ok(Some(crate::learned::tables::TatrContext::new(
992 self,
993 options.clone(),
994 )))
995 }
996 #[cfg(not(feature = "ort-tables"))]
997 {
998 Err(EngineError::Io(std::io::Error::new(
1001 std::io::ErrorKind::InvalidInput,
1002 crate::error::OptionsError::TatrNeedsOrtTables,
1003 )))
1004 }
1005 }
1006}
1007
1008struct FragmentGrid {
1010 sparse: bool,
1012 short: bool,
1014 median: usize,
1016 continued_hits: usize,
1018 continued_in_first_row: bool,
1020 has_word: bool,
1022 bbox: [f64; 4],
1024 em: f64,
1026}
1027
1028type BlocksRulesWithdrawn = (
1030 Vec<Block>,
1031 Vec<Vec<crate::text::HorizontalRule>>,
1032 Vec<crate::table_trace::WithdrawnTable>,
1033);
1034
1035struct RegionTableInputs {
1040 page_blocks: Vec<Block>,
1042 features: crate::learned::features::PageFeatures,
1044 lines: Vec<crate::learned::judge::Line>,
1046 held: Vec<crate::text::Ruling>,
1048 region_lines: Vec<usize>,
1050}
1051
1052impl PdfDocument {
1053 #[doc(hidden)]
1071 pub fn table_in_region(&self, page: usize, bbox: [f64; 4]) -> Result<Vec<Block>> {
1072 self.table_in_region_inner(page, bbox, None)
1073 }
1074
1075 #[doc(hidden)]
1086 pub fn table_in_region_with_tatr(
1087 &self,
1088 page: usize,
1089 bbox: [f64; 4],
1090 tatr: &TatrOptions,
1091 ) -> Result<Vec<Block>> {
1092 let tatr = self.tatr_context(Some(tatr))?;
1093 self.table_in_region_inner(page, bbox, tatr.as_ref())
1094 }
1095
1096 fn table_in_region_inner(
1098 &self,
1099 page: usize,
1100 bbox: [f64; 4],
1101 tatr: Option<&crate::learned::tables::TatrContext>,
1102 ) -> Result<Vec<Block>> {
1103 let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1104 return Ok(Vec::new());
1105 };
1106 let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1107 let region_tatr = tatr.map(|context| crate::learned::tables::TatrRegion::of(context, true));
1109 let mut out = crate::learned::tables::rebuild_region(
1110 &refs,
1111 &inputs.lines,
1112 &inputs.features.words,
1113 &inputs.held,
1114 &inputs.region_lines,
1115 None,
1116 crate::learned::SWITCHES,
1117 region_tatr.as_ref(),
1118 false,
1120 );
1121 if Self::region_fragment_veto(&out, &inputs.page_blocks) {
1126 crate::table_trace::emit_run(
1127 page,
1128 &out,
1129 &inputs.page_blocks,
1130 Some(crate::table_trace::PageCtx {
1131 rulings: &inputs.held,
1132 graphics: &[],
1133 }),
1134 );
1135 return Ok(Vec::new());
1136 }
1137 for block in out.iter_mut() {
1138 if block.table_origin == crate::table_trace::TableOrigin::Other {
1141 block.table_origin = crate::table_trace::TableOrigin::Region;
1142 }
1143 }
1144 for (i, block) in out.iter_mut().enumerate() {
1145 block.index = i;
1146 }
1147 if !self.keep_ligatures {
1148 for block in out.iter_mut() {
1149 crate::ligatures::fold_block(block);
1150 }
1151 }
1152 Ok(out)
1153 }
1154
1155 #[cfg(feature = "ort-layout")]
1176 pub(crate) fn tatr_cells_in_region(
1177 &self,
1178 page: usize,
1179 bbox: [f64; 4],
1180 tatr: &crate::learned::tables::TatrContext,
1181 ) -> Result<Option<Vec<Block>>> {
1182 let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1183 return Ok(None);
1184 };
1185 let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1186 let valid: Vec<usize> = inputs
1187 .region_lines
1188 .iter()
1189 .copied()
1190 .filter(|&i| i < inputs.lines.len())
1191 .collect();
1192 if valid.is_empty() {
1193 return Ok(None);
1194 }
1195 let (pair_words, _) = crate::learned::tables::words_of_region(
1196 &refs,
1197 &inputs.lines,
1198 &inputs.features.words,
1199 &valid,
1200 );
1201 if pair_words.is_empty() {
1202 return Ok(None);
1203 }
1204 let page_number = valid
1207 .iter()
1208 .filter_map(|&i| inputs.lines.get(i))
1209 .filter_map(|line| refs.get(line.block))
1210 .map(|block| block.page)
1211 .next()
1212 .unwrap_or(0);
1213 let region = crate::learned::tables::TatrRegion::of(tatr, true);
1214 match crate::learned::tables::tatr_for_region_with_cell_path_words(
1215 Some(®ion),
1216 &refs,
1217 &inputs.lines,
1218 &inputs.features.words,
1219 &inputs.region_lines,
1220 &pair_words,
1221 page_number,
1222 false,
1224 ) {
1225 crate::learned::tables::TatrVerdict::Cells(blocks) => {
1226 let mut cells: Vec<Block> = blocks
1227 .into_iter()
1228 .filter(|b| matches!(b.kind, crate::BlockKind::TableCell { .. }))
1229 .collect();
1230 if cells.is_empty() {
1231 return Ok(None);
1232 }
1233 for block in cells.iter_mut() {
1234 block.table_origin = crate::table_trace::TableOrigin::Tatr;
1235 }
1236 if Self::region_fragment_veto(&cells, &inputs.page_blocks) {
1239 crate::table_trace::emit_run(
1240 page,
1241 &cells,
1242 &inputs.page_blocks,
1243 Some(crate::table_trace::PageCtx {
1244 rulings: &inputs.held,
1245 graphics: &[],
1246 }),
1247 );
1248 return Ok(None);
1249 }
1250 for (i, block) in cells.iter_mut().enumerate() {
1251 block.index = i;
1252 }
1253 if !self.keep_ligatures {
1254 for block in cells.iter_mut() {
1255 crate::ligatures::fold_block(block);
1256 }
1257 }
1258 Ok(Some(cells))
1259 }
1260 crate::learned::tables::TatrVerdict::Fallback(_) => Ok(None),
1261 }
1262 }
1263
1264 fn table_in_region_inputs(
1270 &self,
1271 page: usize,
1272 bbox: [f64; 4],
1273 ) -> Result<Option<RegionTableInputs>> {
1274 let count = self.page_count();
1275 if page >= count {
1276 return Err(EngineError::PageOutOfRange { index: page, count });
1277 }
1278 let (blocks, _, rulings, _, _) = self.blocks_rules_and_rulings_before_learning(
1279 crate::TableStrategy::Both,
1280 false,
1281 false,
1282 false,
1283 None,
1284 )?;
1285
1286 let page_blocks: Vec<Block> = blocks.into_iter().filter(|b| b.page == page).collect();
1287 if page_blocks.is_empty() {
1288 return Ok(None);
1289 }
1290 let (width, height) = self.page_geometry(page)?.effective_dimensions();
1291 let refs: Vec<&Block> = page_blocks.iter().collect();
1292 let features = crate::learned::features::page_features(&refs, width, height);
1293 let lines = crate::learned::judge::lines_of_page(&refs, &features.words, &[]);
1294 let mut heights: Vec<f64> = lines
1295 .iter()
1296 .map(|line| line.bbox[3] - line.bbox[1])
1297 .filter(|h| h.is_finite() && *h > 0.0)
1298 .collect();
1299 heights.sort_by(f64::total_cmp);
1300 let median = if heights.is_empty() {
1301 0.0
1302 } else if heights.len() % 2 == 1 {
1303 heights[heights.len() / 2]
1304 } else {
1305 (heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
1306 };
1307 let pad = crate::learned::tables::REGION_PAD_LINES * median;
1308 let left = bbox[0].min(bbox[2]) - pad;
1309 let bottom = bbox[1].min(bbox[3]) - pad;
1310 let right = bbox[0].max(bbox[2]) + pad;
1311 let top = bbox[1].max(bbox[3]) + pad;
1312 let padded = [left, bottom, right, top];
1313 let region_lines = crate::learned::judge::held_by(padded, &lines);
1314 if region_lines.len() < 2 {
1315 return Ok(None);
1316 }
1317 let page_rulings: &[crate::text::Ruling] =
1323 rulings.get(page).map(Vec::as_slice).unwrap_or(&[]);
1324 let held: Vec<crate::text::Ruling> = {
1325 let lattices = crate::learned::lattice::lattices(page_rulings);
1326 let touched: Vec<[f64; 4]> = lattices
1327 .iter()
1328 .map(|lattice| lattice.bbox())
1329 .filter(|bbox| {
1330 bbox[0] <= padded[2]
1331 && bbox[2] >= padded[0]
1332 && bbox[1] <= padded[3]
1333 && bbox[3] >= padded[1]
1334 })
1335 .collect();
1336 if touched.is_empty() {
1337 page_rulings
1338 .iter()
1339 .copied()
1340 .filter(|r| {
1341 r.x0 <= padded[2]
1342 && r.x1 >= padded[0]
1343 && r.y0 <= padded[3]
1344 && r.y1 >= padded[1]
1345 })
1346 .collect()
1347 } else {
1348 page_rulings
1349 .iter()
1350 .copied()
1351 .filter(|r| {
1352 touched.iter().any(|bbox| {
1353 r.x0 <= bbox[2] + 2.0
1358 && r.x1 >= bbox[0] - 2.0
1359 && r.y0 <= bbox[3] + 2.0
1360 && r.y1 >= bbox[1] - 2.0
1361 })
1362 })
1363 .collect()
1364 }
1365 };
1366 Ok(Some(RegionTableInputs {
1367 page_blocks,
1368 features,
1369 lines,
1370 held,
1371 region_lines,
1372 }))
1373 }
1374
1375 fn region_fragment_veto(cells: &[Block], page: &[Block]) -> bool {
1396 let Some(grid) = Self::fragment_grid_stats(cells) else {
1397 return false;
1398 };
1399 if grid.continued_hits >= 2
1400 && grid.continued_in_first_row
1401 && (grid.sparse || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN)
1402 {
1403 return true;
1404 }
1405 if !grid.short || grid.has_word {
1406 return false;
1407 }
1408 Self::caption_near_grid(
1409 &grid,
1410 page.iter().map(|block| (block.text.as_str(), block.bbox)),
1411 )
1412 }
1413
1414 fn fragment_grid_stats(cells: &[Block]) -> Option<FragmentGrid> {
1417 struct RegionCell<'a> {
1418 row: u16,
1419 column: u16,
1420 row_span: u16,
1421 column_span: u16,
1422 text: &'a str,
1423 bbox: [f64; 4],
1424 }
1425 let grid: Vec<RegionCell<'_>> = cells
1426 .iter()
1427 .filter_map(|block| match block.kind {
1428 crate::BlockKind::TableCell {
1429 row,
1430 column,
1431 row_span,
1432 column_span,
1433 ..
1434 } => Some(RegionCell {
1435 row,
1436 column,
1437 row_span,
1438 column_span,
1439 text: block.text.as_str(),
1440 bbox: block.bbox,
1441 }),
1442 _ => None,
1443 })
1444 .collect();
1445 if grid.is_empty() {
1446 return None;
1447 }
1448 let rows = grid
1449 .iter()
1450 .map(|cell| cell.row as usize + cell.row_span.max(1) as usize)
1451 .max()
1452 .unwrap_or(0);
1453 let columns = grid
1454 .iter()
1455 .map(|cell| cell.column as usize + cell.column_span as usize)
1456 .max()
1457 .unwrap_or(0);
1458 if rows == 0 || columns == 0 {
1459 return None;
1460 }
1461 let positions = rows * columns;
1462 let filled: usize = grid
1463 .iter()
1464 .map(|cell| cell.column_span as usize * cell.row_span.max(1) as usize)
1465 .sum();
1466 let sparse = positions > 0 && filled < positions && (positions - filled) * 3 > positions;
1467 let mut lens: Vec<usize> = grid
1468 .iter()
1469 .map(|cell| cell.text.chars().filter(|c| c.is_alphanumeric()).count())
1470 .collect();
1471 lens.sort_unstable();
1472 let mid = lens.len() / 2;
1473 let short = if lens.len() % 2 == 1 {
1475 lens[mid] <= crate::tables::FRAGMENT_CELL_MEDIAN
1476 } else {
1477 lens[mid - 1] + lens[mid] <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN
1478 };
1479 let median = lens[mid];
1480 let has_word = grid.iter().any(|cell| {
1481 let mut run = 0usize;
1482 for c in cell.text.chars() {
1483 if c.is_alphabetic() {
1484 run += 1;
1485 if run >= 4 {
1486 return true;
1487 }
1488 } else {
1489 run = 0;
1490 }
1491 }
1492 false
1493 });
1494 let continued_hits: usize = grid
1495 .iter()
1496 .map(|cell| cell.text.to_ascii_lowercase().matches("continu").count())
1497 .sum();
1498 let continued_in_first_row = grid
1499 .iter()
1500 .all(|cell| !cell.text.to_ascii_lowercase().contains("continu") || cell.row == 0);
1501 let mut bbox = [
1502 f64::INFINITY,
1503 f64::INFINITY,
1504 f64::NEG_INFINITY,
1505 f64::NEG_INFINITY,
1506 ];
1507 let mut heights: Vec<f64> = Vec::with_capacity(grid.len());
1508 for cell in &grid {
1509 bbox[0] = bbox[0].min(cell.bbox[0]);
1510 bbox[2] = bbox[2].max(cell.bbox[2]);
1511 bbox[1] = bbox[1].min(cell.bbox[1]);
1512 bbox[3] = bbox[3].max(cell.bbox[3]);
1513 heights.push(cell.bbox[3] - cell.bbox[1]);
1514 }
1515 if !(bbox[0].is_finite() && bbox[2] > bbox[0] && bbox[3] >= bbox[1]) {
1516 return None;
1517 }
1518 heights.sort_by(f64::total_cmp);
1519 let em = heights[heights.len() / 2];
1520 if !(em.is_finite() && em > 0.0) {
1521 return None;
1522 }
1523 Some(FragmentGrid {
1524 sparse,
1525 short,
1526 median,
1527 continued_hits,
1528 continued_in_first_row,
1529 has_word,
1530 bbox,
1531 em,
1532 })
1533 }
1534
1535 fn caption_near_grid<'a>(
1538 grid: &FragmentGrid,
1539 mut page: impl Iterator<Item = (&'a str, [f64; 4])>,
1540 ) -> bool {
1541 let near = crate::tables::FIGURE_CAPTION_NEAR_EM * grid.em;
1542 page.any(|(text, bbox)| {
1543 if !crate::tables::is_figure_caption(text) {
1544 return false;
1545 }
1546 let [left, lo, right, hi] = bbox;
1547 if !(left.is_finite() && right > left && hi >= lo) {
1548 return false;
1549 }
1550 if right <= grid.bbox[0] || left >= grid.bbox[2] {
1551 return false;
1552 }
1553 let gap = if hi < grid.bbox[1] {
1554 grid.bbox[1] - hi
1555 } else if lo > grid.bbox[3] {
1556 lo - grid.bbox[3]
1557 } else {
1558 0.0
1559 };
1560 gap <= near
1561 })
1562 }
1563
1564 fn demote_fragment_tables(blocks: &mut [Block]) {
1573 let mut start: Option<usize> = None;
1574 let mut runs: Vec<(usize, usize)> = Vec::new();
1575 for (i, block) in blocks.iter().enumerate() {
1576 if matches!(block.kind, crate::BlockKind::TableCell { .. }) {
1577 start.get_or_insert(i);
1578 } else if let Some(first) = start.take() {
1579 runs.push((first, i));
1580 }
1581 }
1582 if let Some(first) = start.take() {
1583 runs.push((first, blocks.len()));
1584 }
1585 for (first, end) in runs {
1586 let mut page_first = first;
1587 while page_first < end {
1588 let page = blocks[page_first].page;
1589 let mut page_end = page_first + 1;
1590 while page_end < end && blocks[page_end].page == page {
1591 page_end += 1;
1592 }
1593 let veto = match Self::fragment_grid_stats(&blocks[page_first..page_end]) {
1594 Some(grid) => {
1595 (grid.continued_hits >= 2
1596 && grid.continued_in_first_row
1597 && (grid.sparse
1598 || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN))
1599 || (grid.sparse
1600 && grid.short
1601 && !grid.has_word
1602 && Self::caption_near_grid(
1603 &grid,
1604 blocks
1605 .iter()
1606 .filter(|block| block.page == page)
1607 .map(|block| (block.text.as_str(), block.bbox)),
1608 ))
1609 }
1610 None => false,
1611 };
1612 if veto {
1613 crate::table_trace::emit_run(page, &blocks[page_first..page_end], blocks, None);
1617 for block in &mut blocks[page_first..page_end] {
1618 block.kind = crate::BlockKind::Paragraph;
1619 block.grid = None;
1620 block.confidence = None;
1621 block.continues_previous = false;
1622 }
1623 }
1624 page_first = page_end;
1625 }
1626 }
1627 }
1628
1629 pub fn page_needs_ocr(&self, index: usize) -> crate::ocr_need::OcrNeed {
1636 let pages = self.pdf.pages();
1637 let page = match pages.get(index) {
1638 Some(page) => page,
1639 None => return crate::ocr_need::OcrNeed::None,
1640 };
1641 let (width, height) = page.render_dimensions();
1642 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1643 let mut device = crate::ocr_need::OcrSurveyDevice::new(f64::from(width), f64::from(height));
1644 let mut ctx = Self::create_context_with_settings(page, settings);
1645 interpret_page(page, &mut ctx, &mut device);
1646 if Self::check_limit_slot(&slot).is_err() {
1647 return crate::ocr_need::OcrNeed::None;
1648 }
1649 device.decide()
1650 }
1651
1652 pub fn vision_advice(&self) -> Result<Vec<VisionAdvice>> {
1666 let pages = self.pdf.pages();
1667 let mut out: Vec<VisionAdvice> = Vec::with_capacity(pages.len());
1668 for (index, page) in pages.iter().enumerate() {
1669 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1670 let mut device = InkDevice::default();
1671 let mut ctx = Self::create_context_with_settings(page, settings);
1672 interpret_page(page, &mut ctx, &mut device);
1673 Self::check_limit_slot(&slot)?;
1674 let text = self.extract_page_blocks(index, 0).map(|blocks| {
1675 blocks
1676 .iter()
1677 .map(|b| b.text.trim().chars().count())
1678 .sum::<usize>()
1679 })?;
1680 out.push(VisionAdvice {
1681 no_text_layer: text < VISION_MIN_CHARACTERS && device.drew_anything(),
1682 ..VisionAdvice::default()
1683 });
1684 }
1685 Ok(out)
1686 }
1687
1688 #[doc(hidden)]
1697 pub fn extract_blocks_before_learning(&self) -> Result<Vec<Block>> {
1698 let (blocks, _, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1699 crate::TableStrategy::Both,
1700 false,
1701 false,
1702 false,
1703 None,
1704 )?;
1705 Ok(blocks)
1706 }
1707
1708 #[doc(hidden)]
1718 pub fn extract_blocks_and_rules_before_learning(
1719 &self,
1720 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1721 self.blocks_and_rules_before_learning()
1722 }
1723
1724 #[cfg(feature = "ort-layout")]
1728 pub(crate) fn page_rulings_and_graphics(
1729 &self,
1730 page: usize,
1731 ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1732 let count = self.page_count();
1733 let pages = self.pdf.pages();
1734 let Some(source) = pages.get(page) else {
1735 return Err(EngineError::PageOutOfRange { index: page, count });
1736 };
1737 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1738 let mut device = TextExtractionDevice::new();
1739 let mut ctx = Self::create_context_with_settings(source, settings);
1740 interpret_page(source, &mut ctx, &mut device);
1741 Self::check_limit_slot(&slot)?;
1742 let rulings = device.take_rulings();
1743 let (width, height) = source.render_dimensions();
1744 let graphics = device.take_graphics(f64::from(width), f64::from(height));
1745 Ok((rulings, graphics))
1746 }
1747
1748 #[doc(hidden)]
1759 pub fn extract_image_boxes(&self) -> Result<Vec<Vec<[f64; 4]>>> {
1760 let mut out = Vec::new();
1761 for (number, page) in self.pdf.pages().iter().enumerate() {
1762 if number >= markdown_images::MAX_GRAPHICS_SCAN_PAGES {
1763 out.push(Vec::new());
1764 continue;
1765 }
1766 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1767 let mut device = TextExtractionDevice::new();
1768 let mut ctx = Self::create_context_with_settings(page, settings);
1769 interpret_page(page, &mut ctx, &mut device);
1770 Self::check_limit_slot(&slot)?;
1771 let (w, h) = page.render_dimensions();
1772 let mut boxes = device.take_images();
1773 boxes.extend(device.take_graphics(f64::from(w), f64::from(h)));
1774 out.push(boxes);
1775 }
1776 Ok(out)
1777 }
1778
1779 #[doc(hidden)]
1785 pub fn page_image_boxes(&self, index: usize) -> Result<Vec<[f64; 4]>> {
1786 let pages = self.pdf.pages();
1787 let Some(page) = pages.get(index) else {
1788 return Ok(Vec::new());
1789 };
1790 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1791 let mut device = TextExtractionDevice::new();
1792 let mut ctx = Self::create_context_with_settings(page, settings);
1793 interpret_page(page, &mut ctx, &mut device);
1794 Self::check_limit_slot(&slot)?;
1795 Ok(device.take_images())
1796 }
1797
1798 pub fn extract_figures(&self, options: &ExtractOptions) -> Result<Vec<Figure>> {
1822 use std::collections::BTreeMap;
1823 let blocks = self.extract_blocks(options)?;
1824 let mut pages: BTreeMap<usize, Vec<[f64; 4]>> = BTreeMap::new();
1825 for block in &blocks {
1826 if matches!(block.kind, crate::BlockKind::Figure) {
1827 pages.entry(block.page).or_default().push(block.bbox);
1828 }
1829 }
1830 let mut out = Vec::new();
1831 for (page, boxes) in &pages {
1832 let renders = markdown_images::render_crops(self, *page, boxes)?;
1833 for (ordinal, (bbox, png)) in boxes.iter().zip(renders).enumerate() {
1834 out.push(Figure {
1835 page: *page,
1836 bbox: *bbox,
1837 name: markdown_images::figure_name(*page, ordinal),
1838 png,
1839 });
1840 }
1841 }
1842 Ok(out)
1843 }
1844
1845 fn blocks_and_rules_before_learning(
1847 &self,
1848 ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1849 let (blocks, ruled, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1850 crate::TableStrategy::Both,
1851 false,
1852 false,
1853 false,
1854 None,
1855 )?;
1856 Ok((blocks, ruled))
1857 }
1858
1859 #[doc(hidden)]
1862 pub fn page_rulings(&self, index: usize) -> Result<Vec<crate::text::Ruling>> {
1863 let pages = self.pdf.pages();
1864 let Some(page) = pages.get(index) else {
1865 return Ok(Vec::new());
1866 };
1867 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1868 let mut device = TextExtractionDevice::new();
1869 let mut ctx = Self::create_context_with_settings(page, settings);
1870 interpret_page(page, &mut ctx, &mut device);
1871 Self::check_limit_slot(&slot)?;
1872 Ok(device.take_rulings())
1873 }
1874
1875 #[doc(hidden)]
1879 #[allow(clippy::type_complexity)]
1880 pub fn page_rulings_and_fills(
1881 &self,
1882 index: usize,
1883 ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1884 let pages = self.pdf.pages();
1885 let Some(page) = pages.get(index) else {
1886 return Ok((Vec::new(), Vec::new()));
1887 };
1888 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1889 let mut device = TextExtractionDevice::new();
1890 let mut ctx = Self::create_context_with_settings(page, settings);
1891 interpret_page(page, &mut ctx, &mut device);
1892 Self::check_limit_slot(&slot)?;
1893 Ok((device.take_rulings(), device.take_fills()))
1894 }
1895
1896 fn clip_rect(margins: &ClipMargins, width: f64, height: f64) -> [f64; 4] {
1900 let [left, top, right, bottom] = margins.as_array();
1901 [left, bottom, width - right, height - top]
1902 }
1903
1904 fn extract_blocks_in_raw_order(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
1907 let pages = self.pdf.pages();
1908 let mut blocks: Vec<Block> = Vec::new();
1909 for (index, page) in pages.iter().enumerate() {
1910 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1911 let mut device = TextExtractionDevice::new()
1912 .raw_runs(true)
1913 .actual_text(options.actual_text);
1914 let mut ctx = Self::create_context_with_settings(page, settings);
1915 interpret_page(page, &mut ctx, &mut device);
1916 Self::check_limit_slot(&slot)?;
1917 if let Some(margins) = &options.clip_margins {
1918 let (width, height) = page.render_dimensions();
1919 device.clip_spans(Self::clip_rect(
1920 margins,
1921 f64::from(width),
1922 f64::from(height),
1923 ));
1924 }
1925 blocks.extend(device.into_raw_blocks(index, blocks.len()));
1926 }
1927 let mut blocks = crate::blocks::window(blocks, options);
1928 if !self.keep_ligatures {
1930 for block in &mut blocks {
1931 crate::ligatures::fold_block(block);
1932 }
1933 }
1934 Ok(blocks)
1935 }
1936
1937 #[allow(clippy::type_complexity)]
1941 fn blocks_rules_and_rulings_before_learning(
1942 &self,
1943 table_strategy: crate::TableStrategy,
1944 document_font_names: bool,
1947 cell_path_words: bool,
1950 actual_text: bool,
1953 clip_margins: Option<ClipMargins>,
1956 ) -> Result<(
1957 Vec<Block>,
1958 Vec<Vec<crate::text::HorizontalRule>>,
1959 Vec<Vec<crate::text::Ruling>>,
1960 Vec<Vec<[f64; 4]>>,
1961 Vec<Vec<[f64; 4]>>,
1962 )> {
1963 let pages = self.pdf.pages();
1964 let mut images: Vec<Vec<[f64; 4]>> = Vec::new();
1965 let mut graphics: Vec<Vec<[f64; 4]>> = Vec::new();
1966 let mut blocks: Vec<Block> = Vec::new();
1967 let mut ruled: Vec<Vec<crate::text::HorizontalRule>> = Vec::new();
1968 let mut lattices: Vec<Vec<crate::text::Ruling>> = Vec::new();
1969
1970 for (index, page) in pages.iter().enumerate() {
1971 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1972 let mut device = TextExtractionDevice::new()
1973 .document_font_names(document_font_names)
1974 .actual_text(actual_text);
1975 let mut ctx = Self::create_context_with_settings(page, settings);
1976 interpret_page(page, &mut ctx, &mut device);
1977 Self::check_limit_slot(&slot)?;
1978 lattices.push(device.take_rulings());
1979 let figures = device.take_images();
1980 images.push(figures);
1981 let (width, height) = page.render_dimensions();
1982 graphics.push(device.take_graphics(f64::from(width), f64::from(height)));
1983 if let Some(margins) = &clip_margins {
1987 device.clip_spans(Self::clip_rect(
1988 margins,
1989 f64::from(width),
1990 f64::from(height),
1991 ));
1992 }
1993 let (page_blocks, page_rules) = device.into_paragraph_blocks_and_rules_with_strategy(
1994 index,
1995 blocks.len(),
1996 table_strategy,
1997 cell_path_words,
1998 );
1999 blocks.extend(page_blocks);
2000 ruled.push(page_rules);
2001 }
2002
2003 crate::furniture::mark(&mut blocks);
2007 crate::headings::classify(&mut blocks);
2012 let blocks = crate::text::join_double_spaced_runs(blocks, 0);
2016 let blocks = crate::reading_order::arrange(blocks, 0);
2020 Ok((blocks, ruled, lattices, images, graphics))
2021 }
2022
2023 fn finish_blocks_with_figures(
2030 blocks: Vec<Block>,
2031 images: &[Vec<[f64; 4]>],
2032 graphics: &[Vec<[f64; 4]>],
2033 options: &ExtractOptions,
2034 ) -> Vec<Block> {
2035 let pages = images.len().max(graphics.len());
2036 let mut page_has_text = vec![false; pages];
2037 for block in &blocks {
2038 if let Some(slot) = page_has_text.get_mut(block.page) {
2039 *slot = true;
2040 }
2041 }
2042 let blocks = if options.drop_text_over_images {
2043 markdown_images::drop_text_over_images(blocks, images)
2044 } else {
2045 blocks
2046 };
2047 markdown_images::merge_figures(blocks, images, graphics, &page_has_text)
2048 }
2049
2050 pub fn extract_markdown(&self, options: &ExtractOptions) -> Result<String> {
2086 if matches!(options.quality, crate::Quality::Balanced) {
2087 return crate::model::balanced_markdown_for(self, options);
2088 }
2089 let mut blocks = self.extract_blocks(options)?;
2090 if options.include_figures && (options.embed_figures || options.figure_image_dir.is_some())
2094 {
2095 markdown_images::resolve_figure_texts(self, &mut blocks, options)?;
2096 }
2097 let markdown = crate::markdown::MarkdownOptions::from(options);
2098 Ok(crate::markdown::to_markdown_with_options(
2099 &blocks, &markdown,
2100 ))
2101 }
2102
2103 pub fn extract_docling(&self, name: &str, options: &ExtractOptions) -> Result<String> {
2113 let blocks = self.extract_blocks(options)?;
2114 let mut pages = Vec::new();
2115 for page in (0..self.page_count()).filter(|page| options.holds(*page)) {
2116 let (width, height) = self.page_geometry(page)?.effective_dimensions();
2119 pages.push(crate::docling::DoclingPage {
2120 page,
2121 width,
2122 height,
2123 });
2124 }
2125 Ok(crate::docling::to_docling_json(name, &pages, &blocks))
2126 }
2127
2128 pub fn extract_acroform_text(&self) -> String {
2134 let Some(tree) = parse_acroform(&self.pdf) else {
2135 return String::new();
2136 };
2137 let mut parts: Vec<String> = Vec::new();
2138 for id in tree.all_ids() {
2139 let node = tree.get(id);
2140 if node.children.is_empty() {
2141 let value_str = match &node.value {
2143 Some(FieldValue::Text(s)) if !s.is_empty() => Some(s.clone()),
2144 Some(FieldValue::StringArray(arr)) => {
2145 let joined = arr
2146 .iter()
2147 .filter(|s| !s.is_empty())
2148 .cloned()
2149 .collect::<Vec<_>>()
2150 .join(", ");
2151 if joined.is_empty() {
2152 None
2153 } else {
2154 Some(joined)
2155 }
2156 }
2157 _ => None,
2158 };
2159 let button_caption =
2160 value_str.is_none() && tree.effective_field_type(id) == Some(FieldType::Button);
2161 let extracted = value_str.or_else(|| {
2162 button_caption.then(|| {
2163 node.mk
2164 .as_ref()
2165 .and_then(|mk| mk.caption.as_ref())
2166 .filter(|caption| !caption.is_empty())
2167 .cloned()
2168 })?
2169 });
2170 if let Some(s) = extracted {
2171 parts.push(s);
2172 }
2173 }
2174 }
2175 let joined = parts.join("\n");
2176 if self.keep_ligatures {
2177 return joined;
2178 }
2179 crate::ligatures::fold(&joined).into_owned()
2185 }
2186
2187 pub fn extract_all_text(&self) -> String {
2190 let pages = self.pdf.pages();
2191 let mut settings = self.text_extraction_settings();
2192 let mut page_texts = Vec::with_capacity(pages.len());
2193 for page in pages.iter() {
2194 let (page_text, next_settings) =
2195 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2196 settings = next_settings;
2197 page_texts.push(page_text);
2198 }
2199
2200 let acroform = self.extract_acroform_text();
2205 if !acroform.is_empty() {
2206 match page_texts.last_mut() {
2207 Some(last) => {
2208 if !last.is_empty() && !last.ends_with('\n') {
2209 last.push('\n');
2210 }
2211 last.push_str(&acroform);
2212 }
2213 None => return acroform,
2214 }
2215 }
2216
2217 join_page_texts(page_texts.iter().map(String::as_str))
2218 }
2219
2220 pub fn search_text(&self, query: &str) -> Vec<usize> {
2227 let pages = self.pdf.pages();
2228 let query_lower = query.to_lowercase();
2229 let query_lower = if self.keep_ligatures {
2234 query_lower
2235 } else {
2236 crate::ligatures::fold(&query_lower).into_owned()
2237 };
2238 #[cfg(feature = "parallel")]
2239 let page_contains = |i: usize| -> Option<usize> {
2240 let page = &pages[i];
2241 let (text, _) = Self::extract_text_with_settings(
2242 page,
2243 self.text_extraction_settings(),
2244 self.keep_ligatures,
2245 );
2246 if text.to_lowercase().contains(&query_lower) {
2247 Some(i)
2248 } else {
2249 None
2250 }
2251 };
2252 #[cfg(feature = "parallel")]
2253 return (0..pages.len())
2254 .into_par_iter()
2255 .filter_map(page_contains)
2256 .collect();
2257 #[cfg(not(feature = "parallel"))]
2258 {
2259 let mut settings = self.text_extraction_settings();
2260 let mut hits = Vec::new();
2261 for (i, page) in pages.iter().enumerate() {
2262 let (text, next_settings) =
2263 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2264 settings = next_settings;
2265 if text.to_lowercase().contains(&query_lower) {
2266 hits.push(i);
2267 }
2268 }
2269 hits
2270 }
2271 }
2272
2273 pub fn info(&self) -> DocumentInfo {
2275 let meta = self.pdf.metadata();
2276 DocumentInfo {
2277 title: meta.title.as_ref().map(|b| bytes_to_string(b)),
2278 author: meta.author.as_ref().map(|b| bytes_to_string(b)),
2279 subject: meta.subject.as_ref().map(|b| bytes_to_string(b)),
2280 keywords: meta.keywords.as_ref().map(|b| bytes_to_string(b)),
2281 creator: meta.creator.as_ref().map(|b| bytes_to_string(b)),
2282 producer: meta.producer.as_ref().map(|b| bytes_to_string(b)),
2283 }
2284 }
2285
2286 pub fn bookmarks(&self) -> Vec<BookmarkItem> {
2288 let xref = self.pdf.xref();
2289 let root_id = xref.root_id();
2290 let catalog: Dict<'_> = match xref.get(root_id) {
2291 Some(d) => d,
2292 None => return Vec::new(),
2293 };
2294
2295 let outlines: Dict<'_> = match catalog.get(OUTLINES) {
2296 Some(d) => d,
2297 None => return Vec::new(),
2298 };
2299
2300 let first: Dict<'_> = match outlines.get(FIRST) {
2301 Some(d) => d,
2302 None => return Vec::new(),
2303 };
2304
2305 let mut visited = BTreeSet::new();
2309 let pages = self.page_object_ids();
2310 parse_outline_items(&first, 0, &mut visited, &pages)
2311 }
2312
2313 pub fn page_links(&self, index: usize) -> Result<Vec<PageLink>> {
2319 let page = self.get_page(index)?;
2320 let pages = self.page_object_ids();
2321 let mut links = Vec::new();
2322 for annot in page.annots() {
2323 if annot.get::<Name>(SUBTYPE).as_deref() != Some(b"Link".as_slice()) {
2324 continue;
2325 }
2326 let rect = match annot.get::<pdf_render::pdf_syntax::object::Rect>(RECT) {
2327 Some(rect) => [rect.x0, rect.y0, rect.x1, rect.y1],
2328 None => continue,
2329 };
2330 let id = annot
2331 .get::<pdf_render::pdf_syntax::object::String>(NM)
2332 .map(|s| bytes_to_string(s.as_bytes()))
2333 .unwrap_or_default();
2334 let xref = annot
2335 .obj_id()
2336 .and_then(|id| u32::try_from(id.obj_number).ok())
2337 .unwrap_or(0);
2338 links.push(PageLink {
2339 target: link_target(&annot, &pages),
2340 rect,
2341 xref,
2342 id,
2343 });
2344 }
2345 Ok(links)
2346 }
2347
2348 fn page_object_ids(&self) -> Vec<Option<ObjectIdentifier>> {
2355 self.pdf.pages().iter().map(|p| p.raw().obj_id()).collect()
2356 }
2357
2358 pub fn ocr_page(
2377 &self,
2378 index: usize,
2379 backend: &dyn crate::ocr::OcrBackend,
2380 dpi: f64,
2381 ) -> crate::error::Result<crate::ocr::OcrResult> {
2382 let opts = crate::render::RenderOptions {
2383 dpi,
2384 ..Default::default()
2385 };
2386 let rendered = self.render_page(index, &opts)?;
2387
2388 let mut rgb = Vec::with_capacity((rendered.width * rendered.height * 3) as usize);
2390 for chunk in rendered.pixels.chunks(4) {
2391 rgb.push(chunk[0]);
2392 rgb.push(chunk[1]);
2393 rgb.push(chunk[2]);
2394 }
2395
2396 backend
2397 .recognize(&rgb, rendered.width, rendered.height)
2398 .map_err(|e| crate::error::EngineError::RenderError(e.to_string()))
2399 }
2400
2401 fn with_limit_collector(settings: &InterpreterSettings) -> (InterpreterSettings, LimitSlot) {
2408 let slot: LimitSlot = Arc::new(Mutex::new(None));
2409 let slot_clone = Arc::clone(&slot);
2410 let prev_sink = settings.warning_sink.clone();
2411 let mut new_settings = settings.clone();
2412 new_settings.warning_sink = Arc::new(move |w: InterpreterWarning| {
2413 if let InterpreterWarning::StreamTooLarge { observed, limit } = w {
2414 let mut guard = slot_clone.lock().unwrap_or_else(|e| e.into_inner());
2415 if guard.is_none() {
2416 *guard = Some((observed, limit));
2417 }
2418 }
2419 prev_sink(w);
2420 });
2421 (new_settings, slot)
2422 }
2423
2424 fn check_limit_slot(slot: &LimitSlot) -> Result<()> {
2429 if let Some((observed, limit)) = *slot.lock().unwrap_or_else(|e| e.into_inner()) {
2430 return Err(EngineError::LimitExceeded(LimitError::StreamTooLarge {
2431 actual_bytes: observed,
2432 limit_bytes: limit,
2433 }));
2434 }
2435 Ok(())
2436 }
2437
2438 fn get_page(&self, index: usize) -> Result<&Page<'_>> {
2439 let pages = self.pdf.pages();
2440 if index >= pages.len() {
2441 return Err(EngineError::PageOutOfRange {
2442 index,
2443 count: pages.len(),
2444 });
2445 }
2446 Ok(&pages[index])
2447 }
2448
2449 fn text_extraction_settings(&self) -> InterpreterSettings {
2450 let mut settings = self.settings.clone();
2451 settings.skip_signature_widgets = false;
2454 settings
2455 }
2456
2457 fn create_context_with_settings<'a>(
2458 page: &Page<'a>,
2459 settings: InterpreterSettings,
2460 ) -> Context<'a> {
2461 let (w, h) = page.render_dimensions();
2462 Context::new(
2463 page.initial_transform(false),
2464 Rect::new(0.0, 0.0, w as f64, h as f64),
2465 page.xref(),
2466 settings,
2467 )
2468 }
2469
2470 fn extract_text_with_settings<'a>(
2471 page: &Page<'a>,
2472 settings: InterpreterSettings,
2473 keep_ligatures: bool,
2474 ) -> (String, InterpreterSettings) {
2475 let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2476 let mut ctx = Self::create_context_with_settings(page, settings);
2477 interpret_page(page, &mut ctx, &mut device);
2478 let settings = ctx.into_settings();
2479 (device.into_text(), settings)
2480 }
2481
2482 fn extract_text_blocks_with_settings<'a>(
2483 page: &Page<'a>,
2484 settings: InterpreterSettings,
2485 keep_ligatures: bool,
2486 ) -> (Vec<TextBlock>, InterpreterSettings) {
2487 let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2488 let mut ctx = Self::create_context_with_settings(page, settings);
2489 interpret_page(page, &mut ctx, &mut device);
2490 let settings = ctx.into_settings();
2491 (device.into_blocks(), settings)
2492 }
2493
2494 #[cfg(feature = "xfa")]
2495 fn open_flattened_xfa_for_render(&self) -> Option<Self> {
2496 if !crate::xfa::has_xfa(self) {
2497 return None;
2498 }
2499
2500 let flat_bytes = crate::xfa::flatten_cached(self).ok()?;
2503 let mut flat_doc = Self::open(flat_bytes).ok()?;
2504 flat_doc.settings = self.settings.clone();
2505 Some(flat_doc)
2506 }
2507}
2508
2509pub fn gray_page(doc: &PdfDocument, page: usize, dpi: u32) -> Result<(u32, u32, Vec<u8>)> {
2517 let options = RenderOptions {
2518 dpi: f64::from(dpi),
2519 ..Default::default()
2520 };
2521 let rendered = doc.render_page(page, &options)?;
2522 let mut data = Vec::with_capacity(rendered.width as usize * rendered.height as usize);
2523 for px in rendered.pixels.chunks_exact(4) {
2524 let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
2525 let alpha = f32::from(a) / 255.0;
2528 let blend = |c: u8| (f32::from(c) * alpha + 255.0 * (1.0 - alpha)).round() as u8;
2529 let (r, g, b) = (blend(r), blend(g), blend(b));
2530 let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
2531 data.push(luma.round().clamp(0.0, 255.0) as u8);
2532 }
2533 Ok((rendered.width, rendered.height, data))
2534}
2535
2536pub fn join_page_texts<I>(page_texts: I) -> String
2548where
2549 I: IntoIterator,
2550 I::Item: AsRef<str>,
2551{
2552 let mut text = String::new();
2553
2554 for page_text in page_texts {
2555 text.push_str(page_text.as_ref());
2556 while !text.is_empty() && !text.ends_with("\n\n") {
2557 text.push('\n');
2558 }
2559 text.push('\u{000C}');
2560 }
2561
2562 text
2563}
2564
2565fn mask_table_switches(
2574 switches: crate::learned::Switches,
2575 strategy: crate::TableStrategy,
2576) -> crate::learned::Switches {
2577 let mut switches = switches;
2578 match strategy {
2579 crate::TableStrategy::Lines => {
2580 switches.table_region = false;
2581 switches.table_structure = false;
2582 switches.table_judge = false;
2583 }
2584 crate::TableStrategy::Text => {
2585 switches.table_lattice = false;
2586 }
2587 crate::TableStrategy::Both => {}
2588 }
2589 switches
2590}
2591
2592#[cfg(test)]
2593mod extract_all_text_tests {
2594 use super::join_page_texts;
2595
2596 #[test]
2597 fn terminates_every_page_like_pdftotext() {
2598 assert_eq!(
2599 join_page_texts(["Page 1", "Page 2"]),
2600 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2601 );
2602 }
2603
2604 #[test]
2605 fn a_page_carries_a_separator_even_when_it_has_no_text() {
2606 assert_eq!(
2608 join_page_texts(["", "Page 2"]),
2609 "\u{000C}Page 2\n\n\u{000C}"
2610 );
2611 }
2612
2613 #[test]
2614 fn reuses_existing_blank_line_before_form_feed() {
2615 assert_eq!(
2616 join_page_texts(["Page 1\n\n", "Page 2"]),
2617 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2618 );
2619 }
2620
2621 #[test]
2622 fn as_many_separators_as_pages() {
2623 let text = join_page_texts(["a", "b", "c"]);
2624 assert_eq!(text.matches('\u{000C}').count(), 3);
2625 assert_eq!(text.split('\u{000C}').count(), 4);
2627 }
2628}
2629
2630const MAX_OUTLINE_DEPTH: usize = 100;
2633
2634fn parse_outline_items(
2641 item_dict: &Dict<'_>,
2642 depth: usize,
2643 visited: &mut BTreeSet<ObjectIdentifier>,
2644 pages: &[Option<ObjectIdentifier>],
2645) -> Vec<BookmarkItem> {
2646 let mut items = Vec::new();
2647 if depth >= MAX_OUTLINE_DEPTH {
2648 return items;
2649 }
2650 let mut current: Option<Dict<'_>> = Some(item_dict.clone());
2651
2652 while let Some(dict) = current {
2653 if let Some(id) = dict.obj_id() {
2656 if !visited.insert(id) {
2657 break;
2658 }
2659 }
2660
2661 let title = dict
2662 .get::<pdf_render::pdf_syntax::object::String>(TITLE)
2663 .map(|s| bytes_to_string(s.as_bytes()))
2664 .unwrap_or_default();
2665
2666 let children = match dict.get::<Dict<'_>>(FIRST) {
2667 Some(child_dict) => parse_outline_items(&child_dict, depth + 1, visited, pages),
2668 None => Vec::new(),
2669 };
2670
2671 items.push(BookmarkItem {
2672 title,
2673 page: outline_destination_page(&dict, pages),
2674 children,
2675 });
2676
2677 current = dict.get::<Dict<'_>>(NEXT);
2678 }
2679
2680 items
2681}
2682
2683fn outline_destination_page(item: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> Option<usize> {
2692 if let Some(page) = explicit_dest_page(item.get::<Array<'_>>(DEST), pages) {
2693 return Some(page);
2694 }
2695 let action = item.get::<Dict<'_>>(A)?;
2696 if action.get::<Name>(S).as_deref() != Some(b"GoTo".as_slice()) {
2697 return None;
2698 }
2699 if action.contains_key(F) {
2700 return None;
2702 }
2703 explicit_dest_page(action.get::<Array<'_>>(D), pages)
2704}
2705
2706fn explicit_dest_page(
2709 dest: Option<Array<'_>>,
2710 pages: &[Option<ObjectIdentifier>],
2711) -> Option<usize> {
2712 let first = dest?.raw_iter().next()?;
2713 let target = ObjectIdentifier::from(ObjRef::try_from(first).ok()?);
2714 pages.iter().position(|id| *id == Some(target))
2715}
2716
2717fn link_target(annot: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2724 if let Some(action) = annot.get::<Dict<'_>>(A) {
2725 if action.get::<Name>(S).as_deref() == Some(b"URI".as_slice()) {
2726 let uri = action
2727 .get::<pdf_render::pdf_syntax::object::String>(URI)
2728 .map(|s| bytes_to_string(s.as_bytes()))
2729 .unwrap_or_default();
2730 return LinkTarget::Uri(uri);
2731 }
2732 if action.get::<Name>(S).as_deref() == Some(b"GoTo".as_slice()) && !action.contains_key(F) {
2733 return link_destination(action.get::<Object<'_>>(D), pages);
2734 }
2735 return LinkTarget::Unknown;
2736 }
2737 link_destination(annot.get::<Object<'_>>(DEST), pages)
2738}
2739
2740fn link_destination(dest: Option<Object<'_>>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2744 match dest {
2745 Some(Object::Array(array)) => explicit_dest_page(Some(array), pages)
2746 .map(LinkTarget::Page)
2747 .unwrap_or(LinkTarget::Unknown),
2748 Some(Object::Name(name)) => LinkTarget::Named(name.as_str().to_string()),
2749 Some(Object::String(text)) => LinkTarget::Named(bytes_to_string(text.as_bytes())),
2750 _ => LinkTarget::Unknown,
2751 }
2752}
2753
2754fn bytes_to_string(bytes: &[u8]) -> String {
2756 if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
2758 let chars: Vec<u16> = bytes[2..]
2759 .chunks(2)
2760 .filter_map(|c| {
2761 if c.len() == 2 {
2762 Some(u16::from_be_bytes([c[0], c[1]]))
2763 } else {
2764 None
2765 }
2766 })
2767 .collect();
2768 return String::from_utf16_lossy(&chars);
2769 }
2770
2771 match std::str::from_utf8(bytes) {
2773 Ok(s) => s.to_string(),
2774 Err(_) => bytes.iter().map(|&b| b as char).collect(),
2775 }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780 use super::*;
2781 use crate::render::{ColorMode, PixelFormat, RenderConfig, RenderOptions};
2782 use lopdf::{Document as LoDocument, Object};
2783 use std::path::PathBuf;
2784
2785 fn corpus_path(name: &str) -> PathBuf {
2786 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2787 .join("../../corpus")
2788 .join(name)
2789 }
2790
2791 #[test]
2794 fn cyclic_outline_terminates_and_is_bounded() {
2795 fn cyclic_outline_pdf() -> Vec<u8> {
2796 let objs: [&[u8]; 6] = [
2797 b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
2798 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
2799 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2800 b"<< /Type /Outlines /First 5 0 R >>",
2801 b"<< /Title (A) /Next 6 0 R >>",
2802 b"<< /Title (B) /Next 5 0 R >>", ];
2804 let mut buf = Vec::new();
2805 let mut offsets = [0usize; 7];
2806 buf.extend_from_slice(b"%PDF-1.7\n");
2807 for (i, body) in objs.iter().enumerate() {
2808 offsets[i + 1] = buf.len();
2809 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2810 buf.extend_from_slice(body);
2811 buf.extend_from_slice(b"\nendobj\n");
2812 }
2813 let xref_off = buf.len();
2814 buf.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
2815 for o in &offsets[1..7] {
2816 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2817 }
2818 buf.extend_from_slice(
2819 format!("trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2820 .as_bytes(),
2821 );
2822 buf
2823 }
2824
2825 fn count(b: &BookmarkItem) -> usize {
2826 1 + b.children.iter().map(count).sum::<usize>()
2827 }
2828
2829 let doc = PdfDocument::open(cyclic_outline_pdf()).expect("open cyclic-outline PDF");
2830 let bookmarks = doc.bookmarks();
2831 let total: usize = bookmarks.iter().map(count).sum();
2832 assert!(
2833 total <= 2,
2834 "cyclic /Next outline must not loop forever; got {total} items"
2835 );
2836 }
2837
2838 #[test]
2841 fn outline_destinations_resolve_to_their_pages() {
2842 fn outlined_pdf() -> Vec<u8> {
2843 let objs: [&[u8]; 9] = [
2844 b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>",
2845 b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
2846 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2847 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2848 b"<< /Type /Outlines /First 6 0 R /Count 3 >>",
2849 b"<< /Title (A) /Parent 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] >>",
2850 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] >> >>",
2851 b"<< /Title (C) /Parent 5 0 R /Prev 7 0 R /Dest (chapter-three) >>",
2852 b"<< /Title (B.1) /Parent 7 0 R /Dest [4 0 R /Fit] >>",
2853 ];
2854 let mut buf = Vec::new();
2855 let mut offsets = [0usize; 10];
2856 buf.extend_from_slice(b"%PDF-1.7\n");
2857 for (i, body) in objs.iter().enumerate() {
2858 offsets[i + 1] = buf.len();
2859 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2860 buf.extend_from_slice(body);
2861 buf.extend_from_slice(b"\nendobj\n");
2862 }
2863 let xref_off = buf.len();
2864 buf.extend_from_slice(b"xref\n0 10\n0000000000 65535 f \n");
2865 for o in &offsets[1..10] {
2866 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2867 }
2868 buf.extend_from_slice(
2869 format!("trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2870 .as_bytes(),
2871 );
2872 buf
2873 }
2874
2875 let doc = PdfDocument::open(outlined_pdf()).expect("open outlined PDF");
2876 let bookmarks = doc.bookmarks();
2877 assert_eq!(bookmarks.len(), 3);
2878 assert_eq!(
2879 (bookmarks[0].title.as_str(), bookmarks[0].page),
2880 ("A", Some(0))
2881 );
2882 assert_eq!(
2883 (bookmarks[1].title.as_str(), bookmarks[1].page),
2884 ("B", Some(1))
2885 );
2886 assert_eq!(bookmarks[1].children.len(), 1);
2887 assert_eq!(
2888 (
2889 bookmarks[1].children[0].title.as_str(),
2890 bookmarks[1].children[0].page
2891 ),
2892 ("B.1", Some(1))
2893 );
2894 assert_eq!(
2895 (bookmarks[2].title.as_str(), bookmarks[2].page),
2896 ("C", None)
2897 );
2898 }
2899
2900 fn repo_fixture(name: &str) -> Option<PathBuf> {
2908 let dir = std::env::var("PDFLUENT_TEST_FIXTURES").ok().map_or_else(
2909 || {
2910 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2911 .join("..")
2912 .join("..")
2913 .join("tests")
2914 },
2915 PathBuf::from,
2916 );
2917 let path = dir.join(name);
2918 path.exists().then_some(path)
2919 }
2920
2921 #[test]
2922 fn shared_image_cache_is_render_neutral() {
2923 let Some(path) = repo_fixture("corpus-mini/scanned.pdf") else {
2924 eprintln!("skipping: repository fixture tree not present");
2925 return;
2926 };
2927 let data = std::fs::read(&path).expect("read scanned.pdf fixture");
2928 let cfg = RenderConfig::default();
2929
2930 let doc_on = PdfDocument::open(data.clone()).expect("open cache-on");
2933 let miss = doc_on
2934 .render_page_with_config(0, &cfg)
2935 .expect("cold render (cache miss)");
2936 let hit = doc_on
2937 .render_page_with_config(0, &cfg)
2938 .expect("warm render (cache hit)");
2939
2940 let mut doc_off = PdfDocument::open(data).expect("open cache-off");
2943 doc_off.set_settings(InterpreterSettings::default());
2944 let uncached = doc_off
2945 .render_page_with_config(0, &cfg)
2946 .expect("render with cache disabled");
2947
2948 assert_eq!(
2949 (miss.width, miss.height),
2950 (uncached.width, uncached.height),
2951 "render dimensions must match"
2952 );
2953 assert_eq!(
2954 miss.pixels, hit.pixels,
2955 "a cache hit must return exactly the freshly-decoded render"
2956 );
2957 assert_eq!(
2958 miss.pixels, uncached.pixels,
2959 "cache-enabled render must equal cache-disabled render"
2960 );
2961 }
2962
2963 fn normalize_text(text: &str) -> String {
2964 text.split_whitespace().collect::<Vec<_>>().join(" ")
2965 }
2966
2967 fn strip_type0_tounicode(data: &[u8]) -> (Vec<u8>, usize) {
2968 fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<Vec<u8>> {
2969 match dict.get(key).ok()? {
2970 Object::Name(name) => Some(name.clone()),
2971 _ => None,
2972 }
2973 }
2974
2975 fn descendant_is_cidfont_type2(doc: &LoDocument, type0: &lopdf::Dictionary) -> bool {
2976 let Some(Object::Array(descendants)) = type0.get(b"DescendantFonts").ok() else {
2977 return false;
2978 };
2979 let Some(Object::Reference(desc_id)) = descendants.first() else {
2980 return false;
2981 };
2982 let Ok(Object::Dictionary(descendant)) = doc.get_object(*desc_id) else {
2983 return false;
2984 };
2985 matches!(
2986 descendant.get(b"Subtype").ok(),
2987 Some(Object::Name(name)) if name.as_slice() == b"CIDFontType2"
2988 )
2989 }
2990
2991 let mut doc = LoDocument::load_mem(data).expect("load stripped-to-unicode fixture");
2992 let ids: Vec<_> = doc.objects.keys().copied().collect();
2993 let mut removed = 0usize;
2994
2995 for id in ids {
2996 let Some(Object::Dictionary(dict)) = doc.objects.get(&id) else {
2997 continue;
2998 };
2999 if !matches!(
3000 dict.get(b"Subtype").ok(),
3001 Some(Object::Name(name)) if name.as_slice() == b"Type0"
3002 ) {
3003 continue;
3004 }
3005 if !matches!(
3006 get_name(dict, b"Encoding").as_deref(),
3007 Some(b"Identity-H") | Some(b"Identity-V")
3008 ) {
3009 continue;
3010 }
3011 if !descendant_is_cidfont_type2(&doc, dict) {
3012 continue;
3013 }
3014
3015 if let Some(Object::Dictionary(type0)) = doc.objects.get_mut(&id) {
3016 if type0.has(b"ToUnicode") {
3017 type0.remove(b"ToUnicode");
3018 removed += 1;
3019 }
3020 }
3021 }
3022
3023 let mut out = Vec::new();
3024 doc.save_to(&mut out)
3025 .expect("save stripped-to-unicode fixture");
3026 (out, removed)
3027 }
3028
3029 fn solid_fill_pdf_bytes(color_operator: &str) -> Vec<u8> {
3030 use lopdf::{dictionary, Document, Object, Stream};
3031
3032 let mut doc = Document::with_version("1.4");
3033
3034 let pages_id = doc.new_object_id();
3035 let page_id = doc.new_object_id();
3036 let content = format!("{color_operator}\n0 0 72 72 re f\n");
3037 let content_id = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
3038
3039 doc.objects.insert(
3040 page_id,
3041 Object::Dictionary(dictionary! {
3042 "Type" => Object::Name(b"Page".to_vec()),
3043 "Parent" => Object::Reference(pages_id),
3044 "MediaBox" => Object::Array(vec![
3045 Object::Integer(0),
3046 Object::Integer(0),
3047 Object::Integer(72),
3048 Object::Integer(72),
3049 ]),
3050 "Contents" => Object::Reference(content_id),
3051 }),
3052 );
3053
3054 doc.objects.insert(
3055 pages_id,
3056 Object::Dictionary(dictionary! {
3057 "Type" => Object::Name(b"Pages".to_vec()),
3058 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3059 "Count" => Object::Integer(1),
3060 }),
3061 );
3062
3063 let catalog_id = doc.new_object_id();
3064 doc.objects.insert(
3065 catalog_id,
3066 Object::Dictionary(dictionary! {
3067 "Type" => Object::Name(b"Catalog".to_vec()),
3068 "Pages" => Object::Reference(pages_id),
3069 }),
3070 );
3071
3072 doc.trailer.set("Root", Object::Reference(catalog_id));
3073
3074 let mut bytes = Vec::new();
3075 doc.save_to(&mut bytes).expect("save solid fill fixture");
3076 bytes
3077 }
3078
3079 fn mixed_rgb_cmyk_pdf_bytes() -> Vec<u8> {
3080 use lopdf::{dictionary, Document, Object, Stream};
3081
3082 let mut doc = Document::with_version("1.4");
3083 let pages_id = doc.new_object_id();
3084 let page_id = doc.new_object_id();
3085 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();
3086 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3087
3088 doc.objects.insert(
3089 page_id,
3090 Object::Dictionary(dictionary! {
3091 "Type" => "Page",
3092 "Parent" => Object::Reference(pages_id),
3093 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3094 "Contents" => Object::Reference(content_id),
3095 }),
3096 );
3097 doc.objects.insert(
3098 pages_id,
3099 Object::Dictionary(dictionary! {
3100 "Type" => "Pages",
3101 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3102 "Count" => Object::Integer(1),
3103 }),
3104 );
3105 let catalog_id = doc.new_object_id();
3106 doc.objects.insert(
3107 catalog_id,
3108 Object::Dictionary(dictionary! {
3109 "Type" => "Catalog",
3110 "Pages" => Object::Reference(pages_id),
3111 }),
3112 );
3113 doc.trailer.set("Root", Object::Reference(catalog_id));
3114
3115 let mut bytes = Vec::new();
3116 doc.save_to(&mut bytes)
3117 .expect("save mixed rgb/cmyk fixture");
3118 bytes
3119 }
3120
3121 fn transparent_cmyk_pdf_bytes() -> Vec<u8> {
3122 use lopdf::{dictionary, Document, Object, Stream};
3123
3124 let mut doc = Document::with_version("1.4");
3125 let pages_id = doc.new_object_id();
3126 let page_id = doc.new_object_id();
3127 let gs_id = doc.add_object(Object::Dictionary(dictionary! {
3128 "Type" => "ExtGState",
3129 "ca" => Object::Real(0.5),
3130 }));
3131 let content = b"/GS1 gs\n1 0 0 0 k\n0 0 72 72 re f\n".to_vec();
3132 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3133
3134 doc.objects.insert(
3135 page_id,
3136 Object::Dictionary(dictionary! {
3137 "Type" => "Page",
3138 "Parent" => Object::Reference(pages_id),
3139 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3140 "Resources" => dictionary! {
3141 "ExtGState" => dictionary! {
3142 "GS1" => Object::Reference(gs_id),
3143 },
3144 },
3145 "Contents" => Object::Reference(content_id),
3146 }),
3147 );
3148 doc.objects.insert(
3149 pages_id,
3150 Object::Dictionary(dictionary! {
3151 "Type" => "Pages",
3152 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3153 "Count" => Object::Integer(1),
3154 }),
3155 );
3156 let catalog_id = doc.new_object_id();
3157 doc.objects.insert(
3158 catalog_id,
3159 Object::Dictionary(dictionary! {
3160 "Type" => "Catalog",
3161 "Pages" => Object::Reference(pages_id),
3162 }),
3163 );
3164 doc.trailer.set("Root", Object::Reference(catalog_id));
3165
3166 let mut bytes = Vec::new();
3167 doc.save_to(&mut bytes)
3168 .expect("save transparent cmyk fixture");
3169 bytes
3170 }
3171
3172 fn cmyk_image_pdf_bytes() -> Vec<u8> {
3173 use lopdf::{dictionary, Document, Object, Stream};
3174
3175 let mut doc = Document::with_version("1.4");
3176 let pages_id = doc.new_object_id();
3177 let page_id = doc.new_object_id();
3178 let image_id = doc.add_object(Stream::new(
3179 dictionary! {
3180 "Type" => "XObject",
3181 "Subtype" => "Image",
3182 "Width" => Object::Integer(2),
3183 "Height" => Object::Integer(1),
3184 "BitsPerComponent" => Object::Integer(8),
3185 "ColorSpace" => "DeviceCMYK",
3186 },
3187 vec![255, 0, 0, 0, 0, 255, 0, 0],
3188 ));
3189 let content = b"q\n2 0 0 1 0 0 cm\n/Im1 Do\nQ\n".to_vec();
3190 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3191
3192 doc.objects.insert(
3193 page_id,
3194 Object::Dictionary(dictionary! {
3195 "Type" => "Page",
3196 "Parent" => Object::Reference(pages_id),
3197 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 2.into(), 1.into()]),
3198 "Resources" => dictionary! {
3199 "XObject" => dictionary! {
3200 "Im1" => Object::Reference(image_id),
3201 },
3202 },
3203 "Contents" => Object::Reference(content_id),
3204 }),
3205 );
3206 doc.objects.insert(
3207 pages_id,
3208 Object::Dictionary(dictionary! {
3209 "Type" => "Pages",
3210 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3211 "Count" => Object::Integer(1),
3212 }),
3213 );
3214 let catalog_id = doc.new_object_id();
3215 doc.objects.insert(
3216 catalog_id,
3217 Object::Dictionary(dictionary! {
3218 "Type" => "Catalog",
3219 "Pages" => Object::Reference(pages_id),
3220 }),
3221 );
3222 doc.trailer.set("Root", Object::Reference(catalog_id));
3223
3224 let mut bytes = Vec::new();
3225 doc.save_to(&mut bytes).expect("save cmyk image fixture");
3226 bytes
3227 }
3228
3229 fn pixel_at(rendered: &RenderedPage, x: u32, y: u32) -> [u8; 4] {
3230 let idx = ((y * rendered.width + x) * 4) as usize;
3231 [
3232 rendered.pixels[idx],
3233 rendered.pixels[idx + 1],
3234 rendered.pixels[idx + 2],
3235 rendered.pixels[idx + 3],
3236 ]
3237 }
3238
3239 fn non_embedded_truetype_pdf_bytes(
3244 base_font: &[u8],
3245 encoding: &[u8],
3246 text_bytes: &[u8],
3247 ) -> Vec<u8> {
3248 use lopdf::{dictionary, Document, Object, Stream};
3249
3250 let mut doc = Document::with_version("1.4");
3251
3252 let font_id = doc.add_object(Object::Dictionary(dictionary! {
3253 "Type" => "Font",
3254 "Subtype" => "TrueType",
3255 "Name" => Object::Name(b"F0".to_vec()),
3256 "BaseFont" => Object::Name(base_font.to_vec()),
3257 "Encoding" => Object::Name(encoding.to_vec()),
3258 }));
3259
3260 let resources_id = doc.add_object(Object::Dictionary(dictionary! {
3261 "Font" => dictionary! { "F0" => Object::Reference(font_id) },
3262 }));
3263
3264 let mut content = Vec::new();
3265 content.extend_from_slice(b"BT\n/F0 12 Tf\n100 700 Td\n(");
3266 for &b in text_bytes {
3267 match b {
3268 b'(' | b')' | b'\\' => {
3269 content.push(b'\\');
3270 content.push(b);
3271 }
3272 _ => content.push(b),
3273 }
3274 }
3275 content.extend_from_slice(b") Tj\nET\n");
3276 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3277
3278 let pages_id = doc.new_object_id();
3279 let page_id = doc.add_object(Object::Dictionary(dictionary! {
3280 "Type" => "Page",
3281 "Parent" => Object::Reference(pages_id),
3282 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3283 "Resources" => Object::Reference(resources_id),
3284 "Contents" => Object::Reference(content_id),
3285 }));
3286 doc.objects.insert(
3287 pages_id,
3288 Object::Dictionary(dictionary! {
3289 "Type" => "Pages",
3290 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3291 "Count" => Object::Integer(1),
3292 }),
3293 );
3294 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
3295 "Type" => "Catalog",
3296 "Pages" => Object::Reference(pages_id),
3297 }));
3298 doc.trailer.set("Root", Object::Reference(catalog_id));
3299
3300 let mut bytes = Vec::new();
3301 doc.save_to(&mut bytes).expect("save non-embedded fixture");
3302 bytes
3303 }
3304
3305 fn push_button_caption_pdf_bytes(caption: &[u8]) -> Vec<u8> {
3308 use lopdf::{dictionary, Document, Object, Stream, StringFormat};
3309
3310 let mut doc = Document::with_version("1.4");
3311
3312 let catalog_id = doc.new_object_id();
3313 let pages_id = doc.new_object_id();
3314 let page_id = doc.new_object_id();
3315 let acroform_id = doc.new_object_id();
3316 let content_id = doc.new_object_id();
3317 let widget_id = doc.new_object_id();
3318
3319 doc.objects.insert(
3320 content_id,
3321 Object::Stream(Stream::new(dictionary! {}, Vec::new())),
3322 );
3323 doc.objects.insert(
3324 widget_id,
3325 Object::Dictionary(dictionary! {
3326 "Type" => "Annot",
3327 "Subtype" => "Widget",
3328 "FT" => "Btn",
3329 "Ff" => Object::Integer(1 << 16),
3330 "T" => Object::String(b"button".to_vec(), StringFormat::Literal),
3331 "MK" => dictionary! {
3332 "CA" => Object::String(caption.to_vec(), StringFormat::Literal),
3333 },
3334 "Rect" => Object::Array(vec![100.into(), 700.into(), 260.into(), 730.into()]),
3335 "P" => Object::Reference(page_id),
3336 }),
3337 );
3338 doc.objects.insert(
3339 page_id,
3340 Object::Dictionary(dictionary! {
3341 "Type" => "Page",
3342 "Parent" => Object::Reference(pages_id),
3343 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3344 "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
3345 "Contents" => Object::Reference(content_id),
3346 }),
3347 );
3348 doc.objects.insert(
3349 pages_id,
3350 Object::Dictionary(dictionary! {
3351 "Type" => "Pages",
3352 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3353 "Count" => Object::Integer(1),
3354 }),
3355 );
3356 doc.objects.insert(
3357 acroform_id,
3358 Object::Dictionary(dictionary! {
3359 "Fields" => Object::Array(vec![Object::Reference(widget_id)]),
3360 }),
3361 );
3362 doc.objects.insert(
3363 catalog_id,
3364 Object::Dictionary(dictionary! {
3365 "Type" => "Catalog",
3366 "Pages" => Object::Reference(pages_id),
3367 "AcroForm" => Object::Reference(acroform_id),
3368 }),
3369 );
3370 doc.trailer.set("Root", Object::Reference(catalog_id));
3371
3372 let mut bytes = Vec::new();
3373 doc.save_to(&mut bytes)
3374 .expect("save push-button caption fixture");
3375 bytes
3376 }
3377
3378 #[test]
3379 fn extract_text_non_embedded_truetype_alias_resolves_via_winansi() {
3380 let bytes = non_embedded_truetype_pdf_bytes(
3386 b"TimesNewRoman",
3387 b"WinAnsiEncoding",
3388 b"UNITED STATES DISTRICT COURT",
3389 );
3390 let text = PdfDocument::open(bytes)
3391 .expect("open non-embedded TrueType fixture")
3392 .extract_text(0)
3393 .expect("extract non-embedded TrueType text");
3394 let norm = normalize_text(&text);
3395 assert!(
3396 norm.contains("UNITED STATES DISTRICT COURT"),
3397 "expected WinAnsi-decoded text, got: {norm:?}"
3398 );
3399 }
3400
3401 #[test]
3402 fn extract_text_non_embedded_truetype_unknown_name_still_decodes() {
3403 let bytes = non_embedded_truetype_pdf_bytes(
3410 b"OpaqueCustomXYZ",
3411 b"WinAnsiEncoding",
3412 b"Hello, world!",
3413 );
3414 let text = PdfDocument::open(bytes)
3415 .expect("open custom non-embedded fixture")
3416 .extract_text(0)
3417 .expect("extract custom non-embedded text");
3418 let norm = normalize_text(&text);
3419 assert!(
3420 norm.contains("Hello, world!"),
3421 "expected WinAnsi-decoded text, got: {norm:?}"
3422 );
3423 }
3424
3425 #[test]
3426 fn extract_acroform_text_includes_push_button_mk_caption() {
3427 let bytes = push_button_caption_pdf_bytes(b"Don't cry over spilt milk");
3428 let doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3429
3430 let page_text = doc.extract_text(0).expect("extract page text");
3431 assert!(
3432 normalize_text(&page_text).is_empty(),
3433 "expected empty page content stream, got: {page_text:?}"
3434 );
3435
3436 let acroform_text = doc.extract_acroform_text();
3437 assert_eq!(normalize_text(&acroform_text), "Don't cry over spilt milk");
3438
3439 let all_text = doc.extract_all_text();
3440 assert_eq!(normalize_text(&all_text), "Don't cry over spilt milk");
3441 }
3442
3443 #[test]
3448 fn acroform_values_fold_their_ligatures() {
3449 let mut caption = vec![0xFE_u8, 0xFF_u8];
3453 for unit in "o\u{FB03}ce hours".encode_utf16() {
3454 caption.extend_from_slice(&unit.to_be_bytes());
3455 }
3456 let bytes = push_button_caption_pdf_bytes(&caption);
3457 let mut doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3458
3459 assert_eq!(normalize_text(&doc.extract_acroform_text()), "office hours");
3460 assert_eq!(normalize_text(&doc.extract_all_text()), "office hours");
3461
3462 doc.set_keep_ligatures(true);
3463 assert_eq!(
3464 normalize_text(&doc.extract_acroform_text()),
3465 "o\u{FB03}ce hours",
3466 "keep_ligatures did not reach the field values"
3467 );
3468 }
3469
3470 #[test]
3471 fn bytes_to_string_utf8() {
3472 assert_eq!(bytes_to_string(b"hello"), "hello");
3473 }
3474
3475 #[test]
3476 fn bytes_to_string_latin1() {
3477 let bytes = &[0xC4, 0xD6, 0xDC]; let s = bytes_to_string(bytes);
3479 assert_eq!(s, "ÄÖÜ");
3480 }
3481
3482 #[test]
3483 fn bytes_to_string_utf16() {
3484 let bytes = &[0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]; assert_eq!(bytes_to_string(bytes), "Hi");
3486 }
3487
3488 #[test]
3489 fn document_info_default() {
3490 let info = DocumentInfo::default();
3491 assert!(info.title.is_none());
3492 assert!(info.author.is_none());
3493 }
3494
3495 #[test]
3496 fn bookmark_item_children() {
3497 let item = BookmarkItem {
3498 title: "Root".into(),
3499 page: None,
3500 children: vec![BookmarkItem {
3501 title: "Child".into(),
3502 page: Some(0),
3503 children: Vec::new(),
3504 }],
3505 };
3506 assert_eq!(item.children.len(), 1);
3507 assert_eq!(item.children[0].title, "Child");
3508 }
3509
3510 #[test]
3511 fn extract_text_type0_without_tounicode_uses_font_program_fallback() {
3512 let original = std::fs::read(corpus_path("sf181.pdf")).expect("read sf181 fixture");
3513 let expected = PdfDocument::open(original.clone())
3514 .expect("open original sf181")
3515 .extract_text(0)
3516 .expect("extract original sf181 text");
3517 assert!(
3518 expected.contains("Guide to Personnel Data Standards"),
3519 "unexpected baseline extraction: {expected}"
3520 );
3521
3522 let (stripped, removed) = strip_type0_tounicode(&original);
3523 assert!(
3524 removed > 0,
3525 "expected to strip at least one Type0 ToUnicode"
3526 );
3527
3528 let actual = PdfDocument::open(stripped)
3529 .expect("open stripped sf181")
3530 .extract_text(0)
3531 .expect("extract stripped sf181 text");
3532
3533 let actual_norm = normalize_text(&actual);
3534 let expected_norm = normalize_text(&expected);
3535
3536 assert!(
3537 actual_norm.contains("Guide to Personnel Data Standards"),
3538 "missing main heading after stripping ToUnicode: {actual_norm}"
3539 );
3540 assert!(
3541 actual_norm.contains("Privacy Act Statement"),
3542 "missing body text after stripping ToUnicode: {actual_norm}"
3543 );
3544 assert!(
3545 actual_norm.len() + 32 >= expected_norm.len(),
3546 "too much text lost after stripping ToUnicode: expected {} chars, got {}",
3547 expected_norm.len(),
3548 actual_norm.len()
3549 );
3550 }
3551
3552 #[test]
3553 fn extract_text_identity_h_bogus_tounicode_recovers_via_identity_fallback() {
3554 let bytes =
3562 std::fs::read(corpus_path("PDFBOX-4322-3.pdf")).expect("read PDFBOX-4322-3 fixture");
3563 let doc = PdfDocument::open(bytes).expect("open PDFBOX-4322-3");
3564 let text = doc.extract_all_text();
3565
3566 let norm = normalize_text(&text);
3567 assert!(
3568 norm.contains("Transatlantic Council"),
3569 "expected Identity-H codes to resolve as Unicode: {norm}"
3570 );
3571 assert!(
3572 norm.contains("Boy Scouts of America"),
3573 "expected body text to be recovered: {norm}"
3574 );
3575 }
3576
3577 #[test]
3578 fn render_max_pixels_none_is_unchanged_default_behavior() {
3579 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3581 let baseline = doc
3582 .render_page(
3583 0,
3584 &RenderOptions {
3585 dpi: 144.0,
3586 ..Default::default()
3587 },
3588 )
3589 .expect("baseline render");
3590 let explicit_none = doc
3591 .render_page(
3592 0,
3593 &RenderOptions {
3594 dpi: 144.0,
3595 max_pixels: None,
3596 ..Default::default()
3597 },
3598 )
3599 .expect("explicit-none render");
3600 assert_eq!(baseline.width, explicit_none.width);
3601 assert_eq!(baseline.height, explicit_none.height);
3602 assert_eq!(baseline.pixels, explicit_none.pixels);
3603 }
3604
3605 #[test]
3606 fn render_max_pixels_budget_clamps_resolution() {
3607 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3608 let full = doc
3609 .render_page(
3610 0,
3611 &RenderOptions {
3612 dpi: 288.0,
3613 ..Default::default()
3614 },
3615 )
3616 .expect("full render");
3617 let full_px = full.width * full.height;
3618 let budget = full_px / 4;
3620 let capped = doc
3621 .render_page(
3622 0,
3623 &RenderOptions {
3624 dpi: 288.0,
3625 max_pixels: Some(budget),
3626 ..Default::default()
3627 },
3628 )
3629 .expect("capped render");
3630 assert!(
3631 capped.width * capped.height <= full_px,
3632 "capped output must not exceed full output"
3633 );
3634 assert!(
3635 capped.width < full.width || capped.height < full.height,
3636 "budget below full pixel count must shrink at least one dimension"
3637 );
3638 }
3639
3640 #[test]
3641 fn render_max_pixels_large_budget_no_clamp() {
3642 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3644 let baseline = doc
3645 .render_page(
3646 0,
3647 &RenderOptions {
3648 dpi: 72.0,
3649 ..Default::default()
3650 },
3651 )
3652 .expect("baseline");
3653 let huge = doc
3654 .render_page(
3655 0,
3656 &RenderOptions {
3657 dpi: 72.0,
3658 max_pixels: Some(100_000_000),
3659 ..Default::default()
3660 },
3661 )
3662 .expect("huge-budget render");
3663 assert_eq!(baseline.width, huge.width);
3664 assert_eq!(baseline.height, huge.height);
3665 assert_eq!(baseline.pixels, huge.pixels);
3666 }
3667
3668 #[test]
3669 fn render_page_with_config_srgb_matches_legacy_render_page() {
3670 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open rgb fixture");
3671 let legacy = doc
3672 .render_page(
3673 0,
3674 &RenderOptions {
3675 dpi: 72.0,
3676 ..Default::default()
3677 },
3678 )
3679 .expect("legacy render succeeds");
3680 let configured = doc
3681 .render_page_with_config(
3682 0,
3683 &RenderConfig {
3684 color_mode: ColorMode::Srgb,
3685 dpi: 72,
3686 },
3687 )
3688 .expect("configured render succeeds");
3689
3690 assert_eq!(legacy.width, configured.width);
3691 assert_eq!(legacy.height, configured.height);
3692 assert_eq!(legacy.pixel_format, PixelFormat::Rgba8);
3693 assert_eq!(configured.pixel_format, PixelFormat::Rgba8);
3694 assert_eq!(legacy.pixels, configured.pixels);
3695 }
3696
3697 #[test]
3698 fn render_page_with_config_preserve_cmyk_returns_cmyk_buffer() {
3699 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3700 let rendered = doc
3701 .render_page_with_config(
3702 0,
3703 &RenderConfig {
3704 color_mode: ColorMode::PreserveCmyk,
3705 dpi: 72,
3706 },
3707 )
3708 .expect("cmyk render succeeds");
3709
3710 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3711 assert_eq!(
3712 rendered.pixels.len(),
3713 rendered.width as usize * rendered.height as usize * 4
3714 );
3715 assert_eq!(
3716 pixel_at(&rendered, rendered.width / 2, rendered.height / 2),
3717 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3718 );
3719 }
3720
3721 #[test]
3722 fn render_page_with_config_simulate_cmyk_does_not_panic_on_cmyk_pdf() {
3723 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3724 let rendered = doc
3725 .render_page_with_config(
3726 0,
3727 &RenderConfig {
3728 color_mode: ColorMode::SimulateCmyk,
3729 dpi: 72,
3730 },
3731 )
3732 .expect("simulate cmyk render succeeds");
3733
3734 assert_eq!(rendered.pixel_format, PixelFormat::Rgba8);
3735 assert!(!rendered.pixels.is_empty());
3736 }
3737
3738 #[test]
3739 fn render_page_with_config_preserve_cmyk_mixed_page_preserves_only_cmyk_region() {
3740 let doc = PdfDocument::open(mixed_rgb_cmyk_pdf_bytes()).expect("open mixed 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("mixed render succeeds");
3750
3751 assert_eq!(
3752 pixel_at(&rendered, 54, 36),
3753 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3754 );
3755 assert_ne!(
3756 pixel_at(&rendered, 18, 36),
3757 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3758 );
3759 }
3760
3761 #[test]
3762 fn render_page_with_config_preserve_cmyk_transparent_page_does_not_crash() {
3763 let doc =
3764 PdfDocument::open(transparent_cmyk_pdf_bytes()).expect("open transparent cmyk fixture");
3765 let rendered = doc
3766 .render_page_with_config(
3767 0,
3768 &RenderConfig {
3769 color_mode: ColorMode::PreserveCmyk,
3770 dpi: 72,
3771 },
3772 )
3773 .expect("transparent cmyk render succeeds");
3774
3775 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3776 assert_eq!(
3777 rendered.pixels.len(),
3778 rendered.width as usize * rendered.height as usize * 4
3779 );
3780 }
3781
3782 #[test]
3783 fn render_page_with_config_preserve_cmyk_keeps_device_cmyk_image_bytes() {
3784 let doc = PdfDocument::open(cmyk_image_pdf_bytes()).expect("open cmyk image fixture");
3785 let rendered = doc
3786 .render_page_with_config(
3787 0,
3788 &RenderConfig {
3789 color_mode: ColorMode::PreserveCmyk,
3790 dpi: 72,
3791 },
3792 )
3793 .expect("cmyk image render succeeds");
3794
3795 assert_eq!(rendered.width, 2);
3796 assert_eq!(rendered.height, 1);
3797 assert_eq!(pixel_at(&rendered, 0, 0), [255, 0, 0, 0]);
3798 assert_eq!(pixel_at(&rendered, 1, 0), [0, 255, 0, 0]);
3799 }
3800}
3801
3802#[cfg(test)]
3803mod load_recovery_tests {
3804 use super::PdfDocument;
3805
3806 #[test]
3807 fn broken_xref_sets_xref_rebuilt() {
3808 let body: &[u8] = b"%PDF-1.7\n\
38111 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
38122 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
38133 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n\
3814trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n999999\n%%EOF";
3815 let doc = PdfDocument::open(body.to_vec()).expect("recovers via xref rebuild");
3816 assert!(doc.load_recovery().xref_rebuilt, "xref_rebuilt must be set");
3817 }
3818
3819 #[test]
3820 fn clean_document_reports_no_recovery() {
3821 let objs: [&[u8]; 3] = [
3823 b"<< /Type /Catalog /Pages 2 0 R >>",
3824 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
3825 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
3826 ];
3827 let mut buf = Vec::new();
3828 let mut off = [0usize; 4];
3829 buf.extend_from_slice(b"%PDF-1.7\n");
3830 for (i, body) in objs.iter().enumerate() {
3831 off[i + 1] = buf.len();
3832 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
3833 buf.extend_from_slice(body);
3834 buf.extend_from_slice(b"\nendobj\n");
3835 }
3836 let xref_off = buf.len();
3837 buf.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
3838 for o in &off[1..4] {
3839 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
3840 }
3841 buf.extend_from_slice(
3842 format!("trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n{xref_off}\n%%EOF").as_bytes(),
3843 );
3844 let doc = PdfDocument::open(buf).expect("clean doc opens");
3845 let r = doc.load_recovery();
3846 assert!(
3847 !r.xref_rebuilt && !r.page_tree_rebuilt,
3848 "a clean document must report no recovery; got {r:?}"
3849 );
3850 }
3851}
3852
3853#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3860pub struct VisionAdvice {
3861 pub no_text_layer: bool,
3869 pub low_confidence_table: bool,
3877 pub figure: bool,
3885}
3886
3887impl VisionAdvice {
3888 pub fn needs_vision(&self) -> bool {
3890 self.no_text_layer || self.low_confidence_table || self.figure
3891 }
3892}
3893
3894const VISION_MIN_CHARACTERS: usize = 40;
3903
3904#[derive(Default)]
3906struct InkDevice {
3907 glyphs: usize,
3908 images: usize,
3909 paths: usize,
3910}
3911
3912impl InkDevice {
3913 fn drew_anything(&self) -> bool {
3914 self.glyphs > 0 || self.images > 0 || self.paths > 0
3915 }
3916}
3917
3918impl<'a> InkDeviceTypes::Device<'a> for InkDevice {
3919 fn set_soft_mask(&mut self, _: Option<InkDeviceTypes::SoftMask<'a>>) {}
3920 fn set_blend_mode(&mut self, _: InkDeviceTypes::BlendMode) {}
3921 fn draw_path(
3922 &mut self,
3923 _: &kurbo::BezPath,
3924 _: kurbo::Affine,
3925 _: &InkDeviceTypes::Paint<'_>,
3926 _: &InkDeviceTypes::PathDrawMode,
3927 ) {
3928 self.paths += 1;
3929 }
3930 fn push_clip_path(&mut self, _: &InkDeviceTypes::ClipPath) {}
3931 fn push_transparency_group(
3932 &mut self,
3933 _: f32,
3934 _: Option<InkDeviceTypes::SoftMask<'_>>,
3935 _: InkDeviceTypes::BlendMode,
3936 ) {
3937 }
3938 fn draw_glyph(
3939 &mut self,
3940 _: &pdf_render::pdf_interpret::font::Glyph<'_>,
3941 _: kurbo::Affine,
3942 _: kurbo::Affine,
3943 _: &InkDeviceTypes::Paint<'_>,
3944 mode: &InkDeviceTypes::GlyphDrawMode,
3945 ) {
3946 if !matches!(mode, InkDeviceTypes::GlyphDrawMode::Invisible) {
3960 self.glyphs += 1;
3961 }
3962 }
3963 fn draw_image(&mut self, _: InkDeviceTypes::Image<'a, '_>, _: kurbo::Affine) {
3964 self.images += 1;
3965 }
3966 fn pop_clip_path(&mut self) {}
3967 fn pop_transparency_group(&mut self) {}
3968}
3969
3970#[cfg(test)]
3971mod last_glyph_record_tests {
3972 use super::*;
3978 use crate::text::last_glyph_is_held;
3979 use lopdf::{dictionary, Document as LoDocument, Object, Stream};
3980 use pdf_render::pdf_interpret::font::{Glyph, GlyphPlace};
3981 use pdf_render::pdf_interpret::{
3982 BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
3983 };
3984
3985 fn one_line() -> Vec<u8> {
3987 let mut doc = LoDocument::with_version("1.7");
3988 let pages_id = doc.new_object_id();
3989 let font_id = doc.add_object(dictionary! {
3990 "Type" => "Font",
3991 "Subtype" => "Type1",
3992 "BaseFont" => "Helvetica",
3993 });
3994 let content = b"BT /F1 12 Tf 72 700 Td (abc) Tj ET".to_vec();
3995 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3996 let page_id = doc.add_object(dictionary! {
3997 "Type" => "Page",
3998 "Parent" => pages_id,
3999 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4000 "Contents" => content_id,
4001 "Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
4002 });
4003 doc.objects.insert(
4004 pages_id,
4005 Object::Dictionary(dictionary! {
4006 "Type" => "Pages",
4007 "Kids" => vec![Object::Reference(page_id)],
4008 "Count" => 1_i64,
4009 }),
4010 );
4011 let catalog_id = doc.add_object(dictionary! {
4012 "Type" => "Catalog",
4013 "Pages" => pages_id,
4014 });
4015 doc.trailer.set("Root", catalog_id);
4016 let mut bytes = Vec::new();
4017 doc.save_to(&mut bytes).expect("fixture saves");
4018 bytes
4019 }
4020
4021 struct PanicsAtTheSecondGlyph {
4024 inner: TextExtractionDevice,
4025 glyphs: usize,
4026 }
4027
4028 impl<'a> Device<'a> for PanicsAtTheSecondGlyph {
4029 fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
4030 self.inner.set_soft_mask(mask);
4031 }
4032 fn set_blend_mode(&mut self, blend_mode: BlendMode) {
4033 self.inner.set_blend_mode(blend_mode);
4034 }
4035 fn draw_path(
4036 &mut self,
4037 path: &kurbo::BezPath,
4038 transform: kurbo::Affine,
4039 paint: &Paint<'a>,
4040 draw_mode: &PathDrawMode,
4041 ) {
4042 self.inner.draw_path(path, transform, paint, draw_mode);
4043 }
4044 fn push_clip_path(&mut self, clip_path: &ClipPath) {
4045 self.inner.push_clip_path(clip_path);
4046 }
4047 fn push_transparency_group(
4048 &mut self,
4049 opacity: f32,
4050 mask: Option<SoftMask<'a>>,
4051 blend_mode: BlendMode,
4052 ) {
4053 self.inner
4054 .push_transparency_group(opacity, mask, blend_mode);
4055 }
4056 fn draw_glyph(
4057 &mut self,
4058 glyph: &Glyph<'a>,
4059 transform: kurbo::Affine,
4060 glyph_transform: kurbo::Affine,
4061 paint: &Paint<'a>,
4062 draw_mode: &GlyphDrawMode,
4063 ) {
4064 self.glyphs += 1;
4065 if self.glyphs == 2 {
4066 assert!(last_glyph_is_held(), "the first glyph is remembered");
4067 panic!("inside extraction");
4068 }
4069 self.inner
4070 .draw_glyph(glyph, transform, glyph_transform, paint, draw_mode);
4071 }
4072 fn draw_image(&mut self, image: Image<'a, '_>, transform: kurbo::Affine) {
4073 self.inner.draw_image(image, transform);
4074 }
4075 fn pop_clip_path(&mut self) {
4076 self.inner.pop_clip_path();
4077 }
4078 fn pop_transparency_group(&mut self) {
4079 self.inner.pop_transparency_group();
4080 }
4081 }
4082
4083 #[test]
4084 fn the_last_glyph_record_is_empty_after_a_page_is_extracted() {
4085 let doc = PdfDocument::open(one_line()).expect("fixture opens");
4086 let text = doc.extract_text(0).expect("text");
4087 assert!(text.contains("abc"), "{text:?}");
4088 assert!(!last_glyph_is_held());
4089 }
4090
4091 #[test]
4092 fn the_last_glyph_record_is_empty_after_a_panic_inside_extraction() {
4093 let doc = PdfDocument::open(one_line()).expect("fixture opens");
4094 let page = doc.get_page(0).expect("page");
4095 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4096 let mut device = PanicsAtTheSecondGlyph {
4097 inner: TextExtractionDevice::new(),
4098 glyphs: 0,
4099 };
4100 let mut ctx =
4101 PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4102 interpret_page(page, &mut ctx, &mut device);
4103 }));
4104 assert!(outcome.is_err(), "the device panicked");
4105 assert!(!last_glyph_is_held());
4106 }
4107
4108 fn c_then_circle() -> Vec<u8> {
4112 let mut doc = LoDocument::with_version("1.7");
4113 let pages_id = doc.new_object_id();
4114 let helvetica = doc.add_object(dictionary! {
4115 "Type" => "Font",
4116 "Subtype" => "Type1",
4117 "BaseFont" => "Helvetica",
4118 });
4119 let circle = doc.add_object(dictionary! {
4120 "Type" => "Font",
4121 "Subtype" => "Type1",
4122 "BaseFont" => "CMSY10",
4123 "FirstChar" => 65_i64,
4124 "LastChar" => 65_i64,
4125 "Widths" => vec![Object::Integer(1000)],
4126 "Encoding" => dictionary! {
4127 "Type" => "Encoding",
4128 "Differences" => vec![Object::Integer(65), Object::Name(b"circlecopyrt".to_vec())],
4129 },
4130 });
4131 let content = b"BT /F1 10 Tf 1 0 0 1 102.8 700 Tm (c) Tj \
4132 /F2 10 Tf 1 0 0 1 100 700 Tm (A) Tj ET"
4133 .to_vec();
4134 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
4135 let page_id = doc.add_object(dictionary! {
4136 "Type" => "Page",
4137 "Parent" => pages_id,
4138 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4139 "Contents" => content_id,
4140 "Resources" => dictionary! {
4141 "Font" => dictionary! { "F1" => helvetica, "F2" => circle },
4142 },
4143 });
4144 doc.objects.insert(
4145 pages_id,
4146 Object::Dictionary(dictionary! {
4147 "Type" => "Pages",
4148 "Kids" => vec![Object::Reference(page_id)],
4149 "Count" => 1_i64,
4150 }),
4151 );
4152 let catalog_id = doc.add_object(dictionary! {
4153 "Type" => "Catalog",
4154 "Pages" => pages_id,
4155 });
4156 doc.trailer.set("Root", catalog_id);
4157 let mut bytes = Vec::new();
4158 doc.save_to(&mut bytes).expect("fixture saves");
4159 bytes
4160 }
4161
4162 #[derive(Default)]
4165 struct AsksAtTheSecondGlyph {
4166 places: Vec<Option<GlyphPlace>>,
4167 foreign: Option<GlyphPlace>,
4169 answered: Vec<(&'static str, bool)>,
4171 }
4172
4173 impl<'a> Device<'a> for AsksAtTheSecondGlyph {
4174 fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
4175 fn set_blend_mode(&mut self, _: BlendMode) {}
4176 fn draw_path(
4177 &mut self,
4178 _: &kurbo::BezPath,
4179 _: kurbo::Affine,
4180 _: &Paint<'a>,
4181 _: &PathDrawMode,
4182 ) {
4183 }
4184 fn push_clip_path(&mut self, _: &ClipPath) {}
4185 fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
4186 fn draw_glyph(
4187 &mut self,
4188 glyph: &Glyph<'a>,
4189 _: kurbo::Affine,
4190 _: kurbo::Affine,
4191 _: &Paint<'a>,
4192 _: &GlyphDrawMode,
4193 ) {
4194 let Glyph::Outline(outline) = glyph else {
4195 return;
4196 };
4197 if let [Some(before)] = self.places[..] {
4198 if let Some(foreign) = self.foreign {
4199 let read = outline.reread_previous(foreign).is_some();
4200 self.answered
4201 .push(("a place from another interpretation", read));
4202 }
4203 let own = outline.place().expect("drawn in an interpretation");
4204 let read = outline.reread_previous(own).is_some();
4205 self.answered.push(("its own place", read));
4206 let read = outline.reread_previous(before).is_some();
4208 self.answered.push(("the glyph directly before", read));
4209 }
4210 self.places.push(outline.place());
4211 }
4212 fn draw_image(&mut self, _: Image<'a, '_>, _: kurbo::Affine) {}
4213 fn pop_clip_path(&mut self) {}
4214 fn pop_transparency_group(&mut self) {}
4215 }
4216
4217 fn asked_on_a_new_thread(foreign: Option<GlyphPlace>) -> AsksAtTheSecondGlyph {
4220 std::thread::spawn(move || {
4221 let doc = PdfDocument::open(c_then_circle()).expect("fixture opens");
4222 let page = doc.get_page(0).expect("page");
4223 let mut device = AsksAtTheSecondGlyph {
4224 foreign,
4225 ..Default::default()
4226 };
4227 let mut ctx =
4228 PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4229 interpret_page(page, &mut ctx, &mut device);
4230 device
4231 })
4232 .join()
4233 .expect("the thread finishes")
4234 }
4235
4236 #[test]
4241 fn a_place_from_elsewhere_than_the_last_glyph_gives_no_reread() {
4242 let first = asked_on_a_new_thread(None);
4243 let foreign = first.places[0].expect("the c has a place");
4244 let second = asked_on_a_new_thread(Some(foreign));
4245 assert_eq!(
4246 second.answered,
4247 [
4248 ("a place from another interpretation", false),
4249 ("its own place", false),
4250 ("the glyph directly before", true),
4251 ]
4252 );
4253 }
4254}