Type: Proportional Metrics bring their kerning — `apkn` with `palt`, `vkrn` and `vapk` with `vpal` (#966)

The follow-up #966 left room for. With automatic kerning, horizontal type
with Proportional Metrics also applies `apkn`, and vertical type applies
`vkrn` and `vapk` along with `vpal`. Manual kerning turns them off, as it
turns off `kern`.

Vertical kerning used to cancel out: both top-to-bottom shapings had
harfrust's default `vkrn`. The baseline now has neither `vpal` nor the
kerning, so their difference carries both. A third shaping (`vpal` alone)
tells which advances `vpal` itself changed, and mojikumi steps aside only
for those. Vertical type without Proportional Metrics isn't kerned, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
imachus 2026-10-12 07:33:21 +09:00 committed by bflatastic
parent 68c3cd56f7
commit 876f8e283c
5 changed files with 72 additions and 20 deletions

View File

@ -162,6 +162,9 @@ impl OtFeatures {
}
if self.proportional(st) {
v.push(f(b"palt", true));
// Kerning for the glyphs `palt` makes proportional (OpenType `apkn`), with automatic
// kerning only.
v.push(f(b"apkn", st.kerning.is_none()));
}
v
}
@ -180,20 +183,24 @@ impl OtFeatures {
}
/// The harfrust features for vertical text in style `st` shaped top to bottom: [`Self::resolve`],
/// with `vpal` when `proportional`.
pub(crate) fn resolve_vertical(&self, st: &CharStyle, proportional: bool) -> Vec<Feature> {
/// with `vpal` when `proportional`, and vertical kerning (`vkrn`, and `vapk` for the glyphs
/// `vpal` makes proportional) when `kerning` and the style's kerning is automatic.
pub(crate) fn resolve_vertical(&self, st: &CharStyle, proportional: bool, kerning: bool) -> Vec<Feature> {
let mut v = self.resolve(st);
if proportional {
v.push(f(b"vpal", true));
}
let kern = kerning && st.kerning.is_none();
v.push(f(b"vkrn", kern));
v.push(f(b"vapk", kern));
v
}
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
/// [`Self::resolve`] without `palt`.
/// [`Self::resolve`] without `palt` (and its kerning, `apkn`).
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
let mut v = self.resolve(st);
v.retain(|x| x.tag != Tag::new(b"palt"));
v.retain(|x| x.tag != Tag::new(b"palt") && x.tag != Tag::new(b"apkn"));
v
}
}

View File

@ -56,13 +56,14 @@ impl Ctx<'_> {
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
g.dx += extra * 0.5;
g.adv += extra;
// Proportional Metrics: `vpal` moves it and changes its length down the column,
// in fonts with vertical metrics (else the cell stays as it is, #966).
// Proportional Metrics: `vpal` (and the vertical kerning with it) moves it and
// changes its length down the column, in fonts with vertical metrics (else the cell
// stays as it is, #966).
if let Some(a) = g.vpal.filter(|_| g.tcy.is_none() && g.face.vertical_glyph(g.gid).is_some()) {
g.dx += a.dx;
g.dy += a.dy;
g.adv += a.advance;
g.proportional = a.advance != 0.0;
g.proportional = a.proportional;
}
}
}

View File

