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