use crate::font::PathCommand;
use crate::jitter;
use kurbo::BezPath;
use skrifa::instance::Size;
use skrifa::outline::OutlinePen;
use skrifa::raw::{FileRef, TableProvider as SkrifaTableProvider};
use skrifa::{GlyphId, MetadataProvider};
use std::path::Path;
use write_fonts::read::types::Tag;
use write_fonts::read::{FontRead, FontRef as WfFontRef};
use write_fonts::tables::glyf::{GlyfLocaBuilder, Glyph, SimpleGlyph};
use write_fonts::tables::gsub::{Gsub, SingleSubst, SubstitutionLookup};
use write_fonts::tables::head::Head;
use write_fonts::tables::hhea::Hhea;
use write_fonts::tables::hmtx::{Hmtx, LongMetric};
use write_fonts::tables::layout::{
ChainedClassSequenceRule, ChainedClassSequenceRuleSet, ChainedSequenceContext, CoverageFormat1,
CoverageTable, Feature, FeatureList, FeatureRecord, LangSys, Lookup, LookupFlag, LookupList,
Script, ScriptList, ScriptRecord, SequenceLookupRecord,
};
use write_fonts::tables::maxp::Maxp;
use write_fonts::tables::post::Post;
use write_fonts::types::Version16Dot16;
use write_fonts::FontBuilder;
pub fn bake_font(
input_path: &Path,
output_path: &Path,
alternates: u32,
intensity: f64,
) -> Result<(), String> {
if alternates == 0 {
return Err("alternates must be at least 1".to_string());
}
let font_data =
std::fs::read(input_path).map_err(|e| format!("Failed to read font file: {e}"))?;
let skrifa_font = match FileRef::new(&font_data) {
Ok(FileRef::Font(f)) => f,
Ok(FileRef::Collection(_)) => {
return Err("Font collections (.ttc/.otc) are not yet supported".to_string())
}
Err(e) => return Err(format!("Failed to parse font: {e}")),
};
let wf_font =
WfFontRef::new(&font_data).map_err(|e| format!("Failed to re-parse font: {e}"))?;
let num_glyphs = skrifa_font
.maxp()
.map_err(|e| format!("Failed to read maxp: {e}"))?
.num_glyphs();
let units_per_em = skrifa_font
.head()
.map_err(|e| format!("Failed to read head: {e}"))?
.units_per_em() as f64;
let outlines = skrifa_font.outline_glyphs();
let mut originals: Vec<OriginalGlyph> = Vec::with_capacity(num_glyphs as usize);
for gid_u16 in 0..num_glyphs {
let gid = GlyphId::new(gid_u16 as u32);
let outline = outlines.get(gid);
let commands = if let Some(glyph) = outline {
let mut pen = CollectPen::new();
glyph
.draw(Size::unscaled(), &mut pen)
.map_err(|e| format!("Failed to draw glyph {gid_u16}: {e}"))?;
pen.commands
} else {
Vec::new()
};
originals.push(OriginalGlyph { commands });
}
let mut alt_map: Vec<Vec<u16>> = vec![Vec::new(); num_glyphs as usize];
let mut new_glyphs: Vec<Glyph> = Vec::with_capacity(num_glyphs as usize);
let mut new_metrics: Vec<LongMetric> = Vec::with_capacity(num_glyphs as usize);
let hhea_bytes_ro = wf_font
.table_data(Tag::new(b"hhea"))
.ok_or_else(|| "Font is missing 'hhea' table".to_string())?;
let hhea_ro = write_fonts::read::tables::hhea::Hhea::read(hhea_bytes_ro)
.map_err(|e| format!("Failed to read hhea: {e}"))?;
let num_long_metrics = hhea_ro.number_of_h_metrics() as usize;
let hmtx_bytes_ro = wf_font
.table_data(Tag::new(b"hmtx"))
.ok_or_else(|| "Font is missing 'hmtx' table".to_string())?;
let original_hmtx =
write_fonts::read::tables::hmtx::Hmtx::read(hmtx_bytes_ro, num_long_metrics as u16)
.map_err(|e| format!("Failed to read hmtx: {e}"))?;
for (gid, orig) in originals.iter().enumerate() {
let glyph = build_simple_glyph(&orig.commands, gid as u16)?;
new_glyphs.push(glyph);
let (advance, lsb) = resolve_original_hmtx(&original_hmtx, gid, num_long_metrics);
new_metrics.push(LongMetric::new(advance, lsb));
}
let mut next_gid: u32 = num_glyphs as u32;
for gid in 0..num_glyphs as usize {
if gid == 0 {
continue;
}
let orig = &originals[gid];
if orig.commands.is_empty() {
continue;
}
for _ in 0..alternates {
if next_gid > u16::MAX as u32 {
return Err(format!(
"Too many glyphs after baking: {} exceeds {}",
next_gid,
u16::MAX
));
}
let jittered = jitter::apply_jitter_one(&orig.commands, intensity, units_per_em);
let glyph = build_simple_glyph(&jittered, next_gid as u16)?;
new_glyphs.push(glyph);
let advance = new_metrics[gid].advance;
let lsb = new_metrics[gid].side_bearing;
new_metrics.push(LongMetric::new(advance, lsb));
alt_map[gid].push(next_gid as u16);
next_gid += 1;
}
}
let total_glyphs = new_glyphs.len();
if total_glyphs > u16::MAX as usize {
return Err(format!(
"Too many glyphs after baking: {} exceeds {}",
total_glyphs,
u16::MAX
));
}
let total_glyphs_u16 = total_glyphs as u16;
let mut glyf_builder = GlyfLocaBuilder::new();
for (i, glyph) in new_glyphs.iter().enumerate() {
glyf_builder
.add_glyph(glyph)
.map_err(|e| format!("Failed to add glyph {i}: {e}"))?;
}
let (glyf_table, loca_table, loca_format) = glyf_builder.build();
let head_bytes = wf_font
.table_data(Tag::new(b"head"))
.ok_or_else(|| "Font is missing 'head' table".to_string())?;
let mut head = Head::read(head_bytes).map_err(|e| format!("Failed to own head: {e}"))?;
head.index_to_loc_format = match loca_format {
write_fonts::tables::loca::LocaFormat::Short => 0,
write_fonts::tables::loca::LocaFormat::Long => 1,
};
let maxp_bytes = wf_font
.table_data(Tag::new(b"maxp"))
.ok_or_else(|| "Font is missing 'maxp' table".to_string())?;
let mut maxp = Maxp::read(maxp_bytes).map_err(|e| format!("Failed to own maxp: {e}"))?;
maxp.num_glyphs = total_glyphs_u16;
let hhea_bytes = wf_font
.table_data(Tag::new(b"hhea"))
.ok_or_else(|| "Font is missing 'hhea' table".to_string())?;
let mut hhea = Hhea::read(hhea_bytes).map_err(|e| format!("Failed to own hhea: {e}"))?;
hhea.number_of_h_metrics = total_glyphs_u16;
let hmtx = Hmtx::new(new_metrics, Vec::new());
let post = build_post_v3(&wf_font)?;
let gsub = build_gsub_calt(&alt_map)?;
let mut builder = FontBuilder::new();
builder
.add_table(&head)
.map_err(|e| format!("head: {e}"))?
.add_table(&maxp)
.map_err(|e| format!("maxp: {e}"))?
.add_table(&hhea)
.map_err(|e| format!("hhea: {e}"))?
.add_table(&hmtx)
.map_err(|e| format!("hmtx: {e}"))?
.add_table(&glyf_table)
.map_err(|e| format!("glyf: {e}"))?
.add_table(&loca_table)
.map_err(|e| format!("loca: {e}"))?
.add_table(&post)
.map_err(|e| format!("post: {e}"))?
.add_table(&gsub)
.map_err(|e| format!("GSUB: {e}"))?;
builder.copy_missing_tables(wf_font);
let out = builder.build();
verify_baked_font(&out, total_glyphs_u16)?;
std::fs::write(output_path, &out).map_err(|e| format!("Failed to write output: {e}"))?;
Ok(())
}
fn verify_baked_font(data: &[u8], expected_num_glyphs: u16) -> Result<(), String> {
if WfFontRef::new(data).is_err() {
return Err("bake produced a font that failed write-fonts re-parse".to_string());
}
let file = skrifa::raw::FileRef::new(data)
.map_err(|e| format!("bake produced a font that failed skrifa re-parse: {e}"))?;
let font = match file {
skrifa::raw::FileRef::Font(f) => f,
skrifa::raw::FileRef::Collection(_) => {
return Err("bake produced a collection, expected a single font".to_string())
}
};
let num_glyphs = font
.maxp()
.map_err(|e| format!("bake produced font whose maxp is unreadable: {e}"))?
.num_glyphs();
if num_glyphs != expected_num_glyphs {
return Err(format!(
"bake produced inconsistent font: maxp.num_glyphs={num_glyphs}, expected {expected_num_glyphs}"
));
}
let hhea = font
.hhea()
.map_err(|e| format!("bake produced font whose hhea is unreadable: {e}"))?;
let num_long_metrics = hhea.number_of_h_metrics();
if num_long_metrics != expected_num_glyphs {
return Err(format!(
"bake produced inconsistent font: hhea.number_of_long_metrics={num_long_metrics}, expected {expected_num_glyphs}"
));
}
let hmtx = font
.hmtx()
.map_err(|e| format!("bake produced font whose hmtx is unreadable: {e}"))?;
let actual_h_metrics = hmtx.h_metrics().len();
if actual_h_metrics != expected_num_glyphs as usize {
return Err(format!(
"bake produced inconsistent font: hmtx.h_metrics.len={actual_h_metrics}, expected {expected_num_glyphs}"
));
}
Ok(())
}
fn build_post_v3(wf_font: &WfFontRef<'_>) -> Result<Post, String> {
let post_bytes = wf_font
.table_data(Tag::new(b"post"))
.ok_or_else(|| "Font is missing 'post' table".to_string())?;
let mut post = Post::read(post_bytes).map_err(|e| format!("Failed to own post: {e}"))?;
post.version = Version16Dot16::VERSION_3_0;
post.num_glyphs = None;
post.glyph_name_index = None;
post.string_data = None;
Ok(post)
}
struct OriginalGlyph {
commands: Vec<PathCommand>,
}
struct CollectPen {
commands: Vec<PathCommand>,
}
impl CollectPen {
fn new() -> Self {
Self {
commands: Vec::new(),
}
}
}
impl OutlinePen for CollectPen {
fn move_to(&mut self, x: f32, y: f32) {
self.commands.push(PathCommand::MoveTo(x, y));
}
fn line_to(&mut self, x: f32, y: f32) {
self.commands.push(PathCommand::LineTo(x, y));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.commands.push(PathCommand::QuadTo(cx0, cy0, x, y));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.commands
.push(PathCommand::CurveTo(cx0, cy0, cx1, cy1, x, y));
}
fn close(&mut self) {
self.commands.push(PathCommand::Close);
}
}
fn build_simple_glyph(commands: &[PathCommand], gid: u16) -> Result<Glyph, String> {
if commands.is_empty() {
return Ok(Glyph::Empty);
}
let mut path = BezPath::new();
let mut has_cubic = false;
for cmd in commands {
match *cmd {
PathCommand::MoveTo(x, y) => path.move_to((x as f64, y as f64)),
PathCommand::LineTo(x, y) => path.line_to((x as f64, y as f64)),
PathCommand::QuadTo(cx, cy, x, y) => {
path.quad_to((cx as f64, cy as f64), (x as f64, y as f64))
}
PathCommand::CurveTo(cx0, cy0, cx1, cy1, x, y) => {
has_cubic = true;
path.curve_to(
(cx0 as f64, cy0 as f64),
(cx1 as f64, cy1 as f64),
(x as f64, y as f64),
);
}
PathCommand::Close => path.close_path(),
}
}
let final_path = if has_cubic {
cubic_to_quadratic(&path, 0.5)
} else {
path
};
match SimpleGlyph::from_bezpath(&final_path) {
Ok(g) => Ok(Glyph::Simple(g)),
Err(_) => {
eprintln!("warning: glyph {gid} produced an invalid outline and was emitted as empty");
Ok(Glyph::Empty)
}
}
}
fn cubic_to_quadratic(path: &BezPath, accuracy: f64) -> BezPath {
let mut quad = BezPath::new();
let mut current = kurbo::Point::ORIGIN;
let mut start = kurbo::Point::ORIGIN;
for el in path.elements() {
match *el {
kurbo::PathEl::MoveTo(p) => {
quad.move_to(p);
current = p;
start = p;
}
kurbo::PathEl::LineTo(p) => {
quad.line_to(p);
current = p;
}
kurbo::PathEl::QuadTo(p1, p2) => {
quad.quad_to(p1, p2);
current = p2;
}
kurbo::PathEl::CurveTo(p1, p2, p3) => {
let c = kurbo::CubicBez::new(current, p1, p2, p3);
for (_, _, q) in c.to_quads(accuracy) {
quad.quad_to(q.p1, q.p2);
}
current = p3;
}
kurbo::PathEl::ClosePath => {
quad.close_path();
current = start;
}
}
}
quad
}
fn resolve_original_hmtx(
hmtx: &write_fonts::read::tables::hmtx::Hmtx<'_>,
gid: usize,
num_long_metrics: usize,
) -> (u16, i16) {
let long = hmtx.h_metrics();
if gid < num_long_metrics && gid < long.len() {
let m = &long[gid];
(m.advance(), m.side_bearing())
} else if !long.is_empty() {
let last_advance = long[long.len() - 1].advance();
let lsbs = hmtx.left_side_bearings();
let lsb_idx = gid.saturating_sub(num_long_metrics);
let lsb = lsbs.get(lsb_idx).map(|v| v.get()).unwrap_or(0);
(last_advance, lsb)
} else {
(0, 0)
}
}
fn build_gsub_calt(alt_map: &[Vec<u16>]) -> Result<Gsub, String> {
use write_fonts::read::types::GlyphId16;
const CHAIN_CHUNK_SIZE: usize = 192;
let mut pairs: Vec<(u16, &Vec<u16>)> = alt_map
.iter()
.enumerate()
.filter_map(|(gid, alts)| {
if alts.is_empty() {
None
} else {
Some((gid as u16, alts))
}
})
.collect();
pairs.sort_by_key(|(gid, _)| *gid);
if pairs.is_empty() {
let lang_sys = LangSys::new(vec![]);
let script = Script::new(Some(lang_sys), vec![]);
let script_list = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), script)]);
let feature_list = FeatureList::new(vec![]);
let lookup_list: LookupList<SubstitutionLookup> = LookupList::new(vec![]);
return Ok(Gsub::new(script_list, feature_list, lookup_list));
}
let max_alts = pairs.iter().map(|(_, alts)| alts.len()).max().unwrap_or(0);
let mut lookups: Vec<SubstitutionLookup> = Vec::with_capacity(max_alts + 1);
for stage in 0..max_alts {
let mut coverage_glyphs = Vec::new();
let mut replacements = Vec::new();
for (origin_gid, alts) in &pairs {
if let Some(&alt_gid) = alts.get(stage) {
coverage_glyphs.push(GlyphId16::new(*origin_gid));
replacements.push(GlyphId16::new(alt_gid));
}
}
let coverage = CoverageTable::Format1(CoverageFormat1::new(coverage_glyphs));
let subst = SingleSubst::format_2(coverage, replacements);
let lookup = Lookup::new(LookupFlag::empty(), vec![subst]);
lookups.push(SubstitutionLookup::Single(lookup));
}
let mut feature_lookup_indices = Vec::new();
for chunk in pairs.chunks(CHAIN_CHUNK_SIZE) {
let chain_lookup_idx = lookups.len() as u16;
lookups.push(SubstitutionLookup::ChainContextual(Lookup::new(
LookupFlag::empty(),
vec![build_chain_subtable_for_chunk(chunk).into()],
)));
feature_lookup_indices.push(chain_lookup_idx);
}
let feature = Feature::new(None, feature_lookup_indices);
let feature_record = FeatureRecord::new(Tag::new(b"calt"), feature);
let feature_list = FeatureList::new(vec![feature_record]);
let lang_sys = LangSys::new(vec![0]);
let script = Script::new(Some(lang_sys), vec![]);
let script_list = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), script)]);
let lookup_list = LookupList::new(lookups);
Ok(Gsub::new(script_list, feature_list, lookup_list))
}
fn build_chain_subtable_for_chunk(chunk: &[(u16, &Vec<u16>)]) -> ChainedSequenceContext {
use write_fonts::read::types::GlyphId16;
let mut input_class_items = Vec::new();
let mut backtrack_class_items = Vec::new();
let mut rule_sets = vec![None];
let mut next_input_class = 1u16;
let mut next_backtrack_class = 1u16;
for (origin_gid, alts) in chunk {
let input_class = next_input_class;
next_input_class += 1;
input_class_items.push((GlyphId16::new(*origin_gid), input_class));
let origin_backtrack_class = next_backtrack_class;
next_backtrack_class += 1;
backtrack_class_items.push((GlyphId16::new(*origin_gid), origin_backtrack_class));
let mut backtrack_classes = Vec::with_capacity(alts.len() + 1);
backtrack_classes.push(origin_backtrack_class);
for &alt_gid in alts.iter() {
let class_id = next_backtrack_class;
next_backtrack_class += 1;
backtrack_class_items.push((GlyphId16::new(alt_gid), class_id));
backtrack_classes.push(class_id);
}
let mut rules = Vec::with_capacity(alts.len() + 1);
rules.push(ChainedClassSequenceRule::new(
vec![backtrack_classes[0]],
vec![],
vec![],
vec![SequenceLookupRecord::new(0, 0)],
));
for (stage, backtrack_class) in backtrack_classes
.iter()
.copied()
.enumerate()
.take(alts.len())
.skip(1)
{
rules.push(ChainedClassSequenceRule::new(
vec![backtrack_class],
vec![],
vec![],
vec![SequenceLookupRecord::new(0, stage as u16)],
));
}
rules.push(ChainedClassSequenceRule::new(
vec![*backtrack_classes.last().unwrap_or(&origin_backtrack_class)],
vec![],
vec![],
vec![SequenceLookupRecord::new(0, 0)],
));
rule_sets.push(Some(ChainedClassSequenceRuleSet::new(rules)));
}
let coverage = CoverageTable::Format1(CoverageFormat1::new(
chunk.iter().map(|(gid, _)| GlyphId16::new(*gid)).collect(),
));
let input_class_def = input_class_items.into_iter().collect();
let backtrack_class_def = backtrack_class_items.into_iter().collect();
let lookahead_class_def = std::iter::empty::<(GlyphId16, u16)>().collect();
ChainedSequenceContext::format_2(
coverage,
backtrack_class_def,
input_class_def,
lookahead_class_def,
rule_sets,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_gsub_calt_handles_empty() {
let g = build_gsub_calt(&[vec![], vec![]]).unwrap();
assert_eq!(g.script_list.script_records.len(), 1);
assert_eq!(g.feature_list.feature_records.len(), 0);
assert_eq!(g.lookup_list.lookups.len(), 0);
}
#[test]
fn build_gsub_calt_uses_shared_stage_lookups() {
let mut map = vec![Vec::new(); 5];
map[1] = vec![3, 4];
let g = build_gsub_calt(&map).unwrap();
assert_eq!(
g.feature_list.feature_records[0].feature_tag,
Tag::new(b"calt")
);
assert_eq!(g.lookup_list.lookups.len(), 3);
}
#[test]
fn build_gsub_calt_reuses_stage_lookups_across_origins() {
let mut map = vec![Vec::new(); 8];
map[1] = vec![3, 4];
map[2] = vec![5, 6];
let g = build_gsub_calt(&map).unwrap();
assert_eq!(g.lookup_list.lookups.len(), 3);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires macOS Arial.ttf; run with --ignored"]
fn bake_arial_roundtrip() {
let arial = std::path::Path::new("/System/Library/Fonts/Supplemental/Arial.ttf");
if !arial.exists() {
eprintln!("Skipping: Arial.ttf not found");
return;
}
let tmp = std::env::temp_dir().join("jitter-bake-roundtrip.ttf");
bake_font(arial, &tmp, 2, 0.3).expect("bake should succeed");
let data = std::fs::read(&tmp).expect("read baked font");
let file = skrifa::raw::FileRef::new(&data).expect("skrifa re-parse");
let font = match file {
skrifa::raw::FileRef::Font(f) => f,
_ => panic!("expected single font"),
};
let maxp = font.maxp().expect("maxp");
assert!(maxp.num_glyphs() > 0);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires macOS STIXGeneral.otf; run with --ignored"]
fn bake_stix_otf_roundtrip() {
let otf = std::path::Path::new("/System/Library/Fonts/Supplemental/STIXGeneral.otf");
if !otf.exists() {
eprintln!("Skipping: STIXGeneral.otf not found");
return;
}
let tmp = std::env::temp_dir().join("jitter-bake-otf-roundtrip.ttf");
bake_font(otf, &tmp, 2, 0.3).expect("bake should succeed for OTF input");
let data = std::fs::read(&tmp).expect("read baked font");
let file = skrifa::raw::FileRef::new(&data).expect("skrifa re-parse");
let font = match file {
skrifa::raw::FileRef::Font(f) => f,
_ => panic!("expected single font"),
};
let maxp = font.maxp().expect("maxp");
assert!(maxp.num_glyphs() > 0);
}
#[test]
fn build_simple_glyph_with_cubic_produces_simple() {
let commands = vec![
PathCommand::MoveTo(0.0, 0.0),
PathCommand::CurveTo(10.0, 0.0, 20.0, 10.0, 30.0, 10.0),
PathCommand::Close,
];
let result = build_simple_glyph(&commands, 1).unwrap();
assert!(
matches!(result, Glyph::Simple(_)),
"cubic commands should be approximated to quadratic and produce SimpleGlyph"
);
}
#[test]
fn build_simple_glyph_empty_returns_empty() {
let result = build_simple_glyph(&[], 0).unwrap();
assert!(matches!(result, Glyph::Empty));
}
}