Skip to content

Finish VARC through Face: nested reset, self-reference, one work budget, short delta sets - #298

Merged
albedosehen merged 7 commits into
mainfrom
fix/varc-face
Oct 4, 2026
Merged

albedosehen merged 7 commits into
mainfrom
fix/varc-face

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #296

This merged at e0ae353. The fixes from its independent review (an unparsable VARC failing every glyph, rotation accuracy past 90 degrees, leaf work, record floods, and the cycle wording corrected below) are in #300.

Finishes VARC through Face (the follow-ups #293 left for src/face/outlines.rs): a VARC glyph is now drawn by one walk that resolves every composite in the font's coords, draws a self-referencing component's plain outline, shares one work budget across the whole glyph, and reads short delta sets as HarfBuzz 14.5.0 does. Checking that walk against HarfBuzz's VARC::get_path_at turned up three more differences, fixed in their own commits: HarfBuzz's decycler, covered glyphs without a record, and VARC deltas at the default instance. Outlines of fonts without VARC are byte-identical to main through every path.

Commits

Commit Item What changed
Read a VARC delta set that ends early as HarfBuzz does 4 A MultiItemVariationStore delta set that ends before it fills its tuples adds what it holds, read as HarfBuzz's TupleValues::fetcher_t reads it (a run cut short ends that region's tuple after its control byte, and the next tuple starts at the byte after it). A Value condition adds the summed deltas to its default, in HarfBuzz's order. The sigilbuzz-subset round-trip test now expects HarfBuzz's reading.
Draw VARC composites in one walk from the font's coords 1, 2 src/face/varc_draw.rs: every composite is resolved with composite_with_font_coords and the font's coords, so a reset component starts from them however deep it sits; a component naming its parent's glyph draws that glyph from glyf/CFF; transforms compose top down in hb_transform_t's order and each leaf is drawn once through a placing sink; leaf tables are read once per walk. GlyphOutlines draws VARC glyphs with the same walk and its cached Varc.
Share one VARC work budget across a glyph's walk 3 One VarcMemo per walk: one 2^20-unit work budget and one 65,536-visit condition budget for every composite the glyph reaches, with component records, axis values, And/Or offsets, coord vectors and region axes now charged; condition results, region scalars and resolved composites kept by coord vector, so a glyph reached again at the same coords is resolved once.
Cut VARC cycles and draw missing records as HarfBuzz does found while verifying HarfBuzz's tortoise-and-hare hb_decycler_t (a covered glyph at position k of the path is skipped when the glyph at k / 2 is the same), and a glyph the coverage names past the glyph records draws nothing.
Give VARC a zero per axis at the default instance found while verifying hb_font_create gives a font with fvar one coord per axis even at the default, so VARC deltas from a region that constrains no axis apply there; the walk now does the same.
Work out VARC region scalars with one sort 3 A subtable's region scalars were deduplicated through a BTreeMap, the costliest unit of the budget (34 to 42 ms for a glyph over 30,000 distinct regions); slots and positions are now packed into u32s and sorted once.
Check nested VARC composites against HarfBuzz tests tests/fixtures/varc_face.ttf (41 VARC glyphs), its builder and HarfBuzz expected file; tests/varc_parity.rs checks both fonts through all three outline paths.

Design

Face::glyph_outline_at_coords and GlyphOutlines::draw hand a glyph the VARC coverage names to VarcDraw (src/face/varc_draw.rs), which walks it as VARC::get_path_at does:

  • Coords. Every composite is resolved with Varc::resolve(gid, coords, font_coords, memo), the font's coords fixed for the walk, so RESET_UNSPECIFIED_AXES starts from them at any depth. Empty coords become one zero per fvar axis.
  • Self-reference and cycles. A component whose glyph equals its parent's is drawn from glyf/CFF at its coords. A path of the glyphs being drawn applies HarfBuzz's decycler rule.
  • Transforms and leaves. The walk multiplies transforms top down (hb_transform_t::multiply's sums) and draws each leaf once through a sink that maps its points (x0 + xx*x + xy*y) and counts its ops against the 2^20-op cap. Leaf tables (CFF2, CFF, or glyf/loca/hmtx/vmtx, and gvar for the first leaf away from the default) are read once per walk, in the face's order, errors included.
  • Budgets. VarcMemo (in src/tables/varc.rs) holds the walk's work and visit budgets and, per coord vector (keyed by f32 bits), condition results by table offset, region scalars by subtable, and composites by glyph (Rc). Keeping a coord vector costs one unit per value, so the memo is bounded by the budget. Out of work: the delta that ran out adds nothing, a condition that needed it fails, and the composite being read (and every later one) ends, as HarfBuzz stops when its budget goes negative. Varc::composite_with_font_coords is one walk over one composite with the same budget.
  • Region scalars. Worked out once per coord vector and subtable, each distinct region once, through one sort of the subtable's region indexes.
  • Caps kept. 2,048 components, 2^20 ops and 64 levels per glyph, each failing with Malformed as before. A leaf's ops now count once instead of once per level they were copied through.

Evidence, before and after

Outlines compared with HarfBuzz 14.5.0 (uharfbuzz 0.56.2, draw_glyph_with_pen), same op sequence and every coordinate within 0.01 units, at the eight locations of varc_parity.expected. "Before" is main at 8b04190.

varc_parity.ttf (#293's 14-glyph font):

Path Before After
Face::glyph_outline_at_coords 13 of 14 at each of 8 locations (v_outer, the nested reset, up to 31.5 units off) 14 of 14 at all 8
GlyphOutlines::outline 13 of 14 14 of 14
GlyphOutlines::draw 13 of 14 14 of 14
Largest difference on matching glyphs 9.0e-5 units (v_nested) 3.0e-5

varc_face.ttf (new, 41 VARC glyphs), glyph-locations matching HarfBuzz through Face (the two GlyphOutlines paths give the same counts):

Group Glyphs Before After
Reset two and three composites down, in the middle of a chain, with varied axis values 10 43 of 80 80 of 80
Component naming its own glyph (top, nested, with reset) 4 0 of 32 (each failed: "VARC composite recursion exceeded cap") 32 of 32
Cycles of two and three glyphs 5 0 of 40 (same error) 40 of 40
Covered glyph past the glyph records, and a glyph using it 2 0 of 16 (drew the glyf outline) 16 of 16
Short delta sets (cut in the first, a middle, the last tuple; empty; cut i32 run; axis values; three regions) 11 43 of 88 (up to 100 units off) 88 of 88
Conditions inside nested composites, one gating a reset component 4 15 of 32 32 of 32
One glyph reached three times at the same coords and once elsewhere 2 16 of 16 16 of 16
Region without axes (translation, condition, nested), at the default 3 21 of 24 24 of 24
Per location 41 14 to 22 41 at all 8

Largest difference on matching glyphs after: 3.3e-5 units. HarfBuzz's own self-reference regression font, test/api/fonts/varc-static-gvar.ttf (glyph a names itself with an axis value): before, Face failed it with the depth-cap error; after, it matches HarfBuzz.

Hostile fonts (built for this change with #293's encoders; release build, the slowest glyph of each at the default and at AAAA=100 through Face::glyph_outline_at_coords, GlyphOutlines within the same range, on a laptop shared with other work, so ranges are over two runs; peak heap by a counting allocator):

Font Before After
distinct_regions (526 KB): 2,047 components at distinct coords naming a glyph whose translation varies over 30,000 distinct regions 16,765 ms, 6.0 MB 11 to 13 ms, 3.1 MB
fanout_cond (200 KB): 2,047 components at distinct coords, each reaching a glyph gated by 20 shared And levels over a 60,000-mention Value condition and translated through the same set 590 ms, 0.8 MB 5 to 9 ms, 2.0 MB
fanout_cond_same: the same, all at one coords 579 ms, 0.8 MB 3 to 6 ms, 0.9 MB
gated_flood (115 KB): 2,047 components naming a glyph of 20,000 gated-off components 250 ms, 0.5 MB 13 to 18 ms, 0.2 MB
deep_chain (197 KB): 64 nested glyphs of 31 components each at distinct coords over the condition 1,497 ms (GlyphOutlines 2,911 ms), 8.0 MB 5 to 9 ms, 2.5 MB
wide_coords (17 KB): 2,047 components with 4,096-axis coords naming a glyph of 200 more 23 ms, 7.7 MB 2 to 3 ms, 4.0 MB
self_fan: 2,047 components naming their own glyph 42 ms, then the depth-cap error 1 to 2 ms, 0.2 MB
cycle_fan: 40 x 40 components in a two-glyph cycle 0.8 ms, depth-cap error 0.5 to 0.9 ms, component-cap error
#293's condition_attack.ttf (182 KB) 0.7 to 0.9 ms, 0.4 MB 0.6 to 1.1 ms, 0.7 MB

Every glyph of every one of these fonts draws in under 19 ms after, against up to 16.8 s before.

Work-count tests (no timing): in src/face/varc_draw.rs, 600 components reaching a glyph at distinct coords whose condition and deltas cost 180,053 units there spend the shared 2^20 budget after six copies (one budget per composite let them spend 108 million units), visiting 246 condition tables; 600 reaching it at one coords resolve it once (183,057 units, 41 visits). In src/tables/varc/tests.rs, the #293 counts move with the new charges (the 20-level chain: 41 visits, 120,045 units), a memo test checks that a glyph at the same coords costs nothing more, and the run-cut-short reading is pinned byte by byte.

Fonts without VARC: byte-identical to main. Every glyph (or every 2nd to 53rd for large fonts), at 1 to 3 settings each, through glyph_outline, glyph_outline_at_coords, GlyphOutlines::outline, GlyphOutlines::draw and glyph_bounds_at_coords, floats printed exactly: 26 of 26 dumps identical. Fonts: Fraunces and Fraunces Italic, Hahmlet, Nabla (COLR), Noto Sans KR VF (CFF2 and glyf), Recursive, Roboto Flex, Source Serif 4 VF (CFF2), Arial, Segoe UI, Segoe UI Emoji (COLR), Calibri, Georgia, Consolas, Bahnschrift, Sitka VF, Gabriola, Ebrima, Malgun Gothic, and the fixtures Source Code Pro (CFF), Source Sans 3 VF (CFF2), CidCff1Synthetic (CID CFF), the Noto Sans KR CFF2 subset, the Hahmlet subset and Rubik VF.

Verification

  • cargo fmt --all -- --check: clean.
  • cargo +1.99.0 clippy --workspace --all-targets --all-features -- -D warnings, the same with --no-default-features, and cargo +1.99.0 clippy -p sigilbuzz --no-default-features -- -D warnings: clean.
  • cargo test --workspace --all-features: 3,817 passed, 0 failed, 9 ignored, 200 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 (one existing, from glyph_points).
  • 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 per round. Seeds: the VARC, morx, sbix and COLRv1 fixtures, varc_face.ttf, the hostile fonts under 64 KB and HarfBuzz's varc-static-gvar.ttf, each also with zeroed and set control bytes. Three rounds: on the walk before the default-coords and scalar commits, face 38,402 runs and shape 250,148; on 6420629, 31,321 and 207,051; on the head e0ae353, 28,054 and 183,892. Peak RSS at most 618 MB. No crashes, timeouts or OOMs in any round. face runs slowly on these seeds (30 to 60 a second) because it draws every glyph of the hostile fonts through every path.

Not done

  • The 2,048-component, 2^20-op and 64-level caps still fail the glyph with Malformed. HarfBuzz stops expanding at 64 levels and 16,384 edges and stops drawing when its 2^24-unit glyph budget (shared with the leaves) runs out, and returns what it drew. With the decycler, cycles of up to 32 glyphs no longer reach these caps; longer ones still reach the 64-level cap first (corrected after review; documented in Fix the VARC review findings of #298 #300).
  • (Fixed in Fix the VARC review findings of #298 #300.) A VARC table sigilbuzz cannot parse failed every glyph of the font (Face::varc errors), where HarfBuzz 14.5.0's sanitizer drops the table and draws glyf. Correction: HarfBuzz's test/api/fonts/varc-6868.ttf and the varc-ac0* fonts are in the MultiItemVariationStore layout HarfBuzz adopted after 14.5.0 (bd079077d2), not an older one; the copies at tag 14.5.0 parse in sigilbuzz.
  • Glyph extents of VARC glyphs (shaping's fallback mark positioning and vertical origins): HarfBuzz asks VARC first and unions each leaf's extents box mapped by its transform; sigilbuzz takes the glyf header box for a glyf font and the control box of the VARC outline for a CFF font. The outline the extents read for CFF fonts does get every fix here.
  • varc-short-cff2.otf (HarfBuzz test font): HarfBuzz 14.5.0 draws nothing for its glyphs because its CFF2 charstring count differs from maxp; sigilbuzz draws them. A CFF2 difference, not VARC.
  • HarfBuzz after 14.5.0 applies VARC deltas in a font without fvar (its varc-static-deltas test); 14.5.0 does not, and neither does sigilbuzz.
  • GlyphOutlines draws VARC leaves from tables its walk reads once per glyph, not from the tables it keeps for all glyphs (its CFF2 region scalars are for one coord vector, and leaves have their own). Face::glyph_outline_at_coords still parses VARC once per call.

CHANGELOG lines

Changed:

Fixed:

A MultiItemVariationStore delta set that ended before it filled its
tuples (one per region of its subtable) added nothing: the reader
checked the whole set first. HarfBuzz's MultiVarData::get_delta adds
what it finds, region by region, so on a crafted font a component
moved by its first values in HarfBuzz and not at all in sigilbuzz.

The reader now walks such a set as HarfBuzz's TupleValues::fetcher_t
does: a region's tuple stops where the stream does, and a run whose
values do not fit the bytes left ends that region's tuple (or the lazy
skip past regions whose scalar is zero) after its control byte, so the
next region's tuple starts at the byte after it. It used to end the
stream there.

A Value condition now adds the summed deltas to its default value, as
HarfBuzz's instancer returns them, instead of adding each region's
delta to the default in turn; the two can round differently in f32.

The sigilbuzz-subset round-trip test that encoded the old reading
(each delta set holds one value for two translations) now expects
HarfBuzz's: at axis 0 = 1 each set's value moves X and Y keeps its
stored value, which also checks that every remapped variation index
still reads its own set.
Face::glyph_outline_at_coords resolved a nested VARC composite by
calling itself with the component's coords, so Varc::composite took
them as the font's: a RESET_UNSPECIFIED_AXES component one or more
composites down started from its parent's coords instead of the
font's. A component naming its own glyph recursed until the 64-level
cap and failed the glyph, where HarfBuzz draws that glyph's plain
outline.

A VARC glyph is now drawn by one walk (src/face/varc_draw.rs), as
HarfBuzz's VARC::get_path_at walks it:

- Every composite is resolved with composite_with_font_coords and the
  coords of the glyph drawn, so a reset starts from the font's coords
  however deep it sits.
- A component whose glyph is its parent's draws that glyph from glyf
  or CFF at the component's coords.
- Transforms compose top down, in hb_transform_t's order, and each
  leaf is drawn once through a sink that places its points
  (x0 + xx*x + xy*y, as HarfBuzz's transform_point sums them) instead
  of copying every nested outline into its parent's.
- The leaves' tables (CFF2, CFF, or glyf with loca, hmtx, vmtx and,
  away from the default instance, gvar) are read once per walk, in
  the order the face reads them, instead of once per leaf.

The component, op and depth caps are unchanged, except that a leaf's
ops now count once rather than once per level they were copied
through. GlyphOutlines draws a VARC glyph with the same walk and its
cached VARC table, streaming into the sink, instead of resolving the
composite once to test it and then asking the face.
The condition and delta budget from #293 was per
composite_with_font_coords call, and one Face outline call makes up to
2,049 of them, so a glyph whose 2,048 components each reached heavy
condition or delta work could spend 2,049 budgets: 0.5 s for a 200 KB
font, 1.2 s for 64 nested levels of 31 components, 0.25 s for 2,047
components naming a glyph of 20,000 gated-off components (release
build, HarfBuzz-style hostile fonts built for this change).

A walk now holds one VarcMemo for every composite it resolves:

- One work budget of 2^20 units and one condition-visit budget of
  65,536. Besides the region scalars and delta values #293 charged,
  units now pay for each component record read and each axis value in
  it, each offset of an And or Or condition, each coord a component's
  coord vector holds, the axes a region scalar evaluates, and each
  coord vector the memo keeps. Every one of these was free or charged
  per call before, so the walk's total work is bounded by the budget
  however its components share children.
- What depends only on coords, kept by coord vector for the whole walk:
  condition results, region scalars, and resolved composites (an Rc
  per glyph). A glyph reached again at the same coords, from any
  parent, costs nothing more.

Out of work, the delta that ran out adds nothing, a condition that
needed it fails, and no more components are read, so the composite
being read and every later one ends there. A walk holds at most one
budget's worth of coords and composites, so it stays small: the worst
of the hostile fonts peaks at 4.1 MB of heap and every one of their
glyphs draws in under 14 ms.

Varc::composite_with_font_coords is one walk over one composite, with
the same budget. Its per-composite cap of 2^20 coord values is gone:
the coords are charged to the budget instead, so a record of
4096-axis components now stops at 255 of them rather than 256.

Tests count the work: 600 components reaching a glyph at distinct
coords whose condition and deltas cost 180,047 units spend the budget
after six of them (one budget per composite let them spend 108
million units), and 600 reaching it at the same coords resolve it
once, for 181,848 units.
Two more differences from HarfBuzz 14.5.0's VARC::get_path_at, found
while checking the walk against it:

- A cycle longer than a self-reference (A names B, B names A) recursed
  until the 64-level cap and failed the glyph. HarfBuzz walks the
  cycle until its hb_decycler_t, a tortoise moving at half the speed
  of the path, meets the glyph again: a covered glyph at position k
  of the path of glyphs being drawn is skipped when the glyph at
  position k / 2 is the same. The walk keeps that path and applies
  the same rule, so a two-glyph cycle draws its leaves twice over and
  stops, as in HarfBuzz.
- A glyph the VARC coverage names past the end of the glyph records
  fell back to its glyf or CFF outline. HarfBuzz reads such a record
  as empty and draws nothing, at the top or as a component. Varc's
  composite and composite_with_font_coords now return no components
  for it (still None for an uncovered glyph, as documented).
hb_font_create sets a face's default variations, so a HarfBuzz font
with fvar holds one coord per axis even at the default instance, and
VARC applies deltas whenever the font has coords: a region that
constrains no axis holds there, and its deltas apply. sigilbuzz reads
coords that all round to zero as no coords at all, so the same glyph
skipped those deltas (a component on such a region drew 100 units off
in a crafted font). A font without fvar has no coords in HarfBuzz
either, and still gets none.

The walk now starts from one zero per fvar axis when the coords are
empty, for Face::glyph_outline_at_coords and GlyphOutlines alike. The
walk's work-count tests move with it (their font has three axes, so
its coord vectors cost three units each).
tests/fixtures/varc_face.ttf has the axes of varc_parity.ttf and 41
glyphs the VARC coverage names: reset components two and three
composites down, a reset in the middle of a chain and one whose axis
values vary; components naming their own glyph (which carries a varied
glyf outline of its own), at the top, one composite down, and with a
reset; cycles of two and three glyphs; a covered glyph past the glyph
records and a glyph using it; delta sets that end early, cut a run in
the first, a middle or the last tuple, are empty, or cut an i32 run,
for translations, axis values and a Value condition; conditions read
inside nested composites, one gating a reset component; one glyph
reached three times at the same coords and once elsewhere; and a
region without axes, for a translation, a condition and a nested
composite. tests/tools/build_varc_face_fixtures.py writes it byte by
byte with the encoders of build_varc_morx_parity_fixtures.py, and
tests/tools/varc_face_expected.py records HarfBuzz 14.5.0's outlines
(uharfbuzz 0.56.2) at the eight locations varc_parity.expected uses.

tests/varc_parity.rs now checks every glyph of both fonts through
Face::glyph_outline_at_coords, GlyphOutlines::outline and
GlyphOutlines::draw, and the parity font also through
composite_with_font_coords. v_outer, the nested reset, no longer needs
its own path. Before this branch the new font matched HarfBuzz on 14
to 22 of its 41 glyphs per location; now all 41 at all eight.
@albedosehen albedosehen added this to the 0.24.0 milestone Oct 4, 2026
A subtable's region scalars were deduplicated through a BTreeMap, one
insert per distinct region, which made them the costliest unit of the
walk's budget. A crafted font whose glyph names, 2,047 times at
distinct coords, a glyph varying through a subtable of 30,000 distinct
regions took 16.8 s on main and 34 to 42 ms with the shared budget.

The region slots and positions are now packed into one u32 each
(both fit 16 bits) and sorted once, and each distinct region is
evaluated as the sorted list reaches it. The font above now takes 11
to 13 ms per glyph, and every hostile font built for this change draws
its slowest glyph in under 19 ms. The scalars and the axis count
charged for them are unchanged; a new test checks that scalars keep
the subtable's order with indexes out of order, repeated, and past the
region list.
@albedosehen
albedosehen marked this pull request as ready for review October 4, 2026 23:19
@albedosehen
albedosehen merged commit 5d59ff3 into main Oct 4, 2026
8 checks passed
albedosehen added a commit that referenced this pull request Oct 5, 2026
Fix the VARC review findings of #298
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.

Finish VARC through Face: nested reset, self-reference, a shared work budget, short delta sets

1 participant