From b720ff6147b2f71d9ed5297db0bb1487fdd2eefe Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 16:02:52 +0000 Subject: [PATCH] CJK: ICF top and bottom from the font's BASE table for grid and character alignment --- crates/compose/src/grid.rs | 13 ++++------ crates/compose/src/lib.rs | 15 ++++++++++-- crates/fonts/src/fontdb.rs | 8 ++++++ crates/fonts/src/testing.rs | 30 +++++++++++++++++++++++ crates/fonts/src/tests_vertical.rs | 18 +++++++++++++- crates/fonts/src/vertical.rs | 39 ++++++++++++++++++++++++------ 6 files changed, 105 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/grid.rs b/crates/compose/src/grid.rs index 4bd2305e..5dc1a7eb 100644 --- a/crates/compose/src/grid.rs +++ b/crates/compose/src/grid.rs @@ -16,10 +16,6 @@ use crate::shape::Glyph; /// Most grid lines one line or paragraph takes (input-derived). pub(crate) const MAX_GYOUDORI: u32 = 100; -/// The ideographic character face's inset from the em box, top and bottom, as a share of the em. -/// An approximation: fonts with a `BASE` table give `icft` / `icfb`, which aren't read yet. -const ICF_INSET: f64 = 0.05; - #[derive(Clone, Copy, Debug, PartialEq)] enum Anchor { First, @@ -36,21 +32,22 @@ fn anchor(r: Reference) -> Anchor { } /// The line's grid reference point below its baseline (negative = above), from its largest -/// character's em box ([`crate::cjk_em_box`], as the leading model measures it). +/// character's em box ([`crate::cjk_em_box`], as the leading model measures it) or ICF box +/// ([`crate::cjk_icf_box`]). pub(crate) fn reference_offset(line: &[Glyph], r: Reference) -> f64 { if r == Reference::Baseline { return 0.0; } let Some(g) = line.iter().filter(|g| g.adv > 0.0).max_by(|a, b| a.size.total_cmp(&b.size)) else { return 0.0 }; let (top, bottom) = crate::cjk_em_box(g); - let inset = (bottom - top) * ICF_INSET; + let (icf_top, icf_bottom) = crate::cjk_icf_box(g); match r { Reference::Baseline => 0.0, Reference::EmTop => top, Reference::EmCenter => (top + bottom) / 2.0, Reference::EmBottom => bottom, - Reference::IcfTop => top + inset, - Reference::IcfBottom => bottom - inset, + Reference::IcfTop => icf_top, + Reference::IcfBottom => icf_bottom, } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8102f5aa..8661155c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2087,6 +2087,17 @@ fn cjk_em_box(g: &Glyph) -> (f64, f64) { let top = -height * g.ascent / (g.ascent + g.descent).max(1e-9); (top, top + height) } +/// The ideographic character face (ICF) box relative to the baseline: the em box of +/// [`cjk_em_box`] inset by the font's own ICF insets (`BASE` `icft`/`icfb`, else 5% of the em). +fn cjk_icf_box(g: &Glyph) -> (f64, f64) { + let (top, bottom) = cjk_em_box(g); + let (em_top, em_bottom) = g.face.em_box(); + let (icf_top, icf_bottom) = g.face.icf_box(); + let em = (em_top - em_bottom).max(1e-9); + let height = bottom - top; + let inset = |v: f64| if v.is_finite() { v.clamp(0.0, 0.5) } else { 0.05 }; + (top + inset((em_top - icf_top) / em) * height, bottom - inset((icf_bottom - em_bottom) / em) * height) +} fn cjk_alignment_shift(g: &Glyph, reference: &Glyph) -> f64 { use designcraft_doc::cjk::CharacterAlignment as A; let (t, b) = cjk_em_box(g); @@ -2096,8 +2107,8 @@ fn cjk_alignment_shift(g: &Glyph, reference: &Glyph) -> f64 { A::EmTop => t - rt, A::EmCenter => (t + b - rt - rb) / 2.0, A::EmBottom => b - rb, - A::IcfTop => reference.ascent - g.ascent, - A::IcfBottom => g.descent - reference.descent, + A::IcfTop => cjk_icf_box(g).0 - cjk_icf_box(reference).0, + A::IcfBottom => cjk_icf_box(g).1 - cjk_icf_box(reference).1, } } fn cjk_line_reference(line: &[Glyph]) -> f64 { diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e14..1c8fd41a 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -119,6 +119,8 @@ pub struct FontFace { bmp: std::sync::OnceLock>, /// The ideographic em box, read on first use. em: std::sync::OnceLock<(f64, f64)>, + /// The ideographic character face box, read on first use. + icf: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, } @@ -265,6 +267,11 @@ impl FontFace { pub fn em_box(&self) -> (f64, f64) { *self.em.get_or_init(|| crate::vertical::em_box(self)) } + /// The ideographic character face (ICF) box (top, bottom) in font units, y up: `BASE` + /// `icft`/`icfb`, else the em box inset by 5% of the em at the top and bottom. + pub fn icf_box(&self) -> (f64, f64) { + *self.icf.get_or_init(|| crate::vertical::icf_box(self)) + } /// The font menu group the font is for and, for a CJK group, its family name in that language /// (see [`crate::FamilyInfo`]); read from the font once. pub fn menu_group(&self) -> (FontGroup, Option<&str>) { @@ -708,6 +715,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s index, bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), + icf: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), }) } diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 830f3d35..54b4bffd 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -162,6 +162,36 @@ pub fn with_vorg(font: &[u8], default: i16, overrides: &[(u16, i16)]) -> Option< with_tables(font, vec![(*b"VORG", vorg)]) } +/// `font` with a `BASE` table whose horizontal axis gives the default script the `baselines` +/// (tag, coordinate in font units). `None` if `font` can't be read. +pub fn with_base(font: &[u8], baselines: &[([u8; 4], i16)]) -> Option> { + let mut lines = baselines.to_vec(); + lines.sort_unstable_by_key(|l| l.0); + lines.dedup_by_key(|l| l.0); + let n = u16::try_from(lines.len()).ok()?; + let u16s = |out: &mut Vec, vs: &[u16]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_be_bytes())); + // Header (version 1.0, horizontal axis at 8), the axis (tag list at 4, script list after it), + // the tag list, one `DFLT` script record, its BaseScript (values at 6) and the BaseValues + // with one format-1 BaseCoord per baseline. + let mut base = Vec::new(); + u16s(&mut base, &[1, 0, 8, 0]); + u16s(&mut base, &[4, 4 + 2 + 4 * n]); + u16s(&mut base, &[n]); + lines.iter().for_each(|(tag, _)| base.extend_from_slice(tag)); + u16s(&mut base, &[1]); + base.extend_from_slice(b"DFLT"); + u16s(&mut base, &[8, 6, 0, 0]); + u16s(&mut base, &[0, n]); + for k in 0..n { + u16s(&mut base, &[4 + 2 * n + 4 * k]); + } + for (_, y) in &lines { + u16s(&mut base, &[1]); + base.extend_from_slice(&y.to_be_bytes()); + } + with_tables(font, vec![(*b"BASE", base)]) +} + /// `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> { diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs index 729ba3b7..ff87011f 100644 --- a/crates/fonts/src/tests_vertical.rs +++ b/crates/fonts/src/tests_vertical.rs @@ -6,7 +6,7 @@ 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::testing::{font_mapping, font_with, with_base, with_table_len, with_table_u16, with_vmtx, with_vorg}; use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; /// Text in Japanese. @@ -121,6 +121,20 @@ fn fonts_without_vmtx_use_the_em_box() { assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); } +#[test] +fn icf_box_comes_from_base_else_an_inset_em_box() { + let font = font_with("ICF", &['一']).unwrap(); + let face = face_of(with_base(&font, &[(*b"ideo", -120), (*b"idtp", 880), (*b"icfb", -80), (*b"icft", 840)]).unwrap()); + assert_eq!((face.em_box(), face.icf_box()), ((880.0, -120.0), (840.0, -80.0))); + // One of the two: mirrored inside the em box. + let face = face_of(with_base(&font, &[(*b"ideo", -120), (*b"icfb", -90)]).unwrap()); + assert_eq!(face.icf_box(), (850.0, -90.0)); + // No ICF baselines (Source Sans 3's BASE has only ideo and romn): the em box inset by 5% of the em. + let face = face_of(font); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.icf_box(), (780.0, -120.0)); +} + #[test] fn broken_vertical_tables_do_not_panic() { let mut fonts = vec![("synthetic", vertical_font())]; @@ -146,6 +160,8 @@ fn broken_vertical_tables_do_not_panic() { let face = face_of(bytes); let (top, bottom) = face.em_box(); assert!(top > bottom && (top - bottom - face.upem).abs() < 1e-9, "{what}: {top} {bottom}"); + let (icf_top, icf_bottom) = face.icf_box(); + assert!(icf_top > icf_bottom && icf_top.is_finite() && icf_bottom.is_finite(), "{what}: ICF {icf_top} {icf_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}"); diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs index d360b6f2..a90cfec5 100644 --- a/crates/fonts/src/vertical.rs +++ b/crates/fonts/src/vertical.rs @@ -74,13 +74,42 @@ pub(crate) fn em_box(face: &FontFace) -> (f64, f64) { .unwrap_or((upem * 0.88, upem * -0.12)) } +/// The ideographic character face (ICF) box (top, bottom) in font units, y up: `BASE` +/// `icft`/`icfb` (one of them alone is mirrored inside the em box), else the em box inset by 5% of +/// the em at the top and bottom. +pub(crate) fn icf_box(face: &FontFace) -> (f64, f64) { + let (top, bottom) = face.em_box(); + let font = face.skrifa(); + let base = match font.as_ref().and_then(|f| base_box(f, b"icfb", b"icft")) { + Some((Some(b), Some(t))) => Some((t, b)), + Some((Some(b), None)) => Some((top - (b - bottom), b)), + Some((None, Some(t))) => Some((t, bottom + (top - t))), + _ => None, + }; + base.filter(|(t, b)| t.is_finite() && b.is_finite() && t > b).unwrap_or_else(|| { + let inset = (top - bottom) * 0.05; + (top - inset, bottom + inset) + }) +} + /// 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)> { + match base_box(font, b"ideo", b"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 `BASE` horizontal axis's `bottom` and `top` baselines in font units from the Han script's +/// values, else the default script's, else the first script's that has either. +fn base_box(font: &skrifa::FontRef<'_>, bottom_tag: &[u8; 4], top_tag: &[u8; 4]) -> Option<(Option, Option)> { 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")); + let (ideo, idtp) = (index(bottom_tag), index(top_tag)); if ideo.is_none() && idtp.is_none() { return None; } @@ -92,12 +121,8 @@ fn base_em_box(font: &skrifa::FontRef<'_>, upem: f64) -> Option<(f64, f64)> { 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, - } + let pair = (coord(ideo), coord(idtp)); + (pair.0.is_some() || pair.1.is_some()).then_some(pair) }) }