xberg-native-pdf 1.2.2

PDF parsing, extraction and rendering -- the native PDF engine behind xberg.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
//! Comprehensive auto-extraction — public vocabulary.
//!
//! The configured-once [`AutoExtractor`] returns *all* recoverable text
//! from a PDF — native text **and** text embedded in images,
//! image-tables and figures — decided per-page/per-region, with a
//! machine-readable [`ReasonCode`] for every degraded result.
//!
//! This module is the **dependency root** and is *pure PDF inspection*:
//! it carries no OCR feature gate — this crate has never had one — and is
//! fully testable (00-common-foundation §5/§9). The
//! classification signal model (T2/T3) and the [`AutoExtractor`]
//! pipeline (T4–T8) build on these types.
//!
//! Design contracts (api-design.md §3, README "Locked decisions"):
//! - **Strictly additive** — existing `extract_text`/`extract_spans`/…
//!   are byte-identical; this is a *new* surface.
//! - **Single mode knob** [`ExtractMode`] (Tika/unstructured pattern),
//!   not boolean soup.
//! - **Typed reason per region** — never a bare empty string; degraded
//!   results always say *why* (the #1 cross-tool user pain).
//! - **Graceful, never fail-loud** — OCR unavailable → warn + native
//!   fallback + [`ReasonCode::OcrRequestedButUnavailable`] (best-effort
//!   extraction degrades gracefully; only security ops fail-closed).
//! - All public types are `#[non_exhaustive]` + `serde` (the JSON
//!   C-ABI boundary, matching the shipped split-by-bookmarks idiom) so
//!   later tiers (T2/T3) are additive and non-breaking.

use serde::{Deserialize, Serialize};

/// How [`AutoExtractor`](crate::extractors::auto) decides text-vs-OCR.
///
/// One knob collapses off/auto/force (Tika `OCR_STRATEGY` /
/// unstructured `strategy` — the cleanest mode model; the multi-boolean
/// designs are the ones that caused upstream bugs). `#[non_exhaustive]`
/// → a future tier is a non-breaking addition (T2/T3 are deliberately
/// *not* present — `tier-model-strategy.md`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractMode {
    /// Native text layer only — never OCR (≈ today's `extract_text`).
    TextOnly,
    /// Per-region heuristic: text-layer vs OCR vs image-table recovery.
    /// The default (README locked decision 2).
    #[default]
    Auto,
    /// OCR everything, ignore any native text layer (the
    /// "suspect text layer" forced-OCR escape hatch).
    ForceOcr,
}

/// Options for [`AutoExtractor`](crate::extractors::auto).
///
/// Plain struct + `Default` + `#[non_exhaustive]` (house style:
/// `RedactionOptions`/`SplitByBookmarksOptions`); construct with
/// struct-update (`..Default::default()`), a preset
/// ([`fast`](Self::fast)/[`balanced`](Self::balanced)/
/// [`high_fidelity`](Self::high_fidelity)), or the
/// [`builder`](Self::builder) (mirrors the in-repo `OcrConfigBuilder`).
/// `serde` is the JSON config wire at the C-ABI.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "snake_case")]
#[non_exhaustive]
pub struct AutoExtractOptions {
    /// Text-vs-OCR mode. Default [`ExtractMode::Auto`].
    pub mode: ExtractMode,
    /// Reconstruct image-tables into a structured [`TableData`] via the
    /// existing spatial detector over OCR spans (default `true`).
    pub reconstruct_image_tables: bool,
    /// Emit positioned `Figure`/`Table` placeholders in the text flow
    /// so reading order is never silently corrupted (default `true`).
    pub emit_placeholders: bool,
    /// Auto-decision confidence threshold (`None` = preset/calibrated).
    pub min_text_confidence: Option<f32>,
    /// Image-table reconstruction confidence threshold (`None` =
    /// preset/calibrated).
    pub table_confidence: Option<f32>,
    /// Force OCR on these **0-based** page indices regardless of `mode`
    /// (additive on `Auto`; does not change [`ExtractMode`]). Empty =
    /// honour `mode` for every page.
    pub force_ocr_pages: Vec<usize>,
}

impl Default for AutoExtractOptions {
    fn default() -> Self {
        // `balanced()` is the documented default (README locked dec. 2;
        // api-design.md §3). ~keep
        Self::balanced()
    }
}

impl AutoExtractOptions {
    /// Text-layer biased, no layout/table work — the cheapest path.
    #[must_use]
    pub fn fast() -> Self {
        Self {
            mode: ExtractMode::TextOnly,
            reconstruct_image_tables: false,
            emit_placeholders: true,
            min_text_confidence: None,
            table_confidence: None,
            force_ocr_pages: Vec::new(),
        }
    }

    /// The default — `Auto` per-region routing with image-table
    /// reconstruction (calibrated thresholds).
    #[must_use]
    pub fn balanced() -> Self {
        Self {
            mode: ExtractMode::Auto,
            reconstruct_image_tables: true,
            emit_placeholders: true,
            min_text_confidence: None,
            table_confidence: None,
            force_ocr_pages: Vec::new(),
        }
    }

    /// Aggressive OCR + image-table recovery (still local-CPU T1; no
    /// VLM/cloud — `tier-model-strategy.md`).
    #[must_use]
    pub fn high_fidelity() -> Self {
        Self {
            mode: ExtractMode::Auto,
            reconstruct_image_tables: true,
            emit_placeholders: true,
            // Lower bars → escalate to OCR / table recovery sooner. ~keep
            min_text_confidence: Some(0.55),
            table_confidence: Some(0.45),
            force_ocr_pages: Vec::new(),
        }
    }

    /// Fluent builder (mirrors the in-repo `OcrConfigBuilder`).
    #[must_use]
    pub fn builder() -> AutoExtractOptionsBuilder {
        AutoExtractOptionsBuilder::new()
    }
}

