Update extract and build process with support for context substitutions

This commit is contained in:
kiliman
2018-04-26 18:03:55 -04:00
committed by unknown
parent 036e837e0c
commit 9398884dc6
6 changed files with 674 additions and 302 deletions
+3 -3
View File
@@ -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
+7 -7
View File
@@ -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
+137 -56
View File
@@ -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('<ttFont/>');
@@ -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('<hmtx/>');
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('<CharStrings/>');
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('<Glyph/>').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 => {
+1 -1
View File
@@ -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
+264
View File
@@ -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;
+262 -235
View File
@@ -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) {