Skip to content

Decode GPOS context positioning and mark-to-base subtables once per document - #323

Open
jakejackson1 wants to merge 4 commits into
gravitypdffrom
fix/decode-gpos-context-and-marks-once
Open

jakejackson1 wants to merge 4 commits into
gravitypdffrom
fix/decode-gpos-context-and-marks-once

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Follow-up to #314. That PR decoded most OTL subtables once per document through LuDataCache, but it left out three things: GPOS 7 (all formats), GPOS 8 formats 1 and 2, and mark attachment. The GPOS formats were skipped because no bundled font has them, so they could not be timed or checked against a real font. Mark attachment was under 4% of the profile. This PR adds fixtures for those formats, converts them, and converts mark-to-base. There is one commit for the fixtures and one per lookup type.

What was slow

  • GPOS 7 and GPOS 8 formats 1 and 2. Every time a glyph matched a subtable's Coverage table, the subtable was re-read from the font: the rule-set offsets, then every rule with its glyph or class list, the ClassDefs with their class 0 exclusions (format 2), and the Coverage offsets and sets (7.3). In the Sharada document one pass over the benchmark text offers 7.2 subtables 382 glyphs and an 8.2 subtable 203, and each offer re-read the ClassDefs.
  • GPOS 4 (mark-to-base). Every mark offered to a subtable re-read the five-field header and seeked to the base Coverage table. Every mark that attached also re-read its MarkRecord, the base's anchor offset and the anchor.

What changes

Commit Lookup Decoder
1 fixtures, below
2 GPOS 7.1, 7.2, 7.3 the GSUB 5 decoders: plainRuleSet, plainClassContext + plainClassRules, plainCoverageContext. The layout is the same.
3 GPOS 8.1, 8.2 the GSUB 6 decoders: chainedRuleSet, chainedClassContext + chainedClassRules. 8.3 already shared chainedCoverageContext.
4 GPOS 4 markToBase (the header, plus the base Coverage table as coverageIndexByHex() already caches it), markRecord, and baseAnchor (per base, per mark class, filled on first use)
  • The matchers (checkContextMatch, checkContextMatchMultiple, _applyGPOSlookupRecords) are not changed. When a rule matches, the reader is seeked to its lookup records, as Decode each OTL subtable once per document #314 does for GSUB.
  • The entries live under the font's /GPOS key, so they never collide with the GSUB entries from the same decoders.
  • GSUB 6.1 and GPOS 8.1 read a null rule-set offset differently. GSUB 6.1 has always followed it to the start of the subtable. GPOS 8.1 has always treated it as "no context starts with this glyph". chainedRuleSet now takes $followNull, which only the GSUB caller passes, so each keeps its old behaviour.
  • GPOS 7.1 used to skip the Coverage Index check against the rule-set count. Where a font's Coverage table is longer than its rule-set array, it read past the end of the array. It now goes through plainRuleSet, which finds no rule set there, as GSUB 5.1 always has. No font I tested reaches that case.
  • Types 5 and 6 are unchanged. Types 4, 5 and 6 share a header layout. I converted 5 and 6 the same way, and it made no measurable difference at N=10: applyOTL changed by −1% to +1% on hebrew-niqqud, thai, khmer, arabic-cursive, myanmar and syriac. Both changes were dropped.
  • Nothing is persisted to the font cache, so there is no CACHE_FORMAT bump.

Fixtures

The four fonts are Noto, SIL OFL 1.1. Noto reserves no font name. Each subset keeps its name and licence strings. Each was cut down with fontTools to one script plus the space, the danda, the joiners and the dotted circle, with hinting dropped. The provenance is also in the docblock of ContextualPositioningTextTest.

Fixture Source Size Reaches
NotoSansGurmukhiUI-Subset Noto Sans Gurmukhi UI 2.001, the default instance of the variable font in google/fonts 30 KB 7.1, 8.2
NotoSansSharada-Subset Noto Sans Sharada 2.002, from notofonts/noto-fonts at 9e7321e. No later release has both formats. 49 KB 7.2 (mkmk, dist), 8.2
NotoSansTakri-Subset Noto Sans Takri 2.005, from google/fonts 18 KB six 8.1 lookups, 8.3
NotoSans-ContextCoverage-Synthetic Noto Sans 2.007 cut down to ASCII, with a GPOS written for it 10 KB 7.3
  • 7.3 is synthetic. I scanned 1,993 families in google/fonts (one face each), the macOS supplemental fonts and the other fonts on this machine. Only Gujarati UI and Gurmukhi UI have any GPOS 7 subtable, and none has a 7.3. The synthetic fixture has a single positioning that moves a consonant, and a dist lookup with two 7.3 subtables: vowel + consonant, and consonant + consonant + vowel.
  • Each fixture is confirmed to reach its formats. I counted _applyGPOSsubtable offers per type and format in an instrumented copy. For each format I also rendered a run with that lookup switched off to confirm the lookup is what moves the glyph. Those runs are pinned in ContextualPositioningTextTest.
  • The golden-master fixtures for the four fonts (fontcache, otldump, shaping, subset) were written before any conversion. They still hold after it.

