Skip to main content

docling_pdf/
quality.rs

1//! Per-page conversion-confidence scoring (#183) — the Rust port of docling's
2//! confidence plumbing: `rate_text_quality` from the page-preprocessing stage
3//! (parse quality of the extracted text layer) and the layout/OCR score
4//! aggregation the layout-postprocessing and OCR stages assign.
5
6use std::sync::OnceLock;
7
8use docling_core::confidence::{nanmean, nanquantile, PageConfidence};
9use regex::Regex;
10
11use crate::layout::Region;
12use crate::pdfium_backend::TextCell;
13
14/// docling's `rate_text_quality`: score one text cell's extraction quality.
15/// Hard garbage (replacement chars, `GLYPH<..>` references, `/G123`-style
16/// glyph ids, mostly-slash-number content) is a 0.0; otherwise fragmented-word
17/// runs (`W/or.ds/sp.lit` artifacts) are penalised 0.1 each once at least
18/// three appear.
19pub fn rate_text_quality(text: &str) -> f64 {
20    static GLYPH_RE: OnceLock<Regex> = OnceLock::new();
21    static SLASH_G_RE: OnceLock<Regex> = OnceLock::new();
22    static FRAG_RE: OnceLock<Regex> = OnceLock::new();
23    static SLASH_NUMBER_GARBAGE_RE: OnceLock<Regex> = OnceLock::new();
24    let glyph = GLYPH_RE.get_or_init(|| Regex::new(r"GLYPH<[0-9A-Fa-f]+>").unwrap());
25    let slash_g = SLASH_G_RE.get_or_init(|| Regex::new(r"(?:/G\d+){2,}").unwrap());
26    let frag =
27        FRAG_RE.get_or_init(|| Regex::new(r"\b[A-Za-z](?:/[a-z]{1,3}\.[a-z]{1,3}){2,}\b").unwrap());
28    // Python's `.match` anchors at the start of the string only.
29    let slash_number =
30        SLASH_NUMBER_GARBAGE_RE.get_or_init(|| Regex::new(r"^(?:/\w+\s*){2,}").unwrap());
31
32    if text.contains('\u{fffd}')
33        || glyph.is_match(text)
34        || slash_g.is_match(text)
35        || slash_number.is_match(text)
36    {
37        return 0.0;
38    }
39    let frag_matches = frag.find_iter(text).count();
40    let mut penalty = 0.0;
41    if frag_matches >= 3 {
42        penalty += 0.1 * frag_matches as f64;
43    }
44    (1.0 - penalty).max(0.0)
45}
46
47/// The page `parse_score`: the 10th-percentile `rate_text_quality` over the
48/// extracted text-layer cells (the quantile emphasises problem cells, matching
49/// docling's page-preprocessing stage). Unset when the page has no text layer
50/// (a scanned page — docling's `nanquantile([])` is `NaN` there too).
51pub fn parse_score(cells: &[TextCell]) -> Option<f64> {
52    let scores: Vec<Option<f64>> = cells
53        .iter()
54        .map(|c| Some(rate_text_quality(&c.text)))
55        .collect();
56    nanquantile(&scores, 0.10)
57}
58
59/// The page `layout_score`: mean confidence of the final (postprocessed)
60/// layout regions — `Some(0.0)` for a region-less page, exactly like
61/// docling's `float(np.mean(...)) if clusters else 0.0`.
62///
63/// Orphan-text regions carry the sentinel score `0.0` (detector scores always
64/// clear their ≥ 0.3 label thresholds, so 0.0 can only mean "rescued cell").
65/// docling scores an orphan cluster with its *cell's* confidence: 1.0 for a
66/// text-layer cell, the recognition confidence for an OCR cell — substitute
67/// accordingly (`ocr_mean` is the page's mean OCR confidence when the cells
68/// came from OCR, `None` on a digital page).
69pub fn layout_score(regions: &[Region], ocr_mean: Option<f64>) -> Option<f64> {
70    if regions.is_empty() {
71        return Some(0.0);
72    }
73    let scores: Vec<Option<f64>> = regions
74        .iter()
75        .map(|r| {
76            if r.score == 0.0 {
77                Some(ocr_mean.unwrap_or(1.0))
78            } else {
79                Some(r.score as f64)
80            }
81        })
82        .collect();
83    nanmean(&scores)
84}
85
86/// The page `ocr_score`: mean recognition confidence over the page's OCR'd
87/// cells; unset when nothing on the page came from OCR (docling only assigns
88/// it when OCR cells exist).
89pub fn ocr_score(confs: &[f32]) -> Option<f64> {
90    if confs.is_empty() {
91        return None;
92    }
93    Some(confs.iter().map(|&c| c as f64).sum::<f64>() / confs.len() as f64)
94}
95
96/// Assemble the page's [`PageConfidence`] (`table_score` stays unset — docling
97/// never assigns it either).
98pub fn page_confidence(
99    parse: Option<f64>,
100    regions: &[Region],
101    ocr_confs: &[f32],
102) -> PageConfidence {
103    let ocr = ocr_score(ocr_confs);
104    PageConfidence {
105        parse_score: parse,
106        layout_score: layout_score(regions, ocr),
107        table_score: None,
108        ocr_score: ocr,
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn text_quality_matches_docling_rules() {
118        assert_eq!(rate_text_quality("A perfectly ordinary sentence."), 1.0);
119        // Hard garbage → 0.0.
120        assert_eq!(rate_text_quality("bad \u{fffd} char"), 0.0);
121        assert_eq!(rate_text_quality("see GLYPH<0a3F> here"), 0.0);
122        assert_eq!(rate_text_quality("/G12/G34 rest"), 0.0);
123        assert_eq!(rate_text_quality("/x1 /y2 tail"), 0.0);
124        // The slash-number pattern only fires anchored at the start.
125        assert_eq!(rate_text_quality("word /x1 /y2"), 1.0);
126        // Fragmented words (a letter then ≥2 `/xx.yy` runs): below three
127        // matches no penalty applies; at three, 0.1 each.
128        let frag = "W/or.ds/sp.lit";
129        assert_eq!(rate_text_quality(frag), 1.0, "one match is tolerated");
130        assert_eq!(rate_text_quality(&format!("{frag} {frag}")), 1.0);
131        let three = format!("{frag} {frag} {frag}");
132        assert!((rate_text_quality(&three) - 0.7).abs() < 1e-12, "{three}");
133    }
134
135    #[test]
136    fn parse_score_is_tenth_percentile() {
137        let cell = |text: &str| TextCell {
138            text: text.into(),
139            l: 0.0,
140            t: 0.0,
141            r: 1.0,
142            b: 1.0,
143        };
144        assert_eq!(parse_score(&[]), None);
145        // Nine clean cells and one garbage cell: the 10th percentile sits at
146        // the interpolation between the sorted [0.0, 1.0 × 9] head.
147        let mut cells = vec![cell("ok"); 9];
148        cells.push(cell("GLYPH<12>"));
149        let s = parse_score(&cells).unwrap();
150        assert!((s - 0.9).abs() < 1e-9, "{s}");
151    }
152
153    #[test]
154    fn layout_score_substitutes_orphan_sentinel() {
155        let region = |score: f32| Region {
156            label: "text",
157            score,
158            l: 0.0,
159            t: 0.0,
160            r: 1.0,
161            b: 1.0,
162        };
163        assert_eq!(layout_score(&[], None), Some(0.0));
164        // Digital page: the 0.0-score orphan counts as a 1.0-confidence cell.
165        // (Tolerance covers the f32 detector score widened to f64.)
166        let s = layout_score(&[region(0.8), region(0.0)], None).unwrap();
167        assert!((s - 0.9).abs() < 1e-6, "{s}");
168        // OCR'd page: orphans inherit the page's mean OCR confidence.
169        let s = layout_score(&[region(0.8), region(0.0)], Some(0.6)).unwrap();
170        assert!((s - 0.7).abs() < 1e-6, "{s}");
171    }
172}