Layout: Em Box Height first baseline offset

The first baseline sits the em box top below the frame's top inset: the
font's BASE em box (idtp, else ideo + 1 em), else 0.88 em, for the
largest size on the first line. IDML FirstBaselineOffset="EmboxHeight"
imports and exports as itself; the footnote options list it and the
frame and footnote option commands take emboxHeight.
This commit is contained in:
aiongg
2026-10-10 21:04:33 +00:00
parent a2ff7d03f8
commit f386a98d7c
16 changed files with 99 additions and 14 deletions
+16 -5
View File
@@ -939,7 +939,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let col = cur.split_rect(col);
// Estimate slots for the breaker with the paragraph's base leading.
let est_asc = base_chars.size * 0.75;
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5 };
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 };
let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp);
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
@@ -2093,25 +2093,34 @@ impl Cursor {
fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 {
let off = match f.opts.first_baseline {
// Vertical frames measure from the ascent.
FirstBaseline::Ascent if f.vertical => asc,
FirstBaseline::Ascent | FirstBaseline::EmboxHeight if f.vertical => asc,
FirstBaseline::CapHeight if f.vertical => asc * 0.72,
FirstBaseline::XHeight if f.vertical => asc * 0.5,
FirstBaseline::Ascent => tops.typo_ascent,
FirstBaseline::CapHeight => tops.cap,
FirstBaseline::XHeight => tops.xh,
FirstBaseline::EmboxHeight => tops.em,
FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0,
};
off.max(f.opts.first_baseline_min)
}
/// A line's tallest typographic ascender, cap height and x height above its baseline: the
/// first baseline offsets.
/// A line's tallest typographic ascender, cap height, x height and em box top above its baseline:
/// the first baseline offsets.
#[derive(Clone, Copy, Default)]
struct Tops {
typo_ascent: f64,
cap: f64,
xh: f64,
em: f64,
}
/// The em box top above the baseline at `size`: the font's `BASE` em box, else the default
/// ideographic em box, 0.88 em above the baseline.
fn em_box_top(face: &designcraft_fonts::FontFace, size: f64) -> f64 {
let em = face.declared_em_box().filter(|_| face.upem > 0.0).map_or(0.88, |(top, _)| top / face.upem);
em * size
}
/// Em box relative to the baseline. Font ascender/descender proportions locate it;
@@ -2160,7 +2169,7 @@ fn line_metrics(
if src.is_empty() {
let face = face();
let k = base_size / face.upem;
let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k };
let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) };
return (face.ascent * k, tops, face.descent * k, base_leading);
}
let mut asc: f64 = 0.0;
@@ -2177,6 +2186,7 @@ fn line_metrics(
if !g.list_label {
own_text = true;
tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up);
tops.em = tops.em.max(em_box_top(&g.face, g.size) + up);
}
tops.cap = tops.cap.max(g.cap + up);
tops.xh = tops.xh.max(g.xh + up);
@@ -2187,6 +2197,7 @@ fn line_metrics(
// Only a label (an empty list paragraph): as an empty paragraph.
let face = face();
tops.typo_ascent = face.typo_ascent * base_size / face.upem;
tops.em = em_box_top(&face, base_size);
}
(asc, tops, desc, lead)
}
+41
View File
@@ -1695,6 +1695,47 @@ fn ascent_first_baseline_uses_the_typographic_ascender() {
assert!(close(first(&d), top + 16.0), "{}", first(&d));
}
#[test]
fn em_box_first_baseline_uses_the_base_em_box_else_0_88_em() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
// The test font's `BASE` table puts the em box bottom (`ideo`) at -170 units: its top is 0.83 em.
let mut no_base = typo_test_font("EmBoxTop 0700", Some(700));
let tables = u16::from_be_bytes([no_base[4], no_base[5]]) as usize;
let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &no_base[r..r + 4] == b"BASE").unwrap();
// Still sorted between its neighbours, so the table directory stays valid.
no_base[rec..rec + 4].copy_from_slice(b"BASX");
db.add_font(no_base);
db.add_font(typo_test_font("EmBoxBase 700", Some(700)));
assert_eq!(db.face("EmBoxTop 0700", "Regular").declared_em_box(), None);
assert_eq!(db.face("EmBoxBase 700", "Regular").declared_em_box(), Some((830.0, -170.0)));
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
let mut first = |family: &str, sizes: [f64; 2], kind: FirstBaseline| {
let st = d.story_mut(sid).unwrap();
for (range, size) in [(0..4, sizes[0]), (4..7, sizes[1])] {
st.format_chars(range, |f| {
f.over.font_family = Some(family.into());
f.over.font_style = Some("Regular".into());
f.over.size = Some(size);
});
}
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = kind;
compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0
};
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
// Ascent: the 0.7 em typographic ascender; Em Box Height: the 0.88 em default em box top.
let asc = first("EmBoxTop 0700", [10.0; 2], FirstBaseline::Ascent);
assert!(close(asc, 7.0), "{asc}");
let emb = first("EmBoxTop 0700", [10.0; 2], FirstBaseline::EmboxHeight);
assert!(close(emb, 8.8), "{emb}");
// A mixed first line takes its largest size.
let emb = first("EmBoxTop 0700", [10.0, 20.0], FirstBaseline::EmboxHeight);
assert!(close(emb, 17.6), "{emb}");
// A font's `BASE` em box wins over the default.
let emb = first("EmBoxBase 700", [10.0; 2], FirstBaseline::EmboxHeight);
assert!(close(emb, 8.3), "{emb}");
}
#[test]
fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() {
let db = designcraft_fonts::FontDb::global();
+2
View File
@@ -281,6 +281,8 @@ pub enum FirstBaseline {
Leading,
XHeight,
Fixed,
/// The top of the em box: the font's `BASE` em box, else 0.88 em above the baseline.
EmboxHeight,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
+1 -1
View File
@@ -489,7 +489,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…",
["Object"],
Some("Cmd+B"),
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
has_selection,
text_frame_options
),
+7 -2
View File
@@ -122,7 +122,7 @@ pub struct FontFace {
/// 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)>,
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>)>,
}
@@ -267,7 +267,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) in font units, y up, when the font declares one in `BASE`.
pub fn declared_em_box(&self) -> Option<(f64, f64)> {
let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self));
declared.then_some(b)
}
/// 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.
+4 -3
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 is true for an em box from `BASE`.
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;
@@ -71,7 +72,7 @@ pub(crate) fn em_box(face: &FontFace) -> (f64, f64) {
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))
.map_or(((upem * 0.88, upem * -0.12), false), |b| (b, false))
}
/// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default
+2 -1
View File
@@ -424,10 +424,11 @@ pub fn first_baseline_out(f: FirstBaseline) -> &'static str {
FirstBaseline::Leading => "LeadingOffset",
FirstBaseline::XHeight => "XHeight",
FirstBaseline::Fixed => "FixedHeight",
FirstBaseline::EmboxHeight => "EmboxHeight",
}
}
pub fn first_baseline_in(s: &str) -> FirstBaseline {
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed]
[FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed, FirstBaseline::EmboxHeight]
.into_iter()
.find(|m| first_baseline_out(*m) == s)
.unwrap_or(FirstBaseline::Ascent)
+11
View File
@@ -378,6 +378,17 @@ fn imports_scalar_inset_properties_like_lists_and_attributes() {
assert_eq!(frame_inset(&round_trip), [9.0; 4]);
}
#[test]
fn em_box_first_baseline_offset_round_trips() {
use designcraft_doc::FirstBaseline;
let first = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.first_baseline;
let d = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="EmboxHeight"/>"#, "");
assert_eq!(first(&d), FirstBaseline::EmboxHeight);
assert_eq!(first(&import_idml(&export_idml(&d)).unwrap()), FirstBaseline::EmboxHeight);
let unknown = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="SomethingElse"/>"#, "");
assert_eq!(first(&unknown), FirstBaseline::Ascent);
}
#[test]
fn inset_property_keeps_precedence_over_attribute() {
for (property, expected) in [
+8 -1
View File
@@ -3373,7 +3373,14 @@ fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui,
d,
"firstBaseline",
&[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")],
&[
("ascent", "Ascent"),
("capHeight", "Cap Height"),
("leading", "Leading"),
("xHeight", "x Height"),
("emboxHeight", "Em Box Height"),
("fixed", "Fixed"),
],
);
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Min:"));
+1
View File
@@ -1941,6 +1941,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Cap Height", ["Cap Height", "Cap Height", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "x Height", "x Height", "x Height", "x 字高"]),
("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]),
("Fixed", ["Fixed", "Fixed", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Never (continuous)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]),
+1
View File
@@ -296,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ascent", "الصعود"),
("Cap Height", "ارتفاع الأحرف الكبيرة"),
("x Height", "ارتفاع x"),
("Em Box Height", "ارتفاع مربع em"),
("Fixed", "ثابت"),
("Never (continuous)", "أبدًا (ترقيم متصل)"),
("Spread", "صفحتان متقابلتان"),
+1
View File
@@ -961,6 +961,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "Altezza maiuscole"),
("Leading", "Interlinea"),
("x Height", "Altezza x"),
("Em Box Height", "Altezza riquadro em"),
("Fixed", "Fisso"),
("Never (continuous)", "Mai (continua)"),
("Page", "Pagina"),
+1
View File
@@ -960,6 +960,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "大文字の高さ"),
("Leading", "行送り"),
("x Height", "x の高さ"),
("Em Box Height", "仮想ボディの高さ"),
("Fixed", "固定"),
("Never (continuous)", "振り直しなし (通し番号)"),
("Page", "ページ"),
+1
View File
@@ -297,6 +297,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ascent", "Ascendente"),
("Cap Height", "Altura da caixa alta"),
("x Height", "Altura do x"),
("Em Box Height", "Altura da caixa eme"),
("Fixed", "Fixo"),
("Never (continuous)", "Nunca (contínuo)"),
("Spread", "Página dupla"),
+1
View File
@@ -958,6 +958,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "Висота великих літер"),
("Leading", "Міжрядковий інтервал"),
("x Height", "Висота малих літер"),
("Em Box Height", "Висота em-квадрата"),
("Fixed", "Фіксований"),
("Never (continuous)", "Ніколи (наскрізна нумерація)"),
("Page", "Сторінка"),