Skip to content

feat: Support for Mark to Base Attachment Positioning (incl. GPOS 4, 9) #557

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
09ba12f
feat: additional GPOS lookups parsers
rafallyczkowskiadylic Dec 19, 2022
5580fb6
feat: introduced glyphs positioning features (kern, mark)
rafallyczkowskiadylic Dec 19, 2022
6e07277
fix: code style fixes.
rafallyczkowskiadylic Dec 19, 2022
d1c3fd3
fix: kerning pos fix for pair
rafallyczkowskiadylic Dec 20, 2022
30ef17c
fix: kern processing value to support entire sequence
rafallyczkowskiadylic Dec 20, 2022
951b9f9
Merge branch 'master' into feature/mtbase-merge-testing
rafallyczkowskiadylic Mar 5, 2023
0c78812
Merge branch 'master' into feature/mark-to-base-attachment-positioning
rafallyczkowskiadylic Mar 6, 2023
a48ceb6
fix: pointer offset fix, typos
rafallyczkowskiadylic Mar 6, 2023
89c8b46
Merge branch 'master' into feature/mark-to-base-attachment-positioning
rafallyczkowskiadylic Mar 7, 2023
c262bc9
fix: tests after merge
rafallyczkowskiadylic Mar 7, 2023
ca8e020
Merge branch 'master' into feature/mark-to-base-attachment-positioning
rafallyczkowskiadylic May 11, 2023
5485849
fix: fixed lint code after merge
rafallyczkowskiadylic May 11, 2023
d8fd81d
fix: Fixes the substitution algorithm and support for multiSubstituti…
rafallyczkowskiadylic May 12, 2023
d224142
fix: context condition check
rafallyczkowskiadylic May 12, 2023
6c68a11
fix: code linting and comments
rafallyczkowskiadylic May 12, 2023
0a8f62f
Merge pull request #5 from Adylic/bugfix/substitution-algorithm-fix
rafallyczkowskiadylic May 12, 2023
78847cd
fix: Resolved conflicts
rafallyczkowskiadylic Dec 2, 2023
0de1b3f
Merge pull request #6 from Adylic/resolve-conflicts
rafallyczkowskiadylic Dec 2, 2023
14d9ad6
fix: explicit tokenizer context name
rafallyczkowskiadylic Dec 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ See [https://opentype.js.org/](https://opentype.js.org/) for a live demo.
* Support for composite glyphs (accented letters).
* Support for WOFF, OTF, TTF (both with TrueType `glyf` and PostScript `cff` outlines)
* Support for kerning (Using GPOS or the kern table).
* Support for Mark-to-Base Attachment Positioning.
* Support for ligatures.
* Support for TrueType font hinting.
* Support arabic text rendering (See issue #364 & PR #359 #361)
Expand Down
126 changes: 114 additions & 12 deletions src/bidi.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,18 @@
* the corresponding layout rules.
*/

import Tokenizer from './tokenizer.js';
import FeatureQuery from './features/featureQuery.js';
import Tokenizer, { ContextParams } from './tokenizer.js';
import FeatureQuery, { SubstitutionAction } from './features/featureQuery.js';
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 latinWordCheck from './features/latn/contextCheck/latinWord.js';
import latinLigature from './features/latn/latinLigatures.js';
import thaiWordCheck from './features/thai/contextCheck/thaiWord.js';
import thaiGlyphComposition from './features/thai/thaiGlyphComposition.js';
import thaiLigatures from './features/thai/thaiLigatures.js';
import thaiRequiredLigatures from './features/thai/thaiRequiredLigatures.js';
import unicodeVariationSequenceCheck from './features/unicode/contextCheck/variationSequenceCheck.js';
import unicodeVariationSequences from './features/unicode/variationSequences.js';
import applySubstitution from './features/applySubstitution.js';

/**
* Create Bidi. features
Expand Down Expand Up @@ -116,6 +114,7 @@ Bidi.prototype.applyFeatures = function (font, features) {
if (!font) throw new Error(
'No valid font was provided to apply features'
);
if (!this.font) this.font = font;
if (!this.query) this.query = new FeatureQuery(font);
for (let f = 0; f < features.length; f++) {
const feature = features[f];
Expand All @@ -134,6 +133,115 @@ Bidi.prototype.registerModifier = function (modifierId, condition, modifier) {
this.tokenizer.registerModifier(modifierId, condition, modifier);
};

function getContextParams(tokens, index) {
const context = tokens.map(token => token.activeState.value);
return new ContextParams(context, index || 0);
}

/**
* General method for processing GSUB tables with a specified algorithm:
* During text processing, a client applies a lookup to each glyph in the string before moving to the next lookup.
* A lookup is finished for a glyph after the client locates the target glyph or glyph context and performs a substitution, if specified.
*
* https://learn.microsoft.com/en-us/typography/opentype/spec/gsub#table-organization
*
* Use this algorithm instead of FeatureQuery.prototype.lookupFeature
*
* TODO: Support language option
* TODO: Consider moving this implementation to this.font.substitution (use layout.getFeaturesLookups)
*
* @param {string} script script name
* @param {array} features list of required features to process
* @param {string} tokenizer context name
*/
function applySubstitutions(script, features, contextName) {
const supportedFeatures = features.filter(feature => this.hasFeatureEnabled(script, feature));
const featuresLookups = this.query.getSubstitutionFeaturesLookups(supportedFeatures, script);
for (let idx = 0; idx < featuresLookups.length; idx++) {
const lookupTable = featuresLookups[idx];
const subtables = this.query.getLookupSubtables(lookupTable);
// Extract all thai words to apply the lookup feature per feature lookup table order
const ranges = this.tokenizer.getContextRanges(contextName); // use a context range name convention: latinWord, arabWord, thaiWord, etc.
for (let k = 0; k < ranges.length; k++) {
const range = ranges[k];
let tokens = this.tokenizer.getRangeTokens(range);
let contextParams = getContextParams(tokens);
for (let index = 0; index < contextParams.context.length; index++) {
contextParams.setCurrentIndex(index);
for (let s = 0; s < subtables.length; s++) {
const subtable = subtables[s];
const substType = this.query.getSubstitutionType(lookupTable, subtable);
const lookup = this.query.getLookupMethod(lookupTable, subtable);
let substitution;
switch (substType) {
case '11':
substitution = lookup(contextParams.current);
if (substitution) {
applySubstitution.call(this,
new SubstitutionAction({
id: 11, tag: lookupTable.feature, substitution
}),
tokens,
contextParams.index
);
}
break;
case '12':
substitution = lookup(contextParams.current);
if (substitution) {
applySubstitution.call(this,
new SubstitutionAction({
id: 12, tag: lookupTable.feature, substitution
}),
tokens,
contextParams.index
);
}
break;
case '63':
substitution = lookup(contextParams);
if (Array.isArray(substitution) && substitution.length) {
applySubstitution.call(this,
new SubstitutionAction({
id: 63, tag: lookupTable.feature, substitution
}),
tokens,
contextParams.index
);
}
break;
case '41':
substitution = lookup(contextParams);
if (substitution) {
applySubstitution.call(this,
new SubstitutionAction({
id: 41, tag: lookupTable.feature, substitution
}),
tokens,
contextParams.index
);
}
break;
case '21':
substitution = lookup(contextParams.current);
if (Array.isArray(substitution) && substitution.length) {
applySubstitution.call(this,
new SubstitutionAction({
id: 21, tag: lookupTable.feature, substitution
}),
tokens,
range.startIndex + index
);
}
break;
}
}
contextParams = getContextParams(tokens, index);
}
}
}
}

/**
* Check if 'glyphIndex' is registered
*/
Expand Down Expand Up @@ -199,13 +307,7 @@ function applyUnicodeVariationSequences() {
*/
function applyThaiFeatures() {
checkGlyphIndexStatus.call(this);
const ranges = this.tokenizer.getContextRanges('thaiWord');
for(let i = 0; i < ranges.length; i++) {
const range = ranges[i];
if (this.hasFeatureEnabled('thai', 'liga')) thaiLigatures.call(this, range);
if (this.hasFeatureEnabled('thai', 'rlig')) thaiRequiredLigatures.call(this, range);
if (this.hasFeatureEnabled('thai', 'ccmp')) thaiGlyphComposition.call(this, range);
}
applySubstitutions.call(this, 'thai', ['liga', 'rlig', 'ccmp'], 'thaiWord');
}

/**
Expand Down
31 changes: 29 additions & 2 deletions src/features/applySubstitution.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SubstitutionAction } from './featureQuery.js';
import { Token } from '../tokenizer.js';

/**
* Apply single substitution format 1
Expand Down Expand Up @@ -50,14 +51,40 @@ function ligatureSubstitutionFormat1(action, tokens, index) {
}
}

/**
* Apply multiple substitution format 1
* @param {Array} substitutions substitutions
* @param {any} tokens a list of tokens
* @param {number} index token index
*/
function multiSubstitutionFormat1(action, tokens, index) {
if (this.font && this.tokenizer) {
const newTokensList = [];
const substitution = action.substitution;
for (let i = 0; i < substitution.length; i++) {
const substitutionGlyphIndex = substitution[i];
const glyph = this.font.glyphs.get(substitutionGlyphIndex);
const token = new Token(String.fromCharCode(parseInt(glyph.unicode)));
token.setState('glyphIndex', substitutionGlyphIndex);
newTokensList.push(token);
}

// Replace single range (glyph) index with multiple glyphs
if (newTokensList.length) {
this.tokenizer.replaceRange(index, 1, newTokensList);
}
}
}

/**
* Supported substitutions
*/
const SUBSTITUTIONS = {
11: singleSubstitutionFormat1,
12: singleSubstitutionFormat2,
63: chainingSubstitutionFormat3,
41: ligatureSubstitutionFormat1
41: ligatureSubstitutionFormat1,
21: multiSubstitutionFormat1
};

/**
Expand All @@ -68,7 +95,7 @@ const SUBSTITUTIONS = {
*/
function applySubstitution(action, tokens, index) {
if (action instanceof SubstitutionAction && SUBSTITUTIONS[action.id]) {
SUBSTITUTIONS[action.id](action, tokens, index);
SUBSTITUTIONS[action.id].call(this, action, tokens, index);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/arab/arabicPresentationForms.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function arabicPresentationForms(range) {
for(let j = 0; j < substitutions.length; j++) {
const action = substitutions[j];
if (action instanceof SubstitutionAction) {
applySubstitution(action, tokens, j);
applySubstitution.call(this, action, tokens, j);
contextParams.context[j] = action.substitution;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/features/arab/arabicRequiredLigatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function arabicRequiredLigatures(range) {
if (substitutions.length) {
for(let i = 0; i < substitutions.length; i++) {
const action = substitutions[i];
applySubstitution(action, tokens, index);
applySubstitution.call(this, action, tokens, index);
}
contextParams = getContextParams(tokens);
}
Expand Down
17 changes: 17 additions & 0 deletions src/features/featureQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ FeatureQuery.prototype.getLookupMethod = function(lookupTable, subtable) {
/**
* Lookup a feature using a query parameters
* @param {FQuery} query feature query
* @deprecated Use bidi.applySubstitutions(...)
*/
FeatureQuery.prototype.lookupFeature = function (query) {
let contextParams = query.contextParams;
Expand Down Expand Up @@ -444,6 +445,22 @@ FeatureQuery.prototype.lookupFeature = function (query) {
return substitutions.length ? substitutions : null;
};

/**
* Assembling features into ordered lookup list (wrapper)
* Assemble all features (including any required feature) for the glyph run’s language system.
* Assemble all lookups in these features, in LookupList order, removing any duplicates.
*
* https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/chapter2#lookup-table
*
* @param {string[]} list of requested features
* @param {string} script
* @param {string} language
* @return {Object[]} ordered lookup processing list
*/
FeatureQuery.prototype.getSubstitutionFeaturesLookups = function(features, script, language) {
return this.font.substitution.getFeaturesLookups(features, script, language);
};

/**
* Checks if a font supports a specific features
* @param {FQuery} query feature query object
Expand Down
2 changes: 1 addition & 1 deletion src/features/latn/latinLigatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function latinLigature(range) {
if (substitutions.length) {
for(let i = 0; i < substitutions.length; i++) {
const action = substitutions[i];
applySubstitution(action, tokens, index);
applySubstitution.call(this, action, tokens, index);
}
contextParams = getContextParams(tokens);
}
Expand Down
2 changes: 2 additions & 0 deletions src/features/positioning/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './kern';
export * from './mark';
20 changes: 20 additions & 0 deletions src/features/positioning/kern.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Apply kerning positioning advance glyphs advance
*/

function kern(lookupTable, glyphs) {
const coords = [];
for (let i = 0; i < glyphs.length; i += 1) {
const glyph = glyphs[i];
coords[i] = { xAdvance: 0, yAdvance: 0 };
if (i > 0) {
coords[i] = {
xAdvance: this.position.getKerningValue([lookupTable], glyphs[i - 1].index, glyph.index),
yAdvance: 0
};
}
}
return coords;
}

export { kern };
25 changes: 25 additions & 0 deletions src/features/positioning/mark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Apply MarkToBase positioning advance glyphs advance
*/

function mark(lookupTable, glyphs) {
const coords = [];
for (let i = 0; i < glyphs.length; i += 1) {
const glyph = glyphs[i];
coords[i] = { xAdvance: 0, yAdvance: 0 };
if (i > 0) {
const coordinatedPair = this.position.getMarkToBaseAttachment([lookupTable], glyphs[i - 1].index, glyph.index);
if (coordinatedPair) {
const { attachmentMarkPoint, baseMarkPoint } = coordinatedPair;
// Base mark's advanceWidth must be ignored to have a proper positiong for the attachment mark
coords[i] = {
xAdvance: baseMarkPoint.xCoordinate - attachmentMarkPoint.xCoordinate - glyphs[i - 1].advanceWidth,
yAdvance: baseMarkPoint.yCoordinate - attachmentMarkPoint.yCoordinate
};
}
}
}
return coords;
}

export { mark };
41 changes: 0 additions & 41 deletions src/features/thai/thaiGlyphComposition.js

This file was deleted.

Loading