glifo 0.1.0

Glifo provides APIs for efficiently rendering text.
Documentation
// Copyright 2026 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Cache key for glyph bitmaps stored in the atlas.
//!
//! [`GlyphCacheKey`] captures every parameter that affects the visual appearance
//! of a rasterized glyph — font identity, size, hinting, subpixel position,
//! COLR context color, and variable-font coordinates. Two keys that compare
//! equal produce identical bitmaps and can safely share a single atlas entry.

use crate::color::{AlphaColor, Srgb};
use crate::glyph::FontEmbolden;
use crate::kurbo::Join;
use core::hash::{Hash, Hasher};
#[cfg(not(feature = "std"))]
use core_maths::CoreFloat as _;
use skrifa::instance::NormalizedCoord;
use smallvec::SmallVec;

/// Number of horizontal subpixel quantization buckets (valid range: 1–253).
///
/// Higher values improve rendering quality at the cost of more atlas entries
/// per glyph. Common values: 1 (disabled), 2, 4 (default), 8.
pub(crate) const SUBPIXEL_BUCKETS: u8 = 4;

/// Sentinel `subpixel_x` for COLR glyph cache entries.
///
/// `quantize_subpixel` returns values in `0..SUBPIXEL_BUCKETS`, so values
/// above that range can never appear in an outline key. Using distinct
/// sentinels for COLR and bitmap entries prevents cache collisions between
/// glyph types that would otherwise produce identical keys (same font, glyph
/// id, size, and color).
pub(crate) const SUBPIXEL_COLR: u8 = SUBPIXEL_BUCKETS;

/// Sentinel `subpixel_x` for bitmap glyph cache entries. See [`SUBPIXEL_COLR`].
pub(crate) const SUBPIXEL_BITMAP: u8 = SUBPIXEL_BUCKETS + 1;

/// Unique identifier for a cached glyph bitmap.
///
/// Two glyphs with the same key are visually identical and can share
/// the same cached bitmap. The key includes all parameters that affect
/// the glyph's appearance.
///
/// `var_coords` is deliberately excluded from `Hash`/`Eq` because the
/// [`GlyphAtlas`](crate::atlas::cache::GlyphAtlas) uses a two-level map
/// structure that already partitions entries by variation coordinates.
/// Callers that use a flat map must ensure equivalent `var_coords`
/// externally.
#[derive(Clone, Debug)]
pub struct GlyphCacheKey {
    /// Unique identifier for the font blob.
    pub font_id: u64,
    /// Index within font collection (for TTC files).
    pub font_index: u32,
    /// Glyph index within the font.
    pub glyph_id: u32,
    /// Font size as f32 bits (exact match, no quantization).
    pub size_bits: u32,
    /// Whether hinting was applied.
    pub hinted: bool,
    /// Horizontal subpixel position (0 to SUBPIXEL_BUCKETS-1 for outlines),
    /// or a sentinel (`SUBPIXEL_COLR` / `SUBPIXEL_BITMAP`) for non-outline glyphs.
    pub subpixel_x: u8,
    /// Context color for COLR glyphs. Only used for rendering, not for Hash/Eq.
    pub context_color: AlphaColor<Srgb>,
    /// Pre-packed context color (premultiplied RGBA8 as u32) used in Hash/Eq.
    pub context_color_packed: u32,
    /// Synthetic embolden amount. Only non-zero for outline glyphs.
    pub embolden_x_bits: u32,
    /// Synthetic embolden amount. Only non-zero for outline glyphs.
    pub embolden_y_bits: u32,
    /// Join style for synthetic embolden. Only meaningful for outline glyphs.
    pub embolden_join_bits: u8,
    /// Miter limit for synthetic embolden. Only meaningful for outline glyphs.
    pub embolden_miter_limit_bits: u32,
    /// Tolerance for synthetic embolden. Only meaningful for outline glyphs.
    pub embolden_tolerance_bits: u32,
    /// Variation coordinates for variable fonts.
    pub var_coords: SmallVec<[NormalizedCoord; 4]>,
}

