Type: Insert Aki keeps a widened glyph's own width and an optical pair's letter

A glyph that justification widens to fill the line keeps its Insert Aki
before it at its width, and its own width, which ligature carets stand
in, now starts where the glyph is drawn. Optical kerning measures a pair
from the letter of a cluster rather than from a combining mark whose
advance holds the cluster's Aki.

The SVG test also checks centered and right-aligned lines with Insert Aki.
This commit is contained in:
dotCooCoo
2026-11-12 15:48:12 -08:00
parent 3c10f9bf71
commit 705b688f2b
5 changed files with 44 additions and 10 deletions
+7 -2
View File
@@ -164,14 +164,19 @@ fn the_space_before_the_first_character_on_a_path_is_its_dx() {
}
/// Insert Aki: each character the layout moves by its Aki starts a `<tspan>` where the character is
/// drawn, so a reader draws the text where the canvas does, with fewer `<tspan>`s asked for too.
/// On a path the type is outlined.
/// drawn, so a reader draws the text where the canvas does, on left-aligned, centered and
/// right-aligned lines and with fewer `<tspan>`s asked for too. On a path the type is outlined.
#[test]
fn insert_aki_places_each_moved_character() {
// 250/1000 em of 12 pt before the O (3 pt) and 500/1000 after it (6 pt), then a run with Aki
// after every character, and a second line.
let spaced = CharStyle { aki_left: 250.0, aki_right: 500.0, ..style(12.0) };
let runs = vec![run("H", style(12.0)), run("O", spaced), run("H ", style(12.0)), run("OHO\nHO", CharStyle { aki_right: 500.0, ..style(12.0) })];
// A centered or right-aligned line whose characters the Aki moves is placed segment by
// segment, not anchored.
for justify in [Justify::Center, Justify::Right] {
assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify));
}
let d = area_doc(runs, 200.0, Justify::Left);
assert_drawn_in_place(&d);
let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len();
+14 -1
View File
@@ -337,6 +337,19 @@ impl Ctx<'_> {
n
}
/// Does glyph `g`'s own width start where its placement sets it ([`PositionedGlyph::width_start`])?
/// Not for an inline graphic, nor in vertical type for an upright or tate-chu-yoko glyph, which
/// start their cell at the start of their advance.
fn places_own_width(&self, g: &SGlyph) -> bool {
g.inline.is_none() && !(self.vertical && (g.tcy.is_some() || (takes_cell(g) && stands_upright(g))))
}
/// How far glyph `g`'s own width moves when the glyph is drawn `shift` further along its
/// advance: `shift` when its placement sets where that width starts, otherwise 0.
fn own_width_shift(&self, g: &SGlyph, shift: f64) -> f64 {
if self.places_own_width(g) { shift } else { 0.0 }
}
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
// Only the glyph right after an upright glyph can continue its cluster.
let cluster = self.upright_cluster.take();
@@ -540,7 +553,7 @@ impl Ctx<'_> {
let [a, b, ..] = pre.as_coeffs();
a.hypot(b)
};
let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
let width_start = if self.places_own_width(g) {
Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0)
} else if upright {
Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0)
+6 -6
View File
@@ -1031,12 +1031,9 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
advance += kashida.unwrap_or(0.0);
let y = line.position.baseline - align_shift(glyph, cx.style_at(glyph.byte).char_align, line_em, cx.vertical);
// A glyph scaled to fill the line keeps its Insert Aki before it at its width.
let transform = if scale != 1.0 {
let aki = if glyph.aki_shift.is_finite() { glyph.aki_shift * (1.0 - scale) } else { 0.0 };
Affine::translate((x + aki, y)) * Affine::scale_non_uniform(scale, 1.0)
} else {
Affine::translate((x, y))
};
let aki = if scale != 1.0 && glyph.aki_shift.is_finite() { glyph.aki_shift * (1.0 - scale) } else { 0.0 };
let transform =
if scale != 1.0 { Affine::translate((x + aki, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) };
if glyph_index < paragraph.marker {
let at = cx.out.glyphs.len();
marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range<usize>| range.start.min(at)..range.end.max(at + 1)));
@@ -1045,8 +1042,11 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
}
}
cx.emit(glyph, transform, Point::new(x, y), 0.0, advance, line_index);
// Its own width starts where the glyph is drawn, with the Aki kept at its width.
let own_shift = cx.own_width_shift(glyph, aki);
if let Some(out) = cx.out.glyphs.last_mut() {
out.scale = paragraph.flex.get(glyph_index).map_or(1.0, |flex| flex.scale) * scale;
out.width_start += own_shift;
}
if !leader.is_empty() {
// The leader belongs to the tab's glyph: the gap stays one character for the
+2 -1
View File
@@ -807,7 +807,8 @@ fn optical_pairs(db: &FontDb, text: &str, glyphs: &mut [SGlyph], runs: &[(Range<
let mut cache = HashMap::new();
let mut corrections: HashMap<[u64; 15], Option<f64>> = HashMap::new();
let mut references: HashMap<u32, Option<f64>> = HashMap::new();
let shaped_advances: Vec<f64> = glyphs.iter().map(|g| g.adv).collect();
// The Insert Aki a cluster's last glyph holds does not make a mark its base.
let shaped_advances: Vec<f64> = glyphs.iter().map(|g| g.adv - g.aki).collect();
for i in 0..glyphs.len().saturating_sub(1) {
let Some((a, b)) = glyphs.get(i).zip(glyphs.get(i + 1)) else { continue };
if a.level != b.level {
+15
View File
@@ -66,6 +66,12 @@ fn optical_kerning_spaces_the_pair_as_without_the_aki() {
assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
let moved = moves(&optical, &with(&optical, 0, |s| s.aki_right = 250.0), &[0, 1, 2]);
assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
// A letter with a combining mark: the pair is measured from the letter, not from the mark
// whose advance holds the cluster's Aki.
let marked = point("H\u{301}V", CharStyle { optical_kerning: true, ..style(100.0) });
let after = "H\u{301}".len();
let moved = moves(&marked, &with(&marked, 0, |s| s.aki_right = 1000.0), &[0, after]);
assert!(near(&moved, &[0.0, 100.0]), "{moved:?}");
}
#[test]
@@ -112,6 +118,15 @@ fn justification_does_not_stretch_or_narrow_the_aki() {
assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
let moved = moves(&scaled, &with(&scaled, 1, |s| s.aki_left = 250.0), &[1, 2]);
assert!(near(&moved, &[25.0, 25.0]), "{moved:?}");
// A glyph that justification widens to fill the line keeps the Aki before it at its width,
// and its own width, which ligature carets stand in, starts where the glyph is drawn.
let mut filled = area("HOH", style(100.0), Rect::new(0.0, 0.0, 600.0, 400.0), Justify::JustifyAll);
filled.para.justification.glyph_scaling = [100.0, 100.0, 300.0];
let l = layout(db(), &with(&filled, 1, |s| s.aki_left = 250.0));
let g = l.glyphs.iter().find(|g| g.byte == 1).unwrap();
assert!(g.scale > 1.0 + 1e-6, "the glyph is widened: {}", g.scale);
let drawn = g.xf.as_coeffs()[4];
assert!((g.origin.x + g.width_start - drawn).abs() < 1e-6, "{} {} {drawn}", g.origin.x, g.width_start);
let mut spaced = point("H H", style(100.0));
spaced.para.justification.word_spacing = [200.0, 200.0, 200.0];
let moved = moves(&spaced, &with(&spaced, 1, |s| s.aki_right = 250.0), &[0, 2]);