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 spread in texels: distance saturates ±this many pixels from the edge
11/// (0.5 = on the edge). Also the raster padding so the field has room.
12pub const SDF_PAD: u32 = 8;
13
14/// The stroke a glyph raster can carry: [`valo_geometry::Stroke`] without
15/// its dashes, which a fixed-size atlas key has nowhere to put. Impeller's
16/// `StrokeParameters` carries the same four fields for the same reason.
17/// `width` is in the raster's own pixels, like `px`.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct GlyphStroke {
20    pub width: f32,
21    pub cap: Cap,
22    pub join: Join,
23    /// Miter length ÷ half-width beyond which a join bevels (SVG default 4).
24    pub miter_limit: f32,
25}
26
27/// A rasterized glyph: A8 coverage (or normalized distance for SDF), plus
28/// the placement of the bitmap's top-left relative to the glyph origin
29/// (`left` right of origin, `top` above the baseline — swash conventions).
30pub struct GlyphImage {
31    pub width: u32,
32    pub height: u32,
33    pub left: i32,
34    pub top: i32,
35    pub data: Vec<u8>,
36}
37
38/// CPU glyph rasterization, on swash. One per renderer — swash's context
39/// caches scaling state, and the stroker its segment buffers.
40#[derive(Default)]
41pub struct Rasterizer {
42    context: swash::scale::ScaleContext,
43    stroker: tiny_skia::PathStroker,
44}
45
46impl Rasterizer {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Plain alpha coverage at `px` — the mask tier. `dx` is the subpixel
52    /// x-phase (0/¼/½/¾ px) baked into the raster, Skia/Impeller's
53    /// quarter-pixel positioning.
54    pub fn alpha(&mut self, font: &Font, glyph: u32, px: f32, dx: f32) -> Option<GlyphImage> {
55        let image = self.render(font, glyph, px, dx)?;
56        Some(GlyphImage {
57            width: image.placement.width,
58            height: image.placement.height,
59            left: image.placement.left,
60            top: image.placement.top,
61            data: image.data,
62        })
63    }
64
65    /// Alpha coverage of the glyph's STROKED outline — the stroked mask
66    /// tier. Stroking happens before rasterizing, which is what lets the
67    /// result be an ordinary cached atlas entry (Skia's scaler strokes
68    /// inside the strike for the same reason).
69    ///
70    /// This does NOT go through swash. swash rasterizes with zeno, and
71    /// zeno's miter join short-circuits to a bevel whenever the two segment
72    /// normals point apart (`stroke.rs`'s `dot < 0.0`), which caps its miter
73    /// ratio at √2 and silently flattens every join sharper than a right
74    /// angle — the apex of `A`, `M`, `W`, and most of what a stroked
75    /// headline is made of. tiny-skia, already here for COLRv1, ports
76    /// Skia's stroker and honours `miter_limit`, and it hands back a real
77    /// path whose tight bounds size the atlas cell. That measurement is the
78    /// point: Impeller sizes its slot the same way, by handing the stroking
79    /// paint to `SkFont::getBounds`.
80    pub fn stroked(
81        &mut self,
82        font: &Font,
83        glyph: u32,
84        px: f32,
85        dx: f32,
86        stroke: &GlyphStroke,
87    ) -> Option<GlyphImage> {
88        let outline = glyph_outline(font, glyph, px, dx)?;
89        let stroked = self.stroker.stroke(&outline, &skia_stroke(stroke), 1.0)?;
90        mask_of(&stroked)
91    }
92
93    /// Signed distance field at `px`: the 1× AA coverage seeds the exact
94    /// EDT directly (mapbox TinySDF's shape — partial alpha carries the
95    /// sub-pixel edge, so no supersample; ~7× the old
96    /// 2×-8SSEDT pipeline). 128 = edge, ±[`SDF_PAD`] px span the range.
97    pub fn sdf(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
98        let alpha = self.render(font, glyph, px, 0.0)?;
99        let pad = SDF_PAD;
100        let w = alpha.placement.width + 2 * pad;
101        let h = alpha.placement.height + 2 * pad;
102        let mut coverage = vec![0u8; (w * h) as usize];
103        for y in 0..alpha.placement.height {
104            for x in 0..alpha.placement.width {
105                coverage[((y + pad) * w + x + pad) as usize] =
106                    alpha.data[(y * alpha.placement.width + x) as usize];
107            }
108        }
109        let field = crate::sdf::signed_distances(&coverage, w as usize, h as usize);
110        Some(GlyphImage {
111            width: w,
112            height: h,
113            left: alpha.placement.left - pad as i32,
114            top: alpha.placement.top + pad as i32,
115            data: crate::sdf::encode(&field, SDF_PAD as f32),
116        })
117    }
118
119    /// Color glyph (COLR outlines / CBDT-sbix bitmaps) at `px`: premultiplied
120    /// RGBA, or `None` when the glyph has no color form — the caller falls
121    /// back to the mask tiers. Mini rendered emoji through Canvas2D; swash
122    /// is the native replacement.
123    pub fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
124        let font_ref = swash::FontRef::from_index(font.data(), font.face_index() as usize)?;
125        let mut scaler = self
126            .context
127            .builder(font_ref)
128            .size(px)
129            .hint(false)
130            .variations(swash_variations(font))
131            .build();
132        let image = swash::scale::Render::new(&[
133            swash::scale::Source::ColorOutline(0),
134            swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
135        ])
136        .render(&mut scaler, glyph as swash::GlyphId);
137        let Some(image) = image else {
138            // swash covers CBDT bitmaps and COLRv0 layers; COLRv1 paint
139            // graphs raster through the skrifa painter.
140            return crate::colr::raster(font, glyph, px);
141        };
142        if image.content != swash::scale::image::Content::Color {
143            return crate::colr::raster(font, glyph, px);
144        }
145        let mut data = image.data;
146        for px in data.chunks_exact_mut(4) {
147            let a = px[3] as u32;
148            // Round half up — truncation biases emoji a hair dark and
149            // breaks the round-trip with export's unpremultiply.
150            px[0] = ((px[0] as u32 * a + 127) / 255) as u8;
151            px[1] = ((px[1] as u32 * a + 127) / 255) as u8;
152            px[2] = ((px[2] as u32 * a + 127) / 255) as u8;
153        }
154        Some(GlyphImage {
155            width: image.placement.width,
156            height: image.placement.height,
157            left: image.placement.left,
158            top: image.placement.top,
159            data,
160        })
161    }
162
163    /// Tight non-transparent pixel bounds for a bitmap/color glyph, relative
164    /// to its baseline origin in Valo's y-down coordinates. Metrics query this
165    /// before a monochrome outline because rendering also prefers COLR/CBDT.
166    pub(crate) fn color_bounds(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Rect> {
167        let image = self.color(font, glyph, px)?;
168        let mut left = image.width;
169        let mut top = image.height;
170        let mut right = 0;
171        let mut bottom = 0;
172        for y in 0..image.height {
173            for x in 0..image.width {
174                let alpha = image.data[((y * image.width + x) * 4 + 3) as usize];
175                if alpha == 0 {
176                    continue;
177                }
178                left = left.min(x);
179                top = top.min(y);
180                right = right.max(x + 1);
181                bottom = bottom.max(y + 1);
182            }
183        }
184        (left < right && top < bottom).then(|| {
185            Rect::new(
186                image.left as f32 + left as f32,
187                -image.top as f32 + top as f32,
188                (right - left) as f32,
189                (bottom - top) as f32,
190            )
191        })
192    }
193
194    fn render(
195        &mut self,
196        font: &Font,
197        glyph: u32,
198        px: f32,
199        dx: f32,
200    ) -> Option<swash::scale::image::Image> {
201        let font_ref = swash::FontRef::from_index(font.data(), font.face_index() as usize)?;
202        let mut scaler = self
203            .context
204            .builder(font_ref)
205            .size(px)
206            .hint(false)
207            .variations(swash_variations(font))
208            .build();
209        swash::scale::Render::new(&[swash::scale::Source::Outline])
210            .offset(swash::zeno::Vector::new(dx, 0.0))
211            .render(&mut scaler, glyph as swash::GlyphId)
212    }
213}
214
215fn skia_stroke(stroke: &GlyphStroke) -> tiny_skia::Stroke {
216    tiny_skia::Stroke {
217        width: stroke.width,
218        miter_limit: stroke.miter_limit,
219        line_cap: match stroke.cap {
220            Cap::Butt => tiny_skia::LineCap::Butt,
221            Cap::Round => tiny_skia::LineCap::Round,
222            Cap::Square => tiny_skia::LineCap::Square,
223        },
224        line_join: match stroke.join {
225            Join::Miter => tiny_skia::LineJoin::Miter,
226            Join::Round => tiny_skia::LineJoin::Round,
227            Join::Bevel => tiny_skia::LineJoin::Bevel,
228        },
229        dash: None,
230    }
231}
232
233/// The glyph as a tiny-skia path at `px`, baseline origin, y-down, shifted
234/// by the subpixel x-phase — device space, so the stroke width needs no
235/// further scaling.
236fn glyph_outline(font: &Font, glyph: u32, px: f32, dx: f32) -> Option<tiny_skia::Path> {
237    let font_ref = skrifa::FontRef::from_index(font.data(), font.face_index()).ok()?;
238    let outline = font_ref.outline_glyphs().get(skrifa::GlyphId::new(glyph))?;
239    let mut pen = TsPathPen::default();
240    outline
241        .draw(
242            DrawSettings::unhinted(Size::new(px), font.variation_location()),
243            &mut pen,
244        )
245        .ok()?;
246    pen.builder
247        .finish()?
248        .transform(tiny_skia::Transform::from_row(1.0, 0.0, 0.0, -1.0, dx, 0.0))
249}
250
251/// A device-space path as an A8 image placed the way swash places its own:
252/// `left` right of the origin, `top` above the baseline. Flooring the
253/// tight bounds out to whole pixels is exactly the set of pixels the
254/// antialiased fill can touch, so the cell is never short.
255fn mask_of(path: &tiny_skia::Path) -> Option<GlyphImage> {
256    let bounds = path.compute_tight_bounds()?;
257    let (left, top) = (bounds.left().floor(), bounds.top().floor());
258    let width = (bounds.right().ceil() - left) as u32;
259    let height = (bounds.bottom().ceil() - top) as u32;
260    let mut mask = tiny_skia::Mask::new(width, height)?;
261    mask.fill_path(
262        path,
263        tiny_skia::FillRule::Winding,
264        true,
265        tiny_skia::Transform::from_translate(-left, -top),
266    );
267    Some(GlyphImage {
268        width,
269        height,
270        left: left as i32,
271        top: -top as i32,
272        data: mask.data().to_vec(),
273    })
274}
275
276/// The glyph as a valo `Path` at `px`, baseline-origin, y-down — the huge-
277/// text tier: stencil-then-cover handles it like any shape.
278pub fn glyph_path(font: &Font, glyph: u32, px: f32) -> Option<Arc<Path>> {
279    let font_ref = skrifa::FontRef::from_index(font.data(), font.face_index()).ok()?;
280    let outline = font_ref.outline_glyphs().get(skrifa::GlyphId::new(glyph))?;
281    let mut pen = PathPen {
282        builder: PathBuilder::new(),
283    };
284    outline
285        .draw(
286            DrawSettings::unhinted(Size::new(px), font.variation_location()),
287            &mut pen,
288        )
289        .ok()?;
290    let path = pen.builder.build();
291    // COLR fonts carry EMPTY classic outlines for their color glyphs — an
292    // empty path IS "no outline", so callers take their color fallback
293    // instead of stencil-filling nothing (the >path_min emoji vanish bug).
294    (!path.is_empty()).then_some(path)
295}
296
297/// skrifa outline pen → tiny-skia path, in whatever units the draw was
298/// scaled to and y-up; the consumer's transform does the flip.
299#[derive(Default)]
300pub(crate) struct TsPathPen {
301    pub(crate) builder: tiny_skia::PathBuilder,
302}
303
304impl OutlinePen for TsPathPen {
305    fn move_to(&mut self, x: f32, y: f32) {
306        self.builder.move_to(x, y);
307    }
308
309    fn line_to(&mut self, x: f32, y: f32) {
310        self.builder.line_to(x, y);
311    }
312
313    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
314        self.builder.quad_to(cx, cy, x, y);
315    }
316
317    fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
318        self.builder.cubic_to(c0x, c0y, c1x, c1y, x, y);
319    }
320
321    fn close(&mut self) {
322        self.builder.close();
323    }
324}
325
326/// skrifa pen → PathBuilder, flipping y (fonts are y-up, canvases y-down).
327struct PathPen {
328    builder: PathBuilder,
329}
330
331impl OutlinePen for PathPen {
332    fn move_to(&mut self, x: f32, y: f32) {
333        self.builder.move_to((x, -y));
334    }
335
336    fn line_to(&mut self, x: f32, y: f32) {
337        self.builder.line_to((x, -y));
338    }
339
340    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
341        self.builder.quad_to((cx, -cy), (x, -y));
342    }
343
344    fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
345        self.builder.cubic_to((c0x, -c0y), (c1x, -c1y), (x, -y));
346    }
347
348    fn close(&mut self) {
349        self.builder.close();
350    }
351}
352
353/// A font's variation coordinates in swash's setting form (named
354/// instances rasterize at their own axis positions).
355fn swash_variations(font: &Font) -> impl Iterator<Item = (&str, f32)> + '_ {
356    font.variation_coordinates()
357        .iter()
358        .filter_map(|(tag, value)| Some((std::str::from_utf8(tag).ok()?, *value)))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::font::FaceSet;
365
366    fn fira() -> FaceSet {
367        let path = concat!(
368            env!("CARGO_MANIFEST_DIR"),
369            "/../../assets/fonts/fira_sans.ttf"
370        );
371        let mut c = FaceSet::default();
372        c.register("Fira Sans", std::fs::read(path).unwrap())
373            .unwrap();
374        c
375    }
376
377    /// The atlas cell is nothing but the raster's own placement, so the
378    /// stroked raster has to come out already containing the miter spikes.
379    /// Fira Sans `M` spikes 8.6px above its own outline at 72px with a
380    /// 5px miter stroke — a cell inflated by a flat half-width (2.5px)
381    /// would cut 6px off it, and nothing downstream could tell.
382    #[test]
383    fn stroked_raster_bounds_hold_the_miter_spikes() {
384        let fonts = fira();
385        let font = fonts.family("Fira Sans").unwrap();
386        let mut raster = Rasterizer::new();
387        let stroke = GlyphStroke {
388            width: 5.0,
389            cap: Cap::Butt,
390            join: Join::Miter,
391            miter_limit: 16.0,
392        };
393        let glyph = fonts.get(font).glyph_for('M').unwrap();
394        let fill = raster.alpha(fonts.get(font), glyph, 72.0, 0.0).unwrap();
395        let stroked = raster
396            .stroked(fonts.get(font), glyph, 72.0, 0.0, &stroke)
397            .unwrap();
398        let reach = (stroked.top - fill.top) as f32;
399        assert!(
400            reach > stroke.width,
401            "the stroked cell reaches only {reach}px above the fill — a \
402             miter spike of 8.6px does not fit"
403        );
404
405        // A bevelled join has no spike: same stroke, and the cell shrinks
406        // back to roughly the half-width. That the two DIFFER is what
407        // proves the bound is measured, not assumed.
408        let bevelled = raster
409            .stroked(
410                fonts.get(font),
411                glyph,
412                72.0,
413                0.0,
414                &GlyphStroke {
415                    join: Join::Bevel,
416                    ..stroke
417                },
418            )
419            .unwrap();
420        assert!(
421            bevelled.top < stroked.top,
422            "bevel {} vs miter {}",
423            bevelled.top,
424            stroked.top
425        );
426    }
427
428    /// Tier continuity (005-B7): the SDF raster places its glyph where the
429    /// mask raster does — minus the SDF pad, within the 2×-downsample's
430    /// half-pixel. An extra bias here makes text POP vertically when zoom
431    /// crosses the mask→SDF threshold.
432    #[test]
433    fn sdf_and_mask_tiers_agree_on_placement() {
434        let fonts = fira();
435        let font = fonts.family("Fira Sans").unwrap();
436        let mut raster = Rasterizer::new();
437        for ch in ['H', 'g', 'x', 'Q'] {
438            let glyph = fonts.get(font).glyph_for(ch).unwrap();
439            let alpha = raster.alpha(fonts.get(font), glyph, 64.0, 0.0).unwrap();
440            let sdf = raster.sdf(fonts.get(font), glyph, 64.0).unwrap();
441            let pad = SDF_PAD as i32;
442            for (axis, a, s) in [
443                ("top", alpha.top, sdf.top - pad),
444                ("left", alpha.left, sdf.left + pad),
445            ] {
446                assert!(
447                    (a - s).abs() <= 1,
448                    "'{ch}' {axis}: mask {a} vs sdf-adjusted {s}"
449                );
450            }
451        }
452    }
453}