mirror of
https://github.com/kiliman/operator-mono-lig.git
synced 2026-08-12 02:10:52 +02:00
433 lines
13 KiB
JavaScript
433 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;
|
|
let italicsHack = false;
|
|
function main() {
|
|
fontName = process.argv[2];
|
|
italicsHack =
|
|
process.argv[3] === '--italics-hack' && fontName.includes('Italic');
|
|
|
|
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, { italicsHack }));
|
|
|
|
console.log('Done');
|
|
}
|
|
|
|
const buildFont = (profile, options) => {
|
|
// 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, options);
|
|
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, options) => {
|
|
console.log(`Patching ${name}`);
|
|
patchFunc(dom, ligatures, profile, options);
|
|
};
|
|
|
|
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
|
|
// mac family name does not include style
|
|
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);
|
|
|
|
// windows family name includes style
|
|
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, options) => {
|
|
// don't patch if no ligatures other than LIG
|
|
if (ligatures.length <= 1) return;
|
|
|
|
ligatures.forEach((ligature) => {
|
|
// build gsub tables
|
|
gsub.buildGsubTables(dom, ligature, options);
|
|
});
|
|
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();
|