diff --git a/build.bat b/build.bat index 0b8af9c..c5274ed 100644 --- a/build.bat +++ b/build.bat @@ -11,15 +11,15 @@ if not "%1%"=="" ( rem build all fonts for /d %%d in (.\ligature\*) do call :build_font %%~nd -exit /b - +exit /b + :build_font set lig=%1 set otf=%lig:Lig=% if not exist .\original\%otf%.otf exit /b -if not exist .\ligature\%lig%\charstrings.xml exit /b +if not exist .\ligature\%lig%\glyphs\* exit /b @echo Building %lig% ttx -f .\original\%otf%.otf diff --git a/build.sh b/build.sh index 6f6b42c..725b31e 100755 --- a/build.sh +++ b/build.sh @@ -6,15 +6,15 @@ build_font() { lig="$1" otf=${lig/Lig/} - if [ ! -e "./original/$otf.otf" ] - then - return + if [ ! -e "./original/$otf.otf" ] + then + return fi - if [ ! -e "./ligature/$lig/charstrings.xml" ] - then - return + if [ ! -e "./ligature/$lig/glyphs" ] + then + return fi - + echo Building $1 ttx -f "./original/$otf.otf" node index.js $otf diff --git a/extract.js b/extract.js index 5ebab58..f57fb39 100644 --- a/extract.js +++ b/extract.js @@ -1,38 +1,45 @@ -const Promise = require('bluebird'); -const fs = Promise.promisifyAll(require('fs')); - +const fs = require('fs'); +const os = require('os'); +if (!os.EOL) { + os.EOL = process.platform === 'win32' ? '\r\n' : '\n'; +} const xpath = require('xpath'); const { DOMParser, XMLSerializer } = require('xmldom'); const format = require('xml-formatter'); +const hash = require('hash.js'); + let fontName; -const regEx = /\.liga$/; -const regExBlankLines = /^(?=\n)$|^\s*/gm; -const regExWhitespace = /^\s+$/; +const regEx = /^LIG$|\.liga$/; const NodeType = {}; NodeType.TEXT_NODE = 3; -async function main() { +function main() { fontName = process.argv[2]; const srcFileName = `./ligature_source/${fontName}.ttx`; + const folder = `./ligature/${fontName}`; + const fileName = `./${folder}/names.json`; + if (!fs.existsSync(fileName)) { + return 0; + } + console.log(`Reading file ${srcFileName}`); - const xml = await fs.readFileAsync(srcFileName, 'utf-8'); + const xml = fs.readFileSync(srcFileName, 'utf-8'); const dom = new DOMParser().parseFromString(xml); - await extractAndWrite('charstrings', extractCharStrings, dom); - await extractAndWrite('config', extractConfig, dom); - await extractAndWrite('gpos', extractGpos, dom); - await extractAndWrite('gsub', extractGsub, dom); - await extractAndWrite('hmtx', extractHmtx, dom); - await extractAndWrite('subrs', extractSubrs, dom); - await extractAndWrite('gsubrs', extractGlobalSubrs, dom); + extractAndWrite('config', extractConfig, dom); + + extractCharStrings(dom); + + extractAndWrite('subrs', extractSubrs, dom); + extractAndWrite('gsubrs', extractGlobalSubrs, dom); console.log('Done'); } -async function extractAndWrite(name, func, dom) { +function extractAndWrite(name, func, dom) { const newDom = func(dom); console.log('Finished extracting ' + name); @@ -41,24 +48,11 @@ async function extractAndWrite(name, func, dom) { if (!fs.existsSync(folder)) { fs.mkdirSync(folder); } - await fs.writeFileAsync(fileName, format(serialize(newDom))); + fs.writeFileSync(fileName, format(serialize(newDom))); console.log('Finished writing ' + name); } - -const serialize = dom => - new XMLSerializer().serializeToString(dom, false, node => { - if (node.nodeType === NodeType.TEXT_NODE) { - if (regExWhitespace.test(node.data)) return null; - const data = node.data - .split('\r\n') - .filter(s => /\S+/.test(s)) - .map(s => s.replace(/^\s+/g, '')) - .join('\n'); - - return node.ownerDocument.createTextNode(data); - } - return node; - }); +//const dump = dom => console.log(serialize(dom)); +const serialize = dom => new XMLSerializer().serializeToString(dom); const extractConfig = dom => { const newDom = new DOMParser().parseFromString(''); @@ -92,29 +86,7 @@ const extractFromPath = (path, dom) => { return newDom.documentElement; }; -const extractGpos = dom => { - const gpos = xpath.select('/ttFont/GPOS', dom, true); - return gpos; -}; - -const extractGsub = dom => { - const gsub = xpath.select('/ttFont/GSUB', dom, true); - return gsub; -}; - -const extractHmtx = dom => { - const newDom = new DOMParser().parseFromString(''); - const mtx = xpath.select('/ttFont/hmtx/mtx', dom); - - mtx - .filter(node => regEx.test(node.getAttribute('name'))) - .forEach(node => newDom.documentElement.appendChild(node)); - - return newDom; -}; - const extractCharStrings = dom => { - const newDom = new DOMParser().parseFromString(''); const charStrings = xpath.select( '/ttFont/CFF/CFFFont/CharStrings/CharString', dom @@ -122,9 +94,118 @@ const extractCharStrings = dom => { charStrings .filter(node => regEx.test(node.getAttribute('name'))) - .forEach(node => newDom.documentElement.appendChild(node)); + .forEach(node => writeGlyphData(dom, node)); + console.log('Finished writing charstrings'); +}; - return newDom; +const writeGlyphData = (dom, node) => { + const newDom = new DOMParser().parseFromString('').documentElement; + const name = node.getAttribute('name'); + newDom.setAttribute('name', name); + + // get mtx + const mtx = xpath.select(`/ttFont/hmtx/mtx[@name="${name}"]`, dom, true); + newDom.setAttribute('lsb', mtx.getAttribute('lsb')); + newDom.setAttribute('width', mtx.getAttribute('width')); + + const charStringDom = dom.createElement('CharString'); + + const subrsDom = dom.createElement('Subrs'); + const gsubrsDom = dom.createElement('GlobalSubrs'); + newDom.appendChild(charStringDom); + newDom.appendChild(subrsDom); + newDom.appendChild(gsubrsDom); + + const subrs = { + map: [], + fingerprints: [], + sourcePath: '/ttFont/CFF/CFFFont/Private/Subrs', + target: subrsDom + }; + const gsubrs = { + map: [], + fingerprints: [], + sourcePath: '/ttFont/CFF/GlobalSubrs', + target: gsubrsDom + }; + + extractCharStringSubrs(dom, node, subrs, gsubrs, 8); + + const outline = indentTextContent(node.childNodes[0].textContent, 8); + charStringDom.appendChild(dom.createTextNode(outline)); + + const folder = `./ligature/${fontName}/glyphs`; + const fileName = `./${folder}/${name}.xml`; + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder); + } + fs.writeFileSync(fileName, format(serialize(newDom))); + console.log('* ' + name); +}; + +const extractCharStringSubrs = (dom, node, subrs, gsubrs, indent) => { + // check for callsubr/callgsubr + + const lines = node.childNodes[0].textContent.split(os.EOL); + const newLines = []; + let fingerprint = ''; + + lines.forEach(line => { + if (line.trim().length === 0) return; + const matches = line.match(/(.*?)(-?\d+) (callsubr|callgsubr)$/); + if (matches != null) { + const { map, fingerprints, sourcePath, target } = + matches[3] === 'callsubr' ? subrs : gsubrs; + const index = matches[2]; + const srcIndex = parseInt(index) + 107; + let newIndex = map[srcIndex]; + + if (!newIndex) { + // find subr in source dom and copy to target dom + const srcSubr = xpath.select( + `/${sourcePath}/CharString[@index="${srcIndex}"]`, + dom, + true + ); + + // append subr to target dom and get new index + newIndex = xpath.select('count(CharString)', target, true); + map[srcIndex] = newIndex; + + const clone = srcSubr.cloneNode(true); + clone.setAttribute('index', newIndex); + target.appendChild(clone); + + // patch up source in case it also has any callsubrs + fingerprint = extractCharStringSubrs(dom, clone, subrs, gsubrs, 12); + fingerprints[newIndex] = fingerprint; + } else { + fingerprint = fingerprints[newIndex]; + } + + // rewrite line with fingerprint + line = `${matches[1]}{${fingerprint}} ${matches[3]}`; + } + newLines.push(line.trim()); + }); + const content = newLines.join('\n'); + fingerprint = hash + .sha256() + .update(content) + .digest('hex') + .substr(0, 8); + node.setAttribute('fingerprint', fingerprint); + + node.childNodes[0].textContent = indentTextContent(content, indent); + return fingerprint; +}; + +const indentTextContent = (text, indent) => { + return text + .split('\n') + .map(line => ' '.repeat(indent) + line.trim()) + .join('\n') + .trim(); }; const extractSubrs = dom => { diff --git a/extract.sh b/extract.sh index 88f4b51..798ec0c 100755 --- a/extract.sh +++ b/extract.sh @@ -15,6 +15,6 @@ then else # build all available fonts for f in ./ligature_source/*.otf ; do - extract_font $(basename $f) + extract_font `basename "${f%.*}"` done fi diff --git a/gsub.js b/gsub.js new file mode 100644 index 0000000..d8a0c78 --- /dev/null +++ b/gsub.js @@ -0,0 +1,264 @@ +const xpath = require('xpath'); +//const { XMLSerializer } = require('xmldom'); +//const format = require('xml-formatter'); +const NodeType = {}; +NodeType.TEXT_NODE = 3; + +let dom; +let gsubDom; +let scriptListDom; +let featureListDom; +let lookupListDom; +let featureDom; +let lookupIndex = 0; +let chainIndex = 0; +const substLookupMap = {}; + +const buildGsubTables = (_dom, ligature) => { + const glyphs = ligature.name.split('_'); + + dom = _dom; + gsubDom = xpath.select('ttFont/GSUB', dom, true); + + // look for 'calt' feature + featureListDom = xpath.select('FeatureList', gsubDom, true); + + let featureRecord; + const featureTag = xpath.select( + 'FeatureRecord/FeatureTag[@value="calt"]', + featureListDom, + true + ); + if (!featureTag) { + const featureListCount = xpath.select( + 'count(FeatureRecord)', + featureListDom, + true + ); + featureRecord = createElementWithAttributes('FeatureRecord', { + index: featureListCount + }); + featureRecord.appendChild( + createElementWithAttributes('FeatureTag', { value: 'calt' }) + ); + featureRecord.appendChild(dom.createElement('Feature')); + featureListDom.appendChild(featureRecord); + } else { + featureRecord = featureTag.parentNode; + } + featureDom = xpath.select('Feature', featureRecord, true); + + const featureIndex = featureRecord.getAttribute('index'); + + // add feature to scriptlist for DFLT and latn if not present + addFeatureToScriptList('DFLT', featureIndex); + addFeatureToScriptList('latn', featureIndex); + + lookupListDom = xpath.select('LookupList', gsubDom, true); + lookupIndex = xpath.select('count(Lookup)', lookupListDom, true); + + const lookupDom = createElementWithAttributes('Lookup', { + index: lookupIndex++ + }); + appendChildren( + lookupDom, + createElementWithAttributes('LookupType', { value: 6 }), + createElementWithAttributes('LookupFlag', { value: 0 }) + ); + + lookupListDom.appendChild(lookupDom); + + // add contextual lookup to feature + const featureLookupCount = xpath.select( + 'count(LookupListIndex)', + featureDom, + true + ); + featureDom.appendChild( + createElementWithAttributes('LookupListIndex', { + index: featureLookupCount, + value: lookupDom.getAttribute('index') + }) + ); + + lookupDom.appendChild(buildBacktrackFirst(glyphs)); + lookupDom.appendChild(buildLookAheadLast(glyphs)); + lookupDom.appendChild(buildLigatureSubst(glyphs, ligature.glyph)); + // remaining context + for (let n = glyphs.length - 2; n >= 0; n--) { + lookupDom.appendChild( + buildChainContext(getLIGs(n), [glyphs[n]], glyphs.slice(n + 1), 'LIG') + ); + } +}; + +const finalizeGsubTables = () => { + // get current LookupList count + let newIndex = xpath.select('count(Lookup)', lookupListDom, true); + + // append added lookups + Object.entries(substLookupMap).forEach(lookupEntry => { + // fixup existing lookups with new index + const lookup = lookupEntry[1]; + const oldIndex = lookup.getAttribute('index'); + const substLookups = xpath.select( + `//ChainContextSubst/SubstLookupRecord/LookupListIndex[@value="${oldIndex}"]`, + lookupListDom, + false + ); + substLookups.forEach(substLookup => { + substLookup.setAttribute('value', newIndex); + }); + lookup.setAttribute('index', newIndex++); + lookupListDom.appendChild(lookup); + lookupListDom.appendChild(lookup); + }); +}; + +//const dump = dom => console.log(format(serialize(dom))); + +const addFeatureToScriptList = (tag, featureIndex) => { + scriptListDom = xpath.select('ScriptList', gsubDom, true); + const defaultLangSys = xpath.select( + `ScriptRecord/ScriptTag[@value="${tag}"]/../Script/DefaultLangSys`, + scriptListDom, + true + ); + const featureIndexNode = xpath.select( + `FeatureIndex[@value=${featureIndex}]`, + defaultLangSys, + true + ); + if (!featureIndexNode) { + const count = xpath.select('count(FeatureIndex)', defaultLangSys, true); + defaultLangSys.appendChild( + createElementWithAttributes('FeatureIndex', { + index: count, + value: featureIndex + }) + ); + } +}; + +const buildBacktrackFirst = glyphs => { + // backtrack = first glyph + // input = first glphy + // lookAhead = [1..n] + return buildChainContext([glyphs[0]], [glyphs[0]], glyphs.slice(1)); +}; + +const buildLookAheadLast = glyphs => { + // backtrack = none + // input = first glyph + // lookAhead = [1..n] + [n] + return buildChainContext( + [], + [glyphs[0]], + [...glyphs.slice(1), glyphs.slice(-1)] + ); +}; + +const buildLigatureSubst = (glyphs, lookup) => { + // backtrack = LIG * n-1 + // input = last glyph + // lookAhead = none + return buildChainContext( + getLIGs(glyphs.length - 1), + glyphs.slice(-1), + [], + lookup + ); +}; + +const getLIGs = length => new Array(length).fill('LIG', 0, length); + +const buildChainContext = (backtrack, input, lookAhead, substitute) => { + const chainDom = createElementWithAttributes('ChainContextSubst', { + index: chainIndex++, + Format: 3 + }); + + buildCoverage(chainDom, 'BacktrackCoverage', backtrack); + buildCoverage(chainDom, 'InputCoverage', input); + buildCoverage(chainDom, 'LookAheadCoverage', lookAhead); + + if (substitute) { + const lookup = getSubstLookup(input[0], substitute); + const listIndex = lookup.getAttribute('index'); + const substLookupRecordDom = chainDom.appendChild( + createElementWithAttributes('SubstLookupRecord', { index: 0 }) + ); + appendChildren( + substLookupRecordDom, + createElementWithAttributes('SequenceIndex', { value: 0 }), + createElementWithAttributes('LookupListIndex', { value: listIndex }) + ); + } + return chainDom; +}; + +const buildCoverage = (chainDom, tagName, coverage) => { + if (coverage) { + coverage.forEach((glyph, i) => { + const coverageDom = createElementWithAttributes(tagName, { + index: i++, + Format: 1 + }); + coverageDom.appendChild( + createElementWithAttributes('Glyph', { value: glyph }) + ); + chainDom.appendChild(coverageDom); + }); + } +}; + +const getSubstLookup = (input, output) => { + let lookupDom = substLookupMap[output]; + + if (!lookupDom) { + lookupDom = createElementWithAttributes('Lookup', { + index: Object.keys(substLookupMap).length + 1 + }); + lookupDom.appendChild( + createElementWithAttributes('LookupType', { value: 1 }) + ); + lookupDom.appendChild( + createElementWithAttributes('LookupFlag', { value: 0 }) + ); + lookupDom.appendChild( + createElementWithAttributes('SingleSubst', { index: 0, Format: 2 }) + ); + substLookupMap[output] = lookupDom; + } + const singleSubstDom = xpath.select('SingleSubst', lookupDom, true); + + // look for input subst + const substitionDom = xpath.select( + `Substitution[@in="${input}"]`, + singleSubstDom, + true + ); + if (!substitionDom) { + singleSubstDom.appendChild( + createElementWithAttributes('Substitution', { in: input, out: output }) + ); + } + return lookupDom; +}; + +const createElementWithAttributes = (tagName, attributes) => { + const element = dom.createElement(tagName); + Object.entries(attributes).forEach(attribute => { + element.setAttribute(attribute[0], attribute[1]); + }); + return element; +}; + +const appendChildren = (node, ...children) => { + children.forEach(child => node.appendChild(child)); +}; + +//const serialize = dom => new XMLSerializer().serializeToString(dom); + +exports.buildGsubTables = buildGsubTables; +exports.finalizeGsubTables = finalizeGsubTables; diff --git a/index.js b/index.js index 56d70c6..78d3a9f 100644 --- a/index.js +++ b/index.js @@ -1,76 +1,193 @@ -const Promise = require('bluebird'); -const fs = Promise.promisifyAll(require('fs')); +const fs = require('fs'); const os = require('os'); if (!os.EOL) { os.EOL = process.platform === 'win32' ? '\r\n' : '\n'; } +const gsub = require('./gsub'); + const xpath = require('xpath'); const { DOMParser, XMLSerializer } = require('xmldom'); const format = require('xml-formatter'); let fontName; let ligFontName; -const regEx = /\.liga$/; -const regExBlankLines = /^(?=\n)$|^\s*/gm; const regExWhitespace = /^\s+$/; const NodeType = {}; NodeType.TEXT_NODE = 3; -async function main() { +let dom; +function main() { fontName = process.argv[2]; ligFontName = fontName.split('-').join('Lig-'); const srcFileName = `./original/${fontName}.ttx`; - const dstFileName = `./build/${ligFontName}.ttx`; console.log(`Reading original font file ${srcFileName}`); - const xml = await fs.readFileAsync(srcFileName, 'utf-8'); - const dom = new DOMParser().parseFromString(xml); + const xml = fs.readFileSync(srcFileName, 'utf-8'); + dom = new DOMParser().parseFromString(xml); - try { - await processPatch('names', patchNames, dom); - await processPatch('glyphs', patchGlyphs, dom); - await processPatch('gpos', patchGpos, dom); - await processPatch('gsub', patchGsub, dom); - await processPatch('hmtx', patchHmtx, dom); - //await processPatch('lookup', patchLookup, dom); - await processPatch('charstrings', patchCharStrings, dom); - } catch (err) { - console.log(err); - } + const profiles = getProfiles(); + + // process settings (there may be more than one font to build) + profiles.forEach(profile => buildFont(profile)); - console.log(`Writing ligature font file ${dstFileName}`); - await fs.writeFileAsync(dstFileName, format(serialize(dom))); console.log('Done'); } -async function loadConfigAsync(name) { +const buildFont = profile => { + // add suffix to dstFileName if present + const dstFileName = `./build/${ligFontName}${ + profile.suffixWithLeadingHyphen + }.ttx`; + console.log( + `Building ligature font file ${dstFileName} name = ${profile.name}` + ); + + const ligatures = sortLigatures( + fs + .readdirSync(`./ligature/${ligFontName}/glyphs`) + .filter(file => file != 'LIG.xml') + .map(file => file.replace('.liga.xml', '')) + ) + .filter(name => !/\d+$/.test(name)) // skip alternates (ends with .#) + .filter(name => filterLigatures(name, profile.ligatures)) + .map(name => mapLigatures(name, profile.ligatures)); + + const ligaturesWithLIG = [...ligatures, { name: 'LIG', glyph: 'LIG' }]; + + processPatch('names', patchNames, dom, ligatures, profile); + processPatch('glyphs', patchGlyphs, dom, ligaturesWithLIG); + processPatch('gsub', patchGsub, dom, ligatures); + processPatch('charstrings', patchCharStrings, dom, ligaturesWithLIG); + processPatch('hmtx', patchHmtx, dom); + + console.log(`Writing ligature font file ${dstFileName}`); + fs.writeFileSync(dstFileName, format(serialize(dom))); +}; + +const getProfiles = () => { + const profilePath = './original/profiles.ini'; + // return default profiles if profile doesn't exist + if (!fs.existsSync(profilePath)) { + return [ + { + name: 'default', + suffix: '', + suffixWithLeadingSpace: '', + suffixWithLeadingHyphen: '', + ligatures: [] + } + ]; + } + + const profiles = []; + let profile = null; + + const content = fs.readFileSync(profilePath, 'utf-8'); + content + .split(os.EOL) + .filter(line => /^#/.test(line) === false || line.length > 0) + .forEach(line => { + const ch = line.trim()[0]; + if (ch === '[') { + let name = line.substr(1, line.indexOf(']') - 1); + profile = { + name, + suffix: name === 'default' ? '' : name, + suffixWithLeadingSpace: name === 'default' ? '' : ' ' + name, + suffixWithLeadingHyphen: name === 'default' ? '' : '-' + name, + ligatures: [] + }; + profiles.push(profile); + } else { + if (!profile) { + throw new Error('You must profile a profile name in []'); + } + profile.ligatures.push(line); + } + }); + + return profiles; +}; + +const filterLigatures = (name, ligatures) => { + // loop through ligatures and return if name applies or not + // skip if setting is !name + return ligatures.filter(ligature => ligature === '!' + name).length === 0; +}; + +const mapLigatures = (name, ligatures) => { + // return { ligature, glyph } + let entry = { name, glyph: name + '.liga' }; + ligatures.forEach(ligature => { + const n = ligature.indexOf('='); + if (n > 0 && ligature.substr(0, n) === name) { + entry.glyph = ligature.substr(n + 1); + } + }); + return entry; +}; + +const sortLigatures = ligatures => { + // sort by most glyphs then alphabetically + const sorted = ligatures + .map(ligature => { + return { count: ligature.split('_').length + 1, ligature: ligature }; + }) + .sort( + (a, b) => + -compareProperty(a.count, b.count) || // sort by count descending + compareProperty(a.ligature, b.ligature) // then by ligature alphabetically + ) + .map(entry => entry.ligature); + return sorted; +}; + +const compareProperty = (a, b) => { + if (typeof a === 'number') { + return a || b ? (!a ? -1 : !b ? 1 : a === b ? 0 : a < b ? -1 : 1) : 0; + } else { + return a || b ? (!a ? -1 : !b ? 1 : a.localeCompare(b)) : 0; + } +}; + +const loadXml = name => { const fileName = `./ligature/${ligFontName}/${name}.xml`; - const xml = await fs.readFileAsync(fileName, 'utf-8'); + const xml = fs.readFileSync(fileName, 'utf-8'); return new DOMParser().parseFromString(xml); -} +}; -async function processPatch(name, patchFunc, dom) { +const processPatch = (name, patchFunc, dom, ligatures, profile) => { console.log(`Patching ${name}`); - await patchFunc(dom); -} + patchFunc(dom, ligatures, profile); +}; -async function patchNames(dom) { - const configDom = await loadConfigAsync('names'); +const PlatformId = { + mac: 1, + win: 3 +}; - const names = xpath.select('/name/namerecord', configDom); - const targetName = xpath.select('/ttFont/name', dom, true); +const NameId = { + familyName: 1, + fontStyle: 2, + uniqueId: 3, + fullName: 4, + version: 5, + postscriptName: 6, + windowsFamilyName: 16, + fontStyleName: 17 +}; - // get font and family name - const familyName = getTextNode( - configDom, - '/name/namerecord[@nameID="1" and @platformID="1"]' - ); - const fullName = getTextNode( - configDom, - '/name/namerecord[@nameID="4" and @platformID="1"]' +const patchNames = (dom, ligatures, profile) => { + const names = JSON.parse( + fs.readFileSync(`./ligature/${ligFontName}/names.json`) ); + const [name, style] = names.fontName.split('-'); + const fontName = `${name}${profile.suffixWithLeadingHyphen}-${style}`; + const familyName = `${names.familyName}${profile.suffixWithLeadingSpace}`; + const fullName = `${familyName} ${names.fontStyle}`; + const uniqueId = `${names.foundry}: ${fullName}: ${names.version}`; // patch CFFFont const cffFont = xpath.select('/ttFont/CFF/CFFFont', dom, true); @@ -79,93 +196,65 @@ async function patchNames(dom) { setAttribute(cffFont, 'FamilyName', 'value', familyName); // update existing names with new names - names.forEach(node => { - const nameId = node.getAttribute('nameID'); - const platformId = node.getAttribute('platformID'); + updateName(PlatformId.mac, NameId.familyName, familyName); + updateName(PlatformId.mac, NameId.fontStyle, names.fontStyle); + updateName(PlatformId.mac, NameId.uniqueId, uniqueId); + updateName(PlatformId.mac, NameId.fullName, fullName); + updateName(PlatformId.mac, NameId.postscriptName, fontName); + updateName(PlatformId.mac, NameId.windowsFamilyName, familyName); + updateName(PlatformId.mac, NameId.fontStyleName, names.fontStyle); - // search for namerecord in target dom and replace with this one or append node - const target = xpath.select( - `/ttFont/name/namerecord[@nameID="${nameId}" and @platformID="${platformId}"]`, - dom, - true - ); - if (target) { - target.parentNode.replaceChild(node, target); - } else { - targetName.appendChild(node); - } - }); -} + updateName(PlatformId.win, NameId.familyName, familyName); + updateName(PlatformId.win, NameId.fontStyle, names.windowsFontStyle); + updateName(PlatformId.win, NameId.uniqueId, uniqueId); + updateName(PlatformId.win, NameId.fullName, fullName); + updateName(PlatformId.win, NameId.postscriptName, fontName); + updateName(PlatformId.win, NameId.windowsFamilyName, familyName); + updateName(PlatformId.win, NameId.fontStyleName, names.fontStyle); +}; -async function patchGlyphs(dom) { - const configDom = await loadConfigAsync('../glyphs'); +const updateName = (platformId, nameId, value) => { + // search for namerecord in target dom and replace with this one or append node + const target = xpath.select( + `/ttFont/name/namerecord[@nameID="${nameId}" and @platformID="${platformId}"]`, + dom, + true + ); + if (target) { + target.childNodes[0].textContent = value; + } +}; + +const patchGlyphs = (dom, ligatures) => { // get number of glyphs in target dom const targetGlyphs = xpath.select('/ttFont/GlyphOrder', dom, true); const glyphsCount = xpath.select('count(GlyphID)', targetGlyphs, true); + // only import glyphs specified let n = glyphsCount; // get ligature glyphs - const glyphs = xpath.select('/GlyphOrder/GlyphID', configDom); - glyphs.forEach(node => { - node.setAttribute('id', n++); - targetGlyphs.appendChild(node); + ligatures.forEach(ligature => { + targetGlyphs.appendChild( + createElementWithAttributes('GlyphID', { id: n++, name: ligature.glyph }) + ); }); - // update glyph count setAttribute(dom, '/ttFont/maxp/numGlyphs', 'value', n); -} - -async function patchGpos(dom) { - const newGpos = await loadConfigAsync('gpos'); - const oldGpos = xpath.select('/ttFont/GPOS', dom, true); - dom.documentElement.replaceChild(newGpos, oldGpos); -} - -async function patchGsub(dom) { - const newGsub = await loadConfigAsync('gsub'); - - // loop through Substituion/Ligature nodes and remap chars - remap(newGsub, dom, '//Substitution', 'out'); - remap(newGsub, dom, '//Ligature', 'glyph'); - - const oldGsub = xpath.select('/ttFont/GSUB', dom, true); - dom.documentElement.replaceChild(newGsub, oldGsub); -} - -const remap = (patchDom, cmapDom, path, attr) => { - const cmap = []; - let nodes = xpath.select(path, patchDom); - nodes.forEach(n => { - const out = n.getAttribute(attr); - if (out.startsWith('uni')) { - // get cmap entry - const code = '0x' + out.replace(/uni0*/g, '').toLowerCase(); - let name = cmap[code]; - if (!name) { - const map = xpath.select( - `/ttFont/cmap/cmap_format_4/map[@code="${code}"]`, - cmapDom, - true - ); - name = map == null ? out : map.getAttribute('name'); - cmap[code] = name; - } - n.setAttribute(attr, name); - } - }); }; -async function patchHmtx(dom) { - const hmtxDom = await loadConfigAsync('hmtx'); - const targetHmtx = xpath.select('/ttFont/hmtx', dom, true); - - const mtx = xpath.select('/hmtx/mtx', hmtxDom); - mtx.forEach(node => targetHmtx.appendChild(node)); +const patchGsub = (dom, ligatures) => { + ligatures.forEach(ligature => { + // build gsub tables + gsub.buildGsubTables(dom, ligature); + }); + gsub.finalizeGsubTables(); +}; +const patchHmtx = dom => { const mtxCount = xpath.select('count(/ttFont/hmtx/mtx)', dom, true); setAttribute(dom, '/ttFont/hhea/numberOfHMetrics', 'value', mtxCount); - const configDom = await loadConfigAsync('config'); + const configDom = loadXml('config'); copyConfigAttribute(dom, configDom, '/ttFont/head/xMin', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/head/yMin', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/head/xMax', 'value'); @@ -179,159 +268,95 @@ async function patchHmtx(dom) { '/ttFont/CFF/CFFFont/Private/nominalWidthX', 'value' ); -} - -async function patchLookup(dom) { - const configDom = await loadConfigAsync('../lookup'); - const featureList = xpath.select('/ttFont/GSUB/FeatureList', dom, true); - - const featuresCount = xpath.select( - 'count(/ttFont/GSUB/FeatureList/FeatureRecord)', - dom, - true - ); - const featureRecord = dom.createElement('FeatureRecord'); - featureRecord.setAttribute('index', featuresCount); - const featureTag = dom.createElement('FeatureTag'); - featureTag.setAttribute('value', 'liga'); - const feature = dom.createElement('Feature'); - featureRecord.appendChild(featureTag); - featureRecord.appendChild(feature); - featureList.appendChild(featureRecord); - - const lookupCount = xpath.select( - 'count(/ttFont/GSUB/LookupList/Lookup)', - dom, - true - ); - const lookupListIndex = dom.createElement('LookupListIndex'); - lookupListIndex.setAttribute('index', '0'); - lookupListIndex.setAttribute('value', lookupCount); - feature.appendChild(lookupListIndex); - - // helper function for adding feature to ScriptRecord - const addFeature = (scriptRecord, featureRecord, lang) => { - if (!lang) return; - const index = featureRecord.getAttribute('index'); - if (xpath.select(`FeatureIndex[@value="${index}"]`, lang, true)) return; - - const count = xpath.select('count(FeatureIndex)', lang, true); - const featureIndex = dom.createElement('FeatureIndex'); - featureIndex.setAttribute('index', count); - featureIndex.setAttribute('value', index); - lang.appendChild(featureIndex); - }; - - // nead to add feature to ScriptList - const scriptList = xpath.select('/ttFont/GSUB/ScriptList', dom, true); - const scriptRecords = xpath.select('ScriptRecord', scriptList); - scriptRecords.forEach(node => { - // check for feature in DefaultLangSys - addFeature( - node, - featureRecord, - xpath.select('Script/DefaultLangSys', node, true) - ); - // add features to any other languages - xpath - .select('Script/LangSysRecord/LangSys', node) - .forEach(lang => addFeature(node, featureRecord, lang)); - }); - - // finally add LigatureSubst to Lookup - const lookupList = xpath.select('/ttFont/GSUB/LookupList', dom, true); - const newLookup = xpath.select('/LookupList/Lookup', configDom, true); - newLookup.setAttribute('index', lookupCount); - lookupList.appendChild(newLookup); -} - -async function patchCharStrings(dom) { - const configDom = await loadConfigAsync('charstrings'); - const nameDom = await loadConfigAsync('names'); - - // get font and family name - const familyName = getTextNode( - nameDom, - '/name/namerecord[@nameID="1" and @platformID="1"]' - ); - const fullName = getTextNode( - nameDom, - '/name/namerecord[@nameID="4" and @platformID="1"]' - ); +}; +const patchCharStrings = (dom, ligatures) => { // patch CFFFont const cffFont = xpath.select('/ttFont/CFF/CFFFont', dom, true); + const targetHmtx = xpath.select('/ttFont/hmtx', dom, true); - cffFont.setAttribute('name', ligFontName); - setAttribute(cffFont, 'FullName', 'value', fullName); - setAttribute(cffFont, 'FamilyName', 'value', familyName); - - const charStrings = xpath.select('/CharStrings/CharString', configDom); const targetCharStrings = xpath.select('CharStrings', cffFont, true); + const targetSubrs = xpath.select( + '/ttFont/CFF/CFFFont/Private/Subrs', + dom, + true + ); + const targetGsubrs = xpath.select('/ttFont/CFF/GlobalSubrs', dom, true); + const fingerprints = {}; + ligatures.forEach(ligature => { + console.log( + `* ${ligature.name}${ + ligature.name + '.liga' === ligature.glyph || ligature.name === 'LIG' + ? '' + : ' => ' + ligature.glyph + }` + ); + const glyphDom = loadXml(`glyphs/${ligature.glyph}`).documentElement; + const node = xpath.select('/Glyph/CharString', glyphDom, true); + node.setAttribute('name', ligature.glyph); - const subrs = { - map: [], - source: await loadConfigAsync('subrs'), - target: xpath.select('/ttFont/CFF/CFFFont/Private/Subrs', dom, true) - }; - const gsubrs = { - map: [], - source: await loadConfigAsync('gsubrs'), - target: xpath.select('/ttFont/CFF/GlobalSubrs', dom, true) - }; + const subrs = { + sourcePath: '/Glyph/Subrs', + target: targetSubrs + }; + const gsubrs = { + sourcePath: '/Glyph/GlobalSubrs', + target: targetGsubrs + }; + + patchCharStringSubrs(glyphDom, node, fingerprints, subrs, gsubrs); - charStrings.forEach(node => { - patchCharStringSubrs(node, subrs, gsubrs); targetCharStrings.appendChild(node); + targetHmtx.appendChild( + createElementWithAttributes('mtx', { + name: ligature.glyph, + width: glyphDom.getAttribute('width'), + lsb: glyphDom.getAttribute('lsb') + }) + ); }); -} +}; -const patchCharStringSubrs = (node, subrs, gsubrs) => { +const patchCharStringSubrs = (glyphDom, node, fingerprints, subrs, gsubrs) => { // check for callsubr/callgsubr const lines = node.childNodes[0].textContent.split(/\r|\r\n|\n/g); const newLines = []; - let patched = false; lines.forEach(line => { - const matches = line.match(/(.*?)(-?\d+) (callsubr|callgsubr)$/); + if (line.trim().length === 0) return; + const matches = line.match(/(.*?)\{([0-9a-z]+)\} (callsubr|callgsubr)$/); if (matches != null) { - const { map, source, target } = - matches[3] === 'callsubr' ? subrs : gsubrs; - const index = matches[2]; - let newIndex = 0; - if (!map[index]) { - // find subr in source dom and copy to target dom - const srcIndex = parseInt(index) + 107; + const { sourcePath, target } = matches[3] === 'callsubr' ? subrs : gsubrs; + const fingerprint = matches[2]; + let newIndex = fingerprints[fingerprint]; + if (!newIndex) { const srcSubr = xpath.select( - `//CharString[@index="${srcIndex}"]`, - source, + `/${sourcePath}/CharString[@fingerprint="${fingerprint}"]`, + glyphDom, true ); - // patch up source in case it also has any callsubrs - patchCharStringSubrs(srcSubr, subrs, gsubrs); - // append subr to target dom and get new index newIndex = xpath.select('count(CharString)', target, true); - srcSubr.setAttribute('index', newIndex); - target.appendChild(srcSubr); + + const clone = srcSubr.cloneNode(true); + clone.setAttribute('index', newIndex); + target.appendChild(clone); // add new index to map and rewrite call newIndex = newIndex - 107; - map[index] = newIndex; - } else { - newIndex = map[index]; + fingerprints[fingerprint] = newIndex; + + // patch up source in case it also has any callsubrs + patchCharStringSubrs(glyphDom, clone, fingerprints, subrs, gsubrs); } // rewrite line with new subr index line = `${matches[1]}${newIndex} ${matches[3]}`; - patched = true; } newLines.push(line); }); - if (patched) { - node.childNodes[0].textContent = newLines.join(os.EOL); - } + node.childNodes[0].textContent = newLines.join(os.EOL); }; const setAttribute = (parent, path, name, value) => { @@ -344,13 +369,15 @@ const copyConfigAttribute = (dom, configDom, path, name) => { setAttribute(dom, path, name, value); }; -const getTextNode = (dom, path) => { - var node = xpath.select(path, dom, true); - return node && node.childNodes.length - ? node.childNodes[0].nodeValue.trim() - : ''; +const createElementWithAttributes = (tagName, attributes) => { + const element = dom.createElement(tagName); + Object.entries(attributes).forEach(attribute => { + element.setAttribute(attribute[0], attribute[1]); + }); + return element; }; +//const dump = dom => console.log(serialize(dom)); const serialize = dom => new XMLSerializer().serializeToString(dom, false, node => { if (node.nodeType === NodeType.TEXT_NODE) {