@ -64,14 +64,16 @@ pub(crate) struct SGlyph {
pub word_end: bool,
}
/// How `vpal` moves an upright glyph in vertical type and changes its length down the column, in
/// points: `advance` down the column, `dx` and `dy` in glyph space (y down; `dy` is down the column
/// once the glyph stands upright).
/// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change
/// its length down the column, in points: `advance` down the column, `dx` and `dy` in glyph space
/// (y down; `dy` is down the column once the glyph stands upright). `proportional`: `vpal` itself
/// changed the advance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct VAdjust {
pub advance: f64,
pub dx: f64,
pub dy: f64,
pub proportional: bool,
}
/// Placement of an inline graphic's art, glyph space being points with the pen at the origin on
@ -584,17 +586,24 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
}
if feats.proportional_vertical(st) {
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
// top to bottom with and without it tell how it moves each and changes its advance down
// the column (y up, advances negative).
let (with, without) =
(shape(&feats.resolve_vertical(st, true), Direction::TopToBottom), shape(&feats.resolve_vertical(st, false), Direction::TopToBottom));
if same_glyphs(&with) && same_glyphs(&without) {
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
// and without either tell how it moves each and changes its advance down the column
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
// them with it alone.
let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
vpal = with
.iter()
.zip(&without)
.map(|(a, b)| {
let adj =
VAdjust { advance: f64::from(b.3 - a.3) * k * vs, dx: f64::from(a.4 - b.4) * k * hs, dy: -f64::from(a.5 - b.5) * k * vs };
.zip(&alone)
.map(|((a, b), c)| {
let adj = VAdjust {
advance: f64::from(b.3 - a.3) * k * vs,
dx: f64::from(a.4 - b.4) * k * hs,
dy: -f64::from(a.5 - b.5) * k * vs,
proportional: c.3 != b.3,
};
(adj != VAdjust::default()).then_some(adj)
})
.collect();

View File

@ -582,3 +582,37 @@ fn proportional_metrics_set_upright_glyphs_on_their_proportional_heights() {
let (half, none) = (lay("〘あ〙", "Shippori Mincho", true, Mojikumi::LineEndHalf), lay("〘あ〙", "Shippori Mincho", true, Mojikumi::None));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
}
/// Proportional Metrics turns on the kerning that goes with it (#966): `vkrn` and `vapk` in
/// vertical type with `vpal`, `apkn` with `palt`, only while the style's kerning is automatic.
/// Without Proportional Metrics vertical type isn't kerned, as before. Needs craft-fonts' Noto Sans
/// CJK SC, which has `vpal` and `vkrn` (skipped without it; it isn't a document font, so it's loaded
/// here).
#[test]
fn proportional_metrics_kern_the_glyphs_they_respace() {
let tags = |v: Vec<harfrust::Feature>| v.iter().map(|f| (f.tag.to_be_bytes(), f.value)).collect::<Vec<_>>();
let st = |pm: bool, kerning: Option<f64>| CharStyle { size: 20.0, proportional_metrics: pm, kerning, ..CharStyle::default() };
let h = crate::OtFeatures::default();
assert!(tags(h.resolve(&st(true, None))).contains(&(*b"apkn", 1)));
assert!(tags(h.resolve(&st(true, Some(0.0)))).contains(&(*b"apkn", 0)), "manual kerning: none");
assert!(!tags(h.resolve_fixed_width(&st(true, None))).iter().any(|(t, _)| t == b"apkn" || t == b"palt"));
let v = crate::OtFeatures { vertical: true, ..Default::default() };
let with = tags(v.resolve_vertical(&st(true, None), true, true));
assert!(with.contains(&(*b"vpal", 1)) && with.contains(&(*b"vkrn", 1)) && with.contains(&(*b"vapk", 1)), "{with:?}");
assert!(tags(v.resolve_vertical(&st(true, Some(0.0)), true, true)).contains(&(*b"vkrn", 0)), "manual kerning: none");
assert!(tags(v.resolve_vertical(&st(true, None), false, false)).contains(&(*b"vkrn", 0)));
let Some(noto) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Noto Sans CJK SC") else {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(noto.bytes.to_vec());
let lay = |pm: bool, kerning: Option<f64>| {
let s = CharStyle { font_family: "Noto Sans CJK SC".into(), ..st(pm, kerning) };
layout(&db, &vertical_styled("「カタカナ」とトップ、ヤッホー。", s)).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
};
let (auto, manual) = (lay(true, None), lay(true, Some(0.0)));
assert!(auto.iter().zip(&manual).any(|(a, b)| (a - b).abs() > 0.01), "kerned: {auto:?} {manual:?}");
assert!(lay(false, None).iter().all(|a| (a - 20.0).abs() < 0.01), "off: an em each, as before");
}

View File

@ -2697,8 +2697,9 @@ OpenType `palt` feature; glyphs the font doesn't re-space keep their full width.
the proportional heights of the font's `vpal` feature (the glyphs are shaped top to bottom for it) and move with them;
a font without vertical metrics keeps its cells. Line-end Punctuation Half Width then takes nothing more off the
punctuation `palt` or `vpal` re-spaced (the opening bracket at a line's start, consecutive punctuation, the closing
mark at a line's end); punctuation left full width is trimmed as before. Off by default and saved only when on. One
undo step.
mark at a line's end); punctuation left full width is trimmed as before. With automatic kerning the font's kerning
for the re-spaced glyphs comes too: `apkn` with `palt`, and `vkrn` and `vapk` with `vpal` (vertical type is otherwise
not kerned). Off by default and saved only when on. One undo step.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}