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::blocks::{Block, ClipMargins, ExtractOptions, TatrOptions};
21use crate::markdown_images::{self, Figure};
22use crate::render::{self, ColorMode, RenderConfig, RenderOptions, RenderedPage};
23use crate::text::{GeometryMode, TextBlock, TextExtractionDevice};
24use crate::thumbnail::ThumbnailOptions;
25use crate::words::Word;
26
27use kurbo::Rect;
28use pdf_forms::parse::parse_acroform;
29use pdf_forms::tree::{FieldType, FieldValue};
30use pdf_render::pdf_interpret as InkDeviceTypes;
31use pdf_render::pdf_interpret::PageExt;
32use pdf_render::pdf_interpret::{
33    interpret_page, Cache, Context, InterpreterSettings, InterpreterWarning,
34};
35use pdf_render::pdf_syntax::object::dict::keys::{
36    A, D, DEST, F, FIRST, NEXT, NM, OUTLINES, RECT, S, SUBTYPE, TITLE, URI,
37};
38use pdf_render::pdf_syntax::object::{Array, Dict, Name, ObjRef, Object, ObjectIdentifier};
39use pdf_render::pdf_syntax::page::Page;
40use pdf_render::pdf_syntax::{Pdf, PdfLoadLimits};
41#[cfg(feature = "parallel")]
42use rayon::prelude::*;
43use std::collections::BTreeSet;
44
45/// Document metadata extracted from the info dictionary.
46#[derive(Debug, Clone, Default)]
47pub struct DocumentInfo {
48    /// Document title.
49    pub title: Option<String>,
50    /// Author.
51    pub author: Option<String>,
52    /// Subject.
53    pub subject: Option<String>,
54    /// Keywords.
55    pub keywords: Option<String>,
56    /// Creator application.
57    pub creator: Option<String>,
58    /// Producer application.
59    pub producer: Option<String>,
60}
61
62/// A bookmark / outline item.
63#[derive(Debug, Clone)]
64pub struct BookmarkItem {
65    /// Bookmark title.
66    pub title: String,
67    /// Target page index (0-based), if resolvable.
68    pub page: Option<usize>,
69    /// Nested child bookmarks.
70    pub children: Vec<BookmarkItem>,
71}
72
73/// Where a page link points.
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum LinkTarget {
77    /// A `/URI` action: the link opens this address.
78    Uri(String),
79    /// A same-document jump: the 0-based page it lands on.
80    Page(usize),
81    /// A named destination, unresolved (catalog `/Names` lookup is a
82    /// follow-up, as for outlines): the destination name.
83    Named(String),
84    /// Anything else: a remote jump, a launch or script action, or a
85    /// destination too broken to read.
86    Unknown,
87}
88
89/// One `/Link` annotation on a page: the region it covers and where it goes.
90#[derive(Debug, Clone, PartialEq)]
91pub struct PageLink {
92    /// Where the link points.
93    pub target: LinkTarget,
94    /// The `/Rect` in PDF user-space points, bottom-left origin.
95    pub rect: [f64; 4],
96    /// The annotation's object number, or 0 for a direct (inline) annot.
97    pub xref: u32,
98    /// The `/NM` entry, or empty when the annot carries none.
99    pub id: String,
100}
101
102/// High-level PDF document handle.
103pub struct PdfDocument {
104    pdf: Pdf,
105    settings: InterpreterSettings,
106    /// Hand back the ligature code points a font declared rather than the
107    /// letters they stand for. See [`Self::set_keep_ligatures`].
108    keep_ligatures: bool,
109}
110
111impl PdfDocument {
112    /// Open a PDF from bytes.
113    pub fn open(data: impl Into<pdf_render::pdf_syntax::PdfData>) -> Result<Self> {
114        let pdf = Pdf::new(data).map_err(|e| match e {
115            pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
116                EngineError::Encrypted(format!("{d:?}"))
117            }
118            _ => EngineError::InvalidPdf(format!("{e:?}")),
119        })?;
120        let settings = InterpreterSettings {
121            shared_cache: Some(Cache::new()),
122            ..InterpreterSettings::default()
123        };
124        Ok(Self {
125            pdf,
126            settings,
127            keep_ligatures: false,
128        })
129    }
130
131    /// Open a PDF from bytes with processing limits.
132    pub fn open_with_processing_limits(
133        data: impl Into<pdf_render::pdf_syntax::PdfData>,
134        limits: ProcessingLimits,
135    ) -> Result<Self> {
136        let syntax_limits = PdfLoadLimits::new()
137            .max_object_depth(limits.max_object_depth)
138            .max_image_pixels(limits.max_image_pixels)
139            .max_stream_bytes(limits.max_stream_bytes);
140        let pdf = Pdf::new_with_limits(data, syntax_limits).map_err(|e| match e {
141            pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
142                EngineError::Encrypted(format!("{d:?}"))
143            }
144            _ => EngineError::InvalidPdf(format!("{e:?}")),
145        })?;
146        let settings = InterpreterSettings {
147            max_operator_count: Some(limits.max_operator_count),
148            shared_cache: Some(Cache::new()),
149            ..InterpreterSettings::default()
150        };
151        Ok(Self {
152            pdf,
153            settings,
154            keep_ligatures: false,
155        })
156    }
157
158    /// Open a password-protected PDF.
159    pub fn open_with_password(
160        data: impl Into<pdf_render::pdf_syntax::PdfData>,
161        password: &str,
162    ) -> Result<Self> {
163        let pdf = Pdf::new_with_password(data, password).map_err(|e| match e {
164            pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
165                EngineError::Encrypted(format!("{d:?}"))
166            }
167            _ => EngineError::InvalidPdf(format!("{e:?}")),
168        })?;
169        let settings = InterpreterSettings {
170            shared_cache: Some(Cache::new()),
171            ..InterpreterSettings::default()
172        };
173        Ok(Self {
174            pdf,
175            settings,
176            keep_ligatures: false,
177        })
178    }
179
180    /// Open a password-protected PDF with processing limits.
181    pub fn open_with_password_and_processing_limits(
182        data: impl Into<pdf_render::pdf_syntax::PdfData>,
183        password: &str,
184        limits: ProcessingLimits,
185    ) -> Result<Self> {
186        let syntax_limits = PdfLoadLimits::new()
187            .max_object_depth(limits.max_object_depth)
188            .max_image_pixels(limits.max_image_pixels)
189            .max_stream_bytes(limits.max_stream_bytes);
190        let pdf = Pdf::new_with_password_and_limits(data, password, syntax_limits).map_err(
191            |e| match e {
192                pdf_render::pdf_syntax::LoadPdfError::Decryption(d) => {
193                    EngineError::Encrypted(format!("{d:?}"))
194                }
195                _ => EngineError::InvalidPdf(format!("{e:?}")),
196            },
197        )?;
198        let settings = InterpreterSettings {
199            max_operator_count: Some(limits.max_operator_count),
200            shared_cache: Some(Cache::new()),
201            ..InterpreterSettings::default()
202        };
203        Ok(Self {
204            pdf,
205            settings,
206            keep_ligatures: false,
207        })
208    }
209
210    /// Access the underlying parsed PDF.
211    pub fn pdf(&self) -> &Pdf {
212        &self.pdf
213    }
214
215    /// Structural recovery applied while loading this document (xref / page-tree
216    /// rebuild). All-`false` for a document that parsed cleanly.
217    pub fn load_recovery(&self) -> pdf_render::pdf_syntax::LoadRecovery {
218        self.pdf.load_recovery()
219    }
220
221    /// Take another document's interpreter settings and ligature choice.
222    ///
223    /// For copies opened from derived bytes -- the OCR path's searchable copy
224    /// -- which must read the way the document they derive from does.
225    #[cfg(feature = "ocr-cli")]
226    pub(crate) fn adopt_settings(&mut self, other: &Self) {
227        self.settings = other.settings.clone();
228        self.keep_ligatures = other.keep_ligatures;
229    }
230
231    /// Set interpreter settings (font resolver, cmap resolver, etc.).
232    pub fn set_settings(&mut self, settings: InterpreterSettings) {
233        self.settings = settings;
234    }
235
236    /// Hand back the ligature code points a font declared -- `ffi` rather than
237    /// `ffi` -- from every TEXT extraction on this document: the pages, the
238    /// blocks and their spans, the markdown and JSON over them, and the
239    /// AcroForm field values [`Self::extract_acroform_text`] reads.
240    ///
241    /// Off by default: one glyph for three letters is a typesetting decision,
242    /// and a reader that keeps U+FB03 hands back a word no search box will
243    /// match (#437). Turn it on when the caller wants the file's own
244    /// characters: a typesetting tool, or a comparison against the content
245    /// stream.
246    ///
247    /// Outside it either way is what is not a TEXT output: the training input
248    /// [`Self::extract_blocks_before_learning`], which the models read, and
249    /// glyph-level readers outside this crate.
250    pub fn set_keep_ligatures(&mut self, keep: bool) {
251        self.keep_ligatures = keep;
252    }
253
254    /// Install a warning sink that receives [`InterpreterWarning`]s raised
255    /// during subsequent render / text-extraction operations.
256    ///
257    /// Replaces any previously installed sink; all other settings (cache,
258    /// limits, resolvers) are preserved. The sink is preserved across the
259    /// extraction-settings clone, so it covers both rendering and extraction.
260    pub fn set_warning_sink(&mut self, sink: pdf_render::pdf_interpret::WarningSinkFn) {
261        self.settings.warning_sink = sink;
262    }
263
264    /// Number of pages.
265    pub fn page_count(&self) -> usize {
266        self.pdf.pages().len()
267    }
268
269    /// Get the geometry of a page.
270    pub fn page_geometry(&self, index: usize) -> Result<PageGeometry> {
271        let page = self.get_page(index)?;
272        Ok(geometry::extract_geometry(page))
273    }
274
275    /// Render a single page.
276    ///
277    /// If the document contains an XFA template, it is automatically flattened
278    /// to static PDF content before rendering.  This prevents the "Please wait"
279    /// placeholder page that Adobe Reader would show when rendering an XFA PDF
280    /// with a conventional renderer. If flattening fails, rendering falls back
281    /// to the original document as a best-effort path.
282    pub fn render_page(&self, index: usize, options: &RenderOptions) -> Result<RenderedPage> {
283        #[cfg(feature = "xfa")]
284        if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
285            return flat_doc.render_page(index, options);
286        }
287        let page = self.get_page(index)?;
288        // Pre-flight: reject pathologically small or zero-dimension pages before
289        // allocating any pixel buffer. Non-positive dimensions cause panics or
290        // zero-sized allocations inside the rasteriser.
291        let (w, h) = page.render_dimensions();
292        if w <= 0.0 || h <= 0.0 {
293            return Err(EngineError::InvalidPageGeometry {
294                width: w,
295                height: h,
296                reason: "page has zero or negative dimensions".into(),
297            });
298        }
299        // Also reject pages so small they produce zero pixels even at the
300        // minimum meaningful DPI (1 DPI). Below ~0.72pt at 1 DPI = 0 pixels.
301        const MIN_PAGE_PT: f32 = 1.0;
302        if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
303            return Err(EngineError::InvalidPageGeometry {
304                width: w,
305                height: h,
306                reason: "page too small to render (< 1pt)".into(),
307            });
308        }
309        let (settings, slot) = Self::with_limit_collector(&self.settings);
310        let rendered = render::render_page(page, options, &settings);
311        Self::check_limit_slot(&slot)?;
312        Ok(rendered)
313    }
314
315    /// Render a single page using the high-level render config.
316    ///
317    /// XFA documents are auto-flattened before rendering (same as `render_page`).
318    /// If flattening fails, rendering falls back to the original document.
319    pub fn render_page_with_config(
320        &self,
321        index: usize,
322        config: &RenderConfig,
323    ) -> Result<RenderedPage> {
324        #[cfg(feature = "xfa")]
325        if let Some(flat_doc) = self.open_flattened_xfa_for_render() {
326            return flat_doc.render_page_with_config(index, config);
327        }
328        let page = self.get_page(index)?;
329        let (w, h) = page.render_dimensions();
330        if w <= 0.0 || h <= 0.0 {
331            return Err(EngineError::InvalidPageGeometry {
332                width: w,
333                height: h,
334                reason: "page has zero or negative dimensions".into(),
335            });
336        }
337        const MIN_PAGE_PT: f32 = 1.0;
338        if w < MIN_PAGE_PT || h < MIN_PAGE_PT {
339            return Err(EngineError::InvalidPageGeometry {
340                width: w,
341                height: h,
342                reason: "page too small to render (< 1pt)".into(),
343            });
344        }
345        let (settings, slot) = Self::with_limit_collector(&self.settings);
346        let rendered = render::render_page_with_config(page, config, &settings);
347        Self::check_limit_slot(&slot)?;
348        Ok(rendered)
349    }
350
351    /// Render a single page to a CMYK buffer.
352    pub fn render_page_cmyk(&self, index: usize, dpi: u32) -> Result<RenderedPage> {
353        self.render_page_with_config(
354            index,
355            &RenderConfig {
356                color_mode: ColorMode::PreserveCmyk,
357                dpi,
358            },
359        )
360    }
361
362    /// Render all pages, in parallel when the `parallel` feature is enabled.
363    pub fn render_all(&self, options: &RenderOptions) -> Vec<RenderedPage> {
364        let pages = self.pdf.pages();
365        #[cfg(feature = "parallel")]
366        return (0..pages.len())
367            .into_par_iter()
368            .map(|i| render::render_page(&pages[i], options, &self.settings))
369            .collect();
370        #[cfg(not(feature = "parallel"))]
371        (0..pages.len())
372            .map(|i| render::render_page(&pages[i], options, &self.settings))
373            .collect()
374    }
375
376    /// Render all pages using the high-level render config.
377    pub fn render_all_with_config(&self, config: &RenderConfig) -> Vec<RenderedPage> {
378        let pages = self.pdf.pages();
379        #[cfg(feature = "parallel")]
380        return (0..pages.len())
381            .into_par_iter()
382            .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
383            .collect();
384        #[cfg(not(feature = "parallel"))]
385        (0..pages.len())
386            .map(|i| render::render_page_with_config(&pages[i], config, &self.settings))
387            .collect()
388    }
389
390    /// Generate a thumbnail for a single page.
391    pub fn thumbnail(&self, index: usize, options: &ThumbnailOptions) -> Result<RenderedPage> {
392        let page = self.get_page(index)?;
393        Ok(render::render_thumbnail(
394            page,
395            options.max_dimension,
396            &self.settings,
397        ))
398    }
399
400    /// Generate thumbnails for all pages, in parallel when the `parallel` feature is enabled.
401    pub fn thumbnails_all(&self, options: &ThumbnailOptions) -> Vec<RenderedPage> {
402        let pages = self.pdf.pages();
403        #[cfg(feature = "parallel")]
404        return (0..pages.len())
405            .into_par_iter()
406            .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
407            .collect();
408        #[cfg(not(feature = "parallel"))]
409        (0..pages.len())
410            .map(|i| render::render_thumbnail(&pages[i], options.max_dimension, &self.settings))
411            .collect()
412    }
413
414    /// Extract text from a page as a single string.
415    pub fn extract_text(&self, index: usize) -> Result<String> {
416        let page = self.get_page(index)?;
417        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
418        let mut device = TextExtractionDevice::new().keep_ligatures(self.keep_ligatures);
419        let mut ctx = Self::create_context_with_settings(page, settings);
420        interpret_page(page, &mut ctx, &mut device);
421        Self::check_limit_slot(&slot)?;
422        Ok(device.into_text())
423    }
424
425    /// Extract text from a sequence of pages while reusing the same settings object.
426    #[doc(hidden)]
427    pub fn extract_text_pages_reusing_settings<I>(&self, indices: I) -> Result<Vec<String>>
428    where
429        I: IntoIterator<Item = usize>,
430    {
431        let pages = self.pdf.pages();
432        let mut settings = self.text_extraction_settings();
433        let indices = indices.into_iter();
434        let (lower_bound, upper_bound) = indices.size_hint();
435        let mut texts = Vec::with_capacity(upper_bound.unwrap_or(lower_bound));
436
437        for index in indices {
438            let page = pages.get(index).ok_or(EngineError::PageOutOfRange {
439                index,
440                count: pages.len(),
441            })?;
442            let (text, next_settings) =
443                Self::extract_text_with_settings(page, settings, self.keep_ligatures);
444            settings = next_settings;
445            texts.push(text);
446        }
447
448        Ok(texts)
449    }
450
451    /// Extract structured text blocks from a page.
452    pub fn extract_text_blocks(&self, index: usize) -> Result<Vec<TextBlock>> {
453        let page = self.get_page(index)?;
454        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
455        let mut device = TextExtractionDevice::new().keep_ligatures(self.keep_ligatures);
456        let mut ctx = Self::create_context_with_settings(page, settings);
457        interpret_page(page, &mut ctx, &mut device);
458        Self::check_limit_slot(&slot)?;
459        Ok(device.into_blocks())
460    }
461
462    /// Extract structured text blocks with tight per-glyph geometry.
463    ///
464    /// Same blocks as [`Self::extract_text_blocks`], but the device runs in
465    /// [`GeometryMode::RichGeometry`] so every span also carries its
466    /// outline-tight glyph bounds, advances and bound sources. The door's
467    /// span dicts read those; nothing else in the engine consumes them.
468    pub fn extract_text_blocks_rich(&self, index: usize) -> Result<Vec<TextBlock>> {
469        let page = self.get_page(index)?;
470        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
471        let mut device = TextExtractionDevice::with_mode(GeometryMode::RichGeometry)
472            .keep_ligatures(self.keep_ligatures);
473        let mut ctx = Self::create_context_with_settings(page, settings);
474        interpret_page(page, &mut ctx, &mut device);
475        Self::check_limit_slot(&slot)?;
476        Ok(device.into_blocks())
477    }
478
479    /// Extract structured text blocks from all pages, reusing interpreter settings.
480    pub fn extract_all_text_blocks(&self) -> Vec<Vec<TextBlock>> {
481        let pages = self.pdf.pages();
482        let mut settings = self.text_extraction_settings();
483        let mut blocks = Vec::with_capacity(pages.len());
484
485        for page in pages.iter() {
486            let (page_blocks, next_settings) =
487                Self::extract_text_blocks_with_settings(page, settings, self.keep_ligatures);
488            settings = next_settings;
489            blocks.push(page_blocks);
490        }
491
492        blocks
493    }
494
495    /// Every text block of the document with its page and enclosing box.
496    ///
497    /// The positional counterpart of plain text extraction: what the
498    /// `text_with_layout` call of each binding returns.
499    pub fn text_with_layout(&self) -> Vec<crate::text::LayoutBlock> {
500        let mut out = Vec::new();
501        for (idx, blocks) in self.extract_all_text_blocks().into_iter().enumerate() {
502            for block in blocks {
503                out.push(crate::text::LayoutBlock {
504                    bbox: block.bbox(),
505                    text: block.text(),
506                    page: idx + 1,
507                });
508            }
509        }
510        out
511    }
512
513    /// Extract one page's paragraphs (#567).
514    ///
515    /// Preview: output quality is still improving and the API may change in a minor release.
516    ///
517    /// Where [`Self::extract_text_blocks`] hands back the page's LINES, this
518    /// hands back the units a caller chunks, cites or embeds: the lines merged
519    /// into paragraphs, never across a column gap and never across a table.
520    ///
521    /// `first_index` is where this page's blocks start in the document's
522    /// reading order; [`Self::extract_blocks`] counts it for a whole document.
523    ///
524    /// The heading levels are taken from THIS PAGE's statistics, because a page
525    /// asked for on its own is the only document there is to take them from
526    /// (#568). A caller that wants the levels the whole document implies asks
527    /// for the whole document: [`Self::extract_blocks`] runs the same
528    /// classifier over every page at once.
529    pub fn extract_page_blocks(&self, index: usize, first_index: usize) -> Result<Vec<Block>> {
530        let page = self.get_page(index)?;
531        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
532        let mut device = TextExtractionDevice::new();
533        let mut ctx = Self::create_context_with_settings(page, settings);
534        interpret_page(page, &mut ctx, &mut device);
535        Self::check_limit_slot(&slot)?;
536        let mut blocks = device.into_paragraph_blocks(index, first_index);
537        // No furniture: a running head is what THREE pages repeat, and one
538        // page repeats nothing (#571). The reading order still runs -- the
539        // columns of a page are a property of that page.
540        crate::headings::classify(&mut blocks);
541        // AFTER the headings: stacked headings arrive on the same wide pitch
542        // as a double-spaced run, and joining first turns two headings into
543        // one paragraph (#636).
544        let blocks = crate::text::join_double_spaced_runs(blocks, first_index);
545        let mut blocks = crate::reading_order::arrange(blocks, first_index);
546        // Folded after the decisions, which all read the characters the
547        // glyphs declared (see `crate::ligatures`).
548        if !self.keep_ligatures {
549            for block in &mut blocks {
550                crate::ligatures::fold_block(block);
551            }
552        }
553        Ok(blocks)
554    }
555
556    /// Every block of the document, in reading order, numbered from zero
557    /// (#567, #573).
558    ///
559    /// Preview: output quality is still improving and the API may change in a minor release.
560    ///
561    /// `options` says which pages the caller wants back; the document is read
562    /// whole whatever it says, because what a block IS is decided against the
563    /// rest of the document. See [`ExtractOptions`].
564    ///
565    /// A `Result`, and the limit collector on every page, because the whole
566    /// document is exactly where a caller reading untrusted files leans on
567    /// [`ProcessingLimits`]: a convenience method that swallowed a
568    /// `StreamTooLarge` would hand back a silently short document where the
569    /// caller asked to be told (codex, round 1).
570    ///
571    /// NOTHING ON THE TEXT PATH RAISES IT TODAY. The only producer of that
572    /// warning is the image-XObject decode, and image decode is lazy -- it
573    /// happens in the rendering device and not in `interpret_page`, which is
574    /// why `crates/pdfluent/tests/processing_limits.rs` has to call `to_images`
575    /// to provoke it. So this collector is the same inert guard
576    /// [`Self::extract_text`] and [`Self::extract_page_blocks`] already carry,
577    /// and it is here for the same reason: the day something on this path does
578    /// raise it, the three methods answer alike instead of two of them
579    /// answering and the convenience one going quiet.
580    ///
581    /// [`ProcessingLimits`]: crate::limits::ProcessingLimits
582    pub fn extract_blocks(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
583        options.validate(self.page_count())?;
584        // OCR first, when asked: extraction reads the searchable copy, so
585        // recognized words segment exactly like born-digital text. The copy
586        // runs with OCR off -- its layer is already laid.
587        if let Some(copy) = self.ocr_copy_if_requested(options)? {
588            let mut inner = options.clone();
589            inner.ocr = crate::OcrMode::Never;
590            return copy.extract_blocks(&inner);
591        }
592        // Raw content-stream order, when asked (#643): the runs the pages
593        // draw in the order they draw them, grouped into blocks by
594        // content-order proximity -- and nothing else. The furniture, the
595        // headings, the reading-order sort and the learned pass never run;
596        // the window and the ligature fold still do, so raw blocks page and
597        // read like any.
598        if options.raw_order {
599            return self.extract_blocks_in_raw_order(options);
600        }
601        let (blocks, ruled, lattices, images, graphics) = self
602            .blocks_rules_and_rulings_before_learning(
603                options.table_strategy,
604                options.document_font_names,
605                options.cell_path_words,
606                options.actual_text,
607                options.clip_margins,
608            )?;
609        // LAST OF ALL, over the whole document: the learned decisions (#599).
610        // They read the finished blocks because that is what they were trained
611        // on -- `feature_dump` measures exactly this state -- and the whole
612        // document because a heading model reads repetition across pages.
613        let sizes: Vec<(f64, f64)> = (0..self.page_count())
614            .map(|page| {
615                self.page_geometry(page)
616                    .map(|g| g.effective_dimensions())
617                    .unwrap_or((0.0, 0.0))
618            })
619            .collect();
620        let tatr = self.tatr_context(options.tatr())?;
621        let blocks = if options.learned {
622            crate::learned::refine_with_tatr(
623                blocks,
624                &sizes,
625                &ruled,
626                &lattices,
627                &images,
628                mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
629                tatr.as_ref(),
630                options.cell_path_words,
631            )
632        } else {
633            blocks
634        };
635        let mut blocks = blocks;
636        crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
637        crate::blocks::peel_table_edges(
638            &mut blocks,
639            crate::learned::SWITCHES.table_runs_on_figures,
640        );
641        crate::blocks::tables_across_pages(&mut blocks);
642        Self::demote_fragment_tables(&mut blocks);
643        crate::blocks::join_heading_subtitles(&mut blocks);
644        crate::blocks::join_heading_lines(&mut blocks);
645        crate::blocks::split_paragraphs_on_steps(&mut blocks);
646        crate::split_merged_rows::split_merged_rows(&mut blocks);
647        crate::empty_lines::drop_empty_lines(&mut blocks);
648        crate::continuation_rows::join_continuation_rows(&mut blocks);
649        crate::stub_wrap::join_wrapped_stubs(&mut blocks);
650        crate::trailing_sign::move_trailing_signs(&mut blocks);
651        crate::cell_tidy::tidy_cells(&mut blocks);
652        if options.use_tags {
653            blocks = crate::tagged_evidence::apply(
654                &self.pdf,
655                &self.text_extraction_settings(),
656                blocks,
657                &sizes,
658                options.cell_path_words,
659            );
660        }
661        let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
662        let mut blocks = crate::blocks::window(blocks, options);
663        // Folded after the decisions, which all read the characters the
664        // glyphs declared (see `crate::ligatures`).
665        if !self.keep_ligatures {
666            for block in &mut blocks {
667                crate::ligatures::fold_block(block);
668            }
669        }
670        crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
671        Ok(blocks)
672    }
673
674    /// [`Self::extract_blocks`], with the learned switches given, so a test of
675    /// one switch reads its table with that switch on and the shipped tree
676    /// reads the shipped switches. The table strategy still masks the table
677    /// proposers it sits out; [`TableStrategy::Both`](crate::TableStrategy::Both)
678    /// masks nothing.
679    ///
680    /// Hidden rather than published: a caller gets [`Self::extract_blocks`].
681    #[doc(hidden)]
682    pub fn extract_blocks_with_switches(
683        &self,
684        options: &ExtractOptions,
685        switches: crate::learned::Switches,
686    ) -> Result<Vec<Block>> {
687        options.validate(self.page_count())?;
688        // OCR first, when asked, like `extract_blocks`.
689        if let Some(copy) = self.ocr_copy_if_requested(options)? {
690            let mut inner = options.clone();
691            inner.ocr = crate::OcrMode::Never;
692            return copy.extract_blocks_with_switches(&inner, switches);
693        }
694        let (blocks, ruled, lattices, images, graphics) = self
695            .blocks_rules_and_rulings_before_learning(
696                options.table_strategy,
697                options.document_font_names,
698                options.cell_path_words,
699                options.actual_text,
700                options.clip_margins,
701            )?;
702        let sizes: Vec<(f64, f64)> = (0..self.page_count())
703            .map(|page| {
704                self.page_geometry(page)
705                    .map(|g| g.effective_dimensions())
706                    .unwrap_or((0.0, 0.0))
707            })
708            .collect();
709        let tatr = self.tatr_context(options.tatr())?;
710        let blocks = if options.learned {
711            crate::learned::refine_with_tatr(
712                blocks,
713                &sizes,
714                &ruled,
715                &lattices,
716                &images,
717                mask_table_switches(switches, options.table_strategy),
718                tatr.as_ref(),
719                options.cell_path_words,
720            )
721        } else {
722            blocks
723        };
724        let mut blocks = blocks;
725        crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
726        crate::blocks::peel_table_edges(&mut blocks, switches.table_runs_on_figures);
727        crate::blocks::tables_across_pages(&mut blocks);
728        Self::demote_fragment_tables(&mut blocks);
729        crate::blocks::join_heading_subtitles(&mut blocks);
730        crate::blocks::join_heading_lines(&mut blocks);
731        crate::split_merged_rows::split_merged_rows(&mut blocks);
732        crate::empty_lines::drop_empty_lines(&mut blocks);
733        crate::continuation_rows::join_continuation_rows(&mut blocks);
734        crate::stub_wrap::join_wrapped_stubs(&mut blocks);
735        crate::trailing_sign::move_trailing_signs(&mut blocks);
736        crate::cell_tidy::tidy_cells(&mut blocks);
737        if options.use_tags {
738            blocks = crate::tagged_evidence::apply(
739                &self.pdf,
740                &self.text_extraction_settings(),
741                blocks,
742                &sizes,
743                options.cell_path_words,
744            );
745        }
746        let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
747        let mut blocks = crate::blocks::window(blocks, options);
748        // Folded after the decisions, like `extract_blocks` above.
749        if !self.keep_ligatures {
750            for block in &mut blocks {
751                crate::ligatures::fold_block(block);
752            }
753        }
754        crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
755        Ok(blocks)
756    }
757
758    /// Every word of the document, with the box it was drawn in.
759    ///
760    /// Preview: output quality is still improving and the API may change in a minor release.
761    ///
762    /// The `extract_words=True` of pymupdf4llm: what a RAG pipeline cites,
763    /// highlights and grounds with. Built from the blocks
764    /// [`Self::extract_blocks`] returns -- same options, same window -- by
765    /// splitting each span's text on whitespace and taking the union of that
766    /// word's `char_bounds`. A span without `char_bounds` gives its words
767    /// the span box, split proportionally by character count. Words come in
768    /// reading order, block by block and line by line. One function, no
769    /// second text pass.
770    ///
771    /// # Example
772    ///
773    /// ```no_run
774    /// use pdf_engine::{ExtractOptions, PdfDocument};
775    ///
776    /// let document = PdfDocument::open(std::fs::read("report.pdf").unwrap()).unwrap();
777    /// let words = document.extract_words(&ExtractOptions::default()).unwrap();
778    /// for word in &words {
779    ///     println!("p{} {:?} {:?}", word.page, word.text, word.bbox);
780    /// }
781    /// ```
782    pub fn extract_words(&self, options: &ExtractOptions) -> Result<Vec<Word>> {
783        let blocks = self.extract_blocks(options)?;
784        Ok(crate::words::words_from_blocks(&blocks))
785    }
786
787    /// [`Self::extract_blocks`], and the rules each page drew beside them
788    /// (#600).
789    ///
790    /// The second half is one `Vec` per page, in page order, of the horizontal
791    /// rules the page drew -- what `crates/pdf-learned` reads to tell a ruled
792    /// grid from a column of numbers that merely lines up. The blocks are the
793    /// blocks [`Self::extract_blocks`] returns, learned decisions and all, so
794    /// there is no second reading of the page to disagree with the first.
795    ///
796    /// Hidden rather than published, for the reason
797    /// [`TextExtractionDevice::into_paragraph_blocks_and_rules`] gives.
798    #[doc(hidden)]
799    pub fn extract_blocks_and_rules(
800        &self,
801        options: &ExtractOptions,
802    ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
803        let (blocks, ruled, _) = self.extract_blocks_rules_and_withdrawn(options)?;
804        Ok((blocks, ruled))
805    }
806
807    /// [`Self::extract_blocks_and_rules`], and the tables rule A took out of
808    /// the blocks (#636), for the balanced tier.
809    pub(crate) fn extract_blocks_rules_and_withdrawn(
810        &self,
811        options: &ExtractOptions,
812    ) -> Result<BlocksRulesWithdrawn> {
813        options.validate(self.page_count())?;
814        // OCR first, when asked, like `extract_blocks`.
815        if let Some(copy) = self.ocr_copy_if_requested(options)? {
816            let mut inner = options.clone();
817            inner.ocr = crate::OcrMode::Never;
818            return copy.extract_blocks_rules_and_withdrawn(&inner);
819        }
820        let (blocks, ruled, lattices, images, graphics) = self
821            .blocks_rules_and_rulings_before_learning(
822                options.table_strategy,
823                options.document_font_names,
824                options.cell_path_words,
825                options.actual_text,
826                options.clip_margins,
827            )?;
828        let sizes: Vec<(f64, f64)> = (0..self.page_count())
829            .map(|page| {
830                self.page_geometry(page)
831                    .map(|g| g.effective_dimensions())
832                    .unwrap_or((0.0, 0.0))
833            })
834            .collect();
835        let tatr = self.tatr_context(options.tatr())?;
836        let blocks = if options.learned {
837            crate::learned::refine_with_tatr(
838                blocks,
839                &sizes,
840                &ruled,
841                &lattices,
842                &images,
843                mask_table_switches(crate::learned::SWITCHES, options.table_strategy),
844                tatr.as_ref(),
845                options.cell_path_words,
846            )
847        } else {
848            blocks
849        };
850        let mut blocks = blocks;
851        crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
852        crate::blocks::peel_table_edges(
853            &mut blocks,
854            crate::learned::SWITCHES.table_runs_on_figures,
855        );
856        crate::blocks::tables_across_pages(&mut blocks);
857        Self::demote_fragment_tables(&mut blocks);
858        crate::blocks::join_heading_subtitles(&mut blocks);
859        crate::blocks::join_heading_lines(&mut blocks);
860        crate::blocks::split_paragraphs_on_steps(&mut blocks);
861        crate::split_merged_rows::split_merged_rows(&mut blocks);
862        crate::empty_lines::drop_empty_lines(&mut blocks);
863        crate::continuation_rows::join_continuation_rows(&mut blocks);
864        crate::stub_wrap::join_wrapped_stubs(&mut blocks);
865        crate::trailing_sign::move_trailing_signs(&mut blocks);
866        crate::cell_tidy::tidy_cells(&mut blocks);
867        if options.use_tags {
868            blocks = crate::tagged_evidence::apply(
869                &self.pdf,
870                &self.text_extraction_settings(),
871                blocks,
872                &sizes,
873                options.cell_path_words,
874            );
875        }
876        let blocks = Self::finish_blocks_with_figures(blocks, &images, &graphics, options);
877        let mut blocks = crate::blocks::window(blocks, options);
878        // Folded after the decisions, like `extract_blocks` above.
879        if !self.keep_ligatures {
880            for block in &mut blocks {
881                crate::ligatures::fold_block(block);
882            }
883        }
884        let withdrawn = crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
885        Ok((blocks, ruled, withdrawn))
886    }
887
888    /// The blocks and how sure the table pass is of each page, from one pass.
889    ///
890    /// The blocks are those [`Self::extract_blocks`] returns with
891    /// [`ExtractOptions::default`]; the signals those [`Self::table_signals`]
892    /// returns. One computation, so a caller that wants both never runs the
893    /// table pass twice.
894    #[doc(hidden)]
895    pub fn extract_blocks_with_signals(
896        &self,
897    ) -> Result<(
898        Vec<Block>,
899        Vec<Option<crate::learned::tables::PageTableSignals>>,
900    )> {
901        let (blocks, ruled, lattices, images, graphics) = self
902            .blocks_rules_and_rulings_before_learning(
903                crate::TableStrategy::Both,
904                false,
905                false,
906                false,
907                None,
908            )?;
909        let sizes: Vec<(f64, f64)> = (0..self.page_count())
910            .map(|page| {
911                self.page_geometry(page)
912                    .map(|g| g.effective_dimensions())
913                    .unwrap_or((0.0, 0.0))
914            })
915            .collect();
916        let (blocks, signals) = crate::learned::refine_with_images_and_signals_with_cell_path_words(
917            blocks,
918            &sizes,
919            &ruled,
920            &lattices,
921            &images,
922            crate::learned::SWITCHES,
923            // What this entry promises: `extract_blocks` with the default.
924            false,
925        );
926        let mut blocks = blocks;
927        crate::table_row_reclaim::reclaim_table_edge_rows(&mut blocks, &lattices);
928        crate::blocks::peel_table_edges(
929            &mut blocks,
930            crate::learned::SWITCHES.table_runs_on_figures,
931        );
932        crate::blocks::tables_across_pages(&mut blocks);
933        Self::demote_fragment_tables(&mut blocks);
934        crate::blocks::join_heading_subtitles(&mut blocks);
935        crate::blocks::join_heading_lines(&mut blocks);
936        crate::blocks::split_paragraphs_on_steps(&mut blocks);
937        crate::split_merged_rows::split_merged_rows(&mut blocks);
938        crate::empty_lines::drop_empty_lines(&mut blocks);
939        crate::continuation_rows::join_continuation_rows(&mut blocks);
940        crate::stub_wrap::join_wrapped_stubs(&mut blocks);
941        crate::trailing_sign::move_trailing_signs(&mut blocks);
942        crate::cell_tidy::tidy_cells(&mut blocks);
943        // The blocks this promises are `extract_blocks` under the default,
944        // figures and all.
945        let mut blocks = Self::finish_blocks_with_figures(
946            blocks,
947            &images,
948            &graphics,
949            &ExtractOptions::default(),
950        );
951        crate::table_trace::settle_finals(&mut blocks, &lattices, &graphics);
952        Ok((blocks, signals))
953    }
954
955    /// How sure the table pass is of each page: the same pass
956    /// [`Self::extract_blocks`] runs, with the numbers beside the decision.
957    ///
958    /// One entry per page, in page order. `None` where the learned pass does
959    /// not run: the switch off, or a scanned page with nothing to ask of --
960    /// "not asked" is not "no table".
961    #[doc(hidden)]
962    pub fn table_signals(&self) -> Result<Vec<Option<crate::learned::tables::PageTableSignals>>> {
963        Ok(self.extract_blocks_with_signals()?.1)
964    }
965
966    /// The TATR context for one extraction, if asked for (track B, P3).
967    ///
968    /// The model is resolved up front: where the user asked for TATR and the
969    /// model is absent the extraction refuses here with the shared layer's
970    /// `MODEL_NOT_AVAILABLE` -- naming the install -- rather than running a
971    /// silent fast pass. The fake seam answers without a model, so it skips
972    /// the check.
973    pub(crate) fn tatr_context(
974        &self,
975        tatr: Option<&TatrOptions>,
976    ) -> Result<Option<crate::learned::tables::TatrContext<'_>>> {
977        let Some(options) = tatr else {
978            return Ok(None);
979        };
980        if options.test_hook.is_some() {
981            return Ok(Some(crate::learned::tables::TatrContext::new(
982                self,
983                options.clone(),
984            )));
985        }
986        #[cfg(feature = "ort-tables")]
987        {
988            let config = options.runtime_config();
989            pdf_ocr::runtime::session(&crate::learned::tables_tatr::TATR_SPEC, &config)
990                .map_err(std::io::Error::from)?;
991            Ok(Some(crate::learned::tables::TatrContext::new(
992                self,
993                options.clone(),
994            )))
995        }
996        #[cfg(not(feature = "ort-tables"))]
997        {
998            // Unreachable through the serving entries: `validate` refuses a
999            // TATR request without the fake before the pass runs.
1000            Err(EngineError::Io(std::io::Error::new(
1001                std::io::ErrorKind::InvalidInput,
1002                crate::error::OptionsError::TatrNeedsOrtTables,
1003            )))
1004        }
1005    }
1006}
1007
1008/// What a run of table cells reads as, for the fragment veto (#636).
1009struct FragmentGrid {
1010    /// Over a third of the grid's positions are holes.
1011    sparse: bool,
1012    /// The median cell holds at most the fragment median.
1013    short: bool,
1014    /// The median cell's alphanumeric count.
1015    median: usize,
1016    /// "continu" occurrences across the cells.
1017    continued_hits: usize,
1018    /// Every cell carrying one stands in row 0.
1019    continued_in_first_row: bool,
1020    /// Some cell holds an alphabetic run of four or more.
1021    has_word: bool,
1022    /// The union of the cells' boxes.
1023    bbox: [f64; 4],
1024    /// The median cell height, the caption gap's em.
1025    em: f64,
1026}
1027
1028/// The final blocks, the rules each page drew, and the tables rule A2 withdrew.
1029type BlocksRulesWithdrawn = (
1030    Vec<Block>,
1031    Vec<Vec<crate::text::HorizontalRule>>,
1032    Vec<crate::table_trace::WithdrawnTable>,
1033);
1034
1035/// What one box holds for the rebuild: the page's blocks, words and lines
1036/// plus the rulings and line indices the box claims. Built once by
1037/// [`PdfDocument::table_in_region_inputs`] for both the region entry and the
1038/// TATR-only attempt, so the two cannot drift.
1039struct RegionTableInputs {
1040    /// The page's blocks before the learned pass, in page order.
1041    page_blocks: Vec<Block>,
1042    /// The page's words and geometry.
1043    features: crate::learned::features::PageFeatures,
1044    /// The page's judge lines.
1045    lines: Vec<crate::learned::judge::Line>,
1046    /// The rulings the region holds, outer edges included.
1047    held: Vec<crate::text::Ruling>,
1048    /// The judge lines the padded box holds.
1049    region_lines: Vec<usize>,
1050}
1051
1052impl PdfDocument {
1053    /// The engine's table inside a given region: the same rebuild the learned
1054    /// table pass emits for a chosen region, over the lines a box holds.
1055    ///
1056    /// `page` counts from zero; `bbox` is `[x0, y0, x1, y1]` in PDF user space,
1057    /// y up. A line belongs when at least half of its own area lies inside the
1058    /// box once grown by [`REGION_PAD_LINES`] median line heights on every side
1059    /// -- the judge's `held_by` rule, so a word a loose teacher box cuts is
1060    /// neither halved nor dropped -- and the rulings that intersect the grown
1061    /// box go in with it, so a ruled table decodes by its lines. No stitch
1062    /// absorb and no is-table refusal: the caller already said "table here".
1063    ///
1064    /// `tableCell` blocks as the pass would emit them, in row-major order, or
1065    /// an empty vector where fewer than two lines belong, the lines hold no
1066    /// grid, or the grid is a figure's own labels or drawing fragments (the
1067    /// region veto, [`region_fragment_veto`]).
1068    ///
1069    /// [`REGION_PAD_LINES`]: crate::learned::tables::REGION_PAD_LINES
1070    #[doc(hidden)]
1071    pub fn table_in_region(&self, page: usize, bbox: [f64; 4]) -> Result<Vec<Block>> {
1072        self.table_in_region_inner(page, bbox, None)
1073    }
1074
1075    /// [`Self::table_in_region`], with TATR table structure inside the box
1076    /// (track B, P3).
1077    ///
1078    /// A teacher box IS a detector box -- the caller said "table here" -- so
1079    /// TATR is offered it even under `detector_regions_only`. Where TATR
1080    /// fires its cells are the table, and where it declines, or the region
1081    /// fails, the same rebuild [`Self::table_in_region`] runs. A missing
1082    /// model refuses up front, never a silent base run.
1083    ///
1084    /// Hidden rather than published: a caller gets [`Self::extract_blocks`].
1085    #[doc(hidden)]
1086    pub fn table_in_region_with_tatr(
1087        &self,
1088        page: usize,
1089        bbox: [f64; 4],
1090        tatr: &TatrOptions,
1091    ) -> Result<Vec<Block>> {
1092        let tatr = self.tatr_context(Some(tatr))?;
1093        self.table_in_region_inner(page, bbox, tatr.as_ref())
1094    }
1095
1096    /// The one region entry: the lines a box holds, rebuilt.
1097    fn table_in_region_inner(
1098        &self,
1099        page: usize,
1100        bbox: [f64; 4],
1101        tatr: Option<&crate::learned::tables::TatrContext>,
1102    ) -> Result<Vec<Block>> {
1103        let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1104            return Ok(Vec::new());
1105        };
1106        let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1107        // A teacher box IS a detector box: the caller said "table here".
1108        let region_tatr = tatr.map(|context| crate::learned::tables::TatrRegion::of(context, true));
1109        let mut out = crate::learned::tables::rebuild_region(
1110            &refs,
1111            &inputs.lines,
1112            &inputs.features.words,
1113            &inputs.held,
1114            &inputs.region_lines,
1115            None,
1116            crate::learned::SWITCHES,
1117            region_tatr.as_ref(),
1118            // The teacher path takes no options: its cells read as before.
1119            false,
1120        );
1121        // A FIGURE'S FRAGMENTS ARE NOT A TABLE HERE EITHER (#636): the
1122        // region rebuild grids the same shards the `find` veto refuses, and
1123        // the balanced tier never reaches that veto. The region's lines then
1124        // stay text, as they did before the spans joined.
1125        if Self::region_fragment_veto(&out, &inputs.page_blocks) {
1126            crate::table_trace::emit_run(
1127                page,
1128                &out,
1129                &inputs.page_blocks,
1130                Some(crate::table_trace::PageCtx {
1131                    rulings: &inputs.held,
1132                    graphics: &[],
1133                }),
1134            );
1135            return Ok(Vec::new());
1136        }
1137        for block in out.iter_mut() {
1138            // The rebuild's own stamp, unless TATR fired inside it (the
1139            // stamp it left is exact already).
1140            if block.table_origin == crate::table_trace::TableOrigin::Other {
1141                block.table_origin = crate::table_trace::TableOrigin::Region;
1142            }
1143        }
1144        for (i, block) in out.iter_mut().enumerate() {
1145            block.index = i;
1146        }
1147        if !self.keep_ligatures {
1148            for block in out.iter_mut() {
1149                crate::ligatures::fold_block(block);
1150            }
1151        }
1152        Ok(out)
1153    }
1154
1155    /// TATR ONLY inside a box: the fired table cells, or `None` where TATR
1156    /// declines, fires under coverage, or the region fails (track B, P4).
1157    ///
1158    /// The balanced tier's R1 step (1): per kept table region, TATR runs
1159    /// first, and only on a fire are its cells the region's table -- on any
1160    /// fallback the tier continues exactly as without TATR (the engine grid,
1161    /// then [`Self::table_in_region`]). The attempt reads the same rebuild
1162    /// inputs [`Self::table_in_region`] does (one computation, see
1163    /// [`Self::table_in_region_inputs`]), the same verdict
1164    /// ([`crate::learned::tables::tatr_for_region`], with its coverage bar,
1165    /// gap fill and `tatr | fallback(reason)` trace), and a kept box IS a
1166    /// detector box, so it is offered even under `detector_regions_only`.
1167    ///
1168    /// Only the table cells come back: the region lines no cell claims stay
1169    /// the balanced assembly's to place (R4), exactly as with an engine
1170    /// grid. `None` where the box holds fewer than two lines, as
1171    /// [`Self::table_in_region`] is empty there.
1172    ///
1173    /// Behind `ort-layout`: the balanced tier is the only caller, and without
1174    /// it this is dead code.
1175    #[cfg(feature = "ort-layout")]
1176    pub(crate) fn tatr_cells_in_region(
1177        &self,
1178        page: usize,
1179        bbox: [f64; 4],
1180        tatr: &crate::learned::tables::TatrContext,
1181    ) -> Result<Option<Vec<Block>>> {
1182        let Some(inputs) = self.table_in_region_inputs(page, bbox)? else {
1183            return Ok(None);
1184        };
1185        let refs: Vec<&Block> = inputs.page_blocks.iter().collect();
1186        let valid: Vec<usize> = inputs
1187            .region_lines
1188            .iter()
1189            .copied()
1190            .filter(|&i| i < inputs.lines.len())
1191            .collect();
1192        if valid.is_empty() {
1193            return Ok(None);
1194        }
1195        let (pair_words, _) = crate::learned::tables::words_of_region(
1196            &refs,
1197            &inputs.lines,
1198            &inputs.features.words,
1199            &valid,
1200        );
1201        if pair_words.is_empty() {
1202            return Ok(None);
1203        }
1204        // The trace's page, as `rebuild_region` derives it: the first valid
1205        // line's block's page.
1206        let page_number = valid
1207            .iter()
1208            .filter_map(|&i| inputs.lines.get(i))
1209            .filter_map(|line| refs.get(line.block))
1210            .map(|block| block.page)
1211            .next()
1212            .unwrap_or(0);
1213        let region = crate::learned::tables::TatrRegion::of(tatr, true);
1214        match crate::learned::tables::tatr_for_region_with_cell_path_words(
1215            Some(&region),
1216            &refs,
1217            &inputs.lines,
1218            &inputs.features.words,
1219            &inputs.region_lines,
1220            &pair_words,
1221            page_number,
1222            // The teacher path takes no options: its cells read as before.
1223            false,
1224        ) {
1225            crate::learned::tables::TatrVerdict::Cells(blocks) => {
1226                let mut cells: Vec<Block> = blocks
1227                    .into_iter()
1228                    .filter(|b| matches!(b.kind, crate::BlockKind::TableCell { .. }))
1229                    .collect();
1230                if cells.is_empty() {
1231                    return Ok(None);
1232                }
1233                for block in cells.iter_mut() {
1234                    block.table_origin = crate::table_trace::TableOrigin::Tatr;
1235                }
1236                // The same veto as the region rebuild: TATR can fire on a
1237                // figure's labels too, and its cells read the same way.
1238                if Self::region_fragment_veto(&cells, &inputs.page_blocks) {
1239                    crate::table_trace::emit_run(
1240                        page,
1241                        &cells,
1242                        &inputs.page_blocks,
1243                        Some(crate::table_trace::PageCtx {
1244                            rulings: &inputs.held,
1245                            graphics: &[],
1246                        }),
1247                    );
1248                    return Ok(None);
1249                }
1250                for (i, block) in cells.iter_mut().enumerate() {
1251                    block.index = i;
1252                }
1253                if !self.keep_ligatures {
1254                    for block in cells.iter_mut() {
1255                        crate::ligatures::fold_block(block);
1256                    }
1257                }
1258                Ok(Some(cells))
1259            }
1260            crate::learned::tables::TatrVerdict::Fallback(_) => Ok(None),
1261        }
1262    }
1263
1264    /// What one box holds for the rebuild: the one computation behind
1265    /// [`Self::table_in_region`] and [`Self::tatr_cells_in_region`].
1266    ///
1267    /// `None` where the page holds no blocks or fewer than two lines belong
1268    /// to the box -- the two cases the region entry reads as empty.
1269    fn table_in_region_inputs(
1270        &self,
1271        page: usize,
1272        bbox: [f64; 4],
1273    ) -> Result<Option<RegionTableInputs>> {
1274        let count = self.page_count();
1275        if page >= count {
1276            return Err(EngineError::PageOutOfRange { index: page, count });
1277        }
1278        let (blocks, _, rulings, _, _) = self.blocks_rules_and_rulings_before_learning(
1279            crate::TableStrategy::Both,
1280            false,
1281            false,
1282            false,
1283            None,
1284        )?;
1285
1286        let page_blocks: Vec<Block> = blocks.into_iter().filter(|b| b.page == page).collect();
1287        if page_blocks.is_empty() {
1288            return Ok(None);
1289        }
1290        let (width, height) = self.page_geometry(page)?.effective_dimensions();
1291        let refs: Vec<&Block> = page_blocks.iter().collect();
1292        let features = crate::learned::features::page_features(&refs, width, height);
1293        let lines = crate::learned::judge::lines_of_page(&refs, &features.words, &[]);
1294        let mut heights: Vec<f64> = lines
1295            .iter()
1296            .map(|line| line.bbox[3] - line.bbox[1])
1297            .filter(|h| h.is_finite() && *h > 0.0)
1298            .collect();
1299        heights.sort_by(f64::total_cmp);
1300        let median = if heights.is_empty() {
1301            0.0
1302        } else if heights.len() % 2 == 1 {
1303            heights[heights.len() / 2]
1304        } else {
1305            (heights[heights.len() / 2 - 1] + heights[heights.len() / 2]) / 2.0
1306        };
1307        let pad = crate::learned::tables::REGION_PAD_LINES * median;
1308        let left = bbox[0].min(bbox[2]) - pad;
1309        let bottom = bbox[1].min(bbox[3]) - pad;
1310        let right = bbox[0].max(bbox[2]) + pad;
1311        let top = bbox[1].max(bbox[3]) + pad;
1312        let padded = [left, bottom, right, top];
1313        let region_lines = crate::learned::judge::held_by(padded, &lines);
1314        if region_lines.len() < 2 {
1315            return Ok(None);
1316        }
1317        // THE RULINGS THE REGION HOLDS, outer edges included: a drawn box
1318        // sits outside the ink it holds -- the right edge of a wide column
1319        // stands a hundred points from the words -- so a lattice the padded box
1320        // touches goes in whole, and where no lattice touches, the rulings
1321        // that intersect the box go in for the decoder.
1322        let page_rulings: &[crate::text::Ruling] =
1323            rulings.get(page).map(Vec::as_slice).unwrap_or(&[]);
1324        let held: Vec<crate::text::Ruling> = {
1325            let lattices = crate::learned::lattice::lattices(page_rulings);
1326            let touched: Vec<[f64; 4]> = lattices
1327                .iter()
1328                .map(|lattice| lattice.bbox())
1329                .filter(|bbox| {
1330                    bbox[0] <= padded[2]
1331                        && bbox[2] >= padded[0]
1332                        && bbox[1] <= padded[3]
1333                        && bbox[3] >= padded[1]
1334                })
1335                .collect();
1336            if touched.is_empty() {
1337                page_rulings
1338                    .iter()
1339                    .copied()
1340                    .filter(|r| {
1341                        r.x0 <= padded[2]
1342                            && r.x1 >= padded[0]
1343                            && r.y0 <= padded[3]
1344                            && r.y1 >= padded[1]
1345                    })
1346                    .collect()
1347            } else {
1348                page_rulings
1349                    .iter()
1350                    .copied()
1351                    .filter(|r| {
1352                        touched.iter().any(|bbox| {
1353                            // Two points of slack: a ruling that draws the
1354                            // lattice's own edge snaps inside its bbox by the
1355                            // lattice tolerance, so the edge sits a quarter
1356                            // point outside the box it draws.
1357                            r.x0 <= bbox[2] + 2.0
1358                                && r.x1 >= bbox[0] - 2.0
1359                                && r.y0 <= bbox[3] + 2.0
1360                                && r.y1 >= bbox[1] - 2.0
1361                        })
1362                    })
1363                    .collect()
1364            }
1365        };
1366        Ok(Some(RegionTableInputs {
1367            page_blocks,
1368            features,
1369            lines,
1370            held,
1371            region_lines,
1372        }))
1373    }
1374
1375    /// Whether rebuilt region cells are a figure's own labels or drawing
1376    /// fragments rather than the table the region claims (#636).
1377    ///
1378    /// The `find` veto never sees these cells -- the balanced tier rebuilds the
1379    /// region instead -- so the verdict is read off the cells themselves, with
1380    /// the page's blocks for the caption half. It fires in two shapes, and a
1381    /// genuine word or number table fails both:
1382    ///
1383    /// - FIGURE LABELS: the median cell holds at most
1384    ///   [`FRAGMENT_CELL_MEDIAN`](crate::tables::FRAGMENT_CELL_MEDIAN)
1385    ///   alphanumeric characters, no cell holds a word (an alphabetic run of
1386    ///   four or more), and a figure-caption marker stands near the grid. A word
1387    ///   table under a caption keeps its table through its words; emptiness is
1388    ///   not measured here, because the rebuild merges corridors and a merged
1389    ///   grid reads dense where the `find` grid reads holes.
1390    /// - DRAWING SHEET: the grid carries two or more "-continued" panel labels,
1391    ///   all in its first row, and it is sparse or short (over a third of its
1392    ///   positions empty, or the median cell at most twice the fragment median).
1393    ///   A "continued" note in a body's status column is data: it never stands
1394    ///   twice in the first row.
1395    fn region_fragment_veto(cells: &[Block], page: &[Block]) -> bool {
1396        let Some(grid) = Self::fragment_grid_stats(cells) else {
1397            return false;
1398        };
1399        if grid.continued_hits >= 2
1400            && grid.continued_in_first_row
1401            && (grid.sparse || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN)
1402        {
1403            return true;
1404        }
1405        if !grid.short || grid.has_word {
1406            return false;
1407        }
1408        Self::caption_near_grid(
1409            &grid,
1410            page.iter().map(|block| (block.text.as_str(), block.bbox)),
1411        )
1412    }
1413
1414    /// Read a run of table cells the way the veto sees it: positions against
1415    /// spans, the median cell length, panel labels, words.
1416    fn fragment_grid_stats(cells: &[Block]) -> Option<FragmentGrid> {
1417        struct RegionCell<'a> {
1418            row: u16,
1419            column: u16,
1420            row_span: u16,
1421            column_span: u16,
1422            text: &'a str,
1423            bbox: [f64; 4],
1424        }
1425        let grid: Vec<RegionCell<'_>> = cells
1426            .iter()
1427            .filter_map(|block| match block.kind {
1428                crate::BlockKind::TableCell {
1429                    row,
1430                    column,
1431                    row_span,
1432                    column_span,
1433                    ..
1434                } => Some(RegionCell {
1435                    row,
1436                    column,
1437                    row_span,
1438                    column_span,
1439                    text: block.text.as_str(),
1440                    bbox: block.bbox,
1441                }),
1442                _ => None,
1443            })
1444            .collect();
1445        if grid.is_empty() {
1446            return None;
1447        }
1448        let rows = grid
1449            .iter()
1450            .map(|cell| cell.row as usize + cell.row_span.max(1) as usize)
1451            .max()
1452            .unwrap_or(0);
1453        let columns = grid
1454            .iter()
1455            .map(|cell| cell.column as usize + cell.column_span as usize)
1456            .max()
1457            .unwrap_or(0);
1458        if rows == 0 || columns == 0 {
1459            return None;
1460        }
1461        let positions = rows * columns;
1462        let filled: usize = grid
1463            .iter()
1464            .map(|cell| cell.column_span as usize * cell.row_span.max(1) as usize)
1465            .sum();
1466        let sparse = positions > 0 && filled < positions && (positions - filled) * 3 > positions;
1467        let mut lens: Vec<usize> = grid
1468            .iter()
1469            .map(|cell| cell.text.chars().filter(|c| c.is_alphanumeric()).count())
1470            .collect();
1471        lens.sort_unstable();
1472        let mid = lens.len() / 2;
1473        // The same median the `find` veto holds its cells to.
1474        let short = if lens.len() % 2 == 1 {
1475            lens[mid] <= crate::tables::FRAGMENT_CELL_MEDIAN
1476        } else {
1477            lens[mid - 1] + lens[mid] <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN
1478        };
1479        let median = lens[mid];
1480        let has_word = grid.iter().any(|cell| {
1481            let mut run = 0usize;
1482            for c in cell.text.chars() {
1483                if c.is_alphabetic() {
1484                    run += 1;
1485                    if run >= 4 {
1486                        return true;
1487                    }
1488                } else {
1489                    run = 0;
1490                }
1491            }
1492            false
1493        });
1494        let continued_hits: usize = grid
1495            .iter()
1496            .map(|cell| cell.text.to_ascii_lowercase().matches("continu").count())
1497            .sum();
1498        let continued_in_first_row = grid
1499            .iter()
1500            .all(|cell| !cell.text.to_ascii_lowercase().contains("continu") || cell.row == 0);
1501        let mut bbox = [
1502            f64::INFINITY,
1503            f64::INFINITY,
1504            f64::NEG_INFINITY,
1505            f64::NEG_INFINITY,
1506        ];
1507        let mut heights: Vec<f64> = Vec::with_capacity(grid.len());
1508        for cell in &grid {
1509            bbox[0] = bbox[0].min(cell.bbox[0]);
1510            bbox[2] = bbox[2].max(cell.bbox[2]);
1511            bbox[1] = bbox[1].min(cell.bbox[1]);
1512            bbox[3] = bbox[3].max(cell.bbox[3]);
1513            heights.push(cell.bbox[3] - cell.bbox[1]);
1514        }
1515        if !(bbox[0].is_finite() && bbox[2] > bbox[0] && bbox[3] >= bbox[1]) {
1516            return None;
1517        }
1518        heights.sort_by(f64::total_cmp);
1519        let em = heights[heights.len() / 2];
1520        if !(em.is_finite() && em > 0.0) {
1521            return None;
1522        }
1523        Some(FragmentGrid {
1524            sparse,
1525            short,
1526            median,
1527            continued_hits,
1528            continued_in_first_row,
1529            has_word,
1530            bbox,
1531            em,
1532        })
1533    }
1534
1535    /// Whether a figure-caption marker stands near a vetoed-shape grid: overlapping
1536    /// it sideways within twelve ems above or below it.
1537    fn caption_near_grid<'a>(
1538        grid: &FragmentGrid,
1539        mut page: impl Iterator<Item = (&'a str, [f64; 4])>,
1540    ) -> bool {
1541        let near = crate::tables::FIGURE_CAPTION_NEAR_EM * grid.em;
1542        page.any(|(text, bbox)| {
1543            if !crate::tables::is_figure_caption(text) {
1544                return false;
1545            }
1546            let [left, lo, right, hi] = bbox;
1547            if !(left.is_finite() && right > left && hi >= lo) {
1548                return false;
1549            }
1550            if right <= grid.bbox[0] || left >= grid.bbox[2] {
1551                return false;
1552            }
1553            let gap = if hi < grid.bbox[1] {
1554                grid.bbox[1] - hi
1555            } else if lo > grid.bbox[3] {
1556                lo - grid.bbox[3]
1557            } else {
1558                0.0
1559            };
1560            gap <= near
1561        })
1562    }
1563
1564    /// Release vetoed tables' cells back to paragraphs (#636).
1565    ///
1566    /// The builder veto refuses fragment grids, but the learned pass rebuilds
1567    /// forced regions from the same lines, so a vetoed shape can still come out
1568    /// as `tableCell`s. Every finished table is therefore held against the region
1569    /// veto once more here, page by page; a vetoed run's cells become paragraphs
1570    /// -- kind, record and confidence cleared the way `peel_table_edges` leaves
1571    /// them -- and the text stays on the page, as in base.
1572    fn demote_fragment_tables(blocks: &mut [Block]) {
1573        let mut start: Option<usize> = None;
1574        let mut runs: Vec<(usize, usize)> = Vec::new();
1575        for (i, block) in blocks.iter().enumerate() {
1576            if matches!(block.kind, crate::BlockKind::TableCell { .. }) {
1577                start.get_or_insert(i);
1578            } else if let Some(first) = start.take() {
1579                runs.push((first, i));
1580            }
1581        }
1582        if let Some(first) = start.take() {
1583            runs.push((first, blocks.len()));
1584        }
1585        for (first, end) in runs {
1586            let mut page_first = first;
1587            while page_first < end {
1588                let page = blocks[page_first].page;
1589                let mut page_end = page_first + 1;
1590                while page_end < end && blocks[page_end].page == page {
1591                    page_end += 1;
1592                }
1593                let veto = match Self::fragment_grid_stats(&blocks[page_first..page_end]) {
1594                    Some(grid) => {
1595                        (grid.continued_hits >= 2
1596                            && grid.continued_in_first_row
1597                            && (grid.sparse
1598                                || grid.median <= 2 * crate::tables::FRAGMENT_CELL_MEDIAN))
1599                            || (grid.sparse
1600                                && grid.short
1601                                && !grid.has_word
1602                                && Self::caption_near_grid(
1603                                    &grid,
1604                                    blocks
1605                                        .iter()
1606                                        .filter(|block| block.page == page)
1607                                        .map(|block| (block.text.as_str(), block.bbox)),
1608                                ))
1609                    }
1610                    None => false,
1611                };
1612                if veto {
1613                    // The trace's line for the removed candidate, before its
1614                    // cells go back to paragraphs. No page graphics in this
1615                    // scope: the ruling and drawing fields print `-`.
1616                    crate::table_trace::emit_run(page, &blocks[page_first..page_end], blocks, None);
1617                    for block in &mut blocks[page_first..page_end] {
1618                        block.kind = crate::BlockKind::Paragraph;
1619                        block.grid = None;
1620                        block.confidence = None;
1621                        block.continues_previous = false;
1622                    }
1623                }
1624                page_first = page_end;
1625            }
1626        }
1627    }
1628
1629    /// How much OCR one page needs: born-digital text, a scan, or text around
1630    /// image regions that carry no text of their own.
1631    ///
1632    /// See [`crate::ocr_need::OcrNeed`] for what each verdict means and the
1633    /// thresholds behind it. A page index that does not exist reports
1634    /// [`crate::ocr_need::OcrNeed::None`].
1635    pub fn page_needs_ocr(&self, index: usize) -> crate::ocr_need::OcrNeed {
1636        let pages = self.pdf.pages();
1637        let page = match pages.get(index) {
1638            Some(page) => page,
1639            None => return crate::ocr_need::OcrNeed::None,
1640        };
1641        let (width, height) = page.render_dimensions();
1642        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1643        let mut device = crate::ocr_need::OcrSurveyDevice::new(f64::from(width), f64::from(height));
1644        let mut ctx = Self::create_context_with_settings(page, settings);
1645        interpret_page(page, &mut ctx, &mut device);
1646        if Self::check_limit_slot(&slot).is_err() {
1647            return crate::ocr_need::OcrNeed::None;
1648        }
1649        device.decide()
1650    }
1651
1652    /// Which pages are worth sending to a vision model, and why (#624).
1653    ///
1654    /// One entry per page, in page order. What sets a flag is documented on
1655    /// [`VisionAdvice`]; today that is the scanned page and nothing else, and the
1656    /// two reasons that are not wired say so in their own documentation rather
1657    /// than quietly reading false.
1658    ///
1659    /// A PAGE THAT DRAWS NOTHING IS NOT A SCAN. An empty page yields no text
1660    /// either, and sending it to a vision model costs a call and returns
1661    /// nothing; so the flag asks for ink as well as for silence. Ink is any
1662    /// image, glyph or path the page draws, counted by the interpreter rather
1663    /// than guessed from the resources: a page whose `/XObject` names an image
1664    /// nothing ever draws is not a scan of anything.
1665    pub fn vision_advice(&self) -> Result<Vec<VisionAdvice>> {
1666        let pages = self.pdf.pages();
1667        let mut out: Vec<VisionAdvice> = Vec::with_capacity(pages.len());
1668        for (index, page) in pages.iter().enumerate() {
1669            let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1670            let mut device = InkDevice::default();
1671            let mut ctx = Self::create_context_with_settings(page, settings);
1672            interpret_page(page, &mut ctx, &mut device);
1673            Self::check_limit_slot(&slot)?;
1674            let text = self.extract_page_blocks(index, 0).map(|blocks| {
1675                blocks
1676                    .iter()
1677                    .map(|b| b.text.trim().chars().count())
1678                    .sum::<usize>()
1679            })?;
1680            out.push(VisionAdvice {
1681                no_text_layer: text < VISION_MIN_CHARACTERS && device.drew_anything(),
1682                ..VisionAdvice::default()
1683            });
1684        }
1685        Ok(out)
1686    }
1687
1688    /// The blocks exactly as the heuristics leave them, before any learned
1689    /// decision: every page, every kind, in reading order.
1690    ///
1691    /// For `feature_dump` and nothing else. A model trained on blocks the
1692    /// models had already touched would learn its own output and read it as
1693    /// progress, so the training records are made from this state, and each
1694    /// record says so (`refine: "off"`). Every table stands here, whatever
1695    /// strategy a later call asks for: the dump reads the heuristics whole.
1696    #[doc(hidden)]
1697    pub fn extract_blocks_before_learning(&self) -> Result<Vec<Block>> {
1698        let (blocks, _, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1699            crate::TableStrategy::Both,
1700            false,
1701            false,
1702            false,
1703            None,
1704        )?;
1705        Ok(blocks)
1706    }
1707
1708    /// [`Self::extract_blocks_before_learning`], and the rules each page drew,
1709    /// from the same single pass (#598, `pdfluent.features/4`).
1710    ///
1711    /// For `feature_dump`: its records are made from the blocks before any
1712    /// learned decision, and a ruled table's lines belong beside the very
1713    /// blocks they were read with -- a second interpretation of the page for
1714    /// the lines alone is a second chance for the two readings to disagree
1715    /// (T4, #600). One `Vec` of [`crate::text::HorizontalRule`] per page, in
1716    /// page order.
1717    #[doc(hidden)]
1718    pub fn extract_blocks_and_rules_before_learning(
1719        &self,
1720    ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1721        self.blocks_and_rules_before_learning()
1722    }
1723
1724    /// One page's lattice rulings and drawn-cluster boxes, in the frame of
1725    /// its blocks: what rule A reads to count the drawings inside a table
1726    /// (#636). A pass over that one page.
1727    #[cfg(feature = "ort-layout")]
1728    pub(crate) fn page_rulings_and_graphics(
1729        &self,
1730        page: usize,
1731    ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1732        let count = self.page_count();
1733        let pages = self.pdf.pages();
1734        let Some(source) = pages.get(page) else {
1735            return Err(EngineError::PageOutOfRange { index: page, count });
1736        };
1737        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1738        let mut device = TextExtractionDevice::new();
1739        let mut ctx = Self::create_context_with_settings(source, settings);
1740        interpret_page(source, &mut ctx, &mut device);
1741        Self::check_limit_slot(&slot)?;
1742        let rulings = device.take_rulings();
1743        let (width, height) = source.render_dimensions();
1744        let graphics = device.take_graphics(f64::from(width), f64::from(height));
1745        Ok((rulings, graphics))
1746    }
1747
1748    /// Every page's figure boxes, `[left, bottom, right, top]` in the frame of
1749    /// the blocks (#636): the images it places, in drawing order, then the
1750    /// figures it draws as paths (see `TextExtractionDevice::take_graphics`).
1751    ///
1752    /// A pass of its own over the pages, and only the feature dump asks for
1753    /// it: a caption model has to see where the pictures are, and nothing on
1754    /// the extraction path reads them yet. Capped at
1755    /// [`MAX_GRAPHICS_SCAN_PAGES`](crate::markdown_images::MAX_GRAPHICS_SCAN_PAGES)
1756    /// pages: one entry per page either way, and pages past the cap report
1757    /// no figures.
1758    #[doc(hidden)]
1759    pub fn extract_image_boxes(&self) -> Result<Vec<Vec<[f64; 4]>>> {
1760        let mut out = Vec::new();
1761        for (number, page) in self.pdf.pages().iter().enumerate() {
1762            if number >= markdown_images::MAX_GRAPHICS_SCAN_PAGES {
1763                out.push(Vec::new());
1764                continue;
1765            }
1766            let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1767            let mut device = TextExtractionDevice::new();
1768            let mut ctx = Self::create_context_with_settings(page, settings);
1769            interpret_page(page, &mut ctx, &mut device);
1770            Self::check_limit_slot(&slot)?;
1771            let (w, h) = page.render_dimensions();
1772            let mut boxes = device.take_images();
1773            boxes.extend(device.take_graphics(f64::from(w), f64::from(h)));
1774            out.push(boxes);
1775        }
1776        Ok(out)
1777    }
1778
1779    /// The boxes one page places its images into, in drawing order (#658).
1780    ///
1781    /// The single-page half of [`Self::extract_image_boxes`], without the
1782    /// drawn clusters and without the page cap: bindings list a page's own
1783    /// pictures through this. A page past the last one reports none.
1784    #[doc(hidden)]
1785    pub fn page_image_boxes(&self, index: usize) -> Result<Vec<[f64; 4]>> {
1786        let pages = self.pdf.pages();
1787        let Some(page) = pages.get(index) else {
1788            return Ok(Vec::new());
1789        };
1790        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1791        let mut device = TextExtractionDevice::new();
1792        let mut ctx = Self::create_context_with_settings(page, settings);
1793        interpret_page(page, &mut ctx, &mut device);
1794        Self::check_limit_slot(&slot)?;
1795        Ok(device.take_images())
1796    }
1797
1798    /// Every figure of the window, in reading order, with rendered pixels
1799    /// (#658).
1800    ///
1801    /// Preview: output quality is still improving and the API may change in a minor release.
1802    ///
1803    /// The [`BlockKind::Figure`](crate::BlockKind::Figure) blocks
1804    /// [`Self::extract_blocks`] lists under these options, each with its box
1805    /// rendered at 150 dots per inch and encoded as PNG. One render per page
1806    /// that carries figures. Names are
1807    /// [`figure_name`](crate::markdown_images::figure_name)s, the same ones
1808    /// [`Self::extract_markdown`] references.
1809    ///
1810    /// # Example
1811    ///
1812    /// ```no_run
1813    /// use pdf_engine::{ExtractOptions, PdfDocument};
1814    ///
1815    /// let document = PdfDocument::open(std::fs::read("report.pdf").unwrap()).unwrap();
1816    /// let options = ExtractOptions::default();
1817    /// for figure in document.extract_figures(&options).unwrap() {
1818    ///     std::fs::write(&figure.name, &figure.png).unwrap();
1819    /// }
1820    /// ```
1821    pub fn extract_figures(&self, options: &ExtractOptions) -> Result<Vec<Figure>> {
1822        use std::collections::BTreeMap;
1823        let blocks = self.extract_blocks(options)?;
1824        let mut pages: BTreeMap<usize, Vec<[f64; 4]>> = BTreeMap::new();
1825        for block in &blocks {
1826            if matches!(block.kind, crate::BlockKind::Figure) {
1827                pages.entry(block.page).or_default().push(block.bbox);
1828            }
1829        }
1830        let mut out = Vec::new();
1831        for (page, boxes) in &pages {
1832            let renders = markdown_images::render_crops(self, *page, boxes)?;
1833            for (ordinal, (bbox, png)) in boxes.iter().zip(renders).enumerate() {
1834                out.push(Figure {
1835                    page: *page,
1836                    bbox: *bbox,
1837                    name: markdown_images::figure_name(*page, ordinal),
1838                    png,
1839                });
1840            }
1841        }
1842        Ok(out)
1843    }
1844
1845    /// The same pass, keeping the rules each page drew (#600).
1846    fn blocks_and_rules_before_learning(
1847        &self,
1848    ) -> Result<(Vec<Block>, Vec<Vec<crate::text::HorizontalRule>>)> {
1849        let (blocks, ruled, _, _, _) = self.blocks_rules_and_rulings_before_learning(
1850            crate::TableStrategy::Both,
1851            false,
1852            false,
1853            false,
1854            None,
1855        )?;
1856        Ok((blocks, ruled))
1857    }
1858
1859    /// The thin lines and rectangle edges page `index` draws, as the table
1860    /// lattice reads them (#574). For the table proposers and their dumps.
1861    #[doc(hidden)]
1862    pub fn page_rulings(&self, index: usize) -> Result<Vec<crate::text::Ruling>> {
1863        let pages = self.pdf.pages();
1864        let Some(page) = pages.get(index) else {
1865            return Ok(Vec::new());
1866        };
1867        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1868        let mut device = TextExtractionDevice::new();
1869        let mut ctx = Self::create_context_with_settings(page, settings);
1870        interpret_page(page, &mut ctx, &mut device);
1871        Self::check_limit_slot(&slot)?;
1872        Ok(device.take_rulings())
1873    }
1874
1875    /// The rulings and the solid fills page `index` draws, in one reading
1876    /// (#574): the line network's vector inputs -- a rule over a line, an
1877    /// underline, a box around it, shading behind it -- for the dump.
1878    #[doc(hidden)]
1879    #[allow(clippy::type_complexity)]
1880    pub fn page_rulings_and_fills(
1881        &self,
1882        index: usize,
1883    ) -> Result<(Vec<crate::text::Ruling>, Vec<[f64; 4]>)> {
1884        let pages = self.pdf.pages();
1885        let Some(page) = pages.get(index) else {
1886            return Ok((Vec::new(), Vec::new()));
1887        };
1888        let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1889        let mut device = TextExtractionDevice::new();
1890        let mut ctx = Self::create_context_with_settings(page, settings);
1891        interpret_page(page, &mut ctx, &mut device);
1892        Self::check_limit_slot(&slot)?;
1893        Ok((device.take_rulings(), device.take_fills()))
1894    }
1895
1896    /// The rectangle `margins` leave on a page `width` by `height` points:
1897    /// `[left, bottom, width - right, height - top]`, y up like the blocks.
1898    /// Read per page, because every page brings its own size.
1899    fn clip_rect(margins: &ClipMargins, width: f64, height: f64) -> [f64; 4] {
1900        let [left, top, right, bottom] = margins.as_array();
1901        [left, bottom, width - right, height - top]
1902    }
1903
1904    /// The blocks in raw content-stream order: every page's runs grouped by
1905    /// content-order proximity, in the order the pages draw them (#643).
1906    fn extract_blocks_in_raw_order(&self, options: &ExtractOptions) -> Result<Vec<Block>> {
1907        let pages = self.pdf.pages();
1908        let mut blocks: Vec<Block> = Vec::new();
1909        for (index, page) in pages.iter().enumerate() {
1910            let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1911            let mut device = TextExtractionDevice::new()
1912                .raw_runs(true)
1913                .actual_text(options.actual_text);
1914            let mut ctx = Self::create_context_with_settings(page, settings);
1915            interpret_page(page, &mut ctx, &mut device);
1916            Self::check_limit_slot(&slot)?;
1917            if let Some(margins) = &options.clip_margins {
1918                let (width, height) = page.render_dimensions();
1919                device.clip_spans(Self::clip_rect(
1920                    margins,
1921                    f64::from(width),
1922                    f64::from(height),
1923                ));
1924            }
1925            blocks.extend(device.into_raw_blocks(index, blocks.len()));
1926        }
1927        let mut blocks = crate::blocks::window(blocks, options);
1928        // Folded after the grouping, like `extract_blocks` above.
1929        if !self.keep_ligatures {
1930            for block in &mut blocks {
1931                crate::ligatures::fold_block(block);
1932            }
1933        }
1934        Ok(blocks)
1935    }
1936
1937    /// The same pass, keeping every page's rules, its lattice rulings --
1938    /// the thin lines and rectangle edges the table lattice reads (#574) --
1939    /// and its figure boxes: the placed images, then the drawn clusters.
1940    #[allow(clippy::type_complexity)]
1941    fn blocks_rules_and_rulings_before_learning(
1942        &self,
1943        table_strategy: crate::TableStrategy,
1944        // Whether spans carry the document's font names: ExtractOptions
1945        // says, and the paths without options answer false (#643).
1946        document_font_names: bool,
1947        // Whether cell words join on the cell path: ExtractOptions says,
1948        // and the paths without options answer false (#659).
1949        cell_path_words: bool,
1950        // Whether `/ActualText` replacements apply: ExtractOptions says,
1951        // and the paths without options answer false (#663).
1952        actual_text: bool,
1953        // Clip margins in points, or `None` for the whole page:
1954        // ExtractOptions says, and the paths without options answer None.
1955        clip_margins: Option<ClipMargins>,
1956    ) -> Result<(
1957        Vec<Block>,
1958        Vec<Vec<crate::text::HorizontalRule>>,
1959        Vec<Vec<crate::text::Ruling>>,
1960        Vec<Vec<[f64; 4]>>,
1961        Vec<Vec<[f64; 4]>>,
1962    )> {
1963        let pages = self.pdf.pages();
1964        let mut images: Vec<Vec<[f64; 4]>> = Vec::new();
1965        let mut graphics: Vec<Vec<[f64; 4]>> = Vec::new();
1966        let mut blocks: Vec<Block> = Vec::new();
1967        let mut ruled: Vec<Vec<crate::text::HorizontalRule>> = Vec::new();
1968        let mut lattices: Vec<Vec<crate::text::Ruling>> = Vec::new();
1969
1970        for (index, page) in pages.iter().enumerate() {
1971            let (settings, slot) = Self::with_limit_collector(&self.text_extraction_settings());
1972            let mut device = TextExtractionDevice::new()
1973                .document_font_names(document_font_names)
1974                .actual_text(actual_text);
1975            let mut ctx = Self::create_context_with_settings(page, settings);
1976            interpret_page(page, &mut ctx, &mut device);
1977            Self::check_limit_slot(&slot)?;
1978            lattices.push(device.take_rulings());
1979            let figures = device.take_images();
1980            images.push(figures);
1981            let (width, height) = page.render_dimensions();
1982            graphics.push(device.take_graphics(f64::from(width), f64::from(height)));
1983            // BEFORE segmentation: content the margins cover is never cut
1984            // into lines, so a clipped line cannot pull its neighbours into
1985            // a block of its own.
1986            if let Some(margins) = &clip_margins {
1987                device.clip_spans(Self::clip_rect(
1988                    margins,
1989                    f64::from(width),
1990                    f64::from(height),
1991                ));
1992            }
1993            let (page_blocks, page_rules) = device.into_paragraph_blocks_and_rules_with_strategy(
1994                index,
1995                blocks.len(),
1996                table_strategy,
1997                cell_path_words,
1998            );
1999            blocks.extend(page_blocks);
2000            ruled.push(page_rules);
2001        }
2002
2003        // BEFORE the headings, because a running head is set in a face of its
2004        // own as often as not: a classifier that saw it as body first would
2005        // spend a heading level on the line every page repeats (#571).
2006        crate::furniture::mark(&mut blocks);
2007        // AFTER every page, because the statistics are the DOCUMENT's: a size
2008        // is a heading against what the rest of the document is set in, and a
2009        // page measured on its own would call the one large line on a table of
2010        // contents a title and the body of page two a heading (#568).
2011        crate::headings::classify(&mut blocks);
2012        // AFTER the headings: stacked headings arrive on the same wide pitch
2013        // as a double-spaced run, and joining first turns two headings into
2014        // one paragraph (#636).
2015        let blocks = crate::text::join_double_spaced_runs(blocks, 0);
2016        // LAST, because it is the order the rest of them are read in: the
2017        // columns of each page, the pages in turn, the paragraph the page
2018        // break cut in two, and the furniture after the body (#571).
2019        let blocks = crate::reading_order::arrange(blocks, 0);
2020        Ok((blocks, ruled, lattices, images, graphics))
2021    }
2022
2023    /// The last step before the window: the over-image drop where asked,
2024    /// then the figures beside the text they stand with.
2025    ///
2026    /// Which pages carry text is read BEFORE the drop: a page whose only
2027    /// line stood over its picture still holds its figure after the line
2028    /// goes. The merge renumbers every block in place.
2029    fn finish_blocks_with_figures(
2030        blocks: Vec<Block>,
2031        images: &[Vec<[f64; 4]>],
2032        graphics: &[Vec<[f64; 4]>],
2033        options: &ExtractOptions,
2034    ) -> Vec<Block> {
2035        let pages = images.len().max(graphics.len());
2036        let mut page_has_text = vec![false; pages];
2037        for block in &blocks {
2038            if let Some(slot) = page_has_text.get_mut(block.page) {
2039                *slot = true;
2040            }
2041        }
2042        let blocks = if options.drop_text_over_images {
2043            markdown_images::drop_text_over_images(blocks, images)
2044        } else {
2045            blocks
2046        };
2047        markdown_images::merge_figures(blocks, images, graphics, &page_has_text)
2048    }
2049
2050    /// The document as markdown, in reading order (#573).
2051    ///
2052    /// Preview: output quality is still improving and the API may change in a minor release.
2053    ///
2054    /// [`Self::extract_blocks`] and [`crate::to_markdown`] in one call, which
2055    /// is the whole of it: headings become `#` to `###`, list items `-` and
2056    /// `1.`, a table a pipe table or an HTML one where markdown cannot hold
2057    /// its shape, and a change of page an HTML comment. The running heads are
2058    /// not in it -- they are in the blocks, where a caller that wants them
2059    /// reads them, or the caller sets [`ExtractOptions::include_headers`]
2060    /// and [`ExtractOptions::include_footers`] and they are written as plain
2061    /// paragraphs at their reading-order position.
2062    /// [`ExtractOptions::page_separators`] drops the page comment instead.
2063    /// `docs/STRUCTURED_EXTRACTION.md` is the reader's account of every one
2064    /// of those rules.
2065    ///
2066    /// Monospace runs become code unless [`ExtractOptions::code`] is false: an
2067    /// inline span in a paragraph or a list item, a fenced block for a
2068    ///
2069    /// A `Result` and not a bare `String` for the reason
2070    /// [`Self::extract_blocks`] is one: the limits a caller set on an
2071    /// untrusted file are the whole point of setting them, and a convenience
2072    /// method that swallowed a [`crate::limits::LimitError`] would hand back a
2073    /// silently short document to the caller who asked to be told.
2074    ///
2075    /// With [`Quality::Balanced`](crate::Quality) this runs the balanced tier
2076    /// instead of the above (#641): the layout model regions each page and the
2077    /// engine's own text fills the regions. It needs the `ort-layout` cargo
2078    /// feature and the weights from an explicit `pdfluent models install
2079    /// layout`; without the feature it fails with `MODEL_NOT_AVAILABLE`
2080    /// naming the feature and the install command, and with the feature but
2081    /// without the files, with `MODEL_NOT_AVAILABLE` naming the path looked
2082    /// at and the install command. [`ExtractOptions::model_dir`] points the
2083    /// tier at a scratch directory, [`ExtractOptions::keep_furniture`] keeps
2084    /// running heads as paragraphs.
2085    pub fn extract_markdown(&self, options: &ExtractOptions) -> Result<String> {
2086        if matches!(options.quality, crate::Quality::Balanced) {
2087            return crate::model::balanced_markdown_for(self, options);
2088        }
2089        let mut blocks = self.extract_blocks(options)?;
2090        // Embeds and written files resolve each figure's reference text
2091        // against this document before the serialiser runs; markers need no
2092        // document and are rendered as listed.
2093        if options.include_figures && (options.embed_figures || options.figure_image_dir.is_some())
2094        {
2095            markdown_images::resolve_figure_texts(self, &mut blocks, options)?;
2096        }
2097        let markdown = crate::markdown::MarkdownOptions::from(options);
2098        Ok(crate::markdown::to_markdown_with_options(
2099            &blocks, &markdown,
2100        ))
2101    }
2102
2103    /// The document as a Docling `DoclingDocument`, in reading order (#629).
2104    ///
2105    /// Preview: output quality is still improving and the API may change in a minor release.
2106    ///
2107    /// [`Self::extract_blocks`] and [`crate::to_docling_json`] in one call, with
2108    /// every page of the window listed under `pages` at its size. `name` is
2109    /// `DoclingDocument.name`, which Docling fills with the file's stem. What
2110    /// maps onto Docling's schema and what does not is written out in
2111    /// [`crate::docling`].
2112    pub fn extract_docling(&self, name: &str, options: &ExtractOptions) -> Result<String> {
2113        let blocks = self.extract_blocks(options)?;
2114        let mut pages = Vec::new();
2115        for page in (0..self.page_count()).filter(|page| options.holds(*page)) {
2116            // The frame the blocks are measured in: the crop box, turned by
2117            // the page's `/Rotate` (`initial_transform`).
2118            let (width, height) = self.page_geometry(page)?.effective_dimensions();
2119            pages.push(crate::docling::DoclingPage {
2120                page,
2121                width,
2122                height,
2123            });
2124        }
2125        Ok(crate::docling::to_docling_json(name, &pages, &blocks))
2126    }
2127
2128    /// Extract text values from AcroForm fields, including push-button captions.
2129    ///
2130    /// Returns a single string concatenating all non-empty field values separated
2131    /// by newlines. Useful when the document stores its readable content in form
2132    /// field values rather than (or in addition to) page content streams.
2133    pub fn extract_acroform_text(&self) -> String {
2134        let Some(tree) = parse_acroform(&self.pdf) else {
2135            return String::new();
2136        };
2137        let mut parts: Vec<String> = Vec::new();
2138        for id in tree.all_ids() {
2139            let node = tree.get(id);
2140            if node.children.is_empty() {
2141                // Terminal (widget) — collect text-like values.
2142                let value_str = match &node.value {
2143                    Some(FieldValue::Text(s)) if !s.is_empty() => Some(s.clone()),
2144                    Some(FieldValue::StringArray(arr)) => {
2145                        let joined = arr
2146                            .iter()
2147                            .filter(|s| !s.is_empty())
2148                            .cloned()
2149                            .collect::<Vec<_>>()
2150                            .join(", ");
2151                        if joined.is_empty() {
2152                            None
2153                        } else {
2154                            Some(joined)
2155                        }
2156                    }
2157                    _ => None,
2158                };
2159                let button_caption =
2160                    value_str.is_none() && tree.effective_field_type(id) == Some(FieldType::Button);
2161                let extracted = value_str.or_else(|| {
2162                    button_caption.then(|| {
2163                        node.mk
2164                            .as_ref()
2165                            .and_then(|mk| mk.caption.as_ref())
2166                            .filter(|caption| !caption.is_empty())
2167                            .cloned()
2168                    })?
2169                });
2170                if let Some(s) = extracted {
2171                    parts.push(s);
2172                }
2173            }
2174        }
2175        let joined = parts.join("\n");
2176        if self.keep_ligatures {
2177            return joined;
2178        }
2179        // A field value is a string the author stored rather than glyphs
2180        // anybody drew, but a searcher cannot tell the two apart: it is the
2181        // same `office` and the same failed search (codex, #437). Folding here
2182        // rather than in `extract_all_text` covers this method's own callers
2183        // and the zero-page document, whose whole text is this string.
2184        crate::ligatures::fold(&joined).into_owned()
2185    }
2186
2187    /// Extract all text from the document: page content streams plus AcroForm
2188    /// field values.  Mirrors pdftotext behaviour.
2189    pub fn extract_all_text(&self) -> String {
2190        let pages = self.pdf.pages();
2191        let mut settings = self.text_extraction_settings();
2192        let mut page_texts = Vec::with_capacity(pages.len());
2193        for page in pages.iter() {
2194            let (page_text, next_settings) =
2195                Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2196            settings = next_settings;
2197            page_texts.push(page_text);
2198        }
2199
2200        // AcroForm values ride on the last page rather than after the last
2201        // page separator: they belong to no page of their own, and a trailing
2202        // block behind the final form feed would read as one more page to
2203        // every consumer that counts them.
2204        let acroform = self.extract_acroform_text();
2205        if !acroform.is_empty() {
2206            match page_texts.last_mut() {
2207                Some(last) => {
2208                    if !last.is_empty() && !last.ends_with('\n') {
2209                        last.push('\n');
2210                    }
2211                    last.push_str(&acroform);
2212                }
2213                None => return acroform,
2214            }
2215        }
2216
2217        join_page_texts(page_texts.iter().map(String::as_str))
2218    }
2219
2220    /// Simple text search: returns page indices containing the query string.
2221    ///
2222    /// The query is folded the same way the pages are, so a word carrying a
2223    /// ligature code point -- pasted out of a content stream, or typed from a
2224    /// file that maps its glyphs to U+FB00-FB06 -- still finds the page it
2225    /// came from.
2226    pub fn search_text(&self, query: &str) -> Vec<usize> {
2227        let pages = self.pdf.pages();
2228        let query_lower = query.to_lowercase();
2229        // Fold the needle exactly when the haystack is folded, or the one
2230        // query that most needs the fold -- `office`, carrying the code point
2231        // the file declared -- is the one query that can never match
2232        // (codex, #437).
2233        let query_lower = if self.keep_ligatures {
2234            query_lower
2235        } else {
2236            crate::ligatures::fold(&query_lower).into_owned()
2237        };
2238        #[cfg(feature = "parallel")]
2239        let page_contains = |i: usize| -> Option<usize> {
2240            let page = &pages[i];
2241            let (text, _) = Self::extract_text_with_settings(
2242                page,
2243                self.text_extraction_settings(),
2244                self.keep_ligatures,
2245            );
2246            if text.to_lowercase().contains(&query_lower) {
2247                Some(i)
2248            } else {
2249                None
2250            }
2251        };
2252        #[cfg(feature = "parallel")]
2253        return (0..pages.len())
2254            .into_par_iter()
2255            .filter_map(page_contains)
2256            .collect();
2257        #[cfg(not(feature = "parallel"))]
2258        {
2259            let mut settings = self.text_extraction_settings();
2260            let mut hits = Vec::new();
2261            for (i, page) in pages.iter().enumerate() {
2262                let (text, next_settings) =
2263                    Self::extract_text_with_settings(page, settings, self.keep_ligatures);
2264                settings = next_settings;
2265                if text.to_lowercase().contains(&query_lower) {
2266                    hits.push(i);
2267                }
2268            }
2269            hits
2270        }
2271    }
2272
2273    /// Extract document metadata.
2274    pub fn info(&self) -> DocumentInfo {
2275        let meta = self.pdf.metadata();
2276        DocumentInfo {
2277            title: meta.title.as_ref().map(|b| bytes_to_string(b)),
2278            author: meta.author.as_ref().map(|b| bytes_to_string(b)),
2279            subject: meta.subject.as_ref().map(|b| bytes_to_string(b)),
2280            keywords: meta.keywords.as_ref().map(|b| bytes_to_string(b)),
2281            creator: meta.creator.as_ref().map(|b| bytes_to_string(b)),
2282            producer: meta.producer.as_ref().map(|b| bytes_to_string(b)),
2283        }
2284    }
2285
2286    /// Extract document outline / bookmarks.
2287    pub fn bookmarks(&self) -> Vec<BookmarkItem> {
2288        let xref = self.pdf.xref();
2289        let root_id = xref.root_id();
2290        let catalog: Dict<'_> = match xref.get(root_id) {
2291            Some(d) => d,
2292            None => return Vec::new(),
2293        };
2294
2295        let outlines: Dict<'_> = match catalog.get(OUTLINES) {
2296            Some(d) => d,
2297            None => return Vec::new(),
2298        };
2299
2300        let first: Dict<'_> = match outlines.get(FIRST) {
2301            Some(d) => d,
2302            None => return Vec::new(),
2303        };
2304
2305        // `visited` breaks cyclic /Next loops and /First back-references;
2306        // `depth` bounds deep nesting so a malformed outline cannot hang or
2307        // overflow the stack.
2308        let mut visited = BTreeSet::new();
2309        let pages = self.page_object_ids();
2310        parse_outline_items(&first, 0, &mut visited, &pages)
2311    }
2312
2313    /// Every `/Link` annotation on one page, in `/Annots` order.
2314    ///
2315    /// A link without a readable `/Rect` is skipped: with no region it
2316    /// cannot be rendered or cited. All other annots on the page are
2317    /// skipped too; only `/Link` is a link.
2318    pub fn page_links(&self, index: usize) -> Result<Vec<PageLink>> {
2319        let page = self.get_page(index)?;
2320        let pages = self.page_object_ids();
2321        let mut links = Vec::new();
2322        for annot in page.annots() {
2323            if annot.get::<Name>(SUBTYPE).as_deref() != Some(b"Link".as_slice()) {
2324                continue;
2325            }
2326            let rect = match annot.get::<pdf_render::pdf_syntax::object::Rect>(RECT) {
2327                Some(rect) => [rect.x0, rect.y0, rect.x1, rect.y1],
2328                None => continue,
2329            };
2330            let id = annot
2331                .get::<pdf_render::pdf_syntax::object::String>(NM)
2332                .map(|s| bytes_to_string(s.as_bytes()))
2333                .unwrap_or_default();
2334            let xref = annot
2335                .obj_id()
2336                .and_then(|id| u32::try_from(id.obj_number).ok())
2337                .unwrap_or(0);
2338            links.push(PageLink {
2339                target: link_target(&annot, &pages),
2340                rect,
2341                xref,
2342                id,
2343            });
2344        }
2345        Ok(links)
2346    }
2347
2348    /// The object id of each page's dictionary, in page order.
2349    ///
2350    /// An outline destination names its page by reference, so resolving it is
2351    /// a lookup in this list. A page without an id (an inline page dict, which
2352    /// only brute-force recovery builds) keeps its place as a hole no
2353    /// reference can match.
2354    fn page_object_ids(&self) -> Vec<Option<ObjectIdentifier>> {
2355        self.pdf.pages().iter().map(|p| p.raw().obj_id()).collect()
2356    }
2357
2358    /// Run OCR on a page and return the recognized text and word positions.
2359    ///
2360    /// The page is rendered at `dpi` (default 150) before recognition.
2361    /// Pass any [`OcrBackend`] implementation; use [`OcrsBackend::try_default`]
2362    /// to load the pure-Rust `ocrs` engine from the standard model paths.
2363    ///
2364    /// # Example
2365    ///
2366    /// ```no_run
2367    /// # #[cfg(feature = "ocr")] {
2368    /// use pdf_engine::{PdfDocument, OcrsBackend, RenderOptions};
2369    ///
2370    /// let doc = PdfDocument::open(std::fs::read("scan.pdf").unwrap()).unwrap();
2371    /// let backend = OcrsBackend::try_default().unwrap();
2372    /// let result = doc.ocr_page(0, &backend, 150.0_f64).unwrap();
2373    /// println!("{}", result.text);
2374    /// # }
2375    /// ```
2376    pub fn ocr_page(
2377        &self,
2378        index: usize,
2379        backend: &dyn crate::ocr::OcrBackend,
2380        dpi: f64,
2381    ) -> crate::error::Result<crate::ocr::OcrResult> {
2382        let opts = crate::render::RenderOptions {
2383            dpi,
2384            ..Default::default()
2385        };
2386        let rendered = self.render_page(index, &opts)?;
2387
2388        // Convert RGBA → RGB (ocrs expects RGB input).
2389        let mut rgb = Vec::with_capacity((rendered.width * rendered.height * 3) as usize);
2390        for chunk in rendered.pixels.chunks(4) {
2391            rgb.push(chunk[0]);
2392            rgb.push(chunk[1]);
2393            rgb.push(chunk[2]);
2394        }
2395
2396        backend
2397            .recognize(&rgb, rendered.width, rendered.height)
2398            .map_err(|e| crate::error::EngineError::RenderError(e.to_string()))
2399    }
2400
2401    /// Wrap `settings` with a warning sink that captures the first
2402    /// `InterpreterWarning::StreamTooLarge` into a shared slot.
2403    ///
2404    /// The returned slot is checked by [`Self::check_limit_slot`] after
2405    /// the operation completes. Any previously installed sink is still
2406    /// called so no warnings are silently dropped.
2407    fn with_limit_collector(settings: &InterpreterSettings) -> (InterpreterSettings, LimitSlot) {
2408        let slot: LimitSlot = Arc::new(Mutex::new(None));
2409        let slot_clone = Arc::clone(&slot);
2410        let prev_sink = settings.warning_sink.clone();
2411        let mut new_settings = settings.clone();
2412        new_settings.warning_sink = Arc::new(move |w: InterpreterWarning| {
2413            if let InterpreterWarning::StreamTooLarge { observed, limit } = w {
2414                let mut guard = slot_clone.lock().unwrap_or_else(|e| e.into_inner());
2415                if guard.is_none() {
2416                    *guard = Some((observed, limit));
2417                }
2418            }
2419            prev_sink(w);
2420        });
2421        (new_settings, slot)
2422    }
2423
2424    /// Check the slot populated by [`Self::with_limit_collector`].
2425    ///
2426    /// Returns `Err(EngineError::LimitExceeded(...))` if a
2427    /// `StreamTooLarge` warning was captured, `Ok(())` otherwise.
2428    fn check_limit_slot(slot: &LimitSlot) -> Result<()> {
2429        if let Some((observed, limit)) = *slot.lock().unwrap_or_else(|e| e.into_inner()) {
2430            return Err(EngineError::LimitExceeded(LimitError::StreamTooLarge {
2431                actual_bytes: observed,
2432                limit_bytes: limit,
2433            }));
2434        }
2435        Ok(())
2436    }
2437
2438    fn get_page(&self, index: usize) -> Result<&Page<'_>> {
2439        let pages = self.pdf.pages();
2440        if index >= pages.len() {
2441            return Err(EngineError::PageOutOfRange {
2442                index,
2443                count: pages.len(),
2444            });
2445        }
2446        Ok(&pages[index])
2447    }
2448
2449    fn text_extraction_settings(&self) -> InterpreterSettings {
2450        let mut settings = self.settings.clone();
2451        // Text extraction should include signature widget appearance streams
2452        // that rendering skips to match MuPDF visual output.
2453        settings.skip_signature_widgets = false;
2454        settings
2455    }
2456
2457    fn create_context_with_settings<'a>(
2458        page: &Page<'a>,
2459        settings: InterpreterSettings,
2460    ) -> Context<'a> {
2461        let (w, h) = page.render_dimensions();
2462        Context::new(
2463            page.initial_transform(false),
2464            Rect::new(0.0, 0.0, w as f64, h as f64),
2465            page.xref(),
2466            settings,
2467        )
2468    }
2469
2470    fn extract_text_with_settings<'a>(
2471        page: &Page<'a>,
2472        settings: InterpreterSettings,
2473        keep_ligatures: bool,
2474    ) -> (String, InterpreterSettings) {
2475        let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2476        let mut ctx = Self::create_context_with_settings(page, settings);
2477        interpret_page(page, &mut ctx, &mut device);
2478        let settings = ctx.into_settings();
2479        (device.into_text(), settings)
2480    }
2481
2482    fn extract_text_blocks_with_settings<'a>(
2483        page: &Page<'a>,
2484        settings: InterpreterSettings,
2485        keep_ligatures: bool,
2486    ) -> (Vec<TextBlock>, InterpreterSettings) {
2487        let mut device = TextExtractionDevice::new().keep_ligatures(keep_ligatures);
2488        let mut ctx = Self::create_context_with_settings(page, settings);
2489        interpret_page(page, &mut ctx, &mut device);
2490        let settings = ctx.into_settings();
2491        (device.into_blocks(), settings)
2492    }
2493
2494    #[cfg(feature = "xfa")]
2495    fn open_flattened_xfa_for_render(&self) -> Option<Self> {
2496        if !crate::xfa::has_xfa(self) {
2497            return None;
2498        }
2499
2500        // Cached by content (#468): the flatten is the expensive part, and a
2501        // mutated document has different bytes and so a different cache key.
2502        let flat_bytes = crate::xfa::flatten_cached(self).ok()?;
2503        let mut flat_doc = Self::open(flat_bytes).ok()?;
2504        flat_doc.settings = self.settings.clone();
2505        Some(flat_doc)
2506    }
2507}
2508
2509/// Render a page to Gray8 bytes at the given dpi: `(width, height, data)`,
2510/// row-major, top-left origin, 0 is black.
2511///
2512/// The RGBA output of [`PdfDocument::render_page`] composited over white and
2513/// converted to ITU-R BT.601 luma. The ONE conversion: the vision layer's Gray8
2514/// path calls this, and the dump's image arm reads its bytes without the
2515/// `vision` feature (#574).
2516pub fn gray_page(doc: &PdfDocument, page: usize, dpi: u32) -> Result<(u32, u32, Vec<u8>)> {
2517    let options = RenderOptions {
2518        dpi: f64::from(dpi),
2519        ..Default::default()
2520    };
2521    let rendered = doc.render_page(page, &options)?;
2522    let mut data = Vec::with_capacity(rendered.width as usize * rendered.height as usize);
2523    for px in rendered.pixels.chunks_exact(4) {
2524        let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
2525        // Composite over white; the default background is opaque white so
2526        // alpha is 255 in practice, but never trust it.
2527        let alpha = f32::from(a) / 255.0;
2528        let blend = |c: u8| (f32::from(c) * alpha + 255.0 * (1.0 - alpha)).round() as u8;
2529        let (r, g, b) = (blend(r), blend(g), blend(b));
2530        let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
2531        data.push(luma.round().clamp(0.0, 255.0) as u8);
2532    }
2533    Ok((rendered.width, rendered.height, data))
2534}
2535
2536/// Join per-page text the way `pdftotext` and `mutool` have for twenty years:
2537/// a form feed (U+000C) **after every page**, preceded by a blank line so the
2538/// last paragraph of a page is not glued to the separator.
2539///
2540/// After every page and not between them, which is the convention every reader
2541/// in the #390 comparison emits: a fourteen-page document carries fourteen form
2542/// feeds in `pdftotext`, `mutool`, PyMuPDF and pdfminer.six alike. Splitting on
2543/// U+000C therefore yields `n + 1` fields with an empty one at the end, and
2544/// every consumer of a form-feed stream already drops it. Separating instead of
2545/// terminating would save that one field and cost the property the convention
2546/// exists for: that our text is substitutable for theirs (#449).
2547pub fn join_page_texts<I>(page_texts: I) -> String
2548where
2549    I: IntoIterator,
2550    I::Item: AsRef<str>,
2551{
2552    let mut text = String::new();
2553
2554    for page_text in page_texts {
2555        text.push_str(page_text.as_ref());
2556        while !text.is_empty() && !text.ends_with("\n\n") {
2557            text.push('\n');
2558        }
2559        text.push('\u{000C}');
2560    }
2561
2562    text
2563}
2564
2565/// The learned switches a table strategy allows (#659).
2566///
2567/// [`TableStrategy`](crate::TableStrategy) is enforced before learning by
2568/// keeping grids back, and here by sitting out the proposers that would
2569/// put them back: the word-reading region, structure and judge models
2570/// under [`Lines`](crate::TableStrategy::Lines), the drawn-grid lattice
2571/// under [`Text`](crate::TableStrategy::Text).
2572/// [`Both`](crate::TableStrategy::Both) masks nothing.
2573fn mask_table_switches(
2574    switches: crate::learned::Switches,
2575    strategy: crate::TableStrategy,
2576) -> crate::learned::Switches {
2577    let mut switches = switches;
2578    match strategy {
2579        crate::TableStrategy::Lines => {
2580            switches.table_region = false;
2581            switches.table_structure = false;
2582            switches.table_judge = false;
2583        }
2584        crate::TableStrategy::Text => {
2585            switches.table_lattice = false;
2586        }
2587        crate::TableStrategy::Both => {}
2588    }
2589    switches
2590}
2591
2592#[cfg(test)]
2593mod extract_all_text_tests {
2594    use super::join_page_texts;
2595
2596    #[test]
2597    fn terminates_every_page_like_pdftotext() {
2598        assert_eq!(
2599            join_page_texts(["Page 1", "Page 2"]),
2600            "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2601        );
2602    }
2603
2604    #[test]
2605    fn a_page_carries_a_separator_even_when_it_has_no_text() {
2606        // The page exists; a reader counting form feeds must still find it.
2607        assert_eq!(
2608            join_page_texts(["", "Page 2"]),
2609            "\u{000C}Page 2\n\n\u{000C}"
2610        );
2611    }
2612
2613    #[test]
2614    fn reuses_existing_blank_line_before_form_feed() {
2615        assert_eq!(
2616            join_page_texts(["Page 1\n\n", "Page 2"]),
2617            "Page 1\n\n\u{000C}Page 2\n\n\u{000C}"
2618        );
2619    }
2620
2621    #[test]
2622    fn as_many_separators_as_pages() {
2623        let text = join_page_texts(["a", "b", "c"]);
2624        assert_eq!(text.matches('\u{000C}').count(), 3);
2625        // The trailing field every form-feed consumer already drops.
2626        assert_eq!(text.split('\u{000C}').count(), 4);
2627    }
2628}
2629
2630/// Maximum outline (bookmark) nesting depth. Bounds recursion on adversarial
2631/// `/First` chains; real outlines are far shallower.
2632const MAX_OUTLINE_DEPTH: usize = 100;
2633
2634/// Walk the outline linked list (FIRST → NEXT chain).
2635///
2636/// `visited` (object ids of already-seen items) breaks cyclic `/Next` loops and
2637/// `/First` back-references; `depth` bounds deeply nested `/First` chains. Both
2638/// protect against malformed/adversarial outlines hanging or overflowing.
2639/// `pages` is the page order [`PdfDocument::page_object_ids`] measured, which
2640fn parse_outline_items(
2641    item_dict: &Dict<'_>,
2642    depth: usize,
2643    visited: &mut BTreeSet<ObjectIdentifier>,
2644    pages: &[Option<ObjectIdentifier>],
2645) -> Vec<BookmarkItem> {
2646    let mut items = Vec::new();
2647    if depth >= MAX_OUTLINE_DEPTH {
2648        return items;
2649    }
2650    let mut current: Option<Dict<'_>> = Some(item_dict.clone());
2651
2652    while let Some(dict) = current {
2653        // Stop if we re-enter an item: covers a /Next chain that loops back and
2654        // a /First child that references an ancestor.
2655        if let Some(id) = dict.obj_id() {
2656            if !visited.insert(id) {
2657                break;
2658            }
2659        }
2660
2661        let title = dict
2662            .get::<pdf_render::pdf_syntax::object::String>(TITLE)
2663            .map(|s| bytes_to_string(s.as_bytes()))
2664            .unwrap_or_default();
2665
2666        let children = match dict.get::<Dict<'_>>(FIRST) {
2667            Some(child_dict) => parse_outline_items(&child_dict, depth + 1, visited, pages),
2668            None => Vec::new(),
2669        };
2670
2671        items.push(BookmarkItem {
2672            title,
2673            page: outline_destination_page(&dict, pages),
2674            children,
2675        });
2676
2677        current = dict.get::<Dict<'_>>(NEXT);
2678    }
2679
2680    items
2681}
2682
2683/// The 0-based page an outline item points at, or `None`.
2684///
2685/// An item carries its destination as `/Dest`, or as an explicit go-to action
2686/// (`/A << /S /GoTo /D ... >>`); both hold an explicit destination array whose
2687/// first element is the page's own dictionary by reference. A named
2688/// destination (a string or name, resolved through the catalog's `/Names` or
2689/// `/Dests`) stays `None`: that lookup is a follow-up, and a guessed page
2690/// would be worse than none.
2691fn outline_destination_page(item: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> Option<usize> {
2692    if let Some(page) = explicit_dest_page(item.get::<Array<'_>>(DEST), pages) {
2693        return Some(page);
2694    }
2695    let action = item.get::<Dict<'_>>(A)?;
2696    if action.get::<Name>(S).as_deref() != Some(b"GoTo".as_slice()) {
2697        return None;
2698    }
2699    if action.contains_key(F) {
2700        // A remote go-to: /D names a page in another file.
2701        return None;
2702    }
2703    explicit_dest_page(action.get::<Array<'_>>(D), pages)
2704}
2705
2706/// The page an explicit destination array points at: its first element is the
2707/// page dictionary by reference, looked up in the page order.
2708fn explicit_dest_page(
2709    dest: Option<Array<'_>>,
2710    pages: &[Option<ObjectIdentifier>],
2711) -> Option<usize> {
2712    let first = dest?.raw_iter().next()?;
2713    let target = ObjectIdentifier::from(ObjRef::try_from(first).ok()?);
2714    pages.iter().position(|id| *id == Some(target))
2715}
2716
2717/// Where one link annotation points.
2718///
2719/// An `/A` action wins over a direct `/Dest`, and a `/URI` action over
2720/// everything: only `/URI` and same-document `/GoTo` resolve further.
2721/// Anything else -- a remote jump, a launch or script action, a missing
2722/// address -- is [`LinkTarget::Unknown`] rather than a guess.
2723fn link_target(annot: &Dict<'_>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2724    if let Some(action) = annot.get::<Dict<'_>>(A) {
2725        if action.get::<Name>(S).as_deref() == Some(b"URI".as_slice()) {
2726            let uri = action
2727                .get::<pdf_render::pdf_syntax::object::String>(URI)
2728                .map(|s| bytes_to_string(s.as_bytes()))
2729                .unwrap_or_default();
2730            return LinkTarget::Uri(uri);
2731        }
2732        if action.get::<Name>(S).as_deref() == Some(b"GoTo".as_slice()) && !action.contains_key(F) {
2733            return link_destination(action.get::<Object<'_>>(D), pages);
2734        }
2735        return LinkTarget::Unknown;
2736    }
2737    link_destination(annot.get::<Object<'_>>(DEST), pages)
2738}
2739
2740/// Where one destination object points: an explicit array resolves to
2741/// its page, a name or string stays a named destination, and anything
2742/// else (including a missing destination) is unknown.
2743fn link_destination(dest: Option<Object<'_>>, pages: &[Option<ObjectIdentifier>]) -> LinkTarget {
2744    match dest {
2745        Some(Object::Array(array)) => explicit_dest_page(Some(array), pages)
2746            .map(LinkTarget::Page)
2747            .unwrap_or(LinkTarget::Unknown),
2748        Some(Object::Name(name)) => LinkTarget::Named(name.as_str().to_string()),
2749        Some(Object::String(text)) => LinkTarget::Named(bytes_to_string(text.as_bytes())),
2750        _ => LinkTarget::Unknown,
2751    }
2752}
2753
2754/// Convert PDF string bytes to a Rust String (UTF-8 with Latin-1 fallback).
2755fn bytes_to_string(bytes: &[u8]) -> String {
2756    // Check for UTF-16 BOM
2757    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
2758        let chars: Vec<u16> = bytes[2..]
2759            .chunks(2)
2760            .filter_map(|c| {
2761                if c.len() == 2 {
2762                    Some(u16::from_be_bytes([c[0], c[1]]))
2763                } else {
2764                    None
2765                }
2766            })
2767            .collect();
2768        return String::from_utf16_lossy(&chars);
2769    }
2770
2771    // Try UTF-8, fall back to Latin-1.
2772    match std::str::from_utf8(bytes) {
2773        Ok(s) => s.to_string(),
2774        Err(_) => bytes.iter().map(|&b| b as char).collect(),
2775    }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780    use super::*;
2781    use crate::render::{ColorMode, PixelFormat, RenderConfig, RenderOptions};
2782    use lopdf::{Document as LoDocument, Object};
2783    use std::path::PathBuf;
2784
2785    fn corpus_path(name: &str) -> PathBuf {
2786        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2787            .join("../../corpus")
2788            .join(name)
2789    }
2790
2791    /// A cyclic outline (`/Next` chain that loops back) must not hang the
2792    /// bookmark walker; the cycle guard breaks it into a finite list.
2793    #[test]
2794    fn cyclic_outline_terminates_and_is_bounded() {
2795        fn cyclic_outline_pdf() -> Vec<u8> {
2796            let objs: [&[u8]; 6] = [
2797                b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>",
2798                b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
2799                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2800                b"<< /Type /Outlines /First 5 0 R >>",
2801                b"<< /Title (A) /Next 6 0 R >>",
2802                b"<< /Title (B) /Next 5 0 R >>", // /Next back to A -> cycle
2803            ];
2804            let mut buf = Vec::new();
2805            let mut offsets = [0usize; 7];
2806            buf.extend_from_slice(b"%PDF-1.7\n");
2807            for (i, body) in objs.iter().enumerate() {
2808                offsets[i + 1] = buf.len();
2809                buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2810                buf.extend_from_slice(body);
2811                buf.extend_from_slice(b"\nendobj\n");
2812            }
2813            let xref_off = buf.len();
2814            buf.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n");
2815            for o in &offsets[1..7] {
2816                buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2817            }
2818            buf.extend_from_slice(
2819                format!("trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2820                    .as_bytes(),
2821            );
2822            buf
2823        }
2824
2825        fn count(b: &BookmarkItem) -> usize {
2826            1 + b.children.iter().map(count).sum::<usize>()
2827        }
2828
2829        let doc = PdfDocument::open(cyclic_outline_pdf()).expect("open cyclic-outline PDF");
2830        let bookmarks = doc.bookmarks();
2831        let total: usize = bookmarks.iter().map(count).sum();
2832        assert!(
2833            total <= 2,
2834            "cyclic /Next outline must not loop forever; got {total} items"
2835        );
2836    }
2837
2838    /// An outline item's destination resolves to its 0-based page: `/Dest` and
2839    /// an explicit `/A << /S /GoTo /D ... >>` both name the page by reference.
2840    #[test]
2841    fn outline_destinations_resolve_to_their_pages() {
2842        fn outlined_pdf() -> Vec<u8> {
2843            let objs: [&[u8]; 9] = [
2844                b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>",
2845                b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
2846                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2847                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
2848                b"<< /Type /Outlines /First 6 0 R /Count 3 >>",
2849                b"<< /Title (A) /Parent 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] >>",
2850                b"<< /Title (B) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /First 9 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>",
2851                b"<< /Title (C) /Parent 5 0 R /Prev 7 0 R /Dest (chapter-three) >>",
2852                b"<< /Title (B.1) /Parent 7 0 R /Dest [4 0 R /Fit] >>",
2853            ];
2854            let mut buf = Vec::new();
2855            let mut offsets = [0usize; 10];
2856            buf.extend_from_slice(b"%PDF-1.7\n");
2857            for (i, body) in objs.iter().enumerate() {
2858                offsets[i + 1] = buf.len();
2859                buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
2860                buf.extend_from_slice(body);
2861                buf.extend_from_slice(b"\nendobj\n");
2862            }
2863            let xref_off = buf.len();
2864            buf.extend_from_slice(b"xref\n0 10\n0000000000 65535 f \n");
2865            for o in &offsets[1..10] {
2866                buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
2867            }
2868            buf.extend_from_slice(
2869                format!("trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF")
2870                    .as_bytes(),
2871            );
2872            buf
2873        }
2874
2875        let doc = PdfDocument::open(outlined_pdf()).expect("open outlined PDF");
2876        let bookmarks = doc.bookmarks();
2877        assert_eq!(bookmarks.len(), 3);
2878        assert_eq!(
2879            (bookmarks[0].title.as_str(), bookmarks[0].page),
2880            ("A", Some(0))
2881        );
2882        assert_eq!(
2883            (bookmarks[1].title.as_str(), bookmarks[1].page),
2884            ("B", Some(1))
2885        );
2886        assert_eq!(bookmarks[1].children.len(), 1);
2887        assert_eq!(
2888            (
2889                bookmarks[1].children[0].title.as_str(),
2890                bookmarks[1].children[0].page
2891            ),
2892            ("B.1", Some(1))
2893        );
2894        assert_eq!(
2895            (bookmarks[2].title.as_str(), bookmarks[2].page),
2896            ("C", None)
2897        );
2898    }
2899
2900    /// Cache-on vs cache-off: the shared decoded-image cache is a pure
2901    /// performance optimisation, so rendering with it enabled must produce
2902    /// pixel-identical output to rendering with it disabled. Exercised against a
2903    /// committed image-bearing fixture.
2904    /// Locate a repository test fixture. Published crates do not carry the
2905    /// repository's fixture tree, so callers skip when this returns `None`
2906    /// rather than failing a consumer's `cargo test`.
2907    fn repo_fixture(name: &str) -> Option<PathBuf> {
2908        let dir = std::env::var("PDFLUENT_TEST_FIXTURES").ok().map_or_else(
2909            || {
2910                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2911                    .join("..")
2912                    .join("..")
2913                    .join("tests")
2914            },
2915            PathBuf::from,
2916        );
2917        let path = dir.join(name);
2918        path.exists().then_some(path)
2919    }
2920
2921    #[test]
2922    fn shared_image_cache_is_render_neutral() {
2923        let Some(path) = repo_fixture("corpus-mini/scanned.pdf") else {
2924            eprintln!("skipping: repository fixture tree not present");
2925            return;
2926        };
2927        let data = std::fs::read(&path).expect("read scanned.pdf fixture");
2928        let cfg = RenderConfig::default();
2929
2930        // Cache ON: `PdfDocument::open` installs a shared cache, so the second
2931        // render of the same page is a cache hit.
2932        let doc_on = PdfDocument::open(data.clone()).expect("open cache-on");
2933        let miss = doc_on
2934            .render_page_with_config(0, &cfg)
2935            .expect("cold render (cache miss)");
2936        let hit = doc_on
2937            .render_page_with_config(0, &cfg)
2938            .expect("warm render (cache hit)");
2939
2940        // Cache OFF: default settings carry `shared_cache: None`, so every render
2941        // decodes images from scratch.
2942        let mut doc_off = PdfDocument::open(data).expect("open cache-off");
2943        doc_off.set_settings(InterpreterSettings::default());
2944        let uncached = doc_off
2945            .render_page_with_config(0, &cfg)
2946            .expect("render with cache disabled");
2947
2948        assert_eq!(
2949            (miss.width, miss.height),
2950            (uncached.width, uncached.height),
2951            "render dimensions must match"
2952        );
2953        assert_eq!(
2954            miss.pixels, hit.pixels,
2955            "a cache hit must return exactly the freshly-decoded render"
2956        );
2957        assert_eq!(
2958            miss.pixels, uncached.pixels,
2959            "cache-enabled render must equal cache-disabled render"
2960        );
2961    }
2962
2963    fn normalize_text(text: &str) -> String {
2964        text.split_whitespace().collect::<Vec<_>>().join(" ")
2965    }
2966
2967    fn strip_type0_tounicode(data: &[u8]) -> (Vec<u8>, usize) {
2968        fn get_name(dict: &lopdf::Dictionary, key: &[u8]) -> Option<Vec<u8>> {
2969            match dict.get(key).ok()? {
2970                Object::Name(name) => Some(name.clone()),
2971                _ => None,
2972            }
2973        }
2974
2975        fn descendant_is_cidfont_type2(doc: &LoDocument, type0: &lopdf::Dictionary) -> bool {
2976            let Some(Object::Array(descendants)) = type0.get(b"DescendantFonts").ok() else {
2977                return false;
2978            };
2979            let Some(Object::Reference(desc_id)) = descendants.first() else {
2980                return false;
2981            };
2982            let Ok(Object::Dictionary(descendant)) = doc.get_object(*desc_id) else {
2983                return false;
2984            };
2985            matches!(
2986                descendant.get(b"Subtype").ok(),
2987                Some(Object::Name(name)) if name.as_slice() == b"CIDFontType2"
2988            )
2989        }
2990
2991        let mut doc = LoDocument::load_mem(data).expect("load stripped-to-unicode fixture");
2992        let ids: Vec<_> = doc.objects.keys().copied().collect();
2993        let mut removed = 0usize;
2994
2995        for id in ids {
2996            let Some(Object::Dictionary(dict)) = doc.objects.get(&id) else {
2997                continue;
2998            };
2999            if !matches!(
3000                dict.get(b"Subtype").ok(),
3001                Some(Object::Name(name)) if name.as_slice() == b"Type0"
3002            ) {
3003                continue;
3004            }
3005            if !matches!(
3006                get_name(dict, b"Encoding").as_deref(),
3007                Some(b"Identity-H") | Some(b"Identity-V")
3008            ) {
3009                continue;
3010            }
3011            if !descendant_is_cidfont_type2(&doc, dict) {
3012                continue;
3013            }
3014
3015            if let Some(Object::Dictionary(type0)) = doc.objects.get_mut(&id) {
3016                if type0.has(b"ToUnicode") {
3017                    type0.remove(b"ToUnicode");
3018                    removed += 1;
3019                }
3020            }
3021        }
3022
3023        let mut out = Vec::new();
3024        doc.save_to(&mut out)
3025            .expect("save stripped-to-unicode fixture");
3026        (out, removed)
3027    }
3028
3029    fn solid_fill_pdf_bytes(color_operator: &str) -> Vec<u8> {
3030        use lopdf::{dictionary, Document, Object, Stream};
3031
3032        let mut doc = Document::with_version("1.4");
3033
3034        let pages_id = doc.new_object_id();
3035        let page_id = doc.new_object_id();
3036        let content = format!("{color_operator}\n0 0 72 72 re f\n");
3037        let content_id = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
3038
3039        doc.objects.insert(
3040            page_id,
3041            Object::Dictionary(dictionary! {
3042                "Type" => Object::Name(b"Page".to_vec()),
3043                "Parent" => Object::Reference(pages_id),
3044                "MediaBox" => Object::Array(vec![
3045                    Object::Integer(0),
3046                    Object::Integer(0),
3047                    Object::Integer(72),
3048                    Object::Integer(72),
3049                ]),
3050                "Contents" => Object::Reference(content_id),
3051            }),
3052        );
3053
3054        doc.objects.insert(
3055            pages_id,
3056            Object::Dictionary(dictionary! {
3057                "Type" => Object::Name(b"Pages".to_vec()),
3058                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3059                "Count" => Object::Integer(1),
3060            }),
3061        );
3062
3063        let catalog_id = doc.new_object_id();
3064        doc.objects.insert(
3065            catalog_id,
3066            Object::Dictionary(dictionary! {
3067                "Type" => Object::Name(b"Catalog".to_vec()),
3068                "Pages" => Object::Reference(pages_id),
3069            }),
3070        );
3071
3072        doc.trailer.set("Root", Object::Reference(catalog_id));
3073
3074        let mut bytes = Vec::new();
3075        doc.save_to(&mut bytes).expect("save solid fill fixture");
3076        bytes
3077    }
3078
3079    fn mixed_rgb_cmyk_pdf_bytes() -> Vec<u8> {
3080        use lopdf::{dictionary, Document, Object, Stream};
3081
3082        let mut doc = Document::with_version("1.4");
3083        let pages_id = doc.new_object_id();
3084        let page_id = doc.new_object_id();
3085        let content = b"1 0 0 rg\n0 0 36 72 re f\n1 0 0 0 k\n36 0 36 72 re f\n".to_vec();
3086        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3087
3088        doc.objects.insert(
3089            page_id,
3090            Object::Dictionary(dictionary! {
3091                "Type" => "Page",
3092                "Parent" => Object::Reference(pages_id),
3093                "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3094                "Contents" => Object::Reference(content_id),
3095            }),
3096        );
3097        doc.objects.insert(
3098            pages_id,
3099            Object::Dictionary(dictionary! {
3100                "Type" => "Pages",
3101                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3102                "Count" => Object::Integer(1),
3103            }),
3104        );
3105        let catalog_id = doc.new_object_id();
3106        doc.objects.insert(
3107            catalog_id,
3108            Object::Dictionary(dictionary! {
3109                "Type" => "Catalog",
3110                "Pages" => Object::Reference(pages_id),
3111            }),
3112        );
3113        doc.trailer.set("Root", Object::Reference(catalog_id));
3114
3115        let mut bytes = Vec::new();
3116        doc.save_to(&mut bytes)
3117            .expect("save mixed rgb/cmyk fixture");
3118        bytes
3119    }
3120
3121    fn transparent_cmyk_pdf_bytes() -> Vec<u8> {
3122        use lopdf::{dictionary, Document, Object, Stream};
3123
3124        let mut doc = Document::with_version("1.4");
3125        let pages_id = doc.new_object_id();
3126        let page_id = doc.new_object_id();
3127        let gs_id = doc.add_object(Object::Dictionary(dictionary! {
3128            "Type" => "ExtGState",
3129            "ca" => Object::Real(0.5),
3130        }));
3131        let content = b"/GS1 gs\n1 0 0 0 k\n0 0 72 72 re f\n".to_vec();
3132        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3133
3134        doc.objects.insert(
3135            page_id,
3136            Object::Dictionary(dictionary! {
3137                "Type" => "Page",
3138                "Parent" => Object::Reference(pages_id),
3139                "MediaBox" => Object::Array(vec![0.into(), 0.into(), 72.into(), 72.into()]),
3140                "Resources" => dictionary! {
3141                    "ExtGState" => dictionary! {
3142                        "GS1" => Object::Reference(gs_id),
3143                    },
3144                },
3145                "Contents" => Object::Reference(content_id),
3146            }),
3147        );
3148        doc.objects.insert(
3149            pages_id,
3150            Object::Dictionary(dictionary! {
3151                "Type" => "Pages",
3152                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3153                "Count" => Object::Integer(1),
3154            }),
3155        );
3156        let catalog_id = doc.new_object_id();
3157        doc.objects.insert(
3158            catalog_id,
3159            Object::Dictionary(dictionary! {
3160                "Type" => "Catalog",
3161                "Pages" => Object::Reference(pages_id),
3162            }),
3163        );
3164        doc.trailer.set("Root", Object::Reference(catalog_id));
3165
3166        let mut bytes = Vec::new();
3167        doc.save_to(&mut bytes)
3168            .expect("save transparent cmyk fixture");
3169        bytes
3170    }
3171
3172    fn cmyk_image_pdf_bytes() -> Vec<u8> {
3173        use lopdf::{dictionary, Document, Object, Stream};
3174
3175        let mut doc = Document::with_version("1.4");
3176        let pages_id = doc.new_object_id();
3177        let page_id = doc.new_object_id();
3178        let image_id = doc.add_object(Stream::new(
3179            dictionary! {
3180                "Type" => "XObject",
3181                "Subtype" => "Image",
3182                "Width" => Object::Integer(2),
3183                "Height" => Object::Integer(1),
3184                "BitsPerComponent" => Object::Integer(8),
3185                "ColorSpace" => "DeviceCMYK",
3186            },
3187            vec![255, 0, 0, 0, 0, 255, 0, 0],
3188        ));
3189        let content = b"q\n2 0 0 1 0 0 cm\n/Im1 Do\nQ\n".to_vec();
3190        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3191
3192        doc.objects.insert(
3193            page_id,
3194            Object::Dictionary(dictionary! {
3195                "Type" => "Page",
3196                "Parent" => Object::Reference(pages_id),
3197                "MediaBox" => Object::Array(vec![0.into(), 0.into(), 2.into(), 1.into()]),
3198                "Resources" => dictionary! {
3199                    "XObject" => dictionary! {
3200                        "Im1" => Object::Reference(image_id),
3201                    },
3202                },
3203                "Contents" => Object::Reference(content_id),
3204            }),
3205        );
3206        doc.objects.insert(
3207            pages_id,
3208            Object::Dictionary(dictionary! {
3209                "Type" => "Pages",
3210                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3211                "Count" => Object::Integer(1),
3212            }),
3213        );
3214        let catalog_id = doc.new_object_id();
3215        doc.objects.insert(
3216            catalog_id,
3217            Object::Dictionary(dictionary! {
3218                "Type" => "Catalog",
3219                "Pages" => Object::Reference(pages_id),
3220            }),
3221        );
3222        doc.trailer.set("Root", Object::Reference(catalog_id));
3223
3224        let mut bytes = Vec::new();
3225        doc.save_to(&mut bytes).expect("save cmyk image fixture");
3226        bytes
3227    }
3228
3229    fn pixel_at(rendered: &RenderedPage, x: u32, y: u32) -> [u8; 4] {
3230        let idx = ((y * rendered.width + x) * 4) as usize;
3231        [
3232            rendered.pixels[idx],
3233            rendered.pixels[idx + 1],
3234            rendered.pixels[idx + 2],
3235            rendered.pixels[idx + 3],
3236        ]
3237    }
3238
3239    /// Build a minimal one-page PDF whose only font is a non-embedded TrueType
3240    /// reference (no `FontFile2`). The character codes in the content stream
3241    /// resolve through the declared `/Encoding`, exercising the same code path
3242    /// as corpus PDFs like `171_171940.pdf`.
3243    fn non_embedded_truetype_pdf_bytes(
3244        base_font: &[u8],
3245        encoding: &[u8],
3246        text_bytes: &[u8],
3247    ) -> Vec<u8> {
3248        use lopdf::{dictionary, Document, Object, Stream};
3249
3250        let mut doc = Document::with_version("1.4");
3251
3252        let font_id = doc.add_object(Object::Dictionary(dictionary! {
3253            "Type" => "Font",
3254            "Subtype" => "TrueType",
3255            "Name" => Object::Name(b"F0".to_vec()),
3256            "BaseFont" => Object::Name(base_font.to_vec()),
3257            "Encoding" => Object::Name(encoding.to_vec()),
3258        }));
3259
3260        let resources_id = doc.add_object(Object::Dictionary(dictionary! {
3261            "Font" => dictionary! { "F0" => Object::Reference(font_id) },
3262        }));
3263
3264        let mut content = Vec::new();
3265        content.extend_from_slice(b"BT\n/F0 12 Tf\n100 700 Td\n(");
3266        for &b in text_bytes {
3267            match b {
3268                b'(' | b')' | b'\\' => {
3269                    content.push(b'\\');
3270                    content.push(b);
3271                }
3272                _ => content.push(b),
3273            }
3274        }
3275        content.extend_from_slice(b") Tj\nET\n");
3276        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3277
3278        let pages_id = doc.new_object_id();
3279        let page_id = doc.add_object(Object::Dictionary(dictionary! {
3280            "Type" => "Page",
3281            "Parent" => Object::Reference(pages_id),
3282            "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3283            "Resources" => Object::Reference(resources_id),
3284            "Contents" => Object::Reference(content_id),
3285        }));
3286        doc.objects.insert(
3287            pages_id,
3288            Object::Dictionary(dictionary! {
3289                "Type" => "Pages",
3290                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3291                "Count" => Object::Integer(1),
3292            }),
3293        );
3294        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
3295            "Type" => "Catalog",
3296            "Pages" => Object::Reference(pages_id),
3297        }));
3298        doc.trailer.set("Root", Object::Reference(catalog_id));
3299
3300        let mut bytes = Vec::new();
3301        doc.save_to(&mut bytes).expect("save non-embedded fixture");
3302        bytes
3303    }
3304
3305    /// Build a minimal AcroForm push button whose only human-readable text
3306    /// lives in the widget `/MK /CA` caption entry.
3307    fn push_button_caption_pdf_bytes(caption: &[u8]) -> Vec<u8> {
3308        use lopdf::{dictionary, Document, Object, Stream, StringFormat};
3309
3310        let mut doc = Document::with_version("1.4");
3311
3312        let catalog_id = doc.new_object_id();
3313        let pages_id = doc.new_object_id();
3314        let page_id = doc.new_object_id();
3315        let acroform_id = doc.new_object_id();
3316        let content_id = doc.new_object_id();
3317        let widget_id = doc.new_object_id();
3318
3319        doc.objects.insert(
3320            content_id,
3321            Object::Stream(Stream::new(dictionary! {}, Vec::new())),
3322        );
3323        doc.objects.insert(
3324            widget_id,
3325            Object::Dictionary(dictionary! {
3326                "Type" => "Annot",
3327                "Subtype" => "Widget",
3328                "FT" => "Btn",
3329                "Ff" => Object::Integer(1 << 16),
3330                "T" => Object::String(b"button".to_vec(), StringFormat::Literal),
3331                "MK" => dictionary! {
3332                    "CA" => Object::String(caption.to_vec(), StringFormat::Literal),
3333                },
3334                "Rect" => Object::Array(vec![100.into(), 700.into(), 260.into(), 730.into()]),
3335                "P" => Object::Reference(page_id),
3336            }),
3337        );
3338        doc.objects.insert(
3339            page_id,
3340            Object::Dictionary(dictionary! {
3341                "Type" => "Page",
3342                "Parent" => Object::Reference(pages_id),
3343                "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
3344                "Annots" => Object::Array(vec![Object::Reference(widget_id)]),
3345                "Contents" => Object::Reference(content_id),
3346            }),
3347        );
3348        doc.objects.insert(
3349            pages_id,
3350            Object::Dictionary(dictionary! {
3351                "Type" => "Pages",
3352                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
3353                "Count" => Object::Integer(1),
3354            }),
3355        );
3356        doc.objects.insert(
3357            acroform_id,
3358            Object::Dictionary(dictionary! {
3359                "Fields" => Object::Array(vec![Object::Reference(widget_id)]),
3360            }),
3361        );
3362        doc.objects.insert(
3363            catalog_id,
3364            Object::Dictionary(dictionary! {
3365                "Type" => "Catalog",
3366                "Pages" => Object::Reference(pages_id),
3367                "AcroForm" => Object::Reference(acroform_id),
3368            }),
3369        );
3370        doc.trailer.set("Root", Object::Reference(catalog_id));
3371
3372        let mut bytes = Vec::new();
3373        doc.save_to(&mut bytes)
3374            .expect("save push-button caption fixture");
3375        bytes
3376    }
3377
3378    #[test]
3379    fn extract_text_non_embedded_truetype_alias_resolves_via_winansi() {
3380        // Mirrors corpus PDF `171_171940.pdf`: TrueType font references
3381        // `TimesNewRoman` (resolves through the standard-font alias table)
3382        // with `WinAnsiEncoding` and no embedded font program. Extraction must
3383        // recover the text from the declared encoding even though no glyph
3384        // outlines are available.
3385        let bytes = non_embedded_truetype_pdf_bytes(
3386            b"TimesNewRoman",
3387            b"WinAnsiEncoding",
3388            b"UNITED STATES DISTRICT COURT",
3389        );
3390        let text = PdfDocument::open(bytes)
3391            .expect("open non-embedded TrueType fixture")
3392            .extract_text(0)
3393            .expect("extract non-embedded TrueType text");
3394        let norm = normalize_text(&text);
3395        assert!(
3396            norm.contains("UNITED STATES DISTRICT COURT"),
3397            "expected WinAnsi-decoded text, got: {norm:?}"
3398        );
3399    }
3400
3401    #[test]
3402    fn extract_text_non_embedded_truetype_unknown_name_still_decodes() {
3403        // Custom BaseFont that does not match any standard alias and lacks the
3404        // keywords used by the heuristic. The standard-font fallback (via
3405        // FallbackFontQuery) still picks Helvetica, but on hosts without the
3406        // embedded font assets that path returns None — the new TextOnly
3407        // branch is what keeps extraction non-empty in that case. Either way,
3408        // the WinAnsi-driven char map must produce the original prose.
3409        let bytes = non_embedded_truetype_pdf_bytes(
3410            b"OpaqueCustomXYZ",
3411            b"WinAnsiEncoding",
3412            b"Hello, world!",
3413        );
3414        let text = PdfDocument::open(bytes)
3415            .expect("open custom non-embedded fixture")
3416            .extract_text(0)
3417            .expect("extract custom non-embedded text");
3418        let norm = normalize_text(&text);
3419        assert!(
3420            norm.contains("Hello, world!"),
3421            "expected WinAnsi-decoded text, got: {norm:?}"
3422        );
3423    }
3424
3425    #[test]
3426    fn extract_acroform_text_includes_push_button_mk_caption() {
3427        let bytes = push_button_caption_pdf_bytes(b"Don't cry over spilt milk");
3428        let doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3429
3430        let page_text = doc.extract_text(0).expect("extract page text");
3431        assert!(
3432            normalize_text(&page_text).is_empty(),
3433            "expected empty page content stream, got: {page_text:?}"
3434        );
3435
3436        let acroform_text = doc.extract_acroform_text();
3437        assert_eq!(normalize_text(&acroform_text), "Don't cry over spilt milk");
3438
3439        let all_text = doc.extract_all_text();
3440        assert_eq!(normalize_text(&all_text), "Don't cry over spilt milk");
3441    }
3442
3443    /// A form field value is text a searcher reads, and a ligature in one
3444    /// fails the same search a ligature on the page fails. The option covers
3445    /// it too, which is what makes `extract_all_text` one answer rather than
3446    /// a folded half and an unfolded half (codex, #437).
3447    #[test]
3448    fn acroform_values_fold_their_ligatures() {
3449        // A PDF text string carrying a non-Latin-1 character is UTF-16BE
3450        // behind a byte-order mark; the literal bytes would be read as
3451        // Latin-1 and never reach the fold as one character.
3452        let mut caption = vec![0xFE_u8, 0xFF_u8];
3453        for unit in "o\u{FB03}ce hours".encode_utf16() {
3454            caption.extend_from_slice(&unit.to_be_bytes());
3455        }
3456        let bytes = push_button_caption_pdf_bytes(&caption);
3457        let mut doc = PdfDocument::open(bytes).expect("open push-button caption fixture");
3458
3459        assert_eq!(normalize_text(&doc.extract_acroform_text()), "office hours");
3460        assert_eq!(normalize_text(&doc.extract_all_text()), "office hours");
3461
3462        doc.set_keep_ligatures(true);
3463        assert_eq!(
3464            normalize_text(&doc.extract_acroform_text()),
3465            "o\u{FB03}ce hours",
3466            "keep_ligatures did not reach the field values"
3467        );
3468    }
3469
3470    #[test]
3471    fn bytes_to_string_utf8() {
3472        assert_eq!(bytes_to_string(b"hello"), "hello");
3473    }
3474
3475    #[test]
3476    fn bytes_to_string_latin1() {
3477        let bytes = &[0xC4, 0xD6, 0xDC]; // ÄÖÜ in Latin-1
3478        let s = bytes_to_string(bytes);
3479        assert_eq!(s, "ÄÖÜ");
3480    }
3481
3482    #[test]
3483    fn bytes_to_string_utf16() {
3484        let bytes = &[0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]; // UTF-16 "Hi"
3485        assert_eq!(bytes_to_string(bytes), "Hi");
3486    }
3487
3488    #[test]
3489    fn document_info_default() {
3490        let info = DocumentInfo::default();
3491        assert!(info.title.is_none());
3492        assert!(info.author.is_none());
3493    }
3494
3495    #[test]
3496    fn bookmark_item_children() {
3497        let item = BookmarkItem {
3498            title: "Root".into(),
3499            page: None,
3500            children: vec![BookmarkItem {
3501                title: "Child".into(),
3502                page: Some(0),
3503                children: Vec::new(),
3504            }],
3505        };
3506        assert_eq!(item.children.len(), 1);
3507        assert_eq!(item.children[0].title, "Child");
3508    }
3509
3510    #[test]
3511    fn extract_text_type0_without_tounicode_uses_font_program_fallback() {
3512        let original = std::fs::read(corpus_path("sf181.pdf")).expect("read sf181 fixture");
3513        let expected = PdfDocument::open(original.clone())
3514            .expect("open original sf181")
3515            .extract_text(0)
3516            .expect("extract original sf181 text");
3517        assert!(
3518            expected.contains("Guide to Personnel Data Standards"),
3519            "unexpected baseline extraction: {expected}"
3520        );
3521
3522        let (stripped, removed) = strip_type0_tounicode(&original);
3523        assert!(
3524            removed > 0,
3525            "expected to strip at least one Type0 ToUnicode"
3526        );
3527
3528        let actual = PdfDocument::open(stripped)
3529            .expect("open stripped sf181")
3530            .extract_text(0)
3531            .expect("extract stripped sf181 text");
3532
3533        let actual_norm = normalize_text(&actual);
3534        let expected_norm = normalize_text(&expected);
3535
3536        assert!(
3537            actual_norm.contains("Guide to Personnel Data Standards"),
3538            "missing main heading after stripping ToUnicode: {actual_norm}"
3539        );
3540        assert!(
3541            actual_norm.contains("Privacy Act Statement"),
3542            "missing body text after stripping ToUnicode: {actual_norm}"
3543        );
3544        assert!(
3545            actual_norm.len() + 32 >= expected_norm.len(),
3546            "too much text lost after stripping ToUnicode: expected {} chars, got {}",
3547            expected_norm.len(),
3548            actual_norm.len()
3549        );
3550    }
3551
3552    #[test]
3553    fn extract_text_identity_h_bogus_tounicode_recovers_via_identity_fallback() {
3554        // PDFBOX-4322-3.pdf ships an Identity-H Type0 font whose `/ToUnicode`
3555        // stream is actually an Identity-H *encoding* CMap (only
3556        // `begincidrange <0000> <FFFF> 0`, no bf-mappings). The embedded
3557        // TrueType subset also has no `cmap` table, so both the ToUnicode
3558        // lookup and the reverse-cmap fallback fail. Previously this yielded
3559        // a 0-byte extraction because the character codes — which are Unicode
3560        // code points under Identity-H — were silently discarded.
3561        let bytes =
3562            std::fs::read(corpus_path("PDFBOX-4322-3.pdf")).expect("read PDFBOX-4322-3 fixture");
3563        let doc = PdfDocument::open(bytes).expect("open PDFBOX-4322-3");
3564        let text = doc.extract_all_text();
3565
3566        let norm = normalize_text(&text);
3567        assert!(
3568            norm.contains("Transatlantic Council"),
3569            "expected Identity-H codes to resolve as Unicode: {norm}"
3570        );
3571        assert!(
3572            norm.contains("Boy Scouts of America"),
3573            "expected body text to be recovered: {norm}"
3574        );
3575    }
3576
3577    #[test]
3578    fn render_max_pixels_none_is_unchanged_default_behavior() {
3579        // Default (max_pixels = None) must be byte-identical to an explicit no-budget render.
3580        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3581        let baseline = doc
3582            .render_page(
3583                0,
3584                &RenderOptions {
3585                    dpi: 144.0,
3586                    ..Default::default()
3587                },
3588            )
3589            .expect("baseline render");
3590        let explicit_none = doc
3591            .render_page(
3592                0,
3593                &RenderOptions {
3594                    dpi: 144.0,
3595                    max_pixels: None,
3596                    ..Default::default()
3597                },
3598            )
3599            .expect("explicit-none render");
3600        assert_eq!(baseline.width, explicit_none.width);
3601        assert_eq!(baseline.height, explicit_none.height);
3602        assert_eq!(baseline.pixels, explicit_none.pixels);
3603    }
3604
3605    #[test]
3606    fn render_max_pixels_budget_clamps_resolution() {
3607        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3608        let full = doc
3609            .render_page(
3610                0,
3611                &RenderOptions {
3612                    dpi: 288.0,
3613                    ..Default::default()
3614                },
3615            )
3616            .expect("full render");
3617        let full_px = full.width * full.height;
3618        // Budget well below the full pixel count must reduce output dimensions.
3619        let budget = full_px / 4;
3620        let capped = doc
3621            .render_page(
3622                0,
3623                &RenderOptions {
3624                    dpi: 288.0,
3625                    max_pixels: Some(budget),
3626                    ..Default::default()
3627                },
3628            )
3629            .expect("capped render");
3630        assert!(
3631            capped.width * capped.height <= full_px,
3632            "capped output must not exceed full output"
3633        );
3634        assert!(
3635            capped.width < full.width || capped.height < full.height,
3636            "budget below full pixel count must shrink at least one dimension"
3637        );
3638    }
3639
3640    #[test]
3641    fn render_max_pixels_large_budget_no_clamp() {
3642        // A budget larger than the rendered size must not change the output.
3643        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open fixture");
3644        let baseline = doc
3645            .render_page(
3646                0,
3647                &RenderOptions {
3648                    dpi: 72.0,
3649                    ..Default::default()
3650                },
3651            )
3652            .expect("baseline");
3653        let huge = doc
3654            .render_page(
3655                0,
3656                &RenderOptions {
3657                    dpi: 72.0,
3658                    max_pixels: Some(100_000_000),
3659                    ..Default::default()
3660                },
3661            )
3662            .expect("huge-budget render");
3663        assert_eq!(baseline.width, huge.width);
3664        assert_eq!(baseline.height, huge.height);
3665        assert_eq!(baseline.pixels, huge.pixels);
3666    }
3667
3668    #[test]
3669    fn render_page_with_config_srgb_matches_legacy_render_page() {
3670        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 rg")).expect("open rgb fixture");
3671        let legacy = doc
3672            .render_page(
3673                0,
3674                &RenderOptions {
3675                    dpi: 72.0,
3676                    ..Default::default()
3677                },
3678            )
3679            .expect("legacy render succeeds");
3680        let configured = doc
3681            .render_page_with_config(
3682                0,
3683                &RenderConfig {
3684                    color_mode: ColorMode::Srgb,
3685                    dpi: 72,
3686                },
3687            )
3688            .expect("configured render succeeds");
3689
3690        assert_eq!(legacy.width, configured.width);
3691        assert_eq!(legacy.height, configured.height);
3692        assert_eq!(legacy.pixel_format, PixelFormat::Rgba8);
3693        assert_eq!(configured.pixel_format, PixelFormat::Rgba8);
3694        assert_eq!(legacy.pixels, configured.pixels);
3695    }
3696
3697    #[test]
3698    fn render_page_with_config_preserve_cmyk_returns_cmyk_buffer() {
3699        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3700        let rendered = doc
3701            .render_page_with_config(
3702                0,
3703                &RenderConfig {
3704                    color_mode: ColorMode::PreserveCmyk,
3705                    dpi: 72,
3706                },
3707            )
3708            .expect("cmyk render succeeds");
3709
3710        assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3711        assert_eq!(
3712            rendered.pixels.len(),
3713            rendered.width as usize * rendered.height as usize * 4
3714        );
3715        assert_eq!(
3716            pixel_at(&rendered, rendered.width / 2, rendered.height / 2),
3717            crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3718        );
3719    }
3720
3721    #[test]
3722    fn render_page_with_config_simulate_cmyk_does_not_panic_on_cmyk_pdf() {
3723        let doc = PdfDocument::open(solid_fill_pdf_bytes("1 0 0 0 k")).expect("open cmyk fixture");
3724        let rendered = doc
3725            .render_page_with_config(
3726                0,
3727                &RenderConfig {
3728                    color_mode: ColorMode::SimulateCmyk,
3729                    dpi: 72,
3730                },
3731            )
3732            .expect("simulate cmyk render succeeds");
3733
3734        assert_eq!(rendered.pixel_format, PixelFormat::Rgba8);
3735        assert!(!rendered.pixels.is_empty());
3736    }
3737
3738    #[test]
3739    fn render_page_with_config_preserve_cmyk_mixed_page_preserves_only_cmyk_region() {
3740        let doc = PdfDocument::open(mixed_rgb_cmyk_pdf_bytes()).expect("open mixed fixture");
3741        let rendered = doc
3742            .render_page_with_config(
3743                0,
3744                &RenderConfig {
3745                    color_mode: ColorMode::PreserveCmyk,
3746                    dpi: 72,
3747                },
3748            )
3749            .expect("mixed render succeeds");
3750
3751        assert_eq!(
3752            pixel_at(&rendered, 54, 36),
3753            crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3754        );
3755        assert_ne!(
3756            pixel_at(&rendered, 18, 36),
3757            crate::color::preserve_device_cmyk(1.0, 0.0, 0.0, 0.0)
3758        );
3759    }
3760
3761    #[test]
3762    fn render_page_with_config_preserve_cmyk_transparent_page_does_not_crash() {
3763        let doc =
3764            PdfDocument::open(transparent_cmyk_pdf_bytes()).expect("open transparent cmyk fixture");
3765        let rendered = doc
3766            .render_page_with_config(
3767                0,
3768                &RenderConfig {
3769                    color_mode: ColorMode::PreserveCmyk,
3770                    dpi: 72,
3771                },
3772            )
3773            .expect("transparent cmyk render succeeds");
3774
3775        assert_eq!(rendered.pixel_format, PixelFormat::Cmyk8);
3776        assert_eq!(
3777            rendered.pixels.len(),
3778            rendered.width as usize * rendered.height as usize * 4
3779        );
3780    }
3781
3782    #[test]
3783    fn render_page_with_config_preserve_cmyk_keeps_device_cmyk_image_bytes() {
3784        let doc = PdfDocument::open(cmyk_image_pdf_bytes()).expect("open cmyk image fixture");
3785        let rendered = doc
3786            .render_page_with_config(
3787                0,
3788                &RenderConfig {
3789                    color_mode: ColorMode::PreserveCmyk,
3790                    dpi: 72,
3791                },
3792            )
3793            .expect("cmyk image render succeeds");
3794
3795        assert_eq!(rendered.width, 2);
3796        assert_eq!(rendered.height, 1);
3797        assert_eq!(pixel_at(&rendered, 0, 0), [255, 0, 0, 0]);
3798        assert_eq!(pixel_at(&rendered, 1, 0), [0, 255, 0, 0]);
3799    }
3800}
3801
3802#[cfg(test)]
3803mod load_recovery_tests {
3804    use super::PdfDocument;
3805
3806    #[test]
3807    fn broken_xref_sets_xref_rebuilt() {
3808        // Objects + trailer present, but startxref points nowhere: pd-syntax
3809        // rebuilds the xref by scanning, and the signal must reflect that.
3810        let body: &[u8] = b"%PDF-1.7\n\
38111 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
38122 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
38133 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>\nendobj\n\
3814trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n999999\n%%EOF";
3815        let doc = PdfDocument::open(body.to_vec()).expect("recovers via xref rebuild");
3816        assert!(doc.load_recovery().xref_rebuilt, "xref_rebuilt must be set");
3817    }
3818
3819    #[test]
3820    fn clean_document_reports_no_recovery() {
3821        // A well-formed PDF with a correct xref must report no recovery.
3822        let objs: [&[u8]; 3] = [
3823            b"<< /Type /Catalog /Pages 2 0 R >>",
3824            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
3825            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
3826        ];
3827        let mut buf = Vec::new();
3828        let mut off = [0usize; 4];
3829        buf.extend_from_slice(b"%PDF-1.7\n");
3830        for (i, body) in objs.iter().enumerate() {
3831            off[i + 1] = buf.len();
3832            buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
3833            buf.extend_from_slice(body);
3834            buf.extend_from_slice(b"\nendobj\n");
3835        }
3836        let xref_off = buf.len();
3837        buf.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
3838        for o in &off[1..4] {
3839            buf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
3840        }
3841        buf.extend_from_slice(
3842            format!("trailer\n<< /Root 1 0 R /Size 4 >>\nstartxref\n{xref_off}\n%%EOF").as_bytes(),
3843        );
3844        let doc = PdfDocument::open(buf).expect("clean doc opens");
3845        let r = doc.load_recovery();
3846        assert!(
3847            !r.xref_rebuilt && !r.page_tree_rebuilt,
3848            "a clean document must report no recovery; got {r:?}"
3849        );
3850    }
3851}
3852
3853/// Why a page may be worth sending to a vision model (#624).
3854///
3855/// A flag with its reasons rather than a bare boolean, because an agent that
3856/// routes a page somewhere expensive wants to know what it is routing: a scan
3857/// needs OCR, a chart needs a picture read, and a table we are unsure of needs
3858/// neither -- it needs a second opinion on a structure we already have.
3859#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3860pub struct VisionAdvice {
3861    /// The page draws ink but yields no text: a scan, or text drawn as curves.
3862    ///
3863    /// This is the one signal of the three that is measured rather than
3864    /// promised. On the DocLayNet sample of the round of 24-09-2026 it holds for
3865    /// 25 of 200 pages -- twelve per cent -- and on those pages nothing is
3866    /// extracted at all: the page carries no usable text layer, which is what
3867    /// makes them worth a vision model rather than a better parser.
3868    pub no_text_layer: bool,
3869    /// The page holds a table the table model is unsure of.
3870    ///
3871    /// ALWAYS FALSE TODAY, and deliberately so rather than guessed at: the
3872    /// learned table structure of #600 is not on master, and the heuristics that
3873    /// find a table there now produce a decision and not a probability. A
3874    /// confidence invented from a heuristic would be a number with nothing
3875    /// behind it, which is the opposite of what this whole flag is for.
3876    pub low_confidence_table: bool,
3877    /// The page holds a chart or a figure whose content is not text.
3878    ///
3879    /// ALWAYS FALSE TODAY. [`crate::BlockKind`] has no figure variant -- the
3880    /// engine does not detect one -- so there is nothing to report. The
3881    /// measurement that says it is worth having is on #574: a figure's data
3882    /// labels are one of the three shapes we turn into a table, and on those
3883    /// pages our text score is 0.433 against the reference's 0.526.
3884    pub figure: bool,
3885}
3886
3887impl VisionAdvice {
3888    /// Whether any reason holds.
3889    pub fn needs_vision(&self) -> bool {
3890        self.no_text_layer || self.low_confidence_table || self.figure
3891    }
3892}
3893
3894/// How few characters a page may yield before it counts as having no text layer
3895/// (#624).
3896///
3897/// Not zero, because a scan regularly carries a stamped page number or a
3898/// scanner's own header in real text while the body is pixels: `CN100339781C`
3899/// shows some thirty characters of such stray text on four of its
3900/// twenty-three pages, and all twenty-three are scans. Forty is above that
3901/// stray text and far below what any page of prose yields.
3902const VISION_MIN_CHARACTERS: usize = 40;
3903
3904/// Counts whether a page draws anything at all (#624).
3905#[derive(Default)]
3906struct InkDevice {
3907    glyphs: usize,
3908    images: usize,
3909    paths: usize,
3910}
3911
3912impl InkDevice {
3913    fn drew_anything(&self) -> bool {
3914        self.glyphs > 0 || self.images > 0 || self.paths > 0
3915    }
3916}
3917
3918impl<'a> InkDeviceTypes::Device<'a> for InkDevice {
3919    fn set_soft_mask(&mut self, _: Option<InkDeviceTypes::SoftMask<'a>>) {}
3920    fn set_blend_mode(&mut self, _: InkDeviceTypes::BlendMode) {}
3921    fn draw_path(
3922        &mut self,
3923        _: &kurbo::BezPath,
3924        _: kurbo::Affine,
3925        _: &InkDeviceTypes::Paint<'_>,
3926        _: &InkDeviceTypes::PathDrawMode,
3927    ) {
3928        self.paths += 1;
3929    }
3930    fn push_clip_path(&mut self, _: &InkDeviceTypes::ClipPath) {}
3931    fn push_transparency_group(
3932        &mut self,
3933        _: f32,
3934        _: Option<InkDeviceTypes::SoftMask<'_>>,
3935        _: InkDeviceTypes::BlendMode,
3936    ) {
3937    }
3938    fn draw_glyph(
3939        &mut self,
3940        _: &pdf_render::pdf_interpret::font::Glyph<'_>,
3941        _: kurbo::Affine,
3942        _: kurbo::Affine,
3943        _: &InkDeviceTypes::Paint<'_>,
3944        mode: &InkDeviceTypes::GlyphDrawMode,
3945    ) {
3946        // INVISIBLE TEXT IS NOT INK (codex on #1977). Text rendering mode 3 paints
3947        // nothing, and counting it had two consequences, one of them the opposite
3948        // of what this device is for. A visually blank page carrying a few
3949        // invisible characters would have read as "draws ink, yields no text" and
3950        // been sent to a vision model that has nothing to look at -- the very
3951        // blank-page case `a_blank_page_does_not_either` exists to prevent.
3952        //
3953        // And the other way round matters more for the flag's purpose: an OCR'd
3954        // scan draws its text layer invisibly OVER the image. Those pages have a
3955        // text layer, are read perfectly well, and must not be flagged -- they are
3956        // not flagged, because the character count decides that and the image is
3957        // what supplies the ink. Not counting invisible glyphs leaves that case
3958        // exactly as it was and fixes the blank one.
3959        if !matches!(mode, InkDeviceTypes::GlyphDrawMode::Invisible) {
3960            self.glyphs += 1;
3961        }
3962    }
3963    fn draw_image(&mut self, _: InkDeviceTypes::Image<'a, '_>, _: kurbo::Affine) {
3964        self.images += 1;
3965    }
3966    fn pop_clip_path(&mut self) {}
3967    fn pop_transparency_group(&mut self) {}
3968}
3969
3970#[cfg(test)]
3971mod last_glyph_record_tests {
3972    //! Spec `specs/text/circlecopyrt.md`, review 14-10 02:00 (i): the
3973    //! thread-local that names the text device holding a last glyph's place
3974    //! is empty once a page has been extracted, and once an extraction has
3975    //! panicked.
3976
3977    use super::*;
3978    use crate::text::last_glyph_is_held;
3979    use lopdf::{dictionary, Document as LoDocument, Object, Stream};
3980    use pdf_render::pdf_interpret::font::{Glyph, GlyphPlace};
3981    use pdf_render::pdf_interpret::{
3982        BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
3983    };
3984
3985    /// One page drawing `abc` in Helvetica.
3986    fn one_line() -> Vec<u8> {
3987        let mut doc = LoDocument::with_version("1.7");
3988        let pages_id = doc.new_object_id();
3989        let font_id = doc.add_object(dictionary! {
3990            "Type" => "Font",
3991            "Subtype" => "Type1",
3992            "BaseFont" => "Helvetica",
3993        });
3994        let content = b"BT /F1 12 Tf 72 700 Td (abc) Tj ET".to_vec();
3995        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
3996        let page_id = doc.add_object(dictionary! {
3997            "Type" => "Page",
3998            "Parent" => pages_id,
3999            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4000            "Contents" => content_id,
4001            "Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
4002        });
4003        doc.objects.insert(
4004            pages_id,
4005            Object::Dictionary(dictionary! {
4006                "Type" => "Pages",
4007                "Kids" => vec![Object::Reference(page_id)],
4008                "Count" => 1_i64,
4009            }),
4010        );
4011        let catalog_id = doc.add_object(dictionary! {
4012            "Type" => "Catalog",
4013            "Pages" => pages_id,
4014        });
4015        doc.trailer.set("Root", catalog_id);
4016        let mut bytes = Vec::new();
4017        doc.save_to(&mut bytes).expect("fixture saves");
4018        bytes
4019    }
4020
4021    /// The text device, made to panic at its second glyph, once the first
4022    /// has been remembered.
4023    struct PanicsAtTheSecondGlyph {
4024        inner: TextExtractionDevice,
4025        glyphs: usize,
4026    }
4027
4028    impl<'a> Device<'a> for PanicsAtTheSecondGlyph {
4029        fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
4030            self.inner.set_soft_mask(mask);
4031        }
4032        fn set_blend_mode(&mut self, blend_mode: BlendMode) {
4033            self.inner.set_blend_mode(blend_mode);
4034        }
4035        fn draw_path(
4036            &mut self,
4037            path: &kurbo::BezPath,
4038            transform: kurbo::Affine,
4039            paint: &Paint<'a>,
4040            draw_mode: &PathDrawMode,
4041        ) {
4042            self.inner.draw_path(path, transform, paint, draw_mode);
4043        }
4044        fn push_clip_path(&mut self, clip_path: &ClipPath) {
4045            self.inner.push_clip_path(clip_path);
4046        }
4047        fn push_transparency_group(
4048            &mut self,
4049            opacity: f32,
4050            mask: Option<SoftMask<'a>>,
4051            blend_mode: BlendMode,
4052        ) {
4053            self.inner
4054                .push_transparency_group(opacity, mask, blend_mode);
4055        }
4056        fn draw_glyph(
4057            &mut self,
4058            glyph: &Glyph<'a>,
4059            transform: kurbo::Affine,
4060            glyph_transform: kurbo::Affine,
4061            paint: &Paint<'a>,
4062            draw_mode: &GlyphDrawMode,
4063        ) {
4064            self.glyphs += 1;
4065            if self.glyphs == 2 {
4066                assert!(last_glyph_is_held(), "the first glyph is remembered");
4067                panic!("inside extraction");
4068            }
4069            self.inner
4070                .draw_glyph(glyph, transform, glyph_transform, paint, draw_mode);
4071        }
4072        fn draw_image(&mut self, image: Image<'a, '_>, transform: kurbo::Affine) {
4073            self.inner.draw_image(image, transform);
4074        }
4075        fn pop_clip_path(&mut self) {
4076            self.inner.pop_clip_path();
4077        }
4078        fn pop_transparency_group(&mut self) {
4079            self.inner.pop_transparency_group();
4080        }
4081    }
4082
4083    #[test]
4084    fn the_last_glyph_record_is_empty_after_a_page_is_extracted() {
4085        let doc = PdfDocument::open(one_line()).expect("fixture opens");
4086        let text = doc.extract_text(0).expect("text");
4087        assert!(text.contains("abc"), "{text:?}");
4088        assert!(!last_glyph_is_held());
4089    }
4090
4091    #[test]
4092    fn the_last_glyph_record_is_empty_after_a_panic_inside_extraction() {
4093        let doc = PdfDocument::open(one_line()).expect("fixture opens");
4094        let page = doc.get_page(0).expect("page");
4095        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4096            let mut device = PanicsAtTheSecondGlyph {
4097                inner: TextExtractionDevice::new(),
4098                glyphs: 0,
4099            };
4100            let mut ctx =
4101                PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4102            interpret_page(page, &mut ctx, &mut device);
4103        }));
4104        assert!(outcome.is_err(), "the device panicked");
4105        assert!(!last_glyph_is_held());
4106    }
4107
4108    /// One page drawing a Helvetica `c` at 102.8 and then, at 100, a one-em
4109    /// glyph named `circlecopyrt` whose box holds the `c`'s centre: drawing
4110    /// the circle offers it to the `c`.
4111    fn c_then_circle() -> Vec<u8> {
4112        let mut doc = LoDocument::with_version("1.7");
4113        let pages_id = doc.new_object_id();
4114        let helvetica = doc.add_object(dictionary! {
4115            "Type" => "Font",
4116            "Subtype" => "Type1",
4117            "BaseFont" => "Helvetica",
4118        });
4119        let circle = doc.add_object(dictionary! {
4120            "Type" => "Font",
4121            "Subtype" => "Type1",
4122            "BaseFont" => "CMSY10",
4123            "FirstChar" => 65_i64,
4124            "LastChar" => 65_i64,
4125            "Widths" => vec![Object::Integer(1000)],
4126            "Encoding" => dictionary! {
4127                "Type" => "Encoding",
4128                "Differences" => vec![Object::Integer(65), Object::Name(b"circlecopyrt".to_vec())],
4129            },
4130        });
4131        let content = b"BT /F1 10 Tf 1 0 0 1 102.8 700 Tm (c) Tj \
4132                        /F2 10 Tf 1 0 0 1 100 700 Tm (A) Tj ET"
4133            .to_vec();
4134        let content_id = doc.add_object(Stream::new(dictionary! {}, content));
4135        let page_id = doc.add_object(dictionary! {
4136            "Type" => "Page",
4137            "Parent" => pages_id,
4138            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4139            "Contents" => content_id,
4140            "Resources" => dictionary! {
4141                "Font" => dictionary! { "F1" => helvetica, "F2" => circle },
4142            },
4143        });
4144        doc.objects.insert(
4145            pages_id,
4146            Object::Dictionary(dictionary! {
4147                "Type" => "Pages",
4148                "Kids" => vec![Object::Reference(page_id)],
4149                "Count" => 1_i64,
4150            }),
4151        );
4152        let catalog_id = doc.add_object(dictionary! {
4153            "Type" => "Catalog",
4154            "Pages" => pages_id,
4155        });
4156        doc.trailer.set("Root", catalog_id);
4157        let mut bytes = Vec::new();
4158        doc.save_to(&mut bytes).expect("fixture saves");
4159        bytes
4160    }
4161
4162    /// A device that keeps each glyph's place and, at the second glyph, asks
4163    /// it what the glyph at each of a few places reads as now.
4164    #[derive(Default)]
4165    struct AsksAtTheSecondGlyph {
4166        places: Vec<Option<GlyphPlace>>,
4167        /// A place from another page interpretation, asked first.
4168        foreign: Option<GlyphPlace>,
4169        /// For each question, whether the second glyph answered it.
4170        answered: Vec<(&'static str, bool)>,
4171    }
4172
4173    impl<'a> Device<'a> for AsksAtTheSecondGlyph {
4174        fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
4175        fn set_blend_mode(&mut self, _: BlendMode) {}
4176        fn draw_path(
4177            &mut self,
4178            _: &kurbo::BezPath,
4179            _: kurbo::Affine,
4180            _: &Paint<'a>,
4181            _: &PathDrawMode,
4182        ) {
4183        }
4184        fn push_clip_path(&mut self, _: &ClipPath) {}
4185        fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
4186        fn draw_glyph(
4187            &mut self,
4188            glyph: &Glyph<'a>,
4189            _: kurbo::Affine,
4190            _: kurbo::Affine,
4191            _: &Paint<'a>,
4192            _: &GlyphDrawMode,
4193        ) {
4194            let Glyph::Outline(outline) = glyph else {
4195                return;
4196            };
4197            if let [Some(before)] = self.places[..] {
4198                if let Some(foreign) = self.foreign {
4199                    let read = outline.reread_previous(foreign).is_some();
4200                    self.answered
4201                        .push(("a place from another interpretation", read));
4202                }
4203                let own = outline.place().expect("drawn in an interpretation");
4204                let read = outline.reread_previous(own).is_some();
4205                self.answered.push(("its own place", read));
4206                // Last: this read takes the offer.
4207                let read = outline.reread_previous(before).is_some();
4208                self.answered.push(("the glyph directly before", read));
4209            }
4210            self.places.push(outline.place());
4211        }
4212        fn draw_image(&mut self, _: Image<'a, '_>, _: kurbo::Affine) {}
4213        fn pop_clip_path(&mut self) {}
4214        fn pop_transparency_group(&mut self) {}
4215    }
4216
4217    /// [`c_then_circle`] interpreted on a thread of its own, the first one
4218    /// that thread runs, with `foreign` as the place asked first.
4219    fn asked_on_a_new_thread(foreign: Option<GlyphPlace>) -> AsksAtTheSecondGlyph {
4220        std::thread::spawn(move || {
4221            let doc = PdfDocument::open(c_then_circle()).expect("fixture opens");
4222            let page = doc.get_page(0).expect("page");
4223            let mut device = AsksAtTheSecondGlyph {
4224                foreign,
4225                ..Default::default()
4226            };
4227            let mut ctx =
4228                PdfDocument::create_context_with_settings(page, doc.text_extraction_settings());
4229            interpret_page(page, &mut ctx, &mut device);
4230            device
4231        })
4232        .join()
4233        .expect("the thread finishes")
4234    }
4235
4236    /// Spec review 14-10 07:18: a place answers only for the glyph directly
4237    /// before, in the same interpretation. The foreign place stands at the
4238    /// same position in the same page, drawn by another thread's first
4239    /// interpretation, so only the record's number can tell the two apart.
4240    #[test]
4241    fn a_place_from_elsewhere_than_the_last_glyph_gives_no_reread() {
4242        let first = asked_on_a_new_thread(None);
4243        let foreign = first.places[0].expect("the c has a place");
4244        let second = asked_on_a_new_thread(Some(foreign));
4245        assert_eq!(
4246            second.answered,
4247            [
4248                ("a place from another interpretation", false),
4249                ("its own place", false),
4250                ("the glyph directly before", true),
4251            ]
4252        );
4253    }
4254}