Skip to content

Subset hinted CFF2 fonts, and finish the instancer's metrics - #294

Merged
albedosehen merged 9 commits into
mainfrom
fix/subset-cff2-hints
Oct 4, 2026
Merged

albedosehen merged 9 commits into
mainfrom
fix/subset-cff2-hints

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #290.

Subsets hinted CFF2 fonts (N1), counts a glyph's stems across subroutine calls (A18), finishes the instancer's OS/2, post and CFF2 metrics (B12), and adds a user-space entry point to the instancer (B13). Everything is inside crates/sigilbuzz-subset. No new dependencies; MSRV 1.81.

References: HarfBuzz 14.5.0 (hb-subset-cff-common.hh, hb-cff-interp-cs-common.hh, hb-cff2-interp-cs.hh, hb-subset-cff2.cc, hb-subset-plan-var.cc, hb-ot-os2-table.hh, hb-ot-post-table.hh, hb-ot-hmtx-table.hh), through uharfbuzz 0.56.2, and fontTools 4.66.1.

commit what
3ad21bb N1, A18: the subsetter runs each kept glyph through its subroutine calls; renumbered calls may grow
525b689 B12: a full CFF2 instance rounds every blend to a whole unit; the bake starts at the Private DICT's vsindex
e56f4db B12: usWeightClass, usWidthClass, xAvgCharWidth, italicAngle; CFF2 side bearings, head box, hhea extremes from the outlines
9580c2b B13: instance_user, AxisLimit, UserInstanceInput
da932e8 B12: those metrics round halves up, as HarfBuzz's roundf (floor(x + 0.5)) does
12bb477 Review B1, B2: a blend charges each value it leaves; the CFF2 bake stacks at most 513 operands
c2c1bd1 Review B3: a cross-FD global's local calls resolve with each FD's bias; sparse renumber tables
4f4b62f Review S1: the walk keeps memory for the subroutines it reaches; CID Font DICTs share one Private DICT read
d05e37c Review nit: AxisLimit and UserInstanceInput are #[non_exhaustive]; UserInstanceInput builders

Review fixes (d05e37c)

New commits on top of da932e8; nothing pushed before was rewritten. Timings are wall time with peak working set, release builds; "main" is fce2d02 and "PR head" da932e8. The reviewer's fonts are from review294/ (copied).

B1 and B2: blend amplification (12bb477). A blend over a subtable with no regions (a vsindex past the store, or a VarData without regions) leaves its n values, so it could repeat for two tokens while the full CFF2 bake rounded all n values every time and the subset walk marked them; the bake also had no stack limit. Both now charge a token per value a blend leaves (the deltas were charged when pushed), as the reviewer suggested, and the bake stops at 513 operands, the CFF2 limit. Real fonts spend at most 1.8% of the bake budget (Noto Sans KR VF, from 1.4%; Source Serif 4 1.5%).

font run main PR head now
bakebomb_5000 (9,980 B) full instance 0.14 s, error 50.4 s, budget error 0.16 s, stack past 513
bakebomb_512 full instance 0.06 s, error 4.65 s, budget error 0.03 s, budget error
blendamp_small (4.5 KB) full instance 0.03 s, error 3.79 s, budget error 0.02 s, budget error
blendamp_1mb full instance 0.56 s, error 73.7 s, budget error 0.23 s, budget error
blendamp_4mb full instance 2.12 s, error over 180 s 0.81 s, budget error
blendamp_1mb subset 0.10 s 13.8 s 0.09 s
blendamp_4mb subset 0.32 s 50.2 s 0.26 s
drawamp0_50_0_400 (3.6 KB, a valid 0-region VarData) full instance 0.08 s, ok 3.75 s, ok 0.02 s, budget error
drawamp0_600_1000000_400 full instance 0.28 s, ok 48.1 s, ok 0.07 s, stack past 513
drawamp_800_1000000 full instance 0.37 s, error 65.7 s, budget error 0.08 s, stack past 513

Fonts that main instanced and that now fail: drawamp0_50_0_400, drawamp_50_0 and bakebomb_flat_512 (budget), drawamp0_600_1000000_400 and bakebomb_flat_5000 (more than 513 operands), and of the earlier reviews' crafted fonts chainP50.otf (budget) and stale.otf (more than 513 operands). HarfBuzz's instancer drops the CFF2 table of all of them but bakebomb_flat_512 (one 60 KB glyph running 512 blend 20,000 times: 10 million value touches, which HarfBuzz does not charge).

