From b9c268943a2f2eef38fac42d279b91c9fc7ffad7 Mon Sep 17 00:00:00 2001 From: Jens Kutilek Date: Thu, 22 Oct 2020 11:16:24 +0200 Subject: [PATCH] Add HashPointPen from psautohint (#2005) * Add HashPointPen from psautohint (with changes) * Decompose components * Use format string and disambiguate critical changes * Remove "getHash()" in favour of "hash" property * Add transformation for composites * Omit base glyph name from component hash * Use untransformed coords for components * Add tests * Add example code Co-authored-by: Cosimo Lupo --- Lib/fontTools/pens/hashPointPen.py | 73 +++++++++++++++ Tests/pens/hashPointPen_test.py | 138 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 Lib/fontTools/pens/hashPointPen.py create mode 100644 Tests/pens/hashPointPen_test.py diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py new file mode 100644 index 000000000..f3276f701 --- /dev/null +++ b/Lib/fontTools/pens/hashPointPen.py @@ -0,0 +1,73 @@ +# Modified from https://github.com/adobe-type-tools/psautohint/blob/08b346865710ed3c172f1eb581d6ef243b203f99/python/psautohint/ufoFont.py#L800-L838 +import hashlib + +from fontTools.pens.pointPen import AbstractPointPen + + +class HashPointPen(AbstractPointPen): + """ + This pen can be used to check if a glyph's contents (outlines plus + components) have changed. + + Components are added as the original outline plus each composite's + transformation. + + Example: You have some TrueType hinting code for a glyph which you want to + compile. The hinting code specifies a hash value computed with HashPointPen + that was valid for the glyph's outlines at the time the hinting code was + written. Now you can calculate the hash for the glyph's current outlines to + check if the outlines have changed, which would probably make the hinting + code invalid. + + > glyph = ufo[name] + > hash_pen = HashPointPen(glyph.width, ufo) + > glyph.drawPoints(hash_pen) + > ttdata = glyph.lib.get("public.truetype.instructions", None) + > stored_hash = ttdata.get("id", None) # The hash is stored in the "id" key + > if stored_hash is None or stored_hash != hash_pen.hash: + > logger.error(f"Glyph hash mismatch, glyph '{name}' will have no instructions in font.") + > else: + > # The hash values are identical, the outline has not changed. + > # Compile the hinting code ... + > pass + """ + + def __init__(self, glyphWidth=0, glyphSet=None): + self.glyphset = glyphSet + self.data = ["w%s" % round(glyphWidth, 9)] + + @property + def hash(self): + data = "".join(self.data) + if len(data) >= 128: + data = hashlib.sha512(data.encode("ascii")).hexdigest() + return data + + def beginPath(self, identifier=None, **kwargs): + pass + + def endPath(self): + self.data.append("|") + + def addPoint( + self, + pt, + segmentType=None, + smooth=False, + name=None, + identifier=None, + **kwargs, + ): + if segmentType is None: + pt_type = "o" # offcurve + else: + pt_type = segmentType[0] + self.data.append(f"{pt_type}{pt[0]:g}{pt[1]:+g}") + + def addComponent( + self, baseGlyphName, transformation, identifier=None, **kwargs + ): + tr = "".join([f"{t:+}" for t in transformation]) + self.data.append("[") + self.glyphset[baseGlyphName].drawPoints(self) + self.data.append(f"({tr})]") diff --git a/Tests/pens/hashPointPen_test.py b/Tests/pens/hashPointPen_test.py new file mode 100644 index 000000000..6b744e666 --- /dev/null +++ b/Tests/pens/hashPointPen_test.py @@ -0,0 +1,138 @@ +from fontTools.misc.transform import Identity +from fontTools.pens.hashPointPen import HashPointPen +import pytest + + +class _TestGlyph(object): + width = 500 + + def drawPoints(self, pen): + pen.beginPath(identifier="abc") + pen.addPoint((0.0, 0.0), "line", False, "start", identifier="0000") + pen.addPoint((10, 110), "line", False, None, identifier="0001") + pen.addPoint((50.0, 75.0), None, False, None, identifier="0002") + pen.addPoint((60.0, 50.0), None, False, None, identifier="0003") + pen.addPoint((50.0, 0.0), "curve", True, "last", identifier="0004") + pen.endPath() + + +class _TestGlyph2(_TestGlyph): + def drawPoints(self, pen): + pen.beginPath(identifier="abc") + pen.addPoint((0.0, 0.0), "line", False, "start", identifier="0000") + # Minor difference to _TestGlyph() is in the next line: + pen.addPoint((101, 10), "line", False, None, identifier="0001") + pen.addPoint((50.0, 75.0), None, False, None, identifier="0002") + pen.addPoint((60.0, 50.0), None, False, None, identifier="0003") + pen.addPoint((50.0, 0.0), "curve", True, "last", identifier="0004") + pen.endPath() + + +class _TestGlyph3(_TestGlyph): + def drawPoints(self, pen): + pen.beginPath(identifier="abc") + pen.addPoint((0.0, 0.0), "line", False, "start", identifier="0000") + pen.addPoint((10, 110), "line", False, None, identifier="0001") + pen.endPath() + # Same segment, but in a different path: + pen.beginPath(identifier="pth2") + pen.addPoint((50.0, 75.0), None, False, None, identifier="0002") + pen.addPoint((60.0, 50.0), None, False, None, identifier="0003") + pen.addPoint((50.0, 0.0), "curve", True, "last", identifier="0004") + pen.endPath() + + +class _TestGlyph4(_TestGlyph): + def drawPoints(self, pen): + pen.beginPath(identifier="abc") + pen.addPoint((0.0, 0.0), "move", False, "start", identifier="0000") + pen.addPoint((10, 110), "line", False, None, identifier="0001") + pen.addPoint((50.0, 75.0), None, False, None, identifier="0002") + pen.addPoint((60.0, 50.0), None, False, None, identifier="0003") + pen.addPoint((50.0, 0.0), "curve", True, "last", identifier="0004") + pen.endPath() + + +class _TestGlyph5(_TestGlyph): + def drawPoints(self, pen): + pen.addComponent("b", Identity) + + +class HashPointPenTest(object): + def test_addComponent(self): + pen = HashPointPen(_TestGlyph().width, {"a": _TestGlyph()}) + pen.addComponent("a", (2, 0, 0, 3, -10, 5)) + assert pen.hash == "w500[l0+0l10+110o50+75o60+50c50+0|(+2+0+0+3-10+5)]" + + def test_NestedComponents(self): + pen = HashPointPen( + _TestGlyph().width, {"a": _TestGlyph5(), "b": _TestGlyph()} + ) # "a" contains "b" as a component + pen.addComponent("a", (2, 0, 0, 3, -10, 5)) + + assert ( + pen.hash + == "w500[[l0+0l10+110o50+75o60+50c50+0|(+1+0+0+1+0+0)](+2+0+0+3-10+5)]" + ) + + def test_outlineAndComponent(self): + pen = HashPointPen(_TestGlyph().width, {"a": _TestGlyph()}) + glyph = _TestGlyph() + glyph.drawPoints(pen) + pen.addComponent("a", (2, 0, 0, 2, -10, 5)) + + assert ( + pen.hash + == "w500l0+0l10+110o50+75o60+50c50+0|[l0+0l10+110o50+75o60+50c50+0|(+2+0+0+2-10+5)]" + ) + + def test_addComponent_missing_raises(self): + pen = HashPointPen(_TestGlyph().width, dict()) + with pytest.raises(KeyError) as excinfo: + pen.addComponent("a", Identity) + assert excinfo.value.args[0] == "a" + + def test_similarGlyphs(self): + pen = HashPointPen(_TestGlyph().width) + glyph = _TestGlyph() + glyph.drawPoints(pen) + + pen2 = HashPointPen(_TestGlyph2().width) + glyph = _TestGlyph2() + glyph.drawPoints(pen2) + + assert pen.hash != pen2.hash + + def test_similarGlyphs2(self): + pen = HashPointPen(_TestGlyph().width) + glyph = _TestGlyph() + glyph.drawPoints(pen) + + pen2 = HashPointPen(_TestGlyph3().width) + glyph = _TestGlyph3() + glyph.drawPoints(pen2) + + assert pen.hash != pen2.hash + + def test_similarGlyphs3(self): + pen = HashPointPen(_TestGlyph().width) + glyph = _TestGlyph() + glyph.drawPoints(pen) + + pen2 = HashPointPen(_TestGlyph4().width) + glyph = _TestGlyph4() + glyph.drawPoints(pen2) + + assert pen.hash != pen2.hash + + def test_glyphVsComposite(self): + # If a glyph contains a component, the decomposed glyph should still + # compare false + pen = HashPointPen(_TestGlyph().width, {"a": _TestGlyph()}) + pen.addComponent("a", Identity) + + pen2 = HashPointPen(_TestGlyph().width) + glyph = _TestGlyph() + glyph.drawPoints(pen2) + + assert pen.hash != pen2.hash