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:
@@ -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`
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user