Skip to main content

xberg_native_pdf/extractors/
auto.rs

1//! Comprehensive auto-extraction — public vocabulary.
2//!
3//! The configured-once [`AutoExtractor`] returns *all* recoverable text
4//! from a PDF — native text **and** text embedded in images,
5//! image-tables and figures — decided per-page/per-region, with a
6//! machine-readable [`ReasonCode`] for every degraded result.
7//!
8//! This module is the **dependency root** and is *pure PDF inspection*:
9//! it carries no OCR feature gate — this crate has never had one — and is
10//! fully testable (00-common-foundation §5/§9). The
11//! classification signal model (T2/T3) and the [`AutoExtractor`]
12//! pipeline (T4–T8) build on these types.
13//!
14//! Design contracts (api-design.md §3, README "Locked decisions"):
15//! - **Strictly additive** — existing `extract_text`/`extract_spans`/…
16//!   are byte-identical; this is a *new* surface.
17//! - **Single mode knob** [`ExtractMode`] (Tika/unstructured pattern),
18//!   not boolean soup.
19//! - **Typed reason per region** — never a bare empty string; degraded
20//!   results always say *why* (the #1 cross-tool user pain).
21//! - **Graceful, never fail-loud** — OCR unavailable → warn + native
22//!   fallback + [`ReasonCode::OcrRequestedButUnavailable`] (best-effort
23//!   extraction degrades gracefully; only security ops fail-closed).
24//! - All public types are `#[non_exhaustive]` + `serde` (the JSON
25//!   C-ABI boundary, matching the shipped split-by-bookmarks idiom) so
26//!   later tiers (T2/T3) are additive and non-breaking.
27
28use serde::{Deserialize, Serialize};
29
30/// How [`AutoExtractor`](crate::extractors::auto) decides text-vs-OCR.
31///
32/// One knob collapses off/auto/force (Tika `OCR_STRATEGY` /
33/// unstructured `strategy` — the cleanest mode model; the multi-boolean
34/// designs are the ones that caused upstream bugs). `#[non_exhaustive]`
35/// → a future tier is a non-breaking addition (T2/T3 are deliberately
36/// *not* present — `tier-model-strategy.md`).
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39#[non_exhaustive]
40pub enum ExtractMode {
41    /// Native text layer only — never OCR (≈ today's `extract_text`).
42    TextOnly,
43    /// Per-region heuristic: text-layer vs OCR vs image-table recovery.
44    /// The default (README locked decision 2).
45    #[default]
46    Auto,
47    /// OCR everything, ignore any native text layer (the
48    /// "suspect text layer" forced-OCR escape hatch).
49    ForceOcr,
50}
51
52/// Options for [`AutoExtractor`](crate::extractors::auto).
53///
54/// Plain struct + `Default` + `#[non_exhaustive]` (house style:
55/// `RedactionOptions`/`SplitByBookmarksOptions`); construct with
56/// struct-update (`..Default::default()`), a preset
57/// ([`fast`](Self::fast)/[`balanced`](Self::balanced)/
58/// [`high_fidelity`](Self::high_fidelity)), or the
59/// [`builder`](Self::builder) (mirrors the in-repo `OcrConfigBuilder`).
60/// `serde` is the JSON config wire at the C-ABI.
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62#[serde(default, rename_all = "snake_case")]
63#[non_exhaustive]
64pub struct AutoExtractOptions {
65    /// Text-vs-OCR mode. Default [`ExtractMode::Auto`].
66    pub mode: ExtractMode,
67    /// Reconstruct image-tables into a structured [`TableData`] via the
68    /// existing spatial detector over OCR spans (default `true`).
69    pub reconstruct_image_tables: bool,
70    /// Emit positioned `Figure`/`Table` placeholders in the text flow
71    /// so reading order is never silently corrupted (default `true`).
72    pub emit_placeholders: bool,
73    /// Auto-decision confidence threshold (`None` = preset/calibrated).
74    pub min_text_confidence: Option<f32>,
75    /// Image-table reconstruction confidence threshold (`None` =
76    /// preset/calibrated).
77    pub table_confidence: Option<f32>,
78    /// Force OCR on these **0-based** page indices regardless of `mode`
79    /// (additive on `Auto`; does not change [`ExtractMode`]). Empty =
80    /// honour `mode` for every page.
81    pub force_ocr_pages: Vec<usize>,
82}
83
84impl Default for AutoExtractOptions {
85    fn default() -> Self {
86        // `balanced()` is the documented default (README locked dec. 2;
87        // api-design.md §3). ~keep
88        Self::balanced()
89    }
90}
91
92impl AutoExtractOptions {
93    /// Text-layer biased, no layout/table work — the cheapest path.
94    #[must_use]
95    pub fn fast() -> Self {
96        Self {
97            mode: ExtractMode::TextOnly,
98            reconstruct_image_tables: false,
99            emit_placeholders: true,
100            min_text_confidence: None,
101            table_confidence: None,
102            force_ocr_pages: Vec::new(),
103        }
104    }
105
106    /// The default — `Auto` per-region routing with image-table
107    /// reconstruction (calibrated thresholds).
108    #[must_use]
109    pub fn balanced() -> Self {
110        Self {
111            mode: ExtractMode::Auto,
112            reconstruct_image_tables: true,
113            emit_placeholders: true,
114            min_text_confidence: None,
115            table_confidence: None,
116            force_ocr_pages: Vec::new(),
117        }
118    }
119
120    /// Aggressive OCR + image-table recovery (still local-CPU T1; no
121    /// VLM/cloud — `tier-model-strategy.md`).
122    #[must_use]
123    pub fn high_fidelity() -> Self {
124        Self {
125            mode: ExtractMode::Auto,
126            reconstruct_image_tables: true,
127            emit_placeholders: true,
128            // Lower bars → escalate to OCR / table recovery sooner. ~keep
129            min_text_confidence: Some(0.55),
130            table_confidence: Some(0.45),
131            force_ocr_pages: Vec::new(),
132        }
133    }
134
135    /// Fluent builder (mirrors the in-repo `OcrConfigBuilder`).
136    #[must_use]
137    pub fn builder() -> AutoExtractOptionsBuilder {
138        AutoExtractOptionsBuilder::new()
139    }
140}
141
142/// Fluent builder for [`AutoExtractOptions`] — same shape as the
143/// canonical in-repo `OcrConfigBuilder` (`mut self -> Self`, validating
144/// setters, terminal `build`).
145#[derive(Debug, Clone, Default)]
146pub struct AutoExtractOptionsBuilder {
147    opts: AutoExtractOptions,
148}
149
150impl AutoExtractOptionsBuilder {
151    /// Start from the [`balanced`](AutoExtractOptions::balanced) preset.
152    #[must_use]
153    pub fn new() -> Self {
154        Self {
155            opts: AutoExtractOptions::balanced(),
156        }
157    }
158
159    /// Set the text-vs-OCR [`ExtractMode`].
160    #[must_use]
161    pub fn mode(mut self, mode: ExtractMode) -> Self {
162        self.opts.mode = mode;
163        self
164    }
165
166    /// Toggle image-table reconstruction.
167    #[must_use]
168    pub fn reconstruct_image_tables(mut self, yes: bool) -> Self {
169        self.opts.reconstruct_image_tables = yes;
170        self
171    }
172
173    /// Toggle figure/table placeholders in the text flow.
174    #[must_use]
175    pub fn emit_placeholders(mut self, yes: bool) -> Self {
176        self.opts.emit_placeholders = yes;
177        self
178    }
179
180    /// Auto-decision confidence threshold (clamped to `0.0..=1.0`).
181    #[must_use]
182    pub fn min_text_confidence(mut self, c: f32) -> Self {
183        self.opts.min_text_confidence = Some(c.clamp(0.0, 1.0));
184        self
185    }
186
187    /// Image-table confidence threshold (clamped to `0.0..=1.0`).
188    #[must_use]
189    pub fn table_confidence(mut self, c: f32) -> Self {
190        self.opts.table_confidence = Some(c.clamp(0.0, 1.0));
191        self
192    }
193
194    /// Force OCR on these 0-based page indices (additive on `mode`).
195    #[must_use]
196    pub fn force_ocr_pages<I: IntoIterator<Item = usize>>(mut self, pages: I) -> Self {
197        self.opts.force_ocr_pages = pages.into_iter().collect();
198        self
199    }
200
201    /// Finalise.
202    #[must_use]
203    pub fn build(self) -> AutoExtractOptions {
204        self.opts
205    }
206}
207
208/// Per-page classification outcome (T0/T0.5 — pure inspection).
209#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211#[non_exhaustive]
212pub enum PageKind {
213    /// Usable native text dominates — extract the text layer.
214    TextLayer,
215    /// Image-dominated, no/garbled text — OCR the page.
216    Scanned,
217    /// Native text **and** image regions containing text — hybrid
218    /// (native + region OCR).
219    ImageText,
220    /// Heterogeneous within the page (text + image-table/figure).
221    Mixed,
222    /// Blank/near-empty — neither extract nor OCR; not an error.
223    Empty,
224}
225
226/// Machine-readable *why* for a region/page result — **the #1
227/// cross-tool user-pain fix**. A non-degraded result is
228/// [`Ok`](ReasonCode::Ok); any degraded one **must** name the cause.
229/// `#[non_exhaustive]`, frozen `snake_case` wire tokens (append-only —
230/// never renumber/rename, the `PadesLevel` lesson).
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233#[non_exhaustive]
234pub enum ReasonCode {
235    /// Extracted cleanly.
236    Ok,
237    /// Native text present and high-confidence.
238    NativeTextHighConfidence,
239    /// No text layer on the page at all.
240    NoTextLayerPresent,
241    /// Text layer present but below the usable-quality threshold.
242    TextLayerBelowThreshold,
243    /// Glyphs without usable ToUnicode/`(cid:NN)`/garbled mapping.
244    GlyphMappingMissing,
245    /// Encrypted and not authorised to extract.
246    EncryptedNoExtractPermission,
247    /// An image-table was reconstructed into [`TableData`].
248    ImageTableReconstructed,
249    /// A table region was detected but structure could not be
250    /// recovered (graceful — heuristic fallback used, not fail-loud).
251    ImageTableNoStructure,
252    /// A chart/figure was detected; its internal data is **not**
253    /// transcribed (honest scope boundary — placeholder emitted).
254    ChartNotTranscribed,
255    /// OCR was needed but unavailable (feature off / models absent /
256    /// `mode = TextOnly`) → fell back to native text + warned.
257    OcrRequestedButUnavailable,
258    /// OCR ran but confidence was low → native used/merged.
259    OcrLowConfidenceFallback,
260    /// Region/page yielded no recoverable content.
261    Empty,
262}
263
264/// Where a region's text came from. `#[non_exhaustive]`.
265#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "snake_case")]
267#[non_exhaustive]
268pub enum ExtractSource {
269    /// xberg-native-pdf native text-layer extraction.
270    NativeText,
271    /// OCR (region or full page).
272    Ocr,
273    /// Structured image-table reconstruction.
274    ImageTableRecovery,
275    /// Degraded fallback to native after an unavailable/low-confidence
276    /// higher tier (paired with a non-`Ok` [`ReasonCode`]).
277    Fallback,
278}
279
280/// Region classification. `#[non_exhaustive]`.
281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283#[non_exhaustive]
284pub enum RegionKind {
285    /// Body / inline text.
286    Text,
287    /// Heading (from structure tree or font-size heuristic).
288    Heading,
289    /// Tabular region (native or reconstructed image-table).
290    Table,
291    /// Figure/illustration (text recovered via region OCR if any).
292    Figure,
293    /// Chart/plot — internal data not transcribed (honest boundary).
294    Chart,
295}
296
297/// A 4-point quadrilateral bounding box in PDF points (top-left,
298/// top-right, bottom-right, bottom-left). A quad (not an AABB) so
299/// skewed/rotated regions survive (cases I/J — Textract/Azure
300/// convention). `serde` for the JSON C-ABI boundary.
301#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
302pub struct Quad {
303    /// `[tl, tr, br, bl]`, each `[x, y]` in PDF points.
304    pub points: [[f32; 2]; 4],
305}
306
307impl Quad {
308    /// Axis-aligned quad from `(x, y, w, h)` (PDF points, origin
309    /// bottom-left).
310    #[must_use]
311    pub fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Self {
312        Self {
313            points: [[x, y + h], [x + w, y + h], [x + w, y], [x, y]],
314        }
315    }
316}
317
318/// Structured table payload (serde-friendly — what crosses the JSON
319/// C-ABI). Decoupled from the internal
320/// `structure::table_extractor::Table` on purpose: T7 maps the spatial
321/// detector's output into this; consumers get cells + markdown.
322#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324#[non_exhaustive]
325pub struct TableData {
326    /// Row-major cell text.
327    pub rows: Vec<Vec<String>>,
328    /// First row is a header.
329    pub has_header: bool,
330    /// GitHub-flavoured Markdown rendering of the table.
331    pub markdown: String,
332}
333
334/// One classified, extracted region of a page.
335#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337#[non_exhaustive]
338pub struct Region {
339    /// 4-point bbox in PDF points.
340    pub bbox: Quad,
341    /// Region classification.
342    pub kind: RegionKind,
343    /// Recovered text (`""` if none — e.g. an un-transcribed chart;
344    /// the bbox + reason still locate it for reading order).
345    pub text: String,
346    /// Reconstructed table (only for [`RegionKind::Table`]).
347    pub table: Option<TableData>,
348    /// Confidence `0.0..=1.0`, always present.
349    pub confidence: f32,
350    /// Where the text came from.
351    pub source: ExtractSource,
352    /// Why this source/outcome (typed; never opaque).
353    pub reason: ReasonCode,
354}
355
356/// Document-level rollup status. `#[non_exhaustive]`.
357#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "snake_case")]
359#[non_exhaustive]
360pub enum ExtractionStatus {
361    /// Every region extracted cleanly.
362    Complete,
363    /// Some regions degraded (see per-region [`ReasonCode`]) but text
364    /// was recovered — *not* an error (Docling `PARTIAL_SUCCESS`).
365    PartialSuccess,
366    /// No usable text recovered anywhere.
367    NoTextRecovered,
368}
369
370/// Per-page extraction result.
371#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373#[non_exhaustive]
374pub struct PageExtraction {
375    /// 0-based page index.
376    pub page: usize,
377    /// Per-page classification.
378    pub kind: PageKind,
379    /// Assembled, reading-ordered text for the page.
380    pub text: String,
381    /// Per-region results (bbox + reason always present, even when
382    /// `text` is empty — pain #2/#8).
383    pub regions: Vec<Region>,
384    /// Page-level confidence `0.0..=1.0`.
385    pub confidence: f32,
386    /// Page-level rollup reason.
387    pub reason: ReasonCode,
388    /// Whether OCR actually ran for this page.
389    pub ocr_used: bool,
390    /// Page-level status.
391    pub status: ExtractionStatus,
392}
393
394/// Whole-document extraction result.
395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
396#[serde(rename_all = "snake_case")]
397#[non_exhaustive]
398pub struct DocumentExtraction {
399    /// Per-page results (per-page decision — never one forced doc mode;
400    /// case Q).
401    pub pages: Vec<PageExtraction>,
402    /// Document rollup status.
403    pub status: ExtractionStatus,
404    /// 0-based page indices a cheap preflight says need OCR.
405    pub pages_needing_ocr: Vec<usize>,
406}
407
408/// Lightweight document classification (the cheap `classify_document`
409/// preflight — no OCR, no rasterisation).
410#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412#[non_exhaustive]
413pub struct DocumentClassification {
414    /// Per-page kinds.
415    pub pages: Vec<PageKind>,
416    /// 0-based page indices needing OCR.
417    pub pages_needing_ocr: Vec<usize>,
418    /// Aggregate `mostly_text` / `mostly_scanned` / `mixed`.
419    pub summary: DocumentSummary,
420}
421
422/// Aggregate document character. `#[non_exhaustive]`.
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425#[non_exhaustive]
426pub enum DocumentSummary {
427    /// Predominantly born-digital text.
428    MostlyText,
429    /// Predominantly scanned/image.
430    MostlyScanned,
431    /// Heterogeneous (case Q).
432    Mixed,
433    /// No meaningful content.
434    Empty,
435}
436
437// ───────────────────────── classifier (T2/T2.5/T3) ─────────────────────────
438//
439// Pure inspection of xberg-native-pdf *internals* — never the flattened output
440// string (00-common-foundation §9). Lowest-level decision logic takes
441// **injected primitives** so it is unit-testable without a
442// `PdfDocument` (00-common-foundation §8 — the `sanitize_catalog`
443// injected-resolver precedent). `PdfDocument::classify_page` /
444// `classify_document` (in `document.rs`) gather the internal signals and
445// delegate here. No OCR feature gate — this crate has never had one. ~keep
446
447/// Dominant raster codec on a page — a strong scan-vs-pictorial prior.
448#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
449#[serde(rename_all = "snake_case")]
450#[non_exhaustive]
451pub enum ImageCodecClass {
452    /// No raster images.
453    None,
454    /// CCITT Group 3/4 fax — 1-bit, almost always a scan.
455    Ccitt,
456    /// JBIG2 — 1-bit, almost always a scan.
457    Jbig2,
458    /// DCT/JPEG — photo or colour scan.
459    Dct,
460    /// Flate/other raster.
461    Other,
462}
463
464/// Document-level scanner-vs-authoring prior (case P). Weak, never
465/// decisive — only a tie-break nudge.
466#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "snake_case")]
468#[non_exhaustive]
469pub enum ProducerPrior {
470    /// Producer/Creator looks like scanner/OCR software.
471    Scanner,
472    /// Producer/Creator looks like authoring software.
473    Authoring,
474    /// Unknown / no Info.
475    Unknown,
476}
477
478/// Per-page signals gathered from xberg-native-pdf internals (T2). All ratios
479/// are `0.0..=1.0`. Serde so it can ride the explainable result.
480#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
481#[serde(rename_all = "snake_case")]
482#[non_exhaustive]
483pub struct PageSignals {
484    /// Non-artifact glyph count from the native text layer.
485    pub text_glyph_count: usize,
486    /// Σ text-box area ∩ page ÷ page area.
487    pub text_area_ratio: f32,
488    /// Σ CTM-transformed image-box area ∩ page ÷ page area (clamped;
489    /// summed not max'd → multi-strip scans, case J, are caught).
490    pub image_area_ratio: f32,
491    /// Dominant raster codec.
492    pub codec: ImageCodecClass,
493    /// Tr-mode-3 (invisible) glyphs ÷ all glyphs — the OCR-sidecar /
494    /// painted-over signal (cases C/C2).
495    pub invisible_text_ratio: f32,
496    /// (U+FFFD + control + replacement) ÷ chars — `(cid:NN)`/garbled.
497    pub garbled_ratio: f32,
498    /// Short-fragmented-"word" ratio (broken CMaps split every glyph).
499    pub fragmented_word_ratio: f32,
500    /// Consecutive/duplicated-token run ratio — 2-column born-digital
501    /// scramble (the T0.5 addition, research §3a).
502    pub consecutive_repeat_ratio: f32,
503    /// Path-ish ops ÷ all content ops — outlined/vectorised text
504    /// (case F).
505    pub vector_path_density: f32,
506    /// `MarkInfo.marked && !suspects` — high-precision digital prior.
507    pub has_reliable_structure: bool,
508    /// Scanner-vs-authoring producer prior (weak).
509    pub producer_prior: ProducerPrior,
510    /// No text, no significant image, no paths.
511    pub page_is_empty: bool,
512}
513
514/// Cheap per-page classification (the `classify_page` preflight — no
515/// OCR, no rasterisation): kind + confidence + typed reason + the raw
516/// signals (explainable; our differentiator over downstream wrappers).
517#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
518#[serde(rename_all = "snake_case")]
519#[non_exhaustive]
520pub struct PageClassification {
521    /// 0-based page index.
522    pub page: usize,
523    /// Decided page kind.
524    pub kind: PageKind,
525    /// Confidence `0.0..=1.0`.
526    pub confidence: f32,
527    /// Typed reason for the decision.
528    pub reason: ReasonCode,
529    /// Raw signals (explainability).
530    pub signals: PageSignals,
531}
532
533/// Whether `text` is CJK/Hangul-dominant: a majority of its non-whitespace
534/// characters fall in the Han, Hiragana, Katakana, or Hangul Unicode
535/// blocks. These scripts don't use inter-word spaces, so glyph-adjacency
536/// word clustering naturally produces short (often 1-2 character) tokens —
537/// a `frag`/`avg_word_len` signal calibrated for space-separated Latin
538/// text misreads that as fragmentation. Used to skip the Latin-specific
539/// checks in [`text_quality_gate`] for such text; script-agnostic signals
540/// (garbled/repeat ratio) still apply normally.
541#[must_use]
542pub fn is_cjk_dominant_text(text: &str) -> bool {
543    let mut total = 0usize;
544    let mut cjk = 0usize;
545    for c in text.chars() {
546        if c.is_whitespace() {
547            continue;
548        }
549        total += 1;
550        let cp = c as u32;
551        if (0x4E00..=0x9FFF).contains(&cp)   // CJK Unified Ideographs ~keep
552            || (0x3400..=0x4DBF).contains(&cp) // CJK Extension A ~keep
553            || (0x3040..=0x309F).contains(&cp) // Hiragana ~keep
554            || (0x30A0..=0x30FF).contains(&cp) // Katakana ~keep
555            || (0xAC00..=0xD7A3).contains(&cp)
556        // Hangul Syllables ~keep
557        {
558            cjk += 1;
559        }
560    }
561    total > 0 && (cjk as f32 / total as f32) > 0.5
562}
563
564/// T0.5 enriched text-quality gate (research §3a).
565/// Pure: operates on the native-extracted text only. Returns the
566/// degrading [`ReasonCode`] if the "born-digital" text is unusable, or
567/// `None` if it is good.
568///
569/// Signals (thresholds are conservative defaults; presets/calibration
570/// own tuning — we encode the signals, not 16 user knobs):
571/// - U+FFFD / replacement / control ratio (`(cid:NN)`/garbled);
572/// - **critical** short-fragmented-word ratio — a hard trigger that
573///   overrides everything else;
574/// - **consecutive-repeat / column-scramble** ratio;
575/// - average word length & alphanumeric ratio;
576/// - Unicode-block-aware script validity (no ASCII bias — case H).
577#[must_use]
578pub fn text_quality_gate(text: &str) -> Option<ReasonCode> {
579    let chars: Vec<char> = text.chars().collect();
580    let total = chars.len();
581    if total < 16 {
582        // Too little to judge; let the coverage cascade decide. ~keep
583        return None;
584    }
585    // Half-open `..` ranges are intentional: HT(0x09) LF(0x0A) VT(0x0B)
586    // FF(0x0C) CR(0x0D) and SP(0x20) are legitimate whitespace and are
587    // deliberately NOT counted as garbled (the range stops *before*
588    // 0x09 and *before* 0x20). ~keep
589    let bad = chars
590        .iter()
591        .filter(|&&c| {
592            c == '\u{FFFD}'
593                || ('\u{0}'..'\u{9}').contains(&c)
594                || ('\u{E}'..'\u{20}').contains(&c)
595                || ('\u{E000}'..='\u{F8FF}').contains(&c) // private-use (notdef/tofu) ~keep
596        })
597        .count();
598    let garbled_ratio = bad as f32 / total as f32;
599    if garbled_ratio > 0.20 {
600        return Some(ReasonCode::GlyphMappingMissing);
601    }
602
603    let words: Vec<&str> = text.split_whitespace().collect();
604    if words.len() >= 8 {
605        let frag = words.iter().filter(|w| w.chars().count() <= 2).count() as f32 / words.len() as f32;
606        // CJK/Hangul text has no inter-word spaces, so glyph-adjacency
607        // clustering naturally produces short tokens — `frag` and
608        // `avg_word_len` below are calibrated for space-separated Latin
609        // text and would otherwise misread ordinary dense CJK prose as
610        // fragmented. The repeat-ratio check (script-agnostic) still
611        // applies normally. ~keep
612        let cjk_dominant = is_cjk_dominant_text(text);
613        // Critical hard-trigger (broken CMap splitting every glyph). ~keep
614        if frag > 0.80 && !cjk_dominant {
615            return Some(ReasonCode::GlyphMappingMissing);
616        }
617        // Consecutive-repeat / 2-column scramble: long runs of the
618        // same token, or duplicated adjacent tokens. ~keep
619        let mut repeats = 0usize;
620        for w in words.windows(2) {
621            if w[0] == w[1] {
622                repeats += 1;
623            }
624        }
625        let repeat_ratio = repeats as f32 / words.len() as f32;
626        let avg_word_len = words.iter().map(|w| w.chars().count()).sum::<usize>() as f32 / words.len() as f32;
627        if repeat_ratio > 0.30 || (frag > 0.55 && avg_word_len < 2.5 && !cjk_dominant) {
628            return Some(ReasonCode::TextLayerBelowThreshold);
629        }
630    }
631
632    let alnum = chars.iter().filter(|c| c.is_alphanumeric()).count();
633    if (alnum as f32 / total as f32) < 0.20 {
634        return Some(ReasonCode::TextLayerBelowThreshold);
635    }
636    None
637}
638
639/// T3 decision cascade — pure, from gathered [`PageSignals`] + config.
640/// Deterministic ordered guards then a coverage/density verdict (KISS:
641/// a rule cascade, not ML). Returns `(kind, confidence, reason)`.
642#[must_use]
643pub fn classify_from_signals(s: &PageSignals, cfg: &AutoExtractOptions) -> (PageKind, f32, ReasonCode) {
644    // Calibrated defaults (research §3/§4); presets/cfg override. ~keep
645    let min_text_conf = cfg.min_text_confidence.unwrap_or(0.70);
646    let scan_cover_min = 0.80_f32;
647    let sparse_text_max = 0.10_f32;
648    let min_glyphs = 24_usize;
649
650    if s.page_is_empty {
651        return (PageKind::Empty, 0.99, ReasonCode::Empty);
652    }
653
654    let usable_text = s.text_glyph_count >= min_glyphs && s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;
655
656    // Outlined/vectorised text (case F): paths dominate, ~no text/raster. ~keep
657    if !usable_text && s.vector_path_density > 0.60 && s.image_area_ratio < 0.20 {
658        return (PageKind::Scanned, 0.80, ReasonCode::NoTextLayerPresent);
659    }
660
661    if let Some(verdict) = scan_dominant_verdict(s, usable_text, scan_cover_min, sparse_text_max) {
662        return verdict;
663    }
664
665    // Genuine hybrid: real text AND sub-page image region(s) (cases D/S). ~keep
666    if usable_text && s.image_area_ratio > 0.05 && s.image_area_ratio < scan_cover_min {
667        return (PageKind::ImageText, 0.75, ReasonCode::Ok);
668    }
669
670    // Usable native text dominates (case A, good sidecar). ~keep
671    if usable_text {
672        let mut conf = min_text_conf.max(0.80);
673        if s.has_reliable_structure {
674            conf = (conf + 0.10).min(0.99);
675        }
676        return (PageKind::TextLayer, conf, ReasonCode::NativeTextHighConfidence);
677    }
678
679    if s.text_glyph_count > 0 {
680        return low_glyph_count_verdict(s);
681    }
682
683    let conf = match s.producer_prior {
684        ProducerPrior::Scanner => 0.85,
685        _ => 0.70,
686    };
687    (PageKind::Scanned, conf, ReasonCode::NoTextLayerPresent)
688}
689
690/// Verdict for a scan-dominant page (`image_area_ratio >= scan_cover_min`):
691/// either an OCR sidecar already covers it, or the text present is too
692/// sparse relative to the scan to outweigh it. Returns `None` when the page
693/// isn't scan-dominant, or is but neither sub-case applies (the caller then
694/// falls through to the next guard in the cascade). Split out of
695/// [`classify_from_signals`] purely to keep that function's overall cascade
696/// legible. ~keep
697fn scan_dominant_verdict(
698    s: &PageSignals,
699    usable_text: bool,
700    scan_cover_min: f32,
701    sparse_text_max: f32,
702) -> Option<(PageKind, f32, ReasonCode)> {
703    if s.image_area_ratio < scan_cover_min {
704        return None;
705    }
706    // OCR sidecar already present and usable (cases C/C2) → keep it. ~keep
707    if usable_text && s.invisible_text_ratio >= 0.50 {
708        return Some((PageKind::TextLayer, 0.85, ReasonCode::NativeTextHighConfidence));
709    }
710    // Sparse text over a scan (case G) — the headline fix: text
711    // *presence* is not enough; coverage must be relative. ~keep
712    if s.text_area_ratio < sparse_text_max || !usable_text {
713        let conf = if s.codec == ImageCodecClass::Ccitt || s.codec == ImageCodecClass::Jbig2 {
714            0.95
715        } else {
716            0.85
717        };
718        // A full-bleed background image with a real, usable text
719        // layer (a slide headline, a deck cover) is not "no text
720        // layer" — the text is there, mapped correctly, and
721        // extractable; it just doesn't cover enough of the page to
722        // outweigh the scan-dominant image coverage. Report the
723        // honest reason (coverage too low) rather than claiming no
724        // text layer exists at all, which would tell a caller who
725        // inspects `reason` there is nothing to extract. ~keep
726        let reason = if usable_text {
727            ReasonCode::TextLayerBelowThreshold
728        } else {
729            ReasonCode::NoTextLayerPresent
730        };
731        return Some((PageKind::Scanned, conf, reason));
732    }
733    None
734}
735
736/// Verdict when a page has some text glyphs but not enough to count as
737/// `usable_text` (see [`classify_from_signals`]): distinguishes a short but
738/// cleanly-mapped text page from one whose glyph mapping is actually broken.
739/// Split out of [`classify_from_signals`] purely to keep that function's
740/// overall cascade legible. ~keep
741fn low_glyph_count_verdict(s: &PageSignals) -> (PageKind, f32, ReasonCode) {
742    // Distinguish "few but CLEAN glyphs" from "garbled glyphs".
743    // A short page of well-mapped, image-free text (e.g. a cover
744    // line, a stub, a placeholder) is a *text* page — not a scan.
745    // Only genuinely garbled glyphs (broken CMap/CID) or text
746    // sitting over raster route to OCR. Without this guard a tiny
747    // valid text PDF was misclassified `Scanned` and wrongly
748    // listed in `pages_needing_ocr`. ~keep
749    let clean = s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;
750    if clean && s.image_area_ratio < 0.05 {
751        return (PageKind::TextLayer, 0.60, ReasonCode::NativeTextHighConfidence);
752    }
753    (PageKind::Scanned, 0.80, ReasonCode::GlyphMappingMissing)
754}
755
756mod summary;
757pub use summary::summarise;
758
759// ─────────────────────────── AutoExtractor (T4–T8) ─────────────────────────
760//
761// The configured-once, reusable object (api-design.md §3 — rejected the
762// document-bolted sketch). Construction is cheap & infallible (no I/O,
763// never downloads). The native/markdown/html/classify
764// surface needs **no** `ocr` feature; OCR enrichment is `#[cfg(feature
765// = "ocr")]` and **always** degrades gracefully (warn + native
766// fallback + typed reason — never fail-loud; only security ops
767// fail-closed — `feedback_extraction_graceful_fallback`). ~keep
768
769use crate::document::PdfDocument;
770
771/// Comprehensive auto-extractor — configure once, reuse across pages
772/// and documents.
773#[derive(Debug, Clone)]
774pub struct AutoExtractor {
775    opts: AutoExtractOptions,
776}
777
778impl Default for AutoExtractor {
779    fn default() -> Self {
780        Self::new()
781    }
782}
783
784impl AutoExtractor {
785    /// Default extractor (`balanced` preset, `ExtractMode::Auto`).
786    /// Infallible, no I/O, never downloads.
787    #[must_use]
788    pub fn new() -> Self {
789        Self {
790            opts: AutoExtractOptions::balanced(),
791        }
792    }
793
794    /// Never touches OCR/models at all (`ExtractMode::TextOnly`) — the
795    /// `new(DisableOcr)` shape.
796    #[must_use]
797    pub fn text_only() -> Self {
798        Self {
799            opts: AutoExtractOptions::fast(),
800        }
801    }
802
803    /// Construct with explicit options.
804    #[must_use]
805    pub fn with(opts: AutoExtractOptions) -> Self {
806        Self { opts }
807    }
808
809    /// The active options.
810    #[must_use]
811    pub fn options(&self) -> &AutoExtractOptions {
812        &self.opts
813    }
814
815    /// Cheap whole-document classification preflight (no OCR).
816    pub fn classify(&self, doc: &PdfDocument) -> crate::Result<DocumentClassification> {
817        doc.classify_document()
818    }
819
820    /// Per-page text. `Auto`/`ForceOcr` route via classification; OCR is
821    /// attempted only with the `ocr` feature and **always** falls back
822    /// to native text + a `tracing::warn` if unavailable/failed (never
823    /// crashes, never silent-empty — the reason is observable via
824    /// [`extract_page`](Self::extract_page)).
825    pub fn extract_text(&self, doc: &PdfDocument, page: usize) -> crate::Result<String> {
826        // TextOnly is the cheapest path and must NOT classify —
827        // classification pulls spans/images, defeating the stated
828        // "fast" contract. `doc.extract_text` itself
829        // fails closed on an encrypted-unauthenticated PDF (case L),
830        // so the security invariant holds without classifying here. ~keep
831        if matches!(self.opts.mode, ExtractMode::TextOnly) {
832            return doc.extract_text(page);
833        }
834        // Auto/ForceOcr: classify (fails closed on encrypted, case L)
835        // then route. `route` owns the OCR-vs-native decision and the
836        // observable provenance. ~keep
837        let cls = doc.classify_page(page)?;
838        Ok(self.route(doc, page, &cls)?.0)
839    }
840
841    /// The single source of truth for the Auto/ForceOcr routing
842    /// decision **and** its resulting provenance: returns the text plus
843    /// the *actual* `(ExtractSource, ReasonCode)`. A native fallback
844    /// after a failed / empty / not-compiled OCR is reported as
845    /// [`ExtractSource::Fallback`], never mislabelled as
846    /// [`ExtractSource::Ocr`]. Always falls back to
847    /// native text with a precise `tracing::warn`; never crashes, never
848    /// silent-empty. `cls` is the already-computed classification (the
849    /// caller classifies once — no double work for the rich path).
850    fn route(
851        &self,
852        doc: &PdfDocument,
853        page: usize,
854        cls: &PageClassification,
855    ) -> crate::Result<(String, ExtractSource, ReasonCode)> {
856        let force = matches!(self.opts.mode, ExtractMode::ForceOcr) || self.opts.force_ocr_pages.contains(&page);
857        let needs_ocr = force || matches!(cls.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed);
858        if !needs_ocr {
859            return Ok((doc.extract_text(page)?, ExtractSource::NativeText, cls.reason));
860        }
861        // The page needs OCR, but this build has no OCR backend — always
862        // fall back to native text. ~keep
863        tracing::warn!(
864            "auto-extract: OCR unavailable (no OCR backend compiled in) \
865             for page {page} (kind={:?}); falling back to native \
866             text (reason OcrRequestedButUnavailable)",
867            cls.kind
868        );
869        // The classifier wanted OCR but it is unavailable. Do NOT
870        // blanket-label the native fallback "partial / OCR-unavailable":
871        // if the native text we got is itself high-quality (passes the
872        // T0.5 gate) the extraction is genuinely COMPLETE — trust the
873        // gate over the routing guess. Only when the native fallback is
874        // *also* poor is the result truly degraded. Without this a
875        // misclassified text page reported `partial_success` /
876        // `ocr_requested_but_unavailable` despite a perfect extraction. ~keep
877        let native = doc.extract_text(page)?;
878        if !native.trim().is_empty() && text_quality_gate(&native).is_none() {
879            Ok((native, ExtractSource::NativeText, ReasonCode::NativeTextHighConfidence))
880        } else {
881            Ok((native, ExtractSource::Fallback, ReasonCode::OcrRequestedButUnavailable))
882        }
883    }
884
885    /// Whole-document text (the common LLM/RAG case), reading-ordered
886    /// per page.
887    pub fn extract_document_text(&self, doc: &PdfDocument) -> crate::Result<String> {
888        let n = doc.page_count()?;
889        let mut out = String::new();
890        for p in 0..n {
891            if p > 0 {
892                out.push_str("\n\n");
893            }
894            out.push_str(&self.extract_text(doc, p)?);
895        }
896        Ok(out)
897    }
898
899    /// Rich per-page extraction: classified text + per-region results,
900    /// each with a 4-point bbox and a typed [`ReasonCode`] — **never a
901    /// bare empty string** (the #1 user-pain fix). Reading-order region
902    /// granularity (per-image-region OCR / image-table reconstruction)
903    /// is layered by the OCR pipeline tasks without changing this
904    /// surface.
905    pub fn extract_page(&self, doc: &PdfDocument, page: usize) -> crate::Result<PageExtraction> {
906        let cls = doc.classify_page(page)?;
907
908        // Use `route()`'s ACTUAL provenance instead of re-deriving
909        // source/reason from `cls.kind` + `cfg!(ocr)` + text-emptiness.
910        // The old heuristic mislabelled an OCR-attempted-but-failed
911        // native fallback as `source=Ocr, reason=Ok` and computed
912        // `ocr_used` from the feature flag rather than real usage.
913        // `route()` is the single source of truth and
914        // already returns the truthful `(text, source, reason)`;
915        // `TextOnly` keeps its classify-free fast path (mirrors
916        // `extract_text`). `ocr_used` is now a fact: OCR text was used. ~keep
917        let (text, source, reason) = if matches!(self.opts.mode, ExtractMode::TextOnly) {
918            (doc.extract_text(page)?, ExtractSource::NativeText, cls.reason)
919        } else {
920            self.route(doc, page, &cls)?
921        };
922        let ocr_used = source == ExtractSource::Ocr;
923        let bbox = doc
924            .get_page_media_box(page)
925            .map(|(x0, y0, x1, y1)| Quad::from_xywh(x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()))
926            .unwrap_or(Quad::from_xywh(0.0, 0.0, 0.0, 0.0));
927        // A high-confidence native text result is COMPLETE, not partial
928        // — `NativeTextHighConfidence` is a success reason, same as `Ok`
929        // (do not report `partial_success` for a full extraction). ~keep
930        let status = if text.trim().is_empty() {
931            ExtractionStatus::NoTextRecovered
932        } else if matches!(reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence) {
933            ExtractionStatus::Complete
934        } else {
935            ExtractionStatus::PartialSuccess
936        };
937        let region = Region {
938            bbox,
939            kind: RegionKind::Text,
940            text: text.clone(),
941            table: None,
942            confidence: cls.confidence,
943            source,
944            reason,
945        };
946        Ok(PageExtraction {
947            page,
948            kind: cls.kind,
949            text,
950            regions: vec![region],
951            confidence: cls.confidence,
952            reason,
953            ocr_used,
954            status,
955        })
956    }
957
958    /// Rich whole-document extraction (per-page — never a forced doc
959    /// mode, case Q) + aggregate status + `pages_needing_ocr`.
960    pub fn extract_document(&self, doc: &PdfDocument) -> crate::Result<DocumentExtraction> {
961        let n = doc.page_count()?;
962        let mut pages = Vec::with_capacity(n);
963        let mut need = Vec::new();
964        for p in 0..n {
965            let pe = self.extract_page(doc, p)?;
966            if matches!(pe.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed) {
967                need.push(p);
968            }
969            pages.push(pe);
970        }
971        let any_text = pages.iter().any(|p| !p.text.trim().is_empty());
972        let all_ok = pages
973            .iter()
974            .all(|p| matches!(p.reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence));
975        let status = if !any_text {
976            ExtractionStatus::NoTextRecovered
977        } else if all_ok {
978            ExtractionStatus::Complete
979        } else {
980            ExtractionStatus::PartialSuccess
981        };
982        Ok(DocumentExtraction {
983            pages,
984            status,
985            pages_needing_ocr: need,
986        })
987    }
988}
989
990#[cfg(test)]
991mod tests;