diff --git a/src/tables/gpos.mjs b/src/tables/gpos.mjs index 23f09789..eac31aff 100644 --- a/src/tables/gpos.mjs +++ b/src/tables/gpos.mjs @@ -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)}, @@ -123,4 +247,95 @@ function makeGposTable(gpos) { ]); } +/** + * Converts from kerning pairs created from `kern` table to "type 2" lookup for `GPOS` table. + * @param {Object} 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 }; diff --git a/src/tables/sfnt.mjs b/src/tables/sfnt.mjs index 6e526f64..60cd5000 100644 --- a/src/tables/sfnt.mjs +++ b/src/tables/sfnt.mjs @@ -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'; @@ -357,6 +358,7 @@ function fontToSfntTable(font) { // Optional tables const optionalTables = { + gpos, gsub, cpal, colr, @@ -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); diff --git a/test/tables/gpos.spec.mjs b/test/tables/gpos.spec.mjs index 0fedf140..ffad3143 100644 --- a/test/tables/gpos.spec.mjs +++ b/test/tables/gpos.spec.mjs @@ -1,6 +1,9 @@ import assert from 'assert'; -import { unhex } from '../testutil.mjs'; -import gpos from '../../src/tables/gpos.mjs'; +import { unhex, unhexArray } from '../testutil.mjs'; +import gpos, { subsetGposImplemented } from '../../src/tables/gpos.mjs'; +import { parse } from '../../src/opentype.mjs'; +import { readFileSync } from 'fs'; +const loadSync = (url, opt) => parse(readFileSync(url), opt); // Helper that builds a minimal GPOS table to test a lookup subtable. function parseLookup(lookupType, subTableData) { @@ -13,6 +16,19 @@ function parseLookup(lookupType, subTableData) { return gpos.parse(data).lookups[0].subtables[0]; } +function makeLookup(lookupType, data) { + return gpos.make({ + version: 1, + scripts: [], + features: [], + lookups: [{ + lookupType: lookupType, + lookupFlag: 0, + subtables: [data] + }] + }).encode().slice(0x1a); // sub table start offset: 0x1a +} + describe('tables/gpos.mjs', function() { //// Header /////////////////////////////////////////////////////////////// it('can parse a GPOS header', function() { @@ -138,4 +154,123 @@ describe('tables/gpos.mjs', function() { ] }); }); + + //// Lookup type 2: Pair Position Adjustment ////////////////////////////////// + it('can write lookup2 for pair positioning', function() { + // The substance of the test is taken from the example on the Microsoft website linked below, + // however the order (and subsequently offsets) are edited to match the output of the function. + // https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#example-4-pairposformat1-subtable + const expectedData = unhexArray( + '0001' + // PosFormat + '000E' + // Coverage offset (edited) + '0004 0001 0002' + // ValueFormat1 + ValueFormat2 + PairSetCount + '0016 001E' + // PairSet offsets (edited) + '0001 0002 002D 0031' + // Coverage table + '0001 0059 FFE2 FFEC' + // PairSet 1 + '0001 0059 FFD8 FFE7' // PairSet 2 + ); + assert.deepEqual(makeLookup(2, { + posFormat: 1, + valueFormat1: 4, + valueFormat2: 1, + coverage: { + format: 1, + glyphs: [0x2d, 0x31] // Glyph IDs for "P" and "T" + }, + pairSets: [ + [ + { + secondGlyph: 0x59, // Glyph ID for lowercase "o" + value1: { xAdvance: -30 }, // Adjustments for the pair "Po" + value2: { xPlacement: -20 } + } + ], + [ + { + secondGlyph: 0x59, // Glyph ID for lowercase "o" + value1: { xAdvance: -40 }, // Adjustments for the pair "To" + value2: { xPlacement: -25 } + } + ] + ], + }), expectedData); + }); + + //// Lookup type 2: Pair Position Adjustment with Classes /////////////////////// + it('can write lookup2 for class-based pair positioning', function() { + // https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#example-5-pairposformat2-subtable + const expectedData = unhexArray('0002 0018 0004 0000 0022 0032 0002 0002 0000 0000 0000 FFCE 0001 0003 0046 0047 0049 0002 0002 0046 0047 0001 0049 0049 0001 0002 0001 006A 006B 0001'); + assert.deepEqual(makeLookup(2, { + posFormat: 2, + valueFormat1: 4, + valueFormat2: 0, + coverage: { + format: 1, + glyphs: [0x46, 0x47, 0x49] // Glyph IDs for "v", "w", "y" + }, + classDef1: { + format: 2, + ranges: [ + { start: 0x46, end: 0x47, classId: 1 }, + { start: 0x49, end: 0x49, classId: 1 } + ] + }, + classDef2: { + format: 2, + ranges: [ + { start: 0x6A, end: 0x6B, classId: 1 } // Glyph IDs for "period" and "comma" + ] + }, + classRecords: [ + [ + { + value1: { xAdvance: 0 }, + value2: { xAdvance: 0 }, + }, + { + value1: { xAdvance: 0 }, + value2: { xAdvance: 0 }, + } + ], + [ + { + value1: { xAdvance: 0 }, + value2: { xAdvance: 0 }, + }, + { + value1: { xAdvance: -50 }, + value2: { xAdvance: 0 }, + } + ] + ] + }), expectedData); + }); + + it('can write tables that are read as identical to the original', function() { + // This font is used because it includes both "format 1" and "format 2" subtables. + const font = loadSync('./test/fonts/Roboto-Black.ttf'); + // Gasp table is currently broken, this line can be removed once that bug is fixed. + // See: https://github.com/opentypejs/opentype.js/pull/739 + font.tables.gasp = undefined; + + // Not all GPOS features are supported yet, so we need to subset the table. + const gpos = subsetGposImplemented(font.tables.gpos); + const gpos2 = subsetGposImplemented(parse(font.toArrayBuffer()).tables.gpos); + assert.deepStrictEqual(gpos, gpos2); + }); + + it('can convert from `kern` table to GPOS table, when GPOS does not exist', function() { + // This font contains both a `kern` and a `GPOS` table. + const font = loadSync('./test/fonts/Vibur.woff'); + let font2 = loadSync('./test/fonts/Vibur.woff'); + font2.tables.gpos = undefined; + font2 = parse(font2.toArrayBuffer()); + + const kern1 = font.getKerningValue(font.charToGlyph('T'), font.charToGlyph('i')); + const kern2 = font2.getKerningValue(font2.charToGlyph('T'), font2.charToGlyph('i')); + + assert.notStrictEqual(kern1, 0); + assert.strictEqual(kern1, kern2); + }); + });