Skip to content

Fix the VARC review findings of #298 - #300

Merged
albedosehen merged 8 commits into
mainfrom
fix/varc-face
Oct 5, 2026
Merged

albedosehen merged 8 commits into
mainfrom
fix/varc-face

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #298 (#296), which merged at e0ae353 before these review fixes were pushed. Fixes the five findings of the independent review of #298: an unparsable VARC table failed every glyph, rotations past about 90 degrees were inaccurate, leaf work was not charged, a flood of component records was read past the component cap, and the docs overstated which cycles the decycler cuts. No change for fonts without VARC.

Commits

Commit Review item What changed
Read a VARC table that does not parse as absent 1 (must fix) Face::glyph_outline_at_coords, GlyphOutlines and the glyph extents shaping reads (fallback mark positioning, vertical origins) read VARC through a new Face::drawable_varc, which counts a table that does not parse as absent, as HarfBuzz 14.5.0's sanitizer drops it. Face::varc still reports the error.
  • sigilbuzz-subset: subset leaves out a VARC that does not parse, with a warning, and the glyphs keep their glyf or CFF outlines, as the face reads them (Fix the VARC review findings of #298 #300). The whole subset failed before. The output equals the subset of the font without VARC (tests/varc_unparsable.rs in the subset crate). |
    | Rotate VARC components as HarfBuzz's to_transform does | 2 (must fix) | The decomposed transform is built op for op as hb_transform_decomposed_t::to_transform builds it, in f32, with sine, cosine and tangent worked out in f64 (quarter-turn reduction in two parts, Taylor polynomial over [-π/4, π/4]) and rounded to f32 as libm's sinf/cosf/tanf return them. Replaces a Taylor series that stopped at y^7/y^8 over [-π, π]. |
    | Read no more VARC components than the walk can draw | 4 (nit) | The walk asks Varc::resolve for at most its remaining component cap plus one; reading stops there. |
    | Charge VARC leaves to one budget and draw each leaf once | 3 (should fix) | Leaves share HarfBuzz's 2^24 glyph budget (gvar tuple work, glyph visits and points, charstring operations, CFF2 region scalars per coord vector) and draw nothing past it, as HarfBuzz's do; each leaf outline is kept by glyph and coords and drawn once. Glyf, Cff, Cff2 and the charstring interpreter gain crate-internal entry points that take their work or operation limit from the caller; their own paths keep their per-glyph limits. |
    | Say which VARC cycles the decycler cuts | 5 (nit) | Docs: the decycler cuts a cycle of n glyphs 2n - 1 levels down, so one of more than 32 glyphs still reaches the 64-level cap. |
    | Bucket the VARC leaf memo by a hash of the coords | 3, follow-up | The leaf memo and per-coords CFF2 state key by an FNV-1a hash of the coords with one compare in the bucket, so 4,096-axis coords do not make each lookup compare whole vectors at every step. |
    | Weigh VARC leaf charstring operations at 8 units | 3, follow-up | A charstring operation costs about eight times one point of one gvar tuple; 4 units let a CFF2 hostile glyph take 23 to 28 ms. |

Added by the coordinator after the agent's report: ab18e67 Leave a VARC that does not parse out of a subset (crates/sigilbuzz-subset/src/lib.rs, new crates/sigilbuzz-subset/tests/varc_unparsable.rs). The subset crate's tests, fmt and clippy pass locally.

Evidence, before (#298 at e0ae353) and after

HarfBuzz 14.5.0 is uharfbuzz 0.56.2. Outlines match when the op sequence is the same and every coordinate is within 0.01 units.

1. A VARC table that does not parse. After 14.5.0, HarfBuzz main (bd079077d2, boring-expansion-spec #175) moved the MultiItemVariationStore to a new layout (a u32 region count, an Offset32 from each MultiVarData to its delta-set INDEX) and migrated its test fonts; sigilbuzz's parser, like HarfBuzz 14.5.0's sanitizer, rejects it. Fonts with a new-layout VARC (from the review):

Font Before After HarfBuzz 14.5.0
scp_newvarc.otf (Source Code Pro subset, CFF) outlines 0 of 96 glyphs, every one CFF2 INDEX offSize out of range 96 of 96 match draws from CFF
ss3_newvarc.otf (Source Sans 3, CFF2) outlines 0 of 129 129 of 129 draws from CFF2
parity_newvarc.ttf (glyf) outlines 0 of 17 17 of 17 draws from glyf
scp_newvarc.otf "abc" top to bottom, y offsets -984, -984, -984 (ascender fallback) -871, -978, -871 -871, -978, -871
ss3_newvarc.otf "abc" top to bottom, y offsets -1000, -1000, -1000 -902, -1018, -902 -902, -1018, -902

New tests/varc_unparsable.rs splices a hand-built new-layout VARC 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. Both tests fail before the fix on the first glyph. Correction to #298: its Not done said HarfBuzz's varc-6868.ttf and varc-ac0* use an older encoding. They are in the new layout; the copies at tag 14.5.0 parse, and match HarfBuzz after the rotation fix (below).

2. Rotation. Largest difference from HarfBuzz:

Font Before After
review's rot.ttf: a box rotated about a center 500 units away by 45, 90, 120, 144, 162, 180, -162, -90 degrees 0.0001, 0.075, 1.04, 5.49, 16.1, 42.1, 18.2, 0.088 at most 0.000025
HarfBuzz's varc-6868.ttf at tag 14.5.0, glyph00002 (a 145-degree component), default and wght 700 2.10 0.00003
varc_face.ttf, new glyphs: 90, 135, 180, 270, -135 degrees about a far center, one varied past a half turn, a 72-degree skew 0.088, 3.57, 49.5, 0.106, 4.20, 81.0, 0.035 at most 0.00005

tests/fixtures/varc_face.ttf gains those seven glyphs (48 VARC glyphs now); tests/varc_parity.rs checks them through Face and both GlyphOutlines paths at the eight locations.

3 and 4. Leaf work and record floods (release build, slowest glyph at the default and at AAAA=100, two runs, the face and GlyphOutlines paths within the same range):

Font Before After
review's leaf_gvar.ttf: 2,047 components naming a 480-point glyph with 4,095 gvar tuples, one coords 2,384 to 4,324 ms 17 to 23 ms (drawn once, placed 2,047 times)
leaf_gvar_distinct.ttf: the same, each component at its own coords 2,518 to 3,806 ms 13 to 15 ms (stops after 8 leaves, as HarfBuzz does)
glyf_visits.ttf: 2,047 components at distinct coords naming a glyf composite of 255 x 255 visits 5,467 to 5,478 ms 14 to 17 ms
cff2_ops.otf: 2,047 components at distinct coords naming a CFF2 leaf that runs 39,000 operations 416 to 517 ms 13 to 15 ms
review's flood_1000k_nofvar.ttf: a 3 MB glyph of a million three-byte records 70 ms, 86 MB heap 0.5 ms, 0.9 MB
review's flood_1000k_fvar.ttf 37 ms, 23 MB 0.8 ms, 0.9 MB
review's flood_250k_nofvar.ttf 21.5 ms, 21 MB 0.6 ms, 0.9 MB

The earlier hostile set stays under 24 ms per glyph (distinct_regions 13 to 16 ms, gated_flood 17 to 21, deep_chain 7 to 10, fanout_cond 6, wide_coords 5, up from 2 for the leaf keys it now builds, with 6.6 MB of heap). leaf_gvar.ttf still holds 43 MB: its outline is a million ops, which the 2^20-op cap allows.

New tests: a_leaf_reached_again_at_the_same_coords_is_drawn_once (500 boxes at one coords cost one leaf's 80 units) and leaves_past_their_work_draw_nothing in src/face/varc_draw.rs; a_walk_reads_no_more_components_than_it_can_draw (100,000 records, 3 read, 4 units) in src/tables/varc/tests.rs; sine and cosine against exact values in every quadrant, libm's rounding of sinf(f32 π), and NaN for angles that are not finite.

5. Cycles. The review's cycle_4 through cycle_32 match HarfBuzz; cycle_33 and cycle_40 still fail with the depth-cap error (HarfBuzz stops expanding at that depth), now documented. chain_63/chain_64 match; chain_65/chain_66 fail at the cap as documented.

Parity overall (all glyphs, eight locations, Face, GlyphOutlines::outline, GlyphOutlines::draw):

Font Before After
varc_parity.ttf 17 of 17, largest difference 3.0e-5 17 of 17, 2.2e-5
varc_face.ttf (now 51 glyphs) 44 of 51 (the 7 rotation glyphs fail) 51 of 51, 4.9e-5
review's extra.ttf (nested resets, transforms, conditions, cycles) 20 of 20, 1.8e-3 20 of 20, 2.9e-5
review's extra_cff2.otf (the same over CFF2 leaves) 20 of 20, 1.4e-3 20 of 20, 2.9e-5
review's face_cff2.otf 44 of 44, 2.3e-5 44 of 44, 1.3e-5

Fonts without VARC: byte-identical to main through glyph_outline, glyph_outline_at_coords, GlyphOutlines::outline, GlyphOutlines::draw and glyph_bounds_at_coords, floats printed exactly: 26 of 26 dumps (Fraunces and Fraunces Italic, Hahmlet, Nabla, Noto Sans KR VF CFF2 and glyf, Recursive, Roboto Flex, Source Serif 4 VF, Arial, Segoe UI, Segoe UI Emoji, Calibri, Georgia, Consolas, Bahnschrift, Sitka VF, Gabriola, Ebrima, Malgun Gothic, and the Source Code Pro, Source Sans 3 VF, CID CFF, Noto Sans KR CFF2, Hahmlet and Rubik fixtures).

Verification

  • cargo fmt --all -- --check: clean.
  • cargo +1.99.0 clippy --workspace --all-targets --all-features -- -D warnings, the same with --no-default-features, cargo +1.99.0 clippy -p sigilbuzz --no-default-features -- -D warnings: clean.
  • cargo test --workspace --all-features: 3,825 passed, 0 failed, 9 ignored, 201 test binaries.
  • cargo build --no-default-features: ok.
  • MSRV (CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +stable generate-lockfile, then cargo +1.81 check --workspace --lib --no-default-features and --lib --bins --all-features): ok.
  • cargo doc -p sigilbuzz --no-deps: no new warnings.
  • Fuzzing in Docker (rustlang/rust:nightly, cargo-fuzz, -O -a, -max_len=65536 -rss_limit_mb=2048 -timeout=10, no --privileged), face and shape, 600 s each, seeds adding the review's fonts under 64 KB, rot.ttf, the new-layout fonts and the leaf hostile fonts. On 434e153: face 11,565 runs and shape 34,737, no crashes, timeouts or OOMs; one slow-unit report (13 s under ASan) for cff2_ops.otf, which led to 737e787. On the head 737e787: face 13,307 runs and shape 19,654, peak RSS 906 / 494 MB, no crashes, timeouts, OOMs or slow units. Runs are few because the seeds now include the hostile fonts, whose glyphs each take milliseconds in release and far longer under ASan.

Not done

  • The 2,048-component, 2^20-op and 64-level caps still fail the glyph with Malformed, where HarfBuzz stops expanding at 64 levels and 16,384 edges and draws what it has (the review's cycle_33, cycle_40, chain_65, chain_66, fan_50x50, leaves_2049).
  • The new MultiItemVariationStore layout (HarfBuzz after 14.5.0) is not read; such a VARC now counts as absent, as in HarfBuzz 14.5.0. To be filed as its own issue.
  • Glyph extents of VARC glyphs: HarfBuzz asks VARC first and unions each leaf's mapped extents box; sigilbuzz takes the glyf box for a glyf font and the control box of the VARC outline for a CFF font.
  • A CFF2 leaf is charged its store's regions times axes once per coord vector; a charstring that switches among many vsindex values pays for the extra region scalars only through its operations.
  • The leaf memo keeps at most 2^18 ops of outlines; a larger leaf reached again is drawn and charged again.

CHANGELOG lines

Changed:

Fixed:

Corrections to #298's lines: "longer cycles stop where HarfBuzz's decycler stops them" holds for cycles of up to 32 glyphs; longer ones still fail at the 64-level cap.

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".
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).
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).
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.
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.
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.
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.
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.
@albedosehen
albedosehen marked this pull request as ready for review October 5, 2026 00:50
@albedosehen
albedosehen merged commit 7b16bd9 into main Oct 5, 2026
8 checks passed
@albedosehen albedosehen mentioned this pull request Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant