From 54ed840b15a264c23c069f780d1fd6b62912ebf6 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 23 Sep 2020 14:22:37 +0100 Subject: [PATCH 01/22] Add snippet to remove overlaps on TTF with skia-pathops --- Snippets/remove-overlaps.py | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 Snippets/remove-overlaps.py diff --git a/Snippets/remove-overlaps.py b/Snippets/remove-overlaps.py new file mode 100644 index 000000000..4e29dc491 --- /dev/null +++ b/Snippets/remove-overlaps.py @@ -0,0 +1,76 @@ +#! /usr/bin/env python3 + +# Example script to remove overlaps in TTF using skia-pathops + + +import sys +from fontTools.ttLib import TTFont +from fontTools.pens.recordingPen import DecomposingRecordingPen +from fontTools.pens.ttGlyphPen import TTGlyphPen + +try: + import pathops +except ImportError: + sys.exit( + "This script requires the skia-pathops module. " + "`pip install skia-pathops` and then retry." + ) + + +def skpath_from_simple_glyph(glyphName, glyphSet): + path = pathops.Path() + pathPen = path.getPen() + glyphSet[glyphName].draw(pathPen) + return path + + +def skpath_from_composite_glyph(glyphName, glyphSet): + # record TTGlyph outlines without components + dcPen = DecomposingRecordingPen(glyphSet) + glyphSet[glyphName].draw(dcPen) + # replay recording onto a skia-pathops Path + path = pathops.Path() + pathPen = path.getPen() + dcPen.replay(pathPen) + return path + + +def tt_glyph_from_skpath(path): + ttPen = TTGlyphPen(None) + path.draw(ttPen) + return ttPen.glyph() + + +def main(): + if len(sys.argv) != 3: + print("usage: remove-overlaps.py fontfile.ttf outfile.ttf") + sys.exit(1) + + src = sys.argv[1] + dst = sys.argv[2] + + with TTFont(src) as f: + glyfTable = f["glyf"] + glyphSet = f.getGlyphSet() + + for glyphName in glyphSet.keys(): + if glyfTable[glyphName].isComposite(): + path = skpath_from_composite_glyph(glyphName, glyphSet) + else: + path = skpath_from_simple_glyph(glyphName, glyphSet) + + # duplicate path + path2 = pathops.Path(path) + + # remove overlaps + path2.simplify() + + # replace TTGlyph if simplified copy is different + if path2 != path: + glyfTable[glyphName] = tt_glyph_from_skpath(path2) + + f.save(dst) + + +if __name__ == "__main__": + main() From 19f5915f2e7375942df91265fb0928b1b71d4b08 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 23 Sep 2020 16:04:56 +0100 Subject: [PATCH 02/22] ensure hmtx.lsb == glyph.xMin so that origin is at x=0 for new glyph --- Snippets/remove-overlaps.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Snippets/remove-overlaps.py b/Snippets/remove-overlaps.py index 4e29dc491..4686ddb20 100644 --- a/Snippets/remove-overlaps.py +++ b/Snippets/remove-overlaps.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 -# Example script to remove overlaps in TTF using skia-pathops +# Example script to remove overlaps in TTF using skia-pathops. +# Overlapping components will be decomposed. import sys @@ -35,10 +36,15 @@ def skpath_from_composite_glyph(glyphName, glyphSet): return path -def tt_glyph_from_skpath(path): - ttPen = TTGlyphPen(None) +def simple_glyph_from_skpath(path): + # Skia paths have no 'components', no need for glyphSet + ttPen = TTGlyphPen(glyphSet=None) path.draw(ttPen) - return ttPen.glyph() + glyph = ttPen.glyph() + assert not glyph.isComposite() + # compute glyph.xMin (glyfTable parameter unused for non composites) + glyph.recalcBounds(glyfTable=None) + return glyph def main(): @@ -51,6 +57,7 @@ def main(): with TTFont(src) as f: glyfTable = f["glyf"] + hmtxTable = f["hmtx"] glyphSet = f.getGlyphSet() for glyphName in glyphSet.keys(): @@ -67,7 +74,11 @@ def main(): # replace TTGlyph if simplified copy is different if path2 != path: - glyfTable[glyphName] = tt_glyph_from_skpath(path2) + glyfTable[glyphName] = glyph = simple_glyph_from_skpath(path2) + # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 + width, lsb = hmtxTable[glyphName] + if lsb != glyph.xMin: + hmtxTable[glyphName] = (width, glyph.xMin) f.save(dst) From 8bbc3d509b7a92866dd6df6f5390b50d707cf03d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 23 Sep 2020 17:48:11 +0100 Subject: [PATCH 03/22] factor out remove_overlaps routine, add typing annotations --- Snippets/remove-overlaps.py | 79 ++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/Snippets/remove-overlaps.py b/Snippets/remove-overlaps.py index 4686ddb20..f9264c315 100644 --- a/Snippets/remove-overlaps.py +++ b/Snippets/remove-overlaps.py @@ -5,7 +5,9 @@ import sys -from fontTools.ttLib import TTFont +from typing import Iterable, Optional, Mapping +from fontTools.ttLib import ttFont +from fontTools.ttLib.tables import _g_l_y_f from fontTools.pens.recordingPen import DecomposingRecordingPen from fontTools.pens.ttGlyphPen import TTGlyphPen @@ -17,15 +19,19 @@ except ImportError: "`pip install skia-pathops` and then retry." ) +_TTGlyphMapping = Mapping[str, ttFont._TTGlyph] -def skpath_from_simple_glyph(glyphName, glyphSet): + +def skpath_from_simple_glyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: path = pathops.Path() pathPen = path.getPen() glyphSet[glyphName].draw(pathPen) return path -def skpath_from_composite_glyph(glyphName, glyphSet): +def skpath_from_composite_glyph( + glyphName: str, glyphSet: _TTGlyphMapping +) -> pathops.Path: # record TTGlyph outlines without components dcPen = DecomposingRecordingPen(glyphSet) glyphSet[glyphName].draw(dcPen) @@ -36,7 +42,7 @@ def skpath_from_composite_glyph(glyphName, glyphSet): return path -def simple_glyph_from_skpath(path): +def simple_glyph_from_skpath(path: pathops.Path) -> _g_l_y_f.Glyph: # Skia paths have no 'components', no need for glyphSet ttPen = TTGlyphPen(glyphSet=None) path.draw(ttPen) @@ -47,39 +53,48 @@ def simple_glyph_from_skpath(path): return glyph -def main(): - if len(sys.argv) != 3: - print("usage: remove-overlaps.py fontfile.ttf outfile.ttf") +def remove_overlaps( + font: ttFont.TTFont, glyphNames: Optional[Iterable[str]] = None +) -> None: + if glyphNames is None: + glyphNames = font.getGlyphOrder() + + glyfTable = font["glyf"] + hmtxTable = font["hmtx"] + glyphSet = font.getGlyphSet() + + for glyphName in glyphNames: + if glyfTable[glyphName].isComposite(): + path = skpath_from_composite_glyph(glyphName, glyphSet) + else: + path = skpath_from_simple_glyph(glyphName, glyphSet) + + # duplicate path + path2 = pathops.Path(path) + + # remove overlaps + path2.simplify() + + # replace TTGlyph if simplified copy is different + if path2 != path: + glyfTable[glyphName] = glyph = simple_glyph_from_skpath(path2) + # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 + width, lsb = hmtxTable[glyphName] + if lsb != glyph.xMin: + hmtxTable[glyphName] = (width, glyph.xMin) + + +def main() -> None: + if len(sys.argv) < 3: + print("usage: remove-overlaps.py fontfile.ttf outfile.ttf [GLYPHNAMES ...]") sys.exit(1) src = sys.argv[1] dst = sys.argv[2] + glyphNames = sys.argv[3:] or None - with TTFont(src) as f: - glyfTable = f["glyf"] - hmtxTable = f["hmtx"] - glyphSet = f.getGlyphSet() - - for glyphName in glyphSet.keys(): - if glyfTable[glyphName].isComposite(): - path = skpath_from_composite_glyph(glyphName, glyphSet) - else: - path = skpath_from_simple_glyph(glyphName, glyphSet) - - # duplicate path - path2 = pathops.Path(path) - - # remove overlaps - path2.simplify() - - # replace TTGlyph if simplified copy is different - if path2 != path: - glyfTable[glyphName] = glyph = simple_glyph_from_skpath(path2) - # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 - width, lsb = hmtxTable[glyphName] - if lsb != glyph.xMin: - hmtxTable[glyphName] = (width, glyph.xMin) - + with ttFont.TTFont(src) as f: + remove_overlaps(f, glyphNames) f.save(dst) From 2bcc103c36fc910b902f5c205e60f3b656448726 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 23 Sep 2020 19:13:58 +0100 Subject: [PATCH 04/22] move it to ttLib.removeOverlaps module --- .../fontTools/ttLib/removeOverlaps.py | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) rename Snippets/remove-overlaps.py => Lib/fontTools/ttLib/removeOverlaps.py (54%) diff --git a/Snippets/remove-overlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py similarity index 54% rename from Snippets/remove-overlaps.py rename to Lib/fontTools/ttLib/removeOverlaps.py index f9264c315..508869a5a 100644 --- a/Snippets/remove-overlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -1,37 +1,29 @@ -#! /usr/bin/env python3 +""" Simplify TrueType glyphs by merging overlapping contours/components. -# Example script to remove overlaps in TTF using skia-pathops. -# Overlapping components will be decomposed. +Requires https://github.com/fonttools/skia-pathops +""" - -import sys from typing import Iterable, Optional, Mapping + from fontTools.ttLib import ttFont from fontTools.ttLib.tables import _g_l_y_f from fontTools.pens.recordingPen import DecomposingRecordingPen from fontTools.pens.ttGlyphPen import TTGlyphPen -try: - import pathops -except ImportError: - sys.exit( - "This script requires the skia-pathops module. " - "`pip install skia-pathops` and then retry." - ) +import pathops + _TTGlyphMapping = Mapping[str, ttFont._TTGlyph] -def skpath_from_simple_glyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: +def skPathFromSimpleGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: path = pathops.Path() pathPen = path.getPen() glyphSet[glyphName].draw(pathPen) return path -def skpath_from_composite_glyph( - glyphName: str, glyphSet: _TTGlyphMapping -) -> pathops.Path: +def skPathFromCompositeGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: # record TTGlyph outlines without components dcPen = DecomposingRecordingPen(glyphSet) glyphSet[glyphName].draw(dcPen) @@ -42,7 +34,7 @@ def skpath_from_composite_glyph( return path -def simple_glyph_from_skpath(path: pathops.Path) -> _g_l_y_f.Glyph: +def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: # Skia paths have no 'components', no need for glyphSet ttPen = TTGlyphPen(glyphSet=None) path.draw(ttPen) @@ -53,21 +45,37 @@ def simple_glyph_from_skpath(path: pathops.Path) -> _g_l_y_f.Glyph: return glyph -def remove_overlaps( +def removeOverlaps( font: ttFont.TTFont, glyphNames: Optional[Iterable[str]] = None ) -> None: - if glyphNames is None: - glyphNames = font.getGlyphOrder() + """ Simplify glyphs in TTFont by merging overlapping contours. + + Overlapping components are first decomposed to simple contours, then merged. + + Currently this only works with TrueType fonts with 'glyf' table. + Raises NotImplementedError if 'glyf' table is absent. + + Args: + font: input TTFont object, modified in place. + glyphNames: optional iterable of glyph names (str) to remove overlaps from. + By default, all glyphs in the font are processed. + """ + try: + glyfTable = font["glyf"] + except KeyError: + raise NotImplementedError("removeOverlaps currently only works with TTFs") - glyfTable = font["glyf"] hmtxTable = font["hmtx"] glyphSet = font.getGlyphSet() + if glyphNames is None: + glyphNames = font.getGlyphOrder() + for glyphName in glyphNames: if glyfTable[glyphName].isComposite(): - path = skpath_from_composite_glyph(glyphName, glyphSet) + path = skPathFromCompositeGlyph(glyphName, glyphSet) else: - path = skpath_from_simple_glyph(glyphName, glyphSet) + path = skPathFromSimpleGlyph(glyphName, glyphSet) # duplicate path path2 = pathops.Path(path) @@ -77,24 +85,31 @@ def remove_overlaps( # replace TTGlyph if simplified copy is different if path2 != path: - glyfTable[glyphName] = glyph = simple_glyph_from_skpath(path2) + glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 width, lsb = hmtxTable[glyphName] if lsb != glyph.xMin: hmtxTable[glyphName] = (width, glyph.xMin) -def main() -> None: - if len(sys.argv) < 3: - print("usage: remove-overlaps.py fontfile.ttf outfile.ttf [GLYPHNAMES ...]") +def main(args=None): + import sys + + if args is None: + args = sys.argv[1:] + + if len(args) < 2: + print( + f"usage: fonttools ttLib.removeOverlaps INPUT.ttf OUTPUT.ttf [GLYPHS ...]" + ) sys.exit(1) - src = sys.argv[1] - dst = sys.argv[2] - glyphNames = sys.argv[3:] or None + src = args[0] + dst = args[1] + glyphNames = args[2:] or None with ttFont.TTFont(src) as f: - remove_overlaps(f, glyphNames) + removeOverlaps(f, glyphNames) f.save(dst) From 1c5417d1be5c6619ca885d3e05cce8021c28d34d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 23 Sep 2020 19:24:09 +0100 Subject: [PATCH 05/22] add 'pathops' to extras_require --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 3716fe991..412431abc 100755 --- a/setup.py +++ b/setup.py @@ -122,6 +122,10 @@ extras_require = { "type1": [ "xattr; sys_platform == 'darwin'", ], + # for fontTools.ttLib.removeOverlaps, to remove overlaps in TTF fonts + "pathops": [ + "skia-pathops >= 0.4.1", + ], } # use a special 'all' key as shorthand to includes all the extra dependencies extras_require["all"] = sum(extras_require.values(), []) From 0ceb14619638d76280fc8fdd01b460fd827acd47 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sun, 27 Sep 2020 17:29:25 +0100 Subject: [PATCH 06/22] process simple glyphs before composites to avoid decomposing only because a component's base glyph contains overlaps. --- Lib/fontTools/ttLib/removeOverlaps.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 508869a5a..5f0740871 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -71,6 +71,18 @@ def removeOverlaps( if glyphNames is None: glyphNames = font.getGlyphOrder() + # process all simple glyphs first, then composites with increasing component depth, + # so that we don't unnecessarily decompose components simply because their base + # glyph has overlaps + glyphNames = sorted( + glyphNames, + key=lambda name: ( + glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth + if glyfTable[name].isComposite() + else 0, + name, + ), + ) for glyphName in glyphNames: if glyfTable[glyphName].isComposite(): path = skPathFromCompositeGlyph(glyphName, glyphSet) From 015d8265d276fc6c359074de99b5b999c7ca2f37 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sun, 27 Sep 2020 17:31:30 +0100 Subject: [PATCH 07/22] use pathops.simplify() and remember if original path direction pathops.simplify() returns a copy so we don't need to make a copy ourselves. 'clockwise' option is defined in https://github.com/fonttools/skia-pathops/pull/31 --- Lib/fontTools/ttLib/removeOverlaps.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 5f0740871..28767c300 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -89,13 +89,10 @@ def removeOverlaps( else: path = skPathFromSimpleGlyph(glyphName, glyphSet) - # duplicate path - path2 = pathops.Path(path) - # remove overlaps - path2.simplify() + path2 = pathops.simplify(path, clockwise=path.clockwise) - # replace TTGlyph if simplified copy is different + # replace TTGlyph if simplified path is different if path2 != path: glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 From 7531b7171748d87eec8f9cd0d92bb0f44ce20a4f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sun, 27 Sep 2020 17:40:23 +0100 Subject: [PATCH 08/22] require skia-pathops >= 0.4.2 with the new 'clockwise' option --- requirements.txt | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0f2a1327d..608e8677f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ scipy==1.5.2; platform_python_implementation != "PyPy" munkres==1.1.2; platform_python_implementation == "PyPy" zopfli==0.1.6 fs==2.4.11 +skia-pathops==0.4.2 # this is only required to run Tests/cu2qu/{ufo,cli}_test.py ufoLib2==0.6.2 diff --git a/setup.py b/setup.py index 412431abc..016ba7d31 100755 --- a/setup.py +++ b/setup.py @@ -124,7 +124,7 @@ extras_require = { ], # for fontTools.ttLib.removeOverlaps, to remove overlaps in TTF fonts "pathops": [ - "skia-pathops >= 0.4.1", + "skia-pathops >= 0.4.2", ], } # use a special 'all' key as shorthand to includes all the extra dependencies From 85947cabb3b943a4ffd374d56ee6a3749ef20a22 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Sun, 27 Sep 2020 17:48:52 +0100 Subject: [PATCH 09/22] varLib.instancer: make 'overlap' an enum; add --remove-overlaps CLI option 'overlap' parameter in instantiateVariableFont was a bool, now it is a tri-state IntEnum, with value 2 meaning 'remove the overlaps'. The old bool False/True (0/1) values continue to work like before. Also added a new --remove-overlaps commandline option to fonttools varLib.instancer script. --- Lib/fontTools/varLib/instancer.py | 58 +++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 2d22d622f..f08bc0f01 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -87,6 +87,7 @@ from fontTools.varLib.merger import MutatorMerger from contextlib import contextmanager import collections from copy import deepcopy +from enum import IntEnum import logging from itertools import islice import os @@ -121,6 +122,12 @@ class NormalizedAxisRange(AxisRange): return self +class OverlapsMode(IntEnum): + KEEP_AND_DONT_SET_FLAGS = 0 + KEEP_AND_SET_FLAGS = 1 + REMOVE = 2 + + def instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None ): @@ -578,7 +585,7 @@ class _TupleVarStoreAdapter(object): def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): - """ Compute deltas at partial location, and update varStore in-place. + """Compute deltas at partial location, and update varStore in-place. Remove regions in which all axes were instanced, or fall outside the new axis limits. Scale the deltas of the remaining regions where only some of the axes @@ -1175,9 +1182,13 @@ def populateAxisDefaults(varfont, axisLimits): def instantiateVariableFont( - varfont, axisLimits, inplace=False, optimize=True, overlap=True + varfont, + axisLimits, + inplace=False, + optimize=True, + overlap=OverlapsMode.KEEP_AND_SET_FLAGS, ): - """ Instantiate variable font, either fully or partially. + """Instantiate variable font, either fully or partially. Depending on whether the `axisLimits` dictionary references all or some of the input varfont's axes, the output font will either be a full instance (static @@ -1198,13 +1209,20 @@ def instantiateVariableFont( remaining 'gvar' table's deltas. Possibly faster, and might work around rendering issues in some buggy environments, at the cost of a slightly larger file size. - overlap (bool): variable fonts usually contain overlapping contours, and some - font rendering engines on Apple platforms require that the `OVERLAP_SIMPLE` - and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to force rendering - using a non-zero fill rule. Thus we always set these flags on all glyphs - to maximise cross-compatibility of the generated instance. You can disable - this by setting `overalap` to False. + overlap (OverlapsMode): variable fonts usually contain overlapping contours, and + some font rendering engines on Apple platforms require that the + `OVERLAP_SIMPLE` and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to + force rendering using a non-zero fill rule. Thus we always set these flags + on all glyphs to maximise cross-compatibility of the generated instance. + You can disable this by passing OverlapsMode.KEEP_AND_DONT_SET_FLAGS. + If you want to remove the overlaps altogether and merge overlapping + contours and components, you can pass OverlapsMode.REMOVE. Note that this + requires the skia-pathops package (available to pip install). + The overlap parameter only has effect when generating full static instances. """ + # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool + overlap = OverlapsMode(int(overlap)) + sanityCheckVariableTables(varfont) axisLimits = populateAxisDefaults(varfont, axisLimits) @@ -1245,8 +1263,14 @@ def instantiateVariableFont( instantiateFvar(varfont, axisLimits) if "fvar" not in varfont: - if "glyf" in varfont and overlap: - setMacOverlapFlags(varfont["glyf"]) + if "glyf" in varfont: + if overlap == OverlapsMode.KEEP_AND_SET_FLAGS: + setMacOverlapFlags(varfont["glyf"]) + elif overlap == OverlapsMode.REMOVE: + from fontTools.ttLib.removeOverlaps import removeOverlaps + + log.info("Removing overlaps from glyf table") + removeOverlaps(varfont) varLib.set_default_weight_width_slant( varfont, @@ -1346,6 +1370,13 @@ def parseArgs(args): help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " "when generating a full instance)", ) + parser.add_argument( + "--remove-overlaps", + dest="remove_overlaps", + action="store_true", + help="Merge overlapping contours and components (only applicable " + "when generating a full instance). Requires skia-pathops", + ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." @@ -1355,6 +1386,11 @@ def parseArgs(args): ) options = parser.parse_args(args) + if options.remove_overlaps: + options.overlap = OverlapsMode.REMOVE + else: + options.overlap = OverlapsMode(int(options.overlap)) + infile = options.input if not os.path.isfile(infile): parser.error("No such file '{}'".format(infile)) From b73fa096a8970db917959afa41c8279221896f9e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 28 Sep 2020 19:34:59 +0100 Subject: [PATCH 10/22] [travis] need latest pip to install skia-pathops for manylinux2014 tag --- .travis/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis/install.sh b/.travis/install.sh index f2a0717f3..a62b23658 100755 --- a/.travis/install.sh +++ b/.travis/install.sh @@ -27,4 +27,4 @@ if [ "$TRAVIS_OS_NAME" == "osx" ]; then source .venv/bin/activate fi -python -m pip install $ci_requirements +python -m pip install --upgrade $ci_requirements From 933e3941055f01ed3597d1db3574c1336ba1ce2c Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 28 Sep 2020 19:55:12 +0100 Subject: [PATCH 11/22] tox.ini: add download=true to install latest pip inside tox virtualenv --- tox.ini | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tox.ini b/tox.ini index 5a8d9f209..b2d1033fe 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,11 @@ skip_missing_interpreters=true [testenv] setenv = cy: FONTTOOLS_WITH_CYTHON=1 +# use 'download = true' to have tox install the latest pip inside the virtualenv. +# We need this to be able to install skia-pathops on Linux, which uses a +# relatively recent 'manylinux2014' platform tag. +# https://github.com/tox-dev/tox/issues/791#issuecomment-518713438 +download = true deps = cov: coverage>=4.3 pytest From d4fd5d6eb1c8a749305930f1d8b71b6d1bdbcc67 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 10:04:25 +0100 Subject: [PATCH 12/22] requirements.txt: don't install skia-pathops on pypy we don't have pre-compiled wheels for pypy. The tests will be skipped --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 608e8677f..628f489f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,6 @@ scipy==1.5.2; platform_python_implementation != "PyPy" munkres==1.1.2; platform_python_implementation == "PyPy" zopfli==0.1.6 fs==2.4.11 -skia-pathops==0.4.2 +skia-pathops==0.4.2; platform_python_implementation != "PyPy" # this is only required to run Tests/cu2qu/{ufo,cli}_test.py ufoLib2==0.6.2 From da439c7c57878c352146d932abb18edbc7c2fa60 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 14:14:55 +0100 Subject: [PATCH 13/22] decompose composites only if components intersect; let pathops.PathPen decompose components requires https://github.com/fonttools/skia-pathops/pull/32 --- Lib/fontTools/ttLib/removeOverlaps.py | 61 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 28767c300..b8c38664e 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -3,11 +3,11 @@ Requires https://github.com/fonttools/skia-pathops """ +import itertools from typing import Iterable, Optional, Mapping from fontTools.ttLib import ttFont from fontTools.ttLib.tables import _g_l_y_f -from fontTools.pens.recordingPen import DecomposingRecordingPen from fontTools.pens.ttGlyphPen import TTGlyphPen import pathops @@ -16,22 +16,46 @@ import pathops _TTGlyphMapping = Mapping[str, ttFont._TTGlyph] -def skPathFromSimpleGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: +def skPathFromGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: path = pathops.Path() - pathPen = path.getPen() + pathPen = path.getPen(glyphSet=glyphSet) glyphSet[glyphName].draw(pathPen) return path -def skPathFromCompositeGlyph(glyphName: str, glyphSet: _TTGlyphMapping) -> pathops.Path: - # record TTGlyph outlines without components - dcPen = DecomposingRecordingPen(glyphSet) - glyphSet[glyphName].draw(dcPen) - # replay recording onto a skia-pathops Path - path = pathops.Path() - pathPen = path.getPen() - dcPen.replay(pathPen) - return path +def skPathFromGlyphComponent( + component: _g_l_y_f.GlyphComponent, glyphSet: _TTGlyphMapping +): + baseGlyphName, transformation = component.getComponentInfo() + path = skPathFromGlyph(baseGlyphName, glyphSet) + return path.transform(*transformation) + + +def componentsOverlap(glyph: _g_l_y_f.Glyph, glyphSet: _TTGlyphMapping) -> bool: + if not glyph.isComposite(): + raise ValueError("This method only works with TrueType composite glyphs") + if len(glyph.components) < 2: + return False # single component, no overlaps + + component_paths = {} + + def _get_nth_component_path(index: int) -> pathops.Path: + if index not in component_paths: + component_paths[index] = skPathFromGlyphComponent( + glyph.components[index], glyphSet + ) + return component_paths[index] + + return any( + pathops.op( + _get_nth_component_path(i), + _get_nth_component_path(j), + pathops.PathOp.INTERSECTION, + fix_winding=False, + keep_starting_points=False, + ) + for i, j in itertools.combinations(range(len(glyph.components)), 2) + ) def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: @@ -48,7 +72,7 @@ def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: def removeOverlaps( font: ttFont.TTFont, glyphNames: Optional[Iterable[str]] = None ) -> None: - """ Simplify glyphs in TTFont by merging overlapping contours. + """Simplify glyphs in TTFont by merging overlapping contours. Overlapping components are first decomposed to simple contours, then merged. @@ -66,6 +90,7 @@ def removeOverlaps( raise NotImplementedError("removeOverlaps currently only works with TTFs") hmtxTable = font["hmtx"] + # wraps the underlying glyf Glyphs, takes care of interfacing with drawing pens glyphSet = font.getGlyphSet() if glyphNames is None: @@ -84,10 +109,12 @@ def removeOverlaps( ), ) for glyphName in glyphNames: - if glyfTable[glyphName].isComposite(): - path = skPathFromCompositeGlyph(glyphName, glyphSet) - else: - path = skPathFromSimpleGlyph(glyphName, glyphSet) + glyph = glyfTable[glyphName] + # decompose composite glyphs only if components overlap each other + if glyph.isComposite() and not componentsOverlap(glyph, glyphSet): + continue + + path = skPathFromGlyph(glyphName, glyphSet) # remove overlaps path2 = pathops.simplify(path, clockwise=path.clockwise) From 5abab6b32995ec46f098a004e19372f9a146df96 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 14:32:35 +0100 Subject: [PATCH 14/22] Update skia-pathops to v0.5.0 https://github.com/fonttools/skia-pathops/releases/tag/v0.5.0 --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 628f489f2..a409e4eec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,6 @@ scipy==1.5.2; platform_python_implementation != "PyPy" munkres==1.1.2; platform_python_implementation == "PyPy" zopfli==0.1.6 fs==2.4.11 -skia-pathops==0.4.2; platform_python_implementation != "PyPy" +skia-pathops==0.5.0; platform_python_implementation != "PyPy" # this is only required to run Tests/cu2qu/{ufo,cli}_test.py ufoLib2==0.6.2 diff --git a/setup.py b/setup.py index 016ba7d31..7bce2d243 100755 --- a/setup.py +++ b/setup.py @@ -124,7 +124,7 @@ extras_require = { ], # for fontTools.ttLib.removeOverlaps, to remove overlaps in TTF fonts "pathops": [ - "skia-pathops >= 0.4.2", + "skia-pathops >= 0.5.0", ], } # use a special 'all' key as shorthand to includes all the extra dependencies From e1ad83add707a4565815743fdc4c2ba79ed47ee3 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 14:44:51 +0100 Subject: [PATCH 15/22] minor edit to comment [skip ci] --- Lib/fontTools/ttLib/removeOverlaps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index b8c38664e..2d01f69e6 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -97,8 +97,8 @@ def removeOverlaps( glyphNames = font.getGlyphOrder() # process all simple glyphs first, then composites with increasing component depth, - # so that we don't unnecessarily decompose components simply because their base - # glyph has overlaps + # so that by the time we test for component intersections the respective base glyphs + # have already been simplified glyphNames = sorted( glyphNames, key=lambda name: ( From 7b9da7602c9d0b624e2e936a839d00116fd1cfee Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 14:48:24 +0100 Subject: [PATCH 16/22] skip empty paths with no contours https://github.com/fonttools/fonttools/pull/2068#pullrequestreview-498472660 --- Lib/fontTools/ttLib/removeOverlaps.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 2d01f69e6..26999572a 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -111,7 +111,11 @@ def removeOverlaps( for glyphName in glyphNames: glyph = glyfTable[glyphName] # decompose composite glyphs only if components overlap each other - if glyph.isComposite() and not componentsOverlap(glyph, glyphSet): + if ( + glyph.numberOfContours == 0 + or glyph.isComposite() + and not componentsOverlap(glyph, glyphSet) + ): continue path = skPathFromGlyph(glyphName, glyphSet) From d9d216f8f8f6a6671ef620be795d2906a85d0096 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 19:33:26 +0100 Subject: [PATCH 17/22] minor: rename OverlapsMode enum -> OverlapMode --- Lib/fontTools/varLib/instancer.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index f08bc0f01..5651670df 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -122,7 +122,7 @@ class NormalizedAxisRange(AxisRange): return self -class OverlapsMode(IntEnum): +class OverlapMode(IntEnum): KEEP_AND_DONT_SET_FLAGS = 0 KEEP_AND_SET_FLAGS = 1 REMOVE = 2 @@ -1186,7 +1186,7 @@ def instantiateVariableFont( axisLimits, inplace=False, optimize=True, - overlap=OverlapsMode.KEEP_AND_SET_FLAGS, + overlap=OverlapMode.KEEP_AND_SET_FLAGS, ): """Instantiate variable font, either fully or partially. @@ -1209,19 +1209,19 @@ def instantiateVariableFont( remaining 'gvar' table's deltas. Possibly faster, and might work around rendering issues in some buggy environments, at the cost of a slightly larger file size. - overlap (OverlapsMode): variable fonts usually contain overlapping contours, and + overlap (OverlapMode): variable fonts usually contain overlapping contours, and some font rendering engines on Apple platforms require that the `OVERLAP_SIMPLE` and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to force rendering using a non-zero fill rule. Thus we always set these flags on all glyphs to maximise cross-compatibility of the generated instance. - You can disable this by passing OverlapsMode.KEEP_AND_DONT_SET_FLAGS. + You can disable this by passing OverlapMode.KEEP_AND_DONT_SET_FLAGS. If you want to remove the overlaps altogether and merge overlapping - contours and components, you can pass OverlapsMode.REMOVE. Note that this + contours and components, you can pass OverlapMode.REMOVE. Note that this requires the skia-pathops package (available to pip install). The overlap parameter only has effect when generating full static instances. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool - overlap = OverlapsMode(int(overlap)) + overlap = OverlapMode(int(overlap)) sanityCheckVariableTables(varfont) @@ -1264,9 +1264,9 @@ def instantiateVariableFont( if "fvar" not in varfont: if "glyf" in varfont: - if overlap == OverlapsMode.KEEP_AND_SET_FLAGS: + if overlap == OverlapMode.KEEP_AND_SET_FLAGS: setMacOverlapFlags(varfont["glyf"]) - elif overlap == OverlapsMode.REMOVE: + elif overlap == OverlapMode.REMOVE: from fontTools.ttLib.removeOverlaps import removeOverlaps log.info("Removing overlaps from glyf table") @@ -1387,9 +1387,9 @@ def parseArgs(args): options = parser.parse_args(args) if options.remove_overlaps: - options.overlap = OverlapsMode.REMOVE + options.overlap = OverlapMode.REMOVE else: - options.overlap = OverlapsMode(int(options.overlap)) + options.overlap = OverlapMode(int(options.overlap)) infile = options.input if not os.path.isfile(infile): From 1329c8f7db52b047746d13fe4810184fd62ebb34 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 29 Sep 2020 19:38:17 +0100 Subject: [PATCH 18/22] instancer_test: add tests for --remove-overlaps on generated VF instance --- .../varLib/data/PartialInstancerTest3-VF.ttx | 439 ++++++++++++++++++ ...Test3-VF-instance-400-no-overlap-flags.ttx | 305 ++++++++++++ ...ancerTest3-VF-instance-400-no-overlaps.ttx | 343 ++++++++++++++ ...ancerTest3-VF-instance-700-no-overlaps.ttx | 367 +++++++++++++++ Tests/varLib/instancer_test.py | 49 +- 5 files changed, 1495 insertions(+), 8 deletions(-) create mode 100644 Tests/varLib/data/PartialInstancerTest3-VF.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx create mode 100644 Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx diff --git a/Tests/varLib/data/PartialInstancerTest3-VF.ttx b/Tests/varLib/data/PartialInstancerTest3-VF.ttx new file mode 100644 index 000000000..01c7d050d --- /dev/null +++ b/Tests/varLib/data/PartialInstancerTest3-VF.ttx @@ -0,0 +1,439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Regular + + + Remove Overlaps Test + + + Regular + + + 1.000;NONE;RemoveOverlapsTest-Regular + + + Remove Overlaps Test Regular + + + Version 1.000 + + + RemoveOverlapsTest-Regular + + + Weight + + + Regular + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 400.0 + 400.0 + 700.0 + 256 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx new file mode 100644 index 000000000..fc6310d5d --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Remove Overlaps Test + + + Regular + + + 1.000;NONE;RemoveOverlapsTest-Regular + + + Remove Overlaps Test Regular + + + Version 1.000 + + + RemoveOverlapsTest-Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx new file mode 100644 index 000000000..3e18c9b70 --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Remove Overlaps Test + + + Regular + + + 1.000;NONE;RemoveOverlapsTest-Regular + + + Remove Overlaps Test Regular + + + Version 1.000 + + + RemoveOverlapsTest-Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx new file mode 100644 index 000000000..be0353da1 --- /dev/null +++ b/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx @@ -0,0 +1,367 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Remove Overlaps Test + + + Regular + + + 1.000;NONE;RemoveOverlapsTest-Regular + + + Remove Overlaps Test Regular + + + Version 1.000 + + + RemoveOverlapsTest-Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 3421b1165..0554933c2 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1400,6 +1400,13 @@ def varfont2(): return f +@pytest.fixture +def varfont3(): + f = ttLib.TTFont(recalcTimestamp=False) + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest3-VF.ttx")) + return f + + def _dump_ttx(ttFont): # compile to temporary bytes stream, reload and dump to XML tmp = BytesIO() @@ -1411,13 +1418,16 @@ def _dump_ttx(ttFont): return _strip_ttLibVersion(s.getvalue()) -def _get_expected_instance_ttx(wght, wdth): +def _get_expected_instance_ttx( + name, *locations, overlap=instancer.OverlapMode.KEEP_AND_SET_FLAGS +): + filename = f"{name}-VF-instance-{','.join(str(loc) for loc in locations)}" + if overlap == instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS: + filename += "-no-overlap-flags" + elif overlap == instancer.OverlapMode.REMOVE: + filename += "-no-overlaps" with open( - os.path.join( - TESTDATA, - "test_results", - "PartialInstancerTest2-VF-instance-{0},{1}.ttx".format(wght, wdth), - ), + os.path.join(TESTDATA, "test_results", f"{filename}.ttx"), "r", encoding="utf-8", ) as fp: @@ -1433,7 +1443,7 @@ class InstantiateVariableFontTest(object): partial = instancer.instantiateVariableFont(varfont2, {"wght": wght}) instance = instancer.instantiateVariableFont(partial, {"wdth": wdth}) - expected = _get_expected_instance_ttx(wght, wdth) + expected = _get_expected_instance_ttx("PartialInstancerTest2", wght, wdth) assert _dump_ttx(instance) == expected @@ -1442,7 +1452,30 @@ class InstantiateVariableFontTest(object): varfont2, {"wght": None, "wdth": None} ) - expected = _get_expected_instance_ttx(400, 100) + expected = _get_expected_instance_ttx("PartialInstancerTest2", 400, 100) + + assert _dump_ttx(instance) == expected + + @pytest.mark.parametrize( + "overlap, wght", + [ + (instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS, 400), + (instancer.OverlapMode.REMOVE, 400), + (instancer.OverlapMode.REMOVE, 700), + ], + ) + def test_overlap(self, varfont3, wght, overlap): + pytest.importorskip("pathops") + + location = {"wght": wght} + + instance = instancer.instantiateVariableFont( + varfont3, location, overlap=overlap + ) + + expected = _get_expected_instance_ttx( + "PartialInstancerTest3", wght, overlap=overlap + ) assert _dump_ttx(instance) == expected From e94098606b26249b23b7283c6e3796899c37fc75 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 30 Sep 2020 13:35:05 +0100 Subject: [PATCH 19/22] log modified glyps --- Lib/fontTools/ttLib/removeOverlaps.py | 63 ++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 26999572a..2dee83723 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -4,15 +4,22 @@ Requires https://github.com/fonttools/skia-pathops """ import itertools +import logging from typing import Iterable, Optional, Mapping from fontTools.ttLib import ttFont from fontTools.ttLib.tables import _g_l_y_f +from fontTools.ttLib.tables import _h_m_t_x from fontTools.pens.ttGlyphPen import TTGlyphPen import pathops +__all__ = ["removeOverlaps"] + + +log = logging.getLogger("fontTools.ttLib.removeOverlaps") + _TTGlyphMapping = Mapping[str, ttFont._TTGlyph] @@ -69,6 +76,38 @@ def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph: return glyph +def removeTTGlyphOverlaps( + glyphName: str, + glyphSet: _TTGlyphMapping, + glyfTable: _g_l_y_f.table__g_l_y_f, + hmtxTable: _h_m_t_x.table__h_m_t_x, +) -> bool: + glyph = glyfTable[glyphName] + # decompose composite glyphs only if components overlap each other + if ( + glyph.numberOfContours > 0 + or glyph.isComposite() + and componentsOverlap(glyph, glyphSet) + ): + path = skPathFromGlyph(glyphName, glyphSet) + + # remove overlaps + path2 = pathops.simplify(path, clockwise=path.clockwise) + + # replace TTGlyph if simplified path is different + if path2 != path: + glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) + # simplified glyph is always unhinted + assert not glyph.program + # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 + width, lsb = hmtxTable[glyphName] + if lsb != glyph.xMin: + hmtxTable[glyphName] = (width, glyph.xMin) + return True + + return False + + def removeOverlaps( font: ttFont.TTFont, glyphNames: Optional[Iterable[str]] = None ) -> None: @@ -108,28 +147,12 @@ def removeOverlaps( name, ), ) + modified = set() for glyphName in glyphNames: - glyph = glyfTable[glyphName] - # decompose composite glyphs only if components overlap each other - if ( - glyph.numberOfContours == 0 - or glyph.isComposite() - and not componentsOverlap(glyph, glyphSet) - ): - continue + if removeTTGlyphOverlaps(glyphName, glyphSet, glyfTable, hmtxTable): + modified.add(glyphName) - path = skPathFromGlyph(glyphName, glyphSet) - - # remove overlaps - path2 = pathops.simplify(path, clockwise=path.clockwise) - - # replace TTGlyph if simplified path is different - if path2 != path: - glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) - # also ensure hmtx LSB == glyph.xMin so glyph origin is at x=0 - width, lsb = hmtxTable[glyphName] - if lsb != glyph.xMin: - hmtxTable[glyphName] = (width, glyph.xMin) + log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) def main(args=None): From 66a0d91bf996eea9e800dec05a68714e3d13e0ce Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 30 Sep 2020 13:36:28 +0100 Subject: [PATCH 20/22] remove hinting from all glyphs, whether overlaps are removed or not --- Lib/fontTools/ttLib/removeOverlaps.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 2dee83723..59be1179f 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -81,6 +81,7 @@ def removeTTGlyphOverlaps( glyphSet: _TTGlyphMapping, glyfTable: _g_l_y_f.table__g_l_y_f, hmtxTable: _h_m_t_x.table__h_m_t_x, + removeHinting: bool = True, ) -> bool: glyph = glyfTable[glyphName] # decompose composite glyphs only if components overlap each other @@ -105,11 +106,15 @@ def removeTTGlyphOverlaps( hmtxTable[glyphName] = (width, glyph.xMin) return True + if removeHinting: + glyph.removeHinting() return False def removeOverlaps( - font: ttFont.TTFont, glyphNames: Optional[Iterable[str]] = None + font: ttFont.TTFont, + glyphNames: Optional[Iterable[str]] = None, + removeHinting: bool = True, ) -> None: """Simplify glyphs in TTFont by merging overlapping contours. @@ -118,10 +123,15 @@ def removeOverlaps( Currently this only works with TrueType fonts with 'glyf' table. Raises NotImplementedError if 'glyf' table is absent. + Note that removing overlaps invalidates the hinting. By default we drop hinting + from all glyphs whether or not overlaps are removed from a given one, as it would + look weird if only some glyphs are left (un)hinted. + Args: font: input TTFont object, modified in place. glyphNames: optional iterable of glyph names (str) to remove overlaps from. By default, all glyphs in the font are processed. + removeHinting (bool): set to False to keep hinting for unmodified glyphs. """ try: glyfTable = font["glyf"] @@ -149,7 +159,9 @@ def removeOverlaps( ) modified = set() for glyphName in glyphNames: - if removeTTGlyphOverlaps(glyphName, glyphSet, glyfTable, hmtxTable): + if removeTTGlyphOverlaps( + glyphName, glyphSet, glyfTable, hmtxTable, removeHinting + ): modified.add(glyphName) log.debug("Removed overlaps for %s glyphs:\n%s", len(modified), " ".join(modified)) From 7f9462dfa6050a7f56a080b47b7bb5478d162a36 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 30 Sep 2020 13:40:01 +0100 Subject: [PATCH 21/22] compare paths independently of contour order the order of contours modified by Skia is not stable and may change. So when we compare the original and the modified paths, we compare the paths as unordered sets of contours. The order of contours doesn't produce any visible difference, but we try to keep the changes to the minimum here to avoid unnecessary diffs --- Lib/fontTools/ttLib/removeOverlaps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py index 59be1179f..fb5c77ab6 100644 --- a/Lib/fontTools/ttLib/removeOverlaps.py +++ b/Lib/fontTools/ttLib/removeOverlaps.py @@ -95,8 +95,8 @@ def removeTTGlyphOverlaps( # remove overlaps path2 = pathops.simplify(path, clockwise=path.clockwise) - # replace TTGlyph if simplified path is different - if path2 != path: + # replace TTGlyph if simplified path is different (ignoring contour order) + if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}: glyfTable[glyphName] = glyph = ttfGlyphFromSkPath(path2) # simplified glyph is always unhinted assert not glyph.program From fdab35063da39bfaf4c13c6a140b5c3c1c0600fe Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 30 Sep 2020 13:55:30 +0100 Subject: [PATCH 22/22] README.rst: mention 'pathops' among extras pip install fonttools[pathops] --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index 7ee99331f..810f5fb1e 100644 --- a/README.rst +++ b/README.rst @@ -174,6 +174,16 @@ are required to unlock the extra features named "ufo", etc. *Extra:* ``type1`` +- ``Lib/fontTools/ttLib/removeOverlaps.py`` + + Simplify TrueType glyphs by merging overlapping contours and components. + + * `skia-pathops `__: Python + bindings for the Skia library's PathOps module, performing boolean + operations on paths (union, intersection, etc.). + + *Extra:* ``pathops`` + - ``Lib/fontTools/pens/cocoaPen.py`` Pen for drawing glyphs with Cocoa ``NSBezierPath``, requires: