Skip to main content

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}