Skip to content

Read no list at all where a GSUB or GPOS header states a NULL offset for it (#253) - #262

Open
jakejackson1 wants to merge 3 commits into
gravitypdffrom
fix/253-null-otl-list-offsets
Open

jakejackson1 wants to merge 3 commits into
gravitypdffrom
fix/253-null-otl-list-offsets

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

_getGSUBtables() and _getGPOStables() added the table offset to each of the three list offsets in
the header without testing for NULL. A GSUB or GPOS whose lists are absent stores 0 in all three, and 0
plus the table offset is the table's own start — so the parser read the header as a list and
majorVersion 0x0001 became a count of one: one script whose tag is four NUL bytes, and one lookup with
no subtables. Four warnings a render, a fabricated script tag, and a caller that promotes warnings to
exceptions cannot parse the font at all.

readListOffsets() leaves a NULL offset as 0 rather than making it absolute, and listCount()
answers 0 for one. All three list reads now take their count through listCount(), so the rule is
stated once where it cannot be bypassed rather than three times where each has to remember it.
_getGDEFtables() is the in-repo precedent: it has always treated a NULL subtable offset as an absent
subtable.

The absent Subtables key is separately reachable

readLookupList() now states Subtables before the loop that fills it. The format sets no minimum on
subTableCount, and seven walks of the list index that key with no isset() — two in TTFontFile,
four in Otl, one in OtlDump. So a conforming font that states a lookup with no subtables warns
four times at parse and twice per character while shaping, quite apart from the phantom lookup this PR
removes. NotoSans-EmptyLookup-Synthetic is that font.

The key is stated at construction rather than guarded at the reads because the lookup list is persisted
into .mtx.json: a read-site guard would still write the malformed shape to the cache.

MetricsGenerator::CACHE_FORMAT 3 → 4

.mtx.json stores GSUBScriptLang, GSUBLookups and GPOSLookups, and this change alters all three —
a new Subtables key on every lookup, and the phantom script and lookup gone. The constant's own
docblock names "the keys of an array" as the trigger, and it was raised 1 → 3 for exactly this class of
change. Without the bump the cache is regenerated only when the font file's size changes, so a warm
tmp/ttfontdata keeps serving the phantom script and the absent key after upgrade — the render-time
half of the same defect.

Fonts

Two three-glyph subsets of tests/data/ttf/NotoSans-Regular.ttf, whose OFL 1.1 notice
("Copyright 2015-2021 Google LLC") reserves no name, so clause 3 leaves a modified version free to be
named for itself, as NotoSansTC-*-Synthetic already is. Built with fontTools and
TTFont(src, recalcTimestamp=False).

  • NotoSans-NullOtlLists-Synthetic — GSUB and GPOS are nothing but the ten bytes
    00 01 00 00 00 00 00 00 00 00, byte-for-byte what the five google/fonts families in the issue ship.
    It carries a GDEF, without which useOTL would refuse the font before the GSUB read. Covers both
    _getGSUBtables() and _getGPOStables().
  • NotoSans-EmptyLookup-Synthetic — a real latn ScriptList, a real liga FeatureList, and a
    LookupList whose one Lookup states no subtables. The conforming-font route to the same absent key.

The five google/fonts families themselves are not added to the repository. hb-shape 14.3.1 shapes
both synthetics: [A=0+639|B=1+650], so neither is malformed.

Fixtures

No existing master moves. The eight new ones are the two fonts' subset, otldump, shaping and
fontcache files.

fontcache/NotoSans-NullOtlLists-Synthetic.json is itself a regression test for the phantom tag: all
six of GSUB/GPOSScriptLang, Features and Lookups are [], and the two .dat blobs are "10
bytes". otldump reports "No entries in GSUB table" and records no diagnostics.
fontcache/NotoSans-EmptyLookup-Synthetic.json keeps latn/liga and one lookup with "Subtables": [].

The two shaping files differ only on the Indic runs, and each matches an existing master for a font in
the same position: NullOtlLists reorders Devanagari 0915 094D 0937 093F0915 094D 093F 0937 exactly
as NotoSans-GPOS3-Synthetic does, whose GSUBScriptLang is also []; EmptyLookup leaves it alone
exactly as NotoSans-GSUBClassZero-Synthetic does, whose GSUB also has latn.

Verified

All six tests in tests/Mpdf/NullOtlListOffsetsTest.php fail against the previous TTFontFile.php,
each on its own observable from the issue:

1) testNoScriptIsFabricatedFromTheTableHeader
-Array &0 ()
+Array &0 (
+    Binary String: 0x00000000 => 'DFLT '
+)