/// Fluent builder for [`AutoExtractOptions`] — same shape as the
/// canonical in-repo `OcrConfigBuilder` (`mut self -> Self`, validating
/// setters, terminal `build`).
#[derive(Debug, Clone, Default)]
pub struct AutoExtractOptionsBuilder {
    opts: AutoExtractOptions,
}

impl AutoExtractOptionsBuilder {
    /// Start from the [`balanced`](AutoExtractOptions::balanced) preset.
    #[must_use]
    pub fn new() -> Self {
        Self {
            opts: AutoExtractOptions::balanced(),
        }
    }

    /// Set the text-vs-OCR [`ExtractMode`].
    #[must_use]
    pub fn mode(mut self, mode: ExtractMode) -> Self {
        self.opts.mode = mode;
        self
    }

    /// Toggle image-table reconstruction.
    #[must_use]
    pub fn reconstruct_image_tables(mut self, yes: bool) -> Self {
        self.opts.reconstruct_image_tables = yes;
        self
    }

    /// Toggle figure/table placeholders in the text flow.
    #[must_use]
    pub fn emit_placeholders(mut self, yes: bool) -> Self {
        self.opts.emit_placeholders = yes;
        self
    }

    /// Auto-decision confidence threshold (clamped to `0.0..=1.0`).
    #[must_use]
    pub fn min_text_confidence(mut self, c: f32) -> Self {
        self.opts.min_text_confidence = Some(c.clamp(0.0, 1.0));
        self
    }

    /// Image-table confidence threshold (clamped to `0.0..=1.0`).
    #[must_use]
    pub fn table_confidence(mut self, c: f32) -> Self {
        self.opts.table_confidence = Some(c.clamp(0.0, 1.0));
        self
    }

    /// Force OCR on these 0-based page indices (additive on `mode`).
    #[must_use]
    pub fn force_ocr_pages<I: IntoIterator<Item = usize>>(mut self, pages: I) -> Self {
        self.opts.force_ocr_pages = pages.into_iter().collect();
        self
    }

    /// Finalise.
    #[must_use]
    pub fn build(self) -> AutoExtractOptions {
        self.opts
    }
}

/// Per-page classification outcome (T0/T0.5 — pure inspection).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PageKind {
    /// Usable native text dominates — extract the text layer.
    TextLayer,
    /// Image-dominated, no/garbled text — OCR the page.
    Scanned,
    /// Native text **and** image regions containing text — hybrid
    /// (native + region OCR).
    ImageText,
    /// Heterogeneous within the page (text + image-table/figure).
    Mixed,
    /// Blank/near-empty — neither extract nor OCR; not an error.
    Empty,
}

/// Machine-readable *why* for a region/page result — **the #1
/// cross-tool user-pain fix**. A non-degraded result is
/// [`Ok`](ReasonCode::Ok); any degraded one **must** name the cause.
/// `#[non_exhaustive]`, frozen `snake_case` wire tokens (append-only —
/// never renumber/rename, the `PadesLevel` lesson).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasonCode {
    /// Extracted cleanly.
    Ok,
    /// Native text present and high-confidence.
    NativeTextHighConfidence,
    /// No text layer on the page at all.
    NoTextLayerPresent,
    /// Text layer present but below the usable-quality threshold.
    TextLayerBelowThreshold,
    /// Glyphs without usable ToUnicode/`(cid:NN)`/garbled mapping.
    GlyphMappingMissing,
    /// Encrypted and not authorised to extract.
    EncryptedNoExtractPermission,
    /// An image-table was reconstructed into [`TableData`].
    ImageTableReconstructed,
    /// A table region was detected but structure could not be
    /// recovered (graceful — heuristic fallback used, not fail-loud).
    ImageTableNoStructure,
    /// A chart/figure was detected; its internal data is **not**
    /// transcribed (honest scope boundary — placeholder emitted).
    ChartNotTranscribed,
    /// OCR was needed but unavailable (feature off / models absent /
    /// `mode = TextOnly`) → fell back to native text + warned.
    OcrRequestedButUnavailable,
    /// OCR ran but confidence was low → native used/merged.
    OcrLowConfidenceFallback,
    /// Region/page yielded no recoverable content.
    Empty,
}

/// Where a region's text came from. `#[non_exhaustive]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractSource {
    /// xberg-native-pdf native text-layer extraction.
    NativeText,
    /// OCR (region or full page).
    Ocr,
    /// Structured image-table reconstruction.
    ImageTableRecovery,
    /// Degraded fallback to native after an unavailable/low-confidence
    /// higher tier (paired with a non-`Ok` [`ReasonCode`]).
    Fallback,
}

/// Region classification. `#[non_exhaustive]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RegionKind {
    /// Body / inline text.
    Text,
    /// Heading (from structure tree or font-size heuristic).
    Heading,
    /// Tabular region (native or reconstructed image-table).
    Table,
    /// Figure/illustration (text recovered via region OCR if any).
    Figure,
    /// Chart/plot — internal data not transcribed (honest boundary).
    Chart,
}

/// A 4-point quadrilateral bounding box in PDF points (top-left,
/// top-right, bottom-right, bottom-left). A quad (not an AABB) so
/// skewed/rotated regions survive (cases I/J — Textract/Azure
/// convention). `serde` for the JSON C-ABI boundary.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Quad {
    /// `[tl, tr, br, bl]`, each `[x, y]` in PDF points.
    pub points: [[f32; 2]; 4],
}

impl Quad {
    /// Axis-aligned quad from `(x, y, w, h)` (PDF points, origin
    /// bottom-left).
    #[must_use]
    pub fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Self {
        Self {
            points: [[x, y + h], [x + w, y + h], [x + w, y], [x, y]],
        }
    }
}

/// Structured table payload (serde-friendly — what crosses the JSON
/// C-ABI). Decoupled from the internal
/// `structure::table_extractor::Table` on purpose: T7 maps the spatial
/// detector's output into this; consumers get cells + markdown.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct TableData {
    /// Row-major cell text.
    pub rows: Vec<Vec<String>>,
    /// First row is a header.
    pub has_header: bool,
    /// GitHub-flavoured Markdown rendering of the table.
    pub markdown: String,
}

/// One classified, extracted region of a page.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct Region {
    /// 4-point bbox in PDF points.
    pub bbox: Quad,
    /// Region classification.
    pub kind: RegionKind,
    /// Recovered text (`""` if none — e.g. an un-transcribed chart;
    /// the bbox + reason still locate it for reading order).
    pub text: String,
    /// Reconstructed table (only for [`RegionKind::Table`]).
    pub table: Option<TableData>,
    /// Confidence `0.0..=1.0`, always present.
    pub confidence: f32,
    /// Where the text came from.
    pub source: ExtractSource,
    /// Why this source/outcome (typed; never opaque).
    pub reason: ReasonCode,
}

/// Document-level rollup status. `#[non_exhaustive]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExtractionStatus {
    /// Every region extracted cleanly.
    Complete,
    /// Some regions degraded (see per-region [`ReasonCode`]) but text
    /// was recovered — *not* an error (Docling `PARTIAL_SUCCESS`).
    PartialSuccess,
    /// No usable text recovered anywhere.
    NoTextRecovered,
}

/// Per-page extraction result.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageExtraction {
    /// 0-based page index.
    pub page: usize,
    /// Per-page classification.
    pub kind: PageKind,
    /// Assembled, reading-ordered text for the page.
    pub text: String,
    /// Per-region results (bbox + reason always present, even when
    /// `text` is empty — pain #2/#8).
    pub regions: Vec<Region>,
    /// Page-level confidence `0.0..=1.0`.
    pub confidence: f32,
    /// Page-level rollup reason.
    pub reason: ReasonCode,
    /// Whether OCR actually ran for this page.
    pub ocr_used: bool,
    /// Page-level status.
    pub status: ExtractionStatus,
}

/// Whole-document extraction result.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct DocumentExtraction {
    /// Per-page results (per-page decision — never one forced doc mode;
    /// case Q).
    pub pages: Vec<PageExtraction>,
    /// Document rollup status.
    pub status: ExtractionStatus,
    /// 0-based page indices a cheap preflight says need OCR.
    pub pages_needing_ocr: Vec<usize>,
}

/// Lightweight document classification (the cheap `classify_document`
/// preflight — no OCR, no rasterisation).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct DocumentClassification {
    /// Per-page kinds.
    pub pages: Vec<PageKind>,
    /// 0-based page indices needing OCR.
    pub pages_needing_ocr: Vec<usize>,
    /// Aggregate `mostly_text` / `mostly_scanned` / `mixed`.
    pub summary: DocumentSummary,
}

/// Aggregate document character. `#[non_exhaustive]`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DocumentSummary {
    /// Predominantly born-digital text.
    MostlyText,
    /// Predominantly scanned/image.
    MostlyScanned,
    /// Heterogeneous (case Q).
    Mixed,
    /// No meaningful content.
    Empty,
}

// ───────────────────────── classifier (T2/T2.5/T3) ─────────────────────────
//
// Pure inspection of xberg-native-pdf *internals* — never the flattened output
// string (00-common-foundation §9). Lowest-level decision logic takes
// **injected primitives** so it is unit-testable without a
// `PdfDocument` (00-common-foundation §8 — the `sanitize_catalog`
// injected-resolver precedent). `PdfDocument::classify_page` /
// `classify_document` (in `document.rs`) gather the internal signals and
// delegate here. No OCR feature gate — this crate has never had one. ~keep

/// Dominant raster codec on a page — a strong scan-vs-pictorial prior.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ImageCodecClass {
    /// No raster images.
    None,
    /// CCITT Group 3/4 fax — 1-bit, almost always a scan.
    Ccitt,
    /// JBIG2 — 1-bit, almost always a scan.
    Jbig2,
    /// DCT/JPEG — photo or colour scan.
    Dct,
    /// Flate/other raster.
    Other,
}

/// Document-level scanner-vs-authoring prior (case P). Weak, never
/// decisive — only a tie-break nudge.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProducerPrior {
    /// Producer/Creator looks like scanner/OCR software.
    Scanner,
    /// Producer/Creator looks like authoring software.
    Authoring,
    /// Unknown / no Info.
    Unknown,
}

/// Per-page signals gathered from xberg-native-pdf internals (T2). All ratios
/// are `0.0..=1.0`. Serde so it can ride the explainable result.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageSignals {
    /// Non-artifact glyph count from the native text layer.
    pub text_glyph_count: usize,
    /// Σ text-box area ∩ page ÷ page area.
    pub text_area_ratio: f32,
    /// Σ CTM-transformed image-box area ∩ page ÷ page area (clamped;
    /// summed not max'd → multi-strip scans, case J, are caught).
    pub image_area_ratio: f32,
    /// Dominant raster codec.
    pub codec: ImageCodecClass,
    /// Tr-mode-3 (invisible) glyphs ÷ all glyphs — the OCR-sidecar /
    /// painted-over signal (cases C/C2).
    pub invisible_text_ratio: f32,
    /// (U+FFFD + control + replacement) ÷ chars — `(cid:NN)`/garbled.
    pub garbled_ratio: f32,
    /// Short-fragmented-"word" ratio (broken CMaps split every glyph).
    pub fragmented_word_ratio: f32,
    /// Consecutive/duplicated-token run ratio — 2-column born-digital
    /// scramble (the T0.5 addition, research §3a).
    pub consecutive_repeat_ratio: f32,
    /// Path-ish ops ÷ all content ops — outlined/vectorised text
    /// (case F).
    pub vector_path_density: f32,
    /// `MarkInfo.marked && !suspects` — high-precision digital prior.
    pub has_reliable_structure: bool,
    /// Scanner-vs-authoring producer prior (weak).
    pub producer_prior: ProducerPrior,
    /// No text, no significant image, no paths.
    pub page_is_empty: bool,
}

/// Cheap per-page classification (the `classify_page` preflight — no
/// OCR, no rasterisation): kind + confidence + typed reason + the raw
/// signals (explainable; our differentiator over downstream wrappers).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub struct PageClassification {
    /// 0-based page index.
    pub page: usize,
    /// Decided page kind.
    pub kind: PageKind,
    /// Confidence `0.0..=1.0`.
    pub confidence: f32,
    /// Typed reason for the decision.
    pub reason: ReasonCode,
    /// Raw signals (explainability).
    pub signals: PageSignals,
}

/// Whether `text` is CJK/Hangul-dominant: a majority of its non-whitespace
/// characters fall in the Han, Hiragana, Katakana, or Hangul Unicode
/// blocks. These scripts don't use inter-word spaces, so glyph-adjacency
/// word clustering naturally produces short (often 1-2 character) tokens —
/// a `frag`/`avg_word_len` signal calibrated for space-separated Latin
/// text misreads that as fragmentation. Used to skip the Latin-specific
/// checks in [`text_quality_gate`] for such text; script-agnostic signals
/// (garbled/repeat ratio) still apply normally.
#[must_use]
pub fn is_cjk_dominant_text(text: &str) -> bool {
    let mut total = 0usize;
    let mut cjk = 0usize;
    for c in text.chars() {
        if c.is_whitespace() {
            continue;
        }
        total += 1;
        let cp = c as u32;
        if (0x4E00..=0x9FFF).contains(&cp)   // CJK Unified Ideographs ~keep
            || (0x3400..=0x4DBF).contains(&cp) // CJK Extension A ~keep
            || (0x3040..=0x309F).contains(&cp) // Hiragana ~keep
            || (0x30A0..=0x30FF).contains(&cp) // Katakana ~keep
            || (0xAC00..=0xD7A3).contains(&cp)
        // Hangul Syllables ~keep
        {
            cjk += 1;
        }
    }
    total > 0 && (cjk as f32 / total as f32) > 0.5
}

/// T0.5 enriched text-quality gate (research §3a).
/// Pure: operates on the native-extracted text only. Returns the
/// degrading [`ReasonCode`] if the "born-digital" text is unusable, or
/// `None` if it is good.
///
/// Signals (thresholds are conservative defaults; presets/calibration
/// own tuning — we encode the signals, not 16 user knobs):
/// - U+FFFD / replacement / control ratio (`(cid:NN)`/garbled);
/// - **critical** short-fragmented-word ratio — a hard trigger that
///   overrides everything else;
/// - **consecutive-repeat / column-scramble** ratio;
/// - average word length & alphanumeric ratio;
/// - Unicode-block-aware script validity (no ASCII bias — case H).
#[must_use]
pub fn text_quality_gate(text: &str) -> Option<ReasonCode> {
    let chars: Vec<char> = text.chars().collect();
    let total = chars.len();
    if total < 16 {
        // Too little to judge; let the coverage cascade decide. ~keep
        return None;
    }
    // Half-open `..` ranges are intentional: HT(0x09) LF(0x0A) VT(0x0B)
    // FF(0x0C) CR(0x0D) and SP(0x20) are legitimate whitespace and are
    // deliberately NOT counted as garbled (the range stops *before*
    // 0x09 and *before* 0x20). ~keep
    let bad = chars
        .iter()
        .filter(|&&c| {
            c == '\u{FFFD}'
                || ('\u{0}'..'\u{9}').contains(&c)
                || ('\u{E}'..'\u{20}').contains(&c)
                || ('\u{E000}'..='\u{F8FF}').contains(&c) // private-use (notdef/tofu) ~keep
        })
        .count();
    let garbled_ratio = bad as f32 / total as f32;
    if garbled_ratio > 0.20 {
        return Some(ReasonCode::GlyphMappingMissing);
    }

    let words: Vec<&str> = text.split_whitespace().collect();
    if words.len() >= 8 {
        let frag = words.iter().filter(|w| w.chars().count() <= 2).count() as f32 / words.len() as f32;
        // CJK/Hangul text has no inter-word spaces, so glyph-adjacency
        // clustering naturally produces short tokens — `frag` and
        // `avg_word_len` below are calibrated for space-separated Latin
        // text and would otherwise misread ordinary dense CJK prose as
        // fragmented. The repeat-ratio check (script-agnostic) still
        // applies normally. ~keep
        let cjk_dominant = is_cjk_dominant_text(text);
        // Critical hard-trigger (broken CMap splitting every glyph). ~keep
        if frag > 0.80 && !cjk_dominant {
            return Some(ReasonCode::GlyphMappingMissing);
        }
        // Consecutive-repeat / 2-column scramble: long runs of the
        // same token, or duplicated adjacent tokens. ~keep
        let mut repeats = 0usize;
        for w in words.windows(2) {
            if w[0] == w[1] {
                repeats += 1;
            }
        }
        let repeat_ratio = repeats as f32 / words.len() as f32;
        let avg_word_len = words.iter().map(|w| w.chars().count()).sum::<usize>() as f32 / words.len() as f32;
        if repeat_ratio > 0.30 || (frag > 0.55 && avg_word_len < 2.5 && !cjk_dominant) {
            return Some(ReasonCode::TextLayerBelowThreshold);
        }
    }

    let alnum = chars.iter().filter(|c| c.is_alphanumeric()).count();
    if (alnum as f32 / total as f32) < 0.20 {
        return Some(ReasonCode::TextLayerBelowThreshold);
    }
    None
}

