diff --git a/CHANGELOG.md b/CHANGELOG.md index 851ef649..7a19d58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Ignore the advances of marks zeroed after GPOS when positioning attached marks - Position marks like HarfBuzz in fonts without GPOS (#409) - Fix canonical composition fallback, Arabic mark ordering, and positioning in fonts without GSUB +- Reorder combining marks like HarfBuzz, with its modified combining classes and Arabic modifier marks first - Fix extra quadratic segments in TrueType contours with off-curve endpoints - [BREAKING] Browser build is now ESM-only - [BREAKING] Node builds now targets Node 22+, Browser build targets Firefox 115+, Safari 16+, Chrome 120+ diff --git a/src/opentype/shapers/ArabicShaper.js b/src/opentype/shapers/ArabicShaper.js index 3bee035c..411b9ae0 100644 --- a/src/opentype/shapers/ArabicShaper.js +++ b/src/opentype/shapers/ArabicShaper.js @@ -7,6 +7,11 @@ import { decodeBase64 } from '../../utils'; const trie = new UnicodeTrie(decodeBase64(dataTrieBase64)); const FEATURES = ['isol', 'fina', 'fin2', 'fin3', 'medi', 'med2', 'init']; +const MODIFIER_COMBINING_MARKS = new Set([ + 0x0654, 0x0655, 0x0658, 0x06dc, 0x06e3, 0x06e7, 0x06e8, 0x08ca, 0x08cb, + 0x08cd, 0x08ce, 0x08cf, 0x08d3, 0x08f3, +]); + const ShapingClasses = { Non_Joining: 0, Left_Joining: 1, @@ -120,6 +125,35 @@ export default class ArabicShaper extends DefaultShaper { plan.addStage('mset'); } + // Move modifier combining marks (hamza above or below, small high letters) + // to the start of their mark run, as UTR #53 recommends. Like HarfBuzz, give + // them a class below all Arabic marks so the run stays sorted. + // @override + static reorderMarks(characters, start, end) { + let i = start; + for (let combiningClass of [220, 230]) { + while (i < end && characters[i].combiningClass < combiningClass) i++; + if (i === end) break; + if (characters[i].combiningClass === combiningClass) { + let j = i; + while ( + j < end && + characters[j].combiningClass === combiningClass && + MODIFIER_COMBINING_MARKS.has(characters[j].codePoint) + ) { + j++; + } + let marks = characters.splice(i, j - i); + for (let mark of marks) { + mark.combiningClass = combiningClass === 220 ? 22 : 26; + } + characters.splice(start, 0, ...marks); + start += marks.length; + i = j; + } + } + } + static assignFeatures(plan, glyphs) { super.assignFeatures(plan, glyphs); diff --git a/src/opentype/shapers/DefaultShaper.js b/src/opentype/shapers/DefaultShaper.js index dd3d680b..b0a12b14 100644 --- a/src/opentype/shapers/DefaultShaper.js +++ b/src/opentype/shapers/DefaultShaper.js @@ -42,9 +42,14 @@ export default class DefaultShaper { plan.setFeatureOverrides(userFeatures); } + // @overridable + static reorderMarks(characters, start, end) { + // To be overridden in shapers that need a script-specific mark order. + } + static assignFeatures(plan, glyphs) { // Compose canonical equivalents supported by the font before GSUB. - composeGlyphs(plan.font, glyphs); + composeGlyphs(this, plan.font, glyphs); // Enable contextual fractions for (let i = 0; i < glyphs.length; i++) { @@ -76,9 +81,9 @@ export default class DefaultShaper { } } -// Normalize each base + combining-mark cluster using the font's coverage. -// Preserve the order of surviving marks for downstream GSUB lookups. -function composeGlyphs(font, glyphs) { +// Normalize each base + combining-mark cluster using the font's coverage, +// reordering its marks like HarfBuzz. +function composeGlyphs(shaper, font, glyphs) { let i = 0; while (i < glyphs.length) { // A cluster starts at a non-mark base glyph... @@ -97,7 +102,7 @@ function composeGlyphs(font, glyphs) { } let input = []; for (let j = i; j < end; j++) input.push(glyphs[j].codePoints[0]); - let composed = composeCodePoints(font, input); + let composed = composeCodePoints(shaper, font, input); if ( composed.length !== input.length || composed.some((cp, index) => cp !== input[index]) @@ -115,7 +120,7 @@ function composeGlyphs(font, glyphs) { } } -function composeCodePoints(font, codePoints) { +function composeCodePoints(shaper, font, codePoints) { // A supported singleton should retain its glyph, including characters // excluded from canonical composition whose NFC spelling is different. if (codePoints.length === 1 && font.hasGlyphForCodePoint(codePoints[0])) { @@ -134,30 +139,28 @@ function composeCodePoints(font, codePoints) { parts = recompose(font, parts); if (!parts.every(hasGlyph)) parts = [normalizationChar(cp)]; } - for (let part of parts) { - part.order = decomposed.length; - decomposed.push(part); - } + decomposed.push(...parts); } - // Canonical ordering for composition, without moving marks across a starter - // (class zero, including CGJ). Equal classes retain their relative order. - for (let i = 1; i < decomposed.length; i++) { - let current = decomposed[i]; - if (current.combiningClass === 0) continue; - let j = i; - while (j > 0 && decomposed[j - 1].combiningClass > current.combiningClass) { - decomposed[j] = decomposed[j - 1]; - j--; + // Sort each run of marks by combining class, without moving marks across a + // starter (class zero, including CGJ). The sort is stable, so equal classes + // retain their relative order: + for (let start = 0; start < decomposed.length; start++) { + if (decomposed[start].combiningClass > 0) { + let end = start + 1; + while (end < decomposed.length && decomposed[end].combiningClass > 0) { + end++; + } + let marks = decomposed + .slice(start, end) + .sort((a, b) => a.combiningClass - b.combiningClass); + decomposed.splice(start, marks.length, ...marks); + shaper.reorderMarks(decomposed, start, end); + start = end; } - decomposed[j] = current; } - // Composition may consume marks, but must not reorder the remaining ones: - // for example, Amiri's calt expects shadda before a following vowel. - return recompose(font, decomposed) - .sort((a, b) => a.order - b.order) - .map((part) => part.codePoint); + return recompose(font, decomposed).map((part) => part.codePoint); } function recompose(font, characters) { @@ -189,7 +192,8 @@ function recompose(font, characters) { return result; } -// unicode-properties exposes names; composition blocking needs numeric classes. +// unicode-properties exposes names; reordering and composition blocking need +// numeric classes. const COMBINING_CLASSES = { Not_Reordered: 0, Overlay: 1, @@ -213,10 +217,59 @@ const COMBINING_CLASSES = { Iota_Subscript: 240, }; +// HarfBuzz's modified combining classes, which order marks the way fonts +// expect: Hebrew points as in the SBL Hebrew manual, Arabic shadda before the +// vowels, Telugu length marks and Thai sara u / uu before the virama, and +// Tibetan sign u before sign i. +const MODIFIED_COMBINING_CLASSES = { + 10: 22, + 11: 15, + 12: 16, + 13: 17, + 14: 23, + 15: 18, + 16: 19, + 17: 20, + 18: 21, + 19: 14, + 20: 24, + 21: 12, + 22: 25, + 23: 13, + 24: 10, + 25: 11, + 27: 28, + 28: 29, + 29: 30, + 30: 31, + 31: 32, + 32: 33, + 33: 27, + 84: 4, + 91: 5, + 103: 3, + 130: 132, + 132: 131, +}; + +// Tai Tham sakot and Tibetan padma go after all other marks, Tibetan +// tsa -phru before the vowel signs: +const MODIFIED_COMBINING_CLASSES_BY_CODE_POINT = { + 0x1a60: 254, + 0x0fc6: 254, + 0x0f39: 127, +}; + function normalizationChar(codePoint) { let name = getCombiningClass(codePoint); let combiningClass = name.startsWith('CCC') ? Number(name.slice(3)) : COMBINING_CLASSES[name]; - return { codePoint, combiningClass }; + return { + codePoint, + combiningClass: + MODIFIED_COMBINING_CLASSES_BY_CODE_POINT[codePoint] ?? + MODIFIED_COMBINING_CLASSES[combiningClass] ?? + combiningClass, + }; } diff --git a/test/shaping.js b/test/shaping.js index 5b860154..c684a41c 100644 --- a/test/shaping.js +++ b/test/shaping.js @@ -3087,6 +3087,26 @@ describe('shaping', function () { ); }); + it('reorders marks that do not compose by combining class', function () { + // q + dot above (ccc 230) + dot below (ccc 220), as HarfBuzz orders them: + assert.deepEqual( + font.layout('q\u0307\u0323').glyphs.map((g) => g.name), + ['q', 'dotbelowcomb', 'uni0307'], + ); + }); + + it('moves Arabic modifier combining marks before other marks', function () { + // beh + fatha + hamza above: the hamza goes first (UTR #53), as in + // HarfBuzz. Glyphs are in visual order. + let amiri = fontkit.openSync( + new URL('data/amiri/amiri-regular.ttf', import.meta.url), + ); + assert.deepEqual( + amiri.layout('\u0628\u064e\u0654').glyphs.map((g) => g.name), + ['uni064E', 'uni0654', 'uni0628'], + ); + }); + it('does not compose across a blocking mark or combining grapheme joiner', function () { assert.deepEqual( shape(0x61, 0x305, 0x301), @@ -3160,11 +3180,10 @@ describe('shaping', function () { assert.deepEqual(shapeAr(0x627, 0x64b, 0x654), shapeAr(0x623, 0x64b)); }); - it('leaves a non-composing mark cluster unreordered for GSUB', function () { - // alef + shadda + fathatan don't compose, so the cluster must stay in its - // original order: canonically reordering shadda (ccc 33) after fathatan - // (ccc 27) would break Amiri's calt, which keys on shadda-before-vowel and - // yields fathatan's small variant. Applying a pure NFC reorder regresses it. + it('orders shadda before Arabic vowels', function () { + // Like HarfBuzz, shadda (ccc 33) sorts before fathatan (ccc 27), as + // Amiri's calt keys on shadda-before-vowel and yields fathatan's small + // variant. Plain canonical ordering would put fathatan first. let amiri = fontkit.openSync( new URL('data/amiri/amiri-regular.ttf', import.meta.url), ); @@ -3180,6 +3199,28 @@ describe('shaping', function () { ); }); + it('orders shadda before a vowel typed first', function () { + // Vocalized text often has the vowel typed before shadda. Allah written + // with fatha + shadda still gets Amiri's ligature and small fatha, as in + // HarfBuzz. Glyphs are in visual order. + let amiri = fontkit.openSync( + new URL('data/amiri/amiri-regular.ttf', import.meta.url), + ); + assert.deepEqual( + amiri + .layout('\u0627\u0644\u0644\u064e\u0651\u0647') + .glyphs.map((g) => g.name), + [ + 'uni0647.fina_Lellah', + 'uni064E.small', + 'uni0651', + 'uni0644.medi_Lellah', + 'uni0644.init_Lellah', + 'uni0627', + ], + ); + }); + it('preserves remaining Arabic mark order when the cluster also composes', function () { let amiri = fontkit.openSync( new URL('data/amiri/amiri-regular.ttf', import.meta.url),