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;