/// T3 decision cascade — pure, from gathered [`PageSignals`] + config.
/// Deterministic ordered guards then a coverage/density verdict (KISS:
/// a rule cascade, not ML). Returns `(kind, confidence, reason)`.
#[must_use]
pub fn classify_from_signals(s: &PageSignals, cfg: &AutoExtractOptions) -> (PageKind, f32, ReasonCode) {
    // Calibrated defaults (research §3/§4); presets/cfg override. ~keep
    let min_text_conf = cfg.min_text_confidence.unwrap_or(0.70);
    let scan_cover_min = 0.80_f32;
    let sparse_text_max = 0.10_f32;
    let min_glyphs = 24_usize;

    if s.page_is_empty {
        return (PageKind::Empty, 0.99, ReasonCode::Empty);
    }

    let usable_text = s.text_glyph_count >= min_glyphs && s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;

    // Outlined/vectorised text (case F): paths dominate, ~no text/raster. ~keep
    if !usable_text && s.vector_path_density > 0.60 && s.image_area_ratio < 0.20 {
        return (PageKind::Scanned, 0.80, ReasonCode::NoTextLayerPresent);
    }

    if s.image_area_ratio >= scan_cover_min {
        // OCR sidecar already present and usable (cases C/C2) → keep it. ~keep
        if usable_text && s.invisible_text_ratio >= 0.50 {
            return (PageKind::TextLayer, 0.85, ReasonCode::NativeTextHighConfidence);
        }
        // Sparse text over a scan (case G) — the headline fix: text
        // *presence* is not enough; coverage must be relative. ~keep
        if s.text_area_ratio < sparse_text_max || !usable_text {
            let conf = if s.codec == ImageCodecClass::Ccitt || s.codec == ImageCodecClass::Jbig2 {
                0.95
            } else {
                0.85
            };
            // A full-bleed background image with a real, usable text
            // layer (a slide headline, a deck cover) is not "no text
            // layer" — the text is there, mapped correctly, and
            // extractable; it just doesn't cover enough of the page to
            // outweigh the scan-dominant image coverage. Report the
            // honest reason (coverage too low) rather than claiming no
            // text layer exists at all, which would tell a caller who
            // inspects `reason` there is nothing to extract. ~keep
            let reason = if usable_text {
                ReasonCode::TextLayerBelowThreshold
            } else {
                ReasonCode::NoTextLayerPresent
            };
            return (PageKind::Scanned, conf, reason);
        }
    }

    // Genuine hybrid: real text AND sub-page image region(s) (cases D/S). ~keep
    if usable_text && s.image_area_ratio > 0.05 && s.image_area_ratio < scan_cover_min {
        return (PageKind::ImageText, 0.75, ReasonCode::Ok);
    }

    // Usable native text dominates (case A, good sidecar). ~keep
    if usable_text {
        let mut conf = min_text_conf.max(0.80);
        if s.has_reliable_structure {
            conf = (conf + 0.10).min(0.99);
        }
        return (PageKind::TextLayer, conf, ReasonCode::NativeTextHighConfidence);
    }

    if s.text_glyph_count > 0 {
        // Distinguish "few but CLEAN glyphs" from "garbled glyphs".
        // A short page of well-mapped, image-free text (e.g. a cover
        // line, a stub, a placeholder) is a *text* page — not a scan.
        // Only genuinely garbled glyphs (broken CMap/CID) or text
        // sitting over raster route to OCR. Without this guard a tiny
        // valid text PDF was misclassified `Scanned` and wrongly
        // listed in `pages_needing_ocr`. ~keep
        let clean = s.garbled_ratio <= 0.20 && s.fragmented_word_ratio <= 0.80;
        if clean && s.image_area_ratio < 0.05 {
            return (PageKind::TextLayer, 0.60, ReasonCode::NativeTextHighConfidence);
        }
        return (PageKind::Scanned, 0.80, ReasonCode::GlyphMappingMissing);
    }

    let conf = match s.producer_prior {
        ProducerPrior::Scanner => 0.85,
        _ => 0.70,
    };
    (PageKind::Scanned, conf, ReasonCode::NoTextLayerPresent)
}

/// Roll per-page kinds into a [`DocumentSummary`] (case Q — never a
/// forced single doc mode; this is an *aggregate* only).
#[must_use]
pub fn summarise(pages: &[PageKind]) -> DocumentSummary {
    if pages.is_empty() || pages.iter().all(|k| *k == PageKind::Empty) {
        return DocumentSummary::Empty;
    }
    let non_empty: Vec<&PageKind> = pages.iter().filter(|k| **k != PageKind::Empty).collect();
    let text = non_empty.iter().filter(|k| matches!(k, PageKind::TextLayer)).count();
    let scanned = non_empty.iter().filter(|k| matches!(k, PageKind::Scanned)).count();
    let n = non_empty.len();
    if text * 100 >= n * 80 {
        DocumentSummary::MostlyText
    } else if scanned * 100 >= n * 80 {
        DocumentSummary::MostlyScanned
    } else {
        DocumentSummary::Mixed
    }
}

// ─────────────────────────── AutoExtractor (T4–T8) ─────────────────────────
//
// The configured-once, reusable object (api-design.md §3 — rejected the
// document-bolted sketch). Construction is cheap & infallible (no I/O,
// never downloads). The native/markdown/html/classify
// surface needs **no** `ocr` feature; OCR enrichment is `#[cfg(feature
// = "ocr")]` and **always** degrades gracefully (warn + native
// fallback + typed reason — never fail-loud; only security ops
// fail-closed — `feedback_extraction_graceful_fallback`). ~keep

use crate::document::PdfDocument;

/// Comprehensive auto-extractor — configure once, reuse across pages
/// and documents.
#[derive(Debug, Clone)]
pub struct AutoExtractor {
    opts: AutoExtractOptions,
}

impl Default for AutoExtractor {
    fn default() -> Self {
        Self::new()
    }
}

