Merge pull request #298 from midasdf/fix/vertical-japanese-punctuation
M9: place Japanese vertical punctuation using font metrics
This commit is contained in:
commit
1f5218378c
|
|
@ -214,6 +214,22 @@ impl Face {
|
|||
pub fn has_char(&self, c: char) -> bool {
|
||||
self.font().is_some_and(|f| covers(&f, c))
|
||||
}
|
||||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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<usize>, TextStyle)], primar
|
|||
let mut items: Vec<Item> = 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue