Skip to content

Reach the merged mask of a shared Lookup with a Khmer font of its own (#243) - #264

Open
jakejackson1 wants to merge 2 commits into
gravitypdffrom
fix/243-khmer-mask-merge
Open

jakejackson1 wants to merge 2 commits into
gravitypdffrom
fix/243-khmer-mask-merge

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

#243 de-duplicates the Lookups of one stage of the plan and, where two features of that stage name the
same Lookup, merges their masks. A mask of 0 means "apply to every glyph", so 0 has to absorb: a
Lookup named by one masked and one unmasked feature must come out unmasked, and two masked features must
give the union of their bits.

				$stage[$lu]['mask'] = $stage[$lu]['mask'] && $mask ? $stage[$lu]['mask'] | $mask : 0;

That line was the one part of #243 with no test behind it, and the reason was the corpus.
indicFeatureMasks() masks rphf pref blwf half pstf cfar init, and the only call that puts masked and
unmasked features in one stage is the Khmer basic forms — locl ccmp pref blwf abvf pstf cfar, where
pref blwf pstf cfar are masked and locl ccmp abvf are not. There was no Khmer font in
tests/data/ttf to build a fixture from, and the only Khmer font in the repo,
packages/Khmer-OS/fonts/KhmerOS.ttf, is LGPL-2.1-or-later with no font exception.

No src/ change: this is the test coverage for a fix that already landed.

The donor

Battambang Regular, and the licence is why it rather than KhmerOS:

  • SIL OFL 1.1 with no Reserved Font Name. The notice is Copyright 2019 The Battambang Project Authors (https://github.com/danhhong/Battambang); the only occurrence of "Reserved Font Name" in the
    licence is inside its own definitions clause, not in the notice. The bundled copy is byte-identical to
    https://raw.githubusercontent.com/danhhong/Battambang/master/OFL.txt. So clause 3 does not constrain
    the derived name; clauses 1 and 2 still require the notice to travel, and name IDs 0, 13 and 14 are
    kept.
  • fsType 0, Installable Embedding, preserved in the synthetic — worth stating because the fixture
    is embedded in a PDF rather than only parsed.
  • A GDEF, without which useOTL refuses the font before the GSUB read.
  • khmr/DFLT with abvf blwf clig pref pstf.
  • Same designer as the packaged LGPL font: the donor's name ID 9 is Danh Hong, and
    packages/Khmer-OS/fonts/LICENSE.txt is Copyright 2005 Danh Hong / Open Forum of Cambodia. Same
    design lineage, without the licence problem.

The donor is not added to the repository and no packages/ entry is added — only the synthetic, as
every other synthetic here is done.

The synthetic

tests/data/ttf/Khmer-SharedMask-Synthetic.ttf, a 14-glyph subset with GSUB and GPOS dropped and four
Type 1 lookups built with feaLib. Every substituted form is one of the donor's own below-base or
pre-base glyphs — no outline was invented, only the features that name them. Built with
TTFont(src, recalcTimestamp=False), head.modified still the donor's, and the build is
byte-reproducible from the donor.

lookup 0  blwf              uni1783 -> uni17D21783      control, stays masked
lookup 1  blwf, then abvf   uni1780 -> uni17D2_1780     masked first, unmasked second -> 0
lookup 2  ccmp, then blwf   uni1781 -> uni17D2_1781     unmasked first, masked second -> 0
lookup 3  pref, then blwf   uni1782 -> uni17D2_1782     two masked -> union of bits
                            uni179A -> uni17D2179A

Each feature's first Lookup index is its own, so the fixture does not lean on #245's collision fix. The
nine provider rows state each Lookup twice — on a glyph it should reach and one it should not.

The rows are live

src/ is unchanged, so "revert src/ and watch it fail" does not apply. Instead the merge was broken
deliberately, two ways, and the two breaks fail on different rows:

Dropping the merge line, so the first writer's mask sticks — 3 of 9 fail:

1) …"a Lookup blwf and abvf share, on a base consonant blwf does not mark"
   -    0 => 57346      (0xE002, uni17D2_1780)
   +    0 => 6016       (0x1780)
2) …"a Lookup pref and blwf share, on the below-base form only blwf marks"
3) …"both bits of that union in one syllable, from the one Lookup"
Tests: 9, Assertions: 9, Failures: 3.

A plain $stage[$lu]['mask'] |= $mask;, the most plausible wrong line — 2 of 9 fail, the other pair:

1) …"a Lookup blwf and abvf share, on a base consonant blwf does not mark"
2) …"a Lookup ccmp and blwf share, the unmasked feature of the two named first"
Tests: 9, Assertions: 9, Failures: 2.

Between them both directions of the absorb and both bits of the union are pinned.

hb-shape 14.3.1, including the row where it disagrees

Nine of ten runs agree with mPDF. The union half has a proper oracle: pref and blwf are in one stage
of HarfBuzz's Khmer plan and HarfBuzz merges their masks with |= itself.

