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) {