Skip to content

Choose an Indic run's shaper on the script chosen for it, as HarfBuzz does (#199) - #214

Merged
jakejackson1 merged 4 commits into
fix/191-chinese-region-langsysfrom
fix/199-indic-shaper-choice
Sep 17, 2026
Merged

jakejackson1 merged 4 commits into
fix/191-chinese-region-langsysfrom
fix/199-indic-shaper-choice

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 17, 2026

Copy link
Copy Markdown
Member

Closes #199

What changes

Otl::applyOTL() picked a run's shaper from its Unicode script alone. It then sent the run to that shaper under any GSUB script, DFLT and latn included. When the font offered no script for the run, it skipped the run entirely: no reordering, no GSUB, no GPOS. HarfBuzz does the reverse: hb_ot_shaper_categorize() (src/hb-ot-shaper.hh, 14.3.1) decides on the GSUB script it chose. Under DFLT, or latn picked as the last fallback, the run gets the default shaper. With no script (HB_TAG_NONE), the script's own shaper still runs.

  • shaperForScriptTag() makes that choice once the tags are known, shaper by shaper, and replaces the Myanmar "only under mym2" check.
  • A run with no script now gets past the early continue, as long as its script has a shaper and the document opened it with useOTL 0x80. Every script with a shaper sits behind that bit, so a document that closed the script still gets nothing (testARunInAScriptTheDocumentHasNotOpenedToOpenTypeLayoutIsNotShaped).
  • Such a run is shaped with no lookups. I checked every read on that path:
    • applyGSUB() goes on when there are no features but there is a shaper.
    • loadGsubDerivedData() still sets GSUBfont. That key has no cached derived data, so the reorderers get empty rphf/pref/blwf/pstf/rtlSUB/finals. loadGsubLookups(), which reads the GSUB table, coverage and lookup list, is only called where there are features; a font with no GSUB table has none (NotoSans-GPOS3-Synthetic in the shaping master is one).
    • The four GSUB rule walks, and applyOTL() itself, read features through features(), which returns none for an empty tag (the GPOS read beside it uses it too).
    • removeJoinControls(true), the word-boundary passes and applyGPOS() (gated on its own features) need no change.
    • No warnings under the suite's error handling.

Per shaper, as HarfBuzz 14.3.1 has it

mPDF shaper Scripts HarfBuzz Under DFLT Under latn No script mPDF before
I Devanagari … Malayalam Indic default default Indic I under any tag, skipped with none
S Sinhala USE default default USE S under any tag, skipped with none
E New Tai Lue, Cham, Tai Tham USE default default USE E under any tag, skipped with none
A N'Ko, Mandaic USE default default USE A under any tag, skipped with none
A Syriac Arabic unless DFLT default Arabic Arabic A under any tag, skipped with none
A Arabic Arabic always Arabic Arabic Arabic skipped with none
M Myanmar Myanmar; default under DFLT, latn, mymr default default Myanmar M only under mym2, skipped with none
K Khmer Khmer always Khmer Khmer Khmer skipped with none
T / L Thai, Lao Thai always Thai Thai Thai skipped with none

In each case mPDF now does what the HarfBuzz column says, using its own shaper where HarfBuzz has USE. Scripts mPDF has no shaper for are unchanged. mPDF never picks an *3 tag, so HarfBuzz's USE route for indy3 tags never comes up.

Original specification versus v2. HarfBuzz sets is_old_spec from the chosen tag not ending in 2 (hb-ot-shaper-indic.cc:324), so a run with no script is laid out to the original specification. With no lookups, mPDF's two reorderings draw the same thing. I shaped all 14,762 Bengali sequences of up to five of KA, SSA, RA, YA, VIRAMA, ZWJ, ZWNJ, I and NUKTA (starting with KA or RA) through the guru-only font with and without forcing the original specification, and nothing differed. So the dispatch keeps OtlTags' flag rather than adding a line no test could catch.

Measured

KA + I and KA + VIRAMA + SSA + I, drawn text via TextRecordingMpdf:

Font's GSUB scripts Before After hb-shape 14.3.1
DFLT, deva (NotoSansBengali-DevaScript-Synthetic) 09BF 0997 / 0997 09CD 09BF 09B7 0997 09BF / 0997 09CD 09B7 09BF uni0997 uni09BF / uni0997 uni09CD uni09B7 uni09BF
guru only (NotoSansBengali-GuruScript-Synthetic, new) 0995 09BF / 0995 09CD 09B7 09BF 09BF 0995 / 0995 09CD 09BF 09B7 uni09BF uni0995 / uni0995 uni09CD uni09BF uni09B7

ShaperChoiceTest pins both rows, the useOTL gate, and a Tai Tham run under Lanna Alif's latn (default shaper, no dotted circle; hb-shape draws gid144 = U+F001).

Fixture diff

