diff options
author | Rasmus Andersson <rasmus@notion.se> | 2018-09-03 22:55:49 +0300 |
---|---|---|
committer | Rasmus Andersson <rasmus@notion.se> | 2018-09-03 22:55:49 +0300 |
commit | c833e252c925e8dd68108660710ca835d95daa6f (patch) | |
tree | 6b2e28264ed45efd7f054e453b622098d0d875b8 /misc/tools/fixup-kerning.py | |
parent | 8c1a4c181ef12000179dfec541f1af87e9b03122 (diff) | |
download | inter-c833e252c925e8dd68108660710ca835d95daa6f.tar.xz |
Major overhaul, moving from UFO2 to Glyphs and UFO3, plus a brand new and much simpler fontbuild
Diffstat (limited to 'misc/tools/fixup-kerning.py')
-rwxr-xr-x | misc/tools/fixup-kerning.py | 362 |
1 files changed, 362 insertions, 0 deletions
diff --git a/misc/tools/fixup-kerning.py b/misc/tools/fixup-kerning.py new file mode 100755 index 000000000..fc4ce8071 --- /dev/null +++ b/misc/tools/fixup-kerning.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python +# encoding: utf8 +from __future__ import print_function +import os, sys, plistlib, json +from collections import OrderedDict +from ConfigParser import RawConfigParser +from argparse import ArgumentParser +from fontTools import ttLib +from robofab.objects.objectsRF import OpenFont + + +def getTTCharMap(font): # -> { 2126: 'Omegagreek', ...} + if isinstance(font, str): + font = ttLib.TTFont(font) + + if not 'cmap' in font: + raise Exception('missing cmap table') + + gl = {} + bestCodeSubTable = None + bestCodeSubTableFormat = 0 + + for st in font['cmap'].tables: + if st.platformID == 0: # 0=unicode, 1=mac, 2=(reserved), 3=microsoft + if st.format > bestCodeSubTableFormat: + bestCodeSubTable = st + bestCodeSubTableFormat = st.format + + if bestCodeSubTable is not None: + for cp, glyphname in bestCodeSubTable.cmap.items(): + if cp in gl: + raise Exception('duplicate unicode-to-glyphname mapping: U+%04X => %r and %r' % ( + cp, glyphname, gl[cp])) + gl[cp] = glyphname + + return gl + + +def revCharMap(ucToNames): + # {2126:['Omega','Omegagr']} -> {'Omega':2126, 'Omegagr':2126} + # {2126:'Omega'} -> {'Omega':2126} + m = {} + if len(ucToNames) == 0: + return m + + lists = True + for v in ucToNames.itervalues(): + lists = not isinstance(v, str) + break + + if lists: + for uc, names in ucToNames.iteritems(): + for name in names: + m[name] = uc + else: + for uc, name in ucToNames.iteritems(): + m[name] = uc + + return m + + +def getGlyphNameDifferenceMap(srcCharMap, dstCharMap, dstRevCharMap): + m = {} # { 'Omegagreek': 'Omega', ... } + for uc, srcName in srcCharMap.iteritems(): + dstNames = dstCharMap.get(uc) + if dstNames is not None and len(dstNames) > 0: + if len(dstNames) != 1: + print('warning: ignoring multi-glyph map for U+%04X in source font' % uc) + dstName = dstNames[0] + if srcName != dstName and srcName not in dstRevCharMap: + # Only include names that differ. also, The `srcName not in dstRevCharMap` condition + # makes sure that we don't rename glyphs that are already valid. + m[srcName] = dstName + return m + + +def fixupGroups(fontPath, dstGlyphNames, srcToDstMap, dryRun, stats): + filename = os.path.join(fontPath, 'groups.plist') + groups = plistlib.readPlist(filename) + groups2 = {} + glyphToGroups = {} + + for groupName, glyphNames in groups.iteritems(): + glyphNames2 = [] + for glyphName in glyphNames: + if glyphName in srcToDstMap: + gn2 = srcToDstMap[glyphName] + stats.renamedGlyphs[glyphName] = gn2 + glyphName = gn2 + if glyphName in dstGlyphNames: + glyphNames2.append(glyphName) + glyphToGroups[glyphName] = glyphToGroups.get(glyphName, []) + [groupName] + else: + stats.removedGlyphs.add(glyphName) + if len(glyphNames2) > 0: + groups2[groupName] = glyphNames2 + else: + stats.removedGroups.add(groupName) + + print('Writing', filename) + if not dryRun: + plistlib.writePlist(groups2, filename) + + return groups2, glyphToGroups + + +def fixupKerning(fontPath, dstGlyphNames, srcToDstMap, groups, glyphToGroups, dryRun, stats): + filename = os.path.join(fontPath, 'kerning.plist') + kerning = plistlib.readPlist(filename) + kerning2 = {} + groupPairs = {} # { "lglyphname+lglyphname": ("lgroupname"|"", "rgroupname"|"", 123) } + # pairs = {} # { "name+name" => 123 } + + for leftName, right in kerning.items(): + leftIsGroup = leftName[0] == '@' + leftGroupNames = None + + if leftIsGroup: + # left is a group + if leftName not in groups: + # dead group -- skip + stats.removedGroups.add(leftName) + continue + leftGroupNames = groups[leftName] + else: + if leftName in srcToDstMap: + leftName2 = srcToDstMap[leftName] + stats.renamedGlyphs[leftName] = leftName2 + leftName = leftName2 + if leftName not in dstGlyphNames: + # dead glyphname -- skip + stats.removedGlyphs.add(leftName) + continue + + right2 = {} + rightGroupNamesAndValues = [] + for rightName, kerningValue in right.iteritems(): + rightIsGroup = rightName[0] == '@' + if rightIsGroup: + if leftIsGroup and leftGroupNames is None: + leftGroupNames = [leftName] + if rightName in groups: + right2[rightName] = kerningValue + rightGroupNamesAndValues.append((groups[rightName], rightName, kerningValue)) + else: + stats.removedGroups.add(rightName) + else: + if rightName in srcToDstMap: + rightName2 = srcToDstMap[rightName] + stats.renamedGlyphs[rightName] = rightName2 + rightName = rightName2 + if rightName in dstGlyphNames: + right2[rightName] = kerningValue + if leftIsGroup: + rightGroupNamesAndValues.append(([rightName], '', kerningValue)) + else: + stats.removedGlyphs.add(rightName) + + if len(right2): + kerning2[leftName] = right2 + + # update groupPairs + lgroupname = leftName if rightIsGroup else '' + if leftIsGroup: + for lname in leftGroupNames: + kPrefix = lname + '+' + for rnames, rgroupname, kernv in rightGroupNamesAndValues: + for rname in rnames: + k = kPrefix + rname + v = (lgroupname, rgroupname, kernv) + if k in groupPairs: + raise Exception('duplicate group pair %s: %r and %r' % (k, groupPairs[k], v)) + groupPairs[k] = v + + elif leftIsGroup: + stats.removedGroups.add(leftName) + else: + stats.removedGlyphs.add(leftName) + + # print('groupPairs:', groupPairs) + + # remove individual pairs that are already represented through groups + kerning = kerning2 + kerning2 = {} + for leftName, right in kerning.items(): + leftIsGroup = leftName[0] == '@' + # leftNames = groups[leftName] if leftIsGroup else [leftName] + + if not leftIsGroup: + right2 = {} + for rightName, kernVal in right.iteritems(): + rightIsGroup = rightName[0] == '@' + if not rightIsGroup: + k = leftName + '+' + rightName + if k in groupPairs: + groupPair = groupPairs[k] + print(('simplify individual pair %r: kern %r (individual) -> %r (group)') % ( + k, kernVal, groupPair[2])) + stats.simplifiedKerningPairs.add(k) + else: + right2[rightName] = kernVal + else: + right2[rightName] = kernVal + else: + # TODO, probably + right2 = right + + kerning2[leftName] = right2 + + print('Writing', filename) + if not dryRun: + plistlib.writePlist(kerning2, filename) + + return kerning2 + + +def loadJSONCharMap(filename): + m = None + if filename == '-': + m = json.load(sys.stdin) + else: + with open(filename, 'r') as f: + m = json.load(f) + if not isinstance(m, dict): + raise Exception('json root is not a dict') + if len(m) > 0: + for k, v in m.iteritems(): + if not isinstance(k, int) and not isinstance(k, float): + raise Exception('json dict key is not a number') + if not isinstance(v, str): + raise Exception('json dict value is not a string') + break + return m + + +class Stats: + def __init__(self): + self.removedGroups = set() + self.removedGlyphs = set() + self.simplifiedKerningPairs = set() + self.renamedGlyphs = {} + + +def configFindResFile(config, basedir, name): + fn = os.path.join(basedir, config.get("res", name)) + if not os.path.isfile(fn): + basedir = os.path.dirname(basedir) + fn = os.path.join(basedir, config.get("res", name)) + if not os.path.isfile(fn): + fn = None + return fn + + +def main(): + jsonSchemaDescr = '{[unicode:int]: glyphname:string, ...}' + + argparser = ArgumentParser( + description='Rename glyphnames in UFO kerning and remove unused groups and glyphnames.') + + argparser.add_argument( + '-dry', dest='dryRun', action='store_const', const=True, default=False, + help='Do not modify anything, but instead just print what would happen.') + + argparser.add_argument( + '-no-stats', dest='noStats', action='store_const', const=True, default=False, + help='Do not print statistics at the end.') + + argparser.add_argument( + '-save-stats', dest='saveStatsPath', metavar='<file>', type=str, + help='Write detailed statistics to JSON file.') + + argparser.add_argument( + '-src-json', dest='srcJSONFile', metavar='<file>', type=str, + help='JSON file to read glyph names from.'+ + ' Expected schema: ' + jsonSchemaDescr + ' (e.g. {2126: "Omega"})') + + argparser.add_argument( + '-src-font', dest='srcFontFile', metavar='<file>', type=str, + help='TrueType or OpenType font to read glyph names from.') + + argparser.add_argument( + 'dstFontsPaths', metavar='<ufofile>', type=str, nargs='+', help='UFO fonts to update') + + args = argparser.parse_args() + dryRun = args.dryRun + + if args.srcJSONFile and args.srcFontFile: + argparser.error('Both -src-json and -src-font specified -- please provide only one.') + + # Strip trailing slashes from font paths + args.dstFontsPaths = [s.rstrip('/ ') for s in args.dstFontsPaths] + + # Load source char map + srcCharMap = None + if args.srcJSONFile: + try: + srcCharMap = loadJSONCharMap(args.srcJSONFile) + except Exception as err: + argparser.error('Invalid JSON: Expected schema %s (%s)' % (jsonSchemaDescr, err)) + elif args.srcFontFile: + srcCharMap = getTTCharMap(args.srcFontFile.rstrip('/ ')) # -> { 2126: 'Omegagreek', ...} + else: + argparser.error('No source provided (-src-* argument missing)') + if len(srcCharMap) == 0: + print('Empty character map', file=sys.stderr) + sys.exit(1) + + # Find project source dir + srcDir = '' + for dstFontPath in args.dstFontsPaths: + s = os.path.dirname(dstFontPath) + if not srcDir: + srcDir = s + elif srcDir != s: + raise Exception('All <ufofile>s must be rooted in the same directory') + + # Load font project config + # load fontbuild configuration + config = RawConfigParser(dict_type=OrderedDict) + configFilename = os.path.join(srcDir, 'fontbuild.cfg') + config.read(configFilename) + diacriticsFile = configFindResFile(config, srcDir, 'diacriticfile') + + for dstFontPath in args.dstFontsPaths: + dstFont = OpenFont(dstFontPath) + dstCharMap = dstFont.getCharacterMapping() # -> { 2126: [ 'Omega', ...], ...} + dstRevCharMap = revCharMap(dstCharMap) # { 'Omega': 2126, ...} + srcToDstMap = getGlyphNameDifferenceMap(srcCharMap, dstCharMap, dstRevCharMap) + + stats = Stats() + + groups, glyphToGroups = fixupGroups(dstFontPath, dstRevCharMap, srcToDstMap, dryRun, stats) + fixupKerning(dstFontPath, dstRevCharMap, srcToDstMap, groups, glyphToGroups, dryRun, stats) + + # stats + if args.saveStatsPath or not args.noStats: + if not args.noStats: + print('stats for %s:' % dstFontPath) + print(' Deleted %d groups and %d glyphs.' % ( + len(stats.removedGroups), len(stats.removedGlyphs))) + print(' Renamed %d glyphs.' % len(stats.renamedGlyphs)) + print(' Simplified %d kerning pairs.' % len(stats.simplifiedKerningPairs)) + if args.saveStatsPath: + statsObj = { + 'deletedGroups': stats.removedGroups, + 'deletedGlyphs': stats.removedGlyphs, + 'simplifiedKerningPairs': stats.simplifiedKerningPairs, + 'renamedGlyphs': stats.renamedGlyphs, + } + f = sys.stdout + try: + if args.saveStatsPath != '-': + f = open(args.saveStatsPath, 'w') + print('Writing stats to', args.saveStatsPath) + json.dump(statsObj, sys.stdout, indent=2, separators=(',', ': ')) + finally: + if f is not sys.stdout: + f.close() + + +if __name__ == '__main__': + main() |