use std::cmp::{max, min};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{Error, ErrorKind};
use std::time::{SystemTime, UNIX_EPOCH};
use kurbo::{BezPath, CubicBez, PathEl};
use write_fonts::FontBuilder;
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::glyf::{GlyfLocaBuilder, Glyph, SimpleGlyph};
use write_fonts::tables::gsub::{
Gsub, SubstitutionLookup, SubstitutionLookupList, builders::LigatureSubBuilder,
};
use write_fonts::tables::head::Head;
use write_fonts::tables::hhea::Hhea;
use write_fonts::tables::hmtx::{Hmtx, LongMetric};
use write_fonts::tables::layout::builders::{Builder, LookupBuilder};
use write_fonts::tables::layout::{
Feature, FeatureList, FeatureRecord, LangSys, LookupFlag, Script, ScriptList, ScriptRecord,
};
use write_fonts::tables::maxp::Maxp;
use write_fonts::tables::name::{Name, NameRecord};
use write_fonts::tables::os2::Os2;
use write_fonts::tables::post::Post;
use write_fonts::tables::variations::ivs_builder::VariationStoreBuilder;
use write_fonts::types::{FWord, Fixed, GlyphId, GlyphId16, LongDateTime, NameId, Tag, UfWord};
#[cfg(test)]
use crate::GenerateWebfontsOptions;
use crate::svg::types::ProcessedGlyph;
#[cfg(test)]
use crate::svg::{prepare_svg_font, svg_options_from_options};
#[cfg(test)]
use crate::types::LoadedSvgFile;
use crate::types::ResolvedGenerateWebfontsOptions;
#[cfg(test)]
use crate::{finalize_generate_webfonts_options, resolve_generate_webfonts_options};
const QUAD_APPROXIMATION_ACCURACY: f64 = 0.25;
const DEFAULT_VENDOR_ID: Tag = Tag::new(b"ATLW");
const DEFAULT_TTF_DESCRIPTION: &str = "Generated by svg2ttf from Fontello project.";
const DEFAULT_TTF_MANUFACTURER_URL: &str = "http://fontello.com";
pub(crate) struct TtfOptions<'a> {
pub ascent: Option<f64>,
pub copyright: Option<&'a str>,
pub descent: Option<f64>,
pub description: Option<&'a str>,
pub font_height: Option<f64>,
pub font_name: &'a str,
pub font_style: Option<&'a str>,
pub font_weight: Option<&'a str>,
pub ligature: bool,
pub manufacturer_url: Option<&'a str>,
pub ts: Option<i64>,
pub version: Option<&'a str>,
}
struct CompiledGlyph {
advance_width: u16,
bbox: write_fonts::tables::glyf::Bbox,
codepoint: u32,
left_side_bearing: i16,
name: String,
simple_glyph: SimpleGlyph,
source_index: usize,
}
struct LigaturePlaceholderGlyph {
codepoint: u32,
name: String,
}
#[cfg(test)]
pub(crate) fn generate_ttf_font_bytes(options: GenerateWebfontsOptions) -> Result<Vec<u8>, Error> {
let mut resolved_options = resolve_generate_webfonts_options(options)
.map_err(|error| Error::new(ErrorKind::InvalidInput, error.to_string()))?;
let source_files = resolved_options
.files
.iter()
.map(|path| {
Ok(LoadedSvgFile {
contents: std::fs::read_to_string(path)?,
glyph_name: std::path::Path::new(path)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default()
.to_owned(),
path: path.clone(),
})
})
.collect::<Result<Vec<_>, Error>>()?;
finalize_generate_webfonts_options(&mut resolved_options, &source_files)
.map_err(|error| Error::new(ErrorKind::InvalidInput, error.to_string()))?;
let svg_options = svg_options_from_options(&resolved_options);
let prepared = prepare_svg_font(&svg_options, &source_files)
.map_err(|error| Error::new(ErrorKind::InvalidData, error.to_string()))?;
generate_ttf_font_bytes_from_glyphs(
ttf_options_from_options(&resolved_options),
&prepared.processed_glyphs,
)
}
pub(crate) fn ttf_options_from_options(
options: &ResolvedGenerateWebfontsOptions,
) -> TtfOptions<'_> {
let ttf_format = options
.format_options
.as_ref()
.and_then(|value| value.ttf.as_ref());
TtfOptions {
ascent: options.ascent,
copyright: ttf_format.and_then(|v| v.copyright.as_deref()),
descent: options.descent,
description: ttf_format.and_then(|v| v.description.as_deref()),
font_height: options.font_height,
font_name: &options.font_name,
font_style: options.font_style.as_deref(),
font_weight: options.font_weight.as_deref(),
ligature: options.ligature,
manufacturer_url: ttf_format.and_then(|v| v.url.as_deref()),
ts: ttf_format.and_then(|v| v.ts),
version: ttf_format.and_then(|v| v.version.as_deref()),
}
}
pub(crate) fn generate_ttf_font_bytes_from_glyphs(
options: TtfOptions,
glyphs: &[ProcessedGlyph],
) -> Result<Vec<u8>, Error> {
let font_height = options.font_height.unwrap_or_else(|| {
glyphs
.iter()
.fold(0.0_f64, |current, glyph| current.max(glyph.height))
.max(1.0)
});
let descent = options.descent.unwrap_or(0.0);
let ascent = options.ascent.unwrap_or(font_height - descent);
let (compiled_glyphs, cmap_aliases) = compile_and_dedup_glyphs(glyphs)?;
let ligature_placeholders = build_ligature_placeholders(&compiled_glyphs, options.ligature);
let (glyf, loca, loca_format) = build_glyf_table(&compiled_glyphs, &ligature_placeholders)?;
let metrics = compute_glyph_metrics(&compiled_glyphs);
assemble_font(
&options,
&compiled_glyphs,
&cmap_aliases,
&ligature_placeholders,
glyf,
loca,
loca_format,
&metrics,
ascent,
descent,
font_height,
)
}
type CmapAliases = Vec<(u32, usize)>;
fn compile_and_dedup_glyphs(
glyphs: &[ProcessedGlyph],
) -> Result<(Vec<CompiledGlyph>, CmapAliases), Error> {
let mut compiled: Vec<CompiledGlyph> = Vec::with_capacity(glyphs.len());
let mut aliases: Vec<(u32, usize)> = Vec::new();
let mut seen: HashMap<(usize, u16), Vec<usize>> = HashMap::new();
for (i, glyph) in glyphs.iter().enumerate() {
let advance_width = clamp_to_u16(glyph.width.round(), 0, u16::MAX);
let key = (glyph.path_data.len(), advance_width);
let duplicate_of = seen.get(&key).and_then(|indices| {
indices
.iter()
.find(|&&idx| glyphs[compiled[idx].source_index].path_data == glyph.path_data)
.copied()
});
if let Some(first_idx) = duplicate_of {
aliases.push((glyph.codepoint, first_idx));
} else {
let idx = compiled.len();
seen.entry(key).or_default().push(idx);
compiled.push(compile_glyph(i, glyph)?);
}
}
Ok((compiled, aliases))
}
fn build_glyf_table(
compiled_glyphs: &[CompiledGlyph],
ligature_placeholders: &[LigaturePlaceholderGlyph],
) -> Result<
(
write_fonts::tables::glyf::Glyf,
write_fonts::tables::loca::Loca,
write_fonts::tables::loca::LocaFormat,
),
Error,
> {
let mut builder = GlyfLocaBuilder::new();
builder
.add_glyph(&Glyph::Empty)
.map_err(|error| Error::other(format!("Failed to add .notdef glyph: {error}")))?;
for glyph in compiled_glyphs {
builder.add_glyph(&glyph.simple_glyph).map_err(|error| {
Error::other(format!("Failed to compile glyph '{}': {error}", glyph.name))
})?;
}
for placeholder in ligature_placeholders {
builder.add_glyph(&Glyph::Empty).map_err(|error| {
Error::other(format!(
"Failed to add ligature placeholder '{}': {error}",
placeholder.name
))
})?;
}
Ok(builder.build())
}
struct GlyphMetrics {
advance_width_max: u16,
bbox: (i16, i16, i16, i16),
max_contours: u16,
max_points: u16,
min_left_side_bearing: i16,
min_right_side_bearing: i32,
x_avg_char_width: i16,
x_max_extent: i32,
}
fn compute_glyph_metrics(glyphs: &[CompiledGlyph]) -> GlyphMetrics {
let bbox = glyphs.iter().fold(
(0_i16, 0_i16, 0_i16, 0_i16),
|(x_min, y_min, x_max, y_max), g| {
(
min(x_min, g.bbox.x_min),
min(y_min, g.bbox.y_min),
max(x_max, g.bbox.x_max),
max(y_max, g.bbox.y_max),
)
},
);
GlyphMetrics {
advance_width_max: glyphs.iter().map(|g| g.advance_width).max().unwrap_or(0),
bbox,
max_contours: glyphs
.iter()
.map(|g| g.simple_glyph.contours.len() as u16)
.max()
.unwrap_or(0),
max_points: glyphs
.iter()
.map(|g| {
g.simple_glyph
.contours
.iter()
.map(|c| c.len())
.sum::<usize>() as u16
})
.max()
.unwrap_or(0),
min_left_side_bearing: glyphs
.iter()
.map(|g| g.left_side_bearing)
.min()
.unwrap_or(0),
min_right_side_bearing: glyphs
.iter()
.map(|g| {
i32::from(g.advance_width)
- (i32::from(g.left_side_bearing) + i32::from(g.bbox.x_max)
- i32::from(g.bbox.x_min))
})
.min()
.unwrap_or(0),
x_avg_char_width: average_advance_width(glyphs),
x_max_extent: glyphs
.iter()
.map(|g| {
i32::from(g.left_side_bearing) + (i32::from(g.bbox.x_max) - i32::from(g.bbox.x_min))
})
.max()
.unwrap_or(0),
}
}
#[allow(clippy::too_many_arguments)]
fn assemble_font(
options: &TtfOptions,
compiled_glyphs: &[CompiledGlyph],
cmap_aliases: &[(u32, usize)],
ligature_placeholders: &[LigaturePlaceholderGlyph],
glyf: write_fonts::tables::glyf::Glyf,
loca: write_fonts::tables::loca::Loca,
loca_format: write_fonts::tables::loca::LocaFormat,
metrics: &GlyphMetrics,
ascent: f64,
descent: f64,
font_height: f64,
) -> Result<Vec<u8>, Error> {
let units_per_em = clamp_to_u16(font_height.round(), 16, 16_384);
let h_metrics = std::iter::once(LongMetric::new(0, 0))
.chain(
compiled_glyphs
.iter()
.map(|g| LongMetric::new(g.advance_width, g.left_side_bearing)),
)
.chain(ligature_placeholders.iter().map(|_| LongMetric::new(0, 0)))
.collect::<Vec<_>>();
let character_indices: Vec<u16> = compiled_glyphs
.iter()
.map(|g| g.codepoint)
.chain(ligature_placeholders.iter().map(|g| g.codepoint))
.map(|cp| cp.min(u32::from(u16::MAX)) as u16)
.collect();
let first_char_index = character_indices.iter().copied().min().unwrap_or(0);
let last_char_index = character_indices.iter().copied().max().unwrap_or(0);
let head_timestamp = derive_head_timestamp(options.ts);
let head = Head::new(
Fixed::from_i32(1),
0,
Default::default(),
units_per_em,
head_timestamp,
head_timestamp,
metrics.bbox.0,
metrics.bbox.1,
metrics.bbox.2,
metrics.bbox.3,
Default::default(),
8,
i16::from(loca_format == write_fonts::tables::loca::LocaFormat::Long),
);
let hhea = Hhea::new(
FWord::new(clamp_to_i16(ascent.round())),
FWord::new(clamp_to_i16(-descent.round())),
FWord::new(0),
UfWord::new(metrics.advance_width_max),
FWord::new(metrics.min_left_side_bearing),
FWord::new(clamp_to_i16(f64::from(metrics.min_right_side_bearing))),
FWord::new(clamp_to_i16(f64::from(metrics.x_max_extent))),
1,
0,
0,
h_metrics.len() as u16,
);
let hmtx = Hmtx::new(h_metrics, Vec::new());
let maxp = Maxp {
num_glyphs: (compiled_glyphs.len() + ligature_placeholders.len() + 1) as u16,
max_points: Some(metrics.max_points),
max_contours: Some(metrics.max_contours),
max_composite_points: Some(0),
max_composite_contours: Some(0),
max_zones: Some(2),
max_twilight_points: Some(0),
max_storage: Some(0),
max_function_defs: Some(0),
max_instruction_defs: Some(0),
max_stack_elements: Some(0),
max_size_of_instructions: Some(0),
max_component_elements: Some(0),
max_component_depth: Some(0),
};
let os2 = Os2 {
x_avg_char_width: metrics.x_avg_char_width,
us_weight_class: derive_weight_class(options.font_weight),
ach_vend_id: DEFAULT_VENDOR_ID,
us_first_char_index: first_char_index,
us_last_char_index: last_char_index,
s_typo_ascender: clamp_to_i16(ascent.round()),
s_typo_descender: clamp_to_i16(-descent.round()),
s_typo_line_gap: 0,
us_win_ascent: clamp_to_u16(ascent.round(), 0, u16::MAX),
us_win_descent: clamp_to_u16(descent.round(), 0, u16::MAX),
..Default::default()
};
let cmap = Cmap::from_mappings(
compiled_glyphs
.iter()
.enumerate()
.filter_map(|(i, g)| {
char::from_u32(g.codepoint).map(|c| (c, GlyphId::new((i + 1) as u32)))
})
.chain(cmap_aliases.iter().filter_map(|(cp, idx)| {
char::from_u32(*cp).map(|c| (c, GlyphId::new((*idx + 1) as u32)))
}))
.chain(
ligature_placeholders
.iter()
.enumerate()
.filter_map(|(i, g)| {
char::from_u32(g.codepoint)
.map(|c| (c, GlyphId::new((compiled_glyphs.len() + i + 1) as u32)))
}),
),
)
.map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("Failed to build cmap table: {error}"),
)
})?;
let font_subfamily = derive_subfamily_name(options);
let name = build_name_table(
options.font_name,
&font_subfamily,
options.copyright,
options.description,
options.manufacturer_url,
derive_version_string(options.version).as_deref(),
);
let post = Post::new_v2(
std::iter::once(".notdef")
.chain(compiled_glyphs.iter().map(|g| g.name.as_str()))
.chain(ligature_placeholders.iter().map(|g| g.name.as_str())),
);
let gsub = build_ligature_gsub(compiled_glyphs, ligature_placeholders);
macro_rules! add_table {
($font:expr, $table:expr, $name:literal) => {
$font.add_table($table).map_err(|e| {
Error::new(
ErrorKind::Other,
format!("Failed to add {} table: {e:?}", $name),
)
})?
};
}
let mut font = FontBuilder::new();
add_table!(font, &head, "head");
add_table!(font, &hhea, "hhea");
add_table!(font, &maxp, "maxp");
add_table!(font, &os2, "OS/2");
add_table!(font, &hmtx, "hmtx");
add_table!(font, &cmap, "cmap");
add_table!(font, &loca, "loca");
add_table!(font, &glyf, "glyf");
add_table!(font, &name, "name");
add_table!(font, &post, "post");
if let Some(gsub) = &gsub {
add_table!(font, gsub, "GSUB");
}
Ok(font.build())
}
fn compile_glyph(source_index: usize, glyph: &ProcessedGlyph) -> Result<CompiledGlyph, Error> {
let path = quadratic_path_from_svg_path_data(&glyph.path_data)?;
let simple_glyph = SimpleGlyph::from_bezpath(&path).map_err(|error| {
Error::other(format!(
"Failed to convert glyph '{}' into a TrueType outline: {error:?}",
glyph.name
))
})?;
let bbox = simple_glyph.bbox;
Ok(CompiledGlyph {
advance_width: clamp_to_u16(glyph.width.round(), 0, u16::MAX),
bbox,
codepoint: glyph.codepoint,
left_side_bearing: bbox.x_min,
name: glyph.name.clone(),
simple_glyph,
source_index,
})
}
fn quadratic_path_from_svg_path_data(path_data: &str) -> Result<BezPath, Error> {
let path = BezPath::from_svg(path_data).map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("Failed to parse generated SVG path data as a Bezier path: {error:?}"),
)
})?;
let mut quadratic_path = BezPath::new();
let mut current_point = None;
for element in path.elements() {
match *element {
PathEl::MoveTo(point) => {
quadratic_path.move_to(point);
current_point = Some(point);
}
PathEl::LineTo(point) => {
quadratic_path.line_to(point);
current_point = Some(point);
}
PathEl::QuadTo(control, point) => {
quadratic_path.quad_to(control, point);
current_point = Some(point);
}
PathEl::CurveTo(control1, control2, point) => {
let start = current_point.ok_or_else(|| {
Error::new(
ErrorKind::InvalidData,
"Encountered a cubic segment before a MoveTo while building a TTF glyph.",
)
})?;
let cubic = CubicBez::new(start, control1, control2, point);
for (_, _, quad) in cubic.to_quads(QUAD_APPROXIMATION_ACCURACY) {
quadratic_path.quad_to(quad.p1, quad.p2);
}
current_point = Some(point);
}
PathEl::ClosePath => {
quadratic_path.close_path();
current_point = None;
}
}
}
Ok(quadratic_path)
}
fn build_name_table(
font_family: &str,
font_subfamily: &str,
copyright: Option<&str>,
description: Option<&str>,
url: Option<&str>,
version: Option<&str>,
) -> Name {
let full_name = if font_subfamily == "Regular" {
font_family.to_string()
} else {
format!("{font_family} {font_subfamily}")
};
let postscript_name = full_name.replace(' ', "-");
let mut name_record = vec![
make_windows_name_record(1, font_family),
make_windows_name_record(2, font_subfamily),
make_windows_name_record(4, &full_name),
make_windows_name_record(5, version.unwrap_or("Version 1.0")),
make_windows_name_record(6, &postscript_name),
];
if let Some(copyright) = copyright {
name_record.push(make_windows_name_record(0, copyright));
}
name_record.push(make_windows_name_record(
10,
description.unwrap_or(DEFAULT_TTF_DESCRIPTION),
));
name_record.push(make_windows_name_record(
11,
url.unwrap_or(DEFAULT_TTF_MANUFACTURER_URL),
));
name_record.sort();
Name {
name_record,
..Default::default()
}
}
fn make_windows_name_record(name_id: u16, value: &str) -> NameRecord {
NameRecord::new(3, 1, 0x0409, NameId::new(name_id), value.to_string().into())
}
fn average_advance_width(compiled_glyphs: &[CompiledGlyph]) -> i16 {
let non_zero_widths = compiled_glyphs
.iter()
.map(|glyph| glyph.advance_width)
.filter(|width| *width > 0)
.collect::<Vec<_>>();
if non_zero_widths.is_empty() {
return 0;
}
let total: u32 = non_zero_widths.iter().map(|width| u32::from(*width)).sum();
clamp_to_i16((total / non_zero_widths.len() as u32) as f64)
}
fn derive_subfamily_name(options: &TtfOptions) -> String {
match options.font_style {
Some(style) if style.eq_ignore_ascii_case("italic") => "Italic".to_string(),
_ => "Regular".to_string(),
}
}
fn derive_weight_class(font_weight: Option<&str>) -> u16 {
match font_weight {
Some(value) if value.eq_ignore_ascii_case("bold") => 700,
Some(value) => value
.parse::<u16>()
.ok()
.filter(|weight| (1..=1000).contains(weight))
.unwrap_or(400),
None => 400,
}
}
fn derive_version_string(version: Option<&str>) -> Option<String> {
let version = version?.trim();
if version.is_empty() {
return None;
}
if version.starts_with("Version ") {
return Some(version.to_string());
}
Some(format!("Version {}", version.trim_end_matches('.')))
}
fn derive_head_timestamp(_ts: Option<i64>) -> LongDateTime {
const MAC_EPOCH_OFFSET_SECONDS: i64 = 2_082_844_800;
let unix_seconds = _ts.unwrap_or_else(current_unix_timestamp);
LongDateTime::new(unix_seconds.saturating_add(MAC_EPOCH_OFFSET_SECONDS))
}
fn current_unix_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
fn build_ligature_placeholders(
compiled_glyphs: &[CompiledGlyph],
ligature: bool,
) -> Vec<LigaturePlaceholderGlyph> {
if !ligature {
return Vec::new();
}
let mut seen = HashSet::new();
let mut placeholders = Vec::new();
for glyph in compiled_glyphs {
if glyph.name.chars().count() < 2 {
continue;
}
for character in glyph.name.chars() {
let codepoint = u32::from(character);
if seen.insert(codepoint) {
placeholders.push(LigaturePlaceholderGlyph {
codepoint,
name: format!("ligature-char-{:X}", codepoint),
});
}
}
}
placeholders
}
fn build_ligature_gsub(
compiled_glyphs: &[CompiledGlyph],
ligature_placeholders: &[LigaturePlaceholderGlyph],
) -> Option<Gsub> {
if ligature_placeholders.is_empty() {
return None;
}
let placeholder_glyph_ids = ligature_placeholders
.iter()
.enumerate()
.map(|(index, glyph)| {
(
glyph.codepoint,
GlyphId16::new((compiled_glyphs.len() + index + 1) as u16),
)
})
.collect::<BTreeMap<_, _>>();
let mut lookup_builder = LookupBuilder::<LigatureSubBuilder>::new(LookupFlag::empty(), None);
for (index, glyph) in compiled_glyphs.iter().enumerate() {
let sequence = glyph
.name
.chars()
.filter_map(|character| placeholder_glyph_ids.get(&u32::from(character)).copied())
.collect::<Vec<_>>();
if sequence.len() < 2 {
continue;
}
lookup_builder
.last_mut()
.expect("ligature lookup builder should always contain a subtable")
.insert(sequence, GlyphId16::new((index + 1) as u16));
}
if lookup_builder
.iter_subtables()
.all(LigatureSubBuilder::is_empty)
{
return None;
}
let script_list = ScriptList::new(vec![ScriptRecord::new(
Tag::new(b"DFLT"),
Script::new(Some(LangSys::new(vec![0])), vec![]),
)]);
let feature_list = FeatureList::new(vec![FeatureRecord::new(
Tag::new(b"liga"),
Feature::new(None, vec![0]),
)]);
let mut variation_store = VariationStoreBuilder::new(0);
let lookup = lookup_builder.build(&mut variation_store);
let lookup_list = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(lookup)]);
Some(Gsub::new(script_list, feature_list, lookup_list))
}
fn clamp_to_i16(value: f64) -> i16 {
value
.clamp(f64::from(i16::MIN), f64::from(i16::MAX))
.round() as i16
}
fn clamp_to_u16(value: f64, min_value: u16, max_value: u16) -> u16 {
value
.clamp(f64::from(min_value), f64::from(max_value))
.round() as u16
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::generate_ttf_font_bytes;
use crate::GenerateWebfontsOptions;
use write_fonts::read::{FontRef, TableProvider};
#[test]
fn generates_a_ttf_buffer_with_a_true_type_header() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture = root
.join("src/svg/fixtures/icons/cleanicons")
.join("plus.svg");
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
dest: "artifacts".to_string(),
files: vec![fixture.display().to_string()],
html: Some(false),
font_name: Some("cleanicons".to_string()),
ligature: Some(false),
start_codepoint: Some(0xE001),
..Default::default()
})
.expect("expected native ttf generation to succeed");
assert_eq!(&result[..4], &[0x00, 0x01, 0x00, 0x00]);
assert!(!result.is_empty());
}
#[test]
fn adds_gsub_ligatures_and_placeholder_glyphs_when_enabled() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture = root
.join("src/svg/fixtures/icons/cleanicons")
.join("plus.svg");
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
dest: "artifacts".to_string(),
files: vec![fixture.display().to_string()],
html: Some(false),
font_name: Some("plus".to_string()),
ligature: Some(true),
start_codepoint: Some(0xE001),
..Default::default()
})
.expect("expected native ttf ligature generation to succeed");
let font = FontRef::new(&result).expect("expected a readable ttf font");
assert!(font.gsub().is_ok(), "expected a GSUB table for ligatures");
assert!(font.maxp().expect("expected maxp table").num_glyphs() > 2);
}
use crate::test_helpers::icons_root;
fn create_svg_copies(
source: &std::path::Path,
names: &[&str],
) -> (std::path::PathBuf, Vec<String>) {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("ttf-dedup-{}-{unique}", names.join("-")));
std::fs::create_dir_all(&tmp).unwrap();
let files = names
.iter()
.map(|name| {
let dest = tmp.join(format!("{name}.svg"));
std::fs::copy(source, &dest).unwrap();
dest.display().to_string()
})
.collect();
(tmp, files)
}
#[test]
fn deduplicates_pair_of_identical_glyphs_with_explicit_codepoints() {
let icon = icons_root().join("cleanicons/plus.svg");
let (tmp, copies) = create_svg_copies(&icon, &["plus-copy"]);
let mut files = vec![icon.display().to_string()];
files.extend(copies);
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
codepoints: Some(std::collections::HashMap::from([
("plus".to_string(), 0xE001u32),
("plus-copy".to_string(), 0xE002u32),
])),
dest: "artifacts".to_string(),
files,
html: Some(false),
font_name: Some("dedup-test".to_string()),
ligature: Some(false),
start_codepoint: Some(0xE001),
..Default::default()
})
.expect("TTF generation should succeed");
let _ = std::fs::remove_dir_all(&tmp);
let font = FontRef::new(&result).expect("readable TTF");
assert_eq!(
font.maxp().unwrap().num_glyphs(),
2,
"1 .notdef + 1 deduped glyph"
);
let cmap = font.cmap().expect("cmap");
let gid_1 = cmap.map_codepoint(0xE001u32);
let gid_2 = cmap.map_codepoint(0xE002u32);
assert!(gid_1.is_some(), "E001 should be in cmap");
assert_eq!(gid_1, gid_2, "both codepoints should map to same glyph ID");
}
#[test]
fn deduplicates_pair_of_identical_glyphs_with_implicit_codepoints() {
let icon = icons_root().join("cleanicons/plus.svg");
let (tmp, copies) = create_svg_copies(&icon, &["plus-copy"]);
let mut files = vec![icon.display().to_string()];
files.extend(copies);
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
dest: "artifacts".to_string(),
files,
html: Some(false),
font_name: Some("dedup-implicit".to_string()),
ligature: Some(false),
start_codepoint: Some(0xF101),
..Default::default()
})
.expect("TTF generation should succeed");
let _ = std::fs::remove_dir_all(&tmp);
let font = FontRef::new(&result).expect("readable TTF");
assert_eq!(
font.maxp().unwrap().num_glyphs(),
2,
"1 .notdef + 1 deduped glyph"
);
let cmap = font.cmap().expect("cmap");
let gid_1 = cmap.map_codepoint(0xF101u32);
let gid_2 = cmap.map_codepoint(0xF102u32);
assert!(gid_1.is_some());
assert_eq!(
gid_1, gid_2,
"auto-assigned codepoints should map to same glyph ID"
);
}
#[test]
fn deduplicates_multiple_copies_into_single_glyph() {
let icon = icons_root().join("cleanicons/plus.svg");
let (tmp, copies) = create_svg_copies(&icon, &["copy-a", "copy-b", "copy-c"]);
let mut files = vec![icon.display().to_string()];
files.extend(copies);
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
codepoints: Some(std::collections::HashMap::from([
("plus".to_string(), 0xE001u32),
("copy-a".to_string(), 0xE002u32),
("copy-c".to_string(), 0xE003u32),
])),
dest: "artifacts".to_string(),
files,
html: Some(false),
font_name: Some("dedup-multi".to_string()),
ligature: Some(false),
start_codepoint: Some(0xE001),
..Default::default()
})
.expect("TTF generation should succeed");
let _ = std::fs::remove_dir_all(&tmp);
let font = FontRef::new(&result).expect("readable TTF");
assert_eq!(
font.maxp().unwrap().num_glyphs(),
2,
"4 identical SVGs → 1 .notdef + 1 deduped glyph"
);
let cmap = font.cmap().expect("cmap");
let gids: Vec<_> = (0xE001u32..=0xE004u32)
.map(|cp| cmap.map_codepoint(cp))
.collect();
assert!(
gids.iter().all(|g| g.is_some()),
"all 4 codepoints should be in cmap"
);
assert!(
gids.windows(2).all(|w| w[0] == w[1]),
"all 4 codepoints should map to same glyph ID"
);
}
#[test]
fn does_not_deduplicate_glyphs_with_different_paths() {
let result = generate_ttf_font_bytes(GenerateWebfontsOptions {
css: Some(false),
codepoints: Some(std::collections::HashMap::from([
("plus".to_string(), 0xE001u32),
("minus".to_string(), 0xE002u32),
])),
dest: "artifacts".to_string(),
files: vec![
icons_root()
.join("cleanicons/plus.svg")
.display()
.to_string(),
icons_root()
.join("cleanicons/minus.svg")
.display()
.to_string(),
],
html: Some(false),
font_name: Some("no-dedup".to_string()),
ligature: Some(false),
start_codepoint: Some(0xE001),
..Default::default()
})
.expect("TTF generation should succeed");
let font = FontRef::new(&result).expect("readable TTF");
assert_eq!(
font.maxp().unwrap().num_glyphs(),
3,
"1 .notdef + 2 unique glyphs"
);
let cmap = font.cmap().expect("cmap");
assert_ne!(
cmap.map_codepoint(0xE001u32),
cmap.map_codepoint(0xE002u32),
"different glyphs should have different glyph IDs"
);
}
}