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 committed 2026-10-10 21:04:33 +00:00
1 parent a2ff7d03f8
commit f386a98d7c
16 files changed
+99 -14

No files matched your search

+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); let col = cur.split_rect(col);
// Estimate slots for the breaker with the paragraph's base leading. // Estimate slots for the breaker with the paragraph's base leading.
let est_asc = base_chars.size * 0.75; 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_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 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); 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 { fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 {
let off = match f.opts.first_baseline { let off = match f.opts.first_baseline {
// Vertical frames measure from the ascent. // 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::CapHeight if f.vertical => asc * 0.72,
FirstBaseline::XHeight if f.vertical => asc * 0.5, FirstBaseline::XHeight if f.vertical => asc * 0.5,
FirstBaseline::Ascent => tops.typo_ascent, FirstBaseline::Ascent => tops.typo_ascent,
FirstBaseline::CapHeight => tops.cap, FirstBaseline::CapHeight => tops.cap,
FirstBaseline::XHeight => tops.xh, FirstBaseline::XHeight => tops.xh,
FirstBaseline::EmboxHeight => tops.em,
FirstBaseline::Leading => lead, FirstBaseline::Leading => lead,
FirstBaseline::Fixed => 0.0, FirstBaseline::Fixed => 0.0,
}; };
off.max(f.opts.first_baseline_min) off.max(f.opts.first_baseline_min)
} }
/// A line's tallest typographic ascender, cap height and x height above its baseline: the /// A line's tallest typographic ascender, cap height, x height and em box top above its baseline:
/// first baseline offsets. /// the first baseline offsets.
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
struct Tops { struct Tops {
typo_ascent: f64, typo_ascent: f64,
cap: f64, cap: f64,
xh: 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; /// Em box relative to the baseline. Font ascender/descender proportions locate it;
@@ -2160,7 +2169,7 @@ fn line_metrics(
if src.is_empty() { if src.is_empty() {
let face = face(); let face = face();
let k = base_size / face.upem; 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); return (face.ascent * k, tops, face.descent * k, base_leading);
} }
let mut asc: f64 = 0.0; let mut asc: f64 = 0.0;
@@ -2177,6 +2186,7 @@ fn line_metrics(
if !g.list_label { if !g.list_label {
own_text = true; own_text = true;
tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); 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.cap = tops.cap.max(g.cap + up);
tops.xh = tops.xh.max(g.xh + 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. // Only a label (an empty list paragraph): as an empty paragraph.
let face = face(); let face = face();
tops.typo_ascent = face.typo_ascent * base_size / face.upem; tops.typo_ascent = face.typo_ascent * base_size / face.upem;
tops.em = em_box_top(&face, base_size);
} }
(asc, tops, desc, lead) (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)); 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] #[test]
fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() { fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() {
let db = designcraft_fonts::FontDb::global(); let db = designcraft_fonts::FontDb::global();
+2
View File
@@ -281,6 +281,8 @@ pub enum FirstBaseline {
Leading, Leading,
XHeight, XHeight,
Fixed, 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)] #[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…", "Document Footnote Options…",
["Type"], ["Type"],
None, 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, has_doc,
options options
), ),
+1 -1
View File
@@ -489,7 +489,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Frame Options…", "Text Frame Options…",
["Object"], ["Object"],
Some("Cmd+B"), 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, has_selection,
text_frame_options text_frame_options
), ),
+7 -2
View File
@@ -122,7 +122,7 @@ pub struct FontFace {
/// Basic Multilingual Plane coverage bitset, built on first use. /// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>, bmp: std::sync::OnceLock<Box<[u64]>>,
/// The ideographic em box, read on first use. /// 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. /// The font menu group and native family name, read on first use.
group: std::sync::OnceLock<(FontGroup, Option<String>)>, 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 /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and
/// descender, the last two centred on one em. /// descender, the last two centred on one em.
pub fn em_box(&self) -> (f64, f64) { 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 /// 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. /// (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 /// 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. /// 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 upem = face.upem;
let font = face.skrifa(); let font = face.skrifa();
if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { 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 centred = |asc: f64, desc: f64| {
let span = asc - desc; let span = asc - desc;
@@ -71,7 +72,7 @@ pub(crate) fn em_box(face: &FontFace) -> (f64, f64) {
font.and_then(|f| f.os2().ok()) font.and_then(|f| f.os2().ok())
.and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())))
.or_else(|| centred(face.ascent, -face.descent)) .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 /// 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::Leading => "LeadingOffset",
FirstBaseline::XHeight => "XHeight", FirstBaseline::XHeight => "XHeight",
FirstBaseline::Fixed => "FixedHeight", FirstBaseline::Fixed => "FixedHeight",
FirstBaseline::EmboxHeight => "EmboxHeight",
} }
} }
pub fn first_baseline_in(s: &str) -> FirstBaseline { 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() .into_iter()
.find(|m| first_baseline_out(*m) == s) .find(|m| first_baseline_out(*m) == s)
.unwrap_or(FirstBaseline::Ascent) .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]); 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] #[test]
fn inset_property_keeps_precedence_over_attribute() { fn inset_property_keeps_precedence_over_attribute() {
for (property, expected) in [ 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, ui,
d, d,
"firstBaseline", "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(); ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Min:")); 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", "大写字母高度"]), ("Cap Height", ["Cap Height", "Cap Height", "Cap Height", "Cap Height", "大写字母高度"]),
("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]), ("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]),
("x Height", ["x Height", "x Height", "x Height", "x Height", "x 字高"]), ("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", "固定"]), ("Fixed", ["Fixed", "Fixed", "Fixed", "Fixed", "固定"]),
("Never (continuous)", ["Never (continuous)", "Never (continuous)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]), ("Never (continuous)", ["Never (continuous)", "Never (continuous)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]),
("Page", ["Page", "Page", "Page", "Page", "页面"]), ("Page", ["Page", "Page", "Page", "Page", "页面"]),
+1
View File
@@ -296,6 +296,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ascent", "الصعود"), ("Ascent", "الصعود"),
("Cap Height", "ارتفاع الأحرف الكبيرة"), ("Cap Height", "ارتفاع الأحرف الكبيرة"),
("x Height", "ارتفاع x"), ("x Height", "ارتفاع x"),
("Em Box Height", "ارتفاع مربع em"),
("Fixed", "ثابت"), ("Fixed", "ثابت"),
("Never (continuous)", "أبدًا (ترقيم متصل)"), ("Never (continuous)", "أبدًا (ترقيم متصل)"),
("Spread", "صفحتان متقابلتان"), ("Spread", "صفحتان متقابلتان"),
+1
View File
@@ -961,6 +961,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "Altezza maiuscole"), ("Cap Height", "Altezza maiuscole"),
("Leading", "Interlinea"), ("Leading", "Interlinea"),
("x Height", "Altezza x"), ("x Height", "Altezza x"),
("Em Box Height", "Altezza riquadro em"),
("Fixed", "Fisso"), ("Fixed", "Fisso"),
("Never (continuous)", "Mai (continua)"), ("Never (continuous)", "Mai (continua)"),
("Page", "Pagina"), ("Page", "Pagina"),
+1
View File
@@ -960,6 +960,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "大文字の高さ"), ("Cap Height", "大文字の高さ"),
("Leading", "行送り"), ("Leading", "行送り"),
("x Height", "x の高さ"), ("x Height", "x の高さ"),
("Em Box Height", "仮想ボディの高さ"),
("Fixed", "固定"), ("Fixed", "固定"),
("Never (continuous)", "振り直しなし (通し番号)"), ("Never (continuous)", "振り直しなし (通し番号)"),
("Page", "ページ"), ("Page", "ページ"),
+1
View File
@@ -297,6 +297,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Ascent", "Ascendente"), ("Ascent", "Ascendente"),
("Cap Height", "Altura da caixa alta"), ("Cap Height", "Altura da caixa alta"),
("x Height", "Altura do x"), ("x Height", "Altura do x"),
("Em Box Height", "Altura da caixa eme"),
("Fixed", "Fixo"), ("Fixed", "Fixo"),
("Never (continuous)", "Nunca (contínuo)"), ("Never (continuous)", "Nunca (contínuo)"),
("Spread", "Página dupla"), ("Spread", "Página dupla"),
+1
View File
@@ -958,6 +958,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cap Height", "Висота великих літер"), ("Cap Height", "Висота великих літер"),
("Leading", "Міжрядковий інтервал"), ("Leading", "Міжрядковий інтервал"),
("x Height", "Висота малих літер"), ("x Height", "Висота малих літер"),
("Em Box Height", "Висота em-квадрата"),
("Fixed", "Фіксований"), ("Fixed", "Фіксований"),
("Never (continuous)", "Ніколи (наскрізна нумерація)"), ("Never (continuous)", "Ніколи (наскрізна нумерація)"),
("Page", "Сторінка"), ("Page", "Сторінка"),