Performance

compare.sh, time inside Otl::applyOTL, interleaved cold-base / cold-new / hot-base / hot-new, one PHP process per run. My copy of the harness adds one scenario per fixture font (ctx-*): 60 paragraphs of four repeats of that script's text.

Against gravitypdf (9d585a1), N=10:

Scenario Cache Before (ms) After (ms) Change
ctx-sharada (7.2, 8.2) cold 3160 ±1% 854 ±3% -73%
ctx-sharada hot 3170 ±1% 843 ±1% -73%
ctx-takri (8.1) cold 665 ±1% 495 ±2% -26%
ctx-takri hot 664 ±2% 498 ±2% -25%
ctx-latin73 (7.3) cold 92 ±4% 73 ±5% -20%
ctx-latin73 hot 90 ±2% 73 ±2% -19%
ctx-gurmukhi (7.1, 8.2) cold 581 ±1% 557 ±3% -4%
ctx-gurmukhi hot 581 ±1% 550 ±0% -5%
hebrew-niqqud cold 572 ±3% 556 ±2% -3%
hebrew-niqqud hot 570 ±1% 569 ±5% -0%
thai cold 82 ±2% 80 ±4% -2%
thai hot 86 ±6% 81 ±3% -5%
khmer cold 226 ±1% 225 ±5% -1%
khmer hot 230 ±3% 222 ±1% -4%
arabic-cursive cold 496 ±8% 486 ±4% -2%
arabic-cursive hot 494 ±5% 475 ±2% -4%
myanmar cold 1544 ±2% 1523 ±1% -1%
myanmar hot 1535 ±1% 1525 ±1% -1%
hindi cold 467 ±3% 460 ±1% -2%
hindi hot 464 ±2% 461 ±3% -1%
arabic cold 357 ±2% 355 ±2% -0%
arabic hot 359 ±4% 356 ±2% -1%
tibetan cold 213 ±1% 213 ±1% +0%
tibetan hot 213 ±0% 213 ±2% +0%
latin-otl cold 177 ±2% 176 ±2% -1%
latin-otl hot 175 ±1% 182 ±12% +4%
  • Whole-document time (total) moves by the same milliseconds: ctx-sharada 3419 → 1111 ms cold (−67%), ctx-takri 917 → 748 ms (−18%).
  • latin-nootl doesn't shape: 216 → 219 ms cold, 208 → 208 ms hot. The +4% on latin-otl hot is one outlier in a ±12% cell. Its cold run and every other scenario are flat or better.

Per commit, N=10, each commit measured against the one before it:

Commit Scenario Cold Hot
GPOS 7 ctx-sharada (its 7.2 share) -6% -7%
GPOS 7 ctx-latin73 -30% ±40% -18%
GPOS 7 ctx-gurmukhi -1% -2%
GPOS 8.1/8.2 ctx-sharada (its 8.2 share) -71% -72%
GPOS 8.1/8.2 ctx-takri -19% -20%
GPOS 8.1/8.2 ctx-gurmukhi -5% -2%
GPOS 4 hebrew-niqqud -2% ±1% -3% ±1%
GPOS 4 thai -2% ±1% -2% ±1%
GPOS 4 khmer -1% -2%
GPOS 4 hindi, myanmar, tibetan, arabic, arabic-cursive within ±1% within ±1%
  • GPOS 7.1 barely measures. Gurmukhi UI's 7.1 covers only BA and one ligature, so one pass over the benchmark text offers it about 30 glyphs. The 8.2 lookup in that font is similar. I kept the 7.1 conversion anyway: it is three lines calling the GSUB 5.1 decoder, and it makes GPOS 7 consistent across formats.
  • GPOS 4 is a small gain: 2–3% on hebrew-niqqud and thai at ±1%, and nothing measurable elsewhere. It held in two quiet N=10 runs. A third run at N=20, on a loaded machine (±6–13%), couldn't separate it from noise. I kept it because it is consistent and the code is short, but it is marginal. The commit stands alone and can be dropped.

Where the time goes now. I profiled on #314 before converting (Xdebug, hot cache). The whole of GPOS was 15–25% of shaping in hebrew-niqqud, 8% in myanmar, 15% in hindi (pair adjustment), 3% in tibetan and 7% in arabic. Mark attachment was 5% inclusive at most (hebrew-niqqud: mark-to-base 3.6%, mark-to-mark 1.4%), and under 1.5% everywhere else. That is why the gain above is small. GSUBsubstitute and ligature substitution still lead every profile.

