diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4491,6 +4491,7 @@ dependencies = [
  "ocrs",
  "pdfcraft-fonts",
  "rten",
+ "rten-imageproc",
  "tempfile",
  "thiserror 2.0.21",
 ]
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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. |
diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs
--- a/crates/engine/src/ocr.rs
+++ b/crates/engine/src/ocr.rs
@@ -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.
diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml
--- a/crates/ocr/Cargo.toml
+++ b/crates/ocr/Cargo.toml
@@ -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]
diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs
--- a/crates/ocr/src/lib.rs
+++ b/crates/ocr/src/lib.rs
@@ -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() {