impl GlyphCacheKey {
    /// Creates a new cache key.
    ///
    /// `fractional_x` (the fractional pixel offset) is quantized into
    /// `SUBPIXEL_BUCKETS` buckets, so nearby positions share the same entry.
    #[inline]
    pub fn new(
        font_id: u64,
        font_index: u32,
        glyph_id: u32,
        size: f32,
        hinted: bool,
        fractional_x: f32,
        context_color: AlphaColor<Srgb>,
        context_color_packed: u32,
        embolden: FontEmbolden,
        var_coords: &[NormalizedCoord],
    ) -> Self {
        Self {
            font_id,
            font_index,
            glyph_id,
            size_bits: size.to_bits(),
            hinted,
            subpixel_x: quantize_subpixel(fractional_x),
            context_color,
            context_color_packed,
            embolden_x_bits: f32_bits(embolden.amount.xx),
            embolden_y_bits: f32_bits(embolden.amount.yy),
            embolden_join_bits: join_bits(embolden.join),
            embolden_miter_limit_bits: f32_bits(embolden.miter_limit),
            embolden_tolerance_bits: f32_bits(embolden.tolerance),
            var_coords: SmallVec::from_slice(var_coords),
        }
    }
}

/// Manual `Hash` and `PartialEq` use the pre-packed `context_color_packed` field
/// (a premultiplied RGBA8 `u32`) instead of `AlphaColor<Srgb>`, which doesn't
/// implement `Hash`/`Eq`. Packing once at construction avoids repeated work
/// during lookups. `glyph_id` is compared first for early short-circuit.
impl Hash for GlyphCacheKey {
    #[inline]
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.font_id.hash(state);
        self.font_index.hash(state);
        self.glyph_id.hash(state);
        self.size_bits.hash(state);
        self.hinted.hash(state);
        self.subpixel_x.hash(state);
        self.context_color_packed.hash(state);
        self.embolden_x_bits.hash(state);
        self.embolden_y_bits.hash(state);
        self.embolden_join_bits.hash(state);
        self.embolden_miter_limit_bits.hash(state);
        self.embolden_tolerance_bits.hash(state);
    }
}

impl PartialEq for GlyphCacheKey {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.glyph_id == other.glyph_id
            && self.subpixel_x == other.subpixel_x
            && self.font_id == other.font_id
            && self.font_index == other.font_index
            && self.size_bits == other.size_bits
            && self.hinted == other.hinted
            && self.context_color_packed == other.context_color_packed
            && self.embolden_x_bits == other.embolden_x_bits
            && self.embolden_y_bits == other.embolden_y_bits
            && self.embolden_join_bits == other.embolden_join_bits
            && self.embolden_miter_limit_bits == other.embolden_miter_limit_bits
            && self.embolden_tolerance_bits == other.embolden_tolerance_bits
    }
}

impl Eq for GlyphCacheKey {}

#[inline(always)]
fn join_bits(join: Join) -> u8 {
    match join {
        Join::Bevel => 0,
        Join::Miter => 1,
        Join::Round => 2,
    }
}

#[expect(
    clippy::cast_possible_truncation,
    reason = "Cache keys intentionally store embolden parameters at f32 precision."
)]
#[inline(always)]
fn f32_bits(value: f64) -> u32 {
    (value as f32).to_bits()
}

/// Premultiply and pack an RGBA color into a `u32` for bitwise hashing/comparison.
#[inline]
pub(crate) fn pack_color(color: AlphaColor<Srgb>) -> u32 {
    color.premultiply().to_rgba8().to_u32()
}

/// Quantize a fractional pixel offset into one of [`SUBPIXEL_BUCKETS`] buckets.
///
/// Values near 1.0 (>= 0.875 with 4 buckets) are clamped to the last bucket
/// rather than wrapping to 0. Wrapping to bucket 0 without also incrementing the
/// integer pixel coordinate would shift the glyph by ~0.75px in the wrong
/// direction. Clamping keeps the worst-case error to 0.125px.
#[expect(
    clippy::cast_possible_truncation,
    reason = "result is clamped to SUBPIXEL_BUCKETS-1 which fits in u8"
)]
#[inline]
fn quantize_subpixel(frac: f32) -> u8 {
    let normalized = frac.fract();
    let normalized = if normalized < 0.0 {
        normalized + 1.0
    } else {
        normalized
    };
    ((normalized * SUBPIXEL_BUCKETS as f32).round() as u8).min(SUBPIXEL_BUCKETS - 1)
}

/// Convert a quantized bucket index back to the fractional pixel offset it represents.
#[inline]
pub fn subpixel_offset(quantized: u8) -> f32 {
    quantized as f32 / SUBPIXEL_BUCKETS as f32
}

#[cfg(test)]
mod tests {
    use crate::color::palette::css::BLACK;

