From 3ed38b1053656e91d9b7e6e84ccb9d8c83d5449c Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 15:20:31 -0800 Subject: [PATCH 1/8] Read a VARC table that does not parse as absent Face::glyph_outline_at_coords, GlyphOutlines and the glyph extents shaping reads all asked Face::varc first and returned its error, so a font whose VARC table this parser rejects failed every glyph: plain glyphs and .notdef included. Shaping then failed with Malformed on text whose marks fall back to positioning by glyph extents, and vertical origins fell back to the ascender. Such fonts exist: after 14.5.0, HarfBuzz and the VARC spec moved the MultiItemVariationStore to a new layout (a u32 region count, and an Offset32 from each MultiVarData to its delta-set INDEX), and HarfBuzz's own test fonts moved with it. HarfBuzz 14.5.0 drops a VARC its sanitizer rejects and draws and shapes the font from glyf or CFF. Those paths now read the table through Face::drawable_varc, which counts a VARC that does not parse as absent. Face::varc still reports the parse error. tests/varc_unparsable.rs splices a VARC with a new-layout store into a CFF font and a glyf font: every glyph draws through every outline path as without the table, and "\u{e1}b" (fallback marks) and a vertical "abc" (origins from extents) shape the same. Before, both tests failed on the first glyph with "CFF2 INDEX offSize out of range". --- src/face/glyph_outlines.rs | 12 +- src/face/outlines.rs | 5 +- src/face/variations.rs | 15 +++ src/shape/fallback/extents.rs | 12 +- tests/varc_unparsable.rs | 216 ++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 tests/varc_unparsable.rs diff --git a/src/face/glyph_outlines.rs b/src/face/glyph_outlines.rs index 76d53ff9..5bb662ae 100644 --- a/src/face/glyph_outlines.rs +++ b/src/face/glyph_outlines.rs @@ -24,7 +24,8 @@ use crate::tables::{tag, Cff, Cff2, Glyf, Gvar, Hmtx, Loca, Outline, OutlineSink /// them: the `CFF2` or `CFF ` table, or `glyf` with `loca`, `gvar` and /// the metrics its phantom points read, and `VARC`. Each glyph comes out /// exactly as [`Face::glyph_outline_at_coords`] draws it, errors -/// included: a table that does not read fails every glyph that needs it. +/// included: a table that does not read fails every glyph that needs it, +/// except `VARC`, which counts as absent then, as for the face. /// /// It caches through cells that are not thread-safe, so it is `Send` but /// not `Sync`: build one per thread. @@ -48,7 +49,8 @@ pub struct GlyphOutlines<'a> { face: Face<'a>, /// The coords as shaping reads them, rounded to F2DOT14. coords: Vec, - varc: OnceCell>>>, + /// The face's `VARC`, `None` when it has none or it does not parse. + varc: OnceCell>>, tables: OnceCell>>, } @@ -111,11 +113,7 @@ impl<'a> GlyphOutlines<'a> { /// The errors [`Face::glyph_outline_at_coords`] returns for the /// glyph. pub fn draw(&self, glyph_id: u16, sink: &mut S) -> Result { - let varc = self - .varc - .get_or_init(|| self.face.varc()) - .as_ref() - .map_err(Clone::clone)?; + let varc = self.varc.get_or_init(|| self.face.drawable_varc()); // A VARC composite is drawn by the walk the face draws it with: // its components carry coords of their own, so its leaves come // from tables the walk reads for them. diff --git a/src/face/outlines.rs b/src/face/outlines.rs index ea9638c2..543491be 100644 --- a/src/face/outlines.rs +++ b/src/face/outlines.rs @@ -175,7 +175,8 @@ impl<'a> Face<'a> { /// given. /// /// A glyph the font's `VARC` table covers is drawn from its - /// components, as HarfBuzz draws it: + /// components, as HarfBuzz draws it (a `VARC` table that does not + /// parse counts as absent, as HarfBuzz drops it): /// /// - Each component is drawn at its own coords, which start from /// the coords of the glyph it belongs to, or from the font's @@ -201,7 +202,7 @@ impl<'a> Face<'a> { coords: &[f32], ) -> Result> { let coords = f2dot14_coords(coords); - if let Some(varc) = self.varc()? { + if let Some(varc) = self.drawable_varc() { let mut out = Outline::new(); if VarcDraw::new(self, &varc, &coords).draw(glyph_id, &mut out)? { return Ok(Some(out)); diff --git a/src/face/variations.rs b/src/face/variations.rs index 7aee318c..e134f550 100644 --- a/src/face/variations.rs +++ b/src/face/variations.rs @@ -84,6 +84,10 @@ impl<'a> Face<'a> { /// shipped to date only by Chrome's experimental VARC fonts and /// a handful of demo files. Returns `Ok(None)` for the vast /// majority of fonts that don't carry it. + /// + /// The outline methods, [`crate::GlyphOutlines`] and shaping treat a + /// `VARC` table this fails to parse as absent, as HarfBuzz's + /// sanitizer drops it, and draw every glyph from `glyf` or CFF. pub fn varc(&self) -> Result>> { match self.table_bytes(tag::VARC) { Ok(bytes) => Ok(Some(Varc::parse(bytes)?)), @@ -91,4 +95,15 @@ impl<'a> Face<'a> { Err(e) => Err(e), } } + + /// The `VARC` table glyphs are drawn through: [`Face::varc`], with a + /// table that does not parse (or whose bytes the directory does not + /// hold) read as absent. HarfBuzz 14.5.0 drops a `VARC` its + /// sanitizer rejects, such as one in the MultiItemVariationStore + /// layout HarfBuzz adopted after 14.5.0, and draws and shapes the + /// font as if it had none; failing every glyph instead would leave + /// the font unusable. + pub(crate) fn drawable_varc(&self) -> Option> { + self.varc().ok().flatten() + } } diff --git a/src/shape/fallback/extents.rs b/src/shape/fallback/extents.rs index 9fa8a4f3..fbfaf151 100644 --- a/src/shape/fallback/extents.rs +++ b/src/shape/fallback/extents.rs @@ -38,8 +38,10 @@ pub(in crate::shape) struct ExtentsTables<'a> { /// [`Face::phantom_vmtx`]). A `vmtx` that does not parse counts as /// absent, as HarfBuzz's sanitizer drops it. vmtx: OnceCell>>, - /// `VARC`, whose composites take precedence over `CFF2` and `CFF `. - varc: OnceCell>>>, + /// `VARC`, whose composites take precedence over `CFF2` and `CFF `; + /// `None` when the font has none or it does not parse, as the face's + /// outlines read it. + varc: OnceCell>>, /// The CFF table a glyph's outline comes from, `None` when the font /// draws its glyphs some other way. cff: OnceCell>>>, @@ -86,11 +88,7 @@ impl<'a> ExtentsTables<'a> { if !has_cff { return Ok(None); } - let varc = self - .varc - .get_or_init(|| face.varc()) - .as_ref() - .map_err(Clone::clone)?; + let varc = self.varc.get_or_init(|| face.drawable_varc()); let cff = self.cff.get_or_init(|| { if face.record(tag::CFF2).is_some() { Some( diff --git a/tests/varc_unparsable.rs b/tests/varc_unparsable.rs new file mode 100644 index 00000000..0e46e218 --- /dev/null +++ b/tests/varc_unparsable.rs @@ -0,0 +1,216 @@ +//! A `VARC` table sigilbuzz cannot parse counts as absent. +//! +//! HarfBuzz 14.5.0 drops a `VARC` its sanitizer rejects and draws and +//! shapes the font from `glyf` or CFF. After 14.5.0, HarfBuzz and the +//! VARC spec moved the MultiItemVariationStore to a new layout (a 32-bit +//! region count, and an Offset32 to each MultiVarData's delta-set +//! INDEX), so fonts built to the updated spec carry a store this parser +//! rejects. Before, every glyph of such a font failed to draw, and +//! shaping failed (fallback mark positioning) or fell back to the +//! ascender (vertical origins), where HarfBuzz 14.5.0 ignores the table. +//! +//! The tests splice a `VARC` table with a new-layout store into a CFF +//! font and a `glyf` font, and check that every glyph draws, and every +//! run shapes, exactly as the font without `VARC`. + +use sigilbuzz::tables::{Outline, OutlineSink, PathOp}; +use sigilbuzz::{shape, Blob, Buffer, Direction, Face, Font}; + +const CFF_FONT: &[u8] = include_bytes!("fonts/SourceCodePro-Latin-Subset.otf"); +const GLYF_FONT: &[u8] = include_bytes!("fixtures/varc_parity.ttf"); + +/// A `VARC` table covering `covered`, each glyph one component naming +/// glyph 1, over a MultiItemVariationStore in the layout HarfBuzz +/// adopted after 14.5.0: `u32` region count, and an Offset32 from each +/// MultiVarData to its delta-set INDEX. +fn new_layout_varc(covered: &[u16]) -> Vec { + let mut coverage = vec![0, 1]; + coverage.extend_from_slice(&(covered.len() as u16).to_be_bytes()); + for g in covered { + coverage.extend_from_slice(&g.to_be_bytes()); + } + + let mut store = Vec::new(); + store.extend_from_slice(&1u16.to_be_bytes()); // format + store.extend_from_slice(&12u32.to_be_bytes()); // region list + store.extend_from_slice(&1u16.to_be_bytes()); // one MultiVarData + store.extend_from_slice(&30u32.to_be_bytes()); // at 30 + // SparseVarRegionList at 12: u32 count, Offset32 per region. + store.extend_from_slice(&1u32.to_be_bytes()); + store.extend_from_slice(&8u32.to_be_bytes()); + // The region at 20: axis 0, peak +1. + store.extend_from_slice(&1u16.to_be_bytes()); + store.extend_from_slice(&0u16.to_be_bytes()); + store.extend_from_slice(&0i16.to_be_bytes()); + store.extend_from_slice(&0x4000i16.to_be_bytes()); + store.extend_from_slice(&0x4000i16.to_be_bytes()); + assert_eq!(store.len(), 30); + // MultiVarData at 30: format, region indexes, Offset32 to the + // delta-set INDEX right after it. + store.push(1); + store.extend_from_slice(&1u16.to_be_bytes()); + store.extend_from_slice(&0u16.to_be_bytes()); + store.extend_from_slice(&9u32.to_be_bytes()); + // One delta set: one i8, 100. + store.extend_from_slice(&1u32.to_be_bytes()); + store.extend_from_slice(&[1, 1, 3, 0x00, 100]); + + // One record per covered glyph: TRANSFORM_HAS_VARIATION and + // HAVE_TRANSLATE_X, glyph 1, variation index 0, translate 0. + let record = [0x18, 0x00, 0x01, 0x00, 0x00, 0x00]; + let mut records = (covered.len() as u32).to_be_bytes().to_vec(); + records.push(1); // offSize + for i in 0..=covered.len() { + records.push((1 + i * record.len()) as u8); + } + for _ in covered { + records.extend_from_slice(&record); + } + + let mut out = Vec::new(); + out.extend_from_slice(&[0, 1, 0, 0]); // version 1.0 + let mut at = 24u32; + for part in [&coverage, &store] { + out.extend_from_slice(&at.to_be_bytes()); + at += part.len() as u32; + } + out.extend_from_slice(&0u32.to_be_bytes()); // no conditions + out.extend_from_slice(&0u32.to_be_bytes()); // no axis indices + out.extend_from_slice(&at.to_be_bytes()); // glyph records + out.extend_from_slice(&coverage); + out.extend_from_slice(&store); + out.extend_from_slice(&records); + out +} + +/// `font` with its tables rewritten into a new directory, `VARC` +/// replaced by `varc`, or dropped when `varc` is `None`. +fn with_varc(font: &[u8], varc: Option<&[u8]>) -> Vec { + let face = Face::parse_bytes(font, 0).unwrap(); + let mut tables: Vec<([u8; 4], &[u8])> = face + .records() + .iter() + .filter(|r| &r.tag != b"VARC") + .map(|r| (r.tag, face.table_bytes(r.tag).unwrap())) + .collect(); + if let Some(varc) = varc { + tables.push((*b"VARC", varc)); + } + tables.sort_by_key(|t| t.0); + let header = 12 + 16 * tables.len(); + let mut out = Vec::new(); + out.extend_from_slice(&font[..4]); + out.extend_from_slice(&(tables.len() as u16).to_be_bytes()); + out.extend_from_slice(&[0; 6]); + let mut body = Vec::new(); + for (tag, data) in &tables { + out.extend_from_slice(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&((header + body.len()) as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + while body.len() % 4 != 0 { + body.push(0); + } + } + out.extend_from_slice(&body); + out +} + +#[derive(Default)] +struct Recorder(Outline); + +impl OutlineSink for Recorder { + fn move_to(&mut self, x: f32, y: f32) { + self.0.push(PathOp::MoveTo { x, y }); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.push(PathOp::LineTo { x, y }); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.0.push(PathOp::QuadTo { cx, cy, x, y }); + } + fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + self.0.push(PathOp::CubicTo { + c1x, + c1y, + c2x, + c2y, + x, + y, + }); + } + fn close(&mut self) { + self.0.push(PathOp::Close); + } +} + +/// Every glyph of `with` draws, through every outline path, exactly as +/// the same glyph of `without` does. +fn assert_outlines_match(with: &Face<'_>, without: &Face<'_>, coords: &[f32]) { + let n = without.maxp().unwrap().num_glyphs; + let (a, b) = (with.glyph_outlines(coords), without.glyph_outlines(coords)); + for gid in 0..n { + let want = without.glyph_outline_at_coords(gid, coords).unwrap(); + assert_eq!( + with.glyph_outline_at_coords(gid, coords).unwrap(), + want, + "glyph {gid}" + ); + assert_eq!( + a.outline(gid).unwrap(), + b.outline(gid).unwrap(), + "glyph {gid}" + ); + assert_eq!(a.outline(gid).unwrap(), want, "glyph {gid}"); + let mut sink = Recorder::default(); + let drew = a.draw(gid, &mut sink).unwrap(); + assert_eq!(drew.then_some(sink.0), want, "glyph {gid}"); + if coords.is_empty() { + assert_eq!(with.glyph_outline(gid).unwrap(), want, "glyph {gid}"); + } + } +} + +#[test] +fn a_cff_font_with_an_unparsable_varc_draws_and_shapes_without_it() { + let varc = new_layout_varc(&[1, 2, 3]); + let with = with_varc(CFF_FONT, Some(&varc)); + let without = with_varc(CFF_FONT, None); + let (with_blob, without_blob) = (Blob::new(&with), Blob::new(&without)); + let with = Face::parse(&with_blob, 0).unwrap(); + let without = Face::parse(&without_blob, 0).unwrap(); + // The table is there, and this parser rejects its store. + assert!(with.varc().is_err()); + assert_outlines_match(&with, &without, &[]); + + // "á" decomposes to a and U+0301, which the font lacks: fallback + // mark positioning reads glyph extents. A vertical run without + // VORG or vmtx centers the extents for the origins. + for (text, direction) in [("\u{e1}b", Direction::Ltr), ("abc", Direction::Ttb)] { + let run = |face: &Face<'_>| { + let font = Font::new(face.clone(), 1000.0); + let mut buffer = Buffer::new(); + buffer.set_direction(direction); + buffer.push_str(text); + shape(&font, &buffer, &[]).unwrap().glyphs + }; + assert_eq!(run(&with), run(&without), "{text:?} {direction:?}"); + } +} + +#[test] +fn a_glyf_font_with_an_unparsable_varc_draws_without_it() { + // varc_parity.ttf with its VARC table swapped for one in the new + // layout: glyphs 3 and up draw their (empty) glyf outlines. + let varc = new_layout_varc(&[3, 4, 5]); + let with = with_varc(GLYF_FONT, Some(&varc)); + let without = with_varc(GLYF_FONT, None); + let (with_blob, without_blob) = (Blob::new(&with), Blob::new(&without)); + let with = Face::parse(&with_blob, 0).unwrap(); + let without = Face::parse(&without_blob, 0).unwrap(); + assert!(with.varc().is_err()); + for coords in [&[][..], &[0.5, -0.25, 0.75][..]] { + assert_outlines_match(&with, &without, coords); + } +} From 375eee82f31d1805e7c1102dac8e6e7c549833d9 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 15:26:09 -0800 Subject: [PATCH 2/8] Rotate VARC components as HarfBuzz's to_transform does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sincos_pi evaluated sine and cosine with a Taylor series that stopped at y^7 and y^8 over [-π, π], so a rotation past about 90 degrees came out wrong: at 180 degrees the sine was 0.075 off, and a box rotated about a center 500 units away landed 42 units from HarfBuzz's. HarfBuzz's own varc-6868 test font (the 14.5.0 version) drew glyph00002, with its 145-degree component, 2.1 units off. The decomposed transform is now built op for op as HarfBuzz's hb_transform_decomposed_t::to_transform builds it, in f32: translate, rotate by the angle in radians (half-turns times f32 π, as HarfBuzz multiplies by HB_PI), scale, skew by tanf of the angles, translate back, each skipping what HarfBuzz skips. Sine, cosine and tangent are worked out in f64 (the nearest quarter turn removed in two parts, a Taylor polynomial over [-π/4, π/4] good to about 1e-14) and rounded to f32, as libm's sinf, cosf and tanf return them. An angle that is not finite gives NaN, and one past 2^30 radians is taken modulo 2π first, so no loop can spin. Against HarfBuzz 14.5.0, the largest difference over the eight rotations of the reviewer's rot.ttf (45 to 180 and -162 to -90 degrees, center 500 units away) drops from 42.1 to 0.00003 units, varc-6868's glyph00002 from 2.10 to 0.00003, and varc_face.ttf gains rotations of 90, 135, 180, 270 and -135 degrees, one varied past a half turn, and a 72-degree skew, all within 0.00005 (49.5 units off before at 180). --- src/tables/varc.rs | 267 ++++++++++++++---------- src/tables/varc/tests.rs | 93 ++++++++- tests/fixtures/varc_face.expected | 72 ++++++- tests/fixtures/varc_face.ttf | Bin 3080 -> 3296 bytes tests/tools/build_varc_face_fixtures.py | 15 +- tests/varc_parity.rs | 13 +- 6 files changed, 329 insertions(+), 131 deletions(-) diff --git a/src/tables/varc.rs b/src/tables/varc.rs index 48e845db..1d5a0eb2 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -56,7 +56,7 @@ use alloc::vec::Vec; use crate::error::{Error, Result}; use crate::tables::layout::Coverage; use crate::tables::multi_var_store::{decode_tuple_values, read_cff2_index, MultiVarStore}; -use crate::tables::parse::{hb_roundf, Reader}; +use crate::tables::parse::{abs_f64, floor_f64, hb_roundf, Reader}; /// A parsed `VARC` table. #[derive(Debug, Clone)] @@ -910,24 +910,110 @@ impl Default for Decomposed { } impl Decomposed { - /// The affine the boring-expansion spec composes: + /// The affine HarfBuzz's `hb_transform_decomposed_t::to_transform` + /// builds, op for op in `f32`, with the angles in radians as + /// HarfBuzz takes them (half-turns times `f32` π): /// /// ```text - /// T(tx + tcx, ty + tcy) * R(rotation * π) * S(sx, sy) * - /// Skew(-skewX * π, skewY * π) * T(-tcx, -tcy) + /// translate(tx + tcx, ty + tcy), rotate(rotation), + /// scale(sx, sy), skew(-skewX, skewY), translate(-tcx, -tcy) /// ``` + /// + /// Returned in `[xx, xy, yx, yy, tx, ty]` order. fn to_affine(&self) -> [f32; 6] { - compose_affine( - self.tx, - self.ty, - self.rotation, - self.sx, - self.sy, - self.skew_x, - self.skew_y, - self.tcx, - self.tcy, - ) + let pi = core::f32::consts::PI; + let mut t = Affine::IDENTITY; + t.translate(self.tx + self.tcx, self.ty + self.tcy); + t.rotate(self.rotation * pi); + t.scale(self.sx, self.sy); + t.skew(-(self.skew_x * pi), self.skew_y * pi); + t.translate(-self.tcx, -self.tcy); + [t.xx, t.xy, t.yx, t.yy, t.x0, t.y0] + } +} + +/// HarfBuzz's `hb_transform_t`, with the operations +/// [`Decomposed::to_affine`] uses, each skipping the cases HarfBuzz +/// skips and summing in HarfBuzz's order. +#[derive(Clone, Copy)] +struct Affine { + xx: f32, + yx: f32, + xy: f32, + yy: f32, + x0: f32, + y0: f32, +} + +impl Affine { + const IDENTITY: Self = Self { + xx: 1.0, + yx: 0.0, + xy: 0.0, + yy: 1.0, + x0: 0.0, + y0: 0.0, + }; + + fn translate(&mut self, x: f32, y: f32) { + if x == 0.0 && y == 0.0 { + return; + } + self.x0 += self.xx * x + self.xy * y; + self.y0 += self.yx * x + self.yy * y; + } + + /// `self` times `b`: `b` applied first. + fn multiply(&mut self, b: Self) { + let a = *self; + *self = Self { + xx: a.xx * b.xx + a.xy * b.yx, + yx: a.yx * b.xx + a.yy * b.yx, + xy: a.xx * b.xy + a.xy * b.yy, + yy: a.yx * b.xy + a.yy * b.yy, + x0: a.xx * b.x0 + a.xy * b.y0 + a.x0, + y0: a.yx * b.x0 + a.yy * b.y0 + a.y0, + }; + } + + fn rotate(&mut self, radians: f32) { + if radians == 0.0 { + return; + } + let (s, c) = sincos(radians); + self.multiply(Self { + xx: c, + yx: s, + xy: -s, + yy: c, + x0: 0.0, + y0: 0.0, + }); + } + + fn scale(&mut self, sx: f32, sy: f32) { + if sx == 1.0 && sy == 1.0 { + return; + } + self.xx *= sx; + self.yx *= sx; + self.xy *= sy; + self.yy *= sy; + } + + fn skew(&mut self, skew_x: f32, skew_y: f32) { + if skew_x == 0.0 && skew_y == 0.0 { + return; + } + let tan_or_zero = |radians: f32| if radians == 0.0 { 0.0 } else { tan(radians) }; + self.multiply(Self { + xx: 1.0, + yx: tan_or_zero(skew_y), + xy: tan_or_zero(skew_x), + yy: 1.0, + x0: 0.0, + y0: 0.0, + }); } } @@ -1089,100 +1175,71 @@ fn decode_axis_indices(data: &[u8]) -> Vec { out } -/// Builds the affine matrix from VARC's transform fields. Matches the -/// boring-expansion-spec composition order: -/// -/// ```text -/// T(tx + tcx, ty + tcy) * R(rotation * π) * -/// S(sx, sy) * Skew(-skewX * π, skewY * π) * T(-tcx, -tcy) -/// ``` -/// -/// Returned in `[xx, xy, yx, yy, tx_eff, ty_eff]` row-major form. -#[allow(clippy::too_many_arguments)] -fn compose_affine( - tx: f32, - ty: f32, - rotation: f32, - sx: f32, - sy: f32, - skew_x: f32, - skew_y: f32, - tcx: f32, - tcy: f32, -) -> [f32; 6] { - // core has no sin or cos without std, so `sincos_pi` evaluates a - // Taylor polynomial instead of calling libm. - let (cos_r, sin_r) = sincos_pi(rotation); - let (cos_skx, sin_skx) = sincos_pi(-skew_x); - let (cos_sky, sin_sky) = sincos_pi(skew_y); - - // Skew matrix: - // [ 1, tan(skewY*π) ] - // [ tan(-skewX*π), 1 ] - // Implemented as cos/sin to avoid blowing up at ±π/2. - // Practically, skews in fonts stay well clear of ±π/2 so the - // tan form is fine; we compute via sin/cos for stability. - let tan_skx = sin_skx / cos_skx; - let tan_sky = sin_sky / cos_sky; - - // Build M = R * S * Skew. S applied to skew first: - // [ sx, 0 ] [ 1, tan_skx ] [ sx, sx*tan_skx ] - // [ 0, sy ] * [ tan_sky, 1 ] = [ sy*tan_sky, sy ] - let m_xx = sx; - let m_xy = sx * tan_skx; - let m_yx = sy * tan_sky; - let m_yy = sy; - - // Rotation * M: - // [ cos, -sin ] [ m_xx, m_xy ] - // [ sin, cos ] * [ m_yx, m_yy ] - let r_xx = cos_r * m_xx - sin_r * m_yx; - let r_xy = cos_r * m_xy - sin_r * m_yy; - let r_yx = sin_r * m_xx + cos_r * m_yx; - let r_yy = sin_r * m_xy + cos_r * m_yy; - - // Effective translation: - // p' = R*M*(p - tcenter) + (tcenter + translate) - // tx_eff = -(r_xx*tcx + r_xy*tcy) + tcx + tx - // ty_eff = -(r_yx*tcx + r_yy*tcy) + tcy + ty - let tx_eff = -(r_xx * tcx + r_xy * tcy) + tcx + tx; - let ty_eff = -(r_yx * tcx + r_yy * tcy) + tcy + ty; - - [r_xx, r_xy, r_yx, r_yy, tx_eff, ty_eff] +/// `sinf` and `cosf` of `radians`, as HarfBuzz's `hb_sincos` takes +/// them from libm: worked out in `f64` and rounded to `f32`, within an +/// `f32` rounding of the exact values at any angle a glyph uses. `core` +/// has no sine or cosine without `std`. An angle that is not finite +/// gives NaN. +fn sincos(radians: f32) -> (f32, f32) { + let (s, c) = sincos_f64(f64::from(radians)); + (s as f32, c as f32) } -/// `sin(x*π)` and `cos(x*π)`. Tight enough for glyph composites. -/// VARC's F4.12 fields stay within `[-8, 8)`, but variation deltas can -/// push an angle anywhere, including infinity. Implemented via a -/// Taylor series for `no_std`-friendliness. -fn sincos_pi(x: f32) -> (f32, f32) { - // Reduce to [-1, 1] (i.e. [-π, π]). - let mut t = x; - // For large or infinite angles the loops below would run for a - // long time or forever, so take the remainder first. `%` is exact - // and so is each loop step below 2^25, so both paths give the same - // angle wherever the loops finish. Clearing the sign of a zero - // remainder matches what the loops produce. - // A range check instead of `abs`, which core lacks before Rust 1.85. - if !(-16.0..=16.0).contains(&t) { - t %= 2.0; - if t == 0.0 { - t = 0.0; - } - } - while t > 1.0 { - t -= 2.0; +/// `tanf` of `radians`, from [`sincos_f64`] as [`sincos`] works it out. +fn tan(radians: f32) -> f32 { + let (s, c) = sincos_f64(f64::from(radians)); + (s / c) as f32 +} + +/// `sin(x)` and `cos(x)`: `x` brought into `[-π/4, π/4]` by the nearest +/// quarter turn (π/2 in two parts, so the remainder keeps its +/// precision), a Taylor polynomial there, accurate to about 1e-14, and +/// the quarter turn put back by swapping and negating. Deltas can push +/// an angle anywhere: past 2^30 radians it is first taken modulo 2π, +/// so the quarter-turn count stays exact and no loop can spin. +fn sincos_f64(x: f64) -> (f64, f64) { + const FRAC_2_PI: f64 = core::f64::consts::FRAC_2_PI; + // π/2 = PIO2_HI + PIO2_LO. + const PIO2_HI: f64 = core::f64::consts::FRAC_PI_2; + const PIO2_LO: f64 = 6.123_233_995_736_766e-17; + if !x.is_finite() { + return (f64::NAN, f64::NAN); } - while t < -1.0 { - t += 2.0; + let x = if abs_f64(x) > 1_073_741_824.0 { + x % core::f64::consts::TAU + } else { + x + }; + let quarter = floor_f64(x * FRAC_2_PI + 0.5); + let r = (x - quarter * PIO2_HI) - quarter * PIO2_LO; + let z = r * r; + // sin r = r - r^3/3! + ... - r^15/15!; cos r = 1 - r^2/2! + ... + r^16/16!. + let sin = r + * (1.0 + + z * (-1.0 / 6.0 + + z * (1.0 / 120.0 + + z * (-1.0 / 5040.0 + + z * (1.0 / 362_880.0 + + z * (-1.0 / 39_916_800.0 + + z * (1.0 / 6_227_020_800.0 + + z * (-1.0 / 1_307_674_368_000.0)))))))); + let cos = 1.0 + + z * (-0.5 + + z * (1.0 / 24.0 + + z * (-1.0 / 720.0 + + z * (1.0 / 40_320.0 + + z * (-1.0 / 3_628_800.0 + + z * (1.0 / 479_001_600.0 + + z * (-1.0 / 87_178_291_200.0 + + z * (1.0 / 20_922_789_888_000.0)))))))); + // `quarter` is a whole number below 2^30 in size, so the cast is + // exact, and its low two bits pick the quadrant. + match (quarter as i64) & 3 { + 0 => (sin, cos), + 1 => (cos, -sin), + 2 => (-sin, -cos), + _ => (-cos, sin), } - // y = t * π - let y = t * core::f32::consts::PI; - // 9-term Taylor expansion good to ~1e-6 over [-π, π]. - let y2 = y * y; - let sin = y * (1.0 - y2 / 6.0 + y2 * y2 / 120.0 - y2 * y2 * y2 / 5040.0); - let cos = 1.0 - y2 / 2.0 + y2 * y2 / 24.0 - y2 * y2 * y2 / 720.0 + y2 * y2 * y2 * y2 / 40320.0; - (cos, sin) } #[cfg(test)] diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index 54a4ea45..be60d0e1 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -270,17 +270,88 @@ fn rotation_rotates_unit_vector() { } #[test] -fn sincos_pi_huge_angle_terminates() { - // Reduction used to subtract 2 until the angle fell below 1, - // which never ends for infinity or for values where x - 2 == x. - let (c, s) = sincos_pi(1.0e30); - assert_eq!((c, s), (1.0, 0.0)); - assert!(sincos_pi(f32::INFINITY).0.is_nan()); - assert!(sincos_pi(f32::NEG_INFINITY).1.is_nan()); - // The remainder path agrees with the loop path, sign of zero - // included. - assert_eq!(sincos_pi(21.25), sincos_pi(1.25)); - assert_eq!(sincos_pi(-18.0).1.to_bits(), sincos_pi(0.0).1.to_bits()); +fn sincos_holds_over_every_quadrant() { + // Angles in every quadrant, negative, and many turns out, against + // their exact sines and cosines. + use core::f64::consts::PI; + let (half, root3) = (0.5, 0.866_025_403_784_438_6); + let root2 = core::f64::consts::FRAC_1_SQRT_2; + let cases = [ + (PI / 6.0, half, root3), + (PI / 4.0, root2, root2), + (PI / 2.0, 1.0, 0.0), + (2.0 * PI / 3.0, root3, -half), + (3.0 * PI / 4.0, root2, -root2), + (PI, 0.0, -1.0), + (7.0 * PI / 6.0, -half, -root3), + (3.0 * PI / 2.0, -1.0, 0.0), + (-3.0 * PI / 4.0, -root2, -root2), + (-PI / 3.0, -root3, half), + (100.0 * PI + PI / 3.0, root3, half), + (-1000.0 * PI - PI / 6.0, -half, root3), + ]; + for (x, sin, cos) in cases { + let (s, c) = sincos_f64(x); + assert!( + (s - sin).abs() < 1e-12 && (c - cos).abs() < 1e-12, + "{x}: {s} {c}" + ); + } +} + +#[test] +fn sincos_rounds_like_libm() { + // HarfBuzz takes 180 degrees as f32 π radians, a little past π, and + // libm's sinf of it is -8.742278e-8, which the f64 work rounds to. + let (s, c) = sincos(core::f32::consts::PI); + assert_eq!((s, c), (-8.742_278e-8, -1.0)); + assert_eq!(sincos(0.0), (0.0, 1.0)); + assert_eq!(tan(core::f32::consts::FRAC_PI_4), 1.0); +} + +#[test] +fn sincos_of_any_angle_terminates() { + // Deltas can push an angle anywhere: far out it is taken modulo 2π, + // and a value that is not finite gives NaN, with no loop to spin. + for x in [1.0e30, -3.0e38, 1.0e10, f32::MAX] { + let (s, c) = sincos(x); + assert!( + (-1.0..=1.0).contains(&s) && (-1.0..=1.0).contains(&c), + "{x}" + ); + } + for x in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN] { + let (s, c) = sincos(x); + assert!(s.is_nan() && c.is_nan(), "{x}"); + } +} + +#[test] +fn half_and_whole_turns_compose_as_harfbuzz_does() { + // 180 degrees about a center far from the origin. HarfBuzz rotates + // by f32 π, whose sine is -8.742278e-8, not 0, so the image of the + // center's opposite point is off by about 1e-4 there; the old + // Taylor series left sine 0.075 off at this angle. + let flags = VC_HAVE_ROTATION | VC_HAVE_TCENTER_X | VC_HAVE_TCENTER_Y; + let mut record = vec![0x80 | (flags >> 8) as u8, flags as u8, 0x00, 0x05]; + record.extend_from_slice(&4096i16.to_be_bytes()); // rotation: one half-turn + record.extend_from_slice(&(-400i16).to_be_bytes()); + record.extend_from_slice(&(-300i16).to_be_bytes()); + let c = first(&record, None, None, &[]); + let t = c.components[0].transform; + let (s, k) = sincos(core::f32::consts::PI); + // translate(tcx, tcy), rotate, translate(-tcx, -tcy), in HarfBuzz's + // operations. + let mut want = Affine::IDENTITY; + want.translate(-400.0, -300.0); + want.rotate(core::f32::consts::PI); + want.translate(400.0, 300.0); + assert_eq!(t, [want.xx, want.xy, want.yx, want.yy, want.x0, want.y0]); + assert_eq!((t[0], t[2]), (k, s)); + assert!( + (t[4] - -800.0).abs() < 1e-3 && (t[5] - -600.0).abs() < 1e-3, + "{t:?}" + ); } /// A MultiItemVariationStore with one axis-free region (scalar 1 diff --git a/tests/fixtures/varc_face.expected b/tests/fixtures/varc_face.expected index d99fccfa..fc3061c8 100644 --- a/tests/fixtures/varc_face.expected +++ b/tests/fixtures/varc_face.expected @@ -40,7 +40,14 @@ outline default 39 x_user M 250 0 L 450 0 L 350 300 Z outline default 40 k_const M 60 -40 L 160 -40 L 160 60 L 60 60 Z outline default 41 k_cond M 0 0 L 200 0 L 100 300 Z M 300 0 L 400 0 L 400 100 L 300 100 Z outline default 42 k_outer M 60 160 L 160 160 L 160 260 L 60 260 Z M 400 0 L 600 0 L 500 300 Z M 700 0 L 800 0 L 800 100 L 700 100 Z -outline default 43 x_norecord +outline default 43 rot_90 M -700 100 L -700 200 L -800 200 L -800 100 Z +outline default 44 rot_135 M -894.9747 -229.2893 L -965.6854 -158.5786 L -1036.3961 -229.2893 L -965.6854 -300 Z +outline default 45 rot_180 M -800 -600 L -900 -600 L -900 -700 L -800 -700 Z +outline default 46 rot_270 M -100 -700 L -100 -800 L 0 -800 L 0 -700 Z +outline default 47 rot_m135 M -470.7107 -794.9747 L -541.4214 -865.6854 L -470.7107 -936.3961 L -400 -865.6854 Z +outline default 48 rot_var M -894.9747 -229.2893 L -1036.3961 -87.868 L -1177.8175 -370.7107 Z +outline default 49 skew_big M -614.8947 413.0479 L -514.8947 275.3652 L -822.342 375.3652 L -922.342 513.0479 Z +outline default 50 x_norecord outline AAAA=50,BBBB=-30,CCCC=70 3 n2_outer M 5 10 L 130 10 L 130 110 L 0.9998 122.0007 Z outline AAAA=50,BBBB=-30,CCCC=70 4 n2_mid M 5 0 L 130 0 L 130 100 L 0.9998 112.0007 Z outline AAAA=50,BBBB=-30,CCCC=70 5 n3_top M 20 10 L 145 10 L 145 110 L 14.0002 127.9993 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -81,7 +88,14 @@ outline AAAA=50,BBBB=-30,CCCC=70 39 x_user M 250 -14.0002 L 450 0 L 321.9995 300 outline AAAA=50,BBBB=-30,CCCC=70 40 k_const M 60 -40 L 185 -40 L 185 60 L 45.9998 102.0007 Z outline AAAA=50,BBBB=-30,CCCC=70 41 k_cond M 0 -14.0002 L 200 0 L 71.9995 300 Z M 300 0 L 425 0 L 425 100 L 285.9998 142.0007 Z outline AAAA=50,BBBB=-30,CCCC=70 42 k_outer M 60 160 L 185 160 L 185 260 L 45.9998 302.0007 Z M 400 -14.0002 L 600 0 L 471.9995 300 Z M 700 0 L 825 0 L 825 100 L 685.9998 142.0007 Z -outline AAAA=50,BBBB=-30,CCCC=70 43 x_norecord +outline AAAA=50,BBBB=-30,CCCC=70 43 rot_90 M -700 100 L -700 225 L -800 225 L -842.0007 85.9997 Z +outline AAAA=50,BBBB=-30,CCCC=70 44 rot_135 M -894.9747 -229.2893 L -983.3631 -140.901 L -1054.0737 -211.6116 L -985.4847 -339.5987 Z +outline AAAA=50,BBBB=-30,CCCC=70 45 rot_180 M -800 -600 L -925 -600 L -925 -700 L -785.9998 -742.0007 Z +outline AAAA=50,BBBB=-30,CCCC=70 46 rot_270 M -100 -700 L -100 -825 L 0 -825 L 42.0007 -685.9998 Z +outline AAAA=50,BBBB=-30,CCCC=70 47 rot_m135 M -470.7107 -794.9747 L -559.0991 -883.3631 L -488.3884 -954.0738 L -360.4014 -885.4847 Z +outline AAAA=50,BBBB=-30,CCCC=70 48 rot_var M -1371.5125 -313.1891 L -1561.646 -249.587 L -1558.194 -575.7345 Z +outline AAAA=50,BBBB=-30,CCCC=70 49 skew_big M -614.8947 413.0479 L -489.8947 240.9446 L -797.342 340.9446 L -1065.4724 574.3245 Z +outline AAAA=50,BBBB=-30,CCCC=70 50 x_norecord outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 3 n2_outer M 5 10 L 121.6504 10 L 127.2032 142.2325 L 0.9998 148.6804 Z outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 4 n2_mid M 5 0 L 121.6504 0 L 127.2032 132.2325 L 0.9998 138.6804 Z outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 5 n3_top M 20 10 L 136.6504 10 L 142.2032 142.2325 L 14.0002 154.679 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -122,7 +136,14 @@ outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 39 x_user M 250 0 L 490.0195 0 L 350 353. outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 40 k_const M 63.75 -40 L 180.4004 -40 L 185.9532 92.2325 L 63.75 86.6797 Z outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 41 k_cond M 0 0 L 240.0195 0 L 100 353.3594 Z M 303.75 0 L 420.4004 0 L 425.9532 132.2325 L 303.75 126.6797 Z outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 42 k_outer M 63.75 160 L 180.4004 160 L 185.9532 292.2325 L 63.75 286.6797 Z M 400 0 L 640.0195 0 L 500 353.3594 Z M 703.75 0 L 820.4004 0 L 825.9532 132.2325 L 703.75 126.6797 Z -outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 43 x_norecord +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 43 rot_90 M -700 103.75 L -700 220.4004 L -832.2325 225.9532 L -826.6797 103.75 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 44 rot_135 M -897.6264 -226.6377 L -980.1107 -144.1534 L -1077.5397 -233.7294 L -987.2025 -316.2137 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 45 rot_180 M -803.75 -600 L -920.4004 -600 L -925.9532 -732.2325 L -803.75 -726.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 46 rot_270 M -100 -703.75 L -100 -820.4004 L 32.2325 -825.9532 L 26.6797 -703.75 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 47 rot_m135 M -473.3623 -797.6264 L -555.8466 -880.1107 L -466.2706 -977.5396 L -383.7863 -887.2025 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 48 rot_var M -1214.5609 -274.393 L -1422.3932 -154.3301 L -1477.9086 -530.3439 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 49 skew_big M -611.1447 407.8848 L -494.4943 247.2775 L -895.4869 371.8647 L -1000.618 534.5645 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 50 x_norecord outline AAAA=-80,BBBB=90,CCCC=55 3 n2_outer M 5 10 L 105 10 L 105 146.001 L 0.9998 158.0017 Z outline AAAA=-80,BBBB=90,CCCC=55 4 n2_mid M 5 0 L 105 0 L 105 136.001 L 0.9998 148.0017 Z outline AAAA=-80,BBBB=90,CCCC=55 5 n3_top M 20 10 L 120 10 L 120 146.001 L 14.0002 164.0002 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -163,7 +184,14 @@ outline AAAA=-80,BBBB=90,CCCC=55 39 x_user M 250 -10.9998 L 504.0015 0 L 328.000 outline AAAA=-80,BBBB=90,CCCC=55 40 k_const M 60 -40 L 160 -40 L 160 96.001 L 49.0002 129.0002 Z outline AAAA=-80,BBBB=90,CCCC=55 41 k_cond M 0 -10.9998 L 254.0015 0 L 78.0005 372.002 Z M 300 0 L 400 0 L 400 136.001 L 289.0002 169.0002 Z outline AAAA=-80,BBBB=90,CCCC=55 42 k_outer M 60 160 L 160 160 L 160 296.001 L 49.0002 329.0002 Z M 400 -10.9998 L 654.0015 0 L 478.0005 372.002 Z M 700 0 L 800 0 L 800 136.001 L 689.0002 169.0002 Z -outline AAAA=-80,BBBB=90,CCCC=55 43 x_norecord +outline AAAA=-80,BBBB=90,CCCC=55 43 rot_90 M -700 100 L -700 200 L -836.001 200 L -869.0002 89.0002 Z +outline AAAA=-80,BBBB=90,CCCC=55 44 rot_135 M -894.9747 -229.2893 L -965.6854 -158.5786 L -1061.8527 -254.7458 L -1006.6979 -356.5685 Z +outline AAAA=-80,BBBB=90,CCCC=55 45 rot_180 M -800 -600 L -900 -600 L -900 -736.001 L -789.0002 -769.0002 Z +outline AAAA=-80,BBBB=90,CCCC=55 46 rot_270 M -100 -700 L -100 -800 L 36.001 -800 L 69.0002 -689.0002 Z +outline AAAA=-80,BBBB=90,CCCC=55 47 rot_m135 M -470.7107 -794.9747 L -541.4214 -865.6854 L -445.2542 -961.8527 L -343.4315 -906.6979 Z +outline AAAA=-80,BBBB=90,CCCC=55 48 rot_var M -887.1967 -221.5113 L -1074.5809 -49.6832 L -1213.1746 -437.1797 Z +outline AAAA=-80,BBBB=90,CCCC=55 49 skew_big M -614.8947 413.0479 L -514.8947 275.3652 L -933.0261 411.3662 L -1145.4812 597.1929 Z +outline AAAA=-80,BBBB=90,CCCC=55 50 x_norecord outline AAAA=70,BBBB=-100,CCCC=20 3 n2_outer M 5 10 L 140.0006 10 L 140.0006 110 L 0.9998 122.0007 Z outline AAAA=70,BBBB=-100,CCCC=20 4 n2_mid M 5 0 L 140.0006 0 L 140.0006 100 L 0.9998 112.0007 Z outline AAAA=70,BBBB=-100,CCCC=20 5 n3_top M 20 10 L 155.0006 10 L 155.0006 110 L 14.0002 127.9993 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -204,7 +232,14 @@ outline AAAA=70,BBBB=-100,CCCC=20 39 x_user M 250 -4.0002 L 450 0 L 341.9995 300 outline AAAA=70,BBBB=-100,CCCC=20 40 k_const M 60 -40 L 195.0006 -40 L 195.0006 60 L 55.9998 72.0007 Z outline AAAA=70,BBBB=-100,CCCC=20 41 k_cond M 0 -4.0002 L 200 0 L 91.9995 300 Z M 300 0 L 435.0006 0 L 435.0006 100 L 295.9998 112.0007 Z outline AAAA=70,BBBB=-100,CCCC=20 42 k_outer M 60 160 L 195.0006 160 L 195.0006 260 L 55.9998 272.0007 Z M 400 -4.0002 L 600 0 L 491.9995 300 Z M 700 0 L 835.0006 0 L 835.0006 100 L 695.9998 112.0007 Z -outline AAAA=70,BBBB=-100,CCCC=20 43 x_norecord +outline AAAA=70,BBBB=-100,CCCC=20 43 rot_90 M -700 100 L -700 235.0006 L -800 235.0006 L -812.0007 95.9997 Z +outline AAAA=70,BBBB=-100,CCCC=20 44 rot_135 M -894.9747 -229.2893 L -990.4346 -133.8295 L -1061.1453 -204.5401 L -971.3426 -311.3144 Z +outline AAAA=70,BBBB=-100,CCCC=20 45 rot_180 M -800 -600 L -935.0006 -600 L -935.0006 -700 L -795.9998 -712.0007 Z +outline AAAA=70,BBBB=-100,CCCC=20 46 rot_270 M -100 -700 L -100 -835.0006 L 0 -835.0006 L 12.0007 -695.9998 Z +outline AAAA=70,BBBB=-100,CCCC=20 47 rot_m135 M -470.7107 -794.9747 L -566.1705 -890.4346 L -495.4598 -961.1453 L -388.6856 -871.3426 Z +outline AAAA=70,BBBB=-100,CCCC=20 48 rot_var M -1564.9563 -410.7827 L -1760.3646 -367.9853 L -1725.3787 -684.9081 Z +outline AAAA=70,BBBB=-100,CCCC=20 49 skew_big M -614.8947 413.0479 L -479.8941 227.1755 L -787.3414 327.1755 L -963.2383 530.5562 Z +outline AAAA=70,BBBB=-100,CCCC=20 50 x_norecord outline AAAA=100,BBBB=100,CCCC=-100 3 n2_outer M 5 10 L 155 10 L 180 175 L 0.9998 162.0007 Z outline AAAA=100,BBBB=100,CCCC=-100 4 n2_mid M 5 0 L 155 0 L 180 165 L 0.9998 152.0007 Z outline AAAA=100,BBBB=100,CCCC=-100 5 n3_top M 20 10 L 170 10 L 195 175 L 14.0002 167.9993 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -245,7 +280,14 @@ outline AAAA=100,BBBB=100,CCCC=-100 39 x_user M 250 0 L 510 0 L 350 380 Z outline AAAA=100,BBBB=100,CCCC=-100 40 k_const M 90 -40 L 240 -40 L 265 125 L 90 100 Z outline AAAA=100,BBBB=100,CCCC=-100 41 k_cond M 0 0 L 260 0 L 100 380 Z M 330 0 L 480 0 L 505 165 L 330 140 Z outline AAAA=100,BBBB=100,CCCC=-100 42 k_outer M 90 160 L 240 160 L 265 325 L 90 300 Z M 400 0 L 660 0 L 500 380 Z M 730 0 L 880 0 L 905 165 L 730 140 Z -outline AAAA=100,BBBB=100,CCCC=-100 43 x_norecord +outline AAAA=100,BBBB=100,CCCC=-100 43 rot_90 M -700 130 L -700 280 L -865 305 L -840 130 Z +outline AAAA=100,BBBB=100,CCCC=-100 44 rot_135 M -916.1879 -208.0761 L -1022.254 -102.0101 L -1156.6042 -201.005 L -1015.1829 -307.071 Z +outline AAAA=100,BBBB=100,CCCC=-100 45 rot_180 M -830 -600 L -980 -600 L -1005 -765 L -830 -740 Z +outline AAAA=100,BBBB=100,CCCC=-100 46 rot_270 M -100 -730 L -100 -880 L 65 -905 L 40 -730 Z +outline AAAA=100,BBBB=100,CCCC=-100 47 rot_m135 M -491.9239 -816.1879 L -597.9899 -922.254 L -498.995 -1056.6042 L -392.929 -915.1829 Z +outline AAAA=100,BBBB=100,CCCC=-100 48 rot_var M -1824 -600.0001 L -2084 -600.0001 L -1924 -980.0001 Z +outline AAAA=100,BBBB=100,CCCC=-100 49 skew_big M -584.8947 371.7431 L -434.8947 165.2191 L -917.1829 295.7985 L -1015.321 511.7431 Z +outline AAAA=100,BBBB=100,CCCC=-100 50 x_norecord outline AAAA=25,BBBB=0,CCCC=0 3 n2_outer M 5 10 L 117.5 10 L 117.5 110 L 0.9998 122.0007 Z outline AAAA=25,BBBB=0,CCCC=0 4 n2_mid M 5 0 L 117.5 0 L 117.5 100 L 0.9998 112.0007 Z outline AAAA=25,BBBB=0,CCCC=0 5 n3_top M 20 10 L 132.5 10 L 132.5 110 L 14.0002 127.9993 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -286,7 +328,14 @@ outline AAAA=25,BBBB=0,CCCC=0 39 x_user M 250 0 L 450 0 L 350 300 Z outline AAAA=25,BBBB=0,CCCC=0 40 k_const M 60 -40 L 172.5 -40 L 172.5 60 L 60 60 Z outline AAAA=25,BBBB=0,CCCC=0 41 k_cond M 0 0 L 200 0 L 100 300 Z M 300 0 L 412.5 0 L 412.5 100 L 300 100 Z outline AAAA=25,BBBB=0,CCCC=0 42 k_outer M 60 160 L 172.5 160 L 172.5 260 L 60 260 Z M 400 0 L 600 0 L 500 300 Z M 700 0 L 812.5 0 L 812.5 100 L 700 100 Z -outline AAAA=25,BBBB=0,CCCC=0 43 x_norecord +outline AAAA=25,BBBB=0,CCCC=0 43 rot_90 M -700 100 L -700 212.5 L -800 212.5 L -800 100 Z +outline AAAA=25,BBBB=0,CCCC=0 44 rot_135 M -894.9747 -229.2893 L -974.5242 -149.7398 L -1045.2349 -220.4505 L -965.6854 -300 Z +outline AAAA=25,BBBB=0,CCCC=0 45 rot_180 M -800 -600 L -912.5 -600 L -912.5 -700 L -800 -700 Z +outline AAAA=25,BBBB=0,CCCC=0 46 rot_270 M -100 -700 L -100 -812.5 L 0 -812.5 L 0 -700 Z +outline AAAA=25,BBBB=0,CCCC=0 47 rot_m135 M -470.7107 -794.9747 L -550.2602 -874.5242 L -479.5495 -945.2349 L -400 -865.6854 Z +outline AAAA=25,BBBB=0,CCCC=0 48 rot_var M -1133.687 -256.0999 L -1299.981 -144.9858 L -1383.505 -449.9837 Z +outline AAAA=25,BBBB=0,CCCC=0 49 skew_big M -614.8947 413.0479 L -502.3947 258.1549 L -809.842 358.1549 L -922.342 513.0479 Z +outline AAAA=25,BBBB=0,CCCC=0 50 x_norecord outline AAAA=-50,BBBB=10,CCCC=60 3 n2_outer M 5 10 L 105 10 L 105 114.0015 L 0.9998 126.0022 Z outline AAAA=-50,BBBB=10,CCCC=60 4 n2_mid M 5 0 L 105 0 L 105 104.0015 L 0.9998 116.0022 Z outline AAAA=-50,BBBB=10,CCCC=60 5 n3_top M 20 10 L 120 10 L 120 114.0015 L 14.0002 132.0007 Z M 170 10 L 270 10 L 270 138.0005 L 164.0002 155.9998 Z @@ -327,4 +376,11 @@ outline AAAA=-50,BBBB=10,CCCC=60 39 x_user M 250 -12.0007 L 456.0022 0 L 325.998 outline AAAA=-50,BBBB=10,CCCC=60 40 k_const M 60 -40 L 160 -40 L 160 64.0015 L 47.9993 100.0037 Z outline AAAA=-50,BBBB=10,CCCC=60 41 k_cond M 0 -12.0007 L 206.0022 0 L 75.9985 308.0029 Z M 300 0 L 400 0 L 400 104.0015 L 287.9993 140.0037 Z outline AAAA=-50,BBBB=10,CCCC=60 42 k_outer M 60 160 L 160 160 L 160 264.0015 L 47.9993 300.0037 Z M 400 -12.0007 L 606.0022 0 L 475.9985 308.0029 Z M 700 0 L 800 0 L 800 104.0015 L 687.9993 140.0037 Z -outline AAAA=-50,BBBB=10,CCCC=60 43 x_norecord +outline AAAA=-50,BBBB=10,CCCC=60 43 rot_90 M -700 100 L -700 200 L -804.0015 200 L -840.0037 87.9993 Z +outline AAAA=-50,BBBB=10,CCCC=60 44 rot_135 M -894.9747 -229.2893 L -965.6854 -158.5786 L -1039.2256 -232.1188 L -985.4865 -336.7726 Z +outline AAAA=-50,BBBB=10,CCCC=60 45 rot_180 M -800 -600 L -900 -600 L -900 -704.0015 L -787.9993 -740.0037 Z +outline AAAA=-50,BBBB=10,CCCC=60 46 rot_270 M -100 -700 L -100 -800 L 4.0015 -800 L 40.0037 -687.9993 Z +outline AAAA=-50,BBBB=10,CCCC=60 47 rot_m135 M -470.7107 -794.9747 L -541.4214 -865.6854 L -467.8813 -939.2256 L -363.2274 -885.4865 Z +outline AAAA=-50,BBBB=10,CCCC=60 48 rot_var M -886.489 -220.8035 L -1040.6403 -83.6238 L -1166.5048 -393.3412 Z +outline AAAA=-50,BBBB=10,CCCC=60 49 skew_big M -614.8947 413.0479 L -514.8947 275.3652 L -834.6445 379.3667 L -1057.333 569.5745 Z +outline AAAA=-50,BBBB=10,CCCC=60 50 x_norecord diff --git a/tests/fixtures/varc_face.ttf b/tests/fixtures/varc_face.ttf index d1f5344bc308253bffcdabacaae2e4ec87c0261f..e7dfa7a389c24ece0cc7e67dd7769d4b7d818efb 100644 GIT binary patch delta 607 zcmYjNO=uHQ5dP*RyNTO0RYXiRha6f^XqseWbOkL&L5fsb8ns0$={DIU#iSe4P%9Mb zQKW~~=go^)@gV5IPz3Ryf`W)3)RXq$K}x|xh3Y|qsgneQEZ@HGXXY{UZu-7<&VvB7 z;|3fU8cc-rxN*`8&?vj(7vjUG2H$sEfSLxJ3#o!xga>Z`5#an#X2vYN{rqkd(0;HU z$mXwSMD}5f^B1h2WI113-YNra$N0|sIm=A9E4MtHtuYSga@Nwk|7h_ZX}LQ)a>1e|P;7;K;N7QnaVbzpq|g<(c}q$KTk2_jtCxue!8xo}#jk zE>lUqqE5L&6Y?W@7#s4odT*DL$l*}ippf_zccKP)+_gd> zx#dcD={x3gIx90)n zOMqrh$w*C1;braO0m|J0VzZ2l)QR;nzEL^($%!llEQUaR9zgM^+{B6k20aEgpdJe# zUm-6sH&uG}vuFke<`5wNc0qn|iMLkIXP}!5fNHLS{Q&fbtxRyt#22BAI+N`fV;Qq2 zA7qrAyp=I&vNDqbkmtgb$sEqWuz3&DT1KFfES8AP7gz!qwYVzc^YV*Qlk&i5xn^b$1X-j?aB3As!R+FlRt2UbLKJVfDC77n;gI$xp_OcIwJs1 Date: Sun, 4 Oct 2026 15:28:45 -0800 Subject: [PATCH 3/8] Read no more VARC components than the walk can draw A glyph whose record held a million three-byte components was read to the end (each record costs one unit of the 2^20 work budget, so all of them fit) and every resolved component kept, before the walk failed at its 2,048-component cap: 70 ms and 86 MB of heap for a 3 MB glyph. The walk now passes Varc::resolve the most components it can still draw plus one, and reading stops there. A longer list fails the walk anyway, so the composite cut short is only kept for a walk that is about to fail; Varc::composite_with_font_coords reads every record as before. The 3 MB glyph now fails in 1.5 ms with under 1 MB of heap (the reviewer's flood_1000k fonts; flood_250k from 21.5 ms and 21 MB). --- src/face/varc_draw.rs | 12 ++++++++++-- src/tables/varc.rs | 25 +++++++++++++++++-------- src/tables/varc/tests.rs | 27 +++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/face/varc_draw.rs b/src/face/varc_draw.rs index 704be4ab..10e81302 100644 --- a/src/face/varc_draw.rs +++ b/src/face/varc_draw.rs @@ -95,7 +95,11 @@ impl<'w, 'a> VarcDraw<'w, 'a> { /// cover the glyph, which is then drawn from `glyf` or CFF. pub(crate) fn draw(&mut self, glyph_id: u16, sink: &mut S) -> Result { let coords = &self.font_coords; - let Some(composite) = self.varc.resolve(glyph_id, coords, coords, &mut self.memo) else { + let max = self.components_left.saturating_add(1); + let Some(composite) = self + .varc + .resolve(glyph_id, coords, coords, &mut self.memo, max) + else { return Ok(false); }; self.path.push(glyph_id); @@ -163,7 +167,11 @@ impl<'w, 'a> VarcDraw<'w, 'a> { return Ok(()); } let font_coords = &self.font_coords; - if let Some(composite) = self.varc.resolve(gid, coords, font_coords, &mut self.memo) { + let max = self.components_left.saturating_add(1); + if let Some(composite) = + self.varc + .resolve(gid, coords, font_coords, &mut self.memo, max) + { self.path.push(gid); let drawn = self.draw_composite(gid, &composite, transform, depth, sink); self.path.pop(); diff --git a/src/tables/varc.rs b/src/tables/varc.rs index 1d5a0eb2..7796e977 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -306,12 +306,19 @@ impl<'a> Varc<'a> { /// reached again at the same coords costs nothing more. `None` when /// VARC does not cover `gid`. Every call with one `memo` must pass /// the same `font_coords`. + /// + /// Reading stops once the composite holds `max_components`: a walk + /// that may draw `n` more components passes `n + 1`, since a longer + /// list fails the walk anyway, so a record of a million components + /// costs no more than the walk can use. A composite cut short that + /// way is only kept for a walk that is about to fail. pub(crate) fn resolve( &self, gid: u16, coords: &[f32], font_coords: &[f32], memo: &mut VarcMemo, + max_components: usize, ) -> Option> { if !self.covers(gid) { return None; @@ -322,7 +329,8 @@ impl<'a> Varc<'a> { return Some(Rc::clone(known)); } } - let composite = Rc::new(self.composite_at(gid, coords, font_coords, id, memo)?); + let composite = + Rc::new(self.composite_at(gid, (coords, font_coords), id, memo, max_components)?); if let Some(id) = id { memo.per_coords[id] .composites @@ -345,19 +353,20 @@ impl<'a> Varc<'a> { return None; } let id = memo.coords_id(coords); - self.composite_at(gid, coords, font_coords, id, memo) + self.composite_at(gid, (coords, font_coords), id, memo, usize::MAX) } - /// [`Self::composite_in`] with what the walk keeps for `coords` at - /// index `id`, or with nothing kept when the walk had no work left - /// to keep a copy of the coords. + /// [`Self::composite_in`] at the glyph's coords and the font's, with + /// what the walk keeps for the glyph's coords at index `id`, or with + /// nothing kept when the walk had no work left to keep a copy of + /// them. Stops reading once the list holds `max_components`. fn composite_at( &self, gid: u16, - coords: &[f32], - font_coords: &[f32], + (coords, font_coords): (&[f32], &[f32]), id: Option, memo: &mut VarcMemo, + max_components: usize, ) -> Option { let idx = self.coverage.index_of(gid)? as usize; // HarfBuzz reads a record past the end of the INDEX as empty. @@ -374,7 +383,7 @@ impl<'a> Varc<'a> { }; let mut composite = VarcComposite::default(); let mut r = Reader::new(raw); - while !r.is_empty() { + while !r.is_empty() && composite.components.len() < max_components { // A VarComponent stops when bytes run out. Reaching the // end mid-record means the font is malformed; we skip the // rest rather than error so a single bad glyph doesn't diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index be60d0e1..58f206a0 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -987,18 +987,20 @@ fn a_memo_keeps_composites_and_scalars_by_coords() { let table = build_varc(&[1], &[&record], Some(&mention_store(1000)), None); let varc = Varc::parse(&table).unwrap(); let mut memo = VarcMemo::new(); - let first = varc.resolve(1, &[1.0], &[], &mut memo).unwrap(); + let first = varc.resolve(1, &[1.0], &[], &mut memo, usize::MAX).unwrap(); // Keeping [1.0] (2), the record (1), its coords (1), the scalars // (1000 and one axis) and the walk (1000). assert_eq!(memo.work_done(), 2005); - let again = varc.resolve(1, &[1.0], &[], &mut memo).unwrap(); + let again = varc.resolve(1, &[1.0], &[], &mut memo, usize::MAX).unwrap(); assert!(Rc::ptr_eq(&first, &again)); assert_eq!(memo.work_done(), 2005); - let half = varc.resolve(1, &[0.5], &[], &mut memo).unwrap(); + let half = varc.resolve(1, &[0.5], &[], &mut memo, usize::MAX).unwrap(); assert_eq!(half.components[0].transform[4], 500.0); assert_eq!(memo.work_done(), 2 * 2005); // A glyph VARC has no record for costs nothing. - assert!(varc.resolve(5, &[0.25], &[], &mut memo).is_none()); + assert!(varc + .resolve(5, &[0.25], &[], &mut memo, usize::MAX) + .is_none()); assert_eq!(memo.work_done(), 2 * 2005); } @@ -1046,3 +1048,20 @@ fn coords_past_harfbuzz_limit_start_from_the_font_coords() { .unwrap(); assert_eq!(c.components[0].coords.len(), 4096); } + +#[test] +fn a_walk_reads_no_more_components_than_it_can_draw() { + // 100,000 three-byte components of glyph 5. A walk that can draw + // two more components asks for three, the third failing it, so the + // other 99,997 records are never read or kept. + let record = [0x00, 0x00, 0x05].repeat(100_000); + let table = build_varc(&[1], &[&record], None, None); + let varc = Varc::parse(&table).unwrap(); + let mut memo = VarcMemo::new(); + let c = varc.resolve(1, &[], &[], &mut memo, 3).unwrap(); + assert_eq!(c.components.len(), 3); + // Keeping the empty coords (1) and three records (3). + assert_eq!(memo.work_done(), 4); + // Without a cap, the list holds every record. + assert_eq!(varc.composite(1, &[]).unwrap().components.len(), 100_000); +} From a4a688baf58645c38194b0671c34b3b3c6d4a995 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 15:53:31 -0800 Subject: [PATCH 4/8] Charge VARC leaves to one budget and draw each leaf once Only a leaf's ops counted toward the walk's caps; the gvar and charstring work behind them did not, and each leaf drew with fresh per-glyph limits. A glyph of 2,047 components naming a 480-point glyph with 4,095 gvar tuples took 2.4 to 3.5 s (HarfBuzz 2.4 ms: it charges its 2^24 glyph budget inside glyf and stops after 8 leaves), and the time grew with the components. The walk's leaves now: - keep each outline they draw by glyph and coords (f32 bits), so a leaf reached again at the same coords is drawn once and placed from that outline (at most 2^18 ops kept; a leaf past that is drawn again when reached again, and charged again); - share one budget of 2^24 units, HarfBuzz's HB_BUDGET_GLYPH: gvar tuple work as a glyf walk charges it (Glyf gains outline_at_coords_with_work, which takes its tuple work from the caller), 64 per glyph record and 1 per point a glyf leaf lays down, 4 per charstring operation (Cff::draw_limited and Cff2::outline_limited take their operation limit from the caller; the Interp limit can be lowered), and regions times axes of the CFF2 store for each coord vector, whose Cff2Shared is now kept per coords for the walk. Past the budget a leaf draws nothing, as HarfBuzz's leaves stop, instead of failing the glyph. The reviewer's leaf_gvar.ttf now takes 13 to 18 ms. Hostile fonts built to defeat the memo, every component at its own coords: the same leaf 2.5 s to 14 ms (it stops after 8 leaves, as HarfBuzz does); a glyf composite of 255 x 255 visits 5.5 s to 15 ms; a CFF2 leaf running 39,000 operations 416 ms to 25 ms. Glyph drawing outside VARC keeps its per-glyph limits and output. --- src/face/varc_draw.rs | 264 ++++++++++++++++++++++++++++++++--- src/tables/cff.rs | 52 ++++++- src/tables/cff/charstring.rs | 39 +++++- src/tables/cff2.rs | 37 ++++- src/tables/glyf.rs | 36 +++++ src/tables/gvar.rs | 6 +- 6 files changed, 404 insertions(+), 30 deletions(-) diff --git a/src/face/varc_draw.rs b/src/face/varc_draw.rs index 10e81302..69c501e9 100644 --- a/src/face/varc_draw.rs +++ b/src/face/varc_draw.rs @@ -4,14 +4,21 @@ use core::cell::OnceCell; +use alloc::collections::BTreeMap; +use alloc::rc::Rc; use alloc::vec; use alloc::vec::Vec; use super::Face; use crate::error::{Error, Result}; +use crate::tables::cff::{MAX_CHARSTRING_OPS, OUT_OF_CHARSTRING_OPS}; +use crate::tables::cff2::Cff2Shared; use crate::tables::glyf::PhantomMetrics; +use crate::tables::gvar::OUT_OF_TUPLE_WORK; use crate::tables::varc::{VarcComposite, VarcMemo}; -use crate::tables::{tag, Cff, Cff2, Glyf, Gvar, Hmtx, Loca, OutlineSink, Varc, Vmtx}; +use crate::tables::{ + tag, Cff, Cff2, Glyf, Gvar, Hmtx, Loca, Outline, OutlineSink, PathOp, Varc, Vmtx, +}; /// Deepest a component may sit below the glyph drawn, as the `glyf` /// composite walk caps its depth. @@ -178,13 +185,16 @@ impl<'w, 'a> VarcDraw<'w, 'a> { return drawn; } } + let Some(leaf) = self.leaves.outline(self.face, gid, coords)? else { + return Ok(()); + }; let mut placed = Placed { sink, transform: (transform != IDENTITY).then_some(transform), left: self.ops_left, drawn: 0, }; - self.leaves.draw(self.face, gid, coords, &mut placed)?; + replay(&leaf, &mut placed); self.ops_left = self .ops_left .checked_sub(placed.drawn) @@ -280,15 +290,62 @@ impl OutlineSink for Placed<'_, S> { } } -/// The tables the leaves of a walk are drawn from, read the first time -/// a leaf needs them, in the order [`Face::glyph_outline_at_coords`] -/// reads them for a glyph `VARC` does not draw, and kept for the walk. -/// A table that fails to read fails every leaf that needs it. -#[derive(Default)] +/// Work the leaves of one glyph may do between them: HarfBuzz's +/// `HB_BUDGET_GLYPH`, which its VARC walk shares with every leaf it +/// draws. In units of: one per `gvar` tuple header and per point of +/// each tuple decoded (as a `glyf` walk charges `gvar`), one per point a +/// `glyf` leaf lays down and [`GLYPH_VISIT_WORK`] per glyph record it +/// visits, [`CHARSTRING_OP_WORK`] per charstring operation, and the +/// regions times axes of a `CFF2` variation store for each coord vector +/// its leaves are drawn at. Past it, a leaf draws nothing, as +/// HarfBuzz's leaves draw nothing once its budget runs out. +const MAX_LEAF_WORK: usize = 1 << 24; + +/// Leaf work per glyph record a `glyf` leaf visits. +const GLYPH_VISIT_WORK: usize = 64; + +/// Most ops the leaf outlines a walk keeps may hold between them. A +/// leaf drawn past that is not kept, so drawing it again costs its work +/// again; the walk's own op cap bounds what it draws. +const MAX_KEPT_LEAF_OPS: usize = 1 << 18; + +/// Leaf work per charstring operation a CFF or `CFF2` leaf runs. +const CHARSTRING_OP_WORK: usize = 4; + +/// The leaves of a walk: the tables they are drawn from, read the first +/// time a leaf needs them, in the order [`Face::glyph_outline_at_coords`] +/// reads them for a glyph `VARC` does not draw, and kept for the walk +/// (a table that fails to read fails every leaf that needs it); every +/// leaf outline drawn so far (up to [`MAX_KEPT_LEAF_OPS`]), so a glyph +/// reached again at the same coords is drawn once; and the work left +/// for them. struct Leaves<'a> { tables: OnceCell>>, /// Read for the first leaf away from the default instance. gvar: OnceCell>>>, + /// Outlines by glyph and coords (as `f32` bits); `None` for a leaf + /// that drew nothing. The coords were charged to the walk's work + /// when its components were built, so the keys are bounded by it. + drawn: BTreeMap<(u16, Vec), Option>>, + /// What the `CFF2` leaves drawn at one coord vector share. + cff2_shared: BTreeMap, Cff2Shared<'a>>, + /// See [`MAX_LEAF_WORK`]. + work: usize, + /// See [`MAX_KEPT_LEAF_OPS`]. + kept_ops_left: usize, +} + +impl Default for Leaves<'_> { + fn default() -> Self { + Self { + tables: OnceCell::new(), + gvar: OnceCell::new(), + drawn: BTreeMap::new(), + cff2_shared: BTreeMap::new(), + work: MAX_LEAF_WORK, + kept_ops_left: MAX_KEPT_LEAF_OPS, + } + } } /// The table a face draws glyphs from without `VARC`. @@ -305,23 +362,72 @@ enum LeafTables<'a> { } impl<'a> Leaves<'a> { - /// Draws glyph `gid` of `face` at `coords` from `glyf` or CFF, as - /// [`Face::glyph_outline_at_coords`] draws a glyph `VARC` does not. - fn draw( - &self, + /// The outline of glyph `gid` of `face` at `coords`, from `glyf` or + /// CFF as [`Face::glyph_outline_at_coords`] draws a glyph `VARC` + /// does not, or `None` when it has none or the leaves are out of + /// work. Drawn once per glyph and coords. + fn outline( + &mut self, face: &Face<'a>, gid: u16, coords: &[f32], - sink: &mut S, - ) -> Result { + ) -> Result>> { + let key = ( + gid, + coords.iter().map(|c| c.to_bits()).collect::>(), + ); + if let Some(known) = self.drawn.get(&key) { + return Ok(known.clone()); + } + let outline = self.draw(face, gid, coords, &key.1)?.map(Rc::new); + let ops = outline.as_ref().map_or(0, |o| o.len()); + if ops <= self.kept_ops_left { + self.kept_ops_left -= ops; + self.drawn.insert(key, outline.clone()); + } + Ok(outline) + } + + /// Draws a leaf [`Leaves::outline`] has not drawn yet, charging its + /// work. `bits` are the coords as `f32` bits. + fn draw( + &mut self, + face: &Face<'a>, + gid: u16, + coords: &[f32], + bits: &[u32], + ) -> Result> { + if self.work == 0 { + return Ok(None); + } let tables = self .tables .get_or_init(|| read_leaf_tables(face)) .as_ref() .map_err(Clone::clone)?; - match tables { - LeafTables::Cff2(cff2) => cff2.outline(gid, coords, sink), - LeafTables::Cff(cff) => cff.outline(gid, sink), + let mut out = Outline::new(); + let drew = match tables { + LeafTables::Cff2(cff2) => { + let fresh = !self.cff2_shared.contains_key(bits); + let shared = self.cff2_shared.entry(bits.to_vec()).or_default(); + let limit = charstring_limit(self.work); + let mut ops = limit; + let drew = cff2.outline_limited(gid, coords, shared, &mut out, &mut ops); + let mut cost = (limit - ops) as usize * CHARSTRING_OP_WORK; + if fresh { + cost = cost.saturating_add(shared.scalar_work()); + } + self.work = self.work.saturating_sub(cost); + charstring_drew(drew, limit, &mut self.work)? + } + LeafTables::Cff(cff) => { + let limit = charstring_limit(self.work); + let mut ops = limit; + let drew = cff.draw_limited(gid, &mut out, &mut ops); + let cost = (limit - ops) as usize * CHARSTRING_OP_WORK; + self.work = self.work.saturating_sub(cost); + charstring_drew(drew, limit, &mut self.work)? + } LeafTables::Glyf { glyf, loca, @@ -341,8 +447,75 @@ impl<'a> Leaves<'a> { hmtx, vmtx: Some(vmtx), }; - glyf.outline_at_coords(loca, gid, gvar, coords, Some(&metrics), sink) + let mut tuple_work = self.work; + let drew = glyf.outline_at_coords_with_work( + loca, + gid, + gvar, + coords, + Some(&metrics), + &mut out, + &mut tuple_work, + ); + match drew { + Ok((drew, visits, points)) => { + let laid = visits + .saturating_mul(GLYPH_VISIT_WORK) + .saturating_add(points); + self.work = tuple_work.saturating_sub(laid); + drew + } + Err(Error::Malformed { context, .. }) if context == OUT_OF_TUPLE_WORK => { + self.work = 0; + false + } + Err(e) => return Err(e), + } } + }; + Ok(drew.then_some(out)) + } +} + +/// Whether a CFF or `CFF2` leaf drew, from its result: a charstring +/// stopped by the leaves' work (a `limit` below one glyph's own) draws +/// nothing and ends the leaves' `work`; other errors fail the walk. +fn charstring_drew(drew: Result, limit: u32, work: &mut usize) -> Result { + match drew { + Err(Error::Malformed { context, .. }) + if context == OUT_OF_CHARSTRING_OPS && limit < MAX_CHARSTRING_OPS => + { + *work = 0; + Ok(false) + } + drew => drew, + } +} + +/// The charstring operations `work` leaf work pays for, at most one +/// glyph's own limit. +fn charstring_limit(work: usize) -> u32 { + u32::try_from(work / CHARSTRING_OP_WORK) + .unwrap_or(u32::MAX) + .min(MAX_CHARSTRING_OPS) +} + +/// Sends the ops of `outline` to `sink`. +fn replay(outline: &Outline, sink: &mut S) { + for op in outline.ops() { + match *op { + PathOp::MoveTo { x, y } => sink.move_to(x, y), + PathOp::LineTo { x, y } => sink.line_to(x, y), + PathOp::QuadTo { cx, cy, x, y } => sink.quad_to(cx, cy, x, y), + PathOp::CubicTo { + c1x, + c1y, + c2x, + c2y, + x, + y, + } => sink.curve_to(c1x, c1y, c2x, c2y, x, y), + PathOp::Close => sink.close(), } } } @@ -543,4 +716,61 @@ mod tests { assert_eq!(at(&[0.0, 0.0, 0.0]), [100.0]); assert_eq!(at(&[0.5]), [100.0]); } + + /// `varc_parity.ttf` with glyph 3 made of one component per entry of + /// `axis0`, each naming the box (glyph 1, varied by `gvar`) with + /// axis 0 set to that F2DOT14 value. + fn boxes_at(axis0: &[i16]) -> Vec { + let mut record = Vec::new(); + for &value in axis0 { + // HAVE_AXES, glyph 1, axis indices list 0, one i16 value. + record.extend_from_slice(&[0x02, 0x00, 0x01, 0x00, 0x40]); + record.extend_from_slice(&value.to_be_bytes()); + } + let table = build_varc(&[3], &[&record], None, Some(&[&[0x00, 0x00]])); + with_varc( + include_bytes!("../../tests/fixtures/varc_parity.ttf"), + &table, + ) + } + + #[test] + fn a_leaf_reached_again_at_the_same_coords_is_drawn_once() { + // 500 boxes at one coords: the box is drawn, and its gvar work + // charged, once; every copy is placed from that outline. + let font = boxes_at(&[8192; 500]); + let blob = Blob::new(&font); + let face = Face::parse(&blob, 0).unwrap(); + let varc = face.varc().unwrap().unwrap(); + let mut draw = VarcDraw::new(&face, &varc, &[]); + let mut out = Outline::new(); + assert!(draw.draw(3, &mut out).unwrap()); + assert_eq!(move_xs(&out).len(), 500); + assert_eq!(draw.leaves.drawn.len(), 1); + let once = MAX_LEAF_WORK - draw.leaves.work; + // One box at axis 0 = 0.5 costs its 4 tuple headers, the 8 + // points (4 and 4 phantoms) of the one tuple that holds there, + // one glyph visit and its 4 points. + assert_eq!(once, 4 + 8 + GLYPH_VISIT_WORK + 4); + } + + #[test] + fn leaves_past_their_work_draw_nothing() { + // Three boxes at distinct coords with work for two of them: the + // third draws nothing and the glyph does not fail, as HarfBuzz's + // leaves stop once its budget runs out. + let font = boxes_at(&[8192, 8193, 8194]); + let blob = Blob::new(&font); + let face = Face::parse(&blob, 0).unwrap(); + let varc = face.varc().unwrap().unwrap(); + let mut draw = VarcDraw::new(&face, &varc, &[]); + draw.leaves.work = 2 * (4 + 8 + GLYPH_VISIT_WORK + 4); + let mut out = Outline::new(); + assert!(draw.draw(3, &mut out).unwrap()); + assert_eq!(move_xs(&out).len(), 2); + assert_eq!(draw.leaves.work, 0); + // With the whole budget, all three draw. + let all = face.glyph_outline(3).unwrap().unwrap(); + assert_eq!(move_xs(&all).len(), 3); + } } diff --git a/src/tables/cff.rs b/src/tables/cff.rs index b67ae643..e5b0dd22 100644 --- a/src/tables/cff.rs +++ b/src/tables/cff.rs @@ -47,7 +47,9 @@ use crate::error::{Error, Result}; use crate::tables::outline::OutlineSink; use crate::tables::parse::Reader; -pub(crate) use charstring::{BlendContext, CharstringSink, Interp2, RegionCache}; +pub(crate) use charstring::{ + BlendContext, CharstringSink, Interp2, RegionCache, MAX_CHARSTRING_OPS, OUT_OF_CHARSTRING_OPS, +}; pub(crate) use dict::FdSelect; pub(crate) use index::{read_index2, Index}; @@ -211,13 +213,38 @@ impl<'a> Cff<'a> { /// [`Cff::outline`] into any [`CharstringSink`], such as one that /// keeps the points in the `f64` the charstring is evaluated in. pub(crate) fn draw(&self, glyph_id: u16, sink: &mut S) -> Result { + self.draw_with(glyph_id, sink, None) + } + + /// [`Cff::draw`] for a caller that bounds many glyphs together: the + /// glyph's charstrings (a seac's base and accent included) may run + /// at most `ops_left` operations between them, which they take from + /// `ops_left`, failing with the operation-limit error past them. + pub(crate) fn draw_limited( + &self, + glyph_id: u16, + sink: &mut S, + ops_left: &mut u32, + ) -> Result { + self.draw_with(glyph_id, sink, Some(ops_left)) + } + + /// [`Cff::draw`], each charstring limited to its own operations, or + /// with `ops_left` shared as [`Cff::draw_limited`] shares it. + fn draw_with( + &self, + glyph_id: u16, + sink: &mut S, + mut ops_left: Option<&mut u32>, + ) -> Result { let gid = usize::from(glyph_id); if gid >= self.char_strings.len() { return Ok(false); } let local_subrs = self.local_subrs(gid)?; - if let Some(seac) = self.run_charstring(gid, local_subrs, sink, None)? { - self.draw_seac(seac, local_subrs, sink)?; + let seac = self.run_charstring(gid, local_subrs, sink, None, ops_left.as_deref_mut())?; + if let Some(seac) = seac { + self.draw_seac(seac, local_subrs, sink, ops_left)?; } Ok(true) } @@ -231,13 +258,23 @@ impl<'a> Cff<'a> { local_subrs: Index<'a>, sink: &mut S, component: Option<(f64, f64)>, + ops_left: Option<&mut u32>, ) -> Result> { let cs = self.char_strings.get(gid)?; let mut interp = Interp::new(self.global_subrs, local_subrs, sink, false); if let Some((x, y)) = component { interp.start_seac_component(x, y); } - interp.run(cs, 0)?; + let ran = match ops_left { + Some(ops_left) => { + interp.limit_ops(*ops_left); + let ran = interp.run(cs, 0); + *ops_left = ops_left.saturating_sub(interp.ops()); + ran + } + None => interp.run(cs, 0), + }; + ran?; // A well-formed CFF1 charstring has already closed its last // contour at endchar; this only covers charstrings that end // without one. @@ -254,6 +291,7 @@ impl<'a> Cff<'a> { seac: Seac, local_subrs: Index<'a>, sink: &mut S, + mut ops_left: Option<&mut u32>, ) -> Result<()> { if let FontDicts::Cid { .. } = self.fonts { return Err(Error::Unsupported { @@ -263,8 +301,10 @@ impl<'a> Cff<'a> { let n_glyphs = self.char_strings.len(); let base = charset::glyph_for_sid(self.data, self.charset, seac.base, n_glyphs)?; let accent = charset::glyph_for_sid(self.data, self.charset, seac.accent, n_glyphs)?; - self.run_charstring(base, local_subrs, sink, Some((0.0, 0.0)))?; - self.run_charstring(accent, local_subrs, sink, Some((seac.adx, seac.ady)))?; + let origin = Some((0.0, 0.0)); + self.run_charstring(base, local_subrs, sink, origin, ops_left.as_deref_mut())?; + let accent_origin = Some((seac.adx, seac.ady)); + self.run_charstring(accent, local_subrs, sink, accent_origin, ops_left)?; Ok(()) } diff --git a/src/tables/cff/charstring.rs b/src/tables/cff/charstring.rs index dc40fa95..bcc66077 100644 --- a/src/tables/cff/charstring.rs +++ b/src/tables/cff/charstring.rs @@ -25,7 +25,11 @@ const CFF2_STACK_LIMIT: usize = 513; /// the work: a subroutine that calls the next one many times, ten /// levels deep, runs for an exponential number of steps. Real glyphs /// stay far below this limit. -const MAX_CHARSTRING_OPS: u32 = 100_000; +pub(crate) const MAX_CHARSTRING_OPS: u32 = 100_000; + +/// The `context` of the error a charstring fails with past its +/// operation limit. +pub(crate) const OUT_OF_CHARSTRING_OPS: &str = "CFF charstring exceeds operation limit"; // ---------------------------------------------------------------------------- // Type 2 charstring interpreter. @@ -89,8 +93,11 @@ pub(crate) struct Interp<'a, 'b, S: CharstringSink> { /// next move operator, endchar, or [`Self::finish`] closes it. in_contour: bool, /// Operands and operators executed so far, checked against - /// [`MAX_CHARSTRING_OPS`]. + /// `ops_limit`. ops: u32, + /// [`MAX_CHARSTRING_OPS`], or less for a caller that bounds many + /// charstrings together (see [`Self::limit_ops`]). + ops_limit: u32, /// CFF2 blend support. pub(crate) blend: Option>, /// Region count and scalars for the blend context's current @@ -170,6 +177,7 @@ impl<'a, 'b, S: CharstringSink> Interp<'a, 'b, S> { done: false, in_contour: false, ops: 0, + ops_limit: MAX_CHARSTRING_OPS, blend: None, blend_regions: None, seac: None, @@ -191,6 +199,19 @@ impl<'a, 'b, S: CharstringSink> Interp<'a, 'b, S> { self.seac.take() } + /// Lowers the operation limit to `limit`, if that is below + /// [`MAX_CHARSTRING_OPS`]; past it the charstring fails with + /// [`OUT_OF_CHARSTRING_OPS`]. + pub(crate) fn limit_ops(&mut self, limit: u32) { + self.ops_limit = limit.min(MAX_CHARSTRING_OPS); + } + + /// Operands and operators executed so far, the one that crossed the + /// limit included. + pub(crate) fn ops(&self) -> u32 { + self.ops + } + pub(crate) fn run(&mut self, code: &'a [u8], depth: u8) -> Result<()> { if depth > MAX_SUBR_DEPTH { return Err(Error::Malformed { @@ -204,10 +225,10 @@ impl<'a, 'b, S: CharstringSink> Interp<'a, 'b, S> { return Ok(()); } self.ops += 1; - if self.ops > MAX_CHARSTRING_OPS { + if self.ops > self.ops_limit { return Err(Error::Malformed { offset: r.position(), - context: "CFF charstring exceeds operation limit", + context: OUT_OF_CHARSTRING_OPS, }); } let b0 = r.read_u8()?; @@ -869,6 +890,16 @@ impl<'a, 'b, S: CharstringSink> Interp2<'a, 'b, S> { self.inner.run(code, depth) } + /// See [`Interp::limit_ops`]. + pub(crate) fn limit_ops(&mut self, limit: u32) { + self.inner.limit_ops(limit); + } + + /// See [`Interp::ops`]. + pub(crate) fn ops(&self) -> u32 { + self.inner.ops() + } + /// Closes the final contour. See [`Interp::finish`]. pub(crate) fn finish(&mut self) { self.inner.finish(); diff --git a/src/tables/cff2.rs b/src/tables/cff2.rs index 3af09f51..0f597b5c 100644 --- a/src/tables/cff2.rs +++ b/src/tables/cff2.rs @@ -31,7 +31,9 @@ use alloc::vec::Vec; use core::cell::{OnceCell, RefCell}; use crate::error::{Error, Result}; -use crate::tables::cff::{read_index2, BlendContext, CharstringSink, FdSelect, Index, RegionCache}; +use crate::tables::cff::{ + read_index2, BlendContext, CharstringSink, FdSelect, Index, RegionCache, MAX_CHARSTRING_OPS, +}; use crate::tables::outline::OutlineSink; use crate::tables::parse::Reader; use crate::tables::variation_store::ItemVariationStore; @@ -157,6 +159,22 @@ impl<'a> Cff2<'a> { coords: &[f32], shared: &Cff2Shared<'a>, sink: &mut S, + ) -> Result { + let mut ops = MAX_CHARSTRING_OPS; + self.outline_limited(glyph_id, coords, shared, sink, &mut ops) + } + + /// [`Cff2::outline_shared`] for a caller that bounds many glyphs + /// together: the charstring may run at most `ops_left` operations + /// (and never more than one glyph may), which it takes from + /// `ops_left`, failing with the operation-limit error past them. + pub(crate) fn outline_limited( + &self, + glyph_id: u16, + coords: &[f32], + shared: &Cff2Shared<'a>, + sink: &mut S, + ops_left: &mut u32, ) -> Result { let gid = usize::from(glyph_id); if gid >= self.char_strings.len() { @@ -207,7 +225,10 @@ impl<'a> Cff2<'a> { }); let mut interp = crate::tables::cff::Interp2::new(self.global_subrs, private.local_subrs, sink, blend); - interp.run(cs, 0)?; + interp.limit_ops(*ops_left); + let ran = interp.run(cs, 0); + *ops_left = ops_left.saturating_sub(interp.ops()); + ran?; // CFF2 has no endchar, so the last contour is still open when // the charstring runs out. Close it here. interp.finish(); @@ -264,6 +285,18 @@ pub(crate) struct Cff2Shared<'a> { regions: RegionCache, } +impl Cff2Shared<'_> { + /// Regions times axes of the variation store read so far: what + /// working out every region scalar at one coord vector costs, at + /// most. Zero before a glyph has read the store, or without one. + pub(crate) fn scalar_work(&self) -> usize { + match self.ivs.get() { + Some(Ok(Some(ivs))) => usize::from(ivs.region_count()) * usize::from(ivs.axis_count()), + _ => 0, + } + } +} + // ---------------------------------------------------------------------------- // CFF2 Top DICT. // ---------------------------------------------------------------------------- diff --git a/src/tables/glyf.rs b/src/tables/glyf.rs index 7cb52362..1b183467 100644 --- a/src/tables/glyf.rs +++ b/src/tables/glyf.rs @@ -513,6 +513,42 @@ impl<'a> Glyf<'a> { self.outline_with(&cx, glyph_id, None, sink) } + /// [`Glyf::outline_at_coords`] for a caller that draws many glyphs + /// for one result and bounds them together: the walk takes its + /// `gvar` tuple work from `work` (see [`MAX_TUPLE_WORK`]) and leaves + /// the rest there, and returns, with whether it drew, the glyph + /// records it visited and the points it laid down, which its own + /// caps bound. Draws into `sink` only once the whole glyph is + /// flattened, so a glyph that fails sends no ops. + #[allow(clippy::too_many_arguments)] + pub(crate) fn outline_at_coords_with_work( + &self, + loca: &Loca<'_>, + glyph_id: u16, + gvar: Option<&Gvar<'_>>, + coords: &[f32], + metrics: Option<&PhantomMetrics<'_>>, + sink: &mut S, + work: &mut usize, + ) -> Result<(bool, usize, usize)> { + let cx = FlattenCtx { + loca, + metrics, + var: Variation::new(gvar, coords), + }; + let mut budget = FlattenBudget::new(); + budget.work = (*work).min(MAX_TUPLE_WORK); + let flat = self.flatten_root(&cx, glyph_id, None, &mut budget); + *work = budget.work; + let visits = (MAX_FLATTEN_GLYPHS - budget.glyphs) as usize; + let points = MAX_FLATTEN_POINTS - budget.points; + let Some(flat) = flat? else { + return Ok((false, visits, points)); + }; + flat.emit(sink); + Ok((true, visits, points)) + } + /// The points [`Glyf::outline_at_coords`] draws `glyph_id` from, /// contour points on and off the curve, handed to `visit` without /// drawing the outline: what a box of the outline needs, since the diff --git a/src/tables/gvar.rs b/src/tables/gvar.rs index 51cc4cf6..27fb698e 100644 --- a/src/tables/gvar.rs +++ b/src/tables/gvar.rs @@ -576,12 +576,16 @@ const PHANTOM_COUNT: usize = 4; /// few hundred points over at most a few hundred tuples. pub(crate) const MAX_TUPLE_WORK: usize = 1 << 24; +/// The `context` of the error a walk fails with when its tuple work +/// runs out. +pub(crate) const OUT_OF_TUPLE_WORK: &str = "gvar variation work exceeds the cap"; + /// Takes `cost` units from `work`, or fails with the byte offset of the /// tuple data that would overspend it. fn charge(work: &mut usize, cost: usize, offset: usize) -> Result<()> { *work = work.checked_sub(cost).ok_or(Error::Malformed { offset, - context: "gvar variation work exceeds the cap", + context: OUT_OF_TUPLE_WORK, })?; Ok(()) } From adb0a43024ac25e75702c3ebe17a938a01ddd74b Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 15:54:38 -0800 Subject: [PATCH 5/8] Say which VARC cycles the decycler cuts The glyph_outline_at_coords docs (and the PR) said cycles no longer reach the depth cap. HarfBuzz's decycler, which the walk follows exactly, cuts a cycle of n glyphs 2n - 1 levels down, so a cycle of more than 32 glyphs still reaches the 64-level cap first and fails the glyph, where HarfBuzz stops expanding at that depth (the reviewer's cycle_33 and cycle_40 fonts; cycle_4 through cycle_32 match HarfBuzz). The docs now say so, and that leaves share one glyph budget and are drawn once per coords. --- src/face/outlines.rs | 11 +++++++++-- src/face/varc_draw.rs | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/face/outlines.rs b/src/face/outlines.rs index 543491be..04b8f53f 100644 --- a/src/face/outlines.rs +++ b/src/face/outlines.rs @@ -183,9 +183,16 @@ impl<'a> Face<'a> { /// (`coords`) when it sets `RESET_UNSPECIFIED_AXES`, however deep /// it sits. /// - A component that names the glyph it belongs to draws that - /// glyph's `glyf` or CFF outline instead of recursing. A longer - /// cycle is cut where HarfBuzz's decycler cuts it, and a covered + /// glyph's `glyf` or CFF outline instead of recursing. A covered /// glyph past the end of the glyph records draws nothing. + /// - A cycle of `n` glyphs is cut where HarfBuzz's decycler cuts it, + /// `2n - 1` levels down. A cycle of more than 32 glyphs reaches + /// the 64-level cap first and fails the glyph, where HarfBuzz stops + /// expanding at that depth and draws the rest. + /// - Leaves (glyphs drawn from `glyf` or CFF) share HarfBuzz's 2^24 + /// glyph budget for their `gvar` and charstring work, and draw + /// nothing once it runs out, as HarfBuzz's do; a leaf reached again + /// at the same coords is drawn once. /// - At the default instance of a font with `fvar` (`coords` empty or /// all zero), VARC reads one zero coord per axis, as HarfBuzz's /// font holds them, so deltas from a region that constrains no diff --git a/src/face/varc_draw.rs b/src/face/varc_draw.rs index 69c501e9..34475bbf 100644 --- a/src/face/varc_draw.rs +++ b/src/face/varc_draw.rs @@ -50,7 +50,9 @@ const IDENTITY: [f32; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; /// glyphs whose composites are being drawn, from the glyph drawn down, /// and a glyph that `VARC` covers is not drawn at position `k` of the /// path when it is the glyph at position `k / 2`, where HarfBuzz's -/// tortoise sits. +/// tortoise sits. That cuts a cycle of `n` glyphs `2n - 1` levels down, +/// so one of more than 32 glyphs reaches [`MAX_VARC_DEPTH`] first, which +/// fails the glyph where HarfBuzz stops expanding at that depth. pub(crate) struct VarcDraw<'w, 'a> { face: &'w Face<'a>, varc: &'w Varc<'a>, From 434e15392e4727316234c9651fa39240c69ccf15 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 15:59:39 -0800 Subject: [PATCH 6/8] Bucket the VARC leaf memo by a hash of the coords The leaves' memo and the CFF2 state kept per coord vector were BTreeMaps keyed by the coords themselves, so a lookup compared whole coord vectors at every step of the search. Components with 4,096-axis coords that differ only in their last axis made each lookup cost tens of thousands of comparisons, and the hostile wide_coords font went from 2.3 ms to 16 to 20 ms with the memo. Both maps now key by the glyph (for leaves) and an FNV-1a hash of the coords' bits, with the coords compared once inside the bucket. That font now takes 4.6 to 5.2 ms; leaf_gvar.ttf and the other leaf fonts are unchanged. --- src/face/varc_draw.rs | 63 +++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/src/face/varc_draw.rs b/src/face/varc_draw.rs index 34475bbf..c301d5cf 100644 --- a/src/face/varc_draw.rs +++ b/src/face/varc_draw.rs @@ -314,6 +314,10 @@ const MAX_KEPT_LEAF_OPS: usize = 1 << 18; /// Leaf work per charstring operation a CFF or `CFF2` leaf runs. const CHARSTRING_OP_WORK: usize = 4; +/// A leaf outline [`Leaves`] keeps: the coords it was drawn at, as `f32` +/// bits, and the outline, `None` when it drew nothing. +type KeptLeaf = (Vec, Option>); + /// The leaves of a walk: the tables they are drawn from, read the first /// time a leaf needs them, in the order [`Face::glyph_outline_at_coords`] /// reads them for a glyph `VARC` does not draw, and kept for the walk @@ -325,12 +329,15 @@ struct Leaves<'a> { tables: OnceCell>>, /// Read for the first leaf away from the default instance. gvar: OnceCell>>>, - /// Outlines by glyph and coords (as `f32` bits); `None` for a leaf - /// that drew nothing. The coords were charged to the walk's work - /// when its components were built, so the keys are bounded by it. - drawn: BTreeMap<(u16, Vec), Option>>, - /// What the `CFF2` leaves drawn at one coord vector share. - cff2_shared: BTreeMap, Cff2Shared<'a>>, + /// Outlines by glyph and coords (as `f32` bits), bucketed by the + /// glyph and [`coords_hash`] so a lookup compares long coord + /// vectors once; `None` for a leaf that drew nothing. The coords + /// were charged to the walk's work when its components were built, + /// so the keys are bounded by it. + drawn: BTreeMap<(u16, u64), Vec>, + /// What the `CFF2` leaves drawn at one coord vector share, bucketed + /// the same way. + cff2_shared: BTreeMap, Cff2Shared<'a>)>>, /// See [`MAX_LEAF_WORK`]. work: usize, /// See [`MAX_KEPT_LEAF_OPS`]. @@ -374,30 +381,35 @@ impl<'a> Leaves<'a> { gid: u16, coords: &[f32], ) -> Result>> { - let key = ( - gid, - coords.iter().map(|c| c.to_bits()).collect::>(), - ); - if let Some(known) = self.drawn.get(&key) { - return Ok(known.clone()); + let bits: Vec = coords.iter().map(|c| c.to_bits()).collect(); + let hash = coords_hash(&bits); + let known = self + .drawn + .get(&(gid, hash)) + .and_then(|bucket| bucket.iter().find(|(key, _)| *key == bits)); + if let Some((_, outline)) = known { + return Ok(outline.clone()); } - let outline = self.draw(face, gid, coords, &key.1)?.map(Rc::new); + let outline = self.draw(face, gid, coords, &bits, hash)?.map(Rc::new); let ops = outline.as_ref().map_or(0, |o| o.len()); if ops <= self.kept_ops_left { self.kept_ops_left -= ops; - self.drawn.insert(key, outline.clone()); + let bucket = self.drawn.entry((gid, hash)).or_default(); + bucket.push((bits, outline.clone())); } Ok(outline) } /// Draws a leaf [`Leaves::outline`] has not drawn yet, charging its - /// work. `bits` are the coords as `f32` bits. + /// work. `bits` are the coords as `f32` bits, `hash` their + /// [`coords_hash`]. fn draw( &mut self, face: &Face<'a>, gid: u16, coords: &[f32], bits: &[u32], + hash: u64, ) -> Result> { if self.work == 0 { return Ok(None); @@ -410,8 +422,14 @@ impl<'a> Leaves<'a> { let mut out = Outline::new(); let drew = match tables { LeafTables::Cff2(cff2) => { - let fresh = !self.cff2_shared.contains_key(bits); - let shared = self.cff2_shared.entry(bits.to_vec()).or_default(); + let bucket = self.cff2_shared.entry(hash).or_default(); + let at = bucket.iter().position(|(key, _)| key == bits); + let fresh = at.is_none(); + let at = at.unwrap_or_else(|| { + bucket.push((bits.to_vec(), Cff2Shared::default())); + bucket.len() - 1 + }); + let shared = &bucket[at].1; let limit = charstring_limit(self.work); let mut ops = limit; let drew = cff2.outline_limited(gid, coords, shared, &mut out, &mut ops); @@ -494,6 +512,17 @@ fn charstring_drew(drew: Result, limit: u32, work: &mut usize) -> Result u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for &b in bits { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + hash ^ bits.len() as u64 +} + /// The charstring operations `work` leaf work pays for, at most one /// glyph's own limit. fn charstring_limit(work: usize) -> u32 { From 737e78752a68e71dadcd27abb2cc9f847eaed73e Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 16:15:03 -0800 Subject: [PATCH 7/8] Weigh VARC leaf charstring operations at 8 units The reviewer-style CFF2 hostile font (2,047 components at distinct coords naming a leaf that runs 39,000 charstring operations) still took 23 to 28 ms per glyph at 4 units an operation, and 13 s in the face fuzz target under ASan, where an operation costs about eight times one point of one gvar tuple. At 8 units the leaves stop after 53 such leaves instead of 106, and the glyph takes 13 to 15 ms. A real CFF2 leaf runs a few hundred operations, far inside the 2^24-unit budget at either weight; reviewer-built CFF2 VARC fonts still match HarfBuzz at every location. --- src/face/varc_draw.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/face/varc_draw.rs b/src/face/varc_draw.rs index c301d5cf..fa7ac63c 100644 --- a/src/face/varc_draw.rs +++ b/src/face/varc_draw.rs @@ -311,8 +311,9 @@ const GLYPH_VISIT_WORK: usize = 64; /// again; the walk's own op cap bounds what it draws. const MAX_KEPT_LEAF_OPS: usize = 1 << 18; -/// Leaf work per charstring operation a CFF or `CFF2` leaf runs. -const CHARSTRING_OP_WORK: usize = 4; +/// Leaf work per charstring operation a CFF or `CFF2` leaf runs: about +/// what an operation costs next to one point of one `gvar` tuple. +const CHARSTRING_OP_WORK: usize = 8; /// A leaf outline [`Leaves`] keeps: the coords it was drawn at, as `f32` /// bits, and the outline, `None` when it drew nothing. From ab18e67344b4e3017e7f7b55dceb2bdbdbed5c5a Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 16:44:43 -0800 Subject: [PATCH 8/8] Leave a VARC that does not parse out of a subset The subset failed on a font whose VARC the core rejects, such as one with the MultiItemVariationStore layout HarfBuzz adopted after 14.5.0. Such a table now counts as absent, as the face's outlines read it and as HarfBuzz drops it: the subset leaves it out with a warning, and its glyphs keep their glyf or CFF outlines. The output equals the subset of the font without VARC. --- crates/sigilbuzz-subset/src/lib.rs | 24 ++-- .../sigilbuzz-subset/tests/varc_unparsable.rs | 134 ++++++++++++++++++ 2 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 crates/sigilbuzz-subset/tests/varc_unparsable.rs diff --git a/crates/sigilbuzz-subset/src/lib.rs b/crates/sigilbuzz-subset/src/lib.rs index 326b6ca5..d9187a48 100644 --- a/crates/sigilbuzz-subset/src/lib.rs +++ b/crates/sigilbuzz-subset/src/lib.rs @@ -706,16 +706,22 @@ fn push_layout_and_variation_tables( // are rewritten to point at the new gid namespace; the // MultiVarStore is pruned to the entries the kept records use. The // core parse only validates the table: `subset_varc` walks the raw - // bytes itself. - if face.varc()?.is_some() { - let varc_bytes = face.table_bytes(tag::VARC).map_err(SubsetError::from)?; - let lookup = |old: GlyphId| -> Option { - let i = gid_map.binary_search_by_key(&old, |(o, _)| *o).ok()?; - gid_map.get(i).map(|&(_, new)| new) - }; - if let Some(b) = varc::subset_varc(varc_bytes, kept, &lookup)? { - tables.push((tag::VARC, b)); + // bytes itself. A VARC that does not parse counts as absent, as the + // face's outlines read it and as HarfBuzz drops it: it is left out + // with a warning, and its glyphs keep their glyf or CFF outlines. + match face.varc() { + Ok(Some(_)) => { + let varc_bytes = face.table_bytes(tag::VARC).map_err(SubsetError::from)?; + let lookup = |old: GlyphId| -> Option { + let i = gid_map.binary_search_by_key(&old, |(o, _)| *o).ok()?; + gid_map.get(i).map(|&(_, new)| new) + }; + if let Some(b) = varc::subset_varc(varc_bytes, kept, &lookup)? { + tables.push((tag::VARC, b)); + } } + Ok(None) => {} + Err(_) => warnings.push(tag::VARC, 0, "VARC that does not parse", "the whole table"), } Ok(()) } diff --git a/crates/sigilbuzz-subset/tests/varc_unparsable.rs b/crates/sigilbuzz-subset/tests/varc_unparsable.rs new file mode 100644 index 00000000..e7f0b0b0 --- /dev/null +++ b/crates/sigilbuzz-subset/tests/varc_unparsable.rs @@ -0,0 +1,134 @@ +//! A `VARC` table the core cannot parse is left out of a subset with a +//! warning, as the face's outlines read the font without it and as +//! HarfBuzz drops it. Before, `subset` failed on such a font. +//! +//! The table splices in a MultiItemVariationStore in the layout HarfBuzz +//! adopted after 14.5.0 (a 32-bit region count, and an Offset32 from each +//! MultiVarData to its delta-set INDEX), which the core rejects. + +use sigilbuzz::Face; +use sigilbuzz_subset::{subset, SubsetInput}; + +const GLYF_FONT: &[u8] = include_bytes!("../../../tests/fixtures/varc_parity.ttf"); + +/// A `VARC` table covering `covered`, each glyph one component naming +/// glyph 1, over a MultiItemVariationStore in the post-14.5.0 layout. +fn new_layout_varc(covered: &[u16]) -> Vec { + let mut coverage = vec![0, 1]; + coverage.extend_from_slice(&(covered.len() as u16).to_be_bytes()); + for g in covered { + coverage.extend_from_slice(&g.to_be_bytes()); + } + + let mut store = Vec::new(); + store.extend_from_slice(&1u16.to_be_bytes()); // format + store.extend_from_slice(&12u32.to_be_bytes()); // region list + store.extend_from_slice(&1u16.to_be_bytes()); // one MultiVarData + store.extend_from_slice(&30u32.to_be_bytes()); // at 30 + store.extend_from_slice(&1u32.to_be_bytes()); // region count (u32) + store.extend_from_slice(&8u32.to_be_bytes()); // Offset32 to the region + store.extend_from_slice(&1u16.to_be_bytes()); // axis count + store.extend_from_slice(&0u16.to_be_bytes()); // axis 0 + store.extend_from_slice(&0i16.to_be_bytes()); + store.extend_from_slice(&0x4000i16.to_be_bytes()); + store.extend_from_slice(&0x4000i16.to_be_bytes()); + assert_eq!(store.len(), 30); + store.push(1); // MultiVarData format + store.extend_from_slice(&1u16.to_be_bytes()); + store.extend_from_slice(&0u16.to_be_bytes()); + store.extend_from_slice(&9u32.to_be_bytes()); // Offset32 to the INDEX + store.extend_from_slice(&1u32.to_be_bytes()); + store.extend_from_slice(&[1, 1, 3, 0x00, 100]); + + let record = [0x18, 0x00, 0x01, 0x00, 0x00, 0x00]; + let mut records = (covered.len() as u32).to_be_bytes().to_vec(); + records.push(1); // offSize + for i in 0..=covered.len() { + records.push((1 + i * record.len()) as u8); + } + for _ in covered { + records.extend_from_slice(&record); + } + + let mut out = Vec::new(); + out.extend_from_slice(&[0, 1, 0, 0]); // version 1.0 + let mut at = 24u32; + for part in [&coverage, &store] { + out.extend_from_slice(&at.to_be_bytes()); + at += part.len() as u32; + } + out.extend_from_slice(&0u32.to_be_bytes()); // no conditions + out.extend_from_slice(&0u32.to_be_bytes()); // no axis indices + out.extend_from_slice(&at.to_be_bytes()); // glyph records + out.extend_from_slice(&coverage); + out.extend_from_slice(&store); + out.extend_from_slice(&records); + out +} + +/// `font` with its tables rewritten into a new directory, `VARC` +/// replaced by `varc`, or dropped when `varc` is `None`. +fn with_varc(font: &[u8], varc: Option<&[u8]>) -> Vec { + let face = Face::parse_bytes(font, 0).unwrap(); + let mut tables: Vec<([u8; 4], &[u8])> = face + .records() + .iter() + .filter(|r| &r.tag != b"VARC") + .map(|r| (r.tag, face.table_bytes(r.tag).unwrap())) + .collect(); + if let Some(varc) = varc { + tables.push((*b"VARC", varc)); + } + tables.sort_by_key(|t| t.0); + let header = 12 + 16 * tables.len(); + let mut out = Vec::new(); + out.extend_from_slice(&font[..4]); + out.extend_from_slice(&(tables.len() as u16).to_be_bytes()); + out.extend_from_slice(&[0; 6]); + let mut body = Vec::new(); + for (tag, data) in &tables { + out.extend_from_slice(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&((header + body.len()) as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + while body.len() % 4 != 0 { + body.push(0); + } + } + out.extend_from_slice(&body); + out +} + +#[test] +fn a_subset_leaves_out_a_varc_that_does_not_parse() { + let bad_varc = new_layout_varc(&[2, 3]); + let with_bad = with_varc(GLYF_FONT, Some(&bad_varc)); + let without = with_varc(GLYF_FONT, None); + let bad_face = Face::parse_bytes(&with_bad, 0).unwrap(); + assert!(bad_face.varc().is_err(), "the spliced VARC must not parse"); + let plain_face = Face::parse_bytes(&without, 0).unwrap(); + + for drop_unhandled in [false, true] { + let input = SubsetInput { + gids: vec![0, 1, 2, 3], + drop_unhandled, + ..Default::default() + }; + let got = subset(&bad_face, &input).expect("subset succeeds without VARC"); + let want = subset(&plain_face, &input).expect("subset of the font without VARC"); + assert_eq!( + got.bytes, want.bytes, + "the subset equals the subset of the font without VARC (drop_unhandled {drop_unhandled})" + ); + let out = Face::parse_bytes(&got.bytes, 0).unwrap(); + assert!(out.record(*b"VARC").is_none(), "VARC is left out"); + assert!( + got.warnings + .iter() + .any(|w| w.table == *b"VARC" && w.dropped == "the whole table"), + "a warning names the dropped VARC: {:?}", + got.warnings + ); + } +}