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:
co-authored by
Claude Opus 5.5
parent
5af22799d0
commit
108c354fc6
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user