From 9d9619829327fd5a4b3275329568d544b3863f3e Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Mon, 26 Jan 2026 09:20:09 -0800 Subject: [PATCH 01/13] Add support for chaining and context substitution tables --- src/features/featureQuery.mjs | 367 +++++++++++++++++++++++++++++++++- test/featureQuery.spec.mjs | 40 ++++ 2 files changed, 406 insertions(+), 1 deletion(-) diff --git a/src/features/featureQuery.mjs b/src/features/featureQuery.mjs index 57adf58b..528f594d 100644 --- a/src/features/featureQuery.mjs +++ b/src/features/featureQuery.mjs @@ -100,6 +100,265 @@ function lookupCoverageList(coverageList, contextParams) { return lookupList; } +/** + * Handle chaining context substitution - format 1 (glyph-based) + * @param {ContextParams} contextParams context params to lookup + * @param {object} subtable the subtable containing chain rule sets + */ +function chainingSubstitutionFormat1(contextParams, subtable) { + // Get the current glyph and check if it's in the coverage + let glyphIndex = contextParams.current; + glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; + const coverageIndex = lookupCoverage(glyphIndex, subtable.coverage); + if (coverageIndex === -1) return []; + + // Get the chain rule set for this coverage index + const chainRuleSet = subtable.chainRuleSets[coverageIndex]; + if (!chainRuleSet) return []; + + // Try each rule in the set + for (let r = 0; r < chainRuleSet.length; r++) { + const rule = chainRuleSet[r]; + + // Check backtrack glyphs (in reverse order) + let backtrackMatch = true; + const backtrackContext = [].concat(contextParams.backtrack); + backtrackContext.reverse(); + // Skip tashkeel (Arabic diacritics) + while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { + backtrackContext.shift(); + } + if (backtrackContext.length < rule.backtrack.length) { + backtrackMatch = false; + } else { + for (let b = 0; b < rule.backtrack.length; b++) { + const backGlyphIndex = backtrackContext[b]; + if (backGlyphIndex !== rule.backtrack[b]) { + backtrackMatch = false; + break; + } + } + } + if (!backtrackMatch) continue; + + // Check input glyphs (starting from second glyph, first is already matched via coverage) + let inputMatch = true; + for (let i = 0; i < rule.input.length; i++) { + const inputGlyphIndex = contextParams.lookahead[i]; + if (inputGlyphIndex === undefined || inputGlyphIndex !== rule.input[i]) { + inputMatch = false; + break; + } + } + if (!inputMatch) continue; + + // Check lookahead glyphs + let lookaheadMatch = true; + const lookaheadOffset = rule.input.length; + let lookaheadContext = contextParams.lookahead.slice(lookaheadOffset); + // Skip tashkeel in lookahead + while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { + lookaheadContext.shift(); + } + if (lookaheadContext.length < rule.lookahead.length) { + lookaheadMatch = false; + } else { + for (let l = 0; l < rule.lookahead.length; l++) { + if (lookaheadContext[l] !== rule.lookahead[l]) { + lookaheadMatch = false; + break; + } + } + } + if (!lookaheadMatch) continue; + + // All context matches! Apply the lookup records + let substitutions = []; + for (let i = 0; i < rule.lookupRecords.length; i++) { + const lookupRecord = rule.lookupRecords[i]; + const lookupListIndex = lookupRecord.lookupListIndex; + const lookupTable = this.getLookupByIndex(lookupListIndex); + + for (let s = 0; s < lookupTable.subtables.length; s++) { + let lookupSubtable = lookupTable.subtables[s]; + let lookup; + let substitutionType = this.getSubstitutionType( + lookupTable, + lookupSubtable, + ); + + if (substitutionType === '71') { + // Extension subtable + substitutionType = this.getSubstitutionType( + lookupSubtable, + lookupSubtable.extension, + ); + lookup = this.getLookupMethod( + lookupSubtable, + lookupSubtable.extension, + ); + lookupSubtable = lookupSubtable.extension; + } else { + lookup = this.getLookupMethod(lookupTable, lookupSubtable); + } + + // Get the glyph at the sequence index + const seqIndex = lookupRecord.sequenceIndex; + const targetGlyph = seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1]; + + if (substitutionType === '11' || substitutionType === '12') { + const substitution = lookup(targetGlyph); + if (substitution) substitutions.push(substitution); + } + } + } + return substitutions; + } + + return []; +} + +/** + * Handle chaining context substitution - format 2 (class-based) + * @param {ContextParams} contextParams context params to lookup + * @param {object} subtable the subtable containing class definitions + */ +function chainingSubstitutionFormat2(contextParams, subtable) { + // Get the current glyph and check if it's in the coverage + let glyphIndex = contextParams.current; + glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; + const coverageIndex = lookupCoverage(glyphIndex, subtable.coverage); + if (coverageIndex === -1) return []; + + // Get the class of the current glyph using the input class definition + const inputClass = this.font.substitution.getGlyphClass( + subtable.inputClassDef, + glyphIndex, + ); + + // Get the chain class set for this class (may be null for unused classes) + const chainClassSet = subtable.chainClassSet[inputClass]; + if (!chainClassSet) return []; + + // Try each rule in the class set + for (let r = 0; r < chainClassSet.length; r++) { + const rule = chainClassSet[r]; + + // Check backtrack classes (in reverse order) + let backtrackMatch = true; + const backtrackContext = [].concat(contextParams.backtrack); + backtrackContext.reverse(); + // Skip tashkeel (Arabic diacritics) + while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { + backtrackContext.shift(); + } + if (backtrackContext.length < rule.backtrack.length) { + backtrackMatch = false; + } else { + for (let b = 0; b < rule.backtrack.length; b++) { + const backGlyphIndex = backtrackContext[b]; + const backClass = this.font.substitution.getGlyphClass( + subtable.backtrackClassDef, + backGlyphIndex, + ); + if (backClass !== rule.backtrack[b]) { + backtrackMatch = false; + break; + } + } + } + if (!backtrackMatch) continue; + + // Check input classes (starting from second glyph, first is already matched via coverage) + let inputMatch = true; + for (let i = 0; i < rule.input.length; i++) { + const inputGlyphIndex = contextParams.lookahead[i]; + if (inputGlyphIndex === undefined) { + inputMatch = false; + break; + } + const inClass = this.font.substitution.getGlyphClass( + subtable.inputClassDef, + inputGlyphIndex, + ); + if (inClass !== rule.input[i]) { + inputMatch = false; + break; + } + } + if (!inputMatch) continue; + + // Check lookahead classes + let lookaheadMatch = true; + const lookaheadOffset = rule.input.length; + // Skip tashkeel in lookahead + let lookaheadContext = contextParams.lookahead.slice(lookaheadOffset); + while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { + lookaheadContext.shift(); + } + if (lookaheadContext.length < rule.lookahead.length) { + lookaheadMatch = false; + } else { + for (let l = 0; l < rule.lookahead.length; l++) { + const lookGlyphIndex = lookaheadContext[l]; + const lookClass = this.font.substitution.getGlyphClass( + subtable.lookaheadClassDef, + lookGlyphIndex, + ); + if (lookClass !== rule.lookahead[l]) { + lookaheadMatch = false; + break; + } + } + } + if (!lookaheadMatch) continue; + + // All context matches! Apply the lookup records + let substitutions = []; + for (let i = 0; i < rule.lookupRecords.length; i++) { + const lookupRecord = rule.lookupRecords[i]; + const lookupListIndex = lookupRecord.lookupListIndex; + const lookupTable = this.getLookupByIndex(lookupListIndex); + + for (let s = 0; s < lookupTable.subtables.length; s++) { + let lookupSubtable = lookupTable.subtables[s]; + let lookup; + let substitutionType = this.getSubstitutionType( + lookupTable, + lookupSubtable, + ); + + if (substitutionType === '71') { + // Extension subtable + substitutionType = this.getSubstitutionType( + lookupSubtable, + lookupSubtable.extension, + ); + lookup = this.getLookupMethod( + lookupSubtable, + lookupSubtable.extension, + ); + lookupSubtable = lookupSubtable.extension; + } else { + lookup = this.getLookupMethod(lookupTable, lookupSubtable); + } + + // Get the glyph at the sequence index + const seqIndex = lookupRecord.sequenceIndex; + const targetGlyph = seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1]; + + if (substitutionType === '11' || substitutionType === '12') { + const substitution = lookup(targetGlyph); + if (substitution) substitutions.push(substitution); + } + } + } + return substitutions; + } + + return []; +} + /** * Handle chaining context substitution - format 3 * @param {ContextParams} contextParams context params to lookup @@ -252,6 +511,97 @@ function contextSubstitutionFormat1(contextParams, subtable) { return null; } +/** + * Handle context substitution - format 2 (class-based) + * @param {ContextParams} contextParams context params to lookup + * @param {object} subtable the subtable containing class definitions + */ +function contextSubstitutionFormat2(contextParams, subtable) { + // Get the current glyph and check if it's in the coverage + let glyphIndex = contextParams.current; + glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; + const coverageIndex = lookupCoverage(glyphIndex, subtable.coverage); + if (coverageIndex === -1) return []; + + // Get the class of the current glyph using the class definition + const inputClass = this.font.substitution.getGlyphClass( + subtable.classDef, + glyphIndex, + ); + + // Get the class set for this class (may be null for unused classes) + const classSet = subtable.classSets[inputClass]; + if (!classSet) return []; + + // Try each rule in the class set + for (let r = 0; r < classSet.length; r++) { + const rule = classSet[r]; + + // Check input classes (starting from second glyph, first is already matched via coverage) + let inputMatch = true; + for (let i = 0; i < rule.classes.length; i++) { + const inputGlyphIndex = contextParams.lookahead[i]; + if (inputGlyphIndex === undefined) { + inputMatch = false; + break; + } + const inClass = this.font.substitution.getGlyphClass( + subtable.classDef, + inputGlyphIndex, + ); + if (inClass !== rule.classes[i]) { + inputMatch = false; + break; + } + } + if (!inputMatch) continue; + + // All context matches! Apply the lookup records + let substitutions = []; + for (let i = 0; i < rule.lookupRecords.length; i++) { + const lookupRecord = rule.lookupRecords[i]; + const lookupListIndex = lookupRecord.lookupListIndex; + const lookupTable = this.getLookupByIndex(lookupListIndex); + + for (let s = 0; s < lookupTable.subtables.length; s++) { + let lookupSubtable = lookupTable.subtables[s]; + let lookup; + let substitutionType = this.getSubstitutionType( + lookupTable, + lookupSubtable, + ); + + if (substitutionType === '71') { + // Extension subtable + substitutionType = this.getSubstitutionType( + lookupSubtable, + lookupSubtable.extension, + ); + lookup = this.getLookupMethod( + lookupSubtable, + lookupSubtable.extension, + ); + lookupSubtable = lookupSubtable.extension; + } else { + lookup = this.getLookupMethod(lookupTable, lookupSubtable); + } + + // Get the glyph at the sequence index + const seqIndex = lookupRecord.sequenceIndex; + const targetGlyph = seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1]; + + if (substitutionType === '11' || substitutionType === '12') { + const substitution = lookup(targetGlyph); + if (substitution) substitutions.push(substitution); + } + } + } + return substitutions; + } + + return []; +} + /** * Handle context substitution - format 3 * @param {ContextParams} contextParams context params to lookup @@ -413,6 +763,14 @@ FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) { return glyphIndex => singleSubstitutionFormat2.apply( this, [glyphIndex, subtable] ); + case '61': + return contextParams => chainingSubstitutionFormat1.apply( + this, [contextParams, subtable] + ); + case '62': + return contextParams => chainingSubstitutionFormat2.apply( + this, [contextParams, subtable] + ); case '63': return contextParams => chainingSubstitutionFormat3.apply( this, [contextParams, subtable] @@ -429,6 +787,10 @@ FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) { return contextParams => contextSubstitutionFormat1.apply( this, [contextParams, subtable] ); + case '52': + return contextParams => contextSubstitutionFormat2.apply( + this, [contextParams, subtable] + ); case '53': return contextParams => contextSubstitutionFormat3.apply( this, [contextParams, subtable] @@ -518,11 +880,13 @@ FeatureQuery.prototype.lookupFeature = function (query) { })); } break; + case '61': + case '62': case '63': substitution = lookup(contextParams); if (Array.isArray(substitution) && substitution.length) { substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: 63, tag: query.tag, substitution + id: parseInt(substType), tag: query.tag, substitution })); } break; @@ -543,6 +907,7 @@ FeatureQuery.prototype.lookupFeature = function (query) { } break; case '51': + case '52': case '53': substitution = lookup(contextParams); if (Array.isArray(substitution) && substitution.length) { diff --git a/test/featureQuery.spec.mjs b/test/featureQuery.spec.mjs index 0ad952a3..c9f35f80 100644 --- a/test/featureQuery.spec.mjs +++ b/test/featureQuery.spec.mjs @@ -183,5 +183,45 @@ describe('featureQuery.mjs', function() { const substitution = lookup(271); assert.deepEqual(substitution, [273, 1087]); }); + it('should route context substitution format 2 (52) correctly', function () { + // This test verifies that case '52' is registered in getLookupMethod switch + // Without this case, getLookupMethod would throw an error for format 2 context substitutions + const featureQuery = query.arabic; + + // Mock a lookup table and subtable with substitution type 52 + const mockLookupTable = { lookupType: 5 }; // Context substitution type 5 + const mockSubtable = { substFormat: 2 }; // Format 2 (class-based) + + // This should not throw an error if case '52' is registered + try { + const substitutionType = featureQuery.getSubstitutionType(mockLookupTable, mockSubtable); + // Type 5 format 2 should be '52' + assert.equal(substitutionType, '52'); + } catch (e) { + assert.fail('getLookupMethod should handle case "52" for context substitution format 2'); + } + }); + it('should route chaining context substitution format 2 (62) correctly', function () { + // This test verifies that case '62' is registered in getLookupMethod switch + // Without this case, getLookupMethod would throw an error for format 2 chaining context substitutions + const featureQuery = query.arabic; + + // Mock a lookup table and subtable with substitution type 62 + const mockLookupTable = { lookupType: 6 }; // Chaining context substitution type 6 + const mockSubtable = { substFormat: 2 }; // Format 2 (class-based) + + // This should not throw an error if case '62' is registered + try { + const substitutionType = featureQuery.getSubstitutionType(mockLookupTable, mockSubtable); + // Type 6 format 2 should be '62' + assert.equal(substitutionType, '62'); + + // Verify getLookupMethod recognizes the type without throwing + const lookup = featureQuery.getLookupMethod(mockLookupTable, mockSubtable); + assert.ok(typeof lookup === 'function', 'getLookupMethod should return a function for case 62'); + } catch (e) { + assert.fail(`getLookupMethod should handle case "62" for chaining context substitution format 2: ${e.message}`); + } + }); }); }); From aa414dbaf517dbbf42489d4f309330cb19a6640f Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Mon, 26 Jan 2026 14:05:09 -0800 Subject: [PATCH 02/13] Fix ligature width scaling, make code more concise --- src/features/applySubstitution.mjs | 3 + src/features/featureQuery.mjs | 402 +++++++++++------------------ src/variationprocessor.mjs | 39 ++- test/applySubstitution.spec.mjs | 27 ++ test/featureQuery.spec.mjs | 98 +++++++ test/variation.spec.mjs | 62 +++++ 6 files changed, 377 insertions(+), 254 deletions(-) create mode 100644 test/applySubstitution.spec.mjs diff --git a/src/features/applySubstitution.mjs b/src/features/applySubstitution.mjs index 3b6f713a..3bfcf61e 100644 --- a/src/features/applySubstitution.mjs +++ b/src/features/applySubstitution.mjs @@ -65,9 +65,12 @@ function ligatureSubstitutionFormat1(action, tokens, index) { const SUBSTITUTIONS = { 11: singleSubstitutionFormat1, 12: singleSubstitutionFormat2, + 61: chainingSubstitutionFormat3, + 62: chainingSubstitutionFormat3, 63: chainingSubstitutionFormat3, 41: ligatureSubstitutionFormat1, 51: chainingSubstitutionFormat3, + 52: chainingSubstitutionFormat3, 53: chainingSubstitutionFormat3 }; diff --git a/src/features/featureQuery.mjs b/src/features/featureQuery.mjs index 528f594d..008d2b06 100644 --- a/src/features/featureQuery.mjs +++ b/src/features/featureQuery.mjs @@ -100,119 +100,149 @@ function lookupCoverageList(coverageList, contextParams) { return lookupList; } +/** + * Prepare backtrack context by reversing and skipping tashkeel + * @param {ContextParams} contextParams context params + * @returns {Array} prepared backtrack context + */ +function prepareBacktrackContext(contextParams) { + const backtrackContext = [].concat(contextParams.backtrack); + backtrackContext.reverse(); + while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { + backtrackContext.shift(); + } + return backtrackContext; +} + +/** + * Prepare lookahead context by slicing from offset and skipping tashkeel + * @param {ContextParams} contextParams context params + * @param {number} offset starting offset + * @returns {Array} prepared lookahead context + */ +function prepareLookaheadContext(contextParams, offset) { + const lookaheadContext = contextParams.lookahead.slice(offset); + while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { + lookaheadContext.shift(); + } + return lookaheadContext; +} + +/** + * Match a sequence against expected values using a value getter function + * @param {Array} context the context to match against + * @param {Array} expected expected values to match + * @param {Function} getValue function to get comparison value from context item (returns the item by default) + * @returns {boolean} true if sequence matches + */ +function matchSequence(context, expected, getValue = (item) => item) { + if (context.length < expected.length) return false; + for (let i = 0; i < expected.length; i++) { + if (getValue(context[i]) !== expected[i]) return false; + } + return true; +} + +/** + * Get the current glyph index from context params + * @param {ContextParams} contextParams context params + */ +function getCurrentGlyphIndex(contextParams) { + let glyphIndex = contextParams.current; + return Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; +} + +/** + * Apply lookup records and return substitutions (shared by all chaining formats) + * @param {Array} lookupRecords the lookup records to apply + * @param {number} glyphIndex the current glyph index + * @param {ContextParams} contextParams context params + * @param {object} options optional handlers + * @returns {Array} substitutions + */ +function applyLookupRecords(lookupRecords, glyphIndex, contextParams, options = {}) { + const getTargets = options.getTargets || ( + (lookupRecord, glyphIndex, contextParams) => { + const seqIndex = lookupRecord.sequenceIndex; + const targetGlyph = ( + seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1] + ); + return [targetGlyph]; + } + ); + const allowedTypes = options.allowedTypes || ['11', '12']; + const throwOnUnsupported = options.throwOnUnsupported || false; + const substitutions = []; + for (let i = 0; i < lookupRecords.length; i++) { + const lookupRecord = lookupRecords[i]; + const lookupListIndex = lookupRecord.lookupListIndex; + const lookupTable = this.getLookupByIndex(lookupListIndex); + + for (let s = 0; s < lookupTable.subtables.length; s++) { + let lookupSubtable = lookupTable.subtables[s]; + let lookup; + let substitutionType = this.getSubstitutionType(lookupTable, lookupSubtable); + + if (substitutionType === '71') { + // Extension subtable + substitutionType = this.getSubstitutionType(lookupSubtable, lookupSubtable.extension); + lookup = this.getLookupMethod(lookupSubtable, lookupSubtable.extension); + lookupSubtable = lookupSubtable.extension; + } else { + lookup = this.getLookupMethod(lookupTable, lookupSubtable); + } + + if (allowedTypes.indexOf(substitutionType) === -1) { + if (throwOnUnsupported) { + throw new Error( + `Substitution type ${substitutionType} is not supported in chaining substitution`, + ); + } + continue; + } + + const targets = getTargets(lookupRecord, glyphIndex, contextParams); + if (!targets || !targets.length) continue; + + for (let t = 0; t < targets.length; t++) { + const substitution = lookup(targets[t]); + if (substitution) substitutions.push(substitution); + } + } + } + return substitutions; +} + /** * Handle chaining context substitution - format 1 (glyph-based) * @param {ContextParams} contextParams context params to lookup * @param {object} subtable the subtable containing chain rule sets */ function chainingSubstitutionFormat1(contextParams, subtable) { - // Get the current glyph and check if it's in the coverage - let glyphIndex = contextParams.current; - glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; + const glyphIndex = getCurrentGlyphIndex(contextParams); const coverageIndex = lookupCoverage(glyphIndex, subtable.coverage); if (coverageIndex === -1) return []; - // Get the chain rule set for this coverage index const chainRuleSet = subtable.chainRuleSets[coverageIndex]; if (!chainRuleSet) return []; - // Try each rule in the set + const backtrackContext = prepareBacktrackContext(contextParams); + for (let r = 0; r < chainRuleSet.length; r++) { const rule = chainRuleSet[r]; - // Check backtrack glyphs (in reverse order) - let backtrackMatch = true; - const backtrackContext = [].concat(contextParams.backtrack); - backtrackContext.reverse(); - // Skip tashkeel (Arabic diacritics) - while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { - backtrackContext.shift(); - } - if (backtrackContext.length < rule.backtrack.length) { - backtrackMatch = false; - } else { - for (let b = 0; b < rule.backtrack.length; b++) { - const backGlyphIndex = backtrackContext[b]; - if (backGlyphIndex !== rule.backtrack[b]) { - backtrackMatch = false; - break; - } - } - } - if (!backtrackMatch) continue; + // Check backtrack glyphs + if (!matchSequence(backtrackContext, rule.backtrack)) continue; - // Check input glyphs (starting from second glyph, first is already matched via coverage) - let inputMatch = true; - for (let i = 0; i < rule.input.length; i++) { - const inputGlyphIndex = contextParams.lookahead[i]; - if (inputGlyphIndex === undefined || inputGlyphIndex !== rule.input[i]) { - inputMatch = false; - break; - } - } - if (!inputMatch) continue; + // Check input glyphs (starting from second glyph) + if (!matchSequence(contextParams.lookahead, rule.input)) continue; // Check lookahead glyphs - let lookaheadMatch = true; - const lookaheadOffset = rule.input.length; - let lookaheadContext = contextParams.lookahead.slice(lookaheadOffset); - // Skip tashkeel in lookahead - while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { - lookaheadContext.shift(); - } - if (lookaheadContext.length < rule.lookahead.length) { - lookaheadMatch = false; - } else { - for (let l = 0; l < rule.lookahead.length; l++) { - if (lookaheadContext[l] !== rule.lookahead[l]) { - lookaheadMatch = false; - break; - } - } - } - if (!lookaheadMatch) continue; + const lookaheadContext = prepareLookaheadContext(contextParams, rule.input.length); + if (!matchSequence(lookaheadContext, rule.lookahead)) continue; // All context matches! Apply the lookup records - let substitutions = []; - for (let i = 0; i < rule.lookupRecords.length; i++) { - const lookupRecord = rule.lookupRecords[i]; - const lookupListIndex = lookupRecord.lookupListIndex; - const lookupTable = this.getLookupByIndex(lookupListIndex); - - for (let s = 0; s < lookupTable.subtables.length; s++) { - let lookupSubtable = lookupTable.subtables[s]; - let lookup; - let substitutionType = this.getSubstitutionType( - lookupTable, - lookupSubtable, - ); - - if (substitutionType === '71') { - // Extension subtable - substitutionType = this.getSubstitutionType( - lookupSubtable, - lookupSubtable.extension, - ); - lookup = this.getLookupMethod( - lookupSubtable, - lookupSubtable.extension, - ); - lookupSubtable = lookupSubtable.extension; - } else { - lookup = this.getLookupMethod(lookupTable, lookupSubtable); - } - - // Get the glyph at the sequence index - const seqIndex = lookupRecord.sequenceIndex; - const targetGlyph = seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1]; - - if (substitutionType === '11' || substitutionType === '12') { - const substitution = lookup(targetGlyph); - if (substitution) substitutions.push(substitution); - } - } - } - return substitutions; + return applyLookupRecords.call(this, rule.lookupRecords, glyphIndex, contextParams); } return []; @@ -224,136 +254,34 @@ function chainingSubstitutionFormat1(contextParams, subtable) { * @param {object} subtable the subtable containing class definitions */ function chainingSubstitutionFormat2(contextParams, subtable) { - // Get the current glyph and check if it's in the coverage - let glyphIndex = contextParams.current; - glyphIndex = Array.isArray(glyphIndex) ? glyphIndex[0] : glyphIndex; + const glyphIndex = getCurrentGlyphIndex(contextParams); const coverageIndex = lookupCoverage(glyphIndex, subtable.coverage); if (coverageIndex === -1) return []; - // Get the class of the current glyph using the input class definition - const inputClass = this.font.substitution.getGlyphClass( - subtable.inputClassDef, - glyphIndex, - ); - - // Get the chain class set for this class (may be null for unused classes) + const inputClass = this.font.substitution.getGlyphClass(subtable.inputClassDef, glyphIndex); const chainClassSet = subtable.chainClassSet[inputClass]; if (!chainClassSet) return []; - // Try each rule in the class set + const backtrackContext = prepareBacktrackContext(contextParams); + const getBacktrackClass = (glyph) => this.font.substitution.getGlyphClass(subtable.backtrackClassDef, glyph); + const getInputClass = (glyph) => this.font.substitution.getGlyphClass(subtable.inputClassDef, glyph); + const getLookaheadClass = (glyph) => this.font.substitution.getGlyphClass(subtable.lookaheadClassDef, glyph); + for (let r = 0; r < chainClassSet.length; r++) { const rule = chainClassSet[r]; - // Check backtrack classes (in reverse order) - let backtrackMatch = true; - const backtrackContext = [].concat(contextParams.backtrack); - backtrackContext.reverse(); - // Skip tashkeel (Arabic diacritics) - while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { - backtrackContext.shift(); - } - if (backtrackContext.length < rule.backtrack.length) { - backtrackMatch = false; - } else { - for (let b = 0; b < rule.backtrack.length; b++) { - const backGlyphIndex = backtrackContext[b]; - const backClass = this.font.substitution.getGlyphClass( - subtable.backtrackClassDef, - backGlyphIndex, - ); - if (backClass !== rule.backtrack[b]) { - backtrackMatch = false; - break; - } - } - } - if (!backtrackMatch) continue; + // Check backtrack classes + if (!matchSequence(backtrackContext, rule.backtrack, getBacktrackClass)) continue; - // Check input classes (starting from second glyph, first is already matched via coverage) - let inputMatch = true; - for (let i = 0; i < rule.input.length; i++) { - const inputGlyphIndex = contextParams.lookahead[i]; - if (inputGlyphIndex === undefined) { - inputMatch = false; - break; - } - const inClass = this.font.substitution.getGlyphClass( - subtable.inputClassDef, - inputGlyphIndex, - ); - if (inClass !== rule.input[i]) { - inputMatch = false; - break; - } - } - if (!inputMatch) continue; + // Check input classes (starting from second glyph) + if (!matchSequence(contextParams.lookahead, rule.input, getInputClass)) continue; // Check lookahead classes - let lookaheadMatch = true; - const lookaheadOffset = rule.input.length; - // Skip tashkeel in lookahead - let lookaheadContext = contextParams.lookahead.slice(lookaheadOffset); - while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { - lookaheadContext.shift(); - } - if (lookaheadContext.length < rule.lookahead.length) { - lookaheadMatch = false; - } else { - for (let l = 0; l < rule.lookahead.length; l++) { - const lookGlyphIndex = lookaheadContext[l]; - const lookClass = this.font.substitution.getGlyphClass( - subtable.lookaheadClassDef, - lookGlyphIndex, - ); - if (lookClass !== rule.lookahead[l]) { - lookaheadMatch = false; - break; - } - } - } - if (!lookaheadMatch) continue; + const lookaheadContext = prepareLookaheadContext(contextParams, rule.input.length); + if (!matchSequence(lookaheadContext, rule.lookahead, getLookaheadClass)) continue; // All context matches! Apply the lookup records - let substitutions = []; - for (let i = 0; i < rule.lookupRecords.length; i++) { - const lookupRecord = rule.lookupRecords[i]; - const lookupListIndex = lookupRecord.lookupListIndex; - const lookupTable = this.getLookupByIndex(lookupListIndex); - - for (let s = 0; s < lookupTable.subtables.length; s++) { - let lookupSubtable = lookupTable.subtables[s]; - let lookup; - let substitutionType = this.getSubstitutionType( - lookupTable, - lookupSubtable, - ); - - if (substitutionType === '71') { - // Extension subtable - substitutionType = this.getSubstitutionType( - lookupSubtable, - lookupSubtable.extension, - ); - lookup = this.getLookupMethod( - lookupSubtable, - lookupSubtable.extension, - ); - lookupSubtable = lookupSubtable.extension; - } else { - lookup = this.getLookupMethod(lookupTable, lookupSubtable); - } - - // Get the glyph at the sequence index - const seqIndex = lookupRecord.sequenceIndex; - const targetGlyph = seqIndex === 0 ? glyphIndex : contextParams.lookahead[seqIndex - 1]; - - if (substitutionType === '11' || substitutionType === '12') { - const substitution = lookup(targetGlyph); - if (substitution) substitutions.push(substitution); - } - } - } - return substitutions; + return applyLookupRecords.call(this, rule.lookupRecords, glyphIndex, contextParams); } return []; @@ -378,20 +306,13 @@ function chainingSubstitutionFormat3(contextParams, subtable) { // LOOKAHEAD LOOKUP // const lookaheadOffset = subtable.inputCoverage.length - 1; if (contextParams.lookahead.length < subtable.lookaheadCoverage.length) return []; - let lookaheadContext = contextParams.lookahead.slice(lookaheadOffset); - while (lookaheadContext.length && isTashkeelArabicChar(lookaheadContext[0].char)) { - lookaheadContext.shift(); - } + const lookaheadContext = prepareLookaheadContext(contextParams, lookaheadOffset); const lookaheadParams = new ContextParams(lookaheadContext, 0); let lookaheadLookups = lookupCoverageList( subtable.lookaheadCoverage, lookaheadParams ); // BACKTRACK LOOKUP // - let backtrackContext = [].concat(contextParams.backtrack); - backtrackContext.reverse(); - while (backtrackContext.length && isTashkeelArabicChar(backtrackContext[0].char)) { - backtrackContext.shift(); - } + const backtrackContext = prepareBacktrackContext(contextParams); if (backtrackContext.length < subtable.backtrackCoverage.length) return []; const backtrackParams = new ContextParams(backtrackContext, 0); let backtrackLookups = lookupCoverageList( @@ -402,41 +323,18 @@ function chainingSubstitutionFormat3(contextParams, subtable) { lookaheadLookups.length === subtable.lookaheadCoverage.length && backtrackLookups.length === subtable.backtrackCoverage.length ); - let substitutions = []; - if (contextRulesMatch) { - for (let i = 0; i < subtable.lookupRecords.length; i++) { - const lookupRecord = subtable.lookupRecords[i]; - const lookupListIndex = lookupRecord.lookupListIndex; - const lookupTable = this.getLookupByIndex(lookupListIndex); - for (let s = 0; s < lookupTable.subtables.length; s++) { - let subtable = lookupTable.subtables[s]; - let lookup; - let substitutionType = this.getSubstitutionType(lookupTable, subtable); - - if (substitutionType === '71') { - // This is an extension subtable, so lookup the target subtable - substitutionType = this.getSubstitutionType(subtable, subtable.extension); - lookup = this.getLookupMethod(subtable, subtable.extension); - subtable = subtable.extension; - } else { - lookup = this.getLookupMethod(lookupTable, subtable); - } - - if (substitutionType === '12') { - const glyphIndex = contextParams.get(lookupRecord.sequenceIndex); - const substitution = lookup(glyphIndex); - if (substitution) substitutions.push(substitution); - } else if (substitutionType === '21') { - const glyphIndex = contextParams.get(lookupRecord.sequenceIndex); - const substitution = lookup(glyphIndex); - if (substitution) substitutions.push(substitution); - } else { - throw new Error(`Substitution type ${substitutionType} is not supported in chaining substitution`); - } + if (!contextRulesMatch) return []; + return applyLookupRecords.call(this, subtable.lookupRecords, null, contextParams, { + allowedTypes: ['12', '21'], + throwOnUnsupported: true, + getTargets: () => { + let targets = []; + for (let n = 0; n < inputLookups.length; n++) { + targets.push(contextParams.get(n)); } - } - } - return substitutions; + return targets; + }, + }); } /** diff --git a/src/variationprocessor.mjs b/src/variationprocessor.mjs index 639d8469..76760cbc 100644 --- a/src/variationprocessor.mjs +++ b/src/variationprocessor.mjs @@ -394,12 +394,47 @@ export class VariationProcessor { } } + // Composite glyphs need component positions adjusted by per-component HVAR deltas. + // TODO: add VVAR support for vertical advances when available. + if (glyph.isComposite && glyph.components && glyph.components.length && transformedGlyph.points) { + const componentInfos = []; + let pointOffset = 0; + for (let c = 0; c < glyph.components.length; c++) { + const component = glyph.components[c]; + const componentGlyph = this.font.glyphs.get(component.glyphIndex); + componentGlyph.getPath(); + const pointCount = componentGlyph.points.length; + const deltaAdvance = this.font.tables.hvar ? + this.getVariableAdjustment(componentGlyph.index, 'hvar', 'advanceWidth', coords) : + 0; + componentInfos.push({ + pointOffset, + pointCount, + deltaAdvanceWidth: deltaAdvance + }); + pointOffset += pointCount; + } + let cumulativeShift = 0; + const shiftedPoints = transformedGlyph.points.map(copyPoint); + for (let i = 0; i < componentInfos.length; i++) { + const info = componentInfos[i]; + // Each component shifts by the sum of prior component width deltas. + for (let p = info.pointOffset; p < info.pointOffset + info.pointCount; p++) { + shiftedPoints[p].x = Math.round(shiftedPoints[p].x + cumulativeShift); + } + cumulativeShift += info.deltaAdvanceWidth; + } + transformedGlyph = new Glyph(Object.assign({}, transformedGlyph, {points: shiftedPoints, path: getPath(shiftedPoints)})); + } + if(this.font.tables.hvar) { glyph._advanceWidth = typeof glyph._advanceWidth !== 'undefined' ? glyph._advanceWidth: glyph.advanceWidth; - glyph.advanceWidth = transformedGlyph.advanceWidth = Math.round(glyph._advanceWidth + this.getVariableAdjustment(transformedGlyph.index, 'hvar', 'advanceWidth', coords)); + const advanceDelta = this.getVariableAdjustment(transformedGlyph.index, 'hvar', 'advanceWidth', coords); + glyph.advanceWidth = transformedGlyph.advanceWidth = Math.round(glyph._advanceWidth + advanceDelta); glyph._leftSideBearing = typeof glyph._leftSideBearing !== 'undefined' ? glyph._leftSideBearing: glyph.leftSideBearing; - glyph.leftSideBearing = transformedGlyph.leftSideBearing = Math.round(glyph._leftSideBearing + this.getVariableAdjustment(transformedGlyph.index, 'hvar', 'lsb', coords)); + const lsbDelta = this.getVariableAdjustment(transformedGlyph.index, 'hvar', 'lsb', coords); + glyph.leftSideBearing = transformedGlyph.leftSideBearing = Math.round(glyph._leftSideBearing + lsbDelta); } return transformedGlyph; diff --git a/test/applySubstitution.spec.mjs b/test/applySubstitution.spec.mjs new file mode 100644 index 00000000..0f5e1845 --- /dev/null +++ b/test/applySubstitution.spec.mjs @@ -0,0 +1,27 @@ +import assert from 'assert'; +import applySubstitution from '../src/features/applySubstitution.mjs'; +import { SubstitutionAction } from '../src/features/featureQuery.mjs'; +import { Token } from '../src/tokenizer.mjs'; + +describe('applySubstitution.mjs', function() { + it('should apply chaining substitutions for ids 61, 62, and 52', function() { + const tokens = [new Token('a'), new Token('b'), new Token('c')]; + const substitutions = [200, [201], []]; + const tag = 'test'; + const actionIds = [61, 62, 52]; + + for (let i = 0; i < actionIds.length; i++) { + const action = new SubstitutionAction({ + id: actionIds[i], + tag, + substitution: substitutions + }); + + applySubstitution(action, tokens, 0); + + assert.equal(tokens[0].getState(tag), 200); + assert.equal(tokens[1].getState(tag), 201); + assert.equal(tokens[2].getState('deleted'), true); + } + }); +}); diff --git a/test/featureQuery.spec.mjs b/test/featureQuery.spec.mjs index c9f35f80..427f2085 100644 --- a/test/featureQuery.spec.mjs +++ b/test/featureQuery.spec.mjs @@ -3,8 +3,32 @@ import { parse } from '../src/opentype.mjs'; import FeatureQuery from '../src/features/featureQuery.mjs'; import { ContextParams } from '../src/tokenizer.mjs'; import { readFileSync } from 'fs'; + const loadSync = (url, opt) => parse(readFileSync(url), opt); +const getGlyphClass = function(classDefTable, glyphIndex) { + switch (classDefTable.format) { + case 1: { + if (classDefTable.startGlyph <= glyphIndex && + glyphIndex < classDefTable.startGlyph + classDefTable.classes.length) { + return classDefTable.classes[glyphIndex - classDefTable.startGlyph]; + } + return 0; + } + case 2: { + const ranges = classDefTable.ranges; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + if (glyphIndex >= range.start && glyphIndex <= range.end) { + return range.classId; + } + } + return 0; + } + } + return 0; +}; + describe('featureQuery.mjs', function() { let arabicFont; let arabicFontChanga; @@ -223,5 +247,79 @@ describe('featureQuery.mjs', function() { assert.fail(`getLookupMethod should handle case "62" for chaining context substitution format 2: ${e.message}`); } }); + it('should apply chaining context substitution format 1 (61) using lookup records', function () { + const singleSubtable = { + substFormat: 1, + coverage: { format: 1, glyphs: [10] }, + deltaGlyphId: 1 + }; + const singleLookupTable = { + lookupType: 1, + subtables: [singleSubtable] + }; + const chainingSubtable = { + substFormat: 1, + coverage: { format: 1, glyphs: [10] }, + chainRuleSets: [[{ + backtrack: [1], + input: [20, 21], + lookahead: [30], + lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 0 }] + }]] + }; + const chainingLookupTable = { lookupType: 6, subtables: [chainingSubtable] }; + const mockFont = { + tables: { gsub: { lookups: [singleLookupTable] } }, + substitution: { getGlyphClass } + }; + const featureQuery = new FeatureQuery(mockFont); + const lookup = featureQuery.getLookupMethod(chainingLookupTable, chainingSubtable); + const contextParams = new ContextParams([1, 10, 20, 21, 30], 1); + const substitutions = lookup(contextParams); + assert.deepEqual(substitutions, [11]); + }); + it('should apply chaining context substitution format 2 (62) using lookup records', function () { + const singleSubtable = { + substFormat: 1, + coverage: { format: 1, glyphs: [10] }, + deltaGlyphId: 5 + }; + const singleLookupTable = { + lookupType: 1, + subtables: [singleSubtable] + }; + const chainingSubtable = { + substFormat: 2, + coverage: { format: 1, glyphs: [10] }, + backtrackClassDef: { format: 2, ranges: [{ start: 1, end: 1, classId: 2 }] }, + inputClassDef: { + format: 2, + ranges: [ + { start: 10, end: 10, classId: 1 }, + { start: 20, end: 20, classId: 3 } + ] + }, + lookaheadClassDef: { format: 2, ranges: [{ start: 30, end: 30, classId: 4 }] }, + chainClassSet: [ + null, + [{ + backtrack: [2], + input: [3], + lookahead: [4], + lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 0 }] + }] + ] + }; + const chainingLookupTable = { lookupType: 6, subtables: [chainingSubtable] }; + const mockFont = { + tables: { gsub: { lookups: [singleLookupTable] } }, + substitution: { getGlyphClass } + }; + const featureQuery = new FeatureQuery(mockFont); + const lookup = featureQuery.getLookupMethod(chainingLookupTable, chainingSubtable); + const contextParams = new ContextParams([1, 10, 20, 30], 1); + const substitutions = lookup(contextParams); + assert.deepEqual(substitutions, [15]); + }); }); }); diff --git a/test/variation.spec.mjs b/test/variation.spec.mjs index 562c1ef2..178b8865 100644 --- a/test/variation.spec.mjs +++ b/test/variation.spec.mjs @@ -1,5 +1,6 @@ import assert from 'assert'; import { parse } from '../src/opentype.mjs'; +import { VariationProcessor } from '../src/variationprocessor.mjs'; import { readFileSync } from 'fs'; import exp from 'constants'; const loadSync = (url, opt) => parse(readFileSync(url), opt); @@ -131,6 +132,67 @@ describe('variation.mjs', function() { }); describe('hvar', function() { + it('shifts composite component points by cumulative advance deltas', function() { + const component1 = { + index: 1, + advanceWidth: 100, + points: [{x: 10, y: 0, onCurve: true, lastPointOfContour: true}], + getPath: () => ({commands: []}) + }; + const component2 = { + index: 2, + advanceWidth: 100, + points: [{x: 110, y: 0, onCurve: true, lastPointOfContour: true}], + getPath: () => ({commands: []}) + }; + const component3 = { + index: 3, + advanceWidth: 100, + points: [{x: 210, y: 0, onCurve: true, lastPointOfContour: true}], + getPath: () => ({commands: []}) + }; + const composite = { + index: 10, + isComposite: true, + components: [ + {glyphIndex: 1}, + {glyphIndex: 2}, + {glyphIndex: 3} + ], + points: [ + component1.points[0], + component2.points[0], + component3.points[0] + ], + advanceWidth: 300, + leftSideBearing: 0 + }; + + const glyphMap = { + 1: component1, + 2: component2, + 3: component3, + 10: composite + }; + + const font = { + glyphs: { get: (id) => glyphMap[id] }, + tables: { hvar: {} }, + variation: { get: () => ({}) }, + defaultRenderOptions: { variation: {} } + }; + + const processor = new VariationProcessor(font); + // Fake HVAR per-component advance deltas to validate cumulative shifts. + processor.getVariableAdjustment = (gid, tableName, parameter) => { + if (tableName !== 'hvar' || parameter !== 'advanceWidth') return 0; + return {1: -10, 2: -20, 3: -30}[gid] || 0; + }; + + const transformed = processor.getTransform(composite, {}); + assert.deepEqual(transformed.points.map((pt) => pt.x), [10, 100, 180]); + }); + it('transforms advanceWidth', function() { let font = fonts.hvar; let glyphPos = []; From dfcc31c43a3ddcee9b46fc6b02b2e85f0887c142 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 12 Mar 2026 18:52:27 -0700 Subject: [PATCH 03/13] Remove the added conditional on throwing an error for unsupported substitution types. --- src/features/featureQuery.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/features/featureQuery.mjs b/src/features/featureQuery.mjs index 008d2b06..9e0cc2a0 100644 --- a/src/features/featureQuery.mjs +++ b/src/features/featureQuery.mjs @@ -171,7 +171,6 @@ function applyLookupRecords(lookupRecords, glyphIndex, contextParams, options = } ); const allowedTypes = options.allowedTypes || ['11', '12']; - const throwOnUnsupported = options.throwOnUnsupported || false; const substitutions = []; for (let i = 0; i < lookupRecords.length; i++) { const lookupRecord = lookupRecords[i]; @@ -193,12 +192,9 @@ function applyLookupRecords(lookupRecords, glyphIndex, contextParams, options = } if (allowedTypes.indexOf(substitutionType) === -1) { - if (throwOnUnsupported) { - throw new Error( - `Substitution type ${substitutionType} is not supported in chaining substitution`, - ); - } - continue; + throw new Error( + `Substitution type ${substitutionType} is not supported in chaining substitution`, + ); } const targets = getTargets(lookupRecord, glyphIndex, contextParams); @@ -326,7 +322,6 @@ function chainingSubstitutionFormat3(contextParams, subtable) { if (!contextRulesMatch) return []; return applyLookupRecords.call(this, subtable.lookupRecords, null, contextParams, { allowedTypes: ['12', '21'], - throwOnUnsupported: true, getTargets: () => { let targets = []; for (let n = 0; n < inputLookups.length; n++) { From 4dae836019c092d54191eeb0c3ecb4787867c9ca Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Fri, 13 Mar 2026 10:48:41 -0700 Subject: [PATCH 04/13] Avoid unnecessary call to .getPath(), add comment --- src/variationprocessor.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/variationprocessor.mjs b/src/variationprocessor.mjs index 76760cbc..0682cc57 100644 --- a/src/variationprocessor.mjs +++ b/src/variationprocessor.mjs @@ -402,7 +402,7 @@ export class VariationProcessor { for (let c = 0; c < glyph.components.length; c++) { const component = glyph.components[c]; const componentGlyph = this.font.glyphs.get(component.glyphIndex); - componentGlyph.getPath(); + // Note that .points is a getter that will lazy-load the path. const pointCount = componentGlyph.points.length; const deltaAdvance = this.font.tables.hvar ? this.getVariableAdjustment(componentGlyph.index, 'hvar', 'advanceWidth', coords) : From 5fae7945182a8188cbc4a9c4ea47bcaf071bab61 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Fri, 13 Mar 2026 12:14:17 -0700 Subject: [PATCH 05/13] Increase sub test coverage for 52 and fix a bug in the 53 case --- test/featureQuery.spec.mjs | 54 +++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/test/featureQuery.spec.mjs b/test/featureQuery.spec.mjs index 427f2085..2405ae1b 100644 --- a/test/featureQuery.spec.mjs +++ b/test/featureQuery.spec.mjs @@ -6,6 +6,7 @@ import { readFileSync } from 'fs'; const loadSync = (url, opt) => parse(readFileSync(url), opt); +// Extracted from layout.mjs for testing const getGlyphClass = function(classDefTable, glyphIndex) { switch (classDefTable.format) { case 1: { @@ -192,7 +193,7 @@ describe('featureQuery.mjs', function() { const lookupSubtables = query.sub5.getLookupSubtables(featureLookups[1]); const substitutionType = query.sub5.getSubstitutionType(featureLookups[1], lookupSubtables[0]); assert.equal(substitutionType, 53); - const lookup = query.sub5.getLookupMethod(featureLookups[0], lookupSubtables[0]); + const lookup = query.sub5.getLookupMethod(featureLookups[1], lookupSubtables[0]); let contextParams = new ContextParams([2, 3], 0); const substitutions = lookup(contextParams); assert.deepEqual(substitutions, [54, 54]); @@ -209,21 +210,48 @@ describe('featureQuery.mjs', function() { }); it('should route context substitution format 2 (52) correctly', function () { // This test verifies that case '52' is registered in getLookupMethod switch - // Without this case, getLookupMethod would throw an error for format 2 context substitutions const featureQuery = query.arabic; - - // Mock a lookup table and subtable with substitution type 52 const mockLookupTable = { lookupType: 5 }; // Context substitution type 5 const mockSubtable = { substFormat: 2 }; // Format 2 (class-based) - - // This should not throw an error if case '52' is registered - try { - const substitutionType = featureQuery.getSubstitutionType(mockLookupTable, mockSubtable); - // Type 5 format 2 should be '52' - assert.equal(substitutionType, '52'); - } catch (e) { - assert.fail('getLookupMethod should handle case "52" for context substitution format 2'); - } + + const substitutionType = featureQuery.getSubstitutionType(mockLookupTable, mockSubtable); + assert.equal(substitutionType, '52'); + + const lookup = featureQuery.getLookupMethod(mockLookupTable, mockSubtable); + assert.ok(typeof lookup === 'function', 'getLookupMethod should return a function for case 52'); + }); + it('should apply context substitution format 2 (52) using lookup records', function () { + const singleSubtable = { + substFormat: 1, + coverage: { format: 1, glyphs: [10] }, + deltaGlyphId: 5 + }; + const singleLookupTable = { lookupType: 1, subtables: [singleSubtable] }; + const contextSubtable = { + substFormat: 2, + coverage: { format: 1, glyphs: [10] }, + classDef: { + format: 2, + ranges: [ + { start: 10, end: 10, classId: 1 }, + { start: 20, end: 20, classId: 2 } + ] + }, + classSets: [ + null, + [{ classes: [2], lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 0 }] }] + ] + }; + const contextLookupTable = { lookupType: 5, subtables: [contextSubtable] }; + const mockFont = { + tables: { gsub: { lookups: [singleLookupTable] } }, + substitution: { getGlyphClass } + }; + const featureQuery = new FeatureQuery(mockFont); + const lookup = featureQuery.getLookupMethod(contextLookupTable, contextSubtable); + const contextParams = new ContextParams([10, 20], 0); + const substitutions = lookup(contextParams); + assert.deepEqual(substitutions, [15]); }); it('should route chaining context substitution format 2 (62) correctly', function () { // This test verifies that case '62' is registered in getLookupMethod switch From 4de13a16b0026f57b5eeca6f66c0392053deeb24 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 12 Mar 2026 15:51:12 -0700 Subject: [PATCH 06/13] Add support for combining mark positioning --- src/font.mjs | 158 ++++++++++++++++++++++++++++++++++++- src/glyph.mjs | 8 +- src/parse.mjs | 44 +++++++++++ src/position.mjs | 139 ++++++++++++++++++++++++++++++++ src/tables/gdef.mjs | 6 ++ src/tables/gpos.mjs | 56 ++++++++++++- src/variationprocessor.mjs | 23 ++++-- 7 files changed, 422 insertions(+), 12 deletions(-) diff --git a/src/font.mjs b/src/font.mjs index e33ea05f..5765a6df 100644 --- a/src/font.mjs +++ b/src/font.mjs @@ -365,6 +365,138 @@ Font.prototype.defaultRenderOptions = { drawSVG: true, }; +/** + * Return the bounding box of a glyph's path, resolving lazy paths as needed. + * Returns null if the glyph has no path or the path provides no bounding box. + * @param {opentype.Glyph} glyph + * @returns {opentype.BoundingBox|null} + */ +function getGlyphBBox(glyph) { + if (!glyph) return null; + const path = typeof glyph.path === 'function' ? glyph.path() : glyph.path; + if (!path || typeof path.getBoundingBox !== 'function') return null; + return path.getBoundingBox(); +} + +/** + * Return true if the glyph is a Unicode combining character (based on Unicode block ranges). + * Used as a fallback when GDEF is not available to identify marks. + * @param {opentype.Glyph} glyph + * @returns {boolean} + */ +function isCombiningByUnicode(glyph) { + if (!glyph || !glyph.unicodes || !glyph.unicodes.length) return false; + return glyph.unicodes.some((u) => + (u >= 0x0300 && u <= 0x036F) || (u >= 0x1AB0 && u <= 0x1AFF) || + (u >= 0x1DC0 && u <= 0x1DFF) || (u >= 0x20D0 && u <= 0x20FF) || + (u >= 0xFE20 && u <= 0xFE2F)); +} + +/** + * Compute a heuristic position for a combining mark over its base glyph. + * Used when GPOS mark-to-base data is unavailable or produces no match. + * Implements horizontal centering and vertical clearance per Unicode Technical Note #2 (UTN #2). + * + * @param {opentype.Font} font + * @param {number} gX - Current pen X (pixels), already advanced past the base. + * @param {number} gY - Current pen Y (pixels, canvas baseline). + * @param {number} baseAdvance - Advance width of the base glyph (font units). + * @param {opentype.Glyph} markGlyph + * @param {number} scale - Font units → pixels scale factor (fontSize / unitsPerEm). + * @param {opentype.Glyph} baseGlyph + * @returns {{ gX: number, gY: number }} + */ +function heuristicMarkPosition(font, gX, gY, baseAdvance, markGlyph, scale, baseGlyph) { + const s = scale; + // UTN #2: "Most combining marks are centered horizontally with respect to the character they are + // placed upon." Use the base's bbox center when available so asymmetric glyphs (e.g. "s") center + // correctly; otherwise fall back to advance width / 2. + let baseCenterX = baseAdvance / 2; + const baseBbox = getGlyphBBox(baseGlyph); + if (baseBbox && Number.isFinite(baseBbox.x1) && Number.isFinite(baseBbox.x2)) { + baseCenterX = (baseBbox.x1 + baseBbox.x2) / 2; + } + // The mark bounding box reflects the full visual extent including composite component offsets, + // so no separate dx/dy handling is needed for composite marks. + const markBbox = getGlyphBBox(markGlyph); + // UTN #2 horizontal: center the mark's bounding box over the base's bounding box center. + // gX_new + markBboxCenterX * s = base_origin + baseCenterX * s + // where gX = base_origin + baseAdvance * s => xOffset = (baseCenterX - baseAdvance - markBboxCenterX) * s + let markBboxCenterX = 0; + if (markBbox && Number.isFinite(markBbox.x1) && Number.isFinite(markBbox.x2)) { + markBboxCenterX = (markBbox.x1 + markBbox.x2) / 2; + } else if (markGlyph && markGlyph.advanceWidth > 0) { + markBboxCenterX = markGlyph.advanceWidth / 2; + } + const xOffset = (baseCenterX - baseAdvance - markBboxCenterX) * s; + // UTN #2 vertical: place above or below base based on the mark's vertical center in font units + // (positive y = up from baseline). Above marks (e.g. diaeresis) have center ≥ 0; below (e.g. cedilla) < 0. + let yOffset = 0; + const isAboveMark = markBbox && Number.isFinite(markBbox.y1) && Number.isFinite(markBbox.y2) + ? (markBbox.y1 + markBbox.y2) / 2 >= 0 + : null; + if (baseBbox && markBbox && Number.isFinite(baseBbox.y1) && Number.isFinite(baseBbox.y2) && + Number.isFinite(markBbox.y1) && Number.isFinite(markBbox.y2) && isAboveMark !== null) { + const capHeight = (font.tables.os2 && font.tables.os2.sCapHeight) + ? font.tables.os2.sCapHeight + : (font.unitsPerEm || 1000) * 0.7; + const gapPx = (capHeight / 8) * s; + if (isAboveMark) { + // Ensure the mark's bottom clears the base's top with a minimum gap. + // Only apply when negative (mark needs moving up); positive means the mark already clears naturally. + const desiredYOffset = (markBbox.y1 - baseBbox.y2) * s - gapPx; + if (desiredYOffset < 0) { + yOffset = desiredYOffset; + } + } else { + // Place mark below base: mark's top at base bottom, hanging downward. + yOffset = (markBbox.y2 - baseBbox.y1) * s - gapPx; + } + } + return { gX: gX + xOffset, gY: gY + yOffset }; +} + +/** + * Compute the rendered position of a combining mark glyph relative to its preceding base glyph. + * Tries GPOS mark-to-base anchors first; falls back to heuristic centering if unavailable. + * Returns the adjusted { gX, gY } pen position for the mark; or the original if it is not a mark. + * + * @param {opentype.Font} font + * @param {opentype.Glyph} glyph - The current (mark) glyph. + * @param {opentype.Glyph} prevGlyph - The immediately preceding glyph (the base candidate). + * @param {number} gX - Current pen X (pixels). + * @param {number} gY - Current pen Y (pixels). + * @param {boolean} applyMarkPositioning - Whether GPOS+GDEF mark data is available. + * @param {object|null} classDef - GDEF glyph class definition table. + * @param {number} fontScale - fontSize / unitsPerEm. + * @param {number} fontSize - Font size in pixels. + * @param {string} script - OpenType script tag. + * @param {string} language - OpenType language tag. + * @returns {{ gX: number, gY: number }} + */ +function positionMarkGlyph(font, glyph, prevGlyph, gX, gY, applyMarkPositioning, classDef, fontScale, fontSize, script, language) { + const baseAdvance = prevGlyph.advanceWidth !== undefined ? prevGlyph.advanceWidth : 0; + if (applyMarkPositioning) { + const markClass = font.position.getGlyphClass(classDef, glyph.index); + const baseClass = font.position.getGlyphClass(classDef, prevGlyph.index); + // GPOS class 3 = mark. Only handle mark-to-base (base class != 3) here. + // TODO: implement mark-to-mark (GPOS type 6) for stacked marks (e.g. base + mark1 + mark2), + // which requires looking back past preceding marks to find the actual base glyph. + if (markClass === 3 && baseClass !== 3) { + const offset = font.position.getMarkToBaseOffset(glyph.index, prevGlyph.index, baseAdvance, script, language); + if (offset) { + // GPOS offsets are in font units; positive Y = up (font space), so subtract for canvas (Y-down). + const fontScaleForOffset = fontSize / font.unitsPerEm; + return { gX: gX + offset.xOffset * fontScaleForOffset, gY: gY - offset.yOffset * fontScaleForOffset }; + } + return heuristicMarkPosition(font, gX, gY, baseAdvance, glyph, fontScale, prevGlyph); + } + } else if (isCombiningByUnicode(glyph) && !isCombiningByUnicode(prevGlyph)) { + return heuristicMarkPosition(font, gX, gY, baseAdvance, glyph, fontScale, prevGlyph); + } + return { gX, gY }; +} + /** * Helper function that invokes the given callback for each glyph in the given text. * The callback gets `(glyph, x, y, fontSize, options)`. @@ -383,13 +515,32 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback) const fontScale = 1 / this.unitsPerEm * fontSize; const glyphs = this.stringToGlyphs(text, options); let kerningLookups; + let script; + let language; if (options.kerning) { - const script = options.script || this.position.getDefaultScriptName(); - kerningLookups = this.position.getKerningTables(script, options.language); + script = options.script || this.position.getDefaultScriptName(); + language = options.language; + kerningLookups = this.position.getKerningTables(script, language); + } + const gdef = this.tables.gdef; + const classDef = gdef && gdef.classDef; + const applyMarkPositioning = this.tables.gpos && classDef; + if (applyMarkPositioning && !script) { + script = options.script || this.position.getDefaultScriptName(); + language = options.language; } for (let i = 0; i < glyphs.length; i += 1) { const glyph = glyphs[i]; - callback.call(this, glyph, x, y, fontSize, options); + const { gX, gY } = i > 0 + ? positionMarkGlyph(this, glyph, glyphs[i - 1], x, y, applyMarkPositioning, classDef, fontScale, fontSize, script, language) + : { gX: x, gY: y }; + callback.call(this, glyph, gX, gY, fontSize, options); + // For variable fonts with HVAR, apply variation-adjusted metrics when requested + // (e.g. by getAdvanceWidth). Otherwise advance width uses raw hmtx values, causing + // narrow advances for punctuation like hyphens at heavier weights. + if (options._applyVariationMetrics && this.variation && this.tables.hvar && glyph.advanceWidth !== undefined) { + this.variation.getTransform(glyph, options.variation); + } if (glyph.advanceWidth) { x += glyph.advanceWidth * fontScale; } @@ -484,6 +635,7 @@ Font.prototype.getPaths = function(text, x, y, fontSize, options) { */ Font.prototype.getAdvanceWidth = function(text, fontSize, options) { options = Object.assign({}, this.defaultRenderOptions, options); + options._applyVariationMetrics = true; return this.forEachGlyph(text, 0, 0, fontSize, options, function() {}); }; diff --git a/src/glyph.mjs b/src/glyph.mjs index a18b63a8..aab4af92 100644 --- a/src/glyph.mjs +++ b/src/glyph.mjs @@ -150,14 +150,16 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { let hPoints; let xScale = options.xScale; let yScale = options.yScale; - const scale = 1 / (this.path.unitsPerEm || 1000) * fontSize; - let useGlyph = this; - + // For variable fonts, the transformed path's unitsPerEm may be set explicitly (see variationprocessor.mjs). + // Prefer it over the original path's value so the scale is always derived from the same path object used for rendering. + let scaleSource = this.path; if(font && font.variation) { useGlyph = font.variation.getTransform(this, options.variation); commands = useGlyph.path.commands; + scaleSource = useGlyph.path.unitsPerEm != null ? useGlyph.path : this.path; } + const scale = 1 / (scaleSource.unitsPerEm || 1000) * fontSize; if (options.hinting && font && font.hinting) { // in case of hinting, the hinting engine takes care diff --git a/src/parse.mjs b/src/parse.mjs index 057a5f31..dd92906c 100644 --- a/src/parse.mjs +++ b/src/parse.mjs @@ -448,6 +448,50 @@ Parser.prototype.parseValueRecordList = function() { return values; }; +/** + * Parse a GPOS Anchor table (format 1, 2, or 3). + * https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#anchor-table + * AnchorFormat1: format (uint16), then xCoordinate (int16), then yCoordinate (int16) — design units. + * Format 3: Device table (non-variable) / VariationIndex table (variable) offsets are parsed; + * VariationIndex (deltaFormat 0x8000) is read and stored for variable-font anchor adjustment. + */ +Parser.prototype.parseAnchor = function() { + const anchorStart = this.offset; + const format = this.parseUShort(); + const anchor = { + format: format, + xCoordinate: this.parseShort(), + yCoordinate: this.parseShort() + }; + if (format === 2) { + anchor.anchorPoint = this.parseUShort(); + } else if (format === 3) { + const xDeviceOffset = this.parseOffset16(); + const yDeviceOffset = this.parseOffset16(); + // Parse Device / VariationIndex tables per OpenType ch.2 § Device and VariationIndex tables. + // VariationIndex (deltaFormat 0x8000) gives delta-set outer/inner index for ItemVariationStore. + if (xDeviceOffset) { + const p = new Parser(this.data, anchorStart + xDeviceOffset); + const word0 = p.parseUShort(); + const word1 = p.parseUShort(); + const deltaFormat = p.parseUShort(); + if (deltaFormat === 0x8000) { + anchor.xVariationIndex = { outer: word0, inner: word1 }; + } + } + if (yDeviceOffset) { + const p = new Parser(this.data, anchorStart + yDeviceOffset); + const word0 = p.parseUShort(); + const word1 = p.parseUShort(); + const deltaFormat = p.parseUShort(); + if (deltaFormat === 0x8000) { + anchor.yVariationIndex = { outer: word0, inner: word1 }; + } + } + } + return anchor; +}; + Parser.prototype.parsePointer = function(description) { const structOffset = this.parseOffset16(); if (structOffset > 0) { diff --git a/src/position.mjs b/src/position.mjs index 692aef61..d83bd795 100644 --- a/src/position.mjs +++ b/src/position.mjs @@ -76,4 +76,143 @@ Position.prototype.getKerningTables = function(script, language) { } }; +/** + * Resolve anchor X/Y coordinates, applying variation deltas when the font is variable and + * the anchor uses VariationIndex (GPOS Anchor format 3). Per GPOS spec, variable fonts use + * ItemVariationStore in GDEF for anchor adjustment; deltas are added to the default coordinates. + * + * Anchor table: GPOS "Anchor table" / AnchorFormat1 — xCoordinate then yCoordinate (int16, design units). + * https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#anchor-table + * + * @param {Object} anchor - Parsed anchor (format 1, 2, or 3) or null + * @returns {{ x: number, y: number } | null} Resolved coordinates in font units, or null if anchor is null + */ +Position.prototype.resolveAnchorCoordinates = function(anchor) { + if (!anchor) return null; + let x = anchor.xCoordinate; + let y = anchor.yCoordinate; + const store = this.font.tables.gdef && this.font.tables.gdef.itemVariationStore; + const vp = this.font.variation && this.font.variation.process; + const coords = this.font.variation && this.font.variation.get(); + if (store && vp && coords) { + if (anchor.xVariationIndex) { + x += vp.getDelta(store, anchor.xVariationIndex.outer, anchor.xVariationIndex.inner, coords); + } + if (anchor.yVariationIndex) { + y += vp.getDelta(store, anchor.yVariationIndex.outer, anchor.yVariationIndex.inner, coords); + } + } + return { x, y }; +}; + +/** + * List Mark-to-Base positioning lookup tables (GPOS Lookup type 4) for the 'mark' feature. + * Used to position combining marks relative to base glyphs via anchor points. + * Also includes type-4 lookups from the script's required feature (required feature often holds 'mark'). + * + * @param {string} [script='DFLT'] + * @param {string} [language='dflt'] + * @return {Array<{ lookup: object, featureTag: string }>} Lookup tables with type 4 subtables (and their feature tag), or empty array + */ +Position.prototype.getMarkToBaseTables = function(script, language) { + if (!this.font.tables.gpos) { + return []; + } + const table = this.font.tables.gpos; + const allLookups = table.lookups || []; + const allFeatures = table.features || []; + const seen = new Set(); + const result = []; + const tryLangSys = (scr, lang) => { + const langSys = this.getLangSysTable(scr, lang); + if (!langSys) return; + const featIndexes = langSys.featureIndexes || []; + const reqFeatureIndex = langSys.reqFeatureIndex; + const indices = (reqFeatureIndex !== undefined && reqFeatureIndex !== 0xFFFF && reqFeatureIndex < allFeatures.length) + ? [reqFeatureIndex].concat(featIndexes.filter(i => i !== reqFeatureIndex)) + : featIndexes; + for (let f = 0; f < indices.length; f++) { + const featureRecord = allFeatures[indices[f]]; + if (!featureRecord || !featureRecord.feature) continue; + const tag = featureRecord.tag; + const lookupListIndexes = featureRecord.feature.lookupListIndexes || []; + for (let i = 0; i < lookupListIndexes.length; i++) { + const lookup = allLookups[lookupListIndexes[i]]; + if (lookup && lookup.lookupType === 4 && !seen.has(lookup)) { + seen.add(lookup); + result.push({ lookup, featureTag: tag }); + } + } + } + }; + tryLangSys(script || this.getDefaultScriptName(), language); + if (script !== 'DFLT') tryLangSys('DFLT', 'dflt'); + if (script !== 'latn') tryLangSys('latn', 'dflt'); + return result; +}; + +/** + * Get the positioning offset for a mark glyph relative to a base glyph from MarkToBase (GPOS type 4) subtables. + * The returned offset should be added to the mark's default position (pen after base advance) so that + * the mark's anchor aligns with the base's anchor. Values are in font units. + * + * Known limitations (TODOs): + * - GSUB 'ccmp'/'mark' features should run before GPOS to ensure the correct base/mark glyphs are present. + * - GPOS Lookup Type 5 (Mark-to-Ligature) is not implemented; ligature bases fall back to heuristic placement. + * - GPOS Lookup Type 6 (Mark-to-Mark) is not implemented; stacked marks (e.g. base + mark1 + mark2) only + * position the first mark via GPOS — subsequent marks use the heuristic. + * - GDEF Mark Attachment Class Definition filtering is not applied; we match by anchor coverage only. + * + * @param {number} markGlyphIndex - Glyph ID of the combining mark + * @param {number} baseGlyphIndex - Glyph ID of the base glyph (the preceding glyph) + * @param {number} baseAdvanceWidth - Advance width of the base glyph (font units) + * @param {string} [script='DFLT'] + * @param {string} [language='dflt'] + * @returns {{ xOffset: number, yOffset: number } | undefined} Offset in font units, or undefined if no positioning defined + */ +Position.prototype.getMarkToBaseOffset = function(markGlyphIndex, baseGlyphIndex, baseAdvanceWidth, script, language) { + const entries = this.getMarkToBaseTables(script, language); + for (let i = 0; i < entries.length; i++) { + const lookup = entries[i].lookup; + if (lookup.error) continue; + const subtables = lookup.subtables || []; + for (let j = 0; j < subtables.length; j++) { + const sub = subtables[j]; + if (sub.error) continue; + if (sub.posFormat !== 1) continue; + const markCov = sub.markCoverage; + const baseCov = sub.baseCoverage; + if (!markCov || !baseCov) continue; + const markIndex = this.getCoverageIndex(markCov, markGlyphIndex); + const baseIndex = this.getCoverageIndex(baseCov, baseGlyphIndex); + if (markIndex < 0 || baseIndex < 0) continue; + const markRecord = sub.markArray && sub.markArray[markIndex]; + const baseAnchors = sub.baseArray && sub.baseArray[baseIndex]; + if (!markRecord || !markRecord.anchor || !baseAnchors) continue; + const markClass = markRecord.markClass; + const baseAnchor = baseAnchors[markClass]; + if (!baseAnchor) continue; + const markCoord = this.resolveAnchorCoordinates(markRecord.anchor); + const baseCoord = this.resolveAnchorCoordinates(baseAnchor); + // GPOS Lookup Type 4: "positioning the mark with respect to the final pen point (advance) position of the base glyph" + // Align anchors: mark's anchor in world = base's anchor in world. + // mark_world_x = penAfterBase + xOffset + markCoord.x => xOffset = baseCoord.x - markCoord.x - baseAdvanceWidth + // mark_world_y = yOffset + markCoord.y => yOffset = baseCoord.y - markCoord.y + // This is correct for both simple and composite mark glyphs: the GPOS anchor is always in the mark + // glyph's own coordinate space (spec: "Anchor table … design units"), and the rendering engine draws + // composite components relative to the positioned glyph origin automatically. No extra dx/dy + // correction is needed here — that would override the explicitly authored mark anchor. + // Spec: https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-4-mark-to-base-attachment-positioning-subtable + return { + xOffset: baseCoord.x - markCoord.x - baseAdvanceWidth, + yOffset: baseCoord.y - markCoord.y + }; + } + } + // Only use GPOS mark-to-base when a single subtable covers both mark and base (direct match). + // Do not fall back to pairing mark from one subtable with base from another; fonts like Noto Sans + // may have no intended mark-to-base for e+ring, and the correct behavior is to use component placement only. + return undefined; +}; + export default Position; diff --git a/src/tables/gdef.mjs b/src/tables/gdef.mjs index 2cfd6b3d..15d302d6 100644 --- a/src/tables/gdef.mjs +++ b/src/tables/gdef.mjs @@ -57,6 +57,12 @@ function parseGDEFTable(data, start) { if (tableVersion >= 1.2) { gdef.markGlyphSets = p.parsePointer(markGlyphSets); } + if (tableVersion >= 1.3) { + const itemVarStoreOffset = p.parseOffset32(); + gdef.itemVariationStore = itemVarStoreOffset + ? new Parser(data, start + itemVarStoreOffset).parseItemVariationStore() + : undefined; + } return gdef; } export default { parse: parseGDEFTable }; diff --git a/src/tables/gpos.mjs b/src/tables/gpos.mjs index 23f09789..9f90d82a 100644 --- a/src/tables/gpos.mjs +++ b/src/tables/gpos.mjs @@ -77,7 +77,61 @@ subtableParsers[2] = function parseLookup2() { }; subtableParsers[3] = function parseLookup3() { return { error: 'GPOS Lookup 3 not supported' }; }; -subtableParsers[4] = function parseLookup4() { return { error: 'GPOS Lookup 4 not supported' }; }; + +// https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-4-mark-to-base-attachment-positioning-subtable +// MarkBasePosFormat1: positions combining marks relative to base glyphs via anchor points. +subtableParsers[4] = function parseLookup4() { + const subtableStart = this.offset + this.relativeOffset; + const format = this.parseUShort(); + check.argument(format === 1, '0x' + subtableStart.toString(16) + ': GPOS Lookup type 4 (MarkBasePos) only format 1 is supported.'); + const markCoverageOffset = this.parseOffset16(); + const baseCoverageOffset = this.parseOffset16(); + const markClassCount = this.parseUShort(); + const markArrayOffset = this.parseOffset16(); + const baseArrayOffset = this.parseOffset16(); + + const markCoverage = markCoverageOffset ? new Parser(this.data, subtableStart + markCoverageOffset).parseCoverage() : undefined; + const baseCoverage = baseCoverageOffset ? new Parser(this.data, subtableStart + baseCoverageOffset).parseCoverage() : undefined; + + let markArray = []; + if (markArrayOffset && markCoverage) { + const markArrayStart = subtableStart + markArrayOffset; + const p = new Parser(this.data, markArrayStart); + const markCount = p.parseUShort(); + for (let i = 0; i < markCount; i++) { + const markClass = p.parseUShort(); + const markAnchorOffset = p.parseOffset16(); + const anchor = markAnchorOffset ? new Parser(this.data, markArrayStart + markAnchorOffset).parseAnchor() : null; + markArray.push({ markClass, anchor }); + } + } + + let baseArray = []; + if (baseArrayOffset && baseCoverage) { + const baseArrayStart = subtableStart + baseArrayOffset; + const p = new Parser(this.data, baseArrayStart); + const baseCount = p.parseUShort(); + for (let i = 0; i < baseCount; i++) { + const baseAnchors = []; + for (let j = 0; j < markClassCount; j++) { + const anchorOffset = p.parseOffset16(); + const anchor = anchorOffset ? new Parser(this.data, baseArrayStart + anchorOffset).parseAnchor() : null; + baseAnchors.push(anchor); + } + baseArray.push(baseAnchors); + } + } + + return { + posFormat: 1, + markCoverage, + baseCoverage, + markClassCount, + markArray, + baseArray + }; +}; + subtableParsers[5] = function parseLookup5() { return { error: 'GPOS Lookup 5 not supported' }; }; subtableParsers[6] = function parseLookup6() { return { error: 'GPOS Lookup 6 not supported' }; }; subtableParsers[7] = function parseLookup7() { return { error: 'GPOS Lookup 7 not supported' }; }; diff --git a/src/variationprocessor.mjs b/src/variationprocessor.mjs index 0682cc57..4cf4fa8b 100644 --- a/src/variationprocessor.mjs +++ b/src/variationprocessor.mjs @@ -386,7 +386,12 @@ export class VariationProcessor { if(variationData) { const glyphPoints = glyph.points; let transformedPoints = this.applyTupleVariationStore(variationData, glyphPoints, coords, 'gvar', { glyph }); - transformedGlyph = new Glyph(Object.assign({}, glyph, {points: transformedPoints, path: getPath(transformedPoints)})); + // unitsPerEm is propagated through the path so that glyph.getPath() can derive the + // correct scale after transformation (variable fonts may produce a transformed path + // whose unitsPerEm differs from the font default if the path object is replaced). + const path = getPath(transformedPoints); + path.unitsPerEm = this.font.unitsPerEm; + transformedGlyph = new Glyph(Object.assign({}, glyph, {points: transformedPoints, path})); } } else if (hasBlend) { const blendPath = glyph.getBlendPath(coords); @@ -407,10 +412,14 @@ export class VariationProcessor { const deltaAdvance = this.font.tables.hvar ? this.getVariableAdjustment(componentGlyph.index, 'hvar', 'advanceWidth', coords) : 0; + const deltaLsb = this.font.tables.hvar ? + this.getVariableAdjustment(componentGlyph.index, 'hvar', 'lsb', coords) : + 0; componentInfos.push({ pointOffset, pointCount, - deltaAdvanceWidth: deltaAdvance + deltaAdvanceWidth: deltaAdvance, + deltaLsb }); pointOffset += pointCount; } @@ -418,13 +427,17 @@ export class VariationProcessor { const shiftedPoints = transformedGlyph.points.map(copyPoint); for (let i = 0; i < componentInfos.length; i++) { const info = componentInfos[i]; - // Each component shifts by the sum of prior component width deltas. + // Each component shifts by the sum of prior component width deltas, plus this component's LSB delta. for (let p = info.pointOffset; p < info.pointOffset + info.pointCount; p++) { - shiftedPoints[p].x = Math.round(shiftedPoints[p].x + cumulativeShift); + shiftedPoints[p].x = Math.round(shiftedPoints[p].x + cumulativeShift + info.deltaLsb); } cumulativeShift += info.deltaAdvanceWidth; } - transformedGlyph = new Glyph(Object.assign({}, transformedGlyph, {points: shiftedPoints, path: getPath(shiftedPoints)})); + transformedGlyph = new Glyph(Object.assign({}, transformedGlyph, {points: shiftedPoints, path: (() => { + const p = getPath(shiftedPoints); + p.unitsPerEm = this.font.unitsPerEm; + return p; + })()})); } if(this.font.tables.hvar) { From 5c376538bf160efc984cd3ff19cd93fb9abc9745 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Wed, 6 May 2026 14:47:01 -0700 Subject: [PATCH 07/13] Fix heuristic gap calculation for attached combining marks --- src/font.mjs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/font.mjs b/src/font.mjs index 5765a6df..942f1f3f 100644 --- a/src/font.mjs +++ b/src/font.mjs @@ -392,6 +392,26 @@ function isCombiningByUnicode(glyph) { (u >= 0xFE20 && u <= 0xFE2F)); } +function isAttached(glyph) { + if (!glyph || !glyph.unicodes || !glyph.unicodes.length) return false; + + // Canonical Combining Class 204, Attached above + // U+036A: ◌ͪ COMBINING LATIN SMALL LETTER O + if (glyph.unicodes.some((u) => u === 0x036A)) + return true; + + // Canonical Combining Class 202, Attached below + if (glyph.unicodes.some((u) => + u == 0x0327 /* Cedilla (◌̧) */ || + u === 0x0328 /* Ogonek (◌̨)*/|| + u === 0x0321 /* Palatalized Hook Below (◌̡)*/|| + u === 0x0322 /* Retroflex Hook Below (◌̢)*/|| + u === 0x0489/* Cyrillic Ten Thousands Sign (◌҉)*/)) + return true; + + return false; +} + /** * Compute a heuristic position for a combining mark over its base glyph. * Used when GPOS mark-to-base data is unavailable or produces no match. @@ -437,10 +457,14 @@ function heuristicMarkPosition(font, gX, gY, baseAdvance, markGlyph, scale, base : null; if (baseBbox && markBbox && Number.isFinite(baseBbox.y1) && Number.isFinite(baseBbox.y2) && Number.isFinite(markBbox.y1) && Number.isFinite(markBbox.y2) && isAboveMark !== null) { + // The gap is a heuristic measurement following the suggestion of 1/8 cap height from + // UTN #2, but only applied if the mark has a Canonical Combining Class that indicates + // that it is detached from the base glyph. const capHeight = (font.tables.os2 && font.tables.os2.sCapHeight) ? font.tables.os2.sCapHeight : (font.unitsPerEm || 1000) * 0.7; - const gapPx = (capHeight / 8) * s; + const gapPx = isAttached(markGlyph) ? 0 : (capHeight / 8) * s; + if (isAboveMark) { // Ensure the mark's bottom clears the base's top with a minimum gap. // Only apply when negative (mark needs moving up); positive means the mark already clears naturally. From 44a22c0f11162fdac9f5bee093afd284f683e802 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 7 May 2026 09:44:05 -0700 Subject: [PATCH 08/13] Add support extended (32-bit) lookup values, add mvar table parsing, API endpoint for variation axis --- src/font.mjs | 83 ++++++++++++++++++++++++++++++++------- src/layout.mjs | 10 +++++ src/opentype.mjs | 13 ++++++ src/position.mjs | 8 +++- src/tables/gpos.mjs | 15 ++++++- src/tables/mvar.mjs | 40 +++++++++++++++++++ test/layout.spec.mjs | 51 ++++++++++++++++++++++++ test/tables/gpos.spec.mjs | 31 +++++++++++++++ test/tables/mvar.spec.mjs | 77 ++++++++++++++++++++++++++++++++++++ 9 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 src/tables/mvar.mjs create mode 100644 test/tables/mvar.spec.mjs diff --git a/src/font.mjs b/src/font.mjs index 942f1f3f..f4c85054 100644 --- a/src/font.mjs +++ b/src/font.mjs @@ -392,12 +392,18 @@ function isCombiningByUnicode(glyph) { (u >= 0xFE20 && u <= 0xFE2F)); } -function isAttached(glyph) { +/** + * Return true if the glyph is a Unicode combining character that is attached to the base glyph. + * Since only a small subset of Unicode combining characters are attached, it is reasonable to just + * test for them explicitly. See the Unicode Technical Note #2 (UTN #2) for more details. + * @param {opentype.Glyph} glyph + * @returns {boolean} + */ +function isAttachedByUnicode(glyph) { if (!glyph || !glyph.unicodes || !glyph.unicodes.length) return false; // Canonical Combining Class 204, Attached above - // U+036A: ◌ͪ COMBINING LATIN SMALL LETTER O - if (glyph.unicodes.some((u) => u === 0x036A)) + if (glyph.unicodes.some((u) => u === 0x036A /* ◌ͪ COMBINING LATIN SMALL LETTER O */)) return true; // Canonical Combining Class 202, Attached below @@ -413,9 +419,10 @@ function isAttached(glyph) { } /** - * Compute a heuristic position for a combining mark over its base glyph. - * Used when GPOS mark-to-base data is unavailable or produces no match. - * Implements horizontal centering and vertical clearance per Unicode Technical Note #2 (UTN #2). + * Compute a heuristic position for a combining mark over its base glyph. Used when GPOS + * mark-to-base data is unavailable or produces no match. Implements horizontal centering + * and vertical clearance per Unicode Technical Note #2 (UTN #2). This code path is not + * expected to be executed for most fonts, but is included for completeness. * * @param {opentype.Font} font * @param {number} gX - Current pen X (pixels), already advanced past the base. @@ -440,8 +447,6 @@ function heuristicMarkPosition(font, gX, gY, baseAdvance, markGlyph, scale, base // so no separate dx/dy handling is needed for composite marks. const markBbox = getGlyphBBox(markGlyph); // UTN #2 horizontal: center the mark's bounding box over the base's bounding box center. - // gX_new + markBboxCenterX * s = base_origin + baseCenterX * s - // where gX = base_origin + baseAdvance * s => xOffset = (baseCenterX - baseAdvance - markBboxCenterX) * s let markBboxCenterX = 0; if (markBbox && Number.isFinite(markBbox.x1) && Number.isFinite(markBbox.x2)) { markBboxCenterX = (markBbox.x1 + markBbox.x2) / 2; @@ -460,14 +465,21 @@ function heuristicMarkPosition(font, gX, gY, baseAdvance, markGlyph, scale, base // The gap is a heuristic measurement following the suggestion of 1/8 cap height from // UTN #2, but only applied if the mark has a Canonical Combining Class that indicates // that it is detached from the base glyph. - const capHeight = (font.tables.os2 && font.tables.os2.sCapHeight) + const capHeightBase = (font.tables.os2 && font.tables.os2.sCapHeight) ? font.tables.os2.sCapHeight : (font.unitsPerEm || 1000) * 0.7; - const gapPx = isAttached(markGlyph) ? 0 : (capHeight / 8) * s; + const mvar = font.tables && font.tables.mvar; + const cphtRecord = mvar && mvar.valueRecords['cpht']; + const cphtDelta = cphtRecord && font.variation && font.variation.process + ? Math.round(font.variation.process.getDelta(mvar.itemVariationStore, cphtRecord.outer, cphtRecord.inner, font.variation.get())) + : 0; + const capHeight = capHeightBase + cphtDelta; + const gapPx = isAttachedByUnicode(markGlyph) ? 0 : (capHeight / 8) * s; if (isAboveMark) { // Ensure the mark's bottom clears the base's top with a minimum gap. - // Only apply when negative (mark needs moving up); positive means the mark already clears naturally. + // Only apply when negative (mark needs moving up); positive means the mark already + // clears naturally. const desiredYOffset = (markBbox.y1 - baseBbox.y2) * s - gapPx; if (desiredYOffset < 0) { yOffset = desiredYOffset; @@ -714,9 +726,52 @@ Font.prototype.drawMetrics = function(ctx, text, x, y, fontSize, options) { }; /** - * @param {string} - * @return {string} - */ + * Return the variation axes defined by this font's fvar table. + * Each entry has the axis tag, human-readable name, and min/default/max values + * in the units defined by the OpenType spec for that axis (e.g. wght = 100–900, + * wdth = percentage of normal width where 100 is default). + * Returns an empty array for non-variable fonts. + * + * @returns {Array<{ tag: string, name: string, min: number, default: number, max: number }>} + */ +Font.prototype.getVariationAxes = function() { + const axes = this.tables.fvar && this.tables.fvar.axes; + if (!axes) return []; + return axes.map(axis => ({ + tag: axis.tag, + name: axis.name, + min: axis.minValue, + default: axis.defaultValue, + max: axis.maxValue, + })); +}; + +/** + * Get caret positions within a ligature glyph, in font units. + * Returns an array of x-coordinates (one per internal boundary, so a 3-component ligature + * returns 2 values). Uses GDEF LigCaretList data, which is already parsed. + * Format 1/3 carets return the stored coordinate directly; format 2 carets resolve the + * coordinate from the glyph's contour point at the given index (TrueType only). + * + * @param {number} glyphIndex - Glyph ID of the ligature glyph + * @returns {number[]} Array of caret x-coordinates in font units, or [] if none defined + */ +Font.prototype.getLigatureCarets = function(glyphIndex) { + const ligCaretList = this.tables.gdef && this.tables.gdef.ligCaretList; + if (!ligCaretList) return []; + const covIndex = this.position.getCoverageIndex(ligCaretList.coverage, glyphIndex); + if (covIndex < 0) return []; + const caretValues = ligCaretList.ligGlyphs[covIndex] || []; + const glyph = this.glyphs.get(glyphIndex); + return caretValues.map(cv => { + if (cv.pointindex !== undefined) { + const points = glyph && glyph.points; + return (points && points[cv.pointindex]) ? points[cv.pointindex].x : 0; + } + return cv.coordinate; + }); +}; + Font.prototype.getEnglishName = function(name) { const translations = (this.names.unicode || this.names.macintosh || this.names.windows)[name]; if (translations) { diff --git a/src/layout.mjs b/src/layout.mjs index fa43971e..9605fcf6 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -254,6 +254,16 @@ Layout.prototype = { lookupTable = allLookups[lookupListIndexes[i]]; if (lookupTable.lookupType === lookupType) { tables.push(lookupTable); + } else if (lookupTable.lookupType === 7 || lookupTable.lookupType === 9) { + // Extension lookup (GSUB type 7 / GPOS type 9): the parser stores each subtable as + // { lookupType: innerType, extension: }. Collect subtables whose + // inner type matches and synthesize a plain lookup so consumers need no extension awareness. + const innerSubtables = lookupTable.subtables + .filter(sub => sub.lookupType === lookupType) + .map(sub => sub.extension); + if (innerSubtables.length > 0) { + tables.push(Object.assign({}, lookupTable, { lookupType, subtables: innerSubtables })); + } } } if (tables.length === 0 && create) { diff --git a/src/opentype.mjs b/src/opentype.mjs index 73637009..c6d2225c 100644 --- a/src/opentype.mjs +++ b/src/opentype.mjs @@ -20,6 +20,7 @@ import gvar from './tables/gvar.mjs'; import cvar from './tables/cvar.mjs'; import avar from './tables/avar.mjs'; import hvar from './tables/hvar.mjs'; +import mvar from './tables/mvar.mjs'; import glyf from './tables/glyf.mjs'; import gdef from './tables/gdef.mjs'; import gpos from './tables/gpos.mjs'; @@ -189,6 +190,7 @@ function parseBuffer(buffer, opt={}) { let gsubTableEntry; let hmtxTableEntry; let hvarTableEntry; + let mvarTableEntry; let kernTableEntry; let locaTableEntry; let nameTableEntry; @@ -322,6 +324,9 @@ function parseBuffer(buffer, opt={}) { table = uncompressTable(data, tableEntry); font.tables.svg = svg.parse(table.data, table.offset); break; + case 'MVAR': + mvarTableEntry = tableEntry; + break; default: // console.info(`Skipping unsupported table ${tableEntry.tag}`); break; @@ -431,6 +436,14 @@ function parseBuffer(buffer, opt={}) { font.tables.hvar = hvar.parse(hvarTable.data, hvarTable.offset, font.tables.fvar); } + if (mvarTableEntry) { + if (!fvarTableEntry) { + console.warn('This font provides an MVAR table, but no fvar table, which is required for variable fonts.'); + } + const mvarTable = uncompressTable(data, mvarTableEntry); + font.tables.mvar = mvar.parse(mvarTable.data, mvarTable.offset); + } + if (metaTableEntry) { const metaTable = uncompressTable(data, metaTableEntry); font.tables.meta = meta.parse(metaTable.data, metaTable.offset); diff --git a/src/position.mjs b/src/position.mjs index d83bd795..2f926703 100644 --- a/src/position.mjs +++ b/src/position.mjs @@ -138,7 +138,11 @@ Position.prototype.getMarkToBaseTables = function(script, language) { const lookupListIndexes = featureRecord.feature.lookupListIndexes || []; for (let i = 0; i < lookupListIndexes.length; i++) { const lookup = allLookups[lookupListIndexes[i]]; - if (lookup && lookup.lookupType === 4 && !seen.has(lookup)) { + if (!lookup) continue; + // Type 9 (Extension Positioning) returns inner subtables transparently via parseLookup9, + // so its lookup.subtables already contain the wrapped type's data (e.g. mark-to-base). + if (lookup.lookupType !== 4 && lookup.lookupType !== 9) continue; + if (!seen.has(lookup)) { seen.add(lookup); result.push({ lookup, featureTag: tag }); } @@ -177,7 +181,7 @@ Position.prototype.getMarkToBaseOffset = function(markGlyphIndex, baseGlyphIndex if (lookup.error) continue; const subtables = lookup.subtables || []; for (let j = 0; j < subtables.length; j++) { - const sub = subtables[j]; + const sub = subtables[j].extension !== undefined ? subtables[j].extension : subtables[j]; if (sub.error) continue; if (sub.posFormat !== 1) continue; const markCov = sub.markCoverage; diff --git a/src/tables/gpos.mjs b/src/tables/gpos.mjs index 9f90d82a..f22715fd 100644 --- a/src/tables/gpos.mjs +++ b/src/tables/gpos.mjs @@ -136,7 +136,20 @@ subtableParsers[5] = function parseLookup5() { return { error: 'GPOS Lookup 5 no subtableParsers[6] = function parseLookup6() { return { error: 'GPOS Lookup 6 not supported' }; }; subtableParsers[7] = function parseLookup7() { return { error: 'GPOS Lookup 7 not supported' }; }; subtableParsers[8] = function parseLookup8() { return { error: 'GPOS Lookup 8 not supported' }; }; -subtableParsers[9] = function parseLookup9() { return { error: 'GPOS Lookup 9 not supported' }; }; + +// https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-9-extension-positioning +subtableParsers[9] = function parseLookup9() { + const posFormat = this.parseUShort(); + check.argument(posFormat === 1, 'GPOS Extension Positioning subtable format must be 1'); + const extensionLookupType = this.parseUShort(); + const extensionOffset = this.parseULong(); + const extensionParser = new Parser(this.data, this.offset + extensionOffset); + return { + posFormat: 1, + lookupType: extensionLookupType, + extension: subtableParsers[extensionLookupType].call(extensionParser) + }; +}; // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos function parseGposTable(data, start) { diff --git a/src/tables/mvar.mjs b/src/tables/mvar.mjs new file mode 100644 index 00000000..59cc4f99 --- /dev/null +++ b/src/tables/mvar.mjs @@ -0,0 +1,40 @@ +// The `MVAR` table stores variation deltas for global font metrics in variable fonts. +// https://learn.microsoft.com/en-us/typography/opentype/spec/mvar + +import parse from '../parse.mjs'; +import check from '../check.mjs'; + +function parseMvarTable(data, start) { + start = start || 0; + const p = new parse.Parser(data, start); + const majorVersion = p.parseUShort(); + const minorVersion = p.parseUShort(); + check.argument(majorVersion === 1 && minorVersion === 0, + `Unsupported MVAR table version ${majorVersion}.${minorVersion}`); + p.parseUShort(); // reserved + const valueRecordSize = p.parseUShort(); + const valueRecordCount = p.parseUShort(); + const varStoreOffset = p.parseOffset16(); + + const valueRecords = {}; + for (let i = 0; i < valueRecordCount; i++) { + const tag = p.parseTag(); + const outer = p.parseUShort(); + const inner = p.parseUShort(); + valueRecords[tag] = { outer, inner }; + if (valueRecordSize > 8) { + p.relativeOffset += valueRecordSize - 8; + } + } + + p.relativeOffset = varStoreOffset; + const itemVariationStore = p.parseItemVariationStore(); + + return { valueRecords, itemVariationStore }; +} + +function makeMvarTable() { + console.warn('Writing of mvar tables is not yet supported.'); +} + +export default { parse: parseMvarTable, make: makeMvarTable }; diff --git a/test/layout.spec.mjs b/test/layout.spec.mjs index 7273a67a..cbba8ec0 100644 --- a/test/layout.spec.mjs +++ b/test/layout.spec.mjs @@ -110,6 +110,57 @@ describe('layout.mjs', function() { }); }); + describe('getLookupTables', function() { + function makeTable(lookupType, subtables) { + return { + version: 1, + scripts: [{ tag: 'DFLT', script: { + defaultLangSys: { reserved: 0, reqFeatureIndex: 0xffff, featureIndexes: [0] }, + langSysRecords: [] + } }], + features: [{ tag: 'liga', feature: { lookupListIndexes: [0] } }], + lookups: [{ lookupType, lookupFlag: 0, subtables }] + }; + } + + it('returns a direct lookup matching the requested type', function() { + const subtable = { substFormat: 1 }; + font.tables.gsub = makeTable(4, [subtable]); + const result = layout.getLookupTables('DFLT', 'dflt', 'liga', 4); + assert.equal(result.length, 1); + assert.deepEqual(result[0].subtables, [subtable]); + }); + + it('unwraps a GSUB type-7 extension lookup that wraps the requested type', function() { + const inner = { substFormat: 1, coverage: { format: 1, glyphs: [10] } }; + font.tables.gsub = makeTable(7, [{ substFormat: 1, lookupType: 4, extension: inner }]); + const result = layout.getLookupTables('DFLT', 'dflt', 'liga', 4); + assert.equal(result.length, 1); + assert.equal(result[0].lookupType, 4); + assert.deepEqual(result[0].subtables, [inner]); + }); + + it('does not return a type-7 extension wrapping a different inner type', function() { + const inner = { substFormat: 2 }; + font.tables.gsub = makeTable(7, [{ substFormat: 1, lookupType: 1, extension: inner }]); + const result = layout.getLookupTables('DFLT', 'dflt', 'liga', 4); + assert.equal(result.length, 0); + }); + + it('unwraps a GPOS type-9 extension lookup that wraps the requested type', function() { + const gposLayout = new Layout(font, 'gpos'); + gposLayout.createDefaultTable = function() { return defaultLayoutTable; }; + const inner = { posFormat: 1, pairSets: [] }; + font.tables.gpos = makeTable(9, [{ posFormat: 1, lookupType: 2, extension: inner }]); + // Patch feature tag to 'kern' for this table + font.tables.gpos.features[0].tag = 'kern'; + const result = gposLayout.getLookupTables('DFLT', 'dflt', 'kern', 2); + assert.equal(result.length, 1); + assert.equal(result[0].lookupType, 2); + assert.deepEqual(result[0].subtables, [inner]); + }); + }); + describe('getCoverageIndex', function() { const cov1 = { format: 1, diff --git a/test/tables/gpos.spec.mjs b/test/tables/gpos.spec.mjs index 0fedf140..0e2e3be8 100644 --- a/test/tables/gpos.spec.mjs +++ b/test/tables/gpos.spec.mjs @@ -100,6 +100,37 @@ describe('tables/gpos.mjs', function() { }); }); + //// Lookup type 9 //////////////////////////////////////////////////////// + it('can parse lookup9 Extension wrapping type 1', function() { + // Extension subtable: posFormat=1, extensionLookupType=1, extensionOffset=8 (points past 8-byte header) + // Followed immediately by a SinglePosFormat1 subtable identical to the lookup1 test above. + const data = '0001 0001 00000008' + // extension header (8 bytes) + '0001 0008 0002 FFB0' + // type-1: posFormat=1, covOffset=8, valFmt=2, yPlacement=-80 + '0002 0001 01B3 01BC 0000'; // coverage: format=2, 1 range [0x1B3..0x1BC] + assert.deepEqual(parseLookup(9, data), { + posFormat: 1, + lookupType: 1, + extension: { + posFormat: 1, + coverage: { format: 2, ranges: [{ start: 0x1b3, end: 0x1bc, index: 0 }] }, + value: { yPlacement: -80 } + } + }); + }); + + it('can parse lookup9 Extension wrapping type 2 (pair kerning)', function() { + // Inner type-2 PairPosFormat1 data from the lookup2 test above, wrapped in a type-9 extension. + const innerType2 = '0001 001E 0004 0001 0002 000E 0016' + + '0001 0059 FFE2 FFEC 0001 0059 FFD8 FFE7' + + '0001 0002 002D 0031'; + const data = '0001 0002 00000008' + innerType2; // extension: posFormat=1, innerType=2, offset=8 + const result = parseLookup(9, data); + assert.equal(result.posFormat, 1); + assert.equal(result.lookupType, 2); + assert.equal(result.extension.posFormat, 1); + assert.deepEqual(result.extension.coverage, { format: 1, glyphs: [0x2d, 0x31] }); + }); + it('can parse lookup2 PairPosFormat2', function() { // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#example-5-pairposformat2-subtable const data = '0002 0018 0004 0000 0022 0032 0002 0002 0000 0000 0000 FFCE 0001 0003 0046 0047 0049 0002 0002 0046 0047 0001 0049 0049 0001 0002 0001 006A 006B 0001'; diff --git a/test/tables/mvar.spec.mjs b/test/tables/mvar.spec.mjs new file mode 100644 index 00000000..0f81fdbe --- /dev/null +++ b/test/tables/mvar.spec.mjs @@ -0,0 +1,77 @@ +import assert from 'assert'; +import { unhex } from '../testutil.mjs'; +import mvar from '../../src/tables/mvar.mjs'; + +// Minimal MVAR table with two value records (cpht and xhgt) and one ItemVariationStore +// subtable covering one variation region (wght axis, peak = 1.0). +// +// Layout (offsets from MVAR start): +// 0x00 majorVersion=1, minorVersion=0 +// 0x04 reserved=0, valueRecordSize=8 +// 0x08 valueRecordCount=2, varStoreOffset=0x001C (28) +// 0x0C record[0]: tag="cpht", outer=0, inner=0 +// 0x14 record[1]: tag="xhgt", outer=0, inner=1 +// 0x1C ItemVariationStore: +// +0 format=1 +// +2 variationRegionListOffset=12 (-> +0x0C) +// +6 itemVariationDataCount=1, dataOffsets[0]=22 (-> +0x16) +// +0C VariationRegionList: axisCount=1, regionCount=1, [start=0, peak=1.0, end=1.0] +// +16 IVD[0]: itemCount=2, wordDeltaCount=1, regionIndexCount=1, +// regionIndices=[0], deltas=[50, 20] +const MVAR_HEX = + '0001 0000' + // version 1.0 + '0000' + // reserved + '0008' + // valueRecordSize + '0002' + // valueRecordCount + '001C' + // varStoreOffset = 28 + '63706874' + // "cpht" + '0000 0000' + // outer=0, inner=0 + '78686774' + // "xhgt" + '0000 0001' + // outer=0, inner=1 + // ItemVariationStore + '0001' + // format + '0000000C' + // variationRegionListOffset = 12 + '0001' + // itemVariationDataCount + '00000016' + // itemVariationDataOffsets[0] = 22 + // VariationRegionList + '0001' + // axisCount + '0001' + // regionCount + '0000 4000 4000' + // region[0]: start=0, peak=1.0, end=1.0 (F2Dot14) + // IVD[0] + '0002' + // itemCount + '0001' + // wordDeltaCount (1 int16 delta per item) + '0001' + // regionIndexCount + '0000' + // regionIndices[0] = 0 + '0032' + // delta[0] = 50 (cpht, inner=0) + '0014'; // delta[1] = 20 (xhgt, inner=1) + +describe('tables/mvar.mjs', function() { + describe('parse', function() { + let table; + before(function() { + table = mvar.parse(unhex(MVAR_HEX)); + }); + + it('parses value records by tag', function() { + assert.deepEqual(table.valueRecords['cpht'], { outer: 0, inner: 0 }); + assert.deepEqual(table.valueRecords['xhgt'], { outer: 0, inner: 1 }); + }); + + it('parses the ItemVariationStore with one subtable', function() { + assert.equal(table.itemVariationStore.itemVariationSubtables.length, 1); + }); + + it('parses the IVD deltaSets correctly', function() { + const ivd = table.itemVariationStore.itemVariationSubtables[0]; + assert.equal(ivd.deltaSets[0][0], 50); // cpht delta + assert.equal(ivd.deltaSets[1][0], 20); // xhgt delta + }); + + it('parses the VariationRegionList', function() { + const regions = table.itemVariationStore.variationRegions; + assert.equal(regions.length, 1); + assert.equal(regions[0].regionAxes[0].peakCoord, 1.0); + }); + }); + +}); From b8f6116ef799e5aab9046d376e40047a0a893b92 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 7 May 2026 16:21:14 -0700 Subject: [PATCH 09/13] Save API changes for later PR --- src/font.mjs | 49 +++---------------------------------------------- 1 file changed, 3 insertions(+), 46 deletions(-) diff --git a/src/font.mjs b/src/font.mjs index f4c85054..9c238d44 100644 --- a/src/font.mjs +++ b/src/font.mjs @@ -726,52 +726,9 @@ Font.prototype.drawMetrics = function(ctx, text, x, y, fontSize, options) { }; /** - * Return the variation axes defined by this font's fvar table. - * Each entry has the axis tag, human-readable name, and min/default/max values - * in the units defined by the OpenType spec for that axis (e.g. wght = 100–900, - * wdth = percentage of normal width where 100 is default). - * Returns an empty array for non-variable fonts. - * - * @returns {Array<{ tag: string, name: string, min: number, default: number, max: number }>} - */ -Font.prototype.getVariationAxes = function() { - const axes = this.tables.fvar && this.tables.fvar.axes; - if (!axes) return []; - return axes.map(axis => ({ - tag: axis.tag, - name: axis.name, - min: axis.minValue, - default: axis.defaultValue, - max: axis.maxValue, - })); -}; - -/** - * Get caret positions within a ligature glyph, in font units. - * Returns an array of x-coordinates (one per internal boundary, so a 3-component ligature - * returns 2 values). Uses GDEF LigCaretList data, which is already parsed. - * Format 1/3 carets return the stored coordinate directly; format 2 carets resolve the - * coordinate from the glyph's contour point at the given index (TrueType only). - * - * @param {number} glyphIndex - Glyph ID of the ligature glyph - * @returns {number[]} Array of caret x-coordinates in font units, or [] if none defined - */ -Font.prototype.getLigatureCarets = function(glyphIndex) { - const ligCaretList = this.tables.gdef && this.tables.gdef.ligCaretList; - if (!ligCaretList) return []; - const covIndex = this.position.getCoverageIndex(ligCaretList.coverage, glyphIndex); - if (covIndex < 0) return []; - const caretValues = ligCaretList.ligGlyphs[covIndex] || []; - const glyph = this.glyphs.get(glyphIndex); - return caretValues.map(cv => { - if (cv.pointindex !== undefined) { - const points = glyph && glyph.points; - return (points && points[cv.pointindex]) ? points[cv.pointindex].x : 0; - } - return cv.coordinate; - }); -}; - + * @param {string} + * @return {string} + */ Font.prototype.getEnglishName = function(name) { const translations = (this.names.unicode || this.names.macintosh || this.names.windows)[name]; if (translations) { From dacfbb868404086a229da1f805a4424eb759e57b Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Tue, 12 May 2026 20:21:16 -0700 Subject: [PATCH 10/13] Add parser for GPOS lookup3 for cursive fonts --- src/tables/gpos.mjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tables/gpos.mjs b/src/tables/gpos.mjs index f22715fd..ad4aac67 100644 --- a/src/tables/gpos.mjs +++ b/src/tables/gpos.mjs @@ -76,7 +76,25 @@ subtableParsers[2] = function parseLookup2() { } }; -subtableParsers[3] = function parseLookup3() { return { error: 'GPOS Lookup 3 not supported' }; }; +// https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-3-cursive-attachment-positioning-subtable +subtableParsers[3] = function parseLookup3() { + const start = this.offset + this.relativeOffset; + const posFormat = this.parseUShort(); + check.argument(posFormat === 1, 'GPOS CursivePos subtable format must be 1'); + const coverageOffset = this.parseOffset16(); + const entryExitCount = this.parseUShort(); + // Read all offsets first — they are relative to the start of this subtable + const offsets = []; + for (let i = 0; i < entryExitCount; i++) { + offsets.push({ entry: this.parseOffset16(), exit: this.parseOffset16() }); + } + const coverage = coverageOffset ? new Parser(this.data, start + coverageOffset).parseCoverage() : null; + const entryExitRecords = offsets.map(r => ({ + entryAnchor: r.entry ? new Parser(this.data, start + r.entry).parseAnchor() : null, + exitAnchor: r.exit ? new Parser(this.data, start + r.exit).parseAnchor() : null, + })); + return { posFormat: 1, coverage, entryExitRecords }; +}; // https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-4-mark-to-base-attachment-positioning-subtable // MarkBasePosFormat1: positions combining marks relative to base glyphs via anchor points. From 83662c2aab974c20881c428cb00cf5876a90618c Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Wed, 13 May 2026 13:58:59 -0700 Subject: [PATCH 11/13] Add support for Contextual Alternatives --- docs/index.html | 4 +- src/bidi.mjs | 18 +- src/features/applySubstitution.mjs | 16 +- src/features/featureQuery.mjs | 193 ++++++----- .../latn/latinContextualAlternates.mjs | 69 ++++ src/font.mjs | 2 +- test/calt.spec.mjs | 299 ++++++++++++++++++ test/position.spec.mjs | 48 +++ 8 files changed, 566 insertions(+), 83 deletions(-) create mode 100644 src/features/latn/latinContextualAlternates.mjs create mode 100644 test/calt.spec.mjs create mode 100644 test/position.spec.mjs diff --git a/docs/index.html b/docs/index.html index bc99139d..949c43fd 100755 --- a/docs/index.html +++ b/docs/index.html @@ -36,6 +36,7 @@

opentype.js

+
@@ -145,7 +146,8 @@

Free Software

hinting: form.hinting.checked, features: { liga: form.ligatures.checked, - rlig: form.ligatures.checked + rlig: form.ligatures.checked, + calt: form.contextualAlternates.checked } }; window.fontOptions = Object.assign({}, window.font.defaultRenderOptions, window.fontOptions, options); diff --git a/src/bidi.mjs b/src/bidi.mjs index aefb425e..f1a31c5b 100644 --- a/src/bidi.mjs +++ b/src/bidi.mjs @@ -13,6 +13,7 @@ import ccmpReplacementCheck from './features/ccmp/contextCheck/ccmpReplacement.m import ccmpReplacement from './features/ccmp/ccmpReplacementLigatures.mjs'; import latinWordCheck from './features/latn/contextCheck/latinWord.mjs'; import latinLigature from './features/latn/latinLigatures.mjs'; +import latinContextualAlternates from './features/latn/latinContextualAlternates.mjs'; import thaiWordCheck from './features/thai/contextCheck/thaiWord.mjs'; import thaiGlyphComposition from './features/thai/thaiGlyphComposition.mjs'; import thaiLigatures from './features/thai/thaiLigatures.mjs'; @@ -202,6 +203,16 @@ function applyLatinLigatures() { } } +function applyLatinContextualAlternates() { + if (!this.hasFeatureEnabled('latn', 'calt')) return; + checkGlyphIndexStatus.call(this); + const ranges = this.tokenizer.getContextRanges('latinWord'); + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + latinContextualAlternates.call(this, range); + } +} + function applyUnicodeVariationSequences() { const ranges = this.tokenizer.getContextRanges('unicodeVariationSequence'); for(let i = 0; i < ranges.length; i++) { @@ -248,6 +259,7 @@ Bidi.prototype.applyFeaturesToContexts = function () { } if (this.checkContextReady('latinWord')) { applyLatinLigatures.call(this); + applyLatinContextualAlternates.call(this); } if (this.checkContextReady('arabicSentence')) { reverseArabicSentences.call(this); @@ -303,7 +315,11 @@ Bidi.prototype.getTextGlyphs = function (text) { const token = this.tokenizer.tokens[i]; if (token.state.deleted) continue; const index = token.activeState.value; - indexes.push(Array.isArray(index) ? index[0] : index); + if (Array.isArray(index)) { + for (const g of index) indexes.push(g); + } else { + indexes.push(index); + } } return indexes; }; diff --git a/src/features/applySubstitution.mjs b/src/features/applySubstitution.mjs index 3bfcf61e..f8531b2e 100644 --- a/src/features/applySubstitution.mjs +++ b/src/features/applySubstitution.mjs @@ -20,6 +20,16 @@ function singleSubstitutionFormat2(action, tokens, index) { tokens[index].setState(action.tag, action.substitution); } +/** + * Apply multiple substitution format 1 (one glyph → sequence of glyphs). + * The full sequence is stored on the token; getTextGlyphs expands it into + * separate glyph indices, and latinContextualAlternates uses only the first + * element for context matching in subsequent lookups. + */ +function multipleSubstitutionFormat1(action, tokens, index) { + tokens[index].setState(action.tag, action.substitution); +} + /** * Apply chaining context substitution format 3 * @param {Array} substitutions substitutions @@ -32,8 +42,9 @@ function chainingSubstitutionFormat3(action, tokens, index) { const token = tokens[index + i]; if (Array.isArray(subst)) { if (subst.length){ - // TODO: replace one glyph with multiple glyphs - token.setState(action.tag, subst[0]); + // Store the full multi-glyph sequence on the token so all + // fragment glyphs reach the output stream via getTextGlyphs. + token.setState(action.tag, subst); } else { token.setState('deleted', true); } @@ -65,6 +76,7 @@ function ligatureSubstitutionFormat1(action, tokens, index) { const SUBSTITUTIONS = { 11: singleSubstitutionFormat1, 12: singleSubstitutionFormat2, + 21: multipleSubstitutionFormat1, 61: chainingSubstitutionFormat3, 62: chainingSubstitutionFormat3, 63: chainingSubstitutionFormat3, diff --git a/src/features/featureQuery.mjs b/src/features/featureQuery.mjs index 9e0cc2a0..fae079ab 100644 --- a/src/features/featureQuery.mjs +++ b/src/features/featureQuery.mjs @@ -170,7 +170,7 @@ function applyLookupRecords(lookupRecords, glyphIndex, contextParams, options = return [targetGlyph]; } ); - const allowedTypes = options.allowedTypes || ['11', '12']; + const allowedTypes = options.allowedTypes || ['11', '12', '21']; const substitutions = []; for (let i = 0; i < lookupRecords.length; i++) { const lookupRecord = lookupRecords[i]; @@ -237,7 +237,6 @@ function chainingSubstitutionFormat1(contextParams, subtable) { const lookaheadContext = prepareLookaheadContext(contextParams, rule.input.length); if (!matchSequence(lookaheadContext, rule.lookahead)) continue; - // All context matches! Apply the lookup records return applyLookupRecords.call(this, rule.lookupRecords, glyphIndex, contextParams); } @@ -276,7 +275,6 @@ function chainingSubstitutionFormat2(contextParams, subtable) { const lookaheadContext = prepareLookaheadContext(contextParams, rule.input.length); if (!matchSequence(lookaheadContext, rule.lookahead, getLookaheadClass)) continue; - // All context matches! Apply the lookup records return applyLookupRecords.call(this, rule.lookupRecords, glyphIndex, contextParams); } @@ -321,7 +319,7 @@ function chainingSubstitutionFormat3(contextParams, subtable) { ); if (!contextRulesMatch) return []; return applyLookupRecords.call(this, subtable.lookupRecords, null, contextParams, { - allowedTypes: ['12', '21'], + allowedTypes: ['11', '12', '21'], getTargets: () => { let targets = []; for (let n = 0; n < inputLookups.length; n++) { @@ -740,84 +738,123 @@ FeatureQuery.prototype.lookupFeature = function (query) { const substitutions = [].concat(contextParams.context); for (let l = 0; l < lookups.length; l++) { const lookupTable = lookups[l]; - const subtables = this.getLookupSubtables(lookupTable); - for (let s = 0; s < subtables.length; s++) { - let subtable = subtables[s]; - let substType = this.getSubstitutionType(lookupTable, subtable); - let lookup; + contextParams = processLookupSubtablesAtIndex( + this, + lookupTable, + query.tag, + currentIndex, + substitutions, + contextParams + ); + } + return substitutions.length ? substitutions : null; +}; - if (substType === '71') { - // This is an extension subtable, so lookup the target subtable - substType = this.getSubstitutionType(subtable, subtable.extension); - lookup = this.getLookupMethod(subtable, subtable.extension); - subtable = subtable.extension; - } else { - lookup = this.getLookupMethod(lookupTable, subtable); - } +/** + * Run every subtable of one GSUB lookup at a fixed glyph index. + * Mutates `substitutions`; refreshes context after each subtable. + * + * @param {FeatureQuery} featureQuery + * @param {object} lookupTable + * @param {string} tag + * @param {number} currentIndex + * @param {Array} substitutions + * @param {ContextParams} contextParams + * @returns {ContextParams} + */ +function processLookupSubtablesAtIndex( + featureQuery, + lookupTable, + tag, + currentIndex, + substitutions, + contextParams +) { + const subtables = featureQuery.getLookupSubtables(lookupTable); + for (let s = 0; s < subtables.length; s++) { + let subtable = subtables[s]; + let substType = featureQuery.getSubstitutionType(lookupTable, subtable); + let lookup; + + if (substType === '71') { + // This is an extension subtable, so lookup the target subtable + substType = featureQuery.getSubstitutionType(subtable, subtable.extension); + lookup = featureQuery.getLookupMethod(subtable, subtable.extension); + subtable = subtable.extension; + } else { + lookup = featureQuery.getLookupMethod(lookupTable, subtable); + } - let substitution; - switch (substType) { - case '11': - substitution = lookup(contextParams.current); - if (substitution) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: 11, tag: query.tag, substitution - })); - } - break; - case '12': - substitution = lookup(contextParams.current); - if (substitution) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: 12, tag: query.tag, substitution - })); - } - break; - case '61': - case '62': - case '63': - substitution = lookup(contextParams); - if (Array.isArray(substitution) && substitution.length) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: parseInt(substType), tag: query.tag, substitution - })); - } - break; - case '41': - substitution = lookup(contextParams); - if (substitution) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: 41, tag: query.tag, substitution - })); - } - break; - case '21': - substitution = lookup(contextParams.current); - if (substitution) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: 21, tag: query.tag, substitution - })); - } - break; - case '51': - case '52': - case '53': - substitution = lookup(contextParams); - if (Array.isArray(substitution) && substitution.length) { - substitutions.splice(currentIndex, 1, new SubstitutionAction({ - id: parseInt(substType), - tag: query.tag, - substitution - })); - } - break; - } - contextParams = new ContextParams(substitutions, currentIndex); - if (Array.isArray(substitution) && !substitution.length) continue; - substitution = null; + const id = parseInt(substType); + let substitution; + switch (substType) { + case '11': + case '12': + case '21': + substitution = lookup(contextParams.current); + if (substitution) { + substitutions.splice(currentIndex, 1, new SubstitutionAction({ + id, tag, substitution + })); + } + break; + case '41': + substitution = lookup(contextParams); + if (substitution) { + substitutions.splice(currentIndex, 1, new SubstitutionAction({ + id, tag, substitution + })); + } + break; + case '51': + case '52': + case '53': + case '61': + case '62': + case '63': + substitution = lookup(contextParams); + if (Array.isArray(substitution) && substitution.length) { + substitutions.splice(currentIndex, 1, new SubstitutionAction({ + id, tag, substitution + })); + } + break; } + + contextParams = new ContextParams(substitutions, currentIndex); + if (Array.isArray(substitution) && !substitution.length) continue; + substitution = null; } - return substitutions.length ? substitutions : null; + return contextParams; +} + + +/** + * Apply a single GSUB lookup table at the current position in contextParams. + * + * Used by features that must apply lookups one-at-a-time over the entire + * glyph string (OpenType spec ordering: each lookup runs over all positions + * before the next lookup starts). This lets later lookups see the substituted + * forms produced by earlier ones. + * + * @param {object} lookupTable - a parsed GSUB lookup table + * @param {string} tag - the feature tag (e.g. 'calt') + * @param {ContextParams} contextParams - context built from the full glyph string + * @returns {SubstitutionAction|null} + */ +FeatureQuery.prototype.applyLookupTableAtPosition = function(lookupTable, tag, contextParams) { + const currentIndex = contextParams.index; + const substitutions = [].concat(contextParams.context); + processLookupSubtablesAtIndex( + this, + lookupTable, + tag, + currentIndex, + substitutions, + contextParams + ); + const result = substitutions[currentIndex]; + return result instanceof SubstitutionAction ? result : null; }; /** diff --git a/src/features/latn/latinContextualAlternates.mjs b/src/features/latn/latinContextualAlternates.mjs new file mode 100644 index 00000000..da758088 --- /dev/null +++ b/src/features/latn/latinContextualAlternates.mjs @@ -0,0 +1,69 @@ +/** + * Apply Latin contextual alternates (calt) to a range of tokens. + * + * Unlike ligature processing, calt rules in cursive fonts routinely check + * across word boundaries — e.g. "use initial form when preceded by a space" + * or "use final form when followed by a space or end-of-text". The standard + * latinWord range only spans a single word, so backtrack for the first letter + * and lookahead for the last letter are empty, and those rules never fire. + * + * This module builds ContextParams from the *full* token stream, using each + * range token's absolute index in that stream. + * + * OpenType ordering: each lookup in the calt feature list must be applied to + * the entire glyph string before the next lookup starts. Fonts like Playwrite + * use a two-stage calt: early lookups (e.g. 40, 44) select ini/med/fin forms, + * and a later lookup (e.g. 48) expands those forms into [glyph, stroke-connector] + * pairs by checking what already-substituted glyph follows. Applying all + * lookups at once per position (the naive approach) breaks the second stage + * because the lookahead still has the pre-substitution glyph IDs. + */ + +import { ContextParams } from '../../tokenizer.mjs'; +import { SubstitutionAction } from '../featureQuery.mjs'; +import applySubstitution from '../applySubstitution.mjs'; + +/** + * Apply calt to every token in `range`, using the full token stream as context. + * Each lookup in the feature is applied to all positions before the next lookup + * starts, matching the OpenType specification's required ordering. + * @param {ContextRange} range a latinWord context range + */ +function latinContextualAlternates(range) { + const script = 'latn'; + const allTokens = this.tokenizer.tokens; + const rangeTokens = this.tokenizer.getRangeTokens(range); + + const feature = this.query.getFeature({ tag: 'calt', script }); + if (!feature || feature.FAIL) return; + const lookupTables = this.query.getFeatureLookups(feature); + + for (let l = 0; l < lookupTables.length; l++) { + // Rebuild the full-text glyph-index array from current token state at + // the start of each lookup pass so this pass sees all changes from + // prior passes. For multi-glyph tokens (array value) use the first + // glyph for context matching; getTextGlyphs expands the full sequence. + let fullContext = allTokens.map(token => { + const v = token.activeState.value; + return Array.isArray(v) ? v[0] : v; + }); + + for (let i = 0; i < rangeTokens.length; i++) { + const fullIndex = range.startIndex + i; + const contextParams = new ContextParams(fullContext, fullIndex); + + const action = this.query.applyLookupTableAtPosition(lookupTables[l], 'calt', contextParams); + if (action instanceof SubstitutionAction) { + applySubstitution(action, allTokens, fullIndex); + // Reflect the change so subsequent positions in this pass see + // the updated glyph. + fullContext = allTokens.map(token => { + const v = token.activeState.value; + return Array.isArray(v) ? v[0] : v; + }); + } + } + } +} + +export default latinContextualAlternates; diff --git a/src/font.mjs b/src/font.mjs index 9c238d44..d828dba2 100644 --- a/src/font.mjs +++ b/src/font.mjs @@ -356,7 +356,7 @@ Font.prototype.defaultRenderOptions = { * and shouldn't be turned off when rendering arabic text. */ { script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] }, - { script: 'latn', tags: ['liga', 'rlig'] }, + { script: 'latn', tags: ['liga', 'rlig', 'calt'] }, { script: 'thai', tags: ['liga', 'rlig', 'ccmp'] }, ], hinting: false, diff --git a/test/calt.spec.mjs b/test/calt.spec.mjs new file mode 100644 index 00000000..0fd17d01 --- /dev/null +++ b/test/calt.spec.mjs @@ -0,0 +1,299 @@ +import assert from 'assert'; +import { Font, Glyph, Path } from '../src/opentype.mjs'; + +/** + * Build a two-stage calt font that simulates the Playwrite pattern: + * + * Stage 1 (lookup 0, type 1): a(2) → a.med(3) — applies to every 'a' + * Stage 2 (lookup 1, type 6): when a.med(3) is followed by a.med(3), + * expand via sub-lookup 2 (type 2): a.med(3) → [a.med(3), cnct(4)] + * + * Feature calt lists lookups [0, 1]. Sub-lookup 2 is not directly in + * the feature; it is only referenced by the chaining context. + * + * The regression: if all lookups are applied at once per position (old + * behaviour), stage 2 can never see a.med in the lookahead because stage 1 + * hasn't yet run on positions to the right. With the fix, stage 1 runs + * over the entire string first, so stage 2 sees the correct lookahead. + */ +function buildMultiStageCaltFont() { + const glyphs = [ + new Glyph({ name: '.notdef', advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'space', unicode: 0x20, advanceWidth: 250, path: new Path() }), + new Glyph({ name: 'a', unicode: 0x61, advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'a.med', advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'cnct', advanceWidth: 0, path: new Path() }), + ]; + + const font = new Font({ + familyName: 'MultiStageCaltTest', + styleName: 'Regular', + unitsPerEm: 1000, + ascender: 800, + descender: -200, + glyphs, + }); + + font.tables.gsub = { + version: 1, + scripts: [{ + tag: 'latn', + script: { + defaultLangSys: { reserved: 0, reqFeatureIndex: 0xffff, featureIndexes: [0] }, + langSysRecords: [], + }, + }], + features: [{ + tag: 'calt', + feature: { params: 0, lookupListIndexes: [0, 1] }, + }], + lookups: [ + // Lookup 0 (stage 1): single substitution — a(2) → a.med(3) + { + lookupType: 1, lookupFlag: 0, + subtables: [{ + substFormat: 2, + coverage: { format: 1, glyphs: [2] }, + substitute: [3], + }], + }, + // Lookup 1 (stage 2): chaining context format 3 + // input=[a.med(3)], lookahead=[a.med(3)] → expand via sub-lookup 2 + { + lookupType: 6, lookupFlag: 0, + subtables: [{ + substFormat: 3, + backtrackCoverage: [], + inputCoverage: [{ format: 1, glyphs: [3] }], + lookaheadCoverage:[{ format: 1, glyphs: [3] }], + lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 2 }], + }], + }, + // Lookup 2 (referenced by lookup 1): multiple substitution — a.med(3) → [a.med(3), cnct(4)] + { + lookupType: 2, lookupFlag: 0, + subtables: [{ + substFormat: 1, + coverage: { format: 1, glyphs: [3] }, + sequences: [[3, 4]], + }], + }, + ], + }; + + return font; +} + +/** + * Build a minimal Latin font whose calt feature exercises cross-word-boundary + * chaining context substitution (GSUB lookup type 6, format 3). + * + * Glyph layout: + * 0 .notdef + * 1 space (U+0020) — present so calt backtrack/lookahead can see word edges + * 2 a (U+0061) + * 3 b (U+0062) + * 4 a.fina — alternate 'a' used at the end of a word (followed by space) + * 5 b.init — alternate 'b' used at the start of a word (preceded by space) + * + * GSUB calt lookup (lookupType 6) has two format-3 subtables: + * Rule A: [] backtrack, [a(2)] input, [space(1)] lookahead → a → a.fina + * Rule B: [space(1)] backtrack, [b(3)] input, [] lookahead → b → b.init + * + * Sub-lookups: + * Lookup 0 (type 1 fmt 2): a(2) → a.fina(4) + * Lookup 1 (type 1 fmt 2): b(3) → b.init(5) + * Lookup 2 (type 6 fmt 3): calt chaining context (rules A and B above) + */ +function buildCaltFont() { + const glyphs = [ + new Glyph({ name: '.notdef', advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'space', unicode: 0x20, advanceWidth: 250, path: new Path() }), + new Glyph({ name: 'a', unicode: 0x61, advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'b', unicode: 0x62, advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'a.fina', advanceWidth: 500, path: new Path() }), + new Glyph({ name: 'b.init', advanceWidth: 500, path: new Path() }), + ]; + + const font = new Font({ + familyName: 'CaltCrossWordTest', + styleName: 'Regular', + unitsPerEm: 1000, + ascender: 800, + descender: -200, + glyphs, + }); + + font.tables.gsub = { + version: 1, + scripts: [{ + tag: 'latn', + script: { + defaultLangSys: { + reserved: 0, + reqFeatureIndex: 0xffff, + featureIndexes: [0], + }, + langSysRecords: [], + }, + }], + features: [{ + tag: 'calt', + feature: { params: 0, lookupListIndexes: [2] }, + }], + lookups: [ + // Lookup 0: single substitution — a(2) → a.fina(4) + { + lookupType: 1, lookupFlag: 0, + subtables: [{ + substFormat: 2, + coverage: { format: 1, glyphs: [2] }, + substitute: [4], + }], + }, + // Lookup 1: single substitution — b(3) → b.init(5) + { + lookupType: 1, lookupFlag: 0, + subtables: [{ + substFormat: 2, + coverage: { format: 1, glyphs: [3] }, + substitute: [5], + }], + }, + // Lookup 2: calt — chaining context format 3 + { + lookupType: 6, lookupFlag: 0, + subtables: [ + // Rule A: 'a' when the next glyph is a space → use a.fina + { + substFormat: 3, + backtrackCoverage: [], + inputCoverage: [{ format: 1, glyphs: [2] }], + lookaheadCoverage: [{ format: 1, glyphs: [1] }], + lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 0 }], + }, + // Rule B: 'b' when the previous glyph is a space → use b.init + { + substFormat: 3, + backtrackCoverage: [{ format: 1, glyphs: [1] }], + inputCoverage: [{ format: 1, glyphs: [3] }], + lookaheadCoverage: [], + lookupRecords: [{ sequenceIndex: 0, lookupListIndex: 1 }], + }, + ], + }, + ], + }; + + return font; +} + +describe('calt — cross-word-boundary contextual alternates', function () { + let font; + + beforeEach(function () { + font = buildCaltFont(); + }); + + it('applies both boundary alternates across a word space', function () { + // "a b": 'a' is the last glyph in the first word (lookahead = space) so + // Rule A fires; 'b' is the first glyph in the second word (backtrack = space) + // so Rule B fires. + // + // Before the cross-word-boundary fix, each latinWord range was processed + // with context limited to its own tokens, so the first letter's backtrack + // and the last letter's lookahead were both empty — neither rule matched. + const indexes = font.stringToGlyphIndexes('a b'); + assert.deepEqual(indexes, [ + 4, // a.fina (Rule A: lookahead is space) + 1, // space (unchanged) + 5, // b.init (Rule B: backtrack is space) + ]); + }); + + it('does not substitute when no word boundary is present', function () { + // "ab": the two letters share a word, no space in backtrack or lookahead. + const indexes = font.stringToGlyphIndexes('ab'); + assert.deepEqual(indexes, [ + 2, // a (unsubstituted) + 3, // b (unsubstituted) + ]); + }); + + it('applies the final alternate at the end of a word before a space', function () { + // "a ": Rule A fires — 'a' lookahead contains the space glyph. + const indexes = font.stringToGlyphIndexes('a '); + assert.deepEqual(indexes, [ + 4, // a.fina + 1, // space + ]); + }); + + it('applies the initial alternate at the start of a word after a space', function () { + // " b": Rule B fires — 'b' backtrack (reversed) starts with the space glyph. + const indexes = font.stringToGlyphIndexes(' b'); + assert.deepEqual(indexes, [ + 1, // space + 5, // b.init + ]); + }); + + it('handles a format-3 calt rule whose sub-lookup uses single substitution format 1 (delta)', function () { + // Fonts like Playwright use type-1 format-1 (delta) sub-lookups inside + // their calt chaining context rules. applyLookupRecords previously threw + // "Substitution type 11 is not supported in chaining substitution" because + // '11' was absent from chainingSubstitutionFormat3's allowedTypes list. + // + // This test replaces lookup 0 with a format-1 (delta) variant: + // a(2) + delta(2) → a.fina(4) + // The calt format-3 rule is otherwise identical to Rule A in buildCaltFont. + const deltaFont = buildCaltFont(); + deltaFont.tables.gsub.lookups[0] = { + lookupType: 1, lookupFlag: 0, + subtables: [{ + substFormat: 1, + coverage: { format: 1, glyphs: [2] }, + deltaGlyphId: 2, // a(2) + 2 = a.fina(4) + }], + }; + + const indexes = deltaFont.stringToGlyphIndexes('a '); + assert.deepEqual(indexes, [ + 4, // a.fina via delta sub-lookup (substFormat 1) + 1, // space + ]); + }); +}); + +describe('calt — multi-stage lookup ordering', function () { + // Simulates the Playwrite pattern: stage-1 lookups convert base glyphs to + // contextual forms, stage-2 lookups then expand those forms into + // [glyph, connector] pairs by checking what already-substituted glyph + // follows. Stage 2 can only fire after stage 1 has run over the whole + // string — which requires each lookup to be applied to all positions + // before the next lookup starts. + + let font; + beforeEach(function () { font = buildMultiStageCaltFont(); }); + + it('inserts a connector between two consecutive contextual forms', function () { + // Both a's become a.med in stage 1. In stage 2, the first a.med has + // a.med in its lookahead, so it expands to [a.med, cnct]. + const indexes = font.stringToGlyphIndexes('aa'); + assert.deepEqual(indexes, [3, 4, 3]); + }); + + it('does not insert a connector after the last contextual form', function () { + // Single a → a.med in stage 1, but stage 2 requires a.med in the + // lookahead, which is absent, so no connector is added. + const indexes = font.stringToGlyphIndexes('a'); + assert.deepEqual(indexes, [3]); + }); + + it('inserts connectors between all consecutive contextual forms', function () { + // Three a's all become a.med in stage 1. Stage 2 inserts a connector + // after each a.med that has another a.med following it. + const indexes = font.stringToGlyphIndexes('aaa'); + assert.deepEqual(indexes, [3, 4, 3, 4, 3]); + }); +}); diff --git a/test/position.spec.mjs b/test/position.spec.mjs new file mode 100644 index 00000000..6464289a --- /dev/null +++ b/test/position.spec.mjs @@ -0,0 +1,48 @@ +import assert from 'assert'; +import { readFileSync } from 'fs'; +import { parse } from '../src/opentype.mjs'; + +function loadFont(path) { + return parse(readFileSync(path).buffer); +} + + +describe('Position.getMarkToBaseTables / getMarkToBaseOffset (Jomhuria)', function () { + let font; + before(function () { + font = loadFont('test/fonts/Jomhuria-Regular.ttf'); + }); + + it('finds mark-to-base lookup tables under the arab script', function () { + const tables = font.position.getMarkToBaseTables('arab'); + assert.ok(tables.length > 0, 'expected at least one mark-to-base lookup table'); + }); + + it('returns undefined for a mark/base pair with no defined positioning', function () { + // Glyph 0 (.notdef) will not be in any mark or base coverage + const offset = font.position.getMarkToBaseOffset(0, 1, 500, 'arab'); + assert.strictEqual(offset, undefined); + }); + + it('returns a finite xOffset and yOffset for a known mark/base pair', function () { + // uni0615 (mark, glyph 417) over uni25CC dotted circle (base, glyph 842, advanceWidth 1392) + const offset = font.position.getMarkToBaseOffset(417, 842, 1392, 'arab'); + assert.ok(offset, 'expected a positioning offset'); + assert.ok(Number.isFinite(offset.xOffset), 'xOffset should be a finite number'); + assert.ok(Number.isFinite(offset.yOffset), 'yOffset should be a finite number'); + assert.strictEqual(offset.xOffset, -1046); + assert.strictEqual(offset.yOffset, -1356); + }); + + it('returns different offsets for different marks over the same base', function () { + // uni0615 and uni0617 are distinct marks — their anchors differ + const base = 842, adv = 1392; + const offset1 = font.position.getMarkToBaseOffset(417, base, adv, 'arab'); // uni0615 + const offset2 = font.position.getMarkToBaseOffset(419, base, adv, 'arab'); // uni0617 + assert.ok(offset1 && offset2, 'both marks should have positioning data'); + assert.ok( + offset1.xOffset !== offset2.xOffset || offset1.yOffset !== offset2.yOffset, + 'different marks should produce different offsets' + ); + }); +}); From f864026bf235d320354a48387eedf66eb85828b8 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Wed, 13 May 2026 14:47:29 -0700 Subject: [PATCH 12/13] Add "calt" to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bbc759c..eb0097f2 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Options is an optional _{GlyphRenderOptions}_ object containing: * `language`: language system used to determine which features to apply (default: `"dflt"`) * `kerning`: if true takes kerning information into account (default: `true`) * `features`: an object with [OpenType feature tags](https://docs.microsoft.com/en-us/typography/opentype/spec/featuretags) as keys, and a boolean value to enable each feature. -Currently only ligature features `"liga"` and `"rlig"` are supported (default: `true`). +Currently only ligature features `"liga"`, `"rlig"` and `"calt"` are supported (default: `true`). * `hinting`: if true uses TrueType font hinting if available (default: `false`). * `colorFormat`: the format colors are converted to for rendering (default: `"hexa"`). Can be `"rgb"`/`"rgba"` for `rgb()`/`rgba()` output, `"hex"`/`"hexa"` for 6/8 digit hex colors, or `"hsl"`/`"hsla"` for `hsl()`/`hsla()` output. `"bgra"` outputs an object with r, g, b, a keys (r/g/b from 0-255, a from 0-1). `"raw"` outputs an integer as used in the CPAL table. * `fill`: font color, the color used to render each glyph (default: `"black"`) From a865096cc9a81aaab1d612646abca85ec4b6dbe2 Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Mon, 22 Jun 2026 17:46:32 -0700 Subject: [PATCH 13/13] Fix logic in handling of an empty private points list in gvar --- src/parse.mjs | 19 +++++++++++++++---- src/variationprocessor.mjs | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/parse.mjs b/src/parse.mjs index dd92906c..94c2d67e 100644 --- a/src/parse.mjs +++ b/src/parse.mjs @@ -931,13 +931,15 @@ Parser.prototype.parseTupleVariationStore = function(tableOffset, axisCount, fla for(let h = 0; h < count; h++) { const header = headers[h]; - header.privatePoints = []; this.relativeOffset = serializedDataOffset; if(flavor === 'cvar' && !header.peakTuple) { console.warn('An embedded peak tuple is required in TupleVariationHeaders for the cvar table.'); } + // Note that privatePoints can be undefined, which means that we + // will use the shared points, an empty array, which means we will + // use all points, or an array of points. if(header.flags.privatePointNumbers) { header.privatePoints = this.parsePackedPointNumbers(); } @@ -953,7 +955,7 @@ Parser.prototype.parseTupleVariationStore = function(tableOffset, axisCount, fla const parseDeltas = () => { let pointsCount = 0; if(flavor === 'gvar') { - pointsCount = header.privatePoints.length || sharedPoints.length; + pointsCount = header.privatePoints ? header.privatePoints.length : sharedPoints.length; if(!pointsCount) { const glyph = glyphs.get(glyphIndex); // make sure the path is available @@ -1056,13 +1058,22 @@ Parser.prototype.parsePackedPointNumbers = function() { if (countByte1 >= 128) { // High bit is set, need to read a second byte and combine. const countByte2 = this.parseByte(); - + // Combine as big-endian uint16, with high bit of the first byte cleared. // This is done by masking the first byte with 0x7F (to clear the high bit) // and then shifting it left by 8 bits before adding the second byte. totalPointCount = ((countByte1 & masks.POINT_RUN_COUNT_MASK) << 8) | countByte2; } - + + // A point count of 0 is a special signal meaning "all points in the glyph are + // referenced" (no point-number data follows). This is distinct from a list that + // happens to contain no points, so return null to let callers apply deltas to + // every point rather than falling back to the shared point numbers. + // https://learn.microsoft.com/en-us/typography/opentype/spec/otvarcommonformats#packed-point-numbers + // if (totalPointCount === 0) { + // return null; + // } + let lastPoint = 0; while (points.length < totalPointCount) { const controlByte = this.parseByte(); diff --git a/src/variationprocessor.mjs b/src/variationprocessor.mjs index 4cf4fa8b..73269b61 100644 --- a/src/variationprocessor.mjs +++ b/src/variationprocessor.mjs @@ -301,7 +301,7 @@ export class VariationProcessor { continue; } - const tuplePoints = header.privatePoints.length ? header.privatePoints: sharedPoints; + const tuplePoints = header.privatePoints || sharedPoints; if(flavor === 'gvar' && args.glyph && args.glyph.isComposite) { /** @TODO: composite glyphs that are not explicitly targeted in the gvar table