Files
operator-mono-lig/index.js
T
2020-03-09 17:08:59 -04:00

427 lines
13 KiB
JavaScript

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))
.filter(entry => filterGlyphsExists(entry));
const ligaturesWithLIG = ligatures;
//[...ligatures, { name: 'LIG', glyph: 'LIG' }];
processPatch('names', patchNames, dom, ligatures, profile);
processPatch('gsub', patchGsub, dom, ligatures);
processPatch('charstrings', patchCharStrings, dom, ligaturesWithLIG);
processPatch('glyphs', patchGlyphs, 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(/\r|\r\n|\n/g)
.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 provide a profile name in []');
}
profile.ligatures.push(line);
}
});
return profiles;
};
const filterLigatures = (name, mappings) => {
// loop through mappings and return if name applies or not
// skip if setting is !name
return mappings.filter(entry => entry === '!' + name).length === 0;
};
const mapLigatures = (name, mappings) => {
let mapping = { name, glyph: name };
mappings.forEach(entry => {
const [val1, val2] = entry.split('=');
// handle lig=altliga
if (val1 === name) {
mapping.glyph = val2;
} else if (val2 === name) {
mapping.name = val1;
}
});
return mapping;
};
const filterGlyphsExists = ({ glyph }) => {
const exists = fs.existsSync(`./ligature/${ligFontName}/glyphs/${glyph}.xml`);
return exists;
};
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, familyNamePlat);
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) => {
// don't patch if no ligatures other than LIG
if (ligatures.length <= 1) return;
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 = 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();