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