From f224678541a6f006c6ef5943c8116cb7e4c47ddc Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:36:26 +0900 Subject: [PATCH] M9: place Japanese vertical punctuation using font metrics Co-Authored-By: Codex --- crates/text/src/fonts.rs | 16 +++++++++ crates/text/src/layout.rs | 13 +++++-- crates/text/src/lib.rs | 20 ++++++++--- crates/text/tests/vertical_punctuation.rs | 44 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 crates/text/tests/vertical_punctuation.rs diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 62c753cb..ea2dc4f0 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -210,6 +210,22 @@ impl Face { pub fn has_char(&self, c: char) -> bool { self.glyph(c).is_some() } + /// Horizontal advance and vertical origin above the baseline, in pixels. + pub(crate) fn vertical_origin(&self, gid: u32, px: f32) -> Option<(f64, f64)> { + use skrifa::raw::TableProvider; + let font = self.font()?; + let glyph = skrifa::GlyphId::new(gid); + let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + let origin = match font.vorg() { + Ok(table) => f32::from(table.vertical_origin_y(glyph)), + Err(_) => metrics.bounds(glyph)?.y_max + f32::from(font.vmtx().ok()?.side_bearing(glyph)?), + }; + let scale = f64::from(px / self.units_per_em()); + let width = f64::from(metrics.advance_width(glyph)?) * scale; + let top = f64::from(origin) * scale; + (width.is_finite() && top.is_finite() && top.abs() < f64::from(px) * 4.0).then_some((width, top)) + } + pub fn metrics(&self, px: f32) -> VMetrics { let Some(f) = self.font() else { return VMetrics { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 629013b7..5dd8df8b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -88,6 +88,8 @@ pub struct TextStyle { /// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar /// phantom points) and outlines use this design-space position. pub variations: Vec<(String, f32)>, + /// Use vertical alternates for upright CJK glyphs (runtime layout setting only). + pub vertical: bool, } impl Default for TextStyle { @@ -113,6 +115,7 @@ impl Default for TextStyle { leading: None, opentype: OpenType::default(), variations: Vec::new(), + vertical: false, } } } @@ -198,6 +201,7 @@ impl Hash for TextStyle { self.leading.map(f32::to_bits).hash(h); (self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h); self.opentype.hash(h); + self.vertical.hash(h); for (t, v) in &self.variations { t.hash(h); hf(h, *v); @@ -450,6 +454,10 @@ fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); buf.guess_segment_properties(); let mut feats = Vec::new(); + if style.vertical && chars.first().is_some_and(|(_, c, _)| crate::is_cjk(*c)) { + feats.push(Feature::new(Tag::new(b"vert"), 1, ..)); + feats.push(Feature::new(Tag::new(b"vrt2"), 1, ..)); + } if !style.kerning || style.optical || style.manual_kern.is_some() { feats.push(Feature::new(Tag::new(b"kern"), 0, ..)); } @@ -666,13 +674,14 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let mut items: Vec = Vec::new(); let mut i = 0; while i < chars.len() { - let key = |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6); + let key = + |c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6, runs.get(c.6).is_some_and(|run| run.1.vertical) && crate::is_cjk(c.1)); let k0 = key(&chars[i]); let mut j = i + 1; while j < chars.len() && key(&chars[j]) == k0 { j += 1; } - let (face, rtl, form, si) = k0; + let (face, rtl, form, si, _) = k0; let style = &runs[si].1; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); let size = match form { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 02c99052..21bb12b1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -168,6 +168,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { leading: s.leading.map(|l| l as f32), opentype: s.opentype, variations: s.variations.clone(), + vertical: false, } } @@ -227,7 +228,9 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { let a = byte_of_char[ci.min(nchars)]; ci += r.len; let b = byte_of_char[ci.min(nchars)]; - runs.push((a..b, text_style(&r.style))); + let mut runtime_style = text_style(&r.style); + runtime_style.vertical = doc.vertical && !r.style.tate_chu_yoko; + runs.push((a..b, runtime_style)); styles.push(r.style); } let width = doc.box_size.map(|b| if doc.vertical { b[1] } else { b[0] } as f32); @@ -308,10 +311,17 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { } else if doc.vertical { let upright = is_cjk(src) || st.is_some_and(|s| s.vertical_roman_upright); if upright { - // Keep the character upright around its centre. - let c = Point::new(advance / 2.0, -0.35 * g.size as f64); - let rc = ROT90 * c; - let t = Affine::translate((rc.x - c.x, rc.y - c.y)); + // Anchor vertical forms using their own vertical origin and horizontal + // advance, not the next glyph's pen (which includes tracking/justification). + let font = fonts::face(g.face); + let metrics = is_cjk(src).then(|| font.vertical_origin(g.id, g.size)).flatten(); + let t = if let Some((width, top)) = metrics { + Affine::translate((0.35 * g.size as f64 - width * g.h_scale as f64 / 2.0, top * g.v_scale as f64)) + } else { + let c = Point::new(advance / 2.0, -0.35 * g.size as f64); + let rc = ROT90 * c; + Affine::translate((rc.x - c.x, rc.y - c.y)) + }; path = t * path; outline_xf = t * outline_xf; } else { diff --git a/crates/text/tests/vertical_punctuation.rs b/crates/text/tests/vertical_punctuation.rs new file mode 100644 index 00000000..31a6b909 --- /dev/null +++ b/crates/text/tests/vertical_punctuation.rs @@ -0,0 +1,44 @@ +use effectcraft_keyframe::TextDoc; +use effectcraft_text::{fonts, kurbo, layout_doc}; + +#[test] +fn japanese_vertical_punctuation_uses_alternates_at_the_top_right() { + fonts::scan_system(); + // This integration test owns its font database; probing a native face cannot + // affect the unit tests that verify fallback selection. + let face = fonts::face(fonts::fallback_for('国', fonts::resolve("Inter", "Regular").face)); + if !"国、。「」ー".chars().all(|c| face.has_char(c)) || !(face.has_feature(b"vert") || face.has_feature(b"vrt2")) { + eprintln!("SKIPPED: no Japanese font with vertical alternates available"); + return; + } + let horizontal = + TextDoc { text: "国、。「」ーABC".into(), font: face.info.family.clone(), style: face.info.style.clone(), size: 48.0, ..Default::default() }; + let h = layout_doc(&horizontal); + let vertical = TextDoc { vertical: true, ..horizontal.clone() }; + let v = layout_doc(&vertical); + assert_eq!(v.glyphs.len(), h.glyphs.len()); + assert_eq!(v.glyphs[0].gid, h.glyphs[0].gid); + for i in 1..6 { + assert_ne!(v.glyphs[i].gid, h.glyphs[i].gid, "{}: vertical form for glyph {i}", face.info.family); + } + for i in [1, 2] { + let g = &v.glyphs[i]; + let ink = kurbo::Shape::bounding_box(&g.path); + assert!(ink.center().x > 0.35 * g.size, "{}: punctuation on the right: {ink:?}", face.info.family); + assert!(ink.center().y < g.size / 2.0, "{}: punctuation at the top: {ink:?}", face.info.family); + } + // Half-width Latin remains sideways, and horizontal cache entries stay horizontal. + for i in 6..9 { + assert_eq!(v.glyphs[i].gid, h.glyphs[i].gid); + } + let h_again = layout_doc(&horizontal); + assert_eq!(h_again.glyphs[1].gid, h.glyphs[1].gid); + assert_eq!(h_again.glyphs[1].path, h.glyphs[1].path); + let tracked = { + let mut d = vertical.clone(); + d.apply_style_all(|s| s.tracking = 200.0); + layout_doc(&d) + }; + assert_eq!(tracked.glyphs[1].path, v.glyphs[1].path, "tracking must not move punctuation across its column"); + eprintln!("verified vertical punctuation with {} / {}", face.info.family, face.info.style); +}