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