Files
pdfcraft/crates/render/src/text/device.rs
T

520 lines
25 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.
use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled};
use super::{
Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText,
Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page,
};
/// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be
/// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to
/// h·sin θ, so below the layout's word-gap tolerance (`WORD_GAP` · h) the boxes still read as they
/// would unturned.
pub(super) const DIRECTION_TOLERANCE: f64 = WORD_GAP as f64;
/// A word gap is wider than the line's typical letter gap by this fraction of the line height.
pub(super) const WORD_GAP: f32 = 0.15;
/// A glyph with its writing direction to the nearest quarter turn (right, down, left, up) and as
/// a unit vector.
pub(super) type Drawn = (usize, [f64; 2], TextGlyph);
pub(super) struct TextDevice {
/// Every glyph in content order.
pub(super) glyphs: Vec<Drawn>,
/// How many marked content sequences are open.
depth: usize,
/// The outermost open `/ActualText` replacement: its depth, text and the glyphs drawn inside
/// it (with no text of their own yet).
actual: Option<(usize, String, Vec<Drawn>)>,
/// Every `/ActualText` replacement made on the page.
pub(super) replaced: Vec<Replaced>,
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
page: Rect,
filter: TextFilter,
excluded_depth: Option<usize>,
actual_filtered: bool,
actual_cropped: bool,
partial_replacement: bool,
}
/// An `/ActualText` replacement: the glyph standing for it in `TextDevice::glyphs` (none for an
/// empty replacement), its text, and the text and box of each replaced glyph that has Unicode of
/// its own.
pub(super) struct Replaced {
pub(super) at: Option<usize>,
pub(super) text: String,
pub(super) originals: Vec<(String, [f32; 4])>,
}
/// Grid cell size, in points, for finding the glyphs drawn at a place.
const CELL: f32 = 8.0;
/// Most grid cells searched around one place: a glyph so tall its tolerance spans more is skipped.
const MAX_CELLS: i64 = 4096;
/// Most work (grid cells visited plus glyphs compared) the redraw dedupe in `TextDevice::finish`
/// may do on one page. A hostile page stacking ~10^5 glyphs and ~10^5 replacements at one spot
/// would otherwise make ~10^10 comparisons; past the budget the remaining replacements keep
/// their unmarked redraws (the text reads twice, as it would without `/ActualText`).
const MAX_REDRAW_WORK: usize = 1 << 22;
/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page.
pub(super) const MAX_REDRAW_TEXT: usize = 1 << 22;
#[cfg(test)]
thread_local! {
/// Replacement text bytes given to redraws on this thread.
pub(super) static REDRAW_TEXT_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn cell(v: f32) -> i64 {
// Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates.
(v / CELL).floor() as i64
}
/// How far a redrawn glyph may sit from the one it redraws: a fifth of its height, as in `redraws`.
fn redraw_tolerance(rect: &[f32; 4]) -> f32 {
0.2 * (rect[3] - rect[1]).max(0.1)
}
/// One glyph carrying `text`, boxed around `drawn` in their writing direction; `None` when they
/// span several lines or writing directions (one box would break selection and line grouping).
fn merge(drawn: &[Drawn], text: String) -> Option<Drawn> {
let first = drawn.first()?;
let (quarter, u) = (first.0, first.1);
// Across the line: from the writing direction a quarter turn towards the line's bottom.
let n = [-u[1], u[0]];
let along = |p: &[f32; 2]| f64::from(p[0]) * u[0] + f64::from(p[1]) * u[1];
let across = |p: &[f32; 2]| f64::from(p[0]) * n[0] + f64::from(p[1]) * n[1];
let height = across(&first.2.quad[0]) - across(&first.2.quad[3]);
let (mut s0, mut s1, mut t0, mut t1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for p in drawn.iter().flat_map(|d| d.2.quad.iter()) {
(s0, s1) = (s0.min(along(p)), s1.max(along(p)));
(t0, t1) = (t0.min(across(p)), t1.max(across(p)));
}
let one_line = drawn.iter().all(|d| compatible(u, d.1)) && t1 - t0 <= 2.0 * height.abs();
if !(one_line && s0.is_finite() && s1.is_finite() && t0.is_finite() && t1.is_finite()) {
return None;
}
// The edge towards the next line is the one the first glyph's quad starts on.
let (near, far) = if height >= 0.0 { (t1, t0) } else { (t0, t1) };
let point = |s: f64, t: f64| [(s * u[0] + t * n[0]) as f32, (s * u[1] + t * n[1]) as f32];
let quad = [point(s0, near), point(s1, near), point(s1, far), point(s0, far)];
let rect = quad.iter().fold([f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY], |r, p| {
[r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]
});
Some((quarter, u, TextGlyph { text, rect, quad, direction: first.2.direction }))
}
impl TextDevice {
pub(super) fn new() -> Self {
TextDevice {
glyphs: Vec::new(),
depth: 0,
actual: None,
replaced: Vec::new(),
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
filter: TextFilter::default(),
excluded_depth: None,
actual_filtered: false,
actual_cropped: false,
partial_replacement: false,
}
}
/// Ends the open `/ActualText` replacement (ISO 32000-2 §14.9.4): its glyphs become one glyph
/// carrying the replacement text, boxed around them all. Chrome (Skia) writes one around
/// each Thai, Indic or Arabic cluster whose shaped glyphs (a tone mark moved clear of a vowel,
/// a ligature) have no Unicode of their own, so without it those marks are lost and the
/// words can't be found.
///
/// A replacement spanning several lines or writing directions stays per glyph (each glyph's own
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all.
fn end_actual_text(&mut self) {
let Some((_, text, drawn)) = self.actual.take() else { return };
let cropped = std::mem::take(&mut self.actual_cropped);
if std::mem::take(&mut self.actual_filtered) {
// ActualText has no mapping from replacement words to individual glyphs. A mixed
// span cannot be filtered honestly; a wholly excluded span can simply be omitted.
self.partial_replacement |= !drawn.is_empty();
return;
}
if cropped {
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())));
return;
}
let Some(glyph) = merge(&drawn, text.clone()) else {
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control)));
return;
};
let originals = drawn.iter().filter(|d| !d.2.text.chars().all(char::is_control)).map(|d| (d.2.text.clone(), d.2.rect)).collect();
let at = if text.chars().all(char::is_control) {
None
} else {
self.glyphs.push(glyph);
Some(self.glyphs.len() - 1)
};
self.replaced.push(Replaced { at, text, originals });
}
/// Ends the page. Some producers draw text twice, the copy for an outline, shadow or
/// glow, and mark only one drawing with `/ActualText` (Canva). The unmarked redraw of a
/// replacement's glyphs (the same text at the same places, in content order) takes the same
/// replacement, so the two read alike and line layout drops the copy as a redraw.
pub(super) fn finish(&mut self) {
self.end_actual_text();
if self.replaced.is_empty() {
return;
}
let mut taken = vec![false; self.glyphs.len()];
for at in self.replaced.iter().filter_map(|r| r.at) {
if let Some(t) = taken.get_mut(at) {
*t = true;
}
}
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, g) in self.glyphs.iter().enumerate() {
if !taken.get(i).copied().unwrap_or(true) {
grid.entry((cell(g.2.rect[0]), cell(g.2.rect[1]))).or_default().push(i);
}
}
// Redraws found: the glyph range and the replacement standing for it.
let mut redraws: Vec<(std::ops::Range<usize>, usize)> = Vec::new();
let mut budget = MAX_REDRAW_WORK;
'replacements: for (k, r) in self.replaced.iter().enumerate() {
let Some((_, rect)) = r.originals.first() else { continue };
let tol = redraw_tolerance(rect);
let (x0, x1, y0, y1) = (cell(rect[0] - tol), cell(rect[0] + tol), cell(rect[1] - tol), cell(rect[1] + tol));
if x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1)) > MAX_CELLS {
continue;
}
// Checking one candidate walks its run twice (free, then same).
let candidate_cost = r.originals.len().saturating_mul(2).saturating_add(1);
for key in (x0..=x1).flat_map(|x| (y0..=y1).map(move |y| (x, y))) {
let Some(rest) = budget.checked_sub(1) else { break 'replacements };
budget = rest;
for &i in grid.get(&key).map_or(&[][..], Vec::as_slice) {
let Some(rest) = budget.checked_sub(candidate_cost) else { break 'replacements };
budget = rest;
let Some(range) = i.checked_add(r.originals.len()).map(|end| i..end) else { continue };
let Some(run) = self.glyphs.get(range.clone()) else { continue };
let free = taken.get(range.clone()).is_some_and(|t| t.iter().all(|t| !t));
let same = run.iter().zip(&r.originals).all(|(g, (text, o))| {
let tol = redraw_tolerance(o);
g.2.text == *text && (g.2.rect[0] - o[0]).abs() < tol && (g.2.rect[1] - o[1]).abs() < tol
});
if free && same {
if let Some(t) = taken.get_mut(range.clone()) {
t.fill(true);
}
redraws.push((range, k));
}
}
}
}
if redraws.is_empty() {
return;
}
redraws.sort_by_key(|(range, _)| range.start);
let glyphs = std::mem::take(&mut self.glyphs);
let mut redraws = redraws.into_iter().peekable();
let mut text_left = MAX_REDRAW_TEXT;
let mut i = 0;
while let Some(g) = glyphs.get(i) {
let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else {
self.glyphs.push(g.clone());
i += 1;
continue;
};
let run = glyphs.get(range.clone()).unwrap_or_default();
let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str());
// Each redraw takes its own copy of the replacement, and one long replacement can
// have many redraws: past the budget a redraw keeps its glyphs, as past the work budget.
let Some(left) = text_left.checked_sub(replacement.len()) else {
self.glyphs.extend_from_slice(run);
i = range.end.max(i + 1);
continue;
};
text_left = left;
let text = replacement.to_owned();
#[cfg(test)]
REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len()));
if !text.chars().all(char::is_control) {
match merge(run, text) {
Some(glyph) => self.glyphs.push(glyph),
None => self.glyphs.extend_from_slice(run),
}
}
i = range.end.max(i + 1);
}
}
}
/// Whether `PDFCRAFT_TEXT_DEBUG` is set. `draw_glyph` runs once per glyph and `getenv` scans the
/// whole environment under a lock, so read it once.
fn text_debug() -> bool {
static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*DEBUG.get_or_init(|| std::env::var_os("PDFCRAFT_TEXT_DEBUG").is_some())
}
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>, mode: &GlyphDrawMode) {
if self.excluded_depth.is_some() || (self.filter.images && matches!(mode, GlyphDrawMode::Invisible)) {
self.actual_filtered |= self.actual.is_some();
return;
}
// Inside an `/ActualText` replacement only the glyph's box matters.
let replaced = self.actual.is_some();
let text = match glyph.as_unicode() {
Some(hayro::hayro_interpret::hayro_cmap::BfString::Char(c)) => c.to_string(),
Some(hayro::hayro_interpret::hayro_cmap::BfString::String(s)) => s,
None if replaced => String::new(),
None => {
if text_debug() {
eprintln!(
"NOUNICODE {} {:?}",
if matches!(glyph, Glyph::Type3(_)) { "t3" } else { "ol" },
(transform * glyph_transform).as_coeffs()
);
}
return;
}
};
if !replaced && text.chars().all(char::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 text_debug() {
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).
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 corners = if matches!(glyph, Glyph::Outline(g) if g.is_vertical()) {
[(em.x0, em.y1), (em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1)]
} else {
[(em.x0, em.y0), (em.x1, em.y0), (em.x1, em.y1), (em.x0, em.y1)]
}
.map(|(x, y)| t * kurbo::Point::new(x, y));
let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y))
});
let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1);
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
|| outside_crop
{
self.actual_cropped |= replaced && outside_crop;
return;
}
let length = dx.hypot(dy);
let direction = if length.is_finite() && length > 0.0 { [dx / length, dy / length] } else { [1.0, 0.0] };
// A glyph whose Unicode is several characters (a ligature such as "ffi") stays one glyph
// with its own box: the PDF gives no position for the characters inside it.
let glyph = TextGlyph {
text,
rect: [b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32],
quad: corners.map(|p| [p.x as f32, p.y as f32]),
direction: direction.map(|d| d as f32),
};
match &mut self.actual {
Some((_, _, drawn)) => drawn.push((dir, direction, glyph)),
None => self.glyphs.push((dir, direction, glyph)),
}
}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
fn begin_marked_content(&mut self, tag: &[u8], mcid: Option<i32>) {
self.depth = self.depth.saturating_add(1);
if self.excluded_depth.is_none() && self.filter.excludes(tag, mcid) {
self.excluded_depth = Some(self.depth);
}
}
fn actual_text(&mut self, text: &[u8]) {
// A replacement nested in another is already covered by the outer one.
if self.actual.is_none() && self.excluded_depth.is_none() {
self.actual = Some((self.depth, pdfcraft_cos::PdfString::literal(text).to_text(), Vec::new()));
}
}
fn end_marked_content(&mut self) {
if self.actual.as_ref().is_some_and(|(depth, _, _)| *depth == self.depth) {
self.end_actual_text();
}
if self.excluded_depth == Some(self.depth) {
self.excluded_depth = None;
}
self.depth = self.depth.saturating_sub(1);
}
}
/// 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> {
extract_page_filtered(pdf, page, settings, TextFilter::default()).ok()
}
pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &InterpreterSettings, filter: TextFilter) -> Result<PageText, String> {
let pages = pdf.pages();
let p = pages.get(page).ok_or("the page could not be read")?;
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 c = p.intersected_crop_box();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings);
let mut dev = TextDevice::new();
dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1));
dev.filter = filter;
interpret_page(p, &mut ctx, &mut dev);
// A replacement the content never closed ends with the page.
dev.finish();
if dev.partial_replacement {
return Err("a replacement text span mixes included and excluded content; disable the exclusions to count this page".into());
}
let glyphs = dev.glyphs;
// Text on a curved path: glyphs that each continue the previous one's line while the
// direction turns. Each such run is one line.
let turned = |u: [f64; 2]| u[0].abs().min(u[1].abs()) > DIRECTION_TOLERANCE;
let mut in_curve = vec![false; glyphs.len()];
// Turned text: curved runs, and straight flows along one writing direction. The two are kept
// apart so finding a glyph's direction scans only the directions (a few dozen at most: each
// differs from the others by more than the tolerance), not every curved run.
let mut curves: Vec<Vec<usize>> = Vec::new();
let mut straight: Vec<([f64; 2], Vec<usize>)> = Vec::new();
let mut start = 0;
for end in 1..=glyphs.len() {
if end < glyphs.len() && continues(&glyphs[end - 1], &glyphs[end]) {
continue;
}
let run = &glyphs[start..end];
if run.iter().any(|g| turned(g.1)) && run.iter().any(|g| !compatible(run[0].1, g.1)) {
in_curve[start..end].fill(true);
curves.push((start..end).collect());
}
start = end;
}
let mut quarters: [Vec<usize>; 4] = Default::default();
for (i, (quarter, u, _)) in glyphs.iter().enumerate() {
if in_curve[i] {
continue;
}
if !turned(*u) {
if let Some(q) = quarters.get_mut(*quarter) {
q.push(i);
}
} else if let Some((_, ids)) = straight.iter_mut().find(|(d, _)| compatible(*d, *u)) {
ids.push(i);
} else {
straight.push((*u, vec![i]));
}
}
// 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 upright = |quarters: [Vec<usize>; 4]| {
let mut flows: Vec<Flow> = quarters
.into_iter()
.enumerate()
.filter(|(_, ids)| !ids.is_empty())
.map(|(quarter, ids)| {
let rects = ids.iter().map(|&i| to_upright(glyphs[i].2.rect, quarter as u8, w, h)).collect();
arrange_flow(&glyphs, ids, rects, Some(quarter as u8))
})
.collect();
flows.sort_by(|a, b| a.top.total_cmp(&b.top));
flows
};
let flows = upright(quarters);
let mut extra: Vec<Flow> = curves
.into_iter()
.map(|ids| (None, ids))
.chain(straight.into_iter().map(|(u, ids)| (Some(u), ids)))
.map(|(u, ids)| {
let rects = match u {
// Turned text is laid out along its own baseline.
Some(u) => ids.iter().map(|&i| along_baseline(&glyphs[i].2.quad, u).map(|c| c as f32)).collect(),
None => unrolled(ids.iter().map(|&i| &glyphs[i])),
};
arrange_flow(&glyphs, ids, rects, None)
})
.collect();
// Turned text takes the place in the reading order where laying it out among the nearest
// quarter turn's text puts its first glyph.
let mut rank = Vec::new();
if !extra.is_empty() {
rank = vec![usize::MAX; glyphs.len()];
let mut nearest: [Vec<usize>; 4] = Default::default();
for (i, g) in glyphs.iter().enumerate() {
if let Some(q) = nearest.get_mut(g.0) {
q.push(i);
}
}
for (r, i) in upright(nearest).into_iter().flat_map(|f| f.ids).enumerate() {
rank[i] = r;
}
}
let first = |ids: &[usize]| ids.iter().filter_map(|&i| rank.get(i).copied()).min().unwrap_or(usize::MAX);
extra.sort_by_key(|f| first(&f.ids));
let mut extra = extra.into_iter().peekable();
let mut slots: Vec<Option<TextGlyph>> = glyphs.into_iter().map(|g| Some(g.2)).collect();
let mut text = PageText::default();
let mut emit = |text: &mut PageText, ids: &[usize], line_of: &[u32], space_before: &[bool], quarter: Option<u8>| {
let offset = text.line_of.last().map_or(0, |line| line.saturating_add(1));
for ((&i, &line), &space) in ids.iter().zip(line_of).zip(space_before) {
let Some(mut g) = slots.get_mut(i).and_then(Option::take) else { continue };
if let Some(quarter) = quarter {
g.rect = from_upright(to_upright(g.rect, quarter, w, h), quarter, w, h);
}
text.glyphs.push(g);
text.line_of.push(line.saturating_add(offset));
text.space_before.push(space);
}
};
for flow in flows {
let mut k = 0;
while k < flow.ids.len() {
let line = flow.line_of[k];
let end = flow.line_of[k..].iter().position(|l| *l != line).map_or(flow.ids.len(), |n| k + n);
let before = first(&flow.ids[k..end]);
while let Some(f) = extra.next_if(|f| first(&f.ids) < before) {
emit(&mut text, &f.ids, &f.line_of, &f.space_before, None);
}
emit(&mut text, &flow.ids[k..end], &vec![0; end - k], &flow.space_before[k..end], flow.quarter);
k = end;
}
}
for f in extra {
emit(&mut text, &f.ids, &f.line_of, &f.space_before, None);
}
Ok(text)
}