From 108c354fc6369683ff36f1645df5b6b3b0c42352 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:27:55 +0700 Subject: [PATCH] Text measures by the reference kernel: kerned steps, ink tail, no pad (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-app/src/combat_text/mod.rs | 1 + crates/benilla-app/src/nameplates.rs | 1 + crates/benilla-app/src/perf/hud.rs | 1 + .../benilla-app/src/ui_script/extract/mod.rs | 2 + .../benilla-app/src/ui_script/extract/text.rs | 4 + crates/benilla-app/src/ui_text/engine/mod.rs | 341 ++++++++++++----- crates/benilla-app/src/ui_text/engine/sfnt.rs | 355 ++++++++++++++++++ .../benilla-app/src/ui_text/layout/measure.rs | 278 ++++++++++---- crates/benilla-app/src/ui_text/layout/mod.rs | 179 +++++++-- crates/benilla-app/src/ui_text/layout/wrap.rs | 250 ++++++++---- crates/benilla-app/src/ui_text/measurer.rs | 107 +++++- crates/benilla-app/src/ui_text/mod.rs | 24 +- .../benilla-ui/src/script/editbox/relayout.rs | 1 + crates/benilla-ui/src/script/editbox/seam.rs | 3 + crates/benilla-ui/src/script/font_block.rs | 2 + crates/benilla-ui/src/script/layout.rs | 2 + .../benilla-ui/src/script/messageframe/mod.rs | 9 + crates/benilla-ui/src/script/types.rs | 21 +- 18 files changed, 1281 insertions(+), 300 deletions(-) create mode 100644 crates/benilla-app/src/ui_text/engine/sfnt.rs diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index b9ecef4c..38d3868a 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -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. diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 0bcc2c43..6214c3c0 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -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. diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index 653fb4c2..b2f600ff 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -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. diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 4bc1dd5e..7d77c7f3 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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, }, ); diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 88b331c1..bfa4c2a6 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -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 diff --git a/crates/benilla-app/src/ui_text/engine/mod.rs b/crates/benilla-app/src/ui_text/engine/mod.rs index b3bad4cc..b2ba9b29 100644 --- a/crates/benilla-app/src/ui_text/engine/mod.rs +++ b/crates/benilla-app/src/ui_text/engine/mod.rs @@ -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>, } /// `(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, - /// Σ `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, 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 = 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 { 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 { 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 = 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::() - / 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, f32) { + /// The whole string shaped in one buffer: its glyph ids. + fn shape_whole(e: &mut TextEngine, family: &str, ppem: u16, text: &str) -> Vec { 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" diff --git a/crates/benilla-app/src/ui_text/engine/sfnt.rs b/crates/benilla-app/src/ui_text/engine/sfnt.rs new file mode 100644 index 00000000..4c0f9fed --- /dev/null +++ b/crates/benilla-app/src/ui_text/engine/sfnt.rs @@ -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, + 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 { + Some(u16::from_be_bytes(d.get(at..at + 2)?.try_into().ok()?)) +} + +fn be16s(d: &[u8], at: usize) -> Option { + Some(i16::from_be_bytes(d.get(at..at + 2)?.try_into().ok()?)) +} + +fn be32(d: &[u8], at: usize) -> Option { + 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) -> Option { + 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 { + 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 { + 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 = pts.iter().map(|&(x, _)| x - pp1).collect(); + let ys_px: Vec = 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> { + 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> { + 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); + } +} diff --git a/crates/benilla-app/src/ui_text/layout/measure.rs b/crates/benilla-app/src/ui_text/layout/measure.rs index de65728b..6d6a74ed 100644 --- a/crates/benilla-app/src/ui_text/layout/measure.rs +++ b/crates/benilla-app/src/ui_text/layout/measure.rs @@ -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 { + 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, + next: Option, +) -> (f32, Option, Option) { + let mut glyphs: Vec = Vec::new(); + // A glyphless character after the last glyph: a code the look-ahead steps to. + let mut code_after: Option> = 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 = 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<(Vec, Option)> { 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 = 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) -> 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], + max_width: f32, +) -> Vec<(Vec, Option)> { + 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> = 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::>()) + .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 diff --git a/crates/benilla-app/src/ui_text/layout/mod.rs b/crates/benilla-app/src/ui_text/layout/mod.rs index 98f40dd2..00467f46 100644 --- a/crates/benilla-app/src/ui_text/layout/mod.rs +++ b/crates/benilla-app/src/ui_text/layout/mod.rs @@ -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> = 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, Option)> = 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 = 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, 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 = 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::>() + }; + 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); diff --git a/crates/benilla-app/src/ui_text/layout/wrap.rs b/crates/benilla-app/src/ui_text/layout/wrap.rs index aeb4fd49..dd45e543 100644 --- a/crates/benilla-app/src/ui_text/layout/wrap.rs +++ b/crates/benilla-app/src/ui_text/layout/wrap.rs @@ -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>; @@ -103,76 +99,112 @@ pub(super) fn tokenize_words(line: &[ColorRun]) -> Vec { } 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 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) -> f32>( words: Vec, - max_width: f32, + limit: f32, + trailing: &str, mut measure: F, -) -> Vec> { - // One slack covers the pack test and the force-break walk. - let max_width = max_width + WIDTH_EPS; - let mut out: Vec> = Vec::new(); +) -> Vec<(Vec, Option)> { + let leads: Vec> = words.iter().map(|w| w.lead.chars().next()).collect(); + let n = words.len(); + let mut out: Vec<(Vec, Option)> = Vec::new(); let mut cur: Vec = 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>, cur: Vec) -> Vec> { - if !cur.is_empty() { - out.push(cur); + let mut row = words_to_runs(&cur); + let tail: Vec = 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 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) -> f32>( text: &str, - max_width: f32, + limit: f32, + after: Option, measure: &mut F, ) -> Option { 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) -> 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) -> f32>( + words: Vec, + limit: f32, + measure: F, + ) -> Vec> { + 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| 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) -> 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 = 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 = 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 = 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"); diff --git a/crates/benilla-app/src/ui_text/measurer.rs b/crates/benilla-app/src/ui_text/measurer.rs index 0dc22078..0d68dced 100644 --- a/crates/benilla-app/src/ui_text/measurer.rs +++ b/crates/benilla-app/src/ui_text/measurer.rs @@ -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::("return FS:GetStringWidth()").unwrap(); + let want = law(12.0 * seam, text) / seam; + assert_eq!(w, want, "seam {seam}, {text:?}: GetStringWidth"); + let framed = s.eval::("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::("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::("return FS:GetStringWidth()").unwrap(); + let want = (law(24.0 * seam, "1234") + seam.round()) / (2.0 * seam); + assert_eq!(w, want, "seam {seam}, scale 2"); + } + } } diff --git a/crates/benilla-app/src/ui_text/mod.rs b/crates/benilla-app/src/ui_text/mod.rs index 3d31b78a..7bff729c 100644 --- a/crates/benilla-app/src/ui_text/mod.rs +++ b/crates/benilla-app/src/ui_text/mod.rs @@ -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, } } diff --git a/crates/benilla-ui/src/script/editbox/relayout.rs b/crates/benilla-ui/src/script/editbox/relayout.rs index 09e6e02a..2470b82c 100644 --- a/crates/benilla-ui/src/script/editbox/relayout.rs +++ b/crates/benilla-ui/src/script/editbox/relayout.rs @@ -193,6 +193,7 @@ fn line_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option { 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, diff --git a/crates/benilla-ui/src/script/editbox/seam.rs b/crates/benilla-ui/src/script/editbox/seam.rs index 7348da0d..fba806c3 100644 --- a/crates/benilla-ui/src/script/editbox/seam.rs +++ b/crates/benilla-ui/src/script/editbox/seam.rs @@ -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, diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs index ef345c68..b08e83cd 100644 --- a/crates/benilla-ui/src/script/font_block.rs +++ b/crates/benilla-ui/src/script/font_block.rs @@ -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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 688acc92..9684b3fc 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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, diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 0224fcbe..8c1856a7 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 18bc7574..1cec6082 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -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,