glyph.rs raw

//! Glyph rendering for the software backend.
//!
//! vello rasterizes glyphs itself on the GPU; here we do the equivalent on
//! the CPU. Two routes:
//!
//! - **Hinted, cached** — when a run asks for hinting, the current
//!   transform is a uniform axis-aligned scale, and the brush is a solid
//!   color: each glyph is rasterized once (hinted by skrifa) into a small
//!   premultiplied pixmap and blitted per use. This mirrors vello's rule
//!   exactly (vello_encoding 0.9 `resolve.rs`): the scale folds into the
//!   font size, each glyph's device translation is
//!   `(tx + x·s, round(ty + y·s))` — y snapped to the pixel grid,
//!   x kept fractional (quantized here into subpixel buckets so rasters
//!   are reusable).
//! - **Outline fill** — everything else (unhinted runs, rotated/skewed
//!   transforms, non-solid brushes): glyph outlines are collected into one
//!   path and filled, as vello falls back to unhinted rendering under the
//!   same conditions. A hintable run with a non-solid brush still hints
//!   its outlines so geometry agrees with vello; it just skips the cache.

use std::collections::HashMap;

use guiduck_scene::geom::{Affine, Rect};
use guiduck_scene::paint::Brush;
use guiduck_scene::{GlyphRun, RenderError};
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{
    DrawSettings, Engine, HintingInstance, HintingOptions, OutlineGlyphCollection, OutlinePen,
    SmoothMode, Target,
};
use skrifa::{FontRef, GlyphId, MetadataProvider};

/// vello's hinting options (vello_encoding 0.9 `glyph_cache.rs`), verbatim
/// and on the same skrifa: hinted outline geometry is identical across
/// backends, so only anti-aliasing differs in the parity comparison.
const HINTING_OPTIONS: HintingOptions = HintingOptions {
    engine: Engine::AutoFallback,
    target: Target::Smooth {
        mode: SmoothMode::Lcd,
        symmetric_rendering: false,
        preserve_linear_metrics: true,
    },
};

/// Fractional-x quantization for cached rasters: a glyph at x = 10.3 reuses
/// the raster drawn for subpixel offset 0.25.
const SUBPIXEL_BUCKETS: u8 = 8;

/// Safety valve, not a tuning knob: a UI's working set (a few fonts × a few
/// sizes × its glyph repertoire × colors) stays far below this; only a
/// pathological session (e.g. animating font size) would grow past it, and
/// clearing simply re-rasterizes.
const MAX_RASTERS: usize = 4096;

/// The per-backend glyph store: rasterized hinted glyphs and the hinting
/// instances (one per font/size/variation) that produce them. Persists
/// across frames on [`TinySkiaBackend`](crate::TinySkiaBackend).
#[derive(Default)]
pub struct GlyphCache {
    rasters: HashMap<RasterKey, Option<CachedGlyph>>,
    hinters: HashMap<HinterKey, Option<HintingInstance>>,
}

/// A rasterized glyph: premultiplied pixels and the placement of their
/// top-left corner relative to the glyph's integer device position.
struct CachedGlyph {
    pixmap: tiny_skia::Pixmap,
    left: i32,
    top: i32,
}

#[derive(Clone, PartialEq, Eq, Hash)]
struct RasterKey {
    blob_id: u64,
    font_index: u32,
    glyph: u32,
    size_bits: u32,
    coords: Box<[i16]>,
    subpixel: u8,
    color: [u8; 4],
}

#[derive(Clone, PartialEq, Eq, Hash)]
struct HinterKey {
    blob_id: u64,
    font_index: u32,
    size_bits: u32,
    coords: Box<[i16]>,
}

/// How a run's glyphs meet the pixel grid under the current transform.
pub enum RunPlacement {
    /// Uniform axis-aligned scale and hinting requested: hint at
    /// `size × scale`, place at device positions with rounded y.
    Hinted { scale: f32, tx: f32, ty: f32 },
    /// Anything else: raw outlines in run-local space, positioned by the
    /// full transform at fill time.
    Raw,
}

