Merge pull request #2718 from fonttools/visitor

Add fontTools.misc.visitor, fontTools.ttLib.ttVisitor, ttLib.scaleUpem, Snippets/print-json.py
This commit is contained in:
Behdad Esfahbod 2022-08-18 06:12:19 -06:00 committed by GitHub
commit 376caff386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 683 additions and 8 deletions

View File

@ -0,0 +1,143 @@
"""Generic visitor pattern implementation for Python objects."""
import enum
class Visitor(object):
defaultStop = False
@classmethod
def _register(celf, clazzes_attrs):
assert celf != Visitor, "Subclass Visitor instead."
if "_visitors" not in celf.__dict__:
celf._visitors = {}
def wrapper(method):
assert method.__name__ == "visit"
for clazzes, attrs in clazzes_attrs:
if type(clazzes) != tuple:
clazzes = (clazzes,)
if type(attrs) == str:
attrs = (attrs,)
for clazz in clazzes:
_visitors = celf._visitors.setdefault(clazz, {})
for attr in attrs:
assert attr not in _visitors, (
"Oops, class '%s' has visitor function for '%s' defined already."
% (clazz.__name__, attr)
)
_visitors[attr] = method
return None
return wrapper
@classmethod
def register(celf, clazzes):
if type(clazzes) != tuple:
clazzes = (clazzes,)
return celf._register([(clazzes, (None,))])
@classmethod
def register_attr(celf, clazzes, attrs):
clazzes_attrs = []
if type(clazzes) != tuple:
clazzes = (clazzes,)
if type(attrs) == str:
attrs = (attrs,)
for clazz in clazzes:
clazzes_attrs.append((clazz, attrs))
return celf._register(clazzes_attrs)
@classmethod
def register_attrs(celf, clazzes_attrs):
return celf._register(clazzes_attrs)
@classmethod
def _visitorsFor(celf, thing, _default={}):
typ = type(thing)
for celf in celf.mro():
_visitors = getattr(celf, "_visitors", None)
if _visitors is None:
break
m = celf._visitors.get(typ, None)
if m is not None:
return m
return _default
def visitObject(self, obj, *args, **kwargs):
"""Called to visit an object. This function loops over all non-private
attributes of the objects and calls any user-registered (via
@register_attr() or @register_attrs()) visit() functions.
If there is no user-registered visit function, of if there is and it
returns True, or it returns None (or doesn't return anything) and
visitor.defaultStop is False (default), then the visitor will proceed
to call self.visitAttr()"""
keys = sorted(vars(obj).keys())
_visitors = self._visitorsFor(obj)
defaultVisitor = _visitors.get("*", None)
for key in keys:
if key[0] == "_":
continue
value = getattr(obj, key)
visitorFunc = _visitors.get(key, defaultVisitor)
if visitorFunc is not None:
ret = visitorFunc(self, obj, key, value, *args, **kwargs)
if ret == False or (ret is None and self.defaultStop):
continue
self.visitAttr(obj, key, value, *args, **kwargs)
def visitAttr(self, obj, attr, value, *args, **kwargs):
"""Called to visit an attribute of an object."""
self.visit(value, *args, **kwargs)
def visitList(self, obj, *args, **kwargs):
"""Called to visit any value that is a list."""
for value in obj:
self.visit(value, *args, **kwargs)
def visitDict(self, obj, *args, **kwargs):
"""Called to visit any value that is a dictionary."""
for value in obj.values():
self.visit(value, *args, **kwargs)
def visitLeaf(self, obj, *args, **kwargs):
"""Called to visit any value that is not an object, list,
or dictionary."""
pass
def visit(self, obj, *args, **kwargs):
"""This is the main entry to the visitor. The visitor will visit object
obj.
The visitor will first determine if there is a registered (via
@register()) visit function for the type of object. If there is, it
will be called, and (visitor, obj, *args, **kwargs) will be passed to
the user visit function.
If there is no user-registered visit function, of if there is and it
returns True, or it returns None (or doesn't return anything) and
visitor.defaultStop is False (default), then the visitor will proceed
to dispatch to one of self.visitObject(), self.visitList(),
self.visitDict(), or self.visitLeaf() (any of which can be overriden in
a subclass)."""
visitorFunc = self._visitorsFor(obj).get(None, None)
if visitorFunc is not None:
ret = visitorFunc(self, obj, *args, **kwargs)
if ret == False or (ret is None and self.defaultStop):
return
if hasattr(obj, "__dict__") and not isinstance(obj, enum.Enum):
self.visitObject(obj, *args, **kwargs)
elif isinstance(obj, list):
self.visitList(obj, *args, **kwargs)
elif isinstance(obj, dict):
self.visitDict(obj, *args, **kwargs)
else:
self.visitLeaf(obj, *args, **kwargs)

