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 regExWhitespace = /^\s+$/; const NodeType = {}; NodeType.TEXT_NODE = 3; let dom; function main() { fontName = process.argv[2]; ligFontName = fontName.split('-').join('Lig-'); const srcFileName = `./original/${fontName}.ttx`; console.log(`Reading original font file ${srcFileName}`); const xml = fs.readFileSync(srcFileName, 'utf-8'); dom = new DOMParser().parseFromString(xml); const profiles = getProfiles(); // process settings (there may be more than one font to build) profiles.forEach(profile => buildFont(profile)); console.log('Done'); } 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 => /\.xml$/.test(file)) .map(file => file.replace('.xml', '')) ) .filter(name => !/\d+\.liga$/.test(name)) // skip alternates (ends with .#) .filter(name => filterLigatures(name, profile.ligatures)) .map(name => mapLigatures(name, profile.ligatures)); const ligaturesWithLIG = ligatures; //[...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 }; 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 = fs.readFileSync(fileName, 'utf-8'); return new DOMParser().parseFromString(xml); }; const processPatch = (name, patchFunc, dom, ligatures, profile) => { console.log(`Patching ${name}`); patchFunc(dom, ligatures, profile); }; const PlatformId = { mac: 1, win: 3 }; const NameId = { familyName: 1, fontStyle: 2, uniqueId: 3, fullName: 4, version: 5, postscriptName: 6, windowsFamilyName: 16, fontStyleName: 17 }; const patchNames = (dom, ligatures, profile) => { const names = JSON.parse( fs.readFileSync(`./ligature/${ligFontName}/names.json`) ); const [name, style] = names.fontName.split('-'); const [familyStyle, fullNameStyleTry] = names.fontStyle.split(' '); let fullNameStyleTemp; if (fullNameStyleTry) { fullNameStyleTemp = fullNameStyleTry; } else { fullNameStyleTemp = ''; } const fullNameStyle = fullNameStyleTemp; const fontName = `${name}${profile.suffixWithLeadingHyphen}-${style}`; const familyNamePlat = `${names.familyName}${profile.suffixWithLeadingSpace}`; const familyName = `${familyNamePlat} ${familyStyle}`; const fullName = `${familyName} ${fullNameStyle}`; const uniqueId = `${names.foundry}: ${fullName}: ${names.version}`; // patch CFFFont const cffFont = xpath.select('/ttFont/CFF/CFFFont', dom, true); cffFont.setAttribute('name', ligFontName); setAttribute(cffFont, 'FullName', 'value', fullName); setAttribute(cffFont, 'FamilyName', 'value', familyNamePlat); // update existing names with new names 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, familyNamePlat); updateName(PlatformId.mac, NameId.fontStyleName, names.fontStyle); 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, familyNamePlat); updateName(PlatformId.win, NameId.fontStyleName, names.fontStyle); }; 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 ligatures.forEach(ligature => { targetGlyphs.appendChild( createElementWithAttributes('GlyphID', { id: n++, name: ligature.glyph }) ); }); // update glyph count setAttribute(dom, '/ttFont/maxp/numGlyphs', 'value', n); }; const patchGsub = (dom, ligatures) => { ligatures .filter(ligature => /_/.test(ligature.glyph)) .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 = loadXml('config'); copyConfigAttribute(dom, configDom, '/ttFont/head/xMin', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/head/yMin', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/head/xMax', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/head/yMax', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/hhea/advanceWidthMax', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/hhea/xMaxExtent', 'value'); copyConfigAttribute(dom, configDom, '/ttFont/CFF/CFFFont/FontBBox', 'value'); copyConfigAttribute( dom, configDom, '/ttFont/CFF/CFFFont/Private/nominalWidthX', 'value' ); }; const patchCharStrings = (dom, ligatures) => { // patch CFFFont const cffFont = xpath.select('/ttFont/CFF/CFFFont', dom, true); const targetHmtx = xpath.select('/ttFont/hmtx', dom, true); const targetCmaps = xpath.select('/ttFont/cmap/cmap_format_4', dom); 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 === ligature.glyph ? '' : ' => ' + ligature.glyph }` ); const glyphDom = loadXml(`glyphs/${ligature.glyph}`).documentElement; const node = xpath.select('/Glyph/CharString', glyphDom, true); node.setAttribute('name', ligature.glyph); const subrs = { sourcePath: '/Glyph/Subrs', target: targetSubrs }; const gsubrs = { sourcePath: '/Glyph/GlobalSubrs', target: targetGsubrs }; patchCharStringSubrs(glyphDom, node, fingerprints, subrs, gsubrs); targetCharStrings.appendChild(node); targetHmtx.appendChild( createElementWithAttributes('mtx', { name: ligature.glyph, width: glyphDom.getAttribute('width'), lsb: glyphDom.getAttribute('lsb') }) ); const code = glyphDom.getAttribute('code'); if (code) { Array.from(targetCmaps).forEach(node => { node.appendChild( createElementWithAttributes('map', { code, name: ligature.glyph }) ); }); } }); }; 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 = []; lines.forEach(line => { if (line.trim().length === 0) return; const matches = line.match(/(.*?)\{([0-9a-z]+)\} (callsubr|callgsubr)$/); if (matches != null) { const { sourcePath, target } = matches[3] === 'callsubr' ? subrs : gsubrs; const fingerprint = matches[2]; let newIndex = fingerprints[fingerprint]; if (!newIndex) { const srcSubr = xpath.select( `/${sourcePath}/CharString[@fingerprint="${fingerprint}"]`, glyphDom, true ); // append subr to target dom and get new index newIndex = xpath.select('count(CharString)', target, true); const clone = srcSubr.cloneNode(true); clone.setAttribute('index', newIndex); target.appendChild(clone); // add new index to map and rewrite call newIndex = newIndex - 107; 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]}`; } newLines.push(line); }); node.childNodes[0].textContent = newLines.join(os.EOL); }; const setAttribute = (parent, path, name, value) => { var node = xpath.select(path, parent, true); node.setAttribute(name, value); }; const copyConfigAttribute = (dom, configDom, path, name) => { const value = xpath.select(path, configDom, true).getAttribute(name); setAttribute(dom, path, name, value); }; 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) { if (regExWhitespace.test(node.data)) return null; const data = node.data .split(/\r|\r\n|\n/g) .filter(s => /\S+/.test(s)) .map(s => s.replace(/^\s+/g, '')) .join(os.EOL); return node.ownerDocument.createTextNode(data); } return node; }); main();