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