pdfrum_render/glyph.rs
1//! Glyphs as alpha bitmaps, rendered the way the oracle's FreeType renders
2//! them.
3//!
4//! **Part of the backend seam,** for exactly one type: [`SubpixelBitmap`] is
5//! what [`RenderDevice::draw_glyph_lcd`](crate::RenderDevice::draw_glyph_lcd)
6//! hands a backend, so the trait cannot be implemented without naming it.
7//! Everything else here — the gray rasterizer, the LCD filter, the session's
8//! bitmap cache — is the engine's own and is private.
9//!
10//! Not a general glyph rasterizer: above a size threshold the outline is
11//! filled instead, and `RenderOptions::subpixel_text_positioning` is how a
12//! caller asks for true fractional placement at every size.
13
14// The oracle asks FreeType for a **bitmap** below
15// `|char2device.a| + |char2device.b| > 50` and blits it rather than filling
16// the outline (`CFX_Face::RenderGlyph` -> `FT_Render_Glyph` ->
17// `DrawNormalTextHelper`). Four stages of producing that bitmap each move
18// pixels by more than the coverage integral that would otherwise decide
19// them, so none can be dropped or applied in isolation:
20//
21// 1. The outline is grid-fitted at a pinned 64 ppem, never at the size it is
22// drawn at: `FT_Set_Pixel_Sizes(rec, 64, 64)` runs once at face
23// construction and the real size arrives afterwards through
24// `FT_Set_Transform`, which FreeType applies *after* hinting. On a 6 pt
25// stem that is worth up to 10 counts per pixel, because a 3x-wide grid
26// resolves a third of the horizontal displacement an ordinary one does.
27// 2. It is rasterized three times as wide: `FT_RENDER_MODE_LCD` multiplies
28// every x coordinate by 3, and the bitmap is padded by 43/64 of a
29// subpixel on each side so the filter's tails have somewhere to land.
30// 3. Every span is spread across five subpixel columns by the FIR5 weights
31// `{8, 77, 86, 77, 8}`, summing to 256, applied as
32// `(coverage * w + 85) >> 8` and *accumulated*. This is the stage that
33// puts ink in columns an outline fill leaves white.
34// 4. The triples are averaged back to gray through `kTextGammaAdjust`:
35// `(r + g + b) / 3`, then a table lookup.
36//
37// The gamma table's input is the average of three FIR5-filtered subpixel
38// coverages, not a pixel's coverage, and the FIR5 filter's input is a
39// 3x-wide rasterization of a *hinted* outline. Applying any one stage to a
40// plain outline coverage is not a weaker version of the pipeline; it is a
41// different function.
42
43use kurbo::{Affine, BezPath, Shape};
44
45use crate::scanline::{Coverage, FillRule, Rasterizer};
46
47/// FreeType's `FT_LCD_FILTER_DEFAULT` five-tap weights
48/// (`ftlcdfil.c`'s `default_weights`).
49///
50/// They sum to exactly 256, which is what lets the filter be applied as a
51/// shift and what bounds the accumulated result at a byte.
52pub(crate) const LCD_FIR5: [i32; 5] = [0x08, 0x4d, 0x56, 0x4d, 0x08];
53
54/// The horizontal padding `ft_lcd_padding` adds to an LCD glyph's control box,
55/// in 26.6 units — "2/3 of a pixel", as its comment says.
56///
57/// It is what gives the FIR5 filter's outer taps somewhere to write, and
58/// omitting it clips the two columns of ink that are the whole reason the LCD
59/// path differs from an outline fill.
60pub(crate) const LCD_PADDING_26_6: i64 = 43;
61
62/// The text gamma table, transcribed verbatim.
63///
64/// Applied to the *average of the three subpixel coverages*, not to a pixel's
65/// own coverage. It is not a power curve: close to `x^(1/1.05)` in the middle,
66/// pinned at both ends, with 24 repeated values. The transcription is the
67/// authority.
68pub(crate) const TEXT_GAMMA_ADJUST: [u8; 256] = [
69 0, 2, 3, 4, 6, 7, 8, 10, 11, 12, 13, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 29, 30,
70 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 54, 55, 56,
71 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
72 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
73 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
74 123, 124, 125, 126, 127, 128, 129, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
75 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 156, 157, 158,
76 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 174, 175, 176,
77 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 190, 191, 192, 193, 194,
78 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 204, 205, 206, 207, 208, 209, 210, 211, 212,
79 213, 214, 215, 216, 217, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 228, 229,
80 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 239, 240, 241, 242, 243, 244, 245, 246, 247,
81 248, 249, 250, 250, 251, 252, 253, 254, 255,
82];
83
84/// The largest glyph bitmap either axis may reach, in device pixels.
85///
86/// A glyph past it renders as nothing at all rather than as a huge
87/// allocation, which is upstream's answer and not a clamp: `RenderGlyph`
88/// returns `nullptr` and `DrawNormalText` skips the glyph.
89pub(crate) const MAX_GLYPH_DIMENSION: i32 = 2048;
90
91/// One glyph rasterized to **three** coverages per pixel — one per LCD stripe.
92///
93/// The same bitmap `GlyphBitmap` holds, with the 3× subpixel triples kept
94/// apart instead of averaged. It is produced for exactly one kind of text on
95/// a page: a live edit's, which is drawn with `ClearType` on while the rest
96/// of the page is not.
97///
98/// The three bytes are the destination's **red, green and blue** coverages in
99/// that order — the oracle assumes RGB-ordered stripes, mapping the leftmost
100/// subpixel to red. Each is gamma-adjusted and merged into its own destination
101/// channel independently, which is what puts colour on the fringes of a glyph
102/// drawn in a single colour.
103///
104/// ```
105/// use pdfrum_render::glyph::SubpixelBitmap;
106///
107/// let bitmap = SubpixelBitmap {
108/// left: 0,
109/// top: 0,
110/// width: 2,
111/// height: 1,
112/// channels: vec![255, 0, 0, 0, 0, 0],
113/// };
114///
115/// // The three bytes are the destination's red, green and blue
116/// // coverages, each merged into its own channel.
117/// assert_eq!(bitmap.at(0, 0), [255, 0, 0]);
118/// ```
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct SubpixelBitmap {
121 /// The device x of column 0, relative to the glyph's snapped origin.
122 pub left: i32,
123 /// The device y of row 0, relative to the glyph's snapped origin.
124 pub top: i32,
125 /// Columns.
126 pub width: i32,
127 /// Rows.
128 pub height: i32,
129 /// `height * width * 3` gamma-adjusted coverages, row-major, `[r, g, b]`
130 /// per pixel.
131 pub channels: Vec<u8>,
132}
133
134impl SubpixelBitmap {
135 /// The `[r, g, b]` coverages at `(x, y)`, or zeroes outside the bitmap.
136 ///
137 /// ```
138 /// use pdfrum_render::glyph::SubpixelBitmap;
139 ///
140 /// let bitmap = SubpixelBitmap {
141 /// left: 0,
142 /// top: 0,
143 /// width: 2,
144 /// height: 1,
145 /// channels: vec![255, 0, 0, 0, 0, 0],
146 /// };
147 ///
148 /// assert_eq!(bitmap.at(1, 0), [0, 0, 0]);
149 /// // Outside the bitmap is zeroes, not a panic.
150 /// assert_eq!(bitmap.at(-1, 0), [0, 0, 0]);
151 /// assert_eq!(bitmap.at(9, 9), [0, 0, 0]);
152 /// ```
153 #[must_use]
154 pub fn at(&self, x: i32, y: i32) -> [u8; 3] {
155 if x < 0 || y < 0 || x >= self.width || y >= self.height {
156 return [0; 3];
157 }
158 let Ok(i) = usize::try_from((y * self.width + x) * 3) else {
159 return [0; 3];
160 };
161 let get = |k: usize| self.channels.get(i + k).copied().unwrap_or(0);
162 [get(0), get(1), get(2)]
163 }
164
165 /// Whether the bitmap has no pixels at all, which a blank glyph produces.
166 ///
167 /// ```
168 /// use pdfrum_render::glyph::SubpixelBitmap;
169 ///
170 /// let bitmap = SubpixelBitmap {
171 /// left: 0,
172 /// top: 0,
173 /// width: 2,
174 /// height: 1,
175 /// channels: vec![255, 0, 0, 0, 0, 0],
176 /// };
177 ///
178 /// assert!(!bitmap.is_empty());
179 /// // What a blank glyph produces.
180 /// let blank = SubpixelBitmap { width: 0, height: 0, ..bitmap };
181 /// assert!(blank.is_empty());
182 /// ```
183 #[must_use]
184 pub fn is_empty(&self) -> bool {
185 self.width <= 0 || self.height <= 0
186 }
187}
188
189/// One glyph rasterized to gray coverage, positioned by its top-left corner.
190///
191/// The coverages are what [`recolour`] multiplies a colour's alpha by; they are
192/// not premultiplied and carry no colour of their own, exactly like the
193/// oracle's `k8bppMask`.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub(crate) struct GlyphBitmap {
196 /// The device x of column 0, relative to the glyph's snapped origin.
197 pub left: i32,
198 /// The device y of row 0, relative to the glyph's snapped origin.
199 pub top: i32,
200 /// Columns.
201 pub width: i32,
202 /// Rows.
203 pub height: i32,
204 /// `height * width` coverage bytes, row-major.
205 pub coverage: Vec<u8>,
206}
207
208impl GlyphBitmap {
209 /// The coverage at `(x, y)` in the bitmap's own coordinates, or zero
210 /// outside it.
211 #[cfg(test)]
212 #[must_use]
213 pub fn at(&self, x: i32, y: i32) -> u8 {
214 if x < 0 || y < 0 || x >= self.width || y >= self.height {
215 return 0;
216 }
217 let Ok(i) = usize::try_from(y * self.width + x) else {
218 return 0;
219 };
220 self.coverage.get(i).copied().unwrap_or(0)
221 }
222}
223
224/// Which third of a pixel a glyph's true origin sits in.
225///
226/// The oracle's `origin_.x` is `floor(device_origin.x)` and the fraction is
227/// recovered inside the blit loop as `(int)(device_origin.x * 3) % 3`, which
228/// shifts `DrawNormalTextHelper`'s sampling window into the 3×-wide bitmap by
229/// that many subpixels. Two glyphs at the same integer column and different
230/// thirds therefore get *different gray*, which is why the phase is part of the
231/// bitmap cache's key rather than something the blit can apply afterwards.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
233pub(crate) enum SubpixelPhase {
234 /// The origin is on the pixel boundary: the window is aligned.
235 Zero,
236 /// One third of a pixel in.
237 One,
238 /// Two thirds of a pixel in.
239 Two,
240}
241
242impl SubpixelPhase {
243 /// The phase of a device x, computed the way the C++ computes it.
244 ///
245 /// `static_cast<int>(x * 3) % 3` truncates toward zero and keeps the sign,
246 /// so a negative origin yields a negative remainder; `-1` and `-2` land on
247 /// [`Self::Two`] and [`Self::One`] respectively, which is the arm the C++'s
248 /// `x_subpixel == 1` / else-branch reaches for them. Spelling it as
249 /// `floor(3x) % 3` instead would differ there.
250 #[must_use]
251 pub fn of(x: f64) -> Self {
252 #[expect(
253 clippy::cast_possible_truncation,
254 reason = "the C++ is `static_cast<int>(x * 3) % 3`; a device origin \
255 beyond i32 has already been clamped by the ±32000 rule"
256 )]
257 let n = (x * 3.0) as i32 % 3;
258 match n {
259 1 | -2 => Self::One,
260 2 | -1 => Self::Two,
261 _ => Self::Zero,
262 }
263 }
264
265 /// How many subpixels the sampling window shifts left, 0..=2.
266 #[must_use]
267 pub fn shift(self) -> usize {
268 match self {
269 Self::Zero => 0,
270 Self::One => 1,
271 Self::Two => 2,
272 }
273 }
274}
275
276/// Rasterize one glyph outline into an alpha bitmap the oracle's way, at one
277/// subpixel phase.
278///
279/// `outline` is in **device pixels**, already positioned so that the glyph's
280/// origin is at `(0, 0)`: the `crate::text::snap_origin` snap is applied by
281/// translating the *bitmap* rather than the outline, which is what lets one
282/// bitmap serve every glyph of the same shape wherever it lands.
283///
284/// A caller drawing more than one glyph should build the [`LcdBitmap`] once
285/// with [`render_lcd`] and call [`LcdBitmap::to_gray`] per phase — that is the
286/// split the oracle's own cache makes, and it is why its key has no phase in
287/// it. This function is the one-shot spelling.
288///
289/// Returns `None` when the glyph would exceed [`MAX_GLYPH_DIMENSION`], which is
290/// what `RenderGlyph` does, or when it has no area at all.
291#[cfg(test)]
292#[must_use]
293pub(crate) fn rasterize(outline: &BezPath, phase: SubpixelPhase) -> Option<GlyphBitmap> {
294 Some(render_lcd(outline)?.to_gray(phase))
295}
296
297/// The 3×-wide LCD coverage bitmap FreeType's `FT_RENDER_MODE_LCD` produces.
298///
299/// This is what is cached, and the reason the cache key carries no subpixel
300/// phase: the phase is a *window shift into this buffer*, applied by the blit
301/// loop, so one bitmap serves all three thirds of a pixel.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub(crate) struct LcdBitmap {
304 /// The device x of column 0, relative to the glyph's snapped origin.
305 pub left: i32,
306 /// The device y of row 0, relative to the glyph's snapped origin.
307 pub top: i32,
308 /// Whole *pixels* across; [`Self::subpixels`] is three times this wide.
309 pub width: i32,
310 /// Rows.
311 pub height: i32,
312 /// `height * width * 3` subpixel coverages, row-major.
313 pub subpixels: Vec<u8>,
314}
315
316impl LcdBitmap {
317 /// Roughly how many bytes this occupies, for the cache's budget.
318 #[must_use]
319 pub fn byte_size(&self) -> usize {
320 self.subpixels.len() + std::mem::size_of::<Self>()
321 }
322}
323
324/// The gamma-adjusted coverage for a window's tap sum.
325///
326/// Three bytes summed and divided by three is a byte, so the table index needs
327/// neither a clamp nor a fallible narrowing — which is what the per-subpixel
328/// spelling paid, once per pixel, for a value that cannot leave `0..=255`.
329fn gamma_of_mean(sum: u32) -> u8 {
330 #[expect(
331 clippy::cast_possible_truncation,
332 reason = "at most `3 * 255 / 3`, which is 255"
333 )]
334 let mean = (sum / 3) as u8;
335 TEXT_GAMMA_ADJUST
336 .get(usize::from(mean))
337 .copied()
338 .unwrap_or(0)
339}
340
341/// Stages 1–3: pad, implode, rasterize, filter.
342///
343/// Independent of where the glyph lands and of the colour it will be drawn in,
344/// which is exactly the part worth caching.
345#[must_use]
346pub(crate) fn render_lcd(outline: &BezPath) -> Option<LcdBitmap> {
347 let bbox = outline.bounding_box();
348 if !bbox.x0.is_finite() || !bbox.y0.is_finite() || !bbox.x1.is_finite() || !bbox.y1.is_finite()
349 {
350 return None;
351 }
352 // `ft_glyphslot_preset_bitmap` works in 26.6 and takes the whole-pixel
353 // floor of the padded control box's minimum and the ceiling of its
354 // maximum (`pbox.min += cbox.min >> 6; pbox.max += (cbox.max + 63) >> 6`,
355 // `ftobjs.c`). Only x is padded: the filter is horizontal.
356 //
357 // Rounding to 26.6 first rather than working in `f64` throughout is not
358 // pedantry — FreeType's control box *is* a 26.6 quantity, and a glyph edge
359 // a millionth of a pixel past a 64th falls on the other side of the ceiling
360 // there and would give a bitmap one column wider here.
361 // The rounding is outward on both sides, so a box that is not exactly on a
362 // 26.6 step never loses a subpixel of the glyph — which truncating toward
363 // zero would do on a negative coordinate and only there, making a glyph
364 // left of the origin render differently from the same glyph right of it.
365 let quantise = |v: f64, outward: fn(f64) -> f64| -> Option<i64> {
366 // The bitmap is bounded by MAX_GLYPH_DIMENSION anyway, but the range
367 // check has to happen before the cast rather than after it.
368 let steps = outward(v * 64.0);
369 (steps.abs() < 1e15).then(|| {
370 #[expect(
371 clippy::cast_possible_truncation,
372 reason = "guarded above: finite, integral, and within i64"
373 )]
374 let n = steps as i64;
375 n
376 })
377 };
378 let low = |v: f64| quantise(v, f64::floor);
379 let high = |v: f64| quantise(v, f64::ceil);
380 let left = div_floor(low(bbox.x0)? - LCD_PADDING_26_6, 64);
381 let right = div_ceil(high(bbox.x1)? + LCD_PADDING_26_6, 64);
382 let top = div_floor(low(bbox.y0)?, 64);
383 let bottom = div_ceil(high(bbox.y1)?, 64);
384
385 let width = i32::try_from(right - left).ok()?;
386 let height = i32::try_from(bottom - top).ok()?;
387 if width <= 0 || height <= 0 || width > MAX_GLYPH_DIMENSION || height > MAX_GLYPH_DIMENSION {
388 return None;
389 }
390 let sub_width = width.checked_mul(3)?;
391 let cells = usize::try_from(sub_width.checked_mul(height)?).ok()?;
392 let mut subpixels = vec![0u8; cells];
393
394 // "Implode": translate the glyph's box to the bitmap's origin, then scale
395 // x by three. Both are exact in binary, so the imploded outline is the
396 // outline FreeType hands ftgrays and not an approximation of it.
397 #[expect(
398 clippy::cast_precision_loss,
399 reason = "a bitmap origin is bounded by MAX_GLYPH_DIMENSION"
400 )]
401 let placed = Affine::scale_non_uniform(3.0, 1.0)
402 * Affine::translate((-(left as f64), -(top as f64)))
403 * outline.clone();
404
405 let mut ras = Rasterizer::new();
406 // The bitmap's rows are the only ones written below; the box was derived
407 // from this very outline, so nothing outside them is expected — saying so
408 // makes the callback's own bound the second of two rather than the only
409 // one, and costs a comparison per cell.
410 ras.keep_rows(0..height);
411 ras.add_path(&placed, FLATTEN_TOLERANCE);
412 ras.sweep(FillRule::NonZero, Coverage::Exact, |x, len, y, alpha| {
413 if y < 0 || y >= height {
414 return;
415 }
416 let row = y * sub_width;
417 for i in 0..len {
418 // `ft_smooth_lcd_spans` writes five subpixels starting two to the
419 // left of the span's own, accumulating into whatever is there.
420 // The accumulation is what makes two adjacent spans' tails add up
421 // rather than overwrite, and it is why the filter is applied per
422 // span rather than as a post-pass over the finished bitmap.
423 for (k, weight) in LCD_FIR5.iter().enumerate() {
424 let Ok(k) = i32::try_from(k) else { continue };
425 let dx = x + i + k - 2;
426 if dx < 0 || dx >= sub_width {
427 continue;
428 }
429 let Ok(idx) = usize::try_from(row + dx) else {
430 continue;
431 };
432 // `(coverage * weight + 85) >> 8`, the C's own rounding, whose
433 // 85 is a third of 256: it biases each tap up by a third of a
434 // count so that five of them recover the coverage rather than
435 // losing it to five truncations.
436 //
437 // The C stores this straight into a `uint8_t` with no clamp,
438 // and it does not need one: the largest tap is
439 // `(255 · 86 + 85) >> 8 == 85`, and the five sum to at most
440 // 255 because the weights sum to 256. `saturating_add` is
441 // therefore never reached in practice, and is how a
442 // *rounding* difference between the two rasterizers stays a
443 // count rather than a wraparound.
444 let add =
445 u8::try_from(((i32::from(alpha) * weight + 85) >> 8).max(0)).unwrap_or(u8::MAX);
446 if let Some(cell) = subpixels.get_mut(idx) {
447 *cell = cell.saturating_add(add);
448 }
449 }
450 }
451 });
452
453 Some(LcdBitmap {
454 left: i32::try_from(left).ok()?,
455 top: i32::try_from(top).ok()?,
456 width,
457 height,
458 subpixels,
459 })
460}
461
462/// How finely the glyph outline is flattened, in device pixels **of the
463/// 3×-wide grid**.
464///
465/// A third of what the page rasterizer uses, because the grid it is measured
466/// against is three times finer horizontally: keeping the flattening error the
467/// same fraction of an output byte is the property that matters, not the
468/// absolute number.
469const FLATTEN_TOLERANCE: f64 = 0.0333;
470
471impl LcdBitmap {
472 /// Stage 4: `DrawNormalTextHelper` with `normalize = true`, at one phase.
473 ///
474 /// The window shift is the whole of the phase's effect. At phase zero a
475 /// pixel's three subpixels are its own; at phase one it borrows one
476 /// subpixel from its left neighbour; at phase two, two. The first column
477 /// has no left neighbour, and the C++ handles that by averaging fewer terms
478 /// *over the same divisor* — `(src[0] + src[1]) / 3` and `src[0] / 3` —
479 /// which darkens it rather than brightening it, and is reproduced rather
480 /// than corrected.
481 #[cfg(test)]
482 #[must_use]
483 pub fn to_gray(&self, phase: SubpixelPhase) -> GlyphBitmap {
484 let mut coverage = Vec::new();
485 self.gray_coverage_into(phase, &mut coverage);
486 GlyphBitmap {
487 left: self.left,
488 top: self.top,
489 width: self.width,
490 height: self.height,
491 coverage,
492 }
493 }
494
495 /// [`Self::to_gray`]'s coverage bytes, into a buffer the caller owns.
496 ///
497 /// The same arithmetic and the same bytes — this is where the body lives
498 /// and [`Self::to_gray`] is this with a fresh `Vec` — split out so that the
499 /// glyph blit, which runs once per glyph *occurrence* rather than once per
500 /// distinct glyph, reuses one buffer instead of allocating per occurrence.
501 /// `out` is cleared and refilled, so nothing carries over from the previous
502 /// glyph.
503 pub(crate) fn gray_coverage_into(&self, phase: SubpixelPhase, out: &mut Vec<u8>) {
504 let shift = phase.shift();
505 let (Ok(width), Ok(height)) = (usize::try_from(self.width), usize::try_from(self.height))
506 else {
507 out.clear();
508 return;
509 };
510 let sub_width = width * 3;
511 // A zero-width bitmap has no columns to average and would make both
512 // `chunks_exact` below panic on a zero chunk size. It draws nothing;
513 // an empty buffer is what the per-subpixel spelling's `0..0` column
514 // loop left behind for it.
515 if sub_width == 0 {
516 out.clear();
517 return;
518 }
519 // Sized to the pixel count, and **every byte is written below**: the
520 // row loop covers every row and the column walk every column of it,
521 // so the `resize`'s fill value never survives. `subpixels.len() / 3`
522 // said the same thing the long way round.
523 out.clear();
524 out.resize(width * height, 0);
525
526 // **The window's shift, applied to the row rather than to every
527 // subpixel index.** Pixel `x` reads subpixels `[3x - shift, 3x -
528 // shift + 3)` of its row, and a shift is the same for every pixel of
529 // every row — so instead of re-deriving that index three times per
530 // pixel and bounds-checking each, the row itself is shifted once:
531 // `shift` zero taps are prepended and the row's last `shift`
532 // subpixels fall off the end, which is exactly the window walking
533 // left.
534 //
535 // Prepending zeros is an out-of-range tap. Only the first pixel of a
536 // row can ever reach left of it, and only by fewer than three
537 // subpixels — the C++ averages the surviving taps *over the same
538 // divisor of three*, which **darkens** that column rather than
539 // brightening it. Clamping the window to the row start instead would
540 // brighten it, and `the_first_column_darkens_at_a_shifted_phase` is
541 // the test that says which.
542 //
543 // The row's last `shift` subpixels are then the tail of no complete
544 // window, and `chunks_exact` drops them — which is right, because
545 // once every window has moved left by `shift` there is no pixel whose
546 // window reaches them.
547 for (dst_row, src_row) in out
548 .chunks_exact_mut(width)
549 .zip(self.subpixels.chunks_exact(sub_width))
550 {
551 // `shift` is 0, 1 or 2, so the taps a pixel reads are the row's
552 // own except for the first pixel's, which reads `shift` fewer.
553 // Splitting the row at `sub_width - shift` puts every complete
554 // window in `body` — walked three at a time with no index
555 // arithmetic and no bounds check — and leaves the leading partial
556 // window, if there is one, to the arm below.
557 // Column zero's window is the one that can reach left of the
558 // row, and `head` is the part of it that survives: `3 - shift`
559 // taps at a non-zero phase, all three at phase zero. Splitting
560 // there leaves `body` holding every *complete* window, one after
561 // another, which is what makes the loop below a `chunks_exact(3)`
562 // with no index arithmetic and no bounds check.
563 let (head, body) = src_row.split_at(3 - shift);
564 let mut cells = dst_row.iter_mut();
565 if let Some(cell) = cells.next() {
566 // The C++ averages column zero's survivors over the same
567 // divisor of three, which **darkens** it rather than
568 // brightening it; summing `head` alone and dividing by three
569 // is exactly that. Clamping the window to the row start —
570 // reading three real taps — would brighten it, and
571 // `the_first_column_darkens_at_a_shifted_phase` is the test
572 // that says which.
573 *cell = gamma_of_mean(head.iter().map(|v| u32::from(*v)).sum());
574 }
575 for (cell, taps) in cells.zip(body.as_chunks::<3>().0) {
576 *cell = gamma_of_mean(taps.iter().map(|v| u32::from(*v)).sum());
577 }
578 }
579 }
580
581 /// Stage 4 with `normalize = false`: the three subpixels kept apart.
582 ///
583 /// The window is [`Self::to_gray`]'s, shifted by the same phase; what
584 /// differs is that the triple is *not* averaged. Each subpixel is
585 /// gamma-adjusted on its own and becomes one destination channel's
586 /// coverage — leftmost to red, then green, then blue.
587 ///
588 /// The left edge is reproduced rather than corrected, and it is not the
589 /// same rule as the gray path's. Where `to_gray` averages the surviving
590 /// samples *over the same divisor of three* — darkening the first column —
591 /// the oracle's per-channel arm simply **does not write** the channels
592 /// whose subpixel would come from before the bitmap
593 /// (`if (start_col > left)` guards them), leaving the destination's own
594 /// value there. A zero coverage is how that is expressed here: the merge
595 /// below leaves a channel untouched at coverage zero, which is the same
596 /// destination byte the oracle's skipped write leaves.
597 #[must_use]
598 pub fn to_subpixel(&self, phase: SubpixelPhase) -> SubpixelBitmap {
599 let shift = i32::try_from(phase.shift()).unwrap_or(0);
600 let sub_width = self.width * 3;
601 let mut channels = vec![0u8; self.subpixels.len()];
602 for y in 0..self.height {
603 let row = y * sub_width;
604 for x in 0..self.width {
605 let start = row + x * 3 - shift;
606 for k in 0..3 {
607 let idx = start + k;
608 // Only the first column can reach left of its row, and the
609 // oracle leaves that channel unwritten rather than clamping.
610 if idx < row {
611 continue;
612 }
613 let raw = usize::try_from(idx)
614 .ok()
615 .and_then(|i| self.subpixels.get(i))
616 .copied()
617 .unwrap_or(0);
618 let gamma = TEXT_GAMMA_ADJUST
619 .get(usize::from(raw))
620 .copied()
621 .unwrap_or(0);
622 if let Ok(i) = usize::try_from((y * self.width + x) * 3 + k)
623 && let Some(cell) = channels.get_mut(i)
624 {
625 *cell = gamma;
626 }
627 }
628 }
629 }
630 SubpixelBitmap {
631 left: self.left,
632 top: self.top,
633 width: self.width,
634 height: self.height,
635 channels,
636 }
637 }
638}
639
640/// Floor division for a positive divisor.
641fn div_floor(a: i64, b: i64) -> i64 {
642 let q = a / b;
643 if a % b != 0 && (a < 0) != (b < 0) {
644 q - 1
645 } else {
646 q
647 }
648}
649
650/// Ceiling division for a positive divisor.
651fn div_ceil(a: i64, b: i64) -> i64 {
652 let q = a / b;
653 if a % b != 0 && (a < 0) == (b < 0) {
654 q + 1
655 } else {
656 q
657 }
658}
659
660/// What identifies one cached [`LcdBitmap`].
661///
662/// It is [`pdfrum_font::GlyphKey`] — which already carries the font, the glyph
663/// and the four substitution parameters that change an outline — plus the
664/// **shape of the device matrix**, because a bitmap is rasterized at a size and
665/// an outline is not.
666///
667/// The matrix is reduced to four integers by `(int)(m · 10000)` on each of
668/// `a`, `b`, `c`, `d`, truncating toward zero rather than rounding. That
669/// coarseness is visible in pixels: two glyph matrices closer together than
670/// one part in ten thousand share a *bitmap*, so a page whose text matrix
671/// drifts by rounding error draws identical glyphs there. A finer key would
672/// draw very slightly different ones.
673///
674/// The translation is deliberately absent, as it is upstream: the glyph is
675/// rasterized about its own origin and *placed* by the blit.
676///
677/// So is the subpixel phase, and for a sharper reason — the cached bitmap is
678/// three times as wide as the glyph, and the phase is a window shift into it
679/// that [`LcdBitmap::to_gray`] applies at blit time. Keying on the phase would
680/// store the same rasterization three times.
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
682pub(crate) struct BitmapKey {
683 /// Which glyph, and at which substitution parameters.
684 pub glyph: pdfrum_font::GlyphKey,
685 /// `(int)(a · 10000)`, and likewise `b`, `c`, `d`.
686 pub matrix: [i32; 4],
687}
688
689impl BitmapKey {
690 /// The key for a glyph drawn under `matrix`.
691 #[must_use]
692 pub fn new(glyph: pdfrum_font::GlyphKey, matrix: Affine) -> Self {
693 #[expect(
694 clippy::cast_possible_truncation,
695 reason = "the C++ is `static_cast<int>(m * 10000)`; a matrix \
696 coefficient past i32 belongs to a glyph the ±32000 \
697 coordinate rule has already rejected"
698 )]
699 fn ten_thousandths(v: f64) -> i32 {
700 (v * 10_000.0) as i32
701 }
702 let [xx, yx, xy, yy, _, _] = matrix.as_coeffs();
703 Self {
704 glyph,
705 matrix: [
706 ten_thousandths(xx),
707 ten_thousandths(yx),
708 ten_thousandths(xy),
709 ten_thousandths(yy),
710 ],
711 }
712 }
713}
714
715/// How many bytes of glyph bitmaps one render session keeps.
716///
717/// A budget rather than an entry count, because glyph bitmaps differ in size by
718/// four orders of magnitude: 24 bytes for a 6 pt comma and megabytes for
719/// display type just under the outline threshold. Sixteen megabytes holds every
720/// glyph of every font on any page in the corpus several times over, and bounds
721/// what a hostile file can make a session allocate.
722pub(crate) const BITMAP_CACHE_BUDGET: usize = 16 * 1024 * 1024;
723
724/// Memoized glyph bitmaps for one render session.
725///
726/// The cache PDFium keeps per face; ours is keyed across faces because the key
727/// already names the font, which keeps one map rather than a map of maps.
728///
729/// A miss is memoized as `None`, exactly as [`pdfrum_font::GlyphCache`] does:
730/// a glyph too large to rasterize, or with no area, must not be re-attempted
731/// once per occurrence on a page of a thousand of them.
732///
733/// When the budget is exhausted the cache **stops inserting** rather than
734/// evicting, and the caller still gets its bitmap — the cache degrades to no
735/// cache, never to no glyph. Eviction would need a recency order, and the
736/// access pattern here makes one worth very little: a page's glyph repertoire
737/// is small and is touched over and over, so the entries that would be evicted
738/// are the ones about to be wanted again. Refusing to grow keeps the bound with
739/// no policy.
740#[derive(Debug, Default)]
741pub(crate) struct BitmapCache {
742 /// Keyed with [`pdfrum_common::FxBuildHasher`], not `std`'s `SipHash`.
743 ///
744 /// [`BitmapKey`] is a glyph id and four `i32` matrix coefficients — sixteen
745 /// bytes this crate computes, none of which a file supplies directly — and
746 /// it is looked up once per glyph *occurrence*, tens of thousands of times
747 /// on a dense page. `SipHash`'s collision resistance buys nothing against a
748 /// key an attacker cannot choose, and its mixing is several times the cost
749 /// of the lookup it guards. See `pdfrum_common::FxBuildHasher`'s docs for
750 /// which keys may and may not use it.
751 entries: std::collections::HashMap<BitmapKey, Option<LcdBitmap>, pdfrum_common::FxBuildHasher>,
752 bytes: usize,
753}
754
755/// A cached bitmap, or one rendered past a full cache.
756///
757/// The two are the same value to a caller; the distinction exists so that the
758/// borrow of a cached entry and the ownership of an uncached one can share one
759/// return type without cloning the cached case, which is the case that matters.
760#[derive(Debug)]
761pub(crate) enum Cached<'a> {
762 /// Held by the cache, borrowed for this draw.
763 Hit(&'a LcdBitmap),
764 /// Rendered past the budget and dropped after this draw.
765 Uncached(LcdBitmap),
766}
767
768impl std::ops::Deref for Cached<'_> {
769 type Target = LcdBitmap;
770
771 fn deref(&self) -> &LcdBitmap {
772 match self {
773 Self::Hit(b) => b,
774 Self::Uncached(b) => b,
775 }
776 }
777}
778
779impl BitmapCache {
780 /// The bitmap for `key`, rasterizing it through `render` on a miss.
781 ///
782 /// `render` is a closure rather than an outline because producing the
783 /// outline is itself the expensive half — a hinted glyph runs the face's
784 /// bytecode — and a hit must not pay for it.
785 pub fn get_or_insert(
786 &mut self,
787 key: BitmapKey,
788 render: impl FnOnce() -> Option<LcdBitmap>,
789 ) -> Option<Cached<'_>> {
790 // Two probes on the hit path, and it is not for want of trying to
791 // make it one. A hit is what every call after the first is — on
792 // `benches/corpus/vector_font_size14.pdf` this runs 8775 times per
793 // render and misses zero times — so the obvious fix is to return the
794 // occupied entry's borrow directly. It does not compile: returning a
795 // borrow taken from `self.entries` in one arm and re-borrowing it
796 // mutably in the other is NLL problem case 3, which the current
797 // borrow checker rejects and Polonius accepts. The `Entry` API does
798 // not rescue it either, because the budget check needs `render()`'s
799 // result and `render()` cannot run while a `Vacant` entry holds the
800 // borrow.
801 //
802 // It was measured before being left: the pair costs about 85 ns of a
803 // 1000 ns per-glyph chain,
804 // and the second probe is at most half of that on a table that is
805 // already in cache from the first.
806 if !self.entries.contains_key(&key) {
807 let bitmap = render();
808 let size = bitmap.as_ref().map_or(0, LcdBitmap::byte_size);
809 if self.bytes.saturating_add(size) > BITMAP_CACHE_BUDGET && !self.entries.is_empty() {
810 // Full: draw this glyph without keeping it.
811 return bitmap.map(Cached::Uncached);
812 }
813 self.bytes = self.bytes.saturating_add(size);
814 self.entries.insert(key, bitmap);
815 }
816 self.entries
817 .get(&key)
818 .and_then(Option::as_ref)
819 .map(Cached::Hit)
820 }
821}
822
823/// Collapse a subpixel bitmap's three channels back to one gray coverage.
824///
825/// The fallback `crate::device::RenderDevice::draw_glyph_lcd`'s default takes
826/// for a backend that cannot address channels separately. It is **not** the
827/// oracle's own gray path and must not be mistaken for it: `to_gray` averages
828/// the *raw* subpixels and gamma-adjusts the average once, where this averages
829/// three values the gamma table has already been applied to. The two differ by
830/// a few counts on a partially covered pixel, because the table is not linear.
831///
832/// Reaching for it means the LCD render is already not happening; this makes
833/// the result grey and legible rather than absent, and the honest description
834/// of it is an approximation of the wrong branch, not a second opinion on the
835/// right one.
836///
837/// Returns `None` for a bitmap with no pixels.
838#[must_use]
839pub(crate) fn average_to_gray(bitmap: &SubpixelBitmap) -> Option<GlyphBitmap> {
840 if bitmap.is_empty() {
841 return None;
842 }
843 let mut coverage = Vec::with_capacity(bitmap.channels.len() / 3);
844 for triple in bitmap.channels.as_chunks::<3>().0 {
845 let sum: u32 = triple.iter().map(|&v| u32::from(v)).sum();
846 #[expect(
847 clippy::cast_possible_truncation,
848 reason = "three bytes divided by three is at most 255"
849 )]
850 let byte = (sum / 3) as u8;
851 coverage.push(byte);
852 }
853 Some(GlyphBitmap {
854 left: bitmap.left,
855 top: bitmap.top,
856 width: bitmap.width,
857 height: bitmap.height,
858 coverage,
859 })
860}
861
862/// One glyph's coverage recoloured into a premultiplied pixmap, ready to blit.
863///
864/// The oracle keeps the mask and the colour apart all the way to the
865/// destination: `DrawNormalTextHelper` merges `bgra` into each pixel at the
866/// glyph's own alpha (`ApplyAlpha` → `AlphaMerge`). Premultiplying here and
867/// compositing source-over is the same arithmetic — `dest·(255−a)/255 +
868/// colour·a/255` either way — expressed in the vocabulary
869/// `crate::device::RenderDevice::draw_image` already speaks, which is what
870/// lets the glyph path use the existing device seam rather than growing a
871/// seventh primitive that every backend would have to reimplement.
872///
873/// The glyph's own coverage is scaled by the colour's alpha first
874/// (`CalcAlpha(gamma, bgra.alpha)` is `gamma · alpha / 255`), so a translucent
875/// fill colour and a partial coverage compose exactly once.
876///
877/// Returns `None` for a bitmap with no pixels, and for a colour that is fully
878/// transparent — both of which draw nothing.
879#[must_use]
880pub(crate) fn recolour(bitmap: &GlyphBitmap, colour: peniko::Color) -> Option<crate::Pixmap> {
881 let mut pixmap = crate::Pixmap::new(0, 0);
882 let by_ref = GlyphBitmapRef {
883 width: bitmap.width,
884 height: bitmap.height,
885 coverage: &bitmap.coverage,
886 };
887 recolour_ref_into(by_ref, colour, &mut pixmap).then_some(pixmap)
888}
889
890/// One glyph occurrence, from the cached LCD bitmap to a pixmap ready to blit.
891///
892/// The two halves the blit runs per occurrence — [`LcdBitmap::to_gray`] and
893/// [`recolour`] — writing into `scratch`'s buffers instead of allocating a
894/// `Vec` and a `Pixmap` each. Both are rewritten in full, so nothing of the
895/// previous glyph survives into this one; what is reused is the memory alone.
896///
897/// The arithmetic is the two functions' own, unchanged: the same gamma table
898/// over the same window shift, the same truncating `CalcAlpha` product, the
899/// same premultiplied bytes. `false` means the glyph draws nothing, which is
900/// the `None` the two spellings return between them.
901pub(crate) fn recolour_glyph_into(
902 lcd: &LcdBitmap,
903 phase: SubpixelPhase,
904 colour: peniko::Color,
905 scratch: &mut crate::ctx::GlyphBlitScratch,
906) -> bool {
907 lcd.gray_coverage_into(phase, &mut scratch.coverage);
908 let bitmap = GlyphBitmapRef {
909 width: lcd.width,
910 height: lcd.height,
911 coverage: &scratch.coverage,
912 };
913 recolour_ref_into(bitmap, colour, &mut scratch.pixels)
914}
915
916/// A [`GlyphBitmap`]'s pixels without owning them.
917///
918/// The blit's coverage lives in a session-owned buffer, so the recolour reads
919/// it by reference; `left` and `top` are absent because only the caller places
920/// the bitmap and this half never looks at them.
921#[derive(Clone, Copy)]
922struct GlyphBitmapRef<'a> {
923 width: i32,
924 height: i32,
925 coverage: &'a [u8],
926}
927
928/// [`recolour`] into a pixmap the caller owns, reporting whether it drew.
929///
930/// The same arithmetic and the same bytes — this is where the body lives and
931/// [`recolour`] is this with a fresh pixmap — split out for the reason
932/// [`LcdBitmap::gray_coverage_into`] is: the blit runs once per glyph
933/// *occurrence*, and a page of ten thousand glyphs otherwise allocates a
934/// pixmap ten thousand times. `out` is reshaped to this glyph and **every one
935/// of its bytes is written**, which is what makes reusing it sound: the
936/// coverage-zero and alpha-zero pixels, which the allocating spelling got for
937/// free from a freshly zeroed buffer, are written as transparent here rather
938/// than skipped. Skipping them blits the previous glyph's ink through this
939/// one's gaps, and
940/// `a_reused_scratch_carries_none_of_the_glyph_before_it` fails when they are.
941///
942/// `false` means nothing was drawn — an empty bitmap or a fully transparent
943/// colour — and leaves `out` in an unspecified state, exactly as the `None`
944/// it replaces gave the caller no pixmap at all.
945fn recolour_ref_into(
946 bitmap: GlyphBitmapRef<'_>,
947 colour: peniko::Color,
948 out: &mut crate::Pixmap,
949) -> bool {
950 if bitmap.width <= 0 || bitmap.height <= 0 {
951 return false;
952 }
953 let [r, g, b, alpha] = colour.to_rgba8().to_u8_array();
954 if alpha == 0 {
955 return false;
956 }
957 let (Ok(width), Ok(height)) = (u32::try_from(bitmap.width), u32::try_from(bitmap.height))
958 else {
959 return false;
960 };
961 out.reshape_keeping_pixels(width, height);
962 let stride = width as usize;
963 let data = out.data_mut();
964 // The colour's three channels and the alpha, lifted out of the loop:
965 // they are the text object's and do not vary with the pixel. That is what
966 // the `let [r, g, b, alpha]` above already does; what is new below is the
967 // *index*.
968 //
969 // The coverage buffer is exactly `height * width` bytes — `render_lcd`
970 // sizes it and `gray_coverage_into` refills it to that — so zipping the
971 // two row-wise does the index arithmetic and the bounds check once per
972 // row. A shorter coverage buffer simply yields fewer rows; the two are
973 // produced together and cannot differ, and `reshape_keeping_pixels` has
974 // already sized `out` to this glyph.
975 //
976 // **A coverage-to-pixel lookup table was tried here and is not this.** It
977 // is the obvious hoist — 256 entries derived once per occurrence turn the
978 // four `mul255`es into one array read — and it was measured 3.9x *slower*
979 // on both vector fixtures, because a glyph is about fifty pixels and a
980 // 256-entry table is five times more arithmetic than the pixels it
981 // serves. The quantity to amortize over here is the glyph, not the page.
982 for (dst_row, cov_row) in data
983 .chunks_exact_mut(stride * 4)
984 .zip(bitmap.coverage.chunks_exact(stride))
985 {
986 for (dest, coverage) in dst_row.as_chunks_mut::<4>().0.iter_mut().zip(cov_row) {
987 // `CalcAlpha(TextGammaAdjust(src), bgra.alpha)`, whose product is
988 // the truncating one the whole engine uses.
989 let a = crate::pixmap::mul255(*coverage, alpha);
990 // Zero coverage and zero alpha both wrote nothing before, into a
991 // buffer that was already zero; writing the zero explicitly is the
992 // same pixel and is what makes a reused buffer sound.
993 dest.copy_from_slice(&[
994 crate::pixmap::mul255(r, a),
995 crate::pixmap::mul255(g, a),
996 crate::pixmap::mul255(b, a),
997 a,
998 ]);
999 }
1000 }
1001 true
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007
1008 /// A unit square filled at the origin, as a device-space outline.
1009 /// Two boxes with a clear column between them, so the middle columns of
1010 /// the glyph's own box have genuinely zero coverage.
1011 fn split_box(w: f64, h: f64) -> BezPath {
1012 let mut p = square(w, h);
1013 let x = w + 3.0;
1014 p.move_to((x, 0.0));
1015 p.line_to((x + w, 0.0));
1016 p.line_to((x + w, h));
1017 p.line_to((x, h));
1018 p.close_path();
1019 p
1020 }
1021
1022 fn square(w: f64, h: f64) -> BezPath {
1023 let mut p = BezPath::new();
1024 p.move_to((0.0, 0.0));
1025 p.line_to((w, 0.0));
1026 p.line_to((w, h));
1027 p.line_to((0.0, h));
1028 p.close_path();
1029 p
1030 }
1031
1032 #[test]
1033 fn the_gamma_table_is_the_oracles() {
1034 // Endpoints, the three plateaus a reader might "fix", and the length.
1035 assert_eq!(TEXT_GAMMA_ADJUST.len(), 256);
1036 assert_eq!(TEXT_GAMMA_ADJUST[0], 0);
1037 assert_eq!(TEXT_GAMMA_ADJUST[255], 255);
1038 // It is monotone non-decreasing, and it repeats rather than descends —
1039 // unlike `kWeightPow11`, whose three descents are transcription errors
1040 // frozen into the oracle's output.
1041 for w in TEXT_GAMMA_ADJUST.windows(2) {
1042 let (Some(a), Some(b)) = (w.first(), w.last()) else {
1043 continue;
1044 };
1045 assert!(a <= b, "the table never descends: {a} then {b}");
1046 }
1047 // It brightens: every entry is at or above the identity below the top.
1048 assert!(TEXT_GAMMA_ADJUST[1] > 1);
1049 assert!(TEXT_GAMMA_ADJUST[128] > 128);
1050 }
1051
1052 #[test]
1053 fn the_filter_weights_sum_to_a_whole_scale() {
1054 // 256, not 255 — which is what lets `(cov * w + 85) >> 8` be exact and
1055 // what bounds the accumulated subpixel at a byte.
1056 assert_eq!(LCD_FIR5.iter().sum::<i32>(), 256);
1057 // Symmetric, so the filter does not shift the glyph.
1058 assert_eq!(LCD_FIR5[0], LCD_FIR5[4]);
1059 assert_eq!(LCD_FIR5[1], LCD_FIR5[3]);
1060 }
1061
1062 #[test]
1063 fn the_bitmap_is_wider_than_the_outline_by_the_filters_reach() {
1064 // The whole reason an LCD glyph inks columns an outline fill leaves
1065 // white: `ft_lcd_padding` widens the box by 43/64 of a pixel on each
1066 // side so the FIR5 tails have somewhere to land.
1067 let bmp = rasterize(&square(2.0, 2.0), SubpixelPhase::Zero).expect("a square rasterizes");
1068 assert_eq!(bmp.top, 0);
1069 assert_eq!(bmp.height, 2);
1070 // 43/64 each side rounds out to one whole pixel each side.
1071 assert_eq!(bmp.left, -1, "one pixel of padding on the left");
1072 assert_eq!(bmp.width, 4, "two pixels of glyph plus one each side");
1073 }
1074
1075 #[test]
1076 fn the_padding_columns_carry_the_filters_tails() {
1077 let bmp = rasterize(&square(2.0, 2.0), SubpixelPhase::Zero).expect("a square rasterizes");
1078 // Column 0 is entirely outside the outline, and it is not blank: it
1079 // holds the two outermost taps of the leftmost span's filter. An
1080 // outline fill writes zero here, and that is the difference the whole
1081 // module exists to reproduce.
1082 assert!(
1083 bmp.at(0, 0) > 0,
1084 "the padding column must carry ink, not zero"
1085 );
1086 // The interior is darker than the padding, but it is NOT opaque: the
1087 // filter takes ink out of a boundary pixel as well as putting it into
1088 // its neighbour, and a two-pixel-wide glyph has no column far enough
1089 // from an edge to keep all of its own. That redistribution is the
1090 // whole effect, and asserting 255 here would assert it away.
1091 assert!(bmp.at(0, 0) < bmp.at(1, 0));
1092 assert!(bmp.at(1, 0) > 200, "the interior is nearly opaque");
1093 // Symmetric about the glyph.
1094 assert_eq!(bmp.at(0, 0), bmp.at(3, 0));
1095 }
1096
1097 #[test]
1098 fn a_phase_shifts_the_window_and_changes_the_gray() {
1099 // Two glyphs at the same integer column and different thirds get
1100 // different gray, which is why the phase keys the cache.
1101 let outline = square(1.5, 2.0);
1102 let zero = rasterize(&outline, SubpixelPhase::Zero).expect("rasterizes");
1103 let one = rasterize(&outline, SubpixelPhase::One).expect("rasterizes");
1104 let two = rasterize(&outline, SubpixelPhase::Two).expect("rasterizes");
1105 // Same geometry, so the same box.
1106 assert_eq!((zero.left, zero.width), (one.left, one.width));
1107 assert_eq!((zero.left, zero.width), (two.left, two.width));
1108 assert_ne!(zero.coverage, one.coverage);
1109 assert_ne!(one.coverage, two.coverage);
1110 }
1111
1112 #[test]
1113 fn the_phase_of_an_x_is_the_c_remainder() {
1114 // The thirds of one pixel, at their boundaries and inside them.
1115 assert_eq!(SubpixelPhase::of(10.0), SubpixelPhase::Zero);
1116 assert_eq!(SubpixelPhase::of(10.32), SubpixelPhase::Zero);
1117 assert_eq!(SubpixelPhase::of(10.34), SubpixelPhase::One);
1118 assert_eq!(SubpixelPhase::of(10.5), SubpixelPhase::One);
1119 assert_eq!(SubpixelPhase::of(10.66), SubpixelPhase::One);
1120 assert_eq!(SubpixelPhase::of(10.7), SubpixelPhase::Two);
1121 assert_eq!(SubpixelPhase::of(10.99), SubpixelPhase::Two);
1122 assert_eq!(SubpixelPhase::of(11.0), SubpixelPhase::Zero);
1123 // A negative origin: `(int)(x * 3) % 3` truncates toward zero and
1124 // keeps the sign, so -10.4 gives -31 % 3 == -1, which is the C++'s
1125 // else-branch — two thirds, not one.
1126 assert_eq!(SubpixelPhase::of(-10.4), SubpixelPhase::Two);
1127 assert_eq!(SubpixelPhase::of(-10.7), SubpixelPhase::One);
1128 assert_eq!(SubpixelPhase::of(-10.0), SubpixelPhase::Zero);
1129 }
1130
1131 #[test]
1132 fn an_enormous_glyph_renders_as_nothing() {
1133 // `RenderGlyph` returns nullptr past `kMaxGlyphDimension` and the blit
1134 // loop skips the glyph; it does not clamp it to the limit.
1135 let huge = square(f64::from(MAX_GLYPH_DIMENSION) + 10.0, 4.0);
1136 assert!(rasterize(&huge, SubpixelPhase::Zero).is_none());
1137 }
1138
1139 #[test]
1140 fn a_degenerate_outline_yields_nothing_rather_than_an_empty_bitmap() {
1141 let mut zero_height = BezPath::new();
1142 zero_height.move_to((0.0, 0.0));
1143 zero_height.line_to((4.0, 0.0));
1144 zero_height.close_path();
1145 // Zero rows: the box collapses and there is no bitmap to blit.
1146 assert!(rasterize(&zero_height, SubpixelPhase::Zero).is_none());
1147 }
1148
1149 #[test]
1150 fn the_first_column_averages_over_three_however_few_it_has() {
1151 // The C++ divides by three even when it summed two terms or one, which
1152 // darkens the leftmost column of a phase-shifted glyph rather than
1153 // brightening it. Reproduced, not corrected.
1154 let outline = square(2.0, 1.0);
1155 let two = rasterize(&outline, SubpixelPhase::Two).expect("rasterizes");
1156 let zero = rasterize(&outline, SubpixelPhase::Zero).expect("rasterizes");
1157 assert!(
1158 two.at(0, 0) <= zero.at(0, 0),
1159 "borrowing from off the left edge cannot brighten the column"
1160 );
1161 }
1162
1163 #[test]
1164 fn coverage_outside_the_bitmap_reads_as_zero() {
1165 let bmp = rasterize(&square(1.0, 1.0), SubpixelPhase::Zero).expect("rasterizes");
1166 assert_eq!(bmp.at(-1, 0), 0);
1167 assert_eq!(bmp.at(0, -1), 0);
1168 assert_eq!(bmp.at(bmp.width, 0), 0);
1169 assert_eq!(bmp.at(0, bmp.height), 0);
1170 }
1171
1172 #[test]
1173 fn the_subpixel_bitmap_reads_the_same_window_as_the_gray_one() {
1174 // `to_subpixel` and `to_gray` differ in one thing only: whether the
1175 // triple is averaged. So the gamma of the average must sit between the
1176 // smallest and largest of the three gammas the subpixel form keeps —
1177 // which it cannot if the two are reading different windows, which is
1178 // the mistake a phase shift invites.
1179 let outline = square(3.0, 2.0);
1180 let lcd = render_lcd(&outline).expect("rasterizes");
1181 for phase in [SubpixelPhase::Zero, SubpixelPhase::One, SubpixelPhase::Two] {
1182 let gray = lcd.to_gray(phase);
1183 let sub = lcd.to_subpixel(phase);
1184 assert_eq!((sub.width, sub.height), (gray.width, gray.height));
1185 assert_eq!((sub.left, sub.top), (gray.left, gray.top));
1186 for y in 0..gray.height {
1187 for x in 0..gray.width {
1188 let three = sub.at(x, y);
1189 let (lo, hi) = (
1190 three.iter().copied().min().unwrap_or(0),
1191 three.iter().copied().max().unwrap_or(0),
1192 );
1193 let g = gray.at(x, y);
1194 assert!(
1195 g >= lo.saturating_sub(2) && g <= hi.saturating_add(2),
1196 "{phase:?} at ({x},{y}): gray {g} outside subpixels {three:?}"
1197 );
1198 }
1199 }
1200 }
1201 }
1202
1203 #[test]
1204 fn a_fully_covered_pixel_has_no_fringe_and_an_edge_does() {
1205 // The whole point of the subpixel form. Inside a solid glyph all three
1206 // stripes are saturated, so there is nothing to tell apart and the
1207 // pixel is neutral; at the glyph's vertical edge they differ, which is
1208 // the colour fringe `bClearType` exists to produce. A form that
1209 // averaged internally would show neither.
1210 let lcd = render_lcd(&square(6.0, 2.0)).expect("rasterizes");
1211 let sub = lcd.to_subpixel(SubpixelPhase::Zero);
1212 let interior = sub.at(sub.width / 2, 0);
1213 assert_eq!(
1214 interior[0], interior[2],
1215 "a fully covered pixel must have no fringe, got {interior:?}"
1216 );
1217 let edges: Vec<_> = (0..sub.width)
1218 .map(|x| sub.at(x, 0))
1219 .filter(|c| c[0] != c[2])
1220 .collect();
1221 assert!(
1222 !edges.is_empty(),
1223 "no pixel had unequal stripes; the triples are being averaged \
1224 somewhere they should not be"
1225 );
1226 }
1227
1228 #[test]
1229 fn the_gamma_table_is_applied_once_per_stripe_not_once_per_pixel() {
1230 // `MergeGammaAdjustRgb` calls `TextGammaAdjust` on each of the three
1231 // subpixels separately (`cfx_renderdevice.cpp:132-140`); the gray path
1232 // calls it once on their average. Every byte the subpixel form emits
1233 // must therefore be a value the table can produce.
1234 let lcd = render_lcd(&square(4.0, 1.0)).expect("rasterizes");
1235 let sub = lcd.to_subpixel(SubpixelPhase::One);
1236 for &byte in &sub.channels {
1237 assert!(
1238 TEXT_GAMMA_ADJUST.contains(&byte),
1239 "{byte} is not in the gamma table's range"
1240 );
1241 }
1242 }
1243
1244 #[test]
1245 fn averaging_back_to_gray_keeps_the_bitmaps_shape() {
1246 // The fallback a backend without per-channel addressing takes. It must
1247 // preserve position and size exactly — a glyph that moved or resized
1248 // when a backend declined the LCD path would be a worse failure than
1249 // the missing fringes it is standing in for.
1250 let lcd = render_lcd(&square(3.0, 2.0)).expect("rasterizes");
1251 let sub = lcd.to_subpixel(SubpixelPhase::Zero);
1252 let gray = average_to_gray(&sub).expect("has pixels");
1253 assert_eq!((gray.width, gray.height), (sub.width, sub.height));
1254 assert_eq!((gray.left, gray.top), (sub.left, sub.top));
1255 assert_eq!(gray.coverage.len(), sub.channels.len() / 3);
1256 }
1257
1258 /// A big glyph then a small one, through one reused scratch.
1259 ///
1260 /// This is the way the reuse could be wrong: the buffers are sized to the
1261 /// *previous* glyph, so a smaller one that only resized the pixmap without
1262 /// rewriting every byte would blit the tail of the glyph before it. Both
1263 /// spellings must produce exactly what the allocating one does.
1264 #[test]
1265 fn a_reused_scratch_carries_none_of_the_glyph_before_it() {
1266 let mut scratch = crate::ctx::GlyphBlitScratch::default();
1267 let colour = peniko::Color::from_rgba8(200, 100, 50, 255);
1268 let big = render_lcd(&square(20.0, 9.0)).expect("rasterizes");
1269 // Two boxes with a gap, not one filled box: the gap's pixels have
1270 // zero coverage, so a spelling that skipped writing them would leave
1271 // the big glyph's ink showing through exactly there. A filled square
1272 // covers every pixel of its own box and cannot detect the leak.
1273 let small = render_lcd(&split_box(3.0, 5.0)).expect("rasterizes");
1274
1275 // Prime the scratch with the large glyph, then draw the small one.
1276 assert!(recolour_glyph_into(
1277 &big,
1278 SubpixelPhase::Zero,
1279 colour,
1280 &mut scratch
1281 ));
1282 assert!(recolour_glyph_into(
1283 &small,
1284 SubpixelPhase::Zero,
1285 colour,
1286 &mut scratch
1287 ));
1288
1289 // The allocating spelling, which starts from a zeroed buffer.
1290 let fresh = recolour(&small.to_gray(SubpixelPhase::Zero), colour).expect("draws");
1291 assert_eq!(
1292 (scratch.pixels.width(), scratch.pixels.height()),
1293 (fresh.width(), fresh.height()),
1294 "the reused pixmap must be resized to this glyph"
1295 );
1296 assert_eq!(
1297 scratch.pixels.data(),
1298 fresh.data(),
1299 "a reused buffer must not leak the previous glyph's pixels"
1300 );
1301 }
1302
1303 /// The fused chain equals the two functions it replaces, at every phase.
1304 ///
1305 /// `recolour_glyph_into` is `to_gray` followed by `recolour`; the split
1306 /// exists to reuse memory and must not have changed a byte. The phase is
1307 /// swept because it is the one input that reaches both halves.
1308 #[test]
1309 fn the_fused_glyph_blit_is_the_two_functions_it_replaces() {
1310 let mut scratch = crate::ctx::GlyphBlitScratch::default();
1311 let colour = peniko::Color::from_rgba8(17, 200, 99, 255);
1312 let lcd = render_lcd(&square(5.0, 3.0)).expect("rasterizes");
1313 for phase in [SubpixelPhase::Zero, SubpixelPhase::One, SubpixelPhase::Two] {
1314 let expected = recolour(&lcd.to_gray(phase), colour).expect("draws");
1315 assert!(recolour_glyph_into(&lcd, phase, colour, &mut scratch));
1316 assert_eq!(
1317 scratch.pixels.data(),
1318 expected.data(),
1319 "the fused blit must be byte-identical at phase {phase:?}"
1320 );
1321 }
1322 }
1323
1324 /// A fully transparent fill draws nothing and says so.
1325 ///
1326 /// The `false` arm stands in for the `None` the allocating spelling
1327 /// returned, and a caller that blitted anyway would paint the previous
1328 /// glyph still sitting in the scratch.
1329 #[test]
1330 fn a_transparent_colour_draws_no_glyph() {
1331 let mut scratch = crate::ctx::GlyphBlitScratch::default();
1332 let lcd = render_lcd(&square(4.0, 2.0)).expect("rasterizes");
1333 assert!(!recolour_glyph_into(
1334 &lcd,
1335 SubpixelPhase::Zero,
1336 peniko::Color::from_rgba8(1, 2, 3, 0),
1337 &mut scratch
1338 ));
1339 }
1340
1341 /// The per-subpixel spelling `gray_coverage_into` replaced, kept in the
1342 /// tests as the *specification*.
1343 ///
1344 /// The same relationship `clip_at` has to `clip_span` and the span loop
1345 /// has to `blit_image`: the reference is the code that was there, written
1346 /// the slow obvious way, so that a divergence in the fast one is a failed
1347 /// comparison rather than an argument about which is right.
1348 fn gray_coverage_per_subpixel(lcd: &LcdBitmap, phase: SubpixelPhase) -> Vec<u8> {
1349 let shift = i32::try_from(phase.shift()).unwrap_or(0);
1350 let sub_width = lcd.width * 3;
1351 let mut coverage = vec![0u8; lcd.subpixels.len() / 3];
1352 for y in 0..lcd.height {
1353 let row = y * sub_width;
1354 for x in 0..lcd.width {
1355 let start = row + x * 3 - shift;
1356 let sum: i32 = (0..3)
1357 .map(|k| {
1358 let idx = start + k;
1359 if idx < row {
1360 return 0;
1361 }
1362 usize::try_from(idx)
1363 .ok()
1364 .and_then(|i| lcd.subpixels.get(i))
1365 .map_or(0, |v| i32::from(*v))
1366 })
1367 .sum();
1368 let average = (sum / 3).clamp(0, 255);
1369 let Ok(average) = usize::try_from(average) else {
1370 continue;
1371 };
1372 let gamma = TEXT_GAMMA_ADJUST.get(average).copied().unwrap_or(0);
1373 if let Ok(i) = usize::try_from(y * lcd.width + x)
1374 && let Some(cell) = coverage.get_mut(i)
1375 {
1376 *cell = gamma;
1377 }
1378 }
1379 }
1380 coverage
1381 }
1382
1383 /// The row-sliced coverage walk equals the per-subpixel one it replaced,
1384 /// on every glyph shape and at every phase.
1385 ///
1386 /// The shapes are chosen for the two things the hoist trades on: the
1387 /// **row band**, so a one-row and a many-row glyph both appear, and the
1388 /// **window's left edge** — column zero at a non-zero phase. A
1389 /// single-column glyph is in the list because for it *every* column is
1390 /// column zero.
1391 #[test]
1392 fn the_sliced_coverage_walk_matches_the_per_subpixel_one() {
1393 let shapes: [BezPath; 6] = [
1394 square(1.0, 1.0),
1395 square(1.0, 7.0),
1396 square(9.0, 1.0),
1397 square(5.0, 3.0),
1398 split_box(3.0, 5.0),
1399 split_box(1.0, 4.0),
1400 ];
1401 let mut compared = 0;
1402 for shape in &shapes {
1403 let lcd = render_lcd(shape).expect("rasterizes");
1404 for phase in [SubpixelPhase::Zero, SubpixelPhase::One, SubpixelPhase::Two] {
1405 let expected = gray_coverage_per_subpixel(&lcd, phase);
1406 let mut got = Vec::new();
1407 lcd.gray_coverage_into(phase, &mut got);
1408 assert_eq!(
1409 got, expected,
1410 "coverage differs at phase {phase:?} on a {}x{} bitmap",
1411 lcd.width, lcd.height
1412 );
1413 compared += 1;
1414 }
1415 }
1416 assert_eq!(compared, 18, "every shape must have been compared");
1417 }
1418
1419 /// Column zero at a non-zero phase reads fewer taps, and darkens.
1420 ///
1421 /// At phase two the first pixel's window starts two subpixels before the
1422 /// row, so only one of its three taps is real and the average is over
1423 /// three anyway.
1424 /// A hoist that clamped the window to the row start instead — reading
1425 /// subpixels 0, 1, 2 rather than dropping the two missing taps — would
1426 /// *brighten* that column, which is the opposite of what the C++ does.
1427 #[test]
1428 fn the_first_column_darkens_at_a_shifted_phase() {
1429 let lcd = render_lcd(&square(4.0, 2.0)).expect("rasterizes");
1430 let mut zero = Vec::new();
1431 lcd.gray_coverage_into(SubpixelPhase::Zero, &mut zero);
1432 let mut two = Vec::new();
1433 lcd.gray_coverage_into(SubpixelPhase::Two, &mut two);
1434 let width = usize::try_from(lcd.width).expect("positive");
1435 let first = |v: &[u8]| v.first().copied().expect("a non-empty bitmap");
1436 assert!(first(&zero) > 0, "the glyph must cover its first column");
1437 assert!(
1438 first(&two) < first(&zero),
1439 "phase two drops two of the first column's three taps: {} vs {}",
1440 first(&two),
1441 first(&zero)
1442 );
1443 // And the rest of the row is the same window moved, not a clamp: it
1444 // still reads three real taps.
1445 assert_eq!(
1446 two,
1447 gray_coverage_per_subpixel(&lcd, SubpixelPhase::Two),
1448 "only the first column of each row may differ from an unshifted read"
1449 );
1450 assert_eq!(
1451 two.len(),
1452 width * usize::try_from(lcd.height).expect("positive")
1453 );
1454 }
1455
1456 /// The row-zipped recolour is the indexed arithmetic it replaced, over
1457 /// **every** coverage byte and a spread of colours.
1458 ///
1459 /// The hoist changed how a coverage byte is *reached* — a zipped row slice
1460 /// rather than `coverage.get(y * stride + x)` — and not what is done with
1461 /// it. So the comparison is against the four `mul255`es spelled out here,
1462 /// swept over all 256 coverages including zero, which is the transparent
1463 /// pixel a reused buffer depends on being written rather than skipped.
1464 #[test]
1465 fn the_row_zipped_recolour_is_the_indexed_arithmetic() {
1466 for colour in [
1467 peniko::Color::from_rgba8(255, 255, 255, 255),
1468 peniko::Color::from_rgba8(0, 0, 0, 255),
1469 peniko::Color::from_rgba8(200, 100, 50, 255),
1470 peniko::Color::from_rgba8(3, 251, 128, 137),
1471 peniko::Color::from_rgba8(17, 200, 99, 1),
1472 ] {
1473 let [r, g, b, alpha] = colour.to_rgba8().to_u8_array();
1474 // Every coverage byte a glyph can carry, as a one-row bitmap.
1475 let coverage: Vec<u8> = (0..=255).collect();
1476 let bitmap = GlyphBitmapRef {
1477 width: 256,
1478 height: 1,
1479 coverage: &coverage,
1480 };
1481 let mut out = crate::Pixmap::new(0, 0);
1482 assert!(recolour_ref_into(bitmap, colour, &mut out));
1483 for (i, dest) in out.data().as_chunks::<4>().0.iter().enumerate() {
1484 #[expect(clippy::cast_possible_truncation, reason = "the index runs 0..256")]
1485 let cov = i as u8;
1486 let a = crate::pixmap::mul255(cov, alpha);
1487 assert_eq!(
1488 *dest,
1489 [
1490 crate::pixmap::mul255(r, a),
1491 crate::pixmap::mul255(g, a),
1492 crate::pixmap::mul255(b, a),
1493 a,
1494 ],
1495 "coverage {cov} under {colour:?}"
1496 );
1497 }
1498 }
1499 }
1500
1501 /// A zero-width bitmap yields no coverage rather than panicking.
1502 ///
1503 /// The row walk chunks by `width` and by `width * 3`, and `chunks_exact`
1504 /// panics on a chunk size of zero — so a degenerate bitmap needs saying
1505 /// out loud here. `render_lcd` does not produce one, and this is the
1506 /// guard for a caller that constructs an `LcdBitmap` some other way.
1507 #[test]
1508 fn a_zero_width_bitmap_yields_no_coverage() {
1509 for (width, height) in [(0, 4), (0, 0), (4, 0)] {
1510 let lcd = LcdBitmap {
1511 left: 0,
1512 top: 0,
1513 width,
1514 height,
1515 subpixels: Vec::new(),
1516 };
1517 for phase in [SubpixelPhase::Zero, SubpixelPhase::One, SubpixelPhase::Two] {
1518 // Primed with a previous glyph's bytes, so "left empty" is a
1519 // claim about this call and not about the buffer's history.
1520 let mut out = vec![7u8; 12];
1521 lcd.gray_coverage_into(phase, &mut out);
1522 assert!(
1523 out.is_empty(),
1524 "a {width}x{height} bitmap has no coverage at phase {phase:?}"
1525 );
1526 }
1527 }
1528 }
1529
1530 #[test]
1531 fn an_empty_subpixel_bitmap_averages_to_nothing() {
1532 let empty = SubpixelBitmap {
1533 left: 0,
1534 top: 0,
1535 width: 0,
1536 height: 0,
1537 channels: Vec::new(),
1538 };
1539 assert!(empty.is_empty());
1540 assert!(average_to_gray(&empty).is_none());
1541 assert_eq!(empty.at(0, 0), [0; 3]);
1542 }
1543}