impl AutoExtractor {
    /// Default extractor (`balanced` preset, `ExtractMode::Auto`).
    /// Infallible, no I/O, never downloads.
    #[must_use]
    pub fn new() -> Self {
        Self {
            opts: AutoExtractOptions::balanced(),
        }
    }

    /// Never touches OCR/models at all (`ExtractMode::TextOnly`) — the
    /// `new(DisableOcr)` shape.
    #[must_use]
    pub fn text_only() -> Self {
        Self {
            opts: AutoExtractOptions::fast(),
        }
    }

    /// Construct with explicit options.
    #[must_use]
    pub fn with(opts: AutoExtractOptions) -> Self {
        Self { opts }
    }

    /// The active options.
    #[must_use]
    pub fn options(&self) -> &AutoExtractOptions {
        &self.opts
    }

    /// Cheap whole-document classification preflight (no OCR).
    pub fn classify(&self, doc: &PdfDocument) -> crate::Result<DocumentClassification> {
        doc.classify_document()
    }

    /// Per-page text. `Auto`/`ForceOcr` route via classification; OCR is
    /// attempted only with the `ocr` feature and **always** falls back
    /// to native text + a `tracing::warn` if unavailable/failed (never
    /// crashes, never silent-empty — the reason is observable via
    /// [`extract_page`](Self::extract_page)).
    pub fn extract_text(&self, doc: &PdfDocument, page: usize) -> crate::Result<String> {
        // TextOnly is the cheapest path and must NOT classify —
        // classification pulls spans/images, defeating the stated
        // "fast" contract. `doc.extract_text` itself
        // fails closed on an encrypted-unauthenticated PDF (case L),
        // so the security invariant holds without classifying here. ~keep
        if matches!(self.opts.mode, ExtractMode::TextOnly) {
            return doc.extract_text(page);
        }
        // Auto/ForceOcr: classify (fails closed on encrypted, case L)
        // then route. `route` owns the OCR-vs-native decision and the
        // observable provenance. ~keep
        let cls = doc.classify_page(page)?;
        Ok(self.route(doc, page, &cls)?.0)
    }

    /// The single source of truth for the Auto/ForceOcr routing
    /// decision **and** its resulting provenance: returns the text plus
    /// the *actual* `(ExtractSource, ReasonCode)`. A native fallback
    /// after a failed / empty / not-compiled OCR is reported as
    /// [`ExtractSource::Fallback`], never mislabelled as
    /// [`ExtractSource::Ocr`]. Always falls back to
    /// native text with a precise `tracing::warn`; never crashes, never
    /// silent-empty. `cls` is the already-computed classification (the
    /// caller classifies once — no double work for the rich path).
    fn route(
        &self,
        doc: &PdfDocument,
        page: usize,
        cls: &PageClassification,
    ) -> crate::Result<(String, ExtractSource, ReasonCode)> {
        let force = matches!(self.opts.mode, ExtractMode::ForceOcr) || self.opts.force_ocr_pages.contains(&page);
        let needs_ocr = force || matches!(cls.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed);
        if !needs_ocr {
            return Ok((doc.extract_text(page)?, ExtractSource::NativeText, cls.reason));
        }
        // The page needs OCR, but this build has no OCR backend — always
        // fall back to native text. ~keep
        tracing::warn!(
            "auto-extract: OCR unavailable (no OCR backend compiled in) \
             for page {page} (kind={:?}); falling back to native \
             text (reason OcrRequestedButUnavailable)",
            cls.kind
        );
        // The classifier wanted OCR but it is unavailable. Do NOT
        // blanket-label the native fallback "partial / OCR-unavailable":
        // if the native text we got is itself high-quality (passes the
        // T0.5 gate) the extraction is genuinely COMPLETE — trust the
        // gate over the routing guess. Only when the native fallback is
        // *also* poor is the result truly degraded. Without this a
        // misclassified text page reported `partial_success` /
        // `ocr_requested_but_unavailable` despite a perfect extraction. ~keep
        let native = doc.extract_text(page)?;
        if !native.trim().is_empty() && text_quality_gate(&native).is_none() {
            Ok((native, ExtractSource::NativeText, ReasonCode::NativeTextHighConfidence))
        } else {
            Ok((native, ExtractSource::Fallback, ReasonCode::OcrRequestedButUnavailable))
        }
    }

    /// Whole-document text (the common LLM/RAG case), reading-ordered
    /// per page.
    pub fn extract_document_text(&self, doc: &PdfDocument) -> crate::Result<String> {
        let n = doc.page_count()?;
        let mut out = String::new();
        for p in 0..n {
            if p > 0 {
                out.push_str("\n\n");
            }
            out.push_str(&self.extract_text(doc, p)?);
        }
        Ok(out)
    }

