Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
34 changes: 34 additions & 0 deletions src/opentype/shapers/ArabicShaper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
107 changes: 80 additions & 27 deletions src/opentype/shapers/DefaultShaper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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...
Expand All @@ -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])
Expand All @@ -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])) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}
51 changes: 46 additions & 5 deletions test/shaping.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
);
Expand All @@ -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),
Expand Down
Loading