From ca1cb8c942f01b8b70bf344c00d770c4867dfc32 Mon Sep 17 00:00:00 2001 From: Rasmus Andersson Date: Fri, 31 Aug 2018 22:21:09 -0700 Subject: tooling --- misc/glyphcheck.py | 45 +++++ misc/kerndiff/README.md | 28 +++ misc/kerndiff/getKerningPairsFromOTF.py | 323 ++++++++++++++++++++++++++++++++ misc/kerndiff/getKerningPairsFromUFO.py | 144 ++++++++++++++ misc/kerndiff/kerndiff.sh | 96 ++++++++++ 5 files changed, 636 insertions(+) create mode 100755 misc/glyphcheck.py create mode 100644 misc/kerndiff/README.md create mode 100644 misc/kerndiff/getKerningPairsFromOTF.py create mode 100644 misc/kerndiff/getKerningPairsFromUFO.py create mode 100755 misc/kerndiff/kerndiff.sh diff --git a/misc/glyphcheck.py b/misc/glyphcheck.py new file mode 100755 index 000000000..755de686f --- /dev/null +++ b/misc/glyphcheck.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# encoding: utf8 +import sys, argparse +from fontTools import ttLib + + +def main(): + argparser = argparse.ArgumentParser(description='Check glyph names') + + argparser.add_argument('fontfiles', metavar='', type=str, nargs='+', + help='TrueType or OpenType font files') + + args = argparser.parse_args() + + nmissing = 0 + + matchnames = set() + for line in sys.stdin: + line = line.strip() + if len(line) > 0 and line[0] != '#': + for line2 in line.split(): + line2 = line2.strip() + if len(line2) > 0: + matchnames.add(line2) + + for fontfile in args.fontfiles: + font = ttLib.TTFont(fontfile) + glyphnames = set(font.getGlyphOrder()) + + # for name in glyphnames: + # if not name in matchnames: + # print('%s missing in input' % name) + + for name in matchnames: + if not name in glyphnames: + print('%s missing in font' % name) + nmissing = nmissing + 1 + + + if nmissing == 0: + print('all glyphs found') + + +if __name__ == '__main__': + main() diff --git a/misc/kerndiff/README.md b/misc/kerndiff/README.md new file mode 100644 index 000000000..61a98ad4c --- /dev/null +++ b/misc/kerndiff/README.md @@ -0,0 +1,28 @@ +# kerndiff + +Shows unified diff for kerning pairs in two font files. + +Accepts OTF, TTF and UFO fonts. + +Synopsis: + +``` +kerndiff.sh +``` + +Example: + +``` +kerndiff.sh Inter-UI-Regular-v2.4.otf src/Inter-UI-Regular.ufo +--- Inter-UI-Regular-v2.4.otf 2018-08-30 19:16:47.000000000 -0700 ++++ Inter-UI-Regular.ufo 2018-08-30 19:16:47.000000000 -0700 +@@ -35126,7 +35081,6 @@ + /s /Ydieresis -149 + /s /Ygrave -149 + /s /Yhook -149 +-/s /a 0 ++/s /a 8 + /s /afii10026 -47 + /s /b -47 + /s /dagger -29 +``` diff --git a/misc/kerndiff/getKerningPairsFromOTF.py b/misc/kerndiff/getKerningPairsFromOTF.py new file mode 100644 index 000000000..889a36813 --- /dev/null +++ b/misc/kerndiff/getKerningPairsFromOTF.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python +# +# Copyright (c) 2013-2016 Adobe Systems Incorporated. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. import sys import os import itertools + +from __future__ import print_function +import os +import sys +from fontTools import ttLib + +__doc__ = '''\ + +Prints all possible kerning pairs within font. +Supports RTL kerning. + +Usage: +------ +python getKerningPairsFromOTF.py + +''' + +kKernFeatureTag = 'kern' +kGPOStableName = 'GPOS' +finalList = [] + + +class myLeftClass: + def __init__(self): + self.glyphs = [] + self.class1Record = 0 + + +class myRightClass: + def __init__(self): + self.glyphs = [] + self.class2Record = 0 + + +def collectUniqueKernLookupListIndexes(featureRecord): + uniqueKernLookupIndexList = [] + for featRecItem in featureRecord: + # print(featRecItem.FeatureTag) + # GPOS feature tags (e.g. kern, mark, mkmk, size) of each ScriptRecord + if featRecItem.FeatureTag == kKernFeatureTag: + feature = featRecItem.Feature + + for featLookupItem in feature.LookupListIndex: + if featLookupItem not in uniqueKernLookupIndexList: + uniqueKernLookupIndexList.append(featLookupItem) + + return uniqueKernLookupIndexList + + +class OTFKernReader(object): + + def __init__(self, fontPath): + self.font = ttLib.TTFont(fontPath) + self.kerningPairs = {} + self.singlePairs = {} + self.classPairs = {} + self.pairPosList = [] + self.allLeftClasses = {} + self.allRightClasses = {} + + if kGPOStableName not in self.font: + print("The font has no %s table" % kGPOStableName, file=sys.stderr) + self.goodbye() + + else: + self.analyzeFont() + self.findKerningLookups() + self.getPairPos() + self.getSinglePairs() + self.getClassPairs() + + def goodbye(self): + print('The fun ends here.', file=sys.stderr) + return + + def analyzeFont(self): + self.gposTable = self.font[kGPOStableName].table + + 'ScriptList:' + self.scriptList = self.gposTable.ScriptList + 'FeatureList:' + self.featureList = self.gposTable.FeatureList + + self.featureCount = self.featureList.FeatureCount + self.featureRecord = self.featureList.FeatureRecord + + self.uniqueKernLookupIndexList = collectUniqueKernLookupListIndexes(self.featureRecord) + + def findKerningLookups(self): + if not len(self.uniqueKernLookupIndexList): + print("The font has no %s feature." % kKernFeatureTag, file=sys.stderr) + self.goodbye() + + 'LookupList:' + self.lookupList = self.gposTable.LookupList + self.lookups = [] + for kernLookupIndex in sorted(self.uniqueKernLookupIndexList): + lookup = self.lookupList.Lookup[kernLookupIndex] + + # Confirm this is a GPOS LookupType 2; or + # using an extension table (GPOS LookupType 9): + + ''' + Lookup types: + 1 Single adjustment Adjust position of a single glyph + 2 Pair adjustment Adjust position of a pair of glyphs + 3 Cursive attachment Attach cursive glyphs + 4 MarkToBase attachment Attach a combining mark to a base glyph + 5 MarkToLigature attachment Attach a combining mark to a ligature + 6 MarkToMark attachment Attach a combining mark to another mark + 7 Context positioning Position one or more glyphs in context + 8 Chained Context positioning Position one or more glyphs in chained context + 9 Extension positioning Extension mechanism for other positionings + 10+ Reserved for future use + ''' + + if lookup.LookupType not in [2, 9]: + print(''' + Info: GPOS LookupType %s found. + This type is neither a pair adjustment positioning lookup (GPOS LookupType 2), + nor using an extension table (GPOS LookupType 9), which are the only ones supported. + ''' % lookup.LookupType, file=sys.stderr) + continue + self.lookups.append(lookup) + + + def getPairPos(self): + for lookup in self.lookups: + for subtableItem in lookup.SubTable: + + if subtableItem.LookupType == 9: # extension table + if subtableItem.ExtensionLookupType == 8: # contextual + print('Contextual Kerning not (yet?) supported.', file=sys.stderr) + continue + elif subtableItem.ExtensionLookupType == 2: + subtableItem = subtableItem.ExtSubTable + + + # if subtableItem.Coverage.Format not in [1, 2]: # previous fontTools + if subtableItem.Format not in [1, 2]: + print("WARNING: Coverage format %d is not yet supported." % subtableItem.Coverage.Format, file=sys.stderr) + + if subtableItem.ValueFormat1 not in [0, 4, 5]: + print("WARNING: ValueFormat1 format %d is not yet supported." % subtableItem.ValueFormat1, file=sys.stderr) + + if subtableItem.ValueFormat2 not in [0]: + print("WARNING: ValueFormat2 format %d is not yet supported." % subtableItem.ValueFormat2, file=sys.stderr) + + + self.pairPosList.append(subtableItem) + + # Each glyph in this list will have a corresponding PairSet + # which will contain all the second glyphs and the kerning + # value in the form of PairValueRecord(s) + # self.firstGlyphsList.extend(subtableItem.Coverage.glyphs) + + + def getSinglePairs(self): + for pairPos in self.pairPosList: + if pairPos.Format == 1: + # single pair adjustment + + firstGlyphsList = pairPos.Coverage.glyphs + + # This iteration is done by index so we have a way + # to reference the firstGlyphsList: + for pairSetIndex, pairSetInstance in enumerate(pairPos.PairSet): + for pairValueRecordItem in pairPos.PairSet[pairSetIndex].PairValueRecord: + secondGlyph = pairValueRecordItem.SecondGlyph + valueFormat = pairPos.ValueFormat1 + + if valueFormat == 5: # RTL kerning + kernValue = "<%d 0 %d 0>" % ( + pairValueRecordItem.Value1.XPlacement, + pairValueRecordItem.Value1.XAdvance) + elif valueFormat == 0: # RTL pair with value <0 0 0 0> + kernValue = "<0 0 0 0>" + elif valueFormat == 4: # LTR kerning + kernValue = pairValueRecordItem.Value1.XAdvance + else: + print("\tValueFormat1 = %d" % valueFormat, file=sys.stdout) + continue # skip the rest + + self.kerningPairs[(firstGlyphsList[pairSetIndex], secondGlyph)] = kernValue + self.singlePairs[(firstGlyphsList[pairSetIndex], secondGlyph)] = kernValue + + def getClassPairs(self): + for loop, pairPos in enumerate(self.pairPosList): + if pairPos.Format == 2: + + leftClasses = {} + rightClasses = {} + + # Find left class with the Class1Record index="0". + # This first class is mixed into the "Coverage" table + # (e.g. all left glyphs) and has no class="X" property + # that is why we have to find the glyphs in that way. + + lg0 = myLeftClass() + + # list of all glyphs kerned to the left of a pair: + allLeftGlyphs = pairPos.Coverage.glyphs + # list of all glyphs contained within left-sided kerning classes: + # allLeftClassGlyphs = pairPos.ClassDef1.classDefs.keys() + + singleGlyphs = [] + classGlyphs = [] + + for gName, classID in pairPos.ClassDef1.classDefs.items(): + if classID == 0: + singleGlyphs.append(gName) + else: + classGlyphs.append(gName) + + # lg0.glyphs = list(set(allLeftGlyphs) - set(allLeftClassGlyphs)) # coverage glyphs minus glyphs in a class (including class 0) + lg0.glyphs = list(set(allLeftGlyphs) - set(classGlyphs)) # coverage glyphs minus glyphs in real class (without class 0) + + lg0.glyphs.sort() + leftClasses[lg0.class1Record] = lg0 + className = "class_%s_%s" % (loop, lg0.class1Record) + self.allLeftClasses[className] = lg0.glyphs + + # Find all the remaining left classes: + for leftGlyph in pairPos.ClassDef1.classDefs: + class1Record = pairPos.ClassDef1.classDefs[leftGlyph] + + if class1Record != 0: # this was the crucial line. + lg = myLeftClass() + lg.class1Record = class1Record + leftClasses.setdefault(class1Record, lg).glyphs.append(leftGlyph) + self.allLeftClasses.setdefault("class_%s_%s" % (loop, lg.class1Record), lg.glyphs) + + # Same for the right classes: + for rightGlyph in pairPos.ClassDef2.classDefs: + class2Record = pairPos.ClassDef2.classDefs[rightGlyph] + rg = myRightClass() + rg.class2Record = class2Record + rightClasses.setdefault(class2Record, rg).glyphs.append(rightGlyph) + self.allRightClasses.setdefault("class_%s_%s" % (loop, rg.class2Record), rg.glyphs) + + for record_l in leftClasses: + for record_r in rightClasses: + if pairPos.Class1Record[record_l].Class2Record[record_r]: + valueFormat = pairPos.ValueFormat1 + + if valueFormat in [4, 5]: + kernValue = pairPos.Class1Record[record_l].Class2Record[record_r].Value1.XAdvance + elif valueFormat == 0: + # valueFormat zero is caused by a value of <0 0 0 0> on a class-class pair; skip these + continue + else: + print("\tValueFormat1 = %d" % valueFormat, file=sys.stdout) + continue # skip the rest + + if kernValue != 0: + leftClassName = 'class_%s_%s' % (loop, leftClasses[record_l].class1Record) + rightClassName = 'class_%s_%s' % (loop, rightClasses[record_r].class2Record) + + self.classPairs[(leftClassName, rightClassName)] = kernValue + + for l in leftClasses[record_l].glyphs: + for r in rightClasses[record_r].glyphs: + if (l, r) in self.kerningPairs: + # if the kerning pair has already been assigned in pair-to-pair kerning + continue + else: + if valueFormat == 5: # RTL kerning + kernValue = "<%d 0 %d 0>" % (pairPos.Class1Record[record_l].Class2Record[record_r].Value1.XPlacement, pairPos.Class1Record[record_l].Class2Record[record_r].Value1.XAdvance) + + + self.kerningPairs[(l, r)] = kernValue + + else: + print('ERROR', file=sys.stderr) + + +if __name__ == "__main__": + + if len(sys.argv) == 2: + assumedFontPath = sys.argv[1] + if os.path.exists(assumedFontPath) and os.path.splitext(assumedFontPath)[1].lower() in ['.otf', '.ttf']: + fontPath = sys.argv[1] + f = OTFKernReader(fontPath) + + finalList = [] + for pair, value in f.kerningPairs.items(): + finalList.append('/%s /%s %s' % ( pair[0], pair[1], value )) + + finalList.sort() + + output = '\n'.join(sorted(finalList)) + print(output, file=sys.stdout) + + # print('\nTotal number of kerning pairs:', file=sys.stdout) + # print(len(f.kerningPairs), file=sys.stdout) + # for i in sorted(f.allLeftClasses): + # print(i, f.allLeftClasses[i], file=sys.stdout) + + else: + print('That is not a valid font.', file=sys.stderr) + else: + print('Please provide a font.', file=sys.stderr) diff --git a/misc/kerndiff/getKerningPairsFromUFO.py b/misc/kerndiff/getKerningPairsFromUFO.py new file mode 100644 index 000000000..47e986006 --- /dev/null +++ b/misc/kerndiff/getKerningPairsFromUFO.py @@ -0,0 +1,144 @@ +#!/usr/bin/python +# coding: utf-8 +# +# Copyright (c) 2013-2016 Adobe Systems Incorporated. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys, os, itertools + + +class UFOkernReader(object): + + def __init__(self, font, includeZero=False): + self.f = font + self.group_group_pairs = {} + self.group_glyph_pairs = {} + self.glyph_group_pairs = {} + self.glyph_glyph_pairs = {} + + self.allKerningPairs = self.makePairDicts(includeZero) + self.output = self.makeOutput(self.allKerningPairs) + + self.totalKerning = sum(self.allKerningPairs.values()) + self.absoluteKerning = sum([abs(value) for value in self.allKerningPairs.values()]) + + def makeOutput(self, kerningDict): + output = [] + for (left, right), value in kerningDict.items(): + output.append('/%s /%s %s' % (left, right, value)) + output.sort() + return output + + def allCombinations(self, left, right): + leftGlyphs = self.f.groups.get(left, [left]) + rightGlyphs = self.f.groups.get(right, [right]) + combinations = list(itertools.product(leftGlyphs, rightGlyphs)) + return combinations + + def makePairDicts(self, includeZero): + kerningPairs = {} + + for (left, right), value in self.f.kerning.items(): + + if '@' in left and '@' in right: + # group-to-group-pair + for combo in self.allCombinations(left, right): + self.group_group_pairs[combo] = value + + elif '@' in left and '@' not in right: + # group-to-glyph-pair + for combo in self.allCombinations(left, right): + self.group_glyph_pairs[combo] = value + + elif '@' not in left and '@' in right: + # glyph-to-group-pair + for combo in self.allCombinations(left, right): + self.glyph_group_pairs[combo] = value + + else: + # glyph-to-glyph-pair a.k.a. single pair + self.glyph_glyph_pairs[(left, right)] = value + + # The updates occur from the most general pairs to the most specific. + # This means that any given class kerning values are overwritten with + # the intended exceptions. + kerningPairs.update(self.group_group_pairs) + kerningPairs.update(self.group_glyph_pairs) + kerningPairs.update(self.glyph_group_pairs) + kerningPairs.update(self.glyph_glyph_pairs) + + if includeZero is False: + # delete any kerning values == 0. + # This cannot be done in the previous loop, since exceptions + # might set a previously established kerning pair to be 0. + cleanKerningPairs = dict(kerningPairs) + for pair in kerningPairs: + if kerningPairs[pair] == 0: + del cleanKerningPairs[pair] + return cleanKerningPairs + + else: + return kerningPairs + + +def run(font): + ukr = UFOkernReader(font, includeZero=True) + print('\n'.join(sorted(ukr.output))) + # scrap = os.popen('pbcopy', 'w') + # output = '\n'.join(ukr.output) + # scrap.write(output) + # scrap.close() + + if inRF: + pass + # print('Total length of kerning:', ukr.totalKerning) + + # if inCL: + # print('\n'.join(ukr.output), '\n') + + # print('Total amount of kerning pairs:', len(ukr.output)) + # print('List of kerning pairs copied to clipboard.') + + +if __name__ == '__main__': + inRF = False + inCL = False + + try: + import mojo + inRF = True + f = CurrentFont() + if f: + run(f) + else: + print(u'You need to open a font first. \U0001F625') + + except ImportError: + try: + import defcon + inCL = True + path = os.path.normpath(sys.argv[-1]) + if os.path.splitext(path)[-1] in ['.ufo', '.UFO']: + f = defcon.Font(path) + run(f) + else: + print('No UFO file given.') + except ImportError: + print(u'You don’t have Defcon installed. \U0001F625') diff --git a/misc/kerndiff/kerndiff.sh b/misc/kerndiff/kerndiff.sh new file mode 100755 index 000000000..64ce8c2f1 --- /dev/null +++ b/misc/kerndiff/kerndiff.sh @@ -0,0 +1,96 @@ +#!/bin/bash -e + +function usage() { + cat 1>&2 <<__END +usage: $0 [options] +options: + -h, --help Show usage and exit + Rest of options are forwarded to the "diff" program +__END +} + +diffargs=() +file1= +file2= + +while [ "$1" != "" ]; do + PARAM=`echo $1 | awk -F= '{print $1}'` + VALUE=`echo $1 | awk -F= '{print $2}'` + case $PARAM in + -h | -help | --help) + usage + exit + ;; + -*) + diffargs[${#diffargs[*]}]=$1 + ;; + *) + if [[ "$file1" == "" ]]; then + file1=$PARAM + elif [[ "$file2" == "" ]]; then + file2=$PARAM + else + echo "Too many files" 1>&2 + exit 1 + fi + ;; + esac + shift +done + +if [[ "$file1" == "" ]] && [[ "$file2" == "" ]]; then + usage + exit 1 +elif [[ "$file1" == "" ]] || [[ "$file2" == "" ]]; then + echo "Not enough files" 1>&2 + exit 1 +fi + +tmpdir=$TMPDIR +if [[ "$tmpdir" == "" ]]; then + tmpdir=/tmp +fi +tmpdir=$tmpdir/kerndiff-tmp +mkdir -p "$tmpdir" + +file1x="$(basename "$file1")" +file2x="$(basename "$file2")" + +pushd "$(dirname "$0")" >/dev/null +KERNDIFF_DIR=$PWD +popd >/dev/null + +case $file1 in + *.otf) + python "$KERNDIFF_DIR/getKerningPairsFromOTF.py" "$file1" \ + > "$tmpdir/$file1x" + ;; + *.ufo) + python "$KERNDIFF_DIR/getKerningPairsFromUFO.py" "$file1" \ + > "$tmpdir/$file1x" + ;; + *) + echo "unsupported file format: $file1" + exit 1 + ;; +esac + +case $file2 in + *.otf) + python "$KERNDIFF_DIR/getKerningPairsFromOTF.py" "$file2" \ + > "$tmpdir/$file2x" + ;; + *.ufo) + python "$KERNDIFF_DIR/getKerningPairsFromUFO.py" "$file2" \ + > "$tmpdir/$file2x" + ;; + *) + echo "unsupported file format: $file2" + exit 1 + ;; +esac + +pushd $tmpdir >/dev/null +diff -u "${diffargs[@]}" "$file1x" "$file2x" +popd >/dev/null +rm -rf "$tmpdir" -- cgit v1.2.3