use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use color_eyre::eyre::{self, WrapErr, eyre};
use fontcull_klippa::{Plan, SubsetFlags, parse_unicodes, subset_font};
use fontcull_write_fonts::read::FontRef;
use fontcull_write_fonts::read::collections::IntSet;
use fontcull_write_fonts::types::{GlyphId, NameId, Tag};
pub fn subset_into(source: &Path, ranges: &str, output_dir: &Path) -> eyre::Result<PathBuf> {
let stem = source
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| eyre!("font path {} has no UTF-8 file stem", source.display()))?;
let output = output_dir.join(format!("{stem}-{:08x}.subset.ttf", ranges_key(ranges)));
if is_fresh(source, &output) {
return Ok(output);
}
let data = std::fs::read(source)
.wrap_err_with(|| format!("failed to read font {}", source.display()))?;
let subset = subset_bytes(&data, ranges)
.wrap_err_with(|| format!("failed to subset font {}", source.display()))?;
std::fs::create_dir_all(output_dir)
.wrap_err_with(|| format!("failed to create {}", output_dir.display()))?;
std::fs::write(&output, &subset)
.wrap_err_with(|| format!("failed to write {}", output.display()))?;
tracing::info!(
"subset {} ({} KiB) to {} ({} KiB) for ranges {ranges}",
source.display(),
data.len() / 1024,
output.display(),
subset.len() / 1024,
);
Ok(output)
}
pub fn subset_bytes(data: &[u8], ranges: &str) -> eyre::Result<Vec<u8>> {
let font = FontRef::new(data).map_err(|error| eyre!("font does not parse: {error}"))?;
let unicodes = parse_unicodes(ranges)
.map_err(|error| eyre!("invalid font_ranges {ranges:?}: {error:?}"))?;
if unicodes.is_empty() {
return Err(eyre!("font_ranges {ranges:?} selects no codepoints"));
}
let plan = Plan::new(
&IntSet::<GlyphId>::empty(),
&unicodes,
&font,
SubsetFlags::default(),
&IntSet::<Tag>::empty(),
&IntSet::<Tag>::all(),
&IntSet::<Tag>::all(),
&IntSet::<NameId>::all(),
&IntSet::<u16>::all(),
);
subset_font(&font, &plan).map_err(|error| eyre!("subsetting failed: {error:?}"))
}
fn is_fresh(source: &Path, output: &Path) -> bool {
let (Ok(source_meta), Ok(output_meta)) = (source.metadata(), output.metadata()) else {
return false;
};
match (source_meta.modified(), output_meta.modified()) {
(Ok(source_time), Ok(output_time)) => output_time >= source_time,
_ => false,
}
}
fn ranges_key(ranges: &str) -> u64 {
let mut hasher = std::hash::DefaultHasher::new();
ranges.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_FONT: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../testing/fonts/Roboto-Regular.ttf"
));
fn host_font() -> Vec<u8> {
TEST_FONT.to_vec()
}
#[test]
fn ascii_subset_shrinks_and_keeps_cmap() {
use fontcull_skrifa::MetadataProvider as _;
let full = host_font();
let subset = subset_bytes(&full, "20-7E").expect("ASCII subset must succeed");
assert!(
subset.len() * 4 < full.len(),
"an ASCII subset should be under a quarter of the full font \
({} vs {} bytes)",
subset.len(),
full.len()
);
let font = fontcull_skrifa::FontRef::new(&subset).expect("subset must parse as a font");
let charmap = font.charmap();
for ch in ['A', 'z', '0', ' ', '~'] {
let glyph = charmap
.map(ch)
.unwrap_or_else(|| panic!("subset cmap must map {ch:?}"));
assert_ne!(glyph.to_u32(), 0, "{ch:?} must not map to .notdef");
}
assert!(
charmap.map('中').is_none() || charmap.map('中').unwrap().to_u32() == 0,
"codepoints outside the ranges must not survive"
);
}
#[test]
fn empty_ranges_fail_fast() {
let full = host_font();
assert!(subset_bytes(&full, "").is_err());
}
}