From f570fe999e428a51d854603c59c51f71884385db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Pirsch?= Date: Mon, 5 Mar 2018 05:41:56 +0100 Subject: [PATCH] Rewrite GPOS parsing (#323) * Rewrite GPOS parsing * let is the new var --- src/font.js | 17 ++- src/layout.js | 63 ++++++++- src/opentype.js | 2 +- src/parse.js | 48 ++++++- src/position.js | 69 +++++++++ src/tables/gpos.js | 331 +++++++++++++++----------------------------- test/layout.js | 91 ++++++++++++ test/parse.js | 4 +- test/tables/gpos.js | 141 +++++++++++++++++++ 9 files changed, 535 insertions(+), 231 deletions(-) create mode 100644 src/position.js create mode 100644 test/tables/gpos.js diff --git a/src/font.js b/src/font.js index 6c85e8c2..f5234a5f 100644 --- a/src/font.js +++ b/src/font.js @@ -4,6 +4,7 @@ import Path from './path'; import sfnt from './tables/sfnt'; import { DefaultEncoding } from './encoding'; import glyphset from './glyphset'; +import Position from './position'; import Substitution from './substitution'; import { isBrowser, checkArgument, arrayBufferToNodeBuffer } from './util'; import HintingTrueType from './hintingtt'; @@ -87,6 +88,7 @@ function Font(options) { this.supported = true; // Deprecated: parseBuffer will throw an error if font is not supported. this.glyphs = new glyphset.GlyphSet(this, options.glyphs || []); this.encoding = new DefaultEncoding(this); + this.position = new Position(this); this.substitution = new Substitution(this); this.tables = this.tables || {}; @@ -234,9 +236,7 @@ Font.prototype.glyphIndexToName = function(gid) { Font.prototype.getKerningValue = function(leftGlyph, rightGlyph) { leftGlyph = leftGlyph.index || leftGlyph; rightGlyph = rightGlyph.index || rightGlyph; - const gposKerning = this.getGposKerningValue; - return gposKerning ? gposKerning(leftGlyph, rightGlyph) : - (this.kerningPairs[leftGlyph + ',' + rightGlyph] || 0); + return this.kerningPairs[leftGlyph + ',' + rightGlyph] || 0; }; /** @@ -275,6 +275,11 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback) options = options || this.defaultRenderOptions; 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); + } for (let i = 0; i < glyphs.length; i += 1) { const glyph = glyphs[i]; callback.call(this, glyph, x, y, fontSize, options); @@ -283,7 +288,11 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback) } if (options.kerning && i < glyphs.length - 1) { - const kerningValue = this.getKerningValue(glyph, glyphs[i + 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; } diff --git a/src/layout.js b/src/layout.js index 3fb34a42..e622c3ec 100644 --- a/src/layout.js +++ b/src/layout.js @@ -37,6 +37,29 @@ function binSearch(arr, value) { return -imin - 1; } +// binary search in a list of ranges (coverage, class definition) +function searchRange(ranges, value) { + // jshint bitwise: false + let range; + let imin = 0; + let imax = ranges.length - 1; + while (imin <= imax) { + const imid = (imin + imax) >>> 1; + range = ranges[imid]; + const start = range.start; + if (start === value) { + return range; + } else if (start < value) { + imin = imid + 1; + } else { imax = imid - 1; } + } + if (imin > 0) { + range = ranges[imin - 1]; + if (value > range.end) return 0; + return range; + } +} + /** * @exports opentype.Layout * @class @@ -215,7 +238,7 @@ Layout.prototype = { * @param {string} [script='DFLT'] * @param {string} [language='dlft'] * @param {string} feature - 4-letter feature code - * @param {number} lookupType - 1 to 8 + * @param {number} lookupType - 1 to 9 * @param {boolean} create - forces the creation of the lookup table if it doesn't exist, with no subtables. * @return {Object[]} */ @@ -249,6 +272,44 @@ Layout.prototype = { return tables; }, + /** + * Find a glyph in a class definition table + * https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table + * @param {object} classDefTable - an OpenType Layout class definition table + * @param {number} glyphIndex - the index of the glyph to find + * @returns {number} -1 if not found + */ + getGlyphClass: function(classDefTable, glyphIndex) { + switch (classDefTable.format) { + case 1: + if (classDefTable.startGlyph <= glyphIndex && glyphIndex < classDefTable.startGlyph + classDefTable.classes.length) { + return classDefTable.classes[glyphIndex - classDefTable.startGlyph]; + } + return 0; + case 2: + const range = searchRange(classDefTable.ranges, glyphIndex); + return range ? range.classId : 0; + } + }, + + /** + * Find a glyph in a coverage table + * https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-table + * @param {object} coverageTable - an OpenType Layout coverage table + * @param {number} glyphIndex - the index of the glyph to find + * @returns {number} -1 if not found + */ + getCoverageIndex: function(coverageTable, glyphIndex) { + switch (coverageTable.format) { + case 1: + const index = binSearch(coverageTable.glyphs, glyphIndex); + return index >= 0 ? index : -1; + case 2: + const range = searchRange(coverageTable.ranges, glyphIndex); + return range ? range.index + glyphIndex - range.start : -1; + } + }, + /** * Returns the list of glyph indexes of a coverage table. * Format 1: the list is stored raw diff --git a/src/opentype.js b/src/opentype.js index 58aa7ab2..3da9625a 100644 --- a/src/opentype.js +++ b/src/opentype.js @@ -334,7 +334,7 @@ function parseBuffer(buffer) { if (gposTableEntry) { const gposTable = uncompressTable(data, gposTableEntry); - gpos.parse(gposTable.data, gposTable.offset, font); + font.tables.gpos = gpos.parse(gposTable.data, gposTable.offset); } if (gsubTableEntry) { diff --git a/src/parse.js b/src/parse.js index 72b68717..54dced83 100644 --- a/src/parse.js +++ b/src/parse.js @@ -345,6 +345,50 @@ Parser.prototype.parseStruct = function(description) { } }; +/** + * Parse a GPOS valueRecord + * https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#value-record + * valueFormat is optional, if omitted it is read from the stream. + */ +Parser.prototype.parseValueRecord = function(valueFormat) { + if (valueFormat === undefined) { + valueFormat = this.parseUShort(); + } + if (valueFormat === 0) { // valueFormat2 in kerning pairs is most often 0 + return; // in this case return undefined instead of an empty object, to save space + } + const valueRecord = {}; + + if (valueFormat & 0x0001) { valueRecord.xPlacement = this.parseShort(); } + if (valueFormat & 0x0002) { valueRecord.yPlacement = this.parseShort(); } + if (valueFormat & 0x0004) { valueRecord.xAdvance = this.parseShort(); } + if (valueFormat & 0x0008) { valueRecord.yAdvance = this.parseShort(); } + + // Device table (non-variable font) / VariationIndex table (variable font) not supported + // https://docs.microsoft.com/fr-fr/typography/opentype/spec/chapter2#devVarIdxTbls + if (valueFormat & 0x0010) { valueRecord.xPlaDevice = undefined; this.parseShort(); } + if (valueFormat & 0x0020) { valueRecord.yPlaDevice = undefined; this.parseShort(); } + if (valueFormat & 0x0040) { valueRecord.xAdvDevice = undefined; this.parseShort(); } + if (valueFormat & 0x0080) { valueRecord.yAdvDevice = undefined; this.parseShort(); } + + return valueRecord; +}; + +/** + * Parse a list of GPOS valueRecords + * https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#value-record + * valueFormat and valueCount are read from the stream. + */ +Parser.prototype.parseValueRecordList = function() { + const valueFormat = this.parseUShort(); + const valueCount = this.parseUShort(); + const values = new Array(valueCount); + for (let i = 0; i < valueCount; i++) { + values[i] = this.parseValueRecord(valueFormat); + } + return values; +}; + Parser.prototype.parsePointer = function(description) { const structOffset = this.parseOffset16(); if (structOffset > 0) { // NULL offset => return undefined @@ -535,7 +579,7 @@ Parser.prototype.parseFeatureList = function() { Parser.prototype.parseLookupList = function(lookupTableParsers) { return this.parsePointer(Parser.list(Parser.pointer(function() { const lookupType = this.parseUShort(); - check.argument(1 <= lookupType && lookupType <= 8, 'GSUB lookup type ' + lookupType + ' unknown.'); + check.argument(1 <= lookupType && lookupType <= 9, 'GPOS/GSUB lookup type ' + lookupType + ' unknown.'); const lookupFlag = this.parseUShort(); const useMarkFilteringSet = lookupFlag & 0x10; return { @@ -551,7 +595,7 @@ Parser.prototype.parseFeatureVariationsList = function() { return this.parsePointer32(function() { const majorVersion = this.parseUShort(); const minorVersion = this.parseUShort(); - check.argument(majorVersion === 1 && minorVersion < 1, 'GSUB feature variations table unknown.'); + check.argument(majorVersion === 1 && minorVersion < 1, 'GPOS/GSUB feature variations table unknown.'); const featureVariations = this.parseRecordList32({ conditionSetOffset: Parser.offset32, featureTableSubstitutionOffset: Parser.offset32 diff --git a/src/position.js b/src/position.js new file mode 100644 index 00000000..8fcec724 --- /dev/null +++ b/src/position.js @@ -0,0 +1,69 @@ +// The Position object provides utility methods to manipulate +// the GPOS position table. + +import Layout from './layout'; + +/** + * @exports opentype.Position + * @class + * @extends opentype.Layout + * @param {opentype.Font} + * @constructor + */ +function Position(font) { + Layout.call(this, font, 'gpos'); +} + +Position.prototype = Layout.prototype; + +/** + * Find a glyph pair in a list of lookup tables of type 2 and retrieve the xAdvance kerning value. + * + * @param {integer} leftIndex - left glyph index + * @param {integer} rightIndex - right glyph index + * @returns {integer} + */ +Position.prototype.getKerningValue = function(kerningLookups, leftIndex, rightIndex) { + for (let i = 0; i < kerningLookups.length; i++) { + const subtables = kerningLookups[i].subtables; + for (let j = 0; j < subtables.length; j++) { + const subtable = subtables[j]; + const covIndex = this.getCoverageIndex(subtable.coverage, leftIndex); + if (covIndex < 0) continue; + switch (subtable.posFormat) { + case 1: + // Search Pair Adjustment Positioning Format 1 + let pairSet = subtable.pairSets[covIndex]; + for (let k = 0; k < pairSet.length; k++) { + let pair = pairSet[k]; + if (pair.secondGlyph === rightIndex) { + return pair.value1 && pair.value1.xAdvance || 0; + } + } + break; // left glyph found, not right glyph - try next subtable + case 2: + // Search Pair Adjustment Positioning Format 2 + const class1 = this.getGlyphClass(subtable.classDef1, leftIndex); + const class2 = this.getGlyphClass(subtable.classDef2, rightIndex); + const pair = subtable.classRecords[class1][class2]; + return pair.value1 && pair.value1.xAdvance || 0; + } + } + } + return 0; +}; + +/** + * List all kerning lookup tables. + * + * @param {string} [script='DFLT'] - use font.position.getDefaultScriptName() for a better default value + * @param {string} [language='dflt'] + * @return {object[]} The list of kerning lookup tables (may be empty), or undefined if there is no GPOS table (and we should use the kern table) + */ +Position.prototype.getKerningTables = function(script, language) { + if (this.font.tables.gpos) { + return this.getLookupTables(script, language, 'kern', 2); + } +}; + +export default Position; diff --git a/src/tables/gpos.js b/src/tables/gpos.js index 39a5a35c..98733396 100644 --- a/src/tables/gpos.js +++ b/src/tables/gpos.js @@ -1,237 +1,126 @@ // The `GPOS` table contains kerning pairs, among other things. -// https://www.microsoft.com/typography/OTSPEC/gpos.htm +// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos import check from '../check'; -import parse from '../parse'; - -// Parse ScriptList and FeatureList tables of GPOS, GSUB, GDEF, BASE, JSTF tables. -// These lists are unused by now, this function is just the basis for a real parsing. -function parseTaggedListTable(data, start) { - const p = new parse.Parser(data, start); - const n = p.parseUShort(); - const list = []; - for (let i = 0; i < n; i++) { - list[p.parseTag()] = { offset: p.parseUShort() }; - } - - return list; -} - -// Parse a coverage table in a GSUB, GPOS or GDEF table. -// Format 1 is a simple list of glyph ids, -// Format 2 is a list of ranges. It is expanded in a list of glyphs, maybe not the best idea. -function parseCoverageTable(data, start) { - const p = new parse.Parser(data, start); - const format = p.parseUShort(); - let count = p.parseUShort(); - if (format === 1) { - return p.parseUShortList(count); - } else if (format === 2) { - const coverage = []; - for (; count--;) { - const begin = p.parseUShort(); - const end = p.parseUShort(); - let index = p.parseUShort(); - for (let i = begin; i <= end; i++) { - coverage[index++] = i; - } - } - - return coverage; - } -} - -// Parse a Class Definition Table in a GSUB, GPOS or GDEF table. -// Returns a function that gets a class value from a glyph ID. -function parseClassDefTable(data, start) { - const p = new parse.Parser(data, start); - const format = p.parseUShort(); - if (format === 1) { - // Format 1 specifies a range of consecutive glyph indices, one class per glyph ID. - const startGlyph = p.parseUShort(); - const glyphCount = p.parseUShort(); - const classes = p.parseUShortList(glyphCount); - return function(glyphID) { - return classes[glyphID - startGlyph] || 0; +import { Parser } from '../parse'; +import table from '../table'; + +const subtableParsers = new Array(10); // subtableParsers[0] is unused + +// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-1-single-adjustment-positioning-subtable +// this = Parser instance +subtableParsers[1] = function parseLookup1() { + const start = this.offset + this.relativeOffset; + const posformat = this.parseUShort(); + if (posformat === 1) { + return { + posFormat: 1, + coverage: this.parsePointer(Parser.coverage), + value: this.parseValueRecord() }; - } else if (format === 2) { - // Format 2 defines multiple groups of glyph indices that belong to the same class. - const rangeCount = p.parseUShort(); - const startGlyphs = []; - const endGlyphs = []; - const classValues = []; - for (let i = 0; i < rangeCount; i++) { - startGlyphs[i] = p.parseUShort(); - endGlyphs[i] = p.parseUShort(); - classValues[i] = p.parseUShort(); - } - - return function(glyphID) { - let l = 0; - let r = startGlyphs.length - 1; - while (l < r) { - const c = (l + r + 1) >> 1; - if (glyphID < startGlyphs[c]) { - r = c - 1; - } else { - l = c; - } - } - - if (startGlyphs[l] <= glyphID && glyphID <= endGlyphs[l]) { - return classValues[l] || 0; - } - - return 0; + } else if (posformat === 2) { + return { + posFormat: 2, + coverage: this.parsePointer(Parser.coverage), + values: this.parseValueRecordList() }; } -} - -// Parse a pair adjustment positioning subtable, format 1 or format 2 -// The subtable is returned in the form of a lookup function. -function parsePairPosSubTable(data, start) { - const p = new parse.Parser(data, start); - // This part is common to format 1 and format 2 subtables - const format = p.parseUShort(); - const coverageOffset = p.parseUShort(); - const coverage = parseCoverageTable(data, start + coverageOffset); - // valueFormat 4: XAdvance only, 1: XPlacement only, 0: no ValueRecord for second glyph - // Only valueFormat1=4 and valueFormat2=0 is supported. - const valueFormat1 = p.parseUShort(); - const valueFormat2 = p.parseUShort(); - let value1; - let value2; - if (valueFormat1 !== 4 || valueFormat2 !== 0) return; - const sharedPairSets = {}; - if (format === 1) { - // Pair Positioning Adjustment: Format 1 - const pairSetCount = p.parseUShort(); - const pairSet = []; - // Array of offsets to PairSet tables-from beginning of PairPos subtable-ordered by Coverage Index - const pairSetOffsets = p.parseOffset16List(pairSetCount); - for (let firstGlyph = 0; firstGlyph < pairSetCount; firstGlyph++) { - const pairSetOffset = pairSetOffsets[firstGlyph]; - let sharedPairSet = sharedPairSets[pairSetOffset]; - if (!sharedPairSet) { - // Parse a pairset table in a pair adjustment subtable format 1 - sharedPairSet = {}; - p.relativeOffset = pairSetOffset; - let pairValueCount = p.parseUShort(); - for (; pairValueCount--;) { - const secondGlyph = p.parseUShort(); - if (valueFormat1) value1 = p.parseShort(); - if (valueFormat2) value2 = p.parseShort(); - // We only support valueFormat1 = 4 and valueFormat2 = 0, - // so value1 is the XAdvance and value2 is empty. - sharedPairSet[secondGlyph] = value1; - } - } - - pairSet[coverage[firstGlyph]] = sharedPairSet; - } - - return function(leftGlyph, rightGlyph) { - const pairs = pairSet[leftGlyph]; - if (pairs) return pairs[rightGlyph]; + check.assert(false, '0x' + start.toString(16) + ': GPOS lookup type 1 format must be 1 or 2.'); +}; + +// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-2-pair-adjustment-positioning-subtable +subtableParsers[2] = function parseLookup2() { + const start = this.offset + this.relativeOffset; + const posFormat = this.parseUShort(); + const coverage = this.parsePointer(Parser.coverage); + const valueFormat1 = this.parseUShort(); + const valueFormat2 = this.parseUShort(); + if (posFormat === 1) { + // Adjustments for Glyph Pairs + return { + posFormat: posFormat, + coverage: coverage, + valueFormat1: valueFormat1, + valueFormat2: valueFormat2, + pairSets: this.parseList(Parser.pointer(Parser.list(function() { + return { // pairValueRecord + secondGlyph: this.parseUShort(), + value1: this.parseValueRecord(valueFormat1), + value2: this.parseValueRecord(valueFormat2) + }; + }))) }; - } else if (format === 2) { - // Pair Positioning Adjustment: Format 2 - const classDef1Offset = p.parseUShort(); - const classDef2Offset = p.parseUShort(); - const class1Count = p.parseUShort(); - const class2Count = p.parseUShort(); - const getClass1 = parseClassDefTable(data, start + classDef1Offset); - const getClass2 = parseClassDefTable(data, start + classDef2Offset); - - // Parse kerning values by class pair. - const kerningMatrix = []; - for (let i = 0; i < class1Count; i++) { - const kerningRow = kerningMatrix[i] = []; - for (let j = 0; j < class2Count; j++) { - if (valueFormat1) value1 = p.parseShort(); - if (valueFormat2) value2 = p.parseShort(); - // We only support valueFormat1 = 4 and valueFormat2 = 0, - // so value1 is the XAdvance and value2 is empty. - kerningRow[j] = value1; - } - } - - // Convert coverage list to a hash - const covered = {}; - for (let i = 0; i < coverage.length; i++) { - covered[coverage[i]] = 1; - } - - // Get the kerning value for a specific glyph pair. - return function(leftGlyph, rightGlyph) { - if (!covered[leftGlyph]) return; - const class1 = getClass1(leftGlyph); - const class2 = getClass2(rightGlyph); - const kerningRow = kerningMatrix[class1]; - - if (kerningRow) { - return kerningRow[class2]; - } + } else if (posFormat === 2) { + const classDef1 = this.parsePointer(Parser.classDef); + const classDef2 = this.parsePointer(Parser.classDef); + const class1Count = this.parseUShort(); + const class2Count = this.parseUShort(); + return { + // Class Pair Adjustment + posFormat: posFormat, + coverage: coverage, + valueFormat1: valueFormat1, + valueFormat2: valueFormat2, + classDef1: classDef1, + classDef2: classDef2, + class1Count: class1Count, + class2Count: class2Count, + classRecords: this.parseList(class1Count, Parser.list(class2Count, function() { + return { + value1: this.parseValueRecord(valueFormat1), + value2: this.parseValueRecord(valueFormat2) + }; + })) }; } -} - -// Parse a LookupTable (present in of GPOS, GSUB, GDEF, BASE, JSTF tables). -function parseLookupTable(data, start) { - const p = new parse.Parser(data, start); - const lookupType = p.parseUShort(); - const lookupFlag = p.parseUShort(); - const useMarkFilteringSet = lookupFlag & 0x10; - const subTableCount = p.parseUShort(); - const subTableOffsets = p.parseOffset16List(subTableCount); - const table = { - lookupType: lookupType, - lookupFlag: lookupFlag, - markFilteringSet: useMarkFilteringSet ? p.parseUShort() : -1 - }; - // LookupType 2, Pair adjustment - if (lookupType === 2) { - const subtables = []; - for (let i = 0; i < subTableCount; i++) { - const pairPosSubTable = parsePairPosSubTable(data, start + subTableOffsets[i]); - if (pairPosSubTable) subtables.push(pairPosSubTable); - } - // Return a function which finds the kerning values in the subtables. - table.getKerningValue = function(leftGlyph, rightGlyph) { - for (let i = subtables.length; i--;) { - const value = subtables[i](leftGlyph, rightGlyph); - if (value !== undefined) return value; - } - - return 0; + check.assert(false, '0x' + start.toString(16) + ': GPOS lookup type 2 format must be 1 or 2.'); +}; + +subtableParsers[3] = function parseLookup3() { return { error: 'GPOS Lookup 3 not supported' }; }; +subtableParsers[4] = function parseLookup4() { return { error: 'GPOS Lookup 4 not supported' }; }; +subtableParsers[5] = function parseLookup5() { return { error: 'GPOS Lookup 5 not supported' }; }; +subtableParsers[6] = function parseLookup6() { return { error: 'GPOS Lookup 6 not supported' }; }; +subtableParsers[7] = function parseLookup7() { return { error: 'GPOS Lookup 7 not supported' }; }; +subtableParsers[8] = function parseLookup8() { return { error: 'GPOS Lookup 8 not supported' }; }; +subtableParsers[9] = function parseLookup9() { return { error: 'GPOS Lookup 9 not supported' }; }; + +// https://docs.microsoft.com/en-us/typography/opentype/spec/gpos +function parseGposTable(data, start) { + start = start || 0; + const p = new Parser(data, start); + const tableVersion = p.parseVersion(1); + check.argument(tableVersion === 1 || tableVersion === 1.1, 'Unsupported GPOS table version ' + tableVersion); + + if (tableVersion === 1) { + return { + version: tableVersion, + scripts: p.parseScriptList(), + features: p.parseFeatureList(), + lookups: p.parseLookupList(subtableParsers) + }; + } else { + return { + version: tableVersion, + scripts: p.parseScriptList(), + features: p.parseFeatureList(), + lookups: p.parseLookupList(subtableParsers), + variations: p.parseFeatureVariationsList() }; } - return table; } -// Parse the `GPOS` table which contains, among other things, kerning pairs. -// https://www.microsoft.com/typography/OTSPEC/gpos.htm -function parseGposTable(data, start, font) { - const p = new parse.Parser(data, start); - const tableVersion = p.parseVersion(1); - check.argument(tableVersion === 1 || tableVersion === 1.1, 'Unsupported GPOS table version.'); - - // ScriptList and FeatureList - ignored for now - parseTaggedListTable(data, start + p.parseUShort()); - // 'kern' is the feature we are looking for. - parseTaggedListTable(data, start + p.parseUShort()); - - // LookupList - const lookupListOffset = p.parseUShort(); - p.relativeOffset = lookupListOffset; - const lookupCount = p.parseUShort(); - const lookupTableOffsets = p.parseOffset16List(lookupCount); - const lookupListAbsoluteOffset = start + lookupListOffset; - for (let i = 0; i < lookupCount; i++) { - const table = parseLookupTable(data, lookupListAbsoluteOffset + lookupTableOffsets[i]); - if (table.lookupType === 2 && !font.getGposKerningValue) font.getGposKerningValue = table.getKerningValue; - } +// GPOS Writing ////////////////////////////////////////////// +// NOT SUPPORTED +const subtableMakers = new Array(10); + +function makeGposTable(gpos) { + return new table.Table('GPOS', [ + {name: 'version', type: 'ULONG', value: 0x10000}, + {name: 'scripts', type: 'TABLE', value: new table.ScriptList(gpos.scripts)}, + {name: 'features', type: 'TABLE', value: new table.FeatureList(gpos.features)}, + {name: 'lookups', type: 'TABLE', value: new table.LookupList(gpos.lookups, subtableMakers)} + ]); } -export default { parse: parseGposTable }; +export default { parse: parseGposTable, make: makeGposTable }; diff --git a/test/layout.js b/test/layout.js index 0fe2e541..6e4ea48a 100644 --- a/test/layout.js +++ b/test/layout.js @@ -62,4 +62,95 @@ describe('layout.js', function() { assert.equal(layout.getScriptTable('DFLT', true), scriptTable, 'must create only one instance for each tag'); }); }); + + describe('getGlyphClass', function() { + const classDef1 = { + format: 1, + startGlyph: 0x32, + classes: [ + 0, 1, 0, 1, 0, 1, 2, 1, 0, 2, 1, 1, 0, + 0, 0, 2, 2, 0, 0, 1, 0, 0, 0, 0, 2, 1 + ] + }; + + const classDef2 = { + format: 2, + ranges: [ + { start: 0x46, end: 0x47, classId: 2 }, + { start: 0x49, end: 0x49, classId: 2 }, + { start: 0xd2, end: 0xd3, classId: 1 } + ] + }; + + it('should find a glyph class in a format 1 class definition table', function() { + assert.equal(layout.getGlyphClass(classDef1, 0x32), 0); + assert.equal(layout.getGlyphClass(classDef1, 0x33), 1); + assert.equal(layout.getGlyphClass(classDef1, 0x34), 0); + assert.equal(layout.getGlyphClass(classDef1, 0x38), 2); + assert.equal(layout.getGlyphClass(classDef1, 0x4a), 2); + assert.equal(layout.getGlyphClass(classDef1, 0x4b), 1); + + // Any glyph not included in the range of covered glyph IDs automatically belongs to Class 0. + assert.equal(layout.getGlyphClass(classDef1, 0x31), 0); + assert.equal(layout.getGlyphClass(classDef1, 0x50), 0); + }); + + it('should find a glyph class in a format 2 class definition table', function() { + assert.equal(layout.getGlyphClass(classDef2, 0x46), 2); + assert.equal(layout.getGlyphClass(classDef2, 0x47), 2); + assert.equal(layout.getGlyphClass(classDef2, 0x49), 2); + assert.equal(layout.getGlyphClass(classDef2, 0xd2), 1); + assert.equal(layout.getGlyphClass(classDef2, 0xd3), 1); + + // Any glyph not covered by a ClassRangeRecord is assumed to belong to Class 0. + assert.equal(layout.getGlyphClass(classDef2, 0x45), 0); + assert.equal(layout.getGlyphClass(classDef2, 0x48), 0); + assert.equal(layout.getGlyphClass(classDef2, 0x4a), 0); + assert.equal(layout.getGlyphClass(classDef2, 0xd4), 0); + }); + }); + + describe('getCoverageIndex', function() { + const cov1 = { + format: 1, + glyphs: [0x4f, 0x125, 0x129] + }; + + const cov2 = { + format: 2, + ranges: [ + { start: 6, end: 6, index: 0 }, + { start: 11, end: 11, index: 1 }, + { start: 16, end: 16, index: 2 }, + { start: 18, end: 18, index: 3 }, + { start: 37, end: 41, index: 4 }, + { start: 44, end: 52, index: 9 }, + { start: 56, end: 62, index: 18 } + ] + }; + + it('should find a glyph in a format 1 coverage table', function() { + assert.equal(layout.getCoverageIndex(cov1, 0x4f), 0); + assert.equal(layout.getCoverageIndex(cov1, 0x125), 1); + assert.equal(layout.getCoverageIndex(cov1, 0x129), 2); + + assert.equal(layout.getCoverageIndex(cov1, 0x33), -1); + assert.equal(layout.getCoverageIndex(cov1, 0x80), -1); + assert.equal(layout.getCoverageIndex(cov1, 0x200), -1); + }); + + it('should find a glyph in a format 2 coverage table', function() { + assert.equal(layout.getCoverageIndex(cov2, 6), 0); + assert.equal(layout.getCoverageIndex(cov2, 11), 1); + assert.equal(layout.getCoverageIndex(cov2, 37), 4); + assert.equal(layout.getCoverageIndex(cov2, 38), 5); + assert.equal(layout.getCoverageIndex(cov2, 56), 18); + assert.equal(layout.getCoverageIndex(cov2, 62), 24); + + assert.equal(layout.getCoverageIndex(cov2, 5), -1); + assert.equal(layout.getCoverageIndex(cov2, 8), -1); + assert.equal(layout.getCoverageIndex(cov2, 55), -1); + assert.equal(layout.getCoverageIndex(cov2, 70), -1); + }); + }); }); diff --git a/test/parse.js b/test/parse.js index 1eb4f125..3540f925 100644 --- a/test/parse.js +++ b/test/parse.js @@ -134,7 +134,7 @@ describe('parse.js', function() { describe('parseClassDef', function() { it('should parse a ClassDefFormat1 table', function() { - // https://www.microsoft.com/typography/OTSPEC/chapter2.htm Example 7 + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#example-7-classdefformat1-table-class-array const data = '0001 0032 001A' + '0000 0001 0000 0001 0000 0001 0002 0001 0000 0002 0001 0001 0000' + '0000 0000 0002 0002 0000 0000 0001 0000 0000 0000 0000 0002 0001'; @@ -151,7 +151,7 @@ describe('parse.js', function() { }); it('should parse a ClassDefFormat2 table', function() { - // https://www.microsoft.com/typography/OTSPEC/chapter2.htm Example 8 + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#example-8-classdefformat2-table-class-ranges const data = '0002 0003 0030 0031 0002 0040 0041 0003 00D2 00D3 0001'; const p = new Parser(unhex(data), 0); assert.deepEqual(p.parseClassDef(), { diff --git a/test/tables/gpos.js b/test/tables/gpos.js new file mode 100644 index 00000000..80bacba3 --- /dev/null +++ b/test/tables/gpos.js @@ -0,0 +1,141 @@ +import assert from 'assert'; +import { unhex } from '../testutil'; +import gpos from '../../src/tables/gpos'; + +// Helper that builds a minimal GPOS table to test a lookup subtable. +function parseLookup(lookupType, subTableData) { + const data = unhex('00010000 000A 000C 000E' + // header + '0000' + // ScriptTable - 0 scripts + '0000' + // FeatureListTable - 0 features + '0001 0004' + // LookupListTable - 1 lookup table + '000' + lookupType + '0000 0001 0008' + // Lookup table - 1 subtable + subTableData); // sub table start offset: 0x1a + return gpos.parse(data).lookups[0].subtables[0]; +} + +describe('tables/gpos.js', function() { + //// Header /////////////////////////////////////////////////////////////// + it('can parse a GPOS header', function() { + const data = unhex( + '00010000 000A 000C 000E' + // header + '0000' + // ScriptTable - 0 scripts + '0000' + // FeatureListTable - 0 features + '0000' // LookupListTable - 0 lookups + ); + assert.deepEqual(gpos.parse(data), { version: 1, scripts: [], features: [], lookups: [] }); + }); + + it('can parse a GPOS header with null pointers', function() { + const data = unhex( + '00010000 0000 0000 0000' + ); + assert.deepEqual(gpos.parse(data), { version: 1, scripts: [], features: [], lookups: [] }); + }); + + //// Lookup type 1 //////////////////////////////////////////////////////// + it('can parse lookup1 SinglePosFormat1', function() { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#example-2-singleposformat1-subtable + const data = '0001 0008 0002 FFB0 0002 0001 01B3 01BC 0000'; + assert.deepEqual(parseLookup(1, data), { + posFormat: 1, + coverage: { + format: 2, + ranges: [{ start: 0x1b3, end: 0x1bc, index: 0 }] + }, + value: { yPlacement: -80 } + }); + }); + + it('can parse lookup1 SinglePosFormat1 with ValueFormat Table and ValueRecord', function() { + // https://docs.microsoft.com/fr-fr/typography/opentype/spec/gpos#example-14-valueformat-table-and-valuerecord + const data = '0001 000E 0099 0050 00D2 0018 0020 0002 0001 00C8 00D1 0000 000B 000F 0001 5540 000B 000F 0001 5540'; + assert.deepEqual(parseLookup(1, data), { + posFormat: 1, + coverage: { + format: 2, + ranges: [{ start: 0xc8, end: 0xd1, index: 0 }] + }, + value: { + xPlacement: 80, // 0x50 + yAdvance: 210, // 0xd2 + xPlaDevice: undefined, // not supported yet + yAdvDevice: undefined // not supported yet + } + }); + }); + + it('can parse lookup1 SinglePosFormat2', function() { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#example-3-singleposformat2-subtable + const data = '0002 0014 0005 0003 0032 0032 0019 0019 000A 000A 0001 0003 004F 0125 0129'; + assert.deepEqual(parseLookup(1, data), { + posFormat: 2, + coverage: { + format: 1, + glyphs: [0x4f, 0x125, 0x129] + }, + values: [ + { xPlacement: 50, xAdvance: 50 }, + { xPlacement: 25, xAdvance: 25 }, + { xPlacement: 10, xAdvance: 10 } + ] + }); + }); + + //// Lookup type 2 //////////////////////////////////////////////////////// + it('can parse lookup2 PairPosFormat1', function() { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#example-4-pairposformat1-subtable + const data = '0001 001E 0004 0001 0002 000E 0016 0001 0059 FFE2 FFEC 0001 0059 FFD8 FFE7 0001 0002 002D 0031'; + assert.deepEqual(parseLookup(2, data), { + posFormat: 1, + coverage: { + format: 1, + glyphs: [0x2d, 0x31] + }, + valueFormat1: 4, + valueFormat2: 1, + pairSets: [ + [{ secondGlyph: 0x59, value1: { xAdvance: -30 }, value2: { xPlacement: -20 } }], + [{ secondGlyph: 0x59, value1: { xAdvance: -40 }, value2: { xPlacement: -25 } }] + ] + }); + }); + + it('can parse lookup2 PairPosFormat2', function() { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#example-5-pairposformat2-subtable + const data = '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(parseLookup(2, data), { + posFormat: 2, + coverage: { + format: 1, + glyphs: [0x46, 0x47, 0x49] + }, + valueFormat1: 4, + valueFormat2: 0, + 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 } + ] + }, + class1Count: 2, + class2Count: 2, + classRecords: [ + [ + { value1: { xAdvance: 0 }, value2: undefined }, + { value1: { xAdvance: 0 }, value2: undefined } + ], + [ + { value1: { xAdvance: 0 }, value2: undefined }, + { value1: { xAdvance: -50 }, value2: undefined } + ] + ] + }); + }); +});