From 1bf738cec6594e83efcae50aba49eda53102e68b Mon Sep 17 00:00:00 2001 From: Rasmus Andersson Date: Thu, 15 Jun 2023 13:17:45 -0700 Subject: [PATCH] adds two debugging tools: lsfvar.py & lsstat.py --- misc/tools/lsfvar.py | 184 ++++++++++++++++++++++++++++++++++++++++ misc/tools/lsstat.py | 194 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 misc/tools/lsfvar.py create mode 100644 misc/tools/lsstat.py diff --git a/misc/tools/lsfvar.py b/misc/tools/lsfvar.py new file mode 100644 index 000000000..6244f35f4 --- /dev/null +++ b/misc/tools/lsfvar.py @@ -0,0 +1,184 @@ +import sys, os, os.path, re, argparse, pprint, shutil +import yaml +try: + from yaml import CLoader as YamlLoader, CDumper as YamlDumper +except ImportError: + from yaml import Loader as YamlLoader, Dumper as YamlDumper +from collections import OrderedDict +from multiprocessing import Pool +from fontTools.ttLib import TTFont + + +def read_fvar_table(filename: str) -> {str:dict}: + font = TTFont(filename, recalcBBoxes=False, recalcTimestamp=False) + nametab = font["name"] + + names = {} + for rec in nametab.names: + names[rec.nameID] = rec.toUnicode() + + fvartab = font["fvar"] + axes = [] + for a in fvartab.axes: + # axisTag + # axisNameID + # flags + # minValue + # defaultValue + # maxValue + d = a.__dict__ + for k in list(d.keys()): + # replace name-table IDs with their values + if k.endswith('NameID'): + d[k[:-2]] = names[d[k]] + del d[k] + axes.append(d) + + instances = [] + for inst in fvartab.instances: + d = inst.__dict__ + for k in list(d.keys()): + # replace name-table IDs with their values + if k.endswith('NameID'): + d[k[:-2]] = names[d[k]] + del d[k] + instances.append(d) + + return OrderedDict(axes=axes, instances=instances) + + +def fmtstructured(tables: [dict], files: [str], cl: dict) -> str: + doc = {} + for i, tab in enumerate(tables): + file = files[i] + doc[file] = dict(tab) + cols = shutil.get_terminal_size((80, 20)).columns + if cl.format == 'yaml': + return yaml.dump(doc, Dumper=YamlDumper, width=cols) + return pprint.PrettyPrinter(indent=2, width=cols).pformat(doc) + + +def fmtplaintext(out: [str], header: [str], rows: [[str]]) -> str: + # calculate width needed for columns + colw = [0] * len(header) + for i, s in enumerate(header): + colw[i] = max(colw[i], len(s)) + for row in rows: + for i, s in enumerate(row): + colw[i] = max(colw[i], len(s)) + + colglue, divglue, divchar = ' │ ', '─┼─', '─' + row_prefix, row_suffix, div_prefix = '', '\n', '' + if cl.format == 'md': + colglue, divglue, divchar = ' | ', ' | ', '-' + row_prefix, row_suffix, div_prefix = '| ', ' |\n', ':' + + def format_row(row): + out.append(row_prefix) + out.append(colglue.join(['%-*s' % (colw[i], s) for i, s in enumerate(row)])) + out.append(row_suffix) + + def format_divider(): + out.append(row_prefix) + xs = [div_prefix + (divchar * (colw[i] - len(div_prefix))) for i in range(len(row))] + out.append(divglue.join(xs)) + out.append(row_suffix) + + format_row(header) + format_divider() + for row in rows: + format_row(row) + + +def build_axes(tables: [dict], files: [str]) -> ([str], [[str]]): + uniqueAxes1 = OrderedDict() + for i, tab in enumerate(tables): + file = files[i] + for a in tab["axes"]: + vals = OrderedDict(sorted(a.items())).values() + key = ' '.join([str(v) for v in vals]) + d = uniqueAxes1.get(key, {}) + filev = d.get('files', []) + d.update(a) + filev.append(file) + d['files'] = filev + uniqueAxes1[key] = d + + uniqueAxes = dict() # {tag:[{axis, ...}]} + for a in uniqueAxes1.values(): + v = uniqueAxes.get(a['axisTag'], []) + v.append(a) + uniqueAxes[a['axisTag']] = v + + header = ['tag', 'name', 'flags', 'min', 'default', 'max', 'files'] + rows = [] + for axesForTag in uniqueAxes.values(): + for a in axesForTag: + rows.append([ + a["axisTag"], + a["axisName"], + str(a["flags"]), + str(a["minValue"]), + str(a["defaultValue"]), + str(a["maxValue"]), + ', '.join(a["files"]), + ]) + + return header, rows + + +def build_instances(tables: [dict], files: [str]) -> ([str], [[str]]): + axisTags = [] + for axis in tables[0]["axes"]: + axisTags.append(axis["axisTag"]) + header = axisTags + ['flags', 'postscriptName', 'subfamilyName', 'file'] + rows = [] + for i, tab in enumerate(tables): + file = files[i] + axes = tab['axes'] + for inst in tab['instances']: + row = [] + for axisTag in axisTags: + row.append(str(inst['coordinates'][axisTag])) + row.append(str(inst['flags'])) + row.append(inst['postscriptName']) + row.append(inst['subfamilyName']) + row.append(file) + rows.append(row) + + return header, rows + + +def format_fvar(tables: [dict], files: [str], cl: dict) -> str: + if cl.format in ('yaml', 'py'): + return fmtstructured(tables, files, cl) + + out = [] + + # axes + header, rows = build_axes(tables, files) + fmtplaintext(out, header, rows) + out.append('\n') + + # instances + header, rows = build_instances(tables, files) + fmtplaintext(out, header, rows) + + return ''.join(out) + + +if __name__ == '__main__': + argparser = argparse.ArgumentParser(description='Print fvar table entries') + a = lambda *args, **kwargs: argparser.add_argument(*args, **kwargs) + a('-f', '--format', metavar='', help='One of: plain, yaml, py, md') + a('inputs', metavar='', nargs='+', help='Input fonts (ttf or otf)') + cl = argparser.parse_args() + + if cl.format not in (None, 'plain', 'yaml', 'py', 'md'): + raise Exception(f'unknown format: "{cl.format}"') + + with Pool() as p: + tables = p.map(read_fvar_table, cl.inputs) + + filenames = [os.path.basename(fn) for fn in cl.inputs] + print(format_fvar(tables, filenames, cl)) diff --git a/misc/tools/lsstat.py b/misc/tools/lsstat.py new file mode 100644 index 000000000..7fa13776d --- /dev/null +++ b/misc/tools/lsstat.py @@ -0,0 +1,194 @@ +import sys, os, os.path, re, argparse, pprint, shutil +import yaml +try: + from yaml import CLoader as YamlLoader, CDumper as YamlDumper +except ImportError: + from yaml import Loader as YamlLoader, Dumper as YamlDumper +from collections import OrderedDict +from multiprocessing import Pool +from fontTools.ttLib import TTFont + + +TODO_PRINTS = dict() + + +def getName(nametab: object, nameID: int) -> str: + return nametab.getName(nameID, 3, 1).toUnicode() + + +def read_stat_table(filename: str) -> {str:dict}: + font = TTFont(filename, recalcBBoxes=False, recalcTimestamp=False) + nametab = font["name"] + stattab = font["STAT"].table.__dict__ + + designAxisRecord = stattab['DesignAxisRecord'].Axis + axisValueArray = stattab['AxisValueArray'].AxisValue + + axes = OrderedDict() + axesArray = [] + + for r in designAxisRecord: + tag = str(r.AxisTag) + axis = dict( + tag = tag, + name = getName(nametab, r.AxisNameID), + values = [], + ) + axesArray.append(axis) + axes[tag] = axis + + for r in axisValueArray: + axis = axesArray[r.AxisIndex] + format = r.Format + value = dict( + axisIndex = r.AxisIndex, + format = format, + flags = r.Flags, + name = getName(nametab, r.ValueNameID), + ) + if format == 1: + value['value'] = r.Value + elif format == 2: + value['value'] = r.NominalValue + value['rangeMaxValue'] = r.RangeMaxValue + value['rangeMinValue'] = r.RangeMinValue + elif format == 3: + value['value'] = r.Value + value['linkedValue'] = r.LinkedValue + elif format == 4: + if not TODO_PRINTS.get('format4'): + TODO_PRINTS['format4'] = True + print('TODO: implement support for format 4 STAT records', file=sys.stderr) + axis['values'].append(value) + + version = stattab['Version'] # e.g. 0x00010002 for 1.2 + + return dict( + version = '%d.%d' % (version >> 16, version & 0xffff), + axes = axes, + elidedFallbackName = getName(nametab, stattab['ElidedFallbackNameID']), + ) + + +def fmtstructured(tables: [dict], files: [str], cl: dict) -> str: + doc = {} + for i, tab in enumerate(tables): + file = files[i] + doc[file] = dict(tab) + cols = shutil.get_terminal_size((80, 20)).columns + if cl.format == 'yaml': + return yaml.dump(doc, Dumper=YamlDumper, width=cols) + return pprint.PrettyPrinter(indent=2, width=cols).pformat(doc) + + +def fmtplaintext(out: [str], header: [str], rows: [[str]], cl: dict) -> str: + # calculate width needed for columns + colw = [0] * len(header) + for i, s in enumerate(header): + colw[i] = max(colw[i], len(s)) + colw2 = [0] * len(header) + for row in rows: + for i, s in enumerate(row): + colw[i] = max(colw[i], len(s)) + colw2[i] = max(colw2[i], len(s)) + + # elide empty columns + if 0 in colw2: + emptyColsIndices = set([i for i, w in enumerate(colw2) if w == 0]) + header = [s for i, s in enumerate(header) if colw2[i] > 0] + rows2 = [] + for row in rows: + row2 = [] + for i, val in enumerate(row): + if i not in emptyColsIndices: + row2.append(val) + rows2.append(row2) + rows = rows2 + colw = [w for i, w in enumerate(colw) if colw2[i] > 0] + + colglue, divglue, divchar = ' │ ', '─┼─', '─' + row_prefix, row_suffix, div_prefix = '', '\n', '' + if cl.format == 'md': + colglue, divglue, divchar = ' | ', ' | ', '-' + row_prefix, row_suffix, div_prefix = '| ', ' |\n', ':' + + def format_row(row): + out.append(row_prefix) + out.append(colglue.join(['%-*s' % (colw[i], s) for i, s in enumerate(row)])) + out.append(row_suffix) + + def format_divider(): + out.append(row_prefix) + xs = [div_prefix + (divchar * (colw[i] - len(div_prefix))) for i in range(len(colw))] + out.append(divglue.join(xs)) + out.append(row_suffix) + + format_row(header) + format_divider() + for row in rows: + format_row(row) + + +def format_stat(tables: [dict], files: [str], cl: dict) -> str: + if cl.format in ('yaml', 'py'): + return fmtstructured(tables, files, cl) + + out = [] + + uniqueAxes = OrderedDict() + for tab in tables: + for a in tab["axes"].values(): + uniqueAxes[a['tag']] = a['name'] + + rubric_prefix, rubric_suffix = '', '\n' + if cl.format == 'md': + rubric_prefix, rubric_suffix = '### ', '\n\n' + + header = ['name', 'value', 'minval', 'maxval', 'linkval', 'format', 'flags', 'file'] + for axisTag, axisName in uniqueAxes.items(): + out.append(f"{rubric_prefix}{axisTag} ({axisName}){rubric_suffix}") + rows = [] + for i, tab in enumerate(tables): + file = files[i] + axis = tab["axes"].get(axisTag) + if not axis: + continue + for v in axis['values']: + row = [ + v['name'], + str(v['value']), + str(v.get('rangeMinValue', '')), + str(v.get('rangeMaxValue', '')), + str(v.get('linkedValue', '')), + str(v['format']), + str(v['flags']), + file, + ] + rows.append(row) + + fmtplaintext(out, header, rows, cl) + out.append('\n') + + if len(uniqueAxes) > 0: + out.pop() # undo last \n + + return ''.join(out) + + +if __name__ == '__main__': + argparser = argparse.ArgumentParser(description='Print STAT table entries') + a = lambda *args, **kwargs: argparser.add_argument(*args, **kwargs) + a('-f', '--format', metavar='', help='One of: plain, yaml, py, md') + a('inputs', metavar='', nargs='+', help='Input fonts (ttf or otf)') + cl = argparser.parse_args() + + if cl.format not in (None, 'plain', 'yaml', 'py', 'md'): + raise Exception(f'unknown format: "{cl.format}"') + + with Pool() as p: + tables = p.map(read_stat_table, cl.inputs) + + print(pprint.PrettyPrinter(indent=2).pformat(tables[0])) + + filenames = [os.path.basename(fn) for fn in cl.inputs] + print(format_stat(tables, filenames, cl))