diff --git a/crates/sigilbuzz-subset/README.md b/crates/sigilbuzz-subset/README.md index 64b926c0..a315618b 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. @@ -44,9 +46,16 @@ 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. +`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/cff.rs b/crates/sigilbuzz-subset/src/cff.rs index 5ce8c9fa..129ccda8 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::{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}; // ---------------------------------------------------------------------------- // 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,72 +283,61 @@ 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(); - let old_local_count = parsed.local_subrs.len(); - let old_global_count = parsed.global_subrs.len(); - - // Rewrite each kept charstring (cloned, then mutated). - let mut new_charstrings: Vec> = 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, - )?; + // Run every kept glyph through its subroutine calls: the + // subroutines reached are the ones kept, and each body's call + // sites are found with its masks sized by the stems its callers + // declared (see `walk`). + let mut walk = CharstringWalk::new(&parsed.global_subrs, None, walk_budget(cff_bytes.len())); + let mut fd_walk = FdWalk::new(&parsed.local_subrs, 0); + let mut charstring_calls: Vec> = 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(); + + // 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 local subr. - let mut new_local_subrs: Vec> = kept_local_idx + // 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() - .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, - new_local_count, - new_global_count, - &local_renumber, - &global_renumber, - )?; - } - - // Rewrite each kept global subr. - let mut new_global_subrs: Vec> = kept_global_idx + .zip(&charstring_calls) + .map(|(cs, calls)| renumber(cs, calls)) + .collect::>()?; + let new_local_subrs: Vec> = kept_local_idx .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, - )?; - } + .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..f7e994de 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,142 @@ 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")) +} + +/// 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 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 +/// 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], + 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 { + 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 remap = match call.kind { + SubrKind::Local => local, + SubrKind::Global => global, }; - 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 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)), } - 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..f8cde6d5 100644 --- a/crates/sigilbuzz-subset/src/cff/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/cid.rs @@ -1,13 +1,12 @@ //! 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; -use super::charstring::{ - compute_cross_fd_globals, compute_kept_subrs, renumber_charstring_impl, scan_subr_calls, - SubrKind, -}; +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, @@ -17,6 +16,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 +31,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. @@ -244,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. @@ -259,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, @@ -305,36 +318,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 = 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 { - 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,31 +347,20 @@ 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)?; - - // 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. - // 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. + 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(), + ) + }), + ); + + // 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 +373,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); @@ -484,29 +425,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. + // 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_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, - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - |old_g| override_slot(overrides, old_g), - )?; - new_charstrings.push(cs); + 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], + &local, + &global, + )?); } // Renumber each kept local subr (per-FD). Locals are FD-scoped so @@ -514,25 +478,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 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, - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - |old_g| override_slot(overrides, old_g), - )?; + 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(), + &local, + &global, + )?); } new_per_fd_local_subrs.push(new_locals); } @@ -545,48 +499,41 @@ 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 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 mut body = parsed.global_subrs[old_g as usize].to_vec(); - 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, - new_local_count, - new_global_count, - &per_fd_local_renumber[fd_pos], - &global_renumber, - |old_g| override_slot(overrides, old_g), - )?; + 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 (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. - renumber_charstring_impl( - &mut body, - 0, - old_global_count, - 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.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..272279f1 100644 --- a/crates/sigilbuzz-subset/src/cff/tests/cid.rs +++ b/crates/sigilbuzz-subset/src/cff/tests/cid.rs @@ -587,3 +587,82 @@ 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()); + } +} + +#[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/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..2c5bee58 --- /dev/null +++ b/crates/sigilbuzz-subset/src/cff/walk.rs @@ -0,0 +1,510 @@ +//! 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 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. 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, BTreeSet}; +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 it reached. A subroutine +/// not reached has no entry. +#[derive(Debug, Clone)] +enum Visit { + /// 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), + Self::Reading => 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, + /// The local subroutines reached, by index. + local_visits: BTreeMap, + /// Global subroutines this Font DICT's glyphs reached. + reached_globals: BTreeSet, +} + +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]> { + let index = u32::try_from(index).ok()?; + 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 { + 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]], + /// 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. + 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: BTreeMap::new(), + blend, + budget, + next_frame: 0, + } + } + + /// The calls of global subroutine `index`, when a glyph reached it. + pub(crate) fn global_calls(&self, index: usize) -> Option<&[SubrCall]> { + let index = u32::try_from(index).ok()?; + 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> { + 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, + }; + 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")); + } + None => { + visits.insert(key, Visit::Reading); + } + } + let mut calls = Vec::new(); + self.run(fd, glyph, body, depth, Some(&mut calls))?; + let visits = match kind { + SubrKind::Local => &mut fd.local_visits, + SubrKind::Global => &mut self.global_visits, + }; + visits.insert(key, 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)?; + // 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; + } + Ok(()) + } +} + +/// The indexes of the `visits` that were read, ascending. +fn kept(visits: &BTreeMap) -> Vec { + visits + .iter() + .filter(|(_, v)| matches!(v, Visit::Read(_))) + .map(|(&i, _)| i) + .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..b50b0446 --- /dev/null +++ b/crates/sigilbuzz-subset/src/cff/walk/tests.rs @@ -0,0 +1,500 @@ +//! 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 = FdWalk::new(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 = 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); + 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 = FdWalk::new(&locals, 1); + let calls = walk.glyph(&mut fd, &charstring).expect("walk"); + assert_eq!(calls.len(), 1); + let mut fd = FdWalk::new(&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 = FdWalk::new(&[], 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 = FdWalk::new(&[], 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 = 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]); + 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 = 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]); + 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 = 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 = 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]); + 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 = FdWalk::new(&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 = FdWalk::new(&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 = FdWalk::new(&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"); +} + +#[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 = FdWalk::new(&[], 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 = 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.rs b/crates/sigilbuzz-subset/src/cff2.rs index cdac9164..f4fea56a 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. @@ -327,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; @@ -356,7 +390,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())); @@ -509,6 +543,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..0b68838c 100644 --- a/crates/sigilbuzz-subset/src/cff2/bake.rs +++ b/crates/sigilbuzz-subset/src/cff2/bake.rs @@ -6,12 +6,12 @@ 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, - 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, + bake_token_budget, biased_subr, charge_token, decode_operand_f64, encode_charstring_number_f64, + 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, @@ -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,8 +332,13 @@ 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"))?; + if self.stack.len() >= MAX_STACK { + return Err(SubsetError::Unsupported( + "CFF2 bake: operand stack past 513 operands", + )); + } self.stack.push(val); pos += len; continue; @@ -341,7 +354,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", )); @@ -444,32 +457,41 @@ 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..) .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..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)] @@ -549,7 +545,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 +556,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/subset.rs b/crates/sigilbuzz-subset/src/cff2/subset.rs index 7da7529c..0fe5d014 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, 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; @@ -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,174 +90,124 @@ 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 = FdWalk::new(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 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()); - 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 per_fd_kept_local: Vec> = fds.iter().map(FdWalk::kept_locals).collect(); + let kept_global_idx = walk.kept_globals(); + 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. + // Step 5: rewrite each kept charstring and subroutine at the call + // sites the walk found in it. 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, - new_local_count, - new_global_count, - fd_local_renumber, - &global_renumber, - )?; - new_charstrings.push(cs); + 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], + &local, + &global, + )?); } // Per-FD local subrs (renumbered). 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 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 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(), + &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 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, - 0, - new_global_count, - &empty_local, - &global_renumber, - )?; + 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(rewrite_calls( + parsed.global_subrs[g as usize], + walk.global_calls(g as usize).unwrap_or_default(), + &no_locals, + &global, + )?); } // FDSelect bytes. diff --git a/crates/sigilbuzz-subset/src/cff2/tests.rs b/crates/sigilbuzz-subset/src/cff2/tests.rs index 3d40f582..5de13216 100644 --- a/crates/sigilbuzz-subset/src/cff2/tests.rs +++ b/crates/sigilbuzz-subset/src/cff2/tests.rs @@ -269,3 +269,220 @@ 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); +} + +#[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()); +} + +#[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:?}"); + } +} 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/instance.rs b/crates/sigilbuzz-subset/src/instance.rs index d6f10706..ac914166 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 //! @@ -70,6 +72,20 @@ //! 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 +//! 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 @@ -91,12 +107,18 @@ //! //! 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. +//! +//! 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 //! @@ -183,9 +205,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; @@ -195,6 +219,7 @@ mod metrics_var; mod partial; mod region; mod store_remap; +mod style; use crate::base::BaseBake; use crate::sfnt; @@ -205,9 +230,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; @@ -223,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. @@ -305,6 +332,292 @@ 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 + /// 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`]. +/// +/// 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 + /// 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, + } + } +} + +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`). +/// +/// 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::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(); +/// ``` +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) { + let default = axis.default_value; + (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 { + 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 { @@ -342,7 +655,22 @@ 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. [`instance_user`] takes the +/// user values themselves. 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 @@ -378,7 +706,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 @@ -634,6 +996,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. @@ -672,25 +1035,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 @@ -698,12 +1075,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` @@ -745,61 +1144,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..a270b13b --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/cff2_metrics.rs @@ -0,0 +1,224 @@ +//! 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 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. +//! +//! 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; +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. +#[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 { + // 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 = 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 = round_half_up(self.max.1); + (top, round_half_up(self.min.1).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..9e816e43 --- /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 up, so -2.5 goes to -2. + assert_eq!( + e, + Extents { + x_bearing: -2, + width: 41 + 2, + 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..3c84cfbe 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; + } + // 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 + } } /// 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..3aa9a5fb --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/style.rs @@ -0,0 +1,172 @@ +//! 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; +use crate::util::round_half_up; + +/// 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") { + // 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, round_half_up(width_class(width)) 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 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(); + } +} + +#[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()); + // 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()); + } +} 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..8f2ddf68 --- /dev/null +++ b/crates/sigilbuzz-subset/src/instance/tests/user_and_style.rs @@ -0,0 +1,248 @@ +//! 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; + +/// 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, + } +} + +fn user(axes: &[([u8; 4], AxisLimit)]) -> UserInstanceInput { + axes.iter() + .fold(UserInstanceInput::new(), |input, &(tag, limit)| { + input.with_axis(tag, limit) + }) +} + +/// 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 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(); + 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_user_matches_normalized_coords() { + let face = rubik_face(); + let axis = face.fvar().unwrap().unwrap().axes()[0]; + 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::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"); +} + +#[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(); + 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); +} + +#[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)>); + +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 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 { + 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_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(); + 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 39f58808..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 //! @@ -69,6 +70,13 @@ //! (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 +//! 64 tokens per byte of the table, plus 2^22, and fails past that. //! //! # What happens to each table //! @@ -122,6 +130,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` @@ -227,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;