use fontcull_font_types::{FWord, Fixed, Tag};
use fontcull_read_fonts::tables::mvar::tags as mvar_tags;
use fontcull_read_fonts::{FontRef, TableProvider};
use fontcull_skrifa::{
instance::Size,
outline::{DrawSettings, OutlinePen},
setting::VariationSetting,
GlyphId, MetadataProvider,
};
use fontcull_write_fonts::{
from_obj::ToOwnedTable,
tables::{
glyf::{Glyf, GlyfLocaBuilder, Glyph, SimpleGlyph},
hmtx::{Hmtx, LongMetric},
loca::{Loca, LocaFormat},
},
FontBuilder,
};
use kurbo::BezPath;
use super::opentype::FontFormat;
use super::VariationInstance;
#[derive(Clone, Debug, PartialEq)]
pub enum InstanceBakeFailure {
Cff2NotSupported,
CompressedSourceUnsupported,
CubicOutlineUnsupported,
Failed(String),
}
impl std::fmt::Display for InstanceBakeFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cff2NotSupported => write!(f, "CFF2 outlines are not instanced by this route"),
Self::CompressedSourceUnsupported => {
write!(f, "the source bytes are WOFF/WOFF2-compressed")
}
Self::CubicOutlineUnsupported => {
write!(f, "the font uses spec_next cubic glyf outlines")
}
Self::Failed(message) => write!(f, "{message}"),
}
}
}
pub(crate) fn bake_instance(
bytes: &[u8],
collection_index: u32,
instance: &VariationInstance,
) -> Result<Vec<u8>, InstanceBakeFailure> {
if matches!(FontFormat::detect(bytes), Ok(FontFormat::Woff(_))) {
return Err(InstanceBakeFailure::CompressedSourceUnsupported);
}
let font = FontRef::from_index(bytes, collection_index)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
if font.data_for_tag(Tag::new(b"CFF2")).is_some() {
return Err(InstanceBakeFailure::Cff2NotSupported);
}
let location = font.axes().location(
instance
.coords
.iter()
.map(|c| VariationSetting::new(Tag::new(&c.axis.0), c.value)),
);
let num_glyphs = font
.maxp()
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.num_glyphs();
let outlines = font.outline_glyphs();
let metrics = font.glyph_metrics(Size::unscaled(), &location);
let mut glyphs: Vec<Glyph> = Vec::with_capacity(num_glyphs as usize);
let mut font_bbox: Option<(i16, i16, i16, i16)> = None;
for gid in 0..num_glyphs {
let glyph_id = GlyphId::from(gid);
let mut pen = PathPen::default();
if let Some(outline) = outlines.get(glyph_id) {
outline
.draw(
DrawSettings::unhinted(Size::unscaled(), &location),
&mut pen,
)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
}
if pen.cubic {
return Err(InstanceBakeFailure::CubicOutlineUnsupported);
}
let glyph = if pen.path.elements().is_empty() {
Glyph::Empty
} else {
let simple = SimpleGlyph::from_bezpath(&pen.path)
.map_err(|e| InstanceBakeFailure::Failed(format!("{e:?}")))?;
let b = simple.bbox;
font_bbox = Some(match font_bbox {
None => (b.x_min, b.y_min, b.x_max, b.y_max),
Some((x0, y0, x1, y1)) => (
x0.min(b.x_min),
y0.min(b.y_min),
x1.max(b.x_max),
y1.max(b.y_max),
),
});
Glyph::Simple(simple)
};
glyphs.push(glyph);
}
let mut glyf_builder = GlyfLocaBuilder::new();
for glyph in &glyphs {
glyf_builder
.add_glyph(glyph)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
}
let (glyf, loca, loca_format): (Glyf, Loca, LocaFormat) = glyf_builder.build();
let h_metrics: Vec<LongMetric> = (0..num_glyphs)
.map(|gid| {
let advance = metrics
.advance_width(GlyphId::from(gid))
.unwrap_or(0.0)
.round() as u16;
let side_bearing = match &glyphs[gid as usize] {
Glyph::Simple(s) => s.bbox.x_min,
_ => 0,
};
LongMetric {
advance,
side_bearing,
}
})
.collect();
let hmtx = Hmtx {
h_metrics,
left_side_bearings: Vec::new(),
};
let mut head: fontcull_write_fonts::tables::head::Head = font
.head()
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.to_owned_table();
head.index_to_loc_format = match loca_format {
LocaFormat::Short => 0,
LocaFormat::Long => 1,
};
if let Some((x_min, y_min, x_max, y_max)) = font_bbox {
head.x_min = x_min;
head.y_min = y_min;
head.x_max = x_max;
head.y_max = y_max;
}
let mvar = font.mvar().ok();
let coords = location.coords();
let mvar_delta = |tag: Tag| -> i32 {
mvar.as_ref()
.and_then(|table| table.metric_delta(tag, coords).ok())
.map(Fixed::to_i32)
.unwrap_or(0)
};
let mut hhea: fontcull_write_fonts::tables::hhea::Hhea = font
.hhea()
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.to_owned_table();
hhea.number_of_h_metrics = num_glyphs;
hhea.ascender = add_fword_delta(hhea.ascender, mvar_delta(mvar_tags::HASC));
hhea.descender = add_fword_delta(hhea.descender, mvar_delta(mvar_tags::HDSC));
hhea.line_gap = add_fword_delta(hhea.line_gap, mvar_delta(mvar_tags::HLGP));
let maxp: fontcull_write_fonts::tables::maxp::Maxp = font
.maxp()
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.to_owned_table();
let os2 = font.os2().ok().map(|os2| {
let mut os2: fontcull_write_fonts::tables::os2::Os2 = os2.to_owned_table();
os2.us_win_ascent = add_u16_delta(os2.us_win_ascent, mvar_delta(mvar_tags::HCLA));
os2.us_win_descent = add_u16_delta(os2.us_win_descent, mvar_delta(mvar_tags::HCLD));
os2.y_strikeout_size = add_i16_delta(os2.y_strikeout_size, mvar_delta(mvar_tags::STRS));
os2.y_strikeout_position =
add_i16_delta(os2.y_strikeout_position, mvar_delta(mvar_tags::STRO));
os2.sx_height = os2
.sx_height
.map(|v| add_i16_delta(v, mvar_delta(mvar_tags::XHGT)));
os2.s_cap_height = os2
.s_cap_height
.map(|v| add_i16_delta(v, mvar_delta(mvar_tags::CPHT)));
os2
});
let post = font.post().ok().map(|post| {
let mut post: fontcull_write_fonts::tables::post::Post = post.to_owned_table();
post.underline_position =
add_fword_delta(post.underline_position, mvar_delta(mvar_tags::UNDO));
post.underline_thickness =
add_fword_delta(post.underline_thickness, mvar_delta(mvar_tags::UNDS));
post
});
let mut builder = FontBuilder::new();
builder
.add_table(&glyf)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.add_table(&loca)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.add_table(&hmtx)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.add_table(&head)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.add_table(&hhea)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?
.add_table(&maxp)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
if let Some(os2) = &os2 {
builder
.add_table(os2)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
}
if let Some(post) = &post {
builder
.add_table(post)
.map_err(|e| InstanceBakeFailure::Failed(e.to_string()))?;
}
const REBUILT: [[u8; 4]; 8] = [
*b"glyf", *b"loca", *b"hmtx", *b"head", *b"hhea", *b"maxp", *b"OS/2", *b"post",
];
const VARIABLE: [[u8; 4]; 8] = [
*b"fvar", *b"gvar", *b"avar", *b"HVAR", *b"VVAR", *b"MVAR", *b"STAT", *b"cvar",
];
for record in font.table_directory().table_records() {
let tag = record.tag();
let bytes = tag.into_bytes();
if REBUILT.contains(&bytes) || VARIABLE.contains(&bytes) {
continue;
}
if let Some(data) = font.data_for_tag(tag) {
builder.add_raw(tag, data.as_bytes().to_vec());
}
}
Ok(builder.build())
}
fn add_fword_delta(base: FWord, delta: i32) -> FWord {
FWord::from(add_i16_delta(i16::from(base), delta))
}
fn add_i16_delta(base: i16, delta: i32) -> i16 {
(i32::from(base) + delta).clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
}
fn add_u16_delta(base: u16, delta: i32) -> u16 {
(i32::from(base) + delta).clamp(0, i32::from(u16::MAX)) as u16
}
#[derive(Default)]
struct PathPen {
path: BezPath,
cubic: bool,
}
impl OutlinePen for PathPen {
fn move_to(&mut self, x: f32, y: f32) {
self.path.move_to((x as f64, y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.path.line_to((x as f64, y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.path
.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
}
fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, _y: f32) {
self.cubic = true;
}
fn close(&mut self) {
self.path.close_path();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::fonts::opentype::fvar::{AxisTag, VariationCoord};
fn fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test-files/fonts")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("fixture '{name}' is missing ({e}) — run scripts/make_font_fixtures.py")
})
}
fn instance(subfamily: &str, wght: f32, wdth: f32) -> VariationInstance {
VariationInstance {
subfamily: subfamily.to_string(),
post_script_name: None,
coords: vec![
VariationCoord {
axis: AxisTag(*b"wght"),
value: wght,
},
VariationCoord {
axis: AxisTag(*b"wdth"),
value: wdth,
},
],
}
}
fn glyf_bytes(sfnt: &[u8]) -> Vec<u8> {
let font = FontRef::from_index(sfnt, 0).expect("baked bytes must be a valid SFNT");
font.data_for_tag(Tag::new(b"glyf"))
.expect("baked bytes must carry glyf")
.as_bytes()
.to_vec()
}
#[test]
fn baked_instances_have_distinct_outlines() {
let bytes = fixture("DxVariable.ttf");
let default = glyf_bytes(&bytes);
let regular =
glyf_bytes(&bake_instance(&bytes, 0, &instance("Regular", 400.0, 100.0)).unwrap());
let semibold =
glyf_bytes(&bake_instance(&bytes, 0, &instance("SemiBold", 600.0, 100.0)).unwrap());
let bold = glyf_bytes(&bake_instance(&bytes, 0, &instance("Bold", 700.0, 100.0)).unwrap());
let condensed_semibold = glyf_bytes(
&bake_instance(&bytes, 0, &instance("Condensed SemiBold", 600.0, 75.0)).unwrap(),
);
assert_eq!(
regular, default,
"the default-location instance must round-trip unchanged"
);
let all = [&semibold, &bold, &condensed_semibold];
for (i, a) in all.iter().enumerate() {
assert_ne!(**a, default, "instance {i} must differ from the default");
for b in &all[i + 1..] {
assert_ne!(a, b, "distinct instances must bake to distinct outlines");
}
}
}
#[test]
fn baked_instances_apply_mvar_deltas() {
let bytes = fixture("DxVariable.ttf");
fn hhea_and_underline(sfnt: &[u8]) -> (i16, i16, i16, i16) {
let font = FontRef::from_index(sfnt, 0).expect("baked bytes must be a valid SFNT");
let hhea = font.hhea().expect("baked bytes must carry hhea");
let post = font.post().expect("baked bytes must carry post");
(
hhea.ascender().to_i16(),
hhea.descender().to_i16(),
post.underline_position().to_i16(),
post.underline_thickness().to_i16(),
)
}
let regular = bake_instance(&bytes, 0, &instance("Regular", 400.0, 100.0)).unwrap();
let semibold = bake_instance(&bytes, 0, &instance("SemiBold", 600.0, 100.0)).unwrap();
let bold = bake_instance(&bytes, 0, &instance("Bold", 700.0, 100.0)).unwrap();
assert_eq!(
hhea_and_underline(®ular),
(800, -200, 0, 0),
"the default location must round-trip the font's own base values"
);
assert_eq!(
hhea_and_underline(&semibold),
(840, -216, -8, 16),
"SemiBold sits 0.4 of the way through the default-to-max tent"
);
assert_eq!(
hhea_and_underline(&bold),
(860, -224, -12, 24),
"Bold sits 0.6 of the way through the default-to-max tent"
);
}
fn sfnt_with_table(tag: &[u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]); out.extend_from_slice(&1u16.to_be_bytes()); out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); let table_start = 12 + 16;
out.extend_from_slice(tag); out.extend_from_slice(&0u32.to_be_bytes()); out.extend_from_slice(&(table_start as u32).to_be_bytes()); out.extend_from_slice(&4u32.to_be_bytes()); out.extend_from_slice(&[0, 0, 0, 0]); out
}
#[test]
fn a_cff2_font_is_reported_not_baked() {
let bytes = sfnt_with_table(b"CFF2");
assert_eq!(
bake_instance(&bytes, 0, &instance("SemiBold", 600.0, 100.0)),
Err(InstanceBakeFailure::Cff2NotSupported)
);
}
#[test]
fn a_woff2_font_is_reported_not_baked() {
let mut bytes = b"wOF2".to_vec();
bytes.extend_from_slice(&[0u8; 40]);
assert_eq!(
bake_instance(&bytes, 0, &instance("SemiBold", 600.0, 100.0)),
Err(InstanceBakeFailure::CompressedSourceUnsupported)
);
}
#[test]
fn a_truncated_font_fails_cleanly_not_a_panic() {
let bytes = [0x00, 0x01, 0x00, 0x00, 0xff, 0xff];
let result = bake_instance(&bytes, 0, &instance("SemiBold", 600.0, 100.0));
assert!(matches!(result, Err(InstanceBakeFailure::Failed(_))));
}
}