From 3ad21bbe7c3f9ea697f9687722e66ea3fb0aa4a2 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:38:26 -0800 Subject: [PATCH 1/9] Walk CFF charstrings through their calls when subsetting Subsetting a hinted CFF2 font failed: the per-body scanner cleared the operand stack at `blend`, so blended stem hints never counted, the hint masks were read as empty, and their bytes passed for operators. Source Serif 4 VF failed 553 of its 1,463 single-glyph subsets this way. The scanner also started every subroutine at zero stems, so a CFF1 mask in a subroutine that counts its caller's stems was misread too. The subsetter now runs each kept glyph's charstring through its subroutine calls, as HarfBuzz's subsetter interprets it: a call runs the callee with the caller's stack and stem count, the first mask fixes the mask size, a CFF2 `blend` drops as many deltas as the store gives the `vsindex` in effect (starting from the Private DICT's), subroutines nest at most 10 deep, and the stack holds at most 513 operands. Each body is read once, at its first call, and the calls found then are renumbered. The walk charges 64 tokens per byte of the table, plus 2^22. A renumbered call whose new number does not fit its push is written in its shortest form and the bytes after it move, as HarfBuzz re-encodes calls, instead of failing; CFF2 no longer falls back to keeping every subroutine. The public per-body helpers (`scan_subr_calls`, `compute_kept_subrs`, `renumber_charstring`) are unchanged. Part of #290. --- crates/sigilbuzz-subset/README.md | 4 +- crates/sigilbuzz-subset/src/cff.rs | 112 ++-- crates/sigilbuzz-subset/src/cff/charstring.rs | 223 +++++--- crates/sigilbuzz-subset/src/cff/cid.rs | 205 +++---- crates/sigilbuzz-subset/src/cff/tests.rs | 19 +- crates/sigilbuzz-subset/src/cff/tests/cid.rs | 31 + .../src/cff/tests/orchestration.rs | 44 +- crates/sigilbuzz-subset/src/cff/walk.rs | 532 ++++++++++++++++++ crates/sigilbuzz-subset/src/cff/walk/tests.rs | 455 +++++++++++++++ crates/sigilbuzz-subset/src/cff2.rs | 38 +- crates/sigilbuzz-subset/src/cff2/subset.rs | 213 +++---- crates/sigilbuzz-subset/src/cff2/tests.rs | 121 ++++ .../src/cff2/tests/partial.rs | 2 +- crates/sigilbuzz-subset/src/lib.rs | 11 + 14 files changed, 1606 insertions(+), 404 deletions(-) create mode 100644 crates/sigilbuzz-subset/src/cff/walk.rs create mode 100644 crates/sigilbuzz-subset/src/cff/walk/tests.rs diff --git a/crates/sigilbuzz-subset/README.md b/crates/sigilbuzz-subset/README.md index 64b926c0..f2141965 100644 --- a/crates/sigilbuzz-subset/README.md +++ b/crates/sigilbuzz-subset/README.md @@ -28,7 +28,9 @@ For TrueType fonts: CFF and CFF2 fonts are supported too, including CID-keyed CFF. When glyphs are dropped, the `CFF ` or `CFF2` table is rebuilt around the kept ones, and every other table follows -the same rules as above. A `CFF2` table keeps its own variation data. A kept `CFF ` glyph +the same rules as above. A `CFF2` table keeps its own variation data. Each kept glyph runs +through its subroutines the way `hb-subset` reads it, so hinted fonts subset whole: a hint +mask counts the stems its glyph declared anywhere, and blended `CFF2` stem hints count. A kept `CFF ` glyph drawn by `seac` (an accented character) keeps its base and accent glyphs. `hb-subset` does the same for a seac in the glyph's own charstring; this subsetter also follows one that a subroutine ends, which sigilbuzz draws. diff --git a/crates/sigilbuzz-subset/src/cff.rs b/crates/sigilbuzz-subset/src/cff.rs index 5ce8c9fa..20044365 100644 --- a/crates/sigilbuzz-subset/src/cff.rs +++ b/crates/sigilbuzz-subset/src/cff.rs @@ -13,6 +13,13 @@ //! transitive local + global subroutine keep-set for a kept-gid //! charstring slice. //! +//! These two read each body on its own, so they cannot size a hint +//! mask whose stems a caller declared, or a CFF2 `blend` whose region +//! count lives in the variation store. The subset orchestration below +//! runs each kept glyph through its subroutine calls instead (the +//! `walk` module), as HarfBuzz's subsetter does, and renumbers the +//! call sites that walk finds. +//! //! # Emitter primitives //! //! - [`encode_index`]: serializes a CFF INDEX (header + offsets + @@ -36,9 +43,10 @@ //! [`subset_non_identity`] is the orchestration that wires the //! analysis layer + emitter primitives end-to-end. It dispatches by //! source shape: non-CID CFF1 sources walk the single-Private path -//! (Top DICT walk, kept-charstring + transitive subroutine keep-set, -//! renumber-in-place, layout in deterministic order: Header / Name -//! INDEX / Top DICT INDEX / String INDEX / Global Subr INDEX / +//! (Top DICT walk, the subroutines the kept charstrings reach, +//! renumbered at the call sites the walk finds, layout in +//! deterministic order: Header / Name INDEX / Top DICT INDEX / +//! String INDEX / Global Subr INDEX / //! Encoding / charset / CharStrings INDEX / Private DICT / Local Subr //! INDEX, then deferred-offset placeholder patches). CID-keyed sources //! (FDArray + FDSelect present in the source Top DICT) route through @@ -70,6 +78,7 @@ mod cid; mod emit; mod reader; mod seac; +mod walk; pub use charstring::{ compute_kept_subrs, encode_int_operand, renumber_charstring, renumber_subr_call, @@ -90,8 +99,10 @@ pub(crate) use reader::{ pub(crate) use seac::SeacClosure; use charset::{extract_kept_charset_sids, extract_kept_encoding_codes}; +pub(crate) use charstring::rewrite_calls; use cid::subset_cid_keyed; use reader::{parse_cff1, OP_CHARSET, OP_ENCODING}; +pub(crate) use walk::{walk_budget, BlendRegions, CharstringWalk, FdWalk}; // ---------------------------------------------------------------------------- // Top DICT / Private DICT serializers. @@ -226,7 +237,8 @@ pub(crate) fn serialise_private_dict( /// Rebuilds a non-identity CFF1 table for the kept-gid subset. /// /// Wires together the Top DICT walker + capture-offsets, the -/// CharStrings INDEX rebuild with in-place subr renumber, the local + +/// CharStrings INDEX rebuild with subr renumbering at the call sites +/// the charstring walk finds, the local + /// global Subr INDEX rebuilds, and the section-layout placeholder-patch /// driver. Charset and Encoding are rebuilt via [`emit_charset_auto`] /// and [`emit_encoding_auto`] when the source uses an explicit @@ -271,9 +283,18 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result> = Vec::with_capacity(kept_charstrings.len()); + for cs in &kept_charstrings { + charstring_calls.push(walk.glyph(&mut fd_walk, cs)?); + } + let kept_local_idx = fd_walk.kept_locals(); + let kept_global_idx = walk.kept_globals(); // Build old -> new renumber tables. let mut local_renumber: Vec> = alloc::vec![None; parsed.local_subrs.len()]; @@ -287,56 +308,45 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result> = kept_charstrings.iter().map(|s| s.to_vec()).collect(); - for cs in &mut new_charstrings { - renumber_charstring( - cs, - old_local_count, - old_global_count, - new_local_count, - new_global_count, - &local_renumber, - &global_renumber, - )?; - } - // Rewrite each kept local subr. - let mut new_local_subrs: Vec> = kept_local_idx - .iter() - .map(|&i| parsed.local_subrs[i as usize].to_vec()) - .collect(); - for sub in &mut new_local_subrs { - renumber_charstring( - sub, - old_local_count, - old_global_count, + // Rewrite each kept charstring and subroutine (cloned, then + // mutated) at the call sites the walk found in it. + let renumber = |body: &[u8], calls: &[SubrCall]| { + rewrite_calls( + body, + calls, new_local_count, new_global_count, &local_renumber, &global_renumber, - )?; - } - - // Rewrite each kept global subr. - let mut new_global_subrs: Vec> = kept_global_idx + |_| None, + ) + }; + let new_charstrings: Vec> = kept_charstrings .iter() - .map(|&i| parsed.global_subrs[i as usize].to_vec()) - .collect(); - for sub in &mut new_global_subrs { - renumber_charstring( - sub, - old_local_count, - old_global_count, - new_local_count, - new_global_count, - &local_renumber, - &global_renumber, - )?; - } + .zip(&charstring_calls) + .map(|(cs, calls)| renumber(cs, calls)) + .collect::>()?; + let new_local_subrs: Vec> = kept_local_idx + .iter() + .map(|&i| { + let i = i as usize; + renumber( + parsed.local_subrs[i], + fd_walk.local_calls(i).unwrap_or_default(), + ) + }) + .collect::>()?; + let new_global_subrs: Vec> = kept_global_idx + .iter() + .map(|&i| { + let i = i as usize; + renumber( + parsed.global_subrs[i], + walk.global_calls(i).unwrap_or_default(), + ) + }) + .collect::>()?; // Charset rebuild: required whenever there's at least one kept // glyph past gid 0. diff --git a/crates/sigilbuzz-subset/src/cff/charstring.rs b/crates/sigilbuzz-subset/src/cff/charstring.rs index bcc7960d..7c066372 100644 --- a/crates/sigilbuzz-subset/src/cff/charstring.rs +++ b/crates/sigilbuzz-subset/src/cff/charstring.rs @@ -33,35 +33,34 @@ pub enum SubrKind { } // Type 2 opcode numbers we need to recognize. Values from Adobe TN -// 5177 §3 and §4. Kept private so `cff::scan_subr_calls` is the only -// surface for charstring walking from the subsetter side. +// 5177 §3 and §4. Shared with the subsetter's charstring walk. pub(super) const OP_HSTEM: u8 = 1; -const OP_VSTEM: u8 = 3; -const OP_VMOVETO: u8 = 4; -const OP_RLINETO: u8 = 5; -const OP_HLINETO: u8 = 6; -const OP_VLINETO: u8 = 7; -const OP_RRCURVETO: u8 = 8; +pub(super) const OP_VSTEM: u8 = 3; +pub(super) const OP_VMOVETO: u8 = 4; +pub(super) const OP_RLINETO: u8 = 5; +pub(super) const OP_HLINETO: u8 = 6; +pub(super) const OP_VLINETO: u8 = 7; +pub(super) const OP_RRCURVETO: u8 = 8; pub(super) const OP_CALLSUBR: u8 = 10; pub(super) const OP_RETURN: u8 = 11; pub(super) const OP_ESCAPE: u8 = 12; pub(super) const OP_ENDCHAR: u8 = 14; -const OP_VSINDEX: u8 = 15; -const OP_BLEND: u8 = 16; -const OP_HSTEMHM: u8 = 18; +pub(super) const OP_VSINDEX: u8 = 15; +pub(super) const OP_BLEND: u8 = 16; +pub(super) const OP_HSTEMHM: u8 = 18; pub(super) const OP_HINTMASK: u8 = 19; -const OP_CNTRMASK: u8 = 20; +pub(super) const OP_CNTRMASK: u8 = 20; pub(super) const OP_RMOVETO: u8 = 21; -const OP_HMOVETO: u8 = 22; -const OP_VSTEMHM: u8 = 23; -const OP_RCURVELINE: u8 = 24; -const OP_RLINECURVE: u8 = 25; -const OP_VVCURVETO: u8 = 26; -const OP_HHCURVETO: u8 = 27; +pub(super) const OP_HMOVETO: u8 = 22; +pub(super) const OP_VSTEMHM: u8 = 23; +pub(super) const OP_RCURVELINE: u8 = 24; +pub(super) const OP_RLINECURVE: u8 = 25; +pub(super) const OP_VVCURVETO: u8 = 26; +pub(super) const OP_HHCURVETO: u8 = 27; pub(super) const OP_SHORTINT: u8 = 28; pub(super) const OP_CALLGSUBR: u8 = 29; -const OP_VHCURVETO: u8 = 30; -const OP_HVCURVETO: u8 = 31; +pub(super) const OP_VHCURVETO: u8 = 30; +pub(super) const OP_HVCURVETO: u8 = 31; /// One subroutine call discovered by [`scan_subr_calls`]. /// @@ -99,10 +98,14 @@ pub struct SubrCall { /// of the byte stream. CFF1 charstrings stop at `endchar` or /// end-of-stream. Both behaviors produce the same call list. /// -/// The stem count that sizes `hintmask` / `cntrmask` data starts at 0 -/// for every body. A subroutine that uses a hint mask set up by its -/// caller's stem hints is therefore sized as if no stems were -/// declared. This is a known limitation of the per-body scan. +/// The scan reads one body on its own. The stem count that sizes +/// `hintmask` / `cntrmask` data starts at 0 for every body, so a +/// subroutine whose masks count stems its caller declared is misread, +/// and the operands a CFF2 `blend` drops depend on a variation store +/// the scan does not see, so the stack is cleared there. The subsetter +/// itself does not scan bodies alone: it runs each kept glyph through +/// its subroutine calls (see `cff::walk`), which sizes masks and blends +/// as HarfBuzz does. /// /// # Errors /// @@ -314,6 +317,10 @@ pub fn encode_int_operand(v: i32) -> Vec { /// 0-based indices into the source INDEX. The caller renumbers these /// to a 0..N compacted layout in input order. /// +/// Each body is read on its own, with the limits [`scan_subr_calls`] +/// describes. The subsetter runs the kept glyphs through their calls +/// instead, which sizes hint masks and CFF2 blends as HarfBuzz does. +/// /// # Errors /// /// Propagates [`scan_subr_calls`] errors from any walked charstring @@ -377,8 +384,10 @@ fn mark_call( } } -/// Identifies the set of kept global subroutines whose body (directly -/// or transitively through other globals) calls a local subroutine. +/// Identifies the global subroutines whose body (directly or +/// transitively through other globals) calls a local subroutine, from +/// the calls of each global body given: `(global index, calls)` pairs +/// for `n` globals. A global without a pair calls nothing. /// /// In CID-keyed CFF1 fonts each gid belongs to a Font DICT (FD) with /// its own local subroutine INDEX. A `callsubr` from inside a *global* @@ -388,27 +397,22 @@ fn mark_call( /// concrete local-index after subsetting because different FDs renumber /// their locals independently. /// -/// Returns a boolean keep-mask aligned with `global_subrs`: index `i` -/// is `true` when global `i` reaches a local call (and therefore must -/// be duplicated per kept FD by the rewriter). Globals that only call -/// other non-cross-FD globals are *not* marked. -/// -/// # Errors -/// -/// Propagates [`scan_subr_calls`] errors from any walked global body. -pub fn compute_cross_fd_globals( - global_subrs: &[&[u8]], - local_count: usize, -) -> Result, SubsetError> { - let n = global_subrs.len(); +/// Returns a boolean mask of `n` entries: index `i` is `true` when +/// global `i` reaches a local call (and therefore must be duplicated +/// per kept FD by the rewriter). Globals that only call other +/// non-cross-FD globals are *not* marked. +pub(super) fn cross_fd_globals<'c>( + n: usize, + bodies: impl IntoIterator, +) -> Vec { let mut is_cross: Vec = alloc::vec![false; n]; // `callers[g]` lists every global whose body calls global `g`. let mut callers: Vec> = alloc::vec![Vec::new(); n]; let mut pending: Vec = Vec::new(); // Pass 1: mark every global whose body directly calls a local, and // record the global-to-global call edges. - for (i, body) in global_subrs.iter().enumerate() { - for call in scan_subr_calls(body, local_count, n)? { + for (i, calls) in bodies { + for call in calls { match call.kind { SubrKind::Local => { if let Some(flag) = is_cross.get_mut(i) { @@ -447,7 +451,7 @@ pub fn compute_cross_fd_globals( } } } - Ok(is_cross) + is_cross } fn collect_kept(keep: &[bool]) -> Vec { @@ -657,46 +661,113 @@ pub(super) fn renumber_charstring_impl( cross_fd_override: impl Fn(usize) -> Option, ) -> Result<(), SubsetError> { let calls = scan_subr_calls(charstring, old_local_count, old_global_count)?; - let new_local_bias = subr_bias(new_local_count); - let new_global_bias = subr_bias(new_global_count); for call in calls.iter().rev() { // Reverse iteration so earlier rewrites don't shift later // offsets, but since we always re-encode at the original byte // width, the offsets stay stable. Reverse-iterate anyway as a // belt-and-suspenders against future variable-width changes. - if call.index_after_bias < 0 { - return Err(SubsetError::Unsupported( - "CFF charstring negative subr index after bias", - )); + let new_raw = new_raw_operand( + call, + new_local_count, + new_global_count, + local_renumber, + global_renumber, + &cross_fd_override, + )?; + renumber_subr_call(charstring, call, new_raw)?; + } + Ok(()) +} + +/// The raw operand `call` takes once its subroutine is renumbered: the +/// new index, through `local_renumber`, `cross_fd_override` or +/// `global_renumber`, less the bias of the new INDEX. +fn new_raw_operand( + call: &SubrCall, + new_local_count: usize, + new_global_count: usize, + local_renumber: &[Option], + global_renumber: &[Option], + cross_fd_override: &impl Fn(usize) -> Option, +) -> Result { + if call.index_after_bias < 0 { + return Err(SubsetError::Unsupported( + "CFF charstring negative subr index after bias", + )); + } + let old_idx = usize::try_from(call.index_after_bias) + .map_err(|_| SubsetError::Unsupported("CFF charstring calls dropped subroutine"))?; + let dropped = SubsetError::Unsupported("CFF charstring calls dropped subroutine"); + let (new_idx, new_bias) = match call.kind { + SubrKind::Local => ( + local_renumber + .get(old_idx) + .copied() + .flatten() + .ok_or(dropped)?, + subr_bias(new_local_count), + ), + SubrKind::Global => ( + match cross_fd_override(old_idx) { + Some(target) => target, + None => global_renumber + .get(old_idx) + .copied() + .flatten() + .ok_or(dropped)?, + }, + subr_bias(new_global_count), + ), + }; + let new_raw = i64::from(new_idx) - i64::from(new_bias); + i32::try_from(new_raw) + .map_err(|_| SubsetError::Unsupported("CFF renumber: new raw operand out of i32 range")) +} + +/// The body `body` with the subroutine number of each call in `calls`, +/// call sites found in it in byte order, renumbered against the +/// old-to-new maps (see [`renumber_charstring_with_cross_fd`]). +/// +/// A new number that fits the width of the push it replaces is padded +/// to that width, so the body keeps its size. One that cannot be, a +/// value in `-107..=107` replacing a 2-byte push or any value wider +/// than its push, is written in its own shortest form and the bytes +/// after it move: nothing in a charstring addresses its own bytes, and +/// HarfBuzz re-encodes every call this way. +pub(crate) fn rewrite_calls( + body: &[u8], + calls: &[SubrCall], + new_local_count: usize, + new_global_count: usize, + local_renumber: &[Option], + global_renumber: &[Option], + cross_fd_override: impl Fn(usize) -> Option, +) -> Result, SubsetError> { + const BAD_SITE: SubsetError = + SubsetError::Unsupported("CFF charstring call sites out of order"); + let mut out = Vec::with_capacity(body.len()); + let mut pos = 0; + for call in calls { + let start = call.operand_byte_offset; + let end = start.checked_add(call.operand_byte_len).ok_or(BAD_SITE)?; + out.extend_from_slice(body.get(pos..start).ok_or(BAD_SITE)?); + if end > body.len() { + return Err(BAD_SITE); } - let old_idx = usize::try_from(call.index_after_bias) - .map_err(|_| SubsetError::Unsupported("CFF charstring calls dropped subroutine"))?; - let new_idx = - match call.kind { - SubrKind::Local => local_renumber.get(old_idx).copied().flatten().ok_or( - SubsetError::Unsupported("CFF charstring calls dropped subroutine"), - )?, - SubrKind::Global => { - if let Some(target) = cross_fd_override(old_idx) { - target - } else { - global_renumber.get(old_idx).copied().flatten().ok_or( - SubsetError::Unsupported("CFF charstring calls dropped subroutine"), - )? - } - } - }; - let new_bias = match call.kind { - SubrKind::Local => new_local_bias, - SubrKind::Global => new_global_bias, - }; - let new_raw = (new_idx as i64) - i64::from(new_bias); - if !(i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(&new_raw) { - return Err(SubsetError::Unsupported( - "CFF renumber: new raw operand out of i32 range", - )); + let new_raw = new_raw_operand( + call, + new_local_count, + new_global_count, + local_renumber, + global_renumber, + &cross_fd_override, + )?; + match encode_int_operand_at_width(new_raw, call.operand_byte_len) { + Ok(padded) if padded.len() == call.operand_byte_len => out.extend_from_slice(&padded), + _ => out.extend_from_slice(&encode_int_operand(new_raw)), } - renumber_subr_call(charstring, call, new_raw as i32)?; + pos = end; } - Ok(()) + out.extend_from_slice(body.get(pos..).ok_or(BAD_SITE)?); + Ok(out) } diff --git a/crates/sigilbuzz-subset/src/cff/cid.rs b/crates/sigilbuzz-subset/src/cff/cid.rs index 65a65af0..60ee4b0d 100644 --- a/crates/sigilbuzz-subset/src/cff/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/cid.rs @@ -4,10 +4,7 @@ use alloc::vec::Vec; use super::charset::extract_kept_charset_sids; -use super::charstring::{ - compute_cross_fd_globals, compute_kept_subrs, renumber_charstring_impl, scan_subr_calls, - SubrKind, -}; +use super::charstring::{cross_fd_globals, rewrite_calls, SubrCall}; use super::emit::{ emit_charset_auto, emit_fd_select_auto, encode_dict_int, encode_dict_offset_placeholder, encode_index, parse_fd_select, patch_dict_offset, @@ -17,6 +14,7 @@ use super::reader::{ OP_CHARSTRINGS, OP_ENCODING, OP_FD_ARRAY, OP_FD_SELECT, OP_PRIVATE, }; use super::serialise_private_dict; +use super::walk::{walk_budget, CharstringWalk, FdWalk}; use crate::SubsetError; // ---------------------------------------------------------------------------- @@ -31,13 +29,13 @@ use crate::SubsetError; // // 1. Walk FDSelect, capture old FD index per kept gid. // 2. Build the kept-FD set (union of FDs referenced by the kept gid set). -// 3. For each kept FD, walk every kept charstring it owns + its -// Private DICT's local subr INDEX, computing the per-FD subroutine -// keep-set (transitive through callsubr/callgsubr). +// 3. For each kept FD, run every kept charstring it owns through its +// subroutine calls, computing the per-FD subroutine keep-set and +// the call sites of every body reached (see `walk`). // 4. Renumber FDs to a 0..N compact range; rewrite FDSelect with the // new FD indices. -// 5. Renumber per-FD local subrs (reuse renumber_charstring with the -// FD-specific renumber tables). +// 5. Renumber per-FD local subrs at the call sites the walk found, +// with the FD-specific renumber tables. // 6. Rebuild the FDArray INDEX with placeholder-patched Font DICTs // whose Private DICT (size, off) and Subrs offsets get patched once // the layout lands. @@ -305,36 +303,26 @@ pub(super) fn subset_cid_keyed( .map(|&old| fd_pos_of[usize::from(old)] as u8) .collect(); - // Step 3: per-kept-FD subroutine keep-set. - // For each kept FD f (old index): collect every kept charstring whose - // gid maps to f; then compute_kept_subrs over those charstrings using - // f's local_subrs and the global_subrs. - // - // Globals are shared across all FDs. We union the per-FD global - // keep-set to a single global keep-set. - let mut kept_global_set: alloc::vec::Vec = alloc::vec![false; parsed.global_subrs.len()]; - let mut per_fd_kept_local: Vec> = Vec::with_capacity(kept_fds_sorted.len()); + // Step 3: run every kept glyph through its subroutine calls, FD by + // FD (see `walk`). The subroutines reached are the ones kept: each + // FD's locals, and the globals, shared across FDs. Each body's call + // sites are found with its masks sized by the stems its callers + // declared. + let mut walk = CharstringWalk::new(&parsed.global_subrs, None, walk_budget(cff_bytes.len())); + let mut fd_walks: Vec> = Vec::with_capacity(kept_fds_sorted.len()); + let mut charstring_calls: Vec> = alloc::vec![Vec::new(); kept_gids.len()]; for &old_fd in &kept_fds_sorted { - let fd_local_subrs = &fd_infos[old_fd as usize].local_subrs; - // Charstrings whose source-FD is old_fd. - let mut cs_for_this_fd: Vec<&[u8]> = Vec::new(); + let mut fd_walk = walk.fd(&fd_infos[old_fd as usize].local_subrs, 0); for (i, &gid) in kept_gids.iter().enumerate() { if kept_fd_old[i] == old_fd { - cs_for_this_fd.push(parsed.char_strings[gid as usize]); + charstring_calls[i] = + walk.glyph(&mut fd_walk, parsed.char_strings[gid as usize])?; } } - let (kept_local, kept_global) = - compute_kept_subrs(&cs_for_this_fd, fd_local_subrs, &parsed.global_subrs)?; - for &gi in &kept_global { - kept_global_set[gi as usize] = true; - } - per_fd_kept_local.push(kept_local); + fd_walks.push(fd_walk); } - let kept_global_idx: Vec = kept_global_set - .iter() - .enumerate() - .filter_map(|(i, &k)| if k { Some(i as u32) } else { None }) - .collect(); + let per_fd_kept_local: Vec> = fd_walks.iter().map(FdWalk::kept_locals).collect(); + let kept_global_idx = walk.kept_globals(); let old_global_count = parsed.global_subrs.len(); @@ -344,7 +332,15 @@ pub(super) fn subset_cid_keyed( // rebuilt CFF, where every FD shares the same global INDEX, we // must duplicate the global per kept FD that uses it and rewrite // each caller's `callgsubr` operand to point at *that* FD's copy. - let cross_fd_mask = compute_cross_fd_globals(&parsed.global_subrs, 0)?; + let cross_fd_mask = cross_fd_globals( + old_global_count, + kept_global_idx.iter().map(|&g| { + ( + g as usize, + walk.global_calls(g as usize).unwrap_or_default(), + ) + }), + ); // Per-kept-FD local renumber tables. let mut per_fd_local_renumber: Vec>> = @@ -358,17 +354,10 @@ pub(super) fn subset_cid_keyed( per_fd_local_renumber.push(renumber); } - // Determine which (cross-FD global, kept-FD) duplicates are needed. - // A duplicate is needed iff a kept charstring or kept local subr in - // FD f calls (transitively) the cross-FD global. Conservatively - // request a duplicate for every (cross-FD-global, FD) pair where - // the global is *reached* from any caller in that FD. - // - // Reachability: walk every kept charstring and every kept local - // subr in FD f; collect the set of globals they reach via the - // global-call graph. Intersect with cross_fd_mask to get the - // duplicates needed for FD f. Each FD's target list is sorted by - // source global index. + // Determine which (cross-FD global, kept-FD) duplicates are needed: + // one for every cross-FD global a glyph of the FD reached, through + // its charstring, its locals or other globals. Each FD's target + // list is sorted by source global index. // // The rebuilt Global Subr INDEX holds the kept non-cross-FD globals // plus one duplicate per (cross-FD global, FD) pair. Its count is a @@ -381,68 +370,17 @@ pub(super) fn subset_cid_keyed( .count(); let mut layout_len = n_non_cross; let mut per_fd_cross_fd_targets: Vec> = Vec::with_capacity(kept_fds_sorted.len()); - for (fd_pos, &old_fd) in kept_fds_sorted.iter().enumerate() { - // Bodies that originate calls in this FD: kept charstrings - // belonging to old_fd plus all kept locals in old_fd. - let fd_local_subrs = &fd_infos[old_fd as usize].local_subrs; - let mut seed_bodies: Vec<&[u8]> = Vec::new(); - for (i, &gid) in kept_gids.iter().enumerate() { - if kept_fd_old[i] == old_fd { - seed_bodies.push(parsed.char_strings[gid as usize]); - } - } - for &kept_local_old in &per_fd_kept_local[fd_pos] { - seed_bodies.push(fd_local_subrs[kept_local_old as usize]); - } - // Reachable globals: transitive closure through global call - // graph. We use the source-font global INDEX because the new - // INDEX hasn't been built yet. - let mut reached = alloc::vec![false; old_global_count]; - let mut frontier: Vec = Vec::new(); - for body in &seed_bodies { - for call in scan_subr_calls(body, fd_local_subrs.len(), old_global_count)? { - if call.kind == SubrKind::Global { - let idx = call.index_after_bias; - if idx >= 0 && (idx as usize) < old_global_count { - let i = idx as usize; - if !reached[i] { - reached[i] = true; - frontier.push(i as u32); - } - } - } - } - } - while let Some(g) = frontier.pop() { - let body = parsed.global_subrs[g as usize]; - for call in scan_subr_calls(body, fd_local_subrs.len(), old_global_count)? { - if call.kind == SubrKind::Global { - let idx = call.index_after_bias; - if idx >= 0 && (idx as usize) < old_global_count { - let i = idx as usize; - if !reached[i] { - reached[i] = true; - frontier.push(i as u32); - } - } - } - } - } + for fd_walk in &fd_walks { let mut targets: Vec = Vec::new(); - for (g, ((&hit, &cross), &kept)) in reached - .iter() - .zip(&cross_fd_mask) - .zip(&kept_global_set) - .enumerate() - { - if hit && cross && kept { + for g in fd_walk.reached_globals() { + if cross_fd_mask.get(g as usize).copied().unwrap_or(false) { layout_len += 1; if layout_len > usize::from(u16::MAX) { return Err(SubsetError::Unsupported( "CFF1 CID rebuilt Global Subr INDEX exceeds 65535 entries", )); } - targets.push(g as u32); + targets.push(g); } } per_fd_cross_fd_targets.push(targets); @@ -486,20 +424,17 @@ pub(super) fn subset_cid_keyed( // Step 5: rewrite each kept charstring with its FD's local-renumber // table + the shared global-renumber table + the FD's cross-FD - // override table. + // override table, at the call sites the walk found. let mut new_charstrings: Vec> = Vec::with_capacity(kept_gids.len()); for (i, &gid) in kept_gids.iter().enumerate() { let old_fd = kept_fd_old[i]; let new_fd_pos = fd_pos_of[usize::from(old_fd)]; - let fd_local_subrs_old = &fd_infos[old_fd as usize].local_subrs; let fd_local_renumber = &per_fd_local_renumber[new_fd_pos]; let new_local_count = per_fd_kept_local[new_fd_pos].len(); let overrides = &per_fd_cross_fd_override[new_fd_pos]; - let mut cs = parsed.char_strings[gid as usize].to_vec(); - renumber_charstring_impl( - &mut cs, - fd_local_subrs_old.len(), - old_global_count, + let cs = rewrite_calls( + parsed.char_strings[gid as usize], + &charstring_calls[i], new_local_count, new_global_count, fd_local_renumber, @@ -517,22 +452,19 @@ pub(super) fn subset_cid_keyed( let kept_local_idx = &per_fd_kept_local[i]; let fd_local_renumber = &per_fd_local_renumber[i]; let new_local_count = kept_local_idx.len(); - let mut new_locals: Vec> = kept_local_idx - .iter() - .map(|&idx| fd_local_subrs_old[idx as usize].to_vec()) - .collect(); let overrides = &per_fd_cross_fd_override[i]; - for sub in &mut new_locals { - renumber_charstring_impl( - sub, - fd_local_subrs_old.len(), - old_global_count, + let mut new_locals: Vec> = Vec::with_capacity(kept_local_idx.len()); + for &idx in kept_local_idx { + let sub = rewrite_calls( + fd_local_subrs_old[idx as usize], + fd_walks[i].local_calls(idx as usize).unwrap_or_default(), new_local_count, new_global_count, fd_local_renumber, &global_renumber, |old_g| override_slot(overrides, old_g), )?; + new_locals.push(sub); } new_per_fd_local_subrs.push(new_locals); } @@ -545,48 +477,45 @@ pub(super) fn subset_cid_keyed( // `callgsubr` operands using `global_renumber` (cross-FD overrides // are inapplicable because by definition this body never calls a // local). A non-cross-FD global that calls a cross-FD global would - // itself become cross-FD by `compute_cross_fd_globals`'s transitive - // pass, so this branch only sees globals whose entire reach stays - // inside the non-cross-FD tier. + // itself become cross-FD by `cross_fd_globals`'s transitive pass, + // so this branch only sees globals whose entire reach stays inside + // the non-cross-FD tier. // - // Cross-FD duplicates re-encode against the *original* FD's local - // INDEX (sized to that FD's source local_subrs.len()), then route - // local-subr operands through that FD's local-renumber table and - // global-subr operands through that FD's cross-FD override (so - // recursive cross-FD calls land on the correct duplicate). + // Cross-FD duplicates route local-subr operands through that FD's + // local-renumber table and global-subr operands through that FD's + // cross-FD override (so recursive cross-FD calls land on the + // correct duplicate). let mut new_global_subrs: Vec> = Vec::with_capacity(new_global_layout.len()); for &(old_g, dup_for_fd) in &new_global_layout { - let mut body = parsed.global_subrs[old_g as usize].to_vec(); - if let Some(old_fd) = dup_for_fd { + let source = parsed.global_subrs[old_g as usize]; + let calls = walk.global_calls(old_g as usize).unwrap_or_default(); + let body = if let Some(old_fd) = dup_for_fd { let fd_pos = fd_pos_of[usize::from(old_fd)]; - let fd_local_subrs_old = &fd_infos[old_fd as usize].local_subrs; let new_local_count = per_fd_kept_local[fd_pos].len(); let overrides = &per_fd_cross_fd_override[fd_pos]; - renumber_charstring_impl( - &mut body, - fd_local_subrs_old.len(), - old_global_count, + rewrite_calls( + source, + calls, new_local_count, new_global_count, &per_fd_local_renumber[fd_pos], &global_renumber, |old_g| override_slot(overrides, old_g), - )?; + )? } else { // Non-cross-FD: zero-sized local pool because the body // never issues a `callsubr`. If it did, the empty local // renumber table would surface a hard error. - renumber_charstring_impl( - &mut body, - 0, - old_global_count, + rewrite_calls( + source, + calls, 0, new_global_count, &[], &global_renumber, |_| None, - )?; - } + )? + }; new_global_subrs.push(body); } diff --git a/crates/sigilbuzz-subset/src/cff/tests.rs b/crates/sigilbuzz-subset/src/cff/tests.rs index 207bd558..32ac3dc6 100644 --- a/crates/sigilbuzz-subset/src/cff/tests.rs +++ b/crates/sigilbuzz-subset/src/cff/tests.rs @@ -2,12 +2,29 @@ //! charset readers. use super::charstring::{ - compute_cross_fd_globals, decode_operand, OP_CALLGSUBR, OP_CALLSUBR, OP_ENDCHAR, OP_ESCAPE, + cross_fd_globals, decode_operand, OP_CALLGSUBR, OP_CALLSUBR, OP_ENDCHAR, OP_ESCAPE, OP_HINTMASK, OP_HSTEM, OP_RETURN, OP_RMOVETO, OP_SHORTINT, }; use super::reader::read_index; use super::*; +/// The cross-FD mask of `global_subrs`, each body scanned on its own +/// against `local_count` locals. +fn compute_cross_fd_globals( + global_subrs: &[&[u8]], + local_count: usize, +) -> Result, SubsetError> { + let n = global_subrs.len(); + let calls = global_subrs + .iter() + .map(|body| scan_subr_calls(body, local_count, n)) + .collect::, _>>()?; + Ok(cross_fd_globals( + n, + calls.iter().enumerate().map(|(i, c)| (i, c.as_slice())), + )) +} + mod cid; mod emit; mod orchestration; diff --git a/crates/sigilbuzz-subset/src/cff/tests/cid.rs b/crates/sigilbuzz-subset/src/cff/tests/cid.rs index f7f689c3..aac7fea3 100644 --- a/crates/sigilbuzz-subset/src/cff/tests/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/tests/cid.rs @@ -587,3 +587,34 @@ fn cid_global_calls_local_unused_fd_drops_duplicate() { // Exactly one duplicate should remain (the one for FD 0). assert_eq!(parsed.global_subrs.len(), 1); } + +#[test] +fn cid_cross_fd_global_mask_is_sized_by_the_callers_stems() { + // Each glyph declares nine stems and calls global 0, which holds a + // two-byte hint mask and then calls local 0: a cross-FD global. + // Read on its own, its mask is empty and its first byte, 9, a + // reserved opcode. + let mut g0 = alloc::vec![OP_HINTMASK, 9, 9]; + g0.extend_from_slice(&encode_int_operand(-107)); + g0.extend_from_slice(&[OP_CALLSUBR, OP_RETURN]); + let local: Vec = alloc::vec![139, 139, OP_RMOVETO, OP_RETURN]; + let mut cs0: Vec = (0..18).map(|v| (139 + v) as u8).collect(); + cs0.push(18); // hstemhm + cs0.extend_from_slice(&encode_int_operand(-107)); + cs0.extend_from_slice(&[OP_CALLGSUBR, OP_ENDCHAR]); + let cs1 = cs0.clone(); + let charstrings: Vec<&[u8]> = alloc::vec![cs0.as_slice(), cs1.as_slice()]; + let globals: Vec<&[u8]> = alloc::vec![g0.as_slice()]; + let per_fd_locals: Vec> = + alloc::vec![alloc::vec![local.as_slice()], alloc::vec![local.as_slice()]]; + let cff = + build_synthetic_cid_cff1_with_subrs(&charstrings, &[0u8, 1], &globals, &per_fd_locals); + + let new_cff = subset_non_identity(&cff, &[0u16, 1]).unwrap(); + let parsed = parse_cff1(&new_cff).unwrap(); + // One duplicate of the global per FD, each with its mask intact. + assert_eq!(parsed.global_subrs.len(), 2); + for body in &parsed.global_subrs { + assert_eq!(body, &g0.as_slice()); + } +} diff --git a/crates/sigilbuzz-subset/src/cff/tests/orchestration.rs b/crates/sigilbuzz-subset/src/cff/tests/orchestration.rs index d2d6fd01..9eac95de 100644 --- a/crates/sigilbuzz-subset/src/cff/tests/orchestration.rs +++ b/crates/sigilbuzz-subset/src/cff/tests/orchestration.rs @@ -10,6 +10,11 @@ use super::*; /// the charstrings don't reference them, so the rewriter has to /// drop them. fn build_synthetic_cff1(charstrings: &[&[u8]]) -> Vec { + build_synthetic_cff1_with_locals(charstrings, &[&[11u8 /* OP_RETURN */]]) +} + +/// [`build_synthetic_cff1`] with the local subrs `local_subrs`. +fn build_synthetic_cff1_with_locals(charstrings: &[&[u8]], local_subrs: &[&[u8]]) -> Vec { // Glyph 0 is .notdef. Encode as a minimal endchar charstring. let notdef: Vec = alloc::vec![14u8 /* OP_ENDCHAR */]; let mut all_cs: Vec> = alloc::vec![notdef]; @@ -33,12 +38,10 @@ fn build_synthetic_cff1(charstrings: &[&[u8]]) -> Vec { let cs_index = encode_index(&cs_refs); - // Private DICT carries op 19 (Subrs) referencing one local subr - // (an empty `RETURN` body) so the orchestration has subrs to - // drop. Body uses a 5-byte placeholder offset that we patch - // later. - let local_subrs: Vec<&[u8]> = alloc::vec![&[11u8 /* OP_RETURN */] as &[u8]]; - let local_subr_index = encode_index(&local_subrs); + // Private DICT carries op 19 (Subrs) referencing the local subrs + // so the orchestration has subrs to drop. Body uses a 5-byte + // placeholder offset that we patch later. + let local_subr_index = encode_index(local_subrs); // Build Top DICT with placeholder offsets for charset (15), // Encoding (16), CharStrings (17), Private (18=size+off pair). @@ -284,3 +287,32 @@ fn orchestration_renumbers_callsubr_when_local_subrs_kept() { assert_eq!(kept_cs[1], 10); // OP_CALLSUBR preserved. assert_eq!(kept_cs[2], 14); // OP_ENDCHAR preserved. } + +#[test] +fn orchestration_sizes_a_mask_in_a_subroutine_by_the_callers_stems() { + // Glyph 1 declares nine stems and calls local 0, whose hint mask + // therefore takes two bytes; local 0 then calls local 2. Local 1 + // is never called. Read on its own, local 0's mask is empty and + // its first byte, 9, a reserved opcode. + let mut cs1: Vec = (0..18).map(|v| (139 + v) as u8).collect(); + cs1.push(18); // hstemhm + cs1.extend_from_slice(&encode_int_operand(-107)); // local 0 + cs1.extend_from_slice(&[OP_CALLSUBR, OP_ENDCHAR]); + let mut local_0 = alloc::vec![OP_HINTMASK, 9, 9]; + local_0.extend_from_slice(&encode_int_operand(2 - 107)); // local 2 + local_0.extend_from_slice(&[OP_CALLSUBR, OP_RETURN]); + let local_1: &[u8] = &[OP_RETURN]; + let local_2: &[u8] = &[139, 139, OP_RMOVETO, OP_RETURN]; + let cff = build_synthetic_cff1_with_locals(&[&cs1], &[&local_0, local_1, local_2]); + + let new_cff = subset_non_identity(&cff, &[0, 1]).unwrap(); + let parsed = parse_cff1(&new_cff).unwrap(); + assert_eq!(parsed.char_strings[1], cs1.as_slice()); + // Locals 0 and 2 survive as 0 and 1; local 0's call is renumbered + // in place. + assert_eq!(parsed.local_subrs.len(), 2); + let mut renumbered = local_0.clone(); + renumbered[3..4].copy_from_slice(&encode_int_operand(1 - 107)); + assert_eq!(parsed.local_subrs[0], renumbered.as_slice()); + assert_eq!(parsed.local_subrs[1], local_2); +} diff --git a/crates/sigilbuzz-subset/src/cff/walk.rs b/crates/sigilbuzz-subset/src/cff/walk.rs new file mode 100644 index 00000000..3cd14f7e --- /dev/null +++ b/crates/sigilbuzz-subset/src/cff/walk.rs @@ -0,0 +1,532 @@ +//! Charstring execution for the subsetter: which subroutines the kept +//! glyphs reach, and where each body reached calls them. +//! +//! A `hintmask` or `cntrmask` is followed by one bit per stem hint the +//! glyph declared before its first mask. Those hints, like the operands +//! a stem operator or a CFF2 `blend` takes, can come from the caller of +//! the subroutine that holds the mask, so a body read on its own does +//! not say how many mask bytes to step over. Read wrong, the mask bytes +//! pass for operators and operands, and the subroutine calls found +//! after them are wrong. +//! +//! The walk runs each kept glyph's charstring as HarfBuzz's subsetter +//! interprets it: +//! +//! - A subroutine call runs the callee with the caller's operand stack +//! and hint count, and the callee's stems count for the caller's +//! masks after it returns. +//! - The stem count sizes the glyph's first mask, operands left on the +//! stack counting as an implicit `vstem`, and every later mask takes +//! the same size. +//! - A CFF2 `blend` takes `n`, then `n` default values and `n` rows of +//! one delta per region of the `vsindex` in effect, and leaves the +//! defaults. The region count comes from the font's variation store; +//! a `vsindex` past its subtables blends no deltas, as in HarfBuzz. +//! The `vsindex` in effect starts at the one the glyph's Private DICT +//! sets. +//! - Subroutines nest at most 10 deep, and the operand stack holds at +//! most 513 operands. +//! +//! Each body is read once, the first time a glyph reaches it, as +//! HarfBuzz parses it, and the walk records the subroutine calls of +//! that reading for the renumbering. A subroutine number must be pushed +//! by the body that calls it, so the push can be rewritten in place. +//! +//! The walk charges one unit of `budget` for every operand and operator +//! it reads, inside subroutines too, and fails once the budget is +//! spent. Subroutines that call each other many times can describe far +//! more work than the table's size. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use sigilbuzz::tables::variation_store::ItemVariationStore; + +use super::charstring::{ + decode_operand, subr_bias, SubrCall, SubrKind, OP_BLEND, OP_CALLGSUBR, OP_CALLSUBR, + OP_CNTRMASK, OP_ENDCHAR, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, OP_HMOVETO, + OP_HSTEM, OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, OP_RLINETO, + OP_RMOVETO, OP_RRCURVETO, OP_SHORTINT, OP_VHCURVETO, OP_VLINETO, OP_VMOVETO, OP_VSINDEX, + OP_VSTEM, OP_VSTEMHM, OP_VVCURVETO, +}; +use crate::SubsetError; + +/// Subroutine calls one glyph may nest, HarfBuzz's `kMaxCallLimit` +/// and the Type 2 limit. +const MAX_CALL_DEPTH: usize = 10; + +/// Operands the stack may hold, HarfBuzz's argument stack size and the +/// CFF2 limit. +const MAX_STACK: usize = 513; + +/// Minimum number of charstring tokens one subset may read. +const MIN_WALK_TOKENS: usize = 1 << 22; + +/// Token allowance per byte of the source table. +const WALK_TOKENS_PER_BYTE: usize = 64; + +/// Token budget for walking the kept glyphs of a `CFF ` or `CFF2` table +/// of `table_len` bytes: 64 tokens per byte, at least 2^22. Real fonts +/// read their subroutines a few times over at most. +pub(crate) fn walk_budget(table_len: usize) -> usize { + table_len + .saturating_mul(WALK_TOKENS_PER_BYTE) + .max(MIN_WALK_TOKENS) +} + +/// Region counts of a CFF2 variation store, per `vsindex`. +pub(crate) struct BlendRegions<'a> { + store: Option>, + counts: BTreeMap, +} + +impl<'a> BlendRegions<'a> { + /// The region counts of the store in `store_bytes` (without the CFF2 + /// length prefix). A store that is absent or cannot be read has no + /// subtables, so every `blend` blends no deltas, as HarfBuzz reads + /// a missing store. + pub(crate) fn new(store_bytes: Option<&'a [u8]>) -> Self { + Self { + store: store_bytes.and_then(|b| ItemVariationStore::parse(b).ok()), + counts: BTreeMap::new(), + } + } + + /// The deltas one blended value carries under `vsindex`: the region + /// count of that subtable, or 0 when there is none. + fn count(&mut self, vsindex: u16) -> usize { + let store = self.store.as_ref(); + *self.counts.entry(vsindex).or_insert_with(|| { + store + .and_then(|s| s.variation_region_count(vsindex)) + .map_or(0, usize::from) + }) + } +} + +/// How far the walk has read one subroutine. +#[derive(Debug, Clone)] +enum Visit { + /// Not reached yet. + Unvisited, + /// Being read for the first time: a call to it now is recursion. + Reading, + /// Read, with the calls its reading found. + Read(Vec), +} + +impl Visit { + fn calls(&self) -> Option<&[SubrCall]> { + match self { + Self::Read(calls) => Some(calls), + _ => None, + } + } +} + +/// The bodies of one Font DICT: its local subroutines and the +/// `vsindex` its Private DICT sets, and what the walk found in them. +pub(crate) struct FdWalk<'a> { + locals: &'a [&'a [u8]], + vsindex: u16, + local_visits: Vec, + /// Global subroutines this Font DICT's glyphs reached, in the + /// order the walk first reached them. + reached_globals: Vec, + /// Marks the globals in `reached_globals` in the walk's stamps. + stamp: u32, +} + +impl FdWalk<'_> { + /// The calls of local subroutine `index`, when a glyph reached it. + pub(crate) fn local_calls(&self, index: usize) -> Option<&[SubrCall]> { + self.local_visits.get(index).and_then(Visit::calls) + } + + /// The local subroutines the glyphs reached, ascending. + pub(crate) fn kept_locals(&self) -> Vec { + kept(&self.local_visits) + } + + /// The global subroutines this Font DICT's glyphs reached, ascending. + pub(crate) fn reached_globals(&self) -> Vec { + let mut out = self.reached_globals.clone(); + out.sort_unstable(); + out + } +} + +/// The walk over every kept glyph of one table. See the module docs. +pub(crate) struct CharstringWalk<'a> { + globals: &'a [&'a [u8]], + global_visits: Vec, + /// Per global: the stamp of the last Font DICT that reached it. + global_stamps: Vec, + next_stamp: u32, + /// `Some` for CFF2, whose `blend` and `vsindex` operators the walk + /// runs. A CFF1 charstring has neither, and the walk clears the + /// stack at those opcodes as the per-body scanner does. + blend: Option>, + budget: usize, + /// Identifies each body run, so a call can tell whether its + /// subroutine number was pushed by the calling body. + next_frame: u64, +} + +/// One operand on the stack. +#[derive(Debug, Clone, Copy)] +struct Operand { + /// The value, its integer part for a 16.16 push. + value: i32, + /// Where it was pushed: the body run, the byte offset and the + /// encoded length. `None` for a `blend` result. + push: Option, +} + +#[derive(Debug, Clone, Copy)] +struct Push { + frame: u64, + offset: usize, + len: usize, +} + +/// The state one glyph's run carries through its subroutine calls. +struct Glyph { + stack: Vec, + /// Stem hints declared so far. + stems: usize, + /// Bytes each mask takes, fixed at the glyph's first mask. + mask_bytes: Option, + vsindex: u16, + /// `endchar` ran: the glyph is done, in whatever body it ran. + ended: bool, +} + +const WORK_EXCEEDED: SubsetError = SubsetError::Unsupported("CFF charstring work budget exceeded"); + +impl<'a> CharstringWalk<'a> { + /// A walk over a table with global subroutines `globals`, charging + /// `budget` (see [`walk_budget`]). `blend` is `Some` for a CFF2 + /// table. + pub(crate) fn new( + globals: &'a [&'a [u8]], + blend: Option>, + budget: usize, + ) -> Self { + Self { + globals, + global_visits: alloc::vec![Visit::Unvisited; globals.len()], + global_stamps: alloc::vec![0; globals.len()], + next_stamp: 0, + blend, + budget, + next_frame: 0, + } + } + + /// The bodies of a Font DICT with local subroutines `locals` and + /// Private DICT `vsindex`, ready for its glyphs. + pub(crate) fn fd(&mut self, locals: &'a [&'a [u8]], vsindex: u16) -> FdWalk<'a> { + self.next_stamp = self.next_stamp.wrapping_add(1); + if self.next_stamp == 0 { + // A wrapped stamp could match a stale one; start over. + self.global_stamps.iter_mut().for_each(|s| *s = 0); + self.next_stamp = 1; + } + FdWalk { + locals, + vsindex, + local_visits: alloc::vec![Visit::Unvisited; locals.len()], + reached_globals: Vec::new(), + stamp: self.next_stamp, + } + } + + /// The calls of global subroutine `index`, when a glyph reached it. + pub(crate) fn global_calls(&self, index: usize) -> Option<&[SubrCall]> { + self.global_visits.get(index).and_then(Visit::calls) + } + + /// The global subroutines any glyph reached, ascending. + pub(crate) fn kept_globals(&self) -> Vec { + kept(&self.global_visits) + } + + /// Runs the charstring of one glyph of `fd` and returns the calls + /// it makes itself, in order. + /// + /// # Errors + /// + /// [`SubsetError::Unsupported`] for a charstring HarfBuzz's + /// subsetter would not read either: a truncated operand or mask, an + /// unknown operator, a call without its number, to a subroutine + /// that does not exist, nested more than 10 deep or reaching itself, + /// a stack past 513 operands, a `blend` short of operands, or a + /// spent budget. + pub(crate) fn glyph( + &mut self, + fd: &mut FdWalk<'a>, + charstring: &[u8], + ) -> Result, SubsetError> { + let mut glyph = Glyph { + stack: Vec::new(), + stems: 0, + mask_bytes: None, + vsindex: fd.vsindex, + ended: false, + }; + let mut calls = Vec::new(); + self.run(fd, &mut glyph, charstring, 0, Some(&mut calls))?; + Ok(calls) + } + + /// Runs one body at call depth `depth`, recording its calls into + /// `record` when this is its first reading. + fn run( + &mut self, + fd: &mut FdWalk<'a>, + glyph: &mut Glyph, + body: &[u8], + depth: usize, + mut record: Option<&mut Vec>, + ) -> Result<(), SubsetError> { + let frame = self.next_frame; + self.next_frame = self.next_frame.wrapping_add(1); + let mut pos = 0; + while let Some(&b0) = body.get(pos) { + self.budget = self.budget.checked_sub(1).ok_or(WORK_EXCEEDED)?; + if b0 >= 32 || b0 == OP_SHORTINT { + let (value, len) = if b0 == OP_SHORTINT { + let bytes = body + .get(pos + 1..pos + 3) + .ok_or(SubsetError::Unsupported("CFF shortint truncated"))?; + (i32::from(i16::from_be_bytes([bytes[0], bytes[1]])), 3) + } else { + decode_operand(body, pos) + .ok_or(SubsetError::Unsupported("CFF charstring operand truncated"))? + }; + if glyph.stack.len() >= MAX_STACK { + return Err(SubsetError::Unsupported( + "CFF charstring stack past 513 operands", + )); + } + glyph.stack.push(Operand { + value, + push: Some(Push { + frame, + offset: pos, + len, + }), + }); + pos += len; + continue; + } + match b0 { + OP_CALLSUBR | OP_CALLGSUBR => { + let (kind, missing) = if b0 == OP_CALLSUBR { + (SubrKind::Local, "CFF callsubr without operand") + } else { + (SubrKind::Global, "CFF callgsubr without operand") + }; + let top = glyph.stack.pop().ok_or(SubsetError::Unsupported(missing))?; + let push = + top.push + .filter(|p| p.frame == frame) + .ok_or(SubsetError::Unsupported( + "CFF subroutine number not pushed by the calling body", + ))?; + let count = match kind { + SubrKind::Local => fd.locals.len(), + SubrKind::Global => self.globals.len(), + }; + let index_after_bias = i64::from(top.value) + i64::from(subr_bias(count)); + let index = usize::try_from(index_after_bias) + .ok() + .filter(|&i| i < count) + .ok_or(SubsetError::Unsupported( + "CFF subroutine index out of range", + ))?; + if let Some(calls) = record.as_deref_mut() { + calls.push(SubrCall { + kind, + index_after_bias, + raw_operand: top.value, + operand_byte_offset: push.offset, + operand_byte_len: push.len, + }); + } + if depth >= MAX_CALL_DEPTH { + return Err(SubsetError::Unsupported( + "CFF subroutines nested more than 10 deep", + )); + } + self.call(fd, glyph, kind, index, depth + 1)?; + if glyph.ended { + return Ok(()); + } + pos += 1; + } + // A subroutine returns; a charstring that returns ends, + // as the per-body scanner reads it. + OP_RETURN => return Ok(()), + OP_ENDCHAR => { + glyph.ended = true; + return Ok(()); + } + OP_HSTEM | OP_VSTEM | OP_HSTEMHM | OP_VSTEMHM => { + glyph.stems = glyph.stems.saturating_add(glyph.stack.len() / 2); + glyph.stack.clear(); + pos += 1; + } + OP_HINTMASK | OP_CNTRMASK => { + let mask_bytes = match glyph.mask_bytes { + Some(bytes) => bytes, + None => { + // Operands left before the first mask are + // an implicit vstem. + glyph.stems = glyph.stems.saturating_add(glyph.stack.len() / 2); + let bytes = glyph.stems.div_ceil(8); + glyph.mask_bytes = Some(bytes); + bytes + } + }; + glyph.stack.clear(); + // `pos < len` here, so `pos + 1` cannot overflow. + pos = (pos + 1) + .checked_add(mask_bytes) + .filter(|&next| next <= body.len()) + .ok_or(SubsetError::Unsupported("CFF hintmask tail truncated"))?; + } + OP_ESCAPE => { + if pos + 2 > body.len() { + return Err(SubsetError::Unsupported("CFF escape truncated")); + } + glyph.stack.clear(); + pos += 2; + } + OP_RMOVETO | OP_HMOVETO | OP_VMOVETO | OP_RLINETO | OP_HLINETO | OP_VLINETO + | OP_RRCURVETO | OP_HHCURVETO | OP_VVCURVETO | OP_HVCURVETO | OP_VHCURVETO + | OP_RCURVELINE | OP_RLINECURVE => { + glyph.stack.clear(); + pos += 1; + } + OP_VSINDEX | OP_BLEND if self.blend.is_none() => { + glyph.stack.clear(); + pos += 1; + } + OP_VSINDEX => { + let top = glyph + .stack + .pop() + .ok_or(SubsetError::Unsupported("CFF2 vsindex without operand"))?; + // Out-of-range values saturate, as the core reads + // them; past the store's subtables they blend no + // deltas. + glyph.vsindex = u16::try_from(top.value.max(0)).unwrap_or(u16::MAX); + glyph.stack.clear(); + pos += 1; + } + OP_BLEND => { + self.blend(glyph)?; + pos += 1; + } + _ => { + return Err(SubsetError::Unsupported("CFF unknown charstring operator")); + } + } + } + Ok(()) + } + + /// Runs subroutine `index` of `kind` for `glyph`, reading it for the + /// first time when no glyph has reached it yet. + fn call( + &mut self, + fd: &mut FdWalk<'a>, + glyph: &mut Glyph, + kind: SubrKind, + index: usize, + depth: usize, + ) -> Result<(), SubsetError> { + let (body, visit) = match kind { + SubrKind::Local => ( + fd.locals.get(index).copied(), + fd.local_visits.get_mut(index), + ), + SubrKind::Global => { + if let Some(stamp) = self.global_stamps.get_mut(index) { + if *stamp != fd.stamp { + *stamp = fd.stamp; + fd.reached_globals.push(index as u32); + } + } + ( + self.globals.get(index).copied(), + self.global_visits.get_mut(index), + ) + } + }; + let (Some(body), Some(visit)) = (body, visit) else { + return Err(SubsetError::Unsupported( + "CFF subroutine index out of range", + )); + }; + let first = match visit { + Visit::Read(_) => false, + Visit::Reading => { + return Err(SubsetError::Unsupported("CFF subroutine reaches itself")); + } + Visit::Unvisited => { + *visit = Visit::Reading; + true + } + }; + if !first { + return self.run(fd, glyph, body, depth, None); + } + let mut calls = Vec::new(); + self.run(fd, glyph, body, depth, Some(&mut calls))?; + let visit = match kind { + SubrKind::Local => fd.local_visits.get_mut(index), + SubrKind::Global => self.global_visits.get_mut(index), + }; + if let Some(visit) = visit { + *visit = Visit::Read(calls); + } + Ok(()) + } + + /// Runs a CFF2 `blend`: pops `n`, the `n` rows of deltas, and leaves + /// the `n` defaults, now results of the blend. + fn blend(&mut self, glyph: &mut Glyph) -> Result<(), SubsetError> { + const SHORT: SubsetError = SubsetError::Unsupported("CFF2 blend short of operands"); + let n = glyph.stack.pop().ok_or(SHORT)?.value; + let n = usize::try_from(n) + .map_err(|_| SubsetError::Unsupported("CFF2 blend count negative"))?; + let regions = self + .blend + .as_mut() + .map_or(0, |blend| blend.count(glyph.vsindex)); + let deltas = n.checked_mul(regions).ok_or(SHORT)?; + let total = deltas.checked_add(n).ok_or(SHORT)?; + let start = glyph.stack.len().checked_sub(total).ok_or(SHORT)?; + glyph.stack.truncate(start + n); + for operand in glyph.stack.get_mut(start..).unwrap_or_default() { + operand.push = None; + } + Ok(()) + } +} + +/// The indexes of the `visits` that were read, ascending. +fn kept(visits: &[Visit]) -> Vec { + visits + .iter() + .enumerate() + .filter(|(_, v)| matches!(v, Visit::Read(_))) + .map(|(i, _)| i as u32) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/sigilbuzz-subset/src/cff/walk/tests.rs b/crates/sigilbuzz-subset/src/cff/walk/tests.rs new file mode 100644 index 00000000..d38f1d2a --- /dev/null +++ b/crates/sigilbuzz-subset/src/cff/walk/tests.rs @@ -0,0 +1,455 @@ +//! Tests for the charstring walk, on hand-built charstrings and stores. + +use super::*; +use crate::cff::charstring::encode_int_operand; + +const BIG: usize = 1 << 20; + +/// Push of integer `v`. +fn int(v: i32) -> Vec { + encode_int_operand(v) +} + +/// Concatenates pushes and opcodes. +fn cs(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +/// The push of the biased number that calls subroutine `index` of a +/// list of `count` subroutines. +fn subr_number(index: i32, count: usize) -> Vec { + int(index - subr_bias(count)) +} + +/// An ItemVariationStore with one subtable per entry of `regions`, the +/// subtable naming that many regions, over a one-axis region list. +fn store(regions: &[u16]) -> Vec { + let region_count = regions.iter().copied().max().unwrap_or(0); + let subtables = regions.len(); + // Header: format, region list offset, subtable count, offsets. + let header_len = 2 + 4 + 2 + 4 * subtables; + let region_list_len = 4 + 6 * usize::from(region_count); + let mut out = Vec::new(); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&(header_len as u32).to_be_bytes()); + out.extend_from_slice(&(subtables as u16).to_be_bytes()); + let mut sub_off = header_len + region_list_len; + for &r in regions { + out.extend_from_slice(&(sub_off as u32).to_be_bytes()); + sub_off += 6 + 2 * usize::from(r); + } + out.extend_from_slice(&1u16.to_be_bytes()); // axis count + out.extend_from_slice(®ion_count.to_be_bytes()); + for _ in 0..region_count { + for v in [0i16, 0x4000, 0x4000] { + out.extend_from_slice(&v.to_be_bytes()); + } + } + for &r in regions { + out.extend_from_slice(&0u16.to_be_bytes()); // item count + out.extend_from_slice(&0u16.to_be_bytes()); // word delta count + out.extend_from_slice(&r.to_be_bytes()); + for i in 0..r { + out.extend_from_slice(&i.to_be_bytes()); + } + } + out +} + +/// What one glyph's walk found: its own calls, and the local and global +/// subroutines it reached. +type Walked = (Vec, Vec, Vec); + +/// Runs `charstring` as the only glyph of a CFF1 table. +fn walk_cff1<'a>( + charstring: &[u8], + locals: &'a [&'a [u8]], + globals: &'a [&'a [u8]], +) -> Result { + let mut walk = CharstringWalk::new(globals, None, BIG); + let mut fd = walk.fd(locals, 0); + let calls = walk.glyph(&mut fd, charstring)?; + Ok((calls, fd.kept_locals(), walk.kept_globals())) +} + +#[test] +fn blended_stems_size_the_mask() { + // `10 20 1 2 3 4 2 blend hstemhm`: two regions, so `blend` leaves + // 10 and 20, one stem, and the mask takes one byte. A mask byte of + // 9 is a reserved opcode: reading the mask as empty fails there. + let ivs = store(&[2]); + let locals_body = [OP_RETURN]; + let locals: [&[u8]; 1] = [&locals_body]; + let charstring = cs(&[ + &int(10), + &int(20), + &int(1), + &int(2), + &int(3), + &int(4), + &int(2), + &[OP_BLEND, OP_HSTEMHM, OP_HINTMASK, 9], + &subr_number(0, 1), + &[OP_CALLSUBR], + ]); + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); + let mut fd = walk.fd(&locals, 0); + let calls = walk.glyph(&mut fd, &charstring).expect("walk"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].kind, SubrKind::Local); + assert_eq!(calls[0].index_after_bias, 0); + assert_eq!(calls[0].operand_byte_offset, charstring.len() - 2); + assert_eq!(fd.kept_locals(), [0]); + // The per-body scanner clears the stack at `blend` and misreads. + assert!(super::super::scan_subr_calls(&charstring, 1, 0).is_err()); +} + +#[test] +fn private_dict_vsindex_picks_the_region_count() { + // Seven stems, then `10 1 2 3 1 blend 20 hstem`. Subtable 1 has + // three regions: `blend` leaves 10, one more stem, eight in all, + // and the mask takes one byte before the call. Subtable 0's one + // region would leave 10 1 2: nine stems, a two-byte mask, and the + // call's number read as mask. + let ivs = store(&[1, 3]); + let local_body = [OP_RETURN]; + let locals: [&[u8]; 1] = [&local_body]; + let stems: Vec = (0..14).flat_map(int).collect(); + let charstring = cs(&[ + &stems, + &[OP_HSTEMHM], + &[10, 1, 2, 3, 1].map(int).concat(), + &[OP_BLEND], + &int(20), + &[OP_HSTEM, OP_HINTMASK, 9], + &subr_number(0, 1), + &[OP_CALLSUBR], + ]); + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); + let mut fd = walk.fd(&locals, 1); + let calls = walk.glyph(&mut fd, &charstring).expect("walk"); + assert_eq!(calls.len(), 1); + let mut fd = walk.fd(&locals, 0); + assert!(walk.glyph(&mut fd, &charstring).is_err()); +} + +#[test] +fn vsindex_past_the_store_blends_no_deltas() { + // `7 vsindex` names no subtable: `blend` takes only its count and + // leaves both defaults, one stem. + let ivs = store(&[4]); + let charstring = cs(&[ + &int(7), + &[OP_VSINDEX], + &int(10), + &int(20), + &int(2), + &[OP_BLEND, OP_HSTEMHM, OP_HINTMASK, 9], + ]); + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); + let mut fd = walk.fd(&[], 0); + walk.glyph(&mut fd, &charstring).expect("walk"); + // A negative vsindex saturates to 0, which has four regions. + let negative = cs(&[ + &int(-3), + &[OP_VSINDEX], + &int(10), + &[1, 2, 3, 4].map(int).concat(), + &int(1), + &[OP_BLEND], + &int(20), + &[OP_HSTEMHM, OP_HINTMASK, 9], + ]); + walk.glyph(&mut fd, &negative).expect("walk"); +} + +#[test] +fn a_missing_store_blends_no_deltas() { + let charstring = cs(&[ + &int(10), + &int(20), + &int(2), + &[OP_BLEND, OP_HSTEMHM, OP_HINTMASK, 9], + ]); + for bytes in [None, Some(&[0u8, 9][..])] { + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(bytes)), BIG); + let mut fd = walk.fd(&[], 0); + walk.glyph(&mut fd, &charstring).expect("walk"); + } +} + +#[test] +fn stems_of_the_caller_size_a_mask_in_a_subroutine() { + // The charstring declares nine stems, then calls local 0, whose + // mask therefore takes two bytes before its call to local 1. + let stems: Vec = (0..18).flat_map(int).collect(); + let local_1 = [OP_RETURN]; + let local_0 = cs(&[ + &[OP_HINTMASK, 9, 9], + &subr_number(1, 2), + &[OP_CALLSUBR, OP_RETURN], + ]); + let locals: [&[u8]; 2] = [&local_0, &local_1]; + let charstring = cs(&[ + &stems, + &[OP_HSTEMHM], + &subr_number(0, 2), + &[OP_CALLSUBR, OP_ENDCHAR], + ]); + let mut walk = CharstringWalk::new(&[], None, BIG); + let mut fd = walk.fd(&locals, 0); + let calls = walk.glyph(&mut fd, &charstring).expect("walk"); + assert_eq!(calls.len(), 1); + assert_eq!(fd.kept_locals(), [0, 1]); + let inner = fd.local_calls(0).expect("local 0 read"); + assert_eq!(inner.len(), 1); + assert_eq!(inner[0].operand_byte_offset, 3); + assert_eq!(inner[0].index_after_bias, 1); + // Read on its own, local 0's mask is empty and its bytes misread. + assert!(super::super::scan_subr_calls(&local_0, 2, 0).is_err()); +} + +#[test] +fn operands_the_caller_left_count_as_an_implicit_vstem() { + // 8 stems, then the caller leaves two operands: the mask in the + // subroutine counts nine stems and takes two bytes. + let stems: Vec = (0..16).flat_map(int).collect(); + let local_0 = [OP_HINTMASK, 9, 9, OP_RETURN]; + let locals: [&[u8]; 1] = [&local_0]; + let charstring = cs(&[ + &stems, + &[OP_HSTEMHM], + &int(5), + &int(6), + &subr_number(0, 1), + &[OP_CALLSUBR, OP_ENDCHAR], + ]); + walk_cff1(&charstring, &locals, &[]).expect("walk"); +} + +#[test] +fn stems_a_subroutine_declares_size_the_callers_masks() { + let stems: Vec = (0..18).flat_map(int).collect(); + let local_0 = cs(&[&stems, &[OP_HSTEMHM, OP_RETURN]]); + let locals: [&[u8]; 1] = [&local_0]; + let charstring = cs(&[ + &subr_number(0, 1), + &[OP_CALLSUBR, OP_HINTMASK, 9, 9, OP_ENDCHAR], + ]); + walk_cff1(&charstring, &locals, &[]).expect("walk"); +} + +#[test] +fn the_mask_size_is_fixed_at_the_first_mask() { + // Eight stems give one-byte masks; a stem declared after the first + // mask does not grow the next one, as HarfBuzz reads it. A + // two-byte mask would swallow the call's number. + let local_body = [OP_RETURN]; + let locals: [&[u8]; 1] = [&local_body]; + let stems: Vec = (0..16).flat_map(int).collect(); + let charstring = cs(&[ + &stems, + &[OP_HSTEMHM, OP_HINTMASK, 9], + &int(1), + &int(2), + &[OP_HSTEM, OP_HINTMASK, 9], + &subr_number(0, 1), + &[OP_CALLSUBR, OP_ENDCHAR], + ]); + let (calls, _, _) = walk_cff1(&charstring, &locals, &[]).expect("walk"); + assert_eq!(calls.len(), 1); +} + +#[test] +fn a_subroutine_is_read_once_and_kept_once() { + let local_1 = [OP_RETURN]; + let local_0 = cs(&[&subr_number(1, 3), &[OP_CALLSUBR, OP_RETURN]]); + let local_2 = [OP_RETURN]; + let locals: [&[u8]; 3] = [&local_0, &local_1, &local_2]; + let glyph = cs(&[&subr_number(0, 3), &[OP_CALLSUBR, OP_ENDCHAR]]); + let mut walk = CharstringWalk::new(&[], None, BIG); + let mut fd = walk.fd(&locals, 0); + walk.glyph(&mut fd, &glyph).expect("first glyph"); + walk.glyph(&mut fd, &glyph).expect("second glyph"); + assert_eq!(fd.kept_locals(), [0, 1]); + assert_eq!(fd.local_calls(0).map(<[_]>::len), Some(1)); + assert_eq!(fd.local_calls(2), None); +} + +#[test] +fn endchar_in_a_subroutine_ends_the_glyph() { + // Local 0 ends the glyph, so the call to local 1 after it is never + // reached. + let local_0 = [OP_ENDCHAR]; + let local_1 = [OP_RETURN]; + let locals: [&[u8]; 2] = [&local_0, &local_1]; + let charstring = cs(&[ + &subr_number(0, 2), + &[OP_CALLSUBR], + &subr_number(1, 2), + &[OP_CALLSUBR], + ]); + let (calls, kept_locals, _) = walk_cff1(&charstring, &locals, &[]).expect("walk"); + assert_eq!(calls.len(), 1); + assert_eq!(kept_locals, [0]); +} + +#[test] +fn globals_are_kept_per_fd_reached() { + let global_0 = [OP_RETURN]; + let global_1 = [OP_RETURN]; + let globals: [&[u8]; 2] = [&global_0, &global_1]; + let calls_0 = cs(&[&subr_number(0, 2), &[OP_CALLGSUBR, OP_ENDCHAR]]); + let calls_1 = cs(&[&subr_number(1, 2), &[OP_CALLGSUBR, OP_ENDCHAR]]); + let mut walk = CharstringWalk::new(&globals, None, BIG); + let mut fd_a = walk.fd(&[], 0); + walk.glyph(&mut fd_a, &calls_1).expect("walk"); + walk.glyph(&mut fd_a, &calls_1).expect("walk"); + let mut fd_b = walk.fd(&[], 0); + walk.glyph(&mut fd_b, &calls_0).expect("walk"); + walk.glyph(&mut fd_b, &calls_1).expect("walk"); + assert_eq!(fd_a.reached_globals(), [1]); + assert_eq!(fd_b.reached_globals(), [0, 1]); + assert_eq!(walk.kept_globals(), [0, 1]); +} + +/// A chain of `depth` local subroutines, each calling the next, and a +/// charstring calling the first. +fn chain(depth: usize) -> (Vec>, Vec) { + let locals: Vec> = (0..depth) + .map(|i| { + if i + 1 < depth { + cs(&[&subr_number(i as i32 + 1, depth), &[OP_CALLSUBR, OP_RETURN]]) + } else { + alloc::vec![OP_RETURN] + } + }) + .collect(); + let charstring = cs(&[&subr_number(0, depth), &[OP_CALLSUBR, OP_ENDCHAR]]); + (locals, charstring) +} + +#[test] +fn subroutines_nest_ten_deep() { + let (locals, charstring) = chain(10); + let refs: Vec<&[u8]> = locals.iter().map(Vec::as_slice).collect(); + let (_, kept, _) = walk_cff1(&charstring, &refs, &[]).expect("ten deep"); + assert_eq!(kept.len(), 10); + let (locals, charstring) = chain(11); + let refs: Vec<&[u8]> = locals.iter().map(Vec::as_slice).collect(); + let r = walk_cff1(&charstring, &refs, &[]); + assert_eq!( + r.unwrap_err(), + SubsetError::Unsupported("CFF subroutines nested more than 10 deep") + ); +} + +#[test] +fn a_subroutine_reaching_itself_fails() { + let local_0 = cs(&[&subr_number(0, 1), &[OP_CALLSUBR, OP_RETURN]]); + let locals: [&[u8]; 1] = [&local_0]; + let charstring = cs(&[&subr_number(0, 1), &[OP_CALLSUBR, OP_ENDCHAR]]); + let r = walk_cff1(&charstring, &locals, &[]); + assert_eq!( + r.unwrap_err(), + SubsetError::Unsupported("CFF subroutine reaches itself") + ); +} + +#[test] +fn the_stack_holds_513_operands() { + let mut full = int(0).repeat(MAX_STACK); + full.push(OP_ENDCHAR); + walk_cff1(&full, &[], &[]).expect("513 operands"); + let mut over = int(0).repeat(MAX_STACK + 1); + over.push(OP_ENDCHAR); + assert!(walk_cff1(&over, &[], &[]).is_err()); +} + +#[test] +fn a_spent_budget_fails() { + // Local 0 calls local 1 ten times, local 1 calls local 2 ten + // times, and so on: a few hundred bytes run 10^9 tokens. + let depth = 9; + let locals: Vec> = (0..depth) + .map(|i| { + if i + 1 < depth { + let call = cs(&[&subr_number(i as i32 + 1, depth), &[OP_CALLSUBR]]); + let mut body = call.repeat(10); + body.push(OP_RETURN); + body + } else { + alloc::vec![OP_RETURN] + } + }) + .collect(); + let refs: Vec<&[u8]> = locals.iter().map(Vec::as_slice).collect(); + let charstring = cs(&[&subr_number(0, depth), &[OP_CALLSUBR, OP_ENDCHAR]]); + let mut walk = CharstringWalk::new(&[], None, walk_budget(64)); + let mut fd = walk.fd(&refs, 0); + let r = walk.glyph(&mut fd, &charstring); + assert_eq!(r.unwrap_err(), WORK_EXCEEDED); +} + +#[test] +fn malformed_charstrings_fail() { + let ivs = store(&[2]); + let local_body = cs(&[&[OP_CALLSUBR, OP_RETURN]]); + let locals: [&[u8]; 1] = [&local_body]; + let cases: [(&str, Vec); 10] = [ + // `blend` short of its defaults and deltas. + ("blend short", cs(&[&int(10), &int(1), &[OP_BLEND]])), + ( + "blend count negative", + cs(&[&int(10), &int(-1), &[OP_BLEND]]), + ), + ("blend without count", alloc::vec![OP_BLEND]), + ("vsindex without operand", alloc::vec![OP_VSINDEX]), + ("callsubr without operand", alloc::vec![OP_CALLSUBR]), + // The subroutine number of local 0's call is pushed by the + // charstring, not by local 0. + ( + "number from another body", + cs(&[&int(5), &subr_number(0, 1), &[OP_CALLSUBR]]), + ), + ( + "index past the end", + cs(&[&subr_number(1, 1), &[OP_CALLSUBR]]), + ), + ( + "mask past the end", + cs(&[&int(1), &int(2), &[OP_HSTEM, OP_HINTMASK]]), + ), + ("reserved opcode", alloc::vec![9]), + ("escape truncated", alloc::vec![OP_ESCAPE]), + ]; + for (what, charstring) in cases { + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); + let mut fd = walk.fd(&locals, 0); + assert!(walk.glyph(&mut fd, &charstring).is_err(), "{what}"); + } + // A blended value is no subroutine number. + let blended = cs(&[ + &int(-107), + &int(0), + &int(0), + &int(1), + &[OP_BLEND, OP_CALLSUBR], + ]); + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); + let mut fd = walk.fd(&locals, 0); + assert!(walk.glyph(&mut fd, &blended).is_err()); +} + +#[test] +fn cff1_clears_the_stack_at_blend_and_vsindex() { + // Opcodes 15 and 16 mean nothing in CFF1: they clear the stack, as + // the per-body scanner reads them. + let charstring = cs(&[ + &int(3), + &[OP_VSINDEX], + &int(1), + &[OP_BLEND, OP_HINTMASK, OP_ENDCHAR], + ]); + walk_cff1(&charstring, &[], &[]).expect("walk"); +} diff --git a/crates/sigilbuzz-subset/src/cff2.rs b/crates/sigilbuzz-subset/src/cff2.rs index cdac9164..216e9821 100644 --- a/crates/sigilbuzz-subset/src/cff2.rs +++ b/crates/sigilbuzz-subset/src/cff2.rs @@ -8,10 +8,11 @@ //! a font carries a single FontDict. The [`parse_cff2`] reader //! synthesizes the implicit "every gid -> FD 0" mapping in that case. //! -//! The Type 2 charstring scanner from [`crate::cff`] already accepts -//! both flavors: it stops at `OP_RETURN` / `OP_ENDCHAR` / -//! end-of-stream, and recognizes `vsindex` / `blend` so CFF2-specific -//! ops don't confuse the operand-stack tracking. The byte-level emitter +//! The subsetter's charstring walk from [`crate::cff`] runs both +//! flavors: it stops at `OP_RETURN` / `OP_ENDCHAR` / end-of-stream, and +//! for CFF2 runs `vsindex` and `blend` against the VariationStore's +//! region counts, starting from the `vsindex` each Private DICT sets, +//! so blended stem hints size the hint masks. The byte-level emitter //! primitives ([`crate::cff::encode_index`], //! [`crate::cff::encode_dict_int`], [`crate::cff::renumber_charstring`]) //! are shared with CFF1. CFF2's smaller surface (no Name / String / @@ -130,6 +131,9 @@ struct ParsedCff2<'a> { /// DICTs that name the same Private DICT share one parse. per_fd_private: Vec<&'a [u8]>, per_fd_local_subrs: Vec>>, + /// Per-FD: the `vsindex` the Private DICT sets (0 when it sets + /// none), which a charstring blends with until it picks another. + per_fd_vsindex: Vec, /// Per-FD: the first Font DICT that names the same Private DICT /// (its own index when none before it does). A bake writes each /// Private DICT once, however many Font DICTs name it. @@ -216,6 +220,7 @@ fn parse_cff2(data: &[u8]) -> Result, SubsetError> { // name one Private DICT; it and its Local Subrs are read once. let mut per_fd_private: Vec<&[u8]> = Vec::with_capacity(fd_array.len()); let mut per_fd_local_subrs: Vec>> = Vec::with_capacity(fd_array.len()); + let mut per_fd_vsindex: Vec = Vec::with_capacity(fd_array.len()); let mut private_of: Vec = Vec::with_capacity(fd_array.len()); let mut first_fd_of: BTreeMap<(u32, u32), usize> = BTreeMap::new(); for (i, fd_bytes) in fd_array.iter().enumerate() { @@ -246,8 +251,14 @@ fn parse_cff2(data: &[u8]) -> Result, SubsetError> { } (None, _) => (&[][..], Rc::default(), i), }; + let vsindex = if first == i { + private_vsindex(priv_bytes)? + } else { + per_fd_vsindex.get(first).copied().unwrap_or(0) + }; per_fd_private.push(priv_bytes); per_fd_local_subrs.push(locals); + per_fd_vsindex.push(vsindex); private_of.push(first); } @@ -289,12 +300,31 @@ fn parse_cff2(data: &[u8]) -> Result, SubsetError> { fd_array, per_fd_private, per_fd_local_subrs, + per_fd_vsindex, private_of, fd_select, vstore_blob, }) } +/// The `vsindex` a CFF2 Private DICT sets: the last operand of its last +/// `vsindex` operator (22), clamped to `0..=65535` as the core reads +/// it, or 0 when it has none. +fn private_vsindex(private: &[u8]) -> Result { + if private.is_empty() { + return Ok(0); + } + let mut vsindex = 0; + for entry in walk_dict(private)? { + if entry.op == 22 { + if let Some(v) = entry.operands.last().and_then(|o| o.int_value) { + vsindex = u16::try_from(v.max(0)).unwrap_or(u16::MAX); + } + } + } + Ok(vsindex) +} + // Type 2 op codes consumed by the baker. Duplicates of crate::cff // constants kept private to this module. The baker only reads, never // renumbers. diff --git a/crates/sigilbuzz-subset/src/cff2/subset.rs b/crates/sigilbuzz-subset/src/cff2/subset.rs index 7da7529c..dc60a7f6 100644 --- a/crates/sigilbuzz-subset/src/cff2/subset.rs +++ b/crates/sigilbuzz-subset/src/cff2/subset.rs @@ -5,8 +5,9 @@ use alloc::vec::Vec; use super::{parse_cff2, serialise_cff2_top_dict, ParsedCff2}; use crate::cff::{ - compute_kept_subrs, emit_fd_select_auto, encode_index_cff2, kept_fd_positions, - patch_dict_offset, renumber_charstring, serialise_font_dict, serialise_private_dict, walk_dict, + emit_fd_select_auto, encode_index_cff2, kept_fd_positions, patch_dict_offset, rewrite_calls, + serialise_font_dict, serialise_private_dict, walk_budget, walk_dict, BlendRegions, + CharstringWalk, FdWalk, SubrCall, }; use crate::SubsetError; @@ -27,14 +28,15 @@ use crate::SubsetError; /// output (the spec requires FDSelect on disk when CIDCount > 0, /// which any non-empty CFF2 satisfies). /// -/// Unreachable subroutines are pruned. Per-kept-FD `compute_kept_subrs` -/// runs the transitive closure over each FD's kept charstrings against -/// that FD's local INDEX; the global keep-set is the union across FDs. -/// The byte-stable charstring renumber pads narrower natural-width -/// operands back to the source's original byte slot -/// (`cff::encode_int_operand_at_width`), so a renumbered call -/// site never shifts the surrounding charstring even when the new -/// (post-bias) operand value is smaller than the original. +/// Unreachable subroutines are pruned. Every kept glyph runs through +/// its subroutine calls (see `cff::walk`), its `blend`s reading their +/// region counts from the VariationStore so blended stem hints size the +/// hint masks. The subroutines reached are kept: each FD's locals, and +/// the globals, the union across FDs. +/// A renumbered subroutine number keeps the width of the push it +/// replaces where it fits (`cff::encode_int_operand_at_width`), so the +/// charstring keeps its size; one that does not fit takes its shortest +/// form and the charstring's later bytes move. /// /// `kept_gids` must be sorted ascending and contain gid 0. /// @@ -88,98 +90,66 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result = - alloc::vec![false; parsed.global_subrs.len()]; - let mut per_fd_kept_local: Vec> = Vec::with_capacity(kept_fds_sorted.len()); - for &old_fd in &kept_fds_sorted { - let mut cs_for_this_fd: Vec<&[u8]> = Vec::new(); - for (i, &gid) in kept_gids.iter().enumerate() { - if kept_fd_old[i] == old_fd { - cs_for_this_fd.push(parsed.char_strings[gid as usize]); - } + // Step 3: run every kept glyph through its subroutine calls, FD by + // FD. The subroutines reached are the ones kept: each FD's locals, + // and the globals, the union across FDs. + let store = parsed.vstore_blob.and_then(|blob| blob.get(2..)); + let mut walk = CharstringWalk::new( + &parsed.global_subrs, + Some(BlendRegions::new(store)), + walk_budget(cff_bytes.len()), + ); + let mut fd_walks: Vec> = Vec::with_capacity(kept_fds_sorted.len()); + let mut charstring_calls: Vec> = alloc::vec![Vec::new(); kept_gids.len()]; + for &old_fd in &kept_fds_sorted { + let locals = parsed.per_fd_local_subrs[old_fd as usize].as_slice(); + let vsindex = parsed.per_fd_vsindex[old_fd as usize]; + let mut fd_walk = walk.fd(locals, vsindex); + for (i, &gid) in kept_gids.iter().enumerate() { + if kept_fd_old[i] == old_fd { + charstring_calls[i] = + walk.glyph(&mut fd_walk, parsed.char_strings[gid as usize])?; } - let fd_local_subrs = &parsed.per_fd_local_subrs[old_fd as usize]; - let (kept_local, kept_global) = - compute_kept_subrs(&cs_for_this_fd, fd_local_subrs, &parsed.global_subrs)?; - for &gi in &kept_global { - kept_global_set[gi as usize] = true; - } - per_fd_kept_local.push(kept_local); } - let kept_global_idx: Vec = kept_global_set - .iter() - .enumerate() - .filter_map(|(i, &k)| if k { Some(i as u32) } else { None }) - .collect(); - (per_fd_kept_local, kept_global_idx) - }; - - // Identity fallback keep-set: every source subr survives. - let identity_per_fd_kept_local: Vec> = kept_fds_sorted - .iter() - .map(|&old_fd| (0..parsed.per_fd_local_subrs[old_fd as usize].len() as u32).collect()) - .collect(); - let identity_kept_global_idx: Vec = (0..parsed.global_subrs.len() as u32).collect(); - - match emit_with_keep_set( + fd_walks.push(fd_walk); + } + emit( cff_bytes, &parsed, kept_gids, &kept_fd_old, &kept_fds_sorted, &new_fd_select, - &pruned_per_fd_kept_local, - &pruned_kept_global_idx, - ) { - Ok(out) => Ok(out), - Err(SubsetError::Unsupported("CFF renumber: cannot pad operand to original width")) => { - emit_with_keep_set( - cff_bytes, - &parsed, - kept_gids, - &kept_fd_old, - &kept_fds_sorted, - &new_fd_select, - &identity_per_fd_kept_local, - &identity_kept_global_idx, - ) - } - Err(e) => Err(e), - } + &walk, + &fd_walks, + &charstring_calls, + ) } +/// Lays out the subset table: the subroutines `walk` reached (each +/// kept FD's `fds` entry, in `kept_fds_sorted` order), with every call +/// site it found renumbered, the kept glyphs' charstrings with theirs +/// (`charstring_calls`, in `kept_gids` order), and FDArray and FDSelect +/// for the kept FDs. #[allow(clippy::too_many_arguments)] -fn emit_with_keep_set( +fn emit( cff_bytes: &[u8], parsed: &ParsedCff2<'_>, kept_gids: &[u16], kept_fd_old: &[u8], kept_fds_sorted: &[u8], new_fd_select: &[u8], - per_fd_kept_local: &[Vec], - kept_global_idx: &[u32], + walk: &CharstringWalk<'_>, + fds: &[FdWalk<'_>], + charstring_calls: &[Vec], ) -> Result, SubsetError> { + let per_fd_kept_local: Vec> = fds.iter().map(FdWalk::kept_locals).collect(); + let kept_global_idx = walk.kept_globals(); let mut global_renumber: Vec> = alloc::vec![None; parsed.global_subrs.len()]; for (new_i, &old_i) in kept_global_idx.iter().enumerate() { global_renumber[old_i as usize] = Some(new_i as u32); } let new_global_count = kept_global_idx.len(); - let old_global_count = parsed.global_subrs.len(); let mut per_fd_local_renumber: Vec>> = Vec::with_capacity(kept_fds_sorted.len()); @@ -192,26 +162,32 @@ fn emit_with_keep_set( per_fd_local_renumber.push(renumber); } - // Step 5: rewrite each kept charstring. - let fd_pos_of = kept_fd_positions(kept_fds_sorted); - let mut new_charstrings: Vec> = Vec::with_capacity(kept_gids.len()); - for (i, &gid) in kept_gids.iter().enumerate() { - let old_fd = kept_fd_old[i]; - let new_fd_pos = fd_pos_of[usize::from(old_fd)]; - let fd_local_subrs_old = &parsed.per_fd_local_subrs[old_fd as usize]; - let fd_local_renumber = &per_fd_local_renumber[new_fd_pos]; - let new_local_count = per_fd_kept_local[new_fd_pos].len(); - let mut cs = parsed.char_strings[gid as usize].to_vec(); - renumber_charstring( - &mut cs, - fd_local_subrs_old.len(), - old_global_count, + // Step 5: rewrite each kept charstring and subroutine at the call + // sites the walk found in it. + let renumber = |body: &[u8], + calls: &[SubrCall], + new_local_count: usize, + local_renumber: &[Option]| { + rewrite_calls( + body, + calls, new_local_count, new_global_count, - fd_local_renumber, + local_renumber, &global_renumber, - )?; - new_charstrings.push(cs); + |_| None, + ) + }; + let fd_pos_of = kept_fd_positions(kept_fds_sorted); + let mut new_charstrings: Vec> = Vec::with_capacity(kept_gids.len()); + for (i, &gid) in kept_gids.iter().enumerate() { + let new_fd_pos = fd_pos_of[usize::from(kept_fd_old[i])]; + new_charstrings.push(renumber( + parsed.char_strings[gid as usize], + &charstring_calls[i], + per_fd_kept_local[new_fd_pos].len(), + &per_fd_local_renumber[new_fd_pos], + )?); } // Per-FD local subrs (renumbered). @@ -219,43 +195,28 @@ fn emit_with_keep_set( for (i, &old_fd) in kept_fds_sorted.iter().enumerate() { let fd_local_subrs_old = &parsed.per_fd_local_subrs[old_fd as usize]; let kept_local_idx = &per_fd_kept_local[i]; - let fd_local_renumber = &per_fd_local_renumber[i]; - let new_local_count = kept_local_idx.len(); - let mut new_locals: Vec> = kept_local_idx - .iter() - .map(|&idx| fd_local_subrs_old[idx as usize].to_vec()) - .collect(); - for sub in &mut new_locals { - renumber_charstring( - sub, - fd_local_subrs_old.len(), - old_global_count, - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - )?; + let mut new_locals: Vec> = Vec::with_capacity(kept_local_idx.len()); + for &idx in kept_local_idx { + new_locals.push(renumber( + fd_local_subrs_old[idx as usize], + fds[i].local_calls(idx as usize).unwrap_or_default(), + kept_local_idx.len(), + &per_fd_local_renumber[i], + )?); } new_per_fd_local_subrs.push(new_locals); } // Renumber globals: pass an empty local table; calls into locals // from globals would cross-FD-collide and surface a hard error. - let mut new_global_subrs: Vec> = kept_global_idx - .iter() - .map(|&i| parsed.global_subrs[i as usize].to_vec()) - .collect(); - let empty_local: Vec> = Vec::new(); - for sub in &mut new_global_subrs { - renumber_charstring( - sub, - 0, - old_global_count, + let mut new_global_subrs: Vec> = Vec::with_capacity(kept_global_idx.len()); + for &g in &kept_global_idx { + new_global_subrs.push(renumber( + parsed.global_subrs[g as usize], + walk.global_calls(g as usize).unwrap_or_default(), 0, - new_global_count, - &empty_local, - &global_renumber, - )?; + &[], + )?); } // FDSelect bytes. diff --git a/crates/sigilbuzz-subset/src/cff2/tests.rs b/crates/sigilbuzz-subset/src/cff2/tests.rs index 3d40f582..0fdaa5f7 100644 --- a/crates/sigilbuzz-subset/src/cff2/tests.rs +++ b/crates/sigilbuzz-subset/src/cff2/tests.rs @@ -269,3 +269,124 @@ fn cff2_orchestration_rejects_kept_set_without_gid0() { let r = subset_non_identity(&cff, &[1u16]); assert!(r.is_err()); } + +/// An ItemVariationStore body (no CFF2 length prefix) with one subtable +/// per entry of `regions`, naming that many one-axis regions. +fn store_with_region_counts(regions: &[u16]) -> Vec { + let region_count = regions.iter().copied().max().unwrap_or(0); + let header_len = 8 + 4 * regions.len(); + let region_list_len = 4 + 6 * usize::from(region_count); + let mut out = Vec::new(); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&(header_len as u32).to_be_bytes()); + out.extend_from_slice(&(regions.len() as u16).to_be_bytes()); + let mut sub_off = header_len + region_list_len; + for &r in regions { + out.extend_from_slice(&(sub_off as u32).to_be_bytes()); + sub_off += 6 + 2 * usize::from(r); + } + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(®ion_count.to_be_bytes()); + for _ in 0..region_count { + for v in [0i16, 0x4000, 0x4000] { + out.extend_from_slice(&v.to_be_bytes()); + } + } + for &r in regions { + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&r.to_be_bytes()); + for i in 0..r { + out.extend_from_slice(&i.to_be_bytes()); + } + } + out +} + +/// Pushes of `values`. +fn pushes(values: &[i32]) -> Vec { + values.iter().flat_map(|&v| encode_int_operand(v)).collect() +} + +/// After a hint mask: a mask byte, then `5 hmoveto`. Read with a +/// second mask byte, the shortint opcode is taken for it and the +/// reserved opcode 0 follows. +const ONE_BYTE_MASK_THEN_MOVE: [u8; 5] = [9, 28, 0, 5, 22]; + +#[test] +fn cff2_subset_reads_blended_stem_hints() { + // `10 20 1 2 3 4 2 blend hstemhm`: the store's two regions leave + // one stem, so the mask takes one byte. The subset failed with + // "CFF unknown charstring operator" when `blend` cleared the stack. + let ivs = store_with_region_counts(&[2]); + let mut cs1 = pushes(&[10, 20, 1, 2, 3, 4, 2]); + cs1.extend_from_slice(&[16, 18, 19]); // blend hstemhm hintmask + cs1.extend_from_slice(&ONE_BYTE_MASK_THEN_MOVE); + let cs0: &[u8] = &[]; + let cff = build_synthetic_cff2(&[cs0, &cs1], &[0, 0], Some(&ivs)); + let new_cff = subset_non_identity(&cff, &[0, 1]).unwrap(); + let parsed = parse_cff2(&new_cff).unwrap(); + assert_eq!(parsed.char_strings[1], cs1.as_slice()); +} + +#[test] +fn cff2_subset_blends_with_the_private_dict_vsindex() { + // Seven stems, then `10 1 2 3 1 blend 20 hstem`. The Private DICT's + // `1 vsindex` picks the three-region subtable: one more stem, eight + // in all, and a one-byte mask. Without it the one-region subtable + // leaves nine stems and a two-byte mask, which misreads. + let ivs = store_with_region_counts(&[1, 3]); + let mut cs1 = pushes(&(0..14).collect::>()); + cs1.push(18); // hstemhm + cs1.extend_from_slice(&pushes(&[10, 1, 2, 3, 1])); + cs1.push(16); // blend + cs1.extend_from_slice(&pushes(&[20])); + cs1.extend_from_slice(&[1, 19]); // hstem hintmask + cs1.extend_from_slice(&ONE_BYTE_MASK_THEN_MOVE); + let cs0: &[u8] = &[]; + let with_vsindex: &[u8] = &[140, 22]; // 1 vsindex + let cff = build_synthetic_cff2_sharing(&[cs0, &cs1], &[0, 0], Some(&ivs), Some(with_vsindex)); + let new_cff = subset_non_identity(&cff, &[0, 1]).unwrap(); + let parsed = parse_cff2(&new_cff).unwrap(); + assert_eq!(parsed.char_strings[1], cs1.as_slice()); + assert_eq!(parsed.per_fd_vsindex, [1]); + + let without: &[u8] = &[139, 20]; // 0 defaultWidthX + let cff = build_synthetic_cff2_sharing(&[cs0, &cs1], &[0, 0], Some(&ivs), Some(without)); + assert!(subset_non_identity(&cff, &[0, 1]).is_err()); +} + +#[test] +fn cff2_subset_sizes_a_mask_in_a_subroutine_by_blended_stems() { + // Glyph 1 blends nine stems and calls local 0, whose mask takes + // two bytes before its call to local 2; local 1 goes. + let ivs = store_with_region_counts(&[2]); + let defaults: Vec = (0..18).collect(); + let deltas: Vec = (0..36).map(|v| v % 7).collect(); + let mut cs1 = pushes(&defaults); + cs1.extend_from_slice(&pushes(&deltas)); + cs1.extend_from_slice(&pushes(&[18])); + cs1.extend_from_slice(&[16, 18]); // blend hstemhm + cs1.extend_from_slice(&pushes(&[-107])); + cs1.push(10); // callsubr local 0 + let mut local_0 = alloc::vec![19, 9, 9]; // hintmask, two bytes + local_0.extend_from_slice(&pushes(&[2 - 107])); + local_0.push(10); // callsubr local 2 + let local_1: &[u8] = &[11]; + let local_2: &[u8] = &[139, 139, 21]; + let cs0: &[u8] = &[]; + let cff = partial::build_synthetic_cff2_with_local_subrs( + &[cs0, &cs1], + &[0, 0], + &[&local_0, local_1, local_2], + Some(&ivs), + ); + let new_cff = subset_non_identity(&cff, &[0, 1]).unwrap(); + let parsed = parse_cff2(&new_cff).unwrap(); + assert_eq!(parsed.char_strings[1], cs1.as_slice()); + let locals = &parsed.per_fd_local_subrs[0]; + assert_eq!(locals.len(), 2); + let mut renumbered = local_0.clone(); + renumbered[3..4].copy_from_slice(&pushes(&[1 - 107])); + assert_eq!(locals[0], renumbered.as_slice()); + assert_eq!(locals[1], local_2); +} diff --git a/crates/sigilbuzz-subset/src/cff2/tests/partial.rs b/crates/sigilbuzz-subset/src/cff2/tests/partial.rs index 6257c921..e3e408d2 100644 --- a/crates/sigilbuzz-subset/src/cff2/tests/partial.rs +++ b/crates/sigilbuzz-subset/src/cff2/tests/partial.rs @@ -246,7 +246,7 @@ fn build_ivs1_for_cff2( } /// Builds a synthetic CFF2 with local subroutines per Font DICT. -fn build_synthetic_cff2_with_local_subrs( +pub(super) fn build_synthetic_cff2_with_local_subrs( charstrings: &[&[u8]], fd_select: &[u8], local_subrs: &[&[u8]], diff --git a/crates/sigilbuzz-subset/src/lib.rs b/crates/sigilbuzz-subset/src/lib.rs index 39f58808..c12f0da0 100644 --- a/crates/sigilbuzz-subset/src/lib.rs +++ b/crates/sigilbuzz-subset/src/lib.rs @@ -70,6 +70,10 @@ //! for CFF2 charstrings, which fail the instance. Real fonts use at //! most about 12.5% of any budget, so none of this applies to them. //! +//! [`subset`] runs every kept glyph of a `CFF ` or `CFF2` table through +//! its subroutine calls (see "For CFF and CFF2 fonts" below), charging +//! 64 tokens per byte of the table, plus 2^22, and fails past that. +//! //! # What happens to each table //! //! For TrueType (`glyf`) fonts: @@ -122,6 +126,13 @@ //! //! - A kept `CFF ` glyph whose charstring ends in the seac form of //! `endchar` keeps the base and accent glyphs it draws. +//! - Each kept glyph runs through its subroutine calls as HarfBuzz's +//! subsetter interprets it: a hint mask takes one bit per stem the +//! glyph declared, in its charstring or any subroutine, a CFF2 +//! `blend` drops as many deltas as its store's regions give (so +//! blended stem hints count), and subroutines nest at most 10 deep. +//! The subroutines reached are kept and renumbered where they are +//! called; a renumbered call that no longer fits its push grows. //! - If every glyph survives, the font passes through and only the SFNT //! directory is rebuilt. Every table is copied unchanged except //! `kern`, `kerx`, and `morx`, and the layout tables (`BASE` From 525b6892f0f56eebf73f18f39573b0a7bf6e6886 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:39:49 -0800 Subject: [PATCH 2/9] Round blended values in a full CFF2 instance as HarfBuzz does A full CFF2 instance wrote each resolved blend as `master + sum(scalar * delta)` and kept the fraction, in 16.16 fixed form: Source Serif 4 VF at wght 700 came to a 614 KB `CFF2` table where HarfBuzz writes 203 KB. HarfBuzz's instancer rounds every blended value to a whole unit, halves away from zero, in double precision. The bake now keeps its operand stack in f64 and rounds each blend once its deltas are in; a source operand with a fraction keeps its exact 16.16 value. The charstrings of the Source Serif 4 instance are now byte-identical to HarfBuzz's. The bake also starts each charstring at the `vsindex` its Private DICT sets, as the core and HarfBuzz read it, instead of 0. Part of #290. --- crates/sigilbuzz-subset/src/cff2.rs | 26 ++++++++- crates/sigilbuzz-subset/src/cff2/bake.rs | 63 ++++++++++++--------- crates/sigilbuzz-subset/src/cff2/partial.rs | 4 +- crates/sigilbuzz-subset/src/cff2/tests.rs | 62 ++++++++++++++++++++ crates/sigilbuzz-subset/src/instance.rs | 17 +++--- 5 files changed, 135 insertions(+), 37 deletions(-) diff --git a/crates/sigilbuzz-subset/src/cff2.rs b/crates/sigilbuzz-subset/src/cff2.rs index 216e9821..6e42f8f8 100644 --- a/crates/sigilbuzz-subset/src/cff2.rs +++ b/crates/sigilbuzz-subset/src/cff2.rs @@ -386,7 +386,7 @@ fn charge_token(budget: &mut usize, err: &'static str) -> Result<(), SubsetError /// Resolves the absolute subroutine index a `callsubr` / `callgsubr` /// operand selects, or `None` when it falls outside `subrs`. -fn biased_subr<'s>(subrs: &[&'s [u8]], operand: f32) -> Option<&'s [u8]> { +fn biased_subr<'s>(subrs: &[&'s [u8]], operand: f64) -> Option<&'s [u8]> { // Float-to-int casts saturate, and NaN maps to 0. let raw = operand as i32; let abs = i64::from(raw) + i64::from(subr_bias(subrs.len())); @@ -539,6 +539,30 @@ fn decode_operand_f32(data: &[u8], pos: usize) -> Option<(f32, usize)> { } } +/// Decodes a single push operand at `data[pos..]` into an f64 plus +/// byte-length: [`decode_operand_f32`] without rounding a 16.16 value. +fn decode_operand_f64(data: &[u8], pos: usize) -> Option<(f64, usize)> { + if data.get(pos) == Some(&255) { + let bytes = data.get(pos + 1..pos + 5)?; + let raw = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + return Some((f64::from(raw) / 65536.0, 5)); + } + decode_operand_f32(data, pos).map(|(v, len)| (f64::from(v), len)) +} + +/// Encodes `v` as a Type 2 push: a whole number in `i16` range in the +/// shortest integer form, anything else in the 16.16 fixed form. +fn encode_charstring_number_f64(v: f64, out: &mut Vec) { + if v == v.round() && (-32768.0..=32767.0).contains(&v) { + // A whole number in range: the integer forms are exact. + encode_charstring_number(v as f32, out); + } else { + // Float-to-int casts saturate, and NaN maps to 0. + let [a, b, c, d] = ((v * 65536.0).round() as i32).to_be_bytes(); + out.extend_from_slice(&[255, a, b, c, d]); + } +} + /// Encodes a numeric value as a Type 2 push, using the shortest valid /// form for the integer case and the 16.16 fixed form when fractional. fn encode_charstring_number(v: f32, out: &mut Vec) { diff --git a/crates/sigilbuzz-subset/src/cff2/bake.rs b/crates/sigilbuzz-subset/src/cff2/bake.rs index ede6dc7c..c788eff0 100644 --- a/crates/sigilbuzz-subset/src/cff2/bake.rs +++ b/crates/sigilbuzz-subset/src/cff2/bake.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; use sigilbuzz::tables::variation_store::ItemVariationStore; use super::{ - bake_token_budget, biased_subr, charge_token, decode_operand_f32, encode_charstring_number, + bake_token_budget, biased_subr, charge_token, decode_operand_f64, encode_charstring_number_f64, parse_cff2, serialise_cff2_top_dict, BlendCache, MAX_BAKE_DEPTH, OP_BLEND, OP_CALLGSUBR, OP_CALLSUBR, OP_CNTRMASK, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, OP_HMOVETO, OP_HSTEM, OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, OP_RLINETO, @@ -38,14 +38,17 @@ use crate::SubsetError; /// pasting the body. Inlining also lets us drop the local + global /// Subr INDEX entirely. /// 2. **Resolve `blend`**: pop `n`, then `n*nRegions` deltas, then `n` -/// masters; emit only the `n` resolved scalars -/// (`master + Σ scalar(coords) * delta`). The trailing count and the -/// delta operands are dropped. +/// masters; emit only the `n` resolved values +/// (`master + Σ scalar(coords) * delta`), each rounded to the nearest +/// whole number, halves away from zero, as HarfBuzz's instancer +/// writes them. The trailing count and the delta operands are +/// dropped. /// 3. **Strip `vsindex`**: tracks which IVS subtable subsequent -/// `blend`s read from. Dropped from the output (no blend remains). -/// 4. Re-encode all push operands. After blend resolution masters can -/// become non-integer floats; we emit them via the `b0=255` 16.16 -/// fixed form when fractional, integer forms otherwise. +/// `blend`s read from, starting from the one the glyph's Private +/// DICT sets. Dropped from the output (no blend remains). +/// 4. Re-encode all push operands: whole numbers in the integer forms, +/// and a source operand with a fraction in the `b0=255` 16.16 fixed +/// form it came in. /// /// The output drops VariationStore, GlobalSubr INDEX entries, and per-FD /// LocalSubr INDEX entries. Top DICT keeps CharStrings / FDArray / @@ -94,7 +97,8 @@ pub fn bake_at_coords(cff_bytes: &[u8], coords: &[f32]) -> Result, Subse .get(fd as usize) .map(|locals| locals.as_slice()) .unwrap_or(&[]); - let baked = baker.bake_charstring(cs, local_subrs)?; + let vsindex = parsed.per_fd_vsindex.get(fd as usize).copied().unwrap_or(0); + let baked = baker.bake_charstring(cs, local_subrs, vsindex)?; new_charstrings.push(baked); } @@ -271,8 +275,9 @@ struct Baker<'a> { blend: BlendCache<'a>, global_subrs: &'a [&'a [u8]], local_subrs: &'a [&'a [u8]], - /// Operand stack: floats so blend deltas don't lose precision. - stack: Vec, + /// Operand stack, in double precision as HarfBuzz keeps it, so a + /// 16.16 operand round-trips and blend sums don't lose precision. + stack: Vec, /// Output charstring bytes. out: Vec, /// Current vsindex (which IVS subtable blend draws from). @@ -297,15 +302,18 @@ impl<'a> Baker<'a> { } } + /// Bakes one charstring of a Font DICT with `local_subrs` whose + /// Private DICT sets `vsindex`. fn bake_charstring( &mut self, cs: &[u8], local_subrs: &'a [&'a [u8]], + vsindex: u16, ) -> Result, SubsetError> { self.local_subrs = local_subrs; self.stack.clear(); self.out = Vec::new(); - self.vsindex = 0; + self.vsindex = vsindex; self.stem_count = 0; self.run(cs, 0)?; Ok(core::mem::take(&mut self.out)) @@ -324,7 +332,7 @@ impl<'a> Baker<'a> { "CFF2 bake: charstring work budget exceeded", )?; if b0 >= 32 || b0 == OP_SHORTINT { - let (val, len) = decode_operand_f32(code, pos) + let (val, len) = decode_operand_f64(code, pos) .ok_or(SubsetError::Unsupported("CFF2 bake: operand truncated"))?; self.stack.push(val); pos += len; @@ -341,7 +349,7 @@ impl<'a> Baker<'a> { let v = self.stack.pop().ok_or(SubsetError::Unsupported( "CFF2 bake: vsindex without operand", ))?; - if v < 0.0 || v > f32::from(u16::MAX) { + if v < 0.0 || v > f64::from(u16::MAX) { return Err(SubsetError::Unsupported( "CFF2 bake: vsindex operand out of range", )); @@ -449,27 +457,30 @@ impl<'a> Baker<'a> { .get_mut(start..) .and_then(|tail| tail.split_at_mut_checked(n)) .ok_or(UNDERFLOW)?; - if n_regions > 0 { - for (master, row) in masters.iter_mut().zip(deltas.chunks_exact(n_regions)) { - let mut accum = 0.0_f32; - for (&delta, &s) in row.iter().zip(scalars) { - accum += s * delta; - } - *master += accum; + // Each value rounds once its deltas are in, as HarfBuzz's + // instancer rounds a blend at fixed coordinates. + for (i, master) in masters.iter_mut().enumerate() { + // `i * n_regions` stays below `needed`, which did not overflow. + let row = deltas + .get(i * n_regions..(i + 1) * n_regions) + .unwrap_or_default(); + let mut accum = 0.0_f64; + for (&delta, &s) in row.iter().zip(scalars) { + accum += f64::from(s) * delta; } + *master = (*master + accum).round(); } self.stack.truncate(start + n); Ok(()) } /// Emits the operands currently sitting on the operand stack as - /// CFF2 push bytes, then clears the stack. The numbers that - /// survive blend resolution may be fractional; we use the 16.16 - /// fixed form (`b0=255`) when needed and the integer forms - /// otherwise. + /// CFF2 push bytes, then clears the stack. Blend results are whole + /// numbers; a source operand with a fraction keeps the 16.16 fixed + /// form (`b0=255`). fn flush_stack(&mut self) { for v in self.stack.drain(..) { - encode_charstring_number(v, &mut self.out); + encode_charstring_number_f64(v, &mut self.out); } } } diff --git a/crates/sigilbuzz-subset/src/cff2/partial.rs b/crates/sigilbuzz-subset/src/cff2/partial.rs index b4141978..40080381 100644 --- a/crates/sigilbuzz-subset/src/cff2/partial.rs +++ b/crates/sigilbuzz-subset/src/cff2/partial.rs @@ -549,7 +549,7 @@ impl<'a> PartialBaker<'a> { "CFF2 partial bake: callsubr without operand", "CFF2 partial bake: callsubr operand decode failed", )?; - let body = biased_subr(self.local_subrs, raw).ok_or( + let body = biased_subr(self.local_subrs, f64::from(raw)).ok_or( SubsetError::Unsupported("CFF2 partial bake: callsubr index out of range"), )?; self.run(body, depth + 1)?; @@ -560,7 +560,7 @@ impl<'a> PartialBaker<'a> { "CFF2 partial bake: callgsubr without operand", "CFF2 partial bake: callgsubr operand decode failed", )?; - let body = biased_subr(self.global_subrs, raw).ok_or( + let body = biased_subr(self.global_subrs, f64::from(raw)).ok_or( SubsetError::Unsupported("CFF2 partial bake: callgsubr index out of range"), )?; self.run(body, depth + 1)?; diff --git a/crates/sigilbuzz-subset/src/cff2/tests.rs b/crates/sigilbuzz-subset/src/cff2/tests.rs index 0fdaa5f7..a8265553 100644 --- a/crates/sigilbuzz-subset/src/cff2/tests.rs +++ b/crates/sigilbuzz-subset/src/cff2/tests.rs @@ -390,3 +390,65 @@ fn cff2_subset_sizes_a_mask_in_a_subroutine_by_blended_stems() { assert_eq!(locals[0], renumbered.as_slice()); assert_eq!(locals[1], local_2); } + +#[test] +fn bake_rounds_blends_halves_away_from_zero() { + // At 0.5 the one region scales its deltas by a half: `0 21 1 blend` + // is 10.5 and `0 -21 1 blend` -10.5, which round to 11 and -11, as + // HarfBuzz's instancer writes them. A 16.16 source operand keeps + // its fraction. + let ivs = store_with_region_counts(&[1]); + let mut cs1 = pushes(&[0, 21, 1]); + cs1.push(16); // blend + cs1.extend_from_slice(&pushes(&[0, -21, 1])); + cs1.extend_from_slice(&[16, 21]); // blend rmoveto + let fixed: [u8; 5] = [255, 0, 1, 0x80, 0]; // 1.5 + cs1.extend_from_slice(&fixed); + cs1.extend_from_slice(&pushes(&[0])); + cs1.push(5); // rlineto + let cs0: &[u8] = &[]; + let cff = build_synthetic_cff2(&[cs0, &cs1], &[0, 0], Some(&ivs)); + let baked = bake_at_coords(&cff, &[0.5]).unwrap(); + let parsed = parse_cff2(&baked).unwrap(); + let mut want = pushes(&[11, -11]); + want.push(21); + want.extend_from_slice(&fixed); + want.extend_from_slice(&pushes(&[0])); + want.push(5); + assert_eq!(parsed.char_strings[1], want.as_slice()); +} + +#[test] +fn bake_rounds_a_blend_without_regions() { + // A blend over a subtable with no regions keeps its defaults, whole. + let ivs = store_with_region_counts(&[0]); + let mut cs1 = pushes(&[3, 4, 2]); + cs1.extend_from_slice(&[16, 21]); // blend rmoveto + let cs0: &[u8] = &[]; + let cff = build_synthetic_cff2(&[cs0, &cs1], &[0, 0], Some(&ivs)); + let baked = bake_at_coords(&cff, &[0.5]).unwrap(); + let parsed = parse_cff2(&baked).unwrap(); + let mut want = pushes(&[3, 4]); + want.push(21); + assert_eq!(parsed.char_strings[1], want.as_slice()); +} + +#[test] +fn bake_starts_at_the_private_dict_vsindex() { + // `1 vsindex` in the Private DICT picks the two-region subtable: + // `0 10 20 1 blend` is 0 + 5 + 10 at 0.5. Read with subtable 0's + // one region it would leave two operands. + let ivs = store_with_region_counts(&[1, 2]); + let mut cs1 = pushes(&[0, 10, 20, 1]); + cs1.push(16); // blend + cs1.extend_from_slice(&pushes(&[0])); + cs1.push(21); // rmoveto + let cs0: &[u8] = &[]; + let with_vsindex: &[u8] = &[140, 22]; // 1 vsindex + let cff = build_synthetic_cff2_sharing(&[cs0, &cs1], &[0, 0], Some(&ivs), Some(with_vsindex)); + let baked = bake_at_coords(&cff, &[0.5]).unwrap(); + let parsed = parse_cff2(&baked).unwrap(); + let mut want = pushes(&[15, 0]); + want.push(21); + assert_eq!(parsed.char_strings[1], want.as_slice()); +} diff --git a/crates/sigilbuzz-subset/src/instance.rs b/crates/sigilbuzz-subset/src/instance.rs index d6f10706..855e44f9 100644 --- a/crates/sigilbuzz-subset/src/instance.rs +++ b/crates/sigilbuzz-subset/src/instance.rs @@ -42,8 +42,10 @@ //! //! Every value rounds to the nearest unit, halves up, as HarfBuzz and //! fontTools round: outlines, metrics, `cvt `, `BASE`, `MVAR`, and the -//! `GPOS` and `GDEF` deltas. (CFF2 charstrings and their store keep -//! their own rounding.) +//! `GPOS` and `GDEF` deltas. A full CFF2 instance rounds each blended +//! charstring value to a whole unit, halves away from zero, as +//! HarfBuzz's instancer does; a partial one keeps the fractions its +//! store projection gives. //! //! # Coordinates //! @@ -91,12 +93,11 @@ //! //! For CFF2 sources the [`crate::cff2::bake_at_coords`] helper walks //! every charstring, inlines `callsubr` / `callgsubr`, resolves every -//! `blend` to its scalar value at `coords`, strips `vsindex`, and -//! emits a fresh CFF2 table without a VariationStore. Output is still -//! CFF2-tagged (the SFNT directory entry remains `CFF2`) but no -//! variable-font opcodes survive. Consumers that ignore CFF2's -//! variable surface see the same outline as a consumer that honors -//! it at the chosen instance. +//! `blend` to its value at `coords` rounded to a whole unit, strips +//! `vsindex`, and emits a fresh CFF2 table without a VariationStore. +//! Output is still CFF2-tagged (the SFNT directory entry remains +//! `CFF2`) but no variable-font opcodes survive; the charstrings are +//! the ones HarfBuzz's instancer writes. //! //! # VVAR-aware vmtx and VORG //! From e56f4db0e96f0520b7ea9a2e53ad80bf01004cec Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:41:20 -0800 Subject: [PATCH 3/9] Set an instance's OS/2, post and CFF2 metrics as HarfBuzz does `instance` left `OS/2.usWeightClass`, `usWidthClass`, `xAvgCharWidth` and `post.italicAngle` at the source's values, and a CFF2 instance kept the source's left side bearings (1,375 of Source Serif 4's 1,464 glyphs at wght 700), `head` box and `hhea` extremes. As HarfBuzz's instancer does: - `usWeightClass` follows a pinned `wght` (rounded, clamped to 1 to 1000), `usWidthClass` a pinned `wdth` (through the spec's width percentages), and `italicAngle` a pinned `slnt` (clamped to -90 to 90). `instance` takes each pinned axis' location as the user value of its normalized coordinate; a kept axis leaves its field. - `xAvgCharWidth` is the mean of the instance's advances that are not zero, in full and partial instances. - A CFF2 instance measures each glyph at the outline coordinates, the box of its outline with control points before the blends round, and takes its left side bearing, the `head` box and the `hhea` extremes from those boxes (`update_instance_metrics_map_from_cff2`). A glyph without a box keeps its bearing. A partial instance measures its new default outlines the same way. The glyphs are drawn after the charstring bake, which ran the same subroutines within its budget; the partial bake now runs before the metrics too. Part of #290. --- crates/sigilbuzz-subset/README.md | 5 +- crates/sigilbuzz-subset/src/instance.rs | 240 ++++++++++++++---- .../src/instance/cff2_metrics.rs | 221 ++++++++++++++++ .../src/instance/cff2_metrics/tests.rs | 160 ++++++++++++ .../sigilbuzz-subset/src/instance/metrics.rs | 48 +++- .../sigilbuzz-subset/src/instance/partial.rs | 40 +-- crates/sigilbuzz-subset/src/instance/style.rs | 169 ++++++++++++ crates/sigilbuzz-subset/src/instance/tests.rs | 1 + .../src/instance/tests/user_and_style.rs | 154 +++++++++++ crates/sigilbuzz-subset/src/lib.rs | 3 + 10 files changed, 966 insertions(+), 75 deletions(-) create mode 100644 crates/sigilbuzz-subset/src/instance/cff2_metrics.rs create mode 100644 crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs create mode 100644 crates/sigilbuzz-subset/src/instance/style.rs create mode 100644 crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs diff --git a/crates/sigilbuzz-subset/README.md b/crates/sigilbuzz-subset/README.md index f2141965..d2d8d530 100644 --- a/crates/sigilbuzz-subset/README.md +++ b/crates/sigilbuzz-subset/README.md @@ -46,7 +46,10 @@ such as `COLR`, `CPAL`, `MVAR` and `DSIG`, stay, since no glyph ID changes, but font, or pins some axes and leaves the rest variable. It moves `glyf` outlines (inferred points and composite offsets included), the advances and side bearings their phantom points give, the `cvt ` values `cvar` varies, and the `BASE` coordinates the store -varies, the way HarfBuzz's instancer does. Glyph instructions stay, as they do in +varies, the way HarfBuzz's instancer does. A `CFF2` instance rounds every blended value +to a whole unit and takes its side bearings, `head` box and `hhea` extremes from its +outlines, as HarfBuzz does. `OS/2` gets the new `usWeightClass`, `usWidthClass` and +`xAvgCharWidth`, and `post` the new `italicAngle`. Glyph instructions stay, as they do in HarfBuzz unless it is asked to drop hinting. A malformed piece of a layout table (a GDEF list, a GSUB or GPOS lookup or subtable, diff --git a/crates/sigilbuzz-subset/src/instance.rs b/crates/sigilbuzz-subset/src/instance.rs index 855e44f9..c7bfdaac 100644 --- a/crates/sigilbuzz-subset/src/instance.rs +++ b/crates/sigilbuzz-subset/src/instance.rs @@ -72,6 +72,16 @@ //! The two differ by one F2DOT14 step where the rounding lands on //! different sides, which moves a delta by a unit now and then. //! +//! # OS/2 and post +//! +//! As HarfBuzz's instancer, an instance sets `OS/2.usWeightClass` from +//! where it pins `wght` (rounded, clamped to 1 to 1000), +//! `OS/2.usWidthClass` from `wdth` (through the spec's percentages), +//! and `post.italicAngle` from `slnt` (clamped to -90 to 90), and +//! `OS/2.xAvgCharWidth` to the mean of its advances that are not zero. +//! [`instance()`] takes the pinned values as the user values of their +//! normalized coordinates. An axis kept variable leaves its field. +//! //! # What gets dropped (or kept verbatim) //! //! When [`InstanceInput::drop_var_tables`] is true (the recommended @@ -99,6 +109,13 @@ //! `CFF2`) but no variable-font opcodes survive; the charstrings are //! the ones HarfBuzz's instancer writes. //! +//! The outlines then set the metrics HarfBuzz's instancer takes from +//! them (see the `cff2_metrics` submodule): each glyph's left side +//! bearing is the left end of the box of its outline at `coords`, +//! control points included, before the blends round; `head` takes the +//! union of the boxes and `hhea` the extremes they give. A partial +//! instance measures the new default outlines the same way. +//! //! # VVAR-aware vmtx and VORG //! //! Symmetric to the HVAR/hmtx bake. When the source carries `vmtx` + @@ -184,9 +201,11 @@ use alloc::vec::Vec; use sigilbuzz::tables::tag; +use sigilbuzz::tables::VariationAxis; use sigilbuzz::Face; mod axes; +mod cff2_metrics; mod cvar; mod gdef_store; mod glyf; @@ -196,6 +215,7 @@ mod metrics_var; mod partial; mod region; mod store_remap; +mod style; use crate::base::BaseBake; use crate::sfnt; @@ -206,9 +226,10 @@ use gdef_store::{gdef_deltas, prune_gdef_store, GdefBake}; use glyf::{bake_glyf_loca, GlyfLocaBake, GlyphMetrics}; use metrics::{ bake_hmtx, bake_mvar_metrics, bake_vmtx, bake_vorg, hmtx_from_metrics, patch_head_bounds, - patch_line_extremes, MvarBake, VmtxBake, VorgBake, + patch_line_extremes, write_head_box, write_line_extremes, MvarBake, VmtxBake, VorgBake, }; use partial::{layout_variations, partial_instance, pinned_axes}; +use style::AxisLocations; pub(crate) use ivs::{bake_ivs_partial, project_ivs_with, Projection, RegionRemap}; pub(crate) use region::project_region_onto_kept_axes; @@ -306,6 +327,52 @@ impl Default for InstanceInput { } } +/// `axis`' range, widened to take in its default as +/// [`VariationAxis::normalize`] widens it. +fn axis_range(axis: &VariationAxis) -> (f32, f32) { + let default = axis.default_value; + (axis.min_value.min(default), axis.max_value.max(default)) +} + +/// The user value of `axis` at its unrounded normalized coordinate +/// `normalized`: the inverse of [`VariationAxis::normalize`]. +fn user_value(axis: &VariationAxis, normalized: f32) -> f32 { + let (lo, hi) = axis_range(axis); + let default = f64::from(axis.default_value); + if !normalized.is_finite() || !lo.is_finite() || !hi.is_finite() || !default.is_finite() { + return axis.default_value; + } + let v = f64::from(normalized.clamp(-1.0, 1.0)); + let span = if v < 0.0 { + default - f64::from(lo) + } else { + f64::from(hi) - default + }; + (default + v * span) as f32 +} + +/// The location HarfBuzz records for each axis of `face` that `input` +/// pins: the user value of its normalized coordinate. Kept axes have +/// none. +fn pinned_locations(face: &Face<'_>, input: &InstanceInput) -> Vec<([u8; 4], Option)> { + let Ok(Some(fvar)) = face.fvar() else { + return Vec::new(); + }; + fvar.axes() + .iter() + .enumerate() + .map(|(i, axis)| { + let pinned = !matches!(input.axis_pins.get(i), Some(AxisPin::Keep)); + let location = input + .coords + .get(i) + .filter(|_| pinned) + .map(|&c| user_value(axis, c)); + (axis.tag, location) + }) + .collect() +} + /// Result of [`instance`]. #[derive(Debug, Clone)] pub struct InstancedOutput { @@ -343,7 +410,21 @@ pub struct InstancedOutput { /// Closure walking is *not* performed: instancing keeps every glyph in /// the source font; it's not a subset operation. Every gid `0..num_glyphs` /// rides through with its outline / metric baked. +/// +/// The pinned `wght`, `wdth` and `slnt` axes set `OS/2`'s +/// `usWeightClass` and `usWidthClass` and `post`'s `italicAngle` from +/// the user value of their coordinates. pub fn instance(face: &Face<'_>, input: &InstanceInput) -> Result { + instance_at(face, input, &pinned_locations(face, input)) +} + +/// [`instance()`], with the location of each pinned axis in user units +/// for the `OS/2` and `post` fields. +fn instance_at( + face: &Face<'_>, + input: &InstanceInput, + locations: &AxisLocations, +) -> Result { if face.record(tag::CFF1).is_some() && face.record(tag::GLYF).is_none() { // Pure CFF1 source: there is no variable data to bake; just // copy through. We still drop the variable-font directory @@ -379,7 +460,7 @@ pub fn instance(face: &Face<'_>, input: &InstanceInput) -> Result, input: &InstanceInput) -> Result, input: &InstanceInput) -> Result, + mvar_bake: &MvarBake, + avg_char_width: u16, + locations: &AxisLocations, + tables: &mut Vec<([u8; 4], Vec)>, +) { + let os2 = mvar_bake + .os2 + .clone() + .or_else(|| face.table_bytes(*b"OS/2").ok().map(<[u8]>::to_vec)); + if let Some(mut os2) = os2 { + style::patch_os2(&mut os2, Some(avg_char_width), locations); + tables.push((*b"OS/2", os2)); + } + let post = mvar_bake + .post + .clone() + .or_else(|| face.table_bytes(tag::POST).ok().map(<[u8]>::to_vec)); + if let Some(mut post) = post { + style::patch_post(&mut post, locations); + tables.push((tag::POST, post)); + } } /// Bakes the glyphs of a `glyf` font at the post-avar `coords` and @@ -635,6 +750,7 @@ fn push_glyf_tables( Some(m) => hmtx_from_metrics(m), None => bake_hmtx(face, coords, num_glyphs, warnings)?, }; + let avg_char_width = hmtx_out.avg_char_width(); let vmtx = bake_vmtx(face, coords, num_glyphs, baked, warnings); // head: the loca format the bake chose, and the new bounding box. @@ -673,25 +789,39 @@ fn push_glyf_tables( glyf: glyf_loca, vmtx, num_glyphs, + avg_char_width, }) } +/// What [`push_metric_tables`] baked besides the tables it pushed. +struct MetricTablesBake { + /// The vertical metrics bake, naming the tables it left out. + vmtx: VmtxBake, + /// `OS/2.xAvgCharWidth` of the baked advances. + avg_char_width: u16, +} + /// Appends `hmtx` and `hhea` (MVAR-baked when `MVAR` varies it, its /// long metrics count patched), with the advances and side bearings /// `HVAR` gives at `coords`, and the vertical metrics `VVAR` gives (see -/// [`push_vertical_metrics`]). For a partial instance of a font whose -/// outlines give no metrics to bake: a CFF2 font, or a `glyf` font -/// without `gvar`. Returns the vertical bake, whose `left_out` tables -/// the caller leaves out. +/// [`push_vertical_metrics`]). For an instance of a font whose outlines +/// give no metrics to bake: a CFF2 font, or a `glyf` font without +/// `gvar` in a partial instance. +/// +/// A CFF2 font's left side bearings, `head` box, and `hhea` extremes +/// follow from its outlines at `coords` (see [`cff2_metrics`]), and +/// `head` is appended too, unless the core cannot read the table. Its +/// `CFF2` table must have been baked first: the outlines are drawn +/// within the work the bake allowed. fn push_metric_tables( face: &Face<'_>, coords: &[f32], mvar_bake: &MvarBake, warnings: &Warnings, tables: &mut Vec<([u8; 4], Vec)>, -) -> Result { +) -> Result { let num_glyphs = face.maxp()?.num_glyphs; - let hmtx_out = bake_hmtx(face, coords, num_glyphs, warnings)?; + let mut hmtx_out = bake_hmtx(face, coords, num_glyphs, warnings)?; let mut hhea_out = match mvar_bake.hhea.clone() { Some(bytes) => bytes, None => face @@ -699,12 +829,34 @@ fn push_metric_tables( .map_err(SubsetError::from)? .to_vec(), }; + let extents = if face.record(tag::CFF2).is_some() { + cff2_metrics::cff2_extents(face, coords, num_glyphs) + } else { + None + }; + if let Some(extents) = extents { + let outlined = cff2_metrics::apply_extents(&hmtx_out, &extents); + let mut head_out = face + .table_bytes(tag::HEAD) + .map_err(SubsetError::from)? + .to_vec(); + if let Some(bounds) = outlined.head_box { + write_head_box(&mut head_out, bounds); + } + tables.push((tag::HEAD, head_out)); + write_line_extremes(&mut hhea_out, outlined.max_advance, outlined.extremes); + hmtx_out = outlined.hmtx; + } util::write_hhea_metrics_count(&mut hhea_out, hmtx_out.number_of_h_metrics)?; + let avg_char_width = hmtx_out.avg_char_width(); tables.push((tag::HHEA, hhea_out)); tables.push((tag::HMTX, hmtx_out.bytes)); let vmtx = bake_vmtx(face, coords, num_glyphs, None, warnings); push_vertical_metrics(face, &vmtx, mvar_bake, None, tables)?; - Ok(vmtx) + Ok(MetricTablesBake { + vmtx, + avg_char_width, + }) } /// Appends the rebuilt `vmtx` with `vhea` (MVAR-baked when `MVAR` @@ -746,61 +898,37 @@ fn push_vertical_metrics( /// CFF2 path: rebuild the CFF2 table with `blend` resolved at the outline /// `coords` (the MVAR, layout and BASE bakes take the `plan` ones), /// then assemble a fresh SFNT directory mirroring the glyf path's -/// hmtx/vmtx/MVAR bakes and GDEF.IVS prune. +/// hmtx/vmtx/MVAR bakes and GDEF.IVS prune. The side bearings, `head` +/// box and `hhea` extremes follow from the outlines at `coords` (see +/// [`push_metric_tables`]). fn cff2_bake( face: &Face<'_>, input: &InstanceInput, coords: &[f32], plan: &[f32], + locations: &AxisLocations, ) -> Result { - let maxp = face.maxp()?; - let num_glyphs = maxp.num_glyphs; + let num_glyphs = face.maxp()?.num_glyphs; let warnings = Warnings::default(); let cff2_bytes = face.table_bytes(tag::CFF2).map_err(SubsetError::from)?; let new_cff2 = crate::cff2::bake_at_coords(cff2_bytes, coords)?; + let mut tables: Vec<([u8; 4], Vec)> = alloc::vec![(tag::CFF2, new_cff2)]; - let hmtx_out = bake_hmtx(face, coords, num_glyphs, &warnings)?; - let vmtx_bake_result = bake_vmtx(face, coords, num_glyphs, None, &warnings); - let vorg_bake = bake_vorg(face, coords, num_glyphs, &warnings); let mvar_bake = bake_mvar_metrics(face, plan)?; - - let head_out = face - .table_bytes(tag::HEAD) - .map_err(SubsetError::from)? - .to_vec(); - - let mut hhea_out = match mvar_bake.hhea.clone() { - Some(bytes) => bytes, - None => face - .table_bytes(tag::HHEA) - .map_err(SubsetError::from)? - .to_vec(), - }; - util::write_hhea_metrics_count(&mut hhea_out, hmtx_out.number_of_h_metrics)?; - - let maxp_out = face - .table_bytes(tag::MAXP) - .map_err(SubsetError::from)? - .to_vec(); - - let mut tables: Vec<([u8; 4], Vec)> = alloc::vec![ - (tag::HEAD, head_out), - (tag::HHEA, hhea_out), - (tag::MAXP, maxp_out), - (tag::HMTX, hmtx_out.bytes), - (tag::CFF2, new_cff2), - ]; - push_vertical_metrics(face, &vmtx_bake_result, &mvar_bake, None, &mut tables)?; + let metrics = push_metric_tables(face, coords, &mvar_bake, &warnings, &mut tables)?; + let vmtx_bake_result = metrics.vmtx; + let vorg_bake = bake_vorg(face, coords, num_glyphs, &warnings); if let VorgBake::Rebuilt(b) = &vorg_bake { tables.push((tag::VORG, b.clone())); } - if let Some(os2_bytes) = mvar_bake.os2.clone() { - tables.push((*b"OS/2", os2_bytes)); - } - if let Some(post_bytes) = mvar_bake.post.clone() { - tables.push((tag::POST, post_bytes)); - } + push_style_tables( + face, + &mvar_bake, + metrics.avg_char_width, + locations, + &mut tables, + ); let gpos_baked = if input.drop_var_tables { bake_gpos_var(face, plan, &warnings)? diff --git a/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs b/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs new file mode 100644 index 00000000..02aca097 --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs @@ -0,0 +1,221 @@ +//! The glyph metrics of a CFF2 instance that follow from its outlines: +//! the left side bearings in `hmtx`, the `head` bounding box, and the +//! `hhea` extremes, as HarfBuzz's instancer works them out. +//! +//! HarfBuzz measures each glyph of the source at the instance's outline +//! coordinates (`update_instance_metrics_map_from_cff2`): the box of +//! every point the outline passes through or pulls toward, curve +//! control points included, before its blends round. The box's ends +//! round halves away from zero. A glyph with a box takes its left end +//! as its left side bearing; one without keeps the source's. The `head` +//! box is the union of the glyphs' boxes, and the `hhea` extremes count +//! every glyph, one without a box as zero wide. +//! +//! The glyphs are drawn through the core's CFF2 outlines right after +//! the bake has run every charstring within its budget, so drawing them +//! again costs the same. + +use alloc::vec::Vec; + +use sigilbuzz::tables::OutlineSink; +use sigilbuzz::Face; + +use super::glyf::clamp_i16; +use super::metrics::HmtxBake; +use crate::hmtx::emit_long_metrics; + +/// A glyph's box as HarfBuzz's `hb_glyph_extents_t` holds it: the left +/// end and width, the top end and (negative) height, each end rounded. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct Extents { + pub(super) x_bearing: i32, + pub(super) width: i32, + pub(super) y_bearing: i32, + pub(super) height: i32, +} + +impl Extents { + /// Whether the box says anything: HarfBuzz keeps the source's side + /// bearing for a glyph whose four values are all zero. + fn has_bounds(&self) -> bool { + *self != Self::default() + } +} + +/// Collects the box of an outline the way HarfBuzz's CFF2 extents do: +/// a contour's start point counts once a segment leaves it, then every +/// segment's control and end points. +#[derive(Debug, Clone, Copy)] +struct ControlBox { + current: (f32, f32), + open: bool, + min: (f32, f32), + max: (f32, f32), +} + +impl ControlBox { + fn new() -> Self { + Self { + current: (0.0, 0.0), + open: false, + min: (f32::INFINITY, f32::INFINITY), + max: (f32::NEG_INFINITY, f32::NEG_INFINITY), + } + } + + fn add(&mut self, x: f32, y: f32) { + self.min = (self.min.0.min(x), self.min.1.min(y)); + self.max = (self.max.0.max(x), self.max.1.max(y)); + } + + /// Counts the contour's start point before its first segment. + fn segment(&mut self) { + if !self.open { + self.open = true; + let (x, y) = self.current; + self.add(x, y); + } + } + + fn extents(&self) -> Extents { + // `roundf`: halves away from zero. Float-to-int casts saturate. + let (x_bearing, width) = if self.min.0 >= self.max.0 { + (0, 0) + } else { + let left = self.min.0.round() as i32; + (left, (self.max.0.round() as i32).saturating_sub(left)) + }; + let (y_bearing, height) = if self.min.1 >= self.max.1 { + (0, 0) + } else { + let top = self.max.1.round() as i32; + (top, (self.min.1.round() as i32).saturating_sub(top)) + }; + Extents { + x_bearing, + width, + y_bearing, + height, + } + } +} + +impl OutlineSink for ControlBox { + fn move_to(&mut self, x: f32, y: f32) { + self.open = false; + self.current = (x, y); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.segment(); + self.current = (x, y); + self.add(x, y); + } + + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.segment(); + self.add(cx, cy); + self.current = (x, y); + self.add(x, y); + } + + fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + self.segment(); + self.add(c1x, c1y); + self.add(c2x, c2y); + self.current = (x, y); + self.add(x, y); + } + + fn close(&mut self) {} +} + +/// The extents of every glyph of `face`'s `CFF2` at the outline +/// `coords`, in glyph order: `None` for a glyph the core cannot draw, +/// which HarfBuzz leaves out too. `None` for them all when the core +/// cannot read the table. +pub(super) fn cff2_extents( + face: &Face<'_>, + coords: &[f32], + num_glyphs: u16, +) -> Option>> { + let cff2 = face.cff2().ok()?; + let mut out = Vec::with_capacity(usize::from(num_glyphs)); + for gid in 0..num_glyphs { + let mut sink = ControlBox::new(); + let drawn = matches!(cff2.outline(gid, coords, &mut sink), Ok(true)); + out.push(drawn.then(|| sink.extents())); + } + Some(out) +} + +/// What the outlines of a CFF2 instance set besides its advances. +pub(super) struct Cff2Metrics { + /// `hmtx` with each glyph's left side bearing from its box. + pub(super) hmtx: HmtxBake, + /// The union of the boxes, `(xMin, yMin, xMax, yMax)`, or `None` + /// when no glyph has one and `head` keeps its box. + pub(super) head_box: Option<[i16; 4]>, + /// `hhea`'s largest advance. + pub(super) max_advance: u16, + /// `hhea`'s smallest left and right side bearings and largest + /// extent, or `None` for a font without glyphs. + pub(super) extremes: Option<[i16; 3]>, +} + +/// Sets the left side bearings of `hmtx` (advances baked, bearings the +/// source's) from `extents`, and works out the `head` box and the +/// `hhea` extremes, as HarfBuzz's instancer does for a CFF2 font. A +/// glyph without extents keeps its bearing and counts in neither. +pub(super) fn apply_extents(hmtx: &HmtxBake, extents: &[Option]) -> Cff2Metrics { + let mut lsbs = hmtx.lsbs.clone(); + let mut head: Option<[i32; 4]> = None; + let mut max_advance: u16 = 0; + let mut extremes: Option<[i32; 3]> = None; + for (i, &advance) in hmtx.advances.iter().enumerate() { + max_advance = max_advance.max(advance); + let Some(e) = extents.get(i).copied().flatten() else { + continue; + }; + let lsb = lsbs.get_mut(i); + let lsb = match lsb { + Some(lsb) => { + if e.has_bounds() { + *lsb = clamp_i16(e.x_bearing); + } + i32::from(*lsb) + } + None => 0, + }; + if e.has_bounds() { + let right = e.x_bearing.saturating_add(e.width); + let bottom = e.y_bearing.saturating_add(e.height); + let b = head.get_or_insert([e.x_bearing, bottom, right, e.y_bearing]); + *b = [ + b[0].min(e.x_bearing), + b[1].min(bottom), + b[2].max(right), + b[3].max(e.y_bearing), + ]; + } + let rsb = i32::from(advance) - lsb - e.width; + let extent = lsb + e.width; + let x = extremes.get_or_insert([lsb, rsb, extent]); + *x = [x[0].min(lsb), x[1].min(rsb), x[2].max(extent)]; + } + let (bytes, number_of_h_metrics) = emit_long_metrics(&hmtx.advances, &lsbs); + Cff2Metrics { + hmtx: HmtxBake { + bytes, + number_of_h_metrics, + advances: hmtx.advances.clone(), + lsbs, + }, + head_box: head.map(|b| b.map(clamp_i16)), + max_advance, + extremes: extremes.map(|x| x.map(clamp_i16)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs b/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs new file mode 100644 index 00000000..aba10e17 --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs @@ -0,0 +1,160 @@ +//! Tests for the CFF2 instance metrics. + +use super::*; + +/// The extents of an outline drawn into a fresh box. +fn extents_of(draw: impl FnOnce(&mut ControlBox)) -> Extents { + let mut sink = ControlBox::new(); + draw(&mut sink); + sink.extents() +} + +#[test] +fn the_box_takes_control_points_and_rounds_its_ends() { + let e = extents_of(|s| { + s.move_to(10.4, 0.0); + s.curve_to(-2.5, 50.0, 30.0, 120.5, 40.5, 20.0); + s.close(); + }); + // x from -2.5 (a control point) to 40.5, y from 0 to 120.5: each + // end rounds halves away from zero. + assert_eq!( + e, + Extents { + x_bearing: -3, + width: 41 + 3, + y_bearing: 121, + height: -121, + } + ); +} + +#[test] +fn a_move_without_a_segment_adds_nothing() { + let e = extents_of(|s| { + s.move_to(500.0, 500.0); + s.move_to(0.0, 0.0); + s.line_to(10.0, 20.0); + s.move_to(-900.0, -900.0); + s.close(); + }); + assert_eq!( + e, + Extents { + x_bearing: 0, + width: 10, + y_bearing: 20, + height: -20, + } + ); + assert!(!extents_of(|s| s.move_to(5.0, 5.0)).has_bounds()); +} + +#[test] +fn a_flat_side_has_no_extent_on_that_axis() { + // A vertical line: no width, so HarfBuzz gives x a zero bearing + // and width, while y keeps its extent. + let e = extents_of(|s| { + s.move_to(30.0, 0.0); + s.line_to(30.0, 700.0); + }); + assert_eq!( + e, + Extents { + x_bearing: 0, + width: 0, + y_bearing: 700, + height: -700, + } + ); + assert!(e.has_bounds()); +} + +fn hmtx(advances: &[u16], lsbs: &[i16]) -> HmtxBake { + let (bytes, number_of_h_metrics) = emit_long_metrics(advances, lsbs); + HmtxBake { + bytes, + number_of_h_metrics, + advances: advances.to_vec(), + lsbs: lsbs.to_vec(), + } +} + +#[test] +fn boxes_set_bearings_head_and_extremes() { + // Glyph 0 has no outline and keeps its bearing of 7; glyphs 1 and + // 2 take theirs from their boxes. + let source = hmtx(&[500, 600, 700], &[7, 50, 60]); + let extents = [ + Some(Extents::default()), + Some(Extents { + x_bearing: 33, + width: 500, + y_bearing: 700, + height: -710, + }), + Some(Extents { + x_bearing: -20, + width: 760, + y_bearing: 650, + height: -650, + }), + ]; + let m = apply_extents(&source, &extents); + assert_eq!(m.hmtx.lsbs, [7, 33, -20]); + assert_eq!(m.hmtx.advances, [500, 600, 700]); + assert_eq!(m.head_box, Some([-20, -10, 740, 700])); + assert_eq!(m.max_advance, 700); + // The empty glyph counts as zero wide: bearings 7, 33, -20; + // trailing bearings 493, 67, -40; extents 7, 533, 740. + assert_eq!(m.extremes, Some([-20, -40, 740])); + let (bytes, _) = emit_long_metrics(&[500, 600, 700], &[7, 33, -20]); + assert_eq!(m.hmtx.bytes, bytes); +} + +#[test] +fn without_boxes_the_bearings_and_head_stay() { + let source = hmtx(&[500, 600], &[7, 8]); + let m = apply_extents(&source, &[Some(Extents::default()); 2]); + assert_eq!(m.hmtx.lsbs, [7, 8]); + assert_eq!(m.head_box, None); + assert_eq!(m.extremes, Some([7, 493, 8])); + let none = apply_extents(&hmtx(&[], &[]), &[]); + assert_eq!(none.extremes, None); + assert_eq!(none.max_advance, 0); +} + +#[test] +fn out_of_range_values_clamp() { + let source = hmtx(&[100], &[0]); + let extents = [Some(Extents { + x_bearing: -40_000, + width: 80_000, + y_bearing: 40_000, + height: -80_000, + })]; + let m = apply_extents(&source, &extents); + assert_eq!(m.hmtx.lsbs, [i16::MIN]); + assert_eq!(m.head_box, Some([i16::MIN, i16::MIN, i16::MAX, i16::MAX])); +} + +#[test] +fn a_glyph_that_cannot_be_drawn_keeps_its_bearing_and_counts_nowhere() { + let source = hmtx(&[500, 900], &[7, 8]); + let extents = [ + Some(Extents { + x_bearing: 10, + width: 400, + y_bearing: 700, + height: -700, + }), + None, + ]; + let m = apply_extents(&source, &extents); + assert_eq!(m.hmtx.lsbs, [10, 8]); + assert_eq!(m.head_box, Some([10, 0, 410, 700])); + // The largest advance counts every glyph; the bearings only the + // first. + assert_eq!(m.max_advance, 900); + assert_eq!(m.extremes, Some([10, 90, 410])); +} diff --git a/crates/sigilbuzz-subset/src/instance/metrics.rs b/crates/sigilbuzz-subset/src/instance/metrics.rs index 62b729c1..ff24499c 100644 --- a/crates/sigilbuzz-subset/src/instance/metrics.rs +++ b/crates/sigilbuzz-subset/src/instance/metrics.rs @@ -25,6 +25,30 @@ use crate::SubsetError; pub(super) struct HmtxBake { pub(super) bytes: Vec, pub(super) number_of_h_metrics: u16, + /// Every glyph's advance, in glyph order. + pub(super) advances: Vec, + /// Every glyph's left side bearing, in glyph order. + pub(super) lsbs: Vec, +} + +impl HmtxBake { + /// The `OS/2` `xAvgCharWidth` of these metrics, as HarfBuzz's + /// instancer works it out: the mean of the advances that are not + /// zero, rounded, or 0 when every advance is. + pub(super) fn avg_char_width(&self) -> u16 { + let (total, count) = self + .advances + .iter() + .filter(|&&a| a != 0) + .fold((0u64, 0u64), |(t, c), &a| (t + u64::from(a), c + 1)); + if count == 0 { + return 0; + } + // `roundf` of the quotient in single precision, halves up for + // these positive values. At most 65,535 advances of at most + // 65,535 each, so the sum fits. + ((total as f64 / count as f64) as f32).round() as u16 + } } /// Builds `hmtx` from the metrics of the baked glyphs. @@ -35,6 +59,8 @@ pub(super) fn hmtx_from_metrics(metrics: &[GlyphMetrics]) -> HmtxBake { HmtxBake { bytes, number_of_h_metrics, + advances, + lsbs, } } @@ -59,7 +85,13 @@ pub(super) fn patch_head_bounds(head: &mut [u8], metrics: &[GlyphMetrics]) { u[3].max(b[3]), ] }); - for (i, v) in union.iter().enumerate() { + write_head_box(head, union); +} + +/// Writes `(xMin, yMin, xMax, yMax)` into the `head` bytes (bytes 36 +/// to 43). +pub(super) fn write_head_box(head: &mut [u8], bounds: [i16; 4]) { + for (i, v) in bounds.iter().enumerate() { write_i16(head, 36 + 2 * i, *v); } } @@ -97,6 +129,18 @@ pub(super) fn patch_line_extremes(table: &mut [u8], metrics: &[GlyphMetrics], ve } } +/// Writes the largest advance (byte 10) and, when given, the smallest +/// leading and trailing bearings and the largest extent (bytes 12, 14 +/// and 16) into `hhea` or `vhea`. +pub(super) fn write_line_extremes(table: &mut [u8], max_advance: u16, extremes: Option<[i16; 3]>) { + write_u16(table, 10, max_advance); + if let Some([lead, trail, extent]) = extremes { + write_i16(table, 12, lead); + write_i16(table, 14, trail); + write_i16(table, 16, extent); + } +} + /// Writes a big-endian `i16` at `off` when the table is long enough. fn write_i16(buf: &mut [u8], off: usize, v: i16) { if let Some(field) = buf.get_mut(off..).and_then(<[u8]>::first_chunk_mut::<2>) { @@ -190,6 +234,8 @@ pub(super) fn bake_hmtx( Ok(HmtxBake { bytes, number_of_h_metrics, + advances, + lsbs, }) } diff --git a/crates/sigilbuzz-subset/src/instance/partial.rs b/crates/sigilbuzz-subset/src/instance/partial.rs index 15046056..b11972cf 100644 --- a/crates/sigilbuzz-subset/src/instance/partial.rs +++ b/crates/sigilbuzz-subset/src/instance/partial.rs @@ -13,9 +13,10 @@ use super::gdef_store::GdefBake; use super::metrics::{bake_vorg, mvar_defaults, VorgBake}; use super::metrics_var::{bake_hvar_partial_with, bake_mvar_partial, bake_vvar_partial_with}; use super::store_remap::{bake_gdef_store_partial, remap_gpos_variation_indices}; +use super::style::AxisLocations; use super::{ - plan_coords, post_avar, push_base, push_glyf_tables, push_metric_tables, AxisPin, - InstanceInput, InstancedOutput, + plan_coords, post_avar, push_base, push_glyf_tables, push_metric_tables, push_style_tables, + AxisPin, InstanceInput, InstancedOutput, }; use crate::base::BaseBake; use crate::sfnt; @@ -70,9 +71,13 @@ use crate::SubsetError; /// `input.drop_var_tables` is not read here. The output keeps live /// axes, so the trimmed variation tables always stay: they drive /// those axes. +/// +/// `locations` holds the user value of each pinned axis, which sets the +/// `OS/2` and `post` fields that follow from `wght`, `wdth` and `slnt`. pub(super) fn partial_instance( face: &Face<'_>, input: &InstanceInput, + locations: &AxisLocations, ) -> Result { let pins = &input.axis_pins; let coords = &input.coords; @@ -134,6 +139,15 @@ pub(super) fn partial_instance( .map(|(&c, pin)| if *pin == AxisPin::Keep { 0.0 } else { c }) .collect(); let mvar_bake = mvar_defaults(face, &plan, pins)?; + // CFF2 VarStore + blend-operator rewrite (optional). VarStore + // region trim via `bake_ivs_partial`; charstrings re-emit blend + // ops with the surviving regions and pre-scaled deltas. It runs + // before the metrics, which draw the outlines within the work it + // allowed. + if let Ok(cff2_bytes) = face.table_bytes(tag::CFF2) { + let new_cff2 = crate::cff2::bake_cff2_partial(cff2_bytes, &plan, pins)?; + tables.push((tag::CFF2, new_cff2)); + } let glyf_bake = if fold_glyphs { Some(push_glyf_tables( face, @@ -160,12 +174,12 @@ pub(super) fn partial_instance( if let VorgBake::Rebuilt(b) = &vorg_bake { tables.push((tag::VORG, b.clone())); } - if let Some(os2_bytes) = mvar_bake.os2.clone() { - tables.push((*b"OS/2", os2_bytes)); - } - if let Some(post_bytes) = mvar_bake.post.clone() { - tables.push((tag::POST, post_bytes)); - } + let avg_char_width = match (&glyf_bake, &metrics_bake) { + (Some(b), _) => b.avg_char_width, + (None, Some(m)) => m.avg_char_width, + (None, None) => 0, + }; + push_style_tables(face, &mvar_bake, avg_char_width, locations, &mut tables); // HVAR / VVAR / MVAR rewrites (optional). A malformed table is // dropped (its metrics stop varying) and reported. @@ -222,14 +236,6 @@ pub(super) fn partial_instance( tables.push((tag::GPOS, b)); } - // CFF2 VarStore + blend-operator rewrite (optional). VarStore - // region trim via `bake_ivs_partial`; charstrings re-emit blend - // ops with the surviving regions and pre-scaled deltas. - if let Ok(cff2_bytes) = face.table_bytes(tag::CFF2) { - let new_cff2 = crate::cff2::bake_cff2_partial(cff2_bytes, &plan, pins)?; - tables.push((tag::CFF2, new_cff2)); - } - // gvar tuple-projection rewrite (optional). Pin-axis support // scalars fold into per-point deltas; Pin-axis dimensions drop // from every tuple region; tuples whose Pin-axis support is zero @@ -293,7 +299,7 @@ pub(super) fn partial_instance( let left_out = glyf_bake .as_ref() .map(|b| &b.vmtx) - .or(metrics_bake.as_ref()) + .or(metrics_bake.as_ref().map(|m| &m.vmtx)) .is_some_and(|v| v.left_out.contains(&rec.tag)); if left_out || (rec.tag == tag::VORG && vorg_bake == VorgBake::Dropped) { continue; diff --git a/crates/sigilbuzz-subset/src/instance/style.rs b/crates/sigilbuzz-subset/src/instance/style.rs new file mode 100644 index 00000000..0f3ed41c --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/style.rs @@ -0,0 +1,169 @@ +//! The `OS/2` and `post` fields an instance sets from where it puts its +//! axes and from its advances, as HarfBuzz's instancer sets them: +//! +//! - `usWeightClass` from the `wght` location, rounded and clamped to +//! 1 to 1000; +//! - `usWidthClass` from the `wdth` location, through the spec's +//! percentage table; +//! - `post.italicAngle` from the `slnt` location, clamped to -90 to 90; +//! - `xAvgCharWidth`, the mean of the instance's advances that are not +//! zero. +//! +//! An axis' location is what HarfBuzz records for it: the value a +//! pinned axis takes, in the axis' own units. An axis kept variable has +//! none, and its fields stay. + +use super::glyf::clamp_i16; + +/// Each axis' tag, and its location in user units when the instance +/// pins it. +pub(super) type AxisLocations = [([u8; 4], Option)]; + +/// The location of the first axis tagged `tag` that has one. +fn location(axes: &AxisLocations, tag: [u8; 4]) -> Option { + axes.iter() + .filter(|(t, _)| *t == tag) + .find_map(|(_, v)| *v) + .filter(|v| v.is_finite()) +} + +/// HarfBuzz's `map_wdth_to_widthclass`: the `usWidthClass`, before +/// rounding, of a `wdth` location, interpolating between the spec's +/// classes (50%, 62.5%, 75%, 87.5%, 100%, 112.5%, 125%, 150% and 200%). +fn width_class(width: f32) -> f32 { + if width < 50.0 { + return 1.0; + } + if width > 200.0 { + return 9.0; + } + let ratio = (width - 50.0) / 12.5; + let mut a = ratio.floor() as i32; + let mut b = ratio.ceil() as i32; + if b <= 6 { + if a == b { + return a as f32 + 1.0; + } + } else if b == 7 { + // No class for 137.5%. + a = 6; + b = 8; + } else if b == 8 { + if a == b { + return 8.0; + } + a = 6; + } else { + if a == b && a == 12 { + return 9.0; + } + b = 12; + a = 8; + } + let va = 50.0 + a as f32 * 12.5; + let vb = 50.0 + b as f32 * 12.5; + let mut class = a as f32 + (width - va) / (vb - va); + if a <= 6 { + class += 1.0; + } + class +} + +/// Writes a big-endian `u16` at `off` when `table` is long enough. +fn write_u16(table: &mut [u8], off: usize, v: u16) { + if let Some(field) = table.get_mut(off..).and_then(<[u8]>::first_chunk_mut::<2>) { + *field = v.to_be_bytes(); + } +} + +/// Sets the `OS/2` fields an instance sets: `xAvgCharWidth` (byte 2) +/// to `avg_char_width` when given, and `usWeightClass` (4) and +/// `usWidthClass` (6) from the `wght` and `wdth` locations in `axes`. +/// A field past the end of a short table is left out. +pub(super) fn patch_os2(os2: &mut [u8], avg_char_width: Option, axes: &AxisLocations) { + if let Some(avg) = avg_char_width { + let avg = clamp_i16(i32::from(avg)); + write_u16(os2, 2, avg as u16); + } + if let Some(weight) = location(axes, *b"wght") { + // `roundf`, halves away from zero; the value is positive. + write_u16(os2, 4, weight.clamp(1.0, 1000.0).round() as u16); + } + if let Some(width) = location(axes, *b"wdth") { + write_u16(os2, 6, width_class(width).round() as u16); + } +} + +/// Sets `post.italicAngle` (bytes 4 to 7, 16.16) from the `slnt` +/// location in `axes`, when it has one. +pub(super) fn patch_post(post: &mut [u8], axes: &AxisLocations) { + let Some(slant) = location(axes, *b"slnt") else { + return; + }; + // HarfBuzz's `set_float`: `roundf (f * 65536)`, halves away from + // zero. + let angle = (slant.clamp(-90.0, 90.0) * 65536.0).round() as i32; + if let Some(field) = post.get_mut(4..).and_then(<[u8]>::first_chunk_mut::<4>) { + *field = angle.to_be_bytes(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn width_classes_match_the_spec_table() { + let table = [ + (50.0, 1.0), + (62.5, 2.0), + (75.0, 3.0), + (87.5, 4.0), + (100.0, 5.0), + (112.5, 6.0), + (125.0, 7.0), + (150.0, 8.0), + (200.0, 9.0), + ]; + for (width, class) in table { + assert_eq!(width_class(width), class, "{width}"); + } + // Between classes, and past either end. + assert_eq!(width_class(93.75), 4.5); + assert_eq!(width_class(137.5), 7.5); + assert_eq!(width_class(175.0), 8.5); + assert_eq!(width_class(25.0), 1.0); + assert_eq!(width_class(300.0), 9.0); + } + + #[test] + fn os2_takes_weight_width_and_average() { + let mut os2 = [0u8; 8]; + let axes = [(*b"wght", Some(651.5)), (*b"wdth", Some(93.75))]; + patch_os2(&mut os2, Some(577), &axes); + assert_eq!(os2, [0, 0, 0x02, 0x41, 0x02, 0x8C, 0, 5]); + // Out of range, and a short table. + let mut short = [0u8; 5]; + patch_os2(&mut short, Some(40_000), &[(*b"wght", Some(5000.0))]); + assert_eq!(short, [0, 0, 0x7F, 0xFF, 0]); + } + + #[test] + fn kept_axes_leave_their_fields() { + let mut os2 = [7u8; 8]; + patch_os2(&mut os2, None, &[(*b"wght", None), (*b"wdth", None)]); + assert_eq!(os2, [7u8; 8]); + let mut post = [7u8; 8]; + patch_post(&mut post, &[(*b"slnt", None)]); + assert_eq!(post, [7u8; 8]); + } + + #[test] + fn post_takes_the_slant() { + let mut post = [0u8; 8]; + patch_post(&mut post, &[(*b"slnt", Some(-7.5))]); + assert_eq!(&post[4..], (-491_520i32).to_be_bytes()); + patch_post(&mut post, &[(*b"slnt", Some(-120.0))]); + assert_eq!(&post[4..], (-90i32 * 65536).to_be_bytes()); + } +} diff --git a/crates/sigilbuzz-subset/src/instance/tests.rs b/crates/sigilbuzz-subset/src/instance/tests.rs index e903972d..cc96d714 100644 --- a/crates/sigilbuzz-subset/src/instance/tests.rs +++ b/crates/sigilbuzz-subset/src/instance/tests.rs @@ -5,6 +5,7 @@ use super::*; mod glyph_bake; mod gpos_bake; +mod user_and_style; const RUBIK: &[u8] = include_bytes!("../../../../tests/fixtures/rubik_vf.ttf"); const SOURCE_SANS: &[u8] = include_bytes!("../../../../tests/fonts/SourceSans3VF-Latin-Subset.otf"); diff --git a/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs new file mode 100644 index 00000000..b1c36557 --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs @@ -0,0 +1,154 @@ +//! Tests for the `OS/2` and `post` fields an instance sets from its +//! axis locations, and the metrics a CFF2 instance takes from its +//! outlines. + +use super::*; +use sigilbuzz::tables::OutlineSink; + +/// An axis like most `wght` axes: 100 to 900, default 400. +fn weight_axis() -> VariationAxis { + VariationAxis { + tag: *b"wght", + min_value: 100.0, + default_value: 400.0, + max_value: 900.0, + flags: 0, + axis_name_id: 256, + } +} + +/// A full instance at normalized `coords`. +fn normalized(coords: &[f32]) -> InstanceInput { + InstanceInput { + coords: coords.to_vec(), + drop_var_tables: true, + axis_pins: Vec::new(), + } +} + +/// Reads a big-endian `u16` at `off` of table `tag`. +fn u16_at(face: &Face<'_>, tag: [u8; 4], off: usize) -> u16 { + let t = face.table_bytes(tag).unwrap(); + u16::from_be_bytes([t[off], t[off + 1]]) +} + +fn i16_at(face: &Face<'_>, tag: [u8; 4], off: usize) -> i16 { + u16_at(face, tag, off) as i16 +} + +#[test] +fn user_values_invert_normalize() { + let axis = weight_axis(); + for v in [100.0, 250.0, 400.0, 650.0, 900.0] { + let back = user_value(&axis, axis.normalize(v)); + assert!((back - v).abs() < 1e-3, "{v} -> {back}"); + } + assert_eq!(user_value(&axis, f32::NAN), 400.0); + assert_eq!(user_value(&axis, 7.0), 900.0); +} + +#[test] +fn instance_sets_weight_class_and_average_width() { + let face = rubik_face(); + let axis = face.fvar().unwrap().unwrap().axes()[0]; + let out = instance(&face, &normalized(&[axis.normalize(651.0)])).unwrap(); + let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); + assert_eq!(u16_at(&baked, *b"OS/2", 4), 651); + let hmtx = baked.hmtx().unwrap(); + let advances: Vec = (0..baked.maxp().unwrap().num_glyphs) + .map(|g| u32::from(hmtx.advance(g).unwrap_or(0))) + .filter(|&a| a != 0) + .collect(); + let mean = advances.iter().sum::() as f64 / advances.len() as f64; + assert_eq!(i16_at(&baked, *b"OS/2", 2), mean.round() as i16); + // The source's weight class differs, so the field moved. + assert_eq!(u16_at(&face, *b"OS/2", 4), 300); +} + +/// Collects every point an outline draws to or pulls toward. +#[derive(Default)] +struct Points(Vec<(f32, f32)>); + +impl OutlineSink for Points { + fn move_to(&mut self, x: f32, y: f32) { + self.0.push((x, y)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.push((x, y)); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.0.extend([(cx, cy), (x, y)]); + } + fn curve_to(&mut self, ax: f32, ay: f32, bx: f32, by: f32, x: f32, y: f32) { + self.0.extend([(ax, ay), (bx, by), (x, y)]); + } + fn close(&mut self) {} +} + +#[test] +fn a_cff2_instance_writes_whole_numbers() { + // Every blend rounds, so every point of the static outlines falls + // on a whole unit, as in HarfBuzz's instance. + let face = Face::parse_bytes(SOURCE_SANS, 0).unwrap(); + let axis = face.fvar().unwrap().unwrap().axes()[0]; + let out = instance(&face, &normalized(&[axis.normalize(613.0)])).unwrap(); + let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); + let cff2 = baked.cff2().unwrap(); + for gid in 0..baked.maxp().unwrap().num_glyphs { + let mut points = Points::default(); + cff2.outline(gid, &[], &mut points).unwrap(); + for (x, y) in points.0 { + assert!( + x.fract() == 0.0 && y.fract() == 0.0, + "gid {gid}: ({x}, {y})" + ); + } + } +} + +#[test] +fn a_cff2_instance_takes_its_metrics_from_its_outlines() { + // wght 900 is the axis' end, 1.0 before and after avar. + let face = Face::parse_bytes(SOURCE_SANS, 0).unwrap(); + let out = instance(&face, &normalized(&[1.0])).unwrap(); + let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); + let n = face.maxp().unwrap().num_glyphs; + let extents = cff2_metrics::cff2_extents(&face, &[1.0], n).unwrap(); + let extents: Vec<_> = extents.into_iter().map(Option::unwrap).collect(); + let source_hmtx = face.hmtx().unwrap(); + let hmtx = baked.hmtx().unwrap(); + let mut boxed = 0; + let mut union: Option<[i32; 4]> = None; + for (gid, e) in (0..n).zip(&extents) { + let lsb = i32::from(hmtx.lsb(gid).unwrap()); + if *e == cff2_metrics::Extents::default() { + assert_eq!(lsb, i32::from(source_hmtx.lsb(gid).unwrap()), "gid {gid}"); + continue; + } + boxed += 1; + assert_eq!(lsb, e.x_bearing, "gid {gid}"); + let b = [ + e.x_bearing, + e.y_bearing + e.height, + e.x_bearing + e.width, + e.y_bearing, + ]; + union = Some(match union { + None => b, + Some(u) => [ + u[0].min(b[0]), + u[1].min(b[1]), + u[2].max(b[2]), + u[3].max(b[3]), + ], + }); + } + assert!(boxed > 100); + let union = union.unwrap(); + let head: Vec = (0..4) + .map(|i| i32::from(i16_at(&baked, *b"head", 36 + 2 * i))) + .collect(); + assert_eq!(head, union); + // The weight class follows the pinned value. + assert_eq!(u16_at(&baked, *b"OS/2", 4), 900); +} diff --git a/crates/sigilbuzz-subset/src/lib.rs b/crates/sigilbuzz-subset/src/lib.rs index c12f0da0..c1b63c91 100644 --- a/crates/sigilbuzz-subset/src/lib.rs +++ b/crates/sigilbuzz-subset/src/lib.rs @@ -69,6 +69,9 @@ //! (see [`InstancedOutput::warnings`] for what each one leaves), except //! for CFF2 charstrings, which fail the instance. Real fonts use at //! most about 12.5% of any budget, so none of this applies to them. +//! A CFF2 instance measures its glyphs' outlines for their metrics +//! after the charstring bake, which has run the same subroutines +//! within its budget. //! //! [`subset`] runs every kept glyph of a `CFF ` or `CFF2` table through //! its subroutine calls (see "For CFF and CFF2 fonts" below), charging From 9580c2b2a04a97e0d41462d65d423103734e87f0 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:43:06 -0800 Subject: [PATCH 4/9] Add instance_user, which takes axis values in user units `instance` takes normalized coordinates, so callers had to normalize each axis with `VariationAxis::normalize` themselves. `instance_user` takes each axis' setting by tag in the units `fvar` gives, as HarfBuzz's instancer takes them: - `AxisLimit::Pin(v)` pins the axis at `v`, clamped to its range; `AxisLimit::Default` pins it at its default; `AxisLimit::Keep` keeps it variable. - `AxisLimit::Range { min, default, max }` follows `hb_subset_input_set_axis_range`: a range of one value pins, the axis' whole range with its own default keeps the axis (and records the default for `usWeightClass` and the like, as HarfBuzz does), and any narrower range or moved default fails, since the instancer cannot narrow an axis. - Axes the input does not name are pinned at their defaults, or kept when `keep_unnamed_axes` is set, as HarfBuzz and fontTools keep them. Each pinned value is normalized as HarfBuzz normalizes it, and the instance derives both of HarfBuzz's coordinate sets (outline and plan) from it, as `instance` does; the output matches `instance` at the same normalized coordinates byte for byte. `instance` keeps working unchanged. Part of #290. --- crates/sigilbuzz-subset/README.md | 4 + crates/sigilbuzz-subset/src/instance.rs | 208 +++++++++++++++++- .../src/instance/tests/user_and_style.rs | 122 ++++++++-- crates/sigilbuzz-subset/src/lib.rs | 8 +- 4 files changed, 325 insertions(+), 17 deletions(-) diff --git a/crates/sigilbuzz-subset/README.md b/crates/sigilbuzz-subset/README.md index d2d8d530..a315618b 100644 --- a/crates/sigilbuzz-subset/README.md +++ b/crates/sigilbuzz-subset/README.md @@ -52,6 +52,10 @@ outlines, as HarfBuzz does. `OS/2` gets the new `usWeightClass`, `usWidthClass` `xAvgCharWidth`, and `post` the new `italicAngle`. Glyph instructions stay, as they do in HarfBuzz unless it is asked to drop hinting. +`instance` takes normalized coordinates. `instance_user` takes axis values in the units +`fvar` gives (`wght` 700, say), pins or keeps each axis by tag, and normalizes them the +way HarfBuzz does. + A malformed piece of a layout table (a GDEF list, a GSUB or GPOS lookup or subtable, a Device table, an anchor), or a malformed vertical metrics table (`vhea`, `vmtx`, `VORG`, `VVAR`), `BASE` or `STAT`, is left out of a subset instead of failing the whole diff --git a/crates/sigilbuzz-subset/src/instance.rs b/crates/sigilbuzz-subset/src/instance.rs index c7bfdaac..ba422ce5 100644 --- a/crates/sigilbuzz-subset/src/instance.rs +++ b/crates/sigilbuzz-subset/src/instance.rs @@ -72,6 +72,10 @@ //! The two differ by one F2DOT14 step where the rounding lands on //! different sides, which moves a delta by a unit now and then. //! +//! [`instance_user`] takes each axis' value in user units and +//! normalizes it as HarfBuzz does ([`VariationAxis::normalize`]), then +//! works out both sets from that, so a caller need not normalize. +//! //! # OS/2 and post //! //! As HarfBuzz's instancer, an instance sets `OS/2.usWeightClass` from @@ -245,6 +249,7 @@ pub(crate) use region::project_region_onto_kept_axes; /// F2DOT14 both before and after `avar` for the other variation tables. /// A value rounded before it gets here can land one F2DOT14 step away /// from HarfBuzz's and move outlines, advances or deltas by a unit. +/// [`instance_user`] takes user values and normalizes them itself. pub type F2Dot14 = f32; /// Per-axis pin policy for partial instancing. @@ -327,6 +332,196 @@ impl Default for InstanceInput { } } +/// One axis' setting for [`instance_user`], in the axis' own units, the +/// user space `fvar` describes (`wght` 100 to 900, say). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AxisLimit { + /// Pins the axis at this value, clamped to the axis' range as + /// HarfBuzz's `hb_subset_input_pin_axis_location` clamps it. A NaN + /// pins the axis at its default. + Pin(f32), + /// Pins the axis at its default value. + Default, + /// Keeps the axis variable over its whole range. + Keep, + /// Limits the axis to `min..=max` with `default` its default, as + /// HarfBuzz's `hb_subset_input_set_axis_range` takes a range: each + /// end clamps to the axis' range and `default` to the new range, + /// and a NaN takes the axis' own value. A range that comes down to + /// one value pins the axis there, and one that keeps the axis' + /// whole range and default keeps the axis variable. Any other range + /// fails with [`SubsetError::Unsupported`]: the instancer cannot + /// narrow an axis or move its default. + Range { + /// The lowest value the axis keeps. + min: f32, + /// The axis' default value. + default: f32, + /// The highest value the axis keeps. + max: f32, + }, +} + +/// Inputs to [`instance_user`]. +#[derive(Debug, Clone)] +pub struct UserInstanceInput { + /// Axis settings by axis tag. A later setting for a tag wins over an + /// earlier one, and a setting applies to every axis with its tag. A + /// tag no axis of the font has fails with + /// [`SubsetError::Unsupported`]. + pub axes: Vec<([u8; 4], AxisLimit)>, + /// What happens to the axes `axes` does not name. When false (the + /// default) they are pinned at their defaults, so naming only + /// `wght` gives a static font. When true they stay variable, as + /// HarfBuzz's and fontTools' instancers keep an axis they are not + /// given. + pub keep_unnamed_axes: bool, + /// As [`InstanceInput::drop_var_tables`]. + pub drop_var_tables: bool, +} + +impl Default for UserInstanceInput { + fn default() -> Self { + Self { + axes: Vec::new(), + keep_unnamed_axes: false, + drop_var_tables: true, + } + } +} + +/// Instances `face` at axis locations given in user units, as HarfBuzz's +/// instancer takes them (`hb_subset_input_pin_axis_location`, +/// `hb_subset_input_set_axis_range`). +/// +/// Each pinned value is normalized as HarfBuzz normalizes it (see +/// [`VariationAxis::normalize`]), and the instance takes both of +/// HarfBuzz's coordinate sets from that (see the module docs, +/// "Coordinates"), as [`instance()`] does from normalized coordinates: +/// callers no longer normalize each axis themselves. The pinned `wght`, +/// `wdth` and `slnt` values set `OS/2`'s `usWeightClass` and +/// `usWidthClass` and `post`'s `italicAngle`; a `Range` sets them from +/// its default, as HarfBuzz does. +/// +/// # Errors +/// +/// As [`instance()`], and [`SubsetError::Unsupported`] for a tag the +/// font has no axis for, a range whose minimum is above its maximum, or +/// a range that would narrow an axis or move its default. +/// +/// ```no_run +/// use sigilbuzz::Face; +/// use sigilbuzz_subset::{instance_user, AxisLimit, UserInstanceInput}; +/// +/// let bytes = std::fs::read("./MyFont-VF.ttf").unwrap(); +/// let face = Face::parse_bytes(&bytes, 0).unwrap(); +/// let input = UserInstanceInput { +/// axes: vec![(*b"wght", AxisLimit::Pin(700.0))], +/// ..Default::default() +/// }; +/// let bold = instance_user(&face, &input).unwrap(); +/// std::fs::write("./MyFont-Bold.ttf", &bold.bytes).unwrap(); +/// ``` +pub fn instance_user( + face: &Face<'_>, + input: &UserInstanceInput, +) -> Result { + let axes: Vec = match face.fvar().map_err(SubsetError::from)? { + Some(fvar) => fvar.axes().to_vec(), + None => Vec::new(), + }; + let unnamed = if input.keep_unnamed_axes { + AxisLimit::Keep + } else { + AxisLimit::Default + }; + let mut limits = alloc::vec![unnamed; axes.len()]; + for &(axis_tag, limit) in &input.axes { + let mut found = false; + for (axis, slot) in axes.iter().zip(limits.iter_mut()) { + if axis.tag == axis_tag { + *slot = limit; + found = true; + } + } + if !found { + return Err(SubsetError::Unsupported( + "instance: the font has no axis with this tag", + )); + } + } + let mut coords = Vec::with_capacity(axes.len()); + let mut pins = Vec::with_capacity(axes.len()); + let mut locations = Vec::with_capacity(axes.len()); + for (axis, &limit) in axes.iter().zip(&limits) { + let (pin, location) = resolve_limit(axis, limit)?; + match pin { + Some(value) => { + coords.push(axis.normalize(value)); + pins.push(AxisPin::Pin); + } + None => { + coords.push(0.0); + pins.push(AxisPin::Keep); + } + } + locations.push((axis.tag, location)); + } + let axis_pins = if pins.contains(&AxisPin::Keep) { + pins + } else { + Vec::new() + }; + let normalized = InstanceInput { + coords, + drop_var_tables: input.drop_var_tables, + axis_pins, + }; + instance_at(face, &normalized, &locations) +} + +/// What `limit` does to `axis`: the user value it pins the axis at +/// (`None` to keep it variable), and the location HarfBuzz records for +/// the axis, which sets the `OS/2` and `post` fields. +fn resolve_limit( + axis: &VariationAxis, + limit: AxisLimit, +) -> Result<(Option, Option), SubsetError> { + let default = axis.default_value; + match limit { + AxisLimit::Pin(value) => { + let value = clamp_to_axis(axis, value); + Ok((Some(value), Some(value))) + } + AxisLimit::Default => Ok((Some(default), Some(default))), + AxisLimit::Keep => Ok((None, None)), + AxisLimit::Range { min, default, max } => { + let or_axis = |v: f32, axis_value: f32| if v.is_nan() { axis_value } else { v }; + let min = or_axis(min, axis.min_value); + let max = or_axis(max, axis.max_value); + let new_default = or_axis(default, axis.default_value); + if min > max { + return Err(SubsetError::Unsupported( + "instance: an axis range's minimum is above its maximum", + )); + } + let lo = clamp_to_axis(axis, min); + let hi = clamp_to_axis(axis, max); + let middle = new_default.max(lo).min(hi); + if lo == hi { + return Ok((Some(lo), Some(lo))); + } + let (axis_lo, axis_hi) = axis_range(axis); + if lo == axis_lo && hi == axis_hi && middle == axis.default_value { + return Ok((None, Some(middle))); + } + Err(SubsetError::Unsupported( + "instance: an axis range narrower than the axis, or with another default, is not supported", + )) + } + } +} + /// `axis`' range, widened to take in its default as /// [`VariationAxis::normalize`] widens it. fn axis_range(axis: &VariationAxis) -> (f32, f32) { @@ -334,6 +529,16 @@ fn axis_range(axis: &VariationAxis) -> (f32, f32) { (axis.min_value.min(default), axis.max_value.max(default)) } +/// `value` clamped to `axis`' range; a NaN, or an axis with a bound that +/// is not finite, gives the default. +fn clamp_to_axis(axis: &VariationAxis, value: f32) -> f32 { + let (lo, hi) = axis_range(axis); + if value.is_nan() || !lo.is_finite() || !hi.is_finite() { + return axis.default_value; + } + value.max(lo).min(hi) +} + /// The user value of `axis` at its unrounded normalized coordinate /// `normalized`: the inverse of [`VariationAxis::normalize`]. fn user_value(axis: &VariationAxis, normalized: f32) -> f32 { @@ -413,7 +618,8 @@ pub struct InstancedOutput { /// /// The pinned `wght`, `wdth` and `slnt` axes set `OS/2`'s /// `usWeightClass` and `usWidthClass` and `post`'s `italicAngle` from -/// the user value of their coordinates. +/// the user value of their coordinates. [`instance_user`] takes the +/// user values themselves. pub fn instance(face: &Face<'_>, input: &InstanceInput) -> Result { instance_at(face, input, &pinned_locations(face, input)) } diff --git a/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs index b1c36557..2408a98c 100644 --- a/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs +++ b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs @@ -1,6 +1,6 @@ -//! Tests for the `OS/2` and `post` fields an instance sets from its -//! axis locations, and the metrics a CFF2 instance takes from its -//! outlines. +//! Tests for the user-space entry point, the `OS/2` and `post` fields +//! an instance sets from its axis locations, and the metrics a CFF2 +//! instance takes from its outlines. use super::*; use sigilbuzz::tables::OutlineSink; @@ -17,12 +17,10 @@ fn weight_axis() -> VariationAxis { } } -/// A full instance at normalized `coords`. -fn normalized(coords: &[f32]) -> InstanceInput { - InstanceInput { - coords: coords.to_vec(), - drop_var_tables: true, - axis_pins: Vec::new(), +fn user(axes: &[([u8; 4], AxisLimit)]) -> UserInstanceInput { + UserInstanceInput { + axes: axes.to_vec(), + ..UserInstanceInput::default() } } @@ -36,6 +34,45 @@ fn i16_at(face: &Face<'_>, tag: [u8; 4], off: usize) -> i16 { u16_at(face, tag, off) as i16 } +#[test] +fn limits_resolve_as_harfbuzz_takes_them() { + let axis = weight_axis(); + let pin = |v: f32| Ok((Some(v), Some(v))); + assert_eq!(resolve_limit(&axis, AxisLimit::Pin(650.0)), pin(650.0)); + assert_eq!(resolve_limit(&axis, AxisLimit::Pin(1200.0)), pin(900.0)); + assert_eq!(resolve_limit(&axis, AxisLimit::Pin(f32::NAN)), pin(400.0)); + assert_eq!(resolve_limit(&axis, AxisLimit::Default), pin(400.0)); + assert_eq!(resolve_limit(&axis, AxisLimit::Keep), Ok((None, None))); + let range = |min, default, max| AxisLimit::Range { min, default, max }; + // The whole axis keeps it, its location the default. + assert_eq!( + resolve_limit(&axis, range(100.0, 400.0, 900.0)), + Ok((None, Some(400.0))) + ); + assert_eq!( + resolve_limit(&axis, range(f32::NAN, f32::NAN, f32::NAN)), + Ok((None, Some(400.0))) + ); + assert_eq!( + resolve_limit(&axis, range(0.0, 400.0, 2000.0)), + Ok((None, Some(400.0))) + ); + // One value pins, past either end after clamping too. + assert_eq!(resolve_limit(&axis, range(500.0, 0.0, 500.0)), pin(500.0)); + assert_eq!(resolve_limit(&axis, range(1000.0, 0.0, 2000.0)), pin(900.0)); + // Narrowing, moving the default, or an inverted range fails. + for limit in [ + range(200.0, 400.0, 900.0), + range(100.0, 500.0, 900.0), + range(800.0, 400.0, 300.0), + ] { + assert!(matches!( + resolve_limit(&axis, limit), + Err(SubsetError::Unsupported(_)) + )); + } +} + #[test] fn user_values_invert_normalize() { let axis = weight_axis(); @@ -48,10 +85,48 @@ fn user_values_invert_normalize() { } #[test] -fn instance_sets_weight_class_and_average_width() { +fn instance_user_matches_normalized_coords() { let face = rubik_face(); let axis = face.fvar().unwrap().unwrap().axes()[0]; - let out = instance(&face, &normalized(&[axis.normalize(651.0)])).unwrap(); + let by_user = instance_user(&face, &user(&[(*b"wght", AxisLimit::Pin(700.0))])).unwrap(); + let by_coords = instance( + &face, + &InstanceInput { + coords: alloc::vec![axis.normalize(700.0)], + drop_var_tables: true, + axis_pins: Vec::new(), + }, + ) + .unwrap(); + assert_eq!(by_user.bytes, by_coords.bytes); +} + +#[test] +fn instance_user_rejects_an_unknown_tag() { + let face = rubik_face(); + let r = instance_user(&face, &user(&[(*b"wdth", AxisLimit::Pin(75.0))])); + assert!(matches!(r, Err(SubsetError::Unsupported(_)))); +} + +#[test] +fn unnamed_axes_are_pinned_or_kept() { + let face = rubik_face(); + let pinned = instance_user(&face, &UserInstanceInput::default()).unwrap(); + let pinned = Face::parse_bytes(&pinned.bytes, 0).unwrap(); + assert!(pinned.fvar().unwrap().is_none(), "a static instance"); + let kept = UserInstanceInput { + keep_unnamed_axes: true, + ..UserInstanceInput::default() + }; + let kept = instance_user(&face, &kept).unwrap(); + let kept = Face::parse_bytes(&kept.bytes, 0).unwrap(); + assert!(kept.fvar().unwrap().is_some(), "the axis stays"); +} + +#[test] +fn instance_sets_weight_class_and_average_width() { + let face = rubik_face(); + let out = instance_user(&face, &user(&[(*b"wght", AxisLimit::Pin(651.0))])).unwrap(); let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); assert_eq!(u16_at(&baked, *b"OS/2", 4), 651); let hmtx = baked.hmtx().unwrap(); @@ -65,6 +140,26 @@ fn instance_sets_weight_class_and_average_width() { assert_eq!(u16_at(&face, *b"OS/2", 4), 300); } +#[test] +fn a_kept_axis_keeps_its_weight_class() { + let face = rubik_face(); + let input = user(&[(*b"wght", AxisLimit::Keep)]); + let out = instance_user(&face, &input).unwrap(); + let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); + assert_eq!(u16_at(&baked, *b"OS/2", 4), 300); + // A range over the whole axis keeps it too, but records its + // default, as HarfBuzz does. + let whole = AxisLimit::Range { + min: 300.0, + default: 300.0, + max: 900.0, + }; + let out = instance_user(&face, &user(&[(*b"wght", whole)])).unwrap(); + let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); + assert!(baked.fvar().unwrap().is_some()); + assert_eq!(u16_at(&baked, *b"OS/2", 4), 300); +} + /// Collects every point an outline draws to or pulls toward. #[derive(Default)] struct Points(Vec<(f32, f32)>); @@ -90,8 +185,7 @@ fn a_cff2_instance_writes_whole_numbers() { // Every blend rounds, so every point of the static outlines falls // on a whole unit, as in HarfBuzz's instance. let face = Face::parse_bytes(SOURCE_SANS, 0).unwrap(); - let axis = face.fvar().unwrap().unwrap().axes()[0]; - let out = instance(&face, &normalized(&[axis.normalize(613.0)])).unwrap(); + let out = instance_user(&face, &user(&[(*b"wght", AxisLimit::Pin(613.0))])).unwrap(); let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); let cff2 = baked.cff2().unwrap(); for gid in 0..baked.maxp().unwrap().num_glyphs { @@ -110,7 +204,7 @@ fn a_cff2_instance_writes_whole_numbers() { fn a_cff2_instance_takes_its_metrics_from_its_outlines() { // wght 900 is the axis' end, 1.0 before and after avar. let face = Face::parse_bytes(SOURCE_SANS, 0).unwrap(); - let out = instance(&face, &normalized(&[1.0])).unwrap(); + let out = instance_user(&face, &user(&[(*b"wght", AxisLimit::Pin(900.0))])).unwrap(); let baked = Face::parse_bytes(&out.bytes, 0).unwrap(); let n = face.maxp().unwrap().num_glyphs; let extents = cff2_metrics::cff2_extents(&face, &[1.0], n).unwrap(); diff --git a/crates/sigilbuzz-subset/src/lib.rs b/crates/sigilbuzz-subset/src/lib.rs index c1b63c91..326b6ca5 100644 --- a/crates/sigilbuzz-subset/src/lib.rs +++ b/crates/sigilbuzz-subset/src/lib.rs @@ -5,7 +5,8 @@ //! in through composites and ligatures. Every table that survives is //! rewritten so glyph references point at the new, compacted glyph order. //! [`instance()`] bakes variable-font axis coordinates into a static font or -//! pins some axes and keeps the rest. +//! pins some axes and keeps the rest; [`instance_user`] takes the axis +//! values in user units, as HarfBuzz's instancer does. //! //! # Pipeline //! @@ -241,7 +242,10 @@ pub use cff2::subset_non_identity as subset_cff2_non_identity; pub use classdef::emit_classdef; pub use closure::compute_closure; pub use coverage::{emit_coverage_from_glyphs, emit_coverage_from_pairs}; -pub use instance::{instance, AxisPin, F2Dot14, InstanceInput, InstancedOutput}; +pub use instance::{ + instance, instance_user, AxisLimit, AxisPin, F2Dot14, InstanceInput, InstancedOutput, + UserInstanceInput, +}; pub use warnings::SubsetWarning; use warnings::Warnings; From da932e8ea5a00f2cbda59ebc14e4a81ae1eb3275 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:46:54 -0800 Subject: [PATCH 5/9] Round instance metrics halves up, as HarfBuzz's roundf does HarfBuzz defines `roundf(x)` as `floor(x + 0.5)`: halves round toward positive infinity, not away from zero. The CFF2 glyph boxes, the `OS/2` weight and width classes, `xAvgCharWidth` (from the double-precision mean) and `post.italicAngle` now round that way. A negative box end at an exact half, -2.5 say, goes to -2. The blends of a CFF2 bake keep C `round`, halves away from zero, which HarfBuzz's interpreter calls there. Part of #290. --- .../sigilbuzz-subset/src/instance/cff2_metrics.rs | 15 +++++++++------ .../src/instance/cff2_metrics/tests.rs | 6 +++--- crates/sigilbuzz-subset/src/instance/metrics.rs | 8 ++++---- crates/sigilbuzz-subset/src/instance/style.rs | 15 +++++++++------ 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs b/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs index 02aca097..a270b13b 100644 --- a/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs +++ b/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs @@ -6,7 +6,8 @@ //! coordinates (`update_instance_metrics_map_from_cff2`): the box of //! every point the outline passes through or pulls toward, curve //! control points included, before its blends round. The box's ends -//! round halves away from zero. A glyph with a box takes its left end +//! round halves up, as HarfBuzz's `roundf` (`floor(x + 0.5)`) does. A +//! glyph with a box takes its left end //! as its left side bearing; one without keeps the source's. The `head` //! box is the union of the glyphs' boxes, and the `hhea` extremes count //! every glyph, one without a box as zero wide. @@ -23,6 +24,7 @@ use sigilbuzz::Face; use super::glyf::clamp_i16; use super::metrics::HmtxBake; use crate::hmtx::emit_long_metrics; +use crate::util::round_half_up; /// A glyph's box as HarfBuzz's `hb_glyph_extents_t` holds it: the left /// end and width, the top end and (negative) height, each end rounded. @@ -78,18 +80,19 @@ impl ControlBox { } fn extents(&self) -> Extents { - // `roundf`: halves away from zero. Float-to-int casts saturate. + // HarfBuzz's `roundf`: halves up. It saturates at the `i32` + // range. let (x_bearing, width) = if self.min.0 >= self.max.0 { (0, 0) } else { - let left = self.min.0.round() as i32; - (left, (self.max.0.round() as i32).saturating_sub(left)) + let left = round_half_up(self.min.0); + (left, round_half_up(self.max.0).saturating_sub(left)) }; let (y_bearing, height) = if self.min.1 >= self.max.1 { (0, 0) } else { - let top = self.max.1.round() as i32; - (top, (self.min.1.round() as i32).saturating_sub(top)) + let top = round_half_up(self.max.1); + (top, round_half_up(self.min.1).saturating_sub(top)) }; Extents { x_bearing, diff --git a/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs b/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs index aba10e17..9e816e43 100644 --- a/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs +++ b/crates/sigilbuzz-subset/src/instance/cff2_metrics/tests.rs @@ -17,12 +17,12 @@ fn the_box_takes_control_points_and_rounds_its_ends() { s.close(); }); // x from -2.5 (a control point) to 40.5, y from 0 to 120.5: each - // end rounds halves away from zero. + // end rounds halves up, so -2.5 goes to -2. assert_eq!( e, Extents { - x_bearing: -3, - width: 41 + 3, + x_bearing: -2, + width: 41 + 2, y_bearing: 121, height: -121, } diff --git a/crates/sigilbuzz-subset/src/instance/metrics.rs b/crates/sigilbuzz-subset/src/instance/metrics.rs index ff24499c..3c84cfbe 100644 --- a/crates/sigilbuzz-subset/src/instance/metrics.rs +++ b/crates/sigilbuzz-subset/src/instance/metrics.rs @@ -44,10 +44,10 @@ impl HmtxBake { if count == 0 { return 0; } - // `roundf` of the quotient in single precision, halves up for - // these positive values. At most 65,535 advances of at most - // 65,535 each, so the sum fits. - ((total as f64 / count as f64) as f32).round() as u16 + // HarfBuzz's `roundf` of the quotient in double precision, + // `floor(x + 0.5)`. At most 65,535 advances of at most 65,535 + // each, so the sum fits and so does the mean. + (total as f64 / count as f64 + 0.5).floor() as u16 } } diff --git a/crates/sigilbuzz-subset/src/instance/style.rs b/crates/sigilbuzz-subset/src/instance/style.rs index 0f3ed41c..3aa9a5fb 100644 --- a/crates/sigilbuzz-subset/src/instance/style.rs +++ b/crates/sigilbuzz-subset/src/instance/style.rs @@ -14,6 +14,7 @@ //! none, and its fields stay. use super::glyf::clamp_i16; +use crate::util::round_half_up; /// Each axis' tag, and its location in user units when the instance /// pins it. @@ -86,11 +87,11 @@ pub(super) fn patch_os2(os2: &mut [u8], avg_char_width: Option, axes: &Axis write_u16(os2, 2, avg as u16); } if let Some(weight) = location(axes, *b"wght") { - // `roundf`, halves away from zero; the value is positive. - write_u16(os2, 4, weight.clamp(1.0, 1000.0).round() as u16); + // HarfBuzz's `roundf`: halves up. + write_u16(os2, 4, round_half_up(weight.clamp(1.0, 1000.0)) as u16); } if let Some(width) = location(axes, *b"wdth") { - write_u16(os2, 6, width_class(width).round() as u16); + write_u16(os2, 6, round_half_up(width_class(width)) as u16); } } @@ -100,9 +101,8 @@ pub(super) fn patch_post(post: &mut [u8], axes: &AxisLocations) { let Some(slant) = location(axes, *b"slnt") else { return; }; - // HarfBuzz's `set_float`: `roundf (f * 65536)`, halves away from - // zero. - let angle = (slant.clamp(-90.0, 90.0) * 65536.0).round() as i32; + // HarfBuzz's `set_float`: `roundf (f * 65536)`, halves up. + let angle = round_half_up(slant.clamp(-90.0, 90.0) * 65536.0); if let Some(field) = post.get_mut(4..).and_then(<[u8]>::first_chunk_mut::<4>) { *field = angle.to_be_bytes(); } @@ -165,5 +165,8 @@ mod tests { assert_eq!(&post[4..], (-491_520i32).to_be_bytes()); patch_post(&mut post, &[(*b"slnt", Some(-120.0))]); assert_eq!(&post[4..], (-90i32 * 65536).to_be_bytes()); + // Half a 16.16 unit below zero rounds up, to zero. + patch_post(&mut post, &[(*b"slnt", Some(-0.5 / 65536.0))]); + assert_eq!(&post[4..], 0i32.to_be_bytes()); } } From 12bb477c5ae0bc122132249f6fe2ad99bfa06fca Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:18:44 -0800 Subject: [PATCH 6/9] Charge each value a blend leaves, and cap the CFF2 bake's stack Review of #294 (B1, B2). A `blend` over a subtable with no regions (a `vsindex` past the store, or a VarData without regions) leaves its `n` values on the stack, so it can run again and again for two tokens while the full CFF2 bake rounds all `n` values each time and the subset walk marks them. The bake also had no operand-stack limit, so `n` could be as large as the charstring. A 9,980-byte font took 50 s to fail the bake's budget, a 4 MB one over 180 s, and a 4 MB font took 50 s to subset. Both now charge one token for each value a blend leaves (the deltas were charged as they were pushed), and the bake stops at 513 operands, the CFF2 limit HarfBuzz's interpreter enforces. The same fonts now fail or finish in under a second. Real fonts spend at most 1.8% of the bake budget (Noto Sans KR VF, from 1.4%). Part of #290. --- crates/sigilbuzz-subset/src/cff/walk.rs | 11 ++++-- crates/sigilbuzz-subset/src/cff/walk/tests.rs | 29 ++++++++++++++++ crates/sigilbuzz-subset/src/cff2.rs | 4 +++ crates/sigilbuzz-subset/src/cff2/bake.rs | 21 +++++++++--- crates/sigilbuzz-subset/src/cff2/partial.rs | 14 +++----- crates/sigilbuzz-subset/src/cff2/tests.rs | 34 +++++++++++++++++++ 6 files changed, 96 insertions(+), 17 deletions(-) diff --git a/crates/sigilbuzz-subset/src/cff/walk.rs b/crates/sigilbuzz-subset/src/cff/walk.rs index 3cd14f7e..df2fd9e5 100644 --- a/crates/sigilbuzz-subset/src/cff/walk.rs +++ b/crates/sigilbuzz-subset/src/cff/walk.rs @@ -33,9 +33,10 @@ //! by the body that calls it, so the push can be rewritten in place. //! //! The walk charges one unit of `budget` for every operand and operator -//! it reads, inside subroutines too, and fails once the budget is -//! spent. Subroutines that call each other many times can describe far -//! more work than the table's size. +//! it reads, inside subroutines too, and one for each value a `blend` +//! leaves, and fails once the budget is spent. Subroutines that call +//! each other many times can describe far more work than the table's +//! size. use alloc::collections::BTreeMap; use alloc::vec::Vec; @@ -510,6 +511,10 @@ impl<'a> CharstringWalk<'a> { let deltas = n.checked_mul(regions).ok_or(SHORT)?; let total = deltas.checked_add(n).ok_or(SHORT)?; let start = glyph.stack.len().checked_sub(total).ok_or(SHORT)?; + // The deltas were charged as they were pushed; the `n` values + // the blend leaves are charged here, so blends that leave the + // same values again and again (no regions) cost what they do. + self.budget = self.budget.checked_sub(n).ok_or(WORK_EXCEEDED)?; glyph.stack.truncate(start + n); for operand in glyph.stack.get_mut(start..).unwrap_or_default() { operand.push = None; diff --git a/crates/sigilbuzz-subset/src/cff/walk/tests.rs b/crates/sigilbuzz-subset/src/cff/walk/tests.rs index d38f1d2a..c922dea7 100644 --- a/crates/sigilbuzz-subset/src/cff/walk/tests.rs +++ b/crates/sigilbuzz-subset/src/cff/walk/tests.rs @@ -453,3 +453,32 @@ fn cff1_clears_the_stack_at_blend_and_vsindex() { ]); walk_cff1(&charstring, &[], &[]).expect("walk"); } + +#[test] +fn a_blend_charges_the_values_it_leaves() { + // Without regions `512 blend` leaves its 512 values where they are, + // so it could run again and again for two tokens. It charges the + // 512 values: 512 pushes, the count, the operator and 512 more. + let mut charstring = int(0).repeat(512); + charstring.extend_from_slice(&int(512)); + charstring.push(OP_BLEND); + for budget in [1026, 1025] { + let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(None)), budget); + let mut fd = walk.fd(&[], 0); + let r = walk.glyph(&mut fd, &charstring); + assert_eq!(r.is_ok(), budget == 1026, "budget {budget}"); + } + // A long run of them spends a table-sized budget. + let mut repeated = int(0).repeat(512); + for _ in 0..10_000 { + repeated.extend_from_slice(&int(512)); + repeated.push(OP_BLEND); + } + let mut walk = CharstringWalk::new( + &[], + Some(BlendRegions::new(None)), + walk_budget(repeated.len()), + ); + let mut fd = walk.fd(&[], 0); + assert_eq!(walk.glyph(&mut fd, &repeated).unwrap_err(), WORK_EXCEEDED); +} diff --git a/crates/sigilbuzz-subset/src/cff2.rs b/crates/sigilbuzz-subset/src/cff2.rs index 6e42f8f8..f4fea56a 100644 --- a/crates/sigilbuzz-subset/src/cff2.rs +++ b/crates/sigilbuzz-subset/src/cff2.rs @@ -357,6 +357,10 @@ const OP_HVCURVETO: u8 = 31; const MAX_BAKE_DEPTH: u8 = 10; +/// Operands a CFF2 charstring may stack: the CFF2 limit, which +/// HarfBuzz's interpreter enforces too. +const MAX_STACK: usize = 513; + /// Minimum number of charstring tokens (operand pushes plus operators, /// counted inside inlined subroutines too) one bake may process. const MIN_BAKE_TOKENS: usize = 1 << 22; diff --git a/crates/sigilbuzz-subset/src/cff2/bake.rs b/crates/sigilbuzz-subset/src/cff2/bake.rs index c788eff0..0b68838c 100644 --- a/crates/sigilbuzz-subset/src/cff2/bake.rs +++ b/crates/sigilbuzz-subset/src/cff2/bake.rs @@ -7,11 +7,11 @@ use sigilbuzz::tables::variation_store::ItemVariationStore; use super::{ bake_token_budget, biased_subr, charge_token, decode_operand_f64, encode_charstring_number_f64, - parse_cff2, serialise_cff2_top_dict, BlendCache, MAX_BAKE_DEPTH, OP_BLEND, OP_CALLGSUBR, - OP_CALLSUBR, OP_CNTRMASK, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, OP_HMOVETO, - OP_HSTEM, OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, OP_RLINETO, - OP_RMOVETO, OP_RRCURVETO, OP_SHORTINT, OP_VHCURVETO, OP_VLINETO, OP_VMOVETO, OP_VSINDEX, - OP_VSTEM, OP_VSTEMHM, OP_VVCURVETO, + parse_cff2, serialise_cff2_top_dict, BlendCache, MAX_BAKE_DEPTH, MAX_STACK, OP_BLEND, + OP_CALLGSUBR, OP_CALLSUBR, OP_CNTRMASK, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, + OP_HMOVETO, OP_HSTEM, OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, + OP_RLINETO, OP_RMOVETO, OP_RRCURVETO, OP_SHORTINT, OP_VHCURVETO, OP_VLINETO, OP_VMOVETO, + OP_VSINDEX, OP_VSTEM, OP_VSTEMHM, OP_VVCURVETO, }; use crate::cff::{ emit_fd_select_auto, encode_index_cff2, patch_dict_offset, serialise_font_dict, @@ -334,6 +334,11 @@ impl<'a> Baker<'a> { if b0 >= 32 || b0 == OP_SHORTINT { let (val, len) = decode_operand_f64(code, pos) .ok_or(SubsetError::Unsupported("CFF2 bake: operand truncated"))?; + if self.stack.len() >= MAX_STACK { + return Err(SubsetError::Unsupported( + "CFF2 bake: operand stack past 513 operands", + )); + } self.stack.push(val); pos += len; continue; @@ -452,6 +457,12 @@ impl<'a> Baker<'a> { .and_then(|total_deltas| total_deltas.checked_add(n)) .ok_or(UNDERFLOW)?; let start = self.stack.len().checked_sub(needed).ok_or(UNDERFLOW)?; + // The deltas were charged as they were pushed; each value the + // blend works out is charged here, so blends that leave the same + // values again and again (no regions) cost what they do. + self.budget = self.budget.checked_sub(n).ok_or(SubsetError::Unsupported( + "CFF2 bake: charstring work budget exceeded", + ))?; let (masters, deltas) = self .stack .get_mut(start..) diff --git a/crates/sigilbuzz-subset/src/cff2/partial.rs b/crates/sigilbuzz-subset/src/cff2/partial.rs index 40080381..667e1666 100644 --- a/crates/sigilbuzz-subset/src/cff2/partial.rs +++ b/crates/sigilbuzz-subset/src/cff2/partial.rs @@ -8,11 +8,11 @@ use sigilbuzz::tables::variation_store::ItemVariationStore; use super::{ bake_token_budget, biased_subr, charge_token, charstring_number_bytes, decode_operand_f32, - parse_cff2, serialise_cff2_top_dict, MAX_BAKE_DEPTH, OP_BLEND, OP_CALLGSUBR, OP_CALLSUBR, - OP_CNTRMASK, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, OP_HMOVETO, OP_HSTEM, - OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, OP_RLINETO, OP_RMOVETO, - OP_RRCURVETO, OP_SHORTINT, OP_VHCURVETO, OP_VLINETO, OP_VMOVETO, OP_VSINDEX, OP_VSTEM, - OP_VSTEMHM, OP_VVCURVETO, + parse_cff2, serialise_cff2_top_dict, MAX_BAKE_DEPTH, MAX_STACK, OP_BLEND, OP_CALLGSUBR, + OP_CALLSUBR, OP_CNTRMASK, OP_ESCAPE, OP_HHCURVETO, OP_HINTMASK, OP_HLINETO, OP_HMOVETO, + OP_HSTEM, OP_HSTEMHM, OP_HVCURVETO, OP_RCURVELINE, OP_RETURN, OP_RLINECURVE, OP_RLINETO, + OP_RMOVETO, OP_RRCURVETO, OP_SHORTINT, OP_VHCURVETO, OP_VLINETO, OP_VMOVETO, OP_VSINDEX, + OP_VSTEM, OP_VSTEMHM, OP_VVCURVETO, }; use crate::cff::{ emit_fd_select_auto, encode_index_cff2, patch_dict_offset, serialise_font_dict, @@ -316,10 +316,6 @@ pub(super) fn bake_cff2_partial_within( Ok((out, rewriter.budget)) } -/// Operands a CFF2 charstring may stack: the CFF2 limit, which -/// HarfBuzz's interpreter enforces too. -const MAX_STACK: usize = 513; - /// One token of the charstring tail the baker can still edit: an /// operand push or an operator, as the bytes it is written with. #[derive(Clone, Copy)] diff --git a/crates/sigilbuzz-subset/src/cff2/tests.rs b/crates/sigilbuzz-subset/src/cff2/tests.rs index a8265553..5de13216 100644 --- a/crates/sigilbuzz-subset/src/cff2/tests.rs +++ b/crates/sigilbuzz-subset/src/cff2/tests.rs @@ -452,3 +452,37 @@ fn bake_starts_at_the_private_dict_vsindex() { want.push(21); assert_eq!(parsed.char_strings[1], want.as_slice()); } + +#[test] +fn bake_charges_each_blended_value() { + // A subtable without regions: `500 blend` leaves its 500 values, and + // 9,000 of them would round 4.5 million values for 18,000 tokens. + // Each value is charged, so the table-sized budget runs out. + let ivs = store_with_region_counts(&[0]); + let mut cs1 = pushes(&[0; 500]); + for _ in 0..9_000 { + cs1.extend_from_slice(&pushes(&[500])); + cs1.push(16); // blend + } + cs1.extend_from_slice(&pushes(&[0, 0])); + cs1.push(21); // rmoveto + let cs0: &[u8] = &[]; + let cff = build_synthetic_cff2(&[cs0, &cs1], &[0, 0], Some(&ivs)); + let r = bake_at_coords(&cff, &[0.5]); + assert_eq!( + r.unwrap_err(), + SubsetError::Unsupported("CFF2 bake: charstring work budget exceeded") + ); +} + +#[test] +fn bake_stacks_at_most_513_operands() { + let cs0: &[u8] = &[]; + for (count, ok) in [(513, true), (514, false)] { + let mut cs1 = pushes(&alloc::vec![0; count]); + cs1.push(5); // rlineto takes them + let cff = build_synthetic_cff2(&[cs0, &cs1], &[0, 0], None); + let r = bake_at_coords(&cff, &[]); + assert_eq!(r.is_ok(), ok, "{count} operands: {r:?}"); + } +} From c2c1bd18f2db1d84bdd9463473a52acf00eda71c Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:19:34 -0800 Subject: [PATCH 7/9] Resolve a cross-FD global's local calls with each FD's bias Review of #294 (B3). In a CID-keyed CFF1 subset, a global subroutine that calls a local is duplicated per Font DICT, and each duplicate is renumbered from the calls the walk recorded when it first read the global. That reading resolved the call against the first Font DICT's local INDEX, so a Font DICT whose local count gives another bias had its duplicate point at the wrong local: the reviewer's font drew a line to (0, 77) instead of (0, 555). A call's source subroutine is now worked out again from the number it pushes and the bias of the Font DICT that runs the body, for every body. Each kept subroutine's new index is its position among the kept ones, found by binary search, so the renumber tables no longer take an entry per source subroutine and Font DICT. Part of #290. --- crates/sigilbuzz-subset/src/cff.rs | 43 ++---- crates/sigilbuzz-subset/src/cff/charstring.rs | 59 +++++-- crates/sigilbuzz-subset/src/cff/cid.rs | 145 +++++++++--------- crates/sigilbuzz-subset/src/cff/tests/cid.rs | 48 ++++++ crates/sigilbuzz-subset/src/cff2/subset.rs | 85 +++++----- 5 files changed, 221 insertions(+), 159 deletions(-) diff --git a/crates/sigilbuzz-subset/src/cff.rs b/crates/sigilbuzz-subset/src/cff.rs index 20044365..b327b0b3 100644 --- a/crates/sigilbuzz-subset/src/cff.rs +++ b/crates/sigilbuzz-subset/src/cff.rs @@ -99,7 +99,7 @@ pub(crate) use reader::{ pub(crate) use seac::SeacClosure; use charset::{extract_kept_charset_sids, extract_kept_encoding_codes}; -pub(crate) use charstring::rewrite_calls; +pub(crate) use charstring::{kept_position, rewrite_calls, Remap}; use cid::subset_cid_keyed; use reader::{parse_cff1, OP_CHARSET, OP_ENCODING}; pub(crate) use walk::{walk_budget, BlendRegions, CharstringWalk, FdWalk}; @@ -296,32 +296,23 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result new renumber tables. - let mut local_renumber: Vec> = alloc::vec![None; parsed.local_subrs.len()]; - for (new_i, &old_i) in kept_local_idx.iter().enumerate() { - local_renumber[old_i as usize] = Some(new_i as u32); - } - let mut global_renumber: Vec> = alloc::vec![None; parsed.global_subrs.len()]; - for (new_i, &old_i) in kept_global_idx.iter().enumerate() { - global_renumber[old_i as usize] = Some(new_i as u32); - } - - let new_local_count = kept_local_idx.len(); - let new_global_count = kept_global_idx.len(); - - // Rewrite each kept charstring and subroutine (cloned, then - // mutated) at the call sites the walk found in it. - let renumber = |body: &[u8], calls: &[SubrCall]| { - rewrite_calls( - body, - calls, - new_local_count, - new_global_count, - &local_renumber, - &global_renumber, - |_| None, - ) + // Each kept subroutine's new index is its position among the kept. + let new_local = |old: usize| kept_position(&kept_local_idx, old); + let new_global = |old: usize| kept_position(&kept_global_idx, old); + let local = Remap { + old_count: parsed.local_subrs.len(), + new_count: kept_local_idx.len(), + new_index: &new_local, }; + let global = Remap { + old_count: parsed.global_subrs.len(), + new_count: kept_global_idx.len(), + new_index: &new_global, + }; + + // Rewrite each kept charstring and subroutine at the call sites the + // walk found in it. + let renumber = |body: &[u8], calls: &[SubrCall]| rewrite_calls(body, calls, &local, &global); let new_charstrings: Vec> = kept_charstrings .iter() .zip(&charstring_calls) diff --git a/crates/sigilbuzz-subset/src/cff/charstring.rs b/crates/sigilbuzz-subset/src/cff/charstring.rs index 7c066372..f7e994de 100644 --- a/crates/sigilbuzz-subset/src/cff/charstring.rs +++ b/crates/sigilbuzz-subset/src/cff/charstring.rs @@ -724,9 +724,33 @@ fn new_raw_operand( .map_err(|_| SubsetError::Unsupported("CFF renumber: new raw operand out of i32 range")) } +/// How the subroutines of one kind (the locals of one Font DICT, or the +/// globals) are renumbered in a subset. +pub(crate) struct Remap<'r> { + /// The count of the source INDEX, whose bias a call's number holds. + pub(crate) old_count: usize, + /// The count of the rebuilt INDEX, whose bias a new number takes. + pub(crate) new_count: usize, + /// The new index of a source subroutine, `None` when it is dropped. + pub(crate) new_index: &'r dyn Fn(usize) -> Option, +} + +/// The new index of source subroutine `old` when `kept` (ascending) lists +/// the kept ones in their new order: its position in `kept`. +pub(crate) fn kept_position(kept: &[u32], old: usize) -> Option { + let old = u32::try_from(old).ok()?; + kept.binary_search(&old).ok().map(|i| i as u32) +} + /// The body `body` with the subroutine number of each call in `calls`, -/// call sites found in it in byte order, renumbered against the -/// old-to-new maps (see [`renumber_charstring_with_cross_fd`]). +/// call sites found in it in byte order, renumbered through `local` and +/// `global`. +/// +/// A call's source subroutine is worked out again from the number it +/// pushes and the bias of `local.old_count` or `global.old_count`, not +/// taken from the call: one global subroutine body serves every Font +/// DICT, and the bias of its local calls is the bias of the local INDEX +/// of the Font DICT that runs it. /// /// A new number that fits the width of the push it replaces is padded /// to that width, so the body keeps its size. One that cannot be, a @@ -737,14 +761,13 @@ fn new_raw_operand( pub(crate) fn rewrite_calls( body: &[u8], calls: &[SubrCall], - new_local_count: usize, - new_global_count: usize, - local_renumber: &[Option], - global_renumber: &[Option], - cross_fd_override: impl Fn(usize) -> Option, + local: &Remap<'_>, + global: &Remap<'_>, ) -> Result, SubsetError> { const BAD_SITE: SubsetError = SubsetError::Unsupported("CFF charstring call sites out of order"); + const DROPPED: SubsetError = + SubsetError::Unsupported("CFF charstring calls dropped subroutine"); let mut out = Vec::with_capacity(body.len()); let mut pos = 0; for call in calls { @@ -754,14 +777,20 @@ pub(crate) fn rewrite_calls( if end > body.len() { return Err(BAD_SITE); } - let new_raw = new_raw_operand( - call, - new_local_count, - new_global_count, - local_renumber, - global_renumber, - &cross_fd_override, - )?; + let remap = match call.kind { + SubrKind::Local => local, + SubrKind::Global => global, + }; + let old = i64::from(call.raw_operand) + i64::from(subr_bias(remap.old_count)); + let old = usize::try_from(old) + .ok() + .filter(|&i| i < remap.old_count) + .ok_or(DROPPED)?; + let new = (remap.new_index)(old).ok_or(DROPPED)?; + let new_raw = i64::from(new) - i64::from(subr_bias(remap.new_count)); + let new_raw = i32::try_from(new_raw).map_err(|_| { + SubsetError::Unsupported("CFF renumber: new raw operand out of i32 range") + })?; match encode_int_operand_at_width(new_raw, call.operand_byte_len) { Ok(padded) if padded.len() == call.operand_byte_len => out.extend_from_slice(&padded), _ => out.extend_from_slice(&encode_int_operand(new_raw)), diff --git a/crates/sigilbuzz-subset/src/cff/cid.rs b/crates/sigilbuzz-subset/src/cff/cid.rs index 60ee4b0d..5bf58436 100644 --- a/crates/sigilbuzz-subset/src/cff/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/cid.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use super::charset::extract_kept_charset_sids; -use super::charstring::{cross_fd_globals, rewrite_calls, SubrCall}; +use super::charstring::{cross_fd_globals, kept_position, rewrite_calls, Remap, SubrCall}; use super::emit::{ emit_charset_auto, emit_fd_select_auto, encode_dict_int, encode_dict_offset_placeholder, encode_index, parse_fd_select, patch_dict_offset, @@ -312,7 +312,7 @@ pub(super) fn subset_cid_keyed( let mut fd_walks: Vec> = Vec::with_capacity(kept_fds_sorted.len()); let mut charstring_calls: Vec> = alloc::vec![Vec::new(); kept_gids.len()]; for &old_fd in &kept_fds_sorted { - let mut fd_walk = walk.fd(&fd_infos[old_fd as usize].local_subrs, 0); + let mut fd_walk = walk.fd(fd_infos[old_fd as usize].local_subrs.as_slice(), 0); for (i, &gid) in kept_gids.iter().enumerate() { if kept_fd_old[i] == old_fd { charstring_calls[i] = @@ -342,18 +342,6 @@ pub(super) fn subset_cid_keyed( }), ); - // Per-kept-FD local renumber tables. - let mut per_fd_local_renumber: Vec>> = - Vec::with_capacity(kept_fds_sorted.len()); - for (i, &old_fd) in kept_fds_sorted.iter().enumerate() { - let local_count = fd_infos[old_fd as usize].local_subrs.len(); - let mut renumber: Vec> = alloc::vec![None; local_count]; - for (new_i, &old_i) in per_fd_kept_local[i].iter().enumerate() { - renumber[old_i as usize] = Some(new_i as u32); - } - per_fd_local_renumber.push(renumber); - } - // Determine which (cross-FD global, kept-FD) duplicates are needed: // one for every cross-FD global a glyph of the FD reached, through // its charstring, its locals or other globals. Each FD's target @@ -422,26 +410,52 @@ pub(super) fn subset_cid_keyed( } let new_global_count = new_global_layout.len(); - // Step 5: rewrite each kept charstring with its FD's local-renumber - // table + the shared global-renumber table + the FD's cross-FD - // override table, at the call sites the walk found. + // Each FD's kept locals take their positions among the FD's kept + // ones; the globals take the layout above, or the FD's duplicate. + // A call's source subroutine is worked out with the bias of the FD + // that runs the body (see `rewrite_calls`), so one global body + // serves FDs whose local INDEXes have different biases. + let local_maps: Vec<_> = per_fd_kept_local + .iter() + .map(|kept| move |old: usize| kept_position(kept, old)) + .collect(); + let global_maps: Vec<_> = per_fd_cross_fd_override + .iter() + .map(|overrides| { + let global_renumber = &global_renumber; + move |old: usize| { + override_slot(overrides, old) + .or_else(|| global_renumber.get(old).copied().flatten()) + } + }) + .collect(); + let fd_remaps = |fd_pos: usize| { + let old_fd = usize::from(kept_fds_sorted[fd_pos]); + ( + Remap { + old_count: fd_infos[old_fd].local_subrs.len(), + new_count: per_fd_kept_local[fd_pos].len(), + new_index: &local_maps[fd_pos], + }, + Remap { + old_count: old_global_count, + new_count: new_global_count, + new_index: &global_maps[fd_pos], + }, + ) + }; + + // Step 5: rewrite each kept charstring with its FD's maps, at the + // call sites the walk found. let mut new_charstrings: Vec> = Vec::with_capacity(kept_gids.len()); for (i, &gid) in kept_gids.iter().enumerate() { - let old_fd = kept_fd_old[i]; - let new_fd_pos = fd_pos_of[usize::from(old_fd)]; - let fd_local_renumber = &per_fd_local_renumber[new_fd_pos]; - let new_local_count = per_fd_kept_local[new_fd_pos].len(); - let overrides = &per_fd_cross_fd_override[new_fd_pos]; - let cs = rewrite_calls( + let (local, global) = fd_remaps(fd_pos_of[usize::from(kept_fd_old[i])]); + new_charstrings.push(rewrite_calls( parsed.char_strings[gid as usize], &charstring_calls[i], - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - |old_g| override_slot(overrides, old_g), - )?; - new_charstrings.push(cs); + &local, + &global, + )?); } // Renumber each kept local subr (per-FD). Locals are FD-scoped so @@ -449,22 +463,15 @@ pub(super) fn subset_cid_keyed( let mut new_per_fd_local_subrs: Vec>> = Vec::with_capacity(kept_fds_sorted.len()); for (i, &old_fd) in kept_fds_sorted.iter().enumerate() { let fd_local_subrs_old = &fd_infos[old_fd as usize].local_subrs; - let kept_local_idx = &per_fd_kept_local[i]; - let fd_local_renumber = &per_fd_local_renumber[i]; - let new_local_count = kept_local_idx.len(); - let overrides = &per_fd_cross_fd_override[i]; - let mut new_locals: Vec> = Vec::with_capacity(kept_local_idx.len()); - for &idx in kept_local_idx { - let sub = rewrite_calls( + let (local, global) = fd_remaps(i); + let mut new_locals: Vec> = Vec::with_capacity(per_fd_kept_local[i].len()); + for &idx in &per_fd_kept_local[i] { + new_locals.push(rewrite_calls( fd_local_subrs_old[idx as usize], fd_walks[i].local_calls(idx as usize).unwrap_or_default(), - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - |old_g| override_slot(overrides, old_g), - )?; - new_locals.push(sub); + &local, + &global, + )?); } new_per_fd_local_subrs.push(new_locals); } @@ -482,39 +489,35 @@ pub(super) fn subset_cid_keyed( // the non-cross-FD tier. // // Cross-FD duplicates route local-subr operands through that FD's - // local-renumber table and global-subr operands through that FD's - // cross-FD override (so recursive cross-FD calls land on the - // correct duplicate). + // local map, with that FD's local bias, and global-subr operands + // through that FD's cross-FD override (so recursive cross-FD calls + // land on the correct duplicate). + let canonical_global = |old: usize| global_renumber.get(old).copied().flatten(); + let no_local = |_: usize| None; + let canonical = ( + Remap { + old_count: 0, + new_count: 0, + new_index: &no_local, + }, + Remap { + old_count: old_global_count, + new_count: new_global_count, + new_index: &canonical_global, + }, + ); let mut new_global_subrs: Vec> = Vec::with_capacity(new_global_layout.len()); for &(old_g, dup_for_fd) in &new_global_layout { let source = parsed.global_subrs[old_g as usize]; let calls = walk.global_calls(old_g as usize).unwrap_or_default(); let body = if let Some(old_fd) = dup_for_fd { - let fd_pos = fd_pos_of[usize::from(old_fd)]; - let new_local_count = per_fd_kept_local[fd_pos].len(); - let overrides = &per_fd_cross_fd_override[fd_pos]; - rewrite_calls( - source, - calls, - new_local_count, - new_global_count, - &per_fd_local_renumber[fd_pos], - &global_renumber, - |old_g| override_slot(overrides, old_g), - )? + let (local, global) = fd_remaps(fd_pos_of[usize::from(old_fd)]); + rewrite_calls(source, calls, &local, &global)? } else { - // Non-cross-FD: zero-sized local pool because the body - // never issues a `callsubr`. If it did, the empty local - // renumber table would surface a hard error. - rewrite_calls( - source, - calls, - 0, - new_global_count, - &[], - &global_renumber, - |_| None, - )? + // Non-cross-FD: an empty local pool because the body never + // issues a `callsubr`. If it did, the call would surface a + // hard error. + rewrite_calls(source, calls, &canonical.0, &canonical.1)? }; new_global_subrs.push(body); } diff --git a/crates/sigilbuzz-subset/src/cff/tests/cid.rs b/crates/sigilbuzz-subset/src/cff/tests/cid.rs index aac7fea3..272279f1 100644 --- a/crates/sigilbuzz-subset/src/cff/tests/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/tests/cid.rs @@ -618,3 +618,51 @@ fn cid_cross_fd_global_mask_is_sized_by_the_callers_stems() { assert_eq!(body, &g0.as_slice()); } } + +#[test] +fn cid_cross_fd_duplicates_resolve_locals_with_their_fds_bias() { + // FD 0 has 1 local (bias 107) and FD 1 has 1,240 (bias 1131), so + // global 0's `-107 callsubr` runs FD 0's local 0 and FD 1's local + // 1024. Glyph 1 (FD 1) also calls FD 1's local 0 itself. Each FD's + // duplicate of the global must call its own FD's local: FD 1's + // keeps locals 0 and 1024 as 0 and 1, so its duplicate calls 1. + let mut g0 = encode_int_operand(-107); + g0.extend_from_slice(&[OP_CALLSUBR, OP_RETURN]); + let fd0_local: Vec = alloc::vec![139, 139, OP_RMOVETO, OP_RETURN]; + let fd1_local_0: Vec = alloc::vec![140, 140, OP_RMOVETO, OP_RETURN]; + let fd1_local_1024: Vec = alloc::vec![141, 141, OP_RMOVETO, OP_RETURN]; + let filler: Vec = alloc::vec![OP_RETURN]; + let mut fd1_locals: Vec<&[u8]> = alloc::vec![filler.as_slice(); 1240]; + fd1_locals[0] = &fd1_local_0; + fd1_locals[1024] = &fd1_local_1024; + let mut cs0 = encode_int_operand(-107); + cs0.extend_from_slice(&[OP_CALLGSUBR, OP_ENDCHAR]); + let mut cs1 = encode_int_operand(-107); + cs1.push(OP_CALLGSUBR); + cs1.extend_from_slice(&encode_int_operand(-1131)); + cs1.extend_from_slice(&[OP_CALLSUBR, OP_ENDCHAR]); + let charstrings: Vec<&[u8]> = alloc::vec![cs0.as_slice(), cs1.as_slice()]; + let globals: Vec<&[u8]> = alloc::vec![g0.as_slice()]; + let per_fd_locals: Vec> = alloc::vec![alloc::vec![fd0_local.as_slice()], fd1_locals]; + let cff = + build_synthetic_cid_cff1_with_subrs(&charstrings, &[0u8, 1], &globals, &per_fd_locals); + + let new_cff = subset_non_identity(&cff, &[0u16, 1]).unwrap(); + let parsed = parse_cff1(&new_cff).unwrap(); + // Slot 0 is FD 0's duplicate, slot 1 FD 1's. + assert_eq!(parsed.global_subrs.len(), 2); + assert_eq!(parsed.global_subrs[0], g0.as_slice()); + let mut fd1_dup = encode_int_operand(1 - 107); + fd1_dup.extend_from_slice(&[OP_CALLSUBR, OP_RETURN]); + assert_eq!(parsed.global_subrs[1], fd1_dup.as_slice()); + // FD 1's new locals are its locals 0 and 1024, in that order. + let (fd_array, _) = read_index(&new_cff, parsed.fd_array_off.unwrap() as usize).unwrap(); + let entries = walk_dict(fd_array[1]).unwrap(); + let (size, off) = entries + .iter() + .find(|e| e.op == OP_PRIVATE) + .and_then(private_operands) + .unwrap(); + let (_, locals) = read_private_dict(&new_cff, size, off, read_index, "past end").unwrap(); + assert_eq!(locals, [fd1_local_0.as_slice(), fd1_local_1024.as_slice()]); +} diff --git a/crates/sigilbuzz-subset/src/cff2/subset.rs b/crates/sigilbuzz-subset/src/cff2/subset.rs index dc60a7f6..653cfe81 100644 --- a/crates/sigilbuzz-subset/src/cff2/subset.rs +++ b/crates/sigilbuzz-subset/src/cff2/subset.rs @@ -5,9 +5,9 @@ use alloc::vec::Vec; use super::{parse_cff2, serialise_cff2_top_dict, ParsedCff2}; use crate::cff::{ - emit_fd_select_auto, encode_index_cff2, kept_fd_positions, patch_dict_offset, rewrite_calls, - serialise_font_dict, serialise_private_dict, walk_budget, walk_dict, BlendRegions, - CharstringWalk, FdWalk, SubrCall, + emit_fd_select_auto, encode_index_cff2, kept_fd_positions, kept_position, patch_dict_offset, + rewrite_calls, serialise_font_dict, serialise_private_dict, walk_budget, walk_dict, + BlendRegions, CharstringWalk, FdWalk, Remap, SubrCall, }; use crate::SubsetError; @@ -145,48 +145,33 @@ fn emit( ) -> Result, SubsetError> { let per_fd_kept_local: Vec> = fds.iter().map(FdWalk::kept_locals).collect(); let kept_global_idx = walk.kept_globals(); - let mut global_renumber: Vec> = alloc::vec![None; parsed.global_subrs.len()]; - for (new_i, &old_i) in kept_global_idx.iter().enumerate() { - global_renumber[old_i as usize] = Some(new_i as u32); - } - let new_global_count = kept_global_idx.len(); - - let mut per_fd_local_renumber: Vec>> = - Vec::with_capacity(kept_fds_sorted.len()); - for (i, &old_fd) in kept_fds_sorted.iter().enumerate() { - let local_count = parsed.per_fd_local_subrs[old_fd as usize].len(); - let mut renumber: Vec> = alloc::vec![None; local_count]; - for (new_i, &old_i) in per_fd_kept_local[i].iter().enumerate() { - renumber[old_i as usize] = Some(new_i as u32); - } - per_fd_local_renumber.push(renumber); - } + let new_global = |old: usize| kept_position(&kept_global_idx, old); + let global = Remap { + old_count: parsed.global_subrs.len(), + new_count: kept_global_idx.len(), + new_index: &new_global, + }; + let local_maps: Vec<_> = per_fd_kept_local + .iter() + .map(|kept| move |old: usize| kept_position(kept, old)) + .collect(); + let local_remap = |fd_pos: usize| Remap { + old_count: parsed.per_fd_local_subrs[usize::from(kept_fds_sorted[fd_pos])].len(), + new_count: per_fd_kept_local[fd_pos].len(), + new_index: &local_maps[fd_pos], + }; // Step 5: rewrite each kept charstring and subroutine at the call // sites the walk found in it. - let renumber = |body: &[u8], - calls: &[SubrCall], - new_local_count: usize, - local_renumber: &[Option]| { - rewrite_calls( - body, - calls, - new_local_count, - new_global_count, - local_renumber, - &global_renumber, - |_| None, - ) - }; let fd_pos_of = kept_fd_positions(kept_fds_sorted); let mut new_charstrings: Vec> = Vec::with_capacity(kept_gids.len()); for (i, &gid) in kept_gids.iter().enumerate() { - let new_fd_pos = fd_pos_of[usize::from(kept_fd_old[i])]; - new_charstrings.push(renumber( + let local = local_remap(fd_pos_of[usize::from(kept_fd_old[i])]); + new_charstrings.push(rewrite_calls( parsed.char_strings[gid as usize], &charstring_calls[i], - per_fd_kept_local[new_fd_pos].len(), - &per_fd_local_renumber[new_fd_pos], + &local, + &global, )?); } @@ -194,28 +179,34 @@ fn emit( let mut new_per_fd_local_subrs: Vec>> = Vec::with_capacity(kept_fds_sorted.len()); for (i, &old_fd) in kept_fds_sorted.iter().enumerate() { let fd_local_subrs_old = &parsed.per_fd_local_subrs[old_fd as usize]; - let kept_local_idx = &per_fd_kept_local[i]; - let mut new_locals: Vec> = Vec::with_capacity(kept_local_idx.len()); - for &idx in kept_local_idx { - new_locals.push(renumber( + let local = local_remap(i); + let mut new_locals: Vec> = Vec::with_capacity(per_fd_kept_local[i].len()); + for &idx in &per_fd_kept_local[i] { + new_locals.push(rewrite_calls( fd_local_subrs_old[idx as usize], fds[i].local_calls(idx as usize).unwrap_or_default(), - kept_local_idx.len(), - &per_fd_local_renumber[i], + &local, + &global, )?); } new_per_fd_local_subrs.push(new_locals); } - // Renumber globals: pass an empty local table; calls into locals + // Renumber globals: pass an empty local pool; calls into locals // from globals would cross-FD-collide and surface a hard error. + let no_local = |_: usize| None; + let no_locals = Remap { + old_count: 0, + new_count: 0, + new_index: &no_local, + }; let mut new_global_subrs: Vec> = Vec::with_capacity(kept_global_idx.len()); for &g in &kept_global_idx { - new_global_subrs.push(renumber( + new_global_subrs.push(rewrite_calls( parsed.global_subrs[g as usize], walk.global_calls(g as usize).unwrap_or_default(), - 0, - &[], + &no_locals, + &global, )?); } From 4f4b62f9f9b474f9ffb56113c4b0a855385661f8 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:20:25 -0800 Subject: [PATCH 8/9] Keep the walk's memory to the subroutines it reaches Review of #294 (S1). The walk gave every kept Font DICT a slot per entry of its local Subrs INDEX, and the CID-keyed CFF1 subsetter read the Private DICT and Subrs INDEX of every Font DICT, even when they all name one. 256 Font DICTs over one 65,535-entry INDEX (a 269 KB font) peaked at 301 MB and took 1.4 s. The walk now keeps a map of the subroutines it reached, per Font DICT and for the globals, and a set of the globals each Font DICT reached. The CID-keyed subsetter reads a Private DICT and its Subrs INDEX once for every Font DICT that names it, as the CFF2 parser does. That font now peaks at 6 MB and takes 0.02 s. Part of #290. --- crates/sigilbuzz-subset/src/cff.rs | 2 +- crates/sigilbuzz-subset/src/cff/cid.rs | 37 +++-- crates/sigilbuzz-subset/src/cff/walk.rs | 139 +++++++----------- crates/sigilbuzz-subset/src/cff/walk/tests.rs | 46 ++++-- crates/sigilbuzz-subset/src/cff2/subset.rs | 2 +- 5 files changed, 115 insertions(+), 111 deletions(-) diff --git a/crates/sigilbuzz-subset/src/cff.rs b/crates/sigilbuzz-subset/src/cff.rs index b327b0b3..129ccda8 100644 --- a/crates/sigilbuzz-subset/src/cff.rs +++ b/crates/sigilbuzz-subset/src/cff.rs @@ -288,7 +288,7 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result> = Vec::with_capacity(kept_charstrings.len()); for cs in &kept_charstrings { charstring_calls.push(walk.glyph(&mut fd_walk, cs)?); diff --git a/crates/sigilbuzz-subset/src/cff/cid.rs b/crates/sigilbuzz-subset/src/cff/cid.rs index 5bf58436..f8cde6d5 100644 --- a/crates/sigilbuzz-subset/src/cff/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/cid.rs @@ -1,6 +1,8 @@ //! CID-keyed CFF1 subsetting: FDArray and FDSelect rebuild with //! per-FD subroutine keep-sets. +use alloc::collections::BTreeMap; +use alloc::rc::Rc; use alloc::vec::Vec; use super::charset::extract_kept_charset_sids; @@ -242,13 +244,18 @@ pub(super) fn subset_cid_keyed( // For each source FD, capture (Font DICT bytes, parsed entries, // optional Private DICT info, Private DICT bytes, Local Subr INDEX). + // Font DICTs that name the same Private DICT share one read of it + // and of its Local Subr INDEX. struct FdInfo<'a> { font_dict_entries: Vec, private_dict: &'a [u8], private: Option<(u32, u32)>, - local_subrs: Vec<&'a [u8]>, + local_subrs: Rc>, } let mut fd_infos: Vec> = Vec::with_capacity(fd_array_entries.len()); + // Private DICT (size, offset) -> its bytes and Local Subr INDEX. + type Privates<'a> = BTreeMap<(u32, u32), (&'a [u8], Rc>)>; + let mut privates: Privates<'_> = BTreeMap::new(); for fd_bytes in &fd_array_entries { let entries = walk_dict(fd_bytes)?; // The last well-formed Private operator wins. @@ -257,15 +264,23 @@ pub(super) fn subset_cid_keyed( .rev() .filter(|e| e.op == OP_PRIVATE) .find_map(private_operands); - let (private_dict, local_subrs): (&[u8], Vec<&[u8]>) = match priv_info { - Some((size, off)) => read_private_dict( - cff_bytes, - size, - off, - read_index, - "CFF1 CID Private DICT past end", - )?, - None => (&[][..], Vec::new()), + let (private_dict, local_subrs): (&[u8], Rc>) = match priv_info { + Some(key) => match privates.get(&key) { + Some((dict, locals)) => (dict, Rc::clone(locals)), + None => { + let (dict, locals) = read_private_dict( + cff_bytes, + key.0, + key.1, + read_index, + "CFF1 CID Private DICT past end", + )?; + let locals = Rc::new(locals); + privates.insert(key, (dict, Rc::clone(&locals))); + (dict, locals) + } + }, + None => (&[][..], Rc::default()), }; fd_infos.push(FdInfo { font_dict_entries: entries, @@ -312,7 +327,7 @@ pub(super) fn subset_cid_keyed( let mut fd_walks: Vec> = Vec::with_capacity(kept_fds_sorted.len()); let mut charstring_calls: Vec> = alloc::vec![Vec::new(); kept_gids.len()]; for &old_fd in &kept_fds_sorted { - let mut fd_walk = walk.fd(fd_infos[old_fd as usize].local_subrs.as_slice(), 0); + let mut fd_walk = FdWalk::new(fd_infos[old_fd as usize].local_subrs.as_slice(), 0); for (i, &gid) in kept_gids.iter().enumerate() { if kept_fd_old[i] == old_fd { charstring_calls[i] = diff --git a/crates/sigilbuzz-subset/src/cff/walk.rs b/crates/sigilbuzz-subset/src/cff/walk.rs index df2fd9e5..2c5bee58 100644 --- a/crates/sigilbuzz-subset/src/cff/walk.rs +++ b/crates/sigilbuzz-subset/src/cff/walk.rs @@ -36,9 +36,11 @@ //! it reads, inside subroutines too, and one for each value a `blend` //! leaves, and fails once the budget is spent. Subroutines that call //! each other many times can describe far more work than the table's -//! size. +//! size. Its memory follows the subroutines the glyphs reach, not the +//! size of the INDEXes: Font DICTs that share one Subrs INDEX keep +//! nothing per entry they do not reach. -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use sigilbuzz::tables::variation_store::ItemVariationStore; @@ -105,11 +107,10 @@ impl<'a> BlendRegions<'a> { } } -/// How far the walk has read one subroutine. +/// How far the walk has read one subroutine it reached. A subroutine +/// not reached has no entry. #[derive(Debug, Clone)] enum Visit { - /// Not reached yet. - Unvisited, /// Being read for the first time: a call to it now is recursion. Reading, /// Read, with the calls its reading found. @@ -120,7 +121,7 @@ impl Visit { fn calls(&self) -> Option<&[SubrCall]> { match self { Self::Read(calls) => Some(calls), - _ => None, + Self::Reading => None, } } } @@ -130,18 +131,28 @@ impl Visit { pub(crate) struct FdWalk<'a> { locals: &'a [&'a [u8]], vsindex: u16, - local_visits: Vec, - /// Global subroutines this Font DICT's glyphs reached, in the - /// order the walk first reached them. - reached_globals: Vec, - /// Marks the globals in `reached_globals` in the walk's stamps. - stamp: u32, + /// The local subroutines reached, by index. + local_visits: BTreeMap, + /// Global subroutines this Font DICT's glyphs reached. + reached_globals: BTreeSet, } -impl FdWalk<'_> { +impl<'a> FdWalk<'a> { + /// The bodies of a Font DICT with local subroutines `locals` and + /// Private DICT `vsindex`, ready for its glyphs. + pub(crate) fn new(locals: &'a [&'a [u8]], vsindex: u16) -> Self { + Self { + locals, + vsindex, + local_visits: BTreeMap::new(), + reached_globals: BTreeSet::new(), + } + } + /// The calls of local subroutine `index`, when a glyph reached it. pub(crate) fn local_calls(&self, index: usize) -> Option<&[SubrCall]> { - self.local_visits.get(index).and_then(Visit::calls) + let index = u32::try_from(index).ok()?; + self.local_visits.get(&index).and_then(Visit::calls) } /// The local subroutines the glyphs reached, ascending. @@ -151,19 +162,15 @@ impl FdWalk<'_> { /// The global subroutines this Font DICT's glyphs reached, ascending. pub(crate) fn reached_globals(&self) -> Vec { - let mut out = self.reached_globals.clone(); - out.sort_unstable(); - out + self.reached_globals.iter().copied().collect() } } /// The walk over every kept glyph of one table. See the module docs. pub(crate) struct CharstringWalk<'a> { globals: &'a [&'a [u8]], - global_visits: Vec, - /// Per global: the stamp of the last Font DICT that reached it. - global_stamps: Vec, - next_stamp: u32, + /// The global subroutines reached, by index. + global_visits: BTreeMap, /// `Some` for CFF2, whose `blend` and `vsindex` operators the walk /// runs. A CFF1 charstring has neither, and the walk clears the /// stack at those opcodes as the per-body scanner does. @@ -216,36 +223,17 @@ impl<'a> CharstringWalk<'a> { ) -> Self { Self { globals, - global_visits: alloc::vec![Visit::Unvisited; globals.len()], - global_stamps: alloc::vec![0; globals.len()], - next_stamp: 0, + global_visits: BTreeMap::new(), blend, budget, next_frame: 0, } } - /// The bodies of a Font DICT with local subroutines `locals` and - /// Private DICT `vsindex`, ready for its glyphs. - pub(crate) fn fd(&mut self, locals: &'a [&'a [u8]], vsindex: u16) -> FdWalk<'a> { - self.next_stamp = self.next_stamp.wrapping_add(1); - if self.next_stamp == 0 { - // A wrapped stamp could match a stale one; start over. - self.global_stamps.iter_mut().for_each(|s| *s = 0); - self.next_stamp = 1; - } - FdWalk { - locals, - vsindex, - local_visits: alloc::vec![Visit::Unvisited; locals.len()], - reached_globals: Vec::new(), - stamp: self.next_stamp, - } - } - /// The calls of global subroutine `index`, when a glyph reached it. pub(crate) fn global_calls(&self, index: usize) -> Option<&[SubrCall]> { - self.global_visits.get(index).and_then(Visit::calls) + let index = u32::try_from(index).ok()?; + self.global_visits.get(&index).and_then(Visit::calls) } /// The global subroutines any glyph reached, ascending. @@ -449,51 +437,37 @@ impl<'a> CharstringWalk<'a> { index: usize, depth: usize, ) -> Result<(), SubsetError> { - let (body, visit) = match kind { - SubrKind::Local => ( - fd.locals.get(index).copied(), - fd.local_visits.get_mut(index), - ), - SubrKind::Global => { - if let Some(stamp) = self.global_stamps.get_mut(index) { - if *stamp != fd.stamp { - *stamp = fd.stamp; - fd.reached_globals.push(index as u32); - } - } - ( - self.globals.get(index).copied(), - self.global_visits.get_mut(index), - ) - } - }; - let (Some(body), Some(visit)) = (body, visit) else { - return Err(SubsetError::Unsupported( - "CFF subroutine index out of range", - )); + const OUT_OF_RANGE: SubsetError = + SubsetError::Unsupported("CFF subroutine index out of range"); + let body = match kind { + SubrKind::Local => fd.locals.get(index).copied(), + SubrKind::Global => self.globals.get(index).copied(), + } + .ok_or(OUT_OF_RANGE)?; + let key = u32::try_from(index).map_err(|_| OUT_OF_RANGE)?; + if kind == SubrKind::Global { + fd.reached_globals.insert(key); + } + let visits = match kind { + SubrKind::Local => &mut fd.local_visits, + SubrKind::Global => &mut self.global_visits, }; - let first = match visit { - Visit::Read(_) => false, - Visit::Reading => { + match visits.get(&key) { + Some(Visit::Read(_)) => return self.run(fd, glyph, body, depth, None), + Some(Visit::Reading) => { return Err(SubsetError::Unsupported("CFF subroutine reaches itself")); } - Visit::Unvisited => { - *visit = Visit::Reading; - true + None => { + visits.insert(key, Visit::Reading); } - }; - if !first { - return self.run(fd, glyph, body, depth, None); } let mut calls = Vec::new(); self.run(fd, glyph, body, depth, Some(&mut calls))?; - let visit = match kind { - SubrKind::Local => fd.local_visits.get_mut(index), - SubrKind::Global => self.global_visits.get_mut(index), + let visits = match kind { + SubrKind::Local => &mut fd.local_visits, + SubrKind::Global => &mut self.global_visits, }; - if let Some(visit) = visit { - *visit = Visit::Read(calls); - } + visits.insert(key, Visit::Read(calls)); Ok(()) } @@ -524,12 +498,11 @@ impl<'a> CharstringWalk<'a> { } /// The indexes of the `visits` that were read, ascending. -fn kept(visits: &[Visit]) -> Vec { +fn kept(visits: &BTreeMap) -> Vec { visits .iter() - .enumerate() .filter(|(_, v)| matches!(v, Visit::Read(_))) - .map(|(i, _)| i as u32) + .map(|(&i, _)| i) .collect() } diff --git a/crates/sigilbuzz-subset/src/cff/walk/tests.rs b/crates/sigilbuzz-subset/src/cff/walk/tests.rs index c922dea7..b50b0446 100644 --- a/crates/sigilbuzz-subset/src/cff/walk/tests.rs +++ b/crates/sigilbuzz-subset/src/cff/walk/tests.rs @@ -67,7 +67,7 @@ fn walk_cff1<'a>( globals: &'a [&'a [u8]], ) -> Result { let mut walk = CharstringWalk::new(globals, None, BIG); - let mut fd = walk.fd(locals, 0); + let mut fd = FdWalk::new(locals, 0); let calls = walk.glyph(&mut fd, charstring)?; Ok((calls, fd.kept_locals(), walk.kept_globals())) } @@ -93,7 +93,7 @@ fn blended_stems_size_the_mask() { &[OP_CALLSUBR], ]); let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); let calls = walk.glyph(&mut fd, &charstring).expect("walk"); assert_eq!(calls.len(), 1); assert_eq!(calls[0].kind, SubrKind::Local); @@ -126,10 +126,10 @@ fn private_dict_vsindex_picks_the_region_count() { &[OP_CALLSUBR], ]); let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); - let mut fd = walk.fd(&locals, 1); + let mut fd = FdWalk::new(&locals, 1); let calls = walk.glyph(&mut fd, &charstring).expect("walk"); assert_eq!(calls.len(), 1); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); assert!(walk.glyph(&mut fd, &charstring).is_err()); } @@ -147,7 +147,7 @@ fn vsindex_past_the_store_blends_no_deltas() { &[OP_BLEND, OP_HSTEMHM, OP_HINTMASK, 9], ]); let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); - let mut fd = walk.fd(&[], 0); + let mut fd = FdWalk::new(&[], 0); walk.glyph(&mut fd, &charstring).expect("walk"); // A negative vsindex saturates to 0, which has four regions. let negative = cs(&[ @@ -173,7 +173,7 @@ fn a_missing_store_blends_no_deltas() { ]); for bytes in [None, Some(&[0u8, 9][..])] { let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(bytes)), BIG); - let mut fd = walk.fd(&[], 0); + let mut fd = FdWalk::new(&[], 0); walk.glyph(&mut fd, &charstring).expect("walk"); } } @@ -197,7 +197,7 @@ fn stems_of_the_caller_size_a_mask_in_a_subroutine() { &[OP_CALLSUBR, OP_ENDCHAR], ]); let mut walk = CharstringWalk::new(&[], None, BIG); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); let calls = walk.glyph(&mut fd, &charstring).expect("walk"); assert_eq!(calls.len(), 1); assert_eq!(fd.kept_locals(), [0, 1]); @@ -268,7 +268,7 @@ fn a_subroutine_is_read_once_and_kept_once() { let locals: [&[u8]; 3] = [&local_0, &local_1, &local_2]; let glyph = cs(&[&subr_number(0, 3), &[OP_CALLSUBR, OP_ENDCHAR]]); let mut walk = CharstringWalk::new(&[], None, BIG); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); walk.glyph(&mut fd, &glyph).expect("first glyph"); walk.glyph(&mut fd, &glyph).expect("second glyph"); assert_eq!(fd.kept_locals(), [0, 1]); @@ -302,10 +302,10 @@ fn globals_are_kept_per_fd_reached() { let calls_0 = cs(&[&subr_number(0, 2), &[OP_CALLGSUBR, OP_ENDCHAR]]); let calls_1 = cs(&[&subr_number(1, 2), &[OP_CALLGSUBR, OP_ENDCHAR]]); let mut walk = CharstringWalk::new(&globals, None, BIG); - let mut fd_a = walk.fd(&[], 0); + let mut fd_a = FdWalk::new(&[], 0); walk.glyph(&mut fd_a, &calls_1).expect("walk"); walk.glyph(&mut fd_a, &calls_1).expect("walk"); - let mut fd_b = walk.fd(&[], 0); + let mut fd_b = FdWalk::new(&[], 0); walk.glyph(&mut fd_b, &calls_0).expect("walk"); walk.glyph(&mut fd_b, &calls_1).expect("walk"); assert_eq!(fd_a.reached_globals(), [1]); @@ -386,7 +386,7 @@ fn a_spent_budget_fails() { let refs: Vec<&[u8]> = locals.iter().map(Vec::as_slice).collect(); let charstring = cs(&[&subr_number(0, depth), &[OP_CALLSUBR, OP_ENDCHAR]]); let mut walk = CharstringWalk::new(&[], None, walk_budget(64)); - let mut fd = walk.fd(&refs, 0); + let mut fd = FdWalk::new(&refs, 0); let r = walk.glyph(&mut fd, &charstring); assert_eq!(r.unwrap_err(), WORK_EXCEEDED); } @@ -425,7 +425,7 @@ fn malformed_charstrings_fail() { ]; for (what, charstring) in cases { let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); assert!(walk.glyph(&mut fd, &charstring).is_err(), "{what}"); } // A blended value is no subroutine number. @@ -437,7 +437,7 @@ fn malformed_charstrings_fail() { &[OP_BLEND, OP_CALLSUBR], ]); let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(Some(&ivs))), BIG); - let mut fd = walk.fd(&locals, 0); + let mut fd = FdWalk::new(&locals, 0); assert!(walk.glyph(&mut fd, &blended).is_err()); } @@ -464,7 +464,7 @@ fn a_blend_charges_the_values_it_leaves() { charstring.push(OP_BLEND); for budget in [1026, 1025] { let mut walk = CharstringWalk::new(&[], Some(BlendRegions::new(None)), budget); - let mut fd = walk.fd(&[], 0); + let mut fd = FdWalk::new(&[], 0); let r = walk.glyph(&mut fd, &charstring); assert_eq!(r.is_ok(), budget == 1026, "budget {budget}"); } @@ -479,6 +479,22 @@ fn a_blend_charges_the_values_it_leaves() { Some(BlendRegions::new(None)), walk_budget(repeated.len()), ); - let mut fd = walk.fd(&[], 0); + let mut fd = FdWalk::new(&[], 0); assert_eq!(walk.glyph(&mut fd, &repeated).unwrap_err(), WORK_EXCEEDED); } + +#[test] +fn font_dicts_keep_only_the_locals_they_reach() { + // Many Font DICTs over one large Subrs INDEX: each keeps an entry + // for the one local it reaches. + let filler = [OP_RETURN]; + let locals: Vec<&[u8]> = alloc::vec![&filler; 60_000]; + let charstring = cs(&[&subr_number(5, locals.len()), &[OP_CALLSUBR, OP_ENDCHAR]]); + let mut walk = CharstringWalk::new(&[], None, BIG); + for _ in 0..256 { + let mut fd = FdWalk::new(&locals, 0); + walk.glyph(&mut fd, &charstring).expect("walk"); + assert_eq!(fd.local_visits.len(), 1); + assert_eq!(fd.kept_locals(), [5]); + } +} diff --git a/crates/sigilbuzz-subset/src/cff2/subset.rs b/crates/sigilbuzz-subset/src/cff2/subset.rs index 653cfe81..0fe5d014 100644 --- a/crates/sigilbuzz-subset/src/cff2/subset.rs +++ b/crates/sigilbuzz-subset/src/cff2/subset.rs @@ -104,7 +104,7 @@ pub fn subset_non_identity(cff_bytes: &[u8], kept_gids: &[u16]) -> Result Date: Sun, 4 Oct 2026 11:21:16 -0800 Subject: [PATCH 9/9] Make AxisLimit and UserInstanceInput non-exhaustive Review of #294 (nit). Both are new in this release, so marking them `#[non_exhaustive]` now costs nothing, and lets a later release add an axis setting (narrowing an axis, say) or an input field without breaking callers. `UserInstanceInput` gains `new`, `with_axis`, `with_unnamed_axes_kept` and `with_var_tables_dropped` to build one outside the crate; its fields stay public. Part of #290. --- crates/sigilbuzz-subset/src/instance.rs | 48 +++++++++++++++++-- .../src/instance/tests/user_and_style.rs | 16 +++---- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/sigilbuzz-subset/src/instance.rs b/crates/sigilbuzz-subset/src/instance.rs index ba422ce5..ac914166 100644 --- a/crates/sigilbuzz-subset/src/instance.rs +++ b/crates/sigilbuzz-subset/src/instance.rs @@ -334,7 +334,11 @@ impl Default for InstanceInput { /// One axis' setting for [`instance_user`], in the axis' own units, the /// user space `fvar` describes (`wght` 100 to 900, say). +/// +/// More settings may come, such as narrowing an axis, so a `match` on +/// it needs a wildcard arm. #[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] pub enum AxisLimit { /// Pins the axis at this value, clamped to the axis' range as /// HarfBuzz's `hb_subset_input_pin_axis_location` clamps it. A NaN @@ -363,7 +367,12 @@ pub enum AxisLimit { } /// Inputs to [`instance_user`]. +/// +/// Build one with [`UserInstanceInput::new`] and the `with_` methods; +/// fields may be added, so it cannot be written as a struct literal +/// outside this crate. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct UserInstanceInput { /// Axis settings by axis tag. A later setting for a tag wins over an /// earlier one, and a setting applies to every axis with its tag. A @@ -390,6 +399,40 @@ impl Default for UserInstanceInput { } } +impl UserInstanceInput { + /// An input that names no axis: every axis is pinned at its + /// default, and the variation tables go. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The input with axis `tag` set to `limit`, after the settings it + /// already has. + #[must_use] + pub fn with_axis(mut self, tag: [u8; 4], limit: AxisLimit) -> Self { + self.axes.push((tag, limit)); + self + } + + /// The input with the axes it does not name kept variable (`true`) + /// or pinned at their defaults (`false`). See + /// [`UserInstanceInput::keep_unnamed_axes`]. + #[must_use] + pub fn with_unnamed_axes_kept(mut self, keep: bool) -> Self { + self.keep_unnamed_axes = keep; + self + } + + /// The input with the variable-font tables dropped (`true`) or kept. + /// See [`UserInstanceInput::drop_var_tables`]. + #[must_use] + pub fn with_var_tables_dropped(mut self, drop: bool) -> Self { + self.drop_var_tables = drop; + self + } +} + /// Instances `face` at axis locations given in user units, as HarfBuzz's /// instancer takes them (`hb_subset_input_pin_axis_location`, /// `hb_subset_input_set_axis_range`). @@ -415,10 +458,7 @@ impl Default for UserInstanceInput { /// /// let bytes = std::fs::read("./MyFont-VF.ttf").unwrap(); /// let face = Face::parse_bytes(&bytes, 0).unwrap(); -/// let input = UserInstanceInput { -/// axes: vec![(*b"wght", AxisLimit::Pin(700.0))], -/// ..Default::default() -/// }; +/// let input = UserInstanceInput::new().with_axis(*b"wght", AxisLimit::Pin(700.0)); /// let bold = instance_user(&face, &input).unwrap(); /// std::fs::write("./MyFont-Bold.ttf", &bold.bytes).unwrap(); /// ``` diff --git a/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs index 2408a98c..8f2ddf68 100644 --- a/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs +++ b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs @@ -18,10 +18,10 @@ fn weight_axis() -> VariationAxis { } fn user(axes: &[([u8; 4], AxisLimit)]) -> UserInstanceInput { - UserInstanceInput { - axes: axes.to_vec(), - ..UserInstanceInput::default() - } + axes.iter() + .fold(UserInstanceInput::new(), |input, &(tag, limit)| { + input.with_axis(tag, limit) + }) } /// Reads a big-endian `u16` at `off` of table `tag`. @@ -114,10 +114,10 @@ fn unnamed_axes_are_pinned_or_kept() { let pinned = instance_user(&face, &UserInstanceInput::default()).unwrap(); let pinned = Face::parse_bytes(&pinned.bytes, 0).unwrap(); assert!(pinned.fvar().unwrap().is_none(), "a static instance"); - let kept = UserInstanceInput { - keep_unnamed_axes: true, - ..UserInstanceInput::default() - }; + let kept = UserInstanceInput::new().with_unnamed_axes_kept(true); + assert!(kept.keep_unnamed_axes && kept.drop_var_tables && kept.axes.is_empty()); + let kept_tables = UserInstanceInput::new().with_var_tables_dropped(false); + assert!(!kept_tables.drop_var_tables); let kept = instance_user(&face, &kept).unwrap(); let kept = Face::parse_bytes(&kept.bytes, 0).unwrap(); assert!(kept.fvar().unwrap().is_some(), "the axis stays");