B3: cross-FD duplicates (c2c1bd1). A global's calls were recorded once, resolved against the local INDEX of the first FD that ran it, and every FD's duplicate was renumbered from them; an FD whose local count gives another bias then called the wrong local. A call's source subroutine is now worked out from the number it pushes and the bias of the FD that runs the body. Kept subroutines take their position among the kept ones by binary search, so the renumber tables no longer hold an entry per source subroutine per FD.

font main PR head now
crossfd_bias.otf (4 singles, 40 multi) 6 subsets fail ("cannot pad") 2 wrong glyphs, 1 fails all 44 draw as the source
crossfd_bias3.otf all draw as the source 2 wrong glyphs (lineTo (0, 77) for (0, 555)), 1 fails all 44 draw as the source

S1: memory (4f4b62f). The walk kept a slot per entry of each kept FD's Subrs INDEX; it now keeps maps of the subroutines it reached. The CID-keyed CFF1 subsetter reads a Private DICT and its Subrs INDEX once for all the FDs that name it (as the CFF2 parser already did).

font main PR head now
manyfd_256_65535.otf (269 KB, 256 FDs over one 65,535-entry Subrs INDEX), subsets 1.39 s, 271 MB 1.39 s, 301 MB 0.02 s, 6 MB
manyfd_256_1000.otf, 257 singles 0.84 s 0.85 s 0.13 s

Nit (d05e37c). AxisLimit and UserInstanceInput are #[non_exhaustive]. UserInstanceInput::new(), with_axis, with_unnamed_axes_kept and with_var_tables_dropped build one; the fields stay public.

Tests. a_blend_charges_the_values_it_leaves, font_dicts_keep_only_the_locals_they_reach (walk), bake_charges_each_blended_value, bake_stacks_at_most_513_operands (CFF2 bake), cid_cross_fd_duplicates_resolve_locals_with_their_fds_bias (two FDs with 1 and 1,240 locals sharing a global that calls a local). All five fail against da932e8.

Re-verification on d05e37c. The CFF corpus, TrueType corpus, Source Serif 4 and NectoMono checks, the 22 B12 comparisons, the instance diffs, the hostile suites and the gate below are from this head; every number in them came out the same as on da932e8. The reviewer's verify.py, with this head, finds no program difference in 467 subsets (56,708 glyphs) of the 37 non-Typodermic fonts and 3,416 subsets (144,965 glyphs) of every third Typodermic font.

N1 and A18: subsetting hinted CFF and CFF2

Problem. The subsetter scanned each charstring and subroutine body on its own. It cleared the stack at blend, so a blended stem hint (... 7 blend hstemhm) counted no stems, and it started every subroutine at zero stems, so a hint mask in a subroutine that counts its caller's stems was sized wrong. Either way the mask bytes were read as operators and operands: "CFF unknown charstring operator", "callsubr without operand", or call sites in the wrong place.

Fix (3ad21bb). A new walk (cff/walk.rs) runs each kept glyph's charstring as HarfBuzz's subsetter interprets it:

  • A call runs the callee with the caller's operand stack and stem count; stems a subroutine declares count for its caller's masks.
  • The first mask fixes the mask size (operands left on the stack count as an implicit vstem), as determine_hintmask_size does.
  • A CFF2 blend pops n, then n rows of one delta per region of the vsindex in effect, leaving n values. Region counts come from the VariationStore, cached per vsindex; the vsindex starts at the one the glyph's Private DICT sets. A vsindex past the store's subtables blends no deltas, as HarfBuzz reads a missing subtable.
  • Subroutines nest at most 10 deep (kMaxCallLimit), a subroutine reaching itself fails, and the stack holds at most 513 operands.
  • Each body is read once, at its first call, as HarfBuzz parses it, and the call sites of that reading are renumbered. A subroutine number must be pushed by the body that calls it, so the push can be rewritten.

The subroutines reached are the ones kept: per FD for CID CFF1 and CFF2, with the cross-FD global duplication of CID CFF1 now worked out from the walk's call lists and the globals each FD reached.

A renumbered call whose new number cannot keep its push's width (a -107..=107 value in a 2-byte push, or a wider value) is written in its shortest form and the bytes after it move, as HarfBuzz re-encodes calls. Before, a non-CID CFF1 subset failed with "CFF renumber: cannot pad operand to original width" and a CFF2 subset fell back to keeping every subroutine.

Budget. The walk charges one token per operand or operator it reads, inside subroutines too, and one per value a blend leaves: 64 per byte of the CFF or CFF2 table, plus 2^22, the same scale as the CFF2 bake. Past it the subset fails with "CFF charstring work budget exceeded". Every other new loop is linear in the input (each body is read once; call lists are at most one entry per byte), and the walk's memory follows the subroutines it reaches.

The public per-body helpers (scan_subr_calls, compute_kept_subrs, renumber_charstring) are unchanged and document their per-body limits; the subsetter no longer uses them.

main this PR
Source Serif 4 VF, "Hello" CFF unknown charstring operator subsets
Source Serif 4 VF, 1,463 single-glyph subsets 910 ok, 553 fail 1,463 ok, all draw as the source
Source Serif 4 VF, 200 random multi-glyph subsets 200 ok, all draw as the source
NectoMono Regular (CFF1, 20 local and 15 global subrs hold masks), 486 single-glyph subsets 451 ok, 35 fail 486 ok
NectoMono Regular, 200 random multi-glyph subsets 45 ok, 155 fail 200 ok
Noto Sans KR VF .otf (no hints), 500 singles (every 50th glyph) and 50 multi ok ok

Tests. cff/walk/tests.rs (17 tests on hand-built charstrings and stores: blended stems, Private DICT vsindex, vsindex past the store and negative, a missing store, stems and implicit vstems across calls, the mask fixed at the first mask, a body read once, endchar in a subroutine, globals per FD, 10 deep and 11 deep, recursion, 513 and 514 operands, a spent budget, and ten malformed cases), plus five orchestration round trips for non-CID CFF1, CID CFF1 (a cross-FD global holding a mask) and CFF2 (blended stems, Private DICT vsindex, a mask in a subroutine). All five fail against main's code.

B12: the instancer's metrics

OS/2 and post (e56f4db, da932e8). As hb-ot-os2-table.hh and hb-ot-post-table.hh do: usWeightClass from a pinned wght (rounded, clamped to 1 to 1000), usWidthClass from a pinned wdth (map_wdth_to_widthclass), post.italicAngle from a pinned slnt (clamped to -90 to 90), and xAvgCharWidth the mean of the instance's advances that are not zero (full and partial instances). instance takes each pinned axis' location as the user value of its normalized coordinate; a kept axis leaves its field. A Range given to instance_user records its default, as HarfBuzz does. usWidthClass is not in the issue but is the same mechanism and was needed for parity on Roboto Flex.

CFF2 rounding (525b689). HarfBuzz's flattener rounds every blended value to a whole unit (round, halves away from zero) when it has coordinates. The bake now keeps its stack in f64 and does the same; a source operand with a fraction keeps its exact 16.16 value. Its charstrings now equal HarfBuzz's byte for byte (197,577 bytes of Source Serif 4 at wght 700). The bake also starts at the Private DICT's vsindex.

CFF2 outline metrics (e56f4db, da932e8). As update_instance_metrics_map_from_cff2: each glyph of the source is drawn at the outline coordinates (the core's CFF2 outlines), and its box is the control box (curve control points included, a contour's start counted once a segment leaves it) before the blends round, its ends rounded halves up. A glyph with a box takes its left end as its left side bearing; one without keeps the source's. head takes the union of the boxes; hhea takes the largest advance and the smallest left and right side bearings and largest extent over every glyph, one without a box as zero wide. A partial CFF2 instance measures its new default outlines the same way, as HarfBuzz does. The glyphs are drawn after the bake, which ran the same subroutines within its budget; the partial bake now runs before the metrics too.

Against HarfBuzz's instancer, 22 locations (all fields; "before" is main, "after" this PR):

Font Location usWeightClass usWidthClass xAvgCharWidth italicAngle hmtx rows != HB head box, hhea = HB outlines != HB
Source Serif 4 VF wght=700 400 -> 700 5 558 -> 577 0 1375 -> 0 yes 1450 -> 0
Source Serif 4 VF wght=250,opsz=48 400 -> 250 5 558 -> 522 0 1400 -> 0 yes 1451 -> 0
Source Serif 4 VF wght=650,opsz=keep 400 -> 650 5 558 -> 572 0 1370 -> 0 yes 1450 -> 1450
Source Serif 4 VF opsz=12,wght=keep 400 5 558 -> 592 0 1379 -> 0 yes 1450 -> 1450
Source Serif 4 VF wght=400 400 5 558 0 3 -> 0 yes 0 -> 0
Noto Sans KR VF (.otf) wght=700 100 -> 700 5 944 -> 946 0 24447 -> 0 yes 24731 -> 0
Noto Sans KR VF (.otf) wght=350 100 -> 350 5 944 -> 945 0 24337 -> 0 yes 24731 -> 0
Noto Sans KR VF (.otf) wght=100 100 5 944 0 25 -> 0 yes 0 -> 0
Roboto Flex wght=700 400 -> 700 5 1145 -> 1211 0 0 -> 0 yes 0 -> 0
Roboto Flex slnt=-10,wdth=75 400 5 -> 3 1145 -> 1108 0 -> -10 0 -> 0 yes 0 -> 0
Roboto Flex wdth=137.5,wght=300,opsz=72 400 -> 300 5 -> 8 1145 -> 1217 0 0 -> 0 yes 0 -> 0
Roboto Flex wght=700,opsz=keep,slnt=keep 400 -> 700 5 1145 -> 1211 0 0 -> 0 yes 0 -> 0
Roboto Flex slnt=-5,wdth=keep 400 5 1145 0 -> -5 0 -> 0 yes 0 -> 0
Recursive wght=700,slnt=-15 300 -> 700 5 627 0 -> -15 0 -> 0 yes 0 -> 0
Recursive slnt=-7.5,CASL=1,wght=keep 300 5 627 0 -> -7.5 0 -> 0 yes 0 -> 0
Recursive MONO=1,wght=550 300 -> 550 5 627 -> 658 0 0 -> 0 yes 0 -> 0
Fraunces wght=500,opsz=72 900 -> 500 5 1265 -> 1087 0 0 -> 0 yes 0 -> 0
Fraunces opsz=144,SOFT=keep 900 5 1265 -> 1088 0 0 -> 0 yes 0 -> 0
Fraunces Italic wght=300 900 -> 300 5 1238 -> 1058 -16 0 -> 0 yes 0 -> 0
Sitka wght=700 400 -> 700 5 1352 -> 1425 0 0 -> 0 yes 0 -> 0
Sitka opsz=20,wght=keep 400 5 1352 -> 1242 0 0 -> 0 yes 0 -> 0
Sitka Italic wght=550 400 -> 550 5 1346 -> 1375 -12 0 -> 0 yes 0 -> 0

Every "after" value equals HarfBuzz's. "outlines != HB" draws every glyph of both instances through HarfBuzz at their default. The two partial Source Serif 4 rows still differ in their default outlines: see Not done.

Sizes (full instances; the partial rows did not change):

Font Location CFF2 bytes HB file bytes HB
Source Serif 4 VF wght=700 614,203 -> 202,985 202,949 1,066,060 -> 654,844 437,416
Source Serif 4 VF wght=250,opsz=48 655,432 -> 198,083 198,047 1,107,288 -> 649,940 432,780
Source Serif 4 VF wght=400 212,512 -> 200,339 200,303 664,368 -> 652,196 434,792
Noto Sans KR VF (.otf) wght=700 13,204,047 -> 4,241,001 4,240,649 13,623,520 -> 4,660,476 4,627,684
Noto Sans KR VF (.otf) wght=350 13,204,047 -> 4,176,435 4,176,083 13,622,688 -> 4,595,076 4,559,084

The 36 CFF2 bytes left on Source Serif 4 are Private DICTs: HarfBuzz writes BlueScale as 0 (this PR keeps 0.0375), and in FD 1 it rounds blended BlueValues per delta where this crate rounds the absolute values as fontTools does (483 499 717 737 against HarfBuzz's 483 498 716 736). The rest of the file difference is GPOS (402,776 bytes against 185,628: not repacked) and STAT.

B13: instance_user

Fix (9580c2b). instance_user(face, &UserInstanceInput) takes axis settings by tag in fvar's units:

  • AxisLimit::Pin(v) clamps to the axis' range as hb_subset_input_pin_axis_location does (a NaN pins the default); AxisLimit::Default pins the default; AxisLimit::Keep keeps the axis.
  • AxisLimit::Range { min, default, max } follows hb_subset_input_set_axis_range's clamping: a range of one value pins, the whole axis with its own default keeps the axis (and sets usWeightClass and the like from the default, as HarfBuzz does), and a narrower range or a moved default returns SubsetError::Unsupported.
  • Unnamed axes are pinned at their defaults, so naming only wght gives a static font; keep_unnamed_axes: true keeps them, as HarfBuzz and fontTools do. An unknown tag is an error.

Both types are #[non_exhaustive]; UserInstanceInput::new().with_axis(*b"wght", AxisLimit::Pin(700.0)) builds an input. Each pinned value goes through VariationAxis::normalize, and the instance derives both coordinate sets (outline and plan) from it as instance does. In all 22 locations above, instance_user with user values and instance with VariationAxis::normalized coordinates wrote the same bytes. instance is unchanged.

Verification

All against HarfBuzz 14.5.0 (uharfbuzz 0.56.2) and fontTools 4.66.1, release builds of a harness against main (fce2d02) and this PR.

  • Source Serif 4 VF. All 1,463 single-glyph subsets and 200 random multi-glyph subsets succeed. Each kept glyph draws the same as the source through the sigilbuzz core at 5 locations (default, all axes min, all max, 0.5, mixed); fontTools decompiles every charstring and its desubroutinized programs equal the source's token for token; fontTools' and HarfBuzz's drawings (default and 3 locations) equal the source's.

  • Hinted CFF1 with masks in subroutines (NectoMono Regular): 486 singles and 100 multi-glyph subsets, the same checks; desubroutinized programs, hint masks included, are identical to the source.

  • CFF corpus, subsets against main. 779 CFF and CFF2 fonts (731 Typodermic CFF1 fonts, most with masks in subroutines; Aika, Fivo Sans, Sora, NectoMono; Source Serif 4 VF, Noto Sans KR VF .otf; the repo fixtures and earlier crafted files): 12 random sets, "Hello" and all glyphs, layout on and off, 21,650 subsets.

    outcome subsets
    byte-identical to main 18,590
    main failed, now subsets 2,965
    main succeeded, now fails 0
    both succeed, bytes differ 58
    both fail, different message 31
    not fonts (fuzz seeds with a control prefix) 6 fonts

    The 58: in 32 main's output was wrong (a glyph's program or drawing differed from the source, or fontTools could not decompile it) and the new one is right; 26 are Source Sans 3 VF fixture subsets that main wrote by keeping all 473 subroutines (the dropped fallback) and that are now pruned ("Hello": 12,368 to 2,588 bytes), both right. The 31: 24 are CidCff1Synthetic.otf calling a subroutine that does not exist ("calls dropped subroutine" is now "subroutine index out of range"); 7 are large Source Serif 4 subsets that now get past CFF2 and stop at the existing GPOS PairPos rewrite: an offset exceeds 64 KiB.

  • CFF outputs against their sources. Every 4th font of that corpus (195 fonts, 5,404 subsets): fontTools decompiles every charstring, the desubroutinized programs equal the source glyphs' token for token, and fontTools and HarfBuzz draw every glyph as the source. (2 sources fontTools cannot read are skipped.)

  • TrueType subsets. 364 fonts (326 in C:\Windows\Fonts, the test corpus, the repo fixtures), 8 random sets, "Hello" and all glyphs, layout on and off: 7,080 outputs byte-identical to main, 180 identical errors, no difference.

  • Instances against main. 17 variable fonts (the issue's six plus Hahmlet, Nabla, Noto Sans KR VF .ttf, Rubik, the Source Sans 3 VF fixture, Cascadia Code and Mono, Segoe UI Variable, Bahnschrift), 3 full locations each and 2 partial for multi-axis fonts (71 runs). With OS/2 bytes 2 to 7, post bytes 4 to 7 and head.checkSumAdjustment masked, every glyf font is byte-identical to main in every table; CFF2 fonts differ only in CFF2 (full instances), hmtx, head and hhea. glyf instances take the same time; CFF2 instances take longer for the outline measurement (Noto Sans KR VF .otf, 24,731 glyphs: 0.16 to 0.25 s on main, 0.26 to 0.34 s here).

  • Hostile inputs. 11 new crafted fonts for the walk and metrics (mk_walk_hostile.py: a 9-level 10-way subroutine fan-out with stems and masks in CFF1 and with blended stems in CFF2, chains 10 and 11 deep each declaring a stem, a recursive subroutine, 400,000 masks in one charstring, 300 operands pushed twice, blends asking for 32,000 values and a 5,000-region subtable, vsindex 300 and -5, blends taking the caller's operands, 20,000 glyphs each running a 6-level fan-out), subsets and full, user and partial instances: 26 runs, no panic, slowest 0.99 s. The 278 crafted fonts of the earlier reviews (Instancer gvar and BASE parity with HarfBuzz, and seac in the subset closure #284, Move pinned-only deltas into a partial instance's defaults #286): 717 runs, no panic, slowest 0.51 s. The reviewer's 17 fonts: 53 runs, no panic, all under 2 s but one partial instance of blendamp_4mb (7.9 s, a budget error), which takes as long on main (6.8 s): the partial bake runs out its budget of 64 tokens per byte of a 4 MB table.

  • Fuzzing. The subset target (which subsets and instances) in Docker for 600 s on d05e37c, seeded with the repo fixtures and four of the crafted and hinted fonts: 168,982 runs, no crash, peak RSS 642 MB. (Before the review fixes, on 9580c2b: 141,792 runs, no crash.)

  • Gate on d05e37c: cargo fmt --check, cargo +1.99.0 clippy (all features and no default features, all targets, -D warnings), cargo test --workspace --all-features (3,723 passed), cargo build --no-default-features, and MSRV cargo +1.81 check (lib, no default features; lib and bins, all features) with a fallback lockfile.

Not done

  • Partial CFF2 instances keep the fractions their store projection gives: Source Serif 4 at wght=650,opsz=keep is 2.59 MB (CFF2 2.11 MB) against HarfBuzz's 0.96 MB (0.53 MB), and its default outlines differ from HarfBuzz's. HarfBuzz 14.5's partial CFF2 path (cff2_instancing_plan_t) rounds each folded default and each projected delta through its own VarData transforms; matching it means redesigning the partial bake, so it is left for a follow-up. This PR only gives partial CFF2 instances HarfBuzz's side bearings, head box, hhea extremes and OS/2 and post fields.
  • CFF2 vertical metrics. HarfBuzz also recomputes vmtx top side bearings from VORG and the glyph boxes, and vhea's extremes from a negative box height (so its minBottomSideBearing and yMaxExtent come out as adv - tsb + height and tsb - height); its VORG values differ from this crate's too. Noto Sans KR VF .otf at wght 700 differs in 24,435 vmtx rows and its VORG records, as on main.
  • Private DICT rounding (36 bytes on Source Serif 4, above) and GPOS repacking are unchanged.
  • Narrowing an axis. AxisLimit::Range cannot narrow an axis or move its default (L4 instancing); it returns Unsupported.
  • Edge semantics. The walk fixes the mask size at the first mask, as HarfBuzz does; the core drawer and the CFF2 bake size each mask by every stem so far. They differ only for a charstring that declares stems after its first mask, which the spec does not allow. For a vsindex past the store, the walk blends no deltas (HarfBuzz); the core infers the count from the stack depth.
  • The subset fuzz target was not extended to call instance_user (it is outside the crate).

CHANGELOG lines

Ready to paste into the 0.24.0 section:

Added:

  • sigilbuzz-subset: instance_user instances a variable font at axis values in user units, given by axis tag, as HarfBuzz's instancer takes them (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). AxisLimit pins an axis at a value (clamped to its range) or at its default, keeps it, or takes a HarfBuzz-style range; a range that would narrow an axis or move its default returns SubsetError::Unsupported. Axes the input does not name are pinned at their defaults, or kept with keep_unnamed_axes. Build the input with UserInstanceInput::new() and its with_ methods; both types are #[non_exhaustive]. Callers no longer normalize each axis with VariationAxis::normalize, and the output equals instance at the same normalized coordinates.

Changed:

  • sigilbuzz-subset: the CFF and CFF2 subsetter runs each kept glyph through its subroutine calls, as HarfBuzz's subsetter does, and fails a charstring whose subroutines nest more than 10 deep or reach themselves, that stacks more than 513 operands, or that takes more than 64 tokens per byte of the table plus 2^22, a blend charging a token per value it leaves (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). Its memory follows the subroutines the kept glyphs reach, and the Font DICTs of a CID-keyed font that name one Private DICT share one read of it. The public per-body helpers scan_subr_calls, compute_kept_subrs and renumber_charstring are unchanged.
  • sigilbuzz-subset: a full CFF2 instance fails a charstring that stacks more than 513 operands, the CFF2 limit, and its bake charges a token per value each blend leaves (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). Crafted fonts that repeat blends without regions fail the budget in under a second instead of running for minutes; real fonts spend under 2% of it.
  • sigilbuzz-subset: a renumbered subroutine call that does not fit its push is written in its shortest form instead of failing the subset with "cannot pad operand to original width", and a CFF2 subset no longer keeps every subroutine when that happens (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). The Source Sans 3 VF fixture's "Hello" subset drops from 12,368 to 2,588 bytes.

Fixed:

  • sigilbuzz-subset: hinted CFF2 fonts subset (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). Blended stem hints were not counted, so hint masks were misread: Source Serif 4 VF failed "Hello" and 553 of its 1,463 single-glyph subsets with "CFF unknown charstring operator". All of them now subset and draw as the source in sigilbuzz, fontTools and HarfBuzz.
  • sigilbuzz-subset: a hint mask in a subroutine counts the stems its callers declared (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). Hinted CFF1 fonts with masks in subroutines failed or kept the wrong subroutines; across 779 CFF fonts, 2,965 of 21,650 test subsets that failed now succeed, and 32 that succeeded with wrong outlines are now right.
  • sigilbuzz-subset: instance sets OS/2.usWeightClass, usWidthClass and xAvgCharWidth and post.italicAngle from the pinned wght, wdth and slnt and the instance's advances, as HarfBuzz's instancer does (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). They kept the source's values.
  • sigilbuzz-subset: a full CFF2 instance rounds each blended value to a whole unit, as HarfBuzz does, and starts each charstring at its Private DICT's vsindex (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). Source Serif 4 VF at wght 700 drops from 1.07 MB to 0.65 MB (its CFF2 from 614 KB to 203 KB, HarfBuzz 203 KB), and its charstrings equal HarfBuzz's.
  • sigilbuzz-subset: a CFF2 instance, full or partial, takes its left side bearings, head box and hhea extremes from its outlines, as HarfBuzz does (Subset hinted CFF2 fonts, and finish the instancer's metrics #290). They kept the source's: 1,375 of Source Serif 4 VF's 1,464 side bearings at wght 700.

Subsetting a hinted CFF2 font failed: the per-body scanner cleared the
operand stack at `blend`, so blended stem hints never counted, the hint
masks were read as empty, and their bytes passed for operators. Source
Serif 4 VF failed 553 of its 1,463 single-glyph subsets this way. The
scanner also started every subroutine at zero stems, so a CFF1 mask in
a subroutine that counts its caller's stems was misread too.

The subsetter now runs each kept glyph's charstring through its
subroutine calls, as HarfBuzz's subsetter interprets it: a call runs the
callee with the caller's stack and stem count, the first mask fixes the
mask size, a CFF2 `blend` drops as many deltas as the store gives the
`vsindex` in effect (starting from the Private DICT's), subroutines nest
at most 10 deep, and the stack holds at most 513 operands. Each body is
read once, at its first call, and the calls found then are renumbered.
The walk charges 64 tokens per byte of the table, plus 2^22.

A renumbered call whose new number does not fit its push is written in
its shortest form and the bytes after it move, as HarfBuzz re-encodes
calls, instead of failing; CFF2 no longer falls back to keeping every
subroutine.

The public per-body helpers (`scan_subr_calls`, `compute_kept_subrs`,
`renumber_charstring`) are unchanged.

Part of #290.
A full CFF2 instance wrote each resolved blend as `master + sum(scalar *
delta)` and kept the fraction, in 16.16 fixed form: Source Serif 4 VF at
wght 700 came to a 614 KB `CFF2` table where HarfBuzz writes 203 KB.
HarfBuzz's instancer rounds every blended value to a whole unit, halves
away from zero, in double precision.

The bake now keeps its operand stack in f64 and rounds each blend once
its deltas are in; a source operand with a fraction keeps its exact
16.16 value. The charstrings of the Source Serif 4 instance are now
byte-identical to HarfBuzz's.

The bake also starts each charstring at the `vsindex` its Private DICT
sets, as the core and HarfBuzz read it, instead of 0.

Part of #290.
`instance` left `OS/2.usWeightClass`, `usWidthClass`, `xAvgCharWidth`
and `post.italicAngle` at the source's values, and a CFF2 instance kept
the source's left side bearings (1,375 of Source Serif 4's 1,464 glyphs
at wght 700), `head` box and `hhea` extremes.

As HarfBuzz's instancer does:

- `usWeightClass` follows a pinned `wght` (rounded, clamped to 1 to
  1000), `usWidthClass` a pinned `wdth` (through the spec's width
  percentages), and `italicAngle` a pinned `slnt` (clamped to -90 to
  90). `instance` takes each pinned axis' location as the user value of
  its normalized coordinate; a kept axis leaves its field.
- `xAvgCharWidth` is the mean of the instance's advances that are not
  zero, in full and partial instances.
- A CFF2 instance measures each glyph at the outline coordinates, the
  box of its outline with control points before the blends round, and
  takes its left side bearing, the `head` box and the `hhea` extremes
  from those boxes (`update_instance_metrics_map_from_cff2`). A glyph
  without a box keeps its bearing. A partial instance measures its new
  default outlines the same way.

The glyphs are drawn after the charstring bake, which ran the same
subroutines within its budget; the partial bake now runs before the
metrics too.

Part of #290.
`instance` takes normalized coordinates, so callers had to normalize
each axis with `VariationAxis::normalize` themselves. `instance_user`
takes each axis' setting by tag in the units `fvar` gives, as HarfBuzz's
instancer takes them:

- `AxisLimit::Pin(v)` pins the axis at `v`, clamped to its range;
  `AxisLimit::Default` pins it at its default; `AxisLimit::Keep` keeps
  it variable.
- `AxisLimit::Range { min, default, max }` follows
  `hb_subset_input_set_axis_range`: a range of one value pins, the
  axis' whole range with its own default keeps the axis (and records
  the default for `usWeightClass` and the like, as HarfBuzz does), and
  any narrower range or moved default fails, since the instancer cannot
  narrow an axis.
- Axes the input does not name are pinned at their defaults, or kept
  when `keep_unnamed_axes` is set, as HarfBuzz and fontTools keep them.

Each pinned value is normalized as HarfBuzz normalizes it, and the
instance derives both of HarfBuzz's coordinate sets (outline and plan)
from it, as `instance` does; the output matches `instance` at the same
normalized coordinates byte for byte. `instance` keeps working
unchanged.

Part of #290.
HarfBuzz defines `roundf(x)` as `floor(x + 0.5)`: halves round toward
positive infinity, not away from zero. The CFF2 glyph boxes, the
`OS/2` weight and width classes, `xAvgCharWidth` (from the
double-precision mean) and `post.italicAngle` now round that way. A
negative box end at an exact half, -2.5 say, goes to -2. The blends of a
CFF2 bake keep C `round`, halves away from zero, which HarfBuzz's
interpreter calls there.

Part of #290.
@albedosehen albedosehen added this to the 0.24.0 milestone Oct 4, 2026
Review of #294 (B1, B2). A `blend` over a subtable with no regions (a
`vsindex` past the store, or a VarData without regions) leaves its `n`
values on the stack, so it can run again and again for two tokens while
the full CFF2 bake rounds all `n` values each time and the subset walk
marks them. The bake also had no operand-stack limit, so `n` could be
as large as the charstring. A 9,980-byte font took 50 s to fail the
bake's budget, a 4 MB one over 180 s, and a 4 MB font took 50 s to
subset.

Both now charge one token for each value a blend leaves (the deltas
were charged as they were pushed), and the bake stops at 513 operands,
the CFF2 limit HarfBuzz's interpreter enforces. The same fonts now fail
or finish in under a second. Real fonts spend at most 1.8% of the bake
budget (Noto Sans KR VF, from 1.4%).

Part of #290.
Review of #294 (B3). In a CID-keyed CFF1 subset, a global subroutine
that calls a local is duplicated per Font DICT, and each duplicate is
renumbered from the calls the walk recorded when it first read the
global. That reading resolved the call against the first Font DICT's
local INDEX, so a Font DICT whose local count gives another bias had
its duplicate point at the wrong local: the reviewer's font drew a
line to (0, 77) instead of (0, 555).

A call's source subroutine is now worked out again from the number it
pushes and the bias of the Font DICT that runs the body, for every
body. Each kept subroutine's new index is its position among the kept
ones, found by binary search, so the renumber tables no longer take an
entry per source subroutine and Font DICT.

Part of #290.
Review of #294 (S1). The walk gave every kept Font DICT a slot per
entry of its local Subrs INDEX, and the CID-keyed CFF1 subsetter read
the Private DICT and Subrs INDEX of every Font DICT, even when they all
name one. 256 Font DICTs over one 65,535-entry INDEX (a 269 KB font)
peaked at 301 MB and took 1.4 s.

The walk now keeps a map of the subroutines it reached, per Font DICT
and for the globals, and a set of the globals each Font DICT reached.
The CID-keyed subsetter reads a Private DICT and its Subrs INDEX once
for every Font DICT that names it, as the CFF2 parser does. That font
now peaks at 6 MB and takes 0.02 s.

Part of #290.
Review of #294 (nit). Both are new in this release, so marking them
`#[non_exhaustive]` now costs nothing, and lets a later release add an
axis setting (narrowing an axis, say) or an input field without
breaking callers. `UserInstanceInput` gains `new`, `with_axis`,
`with_unnamed_axes_kept` and `with_var_tables_dropped` to build one
outside the crate; its fields stay public.

Part of #290.
@albedosehen
albedosehen marked this pull request as ready for review October 4, 2026 20:00
@albedosehen
albedosehen merged commit f309bd0 into main Oct 4, 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.

Subset hinted CFF2 fonts, and finish the instancer's metrics

1 participant