Files
pdfcraft/crates/render/src/text/layout.rs
T
2026-10-28 17:07:36 +08:00

457 lines
23 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::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP};
use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, place_glyphs, redraws, restarts_run, reverse_rtl};
use super::{HashMap, PageText, TextGlyph};
/// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps.
pub(super) struct Flow {
/// The top of the flow's topmost glyph in view space.
pub(super) top: f32,
/// The quarter turn whose frame the flow was laid out in.
pub(super) quarter: Option<u8>,
pub(super) ids: Vec<usize>,
pub(super) line_of: Vec<u32>,
pub(super) space_before: Vec<bool>,
}
/// Lay out the page's glyphs `ids`, giving each the box at the same position in `rects`.
pub(super) fn arrange_flow(glyphs: &[Drawn], ids: Vec<usize>, rects: Vec<[f32; 4]>, quarter: Option<u8>) -> Flow {
let top = ids.iter().filter_map(|&i| glyphs.get(i)).map(|g| g.2.rect[1]).fold(f32::INFINITY, f32::min);
let framed: Vec<TextGlyph> = ids.iter().zip(rects).filter_map(|(&i, rect)| glyphs.get(i).map(|g| TextGlyph { rect, ..g.2.clone() })).collect();
let (order, line_of, space_before) = arrange(&framed);
let ids = order.into_iter().filter_map(|k| ids.get(k).copied()).collect();
Flow { top, quarter, ids, line_of, space_before }
}
/// Boxes for a run of glyphs set along a curve: each glyph's box along its own direction, placed
/// where the pen moved in the previous glyph's direction. The path straightened out.
pub(super) fn unrolled<'g>(run: impl Iterator<Item = &'g Drawn>) -> Vec<[f32; 4]> {
let mut pen = [0.0, 0.0];
let mut previous: Option<([f64; 2], [f64; 4])> = None;
run.map(|(_, u, g)| {
let b = along_baseline(&g.quad, *u);
if let Some((pu, pb)) = previous {
let at = along_baseline(&g.quad, pu);
pen = [pen[0] + at[0] - pb[0], pen[1] + (at[1] + at[3] - pb[1] - pb[3]) / 2.0];
}
previous = Some((*u, b));
let half = (b[3] - b[1]) / 2.0;
[pen[0], pen[1] - half, pen[0] + b[2] - b[0], pen[1] + half].map(|c| c as f32)
})
.collect()
}
/// 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).
pub(super) 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,
}
}
/// Whether two writing directions are close enough to share a frame ([`DIRECTION_TOLERANCE`]).
pub(super) fn compatible(a: [f64; 2], b: [f64; 2]) -> bool {
a[0] * b[0] + a[1] * b[1] > 0.0 && (a[0] * b[1] - a[1] * b[0]).abs() <= DIRECTION_TOLERANCE
}
/// Whether glyph `g` continues the line of glyph `p` (content order), by the test `layout` uses
/// for consecutive glyphs, taken in `p`'s own frame. The direction may turn by less than half a
/// quarter turn, as text set along a curve does.
pub(super) fn continues(p: &Drawn, g: &Drawn) -> bool {
let (pu, gu) = (p.1, g.1);
if (pu[0] * gu[1] - pu[1] * gu[0]).abs() >= pu[0] * gu[0] + pu[1] * gu[1] {
return false;
}
let (pb, gb, own) = (along_baseline(&p.2.quad, pu), along_baseline(&g.2.quad, pu), along_baseline(&g.2.quad, gu));
let h = (pb[3] - pb[1]).min(own[3] - own[1]).max(0.1);
let cy = |b: [f64; 4]| (b[1] + b[3]) / 2.0;
(cy(gb) - cy(pb)).abs() < h * 0.5 && gb[0] - pb[2] < h * 3.0 && gb[0] > pb[0] - h * 2.0
}
/// A view-space quad → its box in the frame where `u` runs left to right and `u` turned a
/// quarter clockwise runs downwards. Along `u` it spans the middles of the quad's start and end
/// sides, so a skewed glyph is no wider than its advance; across, it spans every corner.
pub(super) fn along_baseline(quad: &[[f32; 2]; 4], u: [f64; 2]) -> [f64; 4] {
let p = quad.map(|[x, y]| [f64::from(x) * u[0] + f64::from(y) * u[1], f64::from(y) * u[0] - f64::from(x) * u[1]]);
let (a0, a1) = ((p[0][0] + p[3][0]) / 2.0, (p[1][0] + p[2][0]) / 2.0);
let (c0, c1) = p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q[1]), hi.max(q[1])));
[a0.min(a1), c0, a0.max(a1), c1]
}
/// The inverse of [`to_upright`].
pub(super) 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,
}
}
pub(super) 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)
}
pub(super) 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.
/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment.
/// 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). A column goes on first (#3658):
/// the nearest block below the last one, overlapping it horizontally and at most 2.5 heights of
/// its last line below it, comes next, unless an unread block sits above it in its own span, sits
/// beside it on its left, or shares its first line (the first lines overlap by at least 30% of the
/// taller one: a table or form row, which reads across, even with labels and values in different
/// sizes or on shifted baselines).
/// Known limit: when a paragraph in the next column starts on the same line as the continuation
/// (text on a baseline grid), it counts as a row and the top-most rule applies, so the column
/// order of #3658 is lost there. Telling prose from a row in that case needs more evidence.
/// 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 {
let (order, line_of, space_before) = arrange(&glyphs);
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 }
}
/// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap.
fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
// Drop "fake bold" duplicates: the same character redrawn at (almost) the same place. A redraw
// also overlaps most of its own width: a narrow letter in condensed text (80 Tz and below) sits
// closer than a fifth of its height to its double, as the second "l" of "Alle" (#1467), and
// OCR layers squeeze words to 20 Tz and below (#2457). Only a zero-width mark, which has no
// width to overlap, is matched by its height alone.
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);
let w = g.rect[2] - g.rect[0];
let dx = if w > 0.0 { (h * 0.2).min(w * 0.5) } else { h * 0.2 };
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() < dx && (p.rect[1] - g.rect[1]).abs() < h * 0.2 {
keep[i] = false;
break;
}
}
}
let kept: Vec<usize> = (0..glyphs.len()).filter(|i| keep[*i]).collect();
let glyphs: Vec<&TextGlyph> = kept.iter().map(|i| &glyphs[*i]).collect();
let n = glyphs.len();
if n == 0 {
return Default::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. Every run is a range of consecutive glyphs.
let mut segs: Vec<Seg> = Vec::new();
let mut run_start_x = 0.0;
let mut furthest_progress = 0.0;
for i in 0..n {
let g = glyphs[i].rect;
let hi = height(i);
let cont = segs.last().is_some_and(|s| {
let Some(p) = s.first.end.checked_sub(1) else { return false };
let ph = height(p).min(hi);
let sh = s.h.min(hi);
let gap = g[0] - glyphs[p].rect[2];
// A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by
// more than half its own height, but by less than half the height of the full-size glyph beside it.
let centre_limit = if gap.abs() < ph { height(p).max(hi) * 0.5 } else { ph * 0.5 };
let same_line = ((cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0)
|| ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0);
let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h));
(same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0
});
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
if cont
&& !reset
&& let Some(s) = segs.last_mut()
{
s.first.end = i + 1;
s.extend_box(&g, hi);
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
} else {
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi));
run_start_x = g[0];
furthest_progress = 0.0;
}
}
// A run that redraws an earlier run at the same place (doubled or shadowed text) is dropped, as a doubled glyph is
// above. Runs are keyed by a fingerprint of their glyph texts and a grid cell a fifth of a line high. A key leads to
// the latest retained run with it, and `older[k]` links each retained run to the previous one with the same key, so
// the earlier candidates are checked newest first, up to `REDRAW_CANDIDATES`. Both exist only for this pass.
let retained = {
let candidates = segs.iter().filter(|s| s.first.len() >= 2).count();
let mut latest: HashMap<(u64, i32, i32), usize> = HashMap::with_capacity(candidates);
let mut older = vec![NONE; segs.len()];
let mut kept_runs = 0;
for i in 0..segs.len() {
let run = segs[i].first;
if run.len() >= 2 {
let tol = 0.2 * segs[i].h;
let (cx, cy_) = ((glyphs[run.start].rect[0] / tol).floor() as i32, (glyphs[run.start].rect[1] / tol).floor() as i32);
let fp = fingerprint(&glyphs, run);
let mut checks = REDRAW_CANDIDATES;
let twin = (-1..=1i32).any(|dx| {
(-1..=1i32).any(|dy| {
let mut link = latest.get(&(fp, cx.saturating_add(dx), cy_.saturating_add(dy))).copied();
while let Some(k) = link {
let Some(prev) = segs.get(k) else { break };
if checks == 0 {
return false;
}
checks -= 1;
if redraws(&glyphs, prev.first, run) {
return true;
}
link = older.get(k).copied().filter(|&o| o != NONE);
}
false
})
});
if twin {
continue;
}
older[kept_runs] = latest.insert((fp, cx, cy_), kept_runs).unwrap_or(NONE);
}
segs.swap(kept_runs, i);
kept_runs += 1;
}
kept_runs
};
segs.truncate(retained);
// Merge same-baseline segments that nearly touch, then attach any remaining superscript or
// subscript segments to the closest host line segment they sit on.
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 };
segs[a].merge(sb);
merged = true;
break 'outer;
}
}
}
if !merged {
let mut b = 0;
while b < segs.len() {
let sb = &segs[b];
let best_host = (0..segs.len())
.filter(|&a| {
if a == b {
return false;
}
let sa = &segs[a];
let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
})
.min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs()));
if let Some(a) = best_host {
let sb = segs.remove(b);
let a = if b < a { a - 1 } else { a };
segs[a].merge(sb);
merged = true;
} else {
b += 1;
}
}
}
}
// 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<usize> = Vec::with_capacity(blocks.len());
// A block to the left of `cb` that shares at least half the height of the shorter one: it reads first.
let beside_left = |cb: &[f32; 4], lb: &[f32; 4]| {
let v = lb[3].min(cb[3]) - lb[1].max(cb[1]);
lb[2] <= cb[0] + 1.0 && v > 0.5 * (lb[3] - lb[1]).min(cb[3] - cb[1])
};
// The box of a block's first line. Every block is created with one segment, so the block's own box is a fallback
// that is never used.
let first_line = |b: usize| blocks[b].0.first().and_then(|&s| segs.get(s)).map_or(blocks[b].1, |s| s.bbox);
while let Some(&top) = remaining.iter().min_by(|a, b| blocks[**a].1[1].total_cmp(&blocks[**b].1[1])) {
// A column goes on past a paragraph gap (#3658): the nearest block below the one just read that overlaps it
// horizontally comes next, if the gap above it is at most two and a half heights of the last line read (a
// footer, a footnote or a caption under a figure is further) and no other unread block
// - sits above it in its own horizontal span (the rest of a column above a full-width heading or footnote),
// - is beside it on its left, or
// - shares its first line: their first lines overlap by at least 30% of the taller one (a table or form row,
// which reads across, even with labels and values in different sizes or on shifted baselines; a line that
// only touches it, or a small superscript or footnote marker, is not a row).
let next_in_column = block_order.last().and_then(|&prev| {
let pb = blocks[prev].1;
// The gap is measured in the height of the block's last line, which borders it.
let pl = blocks[prev].0.last().and_then(|&s| segs.get(s)).map_or(pb, |s| s.bbox);
let tol = (pl[3] - pl[1]).max(0.1) * 0.5;
let c = remaining
.iter()
.copied()
.filter(|&c| {
let cb = blocks[c].1;
let overlap = cb[2].min(pb[2]) - cb[0].max(pb[0]);
let minw = (cb[2] - cb[0]).min(pb[2] - pb[0]).max(1.0);
cb[1] >= pb[3] - tol && overlap > minw * 0.3
})
.min_by(|a, b| blocks[*a].1[1].total_cmp(&blocks[*b].1[1]))?;
let cb = blocks[c].1;
if cb[1] - pb[3] >= tol * 5.0 {
return None;
}
let cl = first_line(c);
let blocked = remaining.iter().any(|&r| {
if r == c {
return false;
}
let rb = blocks[r].1;
let rl = first_line(r);
let shared = rl[3].min(cl[3]) - rl[1].max(cl[1]);
let row = shared > 0.3 * (rl[3] - rl[1]).max(cl[3] - cl[1]);
(rb[1] < cb[1] && rb[2].min(cb[2]) > rb[0].max(cb[0])) || beside_left(&cb, &rb) || row
});
(!blocked).then_some(c)
});
let pick = next_in_column.unwrap_or_else(|| {
let tb = blocks[top].1;
remaining
.iter()
.copied()
.filter(|c| beside_left(&tb, &blocks[*c].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 each segment in block order. Its glyphs are placed here, into scratch that every segment reuses, and
// appended straight to the output. Blocks and their order use only segment boxes, so placement can wait until now.
let mut order: Vec<usize> = Vec::with_capacity(n);
let mut line_of: Vec<u32> = Vec::with_capacity(n);
let mut space_before: Vec<bool> = Vec::with_capacity(n);
let mut line = 0u32;
let mut sc = Scratch::default();
// `spaces[w]`: a word gap left of the w-th glyph of the segment, by position on the line, not by drawing order
// (#1062). Reused by every segment.
let mut spaces: Vec<bool> = Vec::new();
for b in block_order {
for &si in &blocks[b].0 {
place_glyphs(&glyphs, &height, &segs[si], &mut sc);
let h = segs[si].h;
// Word gaps relative to this line's typical letter gap (handles tracking).
// Long runs of repeated punctuation belong to leaders, not letter tracking: a TOC leader
// must not raise the threshold above the word gaps in its title (#3086).
sc.gaps.clear();
sc.gaps.extend(sc.placed.windows(2).enumerate().filter_map(|(w, pair)| {
let &[p, c] = pair else { return None };
let (p, c) = (glyphs.get(p)?, glyphs.get(c)?);
if p.text == c.text && p.text.chars().all(|ch| !ch.is_alphanumeric() && !ch.is_whitespace()) {
// Keep short tracked punctuation, including an ellipsis with a final period.
// Treat five or more matching glyphs as a leader. Three neighbours on each
// side suffice to classify this pair, bounding work even on long runs.
let same = |&&i: &&usize| glyphs.get(i).is_some_and(|g| g.text == p.text);
let before = sc.placed.get(..w).unwrap_or_default().iter().rev().take(3).take_while(same).count();
let after = sc.placed.get(w.saturating_add(2)..).unwrap_or_default().iter().take(3).take_while(same).count();
if before + after >= 3 {
return None;
}
}
Some(c.rect[0] - p.rect[2])
}));
sc.gaps.sort_by(f32::total_cmp);
let typical = sc.gaps.get(sc.gaps.len() / 3).copied().unwrap_or(0.0).max(0.0);
let threshold = (typical + h * WORD_GAP).max(h * WORD_GAP);
spaces.clear();
spaces.resize(sc.placed.len(), false);
for (w, pair) in sc.placed.windows(2).enumerate() {
let &[p, c] = pair else { continue };
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 < h * 0.5;
let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < h * 0.6;
let run_start = sc.breaks.binary_search(&c).is_ok();
if let Some(space) = spaces.get_mut(w + 1) {
*space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty();
}
}
// Right-to-left spans are reversed (visual → logical) with their gaps, and only then is a second copy made.
let has_rtl = sc.placed.iter().any(|&i| glyphs[i].text.chars().any(is_rtl));
if has_rtl {
sc.logical.clear();
sc.logical.extend_from_slice(&sc.placed);
reverse_rtl(&glyphs, &mut sc.logical, &mut spaces);
}
let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed };
order.extend_from_slice(idx);
line_of.resize(order.len(), line);
// `spaces[0]` stays false: the first glyph of a segment never takes a space.
space_before.extend(spaces.iter().take(idx.len()));
space_before.resize(order.len(), false);
line += 1;
}
}
(order.into_iter().map(|i| kept[i]).collect(), line_of, space_before)
}