2) testATableStatingNoListsCarriesNoFeatureAndNoLookup
+    0 => Array &1 ('Type' => 1, 'Flag' => 0, 'SubtableCount' => 0, 'MarkFilteringSet' => '')

3) testParsingRaisesNoDiagnostics  "both lists absent"
+    0 => 'Undefined array key "Subtables" in TTFontFile.php:3440'
+    1 => 'foreach() argument must be of type array|object, null given in TTFontFile.php:3440'
+    2 => 'Undefined array key "Subtables" in TTFontFile.php:1596'
+    3 => 'foreach() argument must be of type array|object, null given in TTFontFile.php:1596'

6) testShapingAgainstALookupStatingNoSubtablesDrawsSilently
+    4 => 'Undefined array key "Subtables" in Otl.php:1766'
+    5 => 'foreach() argument must be of type array|object, null given in Otl.php:1766'

Tests: 6, Assertions: 10, Failures: 6.
composer test                                        2519 tests, 6704 assertions, 2 skipped, OK
composer test -- --group=snapshot                    79 tests, 109 assertions, OK
composer cs                                          clean
phpstan --no-progress --memory-limit=2G              33 errors, all pre-existing

Upstream

The issue had not measured this. mpdf/mpdf development at 744f75a shows both phantoms and also
warns four times, but at different sites — TTFontFile.php:1535 and :3377, the feature-ordering line
this fork already guards for Sedan SC's empty smcp, rather than at the two Subtables walks, whose
loops upstream bounds by the count. Upstream also leaves Subtables absent for a zero-subtable lookup,
raising nothing at parse but carrying the same malformed shape.

The same rule is unguarded one level down, in MarkBasePos and MarkMarkPos, and is not fixed here:
#260.

Closes #253

🤖 Generated with Claude Code

jakejackson1 and others added 3 commits September 18, 2026 12:20
…for it (#253)

A GSUB or GPOS whose ScriptList, FeatureList and LookupList are absent stores 0 for
each of the three offsets. Adding the table offset to that points at the table's own
header, where the majorVersion of 0x0001 reads as a count of one, so the table came
back carrying a script whose tag is four NUL bytes and a lookup with no subtables.
That cost four warnings a render and a fabricated script tag; a caller that promotes
warnings to exceptions could not parse the font at all.

readListOffsets() leaves a NULL offset as it stands rather than making it absolute,
and listCount() answers nothing for one. All three list reads are counted there, so
the guard is in one place rather than at each of them.

readLookupList() also states Subtables before the loop that fills it. The format lets
a Lookup state no subtables at all, and the seven walks of the list - two here, four
in Otl and one in OtlDump - read the key without asking whether it is there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…reams (#253)

The lookup list is written into .mtx.json, so the Subtables key this branch adds and
the phantom entries it removes are a change to a persisted shape: without the version
bump a warm tmp/ttfontdata keeps serving the old one, and Otl keeps reading an absent
Subtables for a lookup that states none.

assertDrawsSilently() already draws and asserts nothing was raised, so the shaping test
no longer builds its own document, and a fontkey of its own per parse replaces the
temporary directory the parsing tests were deleting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working create-upstream-pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The NULL ScriptList, FeatureList and LookupList offsets of an empty GSUB are followed to the table header, where the version reads as a count of one

1 participant