Glyph cells clip to the em and keep 4-bit alpha, as in 1.12.1 (#1829)

Two glyph-cell differences from 1.12.1.

Rows: the client keeps at most the font's pixel em of a glyph's rows. `0x5d1120` stores `min(em, rows)` (`0x5d1195`-`0x5d11a2`), and every bake reads that many rows from the top. benilla packed the full raster, so unhinted glyphs one row taller than the em (FRIZQT `ý þ` at 14 px, Arial Narrow `Ò Ó Ù Ü` at 14 px) drew a faint extra row. Cells are now clipped to their first `ppem` rows before the ring is built.

Alpha: the client's glyph texels are ARGB4444. The plain bake keeps coverage's top 4 bits (`0x5cf298`-`0x5cf2a1`), and the outlined fill's gray is `(cov·0xff) >> 12` (`0x5cf1b8`). benilla packed 8-bit coverage. Coverage cells and outlined fills now pack 4-bit values, so coverage under 16 draws nothing, as in 1.12.1.

Tests: an over-tall `Ò` keeps its top 14 rows, and every packed alpha is 4-bit exact.

Fixes #1827
Fixes #1828

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-18 02:20:03 +07:00
committed by GitHub
parent f6d8b16219
commit cff056c367
4 changed files with 164 additions and 19 deletions
+96 -6
View File
@@ -554,7 +554,8 @@ impl TextEngine {
/// (`0x5cb080` mode 0, `0x5cd099`), not at the bitmap's `floor(xMin)`; the bakes put the ink
/// `pad` columns in (`0x5cf237` plain, `0x5ce481`/`0x5cea7e` outlined, `0x5ce48a`/`0x5cea87` one more for THICK).
/// Its top sits `pad` rows over FreeType's `bitmap_top`, which the reference's vertical seat
/// (`0x5d1360`) hangs from the ascender, so the ink keeps the bitmap's own row.
/// (`0x5d1360`) hangs from the ascender, so the ink keeps the bitmap's own row. Only the
/// raster's first `ppem` rows are baked ([`em_rows`]), before any ring, and they keep their rows.
fn ensure_cell(&mut self, face_id: fontdb::ID, ppem: u16, bake: Bake, g: &GlyphRef) {
let key: GlyphKey = (face_id, g.glyph_id, ppem, bake);
if self.cells.contains_key(&key) {
@@ -570,15 +571,18 @@ impl TextEngine {
// seat in whole px.
let baked = match &outline {
Some(o) if bake.mono => mono::rasterize(o, ppem).map(|m| {
let (rgba, w, h, pad) = mono_cell(&m.bits, m.w, m.h, bake.radius, self.dpi);
let (bits, rows) = em_rows(m.bits, m.w, m.h, ppem);
let (rgba, w, h, pad) = mono_cell(&bits, m.w, rows, bake.radius, self.dpi);
(OwnedCell::Rgba(rgba), w, h, pad, o.bearing(), m.top)
}),
Some(o) => smooth::rasterize(o).map(|m| {
let (cell, w, h, pad) = self.coverage_cell(m.coverage, m.w, m.h, bake);
let (cov, rows) = em_rows(m.coverage, m.w, m.h, ppem);
let (cell, w, h, pad) = self.coverage_cell(cov, m.w, rows, bake);
(cell, w, h, pad, o.bearing(), m.top)
}),
None => self.shaper_raster(g.key).map(|(cov, p, bearing)| {
let (cell, w, h, pad) = self.coverage_cell(cov, p.width, p.height, bake);
let (cov, rows) = em_rows(cov, p.width, p.height, ppem);
let (cell, w, h, pad) = self.coverage_cell(cov, p.width, rows, bake);
(cell, w, h, pad, Some(bearing), p.top)
}),
};
@@ -869,6 +873,15 @@ fn outline_box(
(x0 <= x1).then(|| (q(x0), q(x1), q(y0), q(y1)))
}
/// A raster's first `em` rows, one byte per texel, and how many that is: the reference keeps
/// `min(em, rows)` of a glyph's rows from the top (`0x5d1195`), `em` being the pixel size its face
/// is set to (`0x5ca1b5`, `[CGxFont+0x24c]`), which here is the ppem the raster was made at.
fn em_rows(mut texels: Vec<u8>, w: u32, h: u32, em: u16) -> (Vec<u8>, u32) {
let rows = h.min(u32::from(em));
texels.truncate((w * rows) as usize);
(texels, rows)
}
/// A baked cell's texels, owned until the sheet copies them ([`Cell`]).
enum OwnedCell {
Coverage(Vec<u8>),
@@ -884,6 +897,23 @@ impl OwnedCell {
}
}
#[cfg(test)]
mod em_rows_tests {
use super::em_rows;
#[test]
fn a_raster_keeps_at_most_its_em_rows_from_the_top() {
let tall: Vec<u8> = (0..12).collect(); // 3 wide, 4 rows
assert_eq!(em_rows(tall.clone(), 3, 4, 3), ((0..9).collect(), 3));
assert_eq!(em_rows(tall.clone(), 3, 4, 4), (tall.clone(), 4));
assert_eq!(
em_rows(tall.clone(), 3, 4, 9),
(tall, 4),
"a short glyph is whole"
);
}
}
#[cfg(test)]
mod line_law_tests {
use super::*;
@@ -1560,7 +1590,8 @@ mod ppem_tests {
/// An antialiased cell is the unhinted raster (`0x5d1300`: both load-flag words carry
/// `FT_LOAD_NO_HINTING`, rendered in mode 0 at `0x7cec20`). Arial Narrow's `e` at 18 px, the
/// chat frame's, as FreeType's smooth rasterizer draws it unhinted, computed outside this code
/// base: the bar spreads over two partial rows, where a hinter snaps it to one full row.
/// base: the bar spreads over two partial rows, where a hinter snaps it to one full row. The
/// golden is the raster's coverage; the cell holds its top 4 bits ([`Cell::Coverage`]).
#[test]
fn an_antialiased_cell_is_the_unhinted_raster() {
let Some(mut e) = engine_or_skip() else {
@@ -1590,7 +1621,66 @@ mod ppem_tests {
.chunks(8)
.map(|row| row.iter().map(|t| format!("{:02x}", t[3])).collect())
.collect();
assert_eq!(got, golden);
let want: Vec<String> = golden
.iter()
.map(|row| {
(0..row.len())
.step_by(2)
.map(|i| u8::from_str_radix(&row[i..i + 2], 16).expect("hex"))
.map(|c| format!("{:02x}", crate::ui_text::pack::widen4(c >> 4)))
.collect()
})
.collect();
assert_eq!(got, want);
}
/// A glyph taller than the em keeps its first `em` rows (`0x5d1195`), whatever the bake, and
/// they stay where they were. Arial Narrow's `Ò` at 14 px, the chat size, is 15 rows unhinted.
#[test]
fn an_over_tall_glyph_keeps_its_top_em_rows() {
let Some(mut e) = engine_or_skip() else {
return;
};
let face = e.face_for(Some("Fonts\\ARIALN.TTF"));
let (info, texels) = cell_texels(&mut e, face, 14, Bake::ring(0), 'Ò');
let gid = e.char_cell(face, 14, 'Ò').expect("shaped").glyphs[0].glyph_id;
let sfnt = e.faces[face].sfnt.clone().expect("Arial Narrow's tables");
let outline = sfnt.outline(gid, 14).expect("outline");
let raster = smooth::rasterize(&outline).expect("ink");
assert_eq!(raster.h, 15, "the unhinted raster is one row over the em");
assert!(
raster.coverage[(14 * raster.w) as usize..]
.iter()
.any(|&c| c >= 16),
"its last row has ink that would draw"
);
assert_eq!((info.px_w, info.px_h), (raster.w as f32, 14.0));
assert_eq!(
info.bearing_top, raster.top as f32,
"the kept rows keep their seat"
);
let top: Vec<u8> = texels.iter().map(|t| t[3]).collect();
let want: Vec<u8> = raster.coverage[..(14 * raster.w) as usize]
.iter()
.map(|&c| crate::ui_text::pack::widen4(c >> 4))
.collect();
assert_eq!(top, want, "the top 14 rows, not the bottom");
for (radius, mono) in [(1u8, false), (2, false), (0, true), (2, true)] {
let bake = Bake { radius, mono };
let (info, _) = cell_texels(&mut e, face, 14, bake, 'Ò');
let grow = 2.0 * f32::from(radius);
assert_eq!(
info.px_h,
14.0 + grow,
"{bake:?}: an outline grows the em, not the raster"
);
assert_eq!(
info.bearing_top,
raster.top as f32 + f32::from(radius),
"{bake:?}"
);
}
}
/// A cell's left edge is the pen plus `ceil(horiBearingX)`, the quad x of `0x5ccbe0` (`0x5cb080`
+35 -6
View File
@@ -6,6 +6,8 @@
use benilla_ui::script::Outline;
use super::pack::widen4;
/// What a glyph's cell is baked from, the font flags `0x5cf310` picks its bake by: the outline's
/// ring reach in logical px (NORMAL 1, THICK 2) and the monochrome flag (bit 1). Part of the cell
/// cache's key.
@@ -39,6 +41,12 @@ impl Bake {
/// texels in the in-bounds 3×3 box, centre included; its 4-bit alphas are widened × 17.
const AA_NEIGHBOUR_LUT: [u8; 10] = [0, 17, 17, 51, 85, 119, 153, 187, 221, 255];
/// A fill texel's gray, the 4-bit `(coverage · 0xff) >> 12` the AA-outline bake packs into each
/// colour channel (`0x5cf1b0`–`0x5cf1dd`), widened.
fn fill_gray(coverage: u8) -> u8 {
widen4(((u32::from(coverage) * 0xff) >> 12) as u8)
}
/// Composite one glyph's coverage into an outlined cell, the reference's AA-outline blit:
///
/// 1. Mark every texel with coverage.
@@ -46,10 +54,10 @@ const AA_NEIGHBOUR_LUT: [u8; 10] = [0, 17, 17, 51, 85, 119, 153, 187, 221, 255];
/// and 2 for THICK.
/// 3. Alpha: the [`AA_NEIGHBOUR_LUT`] entry for the marked count in the texel's in-bounds 3×3
/// box, for ring and fill edge alike.
/// 4. Pack: the coverage as gray, so the pure ring is black and the fill ramps toward it.
/// 4. Pack: the coverage as a 4-bit gray ([`fill_gray`]), so the pure ring is black and the fill
/// ramps toward it.
///
/// Deviation: `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster,
/// and 8-bit texels where the reference packs ARGB4444, so nothing bands.
/// Deviation: `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster.
///
/// It draws as one quad per glyph (`0x5ccbe0`) whose vertex colour tints the fill and leaves the
/// ring black. Returns `(rgba, out_w, out_h, pad)`: the cell grows `pad` texels each side (the
@@ -88,7 +96,7 @@ pub(super) fn outlined_cell(
}
let alpha = AA_NEIGHBOUR_LUT[count];
let fill = if row >= pad && row < pad + h && col >= pad && col < pad + w {
cov[((row - pad) * w + (col - pad)) as usize]
fill_gray(cov[((row - pad) * w + (col - pad)) as usize])
} else {
0
};
@@ -236,15 +244,36 @@ mod outlined_cell_tests {
#[test]
fn aa_fill_ramps_gray_toward_the_ring() {
// Half coverage packs as gray at the table's alpha, not white at a thin alpha.
// Half coverage packs as gray at the table's alpha, not white at a thin alpha: the gray is
// the 4-bit `(128 · 255) >> 12` = 7, read back as 7/15.
let (rgba, w, _, _) = outlined_cell(&[128], 1, 1, 1, 1.0);
assert_eq!(
px(&rgba, w, 1, 1),
[128, 128, 128, 255],
[119, 119, 119, 255],
"gray fill, LUT alpha"
);
assert_eq!(px(&rgba, w, 1, 0), [0, 0, 0, 153], "ring stays black");
}
#[test]
fn the_fill_gray_is_the_bakes_four_bit_channel() {
// `(c · 0xff) >> 12` (`0x5cf1b8`–`0x5cf1c8`): not `c >> 4`, which is 1 at 16 and 15 at 240.
for (c, n) in [
(0u8, 0u8),
(16, 0),
(17, 1),
(128, 7),
(240, 14),
(241, 15),
(255, 15),
] {
assert_eq!(fill_gray(c), n * 17, "coverage {c}");
}
// Every texel of an outlined cell is 4-bit exact, as the 4444 page holds it.
let cov: Vec<u8> = (0..=255).collect();
let (rgba, ..) = outlined_cell(&cov, 16, 16, 2, 1.0);
assert!(rgba.iter().all(|&b| b % 17 == 0));
}
}
#[cfg(test)]
+30 -4
View File
@@ -26,11 +26,18 @@ pub(super) const SHEET_SIZE: u32 = 2048;
/// pixel samples under half a texel outside its cell at any magnification.
const PAD: u32 = 1;
/// A 4-bit channel as the GPU widens it to 8 bits (`n / 15`): the reference's glyph pages are
/// ARGB4444 (`0x5cf4d0`), so every texel channel it bakes is one of these 16 values.
pub(super) const fn widen4(n: u8) -> u8 {
n * 17
}
/// A cell's texel payload, in the two shapes the rasterizer produces.
pub(super) enum Cell<'a> {
/// One byte per texel: coverage, expanded to white × alpha (the vertex color supplies the ink).
/// One byte per texel: coverage, expanded to white at its top 4 bits as alpha, the plain bake's
/// `(coverage << 8) | 0x0fff` texel (`0x5cf298`); the vertex color supplies the ink.
Coverage(&'a [u8]),
/// Four bytes per texel, straight alpha: an outlined cell, taken verbatim.
/// Four bytes per texel, straight alpha, already 4-bit exact: a baked cell, taken verbatim.
Rgba(&'a [u8]),
}
@@ -135,7 +142,7 @@ impl Sheet {
Cell::Coverage(cov) => {
let mut out = vec![255u8; (w * h * 4) as usize];
for (i, &a) in cov.iter().enumerate().take((w * h) as usize) {
out[i * 4 + 3] = a;
out[i * 4 + 3] = widen4(a >> 4);
}
out
}
@@ -266,7 +273,8 @@ mod tests {
s.alloc(2, 1, &Cell::Coverage(&[0, 128])).expect("room");
let (_, uploads) = s.take_pending();
assert_eq!(texel(&uploads[0], 0, 0), [255, 255, 255, 0]);
assert_eq!(texel(&uploads[0], 1, 0), [255, 255, 255, 128]);
// 128's top 4 bits, 8, read back as 8/15.
assert_eq!(texel(&uploads[0], 1, 0), [255, 255, 255, 136]);
let mut s = sheet();
let ring = vec![0u8, 0, 0, 153, 200, 200, 200, 255];
@@ -280,6 +288,24 @@ mod tests {
);
}
/// The plain bake keeps coverage's top 4 bits as a 4444 texel's alpha (`0x5cf298`): 16 levels,
/// and coverage under 16 draws nothing.
#[test]
fn coverage_keeps_its_top_four_bits_of_alpha() {
let cov: Vec<u8> = (0..=255).collect();
let mut s = sheet();
s.alloc(256, 1, &Cell::Coverage(&cov)).expect("room");
let (_, uploads) = s.take_pending();
for (x, &c) in cov.iter().enumerate() {
let a = texel(&uploads[0], x as u32, 0)[3];
assert_eq!(a % 17, 0, "coverage {c} packs to a 4-bit alpha, not {a}");
assert_eq!(a / 17, c >> 4, "coverage {c}");
}
assert_eq!(texel(&uploads[0], 15, 0)[3], 0, "coverage 15 vanishes");
assert_eq!(texel(&uploads[0], 16, 0)[3], 17);
assert_eq!(texel(&uploads[0], 255, 0)[3], 255);
}
/// A cell uploads its own frame, its edge colour at alpha 0 on every side, since a reset leaves
/// old ink in the gaps a magnified quad samples.
#[test]
+3 -3
View File
@@ -441,11 +441,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- Text the reference magnifies from a smaller raster is rasterized at its true size here: a `SetTextHeight` size (the bit cleared at `0x771600`), an em past 32 px, and 3-D unit names (`UNIT_NAME_FONT`, em 32, string flag `0x80`), because the 32 px cap is a raster-memory budget that blurs big text on a modern display (`super::FONTSTRING_EM_CAP`). ([`src/ui_text/engine/mod.rs:8`](../crates/benilla-app/src/ui_text/engine/mod.rs#L8))
- The reference flushes every glyph cache on a window resize (`0x5c2b50` → `0x5ca6f0`); this keeps the old cells, because nameplate meshes bake UVs and a flush would rebuild them every resize frame. ([`src/ui_text/engine/mod.rs:13`](../crates/benilla-app/src/ui_text/engine/mod.rs#L13))
- The reference's measure kernels (`0x5c6940`, `0x5c6b70`, `0x5c6c50`, `0x5c7300`, `0x5c7470`) rasterize each character, but the step law reads only the advance, so skipping the bitmap changes no width. ([`src/ui_text/engine/mod.rs:362`](../crates/benilla-app/src/ui_text/engine/mod.rs#L362))
- The reference evicts the least recently used glyph and repacks its hole (`0x5cad2b`, over `[CGxFont+0x64..0x6c]`); this drops every cell, because a shelf allocator cannot reclaim an interior cell and the sheet is far larger than the reference's 256×256 pages. ([`src/ui_text/engine/mod.rs:648`](../crates/benilla-app/src/ui_text/engine/mod.rs#L648))
- The reference evicts the least recently used glyph and repacks its hole (`0x5cad2b`, over `[CGxFont+0x64..0x6c]`); this drops every cell, because a shelf allocator cannot reclaim an interior cell and the sheet is far larger than the reference's 256×256 pages. ([`src/ui_text/engine/mod.rs:652`](../crates/benilla-app/src/ui_text/engine/mod.rs#L652))
- Every UI text block sits 1 px below the reference's row, because at that row UI text reads high. ([`src/ui_text/layout/mod.rs:115`](../crates/benilla-app/src/ui_text/layout/mod.rs#L115))
- The cap is 32 logical units, not 32 device px, because the reference's raster-memory ceiling draws all big text at the same 32 device px on a modern display; at the 768-tall design height the two agree. ([`src/ui_text/mod.rs:54`](../crates/benilla-app/src/ui_text/mod.rs#L54))
- `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster, and 8-bit texels where the reference packs ARGB4444, so nothing bands. ([`src/ui_text/outline.rs:51`](../crates/benilla-app/src/ui_text/outline.rs#L51))
- `r × round(dpi)` passes as in `outlined_cell`, the first `round(dpi)` of them the opaque ring. ([`src/ui_text/outline.rs:159`](../crates/benilla-app/src/ui_text/outline.rs#L159))
- `r × round(dpi)` passes, so the ring keeps its logical weight on a high-DPI raster. ([`src/ui_text/outline.rs:60`](../crates/benilla-app/src/ui_text/outline.rs#L60))
- `r × round(dpi)` passes as in `outlined_cell`, the first `round(dpi)` of them the opaque ring. ([`src/ui_text/outline.rs:167`](../crates/benilla-app/src/ui_text/outline.rs#L167))
- One sheet, where the reference keeps up to 8 pages per font (`[CGxFont+0x18c]`), because a nameplate bakes its glyphs into one mesh with one material, which a page would split. ([`src/ui_text/pack.rs:11`](../crates/benilla-app/src/ui_text/pack.rs#L11))
#### ui_trade