Skip to main content

cranpose_render_common/
text_mask_gamma.rs

1//! Skia's glyph mask gamma: the coverage correction Jetpack Compose's text
2//! gets before it is blended.
3//!
4//! Every Cranpose target blends in encoded sRGB, where raw glyph coverage
5//! thins light text on a dark background and thickens dark text on a light
6//! one. Skia corrects each A8 glyph mask for the text's luminance: it
7//! assumes the destination has the opposite luminance, picks the coverage
8//! whose encoded blend equals the blend in linear light, and boosts contrast
9//! by an amount that fades out as the text approaches white. This is
10//! `SkTMaskGamma<3, 3, 3>` with Skia's default parameters (sRGB transfer,
11//! contrast 0.5), which both Android's HWUI and Compose Desktop use.
12
13use std::sync::OnceLock;
14
15use cranpose_ui_graphics::{Brush, Color};
16
17const LUMINANCE_BITS: u32 = 3;
18const LUMINANCE_CLASSES: usize = 1 << LUMINANCE_BITS;
19const CONTRAST: f32 = 0.5;
20
21type CorrectionTable = [u8; 256];
22
23/// The luminance class a glyph mask is corrected for. Text colors whose
24/// luminance falls in the same eighth share a correction, so a glyph cached
25/// for one of them serves them all.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct TextLuminance(u8);
28
29impl TextLuminance {
30    /// The class of text drawn in `color`; alpha does not affect it.
31    pub fn of_color(color: Color) -> Self {
32        let channel = |value: f32| u32::from(unit_to_u8(value));
33        let luminance =
34            (channel(color.0) * 54 + channel(color.1) * 183 + channel(color.2) * 19) >> 8;
35        Self((luminance >> (8 - LUMINANCE_BITS)) as u8)
36    }
37
38    /// The class of text painted with `brush`. Skia corrects text a shader
39    /// paints as mid grey, since no single color describes it.
40    pub fn of_brush(brush: &Brush) -> Self {
41        match brush {
42            Brush::Solid(color) => Self::of_color(*color),
43            _ => Self::of_color(Color(0.5, 0.5, 0.5, 1.0)),
44        }
45    }
46
47    /// The correction for glyph masks of this class.
48    pub fn correction(self) -> MaskGammaCorrection {
49        MaskGammaCorrection(&correction_tables()[usize::from(self.0)])
50    }
51}
52
53/// One luminance class's coverage correction, resolved once per glyph so
54/// its pixels only index a table.
55#[derive(Clone, Copy)]
56pub struct MaskGammaCorrection(&'static CorrectionTable);
57
58impl MaskGammaCorrection {
59    /// The 8-bit mask value to blend in place of `coverage`.
60    pub fn apply(self, coverage: f32) -> u8 {
61        self.0[usize::from(unit_to_u8(coverage))]
62    }
63
64    /// [`Self::apply`] as a unit coverage.
65    pub fn apply_unit(self, coverage: f32) -> f32 {
66        f32::from(self.apply(coverage)) / 255.0
67    }
68}
69
70fn unit_to_u8(value: f32) -> u8 {
71    (value.clamp(0.0, 1.0) * 255.0).round() as u8
72}
73
74fn correction_tables() -> &'static [CorrectionTable; LUMINANCE_CLASSES] {
75    static TABLES: OnceLock<[CorrectionTable; LUMINANCE_CLASSES]> = OnceLock::new();
76    TABLES.get_or_init(|| std::array::from_fn(correction_table))
77}
78
79/// `SkTMaskGamma_build_correcting_lut` for the class's representative
80/// luminance: the class index repeated across eight bits, as
81/// `sk_t_scale255` spreads it. No class sits within 1/256 of mid grey, so
82/// the source and the guessed destination always differ.
83fn correction_table(class: usize) -> CorrectionTable {
84    let class = class as u32;
85    let representative = (class << 5) | (class << 2) | (class >> 1);
86    let src = representative as f32 / 255.0;
87    let dst = 1.0 - src;
88    let linear_src = srgb_to_linear(src);
89    let linear_dst = srgb_to_linear(dst);
90    let contrast = CONTRAST * linear_dst;
91    std::array::from_fn(|index| {
92        let raw = index as f32 / 255.0;
93        let boosted = raw + (1.0 - raw) * contrast * raw;
94        let blended = linear_to_srgb(linear_src * boosted + (1.0 - boosted) * linear_dst);
95        unit_to_u8((blended - dst) / (src - dst))
96    })
97}
98
99fn srgb_to_linear(value: f32) -> f32 {
100    if value <= 0.04045 {
101        value / 12.92
102    } else {
103        ((value + 0.055) / 1.055).powf(2.4)
104    }
105}
106
107fn linear_to_srgb(value: f32) -> f32 {
108    if value <= 0.003_130_8 {
109        value * 12.92
110    } else {
111        1.055 * value.powf(1.0 / 2.4) - 0.055
112    }
113}
114
115#[cfg(test)]
116#[path = "tests/text_mask_gamma_tests.rs"]
117mod tests;