Skip to content
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

Add write support for GPOS type 2 lookups (kerning) #743

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
217 changes: 216 additions & 1 deletion src/tables/gpos.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,131 @@ function parseGposTable(data, start) {
// NOT SUPPORTED
const subtableMakers = new Array(10);

function makeGposTable(gpos) {
function addValueRecordFields(table, valueRecord, valueFormat) {
if (!valueRecord) return;
const components = ['xPlacement', 'yPlacement', 'xAdvance', 'yAdvance', 'xPlacementDevice', 'yPlacementDevice', 'xAdvanceDevice', 'yAdvanceDevice'];

for (let i = 0; i < components.length; i++) {
if (valueFormat & (1 << i)) {
table.fields.push({ name: components[i], type: 'SHORT', value: valueRecord[components[i]] || 0 });
}
}
}

subtableMakers[2] = function makeLookup2(subtable) {

if (subtable.posFormat === 1) {
const posTable = new table.Table('pairPosFormat1', [
{name: 'posFormat', type: 'USHORT', value: 1},
{name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage)},
{name: 'valueFormat1', type: 'USHORT', value: subtable.valueFormat1 },
{name: 'valueFormat2', type: 'USHORT', value: subtable.valueFormat2 },
].concat(table.tableList('pairSets', subtable.pairSets, function(pairSet) {
const pairSetTable = new table.Table('pairSetTable', []);
pairSetTable.fields.push({name: 'pairValueCount', type: 'USHORT', value: pairSet.length });
for (let i = 0; i < pairSet.length; i++) {
const pair = pairSet[i];
pairSetTable.fields.push({name: 'secondGlyph', type: 'USHORT', value: pair.secondGlyph });
addValueRecordFields(pairSetTable, pair.value1, subtable.valueFormat1);
addValueRecordFields(pairSetTable, pair.value2, subtable.valueFormat2);
}
return pairSetTable;
})));
return posTable;
} else if (subtable.posFormat === 2) {
const posTable = new table.Table('pairPosFormat2', [
{ name: 'posFormat', type: 'USHORT', value: 2 },
{ name: 'coverage', type: 'TABLE', value: new table.Coverage(subtable.coverage) },
{ name: 'valueFormat1', type: 'USHORT', value: subtable.valueFormat1 },
{ name: 'valueFormat2', type: 'USHORT', value: subtable.valueFormat2 },
{ name: 'classDef1', type: 'TABLE', value: new table.ClassDef(subtable.classDef1) },
{ name: 'classDef2', type: 'TABLE', value: new table.ClassDef(subtable.classDef2) },
{ name: 'class1Count', type: 'USHORT', value: subtable.classRecords.length },
{ name: 'class2Count', type: 'USHORT', value: subtable.classRecords[0].length }
]);

for (let i = 0; i < subtable.classRecords.length; i++) {
const class1Record = subtable.classRecords[i];
for (let j = 0; j < class1Record.length; j++) {
const class2Record = class1Record[j];
addValueRecordFields(posTable, class2Record.value1, subtable.valueFormat1);
addValueRecordFields(posTable, class2Record.value2, subtable.valueFormat2);
}
}

return posTable;
} else {
throw new Error('Lookup type 2 format must be 1 or 2.');
}
};


/**
* Subsets the `GPOS` table to only include tables that have been implemented (type 2/kerning).
* Once write support for all `GPOS` subtables is implemented, this function should be removed.
*
* @param {*} gpos
* @returns
*/
export function subsetGposImplemented(gpos) {
// Filter lookups to only pair kerning tables; make deep copy to avoid editing original.

const lookups = [];
const lookupsIndices = [];
for(let i = 0; i < gpos.lookups.length; i++) {
if (gpos.lookups[i].lookupType === 2) {
lookupsIndices.push(i);
lookups.push(JSON.parse(JSON.stringify(gpos.lookups[i])));
// lookups.push(structuredClone(gpos.lookups[i]));
}
}

if (lookups.length === 0) return;

const features = [];
const featuresIndices = [];
for(let i = 0; i < gpos.features.length; i++) {
if (gpos.features[i].tag === 'kern') {
featuresIndices.push(i);
features.push(JSON.parse(JSON.stringify(gpos.features[i])));
}
}

// Filter features to only include those that reference the pair kerning tables; update lookupListIndexes to match new indices.
for (let i = 0; i < features.length; i++) {
features[i].feature.lookupListIndexes = features[i].feature.lookupListIndexes.filter((x) => lookupsIndices.includes(x)).map((x) => lookupsIndices.indexOf(x));
}

const scripts = [];

// Filter scripts to only include those that reference the features; update featureIndexes to match new indices.
for (let i = 0; i < gpos.scripts.length; i++) {
const scriptI = JSON.parse(JSON.stringify(gpos.scripts[i]));
scriptI.script.defaultLangSys.featureIndexes = scriptI.script.defaultLangSys.featureIndexes.filter((x) => featuresIndices.includes(x)).map((x) => featuresIndices.indexOf(x));
if (scriptI.script.defaultLangSys.featureIndexes.length === 0) continue;
for (let j = 0; j < scriptI.script.langSysRecords.length; j++) {
scriptI.script.langSysRecords[j].featureIndexes = scriptI.script.langSysRecords[j].langSys.featureIndexes.filter((x) => featuresIndices.includes(x)).map((x) => featuresIndices.indexOf(x));
}
scripts.push(scriptI);
}

return {version: gpos.version, lookups, features, scripts};
}



function makeGposTable(gpos, kerningPairs) {

if (gpos) {
gpos = subsetGposImplemented(gpos);
} else if (kerningPairs && Object.keys(kerningPairs).length > 0) {
gpos = kernToGpos(kerningPairs);
} else {
return;
}

if (!gpos) return;

return new table.Table('GPOS', [
{name: 'version', type: 'ULONG', value: 0x10000},
{name: 'scripts', type: 'TABLE', value: new table.ScriptList(gpos.scripts)},
Expand All @@ -123,4 +247,95 @@ function makeGposTable(gpos) {
]);
}

/**
* Converts from kerning pairs created from `kern` table to "type 2" lookup for `GPOS` table.
* @param {Object<string, number>} kerningPairs
*/
function kernToGpos(kerningPairs) {

// The main difference between the `kern` and `GPOS` format 1 subtable is that the `kern` table lists every kerning pair,
// while the `GPOS` format 1 subtable groups together kerning pairs that share the same first glyph.
const kerningArray = Object.entries(kerningPairs);
kerningArray.sort(function (a, b) {
const aLeftGlyph = parseInt(a[0].match(/\d+/)[0]);
const aRightGlyph = parseInt(a[0].match(/\d+$/)[0]);
const bLeftGlyph = parseInt(b[0].match(/\d+/)[0]);
const bRightGlyph = parseInt(b[0].match(/\d+$/)[0]);
if (aLeftGlyph < bLeftGlyph) {
return -1;
}
if (aLeftGlyph > bLeftGlyph) {
return 1;
}
if (aRightGlyph < bRightGlyph) {
return -1;
}
return 1;
});

const nPairs = kerningArray.length;

const coverage = {
format: 1,
glyphs: []
};
const pairSets = [];

for (let i = 0; i < nPairs; i++) {

let firstGlyph = parseInt(kerningArray[i][0].match(/\d+/)[0]);
let secondGlyph = parseInt(kerningArray[i][0].match(/\d+$/)[0]);

if (firstGlyph !== coverage.glyphs[coverage.glyphs.length - 1]) {
coverage.glyphs.push(firstGlyph);
pairSets.push([]);
}

pairSets[coverage.glyphs.length - 1].push({
secondGlyph,
value1: { xAdvance: kerningArray[i][1]},
value2: undefined
});
}

const scripts = [
{
tag: 'DFLT',
script: {
defaultLangSys: {
featureIndexes: [0]
},
langSysRecords: []
}
}
];

const features = [
{
tag: 'kern',
feature: {
lookupListIndexes: [0]
}
}
];

const lookups = [
{
lookupType: 2,
subtables: [
{
posFormat: 1,
coverage: coverage,
valueFormat1: 0x0004,
valueFormat2: 0x0000,
pairSets: pairSets
}
]
}
];

return {version: 1, scripts, features, lookups};

}

export default { parse: parseGposTable, make: makeGposTable };
6 changes: 5 additions & 1 deletion src/tables/sfnt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import maxp from './maxp.mjs';
import _name from './name.mjs';
import os2 from './os2.mjs';
import post from './post.mjs';
import gpos from './gpos.mjs';
import gsub from './gsub.mjs';
import meta from './meta.mjs';
import colr from './colr.mjs';
Expand Down Expand Up @@ -357,6 +358,7 @@ function fontToSfntTable(font) {

// Optional tables
const optionalTables = {
gpos,
gsub,
cpal,
colr,
Expand All @@ -372,11 +374,13 @@ function fontToSfntTable(font) {
const optionalTableArgs = {
avar: [font.tables.fvar],
fvar: [font.names],
gpos: [font.kerningPairs],
};

for (let tableName in optionalTables) {
const table = font.tables[tableName];
if (table) {
// The GPOS table can also be made using `kerningPairs` from the `kern` table as input.
if (table || tableName === 'gpos') {
const tableData = optionalTables[tableName].make.call(font, table, ...(optionalTableArgs[tableName] || []));
if (tableData) {
tables.push(tableData);
Expand Down
Loading
Loading