$ hb-shape --font-file=Khmer-SharedMask-Synthetic.ttf --unicodes=1780 --no-positions
[uni1780=0]                                              mPDF: E002   ** differs **
$ ... --unicodes=1781 --no-positions
[uni17D2_1781=0]                                         mPDF: E003
$ ... --unicodes=1784,17D2,1782 --no-positions
[uni1784=0|uni17D2=0|uni17D2_1782=2]                     mPDF: 1784 17D2 E004
$ ... --unicodes=1784,17D2,179A,17D2,1782 --no-positions
[uni17D2=0|uni17D2179A=0|uni1784=0|uni17D2=0|uni17D2_1782=4]   mPDF: 17D2 E001 1784 17D2 E004

The disagreement is not the merge, and it is now #263. abvf has no entry in
indicFeatureMasks(), so it is applied with mask 0 — yet Shaper\Indic::initial_reordering_syllable()
sets FLAG(ABVF) on exactly the glyphs it should reach (src/Shaper/Indic.php:792). The bit is written
and never read, so mPDF substitutes on a base consonant HarfBuzz leaves alone. Adding
'abvf' => Indic::FLAG(Indic::ABVF) makes mPDF agree on all ten runs. That is not done here; the row
asserts mPDF's behaviour as it stands and the docblock names the cause.

If #263 is fixed, the masked-first/unmasked-second row must go with it. abvf is the only unmasked
tag following a masked one in the Khmer stage list, so that direction of the absorb becomes unreachable
in mPDF and the case can no longer fail. #263 says so.

One wrinkle, recorded in the docblock: the ccmp+blwf row is not evidence either way against
HarfBuzz, which pauses to reorder between ccmp and the basic forms and so takes that Lookup twice,
once unmasked, reaching the same glyph by a route mPDF does not take. Same glyph, different mechanism.

Fixtures

Four new masters for the new font, written by the four composer *:update scripts. No existing master
moves.
otldump confirms the design (khmr/DFLT: blwf abvf ccmp pref, four Type 1 lookups);
fontcache shows all four features surviving the parser with their Lookup lists; shaping corroborates
the unit test independently — the khmer run 1780 17D2 1781 17C1 => 17C1 E002 17D2 E003, and the
font's own repertoire run substitutes 1780 and 1781 while leaving 1782 and 1783 alone, which is
the merged-to-0 Lookups reaching base consonants and the masked one not.

Verified

composer test                                        2515 tests, 6684 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

Still unreached after this: a merged non-zero mask on a Type 8 reverse Lookup.
_applyGSUBrulesSingly() passes no masks, so it can only arrive by the Indic path, and no font here has
a shared reverse Lookup under two masked tags.

Refs #243. Does not close it — #243 is already closed.

🤖 Generated with Claude Code

jakejackson1 and others added 2 commits September 18, 2026 12:50
…#243)

Otl::lookupsForStage() takes a Lookup two features of one stage name once and merges their masks,
which was the one part of #243 with no test behind it. The merge is only reachable through the Khmer
basic forms, the single stage that holds masked and unmasked features together, and no font in
tests/data/ttf had a Lookup under two of those tags.

Khmer-SharedMask-Synthetic is a 14-glyph subset of Battambang Regular 8.002 - Danh Hong, SIL OFL 1.1
with no Reserved Font Name, fsType 0 - carrying a GSUB of four hand-built single substitutions:
blwf and abvf share one, ccmp and blwf share another, pref and blwf share a third, and blwf names a
fourth alone. The below-base and pre-base forms substituted in are the donor's own; only the features
that name them are new. It keeps the donor's copyright and licence strings, and carries a name of its
own so that a font called Battambang that is not Battambang cannot mislead.

The rows pin both halves of the merge. Where a masked and an unmasked feature share a Lookup the mask
is nothing, whichever of the two is named first, so it reaches a base consonant the reordering marked
for neither. Where two masked features share one the mask is the union of their bits, so it reaches
the pre-base form only pref marks and the below-base form only blwf marks - both in the same syllable
from the one Lookup - and still reaches neither on a base consonant. The Lookup blwf names alone is
the control.

hb-shape 14.3.1 draws all of this the same way save the lone base consonant of the blwf and abvf
Lookup: HarfBuzz gives abvf a mask of its own, while indicFeatureMasks() leaves abvf out although the
reordering sets Indic::ABVF on exactly the glyphs it would cover. That is a gap of its own rather than
anything this merge decides, and the test docblock says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…b-shape runs that decide (#243)

The opening paragraph and seven of the nine hb-shape transcripts repeated what the code under test
and the data provider say four lines apart. What is left is the part neither of them can say: that a
Khmer font is the only route to the merge, what HarfBuzz's Khmer plan does with the three Lookups
that carry the argument, and why one row is mPDF's answer rather than HarfBuzz's.

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

create-upstream-pr enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant