use std::collections::HashSet;
use std::fmt;
use skia_safe::Data;
use crate::render::fonts::{FontRegistry, TypefaceId, TypefaceOrigin};
use crate::render::subset::collect::CodepointUsage;
use crate::render::subset::extract::{extract, ExtractionError};
use crate::render::subset::format::FontFormat;
#[derive(Debug, Clone)]
pub enum SubsetOutcome {
Subsetted {
id: TypefaceId,
bytes_before: usize,
bytes_after: usize,
codepoints_kept: usize,
},
UnchangedNoSavings {
id: TypefaceId,
bytes: usize,
codepoints_kept: usize,
reason: NoSavingsReason,
},
UnsupportedFormat { id: TypefaceId, format: FontFormat },
NoBytesAvailable { id: TypefaceId },
SubsetterError { id: TypefaceId, message: String },
SkiaRebuildFailed { id: TypefaceId },
UnshapeableSubset {
id: TypefaceId,
codepoints_kept: usize,
regressed: usize,
shapeable_before: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoSavingsReason {
NothingToDrop,
NameSpliceOutweighedSavings,
}
struct SplicedFont {
bytes: Vec<u8>,
size_before_splice: usize,
}
fn splice_name_recording_size(subsetted: Vec<u8>, original: &[u8]) -> SplicedFont {
let size_before_splice = subsetted.len();
let bytes = match crate::render::subset::name_splice::splice_original_name(&subsetted, original)
{
Ok(b) => b,
Err(_) => subsetted,
};
SplicedFont {
bytes,
size_before_splice,
}
}
fn savings_verdict(before: usize, subsetted: usize, after: usize) -> Option<NoSavingsReason> {
if after < before {
return None;
}
if subsetted >= before {
Some(NoSavingsReason::NothingToDrop)
} else {
Some(NoSavingsReason::NameSpliceOutweighedSavings)
}
}
impl SubsetOutcome {
pub fn id(&self) -> TypefaceId {
match *self {
Self::Subsetted { id, .. }
| Self::UnchangedNoSavings { id, .. }
| Self::UnsupportedFormat { id, .. }
| Self::NoBytesAvailable { id }
| Self::SubsetterError { id, .. }
| Self::SkiaRebuildFailed { id }
| Self::UnshapeableSubset { id, .. } => id,
}
}
pub fn savings(&self) -> usize {
match *self {
Self::Subsetted {
bytes_before,
bytes_after,
..
} => bytes_before.saturating_sub(bytes_after),
_ => 0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SubsetReport {
pub outcomes: Vec<SubsetOutcome>,
}
impl SubsetReport {
pub fn total_savings(&self) -> usize {
self.outcomes.iter().map(|o| o.savings()).sum()
}
pub fn subsetted_count(&self) -> usize {
self.outcomes
.iter()
.filter(|o| matches!(o, SubsetOutcome::Subsetted { .. }))
.count()
}
}
impl SubsetReport {
pub fn unshapeable_count(&self) -> usize {
self.outcomes
.iter()
.filter(|o| matches!(o, SubsetOutcome::UnshapeableSubset { .. }))
.count()
}
pub fn name_splice_outweighed_count(&self) -> usize {
self.outcomes
.iter()
.filter(|o| {
matches!(
o,
SubsetOutcome::UnchangedNoSavings {
reason: NoSavingsReason::NameSpliceOutweighedSavings,
..
}
)
})
.count()
}
}
impl fmt::Display for SubsetReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let subsetted = self.subsetted_count();
let savings = self.total_savings();
let total = self.outcomes.len();
let other = total - subsetted;
let unshapeable = self.unshapeable_count();
let splice_cost = self.name_splice_outweighed_count();
write!(
f,
"{subsetted}/{total} typefaces subsetted ({savings} bytes saved, {other} unchanged"
)?;
if unshapeable > 0 {
write!(f, ", {unshapeable} rejected as unshapeable")?;
}
if splice_cost > 0 {
write!(f, ", {splice_cost} outweighed by the name table")?;
}
write!(f, ")")
}
}
pub fn apply(usage: CodepointUsage, registry: &mut FontRegistry) -> SubsetReport {
let mut outcomes = Vec::new();
let mut processed: HashSet<TypefaceId> = HashSet::new();
let entries = registry.cached_entries();
for (_key, entry) in entries {
let id = TypefaceId::from(&entry.typeface);
if !processed.insert(id) {
continue;
}
let cps = match usage.codepoints(id) {
Some(c) => c,
None => continue, };
let outcome = process_one(id, &entry, cps, registry);
outcomes.push(outcome);
}
SubsetReport { outcomes }
}
fn process_one(
id: TypefaceId,
entry: &crate::render::fonts::TypefaceEntry,
codepoints: &std::collections::BTreeSet<crate::render::subset::collect::Codepoint>,
registry: &mut FontRegistry,
) -> SubsetOutcome {
let extracted = match extract(entry, registry) {
Ok(e) => e,
Err(ExtractionError::NoBytesAvailable) => return SubsetOutcome::NoBytesAvailable { id },
Err(ExtractionError::UnsupportedFormat(format)) => {
return SubsetOutcome::UnsupportedFormat { id, format };
}
Err(e) => {
return SubsetOutcome::SubsetterError {
id,
message: format!("extract: {e}"),
};
}
};
let bytes_before = extracted.bytes.len();
let unicodes: Vec<u32> = codepoints.iter().map(|c| c.0).collect();
let subsetted = match subset_with_fontcull(&extracted.bytes, &unicodes) {
Ok(b) => b,
Err(e) => {
return SubsetOutcome::SubsetterError {
id,
message: format!("subset: {e}"),
};
}
};
let spliced = splice_name_recording_size(subsetted, &extracted.bytes);
let subsetted = spliced.bytes;
let bytes_after = subsetted.len();
if let Some(reason) = savings_verdict(bytes_before, spliced.size_before_splice, bytes_after) {
return SubsetOutcome::UnchangedNoSavings {
id,
bytes: bytes_before,
codepoints_kept: codepoints.len(),
reason,
};
}
let new_tf = {
let data = Data::new_copy(&subsetted);
registry.font_mgr().new_from_data(&data, 0)
};
let new_tf = match new_tf {
Some(t) => t,
None => return SubsetOutcome::SkiaRebuildFailed { id },
};
if let Some(failure) = check_shapeability(&entry.typeface, &new_tf, codepoints) {
return SubsetOutcome::UnshapeableSubset {
id,
codepoints_kept: codepoints.len(),
regressed: failure.regressed,
shapeable_before: failure.shapeable_before,
};
}
let new_origin = TypefaceOrigin::System {
typeface_id: TypefaceId::from(&new_tf),
};
registry.replace_typeface_by_id(id, new_tf, new_origin);
SubsetOutcome::Subsetted {
id,
bytes_before,
bytes_after,
codepoints_kept: codepoints.len(),
}
}
struct ShapeabilityFailure {
regressed: usize,
shapeable_before: usize,
}
fn check_shapeability(
original: &skia_safe::Typeface,
subsetted: &skia_safe::Typeface,
codepoints: &std::collections::BTreeSet<crate::render::subset::collect::Codepoint>,
) -> Option<ShapeabilityFailure> {
let mut regressed = 0usize;
let mut shapeable_before = 0usize;
for cp in codepoints {
let Ok(unichar) = i32::try_from(cp.0) else {
continue;
};
if original.unichar_to_glyph(unichar) == 0 {
continue;
}
shapeable_before += 1;
if subsetted.unichar_to_glyph(unichar) == 0 {
regressed += 1;
}
}
if regressed == 0 {
return None;
}
Some(ShapeabilityFailure {
regressed,
shapeable_before,
})
}
fn subset_with_fontcull(bytes: &[u8], unicodes: &[u32]) -> Result<Vec<u8>, String> {
fontcull::subset_font_data_unicode(bytes, unicodes, &[]).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::EmbeddedFontVariant;
use crate::render::dimension::Pt;
use crate::render::geometry::{PtOffset, PtSize};
use crate::render::layout::draw_command::{DrawCommand, LayoutedPage};
use crate::render::resolve::color::RgbColor;
use crate::render::subset::collect::collect;
use skia_safe::{Font, FontMgr, FontStyle};
use std::rc::Rc;
fn fmgr() -> FontMgr {
FontMgr::new()
}
fn arbitrary_system_sfnt_bytes() -> Vec<u8> {
let mgr = fmgr();
let candidates = ["Arial", "Helvetica", "Liberation Sans", "Noto Sans"];
for name in candidates {
if let Some(tf) = mgr.match_family_style(name, FontStyle::normal()) {
if tf.family_name().eq_ignore_ascii_case(name) {
if let Some((b, _)) = tf.to_font_data() {
if !b.is_empty() && b[0] == 0x00 {
return b;
}
}
}
}
}
let tf = mgr
.legacy_make_typeface(None::<&str>, FontStyle::normal())
.expect("system has no default typeface");
tf.to_font_data().expect("default lacks bytes").0
}
fn page_with_text(text: &str, family: &str) -> LayoutedPage {
LayoutedPage {
commands: vec![DrawCommand::Text {
position: PtOffset::new(Pt::new(72.0), Pt::new(100.0)),
text: Rc::from(text),
font_family: Rc::from(family),
char_spacing: Pt::ZERO,
font_size: Pt::new(12.0),
bold: false,
italic: false,
color: RgbColor::BLACK,
text_scale: 1.0,
}],
page_size: PtSize::new(Pt::new(612.0), Pt::new(792.0)),
}
}
#[test]
fn subset_shrinks_size_for_partial_codepoint_set() {
let bytes = arbitrary_system_sfnt_bytes();
let unicodes: Vec<u32> = "abc".chars().map(|c| c as u32).collect();
let subsetted = subset_with_fontcull(&bytes, &unicodes)
.expect("fontcull subsetting must succeed for a normal font");
assert!(
subsetted.len() < bytes.len(),
"subsetted bytes ({}) must be smaller than original ({})",
subsetted.len(),
bytes.len()
);
}
#[test]
fn subset_output_is_skia_shapeable() {
let bytes = arbitrary_system_sfnt_bytes();
let unicodes: Vec<u32> = "abc".chars().map(|c| c as u32).collect();
let subsetted = subset_with_fontcull(&bytes, &unicodes).unwrap();
let mgr = fmgr();
let new_tf = mgr
.new_from_data(&Data::new_copy(&subsetted), 0)
.expect("Skia must accept fontcull's output as a valid Typeface");
let font = Font::from_typeface(new_tf, 12.0);
let glyphs = font.text_to_glyphs_vec("abc");
assert!(
glyphs.iter().all(|&g| g != 0),
"shaped glyphs must not be .notdef ({glyphs:?}) — subsetted font must retain cmap"
);
let (width, _) = font.measure_str("abc", None);
assert!(width > 0.0, "measured width must be positive, got {width}");
}
#[test]
fn apply_replaces_typeface_in_registry() {
let bytes = arbitrary_system_sfnt_bytes();
let mut r = FontRegistry::new(fmgr());
r.register_embedded("ApplyProbe", EmbeddedFontVariant::Regular, bytes)
.unwrap();
let original = r.resolve("ApplyProbe", FontStyle::normal());
let original_id = TypefaceId::from(&original.typeface);
let pages = vec![page_with_text("abc", "ApplyProbe")];
let usage = collect(&pages, &r);
let report = apply(usage, &mut r);
assert_eq!(report.outcomes.len(), 1);
match &report.outcomes[0] {
SubsetOutcome::Subsetted {
bytes_after,
bytes_before,
..
} => {
assert!(
bytes_after < bytes_before,
"report must record real shrinkage"
);
}
other => panic!("expected Subsetted, got {other:?}"),
}
let after = r.resolve("ApplyProbe", FontStyle::normal());
let after_id = TypefaceId::from(&after.typeface);
assert_ne!(
after_id, original_id,
"registry resolution must return the subsetted typeface"
);
}
#[test]
fn apply_leaves_unused_typefaces_untouched() {
let bytes = arbitrary_system_sfnt_bytes();
let mut r = FontRegistry::new(fmgr());
r.register_embedded("UnusedProbe", EmbeddedFontVariant::Regular, bytes.clone())
.unwrap();
let original = r.resolve("UnusedProbe", FontStyle::normal());
let original_id = TypefaceId::from(&original.typeface);
let pages = vec![page_with_text("xyz", "UsedFamilyABC")];
let usage = collect(&pages, &r);
let report = apply(usage, &mut r);
let unchanged = r.resolve("UnusedProbe", FontStyle::normal());
assert_eq!(
TypefaceId::from(&unchanged.typeface),
original_id,
"unused typefaces must not be touched"
);
for o in &report.outcomes {
assert_ne!(o.id(), original_id, "unused id must not appear in report");
}
}
#[test]
fn apply_emits_one_outcome_per_typeface_in_usage() {
let bytes = arbitrary_system_sfnt_bytes();
let mut r = FontRegistry::new(fmgr());
r.register_embedded("OutcomeProbe", EmbeddedFontVariant::Regular, bytes)
.unwrap();
let pages = vec![
page_with_text("hello", "OutcomeProbe"),
page_with_text("world", "OutcomeProbe"), ];
let usage = collect(&pages, &r);
assert_eq!(usage.typeface_count(), 1);
let report = apply(usage, &mut r);
assert_eq!(
report.outcomes.len(),
1,
"one outcome per distinct typeface in usage, even with multiple commands"
);
}
#[test]
fn report_display_summarizes_outcomes() {
let report = SubsetReport {
outcomes: vec![
SubsetOutcome::Subsetted {
id: TypefaceId(1),
bytes_before: 100_000,
bytes_after: 10_000,
codepoints_kept: 50,
},
SubsetOutcome::UnsupportedFormat {
id: TypefaceId(2),
format: FontFormat::Woff(crate::render::subset::WoffVersion::V1),
},
],
};
let s = report.to_string();
assert!(s.contains("1/2"));
assert!(s.contains("90000")); }
fn host_font(family: &str) -> Option<skia_safe::Typeface> {
let mgr = fmgr();
let tf = mgr.match_family_style(family, FontStyle::normal())?;
if tf.family_name().eq_ignore_ascii_case(family) {
Some(tf)
} else {
None
}
}
#[test]
fn apply_never_installs_unshapeable_subset() {
let candidates = ["Helvetica Neue", "Arial Unicode MS", "Helvetica", "Arial"];
let target = match candidates.iter().find(|f| host_font(f).is_some()) {
Some(t) => *t,
None => {
eprintln!("skipping: no candidate system font available");
return;
}
};
let mut r = FontRegistry::new(fmgr());
let _ = r.resolve(target, FontStyle::normal());
let pages = vec![page_with_text("Numbers: 1, 2, 3, 4, 5", target)];
let usage = collect(&pages, &r);
assert_eq!(
usage.typeface_count(),
1,
"test precondition — exactly one typeface in use"
);
let _report = apply(usage, &mut r);
let after = r.resolve(target, FontStyle::normal());
let font = skia_safe::Font::from_typeface(after.typeface, 12.0);
let probe = "Numbers: 1, 2, 3, 4, 5";
let glyphs = font.text_to_glyphs_vec(probe);
let nondef: Vec<u16> = glyphs.iter().filter(|&&g| g != 0).copied().collect();
let zeros = glyphs.iter().filter(|&&g| g == 0).count();
assert_eq!(
zeros,
0,
"post-`apply` typeface for '{target}' must shape every probe codepoint \
to a non-.notdef glyph (got {zeros}/{} .notdef out of {nondef:?})",
glyphs.len()
);
}
fn cps_of(text: &str) -> std::collections::BTreeSet<crate::render::subset::collect::Codepoint> {
text.chars()
.map(crate::render::subset::collect::Codepoint::from)
.collect()
}
#[test]
fn check_shapeability_passes_when_nothing_changed() {
let mgr = fmgr();
let tf = mgr
.legacy_make_typeface(None::<&str>, FontStyle::normal())
.expect("system has a default typeface");
assert!(
check_shapeability(&tf, &tf, &cps_of("abc")).is_none(),
"identical typefaces cannot have lost coverage"
);
}
#[test]
fn a_codepoint_the_original_never_had_is_not_a_regression() {
let tf = match host_font("Helvetica").or_else(|| host_font("Arial")) {
Some(t) => t,
None => {
eprintln!("skipping: no Latin-only system font");
return;
}
};
assert_eq!(
tf.unichar_to_glyph('\u{70CF}' as i32),
0,
"precondition: the chosen font must lack this codepoint"
);
assert!(
check_shapeability(&tf, &tf, &cps_of("\u{70CF}")).is_none(),
"the original could not shape it either — not the subsetter's doing"
);
}
#[test]
fn losing_coverage_the_original_had_is_a_regression() {
let mgr = fmgr();
let Some(tf) = host_font("Carlito")
.or_else(|| host_font("Helvetica"))
.or_else(|| host_font("Arial"))
else {
eprintln!("skipping: no system font");
return;
};
let Some((bytes, _)) = tf.to_font_data() else {
eprintln!("skipping: no font data");
return;
};
let Ok(narrow) = fontcull::subset_font_data_unicode(&bytes, &['a' as u32], &[]) else {
eprintln!("skipping: subsetter declined");
return;
};
let Some(narrow_tf) = mgr.new_from_data(&Data::new_copy(&narrow), 0) else {
eprintln!("skipping: rebuild failed");
return;
};
assert_ne!(tf.unichar_to_glyph('b' as i32), 0, "original shapes 'b'");
assert_eq!(
narrow_tf.unichar_to_glyph('b' as i32),
0,
"narrow drops 'b'"
);
let failure = check_shapeability(&tf, &narrow_tf, &cps_of("ab"))
.expect("losing 'b' must be reported");
assert_eq!(failure.regressed, 1, "only 'b' regressed");
assert_eq!(
failure.shapeable_before, 2,
"'a' and 'b' both shaped before"
);
}
#[test]
fn a_dropped_space_is_not_exempt() {
let mgr = fmgr();
let Some(tf) = host_font("Carlito")
.or_else(|| host_font("Helvetica"))
.or_else(|| host_font("Arial"))
else {
eprintln!("skipping: no system font");
return;
};
let Some((bytes, _)) = tf.to_font_data() else {
eprintln!("skipping: no font data");
return;
};
let Ok(narrow) = fontcull::subset_font_data_unicode(&bytes, &['a' as u32], &[]) else {
eprintln!("skipping: subsetter declined");
return;
};
let Some(narrow_tf) = mgr.new_from_data(&Data::new_copy(&narrow), 0) else {
eprintln!("skipping: rebuild failed");
return;
};
if tf.unichar_to_glyph(' ' as i32) == 0 || narrow_tf.unichar_to_glyph(' ' as i32) != 0 {
eprintln!("skipping: this font/subsetter pair keeps the space glyph");
return;
}
assert!(
check_shapeability(&tf, &narrow_tf, &cps_of(" ")).is_some(),
"a space present before and gone after is a regression"
);
}
#[test]
fn a_smaller_result_is_always_installed() {
assert_eq!(savings_verdict(1000, 400, 900), None);
assert_eq!(savings_verdict(1000, 400, 999), None);
}
#[test]
fn no_shrink_before_the_splice_is_nothing_to_drop() {
assert_eq!(
savings_verdict(1000, 1000, 1200),
Some(NoSavingsReason::NothingToDrop)
);
assert_eq!(
savings_verdict(1000, 1100, 1300),
Some(NoSavingsReason::NothingToDrop)
);
}
#[test]
fn shrink_undone_by_the_splice_is_reported_as_such() {
assert_eq!(
savings_verdict(1000, 900, 1000),
Some(NoSavingsReason::NameSpliceOutweighedSavings)
);
assert_eq!(
savings_verdict(1000, 100, 5000),
Some(NoSavingsReason::NameSpliceOutweighedSavings)
);
}
#[test]
fn equal_sizes_do_not_install() {
assert_eq!(
savings_verdict(1000, 1000, 1000),
Some(NoSavingsReason::NothingToDrop)
);
}
#[test]
fn the_name_splice_grows_the_font_and_the_pre_splice_size_is_kept() {
let Some(tf) = host_font("Carlito")
.or_else(|| host_font("Helvetica"))
.or_else(|| host_font("Arial"))
else {
eprintln!("skipping: no system font");
return;
};
let Some((bytes, _)) = tf.to_font_data() else {
eprintln!("skipping: no font data");
return;
};
let unicodes: Vec<u32> = (0x41u32..0x5B).collect();
let Ok(subsetted) = fontcull::subset_font_data_unicode(&bytes, &unicodes, &[]) else {
eprintln!("skipping: subsetter declined");
return;
};
let raw_len = subsetted.len();
let spliced = splice_name_recording_size(subsetted, &bytes);
assert_eq!(
spliced.size_before_splice, raw_len,
"the recorded size must be the one before splicing"
);
assert!(
spliced.bytes.len() > spliced.size_before_splice,
"restoring the name table must add bytes: {} -> {}",
spliced.size_before_splice,
spliced.bytes.len()
);
}
}