diff --git a/src/bidi.js b/src/bidi.js index 7d98c665..61fc792d 100644 --- a/src/bidi.js +++ b/src/bidi.js @@ -9,6 +9,8 @@ import arabicWordCheck from './features/arab/contextCheck/arabicWord.js'; import arabicSentenceCheck from './features/arab/contextCheck/arabicSentence.js'; import arabicPresentationForms from './features/arab/arabicPresentationForms.js'; import arabicRequiredLigatures from './features/arab/arabicRequiredLigatures.js'; +import ccmpReplacementCheck from './features/ccmp/contextCheck/ccmpReplacement.js'; +import ccmpReplacement from './features/ccmp/ccmpReplacementLigatures.js'; import latinWordCheck from './features/latn/contextCheck/latinWord.js'; import latinLigature from './features/latn/latinLigatures.js'; import thaiWordCheck from './features/thai/contextCheck/thaiWord.js'; @@ -42,6 +44,7 @@ Bidi.prototype.setText = function (text) { * arabic sentence check for adjusting arabic layout */ Bidi.prototype.contextChecks = ({ + ccmpReplacementCheck, latinWordCheck, arabicWordCheck, arabicSentenceCheck, @@ -64,6 +67,7 @@ function registerContextChecker(checkId) { * tokenize text input */ function tokenizeText() { + registerContextChecker.call(this, 'ccmpReplacement'); registerContextChecker.call(this, 'latinWord'); registerContextChecker.call(this, 'arabicWord'); registerContextChecker.call(this, 'arabicSentence'); @@ -160,6 +164,18 @@ function applyArabicPresentationForms() { } } +/** + * Apply ccmp replacement + */ +function applyCcmpReplacement() { + checkGlyphIndexStatus.call(this); + const ranges = this.tokenizer.getContextRanges('ccmpReplacement'); + for(let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + ccmpReplacement.call(this, range); + } +} + /** * Apply required arabic ligatures */ @@ -223,6 +239,9 @@ Bidi.prototype.checkContextReady = function (contextId) { * https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-ccmp */ Bidi.prototype.applyFeaturesToContexts = function () { + if (this.checkContextReady('ccmpReplacement')) { + applyCcmpReplacement.call(this); + } if (this.checkContextReady('arabicWord')) { applyArabicPresentationForms.call(this); applyArabicRequireLigatures.call(this); diff --git a/src/features/applySubstitution.js b/src/features/applySubstitution.js index ce507768..eb649d87 100644 --- a/src/features/applySubstitution.js +++ b/src/features/applySubstitution.js @@ -30,6 +30,15 @@ function chainingSubstitutionFormat3(action, tokens, index) { for(let i = 0; i < action.substitution.length; i++) { const subst = action.substitution[i]; 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]); + } else { + token.setState('deleted', true); + } + continue; + } token.setState(action.tag, subst); } } @@ -57,7 +66,9 @@ const SUBSTITUTIONS = { 11: singleSubstitutionFormat1, 12: singleSubstitutionFormat2, 63: chainingSubstitutionFormat3, - 41: ligatureSubstitutionFormat1 + 41: ligatureSubstitutionFormat1, + 51: chainingSubstitutionFormat3, + 53: chainingSubstitutionFormat3 }; /** diff --git a/src/features/ccmp/ccmpReplacementLigatures.js b/src/features/ccmp/ccmpReplacementLigatures.js new file mode 100644 index 00000000..a6703769 --- /dev/null +++ b/src/features/ccmp/ccmpReplacementLigatures.js @@ -0,0 +1,47 @@ +import { ContextParams } from '../../tokenizer.js'; +import applySubstitution from '../applySubstitution.js'; + +// @TODO: use commonFeatureUtils.js for reduction of code duplication +// once #564 has been merged. + +/** + * Update context params + * @param {any} tokens a list of tokens + * @param {number} index current item index + */ +function getContextParams(tokens, index) { + const context = tokens.map(token => token.activeState.value); + return new ContextParams(context, index || 0); +} + +/** + * Apply ccmp replacement ligatures to a context range + * @param {ContextRange} range a range of tokens + */ +function ccmpReplacementLigatures(range) { + const script = 'delf'; + const tag = 'ccmp'; + let tokens = this.tokenizer.getRangeTokens(range); + let contextParams = getContextParams(tokens); + for(let index = 0; index < contextParams.context.length; index++) { + if (!this.query.getFeature({tag, script, contextParams})){ + continue; + } + contextParams.setCurrentIndex(index); + let substitutions = this.query.lookupFeature({ + tag, script, contextParams + }); + if (substitutions.length) { + for(let i = 0; i < substitutions.length; i++) { + const action = substitutions[i]; + applySubstitution(action, tokens, index); + } + contextParams = getContextParams(tokens); + } + } +} + +export default ccmpReplacementLigatures; + + + diff --git a/src/features/ccmp/contextCheck/ccmpReplacement.js b/src/features/ccmp/contextCheck/ccmpReplacement.js new file mode 100644 index 00000000..07b44da1 --- /dev/null +++ b/src/features/ccmp/contextCheck/ccmpReplacement.js @@ -0,0 +1,12 @@ +function ccmpReplacementStartCheck(contextParams) { + return contextParams.index === 0 && contextParams.context.length > 1; +} + +function ccmpReplacementEndCheck(contextParams) { + return contextParams.index === contextParams.context.length - 1; +} + +export default { + startCheck: ccmpReplacementStartCheck, + endCheck: ccmpReplacementEndCheck +}; diff --git a/src/features/featureQuery.js b/src/features/featureQuery.js index 93614a18..3cef6422 100644 --- a/src/features/featureQuery.js +++ b/src/features/featureQuery.js @@ -203,6 +203,94 @@ function ligatureSubstitutionFormat1(contextParams, subtable) { return null; } +/** + * Handle context substitution - format 1 + * @param {ContextParams} contextParams context params to lookup + */ +function contextSubstitutionFormat1(contextParams, subtable) { + let glyphId = contextParams.current; + let ligSetIndex = lookupCoverage(glyphId, subtable.coverage); + if (ligSetIndex === -1) + return null; + for (const ruleSet of subtable.ruleSets) { + for (const rule of ruleSet) { + let matched = true; + for (let i = 0; i < rule.input.length; i++) { + if (contextParams.lookahead[i] !== rule.input[i]){ + matched = false; + break; + } + } + if (matched) { + let substitutions = []; + substitutions.push(glyphId); + for (let i = 0; i < rule.input.length; i++) { + substitutions.push(rule.input[i]); + } + const parser = (substitutions, lookupRecord)=>{ + const {lookupListIndex,sequenceIndex} = lookupRecord; + const {subtables} = this.getLookupByIndex(lookupListIndex); + for (const subtable of subtables){ + let ligSetIndex = lookupCoverage(substitutions[sequenceIndex], subtable.coverage); + if (ligSetIndex !== -1){ + substitutions[sequenceIndex] = subtable.deltaGlyphId; + } + } + }; + + for (let i = 0; i < rule.lookupRecords.length; i++) { + const lookupRecord = rule.lookupRecords[i]; + parser(substitutions, lookupRecord); + } + + return substitutions; + } + } + } + return null; +} + +/** + * Handle context substitution - format 3 + * @param {ContextParams} contextParams context params to lookup + */ +function contextSubstitutionFormat3(contextParams, subtable) { + let substitutions = []; + + for (let i = 0; i < subtable.coverages.length; i++){ + const lookupRecord = subtable.lookupRecords[i]; + const coverage = subtable.coverages[i]; + + let glyphIndex = contextParams.context[contextParams.index + lookupRecord.sequenceIndex]; + let ligSetIndex = lookupCoverage(glyphIndex, coverage); + if (ligSetIndex === -1){ + return null; + } + let lookUp = this.font.tables.gsub.lookups[lookupRecord.lookupListIndex]; + for (let i = 0; i < lookUp.subtables.length; i++){ + let subtable = lookUp.subtables[i]; + let ligSetIndex = lookupCoverage(glyphIndex, subtable.coverage); + if (ligSetIndex === -1) + return null; + switch (lookUp.lookupType) { + case 1:{ + let ligature = subtable.substitute[ligSetIndex]; + substitutions.push(ligature); + break; + } + case 2:{ + let ligatureSet = subtable.sequences[ligSetIndex]; + substitutions.push(ligatureSet); + break; + } + default: + break; + } + } + } + return substitutions; +} + /** * Handle decomposition substitution - format 1 * @param {number} glyphIndex glyph index @@ -327,8 +415,17 @@ FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) { return glyphIndex => decompositionSubstitutionFormat1.apply( this, [glyphIndex, subtable] ); + case '51': + return contextParams => contextSubstitutionFormat1.apply( + this, [contextParams, subtable] + ); + case '53': + return contextParams => contextSubstitutionFormat3.apply( + this, [contextParams, subtable] + ); default: throw new Error( + `substitutionType : ${substitutionType} ` + `lookupType: ${lookupTable.lookupType} - ` + `substFormat: ${subtable.substFormat} ` + 'is not yet supported' @@ -435,6 +532,17 @@ FeatureQuery.prototype.lookupFeature = function (query) { })); } break; + case '51': + 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; diff --git a/test/bidi.js b/test/bidi.js index 2f2b8601..a770a36d 100644 --- a/test/bidi.js +++ b/test/bidi.js @@ -143,4 +143,38 @@ describe('bidi.js', function() { }); }); }); + + describe('noto emoji with ccmp', () => { + let notoEmojiFont; + before(()=> { + notoEmojiFont = loadSync('./test/fonts/noto-emoji.ttf'); + }); + + describe('ccmp features', () => { + + it('shape emoji with sub_0', () => { + let options = { + kerning: true, + language: 'dflt', + features: [ + { script: 'DFLT', tags: ['ccmp'] }, + ] + }; + let glyphIndexes = notoEmojiFont.stringToGlyphIndexes('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸ‘¨β€πŸ‘©β€πŸ‘§',options); + assert.deepEqual(glyphIndexes, [1463,1462]); + }); + + it('shape emoji with sub_5', () => { + let options = { + kerning: true, + language: 'dflt', + features: [ + { script: 'DFLT', tags: ['ccmp'] }, + ] + }; + let glyphIndexes = notoEmojiFont.stringToGlyphIndexes('πŸ‡ΊπŸ‡Ί',options); + assert.deepEqual(glyphIndexes, [1850]); + }); + }); + }); }); diff --git a/test/featureQuery.js b/test/featureQuery.js index 8e7bfc1c..397a907d 100644 --- a/test/featureQuery.js +++ b/test/featureQuery.js @@ -9,6 +9,8 @@ describe('featureQuery.js', function() { let arabicFont; let arabicFontChanga; let latinFont; + let sub5Font; + let query = {}; before(function () { /** @@ -23,6 +25,11 @@ describe('featureQuery.js', function() { */ latinFont = loadSync('./test/fonts/FiraSansMedium.woff'); query.latin = new FeatureQuery(latinFont); + /** + * default + */ + sub5Font = loadSync('./test/fonts/sub5.ttf'); + query.sub5 = new FeatureQuery(sub5Font); }); describe('getScriptFeature', function () { it('should return features indexes of a given script tag', function () { @@ -144,6 +151,28 @@ describe('featureQuery.js', function() { const substitutions = lookup(contextParams); assert.deepEqual(substitutions, { ligGlyph: 1145, components: [76]}); }); + it('should parse multiple glyphs -ligature substitution format 1 (51)', function () { + const feature = query.sub5.getFeature({tag: 'ccmp', script: 'DFLT'}); + const featureLookups = query.sub5.getFeatureLookups(feature); + const lookupSubtables = query.sub5.getLookupSubtables(featureLookups[0]); + const substitutionType = query.sub5.getSubstitutionType(featureLookups[0], lookupSubtables[0]); + assert.equal(substitutionType, 51); + const lookup = query.sub5.getLookupMethod(featureLookups[0], lookupSubtables[0]); + let contextParams = new ContextParams([1, 88, 1], 0); + const substitutions = lookup(contextParams); + assert.deepEqual(substitutions, [85, 88, 85]); + }); + it('should parse multiple glyphs -ligature substitution format 3 (53)', function () { + const feature = query.sub5.getFeature({tag: 'ccmp', script: 'DFLT'}); + const featureLookups = query.sub5.getFeatureLookups(feature); + 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]); + let contextParams = new ContextParams([2, 3], 0); + const substitutions = lookup(contextParams); + assert.deepEqual(substitutions, [54, 54]); + }); it('should decompose a glyph - multiple substitution format 1 (21)', function () { const feature = query.arabic.getFeature({tag: 'ccmp', script: 'arab'}); const featureLookups = query.arabic.getFeatureLookups(feature); diff --git a/test/fonts/LICENSE b/test/fonts/LICENSE index 115cc349..ef158c1c 100644 --- a/test/fonts/LICENSE +++ b/test/fonts/LICENSE @@ -26,6 +26,16 @@ Jomhuria-Regular.ttf SIL Open Font License, Version 1.1. https://www.fontsquirrel.com/license/jomhuria +liga-sub5.ttf + Copyright 2024, Tao Qin (https://glyphsapp.com/). + SIL Open Font License, Version 1.1. + https://opensource.org/licenses/OFL-1.1 + +noto-emoji.ttf + Copyright 2021, Google Inc. + SIL Open Font License, version 1.1 + http://scripts.sil.org/OFL + OpenMojiCOLORv0-subset.ttf All emojis designed by OpenMoji – the open-source emoji and icon project. Creative Commons Share Alike License 4.0 (CC BY-SA 4.0) @@ -87,4 +97,4 @@ TestGVAR-Composite-0-Missing.ttf Vibur.woff Copyright (c) 2010, Johan Kallas (johan.kallas@gmail.com). SIL Open Font License, Version 1.1 - https://www.fontsquirrel.com/license/vibur \ No newline at end of file + https://www.fontsquirrel.com/license/vibur diff --git a/test/fonts/noto-emoji.ttf b/test/fonts/noto-emoji.ttf new file mode 100644 index 00000000..b73aa4c5 Binary files /dev/null and b/test/fonts/noto-emoji.ttf differ diff --git a/test/fonts/sub5.ttf b/test/fonts/sub5.ttf new file mode 100644 index 00000000..60390d13 Binary files /dev/null and b/test/fonts/sub5.ttf differ