    /// Rich per-page extraction: classified text + per-region results,
    /// each with a 4-point bbox and a typed [`ReasonCode`] — **never a
    /// bare empty string** (the #1 user-pain fix). Reading-order region
    /// granularity (per-image-region OCR / image-table reconstruction)
    /// is layered by the OCR pipeline tasks without changing this
    /// surface.
    pub fn extract_page(&self, doc: &PdfDocument, page: usize) -> crate::Result<PageExtraction> {
        let cls = doc.classify_page(page)?;

        // Use `route()`'s ACTUAL provenance instead of re-deriving
        // source/reason from `cls.kind` + `cfg!(ocr)` + text-emptiness.
        // The old heuristic mislabelled an OCR-attempted-but-failed
        // native fallback as `source=Ocr, reason=Ok` and computed
        // `ocr_used` from the feature flag rather than real usage.
        // `route()` is the single source of truth and
        // already returns the truthful `(text, source, reason)`;
        // `TextOnly` keeps its classify-free fast path (mirrors
        // `extract_text`). `ocr_used` is now a fact: OCR text was used. ~keep
        let (text, source, reason) = if matches!(self.opts.mode, ExtractMode::TextOnly) {
            (doc.extract_text(page)?, ExtractSource::NativeText, cls.reason)
        } else {
            self.route(doc, page, &cls)?
        };
        let ocr_used = source == ExtractSource::Ocr;
        let bbox = doc
            .get_page_media_box(page)
            .map(|(x0, y0, x1, y1)| Quad::from_xywh(x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()))
            .unwrap_or(Quad::from_xywh(0.0, 0.0, 0.0, 0.0));
        // A high-confidence native text result is COMPLETE, not partial
        // — `NativeTextHighConfidence` is a success reason, same as `Ok`
        // (do not report `partial_success` for a full extraction). ~keep
        let status = if text.trim().is_empty() {
            ExtractionStatus::NoTextRecovered
        } else if matches!(reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence) {
            ExtractionStatus::Complete
        } else {
            ExtractionStatus::PartialSuccess
        };
        let region = Region {
            bbox,
            kind: RegionKind::Text,
            text: text.clone(),
            table: None,
            confidence: cls.confidence,
            source,
            reason,
        };
        Ok(PageExtraction {
            page,
            kind: cls.kind,
            text,
            regions: vec![region],
            confidence: cls.confidence,
            reason,
            ocr_used,
            status,
        })
    }

