Browse / github.com/storytold/pdfcraft / patch
M10: Recognize text no longer clips the first and last letters of a line
M10: Recognize text no longer clips the first and last letters of a line ## What this changes Recognize text dropped the first and last letters of many lines on 300 dpi scans: "The" came out as "he", "and" as "an", and closing full stops went missing. On clean synthetic pages that was about 4 % of the words, while Tesseract and PaddleOCR read the same pages without errors. **Cause.** ocrs grows each detected word box by a fixed 3 pixels before it cuts the line image out for recognition. At 300 dpi that is less than one pixel of its 800 x 600 detection input, so the line images clip the edge letters. Because the margin is fixed in pixels, it gets worse as the reso
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- huppiflupp (imported from pull request #5058, open)
- Base
9e90b2a54d6250465ba3d11a6eb0b13f3b690381- Size
- +125 −4 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1ebea1f77eded4ff91708a43
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock ROADMAP.md crates/engine/src/ocr.rs crates/ocr/Cargo.toml crates/ocr/src/lib.rs
@@ -4491,6 +4491,7 @@ dependencies = [ "ocrs", "pdfcraft-fonts", "rten",+ "rten-imageproc", "tempfile", "thiserror 2.0.21", ]@@ -684,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---|+| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |@@ -304,6 +304,45 @@ mod tests { } } + /// The first and last letters of a line are read: ocrs' own 3-pixel margin clipped them at+ /// 300 dpi ("The" read as "he", "and" as "an", full stops lost, about 4 % of the words).+ /// Needs the OCR models (`cargo xtask models`).+ #[test]+ fn the_first_and_last_letters_of_a_line_are_read() {+ if !available() {+ eprintln!("skipped: OCR models not installed");+ return;+ }+ let paragraph = "The quarterly report summarizes revenue, operating costs and the outlook for the coming year. \+ Sales in the northern region grew by twelve percent, while the southern offices remained flat. \+ Management expects stable margins despite higher prices for energy and transport. ";+ let text = paragraph.repeat(6);+ let s = Session::new();+ let pdf = s.create_from_text("t", &text).unwrap();+ let mut viewer = Session::new();+ let id = viewer.open("text.pdf", None, pdf, None).unwrap();+ assert_eq!(viewer.get(id).unwrap().info.pages.len(), 1, "the text fits one page");+ let png = export::Exporter::new(viewer.get(id).unwrap()).png(0, 300.0).unwrap();+ let scan = s.create_from_images(&[("1.png".into(), png)]).unwrap();+ let mut reader = Session::new();+ let id = reader.open("scan.pdf", None, scan, None).unwrap();+ let read = reader.recognize_text(id, &[], OcrSettings::default()).unwrap()[0].text();+ let (got, want): (Vec<&str>, Vec<&str>) = (read.split_whitespace().collect(), text.split_whitespace().collect());+ // Word-level edit distance.+ let mut row: Vec<usize> = (0..=want.len()).collect();+ for g in &got {+ let mut prev = row[0];+ row[0] += 1;+ for (j, w) in want.iter().enumerate() {+ let next = (prev + usize::from(g != w)).min(row[j] + 1).min(row[j + 1] + 1);+ prev = row[j + 1];+ row[j + 1] = next;+ }+ }+ let errors = row[want.len()];+ assert!(errors * 100 <= want.len(), "{errors} word errors in {} words:\n{read}", want.len());+ }+ #[test] fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { // A glyph drawn twice (fake bold) does not cover more than the whole word.@@ -14,6 +14,7 @@ workspace = true pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }+rten-imageproc = "0.26.0" thiserror.workspace = true [dev-dependencies]@@ -12,6 +12,8 @@ use std::path::{Path, PathBuf}; +use rten_imageproc::{RectF, RotatedRect};+ pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml.@@ -106,6 +108,52 @@ impl Line { } } +/// How far each found line is widened before its image is cut out for recognition, as fractions+/// of the line's median word height: above and below every word, and before its first and after+/// its last word. ocrs grows each word box by a fixed 3 pixels, under one pixel of its detection+/// model's 800 x 600 input once a page is rendered at 300 dpi: the line images clipped the first+/// and last letters ("The" read as "he", "and" as "an", closing full stops lost), and the more so+/// the higher the resolution. Chosen on synthetic pages at 150 to 400 dpi, where the word error+/// rate fell from 3.8 % to 0.3 %; neighbouring values do about as well.+const LINE_PAD_ACROSS: f32 = 0.15;+const LINE_PAD_ALONG: f32 = 0.1;++/// Widen `line` (its word boxes, in reading order) as [`LINE_PAD_ACROSS`] and+/// [`LINE_PAD_ALONG`] say, after the lines were found, so finding them is unchanged. Returns the+/// box the words covered before, which the recognised words are clipped to: the text layer sizes+/// its text from them.+fn pad_line(line: &mut [RotatedRect]) -> Option<RectF> {+ let before = rten_imageproc::bounding_rect(line.iter())?;+ // A box's height runs along its up axis: one whose up axis is horizontal has width and height+ // swapped.+ let upright = |r: &RotatedRect| {+ let up = r.up_axis();+ up.y.abs() >= up.x.abs()+ };+ let mut heights: Vec<f32> = line.iter().map(|r| if upright(r) { r.height() } else { r.width() }).filter(|h| h.is_finite() && *h > 0.0).collect();+ heights.sort_by(f32::total_cmp);+ let height = heights.get(heights.len() / 2).copied().unwrap_or(0.0);+ let last = line.len().saturating_sub(1);+ for (i, r) in line.iter_mut().enumerate() {+ let along = if i == 0 || i == last { 2.0 * LINE_PAD_ALONG * height } else { 0.0 };+ let across = 2.0 * LINE_PAD_ACROSS * height;+ if upright(r) {+ r.resize(r.width() + along, r.height() + across);+ } else {+ r.resize(r.width() + across, r.height() + along);+ }+ }+ Some(before)+}++/// `rect` ([left, top, right, bottom]) clipped to `to`; unchanged if that leaves nothing.+fn clip(rect: [f32; 4], to: Option<RectF>) -> [f32; 4] {+ let Some(to) = to else { return rect };+ let [l, t, r, b] = rect;+ let clipped = [l.max(to.left()), t.max(to.top()), r.min(to.right()), b.min(to.bottom())];+ if clipped[0] < clipped[2] && clipped[1] < clipped[3] { clipped } else { rect }+}+ /// A loaded recogniser. Loading takes a moment; keep one and reuse it. pub struct Ocr { engine: ocrs::OcrEngine,@@ -144,18 +192,20 @@ impl Ocr { let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?; let input = self.engine.prepare_input(source).map_err(|e| err(&e))?; let found = self.engine.detect_words(&input).map_err(|e| err(&e))?;- let lines = self.engine.find_text_lines(&input, &found);+ let mut lines = self.engine.find_text_lines(&input, &found);+ let before: Vec<Option<RectF>> = lines.iter_mut().map(|l| pad_line(l)).collect(); let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?; use ocrs::TextItem; Ok(read .into_iter()- .flatten()- .map(|line| Line {+ .zip(before)+ .filter_map(|(line, before)| line.map(|l| (l, before)))+ .map(|(line, before)| Line { words: line .words() .map(|w| { let r = w.bounding_rect();- Word { text: w.to_string(), rect: [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32] }+ Word { text: w.to_string(), rect: clip([r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], before) } }) .filter(|w| !w.text.trim().is_empty()) .collect(),@@ -248,6 +298,35 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> { #[cfg(test)] mod tests { use super::*;+ use rten_imageproc::{PointF, Vec2};++ /// A found line is widened by fractions of its word height: every word above and below, the+ /// first and the last word along the line; the box returned is the line's before.+ #[test]+ fn a_line_is_padded_by_its_word_height() {+ let word = |l: f32, r: f32| RotatedRect::from_rect(RectF::from_tlbr(100.0, l, 140.0, r));+ let mut line = vec![word(10.0, 90.0), word(100.0, 200.0), word(210.0, 260.0)];+ let before = pad_line(&mut line).unwrap();+ assert_eq!([before.left(), before.top(), before.right(), before.bottom()], [10.0, 100.0, 260.0, 140.0]);+ let (across, along) = (2.0 * 0.15 * 40.0, 2.0 * 0.1 * 40.0);+ assert!(line.iter().all(|r| (r.height() - (40.0 + across)).abs() < 1e-3));+ assert!((line[0].width() - (80.0 + along)).abs() < 1e-3 && (line[2].width() - (50.0 + along)).abs() < 1e-3);+ assert!((line[1].width() - 100.0).abs() < 1e-3, "a word inside the line keeps its width");+ // A box lying on its side (up axis horizontal) has width and height swapped.+ let mut side = vec![RotatedRect::new(PointF::from_yx(0.0, 0.0), Vec2::from_yx(0.0, 1.0), 40.0, 80.0)];+ pad_line(&mut side).unwrap();+ assert!((side[0].width() - (40.0 + 2.0 * 0.15 * 40.0)).abs() < 1e-3 && (side[0].height() - (80.0 + 2.0 * 0.1 * 40.0)).abs() < 1e-3);+ assert!(pad_line(&mut []).is_none());+ }++ #[test]+ fn recognised_words_are_clipped_to_the_line_found() {+ let line = Some(RectF::from_tlbr(100.0, 10.0, 140.0, 260.0));+ assert_eq!(clip([4.0, 94.0, 60.0, 146.0], line), [10.0, 100.0, 60.0, 140.0]);+ // A word wholly in the padding keeps its own box rather than none.+ assert_eq!(clip([262.0, 94.0, 270.0, 146.0], line), [262.0, 94.0, 270.0, 146.0]);+ assert_eq!(clip([1.0, 2.0, 3.0, 4.0], None), [1.0, 2.0, 3.0, 4.0]);+ } #[test] fn places_words_through_the_mapping() {