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 5 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 @@ -13,6 +13,7 @@ Features
* 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
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 };
114 changes: 87 additions & 27 deletions src/font.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Substitution from './substitution';
import { isBrowser, checkArgument, arrayBufferToNodeBuffer } from './util';
import HintingTrueType from './hintingtt';
import Bidi from './bidi';
import { kern, mark } from './features/positioning';

/**
* @typedef FontOptions
Expand Down Expand Up @@ -153,19 +154,25 @@ Font.prototype.charToGlyph = function(c) {
* @param {any} options features options
*/
Font.prototype.updateFeatures = function (options) {
// TODO: update all features options not only 'latn'.
// TODO: update all features options not only 'DFLT', 'latn'.
const configureable = ['DFLT', 'latn'];
return this.defaultRenderOptions.features.map(feature => {
if (feature.script === 'latn') {
if (configureable.includes(feature.script)) {
return {
script: 'latn',
tags: feature.tags.filter(tag => options[tag])
script: feature.script,
tags: feature.tags.filter(tag => !options || options[tag])
};
} else {
return feature;
}
return feature;
});
};

Font.prototype.getFeaturesConfig = function (options) {
return options ?
this.updateFeatures(options.features) :
this.defaultRenderOptions.features;
};

/**
* Convert the given text to a list of Glyph objects.
* Note that there is no strict one-to-one mapping between characters and
Expand All @@ -183,11 +190,7 @@ Font.prototype.stringToGlyphs = function(s, options) {
const charToGlyphIndexMod = token => this.charToGlyphIndex(token.char);
bidi.registerModifier('glyphIndex', null, charToGlyphIndexMod);

// roll-back to default features
let features = options ?
this.updateFeatures(options.features) :
this.defaultRenderOptions.features;

const features = this.getFeaturesConfig(options);
bidi.applyFeatures(this, features);

const indexes = bidi.getTextGlyphs(s);
Expand Down Expand Up @@ -279,7 +282,8 @@ 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'] },
{ script: 'DFLT', tags: ['mark'] },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to microsoft documentation dflt is lowercase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ILOVEPIE I could not find this. I believe you might have referred to the langSysTable (tag) and this is a script tag? Moreover:

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://learn.microsoft.com/en-us/typography/opentype/spec/scripttags it's lowercase for all other tags, but DFLT is uppercase in the docs

]
};

Expand All @@ -300,27 +304,18 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback)
options = Object.assign({}, this.defaultRenderOptions, options);
const fontScale = 1 / this.unitsPerEm * fontSize;
const glyphs = this.stringToGlyphs(text, options);
let kerningLookups;
if (options.kerning) {
const script = options.script || this.position.getDefaultScriptName();
kerningLookups = this.position.getKerningTables(script, options.language);
}
const glyphsPositions = this.getGlyphsPositions(glyphs, options);

for (let i = 0; i < glyphs.length; i += 1) {
const glyph = glyphs[i];
callback.call(this, glyph, x, y, fontSize, options);
const { xAdvance, yAdvance } = glyphsPositions[i];

callback.call(this, glyph, x + (xAdvance * fontScale), y + (yAdvance * fontScale), fontSize, options);

if (glyph.advanceWidth) {
x += glyph.advanceWidth * fontScale;
}

if (options.kerning && i < glyphs.length - 1) {
// We should apply position adjustment lookups in a more generic way.
// Here we only use the xAdvance value.
const kerningValue = kerningLookups ?
this.position.getKerningValue(kerningLookups, glyph.index, glyphs[i + 1].index) :
this.getKerningValue(glyph, glyphs[i + 1]);
x += kerningValue * fontScale;
}

if (options.letterSpacing) {
x += options.letterSpacing * fontSize;
} else if (options.tracking) {
Expand All @@ -330,6 +325,71 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback)
return x;
};

/**
* Returns array of glyphs' relative position advances for a given sequence.
*
* Supported features:
* - kern - kerning
* - mark - mark to base attachments
*
* @param {opentype.Glyph[]} glyphs
* @returns {Object[]} array of { xAdvance: number, yAdvance: number } for a glyph ordered by their index
*/
Font.prototype.getGlyphsPositions = function(glyphs, options) {

const script = options.script || this.position.getDefaultScriptName();

const features = this
.getFeaturesConfig(options)
.filter(f => f.script === 'DFLT')
.reduce((tags, feature) => tags.concat(feature.tags), []);

// Force a kern feature
if (options && options.kerning) features.push('kern');

const glyphsPositions = [];
for (let i = 0; i < glyphs.length; i += 1) {
glyphsPositions[i] = { xAdvance: 0, yAdvance: 0 };
}

let kernLookupTableProcessed = false;
const featuresLookups = this.position.getPositionFeatures(features, script, options.language);
featuresLookups.forEach(lookupTable => {
let kerningValue = 0;
let pos = [];
switch (lookupTable.feature) {
case 'kern':
pos = kern.call(this, lookupTable, glyphs);
kernLookupTableProcessed = true;
break;
case 'mark':
pos = mark.call(this, lookupTable, glyphs);
break;
}

// Reposition glyphs
pos.forEach((glyphPosition, index) => {
if (lookupTable.feature === 'kern') {
kerningValue += glyphPosition.xAdvance; // kerning apply to entire sequence
glyphsPositions[index].xAdvance += kerningValue;
} else {
glyphsPositions[index].xAdvance += glyphPosition.xAdvance;
glyphsPositions[index].yAdvance += glyphPosition.yAdvance;
}
});
});

// Support for the 'kern' table glyph pairs
if (options.kerning && kernLookupTableProcessed === false) {
let kerningValue = 0;
for (let i = 1; i < glyphs.length; i += 1) {
kerningValue += this.getKerningValue(glyphs[i - 1], glyphs[i]); // kerning apply to entire sequence
glyphsPositions[i].xAdvance += kerningValue;
}
}
return glyphsPositions;
};

/**
* Create a Path object that represents the given text.
* @param {string} text - The text to create.
Expand Down
58 changes: 57 additions & 1 deletion src/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ function searchRange(ranges, value) {
* @exports opentype.Layout
* @class
*/
function Layout(font, tableName) {
function Layout(font, tableName, supportedFeatures) {
this.font = font;
this.tableName = tableName;
this.supportedFeatures = supportedFeatures || [];
}

Layout.prototype = {
Expand Down Expand Up @@ -194,6 +195,61 @@ Layout.prototype = {
}
},

/**
* Returns an ordered, union lookup tables for all requested features.
* This follows an ordered processing requirements (specs):
* > During text processing, it processes the lookups referenced by that feature in their lookup list order.
* > Note that an application may process lookups for multiple features simultaneously. In this case:
* > the list of lookups is the union of lookups referenced by all of those features, and these are all processed in their lookup list order.
*
* https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/chapter2#lookup-list-table
*
* @param {string[]} requestedFeatures
* @param {string} [script='DFLT']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dflt should be lower case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* @param {string} [language='dlft']
* @return {Object[]} an ordered lookup list of requested features
*/
getFeaturesLookups: function(requestedFeatures, script, language) {
if (!this.font.tables[this.tableName] || !requestedFeatures) {
return [];
}

// Fitler out only supported by layout table features
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just y small typo, Fitler => Filter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

requestedFeatures = this.supportedFeatures.filter(f => requestedFeatures.includes(f.featureName));

const lookupUnionList = {};
const allLookups = this.font.tables[this.tableName].lookups;
requestedFeatures.forEach(feature => {
const { featureName, supportedLookups } = feature;
let featureTable = this.getFeatureTable(script, language, featureName);
if (featureTable && supportedLookups.length) {
let lookupTable;
const lookupListIndexes = featureTable.lookupListIndexes;
for (let i = 0; i < lookupListIndexes.length; i++) {
const idx = `idx${lookupListIndexes[i]}`;
if (lookupUnionList.hasOwnProperty(idx)) continue; // Skips a lookup table that is already on the processing list
lookupTable = allLookups[lookupListIndexes[i]];
if (!lookupTable) continue;
let validLookupType = supportedLookups.indexOf(lookupTable.lookupType) !== -1;
// Extension lookup table support
if (lookupTable.subtables.length === 1) {
const { extensionLookupType, extension } = lookupTable.subtables[0];
if (extensionLookupType && extension && supportedLookups.indexOf(extensionLookupType) !== -1) {
lookupTable.lookupType = extensionLookupType;
lookupTable.subtables = [extension];
validLookupType = true;
}
}
if (validLookupType) {
lookupTable.feature = featureName;
lookupUnionList[idx] = lookupTable;
}
}
}
});
return Object.values(lookupUnionList);
},

/**
* Get a specific feature table.
* @instance
Expand Down
69 changes: 69 additions & 0 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,74 @@ Parser.prototype.parseCoverage = function() {
throw new Error('0x' + startOffset.toString(16) + ': Coverage format must be 1 or 2.');
};

/**
* Parse a BaseArray Table in GPOS table
* https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/gpos#lookup-type-4-mark-to-base-attachment-positioning-subtable
*
* @param {Number} marksClassCount
* @returns {Array}
*/
Parser.prototype.parseBaseArray = function(marksClassCount) {
const count = this.parseUShort();
return this.parseList(count, Parser.list(
marksClassCount,
Parser.pointer(Parser.anchor)
));
};

/**
* Parse a MarkArray Table in GPOS table
* https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/gpos_delta#mark-array-table
*
* @returns {Array}
*/
Parser.prototype.parseMarkArray = function() {
const count = this.parseUShort();
return this.parseRecordList(count, {
class: Parser.uShort,
attachmentPoint: Parser.pointer(Parser.anchor)
});
};

/**
* Parse a an anchor definition Table in GPOS table
* https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/gpos_delta#anchor-tables
*
* @returns {Object} Anchor object representing format type
*/
Parser.prototype.parseAnchorPoint = function() {
const startOffset = this.offset + this.relativeOffset;
const format = this.parseUShort();
switch (format) {
case 1:
return {
format,
xCoordinate: this.parseShort(),
yCoordinate: this.parseShort()
};
case 2:
return {
format,
xCoordinate: this.parseShort(),
yCoordinate: this.parseShort(),
anchorPoint: this.parseUShort()
};

// TODO: Add a support Device offsets
// https://learn.microsoft.com/en-us/typography/opentype/otspec191alpha/gpos_delta#anchor-table-format-3-design-units-plus-device-or-variationindex-tables
case 3:
return {
format,
xCoordinate: this.parseShort(),
yCoordinate: this.parseShort(),
xDevice: 0x00,
yDevice: 0x00,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when parsing these values, we have to call this.parseShort() to advance the parser past the xDevice and yDevice or subsequent reads will be at the wrong location.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I will fix shortly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Pointer is moving now

};
}

throw new Error('0x' + startOffset.toString(16) + ': Anchor format must be 1, 2 or 3.');
};

// Parse a Class Definition Table in a GSUB, GPOS or GDEF table.
// https://www.microsoft.com/typography/OTSPEC/chapter2.htm
Parser.prototype.parseClassDef = function() {
Expand Down Expand Up @@ -548,6 +616,7 @@ Parser.uLong = Parser.offset32 = Parser.prototype.parseULong;
Parser.uLongList = Parser.prototype.parseULongList;
Parser.struct = Parser.prototype.parseStruct;
Parser.coverage = Parser.prototype.parseCoverage;
Parser.anchor = Parser.prototype.parseAnchorPoint;
Parser.classDef = Parser.prototype.parseClassDef;

///// Script, Feature, Lookup lists ///////////////////////////////////////////////
Expand Down
Loading