Golden masters were regenerated cold: I deleted tests/Mpdf/tmp/mpdf, tmp/mpdf and tmp/ttfontdata, then ran fontcache, otldump, shaping and subset:update all. Only the shaping master moved, plus the four fixtures for each of the two new fonts. A script read every moved line and shaped the same text in the same font with hb-shape. It split runs by script as analyseCharacters() does, used --direction=ltr --cluster-level=2 so glyph order maps back to codepoints (.notdef taken from its cluster, other glyphs from mPDF's glyphIDtoUni), added --features=kern,-liga for that setting, and left scripts that 0x80 keeps closed unshaped.

Lines
Moved (31 fonts, 96 runs × 3 settings) 288
Glyph order changed 270
Match hb-shape after 288
Matched hb-shape before 18
Matched before and not after 0
Run Fonts Before → after (0xFF) hb-shape
bengali 8 with no Bengali script 0995 09CD 09B7 09BF0995 09CD 09BF 09B7 reordered
bengali 22 under DFLT/latn, 1 more (DevaScript) with its locl 0995 09CD 09BF 09B70995 09CD 09B7 09BF not reordered
devanagari 8 / 12 reordered / no longer reordered, as above same
gurmukhi 7 / 23 reordered / no longer reordered, as above same
khmer 8 with no Khmer script 1780 17D2 1781 17C117C1 1780 17D2 1781 reordered
font characters 0 NotoSansBengali-DevaScript-Synthetic (DFLT) … 09BF 09B7 …… 09B7 09BF … not reordered
arabic 6 with no Arabic script same glyphs, gains gpos={"1":{"kashida":8}} same glyphs

The 18 lines that matched before are the Arabic run. It now reaches the Arabic shaper, which has no forms to substitute, and it marks the tatweel as a kashida point, as it already did in every font that offers any script for Arabic. Glyphs are unchanged. NotoSansGurmukhiUI-GPOS71-Subset is one of the "reordered" fonts: its Devanagari and Bengali runs come back reordered, which is what the issue expects.

Snapshots

tai-tham-sakot is the only snapshot that moves; the other 78 are unchanged. The South East Asian shaper is now reached only under a Tai Tham script tag. Lanna Alif offers latn and Noto Sans Tai Tham offers DFLT and latn, so neither reaches it, and with Lanna Alif the snapshot and SeaTest would no longer reach the #104 path they pin (a cluster reaching the reorderer through a ccmp ligature). Both move to NotoSansTaiTham-LanaScript-Synthetic: a subset of Noto Sans Tai Tham 2.002 (OFL 1.1) with its script retagged lana, its Sakot ligatures moved from liga to ccmp, the rule swapping the ligature for a second form after a High Ka taken out (see #212), and a GDEF added because mPDF refuses useOTL without one.

Mutation check

Restored Fails
DFLT/latn keep the script's shaper (and Myanmar only under mym2) 26: ShaperChoiceTest default-script Indic and Tai Tham tests, and 24 shaping masters
No script skips the run 10: both ShaperChoiceTest no-script tests, and 8 shaping masters
useOTL 0x80 gate dropped testARunInAScriptTheDocumentHasNotOpenedToOpenTypeLayoutIsNotShaped
loadGsubLookups() called with no features shaping master NotoSans-GPOS3-Synthetic (no GSUB table) errors
features() indexes the features directly 62, including both no-script tests

Verification

Test fonts are built in fontTools 4.59.2: NotoSansBengali-GuruScript-Synthetic (Noto Sans Bengali 3.011, OFL 1.1, as #200's DevaScript font with a single guru script whose locl gives KA the glyph of KHA) and NotoSansTaiTham-LanaScript-Synthetic above.

Found on the way and not fixed here: #212, _applyGSUBrulesSingly() applies every feature it is given once per feature it is given.

Upstream

mpdf/mpdf development (744f75a) has the same dispatch inline in src/Otl.php:

  • :233-268 selects the shaper from the script block.
  • :311-321 skips the subchunk when all four tags are empty. Mirror: keep the skip only where the shaper is empty or !($useOTL & 0x80), after replacing :323-326 (the mym2 check) with the per-shaper choice above, made once $GSUBscriptTag is known.
  • :364 gates all of GSUB on ($useOTL & 0xFF) && $GSUBscriptTag && $GSUBlangsys && $GSUBFeatures. Mirror: ($useOTL & 0xFF) && ($GSUBFeatures || $this->shaper), with the GSLuCoverage/GSUBLookups load at :391-398 done only when there are features.
  • :1288, :1336, :1391 and :1448 read $this->mpdf->CurrentFont['GSUBFeatures'][$scriptTag][$langsys] unguarded. Mirror: an isset falling back to [].

🤖 Generated with Claude Code

@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 17, 2026
jakejackson1 and others added 3 commits September 17, 2026 11:40
… does (#199)

Otl::applyOTL() sent a run whose script has a shaper of its own to that
shaper under any GSUB script, DFLT and latn included, and skipped a run
the font offered no script for entirely. HarfBuzz decides the other way
round in hb_ot_shaper_categorize(): under DFLT, or latn picked as the
last fallback, the default shaper lays the run out with the font's
features alone, and with no script chosen the script's shaper still
runs. shaperForScriptTag() now makes that choice once the tags are
known, per shaper as HarfBuzz 14.3.1 has it:

- Indic, Sinhala and the South East Asian scripts (Indic and USE in
  HarfBuzz), and N'Ko and Mandaic: the default shaper under DFLT or latn
- Myanmar: also under mymr, which replaces the mym2-only check
- Syriac: the default shaper under DFLT only
- Arabic, Khmer, Thai and Lao: their own shaper whatever was chosen

A run with no script reaches its shaper as long as the document opened
the script to OpenType layout (useOTL 0x80, which covers every script
with a shaper). It is shaped with no lookups: loadGsubData() reads no
GSUB table for it, which the font need not have, and the GSUB rule
walks read the font's features for a script and language through
gsubFeatures(), which has none to give.

Measured with Noto Sans Bengali subsets whose GSUB has one 'locl'
lookup, KA + I and KA + VIRAMA + SSA + I:

  DFLT and deva  09BF 0997 / 0997 09CD 09BF 09B7 -> 0997 09BF / 0997 09CD 09B7 09BF
  guru only      0995 09BF / 0995 09CD 09B7 09BF -> 09BF 0995 / 0995 09CD 09BF 09B7

which is what hb-shape draws. 288 lines of the shaping golden master
move over 31 fonts, all of them now what hb-shape draws on the same font
and text, 18 of them before as well: those 18 are an Arabic run gaining
a kashida point on its tatweel in fonts with no Arabic script, and draw
the same. The rest are the Devanagari, Bengali and Gurmukhi runs,
reordered in the 8 fonts with no script for them and no longer in the
fonts laying them out under DFLT or latn, and the Khmer run reordered in
the 8 fonts with no Khmer script.

The South East Asian shaper is now reached only under a Tai Tham tag,
and Lanna Alif offers latn, so SeaTest and the Tai Tham snapshot, which
pin a cluster reaching its reorderer through a 'ccmp' ligature, move to
NotoSansTaiTham-LanaScript-Synthetic. The snapshot's broken cluster
loses the dotted circle Lanna Alif drew; the new font has none.

Test fonts: NotoSansBengali-GuruScript-Synthetic (Noto Sans Bengali
3.011, OFL 1.1) and NotoSansTaiTham-LanaScript-Synthetic (Noto Sans Tai
Tham 2.002, OFL 1.1, script retagged lana, its Sakot ligatures moved
from liga to ccmp), built in fontTools 4.59.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… GSUB and GPOS features one way (#199)

loadGsubData() took a flag only to return between its two halves: the
derived tables every shaper reads, and the table, coverage and lookup
list only a run with features needs. They are now two methods, and
applyGSUB() calls the second where there are features. The GPOS feature
read beside the GSUB one goes through the same accessor, and
shaperForScriptTag() takes the shaper it narrows as an argument rather
than reading it back off the object.

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

#175's synthetic font reached gravitypdf after this branch was written. Its GSUB offers DFLT and no
Bengali or Gurmukhi script, so those two runs now take the default shaper and are left in logical
order. hb-shape 14.3.1 does the same: it returns the run as two grapheme clusters, 0 0 2 2, where
the Indic shaper would have made one syllable of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/191-chinese-region-langsys branch from b38b6fd to 4c81cc3 Compare September 17, 2026 01:47
@jakejackson1
jakejackson1 force-pushed the fix/199-indic-shaper-choice branch from 26c367c to ae5339f Compare September 17, 2026 01:47
@jakejackson1

Copy link
Copy Markdown
Member Author

Rebased: #190 was squash-merged into gravitypdf (f9b3c0e), so this stack still carried its pre-squash commits. This branch is rebased onto the rebased fix/191-chinese-region-langsys; no conflicts. One follow-up commit, ae5339f: #175's NotoSansCoptic-IgnoreMarksClass-Synthetic reached gravitypdf after this PR was written, and under this PR's rule its Bengali and Gurmukhi runs take the default shaper under DFLT, so they are left in logical order. hb-shape 14.3.1 agrees: it returns those runs as two grapheme clusters (0 0 2 2), not one Indic syllable. That fixture is the only one to move on a cold regeneration. Suite (2132), snapshots and cs pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 6a0a9fe into fix/191-chinese-region-langsys Sep 17, 2026
3 of 27 checks passed
@jakejackson1

Copy link
Copy Markdown
Member Author

This PR merged into a base branch that had already been squash-merged, so its change never reached gravitypdf. It's carried to gravitypdf by #222.

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.

1 participant