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::render::{self, ColorMode, RenderConfig, RenderOptions, RenderedPage};
21use crate::text::{TextBlock, TextExtractionDevice};
22use crate::thumbnail::ThumbnailOptions;
23
24use pdf_forms::parse::parse_acroform;
25use pdf_forms::tree::{FieldType, FieldValue};
26use pdf_render::pdf_interpret::PageExt;
27use pdf_render::pdf_interpret::{
28 interpret_page, Cache, Context, InterpreterSettings, InterpreterWarning,
29};
30use pdf_render::pdf_syntax::object::dict::keys::{FIRST, NEXT, OUTLINES, TITLE};
31use pdf_render::pdf_syntax::object::{Dict, ObjectIdentifier};
32use pdf_render::pdf_syntax::page::Page;
33use pdf_render::pdf_syntax::{Pdf, PdfLoadLimits};
34#[cfg(feature = "parallel")]
35use rayon::prelude::*;
36use std::collections::BTreeSet;
37
38use kurbo::Rect;
39
40#[derive(Debug, Clone, Default)]
42pub struct DocumentInfo {
43 pub title: Option<String>,
45 pub author: Option<String>,
47 pub subject: Option<String>,
49 pub keywords: Option<String>,
51 pub creator: Option<String>,
53 pub producer: Option<String>,
55}
56
57#[derive(Debug, Clone)]
59pub struct BookmarkItem {
60 pub title: String,
62 pub page: Option<usize>,
64 pub children: Vec<BookmarkItem>,
66}
67
68pub struct PdfDocument {
70 pdf: Pdf,
71 settings: InterpreterSettings,
72 keep_ligatures: bool,
75}
76
77impl PdfDocument {
78 pub fn open(data: impl Into<pdf_render::pdf_syntax::PdfData>) -> Result<Self> {
80 let pdf = Pdf::new(data).map_err(|e| match e {
81 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
82 EngineError::Encrypted(format!("{d:?}"))
83 }
84 _ => EngineError::InvalidPdf(format!("{e:?}")),
85 })?;
86 let settings = InterpreterSettings {
87 shared_cache: Some(Cache::new()),
88 ..InterpreterSettings::default()
89 };
90 Ok(Self {
91 pdf,
92 settings,
93 keep_ligatures: false,
94 })
95 }
96
97 pub fn open_with_processing_limits(
99 data: impl Into<pdf_render::pdf_syntax::PdfData>,
100 limits: ProcessingLimits,
101 ) -> Result<Self> {
102 let syntax_limits = PdfLoadLimits::new()
103 .max_object_depth(limits.max_object_depth)
104 .max_image_pixels(limits.max_image_pixels)
105 .max_stream_bytes(limits.max_stream_bytes);
106 let pdf = Pdf::new_with_limits(data, syntax_limits).map_err(|e| match e {
107 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
108 EngineError::Encrypted(format!("{d:?}"))
109 }
110 _ => EngineError::InvalidPdf(format!("{e:?}")),
111 })?;
112 let settings = InterpreterSettings {
113 max_operator_count: Some(limits.max_operator_count),
114 shared_cache: Some(Cache::new()),
115 ..InterpreterSettings::default()
116 };
117 Ok(Self {
118 pdf,
119 settings,
120 keep_ligatures: false,
121 })
122 }
123
124 pub fn open_with_password(
126 data: impl Into<pdf_render::pdf_syntax::PdfData>,
127 password: &str,
128 ) -> Result<Self> {
129 let pdf = Pdf::new_with_password(data, password).map_err(|e| match e {
130 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
131 EngineError::Encrypted(format!("{d:?}"))
132 }
133 _ => EngineError::InvalidPdf(format!("{e:?}")),
134 })?;
135 let settings = InterpreterSettings {
136 shared_cache: Some(Cache::new()),
137 ..InterpreterSettings::default()
138 };
139 Ok(Self {
140 pdf,
141 settings,
142 keep_ligatures: false,
143 })
144 }
145
146 pub fn open_with_password_and_processing_limits(
148 data: impl Into<pdf_render::pdf_syntax::PdfData>,
149 password: &str,
150 limits: ProcessingLimits,
151 ) -> Result<Self> {
152 let syntax_limits = PdfLoadLimits::new()
153 .max_object_depth(limits.max_object_depth)
154 .max_image_pixels(limits.max_image_pixels)
155 .max_stream_bytes(limits.max_stream_bytes);
156 let pdf = Pdf::new_with_password_and_limits(data, password, syntax_limits).map_err(
157 |e| match e {
158 pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
159 EngineError::Encrypted(format!("{d:?}"))
160 }
161 _ => EngineError::InvalidPdf(format!("{e:?}")),
162 },
163 )?;
164 let settings = InterpreterSettings {
165 max_operator_count: Some(limits.max_operator_count),
166 shared_cache: Some(Cache::new()),
167 ..InterpreterSettings::default()
168 };
169 Ok(Self {
170 pdf,
171 settings,
172 keep_ligatures: false,
173 })
174 }
175
176 pub fn pdf(&self) -> &Pdf {
178 &self.pdf
179 }
180
181 pub fn load_recovery(&self) -> pdf_render::pdf_syntax::LoadRecovery {
184 self.pdf.load_recovery()
185 }
186
187 pub fn set_settings(&mut self, settings: InterpreterSettings) {
189 self.settings = settings;
190 }
191
192 pub fn set_keep_ligatures(&mut self, keep: bool) {
206 self.keep_ligatures = keep;
207 }
208
209 pub fn set_warning_sink(&mut self, sink: pdf_render::pdf_interpret::WarningSinkFn) {
216 self.settings.warning_sink = sink;
217 }
218
219 pub fn page_count(&self) -> usize {
221 self.pdf.pages().len()
222 }
223
224 pub fn page_geometry(&self, index: usize) -> Result<PageGeometry> {
226 let page = self.get_page(index)?;
227 Ok(geometry::extract_geometry(page))
228 }
229
230 pub fn render_page(&self, index: usize, options: &RenderOptions) -> Result<RenderedPage> {
238 #[cfg(feature = "xfa")]
239 if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
240 return flat_doc.render_page(index, options);
241 }
242 let page = self.get_page(index)?;
243 let (w, h) = page.render_dimensions();
247 if w <= 0.0 || h <= 0.0 {
248 return Err(EngineError::InvalidPageGeometry {
249 width: w,
250 height: h,
251 reason: "page has zero or negative dimensions".into(),
252 });
253 }
254 const MIN_PAGE_PT: f32 = 1.0;
257 if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
258 return Err(EngineError::InvalidPageGeometry {
259 width: w,
260 height: h,
261 reason: "page too small to render (< 1pt)".into(),
262 });
263 }
264 let (settings, slot) = Self::with_limit_collector(&self.settings);
265 let rendered = render::render_page(page, options, &settings);
266 Self::check_limit_slot(&slot)?;
267 Ok(rendered)
268 }
269
270 pub fn render_page_with_config(
275 &self,
276 index: usize,
277 config: &RenderConfig,
278 ) -> Result<RenderedPage> {
279 #[cfg(feature = "xfa")]
280 if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
281 return flat_doc.render_page_with_config(index, config);
282 }
283 let page = self.get_page(index)?;
284 let (w, h) = page.render_dimensions();
285 if w <= 0.0 || h <= 0.0 {
286 return Err(EngineError::InvalidPageGeometry {
287 width: w,
288 height: h,
289 reason: "page has zero or negative dimensions".into(),
290 });
291 }
292 const MIN_PAGE_PT: f32 = 1.0;
293 if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
294 return Err(EngineError::InvalidPageGeometry {
295 width: w,
296 height: h,
297 reason: "page too small to render (< 1pt)".into(),
298 });
299 }
300 let (settings, slot) = Self::with_limit_collector(&self.settings);
301 let rendered = render::render_page_with_config(page, config, &settings);
302 Self::check_limit_slot(&slot)?;
303 Ok(rendered)
304 }
305
306 pub fn render_page_cmyk(&self, index: usize, dpi: u32) -> Result<RenderedPage> {
308 self.render_page_with_config(
309 index,
310 &RenderConfig {
311 color_mode: ColorMode::PreserveCmyk,
312 dpi,
313 },
314 )
315 }
316
317 pub fn render_all(&self, options: &RenderOptions) -> Vec<RenderedPage> {
319 let pages = self.pdf.pages();
320 #[cfg(feature = "parallel")]
321 return (0..pages.len())
322 .into_par_iter()
323 .map(|i| render::render_page(&pages[i], options, &self.settings))
324 .collect();
325 #[cfg(not(feature = "parallel"))]
326 (0..pages.len())
327 .map(|i| render::render_page(&pages[i], options, &self.settings))
328 .collect()
329 }
330
331 pub fn render_all_with_config(&self, config: &RenderConfig) -> Vec<RenderedPage> {
333 let pages = self.pdf.pages();
334 #[cfg(feature = "parallel")]
335 return (0..pages.len())
336 .into_par_iter()
337 .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
338 .collect();
339 #[cfg(not(feature = "parallel"))]
340 (0..pages.len())
341 .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
342 .collect()
343 }
344
345 pub fn thumbnail(&self, index: usize, options: &ThumbnailOptions) -> Result<RenderedPage> {
347 let page = self.get_page(index)?;
348 Ok(render::render_thumbnail(
349 page,
350 options.max_dimension,
351 &self.settings,
352 ))
353 }
354
355 pub fn thumbnails_all(&self, options: &ThumbnailOptions) -> Vec<RenderedPage> {
357 let pages = self.pdf.pages();
358 #[cfg(feature = "parallel")]
359 return (0..pages.len())
360 .into_par_iter()
361 .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
362 .collect();
363 #[cfg(not(feature = "parallel"))]
364 (0..pages.len())
365 .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
366 .collect()
367 }
368
369 pub fn extract_text(&self, index: usize) -> Result<String> {
371 let page = self.get_page(index)?;
372 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
373 let mut device = TextExtractionDevice::new().keep_ligatures(self.keep_ligatures);
374 let mut ctx = Self::create_context_with_settings(page, settings);
375 interpret_page(page, &mut ctx, &mut device);
376 Self::check_limit_slot(&slot)?;
377 Ok(device.into_text())
378 }
379
380 #[doc(hidden)]
382 pub fn extract_text_pages_reusing_settings<I>(&self, indices: I) -> Result<Vec<String>>
383 where
384 I: IntoIterator<Item = usize>,
385 {
386 let pages = self.pdf.pages();
387 let mut settings = self.text_extraction_settings();
388 let indices = indices.into_iter();
389 let (lower_bound, upper_bound) = indices.size_hint();
390 let mut texts = Vec::with_capacity(upper_bound.unwrap_or(lower_bound));
391
392 for index in indices {
393 let page = pages.get(index).ok_or(EngineError::PageOutOfRange {
394 index,
395 count: pages.len(),
396 })?;
397 let (text, next_settings) =
398 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
399 settings = next_settings;
400 texts.push(text);
401 }
402
403 Ok(texts)
404 }
405
406 pub fn extract_text_blocks(&self, index: usize) -> Result<Vec<TextBlock>> {
408 let page = self.get_page(index)?;
409 let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
410 let mut device = TextExtractionDevice::new();
411 let mut ctx = Self::create_context_with_settings(page, settings);
412 interpret_page(page, &mut ctx, &mut device);
413 Self::check_limit_slot(&slot)?;
414 Ok(device.into_blocks())
415 }
416
417 pub fn extract_all_text_blocks(&self) -> Vec<Vec<TextBlock>> {
419 let pages = self.pdf.pages();
420 let mut settings = self.text_extraction_settings();
421 let mut blocks = Vec::with_capacity(pages.len());
422
423 for page in pages.iter() {
424 let (page_blocks, next_settings) =
425 Self::extract_text_blocks_with_settings(page, settings);
426 settings = next_settings;
427 blocks.push(page_blocks);
428 }
429
430 blocks
431 }
432
433 pub fn extract_acroform_text(&self) -> String {
439 let Some(tree) = parse_acroform(&self.pdf) else {
440 return String::new();
441 };
442 let mut parts: Vec<String> = Vec::new();
443 for id in tree.all_ids() {
444 let node = tree.get(id);
445 if node.children.is_empty() {
446 let value_str = match &node.value {
448 Some(FieldValue::Text(s)) if !s.is_empty() => Some(s.clone()),
449 Some(FieldValue::StringArray(arr)) => {
450 let joined = arr
451 .iter()
452 .filter(|s| !s.is_empty())
453 .cloned()
454 .collect::<Vec<_>>()
455 .join(", ");
456 if joined.is_empty() {
457 None
458 } else {
459 Some(joined)
460 }
461 }
462 _ => None,
463 };
464 let button_caption =
465 value_str.is_none() && tree.effective_field_type(id) == Some(FieldType::Button);
466 let extracted = value_str.or_else(|| {
467 button_caption.then(|| {
468 node.mk
469 .as_ref()
470 .and_then(|mk| mk.caption.as_ref())
471 .filter(|caption| !caption.is_empty())
472 .cloned()
473 })?
474 });
475 if let Some(s) = extracted {
476 parts.push(s);
477 }
478 }
479 }
480 let joined = parts.join("\n");
481 if self.keep_ligatures {
482 return joined;
483 }
484 crate::ligatures::fold(&joined).into_owned()
490 }
491
492 pub fn extract_all_text(&self) -> String {
495 let pages = self.pdf.pages();
496 let mut settings = self.text_extraction_settings();
497 let mut page_texts = Vec::with_capacity(pages.len());
498 for page in pages.iter() {
499 let (page_text, next_settings) =
500 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
501 settings = next_settings;
502 page_texts.push(page_text);
503 }
504
505 let acroform = self.extract_acroform_text();
510 if !acroform.is_empty() {
511 match page_texts.last_mut() {
512 Some(last) => {
513 if !last.is_empty() && !last.ends_with('\n') {
514 last.push('\n');
515 }
516 last.push_str(&acroform);
517 }
518 None => return acroform,
519 }
520 }
521
522 join_page_texts(page_texts.iter().map(String::as_str))
523 }
524
525 pub fn search_text(&self, query: &str) -> Vec<usize> {
531 let pages = self.pdf.pages();
532 let query_lower = query.to_lowercase();
533 let query_lower = if self.keep_ligatures {
537 query_lower
538 } else {
539 crate::ligatures::fold(&query_lower).into_owned()
540 };
541 #[cfg(feature = "parallel")]
542 let page_contains = |i: usize| -> Option<usize> {
543 let page = &pages[i];
544 let (text, _) = Self::extract_text_with_settings(
545 page,
546 self.text_extraction_settings(),
547 self.keep_ligatures,
548 );
549 if text.to_lowercase().contains(&query_lower) {
550 Some(i)
551 } else {
552 None
553 }
554 };
555 #[cfg(feature = "parallel")]
556 return (0..pages.len())
557 .into_par_iter()
558 .filter_map(page_contains)
559 .collect();
560 #[cfg(not(feature = "parallel"))]
561 {
562 let mut settings = self.text_extraction_settings();
563 let mut hits = Vec::new();
564 for (i, page) in pages.iter().enumerate() {
565 let (text, next_settings) =
566 Self::extract_text_with_settings(page, settings, self.keep_ligatures);
567 settings = next_settings;
568 if text.to_lowercase().contains(&query_lower) {
569 hits.push(i);
570 }
571 }
572 hits
573 }
574 }
575
576 pub fn info(&self) -> DocumentInfo {
578 let meta = self.pdf.metadata();
579 DocumentInfo {
580 title: meta.title.as_ref().map(|b| bytes_to_string(b)),
581 author: meta.author.as_ref().map(|b| bytes_to_string(b)),
582 subject: meta.subject.as_ref().map(|b| bytes_to_string(b)),
583 keywords: meta.keywords.as_ref().map(|b| bytes_to_string(b)),
584 creator: meta.creator.as_ref().map(|b| bytes_to_string(b)),
585 producer: meta.producer.as_ref().map(|b| bytes_to_string(b)),
586 }
587 }
588
589 pub fn bookmarks(&self) -> Vec<BookmarkItem> {
591 let xref = self.pdf.xref();
592 let root_id = xref.root_id();
593 let catalog: Dict<'_> = match xref.get(root_id) {
594 Some(d) => d,
595 None => return Vec::new(),
596 };
597
598 let outlines: Dict<'_> = match catalog.get(OUTLINES) {
599 Some(d) => d,
600 None => return Vec::new(),
601 };
602
603 let first: Dict<'_> = match outlines.get(FIRST) {
604 Some(d) => d,
605 None => return Vec::new(),
606 };
607
608 let mut visited = BTreeSet::new();
612 parse_outline_items(&first, 0, &mut visited)
613 }
614
615 pub fn ocr_page(
634 &self,
635 index: usize,
636 backend: &dyn crate::ocr::OcrBackend,
637 dpi: f64,
638 ) -> crate::error::Result<crate::ocr::OcrResult> {
639 let opts = crate::render::RenderOptions {
640 dpi,
641 ..Default::default()
642 };
643 let rendered = self.render_page(index, &opts)?;
644
645 let mut rgb = Vec::with_capacity((rendered.width * rendered.height * 3) as usize);
647 for chunk in rendered.pixels.chunks(4) {
648 rgb.push(chunk[0]);
649 rgb.push(chunk[1]);
650 rgb.push(chunk[2]);
651 }
652
653 backend
654 .recognize(&rgb, rendered.width, rendered.height)
655 .map_err(|e| crate::error::EngineError::RenderError(e.to_string()))
656 }
657
658 fn with_limit_collector(settings: &InterpreterSettings) -> (InterpreterSettings, LimitSlot) {
665 let slot: LimitSlot = Arc::new(Mutex::new(None));
666 let slot_clone = Arc::clone(&slot);
667 let prev_sink = settings.warning_sink.clone();
668 let mut new_settings = settings.clone();
669 new_settings.warning_sink = Arc::new(move |w: InterpreterWarning| {
670 if let InterpreterWarning::StreamTooLarge { observed, limit } = w {
671 let mut guard = slot_clone.lock().unwrap_or_else(|e| e.into_inner());
672 if guard.is_none() {
673 *guard = Some((observed, limit));
674 }
675 }
676 prev_sink(w);
677 });
678 (new_settings, slot)
679 }
680
681 fn check_limit_slot(slot: &LimitSlot) -> Result<()> {
686 if let Some((observed, limit)) = *slot.lock().unwrap_or_else(|e| e.into_inner()) {
687 return Err(EngineError::LimitExceeded(LimitError::StreamTooLarge {
688 actual_bytes: observed,
689 limit_bytes: limit,
690 }));
691 }
692 Ok(())
693 }
694
695 fn get_page(&self, index: usize) -> Result<&Page<'_>> {
696 let pages = self.pdf.pages();
697 if index >= pages.len() {
698 return Err(EngineError::PageOutOfRange {
699 index,
700 count: pages.len(),
701 });
702 }
703 Ok(&pages[index])
704 }
705
706 fn text_extraction_settings(&self) -> InterpreterSettings {
707 let mut settings = self.settings.clone();
708 settings.skip_signature_widgets = false;
711 settings
712 }
713
714 fn create_context_with_settings<'a>(
715 page: &Page<'a>,
716 settings: InterpreterSettings,
717 ) -> Context<'a> {
718 let (w, h) = page.render_dimensions();
719 Context::new(
720 page.initial_transform(false),
721 Rect::new(0.0, 0.0, w as f64, h as f64),
722 page.xref(),
723 settings,
724 )
725 }
726
727 fn extract_text_with_settings<'a>(
728 page: &Page<'a>,
729 settings: InterpreterSettings,
730 keep_ligatures: bool,
731 ) -> (String, InterpreterSettings) {
732 let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
733 let mut ctx = Self::create_context_with_settings(page, settings);
734 interpret_page(page, &mut ctx, &mut device);
735 let settings = ctx.into_settings();
736 (device.into_text(), settings)
737 }
738
739 fn extract_text_blocks_with_settings<'a>(
740 page: &Page<'a>,
741 settings: InterpreterSettings,
742 ) -> (Vec<TextBlock>, InterpreterSettings) {
743 let mut device = TextExtractionDevice::new();
744 let mut ctx = Self::create_context_with_settings(page, settings);
745 interpret_page(page, &mut ctx, &mut device);
746 let settings = ctx.into_settings();
747 (device.into_blocks(), settings)
748 }
749
750 #[cfg(feature = "xfa")]
751 fn open_flattened_xfa_for_render(&self) -> Option<Self> {
752 if !crate::xfa::has_xfa(self) {
753 return None;
754 }
755
756 let flat_bytes = crate::xfa::flatten(self).ok()?;
757 let mut flat_doc = Self::open(flat_bytes).ok()?;
758 flat_doc.settings = self.settings.clone();
759 Some(flat_doc)
760 }
761}
762
763pub fn join_page_texts<I>(page_texts: I) -> String
775where
776 I: IntoIterator,
777 I::Item: AsRef<str>,
778{
779 let mut text = String::new();
780
781 for page_text in page_texts {
782 text.push_str(page_text.as_ref());
783 while !text.is_empty() && !text.ends_with("\n\n") {
784 text.push('\n');
785 }
786 text.push('\u{000C}');
787 }
788
789 text
790}
791
792#[cfg(test)]
793mod extract_all_text_tests {
794 use super::join_page_texts;
795
796 #[test]
797 fn terminates_every_page_like_pdftotext() {
798 assert_eq!(
799 join_page_texts(["Page 1", "Page 2"]),
800 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
801 );
802 }
803
804 #[test]
805 fn a_page_carries_a_separator_even_when_it_has_no_text() {
806 assert_eq!(
808 join_page_texts(["", "Page 2"]),
809 "\u{000C}Page 2\n\n\u{000C}"
810 );
811 }
812
813 #[test]
814 fn reuses_existing_blank_line_before_form_feed() {
815 assert_eq!(
816 join_page_texts(["Page 1\n\n", "Page 2"]),
817 "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
818 );
819 }
820
821 #[test]
822 fn as_many_separators_as_pages() {
823 let text = join_page_texts(["a", "b", "c"]);
824 assert_eq!(text.matches('\u{000C}').count(), 3);
825 assert_eq!(text.split('\u{000C}').count(), 4);
827 }
828}
829
830const MAX_OUTLINE_DEPTH: usize = 100;
833
834fn parse_outline_items(
840 item_dict: &Dict<'_>,
841 depth: usize,
842 visited: &mut BTreeSet<ObjectIdentifier>,
843) -> Vec<BookmarkItem> {
844 let mut items = Vec::new();
845 if depth >= MAX_OUTLINE_DEPTH {
846 return items;
847 }
848 let mut current: Option<Dict<'_>> = Some(item_dict.clone());
849
850 while let Some(dict) = current {
851 if let Some(id) = dict.obj_id() {
854 if !visited.insert(id) {
855 break;
856 }
857 }
858
859 let title = dict
860 .get::<pdf_render::pdf_syntax::object::String>(TITLE)
861 .map(|s| bytes_to_string(s.as_bytes()))
862 .unwrap_or_default();
863
864 let children = match dict.get::<Dict<'_>>(FIRST) {
865 Some(child_dict) => parse_outline_items(&child_dict, depth + 1, visited),
866 None => Vec::new(),
867 };
868
869 items.push(BookmarkItem {
870 title,
871 page: None, children,
873 });
874
875 current = dict.get::<Dict<'_>>(NEXT);
876 }
877
878 items
879}
880
881fn bytes_to_string(bytes: &[u8]) -> String {
883 if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
885 let chars: Vec<u16> = bytes[2..]
886 .chunks(2)
887 .filter_map(|c| {
888 if c.len() == 2 {
889 Some(u16::from_be_bytes([c[0], c[1]]))
890 } else {
891 None
892 }
893 })
894 .collect();
895 return String::from_utf16_lossy(&chars);
896 }
897
898 match std::str::from_utf8(bytes) {
900 Ok(s) => s.to_string(),
901 Err(_) => bytes.iter().map(|&b| b as char).collect(),
902 }
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908 use crate::render::{ColorMode, PixelFormat, RenderConfig, RenderOptions};
909 use lopdf::{Document as LoDocument, Object};
910 use std::path::PathBuf;
911
912 fn corpus_path(name: &str) -> PathBuf {
913 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
914 .join("../../corpus")
915 .join(name)
916 }
917
918 #[test]
921 fn cyclic_outline_terminates_and_is_bounded() {
922 fn cyclic_outline_pdf() -> Vec<u8> {
923 let objs: [&[u8]; 6] = [
924 b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
925 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
926 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
927 b"<< /Type /Outlines /First 5 0 R >>",
928 b"<< /Title (A) /Next 6 0 R >>",
929 b"<< /Title (B) /Next 5 0 R >>", ];
931 let mut buf = Vec::new();
932 let mut offsets = [0usize; 7];
933 buf.extend_from_slice(b"%PDF-1.7\n");
934 for (i, body) in objs.iter().enumerate() {
935 offsets[i + 1] = buf.len();
936 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
937 buf.extend_from_slice(body);
938 buf.extend_from_slice(b"\nendobj\n");
939 }
940 let xref_off = buf.len();
941 buf.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
942 for o in &offsets[1..7] {
943 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
944 }
945 buf.extend_from_slice(
946 format!("trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
947 .as_bytes(),
948 );
949 buf
950 }
951
952 fn count(b: &BookmarkItem) -> usize {
953 1 + b.children.iter().map(count).sum::<usize>()
954 }
955
956 let doc = PdfDocument::open(cyclic_outline_pdf()).expect("open cyclic-outline PDF");
957 let bookmarks = doc.bookmarks();
958 let total: usize = bookmarks.iter().map(count).sum();
959 assert!(
960 total <= 2,
961 "cyclic /Next outline must not loop forever; got {total} items"
962 );
963 }
964
965 fn repo_fixture(name: &str) -> Option<PathBuf> {
973 let dir = std::env::var("PDFLUENT_TEST_FIXTURES").ok().map_or_else(
974 || {
975 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
976 .join("..")
977 .join("..")
978 .join("tests")
979 },
980 PathBuf::from,
981 );
982 let path = dir.join(name);
983 path.exists().then_some(path)
984 }
985
986 #[test]
987 fn shared_image_cache_is_render_neutral() {
988 let Some(path) = repo_fixture("corpus-mini/scanned.pdf") else {
989 eprintln!("skipping: repository fixture tree not present");
990 return;
991 };
992 let data = std::fs::read(&path).expect("read scanned.pdf fixture");
993 let cfg = RenderConfig::default();
994
995 let doc_on = PdfDocument::open(data.clone()).expect("open cache-on");
998 let miss = doc_on
999 .render_page_with_config(0, &cfg)
1000 .expect("cold render (cache miss)");
1001 let hit = doc_on
1002 .render_page_with_config(0, &cfg)
1003 .expect("warm render (cache hit)");
1004
1005 let mut doc_off = PdfDocument::open(data).expect("open cache-off");
1008 doc_off.set_settings(InterpreterSettings::default());
1009 let uncached = doc_off
1010 .render_page_with_config(0, &cfg)
1011 .expect("render with cache disabled");
1012
1013 assert_eq!(
1014 (miss.width, miss.height),
1015 (uncached.width, uncached.height),
1016 "render dimensions must match"
1017 );
1018 assert_eq!(
1019 miss.pixels, hit.pixels,
1020 "a cache hit must return exactly the freshly-decoded render"
1021 );
1022 assert_eq!(
1023 miss.pixels, uncached.pixels,
1024 "cache-enabled render must equal cache-disabled render"
1025 );
1026 }
1027
1028 fn normalize_text(text: &str) -> String {
1029 text.split_whitespace().collect::<Vec<_>>().join(" ")
1030 }
1031
1032 fn strip_type0_tounicode(data: &[u8]) -> (Vec<u8>, usize) {
1033 fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<Vec<u8>> {
1034 match dict.get(key).ok()? {
1035 Object::Name(name) => Some(name.clone()),
1036 _ => None,
1037 }
1038 }
1039
1040 fn descendant_is_cidfont_type2(doc: &LoDocument, type0: &lopdf::Dictionary) -> bool {
1041 let Some(Object::Array(descendants)) = type0.get(b"DescendantFonts").ok() else {
1042 return false;
1043 };
1044 let Some(Object::Reference(desc_id)) = descendants.first() else {
1045 return false;
1046 };
1047 let Ok(Object::Dictionary(descendant)) = doc.get_object(*desc_id) else {
1048 return false;
1049 };
1050 matches!(
1051 descendant.get(b"Subtype").ok(),
1052 Some(Object::Name(name)) if name.as_slice() == b"CIDFontType2"
1053 )
1054 }
1055
1056 let mut doc = LoDocument::load_mem(data).expect("load stripped-to-unicode fixture");
1057 let ids: Vec<_> = doc.objects.keys().copied().collect();
1058 let mut removed = 0usize;
1059
1060 for id in ids {
1061 let Some(Object::Dictionary(dict)) = doc.objects.get(&id) else {
1062 continue;
1063 };
1064 if !matches!(
1065 dict.get(b"Subtype").ok(),
1066 Some(Object::Name(name)) if name.as_slice() == b"Type0"
1067 ) {
1068 continue;
1069 }
1070 if !matches!(
1071 get_name(dict, b"Encoding").as_deref(),
1072 Some(b"Identity-H") | Some(b"Identity-V")
1073 ) {
1074 continue;
1075 }
1076 if !descendant_is_cidfont_type2(&doc, dict) {
1077 continue;
1078 }
1079
1080 if let Some(Object::Dictionary(type0)) = doc.objects.get_mut(&id) {
1081 if type0.has(b"ToUnicode") {
1082 type0.remove(b"ToUnicode");
1083 removed += 1;
1084 }
1085 }
1086 }
1087
1088 let mut out = Vec::new();
1089 doc.save_to(&mut out)
1090 .expect("save stripped-to-unicode fixture");
1091 (out, removed)
1092 }
1093
1094 fn solid_fill_pdf_bytes(color_operator: &str) -> Vec<u8> {
1095 use lopdf::{dictionary, Document, Object, Stream};
1096
1097 let mut doc = Document::with_version("1.4");
1098
1099 let pages_id = doc.new_object_id();
1100 let page_id = doc.new_object_id();
1101 let content = format!("{color_operator}\n0 0 72 72 re f\n");
1102 let content_id = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
1103
1104 doc.objects.insert(
1105 page_id,
1106 Object::Dictionary(dictionary! {
1107 "Type" => Object::Name(b"Page".to_vec()),
1108 "Parent" => Object::Reference(pages_id),
1109 "MediaBox" => Object::Array(vec![
1110 Object::Integer(0),
1111 Object::Integer(0),
1112 Object::Integer(72),
1113 Object::Integer(72),
1114 ]),
1115 "Contents" => Object::Reference(content_id),
1116 }),
1117 );
1118
1119 doc.objects.insert(
1120 pages_id,
1121 Object::Dictionary(dictionary! {
1122 "Type" => Object::Name(b"Pages".to_vec()),
1123 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1124 "Count" => Object::Integer(1),
1125 }),
1126 );
1127
1128 let catalog_id = doc.new_object_id();
1129 doc.objects.insert(
1130 catalog_id,
1131 Object::Dictionary(dictionary! {
1132 "Type" => Object::Name(b"Catalog".to_vec()),
1133 "Pages" => Object::Reference(pages_id),
1134 }),
1135 );
1136
1137 doc.trailer.set("Root", Object::Reference(catalog_id));
1138
1139 let mut bytes = Vec::new();
1140 doc.save_to(&mut bytes).expect("save solid fill fixture");
1141 bytes
1142 }
1143
1144 fn mixed_rgb_cmyk_pdf_bytes() -> Vec<u8> {
1145 use lopdf::{dictionary, Document, Object, Stream};
1146
1147 let mut doc = Document::with_version("1.4");
1148 let pages_id = doc.new_object_id();
1149 let page_id = doc.new_object_id();
1150 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();
1151 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
1152
1153 doc.objects.insert(
1154 page_id,
1155 Object::Dictionary(dictionary! {
1156 "Type" => "Page",
1157 "Parent" => Object::Reference(pages_id),
1158 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
1159 "Contents" => Object::Reference(content_id),
1160 }),
1161 );
1162 doc.objects.insert(
1163 pages_id,
1164 Object::Dictionary(dictionary! {
1165 "Type" => "Pages",
1166 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1167 "Count" => Object::Integer(1),
1168 }),
1169 );
1170 let catalog_id = doc.new_object_id();
1171 doc.objects.insert(
1172 catalog_id,
1173 Object::Dictionary(dictionary! {
1174 "Type" => "Catalog",
1175 "Pages" => Object::Reference(pages_id),
1176 }),
1177 );
1178 doc.trailer.set("Root", Object::Reference(catalog_id));
1179
1180 let mut bytes = Vec::new();
1181 doc.save_to(&mut bytes)
1182 .expect("save mixed rgb/cmyk fixture");
1183 bytes
1184 }
1185
1186 fn transparent_cmyk_pdf_bytes() -> Vec<u8> {
1187 use lopdf::{dictionary, Document, Object, Stream};
1188
1189 let mut doc = Document::with_version("1.4");
1190 let pages_id = doc.new_object_id();
1191 let page_id = doc.new_object_id();
1192 let gs_id = doc.add_object(Object::Dictionary(dictionary! {
1193 "Type" => "ExtGState",
1194 "ca" => Object::Real(0.5),
1195 }));
1196 let content = b"/GS1 gs\n1 0 0 0 k\n0 0 72 72 re f\n".to_vec();
1197 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
1198
1199 doc.objects.insert(
1200 page_id,
1201 Object::Dictionary(dictionary! {
1202 "Type" => "Page",
1203 "Parent" => Object::Reference(pages_id),
1204 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
1205 "Resources" => dictionary! {
1206 "ExtGState" => dictionary! {
1207 "GS1" => Object::Reference(gs_id),
1208 },
1209 },
1210 "Contents" => Object::Reference(content_id),
1211 }),
1212 );
1213 doc.objects.insert(
1214 pages_id,
1215 Object::Dictionary(dictionary! {
1216 "Type" => "Pages",
1217 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1218 "Count" => Object::Integer(1),
1219 }),
1220 );
1221 let catalog_id = doc.new_object_id();
1222 doc.objects.insert(
1223 catalog_id,
1224 Object::Dictionary(dictionary! {
1225 "Type" => "Catalog",
1226 "Pages" => Object::Reference(pages_id),
1227 }),
1228 );
1229 doc.trailer.set("Root", Object::Reference(catalog_id));
1230
1231 let mut bytes = Vec::new();
1232 doc.save_to(&mut bytes)
1233 .expect("save transparent cmyk fixture");
1234 bytes
1235 }
1236
1237 fn cmyk_image_pdf_bytes() -> Vec<u8> {
1238 use lopdf::{dictionary, Document, Object, Stream};
1239
1240 let mut doc = Document::with_version("1.4");
1241 let pages_id = doc.new_object_id();
1242 let page_id = doc.new_object_id();
1243 let image_id = doc.add_object(Stream::new(
1244 dictionary! {
1245 "Type" => "XObject",
1246 "Subtype" => "Image",
1247 "Width" => Object::Integer(2),
1248 "Height" => Object::Integer(1),
1249 "BitsPerComponent" => Object::Integer(8),
1250 "ColorSpace" => "DeviceCMYK",
1251 },
1252 vec![255, 0, 0, 0, 0, 255, 0, 0],
1253 ));
1254 let content = b"q\n2 0 0 1 0 0 cm\n/Im1 Do\nQ\n".to_vec();
1255 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
1256
1257 doc.objects.insert(
1258 page_id,
1259 Object::Dictionary(dictionary! {
1260 "Type" => "Page",
1261 "Parent" => Object::Reference(pages_id),
1262 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 2.into(), 1.into()]),
1263 "Resources" => dictionary! {
1264 "XObject" => dictionary! {
1265 "Im1" => Object::Reference(image_id),
1266 },
1267 },
1268 "Contents" => Object::Reference(content_id),
1269 }),
1270 );
1271 doc.objects.insert(
1272 pages_id,
1273 Object::Dictionary(dictionary! {
1274 "Type" => "Pages",
1275 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1276 "Count" => Object::Integer(1),
1277 }),
1278 );
1279 let catalog_id = doc.new_object_id();
1280 doc.objects.insert(
1281 catalog_id,
1282 Object::Dictionary(dictionary! {
1283 "Type" => "Catalog",
1284 "Pages" => Object::Reference(pages_id),
1285 }),
1286 );
1287 doc.trailer.set("Root", Object::Reference(catalog_id));
1288
1289 let mut bytes = Vec::new();
1290 doc.save_to(&mut bytes).expect("save cmyk image fixture");
1291 bytes
1292 }
1293
1294 fn pixel_at(rendered: &RenderedPage, x: u32, y: u32) -> [u8; 4] {
1295 let idx = ((y * rendered.width + x) * 4) as usize;
1296 [
1297 rendered.pixels[idx],
1298 rendered.pixels[idx + 1],
1299 rendered.pixels[idx + 2],
1300 rendered.pixels[idx + 3],
1301 ]
1302 }
1303
1304 fn non_embedded_truetype_pdf_bytes(
1309 base_font: &[u8],
1310 encoding: &[u8],
1311 text_bytes: &[u8],
1312 ) -> Vec<u8> {
1313 use lopdf::{dictionary, Document, Object, Stream};
1314
1315 let mut doc = Document::with_version("1.4");
1316
1317 let font_id = doc.add_object(Object::Dictionary(dictionary! {
1318 "Type" => "Font",
1319 "Subtype" => "TrueType",
1320 "Name" => Object::Name(b"F0".to_vec()),
1321 "BaseFont" => Object::Name(base_font.to_vec()),
1322 "Encoding" => Object::Name(encoding.to_vec()),
1323 }));
1324
1325 let resources_id = doc.add_object(Object::Dictionary(dictionary! {
1326 "Font" => dictionary! { "F0" => Object::Reference(font_id) },
1327 }));
1328
1329 let mut content = Vec::new();
1330 content.extend_from_slice(b"BT\n/F0 12 Tf\n100 700 Td\n(");
1331 for &b in text_bytes {
1332 match b {
1333 b'(' | b')' | b'\\' => {
1334 content.push(b'\\');
1335 content.push(b);
1336 }
1337 _ => content.push(b),
1338 }
1339 }
1340 content.extend_from_slice(b") Tj\nET\n");
1341 let content_id = doc.add_object(Stream::new(dictionary! {}, content));
1342
1343 let pages_id = doc.new_object_id();
1344 let page_id = doc.add_object(Object::Dictionary(dictionary! {
1345 "Type" => "Page",
1346 "Parent" => Object::Reference(pages_id),
1347 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
1348 "Resources" => Object::Reference(resources_id),
1349 "Contents" => Object::Reference(content_id),
1350 }));
1351 doc.objects.insert(
1352 pages_id,
1353 Object::Dictionary(dictionary! {
1354 "Type" => "Pages",
1355 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1356 "Count" => Object::Integer(1),
1357 }),
1358 );
1359 let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
1360 "Type" => "Catalog",
1361 "Pages" => Object::Reference(pages_id),
1362 }));
1363 doc.trailer.set("Root", Object::Reference(catalog_id));
1364
1365 let mut bytes = Vec::new();
1366 doc.save_to(&mut bytes).expect("save non-embedded fixture");
1367 bytes
1368 }
1369
1370 fn push_button_caption_pdf_bytes(caption: &[u8]) -> Vec<u8> {
1373 use lopdf::{dictionary, Document, Object, Stream, StringFormat};
1374
1375 let mut doc = Document::with_version("1.4");
1376
1377 let catalog_id = doc.new_object_id();
1378 let pages_id = doc.new_object_id();
1379 let page_id = doc.new_object_id();
1380 let acroform_id = doc.new_object_id();
1381 let content_id = doc.new_object_id();
1382 let widget_id = doc.new_object_id();
1383
1384 doc.objects.insert(
1385 content_id,
1386 Object::Stream(Stream::new(dictionary! {}, Vec::new())),
1387 );
1388 doc.objects.insert(
1389 widget_id,
1390 Object::Dictionary(dictionary! {
1391 "Type" => "Annot",
1392 "Subtype" => "Widget",
1393 "FT" => "Btn",
1394 "Ff" => Object::Integer(1 << 16),
1395 "T" => Object::String(b"button".to_vec(), StringFormat::Literal),
1396 "MK" => dictionary! {
1397 "CA" => Object::String(caption.to_vec(), StringFormat::Literal),
1398 },
1399 "Rect" => Object::Array(vec![100.into(), 700.into(), 260.into(), 730.into()]),
1400 "P" => Object::Reference(page_id),
1401 }),
1402 );
1403 doc.objects.insert(
1404 page_id,
1405 Object::Dictionary(dictionary! {
1406 "Type" => "Page",
1407 "Parent" => Object::Reference(pages_id),
1408 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
1409 "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
1410 "Contents" => Object::Reference(content_id),
1411 }),
1412 );
1413 doc.objects.insert(
1414 pages_id,
1415 Object::Dictionary(dictionary! {
1416 "Type" => "Pages",
1417 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
1418 "Count" => Object::Integer(1),
1419 }),
1420 );
1421 doc.objects.insert(
1422 acroform_id,
1423 Object::Dictionary(dictionary! {
1424 "Fields" => Object::Array(vec![Object::Reference(widget_id)]),
1425 }),
1426 );
1427 doc.objects.insert(
1428 catalog_id,
1429 Object::Dictionary(dictionary! {
1430 "Type" => "Catalog",
1431 "Pages" => Object::Reference(pages_id),
1432 "AcroForm" => Object::Reference(acroform_id),
1433 }),
1434 );
1435 doc.trailer.set("Root", Object::Reference(catalog_id));
1436
1437 let mut bytes = Vec::new();
1438 doc.save_to(&mut bytes)
1439 .expect("save push-button caption fixture");
1440 bytes
1441 }
1442
1443 #[test]
1444 fn extract_text_non_embedded_truetype_alias_resolves_via_winansi() {
1445 let bytes = non_embedded_truetype_pdf_bytes(
1451 b"TimesNewRoman",
1452 b"WinAnsiEncoding",
1453 b"UNITED STATES DISTRICT COURT",
1454 );
1455 let text = PdfDocument::open(bytes)
1456 .expect("open non-embedded TrueType fixture")
1457 .extract_text(0)
1458 .expect("extract non-embedded TrueType text");
1459 let norm = normalize_text(&text);
1460 assert!(
1461 norm.contains("UNITED STATES DISTRICT COURT"),
1462 "expected WinAnsi-decoded text, got: {norm:?}"
1463 );
1464 }
1465
1466 #[test]
1467 fn extract_text_non_embedded_truetype_unknown_name_still_decodes() {
1468 let bytes = non_embedded_truetype_pdf_bytes(
1475 b"OpaqueCustomXYZ",
1476 b"WinAnsiEncoding",
1477 b"Hello, world!",
1478 );
1479 let text = PdfDocument::open(bytes)
1480 .expect("open custom non-embedded fixture")
1481 .extract_text(0)
1482 .expect("extract custom non-embedded text");
1483 let norm = normalize_text(&text);
1484 assert!(
1485 norm.contains("Hello, world!"),
1486 "expected WinAnsi-decoded text, got: {norm:?}"
1487 );
1488 }
1489
1490 #[test]
1491 fn extract_acroform_text_includes_push_button_mk_caption() {
1492 let bytes = push_button_caption_pdf_bytes(b"Don't cry over spilt milk");
1493 let doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
1494
1495 let page_text = doc.extract_text(0).expect("extract page text");
1496 assert!(
1497 normalize_text(&page_text).is_empty(),
1498 "expected empty page content stream, got: {page_text:?}"
1499 );
1500
1501 let acroform_text = doc.extract_acroform_text();
1502 assert_eq!(normalize_text(&acroform_text), "Don't cry over spilt milk");
1503
1504 let all_text = doc.extract_all_text();
1505 assert_eq!(normalize_text(&all_text), "Don't cry over spilt milk");
1506 }
1507
1508 #[test]
1513 fn acroform_values_fold_their_ligatures() {
1514 let mut caption = vec![0xFE_u8, 0xFF_u8];
1518 for unit in "o\u{FB03}ce hours".encode_utf16() {
1519 caption.extend_from_slice(&unit.to_be_bytes());
1520 }
1521 let bytes = push_button_caption_pdf_bytes(&caption);
1522 let mut doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
1523
1524 assert_eq!(normalize_text(&doc.extract_acroform_text()), "office hours");
1525 assert_eq!(normalize_text(&doc.extract_all_text()), "office hours");
1526
1527 doc.set_keep_ligatures(true);
1528 assert_eq!(
1529 normalize_text(&doc.extract_acroform_text()),
1530 "o\u{FB03}ce hours",
1531 "keep_ligatures did not reach the field values"
1532 );
1533 }
1534
1535 #[test]
1536 fn bytes_to_string_utf8() {
1537 assert_eq!(bytes_to_string(b"hello"), "hello");
1538 }
1539
1540 #[test]
1541 fn bytes_to_string_latin1() {
1542 let bytes = &[0xC4, 0xD6, 0xDC]; let s = bytes_to_string(bytes);
1544 assert_eq!(s, "ÄÖÜ");
1545 }
1546
1547 #[test]
1548 fn bytes_to_string_utf16() {
1549 let bytes = &[0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]; assert_eq!(bytes_to_string(bytes), "Hi");
1551 }
1552
1553 #[test]
1554 fn document_info_default() {
1555 let info = DocumentInfo::default();
1556 assert!(info.title.is_none());
1557 assert!(info.author.is_none());
1558 }
1559
1560 #[test]
1561 fn bookmark_item_children() {
1562 let item = BookmarkItem {
1563 title: "Root".into(),
1564 page: None,
1565 children: vec![BookmarkItem {
1566 title: "Child".into(),
1567 page: Some(0),
1568 children: Vec::new(),
1569 }],
1570 };
1571 assert_eq!(item.children.len(), 1);
1572 assert_eq!(item.children[0].title, "Child");
1573 }
1574
1575 #[test]
1576 fn extract_text_type0_without_tounicode_uses_font_program_fallback() {
1577 let original = std::fs::read(corpus_path("sf181.pdf")).expect("read sf181 fixture");
1578 let expected = PdfDocument::open(original.clone())
1579 .expect("open original sf181")
1580 .extract_text(0)
1581 .expect("extract original sf181 text");
1582 assert!(
1583 expected.contains("Guide to Personnel Data Standards"),
1584 "unexpected baseline extraction: {expected}"
1585 );
1586
1587 let (stripped, removed) = strip_type0_tounicode(&original);
1588 assert!(
1589 removed > 0,
1590 "expected to strip at least one Type0 ToUnicode"
1591 );
1592
1593 let actual = PdfDocument::open(stripped)
1594 .expect("open stripped sf181")
1595 .extract_text(0)
1596 .expect("extract stripped sf181 text");
1597
1598 let actual_norm = normalize_text(&actual);
1599 let expected_norm = normalize_text(&expected);
1600
1601 assert!(
1602 actual_norm.contains("Guide to Personnel Data Standards"),
1603 "missing main heading after stripping ToUnicode: {actual_norm}"
1604 );
1605 assert!(
1606 actual_norm.contains("Privacy Act Statement"),
1607 "missing body text after stripping ToUnicode: {actual_norm}"
1608 );
1609 assert!(
1610 actual_norm.len() + 32 >= expected_norm.len(),
1611 "too much text lost after stripping ToUnicode: expected {} chars, got {}",
1612 expected_norm.len(),
1613 actual_norm.len()
1614 );
1615 }
1616
1617 #[test]
1618 fn extract_text_identity_h_bogus_tounicode_recovers_via_identity_fallback() {
1619 let bytes =
1627 std::fs::read(corpus_path("PDFBOX-4322-3.pdf")).expect("read PDFBOX-4322-3 fixture");
1628 let doc = PdfDocument::open(bytes).expect("open PDFBOX-4322-3");
1629 let text = doc.extract_all_text();
1630
1631 let norm = normalize_text(&text);
1632 assert!(
1633 norm.contains("Transatlantic Council"),
1634 "expected Identity-H codes to resolve as Unicode: {norm}"
1635 );
1636 assert!(
1637 norm.contains("Boy Scouts of America"),
1638 "expected body text to be recovered: {norm}"
1639 );
1640 }
1641
1642 #[test]
1643 fn render_max_pixels_none_is_unchanged_default_behavior() {
1644 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
1646 let baseline = doc
1647 .render_page(
1648 0,
1649 &RenderOptions {
1650 dpi: 144.0,
1651 ..Default::default()
1652 },
1653 )
1654 .expect("baseline render");
1655 let explicit_none = doc
1656 .render_page(
1657 0,
1658 &RenderOptions {
1659 dpi: 144.0,
1660 max_pixels: None,
1661 ..Default::default()
1662 },
1663 )
1664 .expect("explicit-none render");
1665 assert_eq!(baseline.width, explicit_none.width);
1666 assert_eq!(baseline.height, explicit_none.height);
1667 assert_eq!(baseline.pixels, explicit_none.pixels);
1668 }
1669
1670 #[test]
1671 fn render_max_pixels_budget_clamps_resolution() {
1672 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
1673 let full = doc
1674 .render_page(
1675 0,
1676 &RenderOptions {
1677 dpi: 288.0,
1678 ..Default::default()
1679 },
1680 )
1681 .expect("full render");
1682 let full_px = full.width * full.height;
1683 let budget = full_px / 4;
1685 let capped = doc
1686 .render_page(
1687 0,
1688 &RenderOptions {
1689 dpi: 288.0,
1690 max_pixels: Some(budget),
1691 ..Default::default()
1692 },
1693 )
1694 .expect("capped render");
1695 assert!(
1696 capped.width * capped.height <= full_px,
1697 "capped output must not exceed full output"
1698 );
1699 assert!(
1700 capped.width < full.width || capped.height < full.height,
1701 "budget below full pixel count must shrink at least one dimension"
1702 );
1703 }
1704
1705 #[test]
1706 fn render_max_pixels_large_budget_no_clamp() {
1707 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
1709 let baseline = doc
1710 .render_page(
1711 0,
1712 &RenderOptions {
1713 dpi: 72.0,
1714 ..Default::default()
1715 },
1716 )
1717 .expect("baseline");
1718 let huge = doc
1719 .render_page(
1720 0,
1721 &RenderOptions {
1722 dpi: 72.0,
1723 max_pixels: Some(100_000_000),
1724 ..Default::default()
1725 },
1726 )
1727 .expect("huge-budget render");
1728 assert_eq!(baseline.width, huge.width);
1729 assert_eq!(baseline.height, huge.height);
1730 assert_eq!(baseline.pixels, huge.pixels);
1731 }
1732
1733 #[test]
1734 fn render_page_with_config_srgb_matches_legacy_render_page() {
1735 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open rgb fixture");
1736 let legacy = doc
1737 .render_page(
1738 0,
1739 &RenderOptions {
1740 dpi: 72.0,
1741 ..Default::default()
1742 },
1743 )
1744 .expect("legacy render succeeds");
1745 let configured = doc
1746 .render_page_with_config(
1747 0,
1748 &RenderConfig {
1749 color_mode: ColorMode::Srgb,
1750 dpi: 72,
1751 },
1752 )
1753 .expect("configured render succeeds");
1754
1755 assert_eq!(legacy.width, configured.width);
1756 assert_eq!(legacy.height, configured.height);
1757 assert_eq!(legacy.pixel_format, PixelFormat::Rgba8);
1758 assert_eq!(configured.pixel_format, PixelFormat::Rgba8);
1759 assert_eq!(legacy.pixels, configured.pixels);
1760 }
1761
1762 #[test]
1763 fn render_page_with_config_preserve_cmyk_returns_cmyk_buffer() {
1764 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
1765 let rendered = doc
1766 .render_page_with_config(
1767 0,
1768 &RenderConfig {
1769 color_mode: ColorMode::PreserveCmyk,
1770 dpi: 72,
1771 },
1772 )
1773 .expect("cmyk render succeeds");
1774
1775 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
1776 assert_eq!(
1777 rendered.pixels.len(),
1778 rendered.width as usize * rendered.height as usize * 4
1779 );
1780 assert_eq!(
1781 pixel_at(&rendered, rendered.width / 2, rendered.height / 2),
1782 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
1783 );
1784 }
1785
1786 #[test]
1787 fn render_page_with_config_simulate_cmyk_does_not_panic_on_cmyk_pdf() {
1788 let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
1789 let rendered = doc
1790 .render_page_with_config(
1791 0,
1792 &RenderConfig {
1793 color_mode: ColorMode::SimulateCmyk,
1794 dpi: 72,
1795 },
1796 )
1797 .expect("simulate cmyk render succeeds");
1798
1799 assert_eq!(rendered.pixel_format, PixelFormat::Rgba8);
1800 assert!(!rendered.pixels.is_empty());
1801 }
1802
1803 #[test]
1804 fn render_page_with_config_preserve_cmyk_mixed_page_preserves_only_cmyk_region() {
1805 let doc = PdfDocument::open(mixed_rgb_cmyk_pdf_bytes()).expect("open mixed fixture");
1806 let rendered = doc
1807 .render_page_with_config(
1808 0,
1809 &RenderConfig {
1810 color_mode: ColorMode::PreserveCmyk,
1811 dpi: 72,
1812 },
1813 )
1814 .expect("mixed render succeeds");
1815
1816 assert_eq!(
1817 pixel_at(&rendered, 54, 36),
1818 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
1819 );
1820 assert_ne!(
1821 pixel_at(&rendered, 18, 36),
1822 crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
1823 );
1824 }
1825
1826 #[test]
1827 fn render_page_with_config_preserve_cmyk_transparent_page_does_not_crash() {
1828 let doc =
1829 PdfDocument::open(transparent_cmyk_pdf_bytes()).expect("open transparent cmyk fixture");
1830 let rendered = doc
1831 .render_page_with_config(
1832 0,
1833 &RenderConfig {
1834 color_mode: ColorMode::PreserveCmyk,
1835 dpi: 72,
1836 },
1837 )
1838 .expect("transparent cmyk render succeeds");
1839
1840 assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
1841 assert_eq!(
1842 rendered.pixels.len(),
1843 rendered.width as usize * rendered.height as usize * 4
1844 );
1845 }
1846
1847 #[test]
1848 fn render_page_with_config_preserve_cmyk_keeps_device_cmyk_image_bytes() {
1849 let doc = PdfDocument::open(cmyk_image_pdf_bytes()).expect("open cmyk image fixture");
1850 let rendered = doc
1851 .render_page_with_config(
1852 0,
1853 &RenderConfig {
1854 color_mode: ColorMode::PreserveCmyk,
1855 dpi: 72,
1856 },
1857 )
1858 .expect("cmyk image render succeeds");
1859
1860 assert_eq!(rendered.width, 2);
1861 assert_eq!(rendered.height, 1);
1862 assert_eq!(pixel_at(&rendered, 0, 0), [255, 0, 0, 0]);
1863 assert_eq!(pixel_at(&rendered, 1, 0), [0, 255, 0, 0]);
1864 }
1865}
1866
1867#[cfg(test)]
1868mod load_recovery_tests {
1869 use super::PdfDocument;
1870
1871 #[test]
1872 fn broken_xref_sets_xref_rebuilt() {
1873 let body: &[u8] = b"%PDF-1.7\n\
18761 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
18772 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
18783 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n\
1879trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n999999\n%%EOF";
1880 let doc = PdfDocument::open(body.to_vec()).expect("recovers via xref rebuild");
1881 assert!(doc.load_recovery().xref_rebuilt, "xref_rebuilt must be set");
1882 }
1883
1884 #[test]
1885 fn clean_document_reports_no_recovery() {
1886 let objs: [&[u8]; 3] = [
1888 b"<< /Type /Catalog /Pages 2 0 R >>",
1889 b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1890 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
1891 ];
1892 let mut buf = Vec::new();
1893 let mut off = [0usize; 4];
1894 buf.extend_from_slice(b"%PDF-1.7\n");
1895 for (i, body) in objs.iter().enumerate() {
1896 off[i + 1] = buf.len();
1897 buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
1898 buf.extend_from_slice(body);
1899 buf.extend_from_slice(b"\nendobj\n");
1900 }
1901 let xref_off = buf.len();
1902 buf.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
1903 for o in &off[1..4] {
1904 buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
1905 }
1906 buf.extend_from_slice(
1907 format!("trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n{xref_off}\n%%EOF").as_bytes(),
1908 );
1909 let doc = PdfDocument::open(buf).expect("clean doc opens");
1910 let r = doc.load_recovery();
1911 assert!(
1912 !r.xref_rebuilt && !r.page_tree_rebuilt,
1913 "a clean document must report no recovery; got {r:?}"
1914 );
1915 }
1916}