Skip to main content

pdf_inspector/
lib.rs

1// Rust 1.95 introduced collapsible_match for `if` inside match arms.
2// The content-stream parsers use this pattern extensively (match on operator
3// name, then check `in_text_block && !op.operands.is_empty()`). Collapsing
4// these into match guards would hurt readability. Allow crate-wide.
5#![allow(clippy::collapsible_match)]
6
7//! Smart PDF detection and text extraction using lopdf
8//!
9//! # Quick start
10//!
11//! ```no_run
12//! // Full processing (detect + extract + markdown) with defaults
13//! let result = pdf_inspector::process_pdf("document.pdf").unwrap();
14//! println!("type: {:?}, pages: {}", result.pdf_type, result.page_count);
15//! if let Some(md) = &result.markdown {
16//!     println!("{md}");
17//! }
18//!
19//! // Fast metadata-only detection (no text extraction)
20//! let info = pdf_inspector::detect_pdf("document.pdf").unwrap();
21//! println!("type: {:?}, pages: {}", info.pdf_type, info.page_count);
22//!
23//! // Custom options via builder
24//! use pdf_inspector::{PdfOptions, ProcessMode};
25//! let result = pdf_inspector::process_pdf_with_options(
26//!     "document.pdf",
27//!     PdfOptions::new().mode(ProcessMode::Analyze),
28//! ).unwrap();
29//! ```
30
31#[cfg(feature = "python")]
32pub mod python;
33
34pub mod adobe_korea1;
35mod bidi;
36mod bidi_mirroring;
37pub mod detector;
38pub mod extractor;
39mod form_bbox_repair;
40pub mod glyph_names;
41mod mac_glyph_order;
42pub mod markdown;
43mod overlong_numerals;
44pub mod process_mode;
45pub mod structure_tree;
46pub mod tables;
47mod text_quality;
48pub mod text_utils;
49pub mod tounicode;
50pub mod types;
51pub mod vision;
52mod xref_repair;
53
54pub use detector::{
55    detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
56    detect_pdf_type_with_config, DetectionConfig, PdfType, PdfTypeResult, ScanStrategy,
57};
58pub use extractor::geometry::PageRotation;
59pub use extractor::{
60    extract_text, extract_text_with_positions, extract_text_with_positions_and_rotations_mem,
61    extract_text_with_positions_and_rotations_mem_in_frame,
62    extract_text_with_positions_and_rotations_mem_with_options, extract_text_with_positions_mem,
63    extract_text_with_positions_mem_in_frame, extract_text_with_positions_mem_with_options,
64    extract_text_with_positions_pages, extract_text_with_positions_pages_with_password,
65    PositionFrame, PositionOptions,
66};
67pub use markdown::{
68    to_markdown, to_markdown_from_items, to_markdown_from_items_with_rects,
69    to_markdown_from_items_with_rects_and_page_count, MarkdownOptions, MarkdownProfile,
70};
71pub use process_mode::ProcessMode;
72pub use types::{BoldSource, LayoutComplexity, PdfLine, PdfRect, TextItem};
73
74use lopdf::Document;
75use std::collections::{BTreeMap, HashMap, HashSet};
76use std::path::Path;
77use text_quality::{
78    analyze_text_quality, detect_encoding_issues, is_cid_garbage, is_garbage_text,
79    region_items_have_decoding_issue,
80};
81use tounicode::FontCMaps;
82
83#[cfg(not(target_arch = "wasm32"))]
84struct ProcessingTimer(std::time::Instant);
85
86#[cfg(target_arch = "wasm32")]
87struct ProcessingTimer;
88
89impl ProcessingTimer {
90    fn start() -> Self {
91        #[cfg(not(target_arch = "wasm32"))]
92        {
93            Self(std::time::Instant::now())
94        }
95
96        #[cfg(target_arch = "wasm32")]
97        {
98            Self
99        }
100    }
101
102    fn elapsed_ms(&self) -> u64 {
103        #[cfg(not(target_arch = "wasm32"))]
104        {
105            self.0.elapsed().as_millis() as u64
106        }
107
108        #[cfg(target_arch = "wasm32")]
109        {
110            // The wasm32-unknown-unknown standard library has no clock.
111            // Browser bindings measure with JavaScript's host clock.
112            0
113        }
114    }
115}
116
117/// OCR reason emitted when the extracted text layer appears garbled due to
118/// broken font decoding or mojibake.
119pub const OCR_REASON_SUSPECTED_GARBLED_TEXT: &str = "suspected_garbled_text";
120
121/// OCR reason: the page is a scanned image (a full-page raster / image-only
122/// page) with no usable text layer.
123pub const OCR_REASON_SCANNED: &str = "scanned";
124
125/// OCR reason: the page has no extractable text and no image to OCR — blank,
126/// or content the parser cannot reach.
127pub const OCR_REASON_NO_TEXT: &str = "no_text";
128
129/// OCR reason: the page's text is drawn as vector outlines (path operators)
130/// rather than real text operators, so it cannot be extracted as characters.
131pub const OCR_REASON_VECTOR_TEXT: &str = "vector_text";
132
133/// OCR reason: every text-showing operator on the page leaves nothing to
134/// see — text render mode 3 (invisible), or mode 7 (clip only) with
135/// nothing painted through the clip — while an image covers at least half
136/// of the page: a scan carrying a text layer nobody sees. What that layer
137/// says is not what the page shows, so the page is read from its raster.
138pub const OCR_REASON_INVISIBLE_TEXT_LAYER: &str = "invisible_text_layer";
139
140// =========================================================================
141// Result type
142// =========================================================================
143
144/// OCR reasons for a single 1-indexed page.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PageOcrReasons {
147    /// 1-indexed page number.
148    pub page: u32,
149    /// Machine-readable OCR reason identifiers.
150    pub reasons: Vec<String>,
151}
152
153/// A font whose ToUnicode CMap (or, for a font without one, the embedded
154/// program's own cmap table) had no entry for some of the codes the document
155/// shows through it, and what became of those codes.
156///
157/// A CMap written for some of a font's glyphs but not all of them loses the
158/// others' letters from the text. A code without an entry is read from the
159/// mapped codes around it when they spell it out — a CMap mapping code 36
160/// to `A` and code 38 to `C` says code 37 is `B`, for a run of digits or of
161/// letters of one case whose glyph order follows the alphabet — and is a
162/// U+FFFD in the text otherwise, so the loss stays visible. The counts let
163/// a caller weigh text read from such a font: `codes - interpolated -
164/// unmapped` of its codes had an entry.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct FontCMapGaps {
167    /// The font's `/BaseFont` name, or its resource name when it has none
168    /// or an empty one.
169    pub font: String,
170    /// Codes shown through the font's CMap, repeats included: two-byte
171    /// codes, or the bytes of a single-byte CMap.
172    pub codes: u32,
173    /// Codes without an entry that were read from the mapped codes around
174    /// them.
175    pub interpolated: u32,
176    /// Codes without an entry that could not be read; each is a U+FFFD in
177    /// the text.
178    pub unmapped: u32,
179}
180
181/// High-level PDF processing result.
182#[derive(Debug)]
183pub struct PdfProcessResult {
184    /// The detected PDF type.
185    pub pdf_type: PdfType,
186    /// Markdown output (populated in [`ProcessMode::Full`], `None` otherwise).
187    pub markdown: Option<String>,
188    /// Page count.
189    pub page_count: u32,
190    /// Processing time in milliseconds.
191    pub processing_time_ms: u64,
192    /// 1-indexed page numbers that need OCR.
193    pub pages_needing_ocr: Vec<u32>,
194    /// Machine-readable OCR reasons by 1-indexed page.
195    pub ocr_reasons_by_page: Vec<PageOcrReasons>,
196    /// The `/Title` of the document information dictionary, decoded as a
197    /// PDF text string (UTF-16 or UTF-8 after a byte order mark,
198    /// PDFDocEncoding otherwise; see [`PdfTypeResult::title`]). `None` when
199    /// the entry is missing or not a string. The entries below follow the
200    /// same decoding and missing-value rule.
201    pub title: Option<String>,
202    /// The document information dictionary's `/Author`.
203    pub author: Option<String>,
204    /// The document information dictionary's `/Subject`.
205    pub subject: Option<String>,
206    /// The document information dictionary's `/Keywords`.
207    pub keywords: Option<String>,
208    /// The document information dictionary's `/Creator`: the application
209    /// the document was authored in.
210    pub creator: Option<String>,
211    /// The document information dictionary's `/Producer`: the application
212    /// that wrote the PDF.
213    pub producer: Option<String>,
214    /// The document information dictionary's `/CreationDate` as written, a
215    /// PDF date string such as `D:20240115103000+01'00'`, neither validated
216    /// nor converted.
217    pub creation_date: Option<String>,
218    /// The document information dictionary's `/ModDate` as written, like
219    /// `creation_date`.
220    pub mod_date: Option<String>,
221    /// Detection confidence score (0.0–1.0).
222    pub confidence: f32,
223    /// Layout complexity analysis (tables, multi-column detection).
224    pub layout: LayoutComplexity,
225    /// `true` when broken font encodings are detected (garbled text,
226    /// replacement characters). Clients should fall back to OCR.
227    pub has_encoding_issues: bool,
228    /// The fonts whose ToUnicode CMap — or, for a font without one, the
229    /// embedded program's cmap table — lacked an entry for a code the
230    /// document shows through it, with the counts of codes shown, read from
231    /// their neighbours and left as U+FFFD (see [`FontCMapGaps`]). Always
232    /// empty in [`ProcessMode::DetectOnly`], which decodes no text;
233    /// otherwise empty when every such code had an entry.
234    pub cmap_gaps: Vec<FontCMapGaps>,
235}
236
237// =========================================================================
238// Options builder
239// =========================================================================
240
241/// Configuration for [`process_pdf_with_options`] and friends.
242///
243/// Use the builder methods to customise behaviour:
244///
245/// ```
246/// use pdf_inspector::{PdfOptions, ProcessMode};
247///
248/// let opts = PdfOptions::new()
249///     .mode(ProcessMode::Analyze)
250///     .pages([1, 3, 5]);
251/// ```
252#[derive(Clone)]
253pub struct PdfOptions {
254    /// How far the pipeline should run (default: [`ProcessMode::Full`]).
255    pub mode: ProcessMode,
256    /// Detection configuration.
257    pub detection: DetectionConfig,
258    /// Markdown formatting options (only used in [`ProcessMode::Full`]).
259    pub markdown: MarkdownOptions,
260    /// Optional set of 1-indexed pages to process.  `None` = all pages.
261    pub page_filter: Option<HashSet<u32>>,
262    /// Password for decrypting an encrypted PDF. `None` falls back to the
263    /// empty password (owner-only encryption).
264    pub password: Option<String>,
265}
266
267// Manual `Debug` so the password is never leaked through debug logging or a
268// panic that formats the options; it renders as `Some("[REDACTED]")`.
269impl std::fmt::Debug for PdfOptions {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.debug_struct("PdfOptions")
272            .field("mode", &self.mode)
273            .field("detection", &self.detection)
274            .field("markdown", &self.markdown)
275            .field("page_filter", &self.page_filter)
276            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
277            .finish()
278    }
279}
280
281impl Default for PdfOptions {
282    fn default() -> Self {
283        Self {
284            mode: ProcessMode::Full,
285            detection: DetectionConfig::default(),
286            markdown: MarkdownOptions::default(),
287            page_filter: None,
288            password: None,
289        }
290    }
291}
292
293impl PdfOptions {
294    /// Create options with all defaults ([`ProcessMode::Full`]).
295    pub fn new() -> Self {
296        Self::default()
297    }
298
299    /// Shorthand for detect-only options.
300    pub fn detect_only() -> Self {
301        Self {
302            mode: ProcessMode::DetectOnly,
303            ..Self::default()
304        }
305    }
306
307    /// Set the processing mode.
308    pub fn mode(mut self, mode: ProcessMode) -> Self {
309        self.mode = mode;
310        self
311    }
312
313    /// Set detection configuration.
314    pub fn detection(mut self, config: DetectionConfig) -> Self {
315        self.detection = config;
316        self
317    }
318
319    /// Set markdown formatting options.
320    pub fn markdown(mut self, options: MarkdownOptions) -> Self {
321        self.markdown = options;
322        self
323    }
324
325    /// Limit processing to specific 1-indexed pages.
326    pub fn pages(mut self, pages: impl IntoIterator<Item = u32>) -> Self {
327        self.page_filter = Some(pages.into_iter().collect());
328        self
329    }
330
331    /// Set the password used to decrypt an encrypted PDF.
332    pub fn password(mut self, password: impl Into<String>) -> Self {
333        self.password = Some(password.into());
334        self
335    }
336}
337
338// =========================================================================
339// Public convenience functions
340// =========================================================================
341
342/// Process a PDF file with full extraction (detect → extract → markdown).
343///
344/// This is the most common entry point.  Equivalent to
345/// `process_pdf_with_options(path, PdfOptions::new())`.
346pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
347    process_pdf_with_options(path, PdfOptions::new())
348}
349
350/// Fast metadata-only detection — no text extraction or markdown generation.
351///
352/// Equivalent to `process_pdf_with_options(path, PdfOptions::detect_only())`.
353pub fn detect_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
354    process_pdf_with_options(path, PdfOptions::detect_only())
355}
356
357/// Process a PDF file with custom options.
358///
359/// The document is loaded **once** and shared between detection and extraction.
360pub fn process_pdf_with_options<P: AsRef<Path>>(
361    path: P,
362    options: PdfOptions,
363) -> Result<PdfProcessResult, PdfError> {
364    let start = ProcessingTimer::start();
365    validate_pdf_file(&path)?;
366
367    // Load the document once — shared by detection AND extraction.
368    let (doc, page_count) =
369        load_document_from_path_with_password(&path, options.password.as_deref())?;
370
371    process_document(doc, page_count, options, start)
372}
373
374/// Process a PDF from a memory buffer with full extraction.
375pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
376    process_pdf_mem_with_options(buffer, PdfOptions::new())
377}
378
379/// Fast metadata-only detection from a memory buffer.
380pub fn detect_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
381    process_pdf_mem_with_options(buffer, PdfOptions::detect_only())
382}
383
384/// Process a PDF from a memory buffer with custom options.
385///
386/// The buffer is parsed **once** and shared between detection and extraction.
387pub fn process_pdf_mem_with_options(
388    buffer: &[u8],
389    options: PdfOptions,
390) -> Result<PdfProcessResult, PdfError> {
391    let start = ProcessingTimer::start();
392    validate_pdf_bytes(buffer)?;
393
394    let (doc, page_count) =
395        load_document_from_mem_with_password(buffer, options.password.as_deref())?;
396
397    process_document(doc, page_count, options, start)
398}
399
400// =========================================================================
401// Deprecated compat shims
402// =========================================================================
403
404/// Process a PDF file with custom detection and markdown configuration.
405#[deprecated(since = "0.2.0", note = "Use process_pdf_with_options instead")]
406pub fn process_pdf_with_config<P: AsRef<Path>>(
407    path: P,
408    config: DetectionConfig,
409    markdown_options: MarkdownOptions,
410) -> Result<PdfProcessResult, PdfError> {
411    process_pdf_with_options(
412        path,
413        PdfOptions::new()
414            .detection(config)
415            .markdown(markdown_options),
416    )
417}
418
419/// Process a PDF file with custom configuration and optional page filter.
420#[deprecated(since = "0.2.0", note = "Use process_pdf_with_options instead")]
421pub fn process_pdf_with_config_pages<P: AsRef<Path>>(
422    path: P,
423    config: DetectionConfig,
424    markdown_options: MarkdownOptions,
425    page_filter: Option<&HashSet<u32>>,
426) -> Result<PdfProcessResult, PdfError> {
427    let mut opts = PdfOptions::new()
428        .detection(config)
429        .markdown(markdown_options);
430    opts.page_filter = page_filter.cloned();
431    process_pdf_with_options(path, opts)
432}
433
434/// Process PDF from memory buffer with custom detection and markdown configuration.
435#[deprecated(since = "0.2.0", note = "Use process_pdf_mem_with_options instead")]
436pub fn process_pdf_mem_with_config(
437    buffer: &[u8],
438    config: DetectionConfig,
439    markdown_options: MarkdownOptions,
440) -> Result<PdfProcessResult, PdfError> {
441    process_pdf_mem_with_options(
442        buffer,
443        PdfOptions::new()
444            .detection(config)
445            .markdown(markdown_options),
446    )
447}
448
449// =========================================================================
450// Region-based text extraction (for hybrid OCR pipelines)
451// =========================================================================
452
453/// Lightweight classification result for routing decisions.
454#[derive(Debug)]
455pub struct PdfClassification {
456    /// The detected PDF type.
457    pub pdf_type: PdfType,
458    /// Total page count.
459    pub page_count: u32,
460    /// 0-indexed page numbers that need OCR (scanned/image pages).
461    pub pages_needing_ocr: Vec<u32>,
462    /// Detection confidence score (0.0–1.0).
463    pub confidence: f32,
464}
465
466/// Classify a PDF from a memory buffer without extracting text.
467/// Returns the PDF type and which pages need OCR (~10-50ms).
468pub fn classify_pdf_mem(buffer: &[u8]) -> Result<PdfClassification, PdfError> {
469    validate_pdf_bytes(buffer)?;
470    let (doc, page_count) = load_document_from_mem(buffer)?;
471    let detection = detector::detect_from_document(&doc, page_count, &DetectionConfig::default())?;
472    Ok(PdfClassification {
473        pdf_type: detection.pdf_type,
474        page_count,
475        // Convert from 1-indexed to 0-indexed for caller convenience
476        pages_needing_ocr: detection.pages_needing_ocr.iter().map(|&p| p - 1).collect(),
477        confidence: detection.confidence,
478    })
479}
480
481/// The PDF written back out with the `/BBox` of its Form XObjects repaired
482/// — a zero-area box widened, numerals too large for any parser saturated
483/// — for callers that render the document with their own renderer. Rust
484/// only: the Python, Node.js and WebAssembly bindings do not expose it.
485///
486/// Some producers write `/BBox [0 0 0 0]` on a form XObject that holds a
487/// page's content; taken as the clip it declares, the box hides the form
488/// entirely, and a page drawn through it renders blank. Others write the
489/// box as ±(DBL_MAX / 2) in full, 308-digit numerals no integer parser
490/// holds: the form drops out of the document for one reader and clips to
491/// nothing for another. pdf-inspector repairs both whenever it loads a
492/// document, so its own extraction and the renderer of its OCR pipeline
493/// see the content; a renderer given the original bytes does not, and can
494/// be given these instead.
495///
496/// Returns `Ok(None)` when no form needs the repair, and for an encrypted
497/// document — whether or not it opens without a password — since a plain
498/// serialization would drop its protection; the OCR pipeline renders a
499/// decrypted copy of such a document in memory instead. Otherwise
500/// `Ok(Some(bytes))` holds a plain serialization of the loaded document:
501/// object streams and incremental updates are flattened, and the file's
502/// own repairs — a recovered cross-reference table, a missing end-of-file
503/// marker — are folded in.
504pub fn widen_degenerate_form_bboxes_mem(buffer: &[u8]) -> Result<Option<Vec<u8>>, PdfError> {
505    validate_pdf_bytes(buffer)?;
506    let (mut doc, _page_count, repairs) = match load_document_from_mem_with_repairs(buffer, None) {
507        Ok(loaded) => loaded,
508        Err(PdfError::Encrypted) => return Ok(None),
509        Err(error) => return Err(error),
510    };
511    if !repairs.repaired_forms() || doc.is_encrypted() || doc.encryption_state.is_some() {
512        return Ok(None);
513    }
514    Ok(form_bbox_repair::serialize_for_rendering(&mut doc))
515}
516
517// =========================================================================
518// Per-page markdown extraction
519// =========================================================================
520
521/// Per-page markdown extraction result.
522#[derive(Debug)]
523pub struct PageMarkdown {
524    /// 0-indexed page number.
525    pub page: u32,
526    /// Formatted markdown for this page.
527    pub markdown: String,
528    /// `true` when text on this page is unreliable (GID-encoded fonts,
529    /// encoding issues, garbage text, or empty extraction).
530    pub needs_ocr: bool,
531    /// Machine-readable OCR reason when the cause is known.
532    pub ocr_reason: Option<String>,
533}
534
535/// Combined per-page markdown extraction and layout classification result.
536#[derive(Debug)]
537pub struct PagesExtractionResult {
538    /// Per-page markdown results.
539    pub pages: Vec<PageMarkdown>,
540    /// 1-indexed pages where tables were detected.
541    pub pages_with_tables: Vec<u32>,
542    /// 1-indexed pages where multi-column layout was detected.
543    pub pages_with_columns: Vec<u32>,
544    /// 1-indexed pages that need OCR (scanned/image-based).
545    pub pages_needing_ocr: Vec<u32>,
546    /// Machine-readable OCR reasons by 1-indexed page.
547    pub ocr_reasons_by_page: Vec<PageOcrReasons>,
548    /// True if any page has tables or columns.
549    pub is_complex: bool,
550}
551
552pub(crate) struct InternalPagesExtraction {
553    pub(crate) result: PagesExtractionResult,
554    #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
555    pub(crate) page_count: u32,
556    #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
557    pub(crate) supplemental_ocr_regions: BTreeMap<u32, Vec<PdfRect>>,
558    /// The document written back out for the renderer when form XObjects
559    /// were repaired at load (see `form_bbox_repair`); `None` when the
560    /// original bytes render as loaded.
561    #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
562    pub(crate) render_bytes: Option<Vec<u8>>,
563}
564
565/// Extract formatted markdown for pages of a PDF, with layout
566/// classification metadata.
567///
568/// Unlike [`process_pdf_mem`] which returns one concatenated markdown string,
569/// this returns per-page markdown so callers can mix direct extraction
570/// (for simple text pages) with GPU OCR (for complex/scanned pages).
571///
572/// When `pages` is `None`, every page (0-indexed, in document order) is
573/// returned. When `Some(&[...])`, only the listed 0-indexed pages are
574/// returned, in the caller's order.
575///
576/// Font statistics are computed from the full document so header
577/// detection thresholds are consistent regardless of which pages are
578/// requested. Per-page `needs_ocr` is set when the page has GID-encoded
579/// fonts, encoding issues, or garbage text.
580///
581/// Layout complexity (tables, columns) is computed from the full document
582/// at near-zero cost since the items/rects/lines are already in memory.
583pub fn extract_pages_markdown_mem(
584    buffer: &[u8],
585    pages: Option<&[u32]>,
586) -> Result<PagesExtractionResult, PdfError> {
587    extract_pages_markdown_mem_impl(
588        buffer,
589        pages,
590        None,
591        &MarkdownOptions::default(),
592        false,
593        false,
594        false,
595    )
596    .map(|extraction| extraction.result)
597}
598
599#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
600/// `render_repairs` asks for the repaired document to be written back out
601/// for the renderer when the loader changed it (see `form_bbox_repair`);
602/// a caller that will not render leaves it off.
603pub(crate) fn extract_pages_markdown_mem_for_ocr(
604    buffer: &[u8],
605    pages: Option<&[u32]>,
606    password: Option<&str>,
607    markdown_options: &MarkdownOptions,
608    render_repairs: bool,
609) -> Result<InternalPagesExtraction, PdfError> {
610    extract_pages_markdown_mem_impl(
611        buffer,
612        pages,
613        password,
614        markdown_options,
615        markdown_options.strip_headers_footers,
616        true,
617        render_repairs,
618    )
619}
620
621fn extract_pages_markdown_mem_impl(
622    buffer: &[u8],
623    pages: Option<&[u32]>,
624    password: Option<&str>,
625    markdown_options: &MarkdownOptions,
626    strip_repeated_headers_footers: bool,
627    preserve_ocr_candidates: bool,
628    render_repairs: bool,
629) -> Result<InternalPagesExtraction, PdfError> {
630    validate_pdf_bytes(buffer)?;
631    let (doc, page_count, repairs) = load_document_from_mem_with_repairs(buffer, password)?;
632    #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
633    let mut doc = doc;
634    #[cfg(not(all(feature = "ocr", not(target_arch = "wasm32"))))]
635    let _ = (repairs, render_repairs);
636    let font_cmaps = FontCMaps::from_doc(&doc);
637
638    // Extract ALL pages to get accurate, document-wide font stats. A malformed
639    // unselected page cannot make a valid requested page fail, but errors on a
640    // requested page retain the normal extraction semantics.
641    let required_pages: Option<HashSet<u32>> = pages.map(|pages| {
642        pages
643            .iter()
644            .filter_map(|page| page.checked_add(1))
645            .collect()
646    });
647    let ((all_items, all_rects, all_lines), page_thresholds, gid_pages, _page_rotations, _) =
648        if let Some(required_pages) = required_pages.as_ref() {
649            extractor::extract_positioned_text_for_document_analysis(
650                &doc,
651                &font_cmaps,
652                required_pages,
653            )?
654        } else {
655            extractor::extract_positioned_text_from_doc(&doc, &font_cmaps, None)?
656        };
657    let text_quality = analyze_text_quality(&all_items);
658
659    // Resolve page numbers with full-document context before partitioning.
660    // Per-page Markdown receives the original items plus these decisions so
661    // table detection can retain legitimate numeric cells.
662    let (filtered_items, removed_page_number_pages, page_number_removal_mask) =
663        extractor::filter_markdown_page_numbers_with_removed_pages(all_items.clone(), page_count);
664
665    // Tables need the original numeric cells; columns use folio-cleaned
666    // evidence so removed page numbers cannot create false layout metadata.
667    let chart_regions = markdown::chart_regions_by_page(&all_items, &all_rects, &all_lines);
668    let complexity = compute_layout_complexity_with_chart_regions(
669        &all_items,
670        &filtered_items,
671        &all_rects,
672        &all_lines,
673        &chart_regions,
674    );
675
676    // Compute font stats from full document (cross-page consistency).
677    let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
678    let repeated_header_footer_items = if strip_repeated_headers_footers {
679        repeated_header_footer_item_keys(&all_items, &page_thresholds, &chart_regions, page_count)
680    } else {
681        HashSet::new()
682    };
683
684    // When caller doesn't specify pages, return every page in document order.
685    let all_pages: Vec<u32>;
686    let pages_slice: &[u32] = match pages {
687        Some(p) => p,
688        None => {
689            all_pages = (0..page_count).collect();
690            &all_pages
691        }
692    };
693
694    let mut results = Vec::with_capacity(pages_slice.len());
695    let mut pages_needing_ocr = Vec::new();
696    let mut ocr_reasons_by_page = BTreeMap::new();
697    #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
698    let mut supplemental_ocr_regions = BTreeMap::new();
699    let lopdf_pages = doc.get_pages();
700
701    for &page_0idx in pages_slice {
702        // Out-of-range pages → empty + needs_ocr
703        if page_0idx >= page_count {
704            pages_needing_ocr.push(page_0idx + 1);
705            results.push(PageMarkdown {
706                page: page_0idx,
707                markdown: String::new(),
708                needs_ocr: true,
709                ocr_reason: None,
710            });
711            continue;
712        }
713
714        let page_1idx = page_0idx + 1;
715
716        // Partition items, removal decisions, and rects for this page only.
717        let (page_items, page_number_removal_mask): (Vec<TextItem>, Vec<bool>) = all_items
718            .iter()
719            .zip(&page_number_removal_mask)
720            .filter(|(item, _)| {
721                item.page == page_1idx
722                    && !repeated_header_footer_items.contains(&HeaderFooterItemKey::from(*item))
723            })
724            .map(|(item, remove)| (item.clone(), *remove))
725            .unzip();
726
727        let page_rects: Vec<PdfRect> = all_rects
728            .iter()
729            .filter(|r| r.page == page_1idx)
730            .cloned()
731            .collect();
732
733        let page_lines: Vec<types::PdfLine> = all_lines
734            .iter()
735            .filter(|l| l.page == page_1idx)
736            .cloned()
737            .collect();
738
739        #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
740        {
741            let image_regions: Vec<PdfRect> = page_items
742                .iter()
743                .filter_map(supplemental_ocr_image_region)
744                .collect();
745            if !image_regions.is_empty() {
746                supplemental_ocr_regions.insert(page_1idx, image_regions);
747            }
748        }
749
750        let has_gid = gid_pages.contains(&page_1idx);
751        let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
752
753        // A page can extract cleanly (no decoding issues, non-empty text)
754        // while still being fundamentally a scan: a full-page raster with
755        // a little genuine native text drawn over it (a header, a stamp, a
756        // cover-sheet annotation). Text-quality signals alone can't see
757        // that — consult the same "large background image" signal
758        // classify_pdf/detect_pdf_type already uses, so the two APIs can't
759        // silently disagree on whether a page needs OCR. See #227.
760        // Also covers vector-outlined text (glyphs drawn as paths, not
761        // shown via a text-showing operator): a hybrid page with real
762        // embedded-font body text elsewhere would otherwise still extract
763        // non-empty, non-garbled markdown and miss OCR routing entirely.
764        // detect_from_document's Mixed-type per-page routing always sends
765        // these pages to OCR; mirror that here too. And a page whose every
766        // text-showing operator is invisible under a covering image: the
767        // text layer it extracts describes the raster rather than being
768        // the page's content. All three signals share one
769        // analyze_page_content pass — see page_ocr_signals's doc comment.
770        let signals = lopdf_pages
771            .get(&page_1idx)
772            .map(|&page_id| detector::page_ocr_signals(&doc, page_id))
773            .unwrap_or_default();
774        let has_template_image = signals.template_image_needs_ocr;
775        let has_vector_text = signals.has_vector_text;
776        let has_invisible_text_layer = signals.has_invisible_text_layer;
777
778        // Build markdown with document-wide font stats
779        let options = MarkdownOptions {
780            base_font_size: Some(font_stats.most_common_size),
781            include_page_numbers: false,
782            strip_headers_footers: false,
783            ..markdown_options.clone()
784        };
785
786        let md = if has_text_quality_issue {
787            String::new()
788        } else {
789            markdown::to_markdown_from_items_with_rects_and_lines(
790                page_items,
791                options,
792                &page_rects,
793                &page_lines,
794                markdown::MarkdownDocumentContext {
795                    page_thresholds: &page_thresholds,
796                    struct_roles: None,
797                    struct_tables: &[],
798                    page_count,
799                    prefiltered_page_number_pages: Some(&removed_page_number_pages),
800                    prefiltered_page_number_mask: Some(&page_number_removal_mask),
801                    precomputed_chart_regions: Some(&chart_regions),
802                },
803            )
804        };
805
806        let has_decoding_issue = has_text_quality_issue
807            || (!md.is_empty() && (is_cid_garbage(&md) || detect_encoding_issues(&md)));
808        // First among a page's reasons, as classification's
809        // `page_ocr_reasons` lists it too, so a page whose whole text layer
810        // is hidden under a scan — a scan whatever its fonts are — gets the
811        // same first reason from both surfaces; the reasons after it keep
812        // this surface's own order.
813        if has_invisible_text_layer {
814            add_ocr_reason(
815                &mut ocr_reasons_by_page,
816                page_1idx,
817                OCR_REASON_INVISIBLE_TEXT_LAYER,
818            );
819        }
820        if has_decoding_issue {
821            add_ocr_reason(
822                &mut ocr_reasons_by_page,
823                page_1idx,
824                OCR_REASON_SUSPECTED_GARBLED_TEXT,
825            );
826        }
827        if has_template_image {
828            add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_SCANNED);
829        }
830        if has_vector_text {
831            add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_VECTOR_TEXT);
832        }
833        let ocr_reason = page_ocr_reason(&ocr_reasons_by_page, page_1idx);
834
835        let needs_ocr = ocr_reason.is_some()
836            || md.trim().is_empty()
837            || has_gid
838            || is_garbage_text(&md)
839            || has_template_image
840            || has_vector_text
841            || has_invisible_text_layer;
842
843        if needs_ocr {
844            pages_needing_ocr.push(page_1idx);
845        }
846
847        results.push(PageMarkdown {
848            page: page_0idx,
849            // The public native extractor continues to suppress unreliable
850            // text. The OCR orchestrator retains clean partial text
851            // internally so it can compare/fuse it with OCR before deciding
852            // what is safe to return.
853            markdown: if needs_ocr && !preserve_ocr_candidates {
854                String::new()
855            } else {
856                md
857            },
858            needs_ocr,
859            ocr_reason,
860        });
861    }
862
863    Ok(InternalPagesExtraction {
864        result: PagesExtractionResult {
865            pages: results,
866            pages_with_tables: complexity.pages_with_tables,
867            pages_with_columns: complexity.pages_with_columns,
868            pages_needing_ocr,
869            ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
870            is_complex: complexity.is_complex,
871        },
872        #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
873        page_count,
874        #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
875        supplemental_ocr_regions,
876        // A renderer reading the original bytes would clip a repaired form
877        // to nothing, so the OCR pipeline renders the repaired document.
878        #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
879        render_bytes: if render_repairs && repairs.repaired_forms() {
880            // A renderer given the original bytes would clip the repaired
881            // forms to nothing again, so a copy that cannot be written is
882            // an error, not a fallback.
883            Some(
884                form_bbox_repair::serialize_for_rendering(&mut doc).ok_or_else(|| {
885                    PdfError::Parse(
886                        "the repaired document could not be written for rendering".to_string(),
887                    )
888                })?,
889            )
890        } else {
891            None
892        },
893    })
894}
895
896/// Image regions large enough to plausibly contain rasterized document
897/// structure such as a table. Logos, icons, and decorative rules remain below
898/// these physical-size gates. OCR still has to produce a valid table inside
899/// the region before any text is fused into a clean native page.
900#[cfg(any(test, all(feature = "ocr", not(target_arch = "wasm32"))))]
901fn supplemental_ocr_image_region(item: &TextItem) -> Option<PdfRect> {
902    const MIN_WIDTH_PT: f32 = 108.0;
903    const MIN_HEIGHT_PT: f32 = 72.0;
904    const MIN_AREA_PT2: f32 = 20_000.0;
905
906    if !matches!(item.item_type, types::ItemType::Image)
907        || !item.x.is_finite()
908        || !item.y.is_finite()
909        || !item.width.is_finite()
910        || !item.height.is_finite()
911    {
912        return None;
913    }
914    let x = item.x.min(item.x + item.width);
915    let y = item.y.min(item.y + item.height);
916    let width = item.width.abs();
917    let height = item.height.abs();
918    (width >= MIN_WIDTH_PT && height >= MIN_HEIGHT_PT && width * height >= MIN_AREA_PT2).then_some(
919        PdfRect {
920            x,
921            y,
922            width,
923            height,
924            page: item.page,
925        },
926    )
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Hash)]
930struct HeaderFooterItemKey {
931    page: u32,
932    x: u32,
933    y: u32,
934    text: String,
935}
936
937impl From<&TextItem> for HeaderFooterItemKey {
938    fn from(item: &TextItem) -> Self {
939        Self {
940            page: item.page,
941            x: item.x.to_bits(),
942            y: item.y.to_bits(),
943            text: item.text.clone(),
944        }
945    }
946}
947
948fn repeated_header_footer_item_keys(
949    items: &[TextItem],
950    page_thresholds: &HashMap<u32, f32>,
951    chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
952    page_count: u32,
953) -> HashSet<HeaderFooterItemKey> {
954    let candidates = items
955        .iter()
956        .filter(|item| {
957            matches!(
958                item.item_type,
959                types::ItemType::Text | types::ItemType::FormField
960            )
961        })
962        .cloned()
963        .collect();
964    let lines = extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
965        candidates,
966        page_thresholds,
967        &HashSet::new(),
968        chart_regions,
969    );
970    let all_items: HashSet<_> = lines
971        .iter()
972        .flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
973        .collect();
974    let kept = markdown::strip_repeated_header_footer_lines(lines, page_count);
975    let kept_items: HashSet<_> = kept
976        .iter()
977        .flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
978        .collect();
979    all_items.difference(&kept_items).cloned().collect()
980}
981
982#[cfg(all(test, feature = "ocr", not(target_arch = "wasm32")))]
983mod ocr_header_footer_tests {
984    use super::*;
985
986    fn item(page: u32, text: &str, y: f32) -> TextItem {
987        TextItem {
988            text: text.to_string(),
989            x: 10.0,
990            y,
991            width: 120.0,
992            height: 10.0,
993            font: "Test".to_string(),
994            font_tag: String::new(),
995            legacy_symbol_rewrite: false,
996            font_size: 10.0,
997            page,
998            is_bold: false,
999            is_italic: false,
1000            font_weight: None,
1001            bold_source: None,
1002            fixed_pitch: None,
1003            fill_color: None,
1004            stroke_color: None,
1005            render_mode: None,
1006            is_underline: false,
1007            is_strikeout: false,
1008            rotation: 0.0,
1009            advance_known: true,
1010            item_type: types::ItemType::Text,
1011            mcid: None,
1012            baseline_shift: 0.0,
1013        }
1014    }
1015
1016    #[test]
1017    fn local_pipeline_prefilters_document_wide_repeated_headers() {
1018        let mut items = Vec::new();
1019        let mut thresholds = HashMap::new();
1020        for page in 1..=3 {
1021            items.push(item(page, "Repeated report header", 800.0));
1022            for line in 0..12 {
1023                items.push(item(
1024                    page,
1025                    &format!("Page {page} paragraph {line} unique content"),
1026                    700.0 - line as f32 * 40.0,
1027                ));
1028            }
1029            thresholds.insert(page, 0.1);
1030        }
1031
1032        let removed = repeated_header_footer_item_keys(&items, &thresholds, &HashMap::new(), 3);
1033        assert_eq!(removed.len(), 2);
1034        for page in 1..=3 {
1035            assert_eq!(
1036                removed.contains(&HeaderFooterItemKey::from(&item(
1037                    page,
1038                    "Repeated report header",
1039                    800.0,
1040                ))),
1041                page > 1,
1042            );
1043            assert!(!removed.contains(&HeaderFooterItemKey::from(&item(
1044                page,
1045                &format!("Page {page} paragraph 5 unique content"),
1046                500.0,
1047            ))));
1048        }
1049    }
1050}
1051
1052/// Path-based wrapper for [`extract_pages_markdown_mem`].
1053///
1054/// Reads the PDF from disk and extracts per-page markdown. Pass `None` for
1055/// `pages` to return every page in document order, or `Some(&[...])` to
1056/// restrict to specific 0-indexed pages (in caller-supplied order).
1057pub fn extract_pages_markdown<P: AsRef<Path>>(
1058    path: P,
1059    pages: Option<&[u32]>,
1060) -> Result<PagesExtractionResult, PdfError> {
1061    validate_pdf_file(&path)?;
1062    let buffer = read_file(path.as_ref())?;
1063    extract_pages_markdown_mem(&buffer, pages)
1064}
1065
1066// =========================================================================
1067// Structure-tree element extraction (tagged PDFs)
1068// =========================================================================
1069
1070/// One structure-tree element reference from a tagged PDF, resolved to a
1071/// page and Marked Content ID.
1072///
1073/// Join `(page, mcid)` against [`TextItem::page`] / [`TextItem::mcid`] from
1074/// [`extract_text_with_positions`] to attach semantic roles (heading levels,
1075/// paragraphs, table cells, …) to extracted text.
1076#[derive(Debug, Clone)]
1077pub struct StructureElement {
1078    /// 1-indexed page number (matches [`TextItem::page`]).
1079    pub page: u32,
1080    /// Marked Content ID from the page's content stream (matches
1081    /// [`TextItem::mcid`]).
1082    pub mcid: i64,
1083    /// Standard structure type name ("H1".."H6", "P", "Table", "TD", …).
1084    /// Custom tags are resolved through the document's `/RoleMap`; tags
1085    /// with no standard mapping are returned verbatim.
1086    pub role: String,
1087}
1088
1089/// Extract structure-tree element references from a tagged PDF in memory.
1090///
1091/// Parses `/StructTreeRoot` (when present) and returns one entry per
1092/// marked-content reference, resolved to its 1-indexed page, MCID, and
1093/// structure type name. Returns an empty list when the PDF is not tagged.
1094///
1095/// Pass `Some(&[...])` with 1-indexed page numbers (matching
1096/// [`TextItem::page`]) to restrict output to those pages; pass `None` for
1097/// the whole document. Entries are sorted by `(page, mcid)`.
1098pub fn extract_structure_elements_mem(
1099    buffer: &[u8],
1100    pages: Option<&[u32]>,
1101) -> Result<Vec<StructureElement>, PdfError> {
1102    validate_pdf_bytes(buffer)?;
1103    let (doc, _page_count) = load_document_from_mem(buffer)?;
1104    let Some(tree) = structure_tree::StructTree::from_doc(&doc) else {
1105        return Ok(Vec::new());
1106    };
1107    let page_ids = doc.get_pages();
1108    let roles = tree.mcid_to_roles(&page_ids);
1109
1110    let page_filter: Option<HashSet<u32>> = pages.map(|p| p.iter().copied().collect());
1111    let mut elements: Vec<StructureElement> = roles
1112        .into_iter()
1113        .filter(|(page, _)| page_filter.as_ref().is_none_or(|f| f.contains(page)))
1114        .flat_map(|(page, mcids)| {
1115            mcids.into_iter().map(move |(mcid, role)| StructureElement {
1116                page,
1117                mcid,
1118                role: role.name().to_string(),
1119            })
1120        })
1121        .collect();
1122    elements.sort_unstable_by_key(|e| (e.page, e.mcid));
1123    Ok(elements)
1124}
1125
1126/// Path-based wrapper for [`extract_structure_elements_mem`].
1127///
1128/// Reads the PDF from disk and extracts structure-tree element references.
1129/// Pass `None` for `pages` to return the whole document, or `Some(&[...])`
1130/// to restrict to specific 1-indexed pages.
1131pub fn extract_structure_elements<P: AsRef<Path>>(
1132    path: P,
1133    pages: Option<&[u32]>,
1134) -> Result<Vec<StructureElement>, PdfError> {
1135    validate_pdf_file(&path)?;
1136    let buffer = read_file(path.as_ref())?;
1137    extract_structure_elements_mem(&buffer, pages)
1138}
1139
1140// =========================================================================
1141// Region-based text extraction (for hybrid OCR pipelines)
1142// =========================================================================
1143
1144/// Result for a single region's text extraction.
1145#[derive(Debug)]
1146pub struct RegionText {
1147    /// Extracted text (may be empty if region has no text items).
1148    pub text: String,
1149    /// `true` when the text should not be trusted and OCR should be used instead.
1150    /// Set when: the region is empty, the page uses GID-encoded fonts, or the
1151    /// extracted text fails garbage/encoding checks.
1152    pub needs_ocr: bool,
1153    /// Machine-readable OCR reason when the cause is known.
1154    pub ocr_reason: Option<String>,
1155}
1156
1157/// Result for a page's region extractions.
1158#[derive(Debug)]
1159pub struct PageRegionResult {
1160    /// 0-indexed page number.
1161    pub page: u32,
1162    /// Per-region results, parallel to the input regions.
1163    pub regions: Vec<RegionText>,
1164}
1165
1166/// Minimum alphanumeric mass an invisible (Tr 3) text layer must carry for
1167/// the OCR-layer fallback in [`extract_text_in_regions_mem`] to adopt it. A
1168/// real OCR layer carries far more; a stray watermark or artifact does not.
1169const OCR_LAYER_MIN_ALNUM: usize = 40;
1170
1171/// Alphanumeric mass of extracted items, ignoring raster placeholders.
1172/// `[Image: ...]` items (ItemType::Image) are synthesized for image
1173/// XObjects — they mark that pixels exist, not that text was read, so they
1174/// must not count as coverage.
1175fn non_placeholder_alnum(items: &[TextItem]) -> usize {
1176    items
1177        .iter()
1178        .filter(|it| !matches!(it.item_type, types::ItemType::Image))
1179        .map(|it| it.text.chars().filter(|c| c.is_alphanumeric()).count())
1180        .sum()
1181}
1182
1183/// Extract text within bounding-box regions from a PDF in memory.
1184///
1185/// This is designed for hybrid OCR pipelines: a layout model detects regions
1186/// in a rendered page image, and this function extracts the PDF text that
1187/// falls within each region — avoiding GPU OCR for text-based pages.
1188///
1189/// Each region result includes a `needs_ocr` flag that is set when extraction
1190/// quality is suspect (empty text, GID-encoded fonts, garbage/encoding issues).
1191///
1192/// # Arguments
1193///
1194/// * `buffer` — PDF file bytes
1195/// * `page_regions` — list of `(page_number_0indexed, Vec<[x1, y1, x2, y2]>)`.
1196///   Coordinates are in **PDF points** with **top-left origin**, relative to
1197///   the page's **visible page box** (`CropBox ∩ MediaBox`, else the
1198///   MediaBox) as laid out in the content stream — the frame
1199///   [`extract_text_with_positions_mem`] reports items in (with `y` flipped
1200///   by the box height). `/Rotate` is not applied and a page whose text is
1201///   predominantly rotated is turned (see [`PageRotation`]), so this matches
1202///   a rendered page image only for pages with `/Rotate 0` whose text is not
1203///   predominantly rotated; rects taken from a rendered page go through
1204///   [`extract_text_in_regions_mem_in_frame`] with [`PositionFrame::Display`].
1205///
1206/// # Returns
1207///
1208/// A `Vec<PageRegionResult>` parallel to `page_regions`.
1209pub fn extract_text_in_regions_mem(
1210    buffer: &[u8],
1211    page_regions: &[(u32, Vec<[f32; 4]>)],
1212) -> Result<Vec<PageRegionResult>, PdfError> {
1213    extract_text_in_regions_mem_in_frame(buffer, page_regions, PositionFrame::Sheet)
1214}
1215
1216/// [`extract_text_in_regions_mem`] with the frame of the region rects given
1217/// explicitly. [`PositionFrame::Sheet`] reads them as that function does;
1218/// [`PositionFrame::Display`] reads them on the rendered page — the visible
1219/// page box turned clockwise by the page's inheritable `/Rotate`, top-left
1220/// origin, `y` down — the frame a layout model working on a page image
1221/// reports boxes in. Pages whose text is predominantly rotated are handled
1222/// the same way in both frames.
1223pub fn extract_text_in_regions_mem_in_frame(
1224    buffer: &[u8],
1225    page_regions: &[(u32, Vec<[f32; 4]>)],
1226    frame: PositionFrame,
1227) -> Result<Vec<PageRegionResult>, PdfError> {
1228    extract_text_in_regions_mem_with_options(
1229        buffer,
1230        page_regions,
1231        PositionOptions::new().frame(frame),
1232    )
1233}
1234
1235/// [`extract_text_in_regions_mem_in_frame`] with every option given as a
1236/// [`PositionOptions`]: the frame the region rects are read in, and whether
1237/// bold is also read from the font's weight class (`bold_from_weight`: a
1238/// weight class of `bold_weight_threshold` or more, 600 by default, is bold,
1239/// and a run the weight makes bold is then its own item while the region's
1240/// lines are assembled). The default options are
1241/// [`extract_text_in_regions_mem`].
1242pub fn extract_text_in_regions_mem_with_options(
1243    buffer: &[u8],
1244    page_regions: &[(u32, Vec<[f32; 4]>)],
1245    options: PositionOptions,
1246) -> Result<Vec<PageRegionResult>, PdfError> {
1247    let frame = options.frame;
1248    validate_pdf_bytes(buffer)?;
1249    let (doc, _page_count) = load_document_from_mem(buffer)?;
1250    let pages = doc.get_pages();
1251
1252    // Build a set of pages we need to extract (1-indexed for lopdf)
1253    let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
1254
1255    // Fast mode: skip expensive TrueType font fallback parsing.
1256    // Fonts that can't be decoded from ToUnicode alone will produce empty/garbage
1257    // text, triggering needs_ocr=true → GPU OCR fallback in the pipeline.
1258    let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1259
1260    // Extract text items for needed pages only
1261    let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
1262    let mut page_heights: HashMap<u32, f32> = HashMap::new();
1263    let mut gid_pages: HashSet<u32> = HashSet::new();
1264    let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
1265    let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
1266    let mut display_pages: HashMap<u32, extractor::DisplayPage> = HashMap::new();
1267    let mut style_cache = extractor::FontStyleCache::new();
1268
1269    for (page_num, &page_id) in pages.iter() {
1270        if !needed_pages.contains(page_num) {
1271            continue;
1272        }
1273
1274        // Extract text items for this page in the visible-page-box frame;
1275        // region bounds flip y with that box's height. The Form XObject
1276        // budget is shared with the invisible-layer retry below so one page
1277        // cannot consume two full expansion budgets.
1278        let mut form_budget = extractor::FormWalkBudget::new();
1279        let extractor::PageBoxExtraction {
1280            mut items,
1281            has_gid_fonts: mut has_gid,
1282            mut coords_rotated,
1283            skipped_invisible,
1284            page_box,
1285            ..
1286        } = extractor::extract_page_text_items_in_page_box_with_options(
1287            &doc,
1288            page_id,
1289            *page_num,
1290            &font_cmaps,
1291            options.text_extraction(false),
1292            &mut style_cache,
1293            &mut form_budget,
1294        )?;
1295        page_heights.insert(*page_num, page_box.height());
1296        if frame == PositionFrame::Display {
1297            display_pages.insert(
1298                *page_num,
1299                extractor::DisplayPage::new(&doc, page_id, page_box),
1300            );
1301        }
1302        // OCR-layer fallback: scanned pages often carry their text as an
1303        // invisible (Tr 3) layer behind the page raster. The visible-only
1304        // pass sees nothing there but `[Image: ...]` placeholders, so every
1305        // region on the page reports needs_ocr even though the exact text is
1306        // embedded in the PDF — and this extractor then disagrees with the
1307        // markdown path, which already retries Mixed PDFs with the invisible
1308        // layer included. Retry page-scoped, and only when (a) the first
1309        // pass actually SKIPPED invisible text — blank pages and image-only
1310        // scans without an OCR layer must not pay a second content-stream
1311        // parse (review catch) — and (b) the page has NO visible text item
1312        // at all (punctuation counts, whitespace-only artifacts don't): an
1313        // invisible OCR layer transcribes the raster, so any visible glyph
1314        // has an invisible twin there and adoption would duplicate it
1315        // (review catches — strict gate, no fuzzy dedupe). Adopt the retry
1316        // only when it contributes real, non-garbage text.
1317        let has_visible_text = items.iter().any(|it| {
1318            !matches!(it.item_type, types::ItemType::Image) && !it.text.trim().is_empty()
1319        });
1320        if skipped_invisible && !has_visible_text {
1321            if let Ok(invisible) = extractor::extract_page_text_items_in_page_box_with_options(
1322                &doc,
1323                page_id,
1324                *page_num,
1325                &font_cmaps,
1326                options.text_extraction(true),
1327                &mut style_cache,
1328                &mut form_budget,
1329            ) {
1330                let inv_items = invisible.items;
1331                let inv_alnum = non_placeholder_alnum(&inv_items);
1332                // Judge the WHOLE recovered layer, not a prefix — a broken
1333                // OCR layer can hide its garbage past any fixed sample size
1334                // (review catch).
1335                let sample: String = inv_items
1336                    .iter()
1337                    .filter(|it| !matches!(it.item_type, types::ItemType::Image))
1338                    .map(|it| it.text.as_str())
1339                    .collect();
1340                if inv_alnum >= OCR_LAYER_MIN_ALNUM && !is_garbage_text(&sample) {
1341                    items = inv_items;
1342                    has_gid = invisible.has_gid_fonts;
1343                    coords_rotated = invisible.coords_rotated;
1344                }
1345            }
1346        }
1347        let threshold = text_utils::fix_letterspaced_items(&mut items);
1348        if threshold > 0.10 {
1349            page_thresholds.insert(*page_num, threshold);
1350        }
1351        if has_gid {
1352            gid_pages.insert(*page_num);
1353        }
1354        if coords_rotated != extractor::geometry::PageRotation::Upright {
1355            rotated_pages.insert(*page_num, coords_rotated.into());
1356        }
1357        items_by_page.insert(*page_num, items);
1358    }
1359
1360    // For each page's regions, filter and assemble text
1361    let mut results = Vec::with_capacity(page_regions.len());
1362
1363    for (page_0idx, regions) in page_regions {
1364        let page_1idx = page_0idx + 1;
1365        let items = items_by_page.get(&page_1idx);
1366        let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
1367        let _page_has_gid = gid_pages.contains(&page_1idx);
1368        let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
1369        let coords = rotated_pages
1370            .get(&page_1idx)
1371            .copied()
1372            .unwrap_or(RegionCoordSpace::Standard);
1373        let display = display_pages.get(&page_1idx).copied();
1374
1375        let mut page_results = Vec::with_capacity(regions.len());
1376
1377        // Exclusive item->region assignment: overlapping layout regions used
1378        // to extract shared items into EVERY region they touched (the
1379        // 1.5pt inclusion margin makes borders generous), duplicating whole
1380        // lines in the final markdown on 21% of bench docs — and downstream
1381        // duplicate-handling sometimes dropped the variant holding a
1382        // sentence tail, turning duplication into content LOSS. Each item
1383        // now belongs to the single region with the largest overlap area;
1384        // items are partitioned, never suppressed, so no content can vanish.
1385        let all_bounds: Vec<RegionBounds> = regions
1386            .iter()
1387            .map(|rect| {
1388                let [rx1, ry1, rx2, ry2] = sheet_region_rect(*rect, display);
1389                region_bounds(rx1, ry1, rx2, ry2, page_h, coords)
1390            })
1391            .collect();
1392        // Single pass over items: assign each to the best-overlap region and
1393        // bucket the clone directly (review: avoid a second O(items x
1394        // regions) traversal). `had_candidates` marks regions that touched
1395        // at least one item even if every one was assigned elsewhere.
1396        let mut region_items: Vec<Vec<TextItem>> = vec![Vec::new(); regions.len()];
1397        let mut had_candidates: Vec<bool> = vec![false; regions.len()];
1398        if let Some(items) = items {
1399            for item in items {
1400                let mut best: Option<usize> = None;
1401                let mut best_area = 0.0_f32;
1402                for (ri, b) in all_bounds.iter().enumerate() {
1403                    if !region_overlaps_item(item, *b) {
1404                        continue;
1405                    }
1406                    had_candidates[ri] = true;
1407                    let area = region_item_overlap_area(item, *b);
1408                    if area > best_area {
1409                        best_area = area;
1410                        best = Some(ri);
1411                    }
1412                }
1413                if let Some(ri) = best {
1414                    region_items[ri].push(item.clone());
1415                }
1416            }
1417        }
1418
1419        for (region_idx, _rect) in regions.iter().enumerate() {
1420            let matched: Vec<TextItem> = std::mem::take(&mut region_items[region_idx]);
1421            let assigned_count = matched.len();
1422            let has_text_quality_issue = region_items_have_decoding_issue(&matched);
1423            let text = collect_text_from_matched_items(matched, adaptive_threshold);
1424            let has_cid_issue = is_cid_garbage(&text);
1425            let has_encoding_issue = detect_encoding_issues(&text);
1426            let ocr_reason = if has_text_quality_issue || has_cid_issue || has_encoding_issue {
1427                Some(suspected_garbled_reason())
1428            } else {
1429                None
1430            };
1431
1432            // Check per-region text quality instead of blanket page-level
1433            // GID rejection. A GID font in a logo elsewhere on the page
1434            // shouldn't force GPU OCR for clean text regions.
1435            // A region whose ONLY overlapping items were assigned to a
1436            // better-overlapping neighbor must not fall back to OCR: the
1437            // pixels it would re-read belong to that neighbor, and OCR
1438            // would reintroduce the duplication exclusivity removed.
1439            // Before exclusive assignment these regions were non-empty
1440            // native (no OCR), so this preserves the old OCR load too.
1441            // Requires ZERO items assigned HERE: a region whose own
1442            // assigned items materialize to empty text (whitespace-only,
1443            // collector-filtered) keeps its OCR fallback.
1444            let lost_to_neighbor = text.trim().is_empty()
1445                && ocr_reason.is_none()
1446                && assigned_count == 0
1447                && had_candidates[region_idx];
1448            let needs_ocr = !lost_to_neighbor
1449                && (ocr_reason.is_some() || text.trim().is_empty() || is_garbage_text(&text));
1450
1451            page_results.push(RegionText {
1452                text,
1453                needs_ocr,
1454                ocr_reason,
1455            });
1456        }
1457
1458        results.push(PageRegionResult {
1459            page: *page_0idx,
1460            regions: page_results,
1461        });
1462    }
1463
1464    Ok(results)
1465}
1466
1467/// Extract tables within bounding-box regions from a PDF in memory.
1468///
1469/// Similar to [`extract_text_in_regions_mem`] but runs table detection on items
1470/// within each region and returns markdown pipe-tables instead of flat text.
1471/// Regions use the same visible-page-box coordinate frame.
1472///
1473/// When table structure is detected, `text` contains a markdown pipe-table and
1474/// `needs_ocr` is `false`. When no table is found (too few items, poor alignment,
1475/// GID fonts, etc.), `text` is empty and `needs_ocr` is `true` so the caller can
1476/// fall back to GPU OCR.
1477pub fn extract_tables_in_regions_mem(
1478    buffer: &[u8],
1479    page_regions: &[(u32, Vec<[f32; 4]>)],
1480) -> Result<Vec<PageRegionResult>, PdfError> {
1481    extract_tables_in_regions_mem_in_frame(buffer, page_regions, PositionFrame::Sheet)
1482}
1483
1484/// [`extract_tables_in_regions_mem`] with the frame of the region rects given
1485/// explicitly — see [`extract_text_in_regions_mem_in_frame`].
1486pub fn extract_tables_in_regions_mem_in_frame(
1487    buffer: &[u8],
1488    page_regions: &[(u32, Vec<[f32; 4]>)],
1489    frame: PositionFrame,
1490) -> Result<Vec<PageRegionResult>, PdfError> {
1491    extract_tables_in_regions_mem_with_options(
1492        buffer,
1493        page_regions,
1494        PositionOptions::new().frame(frame),
1495    )
1496}
1497
1498/// [`extract_tables_in_regions_mem_in_frame`] with every option given as a
1499/// [`PositionOptions`] — see [`extract_text_in_regions_mem_with_options`].
1500pub fn extract_tables_in_regions_mem_with_options(
1501    buffer: &[u8],
1502    page_regions: &[(u32, Vec<[f32; 4]>)],
1503    options: PositionOptions,
1504) -> Result<Vec<PageRegionResult>, PdfError> {
1505    let frame = options.frame;
1506    validate_pdf_bytes(buffer)?;
1507    let (doc, _page_count) = load_document_from_mem(buffer)?;
1508    let pages = doc.get_pages();
1509
1510    let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
1511    let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1512
1513    let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
1514    let mut rects_by_page: HashMap<u32, Vec<PdfRect>> = HashMap::new();
1515    let mut lines_by_page: HashMap<u32, Vec<PdfLine>> = HashMap::new();
1516    let mut page_heights: HashMap<u32, f32> = HashMap::new();
1517    let mut gid_pages: HashSet<u32> = HashSet::new();
1518    let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
1519    let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
1520    let mut display_pages: HashMap<u32, extractor::DisplayPage> = HashMap::new();
1521    let mut style_cache = extractor::FontStyleCache::new();
1522
1523    for (page_num, &page_id) in pages.iter() {
1524        if !needed_pages.contains(page_num) {
1525            continue;
1526        }
1527        let extractor::PageBoxExtraction {
1528            mut items,
1529            rects,
1530            lines,
1531            has_gid_fonts: has_gid,
1532            coords_rotated,
1533            page_box,
1534            ..
1535        } = extractor::extract_page_text_items_in_page_box_with_options(
1536            &doc,
1537            page_id,
1538            *page_num,
1539            &font_cmaps,
1540            options.text_extraction(false),
1541            &mut style_cache,
1542            &mut extractor::FormWalkBudget::new(),
1543        )?;
1544        page_heights.insert(*page_num, page_box.height());
1545        if frame == PositionFrame::Display {
1546            display_pages.insert(
1547                *page_num,
1548                extractor::DisplayPage::new(&doc, page_id, page_box),
1549            );
1550        }
1551        let threshold = text_utils::fix_letterspaced_items(&mut items);
1552        if threshold > 0.10 {
1553            page_thresholds.insert(*page_num, threshold);
1554        }
1555        if has_gid {
1556            gid_pages.insert(*page_num);
1557        }
1558        if coords_rotated != extractor::geometry::PageRotation::Upright {
1559            rotated_pages.insert(*page_num, coords_rotated.into());
1560        }
1561        items_by_page.insert(*page_num, items);
1562        rects_by_page.insert(*page_num, rects);
1563        lines_by_page.insert(*page_num, lines);
1564    }
1565
1566    let mut results = Vec::with_capacity(page_regions.len());
1567
1568    for (page_0idx, regions) in page_regions {
1569        let page_1idx = page_0idx + 1;
1570        let items = items_by_page.get(&page_1idx);
1571        let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
1572        let _page_has_gid = gid_pages.contains(&page_1idx);
1573        let coords = rotated_pages
1574            .get(&page_1idx)
1575            .copied()
1576            .unwrap_or(RegionCoordSpace::Standard);
1577        let display = display_pages.get(&page_1idx).copied();
1578
1579        let mut page_results = Vec::with_capacity(regions.len());
1580
1581        for rect in regions {
1582            let [rx1, ry1, rx2, ry2] = sheet_region_rect(*rect, display);
1583
1584            // Note: we intentionally DO NOT bail on page_has_gid here.
1585            // The GID flag means some font on the page uses unresolvable
1586            // glyph IDs, but that font may only appear in a logo or
1587            // header — not in the table region. Instead we let the
1588            // per-region text quality checks (is_garbage_text, is_cid_garbage,
1589            // detect_encoding_issues) reject based on the actual extracted
1590            // content. This avoids rejecting clean tables just because an
1591            // unrelated decorative font on the same page is GID-encoded.
1592
1593            let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
1594            let matched: Vec<TextItem> = match items {
1595                Some(items) => items
1596                    .iter()
1597                    .filter(|item| region_overlaps_item(item, bounds))
1598                    .cloned()
1599                    .collect(),
1600                None => Vec::new(),
1601            };
1602
1603            if matched.is_empty() {
1604                page_results.push(RegionText {
1605                    text: String::new(),
1606                    needs_ocr: true,
1607                    ocr_reason: None,
1608                });
1609                continue;
1610            }
1611
1612            if region_items_have_decoding_issue(&matched) {
1613                page_results.push(RegionText {
1614                    text: String::new(),
1615                    needs_ocr: true,
1616                    ocr_reason: Some(suspected_garbled_reason()),
1617                });
1618                continue;
1619            }
1620
1621            // Compute base_font_size as most common font size in the region
1622            let base_font_size = {
1623                let mut freq: HashMap<i32, usize> = HashMap::new();
1624                for item in &matched {
1625                    *freq.entry((item.font_size * 10.0) as i32).or_default() += 1;
1626                }
1627                freq.into_iter()
1628                    .max_by_key(|(_, count)| *count)
1629                    .map(|(size, _)| size as f32 / 10.0)
1630                    .unwrap_or(12.0)
1631            };
1632
1633            // Try rect-backed and line-backed vector-grid detectors first,
1634            // then fall back to the heuristic text-only detector. Each
1635            // candidate's markdown is quality-gated by the same
1636            // needs_ocr checks the heuristic-only path used: if a vector
1637            // detector produces a partial/garbled table, we ignore it and
1638            // try the next path rather than degrade the output.
1639            // needs_ocr fires on any of:
1640            //   - garbage text (non-alphanumeric heavy)
1641            //   - CID/Latin-1 mojibake
1642            //   - encoding issues (U+FFFD, dollar-as-space)
1643            //   - structural giveaways that the table is partial /
1644            //     mis-detected (numeric "header", empty header cells,
1645            //     duplicate header cells).
1646            // skip_body_font = false / layout_assisted = true because the
1647            // layout model already identified this region as a table.
1648            let region_rects: Vec<PdfRect> = rects_by_page
1649                .get(&page_1idx)
1650                .map(|rs| {
1651                    rs.iter()
1652                        .filter(|r| region_overlaps_rect(r, bounds))
1653                        .cloned()
1654                        .collect()
1655                })
1656                .unwrap_or_default();
1657            let region_lines: Vec<PdfLine> = lines_by_page
1658                .get(&page_1idx)
1659                .map(|ls| {
1660                    ls.iter()
1661                        .filter(|l| region_overlaps_line(l, bounds))
1662                        .cloned()
1663                        .collect()
1664                })
1665                .unwrap_or_default();
1666
1667            // Total length of text the page extractor saw inside this
1668            // region, used by the captured-fragment guard below.
1669            let region_text_chars: usize = matched.iter().map(|i| i.text.chars().count()).sum();
1670            let region_area = (rx2 - rx1).max(0.0) * (ry2 - ry1).max(0.0);
1671            let line_region_has_vertical_rules = has_vertical_rules(&region_lines);
1672
1673            let evaluate =
1674                |source: TableCandidateSource, t: &tables::Table| -> Option<TableCandidate> {
1675                    let md = tables::table_to_markdown(t);
1676                    let trimmed = md.trim();
1677                    if trimmed.is_empty() {
1678                        return None;
1679                    }
1680                    if is_garbage_text(&md)
1681                        || is_cid_garbage(&md)
1682                        || detect_encoding_issues(&md)
1683                        || looks_like_partial_table_ex(&md, true)
1684                    {
1685                        return None;
1686                    }
1687                    // Reject extractions that only captured a small fraction
1688                    // of the text actually in the region. Two recurring
1689                    // failure shapes this catches:
1690                    //   - "header-only": detector found the column-header band
1691                    //     cleanly but missed every data row below (financial
1692                    //     statements with multi-line column headers + many
1693                    //     data rows are the dominant case).
1694                    //   - "sparse": detector returned a couple of fragmentary
1695                    //     cells even though the region has many lines of text.
1696                    // The region floor (200 chars) keeps short legitimate
1697                    // tables (timestamps, units, axis labels) from being
1698                    // rejected as partial.
1699                    if captured_only_a_fragment(&md, region_text_chars) {
1700                        return None;
1701                    }
1702                    // Text-density floor: when a region has lots of pixel
1703                    // real estate but very few text items, the page
1704                    // extractor likely hit a font-CMap failure (Identity-H
1705                    // fonts with missing or broken ToUnicode entries,
1706                    // Type-3 fonts without unicode metadata, etc.). The
1707                    // page extractor returns punctuation-only fragments or
1708                    // single-glyph strings; the rendered image still has
1709                    // the visible text, so the region should fall back to
1710                    // OCR. `captured_only_a_fragment` compares captured
1711                    // chars against text the extractor saw, which is
1712                    // symmetrically low under font-decode failure — this
1713                    // guard breaks that symmetry by comparing against
1714                    // bbox area, which is independent of extraction.
1715                    if source != TableCandidateSource::KeyValue
1716                        && region_text_density_too_low(region_text_chars, region_area)
1717                        && !markdown_table_body_is_dense(&md)
1718                    {
1719                        return None;
1720                    }
1721                    let shape = markdown_table_shape(&md);
1722                    let issue = if source == TableCandidateSource::Line
1723                        && line_region_has_vertical_rules
1724                        && line_table_collapses_text_rows(t, &matched, shape)
1725                    {
1726                        Some(TableCandidateIssue::LineRowUndercount)
1727                    } else if wide_table_sparse_prefix_undercount(&md) {
1728                        Some(TableCandidateIssue::SparseWideUndercount)
1729                    } else if !matches!(
1730                        source,
1731                        TableCandidateSource::Line | TableCandidateSource::KeyValue
1732                    ) && text_cluster_column_undercount(&matched, shape)
1733                    {
1734                        Some(TableCandidateIssue::TextColumnUndercount)
1735                    } else if prose_grid_fragment_needs_ocr(&md) {
1736                        Some(TableCandidateIssue::ProseGridFragment)
1737                    } else {
1738                        None
1739                    };
1740                    Some(TableCandidate {
1741                        markdown: md,
1742                        source,
1743                        shape,
1744                        issue,
1745                    })
1746                };
1747
1748            let mut candidates: Vec<TableCandidate> = Vec::new();
1749            if !region_rects.is_empty() {
1750                let (rect_tables, _) =
1751                    tables::detect_tables_from_rects(&matched, &region_rects, page_1idx);
1752                if let Some(candidate) = rect_tables
1753                    .iter()
1754                    .find_map(|t| evaluate(TableCandidateSource::Rect, t))
1755                {
1756                    candidates.push(candidate);
1757                }
1758            }
1759            if !region_lines.is_empty() {
1760                let line_tables =
1761                    tables::detect_tables_from_lines(&matched, &region_lines, page_1idx);
1762                if let Some(candidate) = line_tables
1763                    .iter()
1764                    .find_map(|t| evaluate(TableCandidateSource::Line, t))
1765                {
1766                    candidates.push(candidate);
1767                }
1768            }
1769            let detected = tables::detect_tables_with_page_width(
1770                &matched,
1771                base_font_size,
1772                false,
1773                items.map_or(1.0, |items| tables::content_width(items)),
1774            );
1775            if let Some(candidate) = detected
1776                .iter()
1777                .find_map(|t| evaluate(TableCandidateSource::Heuristic, t))
1778            {
1779                candidates.push(candidate);
1780            }
1781            if let Some(table) = tables::try_build_table_from_columns(&matched, page_1idx) {
1782                if let Some(candidate) = evaluate(TableCandidateSource::Column, &table) {
1783                    candidates.push(candidate);
1784                }
1785            }
1786            if let Some(table) = tables::try_build_key_value_table_from_rows(&matched, page_1idx) {
1787                if let Some(candidate) = evaluate(TableCandidateSource::KeyValue, &table) {
1788                    candidates.push(candidate);
1789                }
1790            }
1791
1792            match select_table_candidate(&candidates) {
1793                Some(candidate) => page_results.push(RegionText {
1794                    text: candidate.markdown.clone(),
1795                    needs_ocr: false,
1796                    ocr_reason: None,
1797                }),
1798                None => page_results.push(RegionText {
1799                    text: String::new(),
1800                    needs_ocr: true,
1801                    ocr_reason: None,
1802                }),
1803            }
1804        }
1805
1806        results.push(PageRegionResult {
1807            page: *page_0idx,
1808            regions: page_results,
1809        });
1810    }
1811
1812    Ok(results)
1813}
1814
1815/// Region-scoped vector grid detection result for TSR-compatible callers.
1816#[derive(Debug, Clone)]
1817pub struct VectorGridDetection {
1818    /// HTML-like structure tokens consumed by the TSR path.
1819    pub structure_tokens: Vec<String>,
1820    /// One crop-pixel bbox per `<td>` token, in document order.
1821    pub cell_bboxes: Vec<Vec<f32>>,
1822}
1823
1824#[derive(Clone, Copy)]
1825enum VectorGridSource {
1826    Rects,
1827    Lines,
1828}
1829
1830/// Detect a vector ruled-line / rectangle grid inside one page region.
1831///
1832/// The returned shape intentionally matches [`TsrTableInput`]'s structure
1833/// fields so callers can hand it to `extract_tables_with_structure_*` and let
1834/// the existing PDF-text cell fill path populate contents.
1835///
1836/// `region_pdf_pt_bbox` is `[x1, y1, x2, y2]` in PDF points, top-left origin,
1837/// relative to the visible page box (see [`extract_text_in_regions_mem`]).
1838/// The returned cell bboxes are crop-image pixels.
1839pub fn detect_vector_grid_in_region_mem(
1840    buffer: &[u8],
1841    page_idx: u32,
1842    region_pdf_pt_bbox: [f32; 4],
1843    render_dpi: f32,
1844) -> Result<Option<VectorGridDetection>, PdfError> {
1845    validate_pdf_bytes(buffer)?;
1846    let (doc, _page_count) = load_document_from_mem(buffer)?;
1847    let pages = doc.get_pages();
1848
1849    let page_1idx = page_idx + 1;
1850    let Some(&page_id) = pages.get(&page_1idx) else {
1851        return Ok(None);
1852    };
1853
1854    let needed_pages = HashSet::from([page_1idx]);
1855    let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1856    let extractor::PageBoxExtraction {
1857        mut items,
1858        rects,
1859        lines,
1860        coords_rotated,
1861        page_box,
1862        ..
1863    } = extractor::extract_page_text_items_in_page_box(
1864        &doc,
1865        page_id,
1866        page_1idx,
1867        &font_cmaps,
1868        false,
1869        &mut extractor::FontStyleCache::new(),
1870        &mut extractor::FormWalkBudget::new(),
1871    )?;
1872    let page_h = page_box.height();
1873    text_utils::fix_letterspaced_items(&mut items);
1874
1875    let coords = RegionCoordSpace::from(coords_rotated);
1876    if coords != RegionCoordSpace::Standard {
1877        // TODO: add a rotated-page vector-grid fixture before enabling this.
1878        // The TSR crop contract is top-left page coordinates, while rotated
1879        // extraction normalizes vector geometry into a synthetic coordinate
1880        // space. Returning None is safer than emitting misleading bboxes.
1881        return Ok(None);
1882    }
1883    let [rx1, ry1, rx2, ry2] = region_pdf_pt_bbox;
1884    let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
1885
1886    let items_in_region: Vec<TextItem> = items
1887        .iter()
1888        .filter(|item| region_overlaps_item(item, bounds))
1889        .cloned()
1890        .collect();
1891    if items_in_region.is_empty() {
1892        return Ok(None);
1893    }
1894
1895    let rects_in_region: Vec<PdfRect> = rects
1896        .iter()
1897        .filter(|rect| region_overlaps_rect(rect, bounds))
1898        .cloned()
1899        .collect();
1900    let lines_in_region: Vec<PdfLine> = lines
1901        .iter()
1902        .filter(|line| region_overlaps_line(line, bounds))
1903        .cloned()
1904        .collect();
1905
1906    // Match the existing geometry pipeline priority: rect-backed grids first,
1907    // then line-backed grids. The detector output is only the validity gate;
1908    // bboxes below are rebuilt from the filtered vector geometry because Table
1909    // stores centers for some rect paths and row starts for line paths.
1910    let (rect_tables, _) =
1911        tables::detect_tables_from_rects(&items_in_region, &rects_in_region, page_1idx);
1912    for table in rect_tables {
1913        if let Some(result) = vector_grid_result_from_table(
1914            &table,
1915            VectorGridSource::Rects,
1916            &rects_in_region,
1917            &lines_in_region,
1918            region_pdf_pt_bbox,
1919            render_dpi,
1920            page_h,
1921            coords,
1922        ) {
1923            return Ok(Some(result));
1924        }
1925    }
1926
1927    let line_tables =
1928        tables::detect_vector_grid_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
1929    for table in line_tables {
1930        if let Some(result) = vector_grid_result_from_table(
1931            &table,
1932            VectorGridSource::Lines,
1933            &rects_in_region,
1934            &lines_in_region,
1935            region_pdf_pt_bbox,
1936            render_dpi,
1937            page_h,
1938            coords,
1939        ) {
1940            return Ok(Some(result));
1941        }
1942    }
1943
1944    Ok(None)
1945}
1946
1947#[allow(clippy::too_many_arguments)]
1948fn vector_grid_result_from_table(
1949    table: &tables::Table,
1950    source: VectorGridSource,
1951    rects: &[PdfRect],
1952    lines: &[PdfLine],
1953    crop_pdf_pt_bbox: [f32; 4],
1954    render_dpi: f32,
1955    page_height: f32,
1956    coord_space: RegionCoordSpace,
1957) -> Option<VectorGridDetection> {
1958    let num_rows = table.cells.len();
1959    let num_cols = table.cells.first().map_or(0, Vec::len);
1960    if num_rows == 0 || num_cols == 0 || table.cells.iter().any(|row| row.len() != num_cols) {
1961        return None;
1962    }
1963
1964    let (x_edges, y_edges) = match source {
1965        VectorGridSource::Rects => rect_grid_edges(rects, num_cols, num_rows)
1966            .or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
1967        VectorGridSource::Lines => line_grid_edges(table, lines, num_cols, num_rows)
1968            .or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
1969    };
1970
1971    if x_edges.len() != num_cols + 1 || y_edges.len() != num_rows + 1 {
1972        return None;
1973    }
1974
1975    let mut structure_tokens = Vec::with_capacity(num_rows * (num_cols + 2) + 2);
1976    let mut cell_bboxes = Vec::with_capacity(num_rows * num_cols);
1977    structure_tokens.push("<table>".to_string());
1978
1979    // TODO: refactor vector detectors to return normalized `(x_edges, y_edges)`.
1980    // Today `Table.columns` / `Table.rows` have detector-specific semantics,
1981    // so this export reconstructs edges from the validated table plus geometry.
1982    // Keep v1 structural output uniform: the downstream TSR text-fill path
1983    // does not require header semantics, and reliable header detection can be
1984    // layered later without changing the geometry contract.
1985    for r in 0..num_rows {
1986        structure_tokens.push("<tr>".to_string());
1987        for c in 0..num_cols {
1988            structure_tokens.push("<td></td>".to_string());
1989            let bbox_px = extracted_cell_to_crop_px(
1990                [x_edges[c], y_edges[r + 1], x_edges[c + 1], y_edges[r]],
1991                crop_pdf_pt_bbox,
1992                render_dpi,
1993                page_height,
1994                coord_space,
1995            )?;
1996            if !crop_px_bbox_is_plausible(bbox_px, crop_pdf_pt_bbox, render_dpi) {
1997                return None;
1998            }
1999            cell_bboxes.push(bbox_px.to_vec());
2000        }
2001        structure_tokens.push("</tr>".to_string());
2002    }
2003
2004    structure_tokens.push("</table>".to_string());
2005    Some(VectorGridDetection {
2006        structure_tokens,
2007        cell_bboxes,
2008    })
2009}
2010
2011fn crop_px_bbox_is_plausible(
2012    bbox_px: [f32; 4],
2013    crop_pdf_pt_bbox: [f32; 4],
2014    render_dpi: f32,
2015) -> bool {
2016    let ppi = if render_dpi > 0.0 {
2017        render_dpi / 72.0
2018    } else {
2019        1.0
2020    };
2021    let crop_w = (crop_pdf_pt_bbox[2] - crop_pdf_pt_bbox[0]).abs() * ppi;
2022    let crop_h = (crop_pdf_pt_bbox[3] - crop_pdf_pt_bbox[1]).abs() * ppi;
2023    let slack = 1.0;
2024    bbox_px[0] >= -slack
2025        && bbox_px[1] >= -slack
2026        && bbox_px[2] <= crop_w + slack
2027        && bbox_px[3] <= crop_h + slack
2028}
2029
2030#[cfg(test)]
2031mod vector_grid_tests {
2032    use super::crop_px_bbox_is_plausible;
2033
2034    /// Regression for `forecast_table_chart.pdf` (doc 128 from the
2035    /// opendataloader-bench corpus). The table has six visual columns, but
2036    /// text X-clustering in the cell-rect fallback previously split wide
2037    /// columns into ten spurious columns.
2038    #[test]
2039    fn forecast_table_chart_six_cols() {
2040        use crate::extractor::content_stream::extract_page_text_items;
2041        use crate::tables::detect_tables_from_rects;
2042        use crate::tounicode::FontCMaps;
2043        use lopdf::Document;
2044        use std::collections::HashSet;
2045        use std::fs;
2046
2047        let path = "tests/fixtures/forecast_table_chart.pdf";
2048        let buf = fs::read(path).unwrap();
2049        let doc = Document::load_mem(&buf).unwrap();
2050        let pages = doc.get_pages();
2051        let &page_id = pages.get(&1).unwrap();
2052        let needed: HashSet<u32> = HashSet::from([1]);
2053        let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
2054        let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
2055            extract_page_text_items(
2056                &doc,
2057                page_id,
2058                1,
2059                &cmaps,
2060                false,
2061                &mut crate::extractor::FontStyleCache::new(),
2062                &mut crate::extractor::FormWalkBudget::new(),
2063            )
2064            .unwrap();
2065
2066        let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
2067        assert_eq!(rect_tables.len(), 1, "expected one rect-detected table");
2068        let t = &rect_tables[0];
2069        assert_eq!(
2070            t.columns.len(),
2071            6,
2072            "doc 128 has a 6-column table; got {} edges: {:?}",
2073            t.columns.len(),
2074            t.columns
2075        );
2076        assert!(
2077            t.rows.len() >= 14 && t.rows.len() <= 17,
2078            "row count drift: {}",
2079            t.rows.len()
2080        );
2081    }
2082
2083    /// Helper: load a fixture PDF and run the rect-based table detector.
2084    fn detect_rect_tables_in_fixture_page(path: &str, page_num: u32) -> Vec<crate::tables::Table> {
2085        use crate::extractor::content_stream::extract_page_text_items;
2086        use crate::tables::detect_tables_from_rects;
2087        use crate::tounicode::FontCMaps;
2088        use lopdf::Document;
2089        use std::collections::HashSet;
2090        use std::fs;
2091
2092        let buf = fs::read(path).unwrap();
2093        let doc = Document::load_mem(&buf).unwrap();
2094        let pages = doc.get_pages();
2095        let &page_id = pages.get(&page_num).unwrap();
2096        let needed: HashSet<u32> = HashSet::from([page_num]);
2097        let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
2098        let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
2099            extract_page_text_items(
2100                &doc,
2101                page_id,
2102                page_num,
2103                &cmaps,
2104                false,
2105                &mut crate::extractor::FontStyleCache::new(),
2106                &mut crate::extractor::FormWalkBudget::new(),
2107            )
2108            .unwrap();
2109
2110        let (rect_tables, _) = detect_tables_from_rects(&items, &rects, page_num);
2111        rect_tables
2112    }
2113
2114    fn detect_rect_tables_in_fixture(path: &str) -> Vec<crate::tables::Table> {
2115        detect_rect_tables_in_fixture_page(path, 1)
2116    }
2117
2118    /// Regression for the prose-in-a-frame failure mode introduced by the
2119    /// shaded-header detection lift (PR #76). The accessory_building permit
2120    /// form has a paragraph of legal text laid out in a 2-column justified
2121    /// block; the new fill-priority + dedup changes start producing rects
2122    /// for it, and the rect detector then admits a 10×2 fake table where
2123    /// every cell holds a sentence fragment ("I agree to comply...", "I",
2124    /// "It is the property owner's responsibility..."). This test asserts
2125    /// the detector REJECTS that fake table — only the real 5×3 form data
2126    /// table (TYPE / SIZE / SETBACKS) should survive. See pdf-evals PR #30
2127    /// for the original score regression that surfaced this.
2128    #[test]
2129    fn accessory_building_rejects_prose_in_frame() {
2130        let tables = detect_rect_tables_in_fixture(
2131            "tests/fixtures/accessory_building_permit_prose_frame.pdf",
2132        );
2133        // Real form data table (TYPE / SIZE / SETBACKS) must still be detected.
2134        let data_table = tables.iter().find(|t| t.columns.len() == 3);
2135        assert!(
2136            data_table.is_some(),
2137            "expected to keep the 5×3 TYPE/SIZE/SETBACKS data table; got {:?}",
2138            tables
2139                .iter()
2140                .map(|t| (t.rows.len(), t.columns.len()))
2141                .collect::<Vec<_>>()
2142        );
2143        // Prose paragraph laid out in 2 cols must NOT be detected as a table.
2144        // If the rejection regresses, the 10×2 fake table reappears and
2145        // produces fragmented markdown like "I agree to comply..." | "I"
2146        // that fragments mid-sentence.
2147        let prose_table = tables.iter().find(|t| t.columns.len() == 2);
2148        assert!(
2149            prose_table.is_none(),
2150            "expected the 10×2 prose-in-frame block to be rejected; got rows×cols = {:?}",
2151            prose_table.map(|t| (t.rows.len(), t.columns.len()))
2152        );
2153    }
2154
2155    #[test]
2156    fn td9264_insurance_prose_not_rect_table() {
2157        let tables = detect_rect_tables_in_fixture_page("tests/fixtures/td9264.pdf", 4);
2158        assert!(
2159            tables.is_empty(),
2160            "expected no rect-detected tables for the insurance-company prose; got {:?}",
2161            tables
2162                .iter()
2163                .map(|t| (t.rows.len(), t.columns.len(), t.cells.clone()))
2164                .collect::<Vec<_>>()
2165        );
2166    }
2167
2168    /// Wireless table regression: decorative/text-region rects may provide row
2169    /// bands, but without a real rect-derived column scaffold they must not be
2170    /// accepted as a vector grid.
2171    #[test]
2172    fn wireless_two_col_rejects_rect_grid() {
2173        let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_two_col_no_rects.pdf");
2174        assert!(
2175            tables.is_empty(),
2176            "expected no rect-detected tables for wireless content; got {:?}",
2177            tables
2178                .iter()
2179                .map(|t| (t.rows.len(), t.columns.len()))
2180                .collect::<Vec<_>>()
2181        );
2182    }
2183
2184    #[test]
2185    fn wireless_two_col_region_rejects_vector_grid() {
2186        let buf = std::fs::read("tests/fixtures/wireless_two_col_no_rects.pdf").unwrap();
2187        let crops = [
2188            [49.32_f32, 52.92, 558.72, 214.2],
2189            [49.32_f32, 288.72, 556.56, 378.0],
2190            [51.48_f32, 478.44, 558.36, 567.36],
2191        ];
2192        for crop in crops {
2193            let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
2194            assert!(
2195                detected.is_none(),
2196                "expected no vector grid for wireless crop {crop:?}; got {} cells",
2197                detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
2198            );
2199        }
2200    }
2201
2202    /// Wireless dense table regression: text-position columns alone are not
2203    /// enough evidence for a rect-derived grid.
2204    #[test]
2205    fn wireless_dense_rejects_rect_grid() {
2206        let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_dense_no_rects.pdf");
2207        assert!(
2208            tables.is_empty(),
2209            "expected no rect-detected tables for wireless content; got {:?}",
2210            tables
2211                .iter()
2212                .map(|t| (t.rows.len(), t.columns.len()))
2213                .collect::<Vec<_>>()
2214        );
2215    }
2216
2217    #[test]
2218    fn wireless_dense_region_rejects_vector_grid() {
2219        let buf = std::fs::read("tests/fixtures/wireless_dense_no_rects.pdf").unwrap();
2220        let crops = [
2221            [72.36_f32, 177.48, 243.72, 333.36],
2222            [72.0_f32, 390.24, 286.92, 417.6],
2223        ];
2224        for crop in crops {
2225            let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
2226            assert!(
2227                detected.is_none(),
2228                "expected no vector grid for wireless crop {crop:?}; got {} cells",
2229                detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
2230            );
2231        }
2232    }
2233
2234    #[test]
2235    fn multiline_indent_cell_rect_grid_fixture_detects_table() {
2236        let tables = detect_rect_tables_in_fixture_page(
2237            "tests/fixtures/multiline_indent_cell_rect_grid.pdf",
2238            30,
2239        );
2240        let table = tables
2241            .iter()
2242            .max_by_key(|t| t.rows.len() * t.columns.len())
2243            .expect("expected a rect-detected table");
2244        assert_eq!(
2245            table.columns.len(),
2246            5,
2247            "expected the Controls Version / Control / IG table shape; got {:?}",
2248            tables
2249                .iter()
2250                .map(|t| (t.rows.len(), t.columns.len()))
2251                .collect::<Vec<_>>()
2252        );
2253        assert!(
2254            table.rows.len() >= 3,
2255            "expected at least header plus data rows; got {}",
2256            table.rows.len()
2257        );
2258    }
2259
2260    #[test]
2261    fn multiline_indent_cell_rect_grid_region_detects_vector_grid() {
2262        let buf = std::fs::read("tests/fixtures/multiline_indent_cell_rect_grid.pdf").unwrap();
2263        let detected =
2264            crate::detect_vector_grid_in_region_mem(&buf, 29, [0.0, 0.0, 612.0, 792.0], 200.0)
2265                .unwrap()
2266                .expect("expected vector grid for multiline indented description table");
2267        let rows = detected
2268            .structure_tokens
2269            .iter()
2270            .filter(|token| token.as_str() == "<tr>")
2271            .count();
2272        assert_eq!(detected.cell_bboxes.len() % rows, 0);
2273        assert_eq!(detected.cell_bboxes.len() / rows, 5);
2274        assert!(rows >= 3);
2275        assert!(!detected.cell_bboxes.is_empty());
2276    }
2277
2278    /// Regression for `greencomp_competence.pdf` — a 2-column "Area / Competence"
2279    /// glossary with a green-shaded header row and plain (line-drawn) body cells.
2280    /// Mirrors the production failure cohort #1 (Contractions glossary) and #6
2281    /// (BIO 350 course header): a few colored header rects sit in a horizontal
2282    /// strip while body rows are drawn with `m`/`l` operators, so the rect
2283    /// cluster has only 2 Y-edges and `try_build_grid` rejects.
2284    ///
2285    /// The page repeats a full-page background many times. Those fills must be
2286    /// removed from clustering before chart/table evidence is evaluated, or
2287    /// they swamp the real cell rectangles and make this table look chart-like.
2288    #[test]
2289    fn greencomp_competence_two_cols() {
2290        let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
2291        assert!(
2292            !tables.is_empty(),
2293            "expected at least one rect-detected table for shaded-header + plain-body shape"
2294        );
2295        let t = tables
2296            .iter()
2297            .max_by_key(|t| t.rows.len() * t.columns.len())
2298            .unwrap();
2299        assert_eq!(
2300            t.columns.len(),
2301            2,
2302            "GreenComp competence is a 2-column table; got {}: {:?}",
2303            t.columns.len(),
2304            t.columns
2305        );
2306        assert!(
2307            t.rows.len() >= 6,
2308            "expected at least 6 rows of competences; got {}",
2309            t.rows.len()
2310        );
2311    }
2312
2313    /// Regression for `upstage_key_functions.pdf` — a 4-column "Service Stage /
2314    /// Function Name / Explanation / Expected Benefit" table with a blue-shaded
2315    /// header band plus alternating row backgrounds. Mirrors production crops
2316    /// #2 (Parameter / Value with alternating blue rows) and #7 (Spanish XML
2317    /// schema with shaded header). Currently `pdf2md` returns zero markdown
2318    /// table rows.
2319    #[test]
2320    fn upstage_key_functions_four_cols() {
2321        let tables = detect_rect_tables_in_fixture("tests/fixtures/upstage_key_functions.pdf");
2322        assert!(
2323            !tables.is_empty(),
2324            "expected at least one rect-detected table for shaded-header + alt-row shape"
2325        );
2326        let t = tables
2327            .iter()
2328            .max_by_key(|t| t.rows.len() * t.columns.len())
2329            .unwrap();
2330        assert_eq!(
2331            t.columns.len(),
2332            4,
2333            "Service Flow is a 4-column table; got {}: {:?}",
2334            t.columns.len(),
2335            t.columns
2336        );
2337        assert!(
2338            t.rows.len() >= 8,
2339            "expected at least 8 visible body rows; got {}",
2340            t.rows.len()
2341        );
2342    }
2343
2344    /// Regression for `wired_header_data_misalign.pdf` — a single page from a
2345    /// parts catalog with a 4-column wire-bordered table (`Item | EAN | Nombre
2346    /// | Cant`). Column headers are centered/right-aligned inside their cells
2347    /// while data is left-aligned, so cluster_x_positions merges or drops
2348    /// columns and the cell-rect fallback used to assign text to the wrong
2349    /// columns (lost a column, fragmented neighbor cells). The fix prefers
2350    /// rect-border-derived column edges when they're well-distributed across
2351    /// the actual text items. This test asserts the detector keeps all 4
2352    /// columns and every column ends up populated.
2353    #[test]
2354    fn wired_header_data_misalign_keeps_all_columns() {
2355        let tables = detect_rect_tables_in_fixture("tests/fixtures/wired_header_data_misalign.pdf");
2356        let table = tables
2357            .iter()
2358            .find(|t| t.columns.len() == 4 && t.rows.len() >= 5)
2359            .unwrap_or_else(|| {
2360                panic!(
2361                    "expected a 4-column ≥5-row table; got {:?}",
2362                    tables
2363                        .iter()
2364                        .map(|t| (t.rows.len(), t.columns.len()))
2365                        .collect::<Vec<_>>()
2366                )
2367            });
2368        for c in 0..4 {
2369            let populated_rows = table
2370                .cells
2371                .iter()
2372                .filter(|row| !row[c].trim().is_empty())
2373                .count();
2374            assert!(
2375                populated_rows >= 2,
2376                "column {} only populated in {} rows; cells: {:?}",
2377                c,
2378                populated_rows,
2379                table.cells
2380            );
2381        }
2382    }
2383
2384    #[test]
2385    fn test_crop_px_bbox_is_plausible_bounds() {
2386        let crop = [10.0, 20.0, 110.0, 220.0];
2387
2388        assert!(crop_px_bbox_is_plausible(
2389            [0.0, 0.0, 100.0, 200.0],
2390            crop,
2391            72.0
2392        ));
2393        assert!(crop_px_bbox_is_plausible(
2394            [0.0, 0.0, 200.0, 400.0],
2395            crop,
2396            144.0
2397        ));
2398        assert!(!crop_px_bbox_is_plausible(
2399            [-2.0, 0.0, 50.0, 100.0],
2400            crop,
2401            72.0
2402        ));
2403        assert!(!crop_px_bbox_is_plausible(
2404            [0.0, -2.0, 50.0, 100.0],
2405            crop,
2406            72.0
2407        ));
2408        assert!(!crop_px_bbox_is_plausible(
2409            [0.0, 0.0, 102.0, 200.0],
2410            crop,
2411            72.0
2412        ));
2413        assert!(!crop_px_bbox_is_plausible(
2414            [0.0, 0.0, 100.0, 202.0],
2415            crop,
2416            72.0
2417        ));
2418
2419        // Boundary values at the existing 1px slack should remain valid.
2420        assert!(crop_px_bbox_is_plausible(
2421            [-1.0, -1.0, 101.0, 201.0],
2422            crop,
2423            72.0
2424        ));
2425
2426        // Non-positive DPI falls back to 1.0 ppi, so crop points equal pixels.
2427        assert!(crop_px_bbox_is_plausible(
2428            [0.0, 0.0, 100.0, 200.0],
2429            crop,
2430            0.0
2431        ));
2432        assert!(crop_px_bbox_is_plausible(
2433            [0.0, 0.0, 100.0, 200.0],
2434            crop,
2435            -144.0
2436        ));
2437        assert!(!crop_px_bbox_is_plausible(
2438            [0.0, 0.0, 102.0, 200.0],
2439            crop,
2440            -144.0
2441        ));
2442    }
2443}
2444
2445fn line_grid_edges(
2446    table: &tables::Table,
2447    lines: &[PdfLine],
2448    num_cols: usize,
2449    num_rows: usize,
2450) -> Option<(Vec<f32>, Vec<f32>)> {
2451    if lines.is_empty() || table.columns.len() != num_cols + 1 || table.rows.len() != num_rows {
2452        return None;
2453    }
2454
2455    let angle_tolerance = 2.0_f32.to_radians().tan();
2456    let mut ys = Vec::new();
2457
2458    for line in lines {
2459        let dx = (line.x2 - line.x1).abs();
2460        let dy = (line.y2 - line.y1).abs();
2461        let length = (dx * dx + dy * dy).sqrt();
2462        if length < 20.0 {
2463            continue;
2464        }
2465        if dx > 0.01 && dy / dx <= angle_tolerance {
2466            ys.push((line.y1 + line.y2) * 0.5);
2467        }
2468    }
2469
2470    let mut x_edges = table.columns.clone();
2471    x_edges.sort_by(|a, b| a.total_cmp(b));
2472
2473    let snapped_y = snap_vector_edges(ys, true);
2474    let mut y_edges = Vec::with_capacity(num_rows + 1);
2475    for &row_top in &table.rows {
2476        let matched = snapped_y
2477            .iter()
2478            .copied()
2479            .find(|y| (*y - row_top).abs() <= 3.0)
2480            .unwrap_or(row_top);
2481        y_edges.push(matched);
2482    }
2483
2484    let last_top = *y_edges.last()?;
2485    let bottom = snapped_y
2486        .iter()
2487        .copied()
2488        .filter(|y| *y < last_top - 3.0)
2489        .max_by(|a, b| a.total_cmp(b))?;
2490    y_edges.push(bottom);
2491
2492    if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
2493        Some((x_edges, y_edges))
2494    } else {
2495        None
2496    }
2497}
2498
2499fn rect_grid_edges(
2500    rects: &[PdfRect],
2501    num_cols: usize,
2502    num_rows: usize,
2503) -> Option<(Vec<f32>, Vec<f32>)> {
2504    if rects.is_empty() {
2505        return None;
2506    }
2507
2508    let mut xs = Vec::new();
2509    let mut ys = Vec::new();
2510    for rect in rects {
2511        let (x1, y1, x2, y2) = normalized_rect_edges(rect);
2512        if (x2 - x1) < 5.0 || (y2 - y1) < 5.0 {
2513            continue;
2514        }
2515        xs.push(x1);
2516        xs.push(x2);
2517        ys.push(y1);
2518        ys.push(y2);
2519    }
2520
2521    let x_edges = snap_vector_edges(xs, false);
2522    let y_edges = snap_vector_edges(ys, true);
2523    if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
2524        Some((x_edges, y_edges))
2525    } else {
2526        None
2527    }
2528}
2529
2530fn inferred_grid_edges(
2531    table: &tables::Table,
2532    rects: &[PdfRect],
2533    lines: &[PdfLine],
2534    num_cols: usize,
2535    num_rows: usize,
2536) -> Option<(Vec<f32>, Vec<f32>)> {
2537    let bounds = vector_geometry_bounds(rects, lines);
2538    let x_edges = if table.columns.len() == num_cols + 1 {
2539        let mut edges = table.columns.clone();
2540        edges.sort_by(|a, b| a.total_cmp(b));
2541        Some(edges)
2542    } else {
2543        infer_ascending_edges(&table.columns, num_cols, bounds.map(|b| (b.x_min, b.x_max)))
2544    }?;
2545
2546    let y_edges = if table.rows.len() == num_rows + 1 {
2547        let mut edges = table.rows.clone();
2548        edges.sort_by(|a, b| b.total_cmp(a));
2549        Some(edges)
2550    } else {
2551        infer_descending_edges(&table.rows, num_rows, bounds.map(|b| (b.y_min, b.y_max)))
2552    }?;
2553
2554    Some((x_edges, y_edges))
2555}
2556
2557fn infer_ascending_edges(
2558    positions: &[f32],
2559    expected_centers: usize,
2560    bounds: Option<(f32, f32)>,
2561) -> Option<Vec<f32>> {
2562    if positions.len() != expected_centers || positions.is_empty() {
2563        return None;
2564    }
2565    let mut centers = positions.to_vec();
2566    centers.sort_by(|a, b| a.total_cmp(b));
2567    if centers.len() == 1 {
2568        return None;
2569    }
2570
2571    let mut edges = Vec::with_capacity(centers.len() + 1);
2572    let first_gap = centers[1] - centers[0];
2573    let last_gap = centers[centers.len() - 1] - centers[centers.len() - 2];
2574    let left = bounds
2575        .map(|(min, _)| min)
2576        .filter(|min| min.is_finite() && *min < centers[0])
2577        .unwrap_or(centers[0] - first_gap * 0.5);
2578    let right = bounds
2579        .map(|(_, max)| max)
2580        .filter(|max| max.is_finite() && *max > *centers.last().unwrap())
2581        .unwrap_or(*centers.last().unwrap() + last_gap * 0.5);
2582
2583    edges.push(left);
2584    for pair in centers.windows(2) {
2585        edges.push((pair[0] + pair[1]) * 0.5);
2586    }
2587    edges.push(right);
2588
2589    strictly_ordered(&edges, false).then_some(edges)
2590}
2591
2592fn infer_descending_edges(
2593    positions: &[f32],
2594    expected_centers: usize,
2595    bounds: Option<(f32, f32)>,
2596) -> Option<Vec<f32>> {
2597    if positions.len() != expected_centers || positions.is_empty() {
2598        return None;
2599    }
2600    let mut centers = positions.to_vec();
2601    centers.sort_by(|a, b| b.total_cmp(a));
2602    if centers.len() == 1 {
2603        return None;
2604    }
2605
2606    let mut edges = Vec::with_capacity(centers.len() + 1);
2607    let first_gap = centers[0] - centers[1];
2608    let last_gap = centers[centers.len() - 2] - centers[centers.len() - 1];
2609    let top = bounds
2610        .map(|(_, max)| max)
2611        .filter(|max| max.is_finite() && *max > centers[0])
2612        .unwrap_or(centers[0] + first_gap * 0.5);
2613    let bottom = bounds
2614        .map(|(min, _)| min)
2615        .filter(|min| min.is_finite() && *min < *centers.last().unwrap())
2616        .unwrap_or(*centers.last().unwrap() - last_gap * 0.5);
2617
2618    edges.push(top);
2619    for pair in centers.windows(2) {
2620        edges.push((pair[0] + pair[1]) * 0.5);
2621    }
2622    edges.push(bottom);
2623
2624    strictly_ordered(&edges, true).then_some(edges)
2625}
2626
2627fn snap_vector_edges(mut values: Vec<f32>, descending: bool) -> Vec<f32> {
2628    values.retain(|v| v.is_finite());
2629    values.sort_by(|a, b| a.total_cmp(b));
2630
2631    let mut snapped: Vec<f32> = Vec::new();
2632    let mut cluster: Vec<f32> = Vec::new();
2633    for value in values {
2634        if cluster
2635            .last()
2636            .is_some_and(|last| (value - *last).abs() <= 3.0)
2637        {
2638            cluster.push(value);
2639        } else {
2640            if !cluster.is_empty() {
2641                snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
2642            }
2643            cluster = vec![value];
2644        }
2645    }
2646    if !cluster.is_empty() {
2647        snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
2648    }
2649    if descending {
2650        snapped.sort_by(|a, b| b.total_cmp(a));
2651    }
2652    snapped
2653}
2654
2655fn strictly_ordered(values: &[f32], descending: bool) -> bool {
2656    values.windows(2).all(|pair| {
2657        pair[0].is_finite()
2658            && pair[1].is_finite()
2659            && if descending {
2660                pair[0] > pair[1]
2661            } else {
2662                pair[0] < pair[1]
2663            }
2664    })
2665}
2666
2667fn normalized_rect_edges(rect: &PdfRect) -> (f32, f32, f32, f32) {
2668    let x2 = rect.x + rect.width;
2669    let y2 = rect.y + rect.height;
2670    (
2671        rect.x.min(x2),
2672        rect.y.min(y2),
2673        rect.x.max(x2),
2674        rect.y.max(y2),
2675    )
2676}
2677
2678fn vector_geometry_bounds(rects: &[PdfRect], lines: &[PdfLine]) -> Option<RegionBounds> {
2679    let mut bounds: Option<RegionBounds> = None;
2680    let mut include = |x1: f32, y1: f32, x2: f32, y2: f32| {
2681        let next = RegionBounds {
2682            x_min: x1.min(x2),
2683            y_min: y1.min(y2),
2684            x_max: x1.max(x2),
2685            y_max: y1.max(y2),
2686        };
2687        bounds = Some(if let Some(prev) = bounds {
2688            RegionBounds {
2689                x_min: prev.x_min.min(next.x_min),
2690                y_min: prev.y_min.min(next.y_min),
2691                x_max: prev.x_max.max(next.x_max),
2692                y_max: prev.y_max.max(next.y_max),
2693            }
2694        } else {
2695            next
2696        });
2697    };
2698
2699    for rect in rects {
2700        let (x1, y1, x2, y2) = normalized_rect_edges(rect);
2701        include(x1, y1, x2, y2);
2702    }
2703    for line in lines {
2704        include(line.x1, line.y1, line.x2, line.y2);
2705    }
2706
2707    bounds
2708}
2709
2710fn extracted_cell_to_crop_px(
2711    bbox: [f32; 4],
2712    crop_pdf_pt_bbox: [f32; 4],
2713    render_dpi: f32,
2714    page_height: f32,
2715    coord_space: RegionCoordSpace,
2716) -> Option<[f32; 4]> {
2717    let [x1, y1, x2, y2] = extracted_bbox_to_page_top_left(bbox, page_height, coord_space);
2718    if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
2719        return None;
2720    }
2721    if x1 >= x2 || y1 >= y2 {
2722        return None;
2723    }
2724
2725    let ppi = if render_dpi > 0.0 {
2726        render_dpi / 72.0
2727    } else {
2728        1.0
2729    };
2730    let [crop_x1, crop_y1, _, _] = crop_pdf_pt_bbox;
2731    Some([
2732        (x1 - crop_x1) * ppi,
2733        (y1 - crop_y1) * ppi,
2734        (x2 - crop_x1) * ppi,
2735        (y2 - crop_y1) * ppi,
2736    ])
2737}
2738
2739fn extracted_bbox_to_page_top_left(
2740    bbox: [f32; 4],
2741    page_height: f32,
2742    coord_space: RegionCoordSpace,
2743) -> [f32; 4] {
2744    let [x1, y1, x2, y2] = bbox;
2745    let x_min = x1.min(x2);
2746    let x_max = x1.max(x2);
2747    let y_min = y1.min(y2);
2748    let y_max = y1.max(y2);
2749
2750    match coord_space {
2751        RegionCoordSpace::Standard => [x_min, page_height - y_max, x_max, page_height - y_min],
2752        RegionCoordSpace::Rotated90Ccw => {
2753            [-y_max, page_height - x_max, -y_min, page_height - x_min]
2754        }
2755        RegionCoordSpace::Rotated90Cw => [y_min, page_height + x_min, y_max, page_height + x_max],
2756    }
2757}
2758
2759// =========================================================================
2760// Region-based table extraction with external structure recovery (TSR)
2761// =========================================================================
2762
2763/// Input for [`extract_tables_with_structure_mem`]: one cropped table region
2764/// plus the raw structure-recovery output for it.
2765///
2766/// The structure tokens and bboxes are typically produced by an external
2767/// table-structure recognition model (e.g. SLANet on PaddleOCR) running on
2768/// a rendered crop of the page. pdf-inspector uses the structure to lay out
2769/// the cells and pulls the cell text from the native PDF — no OCR involved.
2770#[derive(Debug, Clone)]
2771pub struct TsrTableInput {
2772    /// 0-indexed page number where the crop was taken from.
2773    pub page: u32,
2774    /// Crop bbox on the page, `[x1, y1, x2, y2]` in PDF points with
2775    /// **top-left origin**, relative to the visible page box — the frame of
2776    /// a rendered page image (see [`extract_text_in_regions_mem`]).
2777    pub crop_pdf_pt_bbox: [f32; 4],
2778    /// DPI the crop image was rendered at (e.g. `200.0`). Used to convert
2779    /// cell bboxes from image-pixels back to PDF points.
2780    pub render_dpi: f32,
2781    /// Raw structure tokens emitted by the TSR model, in document order.
2782    /// See [`tables::structured::parse_structure`] for the accepted grammar.
2783    pub structure_tokens: Vec<String>,
2784    /// One bbox per cell (in document order, parallel to the cell open-tags
2785    /// in `structure_tokens`). May be 4-element `[x1,y1,x2,y2]` or
2786    /// 8-element 4-corner polygon, in **crop image-pixel space**.
2787    pub cell_bboxes: Vec<Vec<f32>>,
2788}
2789
2790/// Extract structured cells using externally-supplied structure recovery.
2791///
2792/// For each input, this:
2793/// 1. Pairs each cell open-tag in `structure_tokens` with the next bbox in
2794///    `cell_bboxes` (document order), tracking row/col with rowspan/colspan
2795///    awareness.
2796/// 2. Converts each cell bbox from crop image-pixels into page PDF-points.
2797/// 3. Pulls the cell's text by overlap-testing PDF text items inside that
2798///    bbox — same primitives used by [`extract_text_in_regions_mem`].
2799///
2800/// Returns one `Vec<StructuredCell>` per input, in input order. Each cell
2801/// carries its (row, col, rowspan, colspan, is_header) metadata, the
2802/// extracted text, and its page-PDF-pt bbox so callers can do their own
2803/// rendering, debug overlays, or per-cell post-processing.
2804///
2805/// Inputs whose page is out of range or whose tokens parse to zero cells
2806/// produce an empty `Vec`.
2807///
2808/// See [`extract_tables_with_structure_mem`] if you just want the rendered
2809/// markdown.
2810pub fn extract_tables_with_structure_cells_mem(
2811    buffer: &[u8],
2812    inputs: &[TsrTableInput],
2813) -> Result<Vec<Vec<tables::StructuredCell>>, PdfError> {
2814    use tables::structured::{
2815        cell_px_to_page_pt, normalize_cell_bands, parse_structure, polygon_to_aabb, StructuredCell,
2816    };
2817
2818    validate_pdf_bytes(buffer)?;
2819    let (doc, _page_count) = load_document_from_mem(buffer)?;
2820    let pages = doc.get_pages();
2821
2822    let needed_pages: HashSet<u32> = inputs.iter().map(|t| t.page + 1).collect();
2823    let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
2824
2825    let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
2826    let mut page_heights: HashMap<u32, f32> = HashMap::new();
2827    let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
2828    let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
2829    let mut style_cache = extractor::FontStyleCache::new();
2830
2831    for (page_num, &page_id) in pages.iter() {
2832        if !needed_pages.contains(page_num) {
2833            continue;
2834        }
2835        let extractor::PageBoxExtraction {
2836            mut items,
2837            coords_rotated,
2838            page_box,
2839            ..
2840        } = extractor::extract_page_text_items_in_page_box(
2841            &doc,
2842            page_id,
2843            *page_num,
2844            &font_cmaps,
2845            false,
2846            &mut style_cache,
2847            &mut extractor::FormWalkBudget::new(),
2848        )?;
2849        page_heights.insert(*page_num, page_box.height());
2850        let threshold = text_utils::fix_letterspaced_items(&mut items);
2851        if threshold > 0.10 {
2852            page_thresholds.insert(*page_num, threshold);
2853        }
2854        if coords_rotated != extractor::geometry::PageRotation::Upright {
2855            rotated_pages.insert(*page_num, coords_rotated.into());
2856        }
2857        items_by_page.insert(*page_num, items);
2858    }
2859
2860    let mut results: Vec<Vec<StructuredCell>> = Vec::with_capacity(inputs.len());
2861
2862    for input in inputs {
2863        let page_1idx = input.page + 1;
2864        let Some(items) = items_by_page.get(&page_1idx) else {
2865            // Out-of-range page or page with no extractable text — emit empty.
2866            results.push(Vec::new());
2867            continue;
2868        };
2869        let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
2870        let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
2871        let coords = rotated_pages
2872            .get(&page_1idx)
2873            .copied()
2874            .unwrap_or(RegionCoordSpace::Standard);
2875
2876        let crop_origin = [input.crop_pdf_pt_bbox[0], input.crop_pdf_pt_bbox[1]];
2877
2878        let slots = parse_structure(&input.structure_tokens);
2879        if slots.is_empty() {
2880            results.push(Vec::new());
2881            continue;
2882        }
2883
2884        let mut cells: Vec<StructuredCell> = Vec::with_capacity(slots.len());
2885        for slot in &slots {
2886            let page_pt_bbox;
2887
2888            if let Some(coords_arr) = input.cell_bboxes.get(slot.bbox_idx) {
2889                if let Some(aabb_px) = polygon_to_aabb(coords_arr) {
2890                    page_pt_bbox = cell_px_to_page_pt(aabb_px, input.render_dpi, crop_origin);
2891                } else {
2892                    page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
2893                }
2894            } else {
2895                page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
2896            }
2897
2898            cells.push(StructuredCell {
2899                row: slot.row,
2900                col: slot.col,
2901                rowspan: slot.rowspan,
2902                colspan: slot.colspan,
2903                is_header: slot.is_header,
2904                text: String::new(),
2905                page_pt_bbox,
2906            });
2907        }
2908
2909        normalize_cell_bands(&mut cells);
2910
2911        // Stage 1: exclusive per-token assignment. Each PDF text item is
2912        // first split into whitespace-separated tokens with estimated x
2913        // positions (see `split_item_into_token_subitems`). For each token
2914        // we find the cell(s) whose (band-clamped) bbox satisfies the
2915        // strict membership rule (`tsr_region_contains_item`: center
2916        // inside OR >=60% overlap on both axes). If multiple cells
2917        // qualify, the closest-center wins. Tokens that don't land in any
2918        // cell are eligible for stage-2 orphan recovery.
2919        //
2920        // Per-token (rather than per-item) routing is what prevents the
2921        // dense-grid collapse bug: a row rendered as one wide Tj
2922        // ("Marshall Islands 0.9 0.9 0.9") produces one TextItem whose
2923        // CENTER lies in only one cell, so per-item routing parks the
2924        // whole row in that single cell and leaves the rest of the row
2925        // empty. Per-token routing distributes the words to whichever
2926        // cells their (estimated) positions fall into. Single-token items
2927        // collapse to a one-element token list and behave exactly as
2928        // before.
2929        //
2930        // The token-level exclusivity (one token → one cell) still
2931        // prevents the cell-overlap bug where SLANet emits cells whose
2932        // y-extents overlap between rows. Closest-center disambiguation
2933        // routes each token to the correct row.
2934
2935        // Pre-compute each cell's bounds + center (in PDF-pt-flipped space)
2936        // so we don't redo the work per token.
2937        let cell_meta: Vec<Option<(RegionBounds, f32, f32)>> = cells
2938            .iter()
2939            .map(|cell| {
2940                let [x1, y1, x2, y2] = cell.page_pt_bbox;
2941                if x1 >= x2 || y1 >= y2 {
2942                    return None;
2943                }
2944                let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
2945                let cx = (bounds.x_min + bounds.x_max) * 0.5;
2946                let cy = (bounds.y_min + bounds.y_max) * 0.5;
2947                Some((bounds, cx, cy))
2948            })
2949            .collect();
2950
2951        let mut per_cell_items: Vec<Vec<TextItem>> = vec![Vec::new(); cells.len()];
2952        // Tokens of an item that did NOT land in any cell during stage 1.
2953        // These are the orphan candidates handed to `tsr_assign_orphan_items`.
2954        // Using token-grain orphan candidates (rather than the original wide
2955        // item) lets stage 2 recover individual words that fell just outside
2956        // their cell's clamped band, without re-attributing already-claimed
2957        // tokens.
2958        let mut orphan_token_subitems: Vec<TextItem> = Vec::new();
2959
2960        for item in items.iter() {
2961            let token_subitems = split_item_into_token_subitems(item);
2962            for token_item in token_subitems {
2963                let token_w = text_utils::effective_width(&token_item);
2964                let token_cx = token_item.x + token_w * 0.5;
2965                let token_cy = token_item.y + text_utils::effective_height(&token_item) * 0.5;
2966                let mut best: Option<(usize, f32)> = None;
2967                for (cell_idx, meta) in cell_meta.iter().enumerate() {
2968                    let Some((bounds, ccx, ccy)) = meta else {
2969                        continue;
2970                    };
2971                    if !tsr_region_contains_item(&token_item, *bounds) {
2972                        continue;
2973                    }
2974                    let dx = token_cx - ccx;
2975                    let dy = token_cy - ccy;
2976                    let dist_sq = dx * dx + dy * dy;
2977                    if best.is_none_or(|(_, d)| dist_sq < d) {
2978                        best = Some((cell_idx, dist_sq));
2979                    }
2980                }
2981                if let Some((ci, _)) = best {
2982                    per_cell_items[ci].push(token_item);
2983                } else {
2984                    orphan_token_subitems.push(token_item);
2985                }
2986            }
2987        }
2988
2989        // Build per-cell text from the assigned tokens. Markdown cells must
2990        // be one line — collapse line breaks from the line-grouping pass.
2991        for (cell_idx, matched) in per_cell_items.into_iter().enumerate() {
2992            cells[cell_idx].text = collect_text_from_matched_items(matched, adaptive_threshold)
2993                .replace(['\n', '\r'], " ");
2994        }
2995
2996        // Stage 2: orphan assignment — tokens that didn't land in any cell
2997        // during stage 1 get assigned to their nearest *empty* cell,
2998        // clamped by a plausibility cap derived from cell geometry.
2999        //
3000        // This recovers two failure modes left by `normalize_cell_bands`:
3001        //   (a) header text positioned to the LEFT of a column whose band
3002        //       was derived from data cells centered farther right, so the
3003        //       header text falls outside the clamped band; and
3004        //   (b) local SLANet row drift where a cell's bbox sits slightly
3005        //       above/below its target text item, so the strict rules miss.
3006        // Empty-cell-only is the safety net: a cell already filled by stage 1
3007        // is never overwritten or augmented, so the cell-bleed case PR #62
3008        // closed cannot regress.
3009        tsr_assign_orphan_items(
3010            &orphan_token_subitems,
3011            &mut cells,
3012            &std::collections::HashSet::new(),
3013            page_h,
3014            coords,
3015        );
3016
3017        results.push(cells);
3018    }
3019
3020    Ok(results)
3021}
3022
3023/// Split a `TextItem` into one virtual sub-item per whitespace-separated
3024/// token, with each token's `x` / `width` estimated from the original item's
3025/// effective width and the token's character offset.
3026///
3027/// PDFs often render an entire row's content as a single Tj — e.g.
3028/// "Marshall Islands 0.9 0.9 0.9" — producing one wide TextItem whose
3029/// center sits in only one of the model-emitted cells. Per-item routing
3030/// then parks the whole row in that one cell. Splitting on whitespace
3031/// gives each word its own approximate position so per-cell routing can
3032/// distribute the words to whichever cells their estimated centers fall
3033/// into.
3034///
3035/// The character-width estimate is `item.width / char_count`. A run with a
3036/// known zero advance keeps all its tokens at its origin — that is where
3037/// they were drawn; only a run whose advance is unknown (and somehow has no
3038/// box) is spread over half an em per character for the interpolation. The
3039/// estimate is uniform across the item —
3040/// fine for routing, since we only need to know which cell each token's
3041/// center lands in, not its exact position. Single-token items collapse to
3042/// a one-element vector equivalent to the input item, making this a no-op
3043/// for the common case.
3044fn split_item_into_token_subitems(item: &TextItem) -> Vec<TextItem> {
3045    let total_chars = item.text.chars().count();
3046    if total_chars == 0 {
3047        return Vec::new();
3048    }
3049    // Token positions are interpolated along +x, the reading direction of
3050    // an upright run only. A rotated run (vertical table header) or an
3051    // upside-down one, whose tokens advance towards -x, stays one item: its
3052    // box already routes it to the right cell.
3053    if !item.is_upright() {
3054        return vec![item.clone()];
3055    }
3056    let item_w = if item.width > 0.0 || item.advance_known {
3057        item.width
3058    } else {
3059        total_chars as f32 * item.font_size * 0.5
3060    };
3061    let char_w = item_w / total_chars as f32;
3062
3063    let mut tokens: Vec<TextItem> = Vec::new();
3064    let mut current_token = String::new();
3065    let mut current_start_idx: Option<usize> = None;
3066
3067    let push_token =
3068        |tokens: &mut Vec<TextItem>, text: String, start_idx: usize, end_idx: usize| {
3069            if text.is_empty() {
3070                return;
3071            }
3072            let mut sub = item.clone();
3073            sub.text = text;
3074            sub.x = item.x + start_idx as f32 * char_w;
3075            sub.width = (end_idx - start_idx) as f32 * char_w;
3076            tokens.push(sub);
3077        };
3078
3079    for (idx, ch) in item.text.chars().enumerate() {
3080        if ch.is_whitespace() {
3081            if let Some(start_idx) = current_start_idx.take() {
3082                let text = std::mem::take(&mut current_token);
3083                push_token(&mut tokens, text, start_idx, idx);
3084            }
3085        } else {
3086            if current_start_idx.is_none() {
3087                current_start_idx = Some(idx);
3088            }
3089            current_token.push(ch);
3090        }
3091    }
3092    if let Some(start_idx) = current_start_idx {
3093        let text = std::mem::take(&mut current_token);
3094        push_token(&mut tokens, text, start_idx, total_chars);
3095    }
3096
3097    tokens
3098}
3099
3100/// Compute plausibility caps for the orphan-assignment pass. Returns
3101/// `(cap_x, cap_y)` — the maximum x/y distance from a text item's center
3102/// to a candidate empty cell's bbox before the candidate is rejected.
3103///
3104/// Caps are derived from cell geometry so they scale with the table:
3105/// dense small-row tables get a tight cap, looser tables get more slack.
3106/// Floor values guard against degenerate single-cell tables collapsing
3107/// the cap to zero.
3108fn tsr_assignment_caps(cells: &[tables::StructuredCell]) -> (f32, f32) {
3109    let mut widths: Vec<f32> = Vec::with_capacity(cells.len());
3110    let mut heights: Vec<f32> = Vec::with_capacity(cells.len());
3111    for cell in cells {
3112        let [x1, y1, x2, y2] = cell.page_pt_bbox;
3113        let w = (x2 - x1).abs();
3114        let h = (y2 - y1).abs();
3115        if w > 0.0 && h > 0.0 {
3116            widths.push(w);
3117            heights.push(h);
3118        }
3119    }
3120    if widths.is_empty() {
3121        return (0.0, 0.0);
3122    }
3123    widths.sort_by(|a, b| a.total_cmp(b));
3124    heights.sort_by(|a, b| a.total_cmp(b));
3125    let median_w = widths[widths.len() / 2];
3126    let median_h = heights[heights.len() / 2];
3127    // Floor values: even on a dense table, a 5pt floor handles small
3128    // pixel-level bbox jitter without being so loose that we'd cross
3129    // into a neighboring row/column. Symmetric in both axes.
3130    let cap_x = median_w.max(5.0);
3131    let cap_y = median_h.max(5.0);
3132    (cap_x, cap_y)
3133}
3134
3135/// For each text item that wasn't claimed by any cell during stage 1,
3136/// find the nearest *empty* cell within `(cap_x, cap_y)` of the item's
3137/// center and append the item's text to that cell. Cells that already
3138/// have content are skipped — stage 2 only fills, never augments.
3139///
3140/// Distance is point-to-rect: 0 if the item center is inside the cell's
3141/// bbox, else the axis-aligned gap to the nearest edge. Both x-gap and
3142/// y-gap must be within their respective caps for a candidate to qualify;
3143/// among qualifying candidates, the smallest combined euclidean distance
3144/// wins.
3145fn tsr_assign_orphan_items(
3146    items: &[TextItem],
3147    cells: &mut [tables::StructuredCell],
3148    claimed: &std::collections::HashSet<usize>,
3149    page_height: f32,
3150    coord_space: RegionCoordSpace,
3151) {
3152    if cells.is_empty() {
3153        return;
3154    }
3155    let (cap_x, cap_y) = tsr_assignment_caps(cells);
3156    if cap_x <= 0.0 || cap_y <= 0.0 {
3157        return;
3158    }
3159    // The caps come from the cells' page-coordinate boxes; in a turned frame
3160    // the page's horizontal extent runs along the frame's y axis and vice
3161    // versa, so swap them (and the same-line tolerance derived from cap_y).
3162    let (cap_x, cap_y) = match coord_space {
3163        RegionCoordSpace::Standard => (cap_x, cap_y),
3164        RegionCoordSpace::Rotated90Ccw | RegionCoordSpace::Rotated90Cw => (cap_y, cap_x),
3165    };
3166    // Y-tolerance for "same line as a previous orphan" — multi-token branch
3167    // names like "Blue Valley Parkway" are 3 separate text items and should
3168    // all stack into the same cell. But two orphans on different rows of
3169    // the PDF (different y values) targeting the same empty cell should
3170    // NOT merge — that produces the "Mitchell Woonsocket" / "Shawnee Blue
3171    // Valley Parkway" run-on cells. Half a row of slack is conservative.
3172    let y_tolerance = (cap_y * 0.5).max(3.0);
3173
3174    // Pre-compute each empty cell's region bounds so we don't re-flip
3175    // page coordinates per orphan-candidate pair.
3176    let cell_bounds: Vec<Option<RegionBounds>> = cells
3177        .iter()
3178        .map(|cell| {
3179            if !cell.text.is_empty() {
3180                return None;
3181            }
3182            let [x1, y1, x2, y2] = cell.page_pt_bbox;
3183            if x1 >= x2 || y1 >= y2 {
3184                return None;
3185            }
3186            Some(region_bounds(x1, y1, x2, y2, page_height, coord_space))
3187        })
3188        .collect();
3189
3190    // Track the y-center of the FIRST orphan that landed in each cell so
3191    // subsequent orphans only stack if they're on the same line.
3192    let mut stage2_first_y: std::collections::HashMap<usize, f32> =
3193        std::collections::HashMap::new();
3194
3195    for (i, item) in items.iter().enumerate() {
3196        if claimed.contains(&i) {
3197            continue;
3198        }
3199        let item_w = text_utils::effective_width(item);
3200        if item.text.trim().is_empty() {
3201            continue;
3202        }
3203        let cx = item.x + item_w * 0.5;
3204        let cy = item.y + text_utils::effective_height(item) * 0.5;
3205
3206        let mut best: Option<(usize, f32)> = None;
3207        for (ci, bounds_opt) in cell_bounds.iter().enumerate() {
3208            let Some(bounds) = bounds_opt else {
3209                continue;
3210            };
3211            // If a previous orphan already landed in this cell, only let a
3212            // new orphan join if it's on the same line. Cross-line orphans
3213            // need to look elsewhere (next-nearest empty cell).
3214            if let Some(&first_y) = stage2_first_y.get(&ci) {
3215                if (first_y - cy).abs() > y_tolerance {
3216                    continue;
3217                }
3218            }
3219            let dx = (bounds.x_min - cx).max(0.0).max(cx - bounds.x_max);
3220            let dy = (bounds.y_min - cy).max(0.0).max(cy - bounds.y_max);
3221            if dx > cap_x || dy > cap_y {
3222                continue;
3223            }
3224            let dist_sq = dx * dx + dy * dy;
3225            if best.is_none_or(|(_, d)| dist_sq < d) {
3226                best = Some((ci, dist_sq));
3227            }
3228        }
3229
3230        if let Some((ci, _)) = best {
3231            // Append, preserving stage 1's content. Same-line orphans
3232            // stack to support multi-token text (e.g. "Blue Valley
3233            // Parkway"); cross-line orphans are filtered out above.
3234            let trimmed = item.text.trim();
3235            if cells[ci].text.is_empty() {
3236                cells[ci].text = trimmed.to_string();
3237            } else {
3238                cells[ci].text.push(' ');
3239                cells[ci].text.push_str(trimmed);
3240            }
3241            stage2_first_y.entry(ci).or_insert(cy);
3242        }
3243    }
3244}
3245
3246/// Extract markdown tables using externally-supplied structure recovery.
3247///
3248/// Convenience wrapper around [`extract_tables_with_structure_cells_mem`]
3249/// that renders each cell list to markdown via
3250/// [`tables::cells_to_markdown`]. Returns one markdown string per input,
3251/// in input order. Inputs whose page is out of range or whose tokens parse
3252/// to zero cells produce an empty string.
3253pub fn extract_tables_with_structure_mem(
3254    buffer: &[u8],
3255    inputs: &[TsrTableInput],
3256) -> Result<Vec<String>, PdfError> {
3257    let cells_lists = extract_tables_with_structure_cells_mem(buffer, inputs)?;
3258    Ok(cells_lists
3259        .into_iter()
3260        .map(|cells| {
3261            if cells.is_empty() {
3262                String::new()
3263            } else {
3264                tables::cells_to_markdown(&cells)
3265            }
3266        })
3267        .collect())
3268}
3269
3270/// Markdown for one extracted table plus a diagnostic flag describing
3271/// which path produced it.
3272///
3273/// `fallback_reason` is `None` when the TSR-hybrid path produced the
3274/// markdown directly; `Some(<short identifier>)` when stage 1's quality
3275/// check fired and either in-place expansion or the heuristic fallback
3276/// produced the output. The reason string is stable enough to use as a
3277/// metric label (e.g. `multi_row_in_cell_expanded`, `phantom_empty_row`).
3278#[derive(Debug, Clone)]
3279pub struct TableExtractionResult {
3280    pub markdown: String,
3281    pub fallback_reason: Option<String>,
3282}
3283
3284#[derive(Debug, Clone)]
3285enum TsrQualityIssue {
3286    PhantomEmptyRow,
3287    MultiRowInCell {
3288        expanded_cells: Option<Vec<tables::StructuredCell>>,
3289    },
3290}
3291
3292impl TsrQualityIssue {
3293    fn reason(&self) -> &'static str {
3294        match self {
3295            Self::PhantomEmptyRow => "phantom_empty_row",
3296            Self::MultiRowInCell { .. } => "multi_row_in_cell",
3297        }
3298    }
3299}
3300
3301#[derive(Clone)]
3302struct TsrCellTextLine {
3303    center_y: f32,
3304    half_height: f32,
3305    items: Vec<TextItem>,
3306}
3307
3308impl TsrCellTextLine {
3309    fn new(item: TextItem) -> Self {
3310        let center_y = item.y + text_utils::effective_height(&item) * 0.5;
3311        let half_height = (text_utils::effective_height(&item) * 0.5).max(2.5);
3312        Self {
3313            center_y,
3314            half_height,
3315            items: vec![item],
3316        }
3317    }
3318
3319    fn add(&mut self, item: TextItem) {
3320        let center_y = item.y + text_utils::effective_height(&item) * 0.5;
3321        let existing = self.items.len() as f32;
3322        self.center_y = (self.center_y * existing + center_y) / (existing + 1.0);
3323        self.half_height = self
3324            .half_height
3325            .max((text_utils::effective_height(&item) * 0.5).max(2.5));
3326        self.items.push(item);
3327    }
3328
3329    fn bottom_y(&self) -> f32 {
3330        self.items
3331            .iter()
3332            .map(|item| item.y)
3333            .fold(f32::INFINITY, f32::min)
3334    }
3335}
3336
3337#[derive(Clone)]
3338struct TsrRowExpansion {
3339    bands: Vec<f32>,
3340    tolerance: f32,
3341}
3342
3343fn collect_items_in_tsr_cell(
3344    items: &[TextItem],
3345    cell: &tables::StructuredCell,
3346    page_height: f32,
3347    coord_space: RegionCoordSpace,
3348) -> Vec<TextItem> {
3349    let [x1, y1, x2, y2] = cell.page_pt_bbox;
3350    if x1 >= x2 || y1 >= y2 {
3351        return Vec::new();
3352    }
3353    let bounds = region_bounds(x1, y1, x2, y2, page_height, coord_space);
3354    items
3355        .iter()
3356        .filter(|item| !item.text.trim().is_empty() && tsr_region_contains_item(item, bounds))
3357        .cloned()
3358        .collect()
3359}
3360
3361fn cluster_tsr_cell_text_lines(mut items: Vec<TextItem>) -> Vec<TsrCellTextLine> {
3362    if items.is_empty() {
3363        return Vec::new();
3364    }
3365
3366    items.sort_by(|a, b| {
3367        let ay = a.y + a.height * 0.5;
3368        let by = b.y + b.height * 0.5;
3369        by.total_cmp(&ay).then(a.x.total_cmp(&b.x))
3370    });
3371
3372    let mut lines: Vec<TsrCellTextLine> = Vec::new();
3373    for item in items {
3374        let item_top = item.y + text_utils::effective_height(&item);
3375        let item_half_height = (text_utils::effective_height(&item) * 0.5).max(2.5);
3376        if let Some(last) = lines.last_mut() {
3377            let gap = last.bottom_y() - item_top;
3378            if gap <= last.half_height.max(item_half_height) {
3379                last.add(item);
3380                continue;
3381            }
3382        }
3383        lines.push(TsrCellTextLine::new(item));
3384    }
3385
3386    lines
3387}
3388
3389fn build_tsr_row_expansion(
3390    row_cells: &[usize],
3391    cells: &[tables::StructuredCell],
3392    cell_lines: &[Vec<TsrCellTextLine>],
3393) -> Option<TsrRowExpansion> {
3394    if row_cells.is_empty() {
3395        return None;
3396    }
3397    let has_spanning_cell = row_cells
3398        .iter()
3399        .any(|&idx| cells[idx].rowspan > 1 || cells[idx].colspan > 1);
3400    if has_spanning_cell {
3401        return None;
3402    }
3403
3404    let multiline_cells = row_cells
3405        .iter()
3406        .filter(|&&idx| cell_lines[idx].len() >= 2)
3407        .count();
3408    if row_cells.len() >= 2 && multiline_cells < 2 {
3409        return None;
3410    }
3411    if row_cells.len() == 1 && multiline_cells == 0 {
3412        return None;
3413    }
3414
3415    let mut centers: Vec<(f32, f32)> = row_cells
3416        .iter()
3417        .flat_map(|&idx| {
3418            cell_lines[idx]
3419                .iter()
3420                .map(|line| (line.center_y, line.half_height))
3421        })
3422        .collect();
3423    if centers.len() < 2 {
3424        return None;
3425    }
3426    centers.sort_by(|a, b| b.0.total_cmp(&a.0));
3427
3428    let mut half_heights: Vec<f32> = centers.iter().map(|(_, h)| *h).collect();
3429    half_heights.sort_by(|a, b| a.total_cmp(b));
3430    let tolerance = (half_heights[half_heights.len() / 2] * 0.8).max(3.0);
3431
3432    let mut bands: Vec<(f32, usize)> = Vec::new();
3433    for (center, _) in centers {
3434        if let Some((band_center, count)) = bands
3435            .iter_mut()
3436            .find(|(band_center, _)| (*band_center - center).abs() <= tolerance)
3437        {
3438            *band_center = (*band_center * *count as f32 + center) / (*count as f32 + 1.0);
3439            *count += 1;
3440        } else {
3441            bands.push((center, 1));
3442        }
3443    }
3444
3445    // V1 targets the common 1-2 lost-row cases; larger compressions stay on
3446    // the existing heuristic fallback path until we have evidence to broaden it.
3447    if !(2..=4).contains(&bands.len()) {
3448        return None;
3449    }
3450
3451    let min_support = if row_cells.len() >= 2 { 2 } else { 1 };
3452    let supported = bands.iter().all(|(band, _)| {
3453        row_cells
3454            .iter()
3455            .filter(|&&idx| {
3456                cell_lines[idx]
3457                    .iter()
3458                    .any(|line| (line.center_y - *band).abs() <= tolerance)
3459            })
3460            .count()
3461            >= min_support
3462    });
3463    if !supported {
3464        return None;
3465    }
3466
3467    bands.sort_by(|a, b| b.0.total_cmp(&a.0));
3468    Some(TsrRowExpansion {
3469        bands: bands.into_iter().map(|(center, _)| center).collect(),
3470        tolerance,
3471    })
3472}
3473
3474fn text_for_tsr_band(
3475    lines: &[TsrCellTextLine],
3476    band: f32,
3477    tolerance: f32,
3478    adaptive_threshold: f32,
3479) -> String {
3480    let mut matched: Vec<TextItem> = lines
3481        .iter()
3482        .filter(|line| (line.center_y - band).abs() <= tolerance)
3483        .flat_map(|line| line.items.iter().cloned())
3484        .collect();
3485    if matched.is_empty() {
3486        return String::new();
3487    }
3488    matched.sort_by(|a, b| b.y.total_cmp(&a.y).then(a.x.total_cmp(&b.x)));
3489    collect_text_from_matched_items(matched, adaptive_threshold).replace(['\n', '\r'], " ")
3490}
3491
3492fn slice_cell_bbox_for_expanded_row(
3493    cell: &tables::StructuredCell,
3494    row_idx: usize,
3495    row_count: usize,
3496) -> [f32; 4] {
3497    let mut bbox = cell.page_pt_bbox;
3498    let top = bbox[1].min(bbox[3]);
3499    let bottom = bbox[1].max(bbox[3]);
3500    let height = bottom - top;
3501    if height <= 0.0 || row_count == 0 {
3502        return bbox;
3503    }
3504    let step = height / row_count as f32;
3505    bbox[1] = top + step * row_idx as f32;
3506    bbox[3] = if row_idx + 1 == row_count {
3507        bottom
3508    } else {
3509        top + step * (row_idx + 1) as f32
3510    };
3511    bbox
3512}
3513
3514fn try_expand_multi_row_cells(
3515    cells: &[tables::StructuredCell],
3516    items: &[TextItem],
3517    page_height: f32,
3518    coord_space: RegionCoordSpace,
3519    adaptive_threshold: f32,
3520) -> Option<Vec<tables::StructuredCell>> {
3521    if cells.is_empty() {
3522        return None;
3523    }
3524
3525    let cell_lines: Vec<Vec<TsrCellTextLine>> = cells
3526        .iter()
3527        .map(|cell| {
3528            if cell.rowspan > 1 {
3529                Vec::new()
3530            } else {
3531                cluster_tsr_cell_text_lines(collect_items_in_tsr_cell(
3532                    items,
3533                    cell,
3534                    page_height,
3535                    coord_space,
3536                ))
3537            }
3538        })
3539        .collect();
3540
3541    let mut cells_by_row: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
3542    for (idx, cell) in cells.iter().enumerate() {
3543        cells_by_row.entry(cell.row).or_default().push(idx);
3544    }
3545
3546    let mut expansions: HashMap<usize, TsrRowExpansion> = HashMap::new();
3547    for (&row, row_cells) in &cells_by_row {
3548        let covered_by_rowspan = cells
3549            .iter()
3550            .any(|cell| cell.rowspan > 1 && cell.row <= row && row < cell.row + cell.rowspan);
3551        if covered_by_rowspan {
3552            continue;
3553        }
3554        if let Some(expansion) = build_tsr_row_expansion(row_cells, cells, &cell_lines) {
3555            expansions.insert(row, expansion);
3556        }
3557    }
3558
3559    if expansions.is_empty() {
3560        return None;
3561    }
3562
3563    let mut expanded = Vec::with_capacity(cells.len() + expansions.len());
3564    let mut row_shift = 0usize;
3565    for (&row, row_cells) in &cells_by_row {
3566        if let Some(expansion) = expansions.get(&row) {
3567            for (band_idx, band) in expansion.bands.iter().enumerate() {
3568                for &cell_idx in row_cells {
3569                    let mut cell = cells[cell_idx].clone();
3570                    cell.row = row + row_shift + band_idx;
3571                    cell.rowspan = 1;
3572                    cell.text = text_for_tsr_band(
3573                        &cell_lines[cell_idx],
3574                        *band,
3575                        expansion.tolerance,
3576                        adaptive_threshold,
3577                    );
3578                    cell.page_pt_bbox =
3579                        slice_cell_bbox_for_expanded_row(&cell, band_idx, expansion.bands.len());
3580                    expanded.push(cell);
3581                }
3582            }
3583            row_shift += expansion.bands.len() - 1;
3584        } else {
3585            for &cell_idx in row_cells {
3586                let mut cell = cells[cell_idx].clone();
3587                cell.row += row_shift;
3588                expanded.push(cell);
3589            }
3590        }
3591    }
3592
3593    let original_rows = cells
3594        .iter()
3595        .map(|cell| cell.row + cell.rowspan.max(1))
3596        .max()
3597        .unwrap_or(0);
3598    let expanded_rows = expanded
3599        .iter()
3600        .map(|cell| cell.row + cell.rowspan.max(1))
3601        .max()
3602        .unwrap_or(0);
3603    (expanded_rows > original_rows).then_some(expanded)
3604}
3605
3606/// Detect quality issues in the TSR-hybrid output for a single input.
3607///
3608/// Returns `Some(issue)` if the cells look like they reflect a known
3609/// SLANet detection pathology. Reasons (also used as metric labels):
3610///
3611/// * `phantom_empty_row` — a row whose every cell is empty, surrounded
3612///   above and below by rows with content. SLANet sometimes emits an
3613///   extra row that doesn't correspond to any visible PDF row.
3614/// * `multi_row_in_cell` — at least one non-label `rowspan==1` cell
3615///   encloses PDF text items that cluster into two distinct visual lines
3616///   separated by a whitespace gap larger than the line height. Cells
3617///   declared as `rowspan>1` are excluded since they are *expected*
3618///   to span multiple lines. First-row/first-column wraps are ignored
3619///   unless the in-place row expansion has enough support to repair them,
3620///   because those are often legitimate wrapped headers or row labels.
3621///   SLANet's row under-detection on tightly-packed tables produces the
3622///   rowspan==1-but-multi-line pattern (the FNBO failure mode).
3623fn detect_tsr_quality_issue(
3624    buffer: &[u8],
3625    input: &TsrTableInput,
3626    cells: &[tables::StructuredCell],
3627) -> Result<Option<TsrQualityIssue>, PdfError> {
3628    if cells.is_empty() {
3629        return Ok(None);
3630    }
3631
3632    // Phantom row: cheap, computed from cell metadata alone.
3633    let max_row = cells.iter().map(|c| c.row).max().unwrap_or(0);
3634    if max_row >= 2 {
3635        let mut row_has_content = vec![false; max_row + 1];
3636        for cell in cells {
3637            if !cell.text.trim().is_empty() {
3638                row_has_content[cell.row] = true;
3639            }
3640        }
3641        for r in 1..max_row {
3642            if !row_has_content[r] && row_has_content[r - 1] && row_has_content[r + 1] {
3643                return Ok(Some(TsrQualityIssue::PhantomEmptyRow));
3644            }
3645        }
3646    }
3647
3648    // Multi-row-in-cell: re-extract PDF text items in the page and look
3649    // for `rowspan==1` cells that contain items grouped into ≥2 visual
3650    // lines separated by a real whitespace gap. This is the FNBO mode:
3651    // a tall TSR cell catches text from two adjacent PDF rows that
3652    // SLANet failed to separate. Cells declared `rowspan>1` are
3653    // expected to be multi-line and are excluded.
3654    let (doc, _page_count) = load_document_from_mem(buffer)?;
3655    let pages = doc.get_pages();
3656    let page_1idx = input.page + 1;
3657    let Some(&page_id) = pages.get(&page_1idx) else {
3658        return Ok(None);
3659    };
3660    let mut needed: HashSet<u32> = HashSet::new();
3661    needed.insert(page_1idx);
3662    let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
3663    let extractor::PageBoxExtraction {
3664        mut items,
3665        coords_rotated,
3666        page_box,
3667        ..
3668    } = extractor::extract_page_text_items_in_page_box(
3669        &doc,
3670        page_id,
3671        page_1idx,
3672        &font_cmaps,
3673        false,
3674        &mut extractor::FontStyleCache::new(),
3675        &mut extractor::FormWalkBudget::new(),
3676    )?;
3677    let page_h = page_box.height();
3678    let adaptive_threshold = text_utils::fix_letterspaced_items(&mut items);
3679    let coords = RegionCoordSpace::from(coords_rotated);
3680    let expanded_cells =
3681        try_expand_multi_row_cells(cells, &items, page_h, coords, adaptive_threshold);
3682    let first_row = cells.iter().map(|cell| cell.row).min().unwrap_or(0);
3683    let first_col = cells.iter().map(|cell| cell.col).min().unwrap_or(0);
3684
3685    for cell in cells {
3686        // rowspan>1 cells are intentionally multi-line — skip them.
3687        if cell.rowspan > 1 {
3688            continue;
3689        }
3690        if cell.text.trim().is_empty() {
3691            continue;
3692        }
3693        let cell_items = collect_items_in_tsr_cell(&items, cell, page_h, coords);
3694        if cell_items.len() < 2 {
3695            continue;
3696        }
3697        if cluster_tsr_cell_text_lines(cell_items).len() < 2 {
3698            continue;
3699        }
3700        if expanded_cells.is_some() {
3701            return Ok(Some(TsrQualityIssue::MultiRowInCell { expanded_cells }));
3702        }
3703        if !is_wrapped_tsr_label_cell(cell, first_row, first_col) {
3704            return Ok(Some(TsrQualityIssue::MultiRowInCell {
3705                expanded_cells: None,
3706            }));
3707        }
3708    }
3709
3710    Ok(None)
3711}
3712
3713fn is_wrapped_tsr_label_cell(
3714    cell: &tables::StructuredCell,
3715    first_row: usize,
3716    first_col: usize,
3717) -> bool {
3718    cell.is_header || cell.row == first_row || cell.col == first_col
3719}
3720
3721/// Auto-fallback variant of [`extract_tables_with_structure_mem`]:
3722/// runs the TSR-hybrid path, checks the resulting cells for known
3723/// SLANet detection pathologies (phantom rows, multi-row-in-cell text),
3724/// and falls back to the heuristic [`extract_tables_in_regions_mem`]
3725/// for any input where the TSR path looks compromised.
3726///
3727/// On clean inputs this is identical to the markdown variant. On
3728/// `multi_row_in_cell`, the wrapper first tries to expand over-stuffed
3729/// rows in place; if that cannot produce a usable table, the heuristic
3730/// markdown replaces the TSR markdown and `fallback_reason` is set to
3731/// the diagnostic label.
3732///
3733/// Two failure modes are guarded against per-input:
3734///
3735/// * **Empty heuristic**: if the heuristic returns empty/whitespace
3736///   markdown for a flagged region, the original TSR markdown is
3737///   preserved and `fallback_reason` is suffixed with
3738///   `_heuristic_empty` (e.g. `multi_row_in_cell_heuristic_empty`).
3739///   This avoids replacing a usable wrong-but-non-empty TSR output
3740///   with literally nothing.
3741/// * **Expanded multi-row cells**: when in-place recovery succeeds, the
3742///   result is labeled `multi_row_in_cell_expanded` and the heuristic is
3743///   not consulted.
3744/// * **Per-input errors**: any failure in detection or heuristic
3745///   extraction for a single input is contained — that input
3746///   returns the raw TSR markdown with `fallback_reason` set to
3747///   an `_error` label so callers can metric on it. Other inputs
3748///   in the same batch are unaffected.
3749///
3750/// Use this from production callers that want self-healing output.
3751/// Use [`extract_tables_with_structure_mem`] when you want raw TSR
3752/// output regardless of quality (e.g. eval harnesses comparing the
3753/// two paths).
3754pub fn extract_tables_with_structure_auto_mem(
3755    buffer: &[u8],
3756    inputs: &[TsrTableInput],
3757) -> Result<Vec<TableExtractionResult>, PdfError> {
3758    let tsr_cells = extract_tables_with_structure_cells_mem(buffer, inputs)?;
3759    let mut results = Vec::with_capacity(inputs.len());
3760
3761    for (i, input) in inputs.iter().enumerate() {
3762        let cells = &tsr_cells[i];
3763        let tsr_md = if cells.is_empty() {
3764            String::new()
3765        } else {
3766            tables::cells_to_markdown(cells)
3767        };
3768
3769        let issue = match detect_tsr_quality_issue(buffer, input, cells) {
3770            Ok(opt) => opt,
3771            Err(_) => {
3772                // Detection failed for this input — fall through with
3773                // the raw TSR markdown so the rest of the batch is
3774                // unaffected. Tag the reason for caller metrics.
3775                results.push(TableExtractionResult {
3776                    markdown: tsr_md,
3777                    fallback_reason: Some("detection_error".to_string()),
3778                });
3779                continue;
3780            }
3781        };
3782
3783        let result = match issue {
3784            None => TableExtractionResult {
3785                markdown: tsr_md,
3786                fallback_reason: None,
3787            },
3788            Some(issue) => {
3789                let reason = issue.reason().to_string();
3790                if let TsrQualityIssue::MultiRowInCell {
3791                    expanded_cells: Some(expanded_cells),
3792                } = issue
3793                {
3794                    let expanded_md = tables::cells_to_markdown(&expanded_cells);
3795                    if !expanded_md.trim().is_empty() {
3796                        results.push(TableExtractionResult {
3797                            markdown: expanded_md,
3798                            fallback_reason: Some("multi_row_in_cell_expanded".to_string()),
3799                        });
3800                        continue;
3801                    }
3802                }
3803                // Fall back to heuristic on the input's table region.
3804                // The crop's PDF-pt bbox IS the table region.
3805                let heuristic_md = match extract_tables_in_regions_mem(
3806                    buffer,
3807                    &[(input.page, vec![input.crop_pdf_pt_bbox])],
3808                ) {
3809                    Ok(pages) => pages
3810                        .into_iter()
3811                        .next()
3812                        .and_then(|p| p.regions.into_iter().next().map(|r| r.text))
3813                        .unwrap_or_default(),
3814                    Err(_) => {
3815                        // Heuristic threw — keep raw TSR markdown.
3816                        results.push(TableExtractionResult {
3817                            markdown: tsr_md,
3818                            fallback_reason: Some(format!("{reason}_heuristic_error")),
3819                        });
3820                        continue;
3821                    }
3822                };
3823                if heuristic_md.trim().is_empty() {
3824                    // Heuristic produced nothing useful — keep TSR
3825                    // markdown rather than ship empty. The reason
3826                    // suffix lets callers count this case.
3827                    TableExtractionResult {
3828                        markdown: tsr_md,
3829                        fallback_reason: Some(format!("{reason}_heuristic_empty")),
3830                    }
3831                } else {
3832                    TableExtractionResult {
3833                        markdown: heuristic_md,
3834                        fallback_reason: Some(reason),
3835                    }
3836                }
3837            }
3838        };
3839        results.push(result);
3840    }
3841
3842    Ok(results)
3843}
3844
3845/// Coordinate frame the extracted items of a page live in: plain page
3846/// coordinates, or the frame turned by the rotated-page correction (see
3847/// `extractor::geometry::PageRotation`). Region boxes arrive in page
3848/// coordinates and are turned the same way before matching items.
3849#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3850enum RegionCoordSpace {
3851    Standard,
3852    /// Frame turned for a counter-clockwise page: `(x, y) → (y, -x)`.
3853    Rotated90Ccw,
3854    /// Frame turned for a clockwise page: `(x, y) → (-y, x)`.
3855    Rotated90Cw,
3856}
3857
3858impl From<extractor::geometry::PageRotation> for RegionCoordSpace {
3859    fn from(rotation: extractor::geometry::PageRotation) -> Self {
3860        use extractor::geometry::PageRotation;
3861        match rotation {
3862            PageRotation::Upright => RegionCoordSpace::Standard,
3863            PageRotation::Ccw => RegionCoordSpace::Rotated90Ccw,
3864            PageRotation::Cw => RegionCoordSpace::Rotated90Cw,
3865        }
3866    }
3867}
3868
3869/// A region rect in the sheet frame's top-left space: unchanged when the
3870/// caller gave it there, turned back from the rendered page when it came in
3871/// the display frame (`display` is the page's mapping in that case).
3872fn sheet_region_rect(rect: [f32; 4], display: Option<extractor::DisplayPage>) -> [f32; 4] {
3873    match display {
3874        Some(page) => page.region_to_sheet(rect),
3875        None => rect,
3876    }
3877}
3878
3879#[derive(Clone, Copy)]
3880struct RegionBounds {
3881    x_min: f32,
3882    y_min: f32,
3883    x_max: f32,
3884    y_max: f32,
3885}
3886
3887/// Collect text items that fall within a region bbox (top-left origin, PDF points)
3888/// and return them as a single string in reading order.
3889///
3890/// `page_height` is the height of the frame the items live in — the visible
3891/// page box height for items from [`extract_text_with_positions_mem`].
3892///
3893/// Items from a page whose text was predominantly rotated live in a turned
3894/// coordinate frame (see [`PageRotation`]). This legacy entry point infers
3895/// only the counter-clockwise turn, from most items sitting at negative y;
3896/// a clockwise turn is never inferred, because content drawn at negative
3897/// page x looks the same. Pass the page's frame explicitly with
3898/// [`collect_text_in_region_in_frame`] (frames come from
3899/// [`extract_text_with_positions_and_rotations_mem`]), or use
3900/// [`extract_text_in_regions_mem`], which handles both turns itself.
3901pub fn collect_text_in_region(
3902    items: &[TextItem],
3903    rx1: f32,
3904    ry1: f32,
3905    rx2: f32,
3906    ry2: f32,
3907    page_height: f32,
3908) -> String {
3909    collect_text_in_region_with_options(
3910        items,
3911        rx1,
3912        ry1,
3913        rx2,
3914        ry2,
3915        page_height,
3916        infer_region_coord_space(items),
3917        0.10,
3918    )
3919}
3920
3921/// [`collect_text_in_region`] with the page's coordinate frame given
3922/// explicitly instead of inferred: `rotation` is the turn
3923/// [`extract_text_with_positions_and_rotations_mem`] reported for the items'
3924/// page (`PageRotation::Upright` for pages absent from that map). The region
3925/// bbox stays in top-left page coordinates and is turned to match.
3926pub fn collect_text_in_region_in_frame(
3927    items: &[TextItem],
3928    rx1: f32,
3929    ry1: f32,
3930    rx2: f32,
3931    ry2: f32,
3932    page_height: f32,
3933    rotation: PageRotation,
3934) -> String {
3935    collect_text_in_region_with_options(
3936        items,
3937        rx1,
3938        ry1,
3939        rx2,
3940        ry2,
3941        page_height,
3942        RegionCoordSpace::from(rotation),
3943        0.10,
3944    )
3945}
3946
3947#[allow(clippy::too_many_arguments)]
3948fn collect_text_in_region_with_options(
3949    items: &[TextItem],
3950    rx1: f32,
3951    ry1: f32,
3952    rx2: f32,
3953    ry2: f32,
3954    page_height: f32,
3955    coord_space: RegionCoordSpace,
3956    adaptive_threshold: f32,
3957) -> String {
3958    let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
3959    let matched: Vec<TextItem> = items
3960        .iter()
3961        .filter(|item| region_overlaps_item(item, bounds))
3962        .cloned()
3963        .collect();
3964    collect_text_from_matched_items(matched, adaptive_threshold)
3965}
3966
3967#[cfg(test)]
3968#[allow(clippy::too_many_arguments)]
3969fn collect_text_in_tsr_cell(
3970    items: &[TextItem],
3971    rx1: f32,
3972    ry1: f32,
3973    rx2: f32,
3974    ry2: f32,
3975    page_height: f32,
3976    coord_space: RegionCoordSpace,
3977    adaptive_threshold: f32,
3978) -> String {
3979    let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
3980    let matched: Vec<TextItem> = items
3981        .iter()
3982        .filter(|item| tsr_region_contains_item(item, bounds))
3983        .cloned()
3984        .collect();
3985    collect_text_from_matched_items(matched, adaptive_threshold)
3986}
3987
3988fn collect_text_from_matched_items(matched: Vec<TextItem>, adaptive_threshold: f32) -> String {
3989    if matched.is_empty() {
3990        return String::new();
3991    }
3992
3993    // Simple extraction: the caller (fire-pdf) already handles reading order
3994    // and column splitting via the layout model. We just need to sort items
3995    // top-to-bottom, left-to-right and group into lines. Baselines go
3996    // through `line_y`, which snaps super/subscript glyph runs onto the body
3997    // baseline they are attached to: sorting raw `y` put every raised
3998    // affiliation marker of an author line ahead of the names, and the 3pt
3999    // window then emitted them as an orphan ",2,3,2,4,*" line.
4000    let mut sorted = matched;
4001    sorted.sort_by(|a, b| b.line_y().total_cmp(&a.line_y()).then(a.x.total_cmp(&b.x)));
4002    let region_rtl = text_utils::is_rtl_text(sorted.iter().map(|i| &i.text));
4003
4004    let y_tolerance = 3.0;
4005    let mut lines: Vec<extractor::TextLine> = Vec::new();
4006
4007    for item in sorted {
4008        let should_merge = lines.last().is_some_and(|last_line: &extractor::TextLine| {
4009            last_line.page == item.page && (last_line.y - item.line_y()).abs() < y_tolerance
4010        });
4011        if should_merge {
4012            lines.last_mut().unwrap().items.push(item);
4013        } else {
4014            let y = item.line_y();
4015            let page = item.page;
4016            lines.push(extractor::TextLine {
4017                items: vec![item],
4018                y,
4019                page,
4020                adaptive_threshold,
4021            });
4022        }
4023    }
4024
4025    // Sort items within each line by X position
4026    for line in &mut lines {
4027        text_utils::sort_line_items(&mut line.items, region_rtl);
4028    }
4029
4030    lines
4031        .into_iter()
4032        .map(|line| line.text())
4033        .collect::<Vec<_>>()
4034        .join("\n")
4035}
4036
4037fn infer_region_coord_space(items: &[TextItem]) -> RegionCoordSpace {
4038    // Rotated-page correction negates one axis: a counter-clockwise turn
4039    // maps y = -(old right edge), a clockwise turn maps x = -(old top edge),
4040    // so most items land at negative y or negative x respectively. Use this
4041    // to keep `collect_text_in_region` behavior compatible for direct
4042    // callers that do not have extractor metadata.
4043    // Legacy heuristic for callers without extractor metadata: a
4044    // counter-clockwise turn maps y = -(old right edge), so most items land
4045    // at negative y (the pre-existing rule, kept as is). A clockwise turn is
4046    // never inferred — its signature (negative x) cannot be told apart from
4047    // content drawn at negative page coordinates. Callers pass the frame
4048    // explicitly through `collect_text_in_region_in_frame` or use
4049    // `extract_text_in_regions_mem`, which carries the page rotation itself.
4050    let negative_y = items.iter().filter(|item| item.y < 0.0).count();
4051    if !items.is_empty() && negative_y * 2 >= items.len() {
4052        RegionCoordSpace::Rotated90Ccw
4053    } else {
4054        RegionCoordSpace::Standard
4055    }
4056}
4057
4058/// Convert a top-left-origin region into bounds in the item frame.
4059/// `page_height` is the visible page box height: items were shifted into
4060/// that box, so flipping `y` by its height lands regions on them.
4061fn region_bounds(
4062    rx1: f32,
4063    ry1: f32,
4064    rx2: f32,
4065    ry2: f32,
4066    page_height: f32,
4067    coord_space: RegionCoordSpace,
4068) -> RegionBounds {
4069    let tx_min = rx1.min(rx2);
4070    let tx_max = rx1.max(rx2);
4071    let ty_min = ry1.min(ry2);
4072    let ty_max = ry1.max(ry2);
4073    let by_min = page_height - ty_max;
4074    let by_max = page_height - ty_min;
4075    match coord_space {
4076        RegionCoordSpace::Standard => RegionBounds {
4077            x_min: tx_min,
4078            y_min: by_min,
4079            x_max: tx_max,
4080            y_max: by_max,
4081        },
4082        RegionCoordSpace::Rotated90Ccw => RegionBounds {
4083            x_min: by_min,
4084            x_max: by_max,
4085            y_min: -tx_max,
4086            y_max: -tx_min,
4087        },
4088        RegionCoordSpace::Rotated90Cw => RegionBounds {
4089            x_min: -by_max,
4090            x_max: -by_min,
4091            y_min: tx_min,
4092            y_max: tx_max,
4093        },
4094    }
4095}
4096
4097/// Inclusion margin shared by the region/item overlap predicates and the
4098/// exclusive-assignment area score — these MUST stay in sync: an item that
4099/// passes the boolean guard must always have positive overlap area.
4100const REGION_MARGIN: f32 = 1.5;
4101
4102/// Overlap area between an item and region bounds (same margin as the
4103/// boolean test) — the exclusive-assignment score.
4104fn region_item_overlap_area(item: &TextItem, bounds: RegionBounds) -> f32 {
4105    let item_x_max = item.x + text_utils::effective_width(item);
4106    let item_y_max = item.y + text_utils::effective_height(item);
4107    let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
4108        - item.x.max(bounds.x_min - REGION_MARGIN))
4109    .max(0.0);
4110    let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
4111        - item.y.max(bounds.y_min - REGION_MARGIN))
4112    .max(0.0);
4113    x_overlap * y_overlap
4114}
4115
4116fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
4117    let item_x_min = item.x;
4118    let item_x_max = item.x + text_utils::effective_width(item);
4119    let item_y_min = item.y;
4120    let item_y_max = item.y + text_utils::effective_height(item);
4121
4122    let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
4123        - item_x_min.max(bounds.x_min - REGION_MARGIN))
4124    .max(0.0);
4125    let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
4126        - item_y_min.max(bounds.y_min - REGION_MARGIN))
4127    .max(0.0);
4128    x_overlap > 0.0 && y_overlap > 0.0
4129}
4130
4131fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
4132    let (x_min, y_min, x_max, y_max) = normalized_rect_edges(rect);
4133    ranges_overlap(
4134        x_min,
4135        x_max,
4136        bounds.x_min - REGION_MARGIN,
4137        bounds.x_max + REGION_MARGIN,
4138    ) && ranges_overlap(
4139        y_min,
4140        y_max,
4141        bounds.y_min - REGION_MARGIN,
4142        bounds.y_max + REGION_MARGIN,
4143    )
4144}
4145
4146fn region_overlaps_line(line: &PdfLine, bounds: RegionBounds) -> bool {
4147    let x_min = line.x1.min(line.x2);
4148    let x_max = line.x1.max(line.x2);
4149    let y_min = line.y1.min(line.y2);
4150    let y_max = line.y1.max(line.y2);
4151    ranges_overlap(
4152        x_min,
4153        x_max,
4154        bounds.x_min - REGION_MARGIN,
4155        bounds.x_max + REGION_MARGIN,
4156    ) && ranges_overlap(
4157        y_min,
4158        y_max,
4159        bounds.y_min - REGION_MARGIN,
4160        bounds.y_max + REGION_MARGIN,
4161    )
4162}
4163
4164fn ranges_overlap(a_min: f32, a_max: f32, b_min: f32, b_max: f32) -> bool {
4165    a_max >= b_min && b_max >= a_min
4166}
4167
4168fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
4169    let item_x_min = item.x;
4170    let item_x_max = item.x + text_utils::effective_width(item);
4171    let item_y_min = item.y;
4172    let item_y_max = item.y + text_utils::effective_height(item);
4173
4174    let center_x = (item_x_min + item_x_max) * 0.5;
4175    let center_y = (item_y_min + item_y_max) * 0.5;
4176    if center_x >= bounds.x_min
4177        && center_x <= bounds.x_max
4178        && center_y >= bounds.y_min
4179        && center_y <= bounds.y_max
4180    {
4181        return true;
4182    }
4183
4184    let x_overlap = (item_x_max.min(bounds.x_max) - item_x_min.max(bounds.x_min)).max(0.0);
4185    let y_overlap = (item_y_max.min(bounds.y_max) - item_y_min.max(bounds.y_min)).max(0.0);
4186    let item_width = (item_x_max - item_x_min).max(0.1);
4187    let item_height = (item_y_max - item_y_min).max(0.1);
4188
4189    x_overlap / item_width >= 0.6 && y_overlap / item_height >= 0.6
4190}
4191
4192// =========================================================================
4193// Internal: single-load document pipeline
4194// =========================================================================
4195
4196/// Load a PDF from disk, returning the parsed document and page count.
4197///
4198/// `Document::load_metadata` for page count + `Document::load` for content
4199/// are combined here, but lopdf loads the full doc in `load()` so we extract
4200/// page count from it directly to avoid the metadata-only round-trip.
4201pub(crate) fn load_document_from_path<P: AsRef<Path>>(
4202    path: P,
4203) -> Result<(Document, u32), PdfError> {
4204    load_document_from_path_with_password(path, None)
4205}
4206
4207/// Load a PDF file, decrypting with `password` if the file is encrypted.
4208pub(crate) fn load_document_from_path_with_password<P: AsRef<Path>>(
4209    path: P,
4210    password: Option<&str>,
4211) -> Result<(Document, u32), PdfError> {
4212    let buffer = read_file(path.as_ref())?;
4213    load_document_from_mem_with_password(&buffer, password)
4214}
4215
4216/// Load a PDF from a memory buffer.
4217pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
4218    load_document_from_mem_with_password(buffer, None)
4219}
4220
4221/// Load a PDF from a memory buffer, decrypting with `password` if encrypted.
4222pub(crate) fn load_document_from_mem_with_password(
4223    buffer: &[u8],
4224    password: Option<&str>,
4225) -> Result<(Document, u32), PdfError> {
4226    load_document_from_mem_with_repairs(buffer, password)
4227        .map(|(doc, page_count, _)| (doc, page_count))
4228}
4229
4230/// Repairs applied to a document's objects once it is loaded, beyond the
4231/// container repairs the loader tries when a file does not parse.
4232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4233pub(crate) struct LoadRepairs {
4234    /// Form XObjects whose zero-area `/BBox` was widened
4235    /// (see `form_bbox_repair`).
4236    pub(crate) widened_form_bboxes: usize,
4237    /// `/BBox` numerals too large for any parser, saturated in the file's
4238    /// bytes before it was read (see `overlong_numerals`).
4239    pub(crate) saturated_bbox_numerals: usize,
4240}
4241
4242impl LoadRepairs {
4243    /// Whether a Form XObject was repaired: a renderer given the original
4244    /// bytes would lose it again.
4245    pub(crate) fn repaired_forms(&self) -> bool {
4246        self.widened_form_bboxes > 0 || self.saturated_bbox_numerals > 0
4247    }
4248}
4249
4250/// [`load_document_from_mem_with_password`], also reporting the repairs
4251/// applied to the loaded objects.
4252pub(crate) fn load_document_from_mem_with_repairs(
4253    buffer: &[u8],
4254    password: Option<&str>,
4255) -> Result<(Document, u32, LoadRepairs), PdfError> {
4256    // Drop anything before the `%PDF-` header. Cross-reference offsets are
4257    // header-relative in every major reader (mupdf, pdfium, poppler, pdf.js;
4258    // lopdf slices at `%PDF-` internally as well), so this keeps them exact
4259    // for lopdf and for the repair candidates below. A file whose offsets are
4260    // off by a constant anyway — they count the leading bytes, or a
4261    // version-like mention in the leading bytes was taken for the header — is
4262    // recovered by lopdf's cross-reference reconstruction, the same path every
4263    // reader takes for it.
4264    let buffer = strip_leading_bytes_before_header(buffer);
4265
4266    // Fix malformed struct element names before parsing. Some PDF generators
4267    // write bare names (/S Code) instead of proper PDF names (/S /Code), which
4268    // causes lopdf to silently drop the entire object.
4269    let fixed = structure_tree::fix_bare_struct_names(buffer);
4270    let buf = fixed.as_ref();
4271
4272    match load_document_bytes(buf, password) {
4273        Ok(doc) => {
4274            let (doc, saturated) = reload_after_saturating_bbox_numerals(doc, buf, password);
4275            finish_loaded_document(doc, saturated)
4276        }
4277        Err(first_err) => {
4278            for repaired in repair_pdf_container_candidates(buf) {
4279                match load_document_bytes(&repaired, password) {
4280                    Ok(doc) => {
4281                        log::debug!("loaded PDF after repairing malformed container bytes");
4282                        let (doc, saturated) =
4283                            reload_after_saturating_bbox_numerals(doc, &repaired, password);
4284                        return finish_loaded_document(doc, saturated);
4285                    }
4286                    Err(e) => {
4287                        if is_encrypted_lopdf_error(&e) {
4288                            return Err(e.into());
4289                        }
4290                    }
4291                }
4292            }
4293            Err(first_err.into())
4294        }
4295    }
4296}
4297
4298/// The document loaded again from `bytes` with the `/BBox` numerals of its
4299/// unloaded objects saturated (see `overlong_numerals`), when that brings
4300/// objects in — otherwise `doc` as it came — and then with the `/BBox`
4301/// arrays its object streams could not yield recovered into it; with the
4302/// count of numerals saturated either way.
4303fn reload_after_saturating_bbox_numerals(
4304    doc: Document,
4305    bytes: &[u8],
4306    password: Option<&str>,
4307) -> (Document, usize) {
4308    let (mut doc, count) = match overlong_numerals::saturate_overlong_bbox_numerals(bytes, &doc) {
4309        Some((rewritten, count)) => match load_document_bytes(&rewritten, password) {
4310            Ok(reloaded) if reloaded.objects.len() > doc.objects.len() => {
4311                log::debug!("loaded PDF after saturating {count} /BBox numeral(s) no parser holds");
4312                (reloaded, count)
4313            }
4314            _ => (doc, 0),
4315        },
4316        None => (doc, 0),
4317    };
4318    let in_object_streams =
4319        overlong_numerals::recover_referenced_bboxes_in_object_streams(&mut doc);
4320    if in_object_streams > 0 {
4321        log::debug!(
4322            "recovered /BBox array(s) from object streams after saturating {in_object_streams} \
4323             numeral(s) no parser holds"
4324        );
4325    }
4326    (doc, count + in_object_streams)
4327}
4328
4329/// A loaded document with zero pages is unusable by every caller, and with
4330/// the decompression bound in place it can also mean the page tree lived in
4331/// an object stream lopdf skipped for exceeding the bound. Fail the load
4332/// either way rather than letting a pageless document masquerade as a
4333/// successful parse.
4334fn finish_loaded_document(
4335    mut doc: Document,
4336    saturated_bbox_numerals: usize,
4337) -> Result<(Document, u32, LoadRepairs), PdfError> {
4338    let page_count = doc.get_pages().len() as u32;
4339    if page_count == 0 {
4340        return Err(PdfError::Parse(
4341            "document has no readable pages".to_string(),
4342        ));
4343    }
4344    // lopdf drops the contents of object streams it could not expand (over
4345    // the decompression bound, or unparseable) without any signal on the
4346    // returned document. The only trace either loader path leaves is a
4347    // deficit between the xref size (`max_id`) and the objects actually
4348    // loaded — which free xref entries also contribute to, so this is a
4349    // heuristic: surface large deficits for diagnosability, worded so a
4350    // legitimately sparse xref isn't reported as data loss. The document is
4351    // still usable either way; unloaded objects resolve as not-found.
4352    let expected = doc.max_id as usize;
4353    let loaded = doc.objects.len();
4354    let missing = expected.saturating_sub(loaded);
4355    if missing > 1000 && missing.saturating_mul(64) > expected {
4356        log::warn!(
4357            "loaded {loaded} of {expected} xref object slots; the rest are free \
4358             xref entries or object streams skipped over the \
4359             {MAX_STREAM_DECOMPRESSED_BYTES}-byte decompression bound, and will \
4360             resolve as not-found"
4361        );
4362    }
4363    let repairs = LoadRepairs {
4364        widened_form_bboxes: form_bbox_repair::widen_degenerate_form_bboxes(&mut doc),
4365        saturated_bbox_numerals,
4366    };
4367    Ok((doc, page_count, repairs))
4368}
4369
4370/// Per-stream decompression budget applied while loading (object streams and
4371/// xref streams), and the bound within which the detector reads a font's
4372/// ToUnicode CMap (`detector::font_decoder`), object streams are read again
4373/// for overlong numerals (`overlong_numerals`), and a CID font's embedded
4374/// program is read to tell whether its subset was renumbered
4375/// (`tounicode::try_remap_subset_cmap`): none of those streams costs more
4376/// than a stream the loader materializes, and changing the bound changes
4377/// them all. Some tagged PDFs pack their structure tree into object
4378/// streams that inflate to hundreds of MB each from a ~20MB file; lopdf
4379/// materializes every object stream eagerly at load, so without a bound one
4380/// such document exhausts memory before any of our code runs. lopdf skips an
4381/// object stream that would exceed the bound (its objects resolve as
4382/// not-found), which the zero-page check in `load_document_from_mem_with_password`
4383/// turns into a load error instead of a silently wrong answer.
4384pub(crate) const MAX_STREAM_DECOMPRESSED_BYTES: usize = 8 * 1024 * 1024;
4385
4386fn bounded_load_options() -> lopdf::LoadOptions {
4387    lopdf::LoadOptions {
4388        max_decompressed_size: Some(MAX_STREAM_DECOMPRESSED_BYTES),
4389        ..Default::default()
4390    }
4391}
4392
4393fn load_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
4394    match Document::load_mem_with_options(buf, bounded_load_options()) {
4395        // Some encrypted PDFs load structurally but leave their streams
4396        // encrypted (`is_encrypted()` stays true); reading them yields garbage
4397        // until we re-load with a password. Others fail load_mem outright with
4398        // an encryption error. Handle both by re-loading with the password.
4399        Ok(doc) if doc.is_encrypted() => decrypt_document_bytes(buf, password),
4400        Ok(doc) => Ok(doc),
4401        Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
4402        Err(e) => Err(e),
4403    }
4404}
4405
4406/// Re-load an encrypted PDF, decrypting with `password`. Falls back to the
4407/// empty password (owner-only encryption, the common "protected" case) when a
4408/// non-empty password was supplied but rejected.
4409fn decrypt_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
4410    let pw = password.unwrap_or("");
4411    let with_password = |pw: &str| lopdf::LoadOptions {
4412        password: Some(pw.to_string()),
4413        ..bounded_load_options()
4414    };
4415    match Document::load_mem_with_options(buf, with_password(pw)) {
4416        Ok(doc) => Ok(doc),
4417        Err(inner) if !pw.is_empty() => {
4418            Document::load_mem_with_options(buf, with_password("")).map_err(|_| inner)
4419        }
4420        Err(inner) => Err(inner),
4421    }
4422}
4423
4424fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
4425    let mut candidates = Vec::new();
4426
4427    add_repair_candidate(&mut candidates, append_missing_eof_marker(buf), buf);
4428    add_repair_candidate(&mut candidates, recover_startxref_pointer(buf), buf);
4429    add_repair_candidate(
4430        &mut candidates,
4431        xref_repair::rebuild_short_xref_entries(buf),
4432        buf,
4433    );
4434
4435    candidates
4436}
4437
4438/// Some PDF writers emit a `startxref` pointer that doesn't actually point
4439/// at the cross-reference table — a single corrupted byte in the offset is
4440/// enough. lopdf trusts that pointer outright and fails to load rather than
4441/// searching for the real table, unlike pypdf/pdfium which both recover by
4442/// locating it directly. This finds the real (classic, non-stream) `xref`
4443/// table by scanning for the keyword — validating that a plausible
4444/// subsection header follows, not just any standalone "xref" token, since
4445/// this crate processes untrusted input and a coincidental match inside
4446/// unrelated stream/string content must not get "repaired" against a bogus
4447/// offset (lopdf would then load successfully against garbage instead of
4448/// returning a clean error) — and appends a corrected trailing
4449/// `startxref`/`%%EOF` block. lopdf's own `get_xref_start` always uses the
4450/// *last* `%%EOF` in the final 512 bytes of the buffer, so ours
4451/// transparently supersedes the broken one without needing to touch
4452/// anything already in the file.
4453///
4454/// Doesn't cover cross-reference *streams* (`N 0 obj << /Type /XRef ...`,
4455/// used by some PDF 1.5+ writers instead of a classic table) — recovering
4456/// those needs the containing object's number, not just a byte offset.
4457fn recover_startxref_pointer(buf: &[u8]) -> Option<Vec<u8>> {
4458    let xref_pos = find_last_valid_xref_table_start(buf)?;
4459
4460    let mut repaired = Vec::with_capacity(buf.len() + 32);
4461    repaired.extend_from_slice(buf);
4462    if !repaired.ends_with(b"\n") {
4463        repaired.push(b'\n');
4464    }
4465    repaired.extend_from_slice(format!("startxref\n{xref_pos}\n%%EOF\n").as_bytes());
4466    Some(repaired)
4467}
4468
4469/// Finds the last standalone `xref` token in `buf` that is immediately
4470/// followed by a plausible classic cross-reference subsection header
4471/// (`<start-id> <count>`, e.g. "0 6") — the shape every real classic xref
4472/// table starts with. A single reverse byte scan: O(n) even on a
4473/// pathological buffer with many non-matching or non-standalone "xref"
4474/// occurrences, unlike repeatedly re-searching a shrinking prefix.
4475fn find_last_valid_xref_table_start(buf: &[u8]) -> Option<usize> {
4476    const KEYWORD: &[u8] = b"xref";
4477    if buf.len() < KEYWORD.len() {
4478        return None;
4479    }
4480    let mut pos = buf.len() - KEYWORD.len();
4481    loop {
4482        if &buf[pos..pos + KEYWORD.len()] == KEYWORD {
4483            let before_ok = pos == 0 || buf[pos - 1].is_ascii_whitespace();
4484            let after_ok = buf
4485                .get(pos + KEYWORD.len())
4486                .is_none_or(|c| c.is_ascii_whitespace());
4487            if before_ok && after_ok && looks_like_xref_subsection_header(buf, pos + KEYWORD.len())
4488            {
4489                return Some(pos);
4490            }
4491        }
4492        if pos == 0 {
4493            return None;
4494        }
4495        pos -= 1;
4496    }
4497}
4498
4499/// Checks that `buf[pos..]` starts (after whitespace) with two
4500/// whitespace-separated runs of ASCII digits — `<start-id> <count>`, the
4501/// first subsection header of a classic PDF cross-reference table.
4502fn looks_like_xref_subsection_header(buf: &[u8], pos: usize) -> bool {
4503    fn skip_ws(buf: &[u8], mut pos: usize) -> usize {
4504        while buf.get(pos).is_some_and(u8::is_ascii_whitespace) {
4505            pos += 1;
4506        }
4507        pos
4508    }
4509    fn skip_digits(buf: &[u8], mut pos: usize) -> usize {
4510        while buf.get(pos).is_some_and(u8::is_ascii_digit) {
4511            pos += 1;
4512        }
4513        pos
4514    }
4515
4516    let pos = skip_ws(buf, pos);
4517    let after_first_digits = skip_digits(buf, pos);
4518    if after_first_digits == pos {
4519        return false; // no start-id
4520    }
4521    let sep = skip_ws(buf, after_first_digits);
4522    if sep == after_first_digits {
4523        return false; // start-id and count must be whitespace-separated
4524    }
4525    let after_count = skip_digits(buf, sep);
4526    if after_count == sep {
4527        return false; // no count
4528    }
4529    // The count run must end at whitespace/buffer-end, not run into trailing
4530    // garbage (e.g. a coincidental "xref\n0 6garbage" in stream content).
4531    buf.get(after_count).is_none_or(u8::is_ascii_whitespace)
4532}
4533
4534fn add_repair_candidate(
4535    candidates: &mut Vec<Vec<u8>>,
4536    candidate: Option<Vec<u8>>,
4537    original: &[u8],
4538) {
4539    let Some(candidate) = candidate else {
4540        return;
4541    };
4542    if candidate.as_slice() == original {
4543        return;
4544    }
4545    if candidates.iter().any(|existing| existing == &candidate) {
4546        return;
4547    }
4548    candidates.push(candidate);
4549}
4550
4551fn append_missing_eof_marker(buf: &[u8]) -> Option<Vec<u8>> {
4552    if contains_recent_eof_marker(buf) {
4553        return None;
4554    }
4555
4556    let mut end = buf.len();
4557    while end > 0 && buf[end - 1].is_ascii_whitespace() {
4558        end -= 1;
4559    }
4560
4561    if !buf[..end].ends_with(b"%%EO") {
4562        return None;
4563    }
4564
4565    let mut repaired = Vec::with_capacity(end + 2);
4566    repaired.extend_from_slice(&buf[..end]);
4567    repaired.extend_from_slice(b"F\n");
4568    Some(repaired)
4569}
4570
4571fn contains_recent_eof_marker(buf: &[u8]) -> bool {
4572    let start = buf.len().saturating_sub(1024);
4573    buf[start..].windows(b"%%EOF".len()).any(|w| w == b"%%EOF")
4574}
4575
4576/// Core processing pipeline operating on a pre-loaded document.
4577fn process_document(
4578    doc: Document,
4579    page_count: u32,
4580    options: PdfOptions,
4581    start: ProcessingTimer,
4582) -> Result<PdfProcessResult, PdfError> {
4583    // Step 1 — Detection (cheap: scans content streams for text operators)
4584    let detection = detector::detect_from_document(&doc, page_count, &options.detection)?;
4585    let pdf_type = detection.pdf_type;
4586    let pages_needing_ocr = detection.pages_needing_ocr;
4587    let title = detection.title;
4588    let author = detection.author;
4589    let subject = detection.subject;
4590    let keywords = detection.keywords;
4591    let creator = detection.creator;
4592    let producer = detection.producer;
4593    let creation_date = detection.creation_date;
4594    let mod_date = detection.mod_date;
4595    let confidence = detection.confidence;
4596    let detection_ocr_reasons = detection.ocr_reasons_by_page;
4597
4598    // DetectOnly → return immediately
4599    if options.mode == ProcessMode::DetectOnly {
4600        return Ok(PdfProcessResult {
4601            pdf_type,
4602            markdown: None,
4603            page_count,
4604            processing_time_ms: start.elapsed_ms(),
4605            pages_needing_ocr,
4606            ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
4607            title,
4608            author,
4609            subject,
4610            keywords,
4611            creator,
4612            producer,
4613            creation_date,
4614            mod_date,
4615            confidence,
4616            layout: LayoutComplexity::default(),
4617            has_encoding_issues: false,
4618            cmap_gaps: Vec::new(),
4619        });
4620    }
4621
4622    // Scanned / ImageBased → nothing to extract
4623    if matches!(pdf_type, PdfType::Scanned | PdfType::ImageBased) {
4624        return Ok(PdfProcessResult {
4625            pdf_type,
4626            markdown: None,
4627            page_count,
4628            processing_time_ms: start.elapsed_ms(),
4629            pages_needing_ocr,
4630            ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
4631            title,
4632            author,
4633            subject,
4634            keywords,
4635            creator,
4636            producer,
4637            creation_date,
4638            mod_date,
4639            confidence,
4640            layout: LayoutComplexity::default(),
4641            has_encoding_issues: false,
4642            cmap_gaps: Vec::new(),
4643        });
4644    }
4645
4646    // Step 2 — Extraction (reuses the already-loaded document)
4647    let extracted = {
4648        let font_cmaps = FontCMaps::from_doc(&doc);
4649        // Most page-filtered requests extract only the selected pages. Gather
4650        // other pages only when a selected contextual folio needs cross-page
4651        // evidence; failures on those context-only pages are non-fatal.
4652        let result = extractor::extract_positioned_text_with_folio_context(
4653            &doc,
4654            &font_cmaps,
4655            options.page_filter.as_ref(),
4656        );
4657
4658        // For Mixed/template PDFs: if normal extraction produces garbage text
4659        // (mostly non-alphanumeric), retry with invisible (Tr=3) text included.
4660        // This unlocks OCR text layers behind scanned images.
4661        if pdf_type == PdfType::Mixed {
4662            if let Ok((ref items, _, _)) = result.as_ref().map(|(e, _, _, _, _)| e) {
4663                let sample: String = items
4664                    .iter()
4665                    .filter(|item| {
4666                        options
4667                            .page_filter
4668                            .as_ref()
4669                            .is_none_or(|filter| filter.contains(&item.page))
4670                    })
4671                    .take(200)
4672                    .map(|item| item.text.as_str())
4673                    .collect();
4674                if is_garbage_text(&sample) || sample.trim().is_empty() {
4675                    extractor::extract_positioned_text_include_invisible_with_folio_context(
4676                        &doc,
4677                        &font_cmaps,
4678                        options.page_filter.as_ref(),
4679                    )
4680                } else {
4681                    result
4682                }
4683            } else {
4684                // Normal extraction failed — try invisible as fallback
4685                extractor::extract_positioned_text_include_invisible_with_folio_context(
4686                    &doc,
4687                    &font_cmaps,
4688                    options.page_filter.as_ref(),
4689                )
4690            }
4691        } else {
4692            result
4693        }
4694    };
4695
4696    // For Mixed PDFs, extraction failure is non-fatal
4697    let extracted = if pdf_type == PdfType::Mixed {
4698        extracted.ok()
4699    } else {
4700        Some(extracted?)
4701    };
4702
4703    // Parse structure tree for tagged PDFs (reuses the loaded document)
4704    let (struct_roles, struct_tables) = structure_tree::StructTree::from_doc(&doc)
4705        .map(|tree| {
4706            let page_ids = doc.get_pages();
4707            let roles = tree.mcid_to_roles(&page_ids);
4708            let tables = tree.extract_tables(&page_ids);
4709            if !roles.is_empty() {
4710                log::debug!(
4711                    "structure tree: {} pages with MCID roles, {} total MCIDs, {} tagged tables",
4712                    roles.len(),
4713                    tree.mcid_count(),
4714                    tables.len()
4715                );
4716            }
4717            let roles = if roles.is_empty() { None } else { Some(roles) };
4718            (roles, tables)
4719        })
4720        .unwrap_or((None, Vec::new()));
4721
4722    let (
4723        markdown,
4724        layout,
4725        has_encoding_issues,
4726        gid_pages,
4727        text_quality_pages,
4728        text_quality_reasons_by_page,
4729        cmap_gaps,
4730    ) = match extracted {
4731        Some((
4732            (items, rects, lines),
4733            page_thresholds,
4734            gid_encoded_pages,
4735            _page_rotations,
4736            cmap_coverage,
4737        )) => {
4738            let mut ocr_reasons_by_page = BTreeMap::new();
4739
4740            // For TextBased PDFs with pages flagged for OCR (Identity-H or
4741            // Type3 fonts without ToUnicode), check whether the CID-as-Unicode
4742            // passthrough actually produced readable text.  If a page's text
4743            // is garbage, strip its items so we don't emit mojibake.
4744            // Only applies to TextBased — for Mixed PDFs, OCR flags come from
4745            // template images rather than font encoding issues.
4746            let (items, rects, lines) =
4747                if pages_needing_ocr.is_empty() || pdf_type != PdfType::TextBased {
4748                    (items, rects, lines)
4749                } else {
4750                    let ocr_set: std::collections::HashSet<u32> =
4751                        pages_needing_ocr.iter().copied().collect();
4752                    // Collect text per OCR-flagged page and check quality
4753                    let mut garbage_pages: std::collections::HashSet<u32> =
4754                        std::collections::HashSet::new();
4755                    for &pg in &ocr_set {
4756                        if options
4757                            .page_filter
4758                            .as_ref()
4759                            .is_some_and(|filter| !filter.contains(&pg))
4760                        {
4761                            continue;
4762                        }
4763                        let page_text: String = items
4764                            .iter()
4765                            .filter(|i| i.page == pg)
4766                            .map(|i| i.text.as_str())
4767                            .collect();
4768                        if is_cid_garbage(&page_text) {
4769                            garbage_pages.insert(pg);
4770                        }
4771                    }
4772                    if garbage_pages.is_empty() {
4773                        (items, rects, lines)
4774                    } else {
4775                        log::debug!(
4776                            "suppressing garbage text from OCR-flagged pages: {:?}",
4777                            garbage_pages
4778                        );
4779                        for page in &garbage_pages {
4780                            add_ocr_reason(
4781                                &mut ocr_reasons_by_page,
4782                                *page,
4783                                OCR_REASON_SUSPECTED_GARBLED_TEXT,
4784                            );
4785                        }
4786                        let items: Vec<_> = items
4787                            .into_iter()
4788                            .filter(|i| !garbage_pages.contains(&i.page))
4789                            .collect();
4790                        let rects: Vec<_> = rects
4791                            .into_iter()
4792                            .filter(|r| !garbage_pages.contains(&r.page))
4793                            .collect();
4794                        let lines: Vec<_> = lines
4795                            .into_iter()
4796                            .filter(|l| !garbage_pages.contains(&l.page))
4797                            .collect();
4798                        (items, rects, lines)
4799                    }
4800                };
4801
4802            let selected_page = |page: u32| {
4803                options
4804                    .page_filter
4805                    .as_ref()
4806                    .is_none_or(|filter| filter.contains(&page))
4807            };
4808            let rects: Vec<_> = rects
4809                .into_iter()
4810                .filter(|rect| selected_page(rect.page))
4811                .collect();
4812            let lines: Vec<_> = lines
4813                .into_iter()
4814                .filter(|line| selected_page(line.page))
4815                .collect();
4816            let gid_encoded_pages: HashSet<_> = gid_encoded_pages
4817                .into_iter()
4818                .filter(|page| selected_page(*page))
4819                .collect();
4820            let FolioFilteredItems {
4821                items,
4822                layout_items,
4823                removal_mask,
4824                removed_pages,
4825            } = select_items_with_document_folio_context(
4826                items,
4827                page_count,
4828                options.page_filter.as_ref(),
4829            );
4830
4831            let text_quality = analyze_text_quality(&items);
4832            merge_ocr_reasons(&mut ocr_reasons_by_page, text_quality.reasons_by_page);
4833            let chart_regions = markdown::chart_regions_by_page(&items, &rects, &lines);
4834            let layout = compute_layout_complexity_with_chart_regions(
4835                &items,
4836                &layout_items,
4837                &rects,
4838                &lines,
4839                &chart_regions,
4840            );
4841
4842            let md = if options.mode == ProcessMode::Analyze {
4843                None
4844            } else {
4845                Some(markdown::to_markdown_from_items_with_rects_and_lines(
4846                    items,
4847                    options.markdown,
4848                    &rects,
4849                    &lines,
4850                    markdown::MarkdownDocumentContext {
4851                        page_thresholds: &page_thresholds,
4852                        struct_roles: struct_roles.as_ref(),
4853                        struct_tables: &struct_tables,
4854                        page_count,
4855                        prefiltered_page_number_pages: Some(&removed_pages),
4856                        prefiltered_page_number_mask: Some(removal_mask.as_slice()),
4857                        precomputed_chart_regions: Some(&chart_regions),
4858                    },
4859                ))
4860            };
4861
4862            // A code no CMap could read is an encoding issue whether or not
4863            // the Markdown that would show its U+FFFD is generated in this
4864            // mode; a gap read from its neighbours is not one.
4865            let cmap_unmapped = cmap_coverage.values().any(|stats| stats.unmapped > 0);
4866            let enc = !ocr_reasons_by_page.is_empty()
4867                || text_quality.has_encoding_issues
4868                || cmap_unmapped
4869                || md.as_ref().is_some_and(|m| detect_encoding_issues(m));
4870            (
4871                md,
4872                layout,
4873                enc,
4874                gid_encoded_pages,
4875                text_quality.pages_needing_ocr,
4876                ocr_reasons_by_page,
4877                font_cmap_gaps(cmap_coverage),
4878            )
4879        }
4880        None => (
4881            None,
4882            LayoutComplexity::default(),
4883            false,
4884            std::collections::HashSet::new(),
4885            Vec::new(),
4886            BTreeMap::new(),
4887            Vec::new(),
4888        ),
4889    };
4890
4891    // If the extracted text is predominantly garbage (non-alphanumeric) and
4892    // the PDF is image-backed (Mixed/template), upgrade to Scanned — the text
4893    // layer comes from a bad OCR pass, and callers should use proper OCR.
4894    let (pdf_type, markdown, confidence) =
4895        if pdf_type == PdfType::Mixed && markdown.as_ref().is_some_and(|m| is_garbage_text(m)) {
4896            (PdfType::Scanned, None, 0.95)
4897        } else {
4898            (pdf_type, markdown, confidence)
4899        };
4900
4901    // If a TextBased PDF produces garbage text, the fonts are undecodable
4902    // (e.g. Identity-H without ToUnicode for non-Latin scripts like Cyrillic).
4903    // Drop the useless markdown and flag all pages for OCR.
4904    let (markdown, has_encoding_issues, force_ocr_all) = if pdf_type == PdfType::TextBased
4905        && markdown.as_ref().is_some_and(|m| is_garbage_text(m))
4906    {
4907        log::debug!("TextBased PDF has garbage text — flagging all pages for OCR");
4908        (None, true, true)
4909    } else {
4910        (markdown, has_encoding_issues, false)
4911    };
4912
4913    // Add pages with gid-encoded fonts (unresolvable encoding) to OCR list.
4914    // When ALL pages have gid-encoded fonts, suppress unreliable markdown.
4915    let all_gid = !gid_pages.is_empty() && gid_pages.len() as u32 >= page_count;
4916    let mut pages_needing_ocr = pages_needing_ocr;
4917    if force_ocr_all {
4918        pages_needing_ocr = (1..=page_count).collect();
4919    }
4920    if !gid_pages.is_empty() {
4921        log::debug!("pages with gid-encoded fonts (need OCR): {:?}", gid_pages);
4922        for page in gid_pages {
4923            if !pages_needing_ocr.contains(&page) {
4924                pages_needing_ocr.push(page);
4925            }
4926        }
4927        pages_needing_ocr.sort_unstable();
4928    }
4929    if !text_quality_pages.is_empty() {
4930        log::debug!(
4931            "pages with OCR reason {} (need OCR): {:?}",
4932            OCR_REASON_SUSPECTED_GARBLED_TEXT,
4933            text_quality_pages
4934        );
4935        for page in text_quality_pages {
4936            if !pages_needing_ocr.contains(&page) {
4937                pages_needing_ocr.push(page);
4938            }
4939        }
4940        pages_needing_ocr.sort_unstable();
4941    }
4942
4943    // Detect sparse extraction: when a TEXT-BASED PDF produces very few
4944    // characters per page, the text is likely embedded in images/forms
4945    // that need OCR.  Flag all pages for OCR in this case.
4946    // Only check when markdown was actually generated (not in Analyze mode).
4947    if pdf_type == PdfType::TextBased
4948        && page_count > 0
4949        && pages_needing_ocr.is_empty()
4950        && markdown.is_some()
4951    {
4952        let md_len = markdown.as_ref().map_or(0, |m| m.len());
4953        let chars_per_page = md_len as f32 / page_count as f32;
4954        if chars_per_page < 50.0 && md_len < 500 {
4955            log::debug!(
4956                "sparse extraction: {:.0} chars/page — recommending OCR for all {} pages",
4957                chars_per_page,
4958                page_count
4959            );
4960            pages_needing_ocr = (1..=page_count).collect();
4961        }
4962    }
4963
4964    let markdown = if all_gid {
4965        log::debug!(
4966            "all {} pages have gid-encoded fonts — suppressing markdown output",
4967            page_count
4968        );
4969        None
4970    } else {
4971        markdown
4972    };
4973
4974    Ok(PdfProcessResult {
4975        pdf_type,
4976        markdown,
4977        page_count,
4978        processing_time_ms: start.elapsed_ms(),
4979        pages_needing_ocr,
4980        ocr_reasons_by_page: {
4981            // Detector reasons (scanned / no_text / vector_text /
4982            // invisible_text_layer / garbled) merged with the
4983            // markdown-stage garbled detection, deduped per page.
4984            let mut merged = detection_ocr_reasons;
4985            merge_ocr_reasons(&mut merged, text_quality_reasons_by_page);
4986            page_ocr_reasons_vec(merged)
4987        },
4988        title,
4989        author,
4990        subject,
4991        keywords,
4992        creator,
4993        producer,
4994        creation_date,
4995        mod_date,
4996        confidence,
4997        layout,
4998        has_encoding_issues,
4999        cmap_gaps,
5000    })
5001}
5002
5003/// The fonts whose CMap lacked an entry for a code shown through it, with
5004/// their counts, from the per-font coverage of an extraction.
5005fn font_cmap_gaps(coverage: types::CMapCoverageByFont) -> Vec<FontCMapGaps> {
5006    coverage
5007        .into_iter()
5008        .filter(|(_, stats)| stats.has_gaps())
5009        .map(|(font, stats)| FontCMapGaps {
5010            font,
5011            codes: stats.codes,
5012            interpolated: stats.interpolated,
5013            unmapped: stats.unmapped,
5014        })
5015        .collect()
5016}
5017
5018// =========================================================================
5019// Internal helpers
5020// =========================================================================
5021
5022fn suspected_garbled_reason() -> String {
5023    OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()
5024}
5025
5026pub(crate) fn add_ocr_reason(
5027    reasons_by_page: &mut BTreeMap<u32, Vec<String>>,
5028    page: u32,
5029    reason: &str,
5030) {
5031    let reasons = reasons_by_page.entry(page).or_default();
5032    if !reasons.iter().any(|existing| existing == reason) {
5033        reasons.push(reason.to_string());
5034    }
5035}
5036
5037fn merge_ocr_reasons(
5038    reasons_by_page: &mut BTreeMap<u32, Vec<String>>,
5039    extra_reasons_by_page: BTreeMap<u32, Vec<String>>,
5040) {
5041    for (page, reasons) in extra_reasons_by_page {
5042        for reason in reasons {
5043            add_ocr_reason(reasons_by_page, page, &reason);
5044        }
5045    }
5046}
5047
5048fn page_ocr_reason(reasons_by_page: &BTreeMap<u32, Vec<String>>, page: u32) -> Option<String> {
5049    reasons_by_page
5050        .get(&page)
5051        .and_then(|reasons| reasons.first())
5052        .cloned()
5053}
5054
5055fn page_ocr_reasons_vec(reasons_by_page: BTreeMap<u32, Vec<String>>) -> Vec<PageOcrReasons> {
5056    reasons_by_page
5057        .into_iter()
5058        .map(|(page, reasons)| PageOcrReasons { page, reasons })
5059        .collect()
5060}
5061
5062/// Detect markdown tables with suspicious structure that suggest the heuristic
5063/// missed/mangled rows or columns. Returns true when the caller should treat
5064/// the result as `needs_ocr` and fall back to GPU OCR.
5065///
5066/// Catches three failure modes observed in production:
5067///
5068/// 1. **Header row looks like a data row** — first row starts with a numeric
5069///    value (e.g. `|2|...`), suggesting we missed the actual header above it.
5070///    Real headers almost never start with a bare number.
5071///
5072/// 2. **Header has empty cells in a multi-column table** — e.g.
5073///    `|Position||Administration|Administration|` (3+ cols, ≥1 empty cell).
5074///    Indicates poor column boundary detection.
5075///
5076/// 3. **Header has duplicate non-empty cells** in a multi-column table —
5077///    e.g. `Administration|Administration` appearing as adjacent cells means
5078///    we collapsed multi-line headers wrong.
5079///
5080/// Conservative by design: a few false positives (perfectly fine tables flagged)
5081/// just mean we run GPU OCR which is the existing safe path.
5082/// When `layout_assisted` is true (the layout model identified this region
5083/// as a table), we relax boundary-detection heuristics (numeric header,
5084/// empty header cells, sparse first data row) because the layout model
5085/// already gave us the table bbox — we're not guessing "is this a table?"
5086/// anymore, only "can we extract it correctly?". Paragraph and duplicate-
5087/// header checks stay, since those indicate genuine extraction quality
5088/// issues regardless of how the region was identified.
5089/// Return true when the captured table markdown represents only a small
5090/// fraction of the text the page extractor actually saw inside the
5091/// region — typically a header-only band or a sparse fragment where
5092/// the detector found valid grid structure but missed most of the
5093/// data rows below.
5094///
5095/// Tuned at a 25% floor: tables that captured at least a quarter of
5096/// the region's text are treated as complete-enough. Below 25%, the
5097/// caller falls back to `needs_ocr = true` so GLM-OCR can take over.
5098/// The 200-char region floor keeps short legitimate tables (units,
5099/// axis labels, single-row stat blocks) from being mis-flagged.
5100fn captured_only_a_fragment(markdown: &str, region_text_chars: usize) -> bool {
5101    if region_text_chars <= 200 {
5102        return false;
5103    }
5104    let captured_text_chars: usize = markdown
5105        .chars()
5106        .filter(|c| !matches!(c, '|' | '-' | '\n'))
5107        .count();
5108    captured_text_chars * 4 < region_text_chars
5109}
5110
5111/// Return true when the text the page extractor saw inside this region
5112/// is far too little for the bbox area — a strong signal that the page
5113/// has a font-CMap failure: Identity-H fonts with missing or broken
5114/// ToUnicode entries, Type-3 fonts without unicode metadata, etc. The
5115/// rendered image still carries the visible text (so GLM-OCR will
5116/// succeed), but the text extractor returns punctuation-only fragments
5117/// or single-glyph repeats.
5118///
5119/// `captured_only_a_fragment` can't catch this case on its own because
5120/// `region_text_chars` is itself symmetrically low under font-decode
5121/// failure — the captured-vs-region ratio still looks fine when both
5122/// numerator and denominator collapse. The area-based floor breaks the
5123/// symmetry: bbox area is independent of extraction success.
5124///
5125/// Threshold of 0.003 chars/sq pt sits between observed clean
5126/// extractions (≥0.005 chars/sq pt on full-page A4 tables, key/value
5127/// layouts, archival catalogs) and observed font-decode failures
5128/// (≤0.0014 chars/sq pt on the prod-traffic samples that motivated
5129/// this guard).
5130///
5131/// Two char-count + area bounds keep the guard from misfiring:
5132///   - text_chars < 20: too few chars to distinguish a font-decode
5133///     failure from a synthetic / fragmentary fixture. Observed
5134///     prod failures decode ≥30 chars before bottoming out.
5135///   - area < 30,000 sq pt: tiny stat blocks where density is
5136///     naturally low even for legitimate extractions.
5137///   - area > 400,000 sq pt: near-whole-A4 bboxes include large
5138///     white-space margins, so density is unreliable. Real font-
5139///     decode failures present at typical table sizes
5140///     (50k–400k sq pt).
5141fn region_text_density_too_low(region_text_chars: usize, region_area: f32) -> bool {
5142    if region_text_chars < 20 {
5143        return false;
5144    }
5145    if !(30_000.0..=400_000.0).contains(&region_area) {
5146        return false;
5147    }
5148    (region_text_chars as f32) / region_area < 0.003
5149}
5150
5151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5152enum TableCandidateSource {
5153    Rect,
5154    Line,
5155    Heuristic,
5156    Column,
5157    KeyValue,
5158}
5159
5160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5161enum TableCandidateIssue {
5162    LineRowUndercount,
5163    SparseWideUndercount,
5164    TextColumnUndercount,
5165    ProseGridFragment,
5166}
5167
5168#[derive(Debug, Clone)]
5169struct TableCandidate {
5170    markdown: String,
5171    source: TableCandidateSource,
5172    shape: MarkdownTableShape,
5173    issue: Option<TableCandidateIssue>,
5174}
5175
5176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5177struct MarkdownTableShape {
5178    rows: usize,
5179    cols: usize,
5180    raw_cols: usize,
5181}
5182
5183fn select_table_candidate(candidates: &[TableCandidate]) -> Option<&TableCandidate> {
5184    let first = candidates.first()?;
5185
5186    // A line grid that visibly collapses several captured text baselines
5187    // into too few rows is structurally undercounted. Prefer a clearly
5188    // wider clean heuristic if one exists; otherwise force OCR rather than
5189    // serving a tidy-looking fragment.
5190    if first.issue == Some(TableCandidateIssue::LineRowUndercount) {
5191        return candidates.iter().find(|candidate| {
5192            matches!(
5193                candidate.source,
5194                TableCandidateSource::Heuristic
5195                    | TableCandidateSource::Column
5196                    | TableCandidateSource::KeyValue
5197            ) && candidate.issue.is_none()
5198                && candidate.shape.cols * 10 >= first.shape.cols * 13
5199        });
5200    }
5201
5202    let mut accepted = candidates
5203        .iter()
5204        .find(|candidate| candidate.issue.is_none())?;
5205
5206    // Keep the vector-first behavior from PR #85 unless the text heuristic
5207    // is also clean and has substantially more structure. This catches
5208    // vector grids that pass text quality checks while missing implicit rows
5209    // or sparse columns, without swapping on small shape noise.
5210    if matches!(
5211        accepted.source,
5212        TableCandidateSource::Rect | TableCandidateSource::Line
5213    ) {
5214        if let Some(heuristic) = candidates.iter().find(|candidate| {
5215            matches!(
5216                candidate.source,
5217                TableCandidateSource::Heuristic
5218                    | TableCandidateSource::Column
5219                    | TableCandidateSource::KeyValue
5220            ) && candidate.issue.is_none()
5221                && heuristic_substantially_better(candidate.shape, accepted.shape)
5222        }) {
5223            accepted = heuristic;
5224        }
5225    }
5226
5227    if accepted.source == TableCandidateSource::Heuristic {
5228        if let Some(layout_candidate) = candidates.iter().find(|candidate| {
5229            matches!(
5230                candidate.source,
5231                TableCandidateSource::Column | TableCandidateSource::KeyValue
5232            ) && candidate.issue.is_none()
5233                && candidate.shape.cols >= accepted.shape.cols
5234                && candidate.shape.rows > accepted.shape.rows
5235        }) {
5236            accepted = layout_candidate;
5237        }
5238    }
5239
5240    Some(accepted)
5241}
5242
5243fn heuristic_substantially_better(
5244    heuristic: MarkdownTableShape,
5245    accepted: MarkdownTableShape,
5246) -> bool {
5247    (accepted.rows > 0 && heuristic.rows * 2 >= accepted.rows * 3)
5248        || (accepted.cols > 0 && heuristic.cols * 10 >= accepted.cols * 13)
5249}
5250
5251fn markdown_table_shape(markdown: &str) -> MarkdownTableShape {
5252    let mut shape = MarkdownTableShape::default();
5253    for cells in markdown_pipe_rows(markdown) {
5254        shape.rows += 1;
5255        shape.raw_cols = shape.raw_cols.max(cells.len());
5256        shape.cols = shape
5257            .cols
5258            .max(cells.iter().filter(|cell| !cell.trim().is_empty()).count());
5259    }
5260    shape
5261}
5262
5263fn markdown_pipe_rows(markdown: &str) -> Vec<Vec<&str>> {
5264    markdown
5265        .lines()
5266        .filter_map(|line| {
5267            let trimmed = line.trim();
5268            if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
5269                return None;
5270            }
5271            if is_markdown_separator_row(trimmed) {
5272                return None;
5273            }
5274            let parts: Vec<&str> = trimmed.split('|').collect();
5275            if parts.len() < 3 {
5276                return None;
5277            }
5278            Some(parts[1..parts.len() - 1].to_vec())
5279        })
5280        .collect()
5281}
5282
5283fn is_markdown_separator_row(line: &str) -> bool {
5284    let mut saw_dash = false;
5285    for ch in line.chars() {
5286        match ch {
5287            '-' => saw_dash = true,
5288            '|' | ':' | ' ' => {}
5289            _ => return false,
5290        }
5291    }
5292    saw_dash
5293}
5294
5295fn line_table_collapses_text_rows(
5296    table: &tables::Table,
5297    items: &[TextItem],
5298    shape: MarkdownTableShape,
5299) -> bool {
5300    let table_rows = shape.rows.max(
5301        table
5302            .cells
5303            .iter()
5304            .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5305            .count(),
5306    );
5307    let table_cols = shape.raw_cols.max(shape.cols);
5308    if !(2..=4).contains(&table_rows) || table_cols < 3 {
5309        return false;
5310    }
5311
5312    let captured_items: Vec<&TextItem> = table
5313        .item_indices
5314        .iter()
5315        .filter_map(|&idx| items.get(idx))
5316        .filter(|item| !item.text.trim().is_empty())
5317        .collect();
5318    let captured_chars: usize = captured_items
5319        .iter()
5320        .map(|item| item.text.chars().count())
5321        .sum();
5322    if captured_chars <= 200 {
5323        return false;
5324    }
5325
5326    let implicit_rows = y_cluster_count(&captured_items);
5327    implicit_rows >= 4 && table_rows * 2 <= implicit_rows
5328}
5329
5330fn y_cluster_count(items: &[&TextItem]) -> usize {
5331    if items.is_empty() {
5332        return 0;
5333    }
5334    let mut ys: Vec<f32> = items.iter().map(|item| item.y).collect();
5335    ys.sort_by(|a, b| a.total_cmp(b));
5336
5337    let mut clusters = 1;
5338    let mut center = ys[0];
5339    let mut count = 1usize;
5340    for &y in &ys[1..] {
5341        if (y - center).abs() > 3.0 {
5342            clusters += 1;
5343            center = y;
5344            count = 1;
5345        } else {
5346            center = (center * count as f32 + y) / (count as f32 + 1.0);
5347            count += 1;
5348        }
5349    }
5350    clusters
5351}
5352
5353fn has_vertical_rules(lines: &[PdfLine]) -> bool {
5354    let angle_tolerance = 2.0_f32.to_radians().tan();
5355    lines
5356        .iter()
5357        .filter(|line| {
5358            let dx = (line.x2 - line.x1).abs();
5359            let dy = (line.y2 - line.y1).abs();
5360            let length = (dx * dx + dy * dy).sqrt();
5361            length >= 20.0 && dy > 0.01 && dx / dy <= angle_tolerance
5362        })
5363        .count()
5364        >= 2
5365}
5366
5367fn wide_table_sparse_prefix_undercount(markdown: &str) -> bool {
5368    let rows = markdown_pipe_rows(markdown);
5369    if rows.len() < 4 {
5370        return false;
5371    }
5372    let header = &rows[0];
5373    let raw_cols = header.len();
5374    if raw_cols < 8 {
5375        return false;
5376    }
5377
5378    let empty_headers: Vec<usize> = header
5379        .iter()
5380        .enumerate()
5381        .filter_map(|(idx, cell)| cell.trim().is_empty().then_some(idx))
5382        .collect();
5383    if empty_headers.len() != 1 {
5384        return false;
5385    }
5386    let empty_header_idx = empty_headers[0];
5387    if empty_header_idx == 0 || empty_header_idx >= raw_cols / 2 {
5388        return false;
5389    }
5390
5391    let prefix_end = (raw_cols / 2).max(empty_header_idx + 1).min(raw_cols);
5392    if prefix_end <= 2 {
5393        return false;
5394    }
5395
5396    let mut data_rows = 0usize;
5397    let mut sparse_prefix_rows = 0usize;
5398    for row in rows.iter().skip(1) {
5399        if row.iter().all(|cell| cell.trim().is_empty()) {
5400            continue;
5401        }
5402        data_rows += 1;
5403        let empty_prefix_cells = row
5404            .iter()
5405            .skip(1)
5406            .take(prefix_end.saturating_sub(1))
5407            .filter(|cell| cell.trim().is_empty())
5408            .count();
5409        if empty_prefix_cells >= 2 {
5410            sparse_prefix_rows += 1;
5411        }
5412    }
5413
5414    data_rows >= 3 && sparse_prefix_rows * 2 >= data_rows
5415}
5416
5417fn text_cluster_column_undercount(items: &[TextItem], shape: MarkdownTableShape) -> bool {
5418    let table_cols = shape.raw_cols.max(shape.cols);
5419    if table_cols < 2 || items.len() < table_cols * 2 {
5420        return false;
5421    }
5422
5423    // Count "significant" x-clusters — clusters whose item count is at
5424    // least 1/4 of the dominant cluster. Filters out within-cell text
5425    // variation (wrapped continuations, bullet starts, indents) that
5426    // produces many small x-clusters not corresponding to real columns.
5427    let cluster_counts = x_cluster_item_counts(items);
5428    let Some(&dominant) = cluster_counts.iter().max() else {
5429        return false;
5430    };
5431    let min_cluster_size = (dominant / 4).max(2);
5432    let significant_clusters: usize = cluster_counts
5433        .iter()
5434        .filter(|&&n| n >= min_cluster_size)
5435        .count();
5436
5437    // Two regimes:
5438    //   - Wide table undercount (e.g. 11-col dropped to 9): fire when the
5439    //     clustering surfaces ≥2 extra columns AND ≥1.2× the markdown
5440    //     column count. Inherits the old wide-table heuristic.
5441    //   - Narrow table undercount (e.g. 4-col dropped to 2): fire when
5442    //     the clustering surfaces ≥2× the markdown column count and at
5443    //     least 3 real columns. Catches narrow numeric columns
5444    //     (dates, amounts, IDs) collapsed into adjacent ones.
5445    let wide_undercount = table_cols >= 6
5446        && significant_clusters >= table_cols + 2
5447        && significant_clusters * 10 >= table_cols * 12;
5448    let narrow_undercount = significant_clusters >= 3 && significant_clusters >= table_cols * 2;
5449    wide_undercount || narrow_undercount
5450}
5451
5452/// Cluster x-positions of non-empty text items with 8pt tolerance and
5453/// return the item count of each cluster. Same clustering policy as
5454/// `x_cluster_count`; the array form lets the caller weight clusters
5455/// by population to filter out single-item outliers.
5456fn x_cluster_item_counts(items: &[TextItem]) -> Vec<usize> {
5457    let mut xs: Vec<f32> = items
5458        .iter()
5459        .filter(|item| !item.text.trim().is_empty())
5460        .map(|item| item.x)
5461        .collect();
5462    if xs.is_empty() {
5463        return Vec::new();
5464    }
5465    xs.sort_by(|a, b| a.total_cmp(b));
5466
5467    let mut counts: Vec<usize> = Vec::new();
5468    let mut center = xs[0];
5469    let mut count = 1usize;
5470    for &x in &xs[1..] {
5471        if (x - center).abs() > 8.0 {
5472            counts.push(count);
5473            center = x;
5474            count = 1;
5475        } else {
5476            center = (center * count as f32 + x) / (count as f32 + 1.0);
5477            count += 1;
5478        }
5479    }
5480    counts.push(count);
5481    counts
5482}
5483
5484fn prose_grid_fragment_needs_ocr(markdown: &str) -> bool {
5485    let rows = markdown_pipe_rows(markdown);
5486    if rows.len() < 2 {
5487        return false;
5488    }
5489    let raw_cols = rows[0].len();
5490    if !(2..=4).contains(&raw_cols) {
5491        return false;
5492    }
5493
5494    let mut seen_by_col = vec![0usize; raw_cols];
5495    let mut compact_by_col = vec![0usize; raw_cols];
5496    let mut long_prose = 0usize;
5497    let mut total = 0usize;
5498
5499    for row in rows.iter().skip(1) {
5500        for (col, cell) in row.iter().take(raw_cols).enumerate() {
5501            let trimmed = cell.trim();
5502            if trimmed.is_empty() {
5503                continue;
5504            }
5505            total += 1;
5506            seen_by_col[col] += 1;
5507            if compact_identifier_cell(trimmed) {
5508                compact_by_col[col] += 1;
5509            }
5510            if long_prose_cell(trimmed) {
5511                long_prose += 1;
5512            }
5513        }
5514    }
5515
5516    let min_total = if raw_cols == 2 { 2 } else { raw_cols * 2 };
5517    if total < min_total {
5518        return false;
5519    }
5520    if long_prose * 3 < total * 2 {
5521        return false;
5522    }
5523
5524    !seen_by_col
5525        .iter()
5526        .zip(compact_by_col.iter())
5527        .any(|(&seen, &compact)| seen >= 3 && compact * 2 >= seen)
5528}
5529
5530fn compact_identifier_cell(cell: &str) -> bool {
5531    let trimmed = cell.trim();
5532    if trimmed.is_empty() {
5533        return false;
5534    }
5535    let words = word_count(trimmed);
5536    if words <= 3 && trimmed.chars().count() <= 40 {
5537        return true;
5538    }
5539
5540    let chars = trimmed.chars().filter(|c| !c.is_whitespace()).count();
5541    if chars == 0 || chars > 48 {
5542        return false;
5543    }
5544    let compact_marks = trimmed
5545        .chars()
5546        .filter(|c| {
5547            c.is_ascii_digit()
5548                || matches!(c, '.' | ',' | ':' | ';' | '/' | '-' | '(' | ')' | '[' | ']')
5549        })
5550        .count();
5551    compact_marks * 2 >= chars
5552}
5553
5554fn long_prose_cell(cell: &str) -> bool {
5555    let trimmed = cell.trim();
5556    if compact_identifier_cell(trimmed) {
5557        return false;
5558    }
5559    let words = word_count(trimmed);
5560    let alpha = trimmed.chars().filter(|c| c.is_alphabetic()).count();
5561    words >= 4 && alpha >= 12
5562}
5563
5564fn word_count(text: &str) -> usize {
5565    text.split_whitespace()
5566        .filter(|word| word.chars().any(|c| c.is_alphanumeric()))
5567        .count()
5568}
5569
5570fn starts_with_numbered_table_label(cell: &str) -> bool {
5571    let trimmed = cell.trim_start();
5572    let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
5573
5574    digit_count > 0
5575        && digit_count <= 3
5576        && trimmed
5577            .chars()
5578            .nth(digit_count)
5579            .is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
5580}
5581
5582fn starts_with_uppercase_alpha(cell: &str) -> bool {
5583    cell.chars()
5584        .find(|c| c.is_alphabetic())
5585        .is_some_and(|c| c.is_uppercase())
5586}
5587
5588fn compact_title_like_cell(cell: &str) -> bool {
5589    let trimmed = cell.trim();
5590    if trimmed.len() < 3 || trimmed.len() > 80 || !starts_with_uppercase_alpha(trimmed) {
5591        return false;
5592    }
5593    let words = trimmed
5594        .split_whitespace()
5595        .filter(|word| word.chars().any(|c| c.is_alphabetic()))
5596        .count();
5597    (1..=6).contains(&words)
5598}
5599
5600fn numbered_rowspan_hierarchy_needs_ocr(markdown: &str) -> bool {
5601    let rows = markdown_pipe_rows(markdown);
5602    if rows.len() < 6 {
5603        return false;
5604    }
5605
5606    let n_cols = rows[0].len();
5607    if !(3..=6).contains(&n_cols) {
5608        return false;
5609    }
5610
5611    let data_rows = &rows[1..];
5612    let numbered_group_rows = data_rows
5613        .iter()
5614        .filter(|row| {
5615            row.first()
5616                .is_some_and(|cell| starts_with_numbered_table_label(cell))
5617        })
5618        .count();
5619    let blank_first_subrows = data_rows
5620        .iter()
5621        .filter(|row| {
5622            row.first().is_some_and(|cell| cell.trim().is_empty())
5623                && row.get(1).is_some_and(|cell| compact_title_like_cell(cell))
5624        })
5625        .count();
5626
5627    // Native Markdown cannot express rowspans.  In numbered hierarchical
5628    // tables, repeated blank first-column sub-rows are a strong signal that
5629    // wrapped content from sibling rows can be assigned to the wrong row even
5630    // after continuation merging is conservative.  Let OCR handle this shape
5631    // instead of serving a tidy-looking but lossy native table.
5632    numbered_group_rows >= 2 && blank_first_subrows >= 2
5633}
5634
5635fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
5636    let lines: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect();
5637    if lines.len() < 2 {
5638        return false;
5639    }
5640    // Header is the first pipe-line; separator is the second
5641    let header_line = lines[0];
5642    let separator_line = lines.get(1).copied().unwrap_or("");
5643    let is_separator = |l: &str| l.chars().all(|c| matches!(c, '|' | '-' | ' '));
5644    if !is_separator(separator_line) {
5645        // No separator after the first line — not a well-formed pipe-table.
5646        // table_to_markdown always emits one when it returns content, so this
5647        // shouldn't happen in practice. If it does, fall through to OCR.
5648        return true;
5649    }
5650
5651    // Parse header cells: split on '|', drop the leading/trailing empty pieces
5652    let cells: Vec<&str> = header_line.split('|').map(|s| s.trim()).collect::<Vec<_>>();
5653    // The first and last items are always empty (string starts and ends with '|')
5654    if cells.len() < 3 {
5655        return false;
5656    }
5657    let header_cells: Vec<&str> = cells[1..cells.len() - 1].to_vec();
5658    let n_cols = header_cells.len();
5659    if n_cols < 2 {
5660        // Single-column tables are usually lists/keys, not tables. Keep them
5661        // (caller can decide), but multi-column header checks below don't
5662        // apply.
5663        return false;
5664    }
5665
5666    if layout_assisted && numbered_rowspan_hierarchy_needs_ocr(markdown) {
5667        return true;
5668    }
5669
5670    // Failure mode 1: header starts with a bare number (likely we missed
5671    // the real header row above). Skip when layout-assisted — the layout
5672    // model's bbox includes the real header; a numeric first cell (e.g.,
5673    // a year "2024") is legitimate.
5674    if !layout_assisted {
5675        if let Some(first) = header_cells.first() {
5676            let trimmed = first.trim();
5677            if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
5678                return true;
5679            }
5680        }
5681    }
5682
5683    // Failure mode 2: header has empty cells in a multi-column table.
5684    // When layout-assisted, tolerate merged/spanning header gaps if the
5685    // body is dense. Region bboxes from a layout model often start at a
5686    // visual table whose header cannot be represented faithfully in a
5687    // flat pipe table, while the body rows are still complete enough to use.
5688    let header_empty_indices: Vec<usize> = header_cells
5689        .iter()
5690        .enumerate()
5691        .filter_map(|(idx, cell)| cell.is_empty().then_some(idx))
5692        .collect();
5693    let empty_count = header_empty_indices.len();
5694    if layout_assisted {
5695        if n_cols >= 3
5696            && empty_count >= 2
5697            && !layout_assisted_empty_header_has_dense_body(markdown, n_cols)
5698        {
5699            return true;
5700        }
5701    } else if n_cols >= 3 && empty_count >= 1 {
5702        return true;
5703    }
5704
5705    // Failure mode 3: header has duplicate non-empty cells
5706    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
5707    for cell in &header_cells {
5708        if cell.is_empty() {
5709            continue;
5710        }
5711        if !seen.insert(cell) {
5712            return true;
5713        }
5714    }
5715
5716    // Failure mode 4: first data row has many empty cells in a multi-column
5717    // table. Real tables rarely have a leading row with most cells blank;
5718    // when this happens it usually means the heuristic split a multi-row
5719    // header (e.g. "Position\nAdministration (1986-1992) | Administration
5720    // (1992-1998)") into a single-row header + a sparse data row.
5721    if let Some(first_data_line) = lines.get(2) {
5722        let data_cells: Vec<&str> = first_data_line
5723            .split('|')
5724            .map(|s| s.trim())
5725            .collect::<Vec<_>>();
5726        if data_cells.len() >= 3 {
5727            let data_inner = &data_cells[1..data_cells.len() - 1];
5728            let empty_data = data_inner.iter().filter(|c| c.is_empty()).count();
5729            // ≥3 cols, and significant portion of cells in the first data
5730            // row are empty → likely we mis-split a multi-row header.
5731            // When layout-assisted, relax from 33% to 50% — the bbox is
5732            // more reliable, and real tables with one sparse first row
5733            // (totals, subtotals) are common.
5734            let threshold = if layout_assisted { 2 } else { 3 };
5735            if n_cols >= 3 && empty_data * threshold >= n_cols {
5736                let sparse_row_shares_header_spacer = layout_assisted
5737                    && data_inner.iter().enumerate().any(|(idx, cell)| {
5738                        cell.trim().is_empty() && header_empty_indices.contains(&idx)
5739                    })
5740                    && layout_assisted_empty_header_has_dense_body(markdown, n_cols);
5741                let sparse_row_is_section_label = layout_assisted
5742                    && layout_assisted_sparse_section_row_is_ok(data_inner, markdown, n_cols);
5743                if !sparse_row_shares_header_spacer && !sparse_row_is_section_label {
5744                    return true;
5745                }
5746            }
5747        }
5748    }
5749
5750    // Failure mode 5: cells flow as continuation paragraph (text wrapping
5751    // mistaken for column structure). When a paragraph of prose gets mis-
5752    // detected as a multi-column table, cells in the same column tend to
5753    // start with lowercase letters or punctuation (continuation), not
5754    // capital letters / digits (new entries). Real tables almost never
5755    // have most data cells starting lowercase.
5756    //
5757    // Signal: ≥2 cols, ≥4 data rows, and ≥60% of non-empty data cells
5758    // start with a lowercase letter or continuation punctuation.
5759    let data_rows: Vec<Vec<&str>> = lines
5760        .iter()
5761        .skip(2) // header + separator
5762        .map(|l| {
5763            let parts: Vec<&str> = l.split('|').map(|s| s.trim()).collect();
5764            if parts.len() >= 3 {
5765                parts[1..parts.len() - 1].to_vec()
5766            } else {
5767                Vec::new()
5768            }
5769        })
5770        .filter(|cells| !cells.is_empty())
5771        .collect();
5772
5773    if n_cols >= 2 && data_rows.len() >= 4 {
5774        let mut continuation = 0;
5775        let mut total = 0;
5776        for row in &data_rows {
5777            for cell in row {
5778                let trimmed = cell.trim();
5779                if trimmed.is_empty() {
5780                    continue;
5781                }
5782                total += 1;
5783                let first = trimmed.chars().next().unwrap();
5784                // Continuation indicators: lowercase letter, common
5785                // mid-sentence punctuation, closing quote
5786                if first.is_lowercase()
5787                    || matches!(first, ',' | '.' | ';' | ')' | '"' | '\'' | '”' | '’')
5788                {
5789                    continuation += 1;
5790                }
5791            }
5792        }
5793        if total > 0 && continuation * 5 >= total * 3 {
5794            // ≥60% of cells look like sentence continuations → paragraph
5795            // misread as table.
5796            return true;
5797        }
5798    }
5799
5800    false
5801}
5802
5803fn layout_assisted_empty_header_has_dense_body(markdown: &str, n_cols: usize) -> bool {
5804    let rows = markdown_pipe_rows(markdown);
5805    let data_rows: Vec<&Vec<&str>> = rows
5806        .iter()
5807        .skip(1)
5808        .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5809        .collect();
5810    if data_rows.len() < 2 || n_cols < 3 {
5811        return false;
5812    }
5813
5814    let total_cells = data_rows.len() * n_cols;
5815    let mut filled_cells = 0usize;
5816    let mut rows_with_multiple_cells = 0usize;
5817    let mut max_filled_in_row = 0usize;
5818    for row in &data_rows {
5819        let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
5820        filled_cells += filled;
5821        max_filled_in_row = max_filled_in_row.max(filled);
5822        if filled >= 2 {
5823            rows_with_multiple_cells += 1;
5824        }
5825    }
5826
5827    // Dense enough to be a useful extraction despite lossy merged headers.
5828    // The row-count gate avoids accepting a single tidy row under a broken
5829    // header, and the density gate keeps sparse fragments on the OCR path.
5830    rows_with_multiple_cells * 2 >= data_rows.len()
5831        && max_filled_in_row >= n_cols.min(3)
5832        && filled_cells * 100 >= total_cells * 45
5833}
5834
5835fn layout_assisted_sparse_section_row_is_ok(row: &[&str], markdown: &str, n_cols: usize) -> bool {
5836    let labels: Vec<&str> = row
5837        .iter()
5838        .map(|cell| cell.trim())
5839        .filter(|cell| !cell.is_empty())
5840        .collect();
5841    if labels.len() != 1 {
5842        return false;
5843    }
5844    let label = labels[0];
5845    if label.len() > 40 || !label.chars().any(|ch| ch.is_alphabetic()) {
5846        return false;
5847    }
5848    if label.ends_with('.') || label.ends_with('!') || label.ends_with('?') || label.ends_with(':')
5849    {
5850        return false;
5851    }
5852    layout_assisted_empty_header_has_dense_body(markdown, n_cols)
5853}
5854
5855fn markdown_table_body_is_dense(markdown: &str) -> bool {
5856    let rows = markdown_pipe_rows(markdown);
5857    let data_rows: Vec<&Vec<&str>> = rows
5858        .iter()
5859        .skip(1)
5860        .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5861        .collect();
5862    if data_rows.len() < 3 {
5863        return false;
5864    }
5865
5866    let cols = rows.iter().map(|row| row.len()).max().unwrap_or_default();
5867    if cols < 3 {
5868        return false;
5869    }
5870
5871    let mut filled_cells = 0usize;
5872    let mut rows_with_multiple_cells = 0usize;
5873    for row in &data_rows {
5874        let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
5875        filled_cells += filled;
5876        if filled >= cols.min(3) {
5877            rows_with_multiple_cells += 1;
5878        }
5879    }
5880
5881    let total_cells = data_rows.len() * cols;
5882    rows_with_multiple_cells * 2 >= data_rows.len() && filled_cells * 100 >= total_cells * 45
5883}
5884
5885/// Original strict validation (no layout assistance). Used by tests and
5886/// full-page extraction paths that don't have layout model assistance.
5887#[cfg(test)]
5888fn looks_like_partial_table(markdown: &str) -> bool {
5889    looks_like_partial_table_ex(markdown, false)
5890}
5891
5892#[cfg(test)]
5893mod text_cluster_column_undercount_tests {
5894    use super::{text_cluster_column_undercount, MarkdownTableShape, TextItem};
5895    use crate::types::ItemType;
5896
5897    fn item(x: f32, y: f32, text: &str) -> TextItem {
5898        TextItem {
5899            text: text.to_string(),
5900            x,
5901            y,
5902            width: text.len() as f32 * 5.0,
5903            height: 10.0,
5904            font: "F".into(),
5905            font_tag: String::new(),
5906            legacy_symbol_rewrite: false,
5907            font_size: 10.0,
5908            page: 1,
5909            is_bold: false,
5910            is_italic: false,
5911            font_weight: None,
5912            bold_source: None,
5913            fixed_pitch: None,
5914            fill_color: None,
5915            stroke_color: None,
5916            render_mode: None,
5917            is_underline: false,
5918            is_strikeout: false,
5919            rotation: 0.0,
5920            advance_known: true,
5921            item_type: ItemType::Text,
5922            mcid: None,
5923            baseline_shift: 0.0,
5924        }
5925    }
5926
5927    /// 22 rows × 4 columns of items, neutral synthetic content. Simulates
5928    /// the "narrow numeric columns dropped" shape: heuristic detector
5929    /// produced 2 markdown columns but the page geometry shows 4.
5930    fn make_four_column_grid() -> Vec<TextItem> {
5931        let xs = [60.0, 145.0, 330.0, 430.0];
5932        let mut items = Vec::new();
5933        for row in 0..22 {
5934            let y = 700.0 - row as f32 * 20.0;
5935            for &x in &xs {
5936                items.push(item(x, y, "x"));
5937            }
5938        }
5939        items
5940    }
5941
5942    fn shape(cols: usize) -> MarkdownTableShape {
5943        MarkdownTableShape {
5944            rows: 22,
5945            cols,
5946            raw_cols: cols,
5947        }
5948    }
5949
5950    #[test]
5951    fn narrow_undercount_2col_markdown_4col_geometry_fires() {
5952        // The shape this PR targets: heuristic detector collapsed a
5953        // 4-column page into 2 markdown columns because narrow numeric
5954        // columns (e.g. dates, IDs) didn't survive x-position
5955        // clustering. Significant clusters (4) >= markdown_cols (2) * 2.
5956        let items = make_four_column_grid();
5957        assert!(text_cluster_column_undercount(&items, shape(2)));
5958    }
5959
5960    #[test]
5961    fn matching_geometry_does_not_fire() {
5962        // 4-col page with 4-col markdown — no undercount.
5963        let items = make_four_column_grid();
5964        assert!(!text_cluster_column_undercount(&items, shape(4)));
5965    }
5966
5967    #[test]
5968    fn single_column_skipped() {
5969        // 1-col markdown is not a table — never flag.
5970        let items = make_four_column_grid();
5971        assert!(!text_cluster_column_undercount(&items, shape(1)));
5972    }
5973
5974    #[test]
5975    fn insufficient_items_skipped() {
5976        // < 2*table_cols items — not enough signal to claim undercount.
5977        let items = vec![
5978            item(60.0, 700.0, "x"),
5979            item(145.0, 700.0, "x"),
5980            item(330.0, 700.0, "x"),
5981        ];
5982        assert!(!text_cluster_column_undercount(&items, shape(2)));
5983    }
5984
5985    #[test]
5986    fn outlier_singletons_filtered_out() {
5987        // 2-col grid (22 items each col) plus a few stray outliers at
5988        // other x positions. Significant clusters = 2; markdown=2;
5989        // doesn't fire (significant >= cols*2 = 4 is false).
5990        let mut items = Vec::new();
5991        for row in 0..22 {
5992            let y = 700.0 - row as f32 * 20.0;
5993            items.push(item(60.0, y, "x"));
5994            items.push(item(330.0, y, "x"));
5995        }
5996        // Add a handful of strays (continuation fragments, footnotes).
5997        items.push(item(145.0, 100.0, "footnote"));
5998        items.push(item(430.0, 50.0, "page#"));
5999        assert!(!text_cluster_column_undercount(&items, shape(2)));
6000    }
6001
6002    #[test]
6003    fn wide_table_path_still_fires() {
6004        // The original wide-table case: 6+ markdown cols with ≥2 extra
6005        // significant clusters. Builds a 12-col geometry with 8-col
6006        // markdown — the legacy wide-undercount path catches this.
6007        let mut items = Vec::new();
6008        let xs: Vec<f32> = (0..12).map(|i| 50.0 + i as f32 * 40.0).collect();
6009        for row in 0..22 {
6010            let y = 700.0 - row as f32 * 20.0;
6011            for &x in &xs {
6012                items.push(item(x, y, "x"));
6013            }
6014        }
6015        assert!(text_cluster_column_undercount(&items, shape(8)));
6016    }
6017}
6018
6019#[cfg(test)]
6020mod region_text_density_tests {
6021    use super::region_text_density_too_low;
6022
6023    #[test]
6024    fn small_region_skips_check() {
6025        // Tiny stat blocks below the area floor are never flagged —
6026        // can't distinguish "font failure" from "small legitimate table".
6027        // 100×100 = 10,000 sq pt, below the 30,000 floor.
6028        assert!(!region_text_density_too_low(5, 10_000.0));
6029    }
6030
6031    #[test]
6032    fn dense_full_table_passes() {
6033        // Observed clean extractions sit at 0.005–0.015 chars/sq pt.
6034        // Full A4 ledger: 438,000 sq pt with 6,500 chars → density 0.015.
6035        assert!(!region_text_density_too_low(6_500, 438_000.0));
6036    }
6037
6038    #[test]
6039    fn moderate_density_table_passes() {
6040        // Key/value tables with multi-line cells sit at ~0.005 chars/sq pt.
6041        // 291,000 sq pt with 1,600 chars → density 0.0055.
6042        assert!(!region_text_density_too_low(1_600, 291_000.0));
6043    }
6044
6045    #[test]
6046    fn font_decode_failure_caught() {
6047        // Big region, almost no extractable text — page extractor hit a
6048        // CMap failure. 102,000 sq pt with 46 chars → density 0.0005.
6049        assert!(region_text_density_too_low(46, 102_000.0));
6050    }
6051
6052    #[test]
6053    fn sparse_glyph_repeat_caught() {
6054        // Full-page Cyrillic where every glyph decoded to "Т". The text
6055        // extractor returned a few hundred chars, but they're all the
6056        // same letter. Density 0.00025 — well under the floor.
6057        assert!(region_text_density_too_low(89, 353_000.0));
6058    }
6059
6060    #[test]
6061    fn sparse_layout_band_caught() {
6062        // Specimen-materials horizontal band: real text decoded fine for
6063        // the slice that's in-bbox, but the table extends below the
6064        // bbox. 73,000 sq pt with 96 chars → density 0.0013.
6065        assert!(region_text_density_too_low(96, 73_000.0));
6066    }
6067
6068    #[test]
6069    fn boundary_at_density_floor() {
6070        // Right at the 0.003 floor: 90 chars / 30,000 sq pt = 0.003
6071        // exactly. The check rejects when density is strictly less than
6072        // the floor, so the boundary is treated as acceptable.
6073        assert!(!region_text_density_too_low(90, 30_000.0));
6074        // Just under: 89 chars / 30,000 = 0.00297 — flagged.
6075        assert!(region_text_density_too_low(89, 30_000.0));
6076    }
6077
6078    #[test]
6079    fn whole_page_bbox_skips_check() {
6080        // Near-whole-A4 bboxes (>400,000 sq pt) include large white-
6081        // space margins, so text density is unreliable. Layout
6082        // typically produces tight per-table bboxes; whole-page
6083        // bboxes appear in fixtures and edge cases where this signal
6084        // would misfire. 612×792 = 484,704 sq pt with 423 chars in
6085        // a real table inside it.
6086        assert!(!region_text_density_too_low(423, 484_704.0));
6087    }
6088
6089    #[test]
6090    fn tiny_text_chars_skips_check() {
6091        // Synthetic / fragmentary fixtures with <20 chars in a
6092        // generously-sized bbox aren't font-decode failures — they're
6093        // unit-test artifacts. Real prod failures decode ≥30 chars.
6094        // 8 chars in a 127,800 sq pt bbox would otherwise flag at
6095        // density 0.00006.
6096        assert!(!region_text_density_too_low(8, 127_800.0));
6097    }
6098}
6099
6100#[cfg(test)]
6101mod captured_only_a_fragment_tests {
6102    use super::captured_only_a_fragment;
6103
6104    #[test]
6105    fn small_region_skips_check() {
6106        // Short legitimate tables (axis labels, unit blocks) shouldn't be
6107        // flagged even when the captured markdown is tiny.
6108        let md = "|Year|Value|\n|---|---|\n|2024|10|";
6109        assert!(!captured_only_a_fragment(md, 50));
6110    }
6111
6112    #[test]
6113    fn full_table_passes() {
6114        // Captured markdown matches the region text — full extraction.
6115        let md =
6116            "|Name|Year|Country|\n|---|---|---|\n|Alice|2020|US|\n|Bob|2021|UK|\n|Carol|2019|FR|";
6117        // Region had ~50 chars of text (rough estimate of just the data words).
6118        assert!(!captured_only_a_fragment(md, 50));
6119        // Even a much larger region matched by the markdown content passes.
6120        assert!(!captured_only_a_fragment(md, md.len()));
6121    }
6122
6123    #[test]
6124    fn header_only_extraction_rejected() {
6125        // Captured the column-header band (~30 chars) while the region
6126        // actually has many rows of data (~1500 chars).
6127        let md = "|Description|Year|Amount|\n|---|---|---|";
6128        assert!(captured_only_a_fragment(md, 1500));
6129    }
6130
6131    #[test]
6132    fn sparse_fragment_rejected() {
6133        // A couple of fragment cells captured from a content-rich region.
6134        let md = "|percent|for|\n|---|---|\n|sites|15|";
6135        assert!(captured_only_a_fragment(md, 2000));
6136    }
6137
6138    #[test]
6139    fn boundary_at_25_percent_floor() {
6140        // Right at the 25% line: 250 captured chars of 1000 region chars.
6141        // The check rejects when captured*4 < region, so 250*4=1000 is NOT
6142        // less than 1000 — boundary is treated as acceptable.
6143        let md = "x".repeat(250);
6144        assert!(!captured_only_a_fragment(&md, 1000));
6145        // Just under 25%: 249*4=996 < 1000 — flagged.
6146        let md_under = "x".repeat(249);
6147        assert!(captured_only_a_fragment(&md_under, 1000));
6148    }
6149}
6150
6151#[cfg(test)]
6152mod table_candidate_selection_tests {
6153    use super::{
6154        line_table_collapses_text_rows, markdown_table_shape, prose_grid_fragment_needs_ocr,
6155        select_table_candidate, text_cluster_column_undercount,
6156        wide_table_sparse_prefix_undercount, MarkdownTableShape, TableCandidate,
6157        TableCandidateIssue, TableCandidateSource,
6158    };
6159    use crate::tables::Table;
6160    use crate::types::{ItemType, TextItem};
6161
6162    fn candidate(
6163        source: TableCandidateSource,
6164        rows: usize,
6165        cols: usize,
6166        issue: Option<TableCandidateIssue>,
6167    ) -> TableCandidate {
6168        TableCandidate {
6169            markdown: format!("{source:?}-{rows}x{cols}"),
6170            source,
6171            shape: MarkdownTableShape {
6172                rows,
6173                cols,
6174                raw_cols: cols,
6175            },
6176            issue,
6177        }
6178    }
6179
6180    fn item(text: &str, y: f32) -> TextItem {
6181        item_at(text, 10.0, y)
6182    }
6183
6184    fn item_at(text: &str, x: f32, y: f32) -> TextItem {
6185        TextItem {
6186            text: text.to_string(),
6187            x,
6188            y,
6189            width: 50.0,
6190            height: 10.0,
6191            font: "F1".to_string(),
6192            font_tag: String::new(),
6193            legacy_symbol_rewrite: false,
6194            font_size: 10.0,
6195            page: 1,
6196            is_bold: false,
6197            is_italic: false,
6198            font_weight: None,
6199            bold_source: None,
6200            fixed_pitch: None,
6201            fill_color: None,
6202            stroke_color: None,
6203            render_mode: None,
6204            is_underline: false,
6205            is_strikeout: false,
6206            rotation: 0.0,
6207            advance_known: true,
6208            item_type: ItemType::Text,
6209            mcid: None,
6210            baseline_shift: 0.0,
6211        }
6212    }
6213
6214    #[test]
6215    fn markdown_shape_counts_rows_and_non_empty_columns() {
6216        let md = "|A|B||D|\n|---|---|---|---|\n|one|two|three||";
6217        let shape = markdown_table_shape(md);
6218        assert_eq!(shape.rows, 2);
6219        assert_eq!(shape.cols, 3);
6220        assert_eq!(shape.raw_cols, 4);
6221    }
6222
6223    #[test]
6224    fn prefers_clean_heuristic_when_substantially_wider_than_vector() {
6225        let candidates = vec![
6226            candidate(TableCandidateSource::Rect, 5, 3, None),
6227            candidate(TableCandidateSource::Heuristic, 5, 4, None),
6228        ];
6229        let selected = select_table_candidate(&candidates).unwrap();
6230        assert_eq!(selected.source, TableCandidateSource::Heuristic);
6231    }
6232
6233    #[test]
6234    fn line_row_undercount_routes_to_ocr_without_wider_heuristic() {
6235        let candidates = vec![
6236            candidate(
6237                TableCandidateSource::Line,
6238                2,
6239                3,
6240                Some(TableCandidateIssue::LineRowUndercount),
6241            ),
6242            candidate(TableCandidateSource::Heuristic, 4, 3, None),
6243        ];
6244        assert!(select_table_candidate(&candidates).is_none());
6245    }
6246
6247    #[test]
6248    fn line_row_undercount_can_use_clearly_wider_heuristic() {
6249        let candidates = vec![
6250            candidate(
6251                TableCandidateSource::Line,
6252                2,
6253                3,
6254                Some(TableCandidateIssue::LineRowUndercount),
6255            ),
6256            candidate(TableCandidateSource::Heuristic, 2, 4, None),
6257        ];
6258        let selected = select_table_candidate(&candidates).unwrap();
6259        assert_eq!(selected.source, TableCandidateSource::Heuristic);
6260    }
6261
6262    #[test]
6263    fn prefers_clean_column_fallback_when_it_recovers_more_rows() {
6264        let candidates = vec![
6265            candidate(TableCandidateSource::Heuristic, 6, 5, None),
6266            candidate(TableCandidateSource::Column, 7, 5, None),
6267        ];
6268        let selected = select_table_candidate(&candidates).unwrap();
6269        assert_eq!(selected.source, TableCandidateSource::Column);
6270    }
6271
6272    #[test]
6273    fn line_candidate_collapsing_captured_y_clusters_is_suspicious() {
6274        let long = "value value value value value value value value value value value value";
6275        let items = vec![
6276            item(long, 100.0),
6277            item(long, 112.0),
6278            item(long, 124.0),
6279            item(long, 136.0),
6280        ];
6281        let table = Table::new(
6282            vec![0.0, 100.0, 200.0, 300.0],
6283            vec![150.0, 120.0],
6284            vec![
6285                vec!["A".to_string(), "B".to_string(), "C".to_string()],
6286                vec!["D".to_string(), "E".to_string(), "F".to_string()],
6287            ],
6288            vec![0, 1, 2, 3],
6289        );
6290        let shape = MarkdownTableShape {
6291            rows: 2,
6292            cols: 3,
6293            raw_cols: 3,
6294        };
6295        assert!(line_table_collapses_text_rows(&table, &items, shape));
6296    }
6297
6298    #[test]
6299    fn wide_sparse_prefix_with_blank_header_is_suspicious() {
6300        let md = "|Name|Flag A|Flag B||Metric A|Metric B|Metric C|Metric D|\n\
6301                  |---|---|---|---|---|---|---|---|\n\
6302                  |Row 1|Y|||1|2|3|4|\n\
6303                  |Row 2|||N|5|6|7|8|\n\
6304                  |Row 3||||9|10|11|12|";
6305        assert!(wide_table_sparse_prefix_undercount(md));
6306    }
6307
6308    #[test]
6309    fn wide_blank_header_with_dense_body_is_allowed() {
6310        let md = "|Name|Flag A|Flag B||Metric A|Metric B|Metric C|Metric D|\n\
6311                  |---|---|---|---|---|---|---|---|\n\
6312                  |Row 1|Y|N|Y|1|2|3|4|\n\
6313                  |Row 2|N|Y|N|5|6|7|8|\n\
6314                  |Row 3|Y|Y|N|9|10|11|12|";
6315        assert!(!wide_table_sparse_prefix_undercount(md));
6316    }
6317
6318    #[test]
6319    fn x_clusters_can_signal_wide_column_undercount() {
6320        let mut items = Vec::new();
6321        for y in [100.0, 112.0, 124.0] {
6322            for x in [
6323                10.0, 45.0, 80.0, 115.0, 150.0, 185.0, 220.0, 255.0, 290.0, 325.0,
6324            ] {
6325                items.push(item_at("value", x, y));
6326            }
6327        }
6328        let shape = MarkdownTableShape {
6329            rows: 4,
6330            cols: 8,
6331            raw_cols: 8,
6332        };
6333        assert!(text_cluster_column_undercount(&items, shape));
6334    }
6335
6336    #[test]
6337    fn wrapped_prose_grid_fragment_is_suspicious() {
6338        let md = "|A useful capability with several words|Another descriptive capability column|A final descriptive capability column|\n\
6339                  |---|---|---|\n\
6340                  |The group includes experienced specialists|received a strong neutral recommendation|system in a neutral evaluation setting|\n\
6341                  |presented several papers in public venues|shown stronger performance than alternatives|ranked highly in a neutral benchmark|\n\
6342                  |recognized by external reviewers|delivered useful results for operators|used in production style workflows|";
6343        assert!(prose_grid_fragment_needs_ocr(md));
6344    }
6345
6346    #[test]
6347    fn compact_identifier_columns_are_not_prose_fragments() {
6348        let md = "|Box 1, F-7|Description|Date|\n\
6349                  |---|---|---|\n\
6350                  |Box 1, F-8|Long neutral description with several words for one record|2020 Jan 1|\n\
6351                  |Box 1, F-9|Another neutral description with several words for another record|2021 Feb 2|\n\
6352                  |Box 1, F-10|Additional neutral description with several words for a record|n.d.|";
6353        assert!(!prose_grid_fragment_needs_ocr(md));
6354    }
6355
6356    #[test]
6357    fn two_column_all_prose_fragment_is_suspicious() {
6358        let md = "|A long descriptive header fragment|Another long descriptive header fragment|\n\
6359                  |---|---|\n\
6360                  |Long wrapped prose content from one visual cell|More wrapped prose content from a neighboring visual cell|";
6361        assert!(prose_grid_fragment_needs_ocr(md));
6362    }
6363
6364    #[test]
6365    fn two_column_key_value_table_is_allowed() {
6366        let md = "|Field|Detail|\n\
6367                  |---|---|\n\
6368                  |Status|Long neutral explanation with several words for this value|\n\
6369                  |Owner|Another neutral explanation with several words for this value|";
6370        assert!(!prose_grid_fragment_needs_ocr(md));
6371    }
6372}
6373
6374#[cfg(test)]
6375mod looks_like_partial_table_tests {
6376    use super::{
6377        looks_like_partial_table, looks_like_partial_table_ex, markdown_table_body_is_dense,
6378    };
6379
6380    #[test]
6381    fn good_table_passes() {
6382        let md = "|Name|Year|Country|\n|---|---|---|\n|Alice|2020|US|\n|Bob|2021|UK|";
6383        assert!(
6384            !looks_like_partial_table(md),
6385            "should not flag well-formed table"
6386        );
6387    }
6388
6389    #[test]
6390    fn header_starting_with_number_is_partial() {
6391        // Heuristic missed the actual header row above
6392        let md = "|2|Cambodian Women for Peace|9,835|\n|---|---|---|\n|3|Association|711|";
6393        assert!(looks_like_partial_table(md));
6394    }
6395
6396    #[test]
6397    fn header_with_empty_cells_in_3col_is_partial() {
6398        // Empty cell in 3+ column header → bad column detection
6399        let md =
6400            "|Position||Administration|Administration|\n|---|---|---|---|\n|Senate|24|8.3|16.7|";
6401        assert!(looks_like_partial_table(md));
6402    }
6403
6404    #[test]
6405    fn header_with_duplicate_cells_is_partial() {
6406        // Duplicate "Administration" → collapsed multi-line header wrong
6407        let md =
6408            "|Position|Administration|Administration|Notes|\n|---|---|---|---|\n|Senate|24|16|x|";
6409        assert!(looks_like_partial_table(md));
6410    }
6411
6412    #[test]
6413    fn two_column_with_one_empty_cell_passes() {
6414        // Many real two-column tables have key-only rows; don't penalise.
6415        let md = "|Key||\n|---|---|\n|Alice|123|\n|Bob|456|";
6416        // Header "Key|" has one empty cell but only 2 cols total — keep it.
6417        assert!(!looks_like_partial_table(md));
6418    }
6419
6420    #[test]
6421    fn single_column_table_is_kept() {
6422        // Single-column "tables" are common (lists). Caller can decide; we
6423        // don't second-guess based on column count alone.
6424        let md = "|Item|\n|---|\n|First|\n|Second|";
6425        assert!(!looks_like_partial_table(md));
6426    }
6427
6428    #[test]
6429    fn no_table_at_all_returns_true() {
6430        // table_to_markdown should never produce this, but defensive — if
6431        // there's no separator, treat as not-a-table.
6432        let md = "Just some text\nWith multiple lines";
6433        // No lines start with '|' so we return false (no header to inspect).
6434        assert!(!looks_like_partial_table(md));
6435    }
6436
6437    #[test]
6438    fn first_data_row_with_many_empty_cells_is_partial() {
6439        // Multi-row header collapsed to single-row → first "data row" has
6440        // most cells empty (the actual sub-header values).
6441        let md = "|Government|No. of Seats|Aquino|Ramos|\n|---|---|---|---|\n|Position|||(1986-1992)|\n|Senate|24|8.3|16.7|";
6442        assert!(looks_like_partial_table(md));
6443    }
6444
6445    #[test]
6446    fn first_data_row_with_one_empty_cell_in_4col_passes() {
6447        // Real data rows can have one empty cell (e.g. missing value);
6448        // only flag when ≥1/3 of cells are empty.
6449        let md = "|A|B|C|D|\n|---|---|---|---|\n|x|y||z|\n|p|q|r|s|";
6450        assert!(!looks_like_partial_table(md));
6451    }
6452
6453    #[test]
6454    fn paragraph_misread_as_two_column_table_is_partial() {
6455        // Real production failure: text-wrapped paragraph mis-detected as
6456        // 2-col table. Each cell continues the previous one as prose.
6457        let md = "|Approval is needed from the|Acquisitions of|\n\
6458                  |---|---|\n\
6459                  |Treasurer if the acquisition|residential and|\n\
6460                  |constitutes a \"significant|agricultural|\n\
6461                  |action,\" including acquiring an|land by foreign|\n\
6462                  |interest in different types of|persons must be|\n\
6463                  |land where the monetary|reported to the|";
6464        assert!(looks_like_partial_table(md));
6465    }
6466
6467    #[test]
6468    fn real_multi_word_table_is_kept() {
6469        // Real table with multi-word entries — cells start with capital
6470        // letters / proper nouns, NOT lowercase continuations.
6471        let md = "|Country|Capital|Notes|\n\
6472                  |---|---|---|\n\
6473                  |United States|Washington DC|Federal capital|\n\
6474                  |United Kingdom|London|City of London is a separate|\n\
6475                  |France|Paris|Île-de-France region|\n\
6476                  |Germany|Berlin|Reunified 1990|\n\
6477                  |Spain|Madrid|Largest city in Spain|";
6478        assert!(!looks_like_partial_table(md));
6479    }
6480
6481    // --- layout_assisted relaxation tests ---
6482
6483    #[test]
6484    fn numeric_header_accepted_when_layout_assisted() {
6485        // Year as first header cell is valid when layout model gave us the bbox.
6486        let md = "|2024|Revenue|Growth|\n|---|---|---|\n|Q1|1.2M|5%|\n|Q2|1.4M|8%|";
6487        assert!(
6488            looks_like_partial_table(md),
6489            "strict mode rejects numeric header"
6490        );
6491        assert!(
6492            !looks_like_partial_table_ex(md, true),
6493            "layout-assisted should accept"
6494        );
6495    }
6496
6497    #[test]
6498    fn one_empty_header_accepted_when_layout_assisted() {
6499        // Common in merged-header tables: one spanning cell leaves a gap.
6500        let md = "|Position||Senate|House|\n|---|---|---|---|\n|Chair|1|2|3|\n|Vice|4|5|6|";
6501        assert!(
6502            looks_like_partial_table(md),
6503            "strict rejects 1 empty header"
6504        );
6505        assert!(
6506            !looks_like_partial_table_ex(md, true),
6507            "layout-assisted allows 1 empty"
6508        );
6509    }
6510
6511    #[test]
6512    fn two_empty_headers_still_rejected_when_layout_assisted() {
6513        // A single tidy row is not enough evidence to trust a badly gapped header.
6514        let md = "|A|||D|\n|---|---|---|---|\n|x|y|z|w|";
6515        assert!(
6516            looks_like_partial_table_ex(md, true),
6517            "2 empty headers with only one body row are rejected even layout-assisted"
6518        );
6519    }
6520
6521    #[test]
6522    fn dense_body_with_empty_merged_header_passes_when_layout_assisted() {
6523        let md = "|Year||Unadjusted Basis|||\n\
6524                  |---|---|---|---|---|\n\
6525                  |1|.1667|$100,000|$16,670|$16,670|\n\
6526                  |2|.3333|$100,000|$33,330|$50,000|\n\
6527                  |3|.3333|$100,000|$33,330|$88,330|\n\
6528                  |4|.1667|$100,000|$16,670|$100,000|";
6529        assert!(
6530            looks_like_partial_table(md),
6531            "strict mode still rejects merged-header gaps"
6532        );
6533        assert!(
6534            !looks_like_partial_table_ex(md, true),
6535            "layout-assisted should trust a dense body under a merged header"
6536        );
6537    }
6538
6539    #[test]
6540    fn sparse_first_row_relaxed_when_layout_assisted() {
6541        // 1/4 empty = 25%, below strict 33% threshold but accepted by layout-assisted 50%.
6542        let md = "|A|B|C|D|\n|---|---|---|---|\n|x||y|z|\n|p|q|r|s|";
6543        assert!(!looks_like_partial_table(md), "strict: 25% empty is OK");
6544        // 2/4 = 50%, strict would flag (2*3>=4), relaxed threshold (2*2>=4) would also flag.
6545        let md2 = "|A|B|C|D|\n|---|---|---|---|\n|||y|z|\n|p|q|r|s|";
6546        assert!(looks_like_partial_table(md2), "strict: 50% empty flagged");
6547        assert!(
6548            looks_like_partial_table_ex(md2, true),
6549            "layout-assisted: 50% also flagged"
6550        );
6551        // 2/6 = 33%, strict flags (2*3>=6), relaxed does not (2*2<6)
6552        let md3 = "|A|B|C|D|E|F|\n|---|---|---|---|---|---|\n|x|||y|z|w|\n|a|b|c|d|e|f|";
6553        assert!(looks_like_partial_table(md3), "strict: 33% flagged");
6554        assert!(
6555            !looks_like_partial_table_ex(md3, true),
6556            "layout-assisted: 33% accepted"
6557        );
6558    }
6559
6560    #[test]
6561    fn sparse_first_row_with_header_spacer_passes_when_layout_assisted() {
6562        let md = "|Properties|Instruction||Training Datasets Alignment|\n\
6563                  |---|---|---|---|\n\
6564                  ||Alpaca-GPT4 OpenOrca Synth. Math-Instruct||Orca DPO Pairs Ultrafeedback Cleaned|\n\
6565                  |Total # Samples|52K 2.91M 126K||12.9K 60.8K 126K|";
6566        assert!(
6567            looks_like_partial_table(md),
6568            "strict mode rejects the sparse first row"
6569        );
6570        assert!(
6571            !looks_like_partial_table_ex(md, true),
6572            "layout-assisted should allow sparse rows that share a header spacer column"
6573        );
6574    }
6575
6576    #[test]
6577    fn sparse_section_row_passes_when_layout_assisted_body_is_dense() {
6578        let md = "|Properties|Conditions|Method|Typical values|Units|\n\
6579                  |---|---|---|---|---|\n\
6580                  |Rheology|||||\n\
6581                  |Melt Flow Rate|230 C/2.16 kg|ASTM D1238|3.0|g/10 min|\n\
6582                  |Tensile Stress at Yield|50 mm/min|ASTM D638|31|MPa|\n\
6583                  |Elongation at Yield|50 mm/min|ASTM D638|8|%|";
6584        assert!(
6585            looks_like_partial_table(md),
6586            "strict mode rejects the sparse first row"
6587        );
6588        assert!(
6589            !looks_like_partial_table_ex(md, true),
6590            "layout-assisted should allow a short section label above dense table rows"
6591        );
6592    }
6593
6594    #[test]
6595    fn paragraph_still_rejected_when_layout_assisted() {
6596        // Paragraph detection is not relaxed — it's a genuine extraction issue.
6597        let md = "|Approval is needed from the|Acquisitions of|\n\
6598                  |---|---|\n\
6599                  |Treasurer if the acquisition|residential and|\n\
6600                  |constitutes a \"significant|agricultural|\n\
6601                  |action,\" including acquiring an|land by foreign|\n\
6602                  |interest in different types of|persons must be|\n\
6603                  |land where the monetary|reported to the|";
6604        assert!(
6605            looks_like_partial_table_ex(md, true),
6606            "paragraph rejection stays strict"
6607        );
6608    }
6609
6610    #[test]
6611    fn numbered_rowspan_hierarchy_rejected_when_layout_assisted() {
6612        let md = "|Group|Task|Detail|Benefit|\n\
6613                  |---|---|---|---|\n\
6614                  |1. Group alpha|Task setup|Begin setup|Faster start|\n\
6615                  |2. Group beta|Storage setup|Provides tools||\n\
6616                  ||Label workspace|Creates review sets|Lets teams review|\n\
6617                  ||Model training|Builds model|Supports rollout|\n\
6618                  |3. Group gamma|Pipeline setup|Configures flow|Improves control|";
6619        assert!(
6620            looks_like_partial_table_ex(md, true),
6621            "layout-assisted native extraction should fall back for numbered row-spanned hierarchies"
6622        );
6623    }
6624
6625    #[test]
6626    fn plain_numbered_table_kept_when_layout_assisted() {
6627        let md = "|Step|Task|Detail|Benefit|\n\
6628                  |---|---|---|---|\n\
6629                  |1. Group alpha|Task setup|Begin setup|Faster start|\n\
6630                  |2. Group beta|Storage setup|Provides tools|Easier review|\n\
6631                  |3. Group gamma|Pipeline setup|Configures flow|Improves control|";
6632        assert!(
6633            !looks_like_partial_table_ex(md, true),
6634            "numbered rows alone are fine; the guard requires blank-first-column sub-rows"
6635        );
6636    }
6637
6638    #[test]
6639    fn duplicate_headers_still_rejected_when_layout_assisted() {
6640        let md =
6641            "|Position|Administration|Administration|Notes|\n|---|---|---|---|\n|Senate|24|16|x|";
6642        assert!(
6643            looks_like_partial_table_ex(md, true),
6644            "duplicate headers rejected even layout-assisted"
6645        );
6646    }
6647
6648    #[test]
6649    fn dense_numeric_table_body_is_structurally_trusted() {
6650        let md = "|Year|3-Year|5-Year|7-Year|\n\
6651                  |---|---|---|---|\n\
6652                  |1|33.0%|20.00%|14.29%|\n\
6653                  |2|44.45%|32.00%|24.49%|\n\
6654                  |3|14.81%|19.20%|17.49%|\n\
6655                  |4|7.41%|11.52%|12.49%|";
6656        assert!(markdown_table_body_is_dense(md));
6657    }
6658
6659    #[test]
6660    fn sparse_markdown_fragment_is_not_structurally_trusted() {
6661        let md = "|A|B|C|D|\n\
6662                  |---|---|---|---|\n\
6663                  |x||||\n\
6664                  |||y||\n\
6665                  ||||z|";
6666        assert!(!markdown_table_body_is_dense(md));
6667    }
6668}
6669
6670struct FolioFilteredItems {
6671    items: Vec<types::TextItem>,
6672    layout_items: Vec<types::TextItem>,
6673    removal_mask: Vec<bool>,
6674    removed_pages: HashSet<u32>,
6675}
6676
6677/// Resolve folios with complete document context, then select the caller's
6678/// requested pages without losing those decisions.
6679fn select_items_with_document_folio_context(
6680    all_items: Vec<types::TextItem>,
6681    page_count: u32,
6682    page_filter: Option<&HashSet<u32>>,
6683) -> FolioFilteredItems {
6684    let (all_layout_items, all_removed_pages, all_removal_mask) =
6685        extractor::filter_markdown_page_numbers_with_removed_pages(all_items.clone(), page_count);
6686    let selected_page = |page: u32| page_filter.is_none_or(|filter| filter.contains(&page));
6687
6688    let (items, removal_mask) = all_items
6689        .into_iter()
6690        .zip(all_removal_mask)
6691        .filter(|(item, _)| selected_page(item.page))
6692        .unzip();
6693    let layout_items = all_layout_items
6694        .into_iter()
6695        .filter(|item| selected_page(item.page))
6696        .collect();
6697    let removed_pages = all_removed_pages
6698        .into_iter()
6699        .filter(|page| selected_page(*page))
6700        .collect();
6701
6702    FolioFilteredItems {
6703        items,
6704        layout_items,
6705        removal_mask,
6706        removed_pages,
6707    }
6708}
6709
6710/// Analyse extracted items and rects for layout complexity.
6711#[cfg(test)]
6712fn compute_layout_complexity(
6713    items: &[types::TextItem],
6714    column_items: &[types::TextItem],
6715    rects: &[types::PdfRect],
6716    lines: &[types::PdfLine],
6717) -> LayoutComplexity {
6718    let page_chart_regions = markdown::chart_regions_by_page(items, rects, lines);
6719    compute_layout_complexity_with_chart_regions(
6720        items,
6721        column_items,
6722        rects,
6723        lines,
6724        &page_chart_regions,
6725    )
6726}
6727
6728fn compute_layout_complexity_with_chart_regions(
6729    items: &[types::TextItem],
6730    column_items: &[types::TextItem],
6731    rects: &[types::PdfRect],
6732    lines: &[types::PdfLine],
6733    page_chart_regions: &markdown::PageChartRegions,
6734) -> LayoutComplexity {
6735    use markdown::analysis::calculate_font_stats_from_items;
6736
6737    // --- Collect unique pages ---
6738    let mut seen_pages: Vec<u32> = items.iter().map(|i| i.page).collect();
6739    seen_pages.sort();
6740    seen_pages.dedup();
6741
6742    let font_stats = calculate_font_stats_from_items(items);
6743    let base_size = font_stats.most_common_size;
6744
6745    // --- Tables: use rect-based → line-based → heuristic detectors per page,
6746    //     with side-by-side band splitting ---
6747    let mut pages_with_tables: Vec<u32> = Vec::new();
6748    for &page in &seen_pages {
6749        let page_items: Vec<&types::TextItem> = items.iter().filter(|i| i.page == page).collect();
6750
6751        // Check for side-by-side layout
6752        let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
6753        let page_content_width = tables::content_width(&owned_items);
6754        let bands = markdown::split_side_by_side(&owned_items);
6755        let chart_regions = page_chart_regions
6756            .get(&page)
6757            .map(Vec::as_slice)
6758            .unwrap_or_default();
6759
6760        let band_ranges: Vec<(f32, f32)> = if bands.is_empty() {
6761            // Single region — use sentinel range that includes everything
6762            vec![(f32::MIN, f32::MAX)]
6763        } else {
6764            bands
6765        };
6766
6767        let mut found_table = false;
6768        for &(x_lo, x_hi) in &band_ranges {
6769            let margin = 2.0;
6770            let band_items: Vec<types::TextItem> = owned_items
6771                .iter()
6772                .filter(|item| {
6773                    (x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin))
6774                        && !markdown::item_is_in_chart_region(item, chart_regions)
6775                })
6776                .cloned()
6777                .collect();
6778
6779            let band_rects: Vec<types::PdfRect> = if x_lo == f32::MIN {
6780                rects.iter().filter(|r| r.page == page).cloned().collect()
6781            } else {
6782                markdown::filter_rects_to_band(rects, page, x_lo, x_hi)
6783            };
6784
6785            let band_lines: Vec<types::PdfLine> = if x_lo == f32::MIN {
6786                lines.iter().filter(|l| l.page == page).cloned().collect()
6787            } else {
6788                markdown::filter_lines_to_band(lines, page, x_lo, x_hi)
6789            };
6790
6791            // TOC pages route through the table detector but render as flat
6792            // lists. They aren't tables in any user-facing sense, so don't
6793            // count them toward LayoutComplexity (would also trip the
6794            // table-page guard in column detection below).
6795            let has_data_table =
6796                |tables: &[tables::Table]| tables.iter().any(|t| t.kind == tables::TableKind::Data);
6797
6798            let (rect_tables, _) = tables::detect_tables_from_rects(&band_items, &band_rects, page);
6799            if has_data_table(&rect_tables) {
6800                found_table = true;
6801                break;
6802            }
6803            let line_tables = tables::detect_tables_from_lines(&band_items, &band_lines, page);
6804            if has_data_table(&line_tables) {
6805                found_table = true;
6806                break;
6807            }
6808            // Heuristic fallback for borderless tables
6809            let heuristic_tables = tables::detect_tables_with_page_width(
6810                &band_items,
6811                base_size,
6812                false,
6813                page_content_width,
6814            );
6815            if has_data_table(&heuristic_tables) {
6816                found_table = true;
6817                break;
6818            }
6819        }
6820        if found_table {
6821            pages_with_tables.push(page);
6822        }
6823    }
6824
6825    let mut pages_with_columns: Vec<u32> = Vec::new();
6826    for &page in &seen_pages {
6827        let chart_regions = page_chart_regions
6828            .get(&page)
6829            .map(Vec::as_slice)
6830            .unwrap_or_default();
6831        let page_column_items: Vec<types::TextItem> = column_items
6832            .iter()
6833            .filter(|item| {
6834                item.page == page && !markdown::item_is_in_chart_region(item, chart_regions)
6835            })
6836            .cloned()
6837            .collect();
6838        let cols =
6839            extractor::detect_columns(&page_column_items, page, pages_with_tables.contains(&page));
6840        if cols.len() >= 2 {
6841            pages_with_columns.push(page);
6842        }
6843    }
6844
6845    let is_complex = !pages_with_tables.is_empty() || !pages_with_columns.is_empty();
6846
6847    LayoutComplexity {
6848        is_complex,
6849        pages_with_tables,
6850        pages_with_columns,
6851    }
6852}
6853
6854#[derive(Debug, thiserror::Error)]
6855pub enum PdfError {
6856    #[error("IO error: {0}")]
6857    Io(#[from] std::io::Error),
6858    #[error("PDF parsing error: {0}")]
6859    Parse(String),
6860    #[error("PDF is encrypted")]
6861    Encrypted,
6862    #[error("Invalid PDF structure")]
6863    InvalidStructure,
6864    #[error("Not a PDF: {0}")]
6865    NotAPdf(String),
6866}
6867
6868impl From<lopdf::Error> for PdfError {
6869    fn from(e: lopdf::Error) -> Self {
6870        match e {
6871            lopdf::Error::IO(io_err) => PdfError::Io(io_err),
6872            lopdf::Error::Decryption(_)
6873            | lopdf::Error::InvalidPassword
6874            | lopdf::Error::AlreadyEncrypted
6875            | lopdf::Error::UnsupportedSecurityHandler(_) => PdfError::Encrypted,
6876            lopdf::Error::Unimplemented(msg) if msg.contains("encrypted") => PdfError::Encrypted,
6877            lopdf::Error::Parse(ref pe) if pe.to_string().contains("invalid file header") => {
6878                PdfError::NotAPdf("invalid PDF file header".to_string())
6879            }
6880            lopdf::Error::MissingXrefEntry
6881            | lopdf::Error::Xref(_)
6882            | lopdf::Error::IndirectObject { .. }
6883            | lopdf::Error::ObjectIdMismatch
6884            | lopdf::Error::InvalidObjectStream(_)
6885            | lopdf::Error::InvalidOffset(_) => PdfError::InvalidStructure,
6886            other => PdfError::Parse(other.to_string()),
6887        }
6888    }
6889}
6890
6891/// Check whether a `lopdf::Error` represents an encryption-related failure.
6892pub(crate) fn is_encrypted_lopdf_error(e: &lopdf::Error) -> bool {
6893    matches!(
6894        e,
6895        lopdf::Error::Decryption(_)
6896            | lopdf::Error::InvalidPassword
6897            | lopdf::Error::AlreadyEncrypted
6898            | lopdf::Error::UnsupportedSecurityHandler(_)
6899    ) || matches!(e, lopdf::Error::Unimplemented(msg) if msg.contains("encrypted"))
6900}
6901
6902// ---------------------------------------------------------------------------
6903// PDF validation helpers
6904// ---------------------------------------------------------------------------
6905
6906/// Strip UTF-8 BOM and leading ASCII whitespace from a byte slice.
6907fn strip_bom_and_whitespace(bytes: &[u8]) -> &[u8] {
6908    let b = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
6909        &bytes[3..]
6910    } else {
6911        bytes
6912    };
6913    let start = b
6914        .iter()
6915        .position(|&c| !c.is_ascii_whitespace())
6916        .unwrap_or(b.len());
6917    &b[start..]
6918}
6919
6920/// Case-insensitive prefix check on byte slices.
6921fn starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool {
6922    if haystack.len() < needle.len() {
6923        return false;
6924    }
6925    haystack[..needle.len()]
6926        .iter()
6927        .zip(needle)
6928        .all(|(a, b)| a.eq_ignore_ascii_case(b))
6929}
6930
6931/// Try to identify what kind of file the bytes represent.
6932fn detect_file_type_hint(bytes: &[u8]) -> String {
6933    if bytes.is_empty() {
6934        return "file is empty".to_string();
6935    }
6936
6937    let trimmed = strip_bom_and_whitespace(bytes);
6938
6939    // HTML
6940    if starts_with_ci(trimmed, b"<!doctype html")
6941        || starts_with_ci(trimmed, b"<html")
6942        || starts_with_ci(trimmed, b"<head")
6943        || starts_with_ci(trimmed, b"<body")
6944    {
6945        return "file appears to be HTML".to_string();
6946    }
6947
6948    // XML (but not HTML)
6949    if trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<") {
6950        if starts_with_ci(trimmed, b"<?xml") {
6951            return "file appears to be XML".to_string();
6952        }
6953        if trimmed.starts_with(b"<") && !trimmed.starts_with(b"<%") {
6954            return "file appears to be XML".to_string();
6955        }
6956    }
6957
6958    // JSON
6959    if trimmed.starts_with(b"{") || trimmed.starts_with(b"[") {
6960        return "file appears to be JSON".to_string();
6961    }
6962
6963    // PNG
6964    if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
6965        return "file appears to be a PNG image".to_string();
6966    }
6967
6968    // JPEG
6969    if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
6970        return "file appears to be a JPEG image".to_string();
6971    }
6972
6973    // ZIP / Office documents
6974    if bytes.starts_with(&[0x50, 0x4B, 0x03, 0x04]) {
6975        return "file appears to be a ZIP archive (possibly an Office document)".to_string();
6976    }
6977
6978    // If it looks like mostly printable ASCII/UTF-8, call it plain text
6979    let sample = &bytes[..bytes.len().min(512)];
6980    let printable = sample
6981        .iter()
6982        .filter(|&&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
6983        .count();
6984    if printable > sample.len() * 3 / 4 {
6985        return "file appears to be plain text".to_string();
6986    }
6987
6988    "file is not a PDF".to_string()
6989}
6990
6991/// The `%PDF` header must *start* within this many bytes of the buffer.
6992///
6993/// mupdf, pdfium and poppler all accept a header that is not at offset 0
6994/// (leading bytes before the header, e.g. an echoed multipart envelope or a
6995/// UTF-8 BOM); this mirrors that tolerance with a bounded search.
6996const PDF_HEADER_SEARCH_WINDOW: usize = 1024;
6997
6998/// Longest header the locator inspects: `%PDF-M.N` plus its line ending.
6999const PDF_HEADER_PROBE_LEN: usize = 9;
7000
7001/// Byte offset of the `%PDF-` header within the first
7002/// [`PDF_HEADER_SEARCH_WINDOW`] bytes of `buffer`.
7003///
7004/// Candidates are ranked by how much they look like a real header line, and
7005/// by buffer order within a rank: a canonical `%PDF-M.N` line (starting a
7006/// line and ending at a line break) first, then `%PDF-` followed by a version
7007/// digit, then any other `%PDF-`. A version-like mention inside leading text
7008/// (`X-Note: %PDF-1.4`, say) therefore does not outrank the actual header.
7009/// lopdf's own header parser requires the literal `%PDF-`, so a bare `%PDF`
7010/// is not a candidate.
7011fn pdf_header_offset(buffer: &[u8]) -> Option<usize> {
7012    // Only the start of the marker is bounded by the window; let the marker
7013    // itself run past it.
7014    let probe_end = (PDF_HEADER_SEARCH_WINDOW + PDF_HEADER_PROBE_LEN).min(buffer.len());
7015    let probe = &buffer[..probe_end];
7016
7017    let mut best: Option<(u8, usize)> = None;
7018    for (offset, window) in probe
7019        .windows(b"%PDF-".len())
7020        .enumerate()
7021        .take(PDF_HEADER_SEARCH_WINDOW)
7022    {
7023        if window != b"%PDF-" {
7024            continue;
7025        }
7026        let version = &probe[offset + b"%PDF-".len()..];
7027        let at_line_start = offset == 0 || matches!(probe[offset - 1], b'\r' | b'\n');
7028        let rank = if at_line_start && is_canonical_pdf_version_line(version) {
7029            return Some(offset);
7030        } else if version.first().is_some_and(u8::is_ascii_digit) {
7031            1
7032        } else {
7033            2
7034        };
7035        if best.is_none_or(|(best_rank, _)| rank < best_rank) {
7036            best = Some((rank, offset));
7037        }
7038    }
7039    best.map(|(_, offset)| offset)
7040}
7041
7042/// `M.N` immediately followed by a line ending (or the end of the probe),
7043/// i.e. the bytes after `%PDF-` on a canonical header line.
7044fn is_canonical_pdf_version_line(version: &[u8]) -> bool {
7045    matches!(version, [major, b'.', minor, rest @ ..]
7046        if major.is_ascii_digit()
7047            && minor.is_ascii_digit()
7048            && rest.first().is_none_or(|b| matches!(b, b'\r' | b'\n')))
7049}
7050
7051/// Return `buffer` starting at its `%PDF-` header, dropping any leading bytes.
7052///
7053/// Returns `buffer` unchanged when no header is found so the loader reports
7054/// its own error.
7055fn strip_leading_bytes_before_header(buffer: &[u8]) -> &[u8] {
7056    match pdf_header_offset(buffer) {
7057        Some(offset) if offset > 0 => {
7058            log::debug!("dropping {offset} leading bytes before the %PDF- header");
7059            &buffer[offset..]
7060        }
7061        _ => buffer,
7062    }
7063}
7064
7065/// Validate that a byte buffer looks like a PDF (has `%PDF-` magic).
7066///
7067/// The header must start within the first [`PDF_HEADER_SEARCH_WINDOW`] bytes;
7068/// see [`pdf_header_offset`]. Leading bytes before it are tolerated here and
7069/// dropped by the loader.
7070pub(crate) fn validate_pdf_bytes(buffer: &[u8]) -> Result<(), PdfError> {
7071    if pdf_header_offset(buffer).is_some() {
7072        Ok(())
7073    } else {
7074        Err(PdfError::NotAPdf(detect_file_type_hint(buffer)))
7075    }
7076}
7077
7078/// Attach `path` to an IO error so a missing input is distinguishable from
7079/// a missing built-in resource. Relative paths also name the working
7080/// directory they were resolved against.
7081pub(crate) fn io_error_at(path: &Path, err: std::io::Error) -> PdfError {
7082    let location = if path.is_absolute() {
7083        path.display().to_string()
7084    } else if let Ok(cwd) = std::env::current_dir() {
7085        format!("{} (working directory {})", path.display(), cwd.display())
7086    } else {
7087        path.display().to_string()
7088    };
7089    PdfError::Io(std::io::Error::new(
7090        err.kind(),
7091        format!("{location}: {err}"),
7092    ))
7093}
7094
7095/// Read a whole file, reporting `path` in the error.
7096pub(crate) fn read_file(path: &Path) -> Result<Vec<u8>, PdfError> {
7097    std::fs::read(path).map_err(|err| io_error_at(path, err))
7098}
7099
7100/// Validate that a file on disk looks like a PDF.
7101///
7102/// Reads only the header search window and delegates to [`validate_pdf_bytes`].
7103pub(crate) fn validate_pdf_file<P: AsRef<Path>>(path: P) -> Result<(), PdfError> {
7104    use std::io::Read;
7105    let path = path.as_ref();
7106    let mut file = std::fs::File::open(path).map_err(|err| io_error_at(path, err))?;
7107    let mut buf = [0u8; PDF_HEADER_SEARCH_WINDOW + PDF_HEADER_PROBE_LEN];
7108    let n = file.read(&mut buf).map_err(|err| io_error_at(path, err))?;
7109    validate_pdf_bytes(&buf[..n])
7110}
7111
7112#[cfg(test)]
7113mod tests {
7114    use super::*;
7115    use crate::types::ItemType;
7116
7117    #[test]
7118    fn missing_file_error_names_the_path() {
7119        let err = validate_pdf_file("this-file-does-not-exist.pdf").unwrap_err();
7120        let msg = err.to_string();
7121        assert!(
7122            msg.contains("this-file-does-not-exist.pdf"),
7123            "missing-file error should name the path, got {msg}"
7124        );
7125        assert!(
7126            msg.contains("working directory"),
7127            "relative path should name the working directory, got {msg}"
7128        );
7129    }
7130
7131    #[test]
7132    fn pdf_header_offset_finds_header_at_start() {
7133        assert_eq!(pdf_header_offset(b"%PDF-1.4\n%%EOF"), Some(0));
7134    }
7135
7136    #[test]
7137    fn pdf_header_offset_skips_bom_and_whitespace() {
7138        let mut buf = vec![0xEF, 0xBB, 0xBF, b'\n', b'\t'];
7139        buf.extend_from_slice(b"%PDF-1.7\n");
7140        assert_eq!(pdf_header_offset(&buf), Some(5));
7141    }
7142
7143    #[test]
7144    fn pdf_header_offset_finds_header_after_leading_bytes() {
7145        // Leading bytes before the header, e.g. an echoed multipart envelope.
7146        let prefix = b"--boundary\r\nContent-Type: application/pdf\r\n\r\n";
7147        let mut buf = prefix.to_vec();
7148        buf.extend_from_slice(b"%PDF-1.5\n%%EOF");
7149        assert_eq!(pdf_header_offset(&buf), Some(prefix.len()));
7150    }
7151
7152    #[test]
7153    fn pdf_header_offset_ignores_bare_marker_in_leading_text() {
7154        let buf = b"filename=\"%PDF.pdf\"\r\n\r\n%PDF-1.6\n";
7155        let expected = buf.len() - b"%PDF-1.6\n".len();
7156        assert_eq!(pdf_header_offset(buf), Some(expected));
7157        assert_eq!(pdf_header_offset(b"xx%PDF\n1 0 obj"), None);
7158    }
7159
7160    #[test]
7161    fn pdf_header_offset_accepts_dash_without_version_digit() {
7162        assert_eq!(pdf_header_offset(b"%PDF-\n1 0 obj"), Some(0));
7163    }
7164
7165    #[test]
7166    fn pdf_header_offset_accepts_header_at_window_edge() {
7167        let mut buf = vec![b' '; PDF_HEADER_SEARCH_WINDOW - 1];
7168        buf.extend_from_slice(b"%PDF-1.4\n");
7169        assert_eq!(pdf_header_offset(&buf), Some(PDF_HEADER_SEARCH_WINDOW - 1));
7170    }
7171
7172    #[test]
7173    fn pdf_header_offset_rejects_header_beyond_window() {
7174        let mut buf = vec![b' '; PDF_HEADER_SEARCH_WINDOW];
7175        buf.extend_from_slice(b"%PDF-1.4\n");
7176        assert_eq!(pdf_header_offset(&buf), None);
7177        assert_eq!(pdf_header_offset(b""), None);
7178        assert_eq!(pdf_header_offset(b"just some text"), None);
7179    }
7180
7181    #[test]
7182    fn pdf_header_offset_ranks_canonical_header_lines_first() {
7183        // dashed-only at 0, version digit at 7, canonical lines at 19 and 28.
7184        let buf = b"%PDF-x\n%PDF-1 note\n%PDF-1.4\n%PDF-2.0\r\n";
7185        assert_eq!(pdf_header_offset(buf), Some(19));
7186        // A canonical-looking version mid-line is only a version-digit match.
7187        let buf = b"X-Note: %PDF-1.4\n%PDF-1.7\n";
7188        assert_eq!(pdf_header_offset(buf), Some(17));
7189        assert_eq!(pdf_header_offset(b"%PDF-x\n%PDF-1 note\n"), Some(7));
7190        assert_eq!(pdf_header_offset(b"%PDF-x\n"), Some(0));
7191
7192        // Many version-like mentions never outrank the real header line.
7193        let mut buf = b"x: %PDF-1\n".repeat(50);
7194        buf.extend_from_slice(b"%PDF-1.4\n");
7195        assert_eq!(
7196            pdf_header_offset(&buf),
7197            Some(buf.len() - b"%PDF-1.4\n".len())
7198        );
7199    }
7200
7201    #[test]
7202    fn strip_leading_bytes_before_header_slices_to_header() {
7203        let buf = b"junk\r\n%PDF-1.4\n%%EOF";
7204        assert_eq!(strip_leading_bytes_before_header(buf), b"%PDF-1.4\n%%EOF");
7205        let clean = b"%PDF-1.4\n%%EOF";
7206        assert_eq!(strip_leading_bytes_before_header(clean), clean);
7207        let none = b"not a pdf";
7208        assert_eq!(strip_leading_bytes_before_header(none), none);
7209    }
7210
7211    #[test]
7212    fn validate_pdf_bytes_accepts_leading_bytes_and_rejects_text() {
7213        assert!(validate_pdf_bytes(b"--b\r\n\r\n%PDF-1.4\n").is_ok());
7214        match validate_pdf_bytes(b"This is plain text mentioning %PDF, not a PDF.") {
7215            Err(PdfError::NotAPdf(hint)) => assert!(hint.contains("plain text"), "{hint}"),
7216            other => panic!("expected NotAPdf, got {other:?}"),
7217        }
7218    }
7219
7220    fn test_item(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextItem {
7221        TextItem {
7222            text: text.to_string(),
7223            x,
7224            y,
7225            width,
7226            height,
7227            font: "Helvetica".to_string(),
7228            font_tag: String::new(),
7229            legacy_symbol_rewrite: false,
7230            font_size: height,
7231            page: 1,
7232            is_bold: false,
7233            is_italic: false,
7234            font_weight: None,
7235            bold_source: None,
7236            fixed_pitch: None,
7237            fill_color: None,
7238            stroke_color: None,
7239            render_mode: None,
7240            is_underline: false,
7241            is_strikeout: false,
7242            rotation: 0.0,
7243            advance_known: true,
7244            item_type: ItemType::Text,
7245            mcid: None,
7246            baseline_shift: 0.0,
7247        }
7248    }
7249
7250    fn test_text_item_on_page(page: u32, text: &str) -> TextItem {
7251        TextItem {
7252            page,
7253            ..test_item(text, 10.0, 10.0, text.len() as f32 * 5.0, 12.0)
7254        }
7255    }
7256
7257    fn test_image_item(width: f32, height: f32) -> TextItem {
7258        TextItem {
7259            item_type: ItemType::Image,
7260            ..test_item("[Image]", 20.0, 30.0, width, height)
7261        }
7262    }
7263
7264    #[test]
7265    fn supplemental_ocr_regions_require_substantial_physical_images() {
7266        let substantial = supplemental_ocr_image_region(&test_image_item(200.0, 120.0)).unwrap();
7267        assert_eq!(substantial.width, 200.0);
7268        assert_eq!(substantial.height, 120.0);
7269
7270        assert!(supplemental_ocr_image_region(&test_image_item(100.0, 200.0)).is_none());
7271        assert!(supplemental_ocr_image_region(&test_image_item(200.0, 60.0)).is_none());
7272        assert!(supplemental_ocr_image_region(&test_image_item(120.0, 100.0)).is_none());
7273        assert!(
7274            supplemental_ocr_image_region(&test_item("text", 0.0, 0.0, 300.0, 300.0)).is_none()
7275        );
7276    }
7277
7278    #[test]
7279    fn supplemental_ocr_regions_normalize_negative_image_dimensions() {
7280        let region = supplemental_ocr_image_region(&test_image_item(-200.0, -120.0)).unwrap();
7281        assert_eq!(region.x, -180.0);
7282        assert_eq!(region.y, -90.0);
7283        assert_eq!(region.width, 200.0);
7284        assert_eq!(region.height, 120.0);
7285    }
7286
7287    #[test]
7288    fn removed_sparse_folios_leave_no_layout_evidence() {
7289        let items = vec![
7290            test_item("1", 25.0, 20.0, 12.0, 10.0),
7291            test_item("2", 520.0, 60.0, 12.0, 10.0),
7292        ];
7293        let (filtered, _, _) =
7294            extractor::filter_markdown_page_numbers_with_removed_pages(items.clone(), 1);
7295        assert!(filtered.is_empty());
7296
7297        let filtered = compute_layout_complexity(&items, &filtered, &[], &[]);
7298
7299        assert!(!filtered.is_complex);
7300        assert!(filtered.pages_with_tables.is_empty());
7301        assert!(filtered.pages_with_columns.is_empty());
7302    }
7303
7304    #[test]
7305    fn dense_chart_panel_is_not_reported_as_a_table() {
7306        let mut items: Vec<TextItem> = (0..8)
7307            .flat_map(|row| {
7308                (0..6).map(move |column| {
7309                    test_item(
7310                        &format!("{}", row * 10 + column),
7311                        105.0 + column as f32 * 35.0,
7312                        525.0 - row as f32 * 15.0,
7313                        24.0,
7314                        10.0,
7315                    )
7316                })
7317            })
7318            .collect();
7319        for row in 0..6 {
7320            items.push(test_item(
7321                "Left column prose continues here",
7322                80.0,
7323                320.0 - row as f32 * 15.0,
7324                160.0,
7325                10.0,
7326            ));
7327            items.push(test_item(
7328                "Right column prose continues here",
7329                300.0,
7330                320.0 - row as f32 * 15.0,
7331                160.0,
7332                10.0,
7333            ));
7334        }
7335        let mut lines: Vec<PdfLine> = (0..30)
7336            .map(|column| PdfLine {
7337                x1: 100.0 + column as f32 * 8.0,
7338                y1: 400.0,
7339                x2: 100.0 + column as f32 * 8.0,
7340                y2: 550.0,
7341                page: 1,
7342            })
7343            .collect();
7344        lines.extend((0..6).map(|row| PdfLine {
7345            x1: 100.0,
7346            y1: 400.0 + row as f32 * 30.0,
7347            x2: 332.0,
7348            y2: 400.0 + row as f32 * 30.0,
7349            page: 1,
7350        }));
7351        let rects = vec![PdfRect {
7352            x: 80.0,
7353            y: 350.0,
7354            width: 280.0,
7355            height: 240.0,
7356            page: 1,
7357        }];
7358
7359        let complexity = compute_layout_complexity(&items, &items, &rects, &lines);
7360
7361        assert!(complexity.pages_with_tables.is_empty());
7362    }
7363
7364    #[test]
7365    fn page_selection_keeps_document_wide_folio_layout_decisions() {
7366        let mut items = Vec::new();
7367        for page in 1..=4 {
7368            for row in 0..8 {
7369                let y = 20.0 + row as f32 * 8.0;
7370                let mut folio = test_item(&(row * 10 + page).to_string(), 25.0, y, 12.0, 10.0);
7371                folio.page = page;
7372                let mut footer =
7373                    test_item(&format!("Footer row {row} summary"), 43.0, y, 470.0, 10.0);
7374                footer.page = page;
7375                let mut body = test_item(&format!("Body{row}"), 530.0, y, 55.0, 10.0);
7376                body.page = page;
7377                items.extend([folio, footer, body]);
7378            }
7379        }
7380
7381        let page_one_items: Vec<_> = items
7382            .iter()
7383            .filter(|item| item.page == 1)
7384            .cloned()
7385            .collect();
7386        let (page_local_layout, _, _) =
7387            extractor::filter_markdown_page_numbers_with_removed_pages(page_one_items.clone(), 4);
7388        let page_local = compute_layout_complexity(&page_one_items, &page_local_layout, &[], &[]);
7389        assert!(
7390            page_local.pages_with_columns.contains(&1),
7391            "fixture must reproduce page-local folio column evidence"
7392        );
7393
7394        let selected =
7395            select_items_with_document_folio_context(items, 4, Some(&HashSet::from([1])));
7396        assert_eq!(
7397            selected
7398                .removal_mask
7399                .iter()
7400                .filter(|remove| **remove)
7401                .count(),
7402            8
7403        );
7404        let document_wide =
7405            compute_layout_complexity(&selected.items, &selected.layout_items, &[], &[]);
7406        assert!(!document_wide.pages_with_columns.contains(&1));
7407    }
7408
7409    #[test]
7410    fn test_detect_encoding_issues_fffd() {
7411        assert!(detect_encoding_issues(
7412            "Some text with \u{FFFD} replacement"
7413        ));
7414    }
7415
7416    #[test]
7417    fn test_detect_encoding_issues_dollar_as_space() {
7418        // Simulates broken CMap: "$Workshop$on$Chest$Wall$Deformities$and$..."
7419        let garbled = "Last$advanced$Book$Programm$3th$Workshop$on$Chest$Wall$Deformities$and$More";
7420        assert!(detect_encoding_issues(garbled));
7421    }
7422
7423    #[test]
7424    fn test_detect_encoding_issues_financial_text() {
7425        // Legitimate dollar signs in financial text should NOT trigger
7426        let financial = "Revenue was $100M in Q1, up from $90M. Costs: $50M, $30M, $20M, $15M, $12M, $8M, $5M, $3M, $2M, $1M, $500K.";
7427        assert!(!detect_encoding_issues(financial));
7428    }
7429
7430    #[test]
7431    fn test_detect_encoding_issues_clean_text() {
7432        assert!(!detect_encoding_issues(
7433            "Normal markdown text with no issues."
7434        ));
7435    }
7436
7437    #[test]
7438    fn test_detect_encoding_issues_few_dollars() {
7439        // Under threshold of 10 total dollars — should not trigger
7440        let text = "a$b c$d e$f";
7441        assert!(!detect_encoding_issues(text));
7442    }
7443
7444    /// Real garbled output from ParseBench `text_simple__att10k.pdf`: a broken
7445    /// ToUnicode CMap shifts every character by a per-range constant, so
7446    /// "Certificate of Designations with respect to Series C" extracts as
7447    /// pure-ASCII ciphertext (issue #118).
7448    const SHIFTED_CIPHER_TEXT: &str =
7449        "8VceZWZTReVW9VdZXReZdhZeYcVdaVTeeHVcZVd8EcVWVccVUHeT:iYZSZe-(,e2'WZ]V \
7450        ;VScfRcj2&,*,*$ .'R CZdecfVehYZTYUVWZVdeYVcZXYedWY]UVcdW]X'eVcUVSeYVcVXZdecReRUR]]ed \
7451        TCBGC@ZUReVUdfSdZUZRcZVdZdWZ]VUYVcVhZeYafcdfReeVXf]ReZV0*S$.$ZZZ$6$&ViTVae \
7452        WceYVZdecfVedcVWVccVUe.'S&.'T&.'U&.'V&.'WSV]h(EfcdfReeYZdcVXf]ReZYV \
7453        cVXZdecReYVcVSjRXcVVdeWfcZdYRTajWRjdfTYZdecfVeWZ]VUYVcVhZeYeYVH:8 \
7454        cVbfVde( .'S fRcRejWTVceRZZXReZTZWZT7V]]IV]VaYV8(RUHfeYhVdeVc7V]]IV]VaYV8 \
7455        :iYZSZe.'TeYVaVcZUVUZX9VTVSVc-&,*$";
7456
7457    #[test]
7458    fn test_detect_encoding_issues_shifted_cipher_text() {
7459        assert!(detect_encoding_issues(SHIFTED_CIPHER_TEXT));
7460    }
7461
7462    #[test]
7463    fn test_shifted_cipher_below_sample_threshold_not_flagged() {
7464        // Fewer than 200 ASCII letters: not enough evidence to condemn.
7465        assert!(!detect_encoding_issues("8VceZWZTReVW9VdZXReZdhZeY"));
7466    }
7467
7468    #[test]
7469    fn test_clean_prose_not_flagged_as_cipher() {
7470        let prose = "Certificate of Designations with respect to Series C Preferred Stock, \
7471            filed February 18, 2020. No instrument which defines the rights of holders of \
7472            long-term debt of the registrant and all of its consolidated subsidiaries is \
7473            filed herewith pursuant to Regulation S-K, Item 601. Pursuant to this regulation, \
7474            the registrant hereby agrees to furnish a copy of any such instrument to the SEC \
7475            upon request.";
7476        assert!(!detect_encoding_issues(prose));
7477    }
7478
7479    #[test]
7480    fn test_camel_case_code_not_flagged_as_cipher() {
7481        let code = "The getElementById and querySelectorAll methods return DOM nodes. Use \
7482            addEventListener with removeEventListener, requestAnimationFrame with \
7483            cancelAnimationFrame, and setTimeout with clearTimeout. The XMLHttpRequest \
7484            object exposes onreadystatechange, responseText and getAllResponseHeaders. \
7485            Prefer createElement, appendChild, insertBefore and replaceChild for DOM \
7486            manipulation, and getBoundingClientRect for layout measurement.";
7487        assert!(!detect_encoding_issues(code));
7488    }
7489
7490    #[test]
7491    fn test_accented_european_text_not_flagged_as_cipher() {
7492        let swedish = "Regeringen föreslår att riksdagen antar förslaget till lag om ändring \
7493            i skatteförfarandelagen. Bestämmelserna föreslås träda i kraft den första januari. \
7494            Förslaget innebär att företag med säte i utlandet måste lämna särskilda uppgifter \
7495            till Skatteverket varje kvartal, och att avgiften höjs för överträdelser av de nya \
7496            bestämmelserna om rapporteringsskyldighet för gränsöverskridande arrangemang.";
7497        assert!(!detect_encoding_issues(swedish));
7498    }
7499
7500    #[test]
7501    fn test_all_caps_text_not_flagged_as_cipher() {
7502        let caps = "EXHIBIT INDEX PURSUANT TO ITEM 601 OF REGULATION SK CERTIFICATE OF \
7503            DESIGNATIONS WITH RESPECT TO SERIES C PREFERRED STOCK FILED FEBRUARY EIGHTEEN \
7504            TWENTY TWENTY AND INCORPORATED HEREIN BY REFERENCE TO THE ANNUAL REPORT ON FORM \
7505            TENK FOR THE PERIOD ENDED DECEMBER THIRTYFIRST TWENTY NINETEEN AS AMENDED";
7506        assert!(!detect_encoding_issues(caps));
7507    }
7508
7509    // A Caesar shift of prose that stays within a single case block does not
7510    // trigger the case-shift signal, but it permutes the letter histogram: the
7511    // frequency SHAPE stays English-like while letter POSITIONS scramble, so
7512    // the permutation signal catches it regardless of case.
7513    const CAESAR_PROSE: &str =
7514        "The registrant hereby agrees to furnish a copy of any such instrument to the \
7515        Commission upon request. This certificate of designations was filed February with \
7516        respect to Series Preferred Stock and incorporated herein by reference to the annual \
7517        report on form for the period ended December as amended and restated thereafter.";
7518
7519    fn caesar_shift(text: &str, k: u8) -> String {
7520        text.chars()
7521            .map(|c| match c {
7522                'a'..='z' => (((c as u8 - b'a' + k) % 26) + b'a') as char,
7523                'A'..='Z' => (((c as u8 - b'A' + k) % 26) + b'A') as char,
7524                _ => c,
7525            })
7526            .collect()
7527    }
7528
7529    #[test]
7530    fn test_mixed_case_caesar_shift_flagged() {
7531        assert!(detect_encoding_issues(&caesar_shift(CAESAR_PROSE, 3)));
7532    }
7533
7534    #[test]
7535    fn test_all_lowercase_caesar_shift_flagged() {
7536        // Uniform all-lowercase garbled prose: the earlier mixed-case guard
7537        // would have exempted this, so it must be caught by the case-agnostic
7538        // permutation signal instead.
7539        assert!(detect_encoding_issues(&caesar_shift(
7540            &CAESAR_PROSE.to_lowercase(),
7541            5
7542        )));
7543    }
7544
7545    #[test]
7546    fn test_all_uppercase_caesar_shift_flagged() {
7547        assert!(detect_encoding_issues(&caesar_shift(
7548            &CAESAR_PROSE.to_uppercase(),
7549            7
7550        )));
7551    }
7552
7553    #[test]
7554    fn test_dna_sequence_not_flagged_as_cipher() {
7555        // Non-linguistic ASCII: unlike English (low vowel ratio, low cosine)
7556        // but not garbled. Its 4-letter alphabet makes the frequency profile
7557        // too steep, so the shape cosine falls below the permutation threshold.
7558        let dna = "ACGT".repeat(120);
7559        assert!(!detect_encoding_issues(&dna));
7560    }
7561
7562    #[test]
7563    fn test_protein_sequence_not_flagged_as_cipher() {
7564        let protein =
7565            "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKR"
7566                .repeat(6);
7567        assert!(!detect_encoding_issues(&protein));
7568    }
7569
7570    #[test]
7571    fn test_ticker_list_not_flagged_as_cipher() {
7572        let tickers = "AAPL MSFT GOOG TSLA NVDA AMZN META NFLX AMD INTC CSCO ORCL CRM ADBE QCOM \
7573            TXN AVGO MU LRCX KLAC ASML SNPS CDNS FTNT PANW "
7574            .repeat(4);
7575        assert!(!detect_encoding_issues(&tickers));
7576    }
7577
7578    #[test]
7579    fn test_text_quality_flags_shifted_cipher_page() {
7580        let items = vec![
7581            test_text_item_on_page(1, SHIFTED_CIPHER_TEXT),
7582            test_text_item_on_page(2, "A clean second page should not be routed to OCR."),
7583        ];
7584
7585        let quality = analyze_text_quality(&items);
7586
7587        assert!(quality.has_encoding_issues);
7588        assert_eq!(quality.pages_needing_ocr, vec![1]);
7589        assert_eq!(
7590            quality.reasons_by_page.get(&1).cloned(),
7591            Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7592        );
7593    }
7594
7595    #[test]
7596    fn test_text_quality_cipher_stats_accumulate_across_items() {
7597        // The garbled page arrives as many short spans; no single span has
7598        // enough letters to flag on its own.
7599        let items: Vec<TextItem> = SHIFTED_CIPHER_TEXT
7600            .split_whitespace()
7601            .map(|chunk| test_text_item_on_page(1, chunk))
7602            .collect();
7603
7604        let quality = analyze_text_quality(&items);
7605
7606        assert_eq!(quality.pages_needing_ocr, vec![1]);
7607    }
7608
7609    #[test]
7610    fn test_text_quality_flags_localized_cid_mojibake_span() {
7611        let items = vec![
7612            test_text_item_on_page(
7613                1,
7614                "Waiting Period 等待期 Maternity and newborn infant care benefit",
7615            ),
7616            test_text_item_on_page(
7617                1,
7618                "Inpatient and Day-care Benefits DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯",
7619            ),
7620            test_text_item_on_page(1, "Covered up to annual maximum. 赔付至年度最高保额。"),
7621            test_text_item_on_page(2, "A clean second page should not be routed to OCR."),
7622        ];
7623
7624        let quality = analyze_text_quality(&items);
7625
7626        assert!(quality.has_encoding_issues);
7627        assert_eq!(quality.pages_needing_ocr, vec![1]);
7628        assert_eq!(
7629            quality.reasons_by_page.get(&1).cloned(),
7630            Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7631        );
7632    }
7633
7634    #[test]
7635    fn test_text_quality_flags_replacement_and_private_use_runs() {
7636        let items = vec![
7637            test_text_item_on_page(1, "broken \u{FFFD}\u{FFFD} text"),
7638            test_text_item_on_page(3, "\u{E000}\u{E001}\u{E002}"),
7639        ];
7640
7641        let quality = analyze_text_quality(&items);
7642
7643        assert_eq!(quality.pages_needing_ocr, vec![1, 3]);
7644        assert_eq!(
7645            quality.reasons_by_page.get(&1).cloned(),
7646            Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7647        );
7648        assert_eq!(
7649            quality.reasons_by_page.get(&3).cloned(),
7650            Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7651        );
7652    }
7653
7654    #[test]
7655    fn test_text_quality_allows_clean_multilingual_and_latin1_text() {
7656        let items = vec![
7657            test_text_item_on_page(1, "你好世界,这是一段正常的中文文本。"),
7658            test_text_item_on_page(1, "Résumé déjà vu: façade, São Paulo, año 2026."),
7659            test_text_item_on_page(1, "A single icon \u{E000} should not force OCR."),
7660        ];
7661
7662        let quality = analyze_text_quality(&items);
7663
7664        assert!(!quality.has_encoding_issues);
7665        assert!(quality.pages_needing_ocr.is_empty());
7666        assert!(quality.reasons_by_page.is_empty());
7667    }
7668
7669    #[test]
7670    fn test_text_quality_allows_toc_leaders_form_rules_and_short_math() {
7671        let items = vec![
7672            test_text_item_on_page(
7673                1,
7674                "Feature Overview ........................................................................................................ 1-5",
7675            ),
7676            test_text_item_on_page(1, "Signature __________________________________________"),
7677            test_text_item_on_page(1, "__________________________________________________"),
7678            test_text_item_on_page(1, "2×()×"),
7679        ];
7680
7681        let quality = analyze_text_quality(&items);
7682
7683        assert!(!quality.has_encoding_issues);
7684        assert!(quality.pages_needing_ocr.is_empty());
7685    }
7686
7687    #[test]
7688    fn test_text_quality_allows_isolated_replacement_character() {
7689        let items = vec![
7690            test_text_item_on_page(1, "\u{FFFD}2026 FINRA"),
7691            test_text_item_on_page(1, "A mostly clean page should not be sent to OCR."),
7692        ];
7693
7694        let quality = analyze_text_quality(&items);
7695
7696        assert!(!quality.has_encoding_issues);
7697        assert!(quality.pages_needing_ocr.is_empty());
7698    }
7699
7700    #[test]
7701    fn test_text_quality_allows_formula_replacement_on_clean_page() {
7702        let items = vec![
7703            test_text_item_on_page(
7704                1,
7705                "The LCOE of a power plant can be decomposed into three parts and described in prose.",
7706            ),
7707            test_text_item_on_page(
7708                1,
7709                "This page has enough normal text that a damaged equation should not force OCR.",
7710            ),
7711            test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} = x + y"),
7712            test_text_item_on_page(1, "More normal explanatory text follows after the formula."),
7713        ];
7714
7715        let quality = analyze_text_quality(&items);
7716
7717        assert!(!quality.has_encoding_issues);
7718        assert!(quality.pages_needing_ocr.is_empty());
7719    }
7720
7721    #[test]
7722    fn test_text_quality_flags_dense_replacement_text_page() {
7723        let items = vec![
7724            test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} broken layer"),
7725            test_text_item_on_page(1, "more \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} broken text"),
7726            test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD}"),
7727        ];
7728
7729        let quality = analyze_text_quality(&items);
7730
7731        assert_eq!(quality.pages_needing_ocr, vec![1]);
7732        assert!(quality.has_encoding_issues);
7733    }
7734
7735    #[test]
7736    fn test_text_quality_allows_tex_ligature_c1_controls_in_words() {
7737        let items = vec![test_text_item_on_page(
7738            1,
7739            "Especialmente de\u{85}ciente es nuestro conocimiento del control. The amniotic \u{87}uid is important.",
7740        )];
7741
7742        let quality = analyze_text_quality(&items);
7743
7744        assert!(!quality.has_encoding_issues);
7745        assert!(quality.pages_needing_ocr.is_empty());
7746    }
7747
7748    #[test]
7749    fn test_region_text_quality_is_scoped_to_matched_items() {
7750        let clean_region = vec![
7751            test_text_item_on_page(1, "Clean native text"),
7752            test_text_item_on_page(1, "Résumé déjà vu"),
7753        ];
7754        let garbled_region = vec![
7755            test_text_item_on_page(1, "Clean prefix"),
7756            test_text_item_on_page(1, "DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯"),
7757        ];
7758
7759        assert!(!region_items_have_decoding_issue(&clean_region));
7760        assert!(region_items_have_decoding_issue(&garbled_region));
7761    }
7762
7763    #[test]
7764    fn test_garbage_text_detection() {
7765        // Simulates garbage output from Identity-H fonts without ToUnicode.
7766        // Needs >= 50 non-whitespace chars and < 50% alphanumeric.
7767        let garbage = ",&<X ~%5&8-!A ~*(!,-!U (/#!U X ~#/=U 9/%*(!U !(  X \
7768                       (%U-(-/ V %&((8-#&&< *,(6--< %5&8-!( (,(/! #/<5U X \
7769                       º&( >/5 /5&(#(8-!5 *,(6--( *,%@/-A W";
7770        assert!(is_garbage_text(garbage));
7771
7772        // Normal text should not be garbage
7773        let normal = "This is a normal paragraph with words and sentences that contains enough characters to pass the threshold.";
7774        assert!(!is_garbage_text(normal));
7775
7776        // Cyrillic text should not be garbage
7777        let cyrillic =
7778            "Роботизированные технологии комплексы для производства металлургических предприятий";
7779        assert!(!is_garbage_text(cyrillic));
7780    }
7781
7782    #[test]
7783    fn test_cid_garbage_detection() {
7784        // Simulates CID garbage from Identity-H fonts: Latin Extended chars
7785        // mixed with C1 control characters (U+0080–U+009F).
7786        let cid_garbage = "Ë>íÓ\tý\r\u{0088}æ&Ït\u{0094}äí;\ný;wAL¢©èåD\rü£\
7787                           qq\u{0096}¶Í Æ\réá; Ô 7G\u{008B}ý;èÕç¢ £ ý;C";
7788        assert!(
7789            is_cid_garbage(cid_garbage),
7790            "CID garbage with C1 controls should be detected"
7791        );
7792
7793        // Valid Korean text (CID-as-Unicode passthrough) should NOT be garbage
7794        let korean = "본 가격표는 국내 거주 중인 외국인을 위한 한국어 가격표의 비공식 번역본입니다";
7795        assert!(
7796            !is_cid_garbage(korean),
7797            "Valid Korean text should not be flagged as garbage"
7798        );
7799
7800        // Valid Japanese text should NOT be garbage
7801        let japanese = "羽田空港新飛行経路に係る航空機騒音の測定結果";
7802        assert!(
7803            !is_cid_garbage(japanese),
7804            "Valid Japanese text should not be flagged as garbage"
7805        );
7806
7807        let japanese_toc = "第1章 市政経営方針の位置づけ ···································· 1";
7808        assert!(
7809            !is_cid_garbage(japanese_toc),
7810            "Japanese TOC dot leaders should not be flagged as garbage"
7811        );
7812
7813        let tex_ligature = "amniotic \u{87}uid volume regulation";
7814        assert!(
7815            !is_cid_garbage(tex_ligature),
7816            "A single TeX ligature byte inside a word should not be CID garbage"
7817        );
7818    }
7819
7820    #[test]
7821    fn tsr_text_fill_does_not_pull_neighboring_overlapping_rows() {
7822        use crate::tables::structured::normalize_cell_bands;
7823        use crate::tables::StructuredCell;
7824
7825        let items = vec![
7826            test_item("Branch Name", 12.0, 88.0, 55.0, 8.0),
7827            test_item("Deposits", 112.0, 88.0, 36.0, 8.0),
7828            test_item("Oak Street", 12.0, 72.0, 48.0, 8.0),
7829            test_item("100", 112.0, 72.0, 18.0, 8.0),
7830            test_item("Boardwalk", 12.0, 55.2, 46.0, 8.0),
7831            test_item("200", 112.0, 55.2, 18.0, 8.0),
7832        ];
7833        let mut cells = vec![
7834            StructuredCell {
7835                row: 0,
7836                col: 0,
7837                rowspan: 1,
7838                colspan: 1,
7839                is_header: true,
7840                text: String::new(),
7841                page_pt_bbox: [10.0, 100.0, 100.0, 125.0],
7842            },
7843            StructuredCell {
7844                row: 0,
7845                col: 1,
7846                rowspan: 1,
7847                colspan: 1,
7848                is_header: true,
7849                text: String::new(),
7850                page_pt_bbox: [100.0, 100.0, 170.0, 125.0],
7851            },
7852            StructuredCell {
7853                row: 1,
7854                col: 0,
7855                rowspan: 1,
7856                colspan: 1,
7857                is_header: false,
7858                text: String::new(),
7859                page_pt_bbox: [10.0, 116.0, 100.0, 141.0],
7860            },
7861            StructuredCell {
7862                row: 1,
7863                col: 1,
7864                rowspan: 1,
7865                colspan: 1,
7866                is_header: false,
7867                text: String::new(),
7868                page_pt_bbox: [100.0, 116.0, 170.0, 141.0],
7869            },
7870            StructuredCell {
7871                row: 2,
7872                col: 0,
7873                rowspan: 1,
7874                colspan: 1,
7875                is_header: false,
7876                text: String::new(),
7877                page_pt_bbox: [10.0, 132.8, 100.0, 157.8],
7878            },
7879            StructuredCell {
7880                row: 2,
7881                col: 1,
7882                rowspan: 1,
7883                colspan: 1,
7884                is_header: false,
7885                text: String::new(),
7886                page_pt_bbox: [100.0, 132.8, 170.0, 157.8],
7887            },
7888        ];
7889
7890        normalize_cell_bands(&mut cells);
7891        for cell in &mut cells {
7892            let [x1, y1, x2, y2] = cell.page_pt_bbox;
7893            cell.text = collect_text_in_tsr_cell(
7894                &items,
7895                x1,
7896                y1,
7897                x2,
7898                y2,
7899                200.0,
7900                RegionCoordSpace::Standard,
7901                0.10,
7902            );
7903        }
7904
7905        assert_eq!(cells[0].text, "Branch Name");
7906        assert_eq!(cells[2].text, "Oak Street");
7907        assert_eq!(cells[4].text, "Boardwalk");
7908        assert!(!cells[0].text.contains("Oak Street"));
7909        assert!(!cells[2].text.contains("Branch Name"));
7910        assert!(!cells[2].text.contains("Boardwalk"));
7911    }
7912
7913    #[test]
7914    fn multi_row_expansion_splits_overstuffed_tsr_row() {
7915        use crate::tables::{cells_to_markdown, StructuredCell};
7916
7917        let items = vec![
7918            test_item("Branch Name", 20.0, 166.0, 55.0, 8.0),
7919            test_item("Deposits", 120.0, 166.0, 36.0, 8.0),
7920            test_item("Oak Street", 20.0, 136.0, 48.0, 8.0),
7921            test_item("100", 120.0, 136.0, 18.0, 8.0),
7922            test_item("Boardwalk", 20.0, 116.0, 46.0, 8.0),
7923            test_item("200", 120.0, 116.0, 18.0, 8.0),
7924        ];
7925        let cells = vec![
7926            StructuredCell {
7927                row: 0,
7928                col: 0,
7929                rowspan: 1,
7930                colspan: 1,
7931                is_header: true,
7932                text: "Branch Name".into(),
7933                page_pt_bbox: [10.0, 20.0, 100.0, 40.0],
7934            },
7935            StructuredCell {
7936                row: 0,
7937                col: 1,
7938                rowspan: 1,
7939                colspan: 1,
7940                is_header: true,
7941                text: "Deposits".into(),
7942                page_pt_bbox: [110.0, 20.0, 190.0, 40.0],
7943            },
7944            StructuredCell {
7945                row: 1,
7946                col: 0,
7947                rowspan: 1,
7948                colspan: 1,
7949                is_header: false,
7950                text: "Oak Street Boardwalk".into(),
7951                page_pt_bbox: [10.0, 40.0, 100.0, 100.0],
7952            },
7953            StructuredCell {
7954                row: 1,
7955                col: 1,
7956                rowspan: 1,
7957                colspan: 1,
7958                is_header: false,
7959                text: "100 200".into(),
7960                page_pt_bbox: [110.0, 40.0, 190.0, 100.0],
7961            },
7962        ];
7963
7964        let expanded =
7965            try_expand_multi_row_cells(&cells, &items, 200.0, RegionCoordSpace::Standard, 0.10)
7966                .expect("overstuffed data row should expand");
7967        let md = cells_to_markdown(&expanded);
7968
7969        assert_eq!(expanded.iter().map(|c| c.row).max().unwrap() + 1, 3);
7970        assert!(
7971            md.contains("|Oak Street|100|"),
7972            "missing first data row: {md}"
7973        );
7974        assert!(
7975            md.contains("|Boardwalk|200|"),
7976            "missing second data row: {md}"
7977        );
7978        assert!(
7979            !md.contains("Oak Street Boardwalk"),
7980            "compressed cell text should be replaced: {md}"
7981        );
7982    }
7983
7984    #[test]
7985    fn tsr_assignment_caps_uses_median_geometry() {
7986        use crate::tables::StructuredCell;
7987        let cells = vec![
7988            StructuredCell {
7989                row: 0,
7990                col: 0,
7991                rowspan: 1,
7992                colspan: 1,
7993                is_header: false,
7994                text: String::new(),
7995                page_pt_bbox: [0.0, 0.0, 100.0, 20.0], // 100x20
7996            },
7997            StructuredCell {
7998                row: 0,
7999                col: 1,
8000                rowspan: 1,
8001                colspan: 1,
8002                is_header: false,
8003                text: String::new(),
8004                page_pt_bbox: [100.0, 0.0, 200.0, 20.0],
8005            },
8006            StructuredCell {
8007                row: 1,
8008                col: 0,
8009                rowspan: 1,
8010                colspan: 1,
8011                is_header: false,
8012                text: String::new(),
8013                page_pt_bbox: [0.0, 20.0, 100.0, 40.0],
8014            },
8015        ];
8016        let (cap_x, cap_y) = tsr_assignment_caps(&cells);
8017        assert_eq!(cap_x, 100.0);
8018        assert_eq!(cap_y, 20.0);
8019    }
8020
8021    #[test]
8022    fn tsr_assignment_caps_floor_protects_degenerate_input() {
8023        use crate::tables::StructuredCell;
8024        let cells = vec![StructuredCell {
8025            row: 0,
8026            col: 0,
8027            rowspan: 1,
8028            colspan: 1,
8029            is_header: false,
8030            text: String::new(),
8031            page_pt_bbox: [0.0, 0.0, 1.0, 1.0],
8032        }];
8033        let (cap_x, cap_y) = tsr_assignment_caps(&cells);
8034        assert_eq!(cap_x, 5.0);
8035        assert_eq!(cap_y, 5.0);
8036    }
8037
8038    #[test]
8039    fn stage2_recovers_left_aligned_header_text_outside_data_band() {
8040        // Symptom A reproduction: the column band derived from data-cell
8041        // centers ends up too far right, so header text positioned at the
8042        // left of the column falls outside the band and stage 1's strict
8043        // membership rejects it. Stage 2 should re-attach by proximity.
8044        //
8045        // Item coords are bottom-left native; cell page_pt_bbox is top-left.
8046        // page_height=200 so a top-left bbox y=[88, 100] flips to native y
8047        // bounds [100, 112]; an item at native y=104 (center 108) lands in.
8048        use crate::tables::StructuredCell;
8049        let items = vec![
8050            // Header text — centered in row 0 (native y=104, center 108) but
8051            // at the LEFT of the column (x=175, far left of the [410, 700]
8052            // data-derived band).
8053            test_item("Address", 175.0, 104.0, 50.0, 8.0),
8054            // Data row 1 — fits its cell.
8055            test_item("205 W Oak St", 420.0, 84.0, 100.0, 8.0),
8056            // Data row 2 — fits its cell.
8057            test_item("155 E Boardwalk Dr", 420.0, 64.0, 100.0, 8.0),
8058        ];
8059        // Cells AFTER normalize_cell_bands would have run — col 0 band
8060        // shifted right by data-cell centers, header cell now excludes
8061        // the "Address" text at center x=200.
8062        let mut cells = vec![
8063            StructuredCell {
8064                row: 0,
8065                col: 0,
8066                rowspan: 1,
8067                colspan: 1,
8068                is_header: true,
8069                text: String::new(),
8070                page_pt_bbox: [410.0, 88.0, 700.0, 100.0],
8071            },
8072            StructuredCell {
8073                row: 1,
8074                col: 0,
8075                rowspan: 1,
8076                colspan: 1,
8077                is_header: false,
8078                text: String::new(),
8079                page_pt_bbox: [410.0, 108.0, 700.0, 116.0],
8080            },
8081            StructuredCell {
8082                row: 2,
8083                col: 0,
8084                rowspan: 1,
8085                colspan: 1,
8086                is_header: false,
8087                text: String::new(),
8088                page_pt_bbox: [410.0, 128.0, 700.0, 136.0],
8089            },
8090        ];
8091        let page_h = 200.0;
8092
8093        // Stage 1 mimic — fill cells via the strict rule, track claimed.
8094        let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8095        for cell in &mut cells {
8096            let [x1, y1, x2, y2] = cell.page_pt_bbox;
8097            let bounds = region_bounds(x1, y1, x2, y2, page_h, RegionCoordSpace::Standard);
8098            let mut matched: Vec<TextItem> = Vec::new();
8099            for (i, item) in items.iter().enumerate() {
8100                if tsr_region_contains_item(item, bounds) {
8101                    claimed.insert(i);
8102                    matched.push(item.clone());
8103                }
8104            }
8105            cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
8106        }
8107        // Header is empty after stage 1 (Address fell outside col 0 band).
8108        assert_eq!(cells[0].text, "", "header should be empty after stage 1");
8109        // Data rows already populated.
8110        assert!(
8111            cells[1].text.contains("Oak"),
8112            "data row 1 should contain Oak: got {:?}",
8113            cells[1].text
8114        );
8115        assert!(
8116            cells[2].text.contains("Boardwalk"),
8117            "data row 2 should contain Boardwalk: got {:?}",
8118            cells[2].text
8119        );
8120
8121        // Stage 2 should fill the orphan "Address" into the empty header.
8122        tsr_assign_orphan_items(
8123            &items,
8124            &mut cells,
8125            &claimed,
8126            page_h,
8127            RegionCoordSpace::Standard,
8128        );
8129        assert_eq!(cells[0].text, "Address");
8130        // Data rows must NOT have been augmented (already filled by stage 1).
8131        assert!(!cells[1].text.contains("Address"));
8132        assert!(!cells[2].text.contains("Address"));
8133    }
8134
8135    #[test]
8136    fn stage2_recovers_y_shifted_col0_in_consecutive_rows() {
8137        // Symptom B reproduction: a stretch of rows where col 0 cell bboxes
8138        // sit just above the actual branch-name text. After stage 1 those
8139        // cells are empty; stage 2 should pull the orphan items in by
8140        // y-proximity.
8141        //
8142        // page_height=800. Cells are 14pt tall in top-left; flipped native
8143        // bounds are [240,254], [220,234], [200,214]. Items sit ~1pt below
8144        // each cell's native y range (still within ~1pt of the edge), so
8145        // both center-containment and 60% overlap fail in stage 1.
8146        use crate::tables::StructuredCell;
8147        let items = vec![
8148            // Bellevue: native y=235, center 239 — just below row 0's
8149            // cell native bottom (240). Closer to row 0 than row 1.
8150            test_item("Bellevue", 30.0, 235.0, 45.0, 8.0),
8151            // Glenwood: native y=215, center 219 — just below row 1.
8152            test_item("Glenwood", 30.0, 215.0, 45.0, 8.0),
8153            // Metro Crossing: native y=195, center 199 — just below row 2.
8154            test_item("Metro Crossing", 30.0, 195.0, 70.0, 8.0),
8155        ];
8156        let mut cells = vec![
8157            StructuredCell {
8158                row: 0,
8159                col: 0,
8160                rowspan: 1,
8161                colspan: 1,
8162                is_header: false,
8163                text: String::new(),
8164                page_pt_bbox: [10.0, 546.0, 200.0, 560.0],
8165            },
8166            StructuredCell {
8167                row: 1,
8168                col: 0,
8169                rowspan: 1,
8170                colspan: 1,
8171                is_header: false,
8172                text: String::new(),
8173                page_pt_bbox: [10.0, 566.0, 200.0, 580.0],
8174            },
8175            StructuredCell {
8176                row: 2,
8177                col: 0,
8178                rowspan: 1,
8179                colspan: 1,
8180                is_header: false,
8181                text: String::new(),
8182                page_pt_bbox: [10.0, 586.0, 200.0, 600.0],
8183            },
8184        ];
8185        let page_h = 800.0;
8186
8187        let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8188        for cell in &mut cells {
8189            let [x1, y1, x2, y2] = cell.page_pt_bbox;
8190            let bounds = region_bounds(x1, y1, x2, y2, page_h, RegionCoordSpace::Standard);
8191            let mut matched: Vec<TextItem> = Vec::new();
8192            for (i, item) in items.iter().enumerate() {
8193                if tsr_region_contains_item(item, bounds) {
8194                    claimed.insert(i);
8195                    matched.push(item.clone());
8196                }
8197            }
8198            cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
8199        }
8200        // All three cells empty after stage 1 (text falls just below each).
8201        for c in &cells {
8202            assert!(
8203                c.text.is_empty(),
8204                "stage 1 should leave all cells empty: {:?}",
8205                c
8206            );
8207        }
8208
8209        tsr_assign_orphan_items(
8210            &items,
8211            &mut cells,
8212            &claimed,
8213            page_h,
8214            RegionCoordSpace::Standard,
8215        );
8216        assert_eq!(cells[0].text, "Bellevue");
8217        assert_eq!(cells[1].text, "Glenwood");
8218        assert_eq!(cells[2].text, "Metro Crossing");
8219    }
8220
8221    #[test]
8222    fn stage2_rejects_cross_line_stacking_into_same_cell() {
8223        // Two orphans on different rows of the PDF, both equidistant from
8224        // the same empty cell. Without the same-line guard they'd both stack
8225        // into that cell ("Shawnee Blue Valley Parkway" run-on); the guard
8226        // keeps the first orphan and routes the second to the next-nearest
8227        // empty cell on its own line.
8228        use crate::tables::StructuredCell;
8229        // page_h=200. Two empty cells:
8230        //   cell X (row 0): top-left y=[100, 110], native [90, 100]
8231        //   cell Y (row 1): top-left y=[112, 122], native [78, 88]
8232        let mut cells = vec![
8233            StructuredCell {
8234                row: 0,
8235                col: 0,
8236                rowspan: 1,
8237                colspan: 1,
8238                is_header: false,
8239                text: String::new(),
8240                page_pt_bbox: [10.0, 100.0, 100.0, 110.0],
8241            },
8242            StructuredCell {
8243                row: 1,
8244                col: 0,
8245                rowspan: 1,
8246                colspan: 1,
8247                is_header: false,
8248                text: String::new(),
8249                page_pt_bbox: [10.0, 112.0, 100.0, 122.0],
8250            },
8251        ];
8252        // Two orphans, different rows of the PDF (y differs by 14pt = a
8253        // full row), both 2pt outside their target cell — both within
8254        // cap_y, both equidistant-ish to cell X. Without the same-line
8255        // guard they'd both land in X.
8256        //   "Shawnee" should belong to cell X (row 0) — center y=98 is
8257        //   2pt below X's native min=100.
8258        //   "BlueValley" should belong to cell Y (row 1) — center y=84
8259        //   is 4pt above Y's native max=88.
8260        let items = vec![
8261            // Shawnee orphan — closer to X (dy=2) than Y (dy=6 from native min=78).
8262            test_item("Shawnee", 30.0, 94.0, 50.0, 8.0),
8263            // BlueValley orphan — closer to Y (dy=4) than X (dy=8 from native max=100).
8264            test_item("BlueValley", 30.0, 80.0, 60.0, 8.0),
8265        ];
8266        let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8267
8268        tsr_assign_orphan_items(
8269            &items,
8270            &mut cells,
8271            &claimed,
8272            200.0,
8273            RegionCoordSpace::Standard,
8274        );
8275        assert_eq!(cells[0].text, "Shawnee");
8276        assert_eq!(cells[1].text, "BlueValley");
8277        assert!(!cells[0].text.contains("BlueValley"));
8278        assert!(!cells[1].text.contains("Shawnee"));
8279    }
8280
8281    #[test]
8282    fn stage2_allows_same_line_orphans_to_stack_into_one_cell() {
8283        // Multi-token branch names like "Blue Valley Parkway" are 3 PDF
8284        // text items at the SAME y-coordinate. They should all stack into
8285        // the cell their row's branch-name belongs to, not get split
8286        // across rows by the cross-line guard.
8287        use crate::tables::StructuredCell;
8288        let mut cells = vec![StructuredCell {
8289            row: 0,
8290            col: 0,
8291            rowspan: 1,
8292            colspan: 1,
8293            is_header: false,
8294            text: String::new(),
8295            page_pt_bbox: [10.0, 100.0, 200.0, 110.0],
8296        }];
8297        // Three same-line items, all 2pt below the cell's native bottom.
8298        let items = vec![
8299            test_item("Blue", 30.0, 94.0, 25.0, 8.0),
8300            test_item("Valley", 60.0, 94.0, 35.0, 8.0),
8301            test_item("Parkway", 100.0, 94.0, 45.0, 8.0),
8302        ];
8303        let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8304
8305        tsr_assign_orphan_items(
8306            &items,
8307            &mut cells,
8308            &claimed,
8309            200.0,
8310            RegionCoordSpace::Standard,
8311        );
8312        // All three same-line orphans stacked into the single empty cell.
8313        assert_eq!(cells[0].text, "Blue Valley Parkway");
8314    }
8315
8316    #[test]
8317    fn stage2_does_not_overwrite_filled_cells_or_admit_far_orphans() {
8318        // Stage 2 must only fill EMPTY cells (preserves stage 1's strict
8319        // behavior on bleed cases) and must reject orphans that fall far
8320        // outside any cell (prevents pulling a figure title into a table).
8321        use crate::tables::StructuredCell;
8322        let items = vec![
8323            test_item("Real", 50.0, 100.0, 30.0, 8.0),
8324            // Far orphan — at native y=20 (page bottom edge) on a page where
8325            // the table sits around native y=92..104 (top-left y=96..108).
8326            // y-distance to nearest cell is ~70pt, far exceeding the ~12pt
8327            // cap from median row height.
8328            test_item("FigureTitle", 50.0, 20.0, 60.0, 8.0),
8329        ];
8330        let mut cells = vec![
8331            StructuredCell {
8332                row: 0,
8333                col: 0,
8334                rowspan: 1,
8335                colspan: 1,
8336                is_header: false,
8337                text: String::new(),
8338                page_pt_bbox: [40.0, 96.0, 100.0, 108.0],
8339            },
8340            StructuredCell {
8341                row: 1,
8342                col: 0,
8343                rowspan: 1,
8344                colspan: 1,
8345                is_header: false,
8346                text: "Pre-filled".to_string(),
8347                page_pt_bbox: [40.0, 116.0, 100.0, 128.0],
8348            },
8349        ];
8350        let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8351        // Pretend "Real" got claimed by a different cell (won't be re-assigned).
8352        // Don't claim "FigureTitle" — it's the far orphan.
8353        claimed.insert(0);
8354
8355        tsr_assign_orphan_items(
8356            &items,
8357            &mut cells,
8358            &claimed,
8359            200.0,
8360            RegionCoordSpace::Standard,
8361        );
8362        // Empty cell stayed empty (orphan was too far).
8363        assert_eq!(cells[0].text, "");
8364        // Pre-filled cell was not touched.
8365        assert_eq!(cells[1].text, "Pre-filled");
8366    }
8367
8368    // -- recover_startxref_pointer / find_last_valid_xref_table_start ------
8369    //
8370    // Direct unit tests on the byte-level scan, addressing review feedback
8371    // on #230: a coincidental standalone "xref" token that isn't actually
8372    // followed by a subsection header (start-id + count) must not be
8373    // treated as a real table — accepting it would let lopdf "succeed"
8374    // against a bogus offset and silently return garbled/empty content
8375    // instead of a clean error.
8376
8377    #[test]
8378    fn find_xref_rejects_standalone_token_without_subsection_header() {
8379        // "xref" appears as a real standalone word, but nothing that looks
8380        // like "<start-id> <count>" follows it.
8381        let buf = b"Please refer to the xref appendix for details.";
8382        assert_eq!(find_last_valid_xref_table_start(buf), None);
8383    }
8384
8385    #[test]
8386    fn find_xref_accepts_real_classic_table_header() {
8387        let buf = b"garbage\nxref\n0 6\n0000000000 65535 f \n%%EOF";
8388        let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
8389        assert_eq!(&buf[pos..pos + 4], b"xref");
8390        assert_eq!(&buf[pos..], b"xref\n0 6\n0000000000 65535 f \n%%EOF");
8391    }
8392
8393    #[test]
8394    fn find_xref_skips_coincidental_match_and_finds_real_table_before_it() {
8395        // A coincidental "xref" (no subsection header) appears *after* the
8396        // real table in the buffer — the scan must not stop at the first
8397        // (rightmost) standalone token it finds; it must keep looking
8398        // backward until one actually validates.
8399        let buf = b"xref\n0 3\n0000000000 65535 f \ntrailer\nsee the xref\n";
8400        let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
8401        assert_eq!(pos, 0);
8402    }
8403
8404    #[test]
8405    fn find_xref_rejects_substring_of_startxref() {
8406        // "xref" is a substring of "startxref" but isn't a standalone
8407        // token there (not preceded by whitespace) — must not match, even
8408        // though a number immediately follows it.
8409        let buf = b"startxref\n1234\n%%EOF";
8410        assert_eq!(find_last_valid_xref_table_start(buf), None);
8411    }
8412
8413    #[test]
8414    fn find_xref_rejects_count_run_with_trailing_garbage() {
8415        // "xref\n0 6garbage" has the right shape (digits, whitespace,
8416        // digits) but the count run doesn't end at whitespace/EOF — it
8417        // runs straight into non-digit garbage, so this must not be
8418        // accepted as a real subsection header.
8419        let buf = b"xref\n0 6garbage\n%%EOF";
8420        assert_eq!(find_last_valid_xref_table_start(buf), None);
8421    }
8422
8423    #[test]
8424    fn recover_startxref_pointer_returns_none_without_a_valid_table() {
8425        let buf = b"Please refer to the xref appendix for details.";
8426        assert!(recover_startxref_pointer(buf).is_none());
8427    }
8428}
8429
8430#[cfg(test)]
8431mod rotated_run_region_tests {
8432    use super::*;
8433    use crate::types::ItemType;
8434
8435    fn item(text: &str, x: f32, y: f32, width: f32, height: f32, rotation: f32) -> TextItem {
8436        TextItem {
8437            baseline_shift: 0.0,
8438            text: text.to_string(),
8439            x,
8440            y,
8441            width,
8442            height,
8443            rotation,
8444            advance_known: true,
8445            font: "Helvetica".to_string(),
8446            font_tag: "F1".to_string(),
8447            legacy_symbol_rewrite: false,
8448            font_size: if rotation == 0.0 { height } else { width },
8449            page: 1,
8450            is_bold: false,
8451            is_italic: false,
8452            font_weight: None,
8453            bold_source: None,
8454            fixed_pitch: None,
8455            fill_color: None,
8456            stroke_color: None,
8457            render_mode: None,
8458            is_underline: false,
8459            is_strikeout: false,
8460            item_type: ItemType::Text,
8461            mcid: None,
8462        }
8463    }
8464
8465    const STAMP: &str = "arXiv:2301.00001v1 [cs.CL] 1 Jan 2023";
8466
8467    #[test]
8468    fn vertical_margin_run_overlaps_only_the_margin_region() {
8469        // Letter page; region boxes are top-left page coordinates as the
8470        // layout model reports them: a left-margin strip and the body area.
8471        let page_h = 792.0;
8472        let margin = region_bounds(0.0, 0.0, 50.0, 792.0, page_h, RegionCoordSpace::Standard);
8473        let body = region_bounds(60.0, 0.0, 612.0, 792.0, page_h, RegionCoordSpace::Standard);
8474
8475        let stamp = item(STAMP, 12.0, 200.0, 20.0, 400.0, 90.0);
8476        assert!(region_overlaps_item(&stamp, margin));
8477        assert!(!region_overlaps_item(&stamp, body));
8478        assert!(region_item_overlap_area(&stamp, margin) > 0.0);
8479        assert_eq!(region_item_overlap_area(&stamp, body), 0.0);
8480    }
8481
8482    #[test]
8483    fn collect_text_in_region_finds_the_vertical_run_by_its_box() {
8484        let items = vec![
8485            item(STAMP, 12.0, 200.0, 20.0, 400.0, 90.0),
8486            item("Body text", 72.0, 500.0, 50.0, 11.0, 0.0),
8487        ];
8488        assert_eq!(
8489            collect_text_in_region(&items, 0.0, 0.0, 50.0, 792.0, 792.0),
8490            STAMP
8491        );
8492        assert_eq!(
8493            collect_text_in_region(&items, 60.0, 0.0, 612.0, 792.0, 792.0),
8494            "Body text"
8495        );
8496    }
8497
8498    #[test]
8499    fn clockwise_frame_region_bounds_follow_the_turned_page() {
8500        // A 270° header along the right margin of a Letter page: page box
8501        // x ∈ [580, 590], y ∈ [664, 700]. In the clockwise-turned frame it
8502        // sits at x = -(top), y = baseline x.
8503        let page_h = 792.0;
8504        let header = item("HEADER", -700.0, 580.0, 36.0, 10.0, 0.0);
8505        // Top-left region boxes: a strip around the header and the body.
8506        let strip = region_bounds(
8507            570.0,
8508            80.0,
8509            600.0,
8510            140.0,
8511            page_h,
8512            RegionCoordSpace::Rotated90Cw,
8513        );
8514        let body = region_bounds(
8515            60.0,
8516            80.0,
8517            500.0,
8518            700.0,
8519            page_h,
8520            RegionCoordSpace::Rotated90Cw,
8521        );
8522        assert!(region_overlaps_item(&header, strip));
8523        assert!(!region_overlaps_item(&header, body));
8524
8525        // The legacy heuristic recognises only the counter-clockwise frame
8526        // (most items at negative y); a clockwise frame is never inferred and
8527        // must be passed explicitly.
8528        assert_eq!(
8529            infer_region_coord_space(&[header.clone()]),
8530            RegionCoordSpace::Standard
8531        );
8532        assert_eq!(
8533            infer_region_coord_space(&[item("x", 100.0, -200.0, 36.0, 12.0, 0.0)]),
8534            RegionCoordSpace::Rotated90Ccw
8535        );
8536        assert_eq!(
8537            infer_region_coord_space(&[item("x", 100.0, 200.0, 36.0, 12.0, 0.0)]),
8538            RegionCoordSpace::Standard
8539        );
8540        let strip = collect_text_in_region_in_frame(
8541            &[header.clone()],
8542            570.0,
8543            80.0,
8544            600.0,
8545            140.0,
8546            page_h,
8547            PageRotation::Cw,
8548        );
8549        assert_eq!(strip, "HEADER");
8550        let misread = collect_text_in_region_in_frame(
8551            &[header.clone()],
8552            570.0,
8553            80.0,
8554            600.0,
8555            140.0,
8556            page_h,
8557            PageRotation::Upright,
8558        );
8559        assert_eq!(misread, "");
8560
8561        // Bounds round-trip back to the top-left page box they came from.
8562        for coords in [
8563            RegionCoordSpace::Standard,
8564            RegionCoordSpace::Rotated90Ccw,
8565            RegionCoordSpace::Rotated90Cw,
8566        ] {
8567            let b = region_bounds(570.0, 80.0, 600.0, 140.0, page_h, coords);
8568            let back = extracted_bbox_to_page_top_left(
8569                [b.x_min, b.y_min, b.x_max, b.y_max],
8570                page_h,
8571                coords,
8572            );
8573            assert_eq!(back, [570.0, 80.0, 600.0, 140.0], "{coords:?}");
8574        }
8575    }
8576
8577    #[test]
8578    fn tsr_line_clustering_uses_the_estimated_height_of_unknown_advances() {
8579        let estimated = STAMP.chars().count() as f32 * 0.5 * 20.0;
8580        let mut stamp = item(STAMP, 12.0, 200.0, 20.0, estimated, 90.0);
8581        stamp.advance_known = false;
8582        let lines = cluster_tsr_cell_text_lines(vec![stamp]);
8583        assert_eq!(lines.len(), 1);
8584        // Half the estimated extent, not the 2.5pt floor.
8585        assert!(
8586            (lines[0].half_height - estimated * 0.5).abs() < 1e-3,
8587            "{}",
8588            lines[0].half_height
8589        );
8590    }
8591
8592    #[test]
8593    fn vertical_run_without_font_widths_still_overlaps_its_region() {
8594        // No width information: the advance is unknown, so the run's box is
8595        // one em wide and zero tall. The estimated height must keep it
8596        // matchable instead of letting it vanish from region extraction.
8597        let page_h = 792.0;
8598        let margin = region_bounds(0.0, 0.0, 50.0, 792.0, page_h, RegionCoordSpace::Standard);
8599        // Extraction lays a half-em-per-glyph estimate along the run for a
8600        // width-less font and flags it (the content-stream tests cover the
8601        // parser side); region matching sees that box like any other.
8602        let estimated = STAMP.chars().count() as f32 * 0.5 * 20.0;
8603        let mut stamp = item(STAMP, 12.0, 200.0, 20.0, estimated, 90.0);
8604        stamp.advance_known = false;
8605        assert!(region_overlaps_item(&stamp, margin));
8606        assert!(region_item_overlap_area(&stamp, margin) > 0.0);
8607        assert!(tsr_region_contains_item(&stamp, margin));
8608    }
8609
8610    #[test]
8611    fn token_splitting_leaves_rotated_runs_whole() {
8612        let stamp = item("two words", 12.0, 200.0, 20.0, 100.0, 90.0);
8613        let tokens = split_item_into_token_subitems(&stamp);
8614        assert_eq!(tokens.len(), 1);
8615        assert_eq!(tokens[0].text, "two words");
8616        assert_eq!((tokens[0].x, tokens[0].width), (12.0, 20.0));
8617
8618        // Upside-down runs advance towards -x: interpolating from the left
8619        // edge would swap the tokens' cells.
8620        let flipped = item("two words", 72.0, 500.0, 90.0, 10.0, 180.0);
8621        assert_eq!(split_item_into_token_subitems(&flipped).len(), 1);
8622
8623        let body = item("two words", 72.0, 500.0, 90.0, 10.0, 0.0);
8624        assert_eq!(split_item_into_token_subitems(&body).len(), 2);
8625    }
8626
8627    #[test]
8628    fn token_splitting_spreads_only_an_unknown_zero_width_run() {
8629        // A known zero advance (an ActualText replacement that painted
8630        // nothing) keeps its tokens where the run was drawn; a run whose
8631        // advance is unknown is spread over half an em per character.
8632        let zero = item("two words", 72.0, 500.0, 0.0, 10.0, 0.0);
8633        let tokens = split_item_into_token_subitems(&zero);
8634        assert_eq!(tokens.len(), 2);
8635        assert_eq!((tokens[0].x, tokens[1].x), (72.0, 72.0));
8636
8637        let mut unknown = zero;
8638        unknown.advance_known = false;
8639        let tokens = split_item_into_token_subitems(&unknown);
8640        assert_eq!(tokens.len(), 2);
8641        assert!(tokens[1].x > tokens[0].x + 15.0, "{tokens:?}");
8642    }
8643}