use swash::{
scale::{Render, ScaleContext, Source, StrikeWith},
FontRef,
};
use crate::backend::{RasterBackend, RasterOutput};
pub struct SwashRaster {
context: std::sync::Mutex<ScaleContext>,
hint: bool,
}
impl SwashRaster {
pub fn new() -> Self {
Self {
context: std::sync::Mutex::new(ScaleContext::new()),
hint: true,
}
}
pub fn with_hint(hint: bool) -> Self {
Self {
context: std::sync::Mutex::new(ScaleContext::new()),
hint,
}
}
}
impl Default for SwashRaster {
fn default() -> Self {
Self::new()
}
}
fn glyph_advance(font_ref: FontRef<'_>, glyph_id: u16, px_size: f32) -> f32 {
let gm = font_ref.glyph_metrics(&[]).scale(px_size);
gm.advance_width(glyph_id)
}
impl RasterBackend for SwashRaster {
fn rasterize(&self, face_data: &[u8], glyph_id: u16, px_size: f32) -> RasterOutput {
let Some(font_ref) = FontRef::from_index(face_data, 0) else {
return zero_output();
};
let advance_x = glyph_advance(font_ref, glyph_id, px_size);
let mut ctx = match self.context.lock() {
Ok(g) => g,
Err(_) => return zero_output(),
};
let Some(fr) = FontRef::from_index(face_data, 0) else {
return zero_output();
};
let mut scaler = ctx.builder(fr).size(px_size).hint(self.hint).build();
let mut render = Render::new(&[Source::ColorBitmap(StrikeWith::BestFit), Source::Outline]);
render.format(swash::zeno::Format::Alpha);
let Some(image) = render.render(&mut scaler, glyph_id) else {
return RasterOutput {
width: 0,
height: 0,
coverage: Vec::new(),
advance_x,
advance_y: 0.0,
bearing_x: 0,
bearing_y: 0,
};
};
let p = image.placement;
if p.width == 0 || p.height == 0 {
return RasterOutput {
width: 0,
height: 0,
coverage: Vec::new(),
advance_x,
advance_y: 0.0,
bearing_x: p.left,
bearing_y: p.top,
};
}
RasterOutput {
width: p.width as usize,
height: p.height as usize,
coverage: image.data,
advance_x,
advance_y: 0.0,
bearing_x: p.left,
bearing_y: p.top,
}
}
fn rasterize_color(
&self,
face_data: &[u8],
glyph_id: u16,
px_size: f32,
) -> Option<oxitext_core::ColorBitmap> {
let mut ctx = self.context.lock().ok()?;
let fr = FontRef::from_index(face_data, 0)?;
let mut scaler = ctx.builder(fr).size(px_size).build();
let render = Render::new(&[Source::ColorBitmap(StrikeWith::BestFit)]);
let image = render.render(&mut scaler, glyph_id)?;
if image.content != swash::scale::image::Content::Color {
return None;
}
let p = image.placement;
if p.width == 0 || p.height == 0 {
return None;
}
Some(oxitext_core::ColorBitmap {
width: p.width,
height: p.height,
rgba: image.data,
})
}
}
#[inline]
fn zero_output() -> RasterOutput {
RasterOutput {
width: 0,
height: 0,
coverage: Vec::new(),
advance_x: 0.0,
advance_y: 0.0,
bearing_x: 0,
bearing_y: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::RasterBackend;
use std::path::Path;
fn load_test_font() -> Vec<u8> {
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/test-font.ttf");
if fixture.exists() {
return std::fs::read(&fixture).expect("read fixture font");
}
let candidates = [
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
];
for p in &candidates {
if Path::new(p).exists() {
return std::fs::read(p).expect("read system font");
}
}
panic!("no test font found — add tests/fixtures/test-font.ttf");
}
#[test]
fn swash_raster_hinted_produces_bitmap() {
let font = load_test_font();
let raster = SwashRaster::new();
let out = raster.rasterize(&font, 36, 16.0);
assert!(
out.width > 0 && out.height > 0,
"expected non-zero bitmap for GID 36 (hinted), got {}x{}",
out.width,
out.height
);
assert_eq!(
out.coverage.len(),
out.width * out.height,
"coverage buffer length mismatch"
);
assert!(
out.coverage.iter().any(|&v| v > 0),
"coverage bitmap is all-zeros for GID 36"
);
}
#[test]
fn swash_raster_unhinted_produces_bitmap() {
let font = load_test_font();
let raster = SwashRaster::with_hint(false);
let out = raster.rasterize(&font, 36, 16.0);
assert!(
out.width > 0 && out.height > 0,
"expected non-zero bitmap for GID 36 (unhinted)"
);
assert_eq!(out.coverage.len(), out.width * out.height);
}
#[test]
fn swash_raster_hinted_vs_unhinted_same_dimensions() {
let font = load_test_font();
let hinted = SwashRaster::new();
let unhinted = SwashRaster::with_hint(false);
let bm_h = hinted.rasterize(&font, 36, 16.0);
let bm_u = unhinted.rasterize(&font, 36, 16.0);
assert!(
(bm_h.width as i64 - bm_u.width as i64).abs() <= 2,
"width differs too much: hinted={}, unhinted={}",
bm_h.width,
bm_u.width
);
assert!(
(bm_h.height as i64 - bm_u.height as i64).abs() <= 2,
"height differs too much: hinted={}, unhinted={}",
bm_h.height,
bm_u.height
);
}
#[test]
fn swash_raster_invalid_font_returns_zero_sized() {
let raster = SwashRaster::new();
let out = raster.rasterize(b"not a font", 36, 16.0);
assert_eq!(
out.width, 0,
"invalid font should produce zero-width bitmap"
);
assert_eq!(
out.height, 0,
"invalid font should produce zero-height bitmap"
);
assert!(out.coverage.is_empty());
}
#[test]
fn swash_raster_whitespace_glyph_handles_gracefully() {
let font = load_test_font();
let raster = SwashRaster::new();
let out = raster.rasterize(&font, 3, 16.0);
if out.width > 0 && out.height > 0 {
assert!(
out.coverage.iter().all(|&p| p == 0),
"space glyph should have all-zero coverage"
);
}
}
#[test]
fn swash_raster_default_is_hinted() {
let font = load_test_font();
let a = SwashRaster::default();
let b = SwashRaster::new();
let out_a = a.rasterize(&font, 36, 16.0);
let out_b = b.rasterize(&font, 36, 16.0);
assert_eq!(out_a.width, out_b.width);
assert_eq!(out_a.height, out_b.height);
assert_eq!(out_a.coverage, out_b.coverage);
}
#[test]
fn swash_raster_coverage_buffer_length_consistent() {
let font = load_test_font();
let raster = SwashRaster::new();
for gid in 30u16..50 {
let out = raster.rasterize(&font, gid, 16.0);
assert_eq!(
out.coverage.len(),
out.width * out.height,
"coverage length mismatch for GID {gid}"
);
}
}
#[test]
fn swash_raster_larger_size_larger_bitmap() {
let font = load_test_font();
let raster = SwashRaster::new();
let small = raster.rasterize(&font, 36, 8.0);
let large = raster.rasterize(&font, 36, 32.0);
if small.width > 0 && large.width > 0 {
assert!(
large.width >= small.width,
"32px should be wider than 8px: large={}, small={}",
large.width,
small.width
);
}
}
}