CJK: smaller runs align to the line's em box centre

Character alignment (em box top, centre, bottom) measured each glyph's em
box from its font's ascender/descender proportions, which puts a Latin
font's em box centre too low, so a smaller CJK run in a Latin line sat
below the line's em box centre. Alignment now uses the font's BASE em box,
else 0.88 em above and 0.12 em below the baseline.
This commit is contained in:
aiongg
2026-10-10 23:17:09 +00:00
parent a127c6d5c3
commit 351ccebe86
4 changed files with 59 additions and 19 deletions
+12 -2
View File
@@ -2018,10 +2018,20 @@ fn cjk_em_box(g: &Glyph) -> (f64, f64) {
let top = -height * g.ascent / (g.ascent + g.descent).max(1e-9);
(top, top + height)
}
/// Em box that character alignment lines up, relative to the baseline (y down) at the glyph's
/// size: the font's `BASE` em box, else 0.88 em above and 0.12 em below the baseline. A font's
/// ascender/descender proportions put a Latin em box's centre too low for this.
fn alignment_em_box(g: &Glyph) -> (f64, f64) {
let upem = g.face.units_per_em();
let (top, bottom) = g.face.base_em_box().unwrap_or((upem * 0.88, upem * -0.12));
let k = g.sy.abs();
(-top * k, -bottom * k)
}
/// Upward shift that puts `g`'s em box (or ICF) edge or centre where the reference glyph's is.
fn cjk_alignment_shift(g: &Glyph, reference: &Glyph) -> f64 {
use designcraft_doc::cjk::CharacterAlignment as A;
let (t, b) = cjk_em_box(g);
let (rt, rb) = cjk_em_box(reference);
let (t, b) = alignment_em_box(g);
let (rt, rb) = alignment_em_box(reference);
match g.character_alignment {
A::Baseline => 0.0,
A::EmTop => t - rt,
+32 -10
View File
@@ -1490,17 +1490,39 @@ fn cjk_center_leading_measures_em_centers_across_different_sizes() {
}
#[test]
fn cjk_em_center_alignment_moves_small_characters() {
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0));
d.story_mut(sid).unwrap().format_chars(1..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = &cs.frames[0].lines[0];
let center = |g: &PlacedGlyph| {
let (a, b) = g.face.vertical_metrics();
g.y + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy
fn smaller_cjk_run_aligns_to_the_line_em_box_centre() {
use designcraft_doc::cjk::CharacterAlignment;
let text = "Ab 甲乙 cd";
let run = "Ab ".len().."Ab 甲乙".len();
// Em box centre (y down) of a placed glyph: its font's BASE em box, else 0.88/-0.12 em.
let centre = |l: &Line, g: &PlacedGlyph| {
let upem = g.face.units_per_em();
let (top, bottom) = g.face.base_em_box().unwrap_or((upem * 0.88, upem * -0.12));
l.baseline + g.y - (top + bottom) / 2.0 * g.sy
};
assert!((center(&line.glyphs[0]) - center(&line.glyphs[1])).abs() < 1e-6);
for alignment in [CharacterAlignment::EmCenter, CharacterAlignment::Baseline] {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
f.over.size = Some(10.0);
f.over.character_alignment = Some(alignment);
});
d.story_mut(sid).unwrap().format_chars(run.clone(), |f| f.over.size = Some(6.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = &cs.frames[0].lines[0];
let latin = &line.glyphs[0];
let cjk: Vec<&PlacedGlyph> = line.glyphs.iter().filter(|g| run.contains(&g.byte)).collect();
assert_eq!(cjk.len(), 2);
assert!(latin.y.abs() < 1e-9);
for g in cjk {
match alignment {
CharacterAlignment::EmCenter => {
assert!((centre(line, g) - centre(line, latin)).abs() < 0.01, "{} vs {}", centre(line, g), centre(line, latin));
assert!(g.y < -1.0, "a 6 pt run rises in a 10 pt line: {}", g.y);
}
_ => assert!(g.y.abs() < 1e-9, "Roman baseline keeps the run on the baseline: {}", g.y),
}
}
}
}
#[test]
+8 -3
View File
@@ -117,8 +117,8 @@ pub struct FontFace {
pub instance: Option<harfrust::ShaperInstance>,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// The ideographic em box, read on first use.
em: std::sync::OnceLock<(f64, f64)>,
/// The ideographic em box and whether the `BASE` table gave it, read on first use.
em: std::sync::OnceLock<((f64, f64), bool)>,
/// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
}
@@ -263,7 +263,12 @@ impl FontFace {
/// -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))
self.em.get_or_init(|| crate::vertical::em_box(self)).0
}
/// The em box (top, bottom) the font's `BASE` table gives, in font units, y up; None without one.
pub fn base_em_box(&self) -> Option<(f64, f64)> {
let (em, from_base) = *self.em.get_or_init(|| crate::vertical::em_box(self));
from_base.then_some(em)
}
/// 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.
+7 -4
View File
@@ -55,11 +55,12 @@ impl<'a> VMetrics<'a> {
/// 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) {
/// The flag says whether `BASE` gave it.
pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) {
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;
return (b, true);
}
let centred = |asc: f64, desc: f64| {
let span = asc - desc;
@@ -68,10 +69,12 @@ pub(crate) fn em_box(face: &FontFace) -> (f64, f64) {
(top, top - upem)
})
};
font.and_then(|f| f.os2().ok())
let em = 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))
.unwrap_or((upem * 0.88, upem * -0.12));
(em, false)
}
/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default