From 876f8e283c700eefa9b38ae5c55580a727b7d1d3 Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:33:21 +0900 Subject: [PATCH] =?UTF-8?q?Type:=20Proportional=20Metrics=20bring=20their?= =?UTF-8?q?=20kerning=20=E2=80=94=20`apkn`=20with=20`palt`,=20`vkrn`=20and?= =?UTF-8?q?=20`vapk`=20with=20`vpal`=20(#966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/text/src/features.rs | 15 ++++++++++---- crates/text/src/layout.rs | 7 ++++--- crates/text/src/shape.rs | 31 ++++++++++++++++++---------- crates/text/src/tests_vertical.rs | 34 +++++++++++++++++++++++++++++++ docs/mcp.md | 5 +++-- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 097ec556..af7a7c40 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -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 { + /// 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 { 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 { 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 } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c284c012..e3d93d70 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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; } } } diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 08e88988..0447b03c 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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, 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(); diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests_vertical.rs index 378e191a..1b8c119d 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests_vertical.rs @@ -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| v.iter().map(|f| (f.tag.to_be_bytes(), f.value)).collect::>(); + let st = |pm: bool, kerning: Option| 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| { + 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::>() + }; + 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"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 75e2d011..00e79e4e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}}