Skip to content

Commit be0d441

Browse files
authored
Feature/ccmp (#688)
Implement ccmp feature execution, enabling muti character emoji support
1 parent ce5fcce commit be0d441

File tree

10 files changed

+272
-2
lines changed

10 files changed

+272
-2
lines changed

src/bidi.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import arabicWordCheck from './features/arab/contextCheck/arabicWord.js';
99
import arabicSentenceCheck from './features/arab/contextCheck/arabicSentence.js';
1010
import arabicPresentationForms from './features/arab/arabicPresentationForms.js';
1111
import arabicRequiredLigatures from './features/arab/arabicRequiredLigatures.js';
12+
import ccmpReplacementCheck from './features/ccmp/contextCheck/ccmpReplacement.js';
13+
import ccmpReplacement from './features/ccmp/ccmpReplacementLigatures.js';
1214
import latinWordCheck from './features/latn/contextCheck/latinWord.js';
1315
import latinLigature from './features/latn/latinLigatures.js';
1416
import thaiWordCheck from './features/thai/contextCheck/thaiWord.js';
@@ -42,6 +44,7 @@ Bidi.prototype.setText = function (text) {
4244
* arabic sentence check for adjusting arabic layout
4345
*/
4446
Bidi.prototype.contextChecks = ({
47+
ccmpReplacementCheck,
4548
latinWordCheck,
4649
arabicWordCheck,
4750
arabicSentenceCheck,
@@ -64,6 +67,7 @@ function registerContextChecker(checkId) {
6467
* tokenize text input
6568
*/
6669
function tokenizeText() {
70+
registerContextChecker.call(this, 'ccmpReplacement');
6771
registerContextChecker.call(this, 'latinWord');
6872
registerContextChecker.call(this, 'arabicWord');
6973
registerContextChecker.call(this, 'arabicSentence');
@@ -160,6 +164,18 @@ function applyArabicPresentationForms() {
160164
}
161165
}
162166

167+
/**
168+
* Apply ccmp replacement
169+
*/
170+
function applyCcmpReplacement() {
171+
checkGlyphIndexStatus.call(this);
172+
const ranges = this.tokenizer.getContextRanges('ccmpReplacement');
173+
for(let i = 0; i < ranges.length; i++) {
174+
const range = ranges[i];
175+
ccmpReplacement.call(this, range);
176+
}
177+
}
178+
163179
/**
164180
* Apply required arabic ligatures
165181
*/
@@ -223,6 +239,9 @@ Bidi.prototype.checkContextReady = function (contextId) {
223239
* https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-ccmp
224240
*/
225241
Bidi.prototype.applyFeaturesToContexts = function () {
242+
if (this.checkContextReady('ccmpReplacement')) {
243+
applyCcmpReplacement.call(this);
244+
}
226245
if (this.checkContextReady('arabicWord')) {
227246
applyArabicPresentationForms.call(this);
228247
applyArabicRequireLigatures.call(this);

src/features/applySubstitution.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ function chainingSubstitutionFormat3(action, tokens, index) {
3030
for(let i = 0; i < action.substitution.length; i++) {
3131
const subst = action.substitution[i];
3232
const token = tokens[index + i];
33+
if (Array.isArray(subst)) {
34+
if (subst.length){
35+
// TODO: replace one glyph with multiple glyphs
36+
token.setState(action.tag, subst[0]);
37+
} else {
38+
token.setState('deleted', true);
39+
}
40+
continue;
41+
}
3342
token.setState(action.tag, subst);
3443
}
3544
}
@@ -57,7 +66,9 @@ const SUBSTITUTIONS = {
5766
11: singleSubstitutionFormat1,
5867
12: singleSubstitutionFormat2,
5968
63: chainingSubstitutionFormat3,
60-
41: ligatureSubstitutionFormat1
69+
41: ligatureSubstitutionFormat1,
70+
51: chainingSubstitutionFormat3,
71+
53: chainingSubstitutionFormat3
6172
};
6273

6374
/**
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ContextParams } from '../../tokenizer.js';
2+
import applySubstitution from '../applySubstitution.js';
3+
4+
// @TODO: use commonFeatureUtils.js for reduction of code duplication
5+
// once #564 has been merged.
6+
7+
/**
8+
* Update context params
9+
* @param {any} tokens a list of tokens
10+
* @param {number} index current item index
11+
*/
12+
function getContextParams(tokens, index) {
13+
const context = tokens.map(token => token.activeState.value);
14+
return new ContextParams(context, index || 0);
15+
}
16+
17+
/**
18+
* Apply ccmp replacement ligatures to a context range
19+
* @param {ContextRange} range a range of tokens
20+
*/
21+
function ccmpReplacementLigatures(range) {
22+
const script = 'delf';
23+
const tag = 'ccmp';
24+
let tokens = this.tokenizer.getRangeTokens(range);
25+
let contextParams = getContextParams(tokens);
26+
for(let index = 0; index < contextParams.context.length; index++) {
27+
if (!this.query.getFeature({tag, script, contextParams})){
28+
continue;
29+
}
30+
contextParams.setCurrentIndex(index);
31+
let substitutions = this.query.lookupFeature({
32+
tag, script, contextParams
33+
});
34+
if (substitutions.length) {
35+
for(let i = 0; i < substitutions.length; i++) {
36+
const action = substitutions[i];
37+
applySubstitution(action, tokens, index);
38+
}
39+
contextParams = getContextParams(tokens);
40+
}
41+
}
42+
}
43+
44+
export default ccmpReplacementLigatures;
45+
46+
47+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
function ccmpReplacementStartCheck(contextParams) {
2+
return contextParams.index === 0 && contextParams.context.length > 1;
3+
}
4+
5+
function ccmpReplacementEndCheck(contextParams) {
6+
return contextParams.index === contextParams.context.length - 1;
7+
}
8+
9+
export default {
10+
startCheck: ccmpReplacementStartCheck,
11+
endCheck: ccmpReplacementEndCheck
12+
};

src/features/featureQuery.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,94 @@ function ligatureSubstitutionFormat1(contextParams, subtable) {
203203
return null;
204204
}
205205

206+
/**
207+
* Handle context substitution - format 1
208+
* @param {ContextParams} contextParams context params to lookup
209+
*/
210+
function contextSubstitutionFormat1(contextParams, subtable) {
211+
let glyphId = contextParams.current;
212+
let ligSetIndex = lookupCoverage(glyphId, subtable.coverage);
213+
if (ligSetIndex === -1)
214+
return null;
215+
for (const ruleSet of subtable.ruleSets) {
216+
for (const rule of ruleSet) {
217+
let matched = true;
218+
for (let i = 0; i < rule.input.length; i++) {
219+
if (contextParams.lookahead[i] !== rule.input[i]){
220+
matched = false;
221+
break;
222+
}
223+
}
224+
if (matched) {
225+
let substitutions = [];
226+
substitutions.push(glyphId);
227+
for (let i = 0; i < rule.input.length; i++) {
228+
substitutions.push(rule.input[i]);
229+
}
230+
const parser = (substitutions, lookupRecord)=>{
231+
const {lookupListIndex,sequenceIndex} = lookupRecord;
232+
const {subtables} = this.getLookupByIndex(lookupListIndex);
233+
for (const subtable of subtables){
234+
let ligSetIndex = lookupCoverage(substitutions[sequenceIndex], subtable.coverage);
235+
if (ligSetIndex !== -1){
236+
substitutions[sequenceIndex] = subtable.deltaGlyphId;
237+
}
238+
}
239+
};
240+
241+
for (let i = 0; i < rule.lookupRecords.length; i++) {
242+
const lookupRecord = rule.lookupRecords[i];
243+
parser(substitutions, lookupRecord);
244+
}
245+
246+
return substitutions;
247+
}
248+
}
249+
}
250+
return null;
251+
}
252+
253+
/**
254+
* Handle context substitution - format 3
255+
* @param {ContextParams} contextParams context params to lookup
256+
*/
257+
function contextSubstitutionFormat3(contextParams, subtable) {
258+
let substitutions = [];
259+
260+
for (let i = 0; i < subtable.coverages.length; i++){
261+
const lookupRecord = subtable.lookupRecords[i];
262+
const coverage = subtable.coverages[i];
263+
264+
let glyphIndex = contextParams.context[contextParams.index + lookupRecord.sequenceIndex];
265+
let ligSetIndex = lookupCoverage(glyphIndex, coverage);
266+
if (ligSetIndex === -1){
267+
return null;
268+
}
269+
let lookUp = this.font.tables.gsub.lookups[lookupRecord.lookupListIndex];
270+
for (let i = 0; i < lookUp.subtables.length; i++){
271+
let subtable = lookUp.subtables[i];
272+
let ligSetIndex = lookupCoverage(glyphIndex, subtable.coverage);
273+
if (ligSetIndex === -1)
274+
return null;
275+
switch (lookUp.lookupType) {
276+
case 1:{
277+
let ligature = subtable.substitute[ligSetIndex];
278+
substitutions.push(ligature);
279+
break;
280+
}
281+
case 2:{
282+
let ligatureSet = subtable.sequences[ligSetIndex];
283+
substitutions.push(ligatureSet);
284+
break;
285+
}
286+
default:
287+
break;
288+
}
289+
}
290+
}
291+
return substitutions;
292+
}
293+
206294
/**
207295
* Handle decomposition substitution - format 1
208296
* @param {number} glyphIndex glyph index
@@ -327,8 +415,17 @@ FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) {
327415
return glyphIndex => decompositionSubstitutionFormat1.apply(
328416
this, [glyphIndex, subtable]
329417
);
418+
case '51':
419+
return contextParams => contextSubstitutionFormat1.apply(
420+
this, [contextParams, subtable]
421+
);
422+
case '53':
423+
return contextParams => contextSubstitutionFormat3.apply(
424+
this, [contextParams, subtable]
425+
);
330426
default:
331427
throw new Error(
428+
`substitutionType : ${substitutionType} ` +
332429
`lookupType: ${lookupTable.lookupType} - ` +
333430
`substFormat: ${subtable.substFormat} ` +
334431
'is not yet supported'
@@ -435,6 +532,17 @@ FeatureQuery.prototype.lookupFeature = function (query) {
435532
}));
436533
}
437534
break;
535+
case '51':
536+
case '53':
537+
substitution = lookup(contextParams);
538+
if (Array.isArray(substitution) && substitution.length) {
539+
substitutions.splice(currentIndex, 1, new SubstitutionAction({
540+
id: parseInt(substType),
541+
tag: query.tag,
542+
substitution
543+
}));
544+
}
545+
break;
438546
}
439547
contextParams = new ContextParams(substitutions, currentIndex);
440548
if (Array.isArray(substitution) && !substitution.length) continue;

test/bidi.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,38 @@ describe('bidi.js', function() {
143143
});
144144
});
145145
});
146+
147+
describe('noto emoji with ccmp', () => {
148+
let notoEmojiFont;
149+
before(()=> {
150+
notoEmojiFont = loadSync('./test/fonts/noto-emoji.ttf');
151+
});
152+
153+
describe('ccmp features', () => {
154+
155+
it('shape emoji with sub_0', () => {
156+
let options = {
157+
kerning: true,
158+
language: 'dflt',
159+
features: [
160+
{ script: 'DFLT', tags: ['ccmp'] },
161+
]
162+
};
163+
let glyphIndexes = notoEmojiFont.stringToGlyphIndexes('👨‍👩‍👧‍👦👨‍👩‍👧',options);
164+
assert.deepEqual(glyphIndexes, [1463,1462]);
165+
});
166+
167+
it('shape emoji with sub_5', () => {
168+
let options = {
169+
kerning: true,
170+
language: 'dflt',
171+
features: [
172+
{ script: 'DFLT', tags: ['ccmp'] },
173+
]
174+
};
175+
let glyphIndexes = notoEmojiFont.stringToGlyphIndexes('🇺🇺',options);
176+
assert.deepEqual(glyphIndexes, [1850]);
177+
});
178+
});
179+
});
146180
});

test/featureQuery.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ describe('featureQuery.js', function() {
99
let arabicFont;
1010
let arabicFontChanga;
1111
let latinFont;
12+
let sub5Font;
13+
1214
let query = {};
1315
before(function () {
1416
/**
@@ -23,6 +25,11 @@ describe('featureQuery.js', function() {
2325
*/
2426
latinFont = loadSync('./test/fonts/FiraSansMedium.woff');
2527
query.latin = new FeatureQuery(latinFont);
28+
/**
29+
* default
30+
*/
31+
sub5Font = loadSync('./test/fonts/sub5.ttf');
32+
query.sub5 = new FeatureQuery(sub5Font);
2633
});
2734
describe('getScriptFeature', function () {
2835
it('should return features indexes of a given script tag', function () {
@@ -144,6 +151,28 @@ describe('featureQuery.js', function() {
144151
const substitutions = lookup(contextParams);
145152
assert.deepEqual(substitutions, { ligGlyph: 1145, components: [76]});
146153
});
154+
it('should parse multiple glyphs -ligature substitution format 1 (51)', function () {
155+
const feature = query.sub5.getFeature({tag: 'ccmp', script: 'DFLT'});
156+
const featureLookups = query.sub5.getFeatureLookups(feature);
157+
const lookupSubtables = query.sub5.getLookupSubtables(featureLookups[0]);
158+
const substitutionType = query.sub5.getSubstitutionType(featureLookups[0], lookupSubtables[0]);
159+
assert.equal(substitutionType, 51);
160+
const lookup = query.sub5.getLookupMethod(featureLookups[0], lookupSubtables[0]);
161+
let contextParams = new ContextParams([1, 88, 1], 0);
162+
const substitutions = lookup(contextParams);
163+
assert.deepEqual(substitutions, [85, 88, 85]);
164+
});
165+
it('should parse multiple glyphs -ligature substitution format 3 (53)', function () {
166+
const feature = query.sub5.getFeature({tag: 'ccmp', script: 'DFLT'});
167+
const featureLookups = query.sub5.getFeatureLookups(feature);
168+
const lookupSubtables = query.sub5.getLookupSubtables(featureLookups[1]);
169+
const substitutionType = query.sub5.getSubstitutionType(featureLookups[1], lookupSubtables[0]);
170+
assert.equal(substitutionType, 53);
171+
const lookup = query.sub5.getLookupMethod(featureLookups[0], lookupSubtables[0]);
172+
let contextParams = new ContextParams([2, 3], 0);
173+
const substitutions = lookup(contextParams);
174+
assert.deepEqual(substitutions, [54, 54]);
175+
});
147176
it('should decompose a glyph - multiple substitution format 1 (21)', function () {
148177
const feature = query.arabic.getFeature({tag: 'ccmp', script: 'arab'});
149178
const featureLookups = query.arabic.getFeatureLookups(feature);

test/fonts/LICENSE

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ Jomhuria-Regular.ttf
2626
SIL Open Font License, Version 1.1.
2727
https://www.fontsquirrel.com/license/jomhuria
2828

29+
liga-sub5.ttf
30+
Copyright 2024, Tao Qin (https://glyphsapp.com/).
31+
SIL Open Font License, Version 1.1.
32+
https://opensource.org/licenses/OFL-1.1
33+
34+
noto-emoji.ttf
35+
Copyright 2021, Google Inc.
36+
SIL Open Font License, version 1.1
37+
http://scripts.sil.org/OFL
38+
2939
OpenMojiCOLORv0-subset.ttf
3040
All emojis designed by OpenMoji – the open-source emoji and icon project.
3141
Creative Commons Share Alike License 4.0 (CC BY-SA 4.0)
@@ -87,4 +97,4 @@ TestGVAR-Composite-0-Missing.ttf
8797
Vibur.woff
8898
Copyright (c) 2010, Johan Kallas ([email protected]).
8999
SIL Open Font License, Version 1.1
90-
https://www.fontsquirrel.com/license/vibur
100+
https://www.fontsquirrel.com/license/vibur

test/fonts/noto-emoji.ttf

848 KB
Binary file not shown.

test/fonts/sub5.ttf

7.48 KB
Binary file not shown.

0 commit comments

Comments
 (0)