mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
457 lines
23 KiB
Rust
457 lines
23 KiB
Rust
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)
|
||
}
|