From 1602b37ec9407e4f34472481faefd6bdce5ffe85 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 14:37:44 -0400 Subject: [PATCH] CJK 1.5: fonts read vertical metrics FontFace::v_advance, v_origin and em_box read vmtx/vhea, VORG (else the glyph top plus its top side bearing) and the ideographic em box (BASE ideo/idtp, else OS/2 typo metrics, else ascender/descender, centred on one em); fonts without them fall back to the em box. shape_vertical shapes top to bottom with those metrics (vert, and vkrn/vpal when asked). --- crates/fonts/src/fontdb.rs | 23 ++++ crates/fonts/src/lib.rs | 75 ++++++++++- crates/fonts/src/testing.rs | 142 +++++++++++++++++---- crates/fonts/src/tests_vertical.rs | 192 +++++++++++++++++++++++++++++ crates/fonts/src/vertical.rs | 127 +++++++++++++++++++ 5 files changed, 537 insertions(+), 22 deletions(-) create mode 100644 crates/fonts/src/tests_vertical.rs create mode 100644 crates/fonts/src/vertical.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7b53235..0ba6531 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -113,6 +113,8 @@ pub struct FontFace { pub instance: Option, /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock>, + /// The ideographic em box, read on first use. + em: std::sync::OnceLock<(f64, f64)>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -241,6 +243,22 @@ impl FontFace { .map(|a| a as f64) .unwrap_or(self.upem * 0.5) } + /// Vertical advance (down the line) of glyph `gid` in font units: `vmtx`, else one em. + pub fn v_advance(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).advance(GlyphId::new(gid)) + } + /// Height of glyph `gid`'s vertical origin above its baseline in font units: `VORG`, else its + /// top plus its `vmtx` top side bearing, else the em box top. Across, the origin is at half the + /// horizontal advance. Upright glyphs in vertical text hang from it on the line's centre. + pub fn v_origin(&self, gid: u32) -> f64 { + crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) + } + /// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880, + /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and + /// descender, the last two centred on one em. + pub fn em_box(&self) -> (f64, f64) { + *self.em.get_or_init(|| crate::vertical::em_box(self)) + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -625,6 +643,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bytes, index, bmp: std::sync::OnceLock::new(), + em: std::sync::OnceLock::new(), }) } @@ -1176,3 +1195,7 @@ mod tests_fallback; #[cfg(not(target_arch = "wasm32"))] #[path = "tests_docfonts.rs"] mod tests_docfonts; + +#[cfg(test)] +#[path = "tests_vertical.rs"] +mod tests_vertical; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 0f25bec..bbce046 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -10,6 +10,7 @@ mod fontdb; #[cfg(any(test, feature = "testing"))] pub mod testing; +mod vertical; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, @@ -57,13 +58,19 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { /// InDesign's default text font is a serif; ours is Source Serif 4. pub const DEFAULT_FAMILY: &str = "Source Serif 4"; -/// One shaped glyph, in font units. +/// One shaped glyph, in font units (y up). +/// +/// Horizontal runs ([`shape`]) advance by `x_advance` (`y_advance` is 0) and the offsets move the +/// glyph from its pen position. Vertical runs ([`shape_vertical`]) advance down the line by +/// `-y_advance` (`x_advance` is 0) and the offsets move the glyph from its vertical origin +/// ([`FontFace::v_origin`]) on the line's centre, as `vpal` or `vkrn` place it. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ShapedGlyph { pub gid: u32, /// Byte offset (in the shaped string) of the cluster this glyph belongs to. pub cluster: usize, pub x_advance: i32, + pub y_advance: i32, pub x_offset: i32, pub y_offset: i32, pub safe_tatweel_before: bool, @@ -205,6 +212,7 @@ fn shape_dir( gid: info.glyph_id, cluster: info.cluster as usize, x_advance: pos.x_advance, + y_advance: 0, x_offset: pos.x_offset, y_offset: pos.y_offset, safe_tatweel_before: info.safe_to_insert_tatweel(), @@ -224,6 +232,7 @@ fn shape_dir( gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, + y_advance: 0, x_offset: 0, y_offset: 0, safe_tatweel_before: false, @@ -234,6 +243,70 @@ fn shape_dir( out } +/// Shape upright text top to bottom (`text`, `features`, `map` and `context` as in +/// [`shape_with_context`]): the shaper applies `vert` (vertical forms), and `vkrn` and `vpal` when +/// `features` turn them on. Advances and origins are the face's vertical metrics +/// ([`FontFace::v_advance`], [`FontFace::v_origin`]). +pub fn shape_vertical(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, context: ShapeContext<'_>) -> Vec { + let mut funcs = vertical::VerticalFuncs { face, metrics: vertical::VMetrics::new(face) }; + let mut out = Vec::with_capacity(text.len()); + let mut origins = Vec::with_capacity(text.len()); + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in text.char_indices() { + buf.add(map(c), i as u32); + } + buf.set_pre_context(context.before); + buf.set_post_context(context.after); + if let Some(language) = context.language.and_then(|t| t.parse::().ok()) { + buf.set_language(language); + } + buf.guess_segment_properties(); + buf.set_direction(Direction::TopToBottom); + let gb = shaper.shape(buf, ShapeOptions::new().features(features).font_funcs(Some(&mut funcs))); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + out.push(ShapedGlyph { + gid: info.glyph_id, + cluster: info.cluster as usize, + x_advance: pos.x_advance, + y_advance: pos.y_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + safe_tatweel_before: false, + rtl: false, + }); + } + }); + if shaped.is_none() + && let Some(f) = face.skrifa() + { + let cmap = f.charmap(); + for (i, c) in text.char_indices() { + let gid = cmap.map(map(c)).unwrap_or_default().to_u32(); + let (x, y) = funcs.origin(gid); + let adv = -(face.v_advance(gid).round() as i32); + out.push(ShapedGlyph { + gid, + cluster: i, + x_advance: 0, + y_advance: adv, + x_offset: -x, + y_offset: -y, + safe_tatweel_before: false, + rtl: false, + }); + } + } + // The shaper hangs each glyph from its vertical origin; keep only what GPOS moved it by. + origins.extend(out.iter().map(|g| funcs.origin(g.gid))); + for (g, (x, y)) in out.iter_mut().zip(origins) { + g.x_offset = g.x_offset.saturating_add(x); + g.y_offset = g.y_offset.saturating_add(y); + } + out +} + /// Glyph id of the first of `chars` the face has (0 = .notdef). pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 1c576ed..3c6e437 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -7,20 +7,21 @@ use skrifa::MetadataProvider; /// isn't installed (a system CJK font on a machine without it). `None` only if the bundled font /// can't be read. pub fn font_with(family: &str, chars: &[char]) -> Option> { - let base = *crate::bundled().first()?; - let gid = skrifa::FontRef::new(base).ok()?.charmap().map('X')?.to_u32(); - let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); - let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let glyphs: Vec<(char, char)> = chars.iter().map(|c| (*c, 'X')).collect(); + font_mapping(family, &glyphs) +} - let mut tables: Vec<([u8; 4], Vec)> = Vec::new(); - for r in 0..usize::from(be16(base, 4)?) { - let rec = 12 + r * 16; - let tag: [u8; 4] = base.get(rec..rec + 4)?.try_into().ok()?; - let (offset, len) = (be32(base, rec + 8)? as usize, be32(base, rec + 12)? as usize); - if &tag != b"name" && &tag != b"cmap" { - tables.push((tag, base.get(offset..offset.checked_add(len)?)?.to_vec())); - } - } +/// [`font_with`], mapping each character of `glyphs` to Source Sans 3's glyph for the character +/// paired with it (`('一', 'X')` draws 一 as X), so characters can have glyphs (and metrics) of +/// their own. `None` if the bundled font can't be read or lacks a glyph asked for. +pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> { + let base = *crate::bundled().first()?; + let base_cmap = skrifa::FontRef::new(base).ok()?.charmap(); + let mut map: Vec<(u32, u32)> = glyphs.iter().map(|(c, g)| Some((*c as u32, base_cmap.map(*g)?.to_u32()))).collect::>()?; + map.sort_unstable(); + map.dedup_by_key(|m| m.0); + let mut tables = tables_of(base)?; + tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap"); // `name`: format 0, family (1) and subfamily (2), Windows Unicode English. let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); @@ -39,10 +40,7 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { tables.push((*b"name", name)); // `cmap`: one format 12 subtable (Windows, full Unicode), one group per character. - let mut cps: Vec = chars.iter().map(|c| *c as u32).collect(); - cps.sort_unstable(); - cps.dedup(); - let sub_len = u32::try_from(16 + 12 * cps.len()).ok()?; + let sub_len = u32::try_from(16 + 12 * map.len()).ok()?; let mut cmap = Vec::new(); for v in [0u16, 1, 3, 10] { cmap.extend_from_slice(&v.to_be_bytes()); @@ -50,21 +48,39 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { cmap.extend_from_slice(&12u32.to_be_bytes()); cmap.extend_from_slice(&12u16.to_be_bytes()); cmap.extend_from_slice(&0u16.to_be_bytes()); - for v in [sub_len, 0, u32::try_from(cps.len()).ok()?] { + for v in [sub_len, 0, u32::try_from(map.len()).ok()?] { cmap.extend_from_slice(&v.to_be_bytes()); } - for cp in cps { + for (cp, gid) in map { for v in [cp, cp, gid] { cmap.extend_from_slice(&v.to_be_bytes()); } } tables.push((*b"cmap", cmap)); + assemble(base, tables) +} - // The file: table directory (sorted by tag), then the tables, each 4-byte aligned. +/// Every table of the font file `font`: (tag, bytes). +fn tables_of(font: &[u8]) -> Option)>> { + let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let mut tables = Vec::new(); + for r in 0..usize::from(be16(font, 4)?) { + let rec = 12 + r * 16; + let tag: [u8; 4] = font.get(rec..rec + 4)?.try_into().ok()?; + let (offset, len) = (be32(font, rec + 8)? as usize, be32(font, rec + 12)? as usize); + tables.push((tag, font.get(offset..offset.checked_add(len)?)?.to_vec())); + } + Some(tables) +} + +/// A font file of `tables`, with the sfnt version of `font`: the table directory (sorted by tag), +/// then the tables, each 4-byte aligned. +fn assemble(font: &[u8], mut tables: Vec<([u8; 4], Vec)>) -> Option> { tables.sort_by_key(|t| t.0); let n = u16::try_from(tables.len()).ok()?; let pow = 1u16 << (15 - n.leading_zeros().min(15)); - let mut out = base.get(0..4)?.to_vec(); + let mut out = font.get(0..4)?.to_vec(); for v in [n, pow * 16, pow.trailing_zeros() as u16, n * 16 - pow * 16] { out.extend_from_slice(&v.to_be_bytes()); } @@ -83,6 +99,62 @@ pub fn font_with(family: &str, chars: &[char]) -> Option> { Some(out) } +/// `font` with `tables` added, each replacing the table of its tag. `None` if `font` can't be read. +fn with_tables(font: &[u8], tables: Vec<([u8; 4], Vec)>) -> Option> { + let mut all = tables_of(font)?; + all.retain(|(tag, _)| !tables.iter().any(|(t, _)| t == tag)); + all.extend(tables); + assemble(font, all) +} + +/// `font` with vertical metrics: a `vhea` table and a `vmtx` table that give every glyph `default` +/// (advance down the line, top side bearing), except the glyphs `overrides` lists (glyph id, +/// advance, top side bearing). Every glyph has a long metric. `None` if `font` can't be read. +pub fn with_vmtx(font: &[u8], default: (u16, i16), overrides: &[(u16, u16, i16)]) -> Option> { + use skrifa::raw::TableProvider; + let glyphs = skrifa::FontRef::new(font).ok()?.maxp().ok()?.num_glyphs(); + let mut vmtx = Vec::with_capacity(usize::from(glyphs) * 4); + let mut max_advance = 0; + for gid in 0..glyphs { + let (advance, tsb) = overrides.iter().find(|o| o.0 == gid).map_or(default, |o| (o.1, o.2)); + max_advance = max_advance.max(advance); + vmtx.extend_from_slice(&advance.to_be_bytes()); + vmtx.extend_from_slice(&tsb.to_be_bytes()); + } + // `vhea` 1.1: typo ascender, descender and line gap, advanceHeightMax, minTop/BottomSideBearing, + // yMaxExtent, caret slope rise and run, caret offset, four reserved, metricDataFormat and + // numOfLongVerMetrics (at byte 34). + let mut vhea = 0x0001_1000_u32.to_be_bytes().to_vec(); + for v in [500i16, -500, 0] { + vhea.extend_from_slice(&v.to_be_bytes()); + } + vhea.extend_from_slice(&max_advance.to_be_bytes()); + for v in [0i16, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] { + vhea.extend_from_slice(&v.to_be_bytes()); + } + vhea.extend_from_slice(&glyphs.to_be_bytes()); + with_tables(font, vec![(*b"vhea", vhea), (*b"vmtx", vmtx)]) +} + +/// `font` with a `VORG` table: every glyph's vertical origin is `default` above its baseline, +/// except the glyphs `overrides` lists (glyph id, origin). `None` if `font` can't be read. +pub fn with_vorg(font: &[u8], default: i16, overrides: &[(u16, i16)]) -> Option> { + let mut entries = overrides.to_vec(); + entries.sort_unstable_by_key(|e| e.0); + entries.dedup_by_key(|e| e.0); + let mut vorg = Vec::new(); + for v in [1u16, 0] { + vorg.extend_from_slice(&v.to_be_bytes()); + } + vorg.extend_from_slice(&default.to_be_bytes()); + vorg.extend_from_slice(&u16::try_from(entries.len()).ok()?.to_be_bytes()); + for (gid, y) in entries { + vorg.extend_from_slice(&gid.to_be_bytes()); + vorg.extend_from_slice(&y.to_be_bytes()); + } + with_tables(font, vec![(*b"VORG", vorg)]) +} + /// `font` with its OS/2 `fsType` (embedding licence bits) set to `fs_type`. `None` if the font has /// no OS/2 table. pub fn with_fs_type(mut font: Vec, fs_type: u16) -> Option> { @@ -92,3 +164,31 @@ pub fn with_fs_type(mut font: Vec, fs_type: u16) -> Option> { font.get_mut(offset + 8..offset + 10)?.copy_from_slice(&fs_type.to_be_bytes()); Some(font) } + +/// Offset and length of table `tag` in `font`'s table directory, and where its record is. +fn table(font: &[u8], tag: &[u8; 4]) -> Option<(usize, usize, usize)> { + let tables = usize::from(u16::from_be_bytes(font.get(4..6)?.try_into().ok()?)); + let rec = (0..tables).map(|r| 12 + r * 16).find(|rec| font.get(*rec..rec + 4) == Some(tag))?; + let offset = u32::from_be_bytes(font.get(rec + 8..rec + 12)?.try_into().ok()?) as usize; + let len = u32::from_be_bytes(font.get(rec + 12..rec + 16)?.try_into().ok()?) as usize; + Some((rec, offset, len)) +} + +/// `font` with table `tag` cut to `len` bytes (its directory length; the bytes stay). `None` if +/// the font has no such table. +pub fn with_table_len(mut font: Vec, tag: &[u8; 4], len: u32) -> Option> { + let (rec, _, _) = table(&font, tag)?; + font.get_mut(rec + 12..rec + 16)?.copy_from_slice(&len.to_be_bytes()); + Some(font) +} + +/// `font` with the big-endian `u16` at byte `at` of table `tag` set to `value`. `None` if the font +/// has no such table or it is too short. +pub fn with_table_u16(mut font: Vec, tag: &[u8; 4], at: usize, value: u16) -> Option> { + let (_, offset, len) = table(&font, tag)?; + if at + 2 > len { + return None; + } + font.get_mut(offset + at..offset + at + 2)?.copy_from_slice(&value.to_be_bytes()); + Some(font) +} diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs new file mode 100644 index 0000000..729ba3b --- /dev/null +++ b/crates/fonts/src/tests_vertical.rs @@ -0,0 +1,192 @@ +//! Vertical metrics (`vmtx`, `vhea`, `VORG`), the ideographic em box and top-to-bottom shaping. +//! +//! The synthetic font (Source Sans 3 with `vhea`/`vmtx`, and `VORG` where asked) runs everywhere; +//! the checks against Shippori Mincho's own tables run when built with craft-fonts. + +use skrifa::raw::TableProvider; + +use super::*; +use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg}; +use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; + +/// Text in Japanese. +fn japanese() -> ShapeContext<'static> { + ShapeContext { language: Some("ja"), ..Default::default() } +} + +/// The synthetic font's characters and the Source Sans 3 glyphs that draw them. +const GLYPHS: &[(char, char)] = &[('一', 'X'), ('ヸ', 'O'), ('、', ',')]; + +/// A font with vertical metrics: every glyph one em down the line with a top side bearing of 120, +/// except ヸ (drawn as O): 1.024 em, top side bearing 100. +fn vertical_font() -> Vec { + let base = font_mapping("Vertical", GLYPHS).unwrap(); + let wi = gid_in(&base, 'ヸ'); + with_vmtx(&base, (1000, 120), &[(wi, 1024, 100)]).unwrap() +} + +fn gid_in(font: &[u8], c: char) -> u16 { + let gid = skrifa::FontRef::new(font).unwrap().charmap().map(c).unwrap().to_u32(); + u16::try_from(gid).unwrap() +} + +/// Shippori Mincho from craft-fonts, or `None` (with a note) when built without it. +fn shippori() -> Option> { + if !crate::japanese_fonts().any(|f| f.family == "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)"); + return None; + } + let face = FontDb::global().face("Shippori Mincho", "Regular"); + assert_eq!(face.family, "Shippori Mincho"); + Some(face) +} + +fn face_of(bytes: Vec) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() +} + +#[test] +fn reads_vmtx() { + let face = face_of(vertical_font()); + let font = face.skrifa().unwrap(); + let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + for (c, advance, tsb) in [('一', 1000.0, 120.0), ('、', 1000.0, 120.0), ('ヸ', 1024.0, 100.0)] { + let gid = face.glyph_for(c); + assert_ne!(gid, 0, "{c}"); + assert_eq!(face.v_advance(gid), advance, "{c}"); + // No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing. + let top = f64::from(gm.bounds(GlyphId::new(gid)).unwrap().y_max); + assert_eq!(face.v_origin(gid), top + tsb, "{c}"); + } + // The em box doesn't come from vmtx: Source Sans 3's BASE table. + assert_eq!(face.em_box(), (830.0, -170.0)); + // Glyphs past the long metrics advance as the last of them. + let face = face_of(with_table_u16(vertical_font(), b"vhea", 34, 1).unwrap()); + assert_eq!(face.v_advance(face.glyph_for('ヸ')), 1000.0); +} + +#[test] +fn vorg_gives_the_vertical_origin() { + let wi = gid_in(&vertical_font(), 'ヸ'); + let face = face_of(with_vorg(&vertical_font(), 880, &[(wi, 900)]).unwrap()); + assert_eq!(face.v_origin(face.glyph_for('一')), 880.0); + assert_eq!(face.v_origin(u32::from(wi)), 900.0); + assert_eq!(face.v_advance(u32::from(wi)), 1024.0, "advances still come from vmtx"); + // VORG without vmtx: origins from VORG, one em down the line. + let base = font_mapping("Vorg Only", GLYPHS).unwrap(); + let face = face_of(with_vorg(&base, 870, &[]).unwrap()); + let ichi = face.glyph_for('一'); + assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 870.0)); +} + +#[test] +fn shippori_reads_vmtx() { + let Some(face) = shippori() else { return }; + let font = face.skrifa().unwrap(); + let vmtx = font.vmtx().unwrap(); + let gm = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + for c in ['「', '、', '一', 'ヸ'] { + let gid = face.glyph_for(c); + let g = GlyphId::new(gid); + assert_eq!(face.v_advance(gid), f64::from(vmtx.advance(g).unwrap()), "{c}"); + // No VORG (TrueType outlines): the origin is the glyph's top plus its top side bearing. + let top = f64::from(gm.bounds(g).unwrap().y_max) + f64::from(vmtx.side_bearing(g).unwrap()); + assert_eq!(face.v_origin(gid), top, "{c}"); + } + // No BASE table: the em box is the OS/2 typo ascender and descender (exactly one em here). + assert_eq!(face.em_box(), (880.0, -120.0)); + // Ideographs fill the em box; ヸ is taller than it is wide. + let ichi = face.glyph_for('一'); + assert_eq!((face.v_advance(ichi), face.v_origin(ichi)), (1000.0, 880.0)); + let wi = face.glyph_for('ヸ'); + assert_eq!((face.advance(wi), face.v_advance(wi)), (1000.0, 1024.0)); +} + +#[test] +fn fonts_without_vmtx_use_the_em_box() { + // Source Sans 3 has no vmtx; its BASE table gives the ideographic em box bottom (-170). + let face = face_of(font_with("No Vmtx", &['一']).unwrap()); + let gid = face.glyph_for('一'); + assert_ne!(gid, 0); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.v_advance(gid), face.upem); + assert_eq!(face.v_origin(gid), 830.0); + // Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em. + let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); + assert_eq!(face.em_box(), (837.0, -163.0)); + // Without OS/2: the ascender and descender, the same way. + let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); + let (top, bottom) = face.em_box(); + assert_eq!(top - bottom, face.upem); + assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); +} + +#[test] +fn broken_vertical_tables_do_not_panic() { + let mut fonts = vec![("synthetic", vertical_font())]; + if let Some(face) = shippori() { + fonts.push(("Shippori Mincho", face.data().to_vec())); + } + let vorg = with_vorg(&vertical_font(), 880, &[(1, 900), (2, 910)]).unwrap(); + let mut cases = vec![ + ("synthetic: VORG cut short".to_string(), with_table_len(vorg.clone(), b"VORG", 6).unwrap()), + ("synthetic: more origins than VORG holds".to_string(), with_table_u16(vorg, b"VORG", 6, u16::MAX).unwrap()), + ("BASE cut short".to_string(), with_table_len(crate::bundled()[0].to_vec(), b"BASE", 6).unwrap()), + ]; + for (name, font) in fonts { + cases.extend([ + (format!("{name}: vmtx cut short"), with_table_len(font.clone(), b"vmtx", 2).unwrap()), + (format!("{name}: vmtx empty"), with_table_len(font.clone(), b"vmtx", 0).unwrap()), + (format!("{name}: more metrics than vmtx holds"), with_table_u16(font.clone(), b"vhea", 34, u16::MAX).unwrap()), + (format!("{name}: no long metrics"), with_table_u16(font.clone(), b"vhea", 34, 0).unwrap()), + (format!("{name}: vhea cut short"), with_table_len(font, b"vhea", 10).unwrap()), + ]); + } + for (what, bytes) in cases { + let face = face_of(bytes); + let (top, bottom) = face.em_box(); + assert!(top > bottom && (top - bottom - face.upem).abs() < 1e-9, "{what}: {top} {bottom}"); + for gid in [0, face.glyph_for('一'), face.glyph_for('、'), u32::from(u16::MAX), u32::MAX] { + let (adv, origin) = (face.v_advance(gid), face.v_origin(gid)); + assert!(adv.is_finite() && adv >= 0.0 && origin.is_finite(), "{what}: {gid} {adv} {origin}"); + } + let g = shape_vertical(&face, "一、ま", &[feature("vpal").unwrap()], |c| c, japanese()); + assert_eq!(g.len(), 3, "{what}"); + } +} + +#[test] +fn upright_text_shapes_top_to_bottom() { + let face = face_of(vertical_font()); + let h = shape_with_context(&face, "一ヸ、", &[], |c| c, japanese()); + let v = shape_vertical(&face, "一ヸ、", &[], |c| c, japanese()); + // This font has no vertical forms (`vert`): the same glyphs. + assert_eq!(v.iter().map(|g| g.gid).collect::>(), h.iter().map(|g| g.gid).collect::>()); + // Advances run down the line, by vmtx; offsets are what GPOS moves a glyph from its vertical + // origin (nothing here). + let placed: Vec<_> = v.iter().map(|g| (g.x_advance, g.y_advance, g.x_offset, g.y_offset)).collect(); + assert_eq!(placed, [(0, -1000, 0, 0), (0, -1024, 0, 0), (0, -1000, 0, 0)]); + // Origins from VORG leave the offsets alone too. + let face = face_of(with_vorg(&vertical_font(), 880, &[(gid_in(&vertical_font(), 'ヸ'), 900)]).unwrap()); + let v = shape_vertical(&face, "一ヸ", &[], |c| c, ShapeContext::default()); + assert_eq!(v.iter().map(|g| (g.y_advance, g.x_offset, g.y_offset)).collect::>(), [(-1000, 0, 0), (-1024, 0, 0)]); +} + +#[test] +fn shippori_takes_vertical_forms_and_proportional_metrics() { + let Some(face) = shippori() else { return }; + let h = shape_with_context(&face, "一、", &[], |c| c, japanese()); + let v = shape_vertical(&face, "一、", &[], |c| c, japanese()); + assert_eq!(v[0].gid, h[0].gid); + // `vert` without asking: the comma's vertical form. + assert_ne!(v[1].gid, h[1].gid); + assert_eq!(face.v_origin(v[1].gid), 880.0); + // Advances run down the line; offsets are what GPOS moves a glyph from its vertical origin. + for g in &v { + assert_eq!((g.x_advance, g.y_advance, g.x_offset, g.y_offset), (0, -1000, 0, 0), "{g:?}"); + } + assert_eq!(shape_vertical(&face, "ヸ", &[], |c| c, ShapeContext::default())[0].y_advance, -1024); + // `vpal` (proportional vertical metrics) shortens kana and moves them along the line. + let ma = shape_vertical(&face, "ま", &[feature("vpal").unwrap()], |c| c, ShapeContext::default())[0]; + assert_eq!((ma.y_advance, ma.x_offset, ma.y_offset), (-974, 0, 4)); +} diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs new file mode 100644 index 0000000..d360b6f --- /dev/null +++ b/crates/fonts/src/vertical.rs @@ -0,0 +1,127 @@ +//! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical +//! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else +//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top. + +use skrifa::instance::Size; +use skrifa::metrics::GlyphMetrics; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::{vmtx::Vmtx, vorg::Vorg}; +use skrifa::raw::types::Tag; +use skrifa::{GlyphId, MetadataProvider}; + +use crate::FontFace; + +/// A face's vertical metrics tables, read once for a run of lookups. +pub(crate) struct VMetrics<'a> { + vmtx: Option>, + vorg: Option>, + glyphs: Option>, + upem: f64, + em_top: f64, +} + +impl<'a> VMetrics<'a> { + pub(crate) fn new(face: &'a FontFace) -> Self { + let font = face.skrifa(); + VMetrics { + vmtx: font.as_ref().and_then(|f| f.vmtx().ok()), + vorg: font.as_ref().and_then(|f| f.vorg().ok()), + glyphs: font.map(|f| f.glyph_metrics(Size::unscaled(), face.location())), + upem: face.upem, + em_top: face.em_box().0, + } + } + + /// Advance down the line in font units. + pub(crate) fn advance(&self, gid: GlyphId) -> f64 { + self.vmtx.as_ref().and_then(|t| t.advance(gid)).map_or(self.upem, f64::from) + } + + /// Height of the vertical origin above the glyph's baseline in font units. + pub(crate) fn origin(&self, gid: GlyphId) -> f64 { + if let Some(t) = &self.vorg { + return f64::from(t.vertical_origin_y(gid)); + } + // TrueType outlines: the glyph's top plus its top side bearing. + if let (Some(t), Some(g)) = (&self.vmtx, &self.glyphs) + && let (Some(tsb), Some(b)) = (t.side_bearing(gid), g.bounds(gid)) + && t.advance(gid).is_some() + { + return f64::from(b.y_max) + f64::from(tsb); + } + self.em_top + } +} + +/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2 +/// typo ascender and descender, else the ascender and descender; the last two centred on one em. +pub(crate) fn em_box(face: &FontFace) -> (f64, f64) { + let upem = face.upem; + let font = face.skrifa(); + if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { + return b; + } + let centred = |asc: f64, desc: f64| { + let span = asc - desc; + (span > 0.0 && span.is_finite()).then(|| { + let top = asc - (span - upem) / 2.0; + (top, top - upem) + }) + }; + font.and_then(|f| f.os2().ok()) + .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) + .or_else(|| centred(face.ascent, -face.descent)) + .unwrap_or((upem * 0.88, upem * -0.12)) +} + +/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default +/// script's, else the first script's that has them. +fn base_em_box(font: &skrifa::FontRef<'_>, upem: f64) -> Option<(f64, f64)> { + let axis = font.base().ok()?.horiz_axis()?.ok()?; + let tags = axis.base_tag_list()?.ok()?; + let index = |tag: &[u8; 4]| tags.baseline_tags().iter().position(|t| t.get() == Tag::new(tag)); + let (ideo, idtp) = (index(b"ideo"), index(b"idtp")); + if ideo.is_none() && idtp.is_none() { + return None; + } + let scripts = axis.base_script_list().ok()?; + let records = scripts.base_script_records(); + let rank = |t: Tag| [Tag::new(b"hani"), Tag::new(b"kana"), Tag::new(b"DFLT")].iter().position(|p| *p == t).unwrap_or(3); + let mut order: Vec<_> = records.iter().collect(); + order.sort_by_key(|r| rank(r.base_script_tag())); + order.into_iter().find_map(|r| { + let values = r.base_script(scripts.offset_data()).ok()?.base_values()?.ok()?; + let coord = |i: Option| i.and_then(|i| values.base_coords().get(i).ok()).map(|c| f64::from(c.coordinate())); + match (coord(ideo), coord(idtp)) { + (Some(bottom), Some(top)) => (top > bottom).then_some((top, bottom)), + (Some(bottom), None) => Some((bottom + upem, bottom)), + (None, Some(top)) => Some((top, top - upem)), + (None, None) => None, + } + }) +} + +/// The shaper's vertical metrics callbacks: ours, so that its vertical advances and origins are +/// the ones glyphs are placed with. +pub(crate) struct VerticalFuncs<'a> { + pub(crate) face: &'a FontFace, + pub(crate) metrics: VMetrics<'a>, +} + +impl VerticalFuncs<'_> { + /// The vertical origin (x, y) in whole font units: centred across, [`VMetrics::origin`] up. + pub(crate) fn origin(&self, gid: u32) -> (i32, i32) { + ((self.face.advance(gid) / 2.0).round() as i32, self.metrics.origin(GlyphId::new(gid)).round() as i32) + } +} + +impl harfrust::font::FontFuncs for VerticalFuncs<'_> { + fn advance_height(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> i32 { + // The shaper's y axis points up: advancing down the line is negative. + -(self.metrics.advance(GlyphId::new(glyph.to_u32())).round() as i32) + } + + fn vertical_origin(&mut self, _: &harfrust::font::BuiltinFontFuncs, glyph: harfrust::GlyphId) -> (i32, i32) { + self.origin(glyph.to_u32()) + } +}