#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod backend;
pub mod cache;
pub mod color;
pub mod detect;
pub mod gamma;
pub mod lcd;
pub mod options;
pub mod outline;
pub mod result;
pub mod scalar;
pub mod simd;
pub mod stem_darken;
pub mod subpixel;
pub mod tl_cache;
#[cfg(feature = "swash-backend")]
pub mod swash_backend;
#[cfg(feature = "svg-backend")]
pub mod svg_backend;
#[cfg(feature = "oxifont-backend")]
pub mod oxifont_backend;
#[cfg(test)]
mod bench_tests;
pub use backend::{FontdueRaster, RasterBackend, RasterOutput};
pub use cache::{BitmapCache, BitmapCacheKey, RenderMode};
pub use color::{render_color_glyph, render_colr_v0, render_colr_v1, ColorGlyphBitmap};
pub use detect::{
detect_color_glyph_type, extract_cbdt_bitmap, extract_raster_glyph, render_cbdt_glyph,
ColorGlyphType, RasterImageFormat, RawRasterGlyph,
};
pub use gamma::{linear_to_srgb, srgb_to_linear};
pub use options::{HintingMode, LcdFilterKernel, RasterOptions, SubpixelMode};
pub use outline::{extract_glyph_outline, GlyphOutline, PathCommand};
#[cfg(feature = "oxifont-backend")]
pub use oxifont_backend::OxifontRaster;
pub use result::RasterResult;
pub use subpixel::{SubpixelBuckets, SubpixelCacheKey, SubpixelOffset, SubpixelOffsetXY};
#[cfg(feature = "svg-backend")]
pub use svg_backend::{render_svg_bytes, render_svg_glyph};
#[cfg(feature = "swash-backend")]
pub use swash_backend::SwashRaster;
pub use tl_cache::get_or_parse_fontdue;
#[inline]
pub fn accumulate_coverage(dst: &mut [f32], src: &[f32]) {
#[cfg(feature = "simd")]
{
simd::accumulate_coverage(dst, src);
}
#[cfg(not(feature = "simd"))]
{
scalar::accumulate_coverage(dst, src);
}
}
#[inline]
pub fn multiply_alpha_u8(buf: &mut [u8], factor: u8) {
#[cfg(feature = "simd")]
{
simd::multiply_alpha_u8(buf, factor);
}
#[cfg(not(feature = "simd"))]
{
scalar::multiply_alpha_u8(buf, factor);
}
}
#[inline]
pub fn coverage_f32_to_u8(dst: &mut [u8], src: &[f32]) {
#[cfg(feature = "simd")]
{
simd::coverage_f32_to_u8(dst, src);
}
#[cfg(not(feature = "simd"))]
{
scalar::coverage_f32_to_u8(dst, src);
}
}
#[inline]
pub fn porter_duff_source_over(dst: &mut [u8], src: &[u8]) {
#[cfg(feature = "simd")]
simd::porter_duff_source_over_simd(dst, src);
#[cfg(not(feature = "simd"))]
scalar::porter_duff_source_over_scalar(dst, src);
}
pub use scalar::porter_duff_source_over_scalar;
#[cfg(feature = "simd")]
pub use simd::porter_duff_source_over_simd;
use oxitext_core::{Bitmap, OxiTextError};
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
const RASTERIZER_FONT_CACHE_CAP: usize = 64;
pub struct FontdueRasterizer {
fonts: Mutex<lru::LruCache<usize, fontdue::Font>>,
}
impl FontdueRasterizer {
pub fn new() -> Self {
let cap = NonZeroUsize::new(RASTERIZER_FONT_CACHE_CAP)
.expect("RASTERIZER_FONT_CACHE_CAP is non-zero");
Self {
fonts: Mutex::new(lru::LruCache::new(cap)),
}
}
pub fn raster(
&self,
glyph_id: u16,
font_data: &Arc<[u8]>,
size: f32,
) -> Result<Bitmap, OxiTextError> {
let key = Arc::as_ptr(font_data) as *const u8 as usize;
let mut cache = self
.fonts
.lock()
.map_err(|_| OxiTextError::Raster("font cache mutex was poisoned".into()))?;
if !cache.contains(&key) {
let font = fontdue::Font::from_bytes(&**font_data, fontdue::FontSettings::default())
.map_err(|e| OxiTextError::Raster(format!("fontdue parse error: {e}")))?;
cache.put(key, font);
}
let font = cache
.get(&key)
.ok_or_else(|| OxiTextError::Raster("cache miss after put — impossible".into()))?;
let (metrics, pixels) = font.rasterize_indexed(glyph_id, size);
Ok(Bitmap {
width: metrics.width as u32,
height: metrics.height as u32,
pixels,
})
}
}
impl Default for FontdueRasterizer {
fn default() -> Self {
Self::new()
}
}
pub fn rasterize_positioned(
glyphs: &[oxitext_core::PositionedGlyph],
_options: &options::RasterOptions,
) -> Vec<Option<Bitmap>> {
let raster = backend::FontdueRaster::new();
glyphs
.iter()
.map(|g| {
let out = raster.rasterize(&g.font_data, g.gid, g.font_size);
Some(Bitmap {
width: out.width as u32,
height: out.height as u32,
pixels: out.coverage,
})
})
.collect()
}
pub fn rasterize_for_sdf(
font_data: &[u8],
glyph_id: u16,
px_size: f32,
) -> Result<Option<Bitmap>, OxiTextError> {
let raster = backend::FontdueRaster::new();
let bm = raster.rasterize_for_sdf(font_data, glyph_id, px_size);
Ok(bm)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn load_test_font() -> Arc<[u8]> {
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/test-font.ttf");
if fixture.exists() {
return Arc::from(
std::fs::read(&fixture)
.expect("read fixture font")
.as_slice(),
);
}
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 Arc::from(std::fs::read(p).expect("read system font").as_slice());
}
}
Arc::from(oxifont_bundled::NOTO_SANS_REGULAR)
}
#[test]
fn rasterize_produces_non_degenerate_bitmap() {
let font_bytes = load_test_font();
let rasterizer = FontdueRasterizer::new();
let bm = rasterizer
.raster(36, &font_bytes, 16.0)
.expect("raster failed");
if bm.width > 0 && bm.height > 0 {
let pixel_sum: u32 = bm.pixels.iter().map(|&p| p as u32).sum();
assert!(pixel_sum > 0, "bitmap for GID 36 is all-zeros");
}
assert_eq!(
bm.pixels.len(),
(bm.width * bm.height) as usize,
"pixel buffer length mismatch"
);
}
fn source_over_channel(src: u8, src_alpha: u8, dst: u8) -> u8 {
let sa = src_alpha as u32;
let out = (src as u32 * sa + dst as u32 * (255 - sa) + 127) / 255;
out.min(255) as u8
}
#[test]
fn porter_duff_source_over_opaque() {
let result = source_over_channel(200, 255, 50);
assert_eq!(result, 200, "opaque source must overwrite destination");
}
#[test]
fn porter_duff_source_over_transparent() {
let result = source_over_channel(255, 0, 100);
assert_eq!(
result, 100,
"transparent source must not modify destination"
);
}
#[test]
fn porter_duff_source_over_half_alpha() {
let result = source_over_channel(255, 128, 0);
assert!(
(127..=129).contains(&result),
"50% white over black should be ~128, got {result}"
);
}
#[test]
fn porter_duff_source_over_white_over_black() {
for channel in [255u8, 255, 255] {
let result = source_over_channel(channel, 255, 0);
assert_eq!(result, channel);
}
}
#[test]
fn subpixel_bucket_quantization() {
use crate::subpixel::SubpixelOffset;
let b0 = SubpixelOffset::from_float(0.0);
let b1 = SubpixelOffset::from_float(0.25);
let b2 = SubpixelOffset::from_float(0.5);
let b3 = SubpixelOffset::from_float(0.75);
assert_ne!(b0, b1, "0.0 and 0.25 must be different buckets");
assert_ne!(b1, b2, "0.25 and 0.5 must be different buckets");
assert_ne!(b2, b3, "0.5 and 0.75 must be different buckets");
assert_ne!(b0, b2, "0.0 and 0.5 must be different buckets");
assert_ne!(b0, b3, "0.0 and 0.75 must be different buckets");
}
#[test]
fn whitespace_glyph_returns_empty_gracefully() {
let raster = crate::backend::FontdueRaster::new();
let out = raster.rasterize(&[], 0, 12.0);
assert_eq!(out.width, 0);
assert_eq!(out.height, 0);
assert!(out.coverage.is_empty());
}
#[test]
fn clear_cache_on_fontdue_raster() {
let font_bytes = load_test_font();
let raster = crate::backend::FontdueRaster::new();
let _ = raster.rasterize(&font_bytes, 36, 16.0);
raster.clear_cache();
let out = raster.rasterize(&font_bytes, 36, 16.0);
assert!(out.width > 0 || out.coverage.is_empty()); }
#[test]
fn raster_positioned_returns_bitmap() {
let font_bytes = load_test_font();
let raster = crate::backend::FontdueRaster::new();
let bm = raster.raster_positioned(&font_bytes, 36, 16.0, 0.25, 0.0);
assert!(
bm.is_some(),
"raster_positioned should return Some for a valid glyph"
);
let bm = bm.expect("bitmap present");
assert_eq!(bm.pixels.len(), (bm.width * bm.height) as usize);
}
#[test]
fn raster_positioned_empty_font_returns_empty() {
let raster = crate::backend::FontdueRaster::new();
let bm = raster.raster_positioned(&[], 0, 12.0, 0.0, 0.0);
assert!(bm.is_some());
let bm = bm.expect("should return Some even for invalid font");
assert_eq!(bm.width, 0);
assert_eq!(bm.height, 0);
}
#[test]
fn rasterize_full_wraps_metrics() {
use crate::backend::RasterBackend;
let font_bytes = load_test_font();
let raster = crate::backend::FontdueRaster::new();
let result = raster.rasterize_full(&font_bytes, 36, 16.0);
assert!(result.advance_x > 0.0, "advance_x should be positive");
assert!(
!result.is_empty(),
"full rasterize should produce a non-empty bitmap"
);
}
#[test]
fn subpixel_buckets_count() {
use crate::subpixel::SubpixelBuckets;
assert_eq!(SubpixelBuckets::Four.count(), 4);
assert_eq!(SubpixelBuckets::Eight.count(), 8);
assert_eq!(SubpixelBuckets::Sixteen.count(), 16);
}
#[test]
fn subpixel_bucket_with_count_eight() {
use crate::subpixel::{SubpixelBuckets, SubpixelOffset};
let b = SubpixelOffset::bucket_with_count(0.5, SubpixelBuckets::Eight);
assert_eq!(b.bucket(), 4);
let b0 = SubpixelOffset::bucket_with_count(0.0, SubpixelBuckets::Eight);
assert_eq!(b0.bucket(), 0);
}
#[test]
fn subpixel_bucket_with_count_sixteen() {
use crate::subpixel::{SubpixelBuckets, SubpixelOffset};
let b = SubpixelOffset::bucket_with_count(0.25, SubpixelBuckets::Sixteen);
assert_eq!(b.bucket(), 4);
}
#[test]
fn color_glyph_type_none_for_empty_data() {
use crate::detect::{detect_color_glyph_type, ColorGlyphType};
assert_eq!(detect_color_glyph_type(&[], 0), ColorGlyphType::None);
}
#[test]
fn extract_cbdt_returns_none_for_invalid_font() {
use crate::detect::extract_cbdt_bitmap;
assert!(extract_cbdt_bitmap(&[], 0, 16).is_none());
}
#[test]
fn test_extract_raster_glyph_smoke() {
use crate::detect::extract_raster_glyph;
let font_data: Option<Vec<u8>> = [
"/System/Library/Fonts/Apple Color Emoji.ttc",
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
]
.iter()
.find_map(|p| std::fs::read(p).ok());
let font_data = match font_data {
Some(d) => d,
None => {
let font_bytes = load_test_font();
font_bytes.to_vec()
}
};
let _ = extract_raster_glyph(&font_data, 3, 32);
}
#[test]
fn test_extract_glyph_outline_non_panic() {
use crate::outline::extract_glyph_outline;
let font_bytes = load_test_font();
let font_data = font_bytes.to_vec();
let outline = extract_glyph_outline(&font_data, 3, 1.0 / 2048.0);
if let Some(o) = outline {
assert!(!o.commands.is_empty());
}
}
#[test]
fn test_thread_local_cache_no_panic() {
let result = crate::tl_cache::get_or_parse_fontdue(&[]);
assert!(result.is_none());
}
#[test]
fn test_rasterize_with_offset_differs() {
use crate::backend::{FontdueRaster, RasterBackend};
let font_bytes = load_test_font();
let raster = FontdueRaster::new();
let out1 = raster.rasterize(&font_bytes, 3, 16.0);
let out2 = raster.rasterize(&font_bytes, 3, 16.0);
assert_eq!(out1.width, out2.width);
assert_eq!(out1.height, out2.height);
assert_eq!(out1.coverage, out2.coverage);
}
#[test]
fn raster_result_empty_is_empty() {
let r = crate::result::RasterResult::empty();
assert!(r.is_empty());
}
#[test]
fn bitmap_cache_clear() {
use crate::cache::{BitmapCache, BitmapCacheKey, RenderMode};
let mut cache = BitmapCache::new(4);
let key = BitmapCacheKey {
glyph_id: 1,
px_size_times_64: 64,
render_mode: RenderMode::Greyscale,
};
cache.insert(key, vec![1u8]);
assert_eq!(cache.len(), 1);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_colrv0_compositing_smoke() {
let font_data: Vec<u8> = [
"/System/Library/Fonts/Apple Color Emoji.ttc",
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
]
.iter()
.find_map(|p| std::fs::read(p).ok())
.unwrap_or_else(|| {
load_test_font().to_vec()
});
if font_data.is_empty() {
return;
}
let _ = crate::color::render_color_glyph(&font_data, 3, 32, 32);
}
#[test]
#[cfg(feature = "ab-glyph-backend")]
fn test_abglyph_raster_non_zero_coverage() {
use crate::backend::{AbGlyphRaster, RasterBackend};
let font_bytes = load_test_font();
let raster = AbGlyphRaster;
for gid in [36u16, 37, 38, 39, 40] {
let out = raster.rasterize(&font_bytes, gid, 24.0);
if out.width > 0 && out.height > 0 && out.coverage.iter().any(|&v| v > 0) {
return; }
}
}
#[test]
fn test_rasterize_for_sdf_produces_coverage_bitmap() {
let font_bytes = load_test_font();
let font_data = &font_bytes;
for gid in 36..50u16 {
if let Ok(Some(bmp)) = crate::rasterize_for_sdf(font_data, gid, 32.0) {
assert_eq!(
bmp.pixels.len(),
bmp.width as usize * bmp.height as usize,
"pixel buffer length must match declared dimensions"
);
assert!(
bmp.pixels.iter().any(|&v| v > 0),
"coverage bitmap should have at least one non-zero pixel"
);
return;
}
}
}
#[test]
fn test_rasterize_for_sdf_zero_size_no_panic() {
let font_bytes = load_test_font();
let font_data = &font_bytes;
let result = crate::rasterize_for_sdf(font_data, 36, 0.0);
match result {
Ok(None) => {}
Ok(Some(bmp)) => {
assert_eq!(bmp.pixels.len(), bmp.width as usize * bmp.height as usize);
}
Err(_) => {}
}
}
#[cfg(feature = "ab-glyph-backend")]
#[test]
fn test_fontdue_vs_abglyph_both_produce_bitmaps() {
use crate::backend::{AbGlyphRaster, FontdueRaster, RasterBackend};
let font_bytes = load_test_font();
let font_data = &font_bytes;
let px_size = 24.0_f32;
let glyph_id = 36u16;
let fontdue = FontdueRaster::new();
let fontdue_result = fontdue.rasterize(font_data, glyph_id, px_size);
let abglyph = AbGlyphRaster;
let abglyph_result = abglyph.rasterize(font_data, glyph_id, px_size);
if fontdue_result.width > 0
&& fontdue_result.height > 0
&& abglyph_result.width > 0
&& abglyph_result.height > 0
{
assert!(
fontdue_result.coverage.iter().any(|&v| v > 0),
"fontdue coverage bitmap should have non-zero pixels"
);
assert!(
abglyph_result.coverage.iter().any(|&v| v > 0),
"ab_glyph coverage bitmap should have non-zero pixels"
);
let max_w = fontdue_result.width.max(abglyph_result.width);
let width_diff = (fontdue_result.width as i32 - abglyph_result.width as i32).abs();
let max_h = fontdue_result.height.max(abglyph_result.height);
let height_diff = (fontdue_result.height as i32 - abglyph_result.height as i32).abs();
assert!(
width_diff as usize <= max_w / 2 + 1,
"width diff too large: fontdue={}, ab_glyph={}",
fontdue_result.width,
abglyph_result.width
);
assert!(
height_diff as usize <= max_h / 2 + 1,
"height diff too large: fontdue={}, ab_glyph={}",
fontdue_result.height,
abglyph_result.height
);
}
}
#[test]
fn test_porter_duff_simd_matches_scalar() {
let mut dst_scalar = vec![100u8, 150u8, 200u8, 255u8, 50u8, 80u8, 120u8, 200u8];
let mut dst_simd = dst_scalar.clone();
let src = vec![255u8, 0u8, 0u8, 128u8, 0u8, 255u8, 0u8, 64u8];
crate::scalar::porter_duff_source_over_scalar(&mut dst_scalar, &src);
#[cfg(feature = "simd")]
crate::simd::porter_duff_source_over_simd(&mut dst_simd, &src);
#[cfg(not(feature = "simd"))]
crate::scalar::porter_duff_source_over_scalar(&mut dst_simd, &src);
for (s, v) in dst_scalar.iter().zip(dst_simd.iter()) {
assert!((*s as i32 - *v as i32).abs() <= 1, "scalar={s}, simd={v}");
}
}
#[test]
fn test_rasterize_positioned_empty_returns_empty() {
let glyphs: Vec<oxitext_core::PositionedGlyph> = vec![];
let opts = crate::options::RasterOptions::default();
let result = crate::rasterize_positioned(&glyphs, &opts);
assert!(result.is_empty(), "empty input must produce empty output");
}
#[test]
fn test_rasterize_positioned_produces_bitmaps() {
use oxitext_core::PositionedGlyph;
let font_bytes = load_test_font();
let font_arc = std::sync::Arc::clone(&font_bytes);
let glyphs = vec![
PositionedGlyph {
gid: 36,
font_data: font_arc.clone(),
pos: (0.0, 0.0),
font_size: 16.0,
advance_x: 10.0,
cluster: 0,
},
PositionedGlyph {
gid: 37,
font_data: font_arc.clone(),
pos: (12.0, 0.0),
font_size: 16.0,
advance_x: 10.0,
cluster: 1,
},
];
let opts = crate::options::RasterOptions::default();
let result = crate::rasterize_positioned(&glyphs, &opts);
assert_eq!(result.len(), 2, "one result per input glyph");
for bm_opt in &result {
let bm = bm_opt.as_ref().expect("should be Some");
assert_eq!(
bm.pixels.len(),
(bm.width * bm.height) as usize,
"pixel buffer length must match dimensions"
);
}
}
#[test]
fn fontdue_raster_cache_bounded() {
use crate::backend::FontdueRaster;
let raster = FontdueRaster::new();
let out = raster.rasterize(&[], 0, 16.0);
assert_eq!(out.width, 0);
assert_eq!(out.height, 0);
assert!(
out.coverage.is_empty(),
"empty font data must produce empty coverage"
);
}
#[test]
fn fontdue_rasterizer_fonts_bounded() {
let font_bytes = load_test_font();
let rasterizer = FontdueRasterizer::new();
for i in 0..65u8 {
let fake: Arc<[u8]> = Arc::from(vec![i; 4].as_slice());
let _ = rasterizer.raster(0, &fake, 16.0);
}
let result = rasterizer.raster(36, &font_bytes, 16.0);
assert!(
result.is_ok(),
"rasterizing after 65 distinct insertions must succeed"
);
}
#[test]
fn tl_font_cache_bounded_no_panic() {
for _ in 0..40 {
let _ = crate::tl_cache::get_or_parse_fontdue(b"not a font");
}
}
}