Text measures by the reference kernel: kerned steps, ink tail, no pad (#956)

Text measured `ceil(sum of steps) + 1`: no kerning, float advances, the last glyph priced by its step, and no outline or shadow lead.

In 1.12.1 the kernel (`0x5c6940`) seeds a line with the outline lead plus the shadow (`0x5c69c0`, `0x5c69a4`), steps each glyph by `ceil(floor(adv) + 1 + min(kern, 0))` on FreeType's 26.6 advances (`0x5ca2d0`, `0x5d1298`), prices the line's last glyph by its ink (`0x5c6b70`, `0x5cb080`) and carries the previous glyph across a break (`0x5c6aa0`). The draw rows use the same law, shadow-seeded, wrapping at `ceil(width)` (`0x5c7470`, `0x5cddb1`).

The measure, the wrap, the justify and the EditBox now follow it, reading the face's own metrics, kerning and composite build (`0x7d40e6`).

Tests pin widths computed outside the code base from FRIZQT and ARIALN at 12–34 px, with and without outline and shadow.

Fixes #427

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 06:27:55 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5af22799d0
commit 108c354fc6
18 changed files with 1281 additions and 300 deletions
@@ -260,6 +260,7 @@ pub(crate) fn float_combat_text(
height: Some(target_px),
outline: Outline::None,
spacing: 0.0, // one line
shadow_x: 0.0,
alpha_gradient: None,
},
// A world overlay with its own seat; the UI grid does not apply.
+1
View File
@@ -310,6 +310,7 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh {
height: Some(BAKE_PX),
outline: Outline::None,
spacing: 0.0, // one line
shadow_x: 0.0,
alpha_gradient: None,
},
// A world billboard: the glyphs are re-seated in pitch units below, off the UI grid.
+1
View File
@@ -201,6 +201,7 @@ pub(super) fn pill_quads(
height: Some(PILL_QUAD_PX),
outline: Outline::None,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
},
// Our own dev overlay: measured off a degenerate rect, then shifted into the pill.
@@ -551,6 +551,8 @@ pub(super) fn tick_script(
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
outline: r.outline, // the outline biases the stepped width
spacing: 0.0, // the gap moves rows, never counts them
// The drawn shadow seeds each row, as the draw's does.
shadow_x: r.shadow_x * s * r.scale,
alpha_gradient: None,
},
);
@@ -79,6 +79,10 @@ pub(super) fn emit(
),
outline: style.outline,
spacing: style.spacing * host.scale * host.font_scale,
// A set shadow seeds each row's pen at its drawn offset (`0x5cddb1`, `0x7726a4`).
shadow_x: style
.shadow
.map_or(0.0, |sh| sh.offset[0] * host.scale * host.font_scale),
alpha_gradient: style.alpha_gradient,
};
// The focused editbox draws windowed (`0x77da80`): the scroll window's substring from the
+239 -102
View File
@@ -19,6 +19,7 @@
mod faces;
mod gpu;
mod sfnt;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, MutexGuard};
@@ -27,7 +28,7 @@ use bevy::image::Image;
use bevy::math::Rect;
use bevy::prelude::*;
use cosmic_text::{
Attrs, Buffer, CacheKey, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
Attrs, Buffer, CacheKey, CacheKeyFlags, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap,
};
use benilla_assets::{LockRecover, WorldAssets};
@@ -57,6 +58,9 @@ struct Face {
stretch: fontdb::Stretch,
/// `hhea.asc / (asc + |desc|)`; Friz's 0.794 when the tables do not parse.
ascent_ratio: f32,
/// The face's own tables, for FreeType's metrics ([`sfnt::Sfnt::glyph`]); `None` for a font
/// with no TrueType outlines, which falls back to the shaper's.
sfnt: Option<Arc<sfnt::Sfnt>>,
}
/// `(face, glyph, ppem, outline radius)`: one bitmap per exact device-pixel size.
@@ -79,18 +83,19 @@ pub(super) struct GlyphRef {
pub(super) glyph_id: u16,
/// Swash's raster key at zero subpixel offset, so another outline radius needs no re-shaping.
key: CacheKey,
/// The face's advance in physical px, unfloored; [`super::layout::client_step`] floors it.
pub(super) advance: f32,
/// The advance in whole physical px, as the reference's FreeType rounds it
/// ([`sfnt::GlyphPx::advance`]); the step law adds its bias ([`TextEngine::step`]).
pub(super) px: f32,
/// The glyph as its line's last, in physical px ([`sfnt::GlyphPx::ink`]).
pub(super) ink: f32,
/// Offset from the baseline in physical px; 0 for every glyph the four client faces shape.
pub(super) y_off: f32,
}
/// One character at one `(face, ppem)`. The step law's bias is per glyph, so a character shaping
/// to two glyphs would take two ([`super::layout::client_step`]); in the client faces none does.
/// One character at one `(face, ppem)`. The step law is per glyph ([`TextEngine::step`]), so a
/// character shaping to two glyphs would take two steps; in the client faces none does.
pub(super) struct CharCell {
pub(super) glyphs: Vec<GlyphRef>,
/// Σ `advance.floor()` over the glyphs in physical px, the width law's per-character term.
pub(super) floor_sum: f32,
}
/// What the cache instrument (`WOW_GLYPH_CACHE=1`) counts.
@@ -168,6 +173,7 @@ impl TextEngine {
}
};
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
let sfnt = sfnt::Sfnt::parse(bytes.clone()).map(Arc::new);
match register_font(&mut font_system, bytes) {
Ok(r) => {
path_to_face.insert(path.to_ascii_lowercase(), faces.len());
@@ -179,6 +185,7 @@ impl TextEngine {
style: r.style,
stretch: r.stretch,
ascent_ratio,
sfnt,
});
}
Err(e) => warn!("ui_text: failed to register {path}: {e:#}"),
@@ -247,6 +254,7 @@ impl TextEngine {
let source = self.source.as_ref()?;
let bytes = read_font_bytes(source, path)?;
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
let sfnt = sfnt::Sfnt::parse(bytes.clone()).map(Arc::new);
let r = register_font(&mut self.font_system, bytes)
.inspect_err(|e| warn!("ui_text: failed to register {path}: {e:#}"))
.ok()?;
@@ -259,6 +267,7 @@ impl TextEngine {
style: r.style,
stretch: r.stretch,
ascent_ratio,
sfnt,
});
self.path_to_face.insert(key.to_string(), index);
info!("ui_text: loaded font face {path}");
@@ -339,8 +348,8 @@ impl TextEngine {
}
/// One character: shape it alone into its [`CharCell`] and rasterize its glyphs at `radius`.
/// Alone, so the width sum holds each face's own advance with no neighbour term (kerning is
/// dropped, per the `ui_text` module doc) and one table answers a measure and a draw.
/// Alone, so a glyph's metrics hold no neighbour term (the pair kern is [`Self::step`]'s) and
/// one table answers a measure and a draw.
fn ensure_char(&mut self, face: usize, ppem: u16, radius: Option<u8>, ch: char) {
if let Some(known) = self.chars.get(&(face, ppem, ch)) {
let Some(radius) = radius else { return };
@@ -357,13 +366,8 @@ impl TextEngine {
// A control character (`\n`, which the markup parser takes as a line break) has no glyph
// by design: an empty cell, before any shaping, so it neither re-shapes nor warns.
if ch.is_control() {
self.chars.insert(
(face, ppem, ch),
CharCell {
glyphs: Vec::new(),
floor_sum: 0.0,
},
);
self.chars
.insert((face, ppem, ch), CharCell { glyphs: Vec::new() });
return;
}
let Some(f) = self.faces.get(face) else {
@@ -378,7 +382,6 @@ impl TextEngine {
.stretch(stretch);
let px = f32::from(ppem);
let mut glyphs: Vec<GlyphRef> = Vec::new();
let mut floor_sum = 0.0f32;
let mut shaped_by = None;
{
let mut buf = Buffer::new(&mut self.font_system, Metrics::new(px, px));
@@ -400,10 +403,10 @@ impl TextEngine {
glyphs.push(GlyphRef {
glyph_id: g.glyph_id,
key: physical.cache_key,
advance: g.w,
px: g.w.floor(),
ink: 0.0,
y_off: g.y,
});
floor_sum += g.w.floor();
}
}
}
@@ -431,24 +434,64 @@ impl TextEngine {
warn!("ui_text: no glyph for {ch:?} in any registered face");
}
// Cache the miss as an empty cell, or every frame's pre-warm shapes it again.
self.chars.insert(
(face, ppem, ch),
CharCell {
glyphs,
floor_sum: 0.0,
},
);
self.chars.insert((face, ppem, ch), CharCell { glyphs });
return;
}
self.stats.chars_shaped += 1;
// The metrics off the face's own tables, as FreeType computes them; a glyph another face
// shaped keeps the shaper's advance and reads its ink off the outline.
let sfnt = self.faces.get(face).and_then(|f| f.sfnt.clone());
for g in &mut glyphs {
let own = sfnt
.as_deref()
.filter(|_| g.key.font_id == face_id)
.and_then(|t| t.glyph(g.glyph_id, ppem));
match own {
Some(m) => (g.px, g.ink) = (m.advance, m.ink),
None => g.ink = glyph_ink(&mut self.swash, &mut self.font_system, g.key, ppem),
}
}
if let Some(radius) = radius {
for g in &glyphs {
let g = *g;
self.ensure_cell(face_id, ppem, radius, &g);
}
}
self.chars
.insert((face, ppem, ch), CharCell { glyphs, floor_sum });
self.chars.insert((face, ppem, ch), CharCell { glyphs });
}
/// The step from glyph `left` to the glyph after it in device px, `ComputeStep` (`0x5ca2d0`):
/// `ceil(stepBase + min(kern, 0) · x_ppem/upem)`, the sum narrowed to f32 (`0x5ca378`) before
/// the ceil (`0x73fdf5`). `stepBase` is the whole-pixel advance plus the law's bias
/// (`step_extra`: `0x5d12a9`, and `0x5ca2b0` for THICK); the kern is the face's unscaled pair
/// value (`FT_Get_Kerning` mode 2). With no glyph after it, the step is the base.
pub(super) fn step(
&self,
face: usize,
ppem: u16,
step_extra: f32,
left: &GlyphRef,
right: Option<&GlyphRef>,
) -> f32 {
let base = left.px + step_extra;
let Some(right) = right else {
return base;
};
let Some(f) = self.faces.get(face) else {
return base;
};
let Some(t) = f.sfnt.as_deref() else {
return base;
};
if left.key.font_id != f.id || right.key.font_id != f.id {
return base;
}
let kern = t.neg_kern(left.glyph_id, right.glyph_id);
if kern == 0 {
return base;
}
let sum = f64::from(base) + f64::from(kern) * f64::from(t.kern_scale(ppem));
(sum as f32).ceil()
}
/// Rasterize and pack one glyph's cell, unless it is already there.
@@ -611,6 +654,7 @@ pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
continue;
};
let ascent_ratio = hhea_ascent_ratio(&bytes).unwrap_or(0.794);
let sfnt = sfnt::Sfnt::parse(bytes.clone()).map(Arc::new);
let r = register_font(&mut font_system, bytes).ok()?;
path_to_face.insert(path.to_ascii_lowercase(), faces.len());
faces.push(Face {
@@ -621,6 +665,7 @@ pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
style: r.style,
stretch: r.stretch,
ascent_ratio,
sfnt,
});
}
let default_face = *path_to_face.get(&CLIENT_FONTS[0].to_ascii_lowercase())?;
@@ -665,6 +710,163 @@ impl UiFontAtlas {
}
}
/// A glyph's ink as its line's last, in physical px, read off the scaled outline: the fallback for
/// a glyph another face shaped, whose tables [`sfnt::Sfnt::glyph`] does not hold. The reference
/// measures a line's last glyph by `ceil(horiBearingX)` plus `out[4]`, the bitmap width plus the
/// outline's growth (`0x5c6b70` → `0x5cb080`, `0x5c6c24`), both stored by the rasterizer
/// (`0x5d1120`: `out[6]` the bearing, `out[4] = out[2] + growth`); the growth is the caller's.
/// FreeType's bitmap spans the outline's control box rounded out, `ceil(xMax) − floor(xMin)`, its
/// bearing is `xMin`, both on the 26.6 grid; a glyph with no ink gets a blank `(ppem + 3) >> 2` px
/// wide (`0x5d11da`) and no bearing. The outline is unhinted, as the reference loads every glyph
/// (`0x5d1300`: both load-flag words carry `FT_LOAD_NO_HINTING`).
fn glyph_ink(swash: &mut SwashCache, fs: &mut FontSystem, key: CacheKey, ppem: u16) -> f32 {
let blank = f32::from((ppem + 3) >> 2);
let key = CacheKey {
flags: key.flags | CacheKeyFlags::DISABLE_HINTING,
..key
};
let Some(commands) = swash.get_outline_commands_uncached(fs, key) else {
return blank;
};
let (mut x0, mut x1, mut y0, mut y1) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
for c in commands.iter() {
let pts = match *c {
cosmic_text::Command::MoveTo(p) | cosmic_text::Command::LineTo(p) => {
[Some(p), None, None]
}
cosmic_text::Command::QuadTo(a, p) => [Some(a), Some(p), None],
cosmic_text::Command::CurveTo(a, b, p) => [Some(a), Some(b), Some(p)],
cosmic_text::Command::Close => [None, None, None],
};
for p in pts.into_iter().flatten() {
(x0, x1) = (x0.min(p.x), x1.max(p.x));
(y0, y1) = (y0.min(p.y), y1.max(p.y));
}
}
// FreeType's 26.6 grid, so an edge a float hair past a whole pixel does not round out.
let q = |v: f32| (v * 64.0).round() / 64.0;
let (x0, x1, y0, y1) = (q(x0), q(x1), q(y0), q(y1));
let (width, rows) = (x1.ceil() - x0.floor(), y1.ceil() - y0.floor());
if !(width > 0.0 && rows > 0.0) {
return blank;
}
x0.ceil() + width
}
#[cfg(test)]
mod line_law_tests {
use super::*;
use crate::ui_text::{string_width, FontSpec};
use benilla_ui::script::Outline;
const FRIZ: &str = "Fonts\\FRIZQT__.TTF";
/// `GetStringWidth`'s measure (`0x5c6940`) in device px, computed outside this code base off
/// FRIZQT__.TTF's own tables with FreeType 2.1's fixed point (`FT_MulFix` advances and outline,
/// `>> 6`, the control box rounded out) and the kernel's law (kerned `ceil` steps, the last
/// glyph at `ceil(bearing) + width`, the outline's lead and growth, the shadow's seed, the
/// carry across a break): `(text, ppem, outline, shadow px, width px)`.
const GOLDEN: &[(&str, u16, Outline, f32, f32)] = &[
("Gold", 12, Outline::None, 0.0, 31.0),
("Gold", 12, Outline::None, 1.0, 32.0),
("1", 12, Outline::None, 0.0, 7.0),
("1", 12, Outline::None, 1.0, 8.0),
("1234", 12, Outline::None, 0.0, 32.0),
("1234", 12, Outline::None, 1.0, 33.0),
// `1`→`2` kerns −86 units, −1.03 px: the step is 7, not 8.
("12", 12, Outline::None, 0.0, 16.0),
("Ironforge", 12, Outline::None, 0.0, 59.0),
// `f` advances 4.0 px in 26.6, 3.996 to a float shaper.
("fi.", 12, Outline::None, 0.0, 13.0),
("fff", 12, Outline::None, 0.0, 16.0),
("Staff of the Shadow Flame", 12, Outline::None, 0.0, 164.0),
("Stormwind City", 12, Outline::None, 0.0, 98.0),
("i\nIronforge", 12, Outline::None, 0.0, 63.0),
("ab\ncd", 12, Outline::None, 0.0, 24.0),
("1234", 12, Outline::Normal, 0.0, 36.0),
("1234", 12, Outline::Thick, 0.0, 43.0),
("Gold", 14, Outline::Normal, 0.0, 39.0),
("11", 14, Outline::None, 0.0, 15.0),
("1234", 17, Outline::None, 1.0, 45.0),
("Ironforge", 17, Outline::None, 0.0, 79.0),
];
/// The measure answers the reference's widths to the pixel, at both DPIs.
#[test]
fn the_measure_answers_the_reference_widths() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
for &(text, ppem, outline, shadow, want) in GOLDEN {
let spec = FontSpec {
path: Some(FRIZ),
height: Some(f32::from(ppem) / dpi),
outline,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
};
let got = string_width(&mut e, text, spec, shadow / dpi);
assert_eq!(
got * dpi,
want,
"{text:?} @{ppem} {outline:?} shadow {shadow}, dpi {dpi}"
);
}
}
}
/// A composite glyph's ink as the reference's FreeType builds it (`0x7d40e6`–`0x7d4246`): its
/// components scaled first, each offset scaled alone and, under `ROUND_XY_TO_GRID`, rounded to
/// a whole pixel. Every composite in ARIALN carries that flag; these ink widths, a lone
/// character's measure, were computed off the font's tables outside this code base.
#[test]
fn a_composite_glyph_is_built_as_the_reference_builds_it() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
for &(text, ppem, want) in &[
("ì", 12u16, 4.0f32),
("î", 12, 5.0),
("Î", 12, 5.0),
("í", 13, 5.0),
("Ï", 13, 5.0),
("½", 16, 13.0),
("¾", 16, 13.0),
("í", 18, 6.0),
("ì", 18, 5.0),
("½", 18, 13.0),
("¼", 18, 13.0),
] {
let spec = FontSpec {
path: Some("Fonts\\ARIALN.TTF"),
height: Some(f32::from(ppem)),
outline: Outline::None,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
};
assert_eq!(
string_width(&mut e, text, spec, 0.0),
want,
"{text:?} @{ppem}"
);
}
}
/// The step's advance is FreeType's 26.6 one shifted down (`0x5d1298`), not a float's floor.
#[test]
fn an_advance_is_the_fixed_point_one() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
let face = e.face_for(Some(FRIZ));
e.ensure_metrics(face, 12, "f");
let g = e.char_cell(face, 12, 'f').expect("shaped").glyphs[0];
assert_eq!(g.px, 4.0, "`f` at 12 px advances 4.0 in 26.6");
assert_eq!(e.step(face, 12, 1.0, &g, None), 5.0);
}
}
#[cfg(test)]
mod differential_tests {
use super::*;
@@ -701,7 +903,7 @@ mod differential_tests {
// A small and a large size, so a size-dependent substitution cannot hide.
for ppem in [10u16, 20] {
for text in CORPUS {
let (want, _) = shape_whole(&mut e, &family, ppem, text);
let want = shape_whole(&mut e, &family, ppem, text);
e.ensure_metrics(face, ppem, text);
let got: Vec<u16> = text
.chars()
@@ -724,70 +926,10 @@ mod differential_tests {
);
}
/// The width is the sum of per-glyph steps off each glyph's own advance, and differs from
/// whole-string shaping, which kerns: the reference kerns only negative pairs, rounded up
/// (`ComputeStep`, `0x5ca2d0`), and this drops even those, so shaping whole runs is no fix.
#[test]
fn the_width_is_the_unkerned_per_character_sum() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / patch chain");
return;
};
let mut kerning_seen = 0usize;
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
for face in 0..e.faces.len() {
let family = e.faces[face].family.clone();
for ppem in [10u16, 20] {
// Both step biases: plain/NORMAL (+1) and THICK (+2).
for step_extra in [1.0f32, 2.0] {
for text in CORPUS {
let got = super::super::layout::measure_line_width_for_test(
&mut e, face, ppem, step_extra, text,
);
let by_glyph: f32 = text
.chars()
.filter_map(|c| e.char_cell(face, ppem, c))
.flat_map(|c| c.glyphs.iter())
.map(|g| g.advance.floor() + step_extra)
.sum::<f32>()
/ dpi;
assert_eq!(
got, by_glyph,
"{family} @{ppem} dpi {dpi} extra {step_extra}: {text:?} — the \
pre-summed floor must be the per-glyph sum"
);
let (_, kerned) = shape_whole(&mut e, &family, ppem, text);
let kerned =
kerned + step_extra * kerned_glyphs(&mut e, face, ppem, text) / dpi;
if (kerned - got).abs() > 1e-3 {
kerning_seen += 1;
}
}
}
}
}
}
assert!(
kerning_seen > 0,
"the corpus is full of kerning pairs — if the shaped sum never differs from ours, \
either kerning stopped being dropped or this test stopped measuring it"
);
}
/// Glyph count of `text` at `(face, ppem)`, which the step bias multiplies.
fn kerned_glyphs(e: &mut TextEngine, face: usize, ppem: u16, text: &str) -> f32 {
e.ensure_metrics(face, ppem, text);
text.chars()
.filter_map(|c| e.char_cell(face, ppem, c))
.map(|c| c.glyphs.len() as f32)
.sum()
}
/// The whole string shaped in one buffer: its glyph ids and kerned, floored advance sum.
fn shape_whole(e: &mut TextEngine, family: &str, ppem: u16, text: &str) -> (Vec<u16>, f32) {
/// The whole string shaped in one buffer: its glyph ids.
fn shape_whole(e: &mut TextEngine, family: &str, ppem: u16, text: &str) -> Vec<u16> {
if text.is_empty() {
return (Vec::new(), 0.0);
return Vec::new();
}
let attrs = Attrs::new().family(Family::Name(family));
let px = f32::from(ppem);
@@ -795,14 +937,9 @@ mod differential_tests {
buf.set_wrap(&mut e.font_system, Wrap::None);
buf.set_text(&mut e.font_system, text, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut e.font_system, false);
let (mut ids, mut w) = (Vec::new(), 0.0f32);
for run in buf.layout_runs() {
for g in run.glyphs {
ids.push(g.glyph_id);
w += g.w.floor() / e.dpi;
}
}
(ids, w)
buf.layout_runs()
.flat_map(|run| run.glyphs.iter().map(|g| g.glyph_id))
.collect()
}
}
@@ -899,7 +1036,7 @@ mod ppem_tests {
e.ensure_str(face, 14, 0, " ");
let c = e.char_cell(face, 14, ' ').expect("a space still shapes");
assert_eq!(c.glyphs.len(), 1);
assert!(c.floor_sum > 0.0, "a space is wide");
assert!(c.glyphs[0].px > 0.0, "a space is wide");
assert!(
e.cell(face, 14, 0, c.glyphs[0].glyph_id).is_none(),
"…and has no ink"
@@ -922,7 +1059,7 @@ mod ppem_tests {
.char_cell(face, 14, '\n')
.expect("the miss is cached, so the second ask short-circuits");
assert!(c.glyphs.is_empty(), "a newline draws nothing");
assert_eq!(c.floor_sum, 0.0, "…and steps nothing");
assert!(c.glyphs.is_empty(), "…and steps nothing");
assert_eq!(
e.stats.chars_shaped, shaped,
"a control character is answered before the shape, not by failing one"
@@ -0,0 +1,355 @@
//! A glyph's metrics as the reference's FreeType computes them off the raw TrueType tables: the
//! pixel advance its pen steps by, the ink its measure ends a line on, and the negative pair kern
//! its step takes. The reference loads every glyph unscaled-then-scaled with no hinting
//! (`0x5d1300`, `FT_LOAD_NO_HINTING`), so each value is FreeType's fixed-point arithmetic on the
//! font units, which a float shaper only approximates: a 4.0 px advance it reads as 3.996 steps a
//! pixel short.
use std::collections::HashMap;
/// FreeType's `FT_MulFix`: `a·b / 65536`, rounded half away from zero.
fn mul_fix(a: i64, b: i64) -> i64 {
let s = if (a < 0) != (b < 0) { -1 } else { 1 };
s * ((a.abs() * b.abs() + 0x8000) >> 16)
}
/// FreeType's `FT_DivFix`: `a·65536 / b`, rounded half away from zero.
fn div_fix(a: i64, b: i64) -> i64 {
let s = if (a < 0) != (b < 0) { -1 } else { 1 };
let (a, b) = (a.abs(), b.abs());
s * (((a << 16) + (b >> 1)) / b)
}
/// One glyph at one pixel size, in device px.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct GlyphPx {
/// The advance in whole pixels: FreeType's 26.6 advance shifted down (`0x5d1298`), before the
/// step law's `+1` (`0x5d12a9`).
pub(super) advance: f32,
/// The glyph as its line's last: `ceil(horiBearingX) + bitmap width` (`0x5c6b70` → `0x5cb080`),
/// before the outline's growth of the bitmap.
pub(super) ink: f32,
}
/// The tables a glyph's metrics come off, located once per face.
pub(super) struct Sfnt {
data: Vec<u8>,
upem: u16,
n_hmetrics: u16,
num_glyphs: u16,
hmtx: usize,
loca: usize,
loca_long: bool,
glyf: usize,
/// The negative pairs of the first horizontal format-0 `kern` subtable, the one FreeType 2.1
/// loads; `FT_Get_Kerning` reads it unscaled (mode 2, `0x7ce87e`), and the step keeps only a
/// negative value (`0x5ca350`–`0x5ca358`).
kern: HashMap<(u16, u16), i16>,
}
fn be16(d: &[u8], at: usize) -> Option<u16> {
Some(u16::from_be_bytes(d.get(at..at + 2)?.try_into().ok()?))
}
fn be16s(d: &[u8], at: usize) -> Option<i16> {
Some(i16::from_be_bytes(d.get(at..at + 2)?.try_into().ok()?))
}
fn be32(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes(d.get(at..at + 4)?.try_into().ok()?))
}
impl Sfnt {
/// Locate the tables; `None` for a font that is not TrueType-outlined.
pub(super) fn parse(data: Vec<u8>) -> Option<Self> {
let d = &data[..];
let num = usize::from(be16(d, 4)?);
let mut table = HashMap::new();
for i in 0..num {
let rec = 12 + 16 * i;
let tag: [u8; 4] = d.get(rec..rec + 4)?.try_into().ok()?;
table.insert(
tag,
(be32(d, rec + 8)? as usize, be32(d, rec + 12)? as usize),
);
}
let head = table.get(b"head")?.0;
let maxp = table.get(b"maxp")?.0;
let hhea = table.get(b"hhea")?.0;
let kern = table
.get(b"kern")
.and_then(|&(at, _)| read_kern(d, at))
.unwrap_or_default();
Some(Self {
upem: be16(d, head + 18)?,
loca_long: be16s(d, head + 50)? != 0,
num_glyphs: be16(d, maxp + 4)?,
n_hmetrics: be16(d, hhea + 34)?,
hmtx: table.get(b"hmtx")?.0,
loca: table.get(b"loca")?.0,
glyf: table.get(b"glyf")?.0,
kern,
data,
})
}
/// The step's kern scale `[CGxFont+0x188] = x_ppem / units_per_EM`, narrowed to f32
/// (`0x5ca142`–`0x5ca151`).
pub(super) fn kern_scale(&self, ppem: u16) -> f32 {
f32::from(ppem) / f32::from(self.upem)
}
/// The negative kern between two glyphs in font units, 0 for none.
pub(super) fn neg_kern(&self, left: u16, right: u16) -> i16 {
self.kern.get(&(left, right)).copied().unwrap_or(0)
}
fn hmetric(&self, gid: u16) -> Option<(u16, i16)> {
let d = &self.data[..];
if gid < self.n_hmetrics {
let at = self.hmtx + 4 * usize::from(gid);
return Some((be16(d, at)?, be16s(d, at + 2)?));
}
let last = self.hmtx + 4 * usize::from(self.n_hmetrics.checked_sub(1)?);
let lsb_at =
self.hmtx + 4 * usize::from(self.n_hmetrics) + 2 * usize::from(gid - self.n_hmetrics);
Some((be16(d, last)?, be16s(d, lsb_at)?))
}
fn glyph_range(&self, gid: u16) -> Option<(usize, usize)> {
if gid >= self.num_glyphs {
return None;
}
let d = &self.data[..];
let i = usize::from(gid);
let (a, b) = if self.loca_long {
(
be32(d, self.loca + 4 * i)? as usize,
be32(d, self.loca + 4 * i + 4)? as usize,
)
} else {
(
usize::from(be16(d, self.loca + 2 * i)?) * 2,
usize::from(be16(d, self.loca + 2 * i + 2)?) * 2,
)
};
Some((self.glyf + a, self.glyf + b))
}
/// The glyph header's `xMin` in font units, 0 for an empty glyph, and every outline point at
/// the scale `xs`, in 26.6. A composite is built as the reference's FreeType builds it
/// (`0x7d40e6`–`0x7d4246`): each component loaded already scaled, its 2×2 matrix applied to
/// the scaled points with `FT_MulFix` (`FT_Vector_Transform`, `0x7d0340`; the 2.14 entries as
/// 16.16), then its offset scaled on its own and, under `ROUND_XY_TO_GRID` (`0x4`), rounded to a
/// whole pixel, `(x + 32) & −64` (`0x7d4229`–`0x7d423b`), hinted or not. `None` when the glyph
/// data is malformed.
fn points(&self, gid: u16, depth: u8, xs: i64, out: &mut Vec<(i64, i64)>) -> Option<i16> {
let (start, end) = self.glyph_range(gid)?;
if start == end {
return Some(0);
}
let d = &self.data[..];
let contours = be16s(d, start)?;
let x_min = be16s(d, start + 2)?;
if contours >= 0 {
let mut fu = Vec::new();
simple_points(d, start, contours as usize, &mut fu)?;
out.extend(
fu.into_iter()
.map(|(x, y)| (mul_fix(x, xs), mul_fix(y, xs))),
);
return Some(x_min);
}
if depth > 8 {
return None;
}
let mut at = start + 10;
loop {
let flags = be16(d, at)?;
let gid = be16(d, at + 2)?;
at += 4;
let (a1, a2) = if flags & 0x1 != 0 {
let v = (i64::from(be16s(d, at)?), i64::from(be16s(d, at + 2)?));
at += 4;
v
} else {
let v = (
i64::from(*d.get(at)? as i8),
i64::from(*d.get(at + 1)? as i8),
);
at += 2;
v
};
// The 2.14 entries as 16.16: `[xx, yx, xy, yy]` in the file's order.
let f16 = |v: i16| i64::from(v) << 2;
let m = if flags & 0x8 != 0 {
let s = f16(be16s(d, at)?);
at += 2;
Some([s, 0, 0, s])
} else if flags & 0x40 != 0 {
let m = [f16(be16s(d, at)?), 0, 0, f16(be16s(d, at + 2)?)];
at += 4;
Some(m)
} else if flags & 0x80 != 0 {
let m = [
f16(be16s(d, at)?),
f16(be16s(d, at + 2)?),
f16(be16s(d, at + 4)?),
f16(be16s(d, at + 6)?),
];
at += 8;
Some(m)
} else {
None
};
let (mut dx, mut dy) = if flags & 0x2 != 0 {
(mul_fix(a1, xs), mul_fix(a2, xs))
} else {
(0, 0)
};
if flags & 0x2 != 0 && flags & 0x4 != 0 {
(dx, dy) = ((dx + 32) & -64, (dy + 32) & -64);
}
let mut sub = Vec::new();
self.points(gid, depth + 1, xs, &mut sub)?;
for (x, y) in sub {
let (tx, ty) = match m {
Some(m) => (
mul_fix(x, m[0]) + mul_fix(y, m[2]),
mul_fix(x, m[1]) + mul_fix(y, m[3]),
),
None => (x, y),
};
out.push((tx + dx, ty + dy));
}
if flags & 0x20 == 0 {
return Some(x_min);
}
}
}
/// Glyph `gid` at `ppem` px as FreeType 2.1 sizes it unhinted (`FT_Set_Pixel_Sizes`, then
/// `FT_Load_Glyph` with `FT_LOAD_NO_HINTING`): the scale `x_scale = FT_DivFix(ppem·64, upem)`,
/// the phantom points `pp1 = xMin − lsb` and `pp2 = pp1 + advance` scaled by `FT_MulFix`, the
/// outline scaled the same and moved by `−pp1`. The advance is `(pp2 − pp1) >> 6`; the bitmap
/// spans the outline's control box rounded out, `ceil(xMax) − floor(xMin)`, and the bearing
/// is the box's `xMin`. With no ink, the rasterizer makes a `(ppem + 3) >> 2` px blank
/// (`0x5d11da`).
pub(super) fn glyph(&self, gid: u16, ppem: u16) -> Option<GlyphPx> {
let (adv_fu, lsb) = self.hmetric(gid)?;
let xs = div_fix(i64::from(ppem) * 64, i64::from(self.upem));
let mut pts = Vec::new();
let x_min = self.points(gid, 0, xs, &mut pts)?;
let origin = i64::from(x_min) - i64::from(lsb);
let pp1 = mul_fix(origin, xs);
let adv = mul_fix(origin + i64::from(adv_fu), xs) - pp1;
// `0x5d1292`–`0x5d1298`: `cdq; and edx,0x3f; add; sar 6`, toward zero.
let advance = (if adv < 0 { -((-adv) >> 6) } else { adv >> 6 }) as f32;
let blank = f32::from((ppem + 3) >> 2);
if pts.is_empty() {
return Some(GlyphPx {
advance,
ink: blank,
});
}
let xs_px: Vec<i64> = pts.iter().map(|&(x, _)| x - pp1).collect();
let ys_px: Vec<i64> = pts.iter().map(|&(_, y)| y).collect();
let (x0, x1) = (*xs_px.iter().min()?, *xs_px.iter().max()?);
let (y0, y1) = (*ys_px.iter().min()?, *ys_px.iter().max()?);
let floor = |v: i64| v.div_euclid(64);
let ceil = |v: i64| (v + 63).div_euclid(64);
let (width, rows) = (ceil(x1) - floor(x0), ceil(y1) - floor(y0));
// `0x5cb080` rounds the 26.6 bearing up (`0x73fdf5`, a ceil); an outline with no extent
// still keeps its bearing beside the blank bitmap.
let width = if width == 0 || rows == 0 {
i64::from((ppem + 3) >> 2)
} else {
width
};
Some(GlyphPx {
advance,
ink: (ceil(x0) + width) as f32,
})
}
}
/// A simple glyph's points in font units, appended to `out`.
fn simple_points(d: &[u8], start: usize, contours: usize, out: &mut Vec<(i64, i64)>) -> Option<()> {
if contours == 0 {
return Some(());
}
let ends_at = start + 10;
let n = usize::from(be16(d, ends_at + 2 * (contours - 1))?) + 1;
let ins = usize::from(be16(d, ends_at + 2 * contours)?);
let mut at = ends_at + 2 * contours + 2 + ins;
let mut flags = Vec::with_capacity(n);
while flags.len() < n {
let f = *d.get(at)?;
at += 1;
flags.push(f);
if f & 0x8 != 0 {
let r = *d.get(at)?;
at += 1;
for _ in 0..r {
flags.push(f);
}
}
}
flags.truncate(n);
let mut coord = |short: u8, same: u8| -> Option<Vec<i64>> {
let mut v = 0i64;
let mut out = Vec::with_capacity(n);
for &f in &flags {
if f & short != 0 {
let b = i64::from(*d.get(at)?);
at += 1;
v += if f & same != 0 { b } else { -b };
} else if f & same == 0 {
v += i64::from(be16s(d, at)?);
at += 2;
}
out.push(v);
}
Some(out)
};
let xs = coord(0x2, 0x10)?;
let ys = coord(0x4, 0x20)?;
out.extend(xs.into_iter().zip(ys));
Some(())
}
/// The first horizontal format-0 subtable's negative pairs (coverage `0x0001`).
fn read_kern(d: &[u8], at: usize) -> Option<HashMap<(u16, u16), i16>> {
let n = usize::from(be16(d, at + 2)?);
let mut sub = at + 4;
for _ in 0..n {
let len = usize::from(be16(d, sub + 2)?);
let coverage = be16(d, sub + 4)?;
if coverage == 0x0001 {
let pairs = usize::from(be16(d, sub + 6)?);
let mut map = HashMap::new();
for j in 0..pairs {
let p = sub + 14 + 6 * j;
let v = be16s(d, p + 4)?;
if v < 0 {
map.insert((be16(d, p)?, be16(d, p + 2)?), v);
}
}
return Some(map);
}
sub += len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_point_rounds_half_away_from_zero() {
assert_eq!(mul_fix(3, 0x8000), 2);
assert_eq!(mul_fix(-3, 0x8000), -2);
assert_eq!(div_fix(12 * 64, 1000), 50332);
}
}
+204 -74
View File
@@ -4,12 +4,12 @@
use bevy::math::Rect;
use benilla_ui::script::{JustifyH, JustifyV};
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use super::super::engine::{TextEngine, UiFontAtlas};
use super::super::engine::{GlyphRef, TextEngine, UiFontAtlas};
use super::super::markup::{fontstring_lines, parse_markup, ColorRun};
use super::wrap::{greedy_pack, tokenize_words};
use super::{client_step, overflow, FontSpec, Justify, HEIGHT_LIMIT_MIN, WRAP_MIN_WIDTH};
use super::wrap::{greedy_pack, row_limit, tokenize_words};
use super::{overflow, FontSpec, Justify, HEIGHT_LIMIT_MIN, WRAP_MIN_WIDTH};
/// A [`FontSpec`] resolved once, so measure and draw agree on face and raster size.
#[derive(Clone, Copy)]
@@ -26,6 +26,12 @@ pub(super) struct Resolved {
pub(super) step_extra: f32,
/// The line gap, whole device pixels in logical px ([`super::line_gap`]).
pub(super) gap: f32,
/// The outline's growth of a line's last glyph in device px ([`outline_lead`]).
pub(super) lead: f32,
/// What seeds each laid-out row's pen in device px: the outline's lead and the drawn shadow's
/// x offset ([`FontSpec::shadow_x`], [`shadow_px`]), as the line builder is handed both
/// (`0x5cddb1`, `0x5c7470` @`0x5c749d`–`0x5c74f2`).
pub(super) seed: f32,
}
/// `n` lines' height, `n·S + (n−1)·gap` (`0x5c2070`); 0 for none.
@@ -40,6 +46,7 @@ pub(super) fn block_height(n: usize, r: Resolved) -> f32 {
pub(super) fn resolve(e: &mut TextEngine, font: &FontSpec) -> Resolved {
let face = e.face_for(font.path);
let ppem = e.ppem(font.height.unwrap_or(super::super::DEFAULT_FONT_SIZE));
let lead = outline_lead(font.outline);
Resolved {
face,
ppem,
@@ -47,48 +54,123 @@ pub(super) fn resolve(e: &mut TextEngine, font: &FontSpec) -> Resolved {
radius: super::super::outline::radius_of(font.outline),
step_extra: super::step_extra_of(font.outline),
gap: super::line_gap(font.spacing, e.dpi()),
lead,
seed: lead + shadow_px(font.shadow_x, e.dpi()),
}
}
/// One line's width in logical px, the sum of its characters' steps ([`client_step`]) as the
/// reference's `GetTextWidth` sums them; every measure, the one inside a Lua call
/// ([`benilla_ui::script::TextMeasure`]) included, runs through here. The caller ensures `text`'s
/// metrics first: this is `&`-only, and a character not in the cache counts 0.
pub(super) fn measure_line_width(e: &TextEngine, r: Resolved, step_extra: f32, text: &str) -> f32 {
// Summed in device px and divided once, as the pen does: a sum of quotients is another `f32`,
// and measure must equal render to the bit.
let steps: f32 = text
.chars()
.filter_map(|c| e.char_cell(r.face, r.ppem, c))
.map(|c| c.floor_sum + step_extra * c.glyphs.len() as f32)
.sum();
steps / e.dpi()
/// What an outline adds to a line in device px, twice: the reference seeds every line's pen with
/// it before the first glyph (`0x5c6940` @`0x5c69c0`, `0x5c7470` @`0x5c74d0`), and its rasterizer
/// widens each glyph's bitmap by it (`0x5ca160` → `0x5d1120`, `out[4]`), which the line's last glyph
/// counts. 4 for THICK (font flag `0x8`), 2 for NORMAL (`0x1`).
fn outline_lead(outline: Outline) -> f32 {
match outline {
Outline::Thick => 4.0,
Outline::Normal => 2.0,
Outline::None => 0.0,
}
}
/// The width law itself, for the differential test that pins it to a shaped sum.
#[cfg(test)]
pub(crate) fn measure_line_width_for_test(
e: &mut TextEngine,
face: usize,
ppem: u16,
step_extra: f32,
text: &str,
) -> f32 {
e.ensure_metrics(face, ppem, text);
let r = Resolved {
face,
ppem,
size: e.logical_size(ppem),
radius: 0,
step_extra,
gap: 0.0,
/// A shadow's x offset, in logical px, as the pen seed it is in device px: whole pixels when it
/// points right, else nothing (`0x5c69a4`/`0x5c74b4`: `round` then `ceil`, only past 0).
fn shadow_px(shadow_x: f32, dpi: f32) -> f32 {
if shadow_x > 0.0 {
(shadow_x * dpi + 0.5).floor()
} else {
0.0
}
}
/// The first glyph `c` draws, the one a step looks at past its own glyph (`0x5c6b70`).
fn first_glyph(e: &TextEngine, r: Resolved, c: char) -> Option<GlyphRef> {
e.char_cell(r.face, r.ppem, c)
.and_then(|cell| cell.glyphs.first().copied())
}
/// One line's extent in device px past its seed, with its first and last glyphs. Each glyph steps
/// to the next ([`TextEngine::step`], kerned); the last looks past itself as the reference does
/// (`0x5c6b70`): when any character follows it, in `text` or as `next`, the source character
/// after it, it takes its step to that character, else its ink plus the outline's growth. A
/// character with no glyph steps nothing and keeps the glyph before it, as the kernels skip it.
fn line_extent_dev(
e: &TextEngine,
r: Resolved,
text: impl Iterator<Item = char>,
next: Option<char>,
) -> (f32, Option<GlyphRef>, Option<GlyphRef>) {
let mut glyphs: Vec<GlyphRef> = Vec::new();
// A glyphless character after the last glyph: a code the look-ahead steps to.
let mut code_after: Option<Option<GlyphRef>> = None;
for c in text {
match e.char_cell(r.face, r.ppem, c) {
Some(cell) if !cell.glyphs.is_empty() => {
glyphs.extend_from_slice(&cell.glyphs);
code_after = None;
}
_ if !glyphs.is_empty() && code_after.is_none() => code_after = Some(None),
_ => {}
}
}
let (Some(&first), Some(&last)) = (glyphs.first(), glyphs.last()) else {
return (0.0, None, None);
};
measure_line_width(e, r, step_extra, text)
let step = |a: &GlyphRef, b: Option<&GlyphRef>| e.step(r.face, r.ppem, r.step_extra, a, b);
let mut w: f32 = glyphs.windows(2).map(|p| step(&p[0], Some(&p[1]))).sum();
let follow = code_after.or_else(|| next.map(|c| first_glyph(e, r, c)));
let ink = last.ink + r.lead;
w += match follow {
// `0x5c6beb`: a zero step falls through to the ink.
Some(right) => Some(step(&last, right.as_ref()))
.filter(|s| *s != 0.0)
.unwrap_or(ink),
None => ink,
};
(w, Some(first), Some(last))
}
/// Greedy wrap of one markup line into rows no wider than `max_width` px, the reference's wrap
/// kernel (`0x5c6c50`/`0x5c7780`): break at the last opportunity, else force-break at the last
/// fitting glyph; inter-word whitespace stays verbatim and only the separator at a break drops.
/// `GetStringWidth`'s measure in logical px, the kernel `0x5c6940` as `0x772890` calls it: each
/// line is [`line_extent_dev`] with nothing after its last glyph, the widest wins, and nothing
/// rounds it or pads it. The first line's pen is seeded with the outline's lead and the shadow's
/// x offset ([`shadow_px`]); `shadow_x` is in this measure's logical px, which `0x772890` leaves
/// unscaled by the frame (`[fs+0x108]`), unlike the draw's ([`FontSpec::shadow_x`]). The
/// reference's kernel resets the pen at a break but not the previous glyph (`0x5c6aa0`–`0x5c6abe`
/// leave `[ebp-0x10]`), so in its measure each later line opens with the step from the last glyph
/// before it, kerned as any step, and without the seed.
pub(crate) fn string_width(e: &mut TextEngine, text: &str, font: FontSpec, shadow_x: f32) -> f32 {
let r = resolve(e, &font);
e.ensure_metrics(r.face, r.ppem, text);
let e = &*e;
let dpi = e.dpi();
let mut widest = 0.0f32;
let mut carry: Option<GlyphRef> = None;
for (i, line) in fontstring_lines(text, [1.0, 1.0, 1.0, 1.0])
.iter()
.enumerate()
{
let chars = line.iter().flat_map(|run| run.text.chars());
let (extent, first, last) = line_extent_dev(e, r, chars, None);
let open = if i == 0 {
r.lead + shadow_px(shadow_x, dpi)
} else {
// The previous glyph the break kept (`0x5c6aa0`), stepped to this line's first.
match (carry, first) {
(Some(p), Some(f)) => e.step(r.face, r.ppem, r.step_extra, &p, Some(&f)),
_ => 0.0,
}
};
widest = widest.max(open + extent);
carry = last.or(carry);
}
widest / dpi
}
/// Greedy wrap of one markup line into rows within `max_width` px, the reference's wrap kernel
/// (`0x5c6c50`/`0x5c7780`): break at the last opportunity, else force-break at the last fitting
/// glyph; inter-word whitespace stays verbatim and only the separator at a break drops. A row is
/// priced as its line builder prices it (`0x5c7470`): its seed, then its glyphs' steps, the last
/// looking past itself to the source character after it ([`line_extent_dev`]), and it fits when
/// that is within the width rounded up to a whole device pixel (`0x5c752a`–`0x5c7530`). Each row
/// comes with the source character after it, `None` at the line's end.
///
/// Break opportunities are whitespace only: the reference's classifier (`0x5c7780`) also breaks
/// after `-`, `:` and `/` and by CJK kinsoku classes, and its `nonspacewrap` flag (ui `0x1000`) is
@@ -98,20 +180,59 @@ pub(super) fn wrap_line(
r: Resolved,
line: &[ColorRun],
max_width: f32,
) -> Vec<Vec<ColorRun>> {
) -> Vec<(Vec<ColorRun>, Option<char>)> {
let words = tokenize_words(line);
if words.is_empty() {
// A blank or all-whitespace line still takes a row.
return vec![line.to_vec()];
return vec![(line.to_vec(), None)];
}
greedy_pack(words, max_width, |t| {
measure_line_width(e, r, r.step_extra, t)
})
let trailing = trailing_whitespace(line);
greedy_pack(
words,
row_limit(max_width, e.dpi()),
&trailing,
|t, next| r.seed + line_extent_dev(e, r, t.chars(), next).0,
)
}
/// `text`'s laid-out size, the widest line by all lines' height, wrapping at `wrap_width` by the
/// render's own [`wrap_line`] pass; the engine's measure round-trip sizes height-less FontStrings
/// by it ([`benilla_ui::script::UiScript::fontstrings_needing_measure`]).
/// The whitespace ending a markup line, which the word walk leaves to the packer's end.
fn trailing_whitespace(line: &[ColorRun]) -> String {
let mut tail: Vec<char> = Vec::new();
for run in line.iter().rev() {
for c in run.text.chars().rev() {
if !c.is_whitespace() {
return tail.iter().rev().collect();
}
tail.push(c);
}
}
tail.iter().rev().collect()
}
/// A laid-out row's width in logical px, its seed included, as the line builder hands it to the
/// justify (`0x5c7470` → `0x5cddf9`); `next` is the source character after it ([`wrap_line`]).
pub(super) fn row_width(e: &TextEngine, r: Resolved, row: &[ColorRun], next: Option<char>) -> f32 {
let chars = row.iter().flat_map(|run| run.text.chars());
(r.seed + line_extent_dev(e, r, chars, next).0) / e.dpi()
}
/// [`wrap_line`] over every source line of `lines`.
pub(super) fn wrap_rows(
e: &TextEngine,
r: Resolved,
lines: &[Vec<ColorRun>],
max_width: f32,
) -> Vec<(Vec<ColorRun>, Option<char>)> {
lines
.iter()
.flat_map(|line| wrap_line(e, r, line, max_width))
.collect()
}
/// `text`'s laid-out size, by all lines' height: unwrapped, the width is [`string_width`]'s with
/// the spec's shadow, the one the reference keeps (`[fs+0xfc]`); wrapped at `wrap_width`, it is the
/// widest row the render's own [`wrap_rows`] pass lays. The engine's measure round-trip sizes
/// height-less FontStrings by it ([`benilla_ui::script::UiScript::fontstrings_needing_measure`]).
pub(crate) fn measure_text(
e: &mut TextEngine,
text: &str,
@@ -121,31 +242,29 @@ pub(crate) fn measure_text(
let r = resolve(e, &font);
// Metrics only: a string measured and never drawn costs no raster.
e.ensure_metrics(r.face, r.ppem, text);
let e = &*e;
let lines = fontstring_lines(text, [1.0, 1.0, 1.0, 1.0]);
let render_lines: Vec<Vec<ColorRun>> = match wrap_width {
Some(w) if w > WRAP_MIN_WIDTH => lines
.iter()
.flat_map(|line| wrap_line(e, r, line, w))
.collect(),
_ => lines,
};
let mut max_w = 0.0f32;
for line in &render_lines {
let mut w = 0.0f32;
for run in line {
if !run.text.is_empty() {
w += measure_line_width(e, r, r.step_extra, &run.text);
}
}
max_w = max_w.max(w);
}
// Deviation: one pixel of headroom over the reference's measured width, kept because dropping
// it narrows every auto-sized FontString, a look change.
// Height: N lines at the font em with no outline pad, the gap between them (`0x5c2070`:
// `N·S + (N-1)·gap`); an outlined ring pokes past it, as in the reference.
(max_w.ceil() + 1.0, block_height(render_lines.len(), r))
let (w, rows) = match wrap_width {
Some(w) if w > WRAP_MIN_WIDTH => {
let e = &*e;
let rows = wrap_rows(e, r, &lines, w);
let widest = rows
.iter()
.map(|(row, next)| row_width(e, r, row, *next))
.fold(0.0f32, f32::max);
(widest, rows.len())
}
_ => (string_width(e, text, font, font.shadow_x), lines.len()),
};
(w, block_height(rows, r))
}
/// `text`'s unwrapped height, its lines and the gaps between them ([`measure_text`]'s, without a
/// width).
pub(crate) fn unwrapped_height(e: &mut TextEngine, text: &str, font: FontSpec) -> f32 {
let r = resolve(e, &font);
block_height(fontstring_lines(text, [1.0, 1.0, 1.0, 1.0]).len(), r)
}
/// How many rows `text` wraps into at `wrap_width`, by the render's own [`wrap_line`] pass: the
@@ -270,7 +389,7 @@ pub(crate) fn line_origin(
let x0 = match justify.h {
JustifyH::Left => rect.min.x,
JustifyH::Center | JustifyH::Right => {
let w = measure_line_width(e, r, r.step_extra, drawn);
let w = (r.seed + line_extent_dev(e, r, drawn.chars(), None).0) / e.dpi();
if matches!(justify.h, JustifyH::Center) {
rect.min.x + ((rect.width() - w) * 0.5).max(0.0)
} else {
@@ -312,13 +431,23 @@ pub(crate) fn line_advances(e: &mut TextEngine, text: &str, font: FontSpec) -> V
let mut written = vec![false; text.len() + 1];
written[0] = true;
// The render's device-px pen, so a caret boundary is the x the glyph before it was drawn at.
// Each glyph with the glyph after it, so a kerned step lands where the draw lands it.
let glyphs: Vec<(usize, GlyphRef)> = drawn
.char_indices()
.flat_map(|(off, ch)| {
e.char_cell(r.face, r.ppem, ch)
.map(|c| c.glyphs.iter().map(move |g| (off, *g)).collect::<Vec<_>>())
.unwrap_or_default()
})
.collect();
// The render's device-px pen, so a caret boundary is the x the glyph after it is drawn at.
let mut x_dev = 0.0f32;
let mut k = 0usize;
for (off, ch) in drawn.char_indices() {
if let Some(c) = e.char_cell(r.face, r.ppem, ch) {
for g in &c.glyphs {
x_dev += client_step(g.advance, r.step_extra);
}
while k < glyphs.len() && glyphs[k].0 == off {
let next = glyphs.get(k + 1).map(|(_, g)| g);
x_dev += e.step(r.face, r.ppem, r.step_extra, &glyphs[k].1, next);
k += 1;
}
// The character's end in drawn bytes, mapped to its raw boundary.
let end = bounds[(off + ch.len_utf8()).min(drawn.len())];
@@ -354,6 +483,7 @@ pub(crate) fn line_rows(
lines
.iter()
.flat_map(|line| wrap_line(e, r, line, wrap_width))
.map(|(row, _)| row)
.collect()
} else {
lines
+145 -34
View File
@@ -8,18 +8,16 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::ui_pass::{UiQuad, UvRect};
use super::engine::TextEngine;
use super::engine::{GlyphRef, TextEngine};
use super::markup::{fontstring_lines, ColorRun};
mod measure;
mod overflow;
mod wrap;
/// The width law, for the engine's differential test against shaped strings.
#[cfg(test)]
pub(super) use measure::measure_line_width_for_test;
pub(crate) use measure::{
ellipsize_to_fit, line_advances, line_origin, line_rows, measure_text, measure_wrapped_rows,
string_width, unwrapped_height,
};
/// A rect this narrow is no wrap constraint: an unsized single-point `FontString` resolves to ~0.
@@ -60,22 +58,17 @@ pub(crate) struct FontSpec<'a> {
/// The gap between lines in logical px, scaled like [`Self::height`]; the line step adds it
/// rounded up to a whole device pixel ([`line_gap`]).
pub spacing: f32,
/// The drawn shadow's x offset in this spec's px, 0 for none: it seeds each laid-out row's pen
/// (`0x5cddb1` passes it whenever a shadow is set, `0x5cd650`), so it moves the wrap and the
/// justify. The draw scales it with the frame (`0x7726a4`: `[fs+0x108]·[fs+0x7c]`).
pub shadow_x: f32,
/// The `SetAlphaGradient` reveal `(start, length)`; on the spec, shadows fade with their fill.
pub alpha_gradient: Option<(f32, f32)>,
}
/// The reference's per-glyph step in device px: the FreeType advance floored plus one (glyph
/// rasterizer `0x5d1120`, `out[5]`), one more for THICK (`GlyphStepBase` `0x5ca2b0`), both in
/// `step_extra`. Divided by the DPI once per line: flooring logical px instead over-tracks.
///
/// Deviation: `ComputeStep`'s (`0x5ca2d0`) negative-only pair kern is dropped, because at UI sizes
/// it rounds to 0 almost always.
fn client_step(raw_physical_advance: f32, step_extra: f32) -> f32 {
raw_physical_advance.floor() + step_extra
}
/// [`client_step`]'s bias: `+1`, or `+2` for THICK, as `GlyphStepBase` (`0x5ca2b0`) tests the THICK
/// flag alone; a NORMAL outline steps like a plain font, though its cell grows.
/// The step law's bias over the whole-pixel advance ([`TextEngine::step`]): `+1` (glyph rasterizer
/// `0x5d1120`, `out[5]`), or `+2` for THICK, as `GlyphStepBase` (`0x5ca2b0`) tests the THICK flag
/// alone; a NORMAL outline steps like a plain font, though its cell grows.
fn step_extra_of(outline: Outline) -> f32 {
match outline {
Outline::Thick => 2.0,
@@ -199,14 +192,12 @@ fn layout_text_quads_inner(
// `0x5cd0e1`) against its blit's baked seat; NORMAL cancels exactly.
let thick_rise = f32::from(u8::from(matches!(font.outline, Outline::Thick)));
// Wrap only within a pinned width; `rect` is in the pen's own space, so no conversion.
let mut render_lines: Vec<Vec<ColorRun>> = if rect.width() > WRAP_MIN_WIDTH {
lines
.iter()
.flat_map(|line| measure::wrap_line(e, r, line, rect.width()))
.collect()
// Wrap only within a pinned width; `rect` is in the pen's own space, so no conversion. Each
// row keeps the source character after it, which prices its last glyph for the justify.
let mut render_lines: Vec<(Vec<ColorRun>, Option<char>)> = if rect.width() > WRAP_MIN_WIDTH {
measure::wrap_rows(e, r, &lines, rect.width())
} else {
lines
lines.into_iter().map(|line| (line, None)).collect()
};
// The line stack (`CGxString+0x40` in `0x5cdc20`): a height-pinned rect stops emitting lines
// once the pitch sum passes it, even for a caller that skips the ellipsis seam.
@@ -257,20 +248,28 @@ fn layout_text_quads_inner(
// `SetAlphaGradient`'s character index counts drawn characters, markup stripped; it trails the
// engine's source count by at most the newline count, a frame or two on the reveal's tail.
let mut char_index: usize = 0;
for line in &render_lines {
// First pass: lay the line at origin 0 and measure its width.
for (line, next) in &render_lines {
// First pass: lay the line at origin 0.
let mut pending: Vec<PendingGlyph> = Vec::new();
// The line's link x-ranges, one per link: a link's runs are contiguous.
let mut line_links: Vec<(std::sync::Arc<super::markup::LinkInfo>, f32, f32)> = Vec::new();
// The pen in device px: every step is an integer, so the logical width is one division.
// It walks characters, not a shaped buffer: each glyph steps to the next glyph on the row
// ([`TextEngine::step`], kerned), across runs, as the line builder's pen does
// (`0x5ccbe0` → `0x5ca2d0`).
let mut pen_dev = 0.0f32;
let row_glyphs: Vec<GlyphRef> = line
.iter()
.flat_map(|run| run.text.chars())
.filter_map(|ch| e.char_cell(r.face, r.ppem, ch))
.flat_map(|cc| cc.glyphs.iter().copied())
.collect();
let mut k = 0usize;
for run in line {
if run.text.is_empty() {
continue;
}
let run_x0 = pen_dev / dpi;
// The pen walks characters, not a shaped buffer: with the kern dropped
// ([`client_step`]) a run is its characters' steps, the sum `measure_line_width` takes.
for ch in run.text.chars() {
let Some(cc) = e.char_cell(r.face, r.ppem, ch) else {
// No face shapes it: it draws and steps nothing, as the measure says.
@@ -292,7 +291,8 @@ fn layout_text_quads_inner(
color,
});
}
pen_dev += client_step(g.advance, r.step_extra);
pen_dev += e.step(r.face, r.ppem, r.step_extra, g, row_glyphs.get(k + 1));
k += 1;
}
char_index += 1;
}
@@ -307,8 +307,10 @@ fn layout_text_quads_inner(
}
}
// Second pass: shift the line by the justify offset and emit.
let line_width = pen_dev / dpi;
// Second pass: shift the line by the justify offset and emit. The offset takes the line
// builder's width (`0x5c7470` → `0x5cddf9`), not the pen's: the seed, and the last glyph
// at its ink when nothing follows it in the source line.
let line_width = measure::row_width(e, r, line, *next);
let origin_x = match justify.h {
JustifyH::Left => rect.min.x,
JustifyH::Center => rect.min.x + (rect.width() - line_width) * 0.5,
@@ -431,6 +433,7 @@ mod measure_fits_render {
height: Some(h),
outline: Outline::None,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
}
}
@@ -701,16 +704,17 @@ mod measure_fits_render {
}
}
/// The pen and `measure_line_width` sum the same steps, so the ink never passes the measure.
/// The measure ends the line on its last glyph's ink, read off the very outline the draw
/// rasterizes, so the drawn ink never passes it: with no pixel added, it is the ink's own edge.
#[test]
fn the_drawn_line_is_exactly_as_wide_as_the_measure_says() {
fn the_drawn_ink_ends_within_the_measure() {
let Some(mut e) = test_engine(2.0) else {
eprintln!("skipping: no install / font chain");
return;
};
for label in LABELS {
let s = spec(16.0 * ERA);
let want = measure_text(&mut e, label, None, s).0 - 1.0; // less the headroom pixel
let want = measure_text(&mut e, label, None, s).0;
let quads = layout_text_quads(
&mut e,
label,
@@ -726,13 +730,119 @@ mod measure_fits_render {
);
let ink = quads.iter().map(|q| q.rect.max.x).fold(0.0f32, f32::max);
assert!(
ink <= want.ceil() + 1e-3,
ink <= want + 1e-3,
"{label:?}: ink reaches {ink} but the measure promised {want}"
);
assert!(!quads.is_empty(), "{label:?} drew nothing");
}
}
/// A line in a rect its own measure sized fills it exactly, so CENTER and RIGHT lay it where
/// LEFT does: the justify offset (`0x5cddf9`–`0x5cde28`) is taken from the line width the line
/// builder sums (`0x5c7470`), its last glyph at its ink as the measure kernel counts it.
#[test]
fn a_line_in_its_measured_rect_lands_alike_under_every_justify() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
// A shadow seeds the measure's line and every drawn row alike (`0x5cddb1`).
for (outline, shadow_x) in [
(Outline::None, 0.0),
(Outline::None, 1.0),
(Outline::Normal, 0.0),
(Outline::Thick, 1.0),
] {
let s = FontSpec {
outline,
shadow_x,
..spec(16.0 * ERA)
};
for label in LABELS {
let w = measure_text(&mut e, label, None, s).0;
let rect = Rect::new(10.25, 20.0, 10.25 + w, 40.0);
let at = |e: &mut TextEngine, h: JustifyH| {
layout_text_quads(
e,
label,
rect,
[1.0; 4],
Justify {
h,
v: JustifyV::Middle,
},
0,
s,
TextSeat::UiGrid,
)
.iter()
.map(|q| q.rect)
.collect::<Vec<_>>()
};
let left = at(&mut e, JustifyH::Left);
assert!(!left.is_empty(), "{label:?} drew");
for h in [JustifyH::Center, JustifyH::Right] {
assert_eq!(
at(&mut e, h),
left,
"{label:?} {outline:?} shadow {shadow_x} dpi {dpi}: {h:?} moved the \
line in its own {w}-wide rect"
);
}
}
}
}
}
/// The pen steps each glyph to the next by the kerned step (`0x5ccbe0` → `0x5ca2d0`): in
/// FRIZQT__ at 12 px `1`→`2` kerns −1.03 px, so the `2` of "12" lands 7 px past a lone `2`,
/// the reference's `ceil(7 + 1 − 1.03)`, where an unkerned pen puts it 8 px past.
#[test]
fn the_pen_steps_by_the_kerned_step() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
let left = Justify {
h: JustifyH::Left,
v: JustifyV::Top,
};
let rect = Rect::new(0.0, 0.0, 0.0, 0.0);
let mut at = |text: &str| {
layout_text_quads(
&mut e,
text,
rect,
[1.0; 4],
left,
0,
spec(12.0),
TextSeat::UiGrid,
)
.last()
.map(|q| q.rect.min.x)
.expect("drew")
};
assert_eq!(at("12") - at("2"), 7.0);
// The reference's widths: "12" is 16 px, a lone "2" 9.
assert_eq!(measure_text(&mut e, "12", None, spec(12.0)).0, 16.0);
assert_eq!(measure_text(&mut e, "2", None, spec(12.0)).0, 9.0);
}
/// Trailing spaces that do not all fit drop whole, and the row's width looks past its last
/// letter to the first space (`0x5c7780` finds no break after whitespace): FRIZQT__ 12 px
/// "ab " in a 20 px box is the row "ab", 15 px wide, as the reference lays it.
#[test]
fn trailing_spaces_that_do_not_all_fit_drop_whole() {
benilla_formats::wow_data_or_skip!();
let mut e = test_engine(1.0).expect("the client faces open");
for wrap in [19.0f32, 20.0, 21.0] {
assert_eq!(
measure_text(&mut e, "ab ", Some(wrap), spec(12.0)),
(15.0, 12.0),
"in a {wrap} px box"
);
}
}
/// Every glyph edge of a single-line string lands on the device grid: no per-letter phase.
#[test]
fn every_glyph_on_a_line_lands_on_the_device_pixel_grid() {
@@ -845,6 +955,7 @@ mod ellipsis_cost {
height: Some(SIZE),
outline: Outline::None,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
};
let r = measure::resolve(&mut e, &s);
+181 -69
View File
@@ -3,10 +3,6 @@
use crate::ui_text::markup::ColorRun;
/// Slack on the wrap width: `max_width` can arrive a few ulps under the content it was sized to,
/// which must not force-break; far below a glyph step (≥ 3 px), far above that dust (≤ 1e-3).
const WIDTH_EPS: f32 = 0.25;
/// A hyperlink handle, shared by every run or piece the link's visible text splits into.
type Link = Option<std::sync::Arc<crate::ui_text::markup::LinkInfo>>;
@@ -103,76 +99,112 @@ pub(super) fn tokenize_words(line: &[ColorRun]) -> Vec<WrapWord> {
}
words
}
/// The greedy packer: fill each line left to right, breaking before the first word that would pass
/// `max_width`; `measure` also prices each separator. Assumes `words` is non-empty.
/// The fit test's bound in device px: the row's width rounded up to a whole pixel, as the line
/// builder takes it (`0x5c752a`–`0x5c7530`, a ceil); a row priced within it fits.
pub(super) fn row_limit(max_width: f32, dpi: f32) -> f32 {
(max_width * dpi).ceil()
}
/// The greedy packer: fill each row left to right, breaking before the first word that would pass
/// `limit`. Assumes `words` is non-empty.
///
/// A word too wide alone force-breaks at its last fitting glyph, as the reference drops the
/// exceeding glyph and ends a line with no opportunity (`0x5c7780`; `0x5c7623` `fcomp`, `0x5c762b`
/// `je`); when not one glyph fits, its builder bails and the rest of the source line drops.
pub(super) fn greedy_pack<F: FnMut(&str) -> f32>(
/// `measure(row, next)` prices a candidate row whole, `next` the source character after it, so a
/// kern across a separator and the last glyph's look-past (`0x5c6b70`) both count. A word too wide
/// alone force-breaks at its last fitting glyph, as the reference drops the exceeding glyph and
/// ends a row with no opportunity (`0x5c7623` `fcomp`, `0x5c762b` `je`); when not one glyph fits,
/// its builder bails and the rest of the source line drops. The line's `trailing` whitespace joins
/// the last row when all of it fits, each character priced with the one after it; when any does
/// not, the row ends before the first, the only break among them (`0x5c7780` finds none after
/// whitespace), and all of it drops, as a new row skips leading whitespace (`0x5c7708`–`0x5c7734`).
/// Each row comes with the source character after it, `None` at the line's end.
pub(super) fn greedy_pack<F: FnMut(&str, Option<char>) -> f32>(
words: Vec<WrapWord>,
max_width: f32,
limit: f32,
trailing: &str,
mut measure: F,
) -> Vec<Vec<ColorRun>> {
// One slack covers the pack test and the force-break walk.
let max_width = max_width + WIDTH_EPS;
let mut out: Vec<Vec<WrapWord>> = Vec::new();
) -> Vec<(Vec<ColorRun>, Option<char>)> {
let leads: Vec<Option<char>> = words.iter().map(|w| w.lead.chars().next()).collect();
let n = words.len();
let mut out: Vec<(Vec<ColorRun>, Option<char>)> = Vec::new();
let mut cur: Vec<WrapWord> = Vec::new();
let mut cur_w = 0.0f32;
for word in words {
let ww = measure(&word.text());
let mut cur_text = String::new();
for (i, word) in words.into_iter().enumerate() {
let after = if i + 1 < n {
leads[i + 1]
} else {
trailing.chars().next()
};
let mut text = word.text();
if !cur.is_empty() {
let candidate = cur_w + measure(&word.lead) + ww;
if candidate <= max_width {
cur_w = candidate;
let candidate = format!("{cur_text}{}{text}", word.lead);
if measure(&candidate, after) <= limit {
cur_text = candidate;
cur.push(word);
continue;
}
// Break before this word, the last opportunity.
out.push(std::mem::take(&mut cur));
out.push((words_to_runs(&std::mem::take(&mut cur)), leads[i]));
}
// A word too wide alone force-breaks into full-line chunks; the remainder keeps packing.
// A word too wide alone force-breaks into full-row chunks; the remainder keeps packing.
let mut word = word;
let mut ww = ww;
while ww > max_width {
let text = word.text();
let Some(at) = split_at_last_fitting_glyph(&text, max_width, &mut measure) else {
while measure(&text, after) > limit {
let Some(at) = split_at_last_fitting_glyph(&text, limit, after, &mut measure) else {
// Not one glyph fits: bail, dropping the rest of the line, as the reference does.
return finish_pack(out, cur);
return out;
};
let (head, rest) = split_pieces(&word.pieces, at);
out.push(vec![WrapWord {
let head = WrapWord {
pieces: head,
lead: std::mem::take(&mut word.lead),
}]);
};
out.push((words_to_runs(&[head]), text[at..].chars().next()));
word.pieces = rest;
ww = measure(&word.text());
text = word.text();
}
cur_w = ww;
cur_text = text;
cur.push(word);
}
finish_pack(out, cur)
}
/// Close the packer: flush the open line and rejoin every line's words into runs.
fn finish_pack(mut out: Vec<Vec<WrapWord>>, cur: Vec<WrapWord>) -> Vec<Vec<ColorRun>> {
if !cur.is_empty() {
out.push(cur);
let mut row = words_to_runs(&cur);
let tail: Vec<char> = trailing.chars().collect();
let mut kept = String::new();
let mut next = None;
for (k, &c) in tail.iter().enumerate() {
let candidate = format!("{cur_text}{kept}{c}");
if measure(&candidate, tail.get(k + 1).copied()) <= limit {
kept.push(c);
} else {
// The only break among them is before the first (`0x5c7780` finds none after
// whitespace, `0x5c77c2`–`0x5c77cd`): the row ends there and all of them drop.
kept.clear();
next = tail.first().copied();
break;
}
}
out.iter().map(|ws| words_to_runs(ws)).collect()
if !kept.is_empty() {
let color = row.last().map_or([1.0; 4], |run| run.color);
row.push(ColorRun {
text: kept,
color,
link: None,
});
}
out.push((row, next));
out
}
/// The force-break point past the longest glyph prefix within `max_width`, `None` when not even
/// one glyph fits; re-measuring each prefix agrees with a step sum, as the law is additive.
fn split_at_last_fitting_glyph<F: FnMut(&str) -> f32>(
/// The force-break point past the longest glyph prefix within `limit`, `None` when not even one
/// glyph fits; each prefix is priced with the character after it, `after` past the word's end.
fn split_at_last_fitting_glyph<F: FnMut(&str, Option<char>) -> f32>(
text: &str,
max_width: f32,
limit: f32,
after: Option<char>,
measure: &mut F,
) -> Option<usize> {
let mut fit_end = 0usize;
for (i, c) in text.char_indices() {
let end = i + c.len_utf8();
if measure(&text[..end]) > max_width {
let next = text[end..].chars().next().or(after);
if measure(&text[..end], next) > limit {
break;
}
fit_end = end;
@@ -210,7 +242,7 @@ mod wrap_tests {
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
/// A stub measure: every char, space included, is one unit wide.
fn char_measure(s: &str) -> f32 {
fn char_measure(s: &str, _next: Option<char>) -> f32 {
s.chars().count() as f32
}
@@ -232,6 +264,18 @@ mod wrap_tests {
}
/// A wrapped line's text, colours dropped.
/// [`greedy_pack`] with no trailing whitespace, rows only.
fn pack<F: FnMut(&str, Option<char>) -> f32>(
words: Vec<WrapWord>,
limit: f32,
measure: F,
) -> Vec<Vec<ColorRun>> {
greedy_pack(words, limit, "", measure)
.into_iter()
.map(|(row, _)| row)
.collect()
}
fn line_text(runs: &[ColorRun]) -> String {
runs.iter().map(|r| r.text.as_str()).collect()
}
@@ -240,7 +284,7 @@ mod wrap_tests {
fn long_line_breaks_at_word_boundaries_within_width() {
// Width 12: "Refreshing" (10) plus " Spring" (7) passes it; "Spring Water" is exactly 12.
let w = words(&[("Refreshing", WHITE), ("Spring", WHITE), ("Water", WHITE)]);
let lines = greedy_pack(w, 12.0, char_measure);
let lines = pack(w, 12.0, char_measure);
assert_eq!(lines.len(), 2, "wraps to two lines");
assert_eq!(line_text(&lines[0]), "Refreshing");
assert_eq!(line_text(&lines[1]), "Spring Water");
@@ -252,7 +296,7 @@ mod wrap_tests {
#[test]
fn short_line_stays_single() {
let w = words(&[("Buy", WHITE), ("now", WHITE)]);
let lines = greedy_pack(w, 100.0, char_measure);
let lines = pack(w, 100.0, char_measure);
assert_eq!(lines.len(), 1);
assert_eq!(line_text(&lines[0]), "Buy now");
}
@@ -260,7 +304,7 @@ mod wrap_tests {
#[test]
fn overlong_word_force_breaks_at_the_last_fitting_glyph() {
let w = words(&[("Supercalifragilistic", WHITE), ("ok", WHITE)]);
let lines = greedy_pack(w, 8.0, char_measure);
let lines = pack(w, 8.0, char_measure);
assert_eq!(lines.len(), 3);
assert_eq!(line_text(&lines[0]), "Supercal");
assert_eq!(line_text(&lines[1]), "ifragili");
@@ -277,34 +321,102 @@ mod wrap_tests {
#[test]
fn force_break_mid_line_starts_from_the_break_opportunity() {
let w = words(&[("at", WHITE), ("Supercalifragilistic", WHITE)]);
let lines = greedy_pack(w, 8.0, char_measure);
let lines = pack(w, 8.0, char_measure);
assert_eq!(line_text(&lines[0]), "at");
assert_eq!(line_text(&lines[1]), "Supercal");
assert_eq!(line_text(&lines[2]), "ifragili");
assert_eq!(line_text(&lines[3]), "stic");
}
/// A box a few ulps under its content does not force-break; one a glyph too narrow does.
/// The bound is the width rounded up to a whole device pixel: a box a few ulps under its
/// content still fits it, and a half pixel of room counts as a whole one.
#[test]
fn content_exact_width_with_float_dust_does_not_break() {
// One "word" of two 5.5-unit glyphs (11.0 total) in a box 15 ulps shy of 11.0.
let measure = |s: &str| s.chars().count() as f32 * 5.5;
let w = vec![word("34", WHITE, "")];
let lines = greedy_pack(w, 11.0 - 0.000015, measure);
assert_eq!(lines.len(), 1, "float dust must not split the digits");
assert_eq!(line_text(&lines[0]), "34");
fn the_bound_rounds_the_width_up_to_a_device_pixel() {
assert_eq!(row_limit(11.0 - 0.000015, 1.0), 11.0);
assert_eq!(row_limit(10.5, 1.0), 11.0);
assert_eq!(row_limit(10.25, 2.0), 21.0);
// A real overflow still breaks.
let measure = |s: &str, _: Option<char>| s.chars().count() as f32 * 6.0;
assert_eq!(pack(vec![word("34", WHITE, "")], 11.0, measure).len(), 2);
}
// One glyph short.
let w2 = vec![word("34", WHITE, "")];
let lines2 = greedy_pack(w2, 5.5, measure);
assert_eq!(lines2.len(), 2, "a real overflow still breaks");
/// A stub where a glyph with nothing after it is half a unit narrower, as ink often is.
fn ink_measure(s: &str, next: Option<char>) -> f32 {
s.chars().count() as f32 - if next.is_none() { 0.5 } else { 0.0 }
}
/// The source line's last word fits by its last glyph's ink (`0x5c6b70` sees nothing after it)…
#[test]
fn the_source_lines_last_glyph_is_priced_by_its_ink() {
let w = words(&[("ab", WHITE), ("cd", WHITE)]);
assert_eq!(
pack(w, 4.5, ink_measure).len(),
1,
"\"ab cd\" is 4.5 by its ink"
);
// Whitespace after the last word is characters after it: the word is priced whole.
let w = words(&[("ab", WHITE), ("cd", WHITE)]);
let rows = greedy_pack(w, 4.5, " ", ink_measure);
let texts: Vec<String> = rows.iter().map(|(r, _)| line_text(r)).collect();
assert_eq!(
texts,
["ab", "cd "],
"the space fits after \"cd\" and rides its row"
);
assert_eq!(
rows[0].1,
Some(' '),
"a broken row looks past itself to the separator"
);
assert_eq!(rows[1].1, None);
}
/// …and a word with text after it by every step, even when it ends up last on its row.
#[test]
fn a_word_before_more_text_is_priced_whole() {
let w = words(&[("ab", WHITE), ("cd", WHITE), ("ef", WHITE)]);
let texts: Vec<String> = pack(w, 4.6, ink_measure)
.iter()
.map(|l| line_text(l))
.collect();
assert_eq!(texts, ["ab", "cd ef"]);
// A force-break prices each prefix with the character after it.
assert_eq!(
pack(vec![word("abcdef", WHITE, "")], 5.5, ink_measure).len(),
1
);
let rows = greedy_pack(vec![word("abcdef", WHITE, "")], 5.5, " ", ink_measure);
let texts: Vec<String> = rows.iter().map(|(r, _)| line_text(r)).collect();
assert_eq!(texts, ["abcde", "f "]);
assert_eq!(rows[0].1, Some('f'));
}
/// Trailing whitespace that does not fit ends the row before its first character and drops
/// whole (`0x5c7780`, `0x5c7708`).
#[test]
fn trailing_whitespace_that_does_not_fit_drops() {
let rows = greedy_pack(vec![word("ab", WHITE, "")], 2.0, " ", char_measure);
assert_eq!(rows.len(), 1);
assert_eq!(line_text(&rows[0].0), "ab");
assert_eq!(rows[0].1, Some(' '));
// One space fits and the second does not: the break is before the first, so both drop.
let rows = greedy_pack(vec![word("ab", WHITE, "")], 3.0, " ", char_measure);
assert_eq!(line_text(&rows[0].0), "ab");
assert_eq!(rows[0].1, Some(' '));
let rows = greedy_pack(vec![word("ab", WHITE, "")], 4.0, " ", char_measure);
assert_eq!(
line_text(&rows[0].0),
"ab ",
"all of it fits and rides the row"
);
assert_eq!(rows[0].1, None);
}
#[test]
fn sub_glyph_width_bails_without_progress() {
// Not one glyph fits: the builder bails and drops the line, as the reference's does.
let w = words(&[("ab", WHITE)]);
let lines = greedy_pack(w, 0.5, char_measure);
let lines = pack(w, 0.5, char_measure);
assert!(lines.is_empty());
}
@@ -329,13 +441,13 @@ mod wrap_tests {
#[test]
fn wrap_preserves_double_space_between_words() {
let w = vec![word("Hello.", WHITE, ""), word("World", WHITE, " ")];
let lines = greedy_pack(w, 100.0, char_measure);
let lines = pack(w, 100.0, char_measure);
assert_eq!(lines.len(), 1);
assert_eq!(line_text(&lines[0]), "Hello. World");
// Width 6 fits "Hello." alone; the separator at the break drops.
let w2 = vec![word("Hello.", WHITE, ""), word("World", WHITE, " ")];
let lines2 = greedy_pack(w2, 6.0, char_measure);
let lines2 = pack(w2, 6.0, char_measure);
assert_eq!(lines2.len(), 2);
assert_eq!(line_text(&lines2[0]), "Hello.");
assert_eq!(line_text(&lines2[1]), "World");
@@ -345,7 +457,7 @@ mod wrap_tests {
fn color_runs_survive_the_wrap() {
// Same-colour words merge, a new colour starts a run, and the space joins the run before.
let w = words(&[("aa", WHITE), ("bb", WHITE), ("cc", RED)]);
let lines = greedy_pack(w, 100.0, char_measure);
let lines = pack(w, 100.0, char_measure);
assert_eq!(lines.len(), 1);
let runs = &lines[0];
assert_eq!(runs.len(), 2);
@@ -381,7 +493,7 @@ mod wrap_tests {
assert_eq!(ws[2].text(), "Bearer]dsfsdfsd");
assert_eq!(ws[2].pieces.len(), 2);
let lines = greedy_pack(ws, 100.0, char_measure);
let lines = pack(ws, 100.0, char_measure);
assert_eq!(lines.len(), 1);
let runs = &lines[0];
assert_eq!(runs.len(), 2, "the color boundary survives the round trip");
@@ -409,7 +521,7 @@ mod wrap_tests {
},
];
// Width 4: "[Cla" | "w]x2" | ".", the colour boundary inside the middle chunk.
let lines = greedy_pack(tokenize_words(&line), 4.0, char_measure);
let lines = pack(tokenize_words(&line), 4.0, char_measure);
assert_eq!(line_text(&lines[0]), "[Cla");
assert_eq!(lines[0][0].color, RED);
assert_eq!(line_text(&lines[1]), "w]x2");
@@ -431,7 +543,7 @@ mod wrap_tests {
("three", WHITE),
("four", RED),
]);
let lines = greedy_pack(w, 10.0, char_measure); // "one two"=7 fits; +three (13) overflows
let lines = pack(w, 10.0, char_measure); // "one two"=7 fits; +three (13) overflows
assert_eq!(lines.len(), 2);
assert_eq!(line_text(&lines[0]), "one two");
assert_eq!(line_text(&lines[1]), "three four");
+100 -7
View File
@@ -29,16 +29,18 @@ pub(crate) fn measure_request(
outline: r.outline,
// The gap between wrapped lines, scaled as the height is (`0x5c2070`).
spacing: r.spacing * rs,
// The drawn rows' seed rides the frame scale, as the draw's does (`0x7726a4`).
shadow_x: r.shadow_x * rs,
alpha_gradient: None, // alpha never changes metrics
};
let wrap = r.wrap_width.map(|w| w * rs);
let (w, h) = super::measure_text(e, &r.text, wrap, spec());
// `GetStringWidth` answers the unwrapped width (the extent measure `0x5c6940`), which only a
// wrapped region needs a second pass for.
let natural = if wrap.is_some() {
super::measure_text(e, &r.text, None, spec()).0
} else {
w
// `GetStringWidth` answers the unwrapped width (the extent measure `0x5c6940`), its line seeded
// with the shadow's x offset at the seam alone: `0x772890` leaves `[fs+0x108]` unscaled.
let natural = super::string_width(e, &r.text, spec(), r.shadow_x * seam);
// Unwrapped, the laid-out width is that same measure (`[fs+0xfc]`).
let (w, h) = match wrap {
Some(_) => super::measure_text(e, &r.text, wrap, spec()),
None => (natural, super::unwrapped_height(e, &r.text, spec())),
};
(w / rs, h / rs, natural / rs)
}
@@ -58,6 +60,8 @@ pub(crate) fn editbox_advances(
outline: req.outline,
// A multiline box's row pitch adds the gap (`0x77d161`).
spacing: req.spacing * seam * req.scale,
// The draw's row seed, at the box's drawn scale.
shadow_x: req.shadow_x * seam * req.scale,
alpha_gradient: None, // alpha never changes metrics
};
let cum = super::line_advances(e, &req.text, spec)
@@ -199,4 +203,93 @@ mod tests {
);
assert!((height(-5.0) - flush).abs() < 1e-4, "a negative gap is 0");
}
/// `GetStringWidth` (`0x79e510` → `0x772890`) answers the measure kernel's width divided by the
/// drawn scale, as it stands, fractions and all: no `ceil` and no added pixel. A frame sized
/// from it, as `MoneyFrame.lua:202-208` sizes each coin (`SetWidth(GetTextWidth() + 13)`), gets
/// exactly that plus its icon. The shadow's x offset seeds the line (`0x5c69a4`), rounded to
/// device px and not scaled with the frame: `0x772890` scales the height by `[fs+0x7c]` but
/// hands the offset `[fs+0x108]` over as it is.
#[test]
fn get_string_width_answers_the_reference_measure() {
benilla_formats::wow_data_or_skip!();
// The reference's widths in device px for FRIZQT__.TTF, computed outside this code base off
// the font's tables (as `engine::line_law_tests`), at 12 px and 17 px, and "1234" at
// 24 px and 34 px for scale 2.
let golden = |ppem: f32, text: &str| -> f32 {
match (ppem.round() as u16, text) {
(12, "1") => 7.0,
(12, "27") => 16.0,
(12, "1234") => 32.0,
(12, "Gold") => 31.0,
(12, "Ironforge") => 59.0,
(17, "1") => 9.0,
(17, "27") => 21.0,
(17, "1234") => 44.0,
(17, "Gold") => 41.0,
(17, "Ironforge") => 79.0,
(24, "1234") => 62.0,
(34, "1234") => 86.0,
other => panic!("no golden for {other:?}"),
}
};
let law = golden;
// A 768-tall window, then a 1080-tall one, where a unit is 1.40625 px.
for seam in [1.0f32, 1080.0 / 768.0] {
let engine = super::super::engine::test_engine(1.0).expect("the client faces open");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(AtlasMeasurer::new(
Arc::new(Mutex::new(engine)),
seam,
)));
s.run(
r#"
F = CreateFrame("Frame", "F")
F:SetPoint("TOPLEFT", 100, -100); F:SetHeight(20)
FS = F:CreateFontString("FS")
FS:SetFont("Fonts\\FRIZQT__.TTF", 12)
FS:SetPoint("LEFT", F, "LEFT")
"#,
)
.unwrap();
s.resolve();
for text in ["1", "27", "1234", "Gold", "Ironforge"] {
s.run(&format!(
"FS:SetText({text:?}); F:SetWidth(FS:GetStringWidth() + 13)"
))
.unwrap();
s.resolve();
let w = s.eval::<f32>("return FS:GetStringWidth()").unwrap();
let want = law(12.0 * seam, text) / seam;
assert_eq!(w, want, "seam {seam}, {text:?}: GetStringWidth");
let framed = s.eval::<f32>("return F:GetWidth()").unwrap();
assert!(
(framed - (want + 13.0)).abs() < 1e-4,
"seam {seam}, {text:?}: the coin frame is {framed}, not {want} + 13"
);
}
// A whole-pixel shadow to the right adds its pixels; one to the left, or under half a
// pixel, adds none.
for (dx, pad) in [(1.0f32, 1.0f32), (2.0, 2.0), (0.3, 0.0), (-1.0, 0.0)] {
s.run(&format!(
"FS:SetShadowOffset({dx}, -1); FS:SetText(\"1234\")"
))
.unwrap();
let w = s.eval::<f32>("return FS:GetStringWidth()").unwrap();
let pad = if pad > 0.0 { (dx * seam).round() } else { 0.0 };
let want = (law(12.0 * seam, "1234") + pad) / seam;
assert_eq!(w, want, "seam {seam}, shadow x {dx}");
}
// At scale 2 the glyphs measure twice the size, but the shadow keeps its pixel.
s.run("FS:SetShadowOffset(1, -1); F:SetScale(2); FS:SetText(\"1234\")")
.unwrap();
s.resolve();
let w = s.eval::<f32>("return FS:GetStringWidth()").unwrap();
let want = (law(24.0 * seam, "1234") + seam.round()) / (2.0 * seam);
assert_eq!(w, want, "seam {seam}, scale 2");
}
}
}
+14 -10
View File
@@ -2,16 +2,18 @@
//! shaped through `cosmic-text` 0.16 and emitted as [`crate::ui_pass::UiQuad`]s.
//!
//! Glyphs are shaped one character at a time ([`engine::TextEngine::ensure_char`]) and the pen
//! steps by the reference's law: `floor(advance) + 1` px per glyph (`(FT_advance>>6)+1.0` at
//! `0x5d1120`), plus 1 more under a THICK outline (`0x5ca2b0`). The four client faces have no
//! ligature or contextual substitution, which `engine::differential_tests` asserts.
//! steps by the reference's law: the advance in whole px plus 1 (`(FT_advance>>6)+1.0` at
//! `0x5d1120`), plus 1 more under a THICK outline (`0x5ca2b0`), and the pair's negative kern, the
//! sum rounded up (`ComputeStep`, `0x5ca2d0`). Advances and kerns come off the face's own tables
//! with FreeType's fixed point (`engine::sfnt`), not the shaper's floats. The four client faces
//! have no ligature or contextual substitution, which `engine::differential_tests` asserts. A width
//! is then a pure function of its characters, so one table answers a measure and a draw, even
//! inside a Lua call.
//!
//! Kerning is dropped, where the reference applies negative pair kerns only, rounded up
//! (`ComputeStep`, `0x5ca2d0`), about 0 px at UI sizes. A width is then a pure function of its
//! characters, so one table answers a measure and a draw, even inside a Lua call.
//!
//! Glyph bitmaps are swash's unhinted coverage, where the reference runs FreeType with hinting
//! (load flags `0x208a`/`0x20c2`), so stems are slightly slimmer at small sizes.
//! The reference loads every glyph unhinted: both its load-flag words (`0x208a`/`0x20c2`, at
//! `0x5d1324`) carry FreeType's `FT_LOAD_NO_HINTING` (`0x2`), as `FT_Load_Glyph` (`0x7cdb40`) reads
//! it. The measure reads the unhinted outline (`engine::glyph_ink`); the cells are rasterized
//! under `cosmic-text`'s default cache key, which hints.
mod engine;
mod layout;
@@ -28,7 +30,8 @@ pub(crate) use measurer::{editbox_advances, measure_request, AtlasMeasurer};
pub(crate) use layout::{
ellipsize_to_fit, layout_text_quads, layout_text_quads_links, line_advances, line_origin,
line_rows, measure_text, measure_wrapped_rows, FontSpec, Justify, TextSeat, UI_SEAT_NUDGE,
line_rows, measure_text, measure_wrapped_rows, string_width, unwrapped_height, FontSpec,
Justify, TextSeat, UI_SEAT_NUDGE,
};
/// The text size in logical px of a FontString with no font object: `GameFontNormal`'s 12.
@@ -184,6 +187,7 @@ mod ellipsis_memo_tests {
height,
outline,
spacing: 0.0,
shadow_x: 0.0,
alpha_gradient: None,
}
}
@@ -193,6 +193,7 @@ fn line_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option<f32> {
outline: d.outline,
// One line has no gap: `0x5c2070` charges `(N−1)·gap`.
spacing: d.line_gap(),
shadow_x: d.shadow_x(),
scale,
text: ONE_LINE.to_string(),
key,
@@ -167,6 +167,7 @@ fn stale_advances(
let outline = d.map(|d| d.outline).unwrap_or_default();
// A multi-line box steps its rows by the font height plus the gap (`0x77d133`-`0x77d161`).
let spacing = d.map_or(0.0, super::super::RegionData::line_gap);
let shadow_x = d.map_or(0.0, |d| d.shadow_x());
// The host measures at the drawn raster size, so the advances land on the drawn glyphs.
let scale = model
.arena
@@ -190,6 +191,7 @@ fn stale_advances(
height.map(f32::to_bits).hash(&mut hasher);
(outline as u8).hash(&mut hasher);
spacing.to_bits().hash(&mut hasher);
shadow_x.to_bits().hash(&mut hasher);
wrap_width.map(f32::to_bits).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
@@ -211,6 +213,7 @@ fn stale_advances(
height,
outline,
spacing,
shadow_x,
scale,
text: display,
wrap_width,
@@ -398,6 +398,8 @@ pub(super) fn install(
color,
});
d.font_explicit.shadow = true;
// The offset seeds `GetStringWidth`'s line, so it is in the measure key.
model.touch_measure(rh);
Ok(())
})?,
)?;
+2
View File
@@ -1910,6 +1910,7 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
let text_height = d.text_height;
let outline = d.outline;
let spacing = d.line_gap();
let shadow_x = d.shadow_x();
let id = model.region_id(rh);
Some(MeasureRequest {
id,
@@ -1919,6 +1920,7 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
wrap_width,
outline,
spacing,
shadow_x,
scale,
text,
key,
@@ -335,6 +335,10 @@ impl UiScript {
font.height.map(f32::to_bits).hash(&mut h);
wrap_width.to_bits().hash(&mut h);
(font.outline as u8).hash(&mut h);
font.shadow
.map_or(0.0, |s| s.offset[0])
.to_bits()
.hash(&mut h);
scale.to_bits().hash(&mut h);
h.finish()
};
@@ -350,6 +354,10 @@ impl UiScript {
font.height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
font.shadow
.map_or(0.0, |s| s.offset[0])
.to_bits()
.hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
@@ -362,6 +370,7 @@ impl UiScript {
height: font.height,
wrap_width,
outline: font.outline,
shadow_x: font.shadow.map_or(0.0, |s| s.offset[0]),
scale,
text: line.text.clone(),
key,
+17 -4
View File
@@ -474,9 +474,14 @@ impl RegionData {
self.spacing.max(0.0)
}
/// The measure-cache key of the text, font, wrap width, outline, line gap and owner scale. A Lua metric
/// read treats a measure under another key as absent; the layout keeps the old box until the
/// new one lands, except for empty text, which is never measured.
/// The shadow's x offset in UI units, 0 for none ([`MeasureRequest::shadow_x`]).
pub(crate) fn shadow_x(&self) -> f32 {
self.font_shadow.map_or(0.0, |s| s.offset[0])
}
/// The measure-cache key of the text, font, wrap width, outline, line gap, shadow and owner
/// scale. A Lua metric read treats a measure under another key as absent; the layout keeps the
/// old box until the new one lands, except for empty text, which is never measured.
pub(crate) fn measure_key(&self, scale: f32) -> u64 {
// By reference: `String` hashes as `str` does, and this runs for every FontString every
// frame, so a clone would allocate for nothing.
@@ -494,9 +499,10 @@ impl RegionData {
let wrap_width = self.size.map(|s| s.0).filter(|w| *w > 0.0);
wrap_width.map(f32::to_bits).hash(&mut hasher);
// The outline changes the measure: a THICK one steps each glyph 1 px further
// (`GlyphStepBase 0x5ca2b0`).
// (`GlyphStepBase 0x5ca2b0`); so does the shadow's x offset, which seeds the line.
(self.outline as u8).hash(&mut hasher);
self.line_gap().to_bits().hash(&mut hasher);
self.shadow_x().to_bits().hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
hasher.finish()
}
@@ -517,6 +523,8 @@ pub struct EditBoxAdvanceRequest {
/// The gap between a multiline box's rows, in frame-local units: the row pitch is the height
/// plus it (`0x77d133`-`0x77d161`).
pub spacing: f32,
/// The shadow's x offset in UI units, 0 for none: it seeds each drawn row's pen.
pub shadow_x: f32,
/// The box's `effective_scale`. The host measures at `height × scale × seam` but divides by
/// the seam alone, so advances come back in screen UI units, like the mouse feed and the
/// box's rect; a [`MeasureRequest`] answer is frame-local instead.
@@ -573,6 +581,9 @@ pub struct MeasureRequest {
/// The gap between wrapped lines in frame-local units ([`RegionData::line_gap`]), scaled like
/// the height: the measure is `N·S + (N−1)·gap` (`0x5c2070`).
pub spacing: f32,
/// The shadow's x offset in UI units, 0 for none: `GetStringWidth` seeds its line with it,
/// unscaled by the frame (`0x772890` hands `[fs+0x108]` to the kernel as it is).
pub shadow_x: f32,
/// The owner's `effective_scale`. The host measures at `font_height × scale × seam` and
/// divides by that whole product: advances step to whole pixels, so a measure at the
/// unscaled size is not proportional to the drawn one.
@@ -596,6 +607,8 @@ pub struct LineMeasureRequest {
/// The frame's inner width: already scale-multiplied, unlike `height`, the font's own size.
pub wrap_width: f32,
pub outline: Outline,
/// The font's shadow x offset in UI units, 0 for none: it seeds each drawn row's pen.
pub shadow_x: f32,
/// As for [`MeasureRequest::scale`].
pub scale: f32,
pub text: String,