Skip to content

Commit

Permalink
Feature/ccmp (#688)
Browse files Browse the repository at this point in the history
Implement ccmp feature execution, enabling muti character emoji support
  • Loading branch information
TonyJR committed Apr 10, 2024
1 parent ce5fcce commit be0d441
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 2 deletions.
19 changes: 19 additions & 0 deletions src/bidi.js
Expand Up @@ -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';
Expand Down Expand Up @@ -42,6 +44,7 @@ Bidi.prototype.setText = function (text) {
* arabic sentence check for adjusting arabic layout
*/
Bidi.prototype.contextChecks = ({
ccmpReplacementCheck,
latinWordCheck,
arabicWordCheck,
arabicSentenceCheck,
Expand All @@ -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');
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion src/features/applySubstitution.js
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -57,7 +66,9 @@ const SUBSTITUTIONS = {
11: singleSubstitutionFormat1,
12: singleSubstitutionFormat2,
63: chainingSubstitutionFormat3,
41: ligatureSubstitutionFormat1
41: ligatureSubstitutionFormat1,
51: chainingSubstitutionFormat3,
53: chainingSubstitutionFormat3
};

/**
Expand Down
47 changes: 47 additions & 0 deletions 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;



12 changes: 12 additions & 0 deletions 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
};
108 changes: 108 additions & 0 deletions src/features/featureQuery.js
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions test/bidi.js
Expand Up @@ -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]);
});
});
});
});
29 changes: 29 additions & 0 deletions test/featureQuery.js
Expand Up @@ -9,6 +9,8 @@ describe('featureQuery.js', function() {
let arabicFont;
let arabicFontChanga;
let latinFont;
let sub5Font;

let query = {};
before(function () {
/**
Expand All @@ -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 () {
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion test/fonts/LICENSE
Expand Up @@ -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)
Expand Down Expand Up @@ -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
https://www.fontsquirrel.com/license/vibur
Binary file added test/fonts/noto-emoji.ttf
Binary file not shown.
Binary file added test/fonts/sub5.ttf
Binary file not shown.

0 comments on commit be0d441

Please sign in to comment.