    use super::*;

    #[test]
    fn test_quantize_subpixel() {
        // Test bucket boundaries
        assert_eq!(quantize_subpixel(0.0), 0);
        assert_eq!(quantize_subpixel(0.1), 0);
        assert_eq!(quantize_subpixel(0.2), 1);
        assert_eq!(quantize_subpixel(0.25), 1);
        assert_eq!(quantize_subpixel(0.4), 2);
        assert_eq!(quantize_subpixel(0.5), 2);
        assert_eq!(quantize_subpixel(0.6), 2);
        assert_eq!(quantize_subpixel(0.7), 3);
        assert_eq!(quantize_subpixel(0.75), 3);
        assert_eq!(quantize_subpixel(0.9), 3);
        assert_eq!(quantize_subpixel(1.0), 0);
    }

    #[test]
    fn test_subpixel_offset() {
        assert_eq!(subpixel_offset(0), 0.0);
        assert_eq!(subpixel_offset(1), 0.25);
        assert_eq!(subpixel_offset(2), 0.5);
        assert_eq!(subpixel_offset(3), 0.75);
    }

    #[test]
    fn test_key_equality() {
        let packed = pack_color(BLACK);
        let key1 = GlyphCacheKey::new(
            1,
            0,
            42,
            16.0,
            true,
            0.3,
            BLACK,
            packed,
            FontEmbolden::default(),
            &[],
        );
        let key2 = GlyphCacheKey::new(
            1,
            0,
            42,
            16.0,
            true,
            0.3,
            BLACK,
            packed,
            FontEmbolden::default(),
            &[],
        );
        assert_eq!(key1, key2);
    }

    #[test]
    fn test_outline_colr_bitmap_keys_never_collide() {
        let packed = pack_color(BLACK);
        let outline_key = GlyphCacheKey::new(
            1,
            0,
            42,
            16.0,
            false,
            0.0,
            BLACK,
            packed,
            FontEmbolden::default(),
            &[],
        );
        let colr_key = GlyphCacheKey {
            font_id: 1,
            font_index: 0,
            glyph_id: 42,
            size_bits: 16.0_f32.to_bits(),
            hinted: false,
            subpixel_x: SUBPIXEL_COLR,
            context_color: BLACK,
            context_color_packed: packed,
            embolden_x_bits: 0,
            embolden_y_bits: 0,
            embolden_join_bits: join_bits(Join::Miter),
            embolden_miter_limit_bits: 4.0_f32.to_bits(),
            embolden_tolerance_bits: 0.1_f32.to_bits(),
            var_coords: SmallVec::new(),
        };
        let bitmap_key = GlyphCacheKey {
            font_id: 1,
            font_index: 0,
            glyph_id: 42,
            size_bits: 16.0_f32.to_bits(),
            hinted: false,
            subpixel_x: SUBPIXEL_BITMAP,
            context_color: BLACK,
            context_color_packed: packed,
            embolden_x_bits: 0,
            embolden_y_bits: 0,
            embolden_join_bits: join_bits(Join::Miter),
            embolden_miter_limit_bits: 4.0_f32.to_bits(),
            embolden_tolerance_bits: 0.1_f32.to_bits(),
            var_coords: SmallVec::new(),
        };
        assert_ne!(outline_key, colr_key);
        assert_ne!(outline_key, bitmap_key);
        assert_ne!(colr_key, bitmap_key);
    }

    #[test]
    fn test_sentinels_unreachable_by_quantize() {
        for i in 0..=255_u8 {
            let frac = i as f32 / 255.0;
            let bucket = quantize_subpixel(frac);
            assert!(bucket < SUBPIXEL_BUCKETS, "bucket {bucket} for frac {frac}");
        }
    }

    #[test]
    fn test_var_coords_excluded_from_equality() {
        let packed = pack_color(BLACK);
        // var_coords is excluded from Hash/Eq (two-level map handles it),
        // so keys differing only in var_coords are considered equal.
        let key1 = GlyphCacheKey::new(
            1,
            0,
            42,
            16.0,
            true,
            0.3,
            BLACK,
            packed,
            FontEmbolden::default(),
            &[],
        );
        let key2 = GlyphCacheKey::new(
            1,
            0,
            42,
            16.0,
            true,
            0.3,
            BLACK,
            packed,
            FontEmbolden::default(),
            &[NormalizedCoord::from_bits(100)],
        );
        assert_eq!(key1, key2);
    }
}