impl RunPlacement {
    /// Apply vello's applicability rule to the current transform.
    pub fn of(run: &GlyphRun, transform: Affine) -> Self {
        let [xx, yx, xy, yy, tx, ty] = transform.as_coeffs();
        if run.hint && xx == yy && yx == 0.0 && xy == 0.0 {
            Self::Hinted {
                scale: xx as f32,
                tx: tx as f32,
                ty: ty as f32,
            }
        } else {
            Self::Raw
        }
    }
}

impl GlyphCache {
    /// Draw a run through the raster cache. Only valid for
    /// [`RunPlacement::Hinted`] with a solid brush — the callers' fast
    /// path; everything else goes through [`run_to_path`].
    #[allow(clippy::too_many_arguments)]
    pub fn draw_hinted_run(
        &mut self,
        run: &GlyphRun,
        scale: f32,
        tx: f32,
        ty: f32,
        color: [u8; 4],
        pixmap: &mut tiny_skia::Pixmap,
        mask: Option<&tiny_skia::Mask>,
    ) -> Result<(), RenderError> {
        self.placed_rasters(run, scale, tx, ty, color, |x, y, cached| {
            pixmap.draw_pixmap(
                x,
                y,
                cached.pixmap.as_ref(),
                &tiny_skia::PixmapPaint::default(),
                tiny_skia::Transform::identity(),
                mask,
            );
        })
    }

    /// The exact device-pixel bounds a hinted run's rasters cover, through
    /// the same cache the draw uses — a prepass that computes bounds
    /// populates rasters the subsequent draw reuses.
    pub fn hinted_run_bounds(
        &mut self,
        run: &GlyphRun,
        scale: f32,
        tx: f32,
        ty: f32,
        color: [u8; 4],
    ) -> Result<Option<Rect>, RenderError> {
        let mut bounds: Option<Rect> = None;
        self.placed_rasters(run, scale, tx, ty, color, |x, y, cached| {
            let rect = Rect::new(
                f64::from(x),
                f64::from(y),
                f64::from(x) + f64::from(cached.pixmap.width()),
                f64::from(y) + f64::from(cached.pixmap.height()),
            );
            bounds = Some(match bounds {
                Some(acc) => acc.union(rect),
                None => rect,
            });
        })?;
        Ok(bounds)
    }

    /// Resolve each glyph of a hinted run to its cached raster and integer
    /// device position, rasterizing on miss — the single definition of how
    /// hinted glyphs meet the pixel grid, shared by drawing and by damage
    /// bounds.
    fn placed_rasters(
        &mut self,
        run: &GlyphRun,
        scale: f32,
        tx: f32,
        ty: f32,
        color: [u8; 4],
        mut placed: impl FnMut(i32, i32, &CachedGlyph),
    ) -> Result<(), RenderError> {
        let font = FontRef::from_index(run.font.data.as_ref(), run.font.index)
            .map_err(|e| RenderError::Backend(Box::new(e)))?;
        let outlines = font.outline_glyphs();
        let size = run.size * scale;
        let coords: Box<[i16]> = run.normalized_coords.as_slice().into();

        if self.rasters.len() > MAX_RASTERS {
            self.rasters.clear();
        }

        for glyph in &run.glyphs {
            let dx = tx + glyph.x * scale;
            let dy = (ty + glyph.y * scale).round();
            let base_x = dx.floor();
            let subpixel =
                (((dx - base_x) * f32::from(SUBPIXEL_BUCKETS)) as u8).min(SUBPIXEL_BUCKETS - 1);
            let key = RasterKey {
                blob_id: run.font.data.id(),
                font_index: run.font.index,
                glyph: glyph.id,
                size_bits: size.to_bits(),
                coords: coords.clone(),
                subpixel,
                color,
            };
            if !self.rasters.contains_key(&key) {
                let hinter = Self::hinter(
                    &mut self.hinters,
                    &outlines,
                    &run.font,
                    size,
                    &run.normalized_coords,
                );
                let raster = rasterize(&outlines, glyph.id, size, subpixel, color, hinter)?;
                self.rasters.insert(key.clone(), raster);
            }
            if let Some(Some(cached)) = self.rasters.get(&key) {
                placed(base_x as i32 + cached.left, dy as i32 + cached.top, cached);
            }
        }
        Ok(())
    }

    /// The hinting instance for a font/size/variation, cached. `None` when
    /// skrifa cannot build one (the caller falls back to unhinted scaling,
    /// matching vello's glyph cache doing the same).
    fn hinter<'a>(
        hinters: &'a mut HashMap<HinterKey, Option<HintingInstance>>,
        outlines: &OutlineGlyphCollection<'_>,
        font: &guiduck_scene::paint::FontData,
        size: f32,
        normalized_coords: &[i16],
    ) -> Option<&'a HintingInstance> {
        let key = HinterKey {
            blob_id: font.data.id(),
            font_index: font.index,
            size_bits: size.to_bits(),
            coords: normalized_coords.into(),
        };
        hinters
            .entry(key)
            .or_insert_with(|| {
                let coords: Vec<NormalizedCoord> = normalized_coords
                    .iter()
                    .map(|c| NormalizedCoord::from_bits(*c))
                    .collect();
                HintingInstance::new(
                    outlines,
                    Size::new(size),
                    LocationRef::new(&coords),
                    HINTING_OPTIONS,
                )
                .ok()
            })
            .as_ref()
    }
}

/// Rasterize one glyph at a subpixel x offset into a premultiplied pixmap.
/// `None` when the glyph has no coverage (whitespace).
fn rasterize(
    outlines: &OutlineGlyphCollection<'_>,
    glyph: u32,
    size: f32,
    subpixel: u8,
    color: [u8; 4],
    hinter: Option<&HintingInstance>,
) -> Result<Option<CachedGlyph>, RenderError> {
    let Some(outline) = outlines.get(GlyphId::new(glyph)) else {
        return Ok(None);
    };
    let mut pen = RunPen {
        builder: tiny_skia::PathBuilder::new(),
        offset: (f32::from(subpixel) / f32::from(SUBPIXEL_BUCKETS), 0.0),
    };
    let settings = match hinter {
        Some(instance) => DrawSettings::hinted(instance, false),
        None => DrawSettings::unhinted(Size::new(size), LocationRef::default()),
    };
    outline
        .draw(settings, &mut pen)
        .map_err(|e| RenderError::Backend(Box::new(e)))?;
    let Some(path) = pen.builder.finish() else {
        return Ok(None);
    };

    // A pixel of padding on every side keeps anti-aliased edges inside.
    let bounds = path.bounds();
    let left = bounds.left().floor() as i32 - 1;
    let top = bounds.top().floor() as i32 - 1;
    let width = (bounds.right().ceil() as i32 - left + 1).max(1) as u32;
    let height = (bounds.bottom().ceil() as i32 - top + 1).max(1) as u32;
    let Some(mut pixmap) = tiny_skia::Pixmap::new(width, height) else {
        return Ok(None);
    };
    let paint = tiny_skia::Paint {
        shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(
            color[0], color[1], color[2], color[3],
        )),
        ..Default::default()
    };
    pixmap.fill_path(
        &path,
        &paint,
        tiny_skia::FillRule::Winding,
        tiny_skia::Transform::from_translate(-left as f32, -top as f32),
        None,
    );
    Ok(Some(CachedGlyph { pixmap, left, top }))
}

