Files
pdfcraft/crates/render/src/text.rs
T
pauliusiv123-rgbandBrandon Thomas 85dcb9dece Issue #295: three clicks select the line, four clicks the page (#366)
* Text: find the word and the line around a glyph

PageText gets word_at(i) and line_at(i). Each returns the first and
last glyph of the word or the line around glyph i, or None when i is
past the end of the page's text. A word stops at a word gap, a blank
glyph or a line end. A line is the run of glyphs with the same line
number.

The canvas's double-click used its own loop for this, which indexed the
glyph, line and gap arrays directly. It now calls word_at, which reads
them with get(). Double-clicking selects the same words as before.

Test: render words_and_lines_around_a_glyph, on two lines with a blank
glyph, a word gap and a line break.

* Issue #295: three clicks select the line, four clicks the page

With the Select tool, quick clicks on text widen the selection like
Acrobat: two clicks select a word (as before), three the line, four or
more all the text on the page. A third click used to clear the
selection.

egui counts only up to triple clicks and reports a fourth quick click
as another triple, so the view keeps its own count of the current run.
A single click resets it and a pause ends it.

With the markup tools (Highlight and the like) and Redact, a
double-click already turns the word into a mark at once. They stop at
the word, so more clicks don't stack line or page marks.

Tests: ui-egui quick_clicks_select_a_word_then_the_line_then_the_page
and quick_clicks_with_the_highlighter_mark_only_the_word.

parity: view.text-select cites the tests. The notes say what is
missing: paragraph selection, and Ctrl/⌘+A across the whole document
(it selects the current page).

