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