use std::sync::Arc;
use skrifa::outline::OutlinePen;
use valo_geometry::{Cap, Join, Path, PathBuilder, Rect};
use crate::font::Font;
use crate::synthesis::FontSynthesis;
pub const SDF_PAD: u32 = 8;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlyphStroke {
pub width: f32,
pub cap: Cap,
pub join: Join,
pub miter_limit: f32,
}
pub struct GlyphImage {
pub width: u32,
pub height: u32,
pub left: i32,
pub top: i32,
pub data: Vec<u8>,
}
pub trait GlyphRaster: Send {
fn alpha(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
) -> Option<GlyphImage>;
fn stroked(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
stroke: &GlyphStroke,
) -> Option<GlyphImage>;
fn sdf(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<GlyphImage>;
fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage>;
fn path(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<Arc<Path>>;
}
impl GlyphRaster for Rasterizer {
fn alpha(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
) -> Option<GlyphImage> {
Rasterizer::alpha(self, font, glyph, px, synthesis, dx)
}
fn stroked(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
stroke: &GlyphStroke,
) -> Option<GlyphImage> {
Rasterizer::stroked(self, font, glyph, px, synthesis, dx, stroke)
}
fn sdf(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<GlyphImage> {
Rasterizer::sdf(self, font, glyph, px, synthesis)
}
fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
Rasterizer::color(self, font, glyph, px)
}
fn path(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<Arc<Path>> {
Rasterizer::path(self, font, glyph, px, synthesis)
}
}
#[derive(Default)]
pub struct Rasterizer {
context: swash::scale::ScaleContext,
stroker: tiny_skia::PathStroker,
}
impl Rasterizer {
pub fn new() -> Self {
Self::default()
}
pub fn alpha(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
) -> Option<GlyphImage> {
let image = self.render(font, glyph, px, synthesis, dx)?;
Some(GlyphImage {
width: image.placement.width,
height: image.placement.height,
left: image.placement.left,
top: image.placement.top,
data: image.data,
})
}
pub fn stroked(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
stroke: &GlyphStroke,
) -> Option<GlyphImage> {
let outline = self.skia_outline(font, glyph, px, synthesis, dx)?;
let stroked = self.stroker.stroke(&outline, &skia_stroke(stroke), 1.0)?;
mask_of(&stroked)
}
pub fn sdf(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<GlyphImage> {
let alpha = self.render(font, glyph, px, synthesis, 0.0)?;
let pad = SDF_PAD;
let w = alpha.placement.width + 2 * pad;
let h = alpha.placement.height + 2 * pad;
let mut coverage = vec![0u8; (w * h) as usize];
for y in 0..alpha.placement.height {
for x in 0..alpha.placement.width {
coverage[((y + pad) * w + x + pad) as usize] =
alpha.data[(y * alpha.placement.width + x) as usize];
}
}
let field = crate::sdf::signed_distances(&coverage, w as usize, h as usize);
Some(GlyphImage {
width: w,
height: h,
left: alpha.placement.left - pad as i32,
top: alpha.placement.top + pad as i32,
data: crate::sdf::encode(&field, SDF_PAD as f32),
})
}
pub fn color(&mut self, font: &Font, glyph: u32, px: f32) -> Option<GlyphImage> {
let mut scaler = self.scaler(font, px)?;
let image = swash::scale::Render::new(&[
swash::scale::Source::ColorOutline(0),
swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
])
.render(&mut scaler, glyph as swash::GlyphId);
let Some(image) = image else {
return crate::colr::raster(font, glyph, px);
};
if image.content != swash::scale::image::Content::Color {
return crate::colr::raster(font, glyph, px);
}
let mut data = image.data;
for px in data.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
px[0] = ((px[0] as u32 * a + 127) / 255) as u8;
px[1] = ((px[1] as u32 * a + 127) / 255) as u8;
px[2] = ((px[2] as u32 * a + 127) / 255) as u8;
}
Some(GlyphImage {
width: image.placement.width,
height: image.placement.height,
left: image.placement.left,
top: image.placement.top,
data,
})
}
pub(crate) fn color_bounds(&mut self, font: &Font, glyph: u32, px: f32) -> Option<Rect> {
let image = self.color(font, glyph, px)?;
let mut left = image.width;
let mut top = image.height;
let mut right = 0;
let mut bottom = 0;
for y in 0..image.height {
for x in 0..image.width {
let alpha = image.data[((y * image.width + x) * 4 + 3) as usize];
if alpha == 0 {
continue;
}
left = left.min(x);
top = top.min(y);
right = right.max(x + 1);
bottom = bottom.max(y + 1);
}
}
(left < right && top < bottom).then(|| {
Rect::new(
image.left as f32 + left as f32,
-image.top as f32 + top as f32,
(right - left) as f32,
(bottom - top) as f32,
)
})
}
pub fn path(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<Arc<Path>> {
let outline = self.outline(font, glyph, px, synthesis)?;
let mut pen = PathPen {
builder: PathBuilder::new(),
};
draw_outline(&outline, &mut pen);
let path = pen.builder.build();
(!path.is_empty()).then_some(path)
}
fn render(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
) -> Option<swash::scale::image::Image> {
let mut scaler = self.scaler(font, px)?;
swash::scale::Render::new(&[swash::scale::Source::Outline])
.embolden(synthesis.edge_growth(px))
.transform(synthesis_transform(synthesis, px))
.offset(swash::zeno::Vector::new(dx, 0.0))
.render(&mut scaler, glyph as swash::GlyphId)
}
fn outline(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
) -> Option<swash::scale::outline::Outline> {
let mut outline = self
.scaler(font, px)?
.scale_outline(glyph as swash::GlyphId)?;
let growth = synthesis.edge_growth(px);
if growth != 0.0 {
outline.embolden(growth, growth);
}
if let Some(transform) = synthesis_transform(synthesis, px) {
outline.transform(&transform);
}
Some(outline)
}
fn skia_outline(
&mut self,
font: &Font,
glyph: u32,
px: f32,
synthesis: FontSynthesis,
dx: f32,
) -> Option<tiny_skia::Path> {
let outline = self.outline(font, glyph, px, synthesis)?;
let mut pen = TsPathPen::default();
draw_outline(&outline, &mut pen);
pen.builder
.finish()?
.transform(tiny_skia::Transform::from_row(1.0, 0.0, 0.0, -1.0, dx, 0.0))
}
fn scaler<'a>(&'a mut self, font: &'a Font, px: f32) -> Option<swash::scale::Scaler<'a>> {
let font_ref = swash::FontRef::from_index(font.data(), font.face_index() as usize)?;
Some(
self.context
.builder(font_ref)
.size(px)
.hint(false)
.variations(swash_variations(font))
.build(),
)
}
}
fn synthesis_transform(synthesis: FontSynthesis, px: f32) -> Option<swash::zeno::Transform> {
if synthesis.is_none() {
return None;
}
let growth = synthesis.edge_growth(px);
let shear = swash::zeno::Transform::new(1.0, 0.0, -synthesis.skew, 1.0, 0.0, 0.0);
Some(swash::zeno::Transform::translation(-growth, -growth).then(&shear))
}
fn draw_outline(outline: &swash::scale::outline::Outline, pen: &mut impl OutlinePen) {
use swash::zeno::Verb;
let mut points = outline.points().iter();
let mut next = || points.next().copied().unwrap_or_default();
for verb in outline.verbs() {
match verb {
Verb::MoveTo => {
let to = next();
pen.move_to(to.x, to.y);
}
Verb::LineTo => {
let to = next();
pen.line_to(to.x, to.y);
}
Verb::QuadTo => {
let (control, to) = (next(), next());
pen.quad_to(control.x, control.y, to.x, to.y);
}
Verb::CurveTo => {
let (first, second, to) = (next(), next(), next());
pen.curve_to(first.x, first.y, second.x, second.y, to.x, to.y);
}
Verb::Close => pen.close(),
}
}
}
fn skia_stroke(stroke: &GlyphStroke) -> tiny_skia::Stroke {
tiny_skia::Stroke {
width: stroke.width,
miter_limit: stroke.miter_limit,
line_cap: match stroke.cap {
Cap::Butt => tiny_skia::LineCap::Butt,
Cap::Round => tiny_skia::LineCap::Round,
Cap::Square => tiny_skia::LineCap::Square,
},
line_join: match stroke.join {
Join::Miter => tiny_skia::LineJoin::Miter,
Join::Round => tiny_skia::LineJoin::Round,
Join::Bevel => tiny_skia::LineJoin::Bevel,
},
dash: None,
}
}
fn mask_of(path: &tiny_skia::Path) -> Option<GlyphImage> {
let bounds = path.compute_tight_bounds()?;
let (left, top) = (bounds.left().floor(), bounds.top().floor());
let width = (bounds.right().ceil() - left) as u32;
let height = (bounds.bottom().ceil() - top) as u32;
let mut mask = tiny_skia::Mask::new(width, height)?;
mask.fill_path(
path,
tiny_skia::FillRule::Winding,
true,
tiny_skia::Transform::from_translate(-left, -top),
);
Some(GlyphImage {
width,
height,
left: left as i32,
top: -top as i32,
data: mask.data().to_vec(),
})
}
pub fn glyph_path(font: &Font, glyph: u32, px: f32, synthesis: FontSynthesis) -> Option<Arc<Path>> {
Rasterizer::new().path(font, glyph, px, synthesis)
}
#[derive(Default)]
pub(crate) struct TsPathPen {
pub(crate) builder: tiny_skia::PathBuilder,
}
impl OutlinePen for TsPathPen {
fn move_to(&mut self, x: f32, y: f32) {
self.builder.move_to(x, y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.builder.line_to(x, y);
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
self.builder.quad_to(cx, cy, x, y);
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
self.builder.cubic_to(c0x, c0y, c1x, c1y, x, y);
}
fn close(&mut self) {
self.builder.close();
}
}
struct PathPen {
builder: PathBuilder,
}
impl OutlinePen for PathPen {
fn move_to(&mut self, x: f32, y: f32) {
self.builder.move_to((x, -y));
}
fn line_to(&mut self, x: f32, y: f32) {
self.builder.line_to((x, -y));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
self.builder.quad_to((cx, -cy), (x, -y));
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
self.builder.cubic_to((c0x, -c0y), (c1x, -c1y), (x, -y));
}
fn close(&mut self) {
self.builder.close();
}
}
fn swash_variations(font: &Font) -> impl Iterator<Item = (&str, f32)> + '_ {
font.variation_coordinates()
.iter()
.filter_map(|(tag, value)| Some((std::str::from_utf8(tag).ok()?, *value)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::FaceSet;
fn fira() -> FaceSet {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../assets/fonts/fira_sans.ttf"
);
let mut c = FaceSet::default();
c.register("Fira Sans", std::fs::read(path).unwrap())
.unwrap();
c
}
#[test]
fn stroked_raster_bounds_hold_the_miter_spikes() {
let fonts = fira();
let font = fonts.family("Fira Sans").unwrap();
let mut raster = Rasterizer::new();
let stroke = GlyphStroke {
width: 5.0,
cap: Cap::Butt,
join: Join::Miter,
miter_limit: 16.0,
};
let glyph = fonts.get(font).glyph_for('M').unwrap();
let fill = raster
.alpha(fonts.get(font), glyph, 72.0, FontSynthesis::NONE, 0.0)
.unwrap();
let stroked = raster
.stroked(
fonts.get(font),
glyph,
72.0,
FontSynthesis::NONE,
0.0,
&stroke,
)
.unwrap();
let reach = (stroked.top - fill.top) as f32;
assert!(
reach > stroke.width,
"the stroked cell reaches only {reach}px above the fill — a \
miter spike of 8.6px does not fit"
);
let bevelled = raster
.stroked(
fonts.get(font),
glyph,
72.0,
FontSynthesis::NONE,
0.0,
&GlyphStroke {
join: Join::Bevel,
..stroke
},
)
.unwrap();
assert!(
bevelled.top < stroked.top,
"bevel {} vs miter {}",
bevelled.top,
stroked.top
);
}
const FAKE_BOLD: FontSynthesis = FontSynthesis {
embolden: 1.0 / 32.0,
skew: 0.0,
};
const FAKE_ITALIC: FontSynthesis = FontSynthesis {
embolden: 0.0,
skew: -0.25,
};
fn ink_centre(image: &GlyphImage, rows: std::ops::Range<u32>) -> f32 {
let (mut sum, mut moment) = (0.0, 0.0);
for y in rows {
for x in 0..image.width {
let coverage = image.data[(y * image.width + x) as usize] as f32 / 255.0;
sum += coverage;
moment += coverage * (image.left as f32 + x as f32 + 0.5);
}
}
moment / sum
}
fn row_coverage(image: &GlyphImage, height: i32) -> f32 {
let y = (image.top - height) as u32;
(0..image.width)
.map(|x| image.data[(y * image.width + x) as usize] as f32 / 255.0)
.sum()
}
#[test]
fn fake_italic_leans_the_top_of_a_glyph_right() {
let fonts = fira();
let font = fonts.get(fonts.family("Fira Sans").unwrap());
let glyph = font.glyph_for('I').unwrap();
let mut raster = Rasterizer::new();
let lean = |image: &GlyphImage| {
let band = image.height / 4;
let top = ink_centre(image, 0..band);
let bottom = ink_centre(image, image.height - band..image.height);
(top - bottom) / (image.height - band) as f32
};
let plain = raster
.alpha(font, glyph, 64.0, FontSynthesis::NONE, 0.0)
.unwrap();
let italic = raster.alpha(font, glyph, 64.0, FAKE_ITALIC, 0.0).unwrap();
assert!(lean(&plain).abs() < 0.02, "upright: {}", lean(&plain));
assert!(
(lean(&italic) - 0.25).abs() < 0.03,
"the top leans right by a quarter: {}",
lean(&italic)
);
let plain = raster
.path(font, glyph, 64.0, FontSynthesis::NONE)
.unwrap()
.tight_bounds();
let italic = raster
.path(font, glyph, 64.0, FAKE_ITALIC)
.unwrap()
.tight_bounds();
let cap_height = -plain.y;
assert!((italic.x - plain.x).abs() < 0.01, "{italic:?} vs {plain:?}");
let reach = italic.right() - plain.right();
assert!(
(reach - cap_height / 4.0).abs() < 0.01,
"the top moved {reach} for a cap height of {cap_height}"
);
}
#[test]
fn fake_bold_grows_a_glyph_as_skias_stroke_and_fill_does() {
let fonts = fira();
let font = fonts.get(fonts.family("Fira Sans").unwrap());
let glyph = font.glyph_for('I').unwrap();
let px = 64.0;
let width = px * FAKE_BOLD.embolden;
let mut raster = Rasterizer::new();
let outline = raster
.skia_outline(font, glyph, px, FontSynthesis::NONE, 0.0)
.unwrap();
let skia = raster
.stroker
.stroke(
&outline,
&tiny_skia::Stroke {
width,
miter_limit: 4.0,
..Default::default()
},
1.0,
)
.unwrap()
.compute_tight_bounds()
.unwrap();
let bold = raster.alpha(font, glyph, px, FAKE_BOLD, 0.0).unwrap();
let ink = (
bold.left as f32,
-bold.top as f32,
(bold.left + bold.width as i32) as f32,
(bold.height as i32 - bold.top) as f32,
);
let expected = (
skia.left().floor(),
skia.top().floor(),
skia.right().ceil(),
skia.bottom().ceil(),
);
assert_eq!(ink, expected, "fake bold's cell vs Skia's stroked outline");
let plain = raster
.alpha(font, glyph, px, FontSynthesis::NONE, 0.0)
.unwrap();
let gain = row_coverage(&bold, 20) - row_coverage(&plain, 20);
assert!(
(gain - width).abs() < 0.1,
"the stem thickens by {gain}, the stroke is {width}"
);
assert!(
(ink_centre(&bold, 0..bold.height) - ink_centre(&plain, 0..plain.height)).abs() < 0.05,
"the glyph grows evenly about its own centre"
);
}
#[test]
fn synthesized_glyphs_stay_inside_their_grown_ink_box() {
let fonts = fira();
let font = fonts.get(fonts.family("Fira Sans").unwrap());
let mut raster = Rasterizer::new();
let both = FontSynthesis {
embolden: 1.0 / 24.0,
skew: -0.25,
};
for synthesis in [FAKE_BOLD, FAKE_ITALIC, both] {
for ch in ['W', 'A', 'l', 'g', 'v', 'Q'] {
let glyph = font.glyph_for(ch).unwrap();
let plain = raster.path(font, glyph, 48.0, FontSynthesis::NONE).unwrap();
let drawn = raster.path(font, glyph, 48.0, synthesis).unwrap();
let (plain, drawn) = (plain.tight_bounds(), drawn.tight_bounds());
let (x_min, y_min, x_max, y_max) =
synthesis.ink_box((plain.x, -plain.bottom(), plain.right(), -plain.y), 48.0);
let epsilon = 1e-3;
assert!(
x_min <= drawn.x + epsilon
&& x_max >= drawn.right() - epsilon
&& y_min <= -drawn.bottom() + epsilon
&& y_max >= -drawn.y - epsilon,
"'{ch}' {synthesis:?}: box {:?} misses {drawn:?}",
(x_min, y_min, x_max, y_max)
);
}
}
}
#[test]
fn sdf_and_mask_tiers_agree_on_placement() {
let fonts = fira();
let font = fonts.family("Fira Sans").unwrap();
let mut raster = Rasterizer::new();
for ch in ['H', 'g', 'x', 'Q'] {
let glyph = fonts.get(font).glyph_for(ch).unwrap();
let alpha = raster
.alpha(fonts.get(font), glyph, 64.0, FontSynthesis::NONE, 0.0)
.unwrap();
let sdf = raster
.sdf(fonts.get(font), glyph, 64.0, FontSynthesis::NONE)
.unwrap();
let pad = SDF_PAD as i32;
for (axis, a, s) in [
("top", alpha.top, sdf.top - pad),
("left", alpha.left, sdf.left + pad),
] {
assert!(
(a - s).abs() <= 1,
"'{ch}' {axis}: mask {a} vs sdf-adjusted {s}"
);
}
}
}
}