Cumulative against /Users/jakejackson/Sites/mpdf-perf-baseline (8c443ff, before #314), N=5, applyOTL:

Scenario Cache Before (ms) After (ms) Change
ctx-sharada cold 3357 ±2% 853 ±1% -75%
ctx-takri cold 1018 ±0% 495 ±1% -51%
ctx-gurmukhi cold 1013 ±4% 561 ±1% -45%
ctx-latin73 cold 88 ±1% 70 ±1% -20%
hebrew-niqqud cold 1167 ±1% 555 ±1% -52%
myanmar cold 3500 ±1% 1521 ±0% -57%
tibetan cold 561 ±3% 216 ±3% -61%
khmer cold 330 ±1% 222 ±0% -33%
thai cold 108 ±3% 78 ±1% -28%
latin-otl cold 228 ±1% 174 ±1% -23%
arabic cold 458 ±3% 362 ±2% -21%
arabic-cursive cold 522 ±2% 480 ±2% -8%

This baseline also predates #311#313 and #321, so the table is the combined effect of everything merged since 8c443ff, not of this PR alone. Hot runs track cold. Hindi is left out because its baseline cells were at ±64% and ±48%.

Output

  • Shaping. One shaper per font ran every harness text, the four fixture texts, and up to 300 runs of 12 of the font's own characters, with and without kern. That covers every bundled OTL font plus every font in tests/data/ttf: 89 fonts, 15,636 runs. The hash of the returned string plus serialised OTLdata (including GPOSinfo) is identical to gravitypdf for every run. I also checked it after each commit.
  • PDF bytes. 21 scenarios were rendered on both trees with a fixed creationDate, exposeVersion off, compression off and a warm cache: the four ctx-*, hebrew-niqqud, myanmar, tibetan, hindi, arabic, arabic-cursive, thai, khmer, syriac, latin-otl, latin-nootl, telugu, kannada, bengali, sinhala, ethiopic and multilingual-auto. All are byte-identical.
  • Suites. The snapshot suite (85 tests) and the four golden masters pass unchanged.

Tests

  • DecodedSubtableTest gains six cases, each checking a cold decode followed by a cached decode: same result, bucket filled, nothing decoded on the second pass.
    • GPOS 4: Taamey David, baseAnchor
    • 7.1: Gurmukhi UI
    • 7.2: Sharada
    • 7.3: synthetic
    • 8.1: Takri
    • 8.2: Sharada
  • ContextualPositioningTextTest is new. It pins one run per format where the lookup moves a glyph, on the new fixtures.

composer test, composer cs and PHPStan all pass.

Left out

  • Mark-to-ligature (Type 5) and mark-to-mark (Type 6), for the reason above.
  • Noto Sans Newa has 27 GPOS 8.2 subtables and two 7.2, and would have been a fifth fixture. mPDF can't shape with it at all: it reads past the end of the font, with Uninitialized string offset warnings from FileReader::readUInt16. That bug is separate from this PR and not fixed here.

🤖 Generated with Claude Code

jakejackson1 and others added 4 commits September 22, 2026 17:37
The fixtures for GPOS Lookup Types 7 and 8 carry a handful of glyphs each: enough to pin one rule,
too few to write a paragraph in, so a document could not be timed through them. These four cover a
whole script each, and a run of ordinary text reaches the lookups glyph after glyph:

- Noto Sans Gurmukhi UI 2.001, cut down to Gurmukhi: Type 7 Format 1 and Type 8 Format 2
- Noto Sans Sharada 2.002, cut down to Sharada: Type 7 Format 2 and Type 8 Format 2
- Noto Sans Takri 2.005, cut down to Takri: six Type 8 Format 1 lookups
- Noto Sans cut down to printable ASCII, with a GPOS of two Type 7 Format 3 subtables written for
  it, since no font surveyed carries that format

ContextualPositioningTextTest pins one run per format in which the lookup moves a glyph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GPOS Lookup Type 7 read each subtable from the font every time a glyph its Coverage table matched
was offered to it: the rule set offsets, then each rule and its glyph or class list, then for
Format 2 the ClassDef and its class 0 exclusions, then for Format 3 the Coverage offsets and sets.

Type 7 has the layout of GSUB Type 5, so each format now shares its decoder with the matching
substitution format: plainRuleSet for Format 1, plainClassContext and plainClassRules for
Format 2, plainCoverageContext for Format 3. The entries are kept under the font's GPOS key, apart
from its GSUB ones.

Format 1 had not tested the first glyph's Coverage Index against the rule set count, and read
past the offset array for a font whose Coverage table is longer than it. plainRuleSet finds no
rule set there, as GSUB Type 5 always has.

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

GPOS Lookup Type 8 Formats 1 and 2 read each subtable from the font every time a glyph its
Coverage table matched was offered to it. Format 3 has been decoded once since it was given the
decoder GSUB Type 6 Format 3 uses.

The other two formats now share theirs too: chainedRuleSet for Format 1, and chainedClassContext
and chainedClassRules for Format 2. Where GPOS read a null rule set offset as a glyph that begins no
context and GSUB followed it, each still does, so chainedRuleSet is told which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GPOS Lookup Type 4 read its header from the font every time a mark its Coverage table matched was
offered to it, and for each mark it attached, the mark's record, the base's anchor offset and the
anchor itself.

The header is now decoded once per subtable (markToBase), keeping the base Coverage table as
coverageIndexByHex() already did. The mark record (markRecord) and the base's anchor for each mark
class (baseAnchor) are decoded the first time they are asked for.

Types 5 and 6 have the same layout and are left as they were: decoding them the same way made no
difference that could be measured.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant