mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 19:56:51 -06:00
Text: the Ascent first baseline uses the font's typographic ascender
This commit is contained in:
@@ -688,7 +688,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
_ => col,
|
||||
};
|
||||
// Estimate slots for the breaker with the paragraph's base leading.
|
||||
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
|
||||
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, base_chars.size * 0.75, &pp);
|
||||
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
|
||||
let width = |j: usize| -> f64 {
|
||||
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
|
||||
@@ -708,8 +708,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
for (k, b) in breaks.iter().enumerate() {
|
||||
let (s, e) = (g0 + b.start, g0 + b.end);
|
||||
let line_glyphs = &glyphs[s..e.max(s)];
|
||||
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
|
||||
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
|
||||
let (asc, typo_asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
|
||||
let mut baseline = cur.next_baseline(f, col, lead, asc, typo_asc, &pp);
|
||||
// Baseline grid.
|
||||
if let Some((g_start, inc)) = f.grid
|
||||
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
|
||||
@@ -1287,13 +1287,16 @@ impl Cursor {
|
||||
self.last_baseline = None;
|
||||
self.pending = 0.0;
|
||||
}
|
||||
/// Baseline for the next line with leading `lead` and ascent `asc`.
|
||||
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
|
||||
/// Baseline for the next line with leading `lead`, ascent `asc` and typographic ascender
|
||||
/// `typo_asc`.
|
||||
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, typo_asc: f64, _pp: &ParaProps) -> f64 {
|
||||
match self.last_baseline {
|
||||
Some(b) => b + lead + self.pending,
|
||||
None => {
|
||||
let off = match f.opts.first_baseline {
|
||||
FirstBaseline::Ascent => asc,
|
||||
// Vertical frames keep the ascent.
|
||||
FirstBaseline::Ascent if f.opts.vertical => asc,
|
||||
FirstBaseline::Ascent => typo_asc,
|
||||
FirstBaseline::CapHeight => asc * 0.72,
|
||||
FirstBaseline::XHeight => asc * 0.5,
|
||||
FirstBaseline::Leading => lead,
|
||||
@@ -1313,23 +1316,25 @@ fn line_metrics(
|
||||
base_size: f64,
|
||||
db: &FontDb,
|
||||
base: &designcraft_doc::CharProps,
|
||||
) -> (f64, f64, f64) {
|
||||
) -> (f64, f64, f64, f64) {
|
||||
let _ = db;
|
||||
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
|
||||
if src.is_empty() {
|
||||
let face = FontDb::global().face(&base.font_family, &base.font_style);
|
||||
let k = base_size / face.upem;
|
||||
return (face.ascent * k, face.descent * k, base_leading);
|
||||
return (face.ascent * k, face.typo_ascent * k, face.descent * k, base_leading);
|
||||
}
|
||||
let mut asc: f64 = 0.0;
|
||||
let mut typo_asc: f64 = 0.0;
|
||||
let mut desc: f64 = 0.0;
|
||||
let mut lead: f64 = 0.0;
|
||||
for g in src {
|
||||
asc = asc.max(g.ascent + g.shift.max(0.0));
|
||||
typo_asc = typo_asc.max(g.typo_ascent + g.shift.max(0.0));
|
||||
desc = desc.max(g.descent - g.shift.min(0.0));
|
||||
lead = lead.max(g.leading);
|
||||
}
|
||||
(asc, desc, lead)
|
||||
(asc, typo_asc, desc, lead)
|
||||
}
|
||||
|
||||
/// Widest free horizontal interval of `col` in the band, or None if blocked.
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct Glyph {
|
||||
/// Baseline shift (positive = up), including super/subscript.
|
||||
pub shift: f64,
|
||||
pub ascent: f64,
|
||||
/// The font's typographic ascender, scaled like `ascent`: the Ascent first baseline offset.
|
||||
pub typo_ascent: f64,
|
||||
pub descent: f64,
|
||||
/// The leading this character asks for (absolute, or auto = size × auto %).
|
||||
pub leading: f64,
|
||||
@@ -219,7 +221,7 @@ pub(crate) fn shape_para(
|
||||
}
|
||||
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
|
||||
g.ch = HIDDEN;
|
||||
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
glyphs.push(g);
|
||||
}
|
||||
continue;
|
||||
@@ -415,10 +417,14 @@ fn shape_run(
|
||||
Some(y) => {
|
||||
g.adv = o.w;
|
||||
g.ascent = g.ascent.max(o.h + y);
|
||||
g.typo_ascent = g.typo_ascent.max(o.h + y);
|
||||
g.descent = g.descent.max(-y);
|
||||
}
|
||||
// Pushes its line down by its height and spacing.
|
||||
None => g.ascent += o.h + o.space,
|
||||
None => {
|
||||
g.ascent += o.h + o.space;
|
||||
g.typo_ascent += o.h + o.space;
|
||||
}
|
||||
}
|
||||
if auto || o.y_offset.is_none() {
|
||||
g.leading = g.leading.max(g.ascent + g.descent);
|
||||
@@ -574,6 +580,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
|
||||
sy: k * p.v_scale,
|
||||
shift,
|
||||
ascent,
|
||||
typo_ascent: face.typo_ascent * k * p.v_scale,
|
||||
descent,
|
||||
leading,
|
||||
cap,
|
||||
@@ -644,6 +651,7 @@ fn shape_segment(
|
||||
sy: k * p.v_scale,
|
||||
shift,
|
||||
ascent,
|
||||
typo_ascent: face.typo_ascent * k * p.v_scale,
|
||||
descent,
|
||||
leading,
|
||||
cap,
|
||||
|
||||
@@ -1027,3 +1027,129 @@ fn only_english_text_gets_english_hyphenation() {
|
||||
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
|
||||
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
|
||||
}
|
||||
|
||||
/// Source Sans 3 Regular (OS/2 typo ascender = hhea ascender = 1000/1000 em) renamed to `family`
|
||||
/// (13 characters, the length of "Source Sans 3"), with OS/2 `sTypoAscender` set to
|
||||
/// `typo_ascender`, or without an OS/2 table for `None`.
|
||||
fn typo_test_font(family: &str, typo_ascender: Option<i16>) -> Vec<u8> {
|
||||
let mut b = designcraft_fonts::bundled()[0].to_vec();
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(p) = b[i..].windows(from.len()).position(|w| w == from.as_slice()) {
|
||||
b[i + p..i + p + to.len()].copy_from_slice(&to);
|
||||
i += p + to.len();
|
||||
}
|
||||
}
|
||||
let tables = u16::from_be_bytes([b[4], b[5]]) as usize;
|
||||
let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"OS/2").unwrap();
|
||||
match typo_ascender {
|
||||
Some(v) => {
|
||||
let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize;
|
||||
b[os2 + 68..os2 + 70].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
// Still sorted between its neighbours, so the table directory stays valid.
|
||||
None => b[rec..rec + 4].copy_from_slice(b"OS/1"),
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// First baseline (frame space) of a 20 pt line in `family` in a frame at y = 36 with a 4 pt top
|
||||
/// inset, set up by `opts`.
|
||||
fn first_baseline_in(family: &str, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 {
|
||||
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..7, |f| {
|
||||
f.over.font_family = Some(family.into());
|
||||
f.over.font_style = Some("Regular".into());
|
||||
f.over.size = Some(20.0);
|
||||
});
|
||||
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
|
||||
tf.options.inset = [4.0, 0.0, 0.0, 0.0];
|
||||
opts(&mut tf.options);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
cs.frames[0].lines[0].baseline
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascent_first_baseline_uses_the_typographic_ascender() {
|
||||
use designcraft_doc::FirstBaseline;
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
db.add_font(typo_test_font("TypoAscent 07", Some(700)));
|
||||
let face = db.face("TypoAscent 07", "Regular");
|
||||
assert_eq!(face.family, "TypoAscent 07");
|
||||
assert_eq!((face.ascent, face.typo_ascent), (1000.0, 700.0), "hhea 1 em, typo 0.7 em");
|
||||
let top = 36.0 + 4.0;
|
||||
let at = |kind: FirstBaseline, min: f64| {
|
||||
first_baseline_in("TypoAscent 07", |o| {
|
||||
o.first_baseline = kind;
|
||||
o.first_baseline_min = min;
|
||||
})
|
||||
};
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
|
||||
// Ascent: top + typo ascender (0.7 em of 20 pt), not the hhea ascender (1 em).
|
||||
assert!(close(at(FirstBaseline::Ascent, 0.0), top + 14.0), "{}", at(FirstBaseline::Ascent, 0.0));
|
||||
// The minimum offset still wins when it is larger.
|
||||
assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0));
|
||||
assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0));
|
||||
// The other kinds are unchanged.
|
||||
assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 20.0 * 0.72));
|
||||
assert!(close(at(FirstBaseline::XHeight, 0.0), top + 10.0));
|
||||
assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0));
|
||||
assert!(close(at(FirstBaseline::Fixed, 0.0), top));
|
||||
// Vertical scale scales it like the ascent.
|
||||
let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..3, |f| {
|
||||
f.over.font_family = Some("TypoAscent 07".into());
|
||||
f.over.size = Some(20.0);
|
||||
f.over.v_scale = Some(1.5);
|
||||
});
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
|
||||
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
|
||||
assert!(close(l.baseline, top + 21.0), "{}", l.baseline);
|
||||
// The line box keeps the font's ascent.
|
||||
assert!(close(l.ascent, 30.0), "{}", l.ascent);
|
||||
// A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt …
|
||||
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.format_chars(0..3, |f| {
|
||||
f.over.font_family = Some("TypoAscent 07".into());
|
||||
f.over.size = Some(20.0);
|
||||
});
|
||||
st.format_chars(3..7, |f| {
|
||||
f.over.font_family = Some("Source Sans 3".into());
|
||||
f.over.size = Some(10.0);
|
||||
});
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
|
||||
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline;
|
||||
assert!(close(first(&d), top + 14.0), "{}", first(&d));
|
||||
// … and 1 em × 16 pt beats it.
|
||||
d.story_mut(sid).unwrap().format_chars(3..7, |f| f.over.size = Some(16.0));
|
||||
assert!(close(first(&d), top + 16.0), "{}", first(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() {
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
// No OS/2 table, a negative and an absurd typo ascender: the (hhea) ascent, 1 em.
|
||||
for (family, typo) in [("TypoAscentNoO", None), ("TypoAscentNeg", Some(-300)), ("TypoAscentBig", Some(i16::MAX))] {
|
||||
db.add_font(typo_test_font(family, typo));
|
||||
let face = db.face(family, "Regular");
|
||||
assert_eq!(face.family, family);
|
||||
assert_eq!(face.typo_ascent, face.ascent, "{family}");
|
||||
let b = first_baseline_in(family, |_| {});
|
||||
assert!((b - (36.0 + 4.0 + 20.0)).abs() < 0.01, "{family}: {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_frames_keep_the_ascent_first_baseline() {
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
db.add_font(typo_test_font("TypoAscentVrt", Some(700)));
|
||||
let b = first_baseline_in("TypoAscentVrt", |o| {
|
||||
o.vertical = true;
|
||||
o.inset = [0.0; 4];
|
||||
});
|
||||
// Composed in the turned box (no inset): the hhea ascent, 1 em of 20 pt.
|
||||
assert!((b - 20.0).abs() < 0.01, "{b}");
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct FontFace {
|
||||
pub upem: f64,
|
||||
/// Ascender in font units (positive = up).
|
||||
pub ascent: f64,
|
||||
/// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the
|
||||
/// Ascent first baseline offset.
|
||||
pub typo_ascent: f64,
|
||||
/// Descender in font units (positive = down).
|
||||
pub descent: f64,
|
||||
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
|
||||
@@ -449,6 +452,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
ascent: m.ascent as f64,
|
||||
typo_ascent: typo_ascent(&f, &location, m.units_per_em.max(1) as f64, m.ascent as f64),
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
@@ -462,6 +466,20 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
})
|
||||
}
|
||||
|
||||
/// The OS/2 typographic ascender in font units at `location`, or `fallback` when the font has no
|
||||
/// OS/2 table or the value isn't a plausible ascender (not above 0, or over 4 em).
|
||||
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64, fallback: f64) -> f64 {
|
||||
use skrifa::raw::TableProvider;
|
||||
let Ok(os2) = f.os2() else { return fallback };
|
||||
let mut v = os2.s_typo_ascender() as f64;
|
||||
if !location.coords().is_empty()
|
||||
&& let Ok(mvar) = f.mvar()
|
||||
{
|
||||
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
|
||||
}
|
||||
if v > 0.0 && v <= 4.0 * upem { v } else { fallback }
|
||||
}
|
||||
|
||||
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
|
||||
pub fn base_style(style: &str) -> &str {
|
||||
style.split('{').next().unwrap_or(style).trim()
|
||||
|
||||
Reference in New Issue
Block a user