* Clippy: as_chunks in the signature preview test (follow-up to #355)

Clippy 1.99 rejects chunks_exact with a constant chunk size
(chunks_exact_to_as_chunks), so `cargo clippy --workspace --all-targets
-- -D warnings` fails on main since #355. Use as_chunks, as #360 did
for the test above it.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:04:10 -04:00

715 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text extraction (bootstrap for `pdfcraft-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<TextGlyph>,
/// For each glyph: its line index (lines are in reading order, top to bottom).
pub line_of: Vec<u32>,
/// `true` when a word break precedes the glyph on the same line.
pub space_before: Vec<bool>,
}
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<usize>) -> 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<std::ops::Range<usize>> {
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<std::ops::Range<usize>> {
let fold = |s: &str| if case_sensitive { s.to_string() } else { s.to_lowercase() };
let needle: Vec<char> = fold(needle).split_whitespace().collect::<Vec<_>>().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<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
let mut chars: Vec<char> = Vec::new();
let mut owner: Vec<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 != ' ') {
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<usize> {
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)
}
/// The word around glyph `i` as its first and last glyph (double-click): it stops at word
/// gaps, blank glyphs and line ends.
pub fn word_at(&self, i: usize) -> Option<(usize, usize)> {
self.glyphs.get(i)?;
let blank = |k: usize| self.glyphs.get(k).is_none_or(|g| g.text.trim().is_empty());
// Glyph `k` (≥ 1) follows glyph `k - 1` with no word gap or line break between them.
let joined = |k: usize| !self.space_before.get(k).copied().unwrap_or(true) && self.line_of.get(k - 1) == self.line_of.get(k);
let mut first = i;
while first > 0 && joined(first) && !blank(first - 1) {
first -= 1;
}
let mut last = i;
while joined(last + 1) && !blank(last + 1) {
last += 1;
}
Some((first, last))
}
/// The line around glyph `i` as its first and last glyph (triple-click). A line's glyphs are
/// consecutive.
pub fn line_at(&self, i: usize) -> Option<(usize, usize)> {
self.glyphs.get(i)?;
let line = self.line_of.get(i)?;
let same = |k: usize| self.line_of.get(k) == Some(line);
let mut first = i;
while first > 0 && same(first - 1) {
first -= 1;
}
let mut last = i;
while same(last + 1) {
last += 1;
}
Some((first, last))
}
/// Merge the boxes of `range` into one rectangle per line (for highlighting).
pub fn line_rects(&self, range: std::ops::Range<usize>) -> 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 {
/// Separate flows by writing direction: right, down, left, up.
glyphs: [Vec<TextGlyph>; 4],
}
impl<'a> Device<'a> for TextDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
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<SoftMask<'a>>, _: 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("PDFCRAFT_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("PDFCRAFT_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 coefficients = t.as_coeffs();
let (dx, dy) = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) {
// PDF vertical fonts advance along negative glyph-space y (WMode 1), while
// keeping the glyph outline upright. Its x axis is not the writing direction.
(-coefficients[2], -coefficients[3])
} else {
(coefficients[0], coefficients[1])
};
let dir = if dx.abs() >= dy.abs() {
if dx >= 0.0 { 0 } else { 2 }
} else if dy > 0.0 {
1
} else {
3
};
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[dir].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<PageText> {
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: Default::default() };
interpret_page(p, &mut ctx, &mut dev);
// A horizontal heading must not force a vertical body back into horizontal line grouping.
// Group each writing direction independently, then join flows from their topmost position.
let mut flows = Vec::new();
for (quarter, mut glyphs) in dev.glyphs.into_iter().enumerate() {
if glyphs.is_empty() {
continue;
}
let top = glyphs.iter().map(|g| g.rect[1]).fold(f32::INFINITY, f32::min);
for g in &mut glyphs {
g.rect = to_upright(g.rect, quarter as u8, w, h);
}
let mut text = layout(glyphs);
for g in &mut text.glyphs {
g.rect = from_upright(g.rect, quarter as u8, w, h);
}
flows.push((top, text));
}
flows.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut text = PageText::default();
for (_, flow) in flows {
let offset = text.line_of.last().map_or(0, |line| line.saturating_add(1));
text.line_of.extend(flow.line_of.into_iter().map(|line| line.saturating_add(offset)));
text.glyphs.extend(flow.glyphs);
text.space_before.extend(flow.space_before);
}
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<usize>,
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<TextGlyph>) -> 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<Seg> = Vec::new();
for i in 0..n {
let g = glyphs[i].rect;
let cont = segs.last().is_some_and(|s| {
let Some(&p) = s.idx.last() else { return false };
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 Some(s) = segs.last_mut() {
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<usize> = (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<usize>, [f32; 4])> = Vec::new();
for si in by_top {
let s = &segs[si];
let target = blocks.iter().position(|(members, bb)| {
let Some(&m) = members.last() else { return false };
let last = &segs[m];
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<usize> = (0..blocks.len()).collect();
let mut block_order = Vec::with_capacity(blocks.len());
while let Some(&top) = remaining.iter().min_by(|a, b| blocks[**a].1[1].total_cmp(&blocks[**b].1[1])) {
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<usize> = 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<f32> = 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<Option<TextGlyph>> = glyphs.into_iter().map(Some).collect();
// Each glyph is emitted exactly once.
let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect();
PageText { glyphs, line_of, space_before }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_v_columns_are_searchable_in_reading_order() {
use lopdf::{Document, Object, Stream, dictionary};
for encoding in ["Identity-V", "UniJIS-UCS2-V"] {
let mut doc = Document::with_version("1.7");
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 5 beginbfchar <0001> <65E5> <0002> <672C> <0003> <8A9E> <0004> <6587> <0005> <7AE0> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let cmap = if encoding == "Identity-V" {
cmap.to_vec()
} else {
let mut mapping = String::from_utf8(cmap.to_vec()).unwrap();
for (code, unicode) in [("0001", "65E5"), ("0002", "672C"), ("0003", "8A9E"), ("0004", "6587"), ("0005", "7AE0")] {
mapping = mapping.replace(&format!("<{code}> <{unicode}>"), &format!("<{unicode}> <{unicode}>"));
}
mapping.into_bytes()
};
let unicode = doc.add_object(Stream::new(dictionary! {}, cmap));
let cid = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVertical",
"CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 },
"DW" => 1000, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity"
});
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVertical", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode });
let horizontal = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVertical", "Encoding" => "Identity-H", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode });
let (first, second) = if encoding == "Identity-V" { ("000100020003", "00040005") } else { ("65E5672C8A9E", "65877AE0") };
let operators =
format!("BT /H1 20 Tf 1 0 0 1 40 280 Tm <{first}> Tj /F1 20 Tf 1 0 0 1 200 250 Tm <{first}> Tj 1 0 0 1 170 250 Tm <{second}> Tj ET");
let content = doc.add_object(Stream::new(dictionary! {}, operators.into_bytes()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font, "H1" => horizontal}}, "Contents" => content });
doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into());
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages});
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
assert_eq!(text.plain_text(), "日本語\n日本語\n文章");
assert_eq!(text.find("日本語"), vec![0..3, 3..6]);
assert_eq!(text.find("文章"), vec![6..8]);
let rects = text.line_rects(3..6);
assert_eq!(rects.len(), 1);
assert!(rects[0][3] - rects[0][1] > rects[0][2] - rects[0][0], "a vertical search highlight: {rects:?}");
assert!(text.glyphs[3].rect[0] > text.glyphs[6].rect[0], "columns read right to left in page view coordinates");
}
}
#[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<TextGlyph>, 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(), "每个字");
}
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
#[test]
fn words_and_lines_around_a_glyph() {
let t = PageText {
glyphs: Vec::from(["a", "b", " ", "c", "d", "e", "f", "g"].map(|s| TextGlyph { text: s.into(), rect: [0.0; 4] })),
line_of: vec![0, 0, 0, 0, 0, 1, 1, 1],
space_before: vec![false, false, false, false, false, false, true, false],
};
assert_eq!(t.word_at(1), Some((0, 1)));
assert_eq!(t.word_at(3), Some((3, 4)));
assert_eq!(t.word_at(4), Some((3, 4)));
assert_eq!(t.word_at(5), Some((5, 5)));
assert_eq!(t.word_at(7), Some((6, 7)));
assert_eq!(t.line_at(2), Some((0, 4)));
assert_eq!(t.line_at(6), Some((5, 7)));
assert_eq!(t.word_at(8), None);
assert_eq!(t.line_at(8), None);
assert_eq!(PageText::default().line_at(0), None);
}
}