View File

@ -0,0 +1,219 @@
"""Change the units-per-EM of a font.
Currently does not support CFF fonts. AAT, Graphite tables are not supported
either."""
from fontTools.ttLib.ttVisitor import TTVisitor
import fontTools.ttLib as ttLib
import fontTools.ttLib.tables.otBase as otBase
import fontTools.ttLib.tables.otTables as otTables
from fontTools.misc.fixedTools import otRound
__all__ = ["scale_upem", "ScalerVisitor"]
class ScalerVisitor(TTVisitor):
def __init__(self, scaleFactor):
self.scaleFactor = scaleFactor
def scale(self, v):
return otRound(v * self.scaleFactor)
@ScalerVisitor.register_attrs(
(
(ttLib.getTableClass("head"), ("unitsPerEm", "xMin", "yMin", "xMax", "yMax")),
(
ttLib.getTableClass("hhea"),
(
"ascent",
"descent",
"lineGap",
"advanceWidthMax",
"minLeftSideBearing",
"minRightSideBearing",
"xMaxExtent",
"caretOffset",
),
),
(
ttLib.getTableClass("vhea"),
(
"ascent",
"descent",
"lineGap",
"advanceHeightMax",
"minTopSideBearing",
"minBottomSideBearing",
"yMaxExtent",
"caretOffset",
),
),
(
ttLib.getTableClass("OS/2"),
(
"xAvgCharWidth",
"ySubscriptXSize",
"ySubscriptYSize",
"ySubscriptXOffset",
"ySubscriptYOffset",
"ySuperscriptXSize",
"ySuperscriptYSize",
"ySuperscriptXOffset",
"ySuperscriptYOffset",
"yStrikeoutSize",
"yStrikeoutPosition",
"sTypoAscender",
"sTypoDescender",
"sTypoLineGap",
"usWinAscent",
"usWinDescent",
"sxHeight",
"sCapHeight",
),
),
(otTables.ValueRecord, ("XAdvance", "YAdvance", "XPlacement", "YPlacement")),
(otTables.Anchor, ("XCoordinate", "YCoordinate")),
(otTables.CaretValue, ("Coordinate")),
(otTables.BaseCoord, ("Coordinate")),
(otTables.ClipBox, ("xMin", "yMin", "xMax", "yMax")),
)
)
def visit(visitor, obj, attr, value):
setattr(obj, attr, visitor.scale(value))
@ScalerVisitor.register_attr(
(ttLib.getTableClass("hmtx"), ttLib.getTableClass("vmtx")), "metrics"
)
def visit(visitor, obj, attr, metrics):
for g in metrics:
advance, lsb = metrics[g]
metrics[g] = visitor.scale(advance), visitor.scale(lsb)
@ScalerVisitor.register_attr(ttLib.getTableClass("glyf"), "glyphs")
def visit(visitor, obj, attr, glyphs):
for g in glyphs.values():
if g.isComposite():
for component in g.components:
component.x = visitor.scale(component.x)
component.y = visitor.scale(component.y)
else:
for attr in ("xMin", "xMax", "yMin", "yMax"):
v = getattr(g, attr, None)
if v is not None:
setattr(g, attr, visitor.scale(v))
glyf = visitor.font["glyf"]
coordinates = g.getCoordinates(glyf)[0]
for i, (x, y) in enumerate(coordinates):
coordinates[i] = visitor.scale(x), visitor.scale(y)
@ScalerVisitor.register_attr(ttLib.getTableClass("gvar"), "variations")
def visit(visitor, obj, attr, variations):
for varlist in variations.values():
for var in varlist:
coordinates = var.coordinates
for i, xy in enumerate(coordinates):
if xy is None:
continue
coordinates[i] = visitor.scale(xy[0]), visitor.scale(xy[1])
@ScalerVisitor.register_attr(ttLib.getTableClass("kern"), "kernTables")
def visit(visitor, obj, attr, kernTables):
for table in kernTables:
kernTable = table.kernTable
for k in kernTable.keys():
kernTable[k] = visitor.scale(kernTable[k])
# ItemVariationStore
@ScalerVisitor.register(otTables.VarData)
def visit(visitor, varData):
for item in varData.Item:
for i, v in enumerate(item):
item[i] = visitor.scale(v)
# COLRv1
@ScalerVisitor.register(otTables.BaseGlyphPaintRecord)
def visit(visitor, record):
oldPaint = record.Paint
transform = otTables.Affine2x3()
transform.populateDefaults()
transform.xy = transform.yx = transform.dx = transform.dy = 0
transform.xx = transform.yy = visitor.scaleFactor
scale = otTables.Paint()
scale.Format = 12 # PaintTransform
scale.Transform = transform
scale.Paint = oldPaint
record.Paint = scale
return True
@ScalerVisitor.register(otTables.Paint)
def visit(visitor, paint):
if paint.Format != 10: # PaintGlyph
return True
newPaint = otTables.Paint()
newPaint.Format = paint.Format
newPaint.Paint = paint.Paint
newPaint.Glyph = paint.Glyph
del paint.Paint
del paint.Glyph
transform = otTables.Affine2x3()
transform.xy = transform.yx = transform.dx = transform.dy = 0
transform.xx = transform.yy = 1 / visitor.scaleFactor
paint.Format = 12 # PaintTransform
paint.Transform = transform
paint.Paint = newPaint
visitor.visit(newPaint.Paint)
return False
def scale_upem(font, new_upem):
"""Change the units-per-EM of font to the new value."""
upem = font["head"].unitsPerEm
visitor = ScalerVisitor(new_upem / upem)
visitor.visit(font)
if __name__ == "__main__":
from fontTools.ttLib import TTFont
import sys
if len(sys.argv) != 3:
print("usage: fonttools ttLib.scaleUpem font new-upem")
sys.exit()
font = TTFont(sys.argv[1])
new_upem = int(sys.argv[2])
if "CFF " in font or "CFF2" in font:
print(
"fonttools ttLib.scaleUpem: CFF/CFF2 fonts are not supported.",
file=sys.stderr,
)
sys.exit(1)
scale_upem(font, new_upem)
print("Writing out.ttf")
font.save("out.ttf")

View File

@ -398,12 +398,17 @@ class BitmapGlyph(object):
# Allow lazy decompile.
if attr[:2] == '__':
raise AttributeError(attr)
if not hasattr(self, "data"):
if attr == "data":
raise AttributeError(attr)
self.decompile()
del self.data
return getattr(self, attr)
def ensureDecompiled(self, recurse=False):
if hasattr(self, "data"):
self.decompile()
del self.data
# Not a fan of this but it is needed for safer safety checking.
def getFormat(self):
return safeEval(self.__class__.__name__[len(_bitmapGlyphSubclassPrefix):])

View File

@ -338,11 +338,15 @@ class EblcIndexSubTable(object):
# Allow lazy decompile.
if attr[:2] == '__':
raise AttributeError(attr)
if not hasattr(self, "data"):
if attr == "data":
raise AttributeError(attr)
self.decompile()
return getattr(self, attr)
def ensureDecompiled(self, recurse=False):
if hasattr(self, "data"):
self.decompile()
# This method just takes care of the indexSubHeader. Implementing subclasses
# should call it to compile the indexSubHeader and then continue compiling
# the remainder of their unique format.

View File

@ -164,7 +164,9 @@ class table__c_m_a_p(DefaultTable.DefaultTable):
if ttFont.lazy is False: # Be lazy for None and True
self.ensureDecompiled()
def ensureDecompiled(self):
def ensureDecompiled(self, recurse=False):
# The recurse argument is unused, but part of the signature of
# ensureDecompiled across the library.
for st in self.tables:
st.ensureDecompiled()
@ -241,7 +243,9 @@ class CmapSubtable(object):
self.platEncID = None #: The encoding ID of this subtable (interpretation depends on ``platformID``)
self.language = None #: The language ID of this subtable (Macintosh platform only)
def ensureDecompiled(self):
def ensureDecompiled(self, recurse=False):
# The recurse argument is unused, but part of the signature of
# ensureDecompiled across the library.
if self.data is None:
return
self.decompile(None, None) # use saved data.

View File

@ -112,7 +112,9 @@ class table__g_l_y_f(DefaultTable.DefaultTable):
if ttFont.lazy is False: # Be lazy for None and True
self.ensureDecompiled()
def ensureDecompiled(self):
def ensureDecompiled(self, recurse=False):
# The recurse argument is unused, but part of the signature of
# ensureDecompiled across the library.
for glyph in self.glyphs.values():
glyph.expand(self)

View File

@ -386,12 +386,14 @@ class TTFont(object):
keys = sortedTagList(keys)
return ["GlyphOrder"] + keys
def ensureDecompiled(self):
def ensureDecompiled(self, recurse=None):
"""Decompile all the tables, even if a TTFont was opened in 'lazy' mode."""
for tag in self.keys():
table = self[tag]
if self.lazy is not False and hasattr(table, "ensureDecompiled"):
table.ensureDecompiled()
if recurse is None:
recurse = self.lazy is not False
if recurse and hasattr(table, "ensureDecompiled"):
table.ensureDecompiled(recurse=recurse)
self.lazy = False
def __len__(self):

View File

@ -0,0 +1,32 @@
"""Specialization of fontTools.misc.visitor to work with TTFont."""
from fontTools.misc.visitor import Visitor
from fontTools.ttLib import TTFont
class TTVisitor(Visitor):
def visitAttr(self, obj, attr, value, *args, **kwargs):
if isinstance(value, TTFont):
return False
super().visitAttr(obj, attr, value, *args, **kwargs)
def visit(self, obj, *args, **kwargs):
if hasattr(obj, "ensureDecompiled"):
obj.ensureDecompiled(recurse=False)
super().visit(obj, *args, **kwargs)
@TTVisitor.register(TTFont)
def visit(visitor, font, *args, **kwargs):
# Some objects have links back to TTFont; even though we
# have a check in visitAttr to stop them from recursing
# onto TTFont, sometimes they still do, for example when
# someone overrides visitAttr.
if hasattr(visitor, "font"):
return False
visitor.font = font
for tag in font.keys():
visitor.visit(font[tag], *args, **kwargs)
del visitor.font
return False

153
Snippets/print-json.py Normal file
View File

