18 KiB
avar2 Partial Instancing for HarfBuzz Subset Library
Context
The HarfBuzz subset library currently rejects partial instancing of fonts with
avar2 tables ("Partial-instancing avar2 table is not supported."). This work
ports the "Offset Compensation" algorithm from fonttools (design doc:
partial-instancing-avar2-design.md in the fonttools repo) to HarfBuzz.
Key architectural difference: In fonttools, the subsetter and instancer are
separate. In HarfBuzz, they are fused -- each table's subset() handles both
glyph subsetting and variation instancing in a single pass. This means we can't
simply "skip" tables; we must route them to their existing non-instancing code
paths that still handle glyph subsetting.
Scope: Full implementation including self-contained axis detection and variation culling.
Algorithm Summary
When partial-instancing a font with avar2:
- Normalize using avar v1 only -- compute intermediate-space axis locations
- Rebase avar2 IVS regions via standard
rebaseTentsolver - Detect self-contained pinned axes -- whose final coordinate is constant
- Add offset compensation deltas to the IVS so final coordinates remain in old coordinate space
- Skip variation instancing in other tables for non-self-contained axes
- Run standard instancing for self-contained axes at their old-space final coordinates
- Cull dead variations in gvar/cvar/HVAR/VVAR/MVAR/GDEF whose regions fall outside reachable old-space final-coord ranges
See partial-instancing-avar2-design.md in the fonttools repo for the full
mathematical proof.
Implementation Plan
Chunk 1: Plan Members and Normalization
Files: src/hb-subset-plan-member-list.hh, src/hb-subset-plan-var.cc,
src/hb-ot-var-avar-table.hh
1a. New plan members (hb-subset-plan-member-list.hh)
Add after axes_triple_distances (line 131):
HB_SUBSET_PLAN_MEMBER (bool, has_avar2)
HB_SUBSET_PLAN_MEMBER (hb_hashmap_t E(<hb_tag_t, Triple>), old_intermediates)
has_avar2: true when font has avar2 data AND partial instancing is requested (not all axes pinned).old_intermediates: maps axis tag -> Triple(a_i, d_i, b_i) -- old intermediate coords at new min/default/max. These are the coordinates needed for offset compensation.
1b. avar v1-only mapping (hb-ot-var-avar-table.hh)
Add a v1_only argument to map_coords_2_14(). When set, return after the
avar v1 segment maps and omit the avar v2 tail processing:
bool map_coords_2_14 (float *coords, unsigned int coords_length,
bool v1_only = false) const
{
// Existing avar v1 mapping...
if (v1_only)
return true;
// Existing avar v2 mapping...
}
1c. Normalization changes (hb-subset-plan-var.cc, normalize_axes_location())
Replace the avar2 early-exit return false (lines 254-258) with avar2-aware
normalization:
- When avar2 partial instancing is detected (
has_v2_data() && !all_axes_pinned):- Set
plan->has_avar2 = true - Call
map_coords_2_14(..., true)for mins, defaults, and maxs arrays - Store the v1-only mapped values in
plan->old_intermediates - Compute
plan->avar2_reachable_rangesand detectplan->avar2_self_containedaxes (see Chunks 7 and 8) - Keep ALL axes in
axes_index_map(including pinned ones), so pinned axes remain in fvar as hidden -- EXCEPT self-contained pinned axes, which are removed entirely - Set
plan->axes_locationandplan->normalized_coordsto the self-contained pins ONLY (in old final-coordinate space); empty when there are none. The rest of the subsetter thereby sees an ordinary partial instancing of just those axes
- Set
- Else: use existing path (
map_coords_2_14()with full avar v1+v2)
Why v1-only: old_intermediates must be in intermediate coordinate space
(post-fvar, post-avar-v1, pre-avar-v2) because:
- avar2 IVS regions are in intermediate space --
rebaseTentneeds matching limits SegmentMaps::subset()uses it and unmaps through avar v1 -- correct with v1-only values- Other tables must NOT use these values for instancing; they consume
axes_location/normalized_coords, which under avar2 hold only the self-contained pins in final space (see Chunks 4 and 7)
Chunk 2: avar2 Subsetting (Core Algorithm)
File: src/hb-ot-var-avar-table.hh, avar::subset() and new helper
2a. Version and segment maps
Modify avar::subset() to:
- Output version 2.0 when
plan->has_avar2is true - For avar2 pinned axes: serialize identity segment maps
{-1->-1, 0->0, 1->1}(compile() needs entries for all fvar axes) - For avar2 restricted axes: use existing
SegmentMaps::subset()(works correctly with intermediate-spaceaxes_location); its optionalout_mappingsparameter hands the instantiated mappings to_subset_avar2(), which needs them to locate the new mappings' kinks
2b. avar2 IVS instancing (_subset_avar2() helper)
Add helper method _subset_avar2(hb_subset_context_t *c):
-
Locate original avar2 data: walk past all SegmentMaps to find
avarV2Tail, resolvevarIdxMapandvarStore. -
Compute default deltas: evaluate original VarStore at old-default intermediate coordinates (d_1, d_2, ..., d_n). For each varIdx,
default_delta = varStore.get_delta(varIdx, default_coords). -
Rebase IVS regions: call
item_vars.create_from_item_varstore()thenitem_vars.instantiate_tuple_vars()usingplan->axes_location(intermediate-space limits). The standard instancing subtracts default deltas viamerge_tuple_variations(). -
Detect self-contained pinned axes: After IVS rebasing, for each pinned axis, check if any remaining TupleVariation has a non-zero delta at that axis's inner position. If no variation remains: axis is self-contained. Its final coord =
d_i + default_delta_i. These axes can be removed from fvar and their contributions folded into gvar/HVAR/etc. -
Privatize shared varIdx delta rows: the avar2 VarIdxMap may map several fvar axes to the SAME IVS delta row (Amstelvar does: GRAD+XOPQ, and YOPQ+YTLC+YTUC). Writing one axis's offset-compensation deltas into a shared row would corrupt every other axis reading that row. Ref-count varIdxes across all fvar axes; for each offset-receiving axis (in
user_axes_location) whose row is shared, append a copy of its row within the same VarData viaitem_vars.duplicate_row()and repoint its varIdx. Sharers keep the clean row; the varstore optimization pass re-merges identical rows afterwards. -
Add offset compensation tuples: For each fvar axis:
- Restricted or non-self-contained pinned axis (in
user_axes_location):- If
varIdx == NO_VARIATION_INDEX: create new VarData viaitem_vars.add_vardata(1), create/update VarIdxMap entry - Add bias tuple: empty region, delta =
d_int + round(defaultDelta) - If pinned, that bias is all that's needed. Otherwise, encode the
piecewise-linear function
offset(z) = inv_renorm(z) - zas tents:- Collect knots:
z = -1 -> a_i + 1,z = 0 -> d_i,z = +1 -> b_i - 1. If the axis default MOVED,inv_renormalso kinks where the old default lands in the new space: addz = z_old -> -z_oldwhen interior. - Collect interior avar v1 breakpoints: each in-range old breakpoint
kinks the new mapping at its output coordinate
z; add thatzwith its old intermediate value (computed through user space and the old fvar + avar v1 chain) sooffset(z)is reproduced at every kink. Keep this larger knot set only if it does not increase the residual estimated by a 257-point sweep; emit aDEBUG_MSGwhen the residual exceeds 8 F2Dot14 units (a steep retained segment that F2Dot14 cannot reproduce bit-exactly). - Synthesize one tent per non-zero knot: peak at
z, extending to the adjacent knots (or the axis end for the outermost knots). Adjacent tents vanish at each other's peaks, so each delta is simplyround((offset(z) - d_i) * 16384); the base valued_iis carried by the empty-region bias. This reduces to the classic two tents(-1,-1,0)/(0,+1,+1)when the default is unchanged and there are no interior knots. (Functionally equivalent to the 1-DVariationModelsynthesis fonttools uses; the serialized regions differ when two or more knots lie strictly on one side of 0 -- fonttools extends an inner tent past the next knot and cascades its deltas, while HarfBuzz emits adjacent-knot tents with independent deltas, which rounds slightly better. Both realize the same piecewise-linear function.)
- Collect knots:
- If
- Self-contained pinned axis: skip offset compensation (will be folded)
- Free/private axis (not in
user_axes_location):- If
round(defaultDelta) != 0: add bias tuple withround(defaultDelta) - Skip varIdxes already processed (multiple free axes can share a row)
- If
- Restricted or non-self-contained pinned axis (in
-
Remove self-contained axes from axis order before building VarStore, so VarRegionList matches post-removal fvar axis count.
-
Finalize: call
item_vars.build_region_list()+item_vars.as_item_varstore(optimize=true, use_no_variation_idx=false). -
Serialize avarV2Tail: serialize
DeltaSetIndexMap(with updated varIdx entries after optimization viaitem_vars.get_varidx_map()) andItemVariationStore. Handle Offset32To resolution relative to avar table start. -
Store self-contained axes info in plan for use by other tables.
Chunk 3: item_variations_t Extensions
File: src/hb-ot-var-common.hh
Add public methods to item_variations_t:
// Pass false to add tuples before explicitly building the region list.
bool instantiate_tuple_vars (
const hb_hashmap_t<hb_tag_t, Triple>& axes_location,
const hb_hashmap_t<hb_tag_t, TripleDistances>& axes_triple_distances,
bool build_regions = true)
// Add a new VarData subtable. Returns outer index.
unsigned add_vardata (unsigned item_count)
// Get item count for a VarData subtable.
unsigned get_item_count (unsigned outer) const
// Add a tuple with a single non-zero delta at position inner.
void add_tuple (unsigned outer,
hb_hashmap_t<hb_tag_t, Triple>&& axis_tuples,
unsigned inner, int delta, unsigned item_count)
// Duplicate the delta row at position inner within VarData outer,
// appending the copy as a new item. Returns the new inner index.
// Used to privatize shared varIdx rows before offset compensation.
unsigned duplicate_row (unsigned outer, unsigned inner)
These methods allow the avar2 subsetting code to manipulate the IVS representation between rebasing and finalization.
Chunk 4: Instancing in Other Tables
Under avar2, the plan presents only the self-contained pins (in old
final-coordinate space) through plan->axes_location and
plan->normalized_coords; both are EMPTY when there are none. The ordinary
if (c->plan->normalized_coords) instancing guards therefore need no avar2
special-casing at all: with no self-contained axes the tables take their
existing non-instancing glyph-subsetting paths (variation data preserved in
old final space), and with self-contained axes the standard instancing
machinery runs and pins exactly those axes -- which is correct, because
their final coordinates are constants in the space the variation tables
live in. This applies uniformly to glyf/head/maxp/hmtx/OS/2/post bounds and
metrics recomputation, GPOS value-format optimization, gvar, HVAR/VVAR,
GDEF, COLR (including the paint instancer), BASE, CFF2, cvar (including cvt
delta application), and MVAR (cvar and MVAR fall back to passthrough when
there are no self-contained axes).
The avar2 variation culling (Chunk 8) hooks both flavors: the byte-level
paths (gvar verbatim copy, plain VarStore serialization) and the decompiled
paths (tuple_variations_t::cull_unreachable, called from
item_variations_t::instantiate and gvar's glyph_variations_t).
Chunk 5: fvar Changes
File: src/hb-ot-var-fvar-table.hh
AxisRecord::subset
For avar2 pinned axes: set HIDDEN flag and min=default=max=pinned_value:
if (c->plan->has_avar2 && axis_limit->is_point())
{
out->minValue.set_float (axis_limit->middle);
out->defaultValue.set_float (axis_limit->middle);
out->maxValue.set_float (axis_limit->middle);
out->flags = out->flags | AxisRecord::AXIS_FLAG_HIDDEN;
}
InstanceRecord::subset
For avar2: don't skip pinned axes when writing instance coordinates (they're still in fvar):
if (axis_limit->is_point () && !c->plan->has_avar2) continue;
Chunk 6: Table Ordering
File: src/hb-subset.cc, _dependencies_satisfied()
Add avar as a dependency for variation tables when avar2 is present. The avar2 subsetting step computes self-contained axes info that other tables need.
case HB_TAG('g','v','a','r'):
case HB_TAG('c','v','a','r'):
case HB_TAG('H','V','A','R'):
case HB_TAG('V','V','A','R'):
case HB_TAG('M','V','A','R'):
case HB_TAG('G','D','E','F'):
case HB_TAG('C','F','F','2'):
return !plan->has_avar2 || !pending_subset_tags.has(HB_TAG('a','v','a','r'));
Chunk 7: Self-Contained Axis Instancing
A pinned axis is self-contained when its final coordinate is CONSTANT over
the retained box: its varIdx is NO_VARIATION_INDEX, or its avar2 delta row
is constant over the box (detected at plan time with the same interval
arithmetic as Chunk 8; a constant delta interval has zero width). Its final
coordinate is d_i + delta.
Plan members:
HB_SUBSET_PLAN_MEMBER (hb_hashmap_t E(<hb_tag_t, double>), avar2_self_contained)
Self-contained axes are removed from axes_index_map/axis_tags (dropped
from fvar and avar entirely; _subset_avar2 skips them and writes the
DeltaSetIndexMap over the retained axes only). The plan presents them to
every other table as an ordinary partial pin at the constant final
coordinate through axes_location/normalized_coords (see Chunk 4); the
intermediate-space ranges avar itself needs remain in old_intermediates.
This mirrors fontTools' selfContainedAxes +
_instantiateFvarForAvar2 + second instancing pass, folded into HarfBuzz's
single-pass architecture.
Detection is suppressed (such pins stay in fvar as ordinary hidden axes)
when the face has a CFF2 table -- CFF2 has no partial-pin instancing path;
its pinned path flattens ALL blends and drops the CFF2 VariationStore --
or a VARC table, which passes through verbatim with explicit fvar axis
indices that axis removal would desynchronize. The CFF2 limitation (which
also silently flattens non-avar2 partial instancing of CFF2 fonts) is
harfbuzz/harfbuzz#4710; fontTools implements proper partial CFF2
instancing in fonttools/fonttools#3506 (instantiateCFF2: lift blend
deltas into the CFF2 VarStore, instantiate it, read back the rewritten
deltas), which is the design to port. Lifting the suppression then just
means deleting the CFF2 check here.
Chunk 8: Variation Culling
Cull dead variations in gvar/HVAR/VVAR/GDEF/COLR/BASE whose axis regions fall outside the reachable old-space final-coord ranges (cvar/MVAR: TODO; they are culled only via their instancing paths when self-contained axes exist).
Reachable ranges are computed at plan time (avar2_reachable_ranges):
with offset compensation, the instance's final coordinates equal the
original font's over the retained user box, so bound
final_i = intermediate_i + delta_i over the box of retained
old-intermediate ranges (restricted axes their [a_i, b_i], free public
axes [-1, +1], pinned axes their d_i, hidden axes 0) with interval
arithmetic over the original avar2 VarStore. This is the same quantity
fontTools bounds via getExtremes on the instanced store, computed from
the original store instead; exact (tighter) for pinned and hidden axes.
A TupleVariation/region is dead if some axis's tent is provably zero over the axis's entire reachable range (invalid tents evaluate as constant 1 at runtime and never kill); ranges are padded by one F2Dot14 unit. Hidden axes are assumed to sit at their default, the same semantic assumption fontTools makes.
Verification
- Differential API test (
test/api/test-subset-avar2.c, usingtest/api/fonts/TestAvar2Instance.ttf): the final normalized coordinates (hb_font_get_var_coords_normalized, i.e. fvar + avar v1 + avar v2) of a partial instance must match the original font's at the same user-space location, within 2 F2Dot14 units (5 for the steepest quantization-limited case, chosen one unit below what dropping the interior-breakpoint collection produces), over grids of locations including the exact kink positions. Rendering-level outputs (advance width and glyph extents, exercising gvar and HVAR through culling and self-contained instancing) are compared as well. Covers: same-default and moved-default restriction (both directions), shared-varIdx rows (restricted, pinned, and both-restricted), pinning at and off the default, a pinned axis driven by a free axis, a NO_VARIATION_INDEX axis (fresh VarData), interior avar v1 breakpoints, steep moved-far-from-min defaults, variation culling via a hidden driven axis, and self-contained pins (alone and combined with a range restriction). - Take an avar2 font (e.g., RobotoFlex-avar2.ttf), partial-instance with both fonttools and HarfBuzz, compare avar2 VarStore output
- Render glyphs at various coordinates using the instanced font -- verify visual match with the original
- Test full pinning (all axes) still works (existing path)
- Test with NO_VARIATION_INDEX axes, fonts without explicit VarIdxMap, multiple restricted axes
- Test self-contained axis detection and removal
- Test variation culling produces smaller output without visual changes