Merge #631: Type: Character Alignment on the ICF top and bottom (ideographic character face)

# Conflicts:
#	ROADMAP.md
#	crates/text/src/layout.rs
This commit is contained in:
bflatastic
2026-10-09 06:24:04 +07:00
19 changed files with 195 additions and 14 deletions
+95
View File
@@ -79,6 +79,19 @@ pub struct FontFace {
pub(crate) instance: Option<harfrust::ShaperInstance>,
/// [`Self::ideographic_centre`], read once: layout asks for it per glyph.
ideographic_centre: std::sync::OnceLock<f64>,
/// [`Self::icf_margins`], read once.
icf_margins: std::sync::OnceLock<IcfMargins>,
}
/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance
/// from each edge of the em box, in ems. `top` and `bottom` along a horizontal line, `right` and
/// `left` across a vertical one.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct IcfMargins {
pub top: f64,
pub bottom: f64,
pub right: f64,
pub left: f64,
}
impl std::fmt::Debug for FontFace {
@@ -177,6 +190,69 @@ impl FontFace {
gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem)
})
}
/// The ideographic character face as margins inside the em box ([`IcfMargins`]): from the
/// font's BASE table as the OpenType baseline tags define it (`icfb`, `icft` against `ideo`,
/// `idtp`), else, as that definition allows, from the bounds of some ideographs and kana
/// averaged, else none (the ICF is the em box).
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/baselinetags>
pub fn icf_margins(&self) -> IcfMargins {
*self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default())
}
/// [`Self::icf_margins`] from the BASE table: the ICF's bottom edge `icfb` (top edge `icft`, else
/// as far below the em box top) against the em box `ideo` .. `idtp` (else the OS/2 typographic
/// descender, and one em above it); across vertical lines, `icfb` and `icft` of the vertical
/// axis against 0 .. `idtp` (else the horizontal margin, and one em).
pub(crate) fn icf_from_base(&self) -> Option<IcfMargins> {
use skrifa::raw::TableProvider;
let f = self.skrifa()?;
let base = f.base().ok()?;
let em = self.upem;
let h = base_values(base.horiz_axis()?.ok()?)?;
let icfb = *h.get(b"icfb")?;
let em_bottom = match h.get(b"ideo") {
Some(v) => *v,
None => f64::from(f.os2().ok()?.s_typo_descender()),
};
let em_top = h.get(b"idtp").copied().unwrap_or(em_bottom + em);
let margin = icfb - em_bottom;
let icf_top = h.get(b"icft").copied().unwrap_or(em_top - margin);
let v = base.vert_axis().and_then(Result::ok).and_then(base_values).unwrap_or_default();
let icf_left = v.get(b"icfb").copied().unwrap_or(margin);
let em_right = v.get(b"idtp").copied().unwrap_or(em);
let icf_right = v.get(b"icft").copied().unwrap_or(em_right - icf_left);
let m = IcfMargins { top: (em_top - icf_top) / em, bottom: margin / em, right: (em_right - icf_right) / em, left: icf_left / em };
// Inside the em box, short of its middle: anything else is a damaged table.
[m.top, m.bottom, m.right, m.left].iter().all(|x| (0.0..0.5).contains(x)).then_some(m)
}
/// [`Self::icf_margins`] from the ink of some ideographs and kana: each edge's margin to the em
/// box (its centre [`Self::ideographic_centre`], one em high and wide), averaged.
pub(crate) fn icf_from_glyphs(&self) -> Option<IcfMargins> {
let f = self.skrifa()?;
let metrics = f.glyph_metrics(Size::unscaled(), self.location());
let em = self.upem;
let (em_bottom, em_top) = ((self.ideographic_centre() - 0.5) * em, (self.ideographic_centre() + 0.5) * em);
let boxes: Vec<_> = ['国', '東', '永', '書', 'あ', 'ア']
.into_iter()
.map(|c| self.glyph_for(c))
.filter(|g| *g != 0)
.filter_map(|g| metrics.bounds(GlyphId::new(g)))
.collect();
if boxes.is_empty() {
return None;
}
let n = boxes.len() as f64;
let mean = |v: &dyn Fn(&skrifa::metrics::BoundingBox) -> f32| boxes.iter().map(|b| f64::from(v(b))).sum::<f64>() / n;
let m = IcfMargins {
top: (em_top - mean(&|b| b.y_max)) / em,
bottom: (mean(&|b| b.y_min) - em_bottom) / em,
right: (em - mean(&|b| b.x_max)) / em,
left: mean(&|b| b.x_min) / em,
};
[m.top, m.bottom, m.right, m.left].iter().all(|x| (0.0..0.5).contains(x)).then_some(m)
}
/// 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)
@@ -926,9 +1002,28 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
index,
path,
ideographic_centre: std::sync::OnceLock::new(),
icf_margins: std::sync::OnceLock::new(),
})
}
/// An axis of a BASE table: its default baseline values for Han ideographs (`hani`), else kana
/// (`kana`), else its first script, by tag, in font units.
fn base_values(axis: skrifa::raw::tables::base::Axis<'_>) -> Option<HashMap<[u8; 4], f64>> {
let tags = axis.base_tag_list()?.ok()?;
let list = axis.base_script_list().ok()?;
let records = list.base_script_records();
let record =
[b"hani", b"kana"].into_iter().find_map(|t| records.iter().find(|r| r.base_script_tag() == Tag::new(t))).or_else(|| records.first())?;
let values = record.base_script(list.offset_data()).ok()?.base_values()?.ok()?;
let mut out = HashMap::new();
for (tag, coord) in tags.baseline_tags().iter().zip(values.base_coords().iter()) {
if let Ok(c) = coord {
out.insert(tag.get().to_be_bytes(), f64::from(c.coordinate()));
}
}
Some(out)
}
/// `s` lowercased, without anything but letters and digits.
fn norm_chars(s: &str) -> impl Iterator<Item = char> + '_ {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase)
+8 -4
View File
@@ -430,19 +430,23 @@ fn glyph_em(g: &SGlyph) -> f64 {
/// How far up (line space) Character Alignment `a` moves glyph `g` on a line whose largest em is
/// `line_em`: the glyph's em box top, centre or bottom onto the line's (the em box running from
/// its centre less half an em to its centre plus half an em above the baseline). Nothing on the
/// Roman baseline, or for the line's largest characters.
/// its centre less half an em to its centre plus half an em above the baseline), or its ICF's top
/// or bottom (the em box's less the face's [`crate::IcfMargins`]; across a vertical line, its
/// right and left). Nothing on the Roman baseline, or for the line's largest characters.
///
/// An inline graphic has its run's em (whatever its scale): it counts as one of the line's
/// largest characters only when its run's size is, and moves with the text of its run (its art
/// stays centred on that text's cap height).
fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64) -> f64 {
fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64, vertical: bool) -> f64 {
use vectorcraft_doc::CharAlign;
let icf = || g.face.icf_margins();
let k = match a {
CharAlign::RomanBaseline => return 0.0,
CharAlign::EmBoxTop => 0.5,
CharAlign::EmBoxCenter => 0.0,
CharAlign::EmBoxBottom => -0.5,
CharAlign::IcfTop => 0.5 - if vertical { icf().right } else { icf().top },
CharAlign::IcfBottom => -0.5 + if vertical { icf().left } else { icf().bottom },
};
(g.face.ideographic_centre() + k) * (line_em - glyph_em(g)).max(0.0)
}
@@ -1534,7 +1538,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
adv += per_gap;
}
}
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em);
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical);
cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li);
x += adv;
if j < trimmed {
+2 -1
View File
@@ -25,7 +25,8 @@ pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, PlatformFontFiles, set_platform_font_files, style_weight, system_font_dirs,
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, style_weight,
system_font_dirs,
};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
+36
View File
@@ -647,6 +647,42 @@ fn character_alignment_lines_small_characters_up_with_the_largest_em_box() {
}
}
/// Character Alignment on the ICF: a 20 pt character next to a 40 pt one lines its ideographic
/// character face's top (right, vertical) or bottom (left) up with the big one's, which lies inside
/// the em box by the face's ICF margins. A face without ideographs has none (the ICF is the em
/// box). The Japanese faces need craft-fonts (skipped without them).
#[test]
fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_face() {
use vectorcraft_doc::{CharAlign, TextRun};
let place = |family: &str, a: CharAlign, vertical_type: bool| {
let st = |size: f64| CharStyle { font_family: family.into(), ..style(size) };
let mut t = point("", st(40.0));
t.vertical = vertical_type;
t.runs = vec![TextRun { text: "大".into(), style: st(40.0) }, TextRun { text: "小".into(), style: CharStyle { char_align: a, ..st(20.0) } }];
let l = layout(db(), &t);
let (big, small) = (l.glyphs[0].origin, l.glyphs[1].origin);
if vertical_type { small.x - big.x } else { big.y - small.y }
};
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
// No ideographs: the ICF is the em box.
assert_eq!(db().face("Source Sans 3", "Regular").unwrap().icf_margins(), crate::IcfMargins::default());
let Some(face) = db().face("Shippori Mincho", "Regular").filter(|f| f.family == "Shippori Mincho") else {
return; // no Japanese font here
};
let m = face.icf_margins();
assert!([m.top, m.bottom, m.right, m.left].iter().all(|x| *x > 0.01 && *x < 0.2), "{m:?}");
for vertical_type in [false, true] {
let (top, bottom) = if vertical_type { (m.right, m.left) } else { (m.top, m.bottom) };
// The big character's ICF top is `top` ems below its em box top: 20 pt of difference.
let em_top = place("Shippori Mincho", CharAlign::EmBoxTop, vertical_type);
let icf_top = place("Shippori Mincho", CharAlign::IcfTop, vertical_type);
assert!(near(em_top - icf_top, top * 20.0), "vertical {vertical_type}: {em_top} {icf_top} {m:?}");
let em_bottom = place("Shippori Mincho", CharAlign::EmBoxBottom, vertical_type);
let icf_bottom = place("Shippori Mincho", CharAlign::IcfBottom, vertical_type);
assert!(near(icf_bottom - em_bottom, bottom * 20.0), "vertical {vertical_type}: {em_bottom} {icf_bottom} {m:?}");
}
}
/// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of
/// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a
/// Latin line ending in a full stop isn't shortened by Forced.