1use 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
14pub 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 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
47pub 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
59pub 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
86pub 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
96pub 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 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 assert_eq!(rate_text_quality("word /x1 /y2"), 1.0);
126 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 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 let s = layout_score(&[region(0.8), region(0.0)], None).unwrap();
167 assert!((s - 0.9).abs() < 1e-6, "{s}");
168 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}