//! Text extraction (bootstrap for `printcraft-text`, architecture §8). //! //! A hayro `Device` that ignores paint and records every glyph: its Unicode value (ToUnicode → //! encoding → glyph name fallbacks, handled by hayro) and its box in *page view space*, i.e. //! points with the origin at the top-left of the displayed page, rotation applied, y down. The UI //! maps view space to the screen with a single scale. //! //! On top of the glyph list: line grouping (baseline clustering), word gaps, plain-text output, //! case-insensitive search and reading-order selection. use hayro::hayro_interpret::font::Glyph; use hayro::hayro_interpret::hayro_syntax::Pdf; use hayro::hayro_interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; /// One glyph on the page. #[derive(Clone, Debug, PartialEq)] pub struct TextGlyph { pub text: String, /// Box in page view space (points, y down): [x0, y0, x1, y1]. pub rect: [f32; 4], } /// The text of one page, in content order, with line structure. #[derive(Clone, Debug, Default, PartialEq)] pub struct PageText { pub glyphs: Vec, /// For each glyph: its line index (lines are in reading order, top to bottom). pub line_of: Vec, /// `true` when a word break precedes the glyph on the same line. pub space_before: Vec, } impl PageText { pub fn is_empty(&self) -> bool { self.glyphs.is_empty() } /// Text of glyphs `range` with spaces and line breaks reconstructed. pub fn text_of(&self, range: std::ops::Range) -> String { let mut s = String::new(); let mut prev_line = None; for i in range.clone() { let Some(g) = self.glyphs.get(i) else { break }; if i != range.start { if prev_line != Some(self.line_of[i]) { s.push('\n'); } else if self.space_before[i] { s.push(' '); } } s.push_str(&g.text); prev_line = Some(self.line_of[i]); } s } pub fn plain_text(&self) -> String { self.text_of(0..self.glyphs.len()) } /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks, /// which count as a single space). pub fn find(&self, needle: &str) -> Vec> { self.find_opts(needle, false, false) } /// Search with Acrobat's find options: case-sensitive, whole words only. pub fn find_opts(&self, needle: &str, case_sensitive: bool, whole_words: bool) -> Vec> { let fold = |s: &str| if case_sensitive { s.to_string() } else { s.to_lowercase() }; let needle: Vec = fold(needle).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); } // Flatten to chars with the glyph index each char came from. let mut chars: Vec<(char, usize)> = Vec::new(); for (i, g) in self.glyphs.iter().enumerate() { if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| c.0 != ' ') { chars.push((' ', i)); } for c in fold(&g.text).chars() { chars.push((if c.is_whitespace() { ' ' } else { c }, i)); } } let word = |k: Option<&(char, usize)>| k.is_some_and(|c| c.0.is_alphanumeric()); let mut out = Vec::new(); let mut i = 0; while i + needle.len() <= chars.len() { let bounded = !whole_words || (!word(i.checked_sub(1).and_then(|p| chars.get(p))) && !word(chars.get(i + needle.len()))); if bounded && chars[i..i + needle.len()].iter().map(|c| c.0).eq(needle.iter().copied()) { let start = chars[i].1; let end = chars[i + needle.len() - 1].1 + 1; out.push(start..end); i += needle.len(); } else { i += 1; } } out } /// Glyph ranges of the matches a character matcher finds in the page text (words and lines /// separated by one space, original case). Used for pattern search (Search & Redact). pub fn find_with(&self, matcher: impl Fn(&[char]) -> Vec>) -> Vec> { let mut chars: Vec = Vec::new(); let mut owner: Vec = Vec::new(); for (i, g) in self.glyphs.iter().enumerate() { if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| *c != ' ') { chars.push(' '); owner.push(i); } for c in g.text.chars() { chars.push(if c.is_whitespace() { ' ' } else { c }); owner.push(i); } } matcher(&chars).into_iter().filter(|r| r.start < r.end && r.end <= chars.len()).map(|r| owner[r.start]..owner[r.end - 1] + 1).collect() } /// Index of the glyph nearest to a view-space point (for selection anchors). pub fn nearest(&self, x: f32, y: f32) -> Option { self.glyphs .iter() .enumerate() .map(|(i, g)| { let dx = if x < g.rect[0] { g.rect[0] - x } else if x > g.rect[2] { x - g.rect[2] } else { 0.0 }; let dy = if y < g.rect[1] { g.rect[1] - y } else if y > g.rect[3] { y - g.rect[3] } else { 0.0 }; // Prefer the same line: vertical distance weighs more. (i, dx + dy * 3.0) }) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(i, _)| i) } /// Merge the boxes of `range` into one rectangle per line (for highlighting). pub fn line_rects(&self, range: std::ops::Range) -> Vec<[f32; 4]> { let mut out: Vec<(u32, [f32; 4])> = Vec::new(); for i in range { let Some(g) = self.glyphs.get(i) else { break }; let line = self.line_of[i]; match out.last_mut() { Some((l, r)) if *l == line => { r[0] = r[0].min(g.rect[0]); r[1] = r[1].min(g.rect[1]); r[2] = r[2].max(g.rect[2]); r[3] = r[3].max(g.rect[3]); } _ => out.push((line, g.rect)), } } out.into_iter().map(|(_, r)| r).collect() } } struct TextDevice { glyphs: Vec, /// How many glyphs run in each view direction: right, down, left, up. directions: [usize; 4], } impl<'a> Device<'a> for TextDevice { fn set_soft_mask(&mut self, _: Option>) {} fn set_blend_mode(&mut self, _: BlendMode) {} fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) { let Some(u) = glyph.as_unicode() else { if std::env::var_os("PRINTCRAFT_TEXT_DEBUG").is_some() { eprintln!("NOUNICODE {} {:?}", if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" }, (transform * glyph_transform).as_coeffs()); } return; }; let text = match u { hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(), hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s, }; if text.chars().all(|c| c.is_control()) { return; } // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em // box spans descender (−200) to ascender (800), the advance gives the width. let t = transform * glyph_transform; if std::env::var_os("PRINTCRAFT_TEXT_DEBUG").is_some() { let kind = if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" }; eprintln!("{kind} {text:?} transform={:?} glyph_transform={:?}", transform.as_coeffs(), glyph_transform.as_coeffs()); } let advance = match glyph { Glyph::Outline(g) => { g.advance_width().filter(|a| *a > 0.0).map(f64::from).unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)) } Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; // The baseline direction in view space (y down), to the nearest quarter turn. let (dx, dy) = (t.as_coeffs()[0], t.as_coeffs()[1]); let dir = if dx.abs() >= dy.abs() { if dx >= 0.0 { 0 } else { 2 } } else if dy > 0.0 { 1 } else { 3 }; self.directions[dir] += text.chars().count(); let em = Rect::new(0.0, -200.0, advance, 800.0); let b = (t * em.to_path(0.1)).bounding_box(); if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 { return; } for (i, ch) in text.chars().enumerate() { // Ligatures (e.g. "ffi") share the glyph box, split evenly. let n = text.chars().count().max(1) as f64; let w = b.width() / n; let x0 = b.x0 + w * i as f64; self.glyphs.push(TextGlyph { text: ch.to_string(), rect: [x0 as f32, b.y0 as f32, (x0 + w) as f32, b.y1 as f32] }); } } fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} fn pop_transparency_group(&mut self) {} } /// Extract the text of one page. Panics inside the interpreter are the caller's to catch. pub(crate) fn extract_page(pdf: &Pdf, page: usize, settings: &InterpreterSettings) -> Option { let pages = pdf.pages(); let p = pages.get(page)?; let (w, h) = p.render_dimensions(); let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, // as in Acrobat, and match what other extractors report. let settings = settings.clone(); let initial = p.initial_transform(true).to_kurbo(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings); let mut dev = TextDevice { glyphs: Vec::new(), directions: [0; 4] }; interpret_page(p, &mut ctx, &mut dev); // Lay out in the frame where most text runs left to right, so a page shown rotated (by // `/Rotate` or by its content matrix) still reads along its lines; then map the boxes back. // Pages whose `/Rotate` is cancelled by counter-rotated content stay as they are. let quarter = (0..4u8).max_by_key(|q| (dev.directions[*q as usize], *q == 0)).unwrap_or(0); for g in &mut dev.glyphs { g.rect = to_upright(g.rect, quarter, w, h); } let mut text = layout(dev.glyphs); for g in &mut text.glyphs { g.rect = from_upright(g.rect, quarter, w, h); } Some(text) } /// View-space box → the frame where text runs left to right, for text running `quarter` × 90° /// clockwise from that in a `w` × `h` view (1: text runs downwards). fn to_upright(r: [f32; 4], quarter: u8, w: f32, h: f32) -> [f32; 4] { let [x0, y0, x1, y1] = r; match quarter { 1 => [y0, w - x1, y1, w - x0], 2 => [w - x1, h - y1, w - x0, h - y0], 3 => [h - y1, x0, h - y0, x1], _ => r, } } /// The inverse of [`to_upright`]. fn from_upright(r: [f32; 4], quarter: u8, w: f32, h: f32) -> [f32; 4] { let [u0, v0, u1, v1] = r; match quarter { 1 => [w - v1, u0, w - v0, u1], 2 => [w - u1, h - v1, w - u0, h - v0], 3 => [v0, h - u1, v1, h - u0], _ => r, } } use hayro::hayro_interpret::TransformExt; fn is_rtl(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF) } /// Scripts written with explicit spaces whose combining signs can sit apart from their base /// (Indic, Thai, Lao, Myanmar, Khmer): never infer a space inside a tight cluster. fn is_complex(c: char) -> bool { matches!(c as u32, 0x0300..=0x036F | 0x0900..=0x0DFF | 0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1780..=0x17FF) } fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3134F | 0x1100..=0x11FF) } struct Seg { idx: Vec, bbox: [f32; 4], h: f32, } impl Seg { fn cy(&self) -> f32 { (self.bbox[1] + self.bbox[3]) / 2.0 } } fn union(a: &mut [f32; 4], b: &[f32; 4]) { a[0] = a[0].min(b[0]); a[1] = a[1].min(b[1]); a[2] = a[2].max(b[2]); a[3] = a[3].max(b[3]); } /// Reading-order layout. Glyphs are reordered so that `glyphs` is in reading order, lines are /// numbered in that order, and word gaps are inferred per line. /// /// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8): /// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap. /// Segments on the same baseline that nearly touch are merged (text drawn out of order). /// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally. /// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left /// that overlaps it vertically (so columns read left, then right). /// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the /// text comes out in logical order. pub fn layout(glyphs: Vec) -> PageText { // Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. let mut glyphs = glyphs; let mut keep = vec![true; glyphs.len()]; for i in 1..glyphs.len() { let g = &glyphs[i]; let h = (g.rect[3] - g.rect[1]).max(0.1); for j in (i.saturating_sub(4)..i).rev() { let p = &glyphs[j]; if keep[j] && p.text == g.text && (p.rect[0] - g.rect[0]).abs() < h * 0.2 && (p.rect[1] - g.rect[1]).abs() < h * 0.2 { keep[i] = false; break; } } } if keep.iter().any(|k| !k) { let mut it = keep.iter(); glyphs.retain(|_| *it.next().unwrap_or(&true)); } let n = glyphs.len(); if n == 0 { return PageText::default(); } let height = |i: usize| (glyphs[i].rect[3] - glyphs[i].rect[1]).max(0.1); let cy = |i: usize| (glyphs[i].rect[1] + glyphs[i].rect[3]) / 2.0; // 1. Segments in content order. let mut segs: Vec = Vec::new(); for i in 0..n { let g = glyphs[i].rect; let cont = segs.last().is_some_and(|s| { let p = *s.idx.last().expect("non-empty"); let ph = height(p).min(height(i)); let gap = g[0] - glyphs[p].rect[2]; (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); if cont { let s = segs.last_mut().expect("non-empty"); s.idx.push(i); union(&mut s.bbox, &g); s.h = s.h.max(height(i)); } else { segs.push(Seg { idx: vec![i], bbox: g, h: height(i) }); } } // Merge same-baseline segments that nearly touch. let mut merged = true; while merged { merged = false; 'outer: for a in 0..segs.len() { for b in 0..segs.len() { if a == b { continue; } let (sa, sb) = (&segs[a], &segs[b]); let h = sa.h.min(sb.h); let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]); if (sa.cy() - sb.cy()).abs() < h * 0.3 && (sa.h / sb.h - 1.0).abs() < 0.35 && gap < h * 1.2 { let sb = segs.remove(b); let a = if b < a { a - 1 } else { a }; let sa = &mut segs[a]; sa.idx.extend(sb.idx); union(&mut sa.bbox, &sb.bbox); sa.h = sa.h.max(sb.h); merged = true; break 'outer; } } } } for s in &mut segs { s.idx.sort_by(|a, b| glyphs[*a].rect[0].total_cmp(&glyphs[*b].rect[0])); } // 2. Blocks. let mut by_top: Vec = (0..segs.len()).collect(); by_top.sort_by(|a, b| segs[*a].bbox[1].total_cmp(&segs[*b].bbox[1])); let mut blocks: Vec<(Vec, [f32; 4])> = Vec::new(); for si in by_top { let s = &segs[si]; let target = blocks.iter().position(|(members, bb)| { let last = &segs[*members.last().expect("non-empty")]; let vgap = s.bbox[1] - last.bbox[3]; let overlap = s.bbox[2].min(bb[2]) - s.bbox[0].max(bb[0]); let minw = (s.bbox[2] - s.bbox[0]).min(bb[2] - bb[0]).max(1.0); vgap > -last.h * 0.5 && vgap < last.h.max(s.h) * 1.1 && overlap > minw * 0.3 && (last.h / s.h - 1.0).abs() < 0.6 }); match target { Some(b) => { blocks[b].0.push(si); let bb = s.bbox; union(&mut blocks[b].1, &bb); } None => blocks.push((vec![si], s.bbox)), } } // 3. Block order. let mut remaining: Vec = (0..blocks.len()).collect(); let mut block_order = Vec::with_capacity(blocks.len()); while !remaining.is_empty() { let top = *remaining.iter().min_by(|a, b| blocks[**a].1[1].total_cmp(&blocks[**b].1[1])).expect("non-empty"); let tb = blocks[top].1; let pick = remaining .iter() .copied() .filter(|c| { let cb = blocks[*c].1; let v = cb[3].min(tb[3]) - cb[1].max(tb[1]); cb[2] <= tb[0] + 1.0 && v > 0.5 * (cb[3] - cb[1]).min(tb[3] - tb[1]) }) .min_by(|a, b| blocks[*a].1[0].total_cmp(&blocks[*b].1[0])) .unwrap_or(top); remaining.retain(|r| *r != pick); block_order.push(pick); } // 4. Emit glyphs in reading order with line numbers and word gaps. let mut order: Vec = Vec::with_capacity(n); let mut line_of = Vec::with_capacity(n); let mut space_before = Vec::with_capacity(n); let mut line = 0u32; for b in block_order { for si in &blocks[b].0 { let s = &segs[*si]; let mut idx = s.idx.clone(); // Reverse right-to-left runs (visual → logical). let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); let mut k = 0; while k < idx.len() { if rtl(idx[k]) { let mut e = k; while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) { e += 1; } idx[k..=e].reverse(); k = e + 1; } else { k += 1; } } // Word gaps relative to this line's typical letter gap (handles tracking). let mut gaps: Vec = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect(); gaps.sort_by(f32::total_cmp); let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0); let threshold = (typical + s.h * 0.15).max(s.h * 0.15); let mut spaces = vec![false; idx.len()]; for w in 1..s.idx.len() { let (p, c) = (s.idx[w - 1], s.idx[w]); let gap = glyphs[c].rect[0] - glyphs[p].rect[2]; let (pt, ct) = (&glyphs[p].text, &glyphs[c].text); let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5; let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6; if gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty() { // Mark the space before glyph `c` wherever it ended up after RTL reversal. if let Some(pos) = idx.iter().position(|x| *x == c.max(p)) { spaces[pos] = true; } } } for (k, g) in idx.iter().enumerate() { order.push(*g); line_of.push(line); space_before.push(k > 0 && spaces[k]); } line += 1; } } let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); let glyphs = order.iter().map(|i| slots[*i].take().expect("each glyph emitted once")).collect(); PageText { glyphs, line_of, space_before } } #[cfg(test)] mod tests { use super::*; #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0]; for q in 0..4 { assert_eq!(from_upright(to_upright(r, q, 300.0, 200.0), q, 300.0, 200.0), r, "quarter {q}"); } // A 90° page: the view's top-right corner is the upright top-left. assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); } fn g(t: &str, x0: f32, y0: f32, x1: f32) -> TextGlyph { TextGlyph { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] } } #[test] fn lines_words_and_search() { // "Hello world" on line 1, "Next" on line 2. let mut v = Vec::new(); for (i, c) in "Hello".chars().enumerate() { v.push(g(&c.to_string(), 10.0 + i as f32 * 6.0, 10.0, 16.0 + i as f32 * 6.0)); } for (i, c) in "world".chars().enumerate() { v.push(g(&c.to_string(), 50.0 + i as f32 * 6.0, 10.0, 56.0 + i as f32 * 6.0)); } for (i, c) in "Next".chars().enumerate() { v.push(g(&c.to_string(), 10.0 + i as f32 * 6.0, 30.0, 16.0 + i as f32 * 6.0)); } let t = layout(v); assert_eq!(t.plain_text(), "Hello world\nNext"); assert_eq!(t.find("WORLD next"), vec![5..14]); assert_eq!(t.find("o"), vec![4..5, 6..7]); assert_eq!(t.line_rects(3..12).len(), 2); assert_eq!(t.nearest(52.0, 14.0), Some(5)); } fn word(v: &mut Vec, s: &str, x: f32, y: f32, advance: f32) { for (i, c) in s.chars().enumerate() { let x0 = x + i as f32 * advance; v.push(g(&c.to_string(), x0, y, x0 + 6.0)); } } #[test] fn fake_bold_duplicates_are_removed() { let mut v = Vec::new(); for (i, c) in "Bold".chars().enumerate() { let x = 10.0 + i as f32 * 7.0; v.push(g(&c.to_string(), x, 10.0, x + 6.0)); v.push(g(&c.to_string(), x + 0.3, 10.0, x + 6.3)); } assert_eq!(layout(v).plain_text(), "Bold"); } #[test] fn two_columns_read_left_then_right_even_if_drawn_interleaved() { let mut v = Vec::new(); // Drawn row by row across both columns, as some producers do. word(&mut v, "Left1", 10.0, 10.0, 6.0); word(&mut v, "Right1", 200.0, 10.0, 6.0); word(&mut v, "Left2", 10.0, 24.0, 6.0); word(&mut v, "Right2", 200.0, 24.0, 6.0); let t = layout(v); assert_eq!(t.plain_text(), "Left1\nLeft2\nRight1\nRight2"); } #[test] fn tracked_text_keeps_letters_together() { let mut v = Vec::new(); word(&mut v, "CHAPTER", 10.0, 10.0, 9.0); // 3pt tracking between 6pt-wide letters word(&mut v, "FOUR", 10.0 + 7.0 * 9.0 + 5.0, 10.0, 9.0); assert_eq!(layout(v).plain_text(), "CHAPTER FOUR"); } #[test] fn rtl_runs_come_out_in_logical_order_and_cjk_has_no_spaces() { let mut v = Vec::new(); // Hebrew "שלום" drawn visually right-to-left: ם ו ל ש from left to right. word(&mut v, "םולש", 10.0, 10.0, 6.0); assert_eq!(layout(v).plain_text(), "שלום"); let mut v = Vec::new(); word(&mut v, "每个字", 10.0, 10.0, 9.0); assert_eq!(layout(v).plain_text(), "每个字"); } }