Skip to content

Support OFF 2026 VARC and extended glyph repertoires - #326

Open
behdad wants to merge 27 commits into
khaledhosny:mainfrom
behdad:varc-table
Open

behdad wants to merge 27 commits into
khaledhosny:mainfrom
behdad:varc-table

Conversation

@behdad

@behdad behdad commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

Add sanitization for OFF 2026 variable composites and extended glyph repertoires. Fonts using these formats can retain their outlines, mappings, layout and variation data through sanitization, including fonts with both legacy and uppercase tables. CFSH is excluded.

  • Add VARC validation for components, transforms, axis values, conditions and variation-store references. Use the current 32-bit sparse-region count and indirect axis-coordinate and delta-set INDEX offsets, as clarified in the VARC specification update.
  • Support GSUB/GPOS 1.2 and GDEF 1.4, extended lookup formats, Coverage/ClassDef formats 3 and 4, and value/boolean conditions. Memoize conditions by resolved offset and retain subtree heights when checking nesting limits.
  • Support MAXP, GLYF, LOCA, HHEA, HMTX, VHEA, VMTX and GVAR, including cubic outlines, 24-bit composite references and matching empty variation-table synthesis for hybrid fonts.
  • Support DMAP, cmap format 15, COLR PaintGlyph2, TTC 1.1/2.1 directories, extended CFF2 glyph counts and the smaller CFF2 outline sets permitted with VARC.
  • Correct collection-table cache identities and synthetic-table serialization identities, and reject duplicate directory tags before parsing. Fix two existing cleanup errors exposed by sanitizer-instrumented corpus tests.

Add regression tests for extended glyph IDs, offsets beyond 64 KiB, mixed field widths, malformed/truncated data, shared condition graphs, depth limits, and uppercase-only and hybrid font round trips. Update four VARC corpus fonts to the current variation-store encoding.

Validation: all 685 Meson tests complete successfully in both the normal and AddressSanitizer/UBSan builds: 562 passes and 123 expected rejections. The CIFuzz duplicate-tag reproducer is rejected cleanly. A separate five-minute libFuzzer/ASan run replayed the public OSS-Fuzz corpus and completed 638,152 input executions without a crash.

Also tested NotoSans-beyond64k.ttf with 104,404 glyphs. FreeType confirms identical outlines and metrics for every glyph; HarfBuzz shaping matches for 20 multilingual samples. GDEF, GPOS, GSUB and cmap remain byte-identical, and a second sanitization produces identical output.

@behdad
behdad requested review from jfkthame and khaledhosny July 2, 2026 20:06
@behdad

behdad commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@khaledhosny can you review this please?

Implements a sanitizer for the VARC table from the boring-expansion-spec,
which encodes variable-composite glyphs combinable with glyf/gvar or CFF2.

The parser fully validates:
- the table header and its five sub-table offsets;
- the MultiItemVariationStore (sparse regions, item variation data,
  delta-set indices), collecting per-outer delta-set counts;
- the ConditionList and all five Condition formats (recursively, with a
  depth limit);
- the axisIndicesList, decoding each TupleValues entry to determine the
  axis-value count each referring component must supply;
- every VarCompositeGlyph record: uint32var flags, 16/24-bit gid,
  condition index, axis indices/values, variation indices and transform
  fields, plus reserved uint32var fields;
- that the coverage table's glyph count matches the glyphRecords count,
  since the former indexes the latter.

Cross-references (gid, condition index, axisIndices index, VarIdx,
region/axis indices) are bounds-checked; VarIdx 0xFFFFFFFF is accepted
as the NO_VARIATION sentinel used by HarfBuzz and fonttools. Like
CFF/COLR, the validated table is re-emitted verbatim.

Adds three VARC good-font tests (glyf-flavored, one exercising all five
condition formats).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@behdad

behdad commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Hi, I’m Codex, an AI coding agent from OpenAI. I reviewed this PR for sanitizer blockers and correctness bugs.

I would block merging on the first four findings:

  1. Blocker — exponential condition-validation DoS.
    ConditionAnd/ConditionOr recursively validate every referenced child, but only recursion depth is limited. Reusing the same child twice at each level produces exponential work. A 309-byte VARC with 29 levels took 3.32 seconds to sanitize; slightly deeper inputs can effectively hang OTS. Add memoization or a global condition-edge/work budget.

  2. Blocker — variation delta payloads are not sanitized.
    ParseMultiItemVariationData only validates the enclosing CFF2 Index; it never parses its TupleValues objects. I changed a 14-byte delta set to declare a 64-byte run: OTS reported success and preserved it, while fontTools rejected it as truncated. Each object must be decoded within its own bounds. Tuple cardinality should also be retained so VarIdx uses can be checked against the required axis/transform/value tuple length.

  3. Blocker — axisIndicesList values are never validated.
    The parser counts tuple entries but discards their values. In a font with eight fvar axes, I changed [2,3,4] to [127,3,4]; OTS sanitized it successfully and retained axis 127. Decode these values and require every index to be below fvar.axisCount. Component axis values are similarly only skipped rather than semantically checked.

  4. Blocker — zero glyph records disables the required coverage-count check.
    A count of zero is passed to ParseCoverageTable, where zero means “do not check.” Changing the fixture from four glyph records to zero while leaving four covered glyphs was accepted. Counts above 0xFFFF also disable validation. Coverage indices select glyph records, so the counts must always agree.

  5. Bug — invalid five-byte uint32var values are truncated.
    The implementation acknowledges that only 0xF0 is valid but accepts 0xF1–0xFF, discarding the high bits. F1 00 00 00 00, representing 2^32, was accepted as zero. Reject any five-byte lead other than 0xF0; ideally also enforce canonical encodings.

  6. Bug — Condition format 1 accepts coordinates outside the normalized range.
    The new parser checks only axisIndex. A condition spanning raw [-0x8000, 0x7FFF] was accepted, although these condition values must be between −1 and +1. The existing OTS condition validator already performs this check.

The full local 658-test suite and all GitHub checks pass, but the PR adds only good-font fixtures, so none of these rejection paths are covered.

behdad and others added 6 commits September 10, 2026 16:52
The delta sets of a MultiItemVariationData were only validated as
objects of the enclosing CFF2 Index; their TupleValues contents were
never decoded, so a run declaring more bytes than its object holds was
accepted and passed through.

Decode every delta set within its own bounds, require its value count
to be a multiple of the region count, and record the quotient (the
tuple length) per delta set. Every VarIdx use is now checked against
the tuple length its consumer reads: 1 for a ConditionValue, the axis
count of the referenced axisIndices entry for axisValuesVarIndex, and
the number of present transform fields for transformVarIndex.

The all-conditions test font had its ConditionValue pointing at a
delta set of tuple length 3; regenerate it with a length-1 delta set.

Adds bad fonts with an overshooting delta-set run, a delta set whose
value count is not a multiple of the region count, and a ConditionValue
referring to a delta set of the wrong tuple length.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
ConditionAnd/ConditionOr/ConditionNegate were validated by recursing
into every child, bounded only by nesting depth. Since a child may be
shared by several parents, a small table could demand exponential work
(two references per level, 30 levels or so) and effectively hang the
sanitizer.

Track, per byte offset into the ConditionList, whether the condition
there has already been validated, and skip repeats. The work is now
linear in the size of the list.

Adds a good font whose ConditionList is a 60-level chain of ConditionAnd
tables, each referring to the next one twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
The axisIndicesList entries were decoded only to count their values;
the values themselves were discarded, so an axis index beyond the fvar
axis count was retained. Component axis values were likewise skipped
over without inspection.

Have ParseTupleValues hand back the decoded values, require every axis
index to name an fvar axis, and require every component axis value to
be a normalized coordinate in [-1, 1].

Adds bad fonts with an axis index of 127 in an eight-axis font and a
component axis value just below -1.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
The coverage glyph count was compared with the glyphRecords count by
passing the latter as ParseCoverageTable's expected count, where 0
means "don't check". A table with zero glyph records (or more than
0xFFFF) therefore skipped the comparison altogether, and a coverage
table listing glyphs with no records behind them was accepted.

Reject an empty glyphRecords Index and one with more entries than a
coverage table can express, so the comparison always takes place.

Adds a bad font with zero glyph records and four covered glyphs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
…0xF0

In the five-byte form of uint32var the low nibble of the lead byte
holds bits 32 and up. Those cannot be represented in a uint32, so only
a lead byte of exactly 0xF0 is valid; the reader nevertheless accepted
0xF1-0xFF and silently dropped the high bits, reading "F1 00 00 00 00"
(2^32) as zero.

Adds a bad font whose first component's flags are encoded that way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
Condition format 1 was checked only for a valid axis index; its filter
range was read and ignored, so a condition spanning the raw int16 range
was accepted. The values are normalized F2Dot14 coordinates and must lie
within [-1, 1], as the GSUB/GPOS condition validator already enforces.
Like that validator, warn if the range is misordered.

Adds a bad font with a filterRangeMinValue of -2.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChZAjXEo18sgTrMqfVsqBU
@behdad

behdad commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

I did a review with codex and addressed the findings with Claude. Should be in better shape now, but more LLM review might be justified.

@behdad behdad changed the title Add VARC (Variable Composites / Components) table support Support OFF 2026 VARC and extended glyph repertoires Oct 15, 2026
Duplicate directory entries can cause a dropped table to be parsed again from a different location, violating the per-font table uniqueness assertion. Reject duplicate tags before parsing any tables and cover both retained and dropped gasp entries.
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