mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
M3: a stacked, raised or squeezed combining mark stays with its letter (view.copy-text, view.find)
A second mark stacked on a letter now rides on the letter as well as on the mark before it; a mark drawn up to an em above or below its letter stays on the letter's line, and the glyph after it continues from the letter; marks in a zero-width run are ordered by their letter; and a word gap after a mark is measured from the letter's advance, not the mark's offset start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5307510f66
commit
f1bd39e578
@@ -130,12 +130,23 @@ fn rides_on(p: &TextGlyph, m: &TextGlyph) -> bool {
|
||||
&& m.rect[0] <= p.rect[2] + reach
|
||||
}
|
||||
|
||||
/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph, it joins its cluster.
|
||||
/// How far, as a fraction of its letter's height, a combining mark's em box may sit above or below the letter's and
|
||||
/// still ride on it. A tone mark lifted over an upper vowel can be drawn more than half an em higher, past where a
|
||||
/// glyph of its own would start a new line.
|
||||
const MARK_RISE: f32 = 1.0;
|
||||
|
||||
/// Whether glyph `m`, drawn right after glyph `p` (a letter, or a mark riding on letter `a`), rides on that cluster:
|
||||
/// on `p` itself, or, for a mark stacked on another one, on its letter `a`.
|
||||
fn rides_on_cluster(glyphs: &[&TextGlyph], p: usize, a: usize, m: &TextGlyph) -> bool {
|
||||
glyphs.get(p).is_some_and(|pg| rides_on(pg, m)) || (a != p && glyphs.get(a).is_some_and(|ag| rides_on(ag, m)))
|
||||
}
|
||||
|
||||
/// Glyph `i` continues the run of glyph `i - 1`: when it is a combining mark on that glyph's cluster, it joins it.
|
||||
fn attach_mark(glyphs: &[&TextGlyph], anchor: &mut [usize], i: usize) {
|
||||
let Some(p) = i.checked_sub(1) else { return };
|
||||
if let (Some(pg), Some(mg)) = (glyphs.get(p), glyphs.get(i))
|
||||
&& rides_on(pg, mg)
|
||||
&& let Some(&a) = anchor.get(p)
|
||||
let Some(&a) = anchor.get(p) else { return };
|
||||
if let Some(mg) = glyphs.get(i)
|
||||
&& rides_on_cluster(glyphs, p, a, mg)
|
||||
&& let Some(slot) = anchor.get_mut(i)
|
||||
{
|
||||
*slot = a;
|
||||
@@ -221,7 +232,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
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 Some(last) = s.first.end.checked_sub(1) else { return false };
|
||||
// A glyph after a combining mark continues from the mark's letter, and a mark on that letter's cluster
|
||||
// stays on its line even when drawn raised or lowered by up to an em (`MARK_RISE`).
|
||||
let p = anchor.get(last).copied().unwrap_or(last);
|
||||
if rides_on_cluster(&glyphs, last, p, glyphs[i]) && (cy(i) - cy(p)).abs() < height(p) * MARK_RISE {
|
||||
return true;
|
||||
}
|
||||
let ph = height(p).min(hi);
|
||||
let sh = s.h.min(hi);
|
||||
let gap = g[0] - glyphs[p].rect[2];
|
||||
|
||||
@@ -161,8 +161,9 @@ pub(super) struct Scratch {
|
||||
}
|
||||
|
||||
/// Fills `sc.cluster_gap` with the gap left of each glyph of `sc.placed`, between clusters: a combining mark riding on a
|
||||
/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge or the furthest mark start.
|
||||
/// A zero-advance mark's box is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em).
|
||||
/// glyph has none (no word ever starts at it), and its cluster ends at the base's right edge. A zero-advance mark's box
|
||||
/// is no measure of where the pen went (`draw_glyph` widens it to its outline or half an em), and its start is only
|
||||
/// the base's advance moved by a mark-positioning offset.
|
||||
pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) {
|
||||
sc.cluster_gap.clear();
|
||||
let mut right = f32::NEG_INFINITY;
|
||||
@@ -172,7 +173,6 @@ pub(super) fn measure_cluster_gaps(glyphs: &[&TextGlyph], sc: &mut Scratch) {
|
||||
continue;
|
||||
};
|
||||
if anchor_of(&sc.anchor, c) != c {
|
||||
right = right.max(g.rect[0]);
|
||||
sc.cluster_gap.push(None);
|
||||
} else {
|
||||
sc.cluster_gap.push((w > 0).then_some(g.rect[0] - right));
|
||||
@@ -304,14 +304,15 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32,
|
||||
} else {
|
||||
collide(glyphs, height, s.h, sc);
|
||||
}
|
||||
// Each mark goes before the first placed glyph whose x is greater than its own. Marks are visited in x order, and
|
||||
// that position never moves back, so one pass over `placed` puts all of them in.
|
||||
sc.marks.sort_by(|a, b| x0(*a).total_cmp(&x0(*b)));
|
||||
// Each mark goes before the first placed glyph whose x is greater than its own, or its letter's when it is a
|
||||
// combining mark riding on one. Marks are visited in that order, and that position never moves back, so one pass
|
||||
// over `placed` puts all of them in.
|
||||
sc.marks.sort_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b));
|
||||
if !sc.marks.is_empty() {
|
||||
sc.merged.clear();
|
||||
let mut next = 0;
|
||||
for &m in &sc.marks {
|
||||
while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(m) {
|
||||
while next < sc.placed.len() && x0(anchor_of(&sc.anchor, sc.placed[next])) <= x0(anchor_of(&sc.anchor, m)) {
|
||||
sc.merged.push(sc.placed[next]);
|
||||
next += 1;
|
||||
}
|
||||
|
||||
@@ -2064,3 +2064,33 @@ fn a_mark_moves_with_its_letter_in_a_line_drawn_backwards() {
|
||||
let v = vec![g("a", 12.0, 10.0, 18.0), g("b", 18.0, 10.0, 24.0), g("c", 24.0, 10.0, 30.0), g("\u{301}", 13.0, 10.0, 18.0)];
|
||||
assert_eq!(layout(v).plain_text(), "a\u{301}bc");
|
||||
}
|
||||
|
||||
/// A second mark stacked on a letter rides on the letter, not only on the mark before it: a mark drawn back over its
|
||||
/// base, then a tone mark a hair past the base's advance, both stay before the next letter.
|
||||
#[test]
|
||||
fn a_stacked_mark_rides_on_its_base_letter() {
|
||||
let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e34}", 10.0, 10.0, 15.0), g("\u{e48}", 19.5, 10.0, 24.5), g("ข", 18.0, 10.0, 24.0)];
|
||||
assert_eq!(layout(v).plain_text(), "ก\u{e34}\u{e48}ข");
|
||||
}
|
||||
|
||||
/// A mark drawn raised by more than half an em over its letter (a tone mark lifted above an upper vowel) stays on the
|
||||
/// letter's line and before the next letter.
|
||||
#[test]
|
||||
fn a_raised_mark_stays_with_its_letter() {
|
||||
let v = vec![g("ก", 12.0, 10.0, 18.0), g("\u{e48}", 18.4, 16.0, 23.4), g("ข", 18.0, 10.0, 24.0), g("ค", 24.0, 10.0, 30.0)];
|
||||
assert_eq!(layout(v).plain_text(), "ก\u{e48}ขค");
|
||||
}
|
||||
|
||||
/// A word gap after a mark is measured from its letter's advance: a mark drawn a hair past the pen does not narrow it.
|
||||
#[test]
|
||||
fn a_word_gap_after_a_mark_is_measured_from_its_letter() {
|
||||
let v = vec![g("a", 0.0, 10.0, 6.0), g("b", 6.0, 10.0, 12.0), g("\u{301}", 12.4, 10.0, 17.4), g("c", 13.8, 10.0, 19.8), g("d", 19.8, 10.0, 25.8)];
|
||||
assert_eq!(layout(v).plain_text(), "ab\u{301} cd");
|
||||
}
|
||||
|
||||
/// In a run squeezed to no width, a mark still follows its letter, even when it is drawn past the next letter.
|
||||
#[test]
|
||||
fn a_mark_in_a_zero_width_run_follows_its_letter() {
|
||||
let v = vec![g("a", 10.0, 10.0, 10.0), g("\u{301}", 10.6, 10.0, 10.6), g("b", 10.3, 10.0, 10.3)];
|
||||
assert_eq!(layout(v).plain_text(), "a\u{301}b");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user