Skip to main content

valo_text/
raster.rs

1use std::sync::Arc;
2
3use skrifa::instance::Size;
4use skrifa::outline::{DrawSettings, OutlinePen};
5use skrifa::MetadataProvider;
6use valo_geometry::{Cap, Join, Path, PathBuilder, Rect};
7
8use crate::font::Font;
9
10/// `SDF_PAD` is the padding and maximum encoded distance around an SDF glyph.
11///
12/// Distances saturate this many pixels inside or outside the glyph edge.
13pub const SDF_PAD: u32 = 8;
14
15/// `GlyphStroke` describes an outline applied before rasterizing a glyph.
16///
17/// It is equivalent to [`valo_geometry::Stroke`] without a dash pattern.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct GlyphStroke {
20    /// `width` is the full stroke width in raster pixels.
21    pub width: f32,
22    /// `cap` controls the ends of open outline contours.
23    pub cap: Cap,
24    /// `join` controls how consecutive outline segments meet.
25    pub join: Join,
26    /// `miter_limit` is the maximum miter length divided by half the stroke width.
27    pub miter_limit: f32,
28}
29
30/// `GlyphImage` contains rasterized glyph pixels and baseline-relative placement.
31///
32/// Alpha and SDF images store one byte per pixel. Color images store
33/// premultiplied RGBA8. To place the bitmap at glyph origin `(x, y)`, draw its
34/// top-left at `(x + left, y - top)`.
35pub struct GlyphImage {
36    /// `width` is the bitmap width in pixels.
37    pub width: u32,
38    /// `height` is the bitmap height in pixels.
39    pub height: u32,
40    /// `left` is the bitmap's horizontal offset from the glyph origin.
41    pub left: i32,
42    /// `top` is the bitmap's upward offset from the baseline.
43    pub top: i32,
44    /// `data` contains tightly packed rows in the format produced by the raster method.
45    pub data: Vec<u8>,
46}
47
48/// `GlyphRaster` turns one glyph of a font into pixels or an outline.
49///
50/// It is the renderer's only way to a glyph's shape: everything else in text
51/// (shaping, layout, the glyph cache) reads a font's metric and shaping tables,
52/// which a font without outline tables still has. [`Rasterizer`] is the
53/// implementation over the font's own bytes; a host whose glyphs are rendered
54/// elsewhere, such as a program running behind a boundary, supplies another.
55/// All sizes are in raster pixels, and the results are as [`Rasterizer`]'s
56/// methods document them.
57pub trait GlyphRaster: Send {
58    /// See [`Rasterizer::alpha`].
59    fn alpha(&mut self, font: &Font, glyph: u32, px: f32, dx: f32) -> Option<GlyphImage>;
60
61    /// See [`Rasterizer::stroked`].
62    fn stroked(
63        &mut self,
64        font: &Font,
65        glyph: u32,
66        px: f32,
67        dx: f32,
68        stroke: &GlyphStroke,
69    ) -> Option<GlyphImage>;
70
71    /// See [`Rasterizer::sdf`].
72    fn sdf(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage>;
73
74    /// See [`Rasterizer::color`].
75    fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage>;
76
77    /// See [`glyph_path`].
78    fn path(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Arc<Path>>;
79}
80
81impl GlyphRaster for Rasterizer {
82    fn alpha(&mut self, font: &Font, glyph: u32, px: f32, dx: f32) -> Option<GlyphImage> {
83        Rasterizer::alpha(self, font, glyph, px, dx)
84    }
85
86    fn stroked(
87        &mut self,
88        font: &Font,
89        glyph: u32,
90        px: f32,
91        dx: f32,
92        stroke: &GlyphStroke,
93    ) -> Option<GlyphImage> {
94        Rasterizer::stroked(self, font, glyph, px, dx, stroke)
95    }
96
97    fn sdf(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
98        Rasterizer::sdf(self, font, glyph, px)
99    }
100
101    fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
102        Rasterizer::color(self, font, glyph, px)
103    }
104
105    fn path(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Arc<Path>> {
106        glyph_path(font, glyph, px)
107    }
108}
109
110/// `Rasterizer` converts font glyphs into CPU bitmap or distance-field images.
111///
112/// Valo's renderer owns one internally. Hosts need this type only when building
113/// a custom glyph cache or text renderer. Reuse an instance to retain scaling
114/// and stroking scratch state; its methods require mutable access.
115#[derive(Default)]
116pub struct Rasterizer {
117    context: swash::scale::ScaleContext,
118    stroker: tiny_skia::PathStroker,
119}
120
121impl Rasterizer {
122    /// `new` creates an empty CPU glyph rasterizer.
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// `alpha` rasterizes a glyph into one-byte alpha coverage.
128    ///
129    /// `px` is the font size in raster pixels. `dx` shifts the outline
130    /// horizontally for subpixel positioning and is usually one of
131    /// `0.0`, `0.25`, `0.5`, or `0.75`. It returns `None` when the glyph has no
132    /// rasterizable monochrome outline.
133    pub fn alpha(&mut self, font: &Font, glyph: u32, px: f32, dx: f32) -> Option<GlyphImage> {
134        let image = self.render(font, glyph, px, dx)?;
135        Some(GlyphImage {
136            width: image.placement.width,
137            height: image.placement.height,
138            left: image.placement.left,
139            top: image.placement.top,
140            data: image.data,
141        })
142    }
143
144    /// `stroked` rasterizes a stroked glyph outline into one-byte alpha coverage.
145    ///
146    /// `px`, `dx`, and stroke dimensions are in raster pixels. It returns
147    /// `None` when the glyph has no outline or the stroked path cannot be built.
148    pub fn stroked(
149        &mut self,
150        font: &Font,
151        glyph: u32,
152        px: f32,
153        dx: f32,
154        stroke: &GlyphStroke,
155    ) -> Option<GlyphImage> {
156        let outline = glyph_outline(font, glyph, px, dx)?;
157        let stroked = self.stroker.stroke(&outline, &skia_stroke(stroke), 1.0)?;
158        mask_of(&stroked)
159    }
160
161    /// `sdf` rasterizes a glyph into a one-byte signed distance field.
162    ///
163    /// `px` is the font size in raster pixels. A value near 128 marks the edge;
164    /// larger values are inside and smaller values are outside. The image is
165    /// padded by [`SDF_PAD`] pixels. It returns `None` when no monochrome
166    /// outline can be rasterized.
167    pub fn sdf(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
168        let alpha = self.render(font, glyph, px, 0.0)?;
169        let pad = SDF_PAD;
170        let w = alpha.placement.width + 2 * pad;
171        let h = alpha.placement.height + 2 * pad;
172        let mut coverage = vec![0u8; (w * h) as usize];
173        for y in 0..alpha.placement.height {
174            for x in 0..alpha.placement.width {
175                coverage[((y + pad) * w + x + pad) as usize] =
176                    alpha.data[(y * alpha.placement.width + x) as usize];
177            }
178        }
179        let field = crate::sdf::signed_distances(&coverage, w as usize, h as usize);
180        Some(GlyphImage {
181            width: w,
182            height: h,
183            left: alpha.placement.left - pad as i32,
184            top: alpha.placement.top + pad as i32,
185            data: crate::sdf::encode(&field, SDF_PAD as f32),
186        })
187    }
188
189    /// `color` rasterizes a color glyph into premultiplied RGBA8 pixels.
190    ///
191    /// `px` is the font size in raster pixels. It supports color outlines and
192    /// embedded color bitmaps. It returns `None` when the glyph has no supported
193    /// color representation, allowing callers to fall back to alpha rendering.
194    pub fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
195        let font_ref = swash::FontRef::from_index(font.data(), font.face_index() as usize)?;
196        let mut scaler = self
197            .context
198            .builder(font_ref)
199            .size(px)
200            .hint(false)
201            .variations(swash_variations(font))
202            .build();
203        let image = swash::scale::Render::new(&[
204            swash::scale::Source::ColorOutline(0),
205            swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
206        ])
207        .render(&mut scaler, glyph as swash::GlyphId);
208        let Some(image) = image else {
209            // swash covers CBDT bitmaps and COLRv0 layers; COLRv1 paint
210            // graphs raster through the skrifa painter.
211            return crate::colr::raster(font, glyph, px);
212        };
213        if image.content != swash::scale::image::Content::Color {
214            return crate::colr::raster(font, glyph, px);
215        }
216        let mut data = image.data;
217        for px in data.chunks_exact_mut(4) {
218            let a = px[3] as u32;
219            // Round half up — truncation biases emoji a hair dark and
220            // breaks the round-trip with export's unpremultiply.
221            px[0] = ((px[0] as u32 * a + 127) / 255) as u8;
222            px[1] = ((px[1] as u32 * a + 127) / 255) as u8;
223            px[2] = ((px[2] as u32 * a + 127) / 255) as u8;
224        }
225        Some(GlyphImage {
226            width: image.placement.width,
227            height: image.placement.height,
228            left: image.placement.left,
229            top: image.placement.top,
230            data,
231        })
232    }
233
234    /// Tight non-transparent pixel bounds for a bitmap/color glyph, relative
235    /// to its baseline origin in Valo's y-down coordinates. Metrics query this
236    /// before a monochrome outline because rendering also prefers COLR/CBDT.
237    pub(crate) fn color_bounds(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Rect> {
238        let image = self.color(font, glyph, px)?;
239        let mut left = image.width;
240        let mut top = image.height;
241        let mut right = 0;
242        let mut bottom = 0;
243        for y in 0..image.height {
244            for x in 0..image.width {
245                let alpha = image.data[((y * image.width + x) * 4 + 3) as usize];
246                if alpha == 0 {
247                    continue;
248                }
249                left = left.min(x);
250                top = top.min(y);
251                right = right.max(x + 1);
252                bottom = bottom.max(y + 1);
253            }
254        }
255        (left < right && top < bottom).then(|| {
256            Rect::new(
257                image.left as f32 + left as f32,
258                -image.top as f32 + top as f32,
259                (right - left) as f32,
260                (bottom - top) as f32,
261            )
262        })
263    }
264
265    fn render(
266        &mut self,
267        font: &Font,
268        glyph: u32,
269        px: f32,
270        dx: f32,
271    ) -> Option<swash::scale::image::Image> {
272        let font_ref = swash::FontRef::from_index(font.data(), font.face_index() as usize)?;
273        let mut scaler = self
274            .context
275            .builder(font_ref)
276            .size(px)
277            .hint(false)
278            .variations(swash_variations(font))
279            .build();
280        swash::scale::Render::new(&[swash::scale::Source::Outline])
281            .offset(swash::zeno::Vector::new(dx, 0.0))
282            .render(&mut scaler, glyph as swash::GlyphId)
283    }
284}
285
286fn skia_stroke(stroke: &GlyphStroke) -> tiny_skia::Stroke {
287    tiny_skia::Stroke {
288        width: stroke.width,
289        miter_limit: stroke.miter_limit,
290        line_cap: match stroke.cap {
291            Cap::Butt => tiny_skia::LineCap::Butt,
292            Cap::Round => tiny_skia::LineCap::Round,
293            Cap::Square => tiny_skia::LineCap::Square,
294        },
295        line_join: match stroke.join {
296            Join::Miter => tiny_skia::LineJoin::Miter,
297            Join::Round => tiny_skia::LineJoin::Round,
298            Join::Bevel => tiny_skia::LineJoin::Bevel,
299        },
300        dash: None,
301    }
302}
303
304/// The glyph as a tiny-skia path at `px`, baseline origin, y-down, shifted
305/// by the subpixel x-phase — device space, so the stroke width needs no
306/// further scaling.
307fn glyph_outline(font: &Font, glyph: u32, px: f32, dx: f32) -> Option<tiny_skia::Path> {
308    let font_ref = skrifa::FontRef::from_index(font.data(), font.face_index()).ok()?;
309    let outline = font_ref.outline_glyphs().get(skrifa::GlyphId::new(glyph))?;
310    let mut pen = TsPathPen::default();
311    outline
312        .draw(
313            DrawSettings::unhinted(Size::new(px), font.variation_location()),
314            &mut pen,
315        )
316        .ok()?;
317    pen.builder
318        .finish()?
319        .transform(tiny_skia::Transform::from_row(1.0, 0.0, 0.0, -1.0, dx, 0.0))
320}
321
322/// A device-space path as an A8 image placed the way swash places its own:
323/// `left` right of the origin, `top` above the baseline. Flooring the
324/// tight bounds out to whole pixels is exactly the set of pixels the
325/// antialiased fill can touch, so the cell is never short.
326fn mask_of(path: &tiny_skia::Path) -> Option<GlyphImage> {
327    let bounds = path.compute_tight_bounds()?;
328    let (left, top) = (bounds.left().floor(), bounds.top().floor());
329    let width = (bounds.right().ceil() - left) as u32;
330    let height = (bounds.bottom().ceil() - top) as u32;
331    let mut mask = tiny_skia::Mask::new(width, height)?;
332    mask.fill_path(
333        path,
334        tiny_skia::FillRule::Winding,
335        true,
336        tiny_skia::Transform::from_translate(-left, -top),
337    );
338    Some(GlyphImage {
339        width,
340        height,
341        left: left as i32,
342        top: -top as i32,
343        data: mask.data().to_vec(),
344    })
345}
346
347/// `glyph_path` returns a glyph outline as a baseline-relative Valo path.
348///
349/// `px` is the font size in logical pixels. The path uses Valo's y-down
350/// coordinates with its origin on the baseline. It returns `None` when the
351/// glyph has no nonempty monochrome outline.
352pub fn glyph_path(font: &Font, glyph: u32, px: f32) -> Option<Arc<Path>> {
353    let font_ref = skrifa::FontRef::from_index(font.data(), font.face_index()).ok()?;
354    let outline = font_ref.outline_glyphs().get(skrifa::GlyphId::new(glyph))?;
355    let mut pen = PathPen {
356        builder: PathBuilder::new(),
357    };
358    outline
359        .draw(
360            DrawSettings::unhinted(Size::new(px), font.variation_location()),
361            &mut pen,
362        )
363        .ok()?;
364    let path = pen.builder.build();
365    // COLR fonts carry EMPTY classic outlines for their color glyphs — an
366    // empty path IS "no outline", so callers take their color fallback
367    // instead of stencil-filling nothing (the >path_min emoji vanish bug).
368    (!path.is_empty()).then_some(path)
369}
370
371/// skrifa outline pen → tiny-skia path, in whatever units the draw was
372/// scaled to and y-up; the consumer's transform does the flip.
373#[derive(Default)]
374pub(crate) struct TsPathPen {
375    pub(crate) builder: tiny_skia::PathBuilder,
376}
377
378impl OutlinePen for TsPathPen {
379    fn move_to(&mut self, x: f32, y: f32) {
380        self.builder.move_to(x, y);
381    }
382
383    fn line_to(&mut self, x: f32, y: f32) {
384        self.builder.line_to(x, y);
385    }
386
387    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
388        self.builder.quad_to(cx, cy, x, y);
389    }
390
391    fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
392        self.builder.cubic_to(c0x, c0y, c1x, c1y, x, y);
393    }
394
395    fn close(&mut self) {
396        self.builder.close();
397    }
398}
399
400/// skrifa pen → PathBuilder, flipping y (fonts are y-up, canvases y-down).
401struct PathPen {
402    builder: PathBuilder,
403}
404
405impl OutlinePen for PathPen {
406    fn move_to(&mut self, x: f32, y: f32) {
407        self.builder.move_to((x, -y));
408    }
409
410    fn line_to(&mut self, x: f32, y: f32) {
411        self.builder.line_to((x, -y));
412    }
413
414    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
415        self.builder.quad_to((cx, -cy), (x, -y));
416    }
417
418    fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
419        self.builder.cubic_to((c0x, -c0y), (c1x, -c1y), (x, -y));
420    }
421
422    fn close(&mut self) {
423        self.builder.close();
424    }
425}
426
427/// A font's variation coordinates in swash's setting form (named
428/// instances rasterize at their own axis positions).
429fn swash_variations(font: &Font) -> impl Iterator<Item = (&str, f32)> + '_ {
430    font.variation_coordinates()
431        .iter()
432        .filter_map(|(tag, value)| Some((std::str::from_utf8(tag).ok()?, *value)))
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::font::FaceSet;
439
440    fn fira() -> FaceSet {
441        let path = concat!(
442            env!("CARGO_MANIFEST_DIR"),
443            "/../../assets/fonts/fira_sans.ttf"
444        );
445        let mut c = FaceSet::default();
446        c.register("Fira Sans", std::fs::read(path).unwrap())
447            .unwrap();
448        c
449    }
450
451    /// The atlas cell is nothing but the raster's own placement, so the
452    /// stroked raster has to come out already containing the miter spikes.
453    /// Fira Sans `M` spikes 8.6px above its own outline at 72px with a
454    /// 5px miter stroke — a cell inflated by a flat half-width (2.5px)
455    /// would cut 6px off it, and nothing downstream could tell.
456    #[test]
457    fn stroked_raster_bounds_hold_the_miter_spikes() {
458        let fonts = fira();
459        let font = fonts.family("Fira Sans").unwrap();
460        let mut raster = Rasterizer::new();
461        let stroke = GlyphStroke {
462            width: 5.0,
463            cap: Cap::Butt,
464            join: Join::Miter,
465            miter_limit: 16.0,
466        };
467        let glyph = fonts.get(font).glyph_for('M').unwrap();
468        let fill = raster.alpha(fonts.get(font), glyph, 72.0, 0.0).unwrap();
469        let stroked = raster
470            .stroked(fonts.get(font), glyph, 72.0, 0.0, &stroke)
471            .unwrap();
472        let reach = (stroked.top - fill.top) as f32;
473        assert!(
474            reach > stroke.width,
475            "the stroked cell reaches only {reach}px above the fill — a \
476             miter spike of 8.6px does not fit"
477        );
478
479        // A bevelled join has no spike: same stroke, and the cell shrinks
480        // back to roughly the half-width. That the two DIFFER is what
481        // proves the bound is measured, not assumed.
482        let bevelled = raster
483            .stroked(
484                fonts.get(font),
485                glyph,
486                72.0,
487                0.0,
488                &GlyphStroke {
489                    join: Join::Bevel,
490                    ..stroke
491                },
492            )
493            .unwrap();
494        assert!(
495            bevelled.top < stroked.top,
496            "bevel {} vs miter {}",
497            bevelled.top,
498            stroked.top
499        );
500    }
501
502    /// Tier continuity (005-B7): the SDF raster places its glyph where the
503    /// mask raster does — minus the SDF pad, within the 2×-downsample's
504    /// half-pixel. An extra bias here makes text POP vertically when zoom
505    /// crosses the mask→SDF threshold.
506    #[test]
507    fn sdf_and_mask_tiers_agree_on_placement() {
508        let fonts = fira();
509        let font = fonts.family("Fira Sans").unwrap();
510        let mut raster = Rasterizer::new();
511        for ch in ['H', 'g', 'x', 'Q'] {
512            let glyph = fonts.get(font).glyph_for(ch).unwrap();
513            let alpha = raster.alpha(fonts.get(font), glyph, 64.0, 0.0).unwrap();
514            let sdf = raster.sdf(fonts.get(font), glyph, 64.0).unwrap();
515            let pad = SDF_PAD as i32;
516            for (axis, a, s) in [
517                ("top", alpha.top, sdf.top - pad),
518                ("left", alpha.left, sdf.left + pad),
519            ] {
520                assert!(
521                    (a - s).abs() <= 1,
522                    "'{ch}' {axis}: mask {a} vs sdf-adjusted {s}"
523                );
524            }
525        }
526    }
527}