From 789c8bd9963ee01dd6ceb4612a477290b385e90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Tue, 7 Apr 2026 20:14:11 +0200 Subject: [PATCH] Fix chaining context backtrack matching order - Backtrack arrays are stored in reverse buffer order per OT spec - Reverse before matching so backtrack[0] aligns with position before input --- src/opentype/OTProcessor.js | 10 +++++++--- test/shaping.js | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/opentype/OTProcessor.js b/src/opentype/OTProcessor.js index 80d1ce69..babd583f 100644 --- a/src/opentype/OTProcessor.js +++ b/src/opentype/OTProcessor.js @@ -393,7 +393,9 @@ export default class OTProcessor { let set = table.chainRuleSets[index]; for (let rule of set) { - if (this.sequenceMatches(-rule.backtrack.length, rule.backtrack) + // Backtrack is stored in reverse order per OpenType spec, so reverse + // it before matching forward from the start position. + if (this.sequenceMatches(-rule.backtrack.length, [...rule.backtrack].reverse()) && this.sequenceMatches(1, rule.input) && this.sequenceMatches(1 + rule.input.length, rule.lookahead)) { return this.applyLookupList(rule.lookupRecords); @@ -414,7 +416,8 @@ export default class OTProcessor { } for (let rule of rules) { - if (this.classSequenceMatches(-rule.backtrack.length, rule.backtrack, table.backtrackClassDef) && + // Backtrack is stored in reverse order per OpenType spec. + if (this.classSequenceMatches(-rule.backtrack.length, [...rule.backtrack].reverse(), table.backtrackClassDef) && this.classSequenceMatches(1, rule.input, table.inputClassDef) && this.classSequenceMatches(1 + rule.input.length, rule.lookahead, table.lookaheadClassDef)) { return this.applyLookupList(rule.lookupRecords); @@ -424,7 +427,8 @@ export default class OTProcessor { break; case 3: - if (this.coverageSequenceMatches(-table.backtrackGlyphCount, table.backtrackCoverage) && + // Backtrack is stored in reverse order per OpenType spec. + if (this.coverageSequenceMatches(-table.backtrackGlyphCount, [...table.backtrackCoverage].reverse()) && this.coverageSequenceMatches(0, table.inputCoverage) && this.coverageSequenceMatches(table.inputGlyphCount, table.lookaheadCoverage)) { return this.applyLookupList(table.lookupRecords); diff --git a/test/shaping.js b/test/shaping.js index dc005ea0..f4b6206f 100644 --- a/test/shaping.js +++ b/test/shaping.js @@ -56,6 +56,15 @@ describe('shaping', function () { '218+545|11+1781|94+1362|26@35,0+1139|34+564|32+1250|3+532|9+1904|96+1088|93+1383|51+569|8+1904|' + '3+532|33+1225|21+1470|3+532|96+1088|17+1496|96+1088|17+1496|32+1250|3+532|9+1904|95+1104|12+1781|39+1052'); + // Exercises chain context backtrack ordering: the Syriac `calt` chain rule + // requires backtrack[0] (mark uni0731) immediately before the input glyph + // (uni0713.Fina) and backtrack[1] (uni0712.Init) one position further + // back. Without reversing the backtrack array, fontkit matches in stored + // order and the rule fails to fire, leaving uni0713.Fina unsubstituted. + test('should match chain context backtrack in OpenType spec order', + 'NotoSans/NotoSansSyriacEstrangela-Regular.ttf', 'ܒܱܓ', + '249+2485|141@606,-200+0|17+1496'); + test('should shape N\'Ko text', 'NotoSans/NotoSansNKo-Regular.ttf', 'ߞߊ߬ ߞߐߕߐ߮ ߞߎߘߊ ߘߏ߫ ߘߊߦߟߍ߬ ߸ ߏ߬', '52@10,-300+0|23+1128|3+532|64+985|3+532|52@150,-300+0|84+1268|139+1184|160+1067|76+543|119+1622|3+532|51@10,-300+0|90+1128' + '|119+1622|3+532|75+543|118+1622|88+1212|137+1114|3+532|54@170,0+0|93+1321|109+1155|94+1321|137+1114|3+532|52@-210,0+0|75+543|137+1114');