Handle extension tables automatically in addfeatures - #1851
Draft
skef wants to merge 22 commits into
Draft
Conversation
Implements automatic subtable splitting and extension promotion to handle GPOS kerning overflow without requiring manual intervention in feature files. Addresses adobe-type-tools#1849. Subtable splitting (internal overflow): - PairPos Format 2: when the class matrix exceeds 64K, automatically partition Class1 classes into N groups and create separate subtables - PairPos Format 1: when pair set data exceeds 64K, greedily split by first-glyph groups until each subtable fits Extension promotion (offset overflow): - After all subtables are constructed, check if the total inline section exceeds the 64K offset budget - Promote the largest non-extension subtables to extension format (in decreasing order of size) until the remainder fits - Honors explicitly-marked useExtension subtables first Both mechanisms respect explicit subtable breaks and useExtension directives already in the feature file. A new -nao flag suppresses automatic overflow handling for users who want manual control. NOTE: This is a draft. The extension promotion offset recomputation has a known limitation around coverage/class table migration for newly-promoted subtables. The subtable splitting is the primary mechanism and is complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Don't skip ext subtables when computing sizes from offset gaps; their 8-byte stubs are in the same offset space as regular subtables - Add assertion validating that computed sizes sum back to the tracked offset.subtable total - Report unresolvable overflow as sERROR (not sFATAL) so processing continues to the natural checkOverflow failure point Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add subtableSize member to OTL::Subtable, computed in AddSubtable() as offset.subtable - sub->offset (no offset-gap arithmetic needed) - Rewrite autoPromoteExtensions() to sort subtables by decreasing size directly, eliminating the SubSize struct and index-based lookups - Size validation assertion no longer depends on vector ordering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Infrastructure for precise extension promotion decisions: - Add refcount to CoverageRecord and ClassRecord - Add coverageEndRC()/classEndRC() variants that increment refcount and return the record index - Add activeCoverageSize()/activeClassSize() that count only entries with refcount > 0 - Add releaseCoverageRef()/releaseClassRef() that decrement refcount and return freed size when it hits 0 - Add cacCoverageRefs/cacClassRefs vectors to Subtable for tracking which shared cac entries each subtable references - Update autoPromoteExtensions() to use releaseRef for precise savings calculation (subtable size + freed cac entries) Subtable constructors still need to be wired up to use the RC variants (coverageEndRC/classEndRC) to populate the ref vectors. Currently the refcounts remain at 0 so the savings calculation is conservative (cacFreed = 0). This is a no-op change in behavior until wired up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of reference counting shared cac entries, give every subtable its own private CoverageAndClass during construction. This eliminates the chicken-and-egg problem: promotion decisions don't depend on the shared cac state, and the shared cac is built only after decisions are finalized. Architecture: - Base Subtable constructor always allocates a private cac - autoPromoteExtensions() uses calcMergedCacSize() to compute the hypothetical merged size for non-ext subtables (with dedup) - Promotion loop: calculate merged cac, check fit, promote largest, repeat until total fits - buildMergedCac() merges non-ext subtables' records into the shared cac with deduplication, updating offset fields via remapCacOffsets() - Subtable types override remapCacOffsets() to fix their Coverage/ ClassDef fields (virtual, default no-op) Replaces the previous reference-counting approach which was more complex and couldn't precisely account for dedup interactions. NOTE: remapCacOffsets() overrides in GPOS/GSUB subtable types still need to be implemented. This commit provides the infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Collapse calcMergedCacSize() and buildMergedCac() into a single buildMergedCac() that returns the merged cac. The promotion loop builds it each iteration and checks its size — when it fits, that's the one we keep. No separate "estimate" vs "build for real" step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Core infrastructure for the private-cac-per-subtable architecture: - coverageEnd()/classEnd() now record the call sequence (which record index was returned for each call, preserving order and dedup) - Add getCoverageOffset()/getClassOffset(): iterator-style replay that returns offsets in original call order, resolving through sharedCac if set (for non-ext subtables) or returning local offset (for ext) - Add setSharedCac()/resetReplay() for controlling resolution - Replace remapCacOffsets with setCacOffsets() virtual (no-op base) — subtable types override to call getCoverageOffset/getClassOffset mirroring their construction sequence - buildMergedCac() now just replays records into merged pool and sets sharedCac pointers; called unconditionally from fillOTL() - autoPromoteExtensions() uses temporary cacs for size checking only, does not set sharedCac — that's buildMergedCac's job Remaining: setCacOffsets() overrides in each subtable type (GPOS/GSUB). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each subtable type calls getCoverageOffset()/getClassOffset() in the same order as its constructor called coverageEnd()/classEnd(): GPOS: - SinglePos::Format1/Format2: Coverage - PairPos::Format1: Coverage - PairPos::Format2: Coverage, ClassDef1, ClassDef2 - ChainContextPos: backtracks[], inputGlyphs[], lookaheads[] - CursivePos: Coverage - MarkBasePos: MarkCoverage, BaseCoverage - MarkLigaturePos: MarkCoverage, LigatureCoverage GSUB: - SingleSubst::Format1/Format2: Coverage - MultipleSubst: Coverage - AlternateSubst: Coverage - LigatureSubst: Coverage - ChainSubst: backtracks[], inputGlyphs[], lookaheads[] - ReverseSubst: backtracks[], InputCoverage, lookaheads[] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore original GPOS.cpp fill() and remove fillFormat1/fillFormat2 declarations. Subtable splitting will be on a branch based on this one. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Since every subtable now owns its own CoverageAndClass, there's no need for shared_ptr indirection. Change to a plain member: - CoverageAndClass cac; (initialized in member initializer list) - coverageSize()/classSize() delegate through sharedCac pointer when set, so write methods transparently get the merged total - sharedCac is a raw non-owning pointer (set by buildMergedCac) - Remove sub->cac = merged from buildMergedCac (no longer needed) - All subtable code uses cac. (dot) notation - OTL-level cac stays as shared_ptr (it's the merged pool) - setCoverages() and classDefMake() take CoverageAndClass& not shared_ptr Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace size_t index counters with std::vector<uint16_t>::const_iterator for the coverage/class call sequence replay. More idiomatic for sequential access; no invalidation concern since vectors don't mutate after construction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All subtables are now constructed uniformly — no ext/non-ext distinction at construction time. Every constructor just calls incSubOffset(dataSize) regardless of useExtension. Extension-related adjustments (Coverage += dataSize, extension.offset assignment, incExtOffset) are deferred to a post-decision phase in fillOTL(), after autoPromoteExtensions() determines which subtables are actually extensions. This resolves the "promoted subtables have wrong offsets" problem: since no subtable gets ext-adjusted during construction, all of them (explicit and promoted) get adjusted uniformly at the same later point. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 8, 2026 21:35
fb935f5 to
a1ad178
Compare
Constructors now just record cac data and store their size: - incSubOffset(sz) → subtableSize = sz (direct assignment) - Coverage = cac.coverageEnd() → cac.coverageEnd() (call for sequence recording, return value discarded) - ClassDef = classDefMake(...) → classDefMake(...) (discarded) - setCoverages pushes 0 placeholders (overwritten by setCacOffsets) - Base constructor sets offset(0) (assigned in post-decision phase) - AddSubtable accumulates offset.subtable from stored subtableSize Values that were dead stores (overwritten by setCacOffsets or adjustForExtension) are no longer assigned, making it clear that offset fields are set in the post-decision phase, not at construction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 8, 2026 21:41
a1ad178 to
6e4935c
Compare
Assign final subtable offsets after promotion decisions and cac merge: - Non-ext: offset += subtableSize - Ext: offset += extStubSize, extension.offset tracks ext section Remove stale offset.subtable tracking from autoPromoteExtensions (computed from scratch each iteration in the promotion loop). NOTE: tests still failing - offset/type corruption under investigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 8, 2026 22:54
bed9814 to
b1555be
Compare
Extract the repeated "iterate records and register into target" pattern into mergeFrom(). Used by both buildMergedCac() and the temp cac size check in autoPromoteExtensions(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 9, 2026 06:34
b1555be to
8d89f57
Compare
Call setCacOffsets() on extension subtables too (they resolve through their private cac). Then unify the offset adjustment in all write() methods: instead of guarding with if (!isExt()), compute adjust as either subtableSize (ext: coverage follows data) or subOffset()-offset (non-ext: distance to shared coverage section). The rest of the calculation is identical for both paths. This eliminates the need for a separate adjustForExtension() virtual. Extension subtables now get correct Coverage/ClassDef offsets through the same code path as non-extension ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test feature files exercising cac deduplication during promotion: - cursive_range_a/b/c.fea: 60/70/80-rule lookups with unique coverages (different sizes, validate size-descending heuristic) - test_dedup_promotion.fea: 3 unique + 2 shared + fillers, total overflows. Tests that promoting unique lookups frees coverage from merged cac, while shared lookups' coverage stays (other users) - test_dedup_promotion_one_explicit.fea: same but unique_small is already useExtension (tests that explicit extensions reduce auto-promotions) Test functions to exercise these will be added in a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- test_auto_extension_promotion: verifies overflow font compiles with auto-promotion producing a valid font with extension lookups - test_auto_extension_promotion_explicit_reduces: verifies explicit useExtension reduces number of auto-promotions needed - test_auto_extension_nao_flag: verifies -nao preserves fatal overflow - test_auto_extension_verbose_message: (xfail) verbose output check - Add -nao to existing overflow tests so they continue testing the fatal error path - Remove stray debug cerr print from GPOS CursivePos constructor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 9, 2026 08:12
4978e71 to
4239322
Compare
Early checkOverflow calls in constructors no longer check meaningful values (offset.subtable is raw accumulated sizes, not true inline total). Replace with a single correct check in fillOTL() after offset assignment, gated by -nao flag. - Remove 5 earlyCheck calls (GPOS LookupEnd, ChainContext; GSUB LookupEnd, ChainSubst, ReverseSubst) - Remove 4 individual-size checks that were incorrectly rewritten from accumulated-total checks (CursivePos, MarkBasePos, MarkLigaturePos, LigatureSubst) - Remove earlyCheck parameter from checkOverflow() (no longer needed) - Add single overflow check in fillOTL() using correct computed total (offset.subtable + merged cac sizes), only fires with -nao - Update test: overflow tests check for generic "offset overflow" message since per-type messages no longer exist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
skef
force-pushed
the
feature/auto-overflow-handling
branch
from
July 9, 2026 08:47
697ab92 to
55d2c92
Compare
CodeQL flagged that %lx expects unsigned long but LOffset is uint32_t. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These methods are no longer called — offset.subtable and offset.extension are now computed directly in fillOTL()'s offset assignment loop. Also remove the redundant offset.subtable accumulation in AddSubtable() (reset and recomputed in fillOTL). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both were (1 << 15), causing -V to also suppress auto-overflow handling. Move HOT_NO_AUTO_OVERFLOW to bit 16. Remove xfail from test_auto_extension_verbose_message (now passes). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses #1849. Implements automatic subtable splitting and extension promotion so that
addfeatures/makeotfcan handle kerning overflow without requiring manualuseExtensionorsubtable;directives in feature files.Subtable splitting (internal overflow > 64K)
Extension promotion (offset overflow)
useExtensionsubtables first (they're already "out" of the budget)Design principles
subtable;breaks anduseExtensiondirectives — automatic handling only supplements what the user specified-naoflag foraddfeatures(also supported bymakeotf) suppresses auto-overflow handling for users who need manual controlKnown limitations (draft)
CoverageAndClassduring subtable construction. A complete implementation must rebuild coverage for newly-promoted subtables.Test plan
-naoflag preserves current (fatal error) behavior🤖 Generated with Claude Code