Skip to main content

pdf_engine/
document.rs

1//! Unified document facade — multi-page rendering, text extraction,
2//! metadata, bookmarks, and thumbnails.
3
4// Copyright (c) 2026 Innovation Trigger B.V.
5//
6// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
7// the PDFluent Commercial Licence. See the LICENSE file in this repository --
8// that file travels with the copy you received, which a URL does not.
9
10use crate::error::{EngineError, Result};
11use crate::geometry::{self, PageGeometry};
12use crate::limits::{LimitError, ProcessingLimits};
13use std::sync::{Arc, Mutex};
14
15/// Shared slot used by the limit-warning collector.
16///
17/// Stores `Some((observed_bytes, limit_bytes))` when a
18/// `StreamTooLarge` warning fires during rendering or extraction.
19type 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/// Document metadata extracted from the info dictionary.
41#[derive(Debug, Clone, Default)]
42pub struct DocumentInfo {
43    /// Document title.
44    pub title: Option<String>,
45    /// Author.
46    pub author: Option<String>,
47    /// Subject.
48    pub subject: Option<String>,
49    /// Keywords.
50    pub keywords: Option<String>,
51    /// Creator application.
52    pub creator: Option<String>,
53    /// Producer application.
54    pub producer: Option<String>,
55}
56
57/// A bookmark / outline item.
58#[derive(Debug, Clone)]
59pub struct BookmarkItem {
60    /// Bookmark title.
61    pub title: String,
62    /// Target page index (0-based), if resolvable.
63    pub page: Option<usize>,
64    /// Nested child bookmarks.
65    pub children: Vec<BookmarkItem>,
66}
67
68/// High-level PDF document handle.
69pub struct PdfDocument {
70    pdf: Pdf,
71    settings: InterpreterSettings,
72    /// Hand back the ligature code points a font declared rather than the
73    /// letters they stand for. See [`Self::set_keep_ligatures`].
74    keep_ligatures: bool,
75}
76
77impl PdfDocument {
78    /// Open a PDF from bytes.
79    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    /// Open a PDF from bytes with processing limits.
98    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    /// Open a password-protected PDF.
125    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    /// Open a password-protected PDF with processing limits.
147    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    /// Access the underlying parsed PDF.
177    pub fn pdf(&self) -> &Pdf {
178        &self.pdf
179    }
180
181    /// Structural recovery applied while loading this document (xref / page-tree
182    /// rebuild). All-`false` for a document that parsed cleanly.
183    pub fn load_recovery(&self) -> pdf_render::pdf_syntax::LoadRecovery {
184        self.pdf.load_recovery()
185    }
186
187    /// Set interpreter settings (font resolver, cmap resolver, etc.).
188    pub fn set_settings(&mut self, settings: InterpreterSettings) {
189        self.settings = settings;
190    }
191
192    /// Hand back the ligature code points a font declared -- `ffi` rather than
193    /// `ffi` -- from every plain-text extraction on this document: the pages,
194    /// and the AcroForm field values [`Self::extract_acroform_text`] reads.
195    ///
196    /// Off by default: one glyph for three letters is a typesetting decision,
197    /// and a reader that keeps U+FB03 hands back a word no search box will
198    /// match (#437). Turn it on when the caller wants the file's own
199    /// characters: a typesetting tool, or a comparison against the content
200    /// stream.
201    ///
202    /// Structured output is outside it either way:
203    /// [`Self::extract_text_blocks`] and the spans under it are never folded,
204    /// because a caller who asked for per-span geometry asked for the glyphs.
205    pub fn set_keep_ligatures(&mut self, keep: bool) {
206        self.keep_ligatures = keep;
207    }
208
209    /// Install a warning sink that receives [`InterpreterWarning`]s raised
210    /// during subsequent render / text-extraction operations.
211    ///
212    /// Replaces any previously installed sink; all other settings (cache,
213    /// limits, resolvers) are preserved. The sink is preserved across the
214    /// extraction-settings clone, so it covers both rendering and extraction.
215    pub fn set_warning_sink(&mut self, sink: pdf_render::pdf_interpret::WarningSinkFn) {
216        self.settings.warning_sink = sink;
217    }
218
219    /// Number of pages.
220    pub fn page_count(&self) -> usize {
221        self.pdf.pages().len()
222    }
223
224    /// Get the geometry of a page.
225    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    /// Render a single page.
231    ///
232    /// If the document contains an XFA template, it is automatically flattened
233    /// to static PDF content before rendering.  This prevents the "Please wait"
234    /// placeholder page that Adobe Reader would show when rendering an XFA PDF
235    /// with a conventional renderer. If flattening fails, rendering falls back
236    /// to the original document as a best-effort path.
237    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        // Pre-flight: reject pathologically small or zero-dimension pages before
244        // allocating any pixel buffer. Non-positive dimensions cause panics or
245        // zero-sized allocations inside the rasteriser.
246        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        // Also reject pages so small they produce zero pixels even at the
255        // minimum meaningful DPI (1 DPI). Below ~0.72pt at 1 DPI = 0 pixels.
256        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    /// Render a single page using the high-level render config.
271    ///
272    /// XFA documents are auto-flattened before rendering (same as `render_page`).
273    /// If flattening fails, rendering falls back to the original document.
274    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    /// Render a single page to a CMYK buffer.
307    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    /// Render all pages, in parallel when the `parallel` feature is enabled.
318    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    /// Render all pages using the high-level render config.
332    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    /// Generate a thumbnail for a single page.
346    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    /// Generate thumbnails for all pages, in parallel when the `parallel` feature is enabled.
356    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    /// Extract text from a page as a single string.
370    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    /// Extract text from a sequence of pages while reusing the same settings object.
381    #[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    /// Extract structured text blocks from a page.
407    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    /// Extract structured text blocks from all pages, reusing interpreter settings.
418    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    /// Extract text values from AcroForm fields, including push-button captions.
434    ///
435    /// Returns a single string concatenating all non-empty field values separated
436    /// by newlines. Useful when the document stores its readable content in form
437    /// field values rather than (or in addition to) page content streams.
438    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                // Terminal (widget) — collect text-like values.
447                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        // A field value is a string the author stored rather than glyphs
485        // anybody drew, but a searcher cannot tell the two apart: it is the
486        // same `office` and the same failed search (codex, #437). Folding here
487        // rather than in `extract_all_text` covers this method's own callers
488        // and the zero-page document, whose whole text is this string.
489        crate::ligatures::fold(&joined).into_owned()
490    }
491
492    /// Extract all text from the document: page content streams plus AcroForm
493    /// field values.  Mirrors pdftotext behaviour.
494    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        // AcroForm values ride on the last page rather than after the last
506        // page separator: they belong to no page of their own, and a trailing
507        // block behind the final form feed would read as one more page to
508        // every consumer that counts them.
509        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    /// Simple text search: returns page indices containing the query string.
526    ///
527    /// The query is folded the same way the pages are, so a word copied out of
528    /// the structured output -- which keeps the ligature code points -- still
529    /// finds the page it was copied from.
530    pub fn search_text(&self, query: &str) -> Vec<usize> {
531        let pages = self.pdf.pages();
532        let query_lower = query.to_lowercase();
533        // Fold the needle exactly when the haystack is folded, or the one
534        // query that most needs the fold -- `office`, pasted out of a span --
535        // is the one query that can never match (codex, #437).
536        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    /// Extract document metadata.
577    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    /// Extract document outline / bookmarks.
590    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        // `visited` breaks cyclic /Next loops and /First back-references;
609        // `depth` bounds deep nesting so a malformed outline cannot hang or
610        // overflow the stack.
611        let mut visited = BTreeSet::new();
612        parse_outline_items(&first, 0, &mut visited)
613    }
614
615    /// Run OCR on a page and return the recognized text and word positions.
616    ///
617    /// The page is rendered at `dpi` (default 150) before recognition.
618    /// Pass any [`OcrBackend`] implementation; use [`OcrsBackend::try_default`]
619    /// to load the pure-Rust `ocrs` engine from the standard model paths.
620    ///
621    /// # Example
622    ///
623    /// ```no_run
624    /// # #[cfg(feature = "ocr")] {
625    /// use pdf_engine::{PdfDocument, OcrsBackend, RenderOptions};
626    ///
627    /// let doc = PdfDocument::open(std::fs::read("scan.pdf").unwrap()).unwrap();
628    /// let backend = OcrsBackend::try_default().unwrap();
629    /// let result = doc.ocr_page(0, &backend, 150.0_f64).unwrap();
630    /// println!("{}", result.text);
631    /// # }
632    /// ```
633    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        // Convert RGBA → RGB (ocrs expects RGB input).
646        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    /// Wrap `settings` with a warning sink that captures the first
659    /// `InterpreterWarning::StreamTooLarge` into a shared slot.
660    ///
661    /// The returned slot is checked by [`Self::check_limit_slot`] after
662    /// the operation completes. Any previously installed sink is still
663    /// called so no warnings are silently dropped.
664    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    /// Check the slot populated by [`Self::with_limit_collector`].
682    ///
683    /// Returns `Err(EngineError::LimitExceeded(...))` if a
684    /// `StreamTooLarge` warning was captured, `Ok(())` otherwise.
685    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        // Text extraction should include signature widget appearance streams
709        // that rendering skips to match MuPDF visual output.
710        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
763/// Join per-page text the way `pdftotext` and `mutool` have for twenty years:
764/// a form feed (U+000C) **after every page**, preceded by a blank line so the
765/// last paragraph of a page is not glued to the separator.
766///
767/// After every page and not between them, which is the convention every reader
768/// in the #390 comparison emits: a fourteen-page document carries fourteen form
769/// feeds in `pdftotext`, `mutool`, PyMuPDF and pdfminer.six alike. Splitting on
770/// U+000C therefore yields `n + 1` fields with an empty one at the end, and
771/// every consumer of a form-feed stream already drops it. Separating instead of
772/// terminating would save that one field and cost the property the convention
773/// exists for: that our text is substitutable for theirs (#449).
774pub 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        // The page exists; a reader counting form feeds must still find it.
807        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        // The trailing field every form-feed consumer already drops.
826        assert_eq!(text.split('\u{000C}').count(), 4);
827    }
828}
829
830/// Maximum outline (bookmark) nesting depth. Bounds recursion on adversarial
831/// `/First` chains; real outlines are far shallower.
832const MAX_OUTLINE_DEPTH: usize = 100;
833
834/// Walk the outline linked list (FIRST → NEXT chain).
835///
836/// `visited` (object ids of already-seen items) breaks cyclic `/Next` loops and
837/// `/First` back-references; `depth` bounds deeply nested `/First` chains. Both
838/// protect against malformed/adversarial outlines hanging or overflowing.
839fn 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        // Stop if we re-enter an item: covers a /Next chain that loops back and
852        // a /First child that references an ancestor.
853        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, // Destination resolution requires named-dest lookup — left for follow-up
872            children,
873        });
874
875        current = dict.get::<Dict<'_>>(NEXT);
876    }
877
878    items
879}
880
881/// Convert PDF string bytes to a Rust String (UTF-8 with Latin-1 fallback).
882fn bytes_to_string(bytes: &[u8]) -> String {
883    // Check for UTF-16 BOM
884    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    // Try UTF-8, fall back to Latin-1.
899    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    /// A cyclic outline (`/Next` chain that loops back) must not hang the
919    /// bookmark walker; the cycle guard breaks it into a finite list.
920    #[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 >>", // /Next back to A -> cycle
930            ];
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    /// Cache-on vs cache-off: the shared decoded-image cache is a pure
966    /// performance optimisation, so rendering with it enabled must produce
967    /// pixel-identical output to rendering with it disabled. Exercised against a
968    /// committed image-bearing fixture.
969    /// Locate a repository test fixture. Published crates do not carry the
970    /// repository's fixture tree, so callers skip when this returns `None`
971    /// rather than failing a consumer's `cargo test`.
972    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        // Cache ON: `PdfDocument::open` installs a shared cache, so the second
996        // render of the same page is a cache hit.
997        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        // Cache OFF: default settings carry `shared_cache: None`, so every render
1006        // decodes images from scratch.
1007        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    /// Build a minimal one-page PDF whose only font is a non-embedded TrueType
1305    /// reference (no `FontFile2`). The character codes in the content stream
1306    /// resolve through the declared `/Encoding`, exercising the same code path
1307    /// as corpus PDFs like `171_171940.pdf`.
1308    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    /// Build a minimal AcroForm push button whose only human-readable text
1371    /// lives in the widget `/MK /CA` caption entry.
1372    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        // Mirrors corpus PDF `171_171940.pdf`: TrueType font references
1446        // `TimesNewRoman` (resolves through the standard-font alias table)
1447        // with `WinAnsiEncoding` and no embedded font program. Extraction must
1448        // recover the text from the declared encoding even though no glyph
1449        // outlines are available.
1450        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        // Custom BaseFont that does not match any standard alias and lacks the
1469        // keywords used by the heuristic. The standard-font fallback (via
1470        // FallbackFontQuery) still picks Helvetica, but on hosts without the
1471        // embedded font assets that path returns None — the new TextOnly
1472        // branch is what keeps extraction non-empty in that case. Either way,
1473        // the WinAnsi-driven char map must produce the original prose.
1474        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    /// A form field value is text a searcher reads, and a ligature in one
1509    /// fails the same search a ligature on the page fails. The option covers
1510    /// it too, which is what makes `extract_all_text` one answer rather than
1511    /// a folded half and an unfolded half (codex, #437).
1512    #[test]
1513    fn acroform_values_fold_their_ligatures() {
1514        // A PDF text string carrying a non-Latin-1 character is UTF-16BE
1515        // behind a byte-order mark; the literal bytes would be read as
1516        // Latin-1 and never reach the fold as one character.
1517        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]; // ÄÖÜ in Latin-1
1543        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]; // UTF-16 "Hi"
1550        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        // PDFBOX-4322-3.pdf ships an Identity-H Type0 font whose `/ToUnicode`
1620        // stream is actually an Identity-H *encoding* CMap (only
1621        // `begincidrange <0000> <FFFF> 0`, no bf-mappings). The embedded
1622        // TrueType subset also has no `cmap` table, so both the ToUnicode
1623        // lookup and the reverse-cmap fallback fail. Previously this yielded
1624        // a 0-byte extraction because the character codes — which are Unicode
1625        // code points under Identity-H — were silently discarded.
1626        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        // Default (max_pixels = None) must be byte-identical to an explicit no-budget render.
1645        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        // Budget well below the full pixel count must reduce output dimensions.
1684        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        // A budget larger than the rendered size must not change the output.
1708        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        // Objects + trailer present, but startxref points nowhere: pd-syntax
1874        // rebuilds the xref by scanning, and the signal must reflect that.
1875        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        // A well-formed PDF with a correct xref must report no recovery.
1887        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}