/// Build one path containing every glyph outline in the run — the fallback
/// route. For [`RunPlacement::Hinted`] the path is in *device* coordinates
/// (fill it under the identity transform); for [`RunPlacement::Raw`] it is
/// in run-local coordinates (fill it under the current transform), exactly
/// as before hinting existed. Returns `None` when nothing is drawable.
pub fn run_to_path(
    run: &GlyphRun,
    placement: &RunPlacement,
    cache: &mut GlyphCache,
) -> Result<Option<tiny_skia::Path>, RenderError> {
    let font = FontRef::from_index(run.font.data.as_ref(), run.font.index)
        .map_err(|e| RenderError::Backend(Box::new(e)))?;
    let outlines = font.outline_glyphs();
    let coords: Vec<NormalizedCoord> = run
        .normalized_coords
        .iter()
        .map(|c| NormalizedCoord::from_bits(*c))
        .collect();
    let location = LocationRef::new(&coords);

    let mut pen = RunPen {
        builder: tiny_skia::PathBuilder::new(),
        offset: (0.0, 0.0),
    };
    match placement {
        RunPlacement::Hinted { scale, tx, ty } => {
            let size = run.size * scale;
            let hinter = GlyphCache::hinter(
                &mut cache.hinters,
                &outlines,
                &run.font,
                size,
                &run.normalized_coords,
            );
            for glyph in &run.glyphs {
                let Some(outline) = outlines.get(GlyphId::new(glyph.id)) else {
                    continue;
                };
                // `DrawSettings` is not `Clone`; build it per glyph from
                // the (Copy) hinter reference.
                let settings = match hinter {
                    Some(instance) => DrawSettings::hinted(instance, false),
                    None => DrawSettings::unhinted(Size::new(size), location),
                };
                pen.offset = (tx + glyph.x * scale, (ty + glyph.y * scale).round());
                outline
                    .draw(settings, &mut pen)
                    .map_err(|e| RenderError::Backend(Box::new(e)))?;
            }
        }
        RunPlacement::Raw => {
            for glyph in &run.glyphs {
                let Some(outline) = outlines.get(GlyphId::new(glyph.id)) else {
                    continue;
                };
                pen.offset = (glyph.x, glyph.y);
                outline
                    .draw(
                        DrawSettings::unhinted(Size::new(run.size), location),
                        &mut pen,
                    )
                    .map_err(|e| RenderError::Backend(Box::new(e)))?;
            }
        }
    }
    Ok(pen.builder.finish())
}

/// The device-space bounds of a glyph run under `transform`, mirroring the
/// route the draw takes: exact raster extents for the hinted-and-solid fast
/// path, outline path bounds for everything else.
pub fn run_device_bounds(
    run: &GlyphRun,
    transform: Affine,
    cache: &mut GlyphCache,
) -> Result<Option<Rect>, RenderError> {
    let placement = RunPlacement::of(run, transform);
    match (&placement, solid_color(&run.brush)) {
        (RunPlacement::Hinted { scale, tx, ty }, Some(color)) => {
            cache.hinted_run_bounds(run, *scale, *tx, *ty, color)
        }
        _ => {
            let Some(path) = run_to_path(run, &placement, cache)? else {
                return Ok(None);
            };
            let bounds = path.bounds();
            let bounds = Rect::new(
                f64::from(bounds.left()),
                f64::from(bounds.top()),
                f64::from(bounds.right()),
                f64::from(bounds.bottom()),
            );
            Ok(Some(match placement {
                // Hinted outline paths are already in device space.
                RunPlacement::Hinted { .. } => bounds,
                RunPlacement::Raw => transform.transform_rect_bbox(bounds),
            }))
        }
    }
}

/// Whether a brush is one solid color, as rgba8 (the raster-cache key).
pub fn solid_color(brush: &Brush) -> Option<[u8; 4]> {
    match brush {
        Brush::Solid(color) => {
            let rgba = color.to_rgba8();
            Some([rgba.r, rgba.g, rgba.b, rgba.a])
        }
        _ => None,
    }
}

/// Pen translating skrifa's y-up, glyph-local coordinates into y-down space
/// at the current glyph offset.
struct RunPen {
    builder: tiny_skia::PathBuilder,
    offset: (f32, f32),
}

impl RunPen {
    fn map(&self, x: f32, y: f32) -> (f32, f32) {
        (self.offset.0 + x, self.offset.1 - y)
    }
}

impl OutlinePen for RunPen {
    fn move_to(&mut self, x: f32, y: f32) {
        let (x, y) = self.map(x, y);
        self.builder.move_to(x, y);
    }

    fn line_to(&mut self, x: f32, y: f32) {
        let (x, y) = self.map(x, y);
        self.builder.line_to(x, y);
    }

    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
        let (cx0, cy0) = self.map(cx0, cy0);
        let (x, y) = self.map(x, y);
        self.builder.quad_to(cx0, cy0, x, y);
    }

    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
        let (cx0, cy0) = self.map(cx0, cy0);
        let (cx1, cy1) = self.map(cx1, cy1);
        let (x, y) = self.map(x, y);
        self.builder.cubic_to(cx0, cy0, cx1, cy1, x, y);
    }

    fn close(&mut self) {
        self.builder.close();
    }
}