@ -0,0 +1,153 @@
import fontTools.ttLib as ttLib
from fontTools.ttLib.ttVisitor import TTVisitor
from fontTools.misc.textTools import Tag
from array import array
class JsonVisitor(TTVisitor):
def _open(self, s):
print(s, file=self.file)
self._indent += self.indent
self.comma = False
def _close(self, s):
self._indent = self._indent[: -len(self.indent)]
print("\n%s%s" % (self._indent, s), end="", file=self.file)
self.comma = True
def __init__(self, file, indent=" "):
self.file = file
self.indent = indent
self._indent = ""
def visitObject(self, obj):
self._open("{")
super().visitObject(obj)
if self.comma:
print(",", end="", file=self.file)
print(
'\n%s"type": "%s"' % (self._indent, obj.__class__.__name__),
end="",
file=self.file,
)
self._close("}")
def visitAttr(self, obj, attr, value):
if self.comma:
print(",", file=self.file)
print('%s"%s": ' % (self._indent, attr), end="", file=self.file)
self.visit(value)
self.comma = True
def visitList(self, obj, *args, **kwargs):
self._open("[")
comma = False
for value in obj:
if comma:
print(",", end="", file=self.file)
print(file=self.file)
print(self._indent, end="", file=self.file)
self.visit(value, *args, **kwargs)
comma = True
self._close("]")
def visitDict(self, obj, *args, **kwargs):
self._open("{")
comma = False
for key, value in obj.items():
if comma:
print(",", end="", file=self.file)
print(file=self.file)
print('%s"%s": ' % (self._indent, key), end="", file=self.file)
self.visit(value, *args, **kwargs)
comma = True
self._close("}")
def visitLeaf(self, obj):
if isinstance(obj, tuple):
obj = list(obj)
elif isinstance(obj, bytes):
obj = list(obj)
if obj is None:
s = "null"
elif obj is True:
s = "true"
elif obj is False:
s = "false"
else:
s = repr(obj)
if s[0] == "'":
s = '"' + s[1:-1] + '"'
print("%s" % s, end="", file=self.file)
@JsonVisitor.register(ttLib.TTFont)
def visit(self, font):
if hasattr(visitor, "font"):
print("{}", end="", file=self.file)
return False
visitor.font = font
self._open("{")
for tag in font.keys():
if self.comma:
print(",", file=self.file)
print('\n%s"%s": ' % (self._indent, tag), end="", file=self.file)
visitor.visit(font[tag])
self._close("}")
del visitor.font
return False
@JsonVisitor.register(ttLib.GlyphOrder)
def visit(self, obj):
self.visitList(self.font.getGlyphOrder())
return False
@JsonVisitor.register_attr(ttLib.getTableClass("glyf"), "glyphOrder")
def visit(visitor, obj, attr, value):
return False
@JsonVisitor.register(ttLib.getTableModule("glyf").GlyphCoordinates)
def visit(self, obj):
self.visitList(obj)
return False
@JsonVisitor.register(Tag)
def visit(self, obj):
print('"%s"' % str(obj), end="", file=self.file)
return False
@JsonVisitor.register(array)
def visit(self, obj):
self.visitList(obj)
return False
@JsonVisitor.register(bytearray)
def visit(self, obj):
self.visitList(obj)
return False
if __name__ == "__main__":
from fontTools.ttLib import TTFont
import sys
if len(sys.argv) != 2:
print("usage: print-json.py font")
sys.exit()
font = TTFont(sys.argv[1])
visitor = JsonVisitor(sys.stdout)
visitor.visit(font)

View File

@ -0,0 +1,72 @@
from fontTools.misc.visitor import Visitor
import enum
import pytest
class E(enum.Enum):
E1 = 1
E2 = 2
E3 = 3
class A:
def __init__(self):
self.a = 1
self.b = [2, 3]
self.c = {4: 5, 6: 7}
self._d = 8
self.e = E.E2
self.f = 10
class B:
def __init__(self):
self.a = A()
class TestVisitor(Visitor):
def __init__(self):
self.value = []
def _add(self, s):
self.value.append(s)
def visitLeaf(self, obj):
self._add(obj)
super().visitLeaf(obj)
@TestVisitor.register(A)
def visit(self, obj):
self._add("A")
@TestVisitor.register_attrs([(A, "e")])
def visit(self, obj, attr, value):
self._add(attr)
self._add(value)
return False
@TestVisitor.register(B)
def visit(self, obj):
self._add("B")
self.visitObject(obj)
return False
@TestVisitor.register_attr(B, "a")
def visit(self, obj, attr, value):
self._add("B a")
class VisitorTest(object):
def test_visitor(self):
b = B()
visitor = TestVisitor()
visitor.visit(b)
assert visitor.value == ["B", "B a", "A", 1, 2, 3, 5, 7, "e", E.E2, 10]
visitor.value = []
visitor.defaultStop = True
visitor.visit(b)
assert visitor.value == ["B", "B a"]

View File

@ -0,0 +1,39 @@
from fontTools.ttLib import TTFont
from fontTools.ttLib.ttVisitor import TTVisitor
import os
import pytest
class TestVisitor(TTVisitor):
def __init__(self):
self.value = []
self.depth = 0
def _add(self, s):
self.value.append(s)
def visit(self, obj, target_depth):
if self.depth == target_depth:
self._add(obj)
self.depth += 1
super().visit(obj, target_depth)
self.depth -= 1
class TTVisitorTest(object):
@staticmethod
def getpath(testfile):
path = os.path.dirname(__file__)
return os.path.join(path, "data", testfile)
def test_ttvisitor(self):
font = TTFont(self.getpath("TestVGID-Regular.otf"))
visitor = TestVisitor()
# Count number of objects at depth 1:
# That is, number of font tables, including GlyphOrder.
visitor.visit(font, 1)
assert len(visitor.value) == 14