summaryrefslogtreecommitdiff
path: root/misc/tools/rmglyph.py
diff options
context:
space:
mode:
Diffstat (limited to 'misc/tools/rmglyph.py')
-rwxr-xr-xmisc/tools/rmglyph.py548
1 files changed, 548 insertions, 0 deletions
diff --git a/misc/tools/rmglyph.py b/misc/tools/rmglyph.py
new file mode 100755
index 000000000..7e6d7f408
--- /dev/null
+++ b/misc/tools/rmglyph.py
@@ -0,0 +1,548 @@
+#!/usr/bin/env python
+# encoding: utf8
+from __future__ import print_function
+import os, sys, plistlib, re, subprocess
+from collections import OrderedDict
+from ConfigParser import RawConfigParser
+from argparse import ArgumentParser
+from robofab.objects.objectsRF import OpenFont
+from textwrap import TextWrapper
+from StringIO import StringIO
+import glob
+import cleanup_kerning
+
+
+dryRun = False
+BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
+
+
+def readLines(filename):
+ with open(filename, 'r') as f:
+ return f.read().strip().splitlines()
+
+
+def loadAGL(filename): # -> { 2126: 'Omega', ... }
+ m = {}
+ with open(filename, 'r') as f:
+ for line in f:
+ # Omega;2126
+ # dalethatafpatah;05D3 05B2 # higher-level combinations; ignored
+ line = line.strip()
+ if len(line) > 0 and line[0] != '#':
+ name, uc = tuple([c.strip() for c in line.split(';')])
+ if uc.find(' ') == -1:
+ # it's a 1:1 mapping
+ m[int(uc, 16)] = name
+ return m
+
+
+def decomposeComponentInstances(font, glyph, componentsToDecompose):
+ """Moves the components of a glyph to its outline."""
+ if len(glyph.components):
+ deepCopyContours(font, glyph, glyph, (0, 0), (1, 1), componentsToDecompose)
+ glyph.clearComponents()
+
+
+def deepCopyContours(font, parent, component, offset, scale, componentsToDecompose):
+ """Copy contours to parent from component, including nested components."""
+ for nested in component.components:
+ if componentsToDecompose is None or nested.baseGlyph in componentsToDecompose:
+ deepCopyContours(
+ font, parent, font[nested.baseGlyph],
+ (offset[0] + nested.offset[0], offset[1] + nested.offset[1]),
+ (scale[0] * nested.scale[0], scale[1] * nested.scale[1]),
+ None)
+ component.removeComponent(nested)
+ if component == parent:
+ return
+ for contour in component:
+ contour = contour.copy()
+ contour.scale(scale)
+ contour.move(offset)
+ parent.appendContour(contour)
+
+
+def addGlyphsForCPFont(cp, ucmap, glyphnames):
+ if cp in ucmap:
+ for name in ucmap[cp]:
+ glyphnames.add(name)
+ # else:
+ # print('no glyph for U+%04X' % cp)
+
+
+def getGlyphNamesFont(font, ucmap, glyphs):
+ glyphnames = set()
+ for s in glyphs:
+ if len(s) > 2 and s[:2] == 'U+':
+ p = s.find('-')
+ if p != -1:
+ # range, e.g. "U+1D0A-1DBC"
+ cpStart = int(s[2:p], 16)
+ cpEnd = int(s[p+1:], 16)
+ for cp in range(cpStart, cpEnd+1):
+ addGlyphsForCPFont(cp, ucmap, glyphnames)
+ else:
+ # single code point e.g. "U+1D0A"
+ cp = int(s[2:], 16)
+ addGlyphsForCPFont(cp, ucmap, glyphnames)
+ elif s in font:
+ glyphnames.add(s)
+ return glyphnames
+
+
+def addGlyphsForCPComps(cp, comps, agl, glyphnames):
+ uniName = 'uni%04X' % cp
+ symbolicName = agl.get(cp)
+ if uniName in comps:
+ glyphnames.add(uniName)
+ if symbolicName in comps:
+ glyphnames.add(symbolicName)
+
+
+def getGlyphNamesComps(comps, agl, glyphs):
+ # comps: { glyphName => (baseName, accentNames, offset) ... }
+ # agl: { 2126: 'Omega' ... }
+ glyphnames = set()
+ for s in glyphs:
+ if len(s) > 2 and s[:2] == 'U+':
+ p = s.find('-')
+ if p != -1:
+ # range, e.g. "U+1D0A-1DBC"
+ cpStart = int(s[2:p], 16)
+ cpEnd = int(s[p+1:], 16)
+ for cp in range(cpStart, cpEnd+1):
+ addGlyphsForCPComps(cp, comps, agl, glyphnames)
+ else:
+ # single code point e.g. "U+1D0A"
+ cp = int(s[2:], 16)
+ addGlyphsForCPComps(cp, comps, agl, glyphnames)
+ elif s in comps:
+ glyphnames.add(s)
+ return glyphnames
+
+
+def updateConfigFile(config, filename, rmnames):
+ wrapper = TextWrapper()
+ wrapper.width = 80
+ wrapper.break_long_words = False
+ wrapper.break_on_hyphens = False
+ wrap = lambda names: '\n'.join(wrapper.wrap(' '.join(names)))
+
+ didChange = False
+
+ for propertyName, values in config.items('glyphs'):
+ glyphNames = values.split()
+ propChanged = False
+ glyphNames2 = [name for name in glyphNames if name not in rmnames]
+ if len(glyphNames2) < len(glyphNames):
+ print('[fontbuild.cfg] updating glyphs property', propertyName)
+ config.set('glyphs', propertyName, wrap(glyphNames2)+'\n')
+ didChange = True
+
+ if didChange:
+ s = StringIO()
+ config.write(s)
+ s = s.getvalue()
+ s = re.sub(r'\n(\w+)\s+=\s*', '\n\\1: ', s, flags=re.M)
+ s = re.sub(r'((?:^|\n)\[[^\]]*\])', '\\1\n', s, flags=re.M)
+ s = re.sub(r'\n\t\n', '\n\n', s, flags=re.M)
+ s = s.strip() + '\n'
+ print('Writing', filename)
+ if not dryRun:
+ with open(filename, 'w') as f:
+ f.write(s)
+
+
+def parseGlyphComposition(composite):
+ c = composite.split("=")
+ d = c[1].split("/")
+ glyphName = d[0]
+ if len(d) == 1:
+ offset = [0, 0]
+ else:
+ offset = [int(i) for i in d[1].split(",")]
+ accentString = c[0]
+ accents = accentString.split("+")
+ baseName = accents.pop(0)
+ accentNames = [i.split(":") for i in accents]
+ return (glyphName, baseName, accentNames, offset)
+
+
+def fmtGlyphComposition(glyphName, baseName, accentNames, offset):
+ # glyphName = 'uni03D3'
+ # baseName = 'uni03D2'
+ # accentNames = [['tonos', 'top'], ['acute', 'top']]
+ # offset = [100, 0]
+ # => "uni03D2+tonos:top+acute:top=uni03D3/100,0"
+ s = baseName
+ for accentNameTuple in accentNames:
+ s += '+' + accentNameTuple[0]
+ if len(accentNameTuple) > 1:
+ s += ':' + accentNameTuple[1]
+ s += '=' + glyphName
+ if offset[0] != 0 or offset[1] != 0:
+ s += '/%d,%d' % tuple(offset)
+ return s
+
+
+def loadGlyphCompositions(filename): # { glyphName => (baseName, accentNames, offset) }
+ compositions = OrderedDict()
+ with open(filename, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if len(line) > 0 and line[0] != '#':
+ glyphName, baseName, accentNames, offset = parseGlyphComposition(line)
+ compositions[glyphName] = (baseName, accentNames, offset)
+ return compositions
+
+
+def updateDiacriticsFile(filename, rmnames):
+ lines = []
+ didChange = False
+
+ for line in readLines(filename):
+ line = line.strip()
+ if len(line) == 0 or len(line.lstrip()) == 0 or line.lstrip()[0] == '#':
+ lines.append(line)
+ else:
+ glyphName, baseName, accentNames, offset = parseGlyphComposition(line)
+
+ skipLine = False
+ if baseName in rmnames or glyphName in rmnames:
+ skipLine = True
+ else:
+ for accent in accentNames:
+ name = accent[0]
+ if name in rmnames:
+ skipLine = True
+ break
+
+ if not skipLine:
+ lines.append(line)
+ else:
+ print('[diacritics] removing', line.strip())
+ didChange = True
+
+ if didChange:
+ print('Writing', filename)
+ if not dryRun:
+ with open(filename, 'w') as f:
+ for line in lines:
+ f.write(line + '\n')
+
+
+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
+
+
+includeRe = re.compile(r'^include\(([^\)]+)\);\s*$')
+tokenSepRe = re.compile(r'([\@A-Za-z0-9_\.]+|[=\-\[\]\(\)\{\};<>\'])')
+spaceRe = re.compile(r'[ \t]+')
+
+
+def loadFeaturesFile(filepath, followIncludes=True):
+ print('read', filepath)
+ lines = []
+ with open(filepath, 'r') as f:
+ for line in f:
+ m = includeRe.match(line)
+ if m is not None:
+ if followIncludes:
+ includedFilename = m.group(1)
+ includedPath = os.path.normpath(os.path.join(os.path.dirname(filepath), includedFilename))
+ lines = lines + loadFeaturesFile(includedPath, followIncludes)
+ else:
+ lines.append(line)
+ return lines
+
+
+def collapseSpace(s):
+ lm = len(s) - len(s.lstrip(' \t'))
+ return s[:lm] + spaceRe.sub(' ', s[lm:])
+
+def updateFeaturesFile(filename, rmnames):
+ # this is a VERY crude approach that simply tokenizes the input and filters
+ # out strings that seem to be names but are not found in glyphnames.
+
+ lines = []
+ didChange = False
+
+ for line in loadFeaturesFile(filename, followIncludes=False):
+ line = line.rstrip('\r\n ')
+ tokens = tokenSepRe.split(line)
+ tokens2 = [t for t in tokens if t not in rmnames]
+ if len(tokens2) != len(tokens):
+ line = collapseSpace(''.join(tokens2))
+ didChange = True
+ lines.append(line)
+
+ if didChange:
+ print('Write', filename)
+ if not dryRun:
+ with open(filename, 'w') as f:
+ for line in lines:
+ f.write(line + '\n')
+
+ return didChange
+
+
+def grep(filename, names):
+ hasPrintedFilename = False
+ relFilename = os.path.relpath(os.path.abspath(filename), BASEDIR)
+ findCount = 0
+ with open(filename, 'r') as f:
+ lineno = 1
+ for line in f:
+ foundNames = []
+ for name in names:
+ col = line.find(name)
+ if col != -1:
+ foundNames.append((name, lineno, col, line))
+ findCount += 1
+ if len(foundNames):
+ if not hasPrintedFilename:
+ print('%s:' % relFilename)
+ hasPrintedFilename = True
+ for name, lineno, col, line in foundNames:
+ line = line.strip()
+ if len(line) > 50:
+ line = line[:47] + '...'
+ print(' %s\t%d:%d\t%s' % (name, lineno, col, line))
+ lineno += 1
+ return findCount
+
+
+
+def main(argv=None):
+ argparser = ArgumentParser(
+ description='Remove glyphs from all UFOs in src dir')
+
+ 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(
+ '-decompose', dest='decompose', action='store_const', const=True, default=False,
+ help='When deleting a glyph which is used as a component by another glyph '+
+ 'which is not being deleted, instead of refusing to delete the glyph, '+
+ 'decompose the component instances in other glyphs.')
+
+ argparser.add_argument(
+ '-ignore-git-state', dest='ignoreGitState', action='store_const', const=True, default=False,
+ help='Skip checking with git if there are changes to the target UFO file.')
+
+ argparser.add_argument(
+ 'glyphs', metavar='<glyph>', type=str, nargs='+',
+ help='Glyph to remove. '+
+ 'Can be a glyphname, '+
+ 'a Unicode code point formatted as "U+<CP>", '+
+ 'or a Unicode code point range formatted as "U+<CP>-<CP>"')
+
+ args = argparser.parse_args(argv)
+ global dryRun
+ dryRun = args.dryRun
+ srcDir = os.path.join(BASEDIR, 'src')
+
+ # check if src font has modifications
+ if not args.ignoreGitState:
+ gitStatus = subprocess.check_output(
+ ['git', '-C', BASEDIR, 'status', '-s', '--',
+ os.path.relpath(os.path.abspath(srcDir), BASEDIR) ],
+ shell=False)
+ gitIsDirty = False
+ gitStatusLines = gitStatus.splitlines()
+ for line in gitStatusLines:
+ if len(line) > 3 and line[:2] != '??':
+ gitIsDirty = True
+ break
+ if gitIsDirty:
+ if len(gitStatusLines) > 5:
+ gitStatusLines = gitStatusLines[:5] + [' ...']
+ print(
+ ("%s has uncommitted changes. It's strongly recommended to run this "+
+ "script on an unmodified UFO path so to allow \"undoing\" any changes. "+
+ "Run with -ignore-git-state to ignore this warning.\n%s") % (
+ srcDir, '\n'.join(gitStatusLines)),
+ file=sys.stderr)
+ sys.exit(1)
+
+ # Find UFO fonts
+ fontPaths = glob.glob(os.path.join(srcDir, '*.ufo'))
+ if len(fontPaths) == 0:
+ print('No UFOs found in', srcDir, file=sys.stderr)
+ sys.exit(1)
+
+ # load fontbuild config
+ config = RawConfigParser(dict_type=OrderedDict)
+ configFilename = os.path.join(srcDir, 'fontbuild.cfg')
+ config.read(configFilename)
+ glyphOrderFile = configFindResFile(config, srcDir, 'glyphorder')
+ diacriticsFile = configFindResFile(config, srcDir, 'diacriticfile')
+ featuresFile = os.path.join(srcDir, 'features.fea')
+
+ # load AGL and diacritics
+ agl = loadAGL(os.path.join(srcDir, 'glyphlist.txt')) # { 2126: 'Omega', ... }
+ comps = loadGlyphCompositions(diacriticsFile)
+ # { glyphName => (baseName, accentNames, offset) }
+
+ # find glyphnames to remove that are composed (removal happens later)
+ rmnamesUnion = getGlyphNamesComps(comps, agl, args.glyphs)
+
+ # find glyphnames to remove from UFOs (and remove them)
+ for fontPath in fontPaths:
+ relFontPath = os.path.relpath(fontPath, BASEDIR)
+ print('Loading glyph data for %s...' % relFontPath)
+ font = OpenFont(fontPath)
+ ucmap = font.getCharacterMapping() # { 2126: [ 'Omega', ...], ...}
+ cnmap = font.getReverseComponentMapping() # { 'A' : ['Aacute', 'Aring'], 'acute' : ['Aacute'] ... }
+
+ glyphnames = getGlyphNamesFont(font, ucmap, args.glyphs)
+
+ if len(glyphnames) == 0:
+ print('None of the glyphs requested exist in', relFontPath, file=sys.stderr)
+
+ print('Preparing to remove %d glyphs — resolving component usage...' % len(glyphnames))
+
+ # Check component usage
+ cnConflicts = {}
+ for gname in glyphnames:
+ cnUses = cnmap.get(gname)
+ if cnUses:
+ extCnUses = [n for n in cnUses if n not in glyphnames]
+ if len(extCnUses) > 0:
+ cnConflicts[gname] = extCnUses
+
+ if len(cnConflicts) > 0:
+ if args.decompose:
+ componentsToDecompose = set()
+ for gname in cnConflicts.keys():
+ componentsToDecompose.add(gname)
+ for gname, dependants in cnConflicts.iteritems():
+ print('decomposing %s in %s' % (gname, ', '.join(dependants)))
+ for depname in dependants:
+ decomposeComponentInstances(font, font[depname], componentsToDecompose)
+ else:
+ print(
+ '\nComponent conflicts.\n\n'+
+ 'Some glyphs to-be deleted are used as components in other glyphs.\n'+
+ 'You need to either decompose the components, also delete glyphs\n'+
+ 'using them, or not delete the glyphs at all.\n', file=sys.stderr)
+ for gname, dependants in cnConflicts.iteritems():
+ print('%s used by %s' % (gname, ', '.join(dependants)), file=sys.stderr)
+ sys.exit(1)
+
+ # find orphaned pure-components
+ for gname in glyphnames:
+ try:
+ g = font[gname]
+ except:
+ print('no glyph %r in %s' % (gname, relFontPath), file=sys.stderr)
+ sys.exit(1)
+ useCount = 0
+ for cn in g.components:
+ usedBy = cnmap.get(cn.baseGlyph)
+ if usedBy:
+ usedBy = [name for name in usedBy if name not in glyphnames]
+ if len(usedBy) == 0:
+ cng = font[cn.baseGlyph]
+ if len(cng.unicodes) == 0:
+ print('Note: pure-component %s orphaned' % cn.baseGlyph)
+
+ # remove glyphs from UFO
+ print('Removing %d glyphs' % len(glyphnames))
+
+ libPlistFilename = os.path.join(fontPath, 'lib.plist')
+ libPlist = plistlib.readPlist(libPlistFilename)
+
+ glyphOrder = libPlist.get('public.glyphOrder')
+ if glyphOrder is not None:
+ v = [name for name in glyphOrder if name not in glyphnames]
+ libPlist['public.glyphOrder'] = v
+
+ roboSort = libPlist.get('com.typemytype.robofont.sort')
+ if roboSort is not None:
+ for entry in roboSort:
+ if isinstance(entry, dict) and entry.get('type') == 'glyphList':
+ asc = entry.get('ascending')
+ if asc is not None:
+ entry['ascending'] = [name for name in asc if name not in glyphnames]
+ desc = entry.get('descending')
+ if desc is not None:
+ entry['descending'] = [name for name in desc if name not in glyphnames]
+
+ for gname in glyphnames:
+ font.removeGlyph(gname)
+ rmnamesUnion.add(gname)
+
+ if not dryRun:
+ print('Writing changes to %s' % relFontPath)
+ font.save()
+ plistlib.writePlist(libPlist, libPlistFilename)
+ else:
+ print('Writing changes to %s (dry run)' % relFontPath)
+
+ print('Cleaning up kerning')
+ if dryRun:
+ cleanup_kerning.main(['-dry', fontPath])
+ else:
+ cleanup_kerning.main([fontPath])
+
+ # end for fontPath in fontPaths
+
+
+ # fontbuild config
+ updateDiacriticsFile(diacriticsFile, rmnamesUnion)
+ updateConfigFile(config, configFilename, rmnamesUnion)
+ featuresChanged = updateFeaturesFile(featuresFile, rmnamesUnion)
+
+
+ # TMP for testing fuzzy
+ # rmnamesUnion = set()
+ # featuresChanged = False
+ # with open('_local/rmlog') as f:
+ # for line in f:
+ # line = line.strip()
+ # if len(line):
+ # rmnamesUnion.add(line)
+
+
+ print('\n————————————————————————————————————————————————————\n'+
+ 'Removed %d glyphs:\n %s' % (
+ len(rmnamesUnion), '\n '.join(sorted(rmnamesUnion))))
+
+ print('\n————————————————————————————————————————————————————\n')
+
+ # find possibly-missed instances
+ print('Fuzzy matches:')
+ fuzzyMatchCount = 0
+ fuzzyMatchCount += grep(diacriticsFile, rmnamesUnion)
+ fuzzyMatchCount += grep(configFilename, rmnamesUnion)
+ fuzzyMatchCount += grep(featuresFile, rmnamesUnion)
+ for fontPath in fontPaths:
+ fuzzyMatchCount += grep(os.path.join(fontPath, 'lib.plist'), rmnamesUnion)
+ if fuzzyMatchCount == 0:
+ print(' (none)\n')
+ else:
+ print('You may want to look into those ^\n')
+
+ if featuresChanged:
+ print('You need to manually edit features.\n'+
+ '- git diff src/features.fea\n'+
+ '- $EDITOR %s/features.fea\n' % '/features.fea\n- $EDITOR '.join(fontPaths))
+
+ print(('You need to re-generate %s via\n'+
+ '`make src/glyphorder.txt` (or misc/gen-glyphorder.py)'
+ ) % glyphOrderFile)
+
+ print('\nFinally, you should build the Medium weight and make sure it all '+
+ 'looks good and that no mixglyph failures occur. E.g. `make Medium -j`')
+
+
+if __name__ == '__main__':
+ main()