    /// Rich whole-document extraction (per-page — never a forced doc
    /// mode, case Q) + aggregate status + `pages_needing_ocr`.
    pub fn extract_document(&self, doc: &PdfDocument) -> crate::Result<DocumentExtraction> {
        let n = doc.page_count()?;
        let mut pages = Vec::with_capacity(n);
        let mut need = Vec::new();
        for p in 0..n {
            let pe = self.extract_page(doc, p)?;
            if matches!(pe.kind, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed) {
                need.push(p);
            }
            pages.push(pe);
        }
        let any_text = pages.iter().any(|p| !p.text.trim().is_empty());
        let all_ok = pages
            .iter()
            .all(|p| matches!(p.reason, ReasonCode::Ok | ReasonCode::NativeTextHighConfidence));
        let status = if !any_text {
            ExtractionStatus::NoTextRecovered
        } else if all_ok {
            ExtractionStatus::Complete
        } else {
            ExtractionStatus::PartialSuccess
        };
        Ok(DocumentExtraction {
            pages,
            status,
            pages_needing_ocr: need,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extract_mode_default_is_auto() {
        // README locked decision 2. ~keep
        assert_eq!(ExtractMode::default(), ExtractMode::Auto);
        assert_eq!(AutoExtractOptions::default().mode, ExtractMode::Auto);
    }

    #[test]
    fn presets_have_expected_shape() {
        assert_eq!(AutoExtractOptions::fast().mode, ExtractMode::TextOnly);
        assert!(!AutoExtractOptions::fast().reconstruct_image_tables);
        assert_eq!(AutoExtractOptions::balanced().mode, ExtractMode::Auto);
        assert!(AutoExtractOptions::balanced().reconstruct_image_tables);
        assert_eq!(AutoExtractOptions::high_fidelity().mode, ExtractMode::Auto);
        assert!(AutoExtractOptions::high_fidelity().min_text_confidence.is_some());
        assert_eq!(AutoExtractOptions::default(), AutoExtractOptions::balanced());
    }

    #[test]
    fn builder_mirrors_ocrconfigbuilder_shape() {
        let o = AutoExtractOptions::builder()
            .mode(ExtractMode::ForceOcr)
            .reconstruct_image_tables(false)
            .min_text_confidence(2.0)
            .force_ocr_pages([0, 2])
            .build();
        assert_eq!(o.mode, ExtractMode::ForceOcr);
        assert!(!o.reconstruct_image_tables);
        assert_eq!(o.min_text_confidence, Some(1.0));
        assert_eq!(o.force_ocr_pages, vec![0, 2]);
    }

    #[test]
    fn options_json_roundtrip_is_stable() {
        // The JSON wire is the C-ABI boundary (matches split-by-bookmarks). ~keep
        let o = AutoExtractOptions::high_fidelity();
        let js = serde_json::to_string(&o).expect("serialize");
        assert!(js.contains("\"mode\":\"auto\""));
        let back: AutoExtractOptions = serde_json::from_str(&js).expect("deserialize");
        assert_eq!(o, back);
        // Partial JSON fills via #[serde(default)] (forward-compat). ~keep
        let partial: AutoExtractOptions = serde_json::from_str(r#"{"mode":"force_ocr"}"#).expect("partial");
        assert_eq!(partial.mode, ExtractMode::ForceOcr);
        assert!(partial.reconstruct_image_tables); // from Default(=balanced) ~keep
    }

    #[test]
    fn reason_and_enum_wire_tokens_are_snake_case_frozen() {
        // Frozen append-only wire tokens (PadesLevel lesson). ~keep
        assert_eq!(
            serde_json::to_string(&ReasonCode::OcrRequestedButUnavailable).unwrap(),
            "\"ocr_requested_but_unavailable\""
        );
        assert_eq!(
            serde_json::to_string(&ExtractSource::ImageTableRecovery).unwrap(),
            "\"image_table_recovery\""
        );
        assert_eq!(serde_json::to_string(&PageKind::ImageText).unwrap(), "\"image_text\"");
    }

    #[test]
    fn quad_from_xywh_is_tl_tr_br_bl() {
        let q = Quad::from_xywh(10.0, 20.0, 30.0, 40.0);
        assert_eq!(q.points[0], [10.0, 60.0]);
        assert_eq!(q.points[2], [40.0, 20.0]);
    }

    fn sig() -> PageSignals {
        PageSignals {
            text_glyph_count: 0,
            text_area_ratio: 0.0,
            image_area_ratio: 0.0,
            codec: ImageCodecClass::None,
            invisible_text_ratio: 0.0,
            garbled_ratio: 0.0,
            fragmented_word_ratio: 0.0,
            consecutive_repeat_ratio: 0.0,
            vector_path_density: 0.0,
            has_reliable_structure: false,
            producer_prior: ProducerPrior::Unknown,
            page_is_empty: false,
        }
    }

    #[test]
    fn quality_gate_flags_cid_garbage_and_passes_clean() {
        let garbage: String = "\u{FFFD}".repeat(40);
        assert_eq!(text_quality_gate(&garbage), Some(ReasonCode::GlyphMappingMissing));
        assert_eq!(
            text_quality_gate("The quick brown fox jumps over the lazy dog repeatedly."),
            None
        );
    }

    #[test]
    fn quality_gate_catches_column_scramble_and_fragmentation() {
        // Critical fragmentation hard-trigger (every glyph split). ~keep
        let frag = "a b c d e f g h i j k l m n o p q r s t";
        assert_eq!(text_quality_gate(frag), Some(ReasonCode::GlyphMappingMissing));
        // Consecutive-repeat / 2-column scramble. ~keep
        let scramble = "alpha alpha beta beta gamma gamma delta delta epsilon epsilon zeta zeta";
        assert_eq!(text_quality_gate(scramble), Some(ReasonCode::TextLayerBelowThreshold));
    }

    #[test]
    fn quality_gate_does_not_flag_dense_cjk_prose_as_fragmented() {
        // Real Japanese sentence about cats (no inter-word spaces — this
        // script never has them). Naturally clusters into short 1-3
        // character "words" once split at whatever boundary a caller
        // uses; that must not read as glyph-per-span CMap breakage the
        // way it legitimately would for Latin text. ~keep
        let ja = "ネコ 猫 は 狭義 に は 食肉目 ネコ科 ネコ属 に 分類 される \
                  リビア ヤマネコ が 家畜 化 された イエネコ に 対する 通称 である";
        assert_eq!(
            text_quality_gate(ja),
            None,
            "dense CJK prose must not trigger the text-quality gate"
        );
        assert!(is_cjk_dominant_text(ja));
        assert!(!is_cjk_dominant_text("The quick brown fox jumps over the lazy dog."));
    }

    #[test]
    fn cascade_empty_scanned_sparse_over_scan_hybrid_textlayer() {
        let mut s = sig();
        s.page_is_empty = true;
        assert_eq!(
            classify_from_signals(&s, &AutoExtractOptions::balanced()).0,
            PageKind::Empty
        );

        // Pure scan (CCITT) → high-confidence Scanned. ~keep
        let mut s = sig();
        s.image_area_ratio = 0.97;
        s.codec = ImageCodecClass::Ccitt;
        let (k, c, _) = classify_from_signals(&s, &AutoExtractOptions::balanced());
        assert_eq!(k, PageKind::Scanned);
        assert!(c >= 0.95);

        // Sparse text over a scan (case G — the headline fix): a tiny
        // header must NOT classify as TextLayer. ~keep
        let mut s = sig();
        s.image_area_ratio = 0.95;
        s.text_glyph_count = 60; // a Bates/header line ~keep
        s.text_area_ratio = 0.02;
        assert_eq!(
            classify_from_signals(&s, &AutoExtractOptions::balanced()).0,
            PageKind::Scanned
        );

        // Hybrid: real text + sub-page image (cases D/S). ~keep
        let mut s = sig();
        s.text_glyph_count = 800;
        s.text_area_ratio = 0.5;
        s.image_area_ratio = 0.25;
        assert_eq!(
            classify_from_signals(&s, &AutoExtractOptions::balanced()).0,
            PageKind::ImageText
        );

        let mut s = sig();
        s.text_glyph_count = 1200;
        s.text_area_ratio = 0.6;
        s.has_reliable_structure = true;
        let (k, c, r) = classify_from_signals(&s, &AutoExtractOptions::balanced());
        assert_eq!(k, PageKind::TextLayer);
        assert_eq!(r, ReasonCode::NativeTextHighConfidence);
        assert!(c >= 0.90);
    }

    #[test]
    fn cascade_keeps_good_ocr_sidecar_over_scan() {
        // Case C/C2: scan + usable invisible OCR text → keep the text. ~keep
        let mut s = sig();
        s.image_area_ratio = 0.96;
        s.text_glyph_count = 1500;
        s.text_area_ratio = 0.55;
        s.invisible_text_ratio = 0.95;
        assert_eq!(
            classify_from_signals(&s, &AutoExtractOptions::balanced()).0,
            PageKind::TextLayer
        );
    }

    #[test]
    fn summary_is_aggregate_only_never_forced_mode() {
        use PageKind::*;
        assert_eq!(summarise(&[]), DocumentSummary::Empty);
        assert_eq!(summarise(&[Empty, Empty]), DocumentSummary::Empty);
        assert_eq!(
            summarise(&[TextLayer, TextLayer, TextLayer, TextLayer, Empty]),
            DocumentSummary::MostlyText
        );
        assert_eq!(
            summarise(&[Scanned, Scanned, Scanned, Scanned]),
            DocumentSummary::MostlyScanned
        );
        // Heterogeneous doc (case Q) stays Mixed — not forced. ~keep
        assert_eq!(
            summarise(&[TextLayer, Scanned, ImageText, Scanned]),
            DocumentSummary::Mixed
        );
    }

    #[test]
    fn auto_extractor_construction_is_cheap_and_infallible() {
        assert_eq!(AutoExtractor::new().options().mode, ExtractMode::Auto);
        assert_eq!(AutoExtractor::default().options().mode, ExtractMode::Auto);
        assert_eq!(AutoExtractor::text_only().options().mode, ExtractMode::TextOnly);
        let ae = AutoExtractor::with(AutoExtractOptions::high_fidelity());
        assert!(ae.options().min_text_confidence.is_some());
    }
}