diff options
author | Haibo Huang <hhb@google.com> | 2021-01-25 19:23:39 -0800 |
---|---|---|
committer | Haibo Huang <hhb@google.com> | 2021-01-26 12:43:36 -0800 |
commit | f8d82f7a26154921102824802b7c16b9ac722f85 (patch) | |
tree | 7e436bb43355218e783d501f0bd394bb9a19bc24 | |
parent | e7b287f76cb1898590bcccd533663f6e5830b24b (diff) | |
download | fonttools-f8d82f7a26154921102824802b7c16b9ac722f85.tar.gz |
Upgrade fonttools to 4.19.0
Test: make
Change-Id: I94aa7732ede5deb42a281dd99302c2e3140af4a4
-rw-r--r-- | Lib/fontTools/__init__.py | 2 | ||||
-rw-r--r-- | Lib/fontTools/colorLib/builder.py | 75 | ||||
-rw-r--r-- | Lib/fontTools/colorLib/geometry.py | 145 | ||||
-rw-r--r-- | Lib/fontTools/encodings/codecs.py | 42 | ||||
-rw-r--r-- | Lib/fontTools/otlLib/builder.py | 3 | ||||
-rw-r--r-- | Lib/fontTools/pens/basePen.py | 12 | ||||
-rw-r--r-- | Lib/fontTools/pens/hashPointPen.py | 6 | ||||
-rw-r--r-- | Lib/fontTools/subset/__init__.py | 36 | ||||
-rw-r--r-- | Lib/fontTools/varLib/featureVars.py | 7 | ||||
-rw-r--r-- | METADATA | 10 | ||||
-rw-r--r-- | NEWS.rst | 16 | ||||
-rw-r--r-- | Tests/colorLib/builder_test.py | 167 | ||||
-rw-r--r-- | Tests/otlLib/builder_test.py | 8 | ||||
-rw-r--r-- | Tests/subset/data/CmapSubsetTest.subset.ttx | 14 | ||||
-rw-r--r-- | Tests/subset/data/CmapSubsetTest.ttx | 225 | ||||
-rw-r--r-- | Tests/subset/data/TestContextSubstFormat3.ttx | 8 | ||||
-rw-r--r-- | Tests/subset/subset_test.py | 7 | ||||
-rw-r--r-- | Tests/ttLib/tables/_n_a_m_e_test.py | 12 | ||||
-rw-r--r-- | Tests/ttLib/woff2_test.py | 2 | ||||
-rw-r--r-- | setup.cfg | 2 | ||||
-rwxr-xr-x | setup.py | 2 |
21 files changed, 740 insertions, 61 deletions
diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 09dce766..cc7d69f9 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -4,6 +4,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.18.2" +version = __version__ = "4.19.0" __all__ = ["version", "log", "configLogger"] diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 724136ab..998ab60d 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -6,6 +6,7 @@ import collections import copy import enum from functools import partial +from math import ceil, log from typing import ( Any, Dict, @@ -34,6 +35,7 @@ from fontTools.ttLib.tables.otTables import ( VariableInt, ) from .errors import ColorLibError +from .geometry import round_start_circle_stable_containment # TODO move type aliases to colorLib.types? @@ -328,9 +330,9 @@ def _split_color_glyphs_by_version( def _to_variable_value( value: _ScalarInput, - minValue: _Number, - maxValue: _Number, - cls: Type[VariableValue], + cls: Type[VariableValue] = VariableFloat, + minValue: Optional[_Number] = None, + maxValue: Optional[_Number] = None, ) -> VariableValue: if not isinstance(value, cls): try: @@ -339,9 +341,9 @@ def _to_variable_value( value = cls(value) else: value = cls._make(it) - if value.value < minValue: + if minValue is not None and value.value < minValue: raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}") - if value.value > maxValue: + if maxValue is not None and value.value > maxValue: raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}") return value @@ -526,7 +528,21 @@ class LayerV1ListBuilder: ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient) ot_paint.ColorLine = _to_color_line(colorLine) - for i, (x, y), r in [(0, c0, r0), (1, c1, r1)]: + # normalize input types (which may or may not specify a varIdx) + x0, y0 = _to_variable_value(c0[0]), _to_variable_value(c0[1]) + r0 = _to_variable_value(r0) + x1, y1 = _to_variable_value(c1[0]), _to_variable_value(c1[1]) + r1 = _to_variable_value(r1) + + # avoid abrupt change after rounding when c0 is near c1's perimeter + c = round_start_circle_stable_containment( + (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value + ) + x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) + r0 = r0._replace(value=c.radius) + + for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): + # rounding happens here as floats are converted to integers setattr(ot_paint, f"x{i}", _to_variable_int16(x)) setattr(ot_paint, f"y{i}", _to_variable_int16(y)) setattr(ot_paint, f"r{i}", _to_variable_uint16(r)) @@ -617,7 +633,10 @@ class LayerV1ListBuilder: ot_paint.Format = int(ot.Paint.Format.PaintColrLayers) self.slices.append(ot_paint) - paints = [self.buildPaint(p) for p in paints] + paints = [ + self.buildPaint(p) + for p in _build_n_ary_tree(paints, n=MAX_PAINT_COLR_LAYER_COUNT) + ] # Look for reuse, with preference to longer sequences found_reuse = True @@ -761,3 +780,45 @@ def buildColrV1( glyphs.BaseGlyphCount = len(baseGlyphs) glyphs.BaseGlyphV1Record = baseGlyphs return (layers, glyphs) + + +def _build_n_ary_tree(leaves, n): + """Build N-ary tree from sequence of leaf nodes. + + Return a list of lists where each non-leaf node is a list containing + max n nodes. + """ + if not leaves: + return [] + + assert n > 1 + + depth = ceil(log(len(leaves), n)) + + if depth <= 1: + return list(leaves) + + # Fully populate complete subtrees of root until we have enough leaves left + root = [] + unassigned = None + full_step = n ** (depth - 1) + for i in range(0, len(leaves), full_step): + subtree = leaves[i : i + full_step] + if len(subtree) < full_step: + unassigned = subtree + break + while len(subtree) > n: + subtree = [subtree[k : k + n] for k in range(0, len(subtree), n)] + root.append(subtree) + + if unassigned: + # Recurse to fill the last subtree, which is the only partially populated one + subtree = _build_n_ary_tree(unassigned, n) + if len(subtree) <= n - len(root): + # replace last subtree with its children if they can still fit + root.extend(subtree) + else: + root.append(subtree) + assert len(root) <= n + + return root diff --git a/Lib/fontTools/colorLib/geometry.py b/Lib/fontTools/colorLib/geometry.py new file mode 100644 index 00000000..ec647535 --- /dev/null +++ b/Lib/fontTools/colorLib/geometry.py @@ -0,0 +1,145 @@ +"""Helpers for manipulating 2D points and vectors in COLR table.""" + +from math import copysign, cos, hypot, pi +from fontTools.misc.fixedTools import otRound + + +def _vector_between(origin, target): + return (target[0] - origin[0], target[1] - origin[1]) + + +def _round_point(pt): + return (otRound(pt[0]), otRound(pt[1])) + + +def _unit_vector(vec): + length = hypot(*vec) + if length == 0: + return None + return (vec[0] / length, vec[1] / length) + + +# This is the same tolerance used by Skia's SkTwoPointConicalGradient.cpp to detect +# when a radial gradient's focal point lies on the end circle. +_NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625 + + +# The unit vector's X and Y components are respectively +# U = (cos(α), sin(α)) +# where α is the angle between the unit vector and the positive x axis. +_UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984 + + +def _rounding_offset(direction): + # Return 2-tuple of -/+ 1.0 or 0.0 approximately based on the direction vector. + # We divide the unit circle in 8 equal slices oriented towards the cardinal + # (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we + # map one of the possible cases: -1, 0, +1 for either X and Y coordinate. + # E.g. Return (+1.0, -1.0) if unit vector is oriented towards SE, or + # (-1.0, 0.0) if it's pointing West, etc. + uv = _unit_vector(direction) + if not uv: + return (0, 0) + + result = [] + for uv_component in uv: + if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD: + # unit vector component near 0: direction almost orthogonal to the + # direction of the current axis, thus keep coordinate unchanged + result.append(0) + else: + # nudge coord by +/- 1.0 in direction of unit vector + result.append(copysign(1.0, uv_component)) + return tuple(result) + + +class Circle: + def __init__(self, centre, radius): + self.centre = centre + self.radius = radius + + def __repr__(self): + return f"Circle(centre={self.centre}, radius={self.radius})" + + def round(self): + return Circle(_round_point(self.centre), otRound(self.radius)) + + def inside(self, outer_circle): + dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre)) + return ( + abs(outer_circle.radius - dist) <= _NEARLY_ZERO + or outer_circle.radius > dist + ) + + def concentric(self, other): + return self.centre == other.centre + + def move(self, dx, dy): + self.centre = (self.centre[0] + dx, self.centre[1] + dy) + + +def round_start_circle_stable_containment(c0, r0, c1, r1): + """Round start circle so that it stays inside/outside end circle after rounding. + + The rounding of circle coordinates to integers may cause an abrupt change + if the start circle c0 is so close to the end circle c1's perimiter that + it ends up falling outside (or inside) as a result of the rounding. + To keep the gradient unchanged, we nudge it in the right direction. + + See: + https://github.com/googlefonts/colr-gradients-spec/issues/204 + https://github.com/googlefonts/picosvg/issues/158 + """ + start, end = Circle(c0, r0), Circle(c1, r1) + + inside_before_round = start.inside(end) + + round_start = start.round() + round_end = end.round() + inside_after_round = round_start.inside(round_end) + + if inside_before_round == inside_after_round: + return round_start + elif inside_after_round: + # start was outside before rounding: we need to push start away from end + direction = _vector_between(round_end.centre, round_start.centre) + radius_delta = +1.0 + else: + # start was inside before rounding: we need to push start towards end + direction = _vector_between(round_start.centre, round_end.centre) + radius_delta = -1.0 + dx, dy = _rounding_offset(direction) + + # At most 2 iterations ought to be enough to converge. Before the loop, we + # know the start circle didn't keep containment after normal rounding; thus + # we continue adjusting by -/+ 1.0 until containment is restored. + # Normal rounding can at most move each coordinates -/+0.5; in the worst case + # both the start and end circle's centres and radii will be rounded in opposite + # directions, e.g. when they move along a 45 degree diagonal: + # c0 = (1.5, 1.5) ===> (2.0, 2.0) + # r0 = 0.5 ===> 1.0 + # c1 = (0.499, 0.499) ===> (0.0, 0.0) + # r1 = 2.499 ===> 2.0 + # In this example, the relative distance between the circles, calculated + # as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and + # -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both + # x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these + # moves cover twice that distance, which is enough to restore containment. + max_attempts = 2 + for _ in range(max_attempts): + if round_start.concentric(round_end): + # can't move c0 towards c1 (they are the same), so we change the radius + round_start.radius += radius_delta + assert round_start.radius >= 0 + else: + round_start.move(dx, dy) + if inside_before_round == round_start.inside(round_end): + break + else: # likely a bug + raise AssertionError( + f"Rounding circle {start} " + f"{'inside' if inside_before_round else 'outside'} " + f"{end} failed after {max_attempts} attempts!" + ) + + return round_start diff --git a/Lib/fontTools/encodings/codecs.py b/Lib/fontTools/encodings/codecs.py index ac2b9909..c2288a77 100644 --- a/Lib/fontTools/encodings/codecs.py +++ b/Lib/fontTools/encodings/codecs.py @@ -16,43 +16,29 @@ class ExtendCodec(codecs.Codec): self.info = codecs.CodecInfo(name=self.name, encode=self.encode, decode=self.decode) codecs.register_error(name, self.error) - def encode(self, input, errors='strict'): - assert errors == 'strict' - #return codecs.encode(input, self.base_encoding, self.name), len(input) - - # The above line could totally be all we needed, relying on the error - # handling to replace the unencodable Unicode characters with our extended - # byte sequences. - # - # However, there seems to be a design bug in Python (probably intentional): - # the error handler for encoding is supposed to return a **Unicode** character, - # that then needs to be encodable itself... Ugh. - # - # So we implement what codecs.encode() should have been doing: which is expect - # error handler to return bytes() to be added to the output. - # - # This seems to have been fixed in Python 3.3. We should try using that and - # use fallback only if that failed. - # https://docs.python.org/3.3/library/codecs.html#codecs.register_error - + def _map(self, mapper, output_type, exc_type, input, errors): + base_error_handler = codecs.lookup_error(errors) length = len(input) - out = b'' + out = output_type() while input: + # first try to use self.error as the error handler try: - part = codecs.encode(input, self.base_encoding) + part = mapper(input, self.base_encoding, errors=self.name) out += part - input = '' # All converted - except UnicodeEncodeError as e: - # Convert the correct part - out += codecs.encode(input[:e.start], self.base_encoding) - replacement, pos = self.error(e) + break # All converted + except exc_type as e: + # else convert the correct part, handle error as requested and continue + out += mapper(input[:e.start], self.base_encoding, self.name) + replacement, pos = base_error_handler(e) out += replacement input = input[pos:] return out, length + def encode(self, input, errors='strict'): + return self._map(codecs.encode, bytes, UnicodeEncodeError, input, errors) + def decode(self, input, errors='strict'): - assert errors == 'strict' - return codecs.decode(input, self.base_encoding, self.name), len(input) + return self._map(codecs.decode, str, UnicodeDecodeError, input, errors) def error(self, e): if isinstance(e, UnicodeDecodeError): diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 7e144451..029aa3fc 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -2574,7 +2574,8 @@ class ClassDefBuilder(object): return self.classes_.add(glyphs) for glyph in glyphs: - assert glyph not in self.glyphs_ + if glyph in self.glyphs_: + raise OpenTypeLibError(f"Glyph {glyph} is already present in class.", None) self.glyphs_[glyph] = glyphs def classes(self): diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 1593024f..c8c4c551 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -147,6 +147,10 @@ class LoggingPen(LogMixin, AbstractPen): pass +class MissingComponentError(KeyError): + """Indicates a component pointing to a non-existent glyph in the glyphset.""" + + class DecomposingPen(LoggingPen): """ Implements a 'addComponent' method that decomposes components @@ -155,10 +159,12 @@ class DecomposingPen(LoggingPen): You must override moveTo, lineTo, curveTo and qCurveTo. You may additionally override closePath, endPath and addComponent. + + By default a warning message is logged when a base glyph is missing; + set the class variable ``skipMissingComponents`` to False if you want + to raise a :class:`MissingComponentError` exception. """ - # By default a warning message is logged when a base glyph is missing; - # set this to False if you want to raise a 'KeyError' exception skipMissingComponents = True def __init__(self, glyphSet): @@ -176,7 +182,7 @@ class DecomposingPen(LoggingPen): glyph = self.glyphSet[glyphName] except KeyError: if not self.skipMissingComponents: - raise + raise MissingComponentError(glyphName) self.log.warning( "glyph '%s' is missing from glyphSet; skipped" % glyphName) else: diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py index f3276f70..9aef5d87 100644 --- a/Lib/fontTools/pens/hashPointPen.py +++ b/Lib/fontTools/pens/hashPointPen.py @@ -1,6 +1,7 @@ # Modified from https://github.com/adobe-type-tools/psautohint/blob/08b346865710ed3c172f1eb581d6ef243b203f99/python/psautohint/ufoFont.py#L800-L838 import hashlib +from fontTools.pens.basePen import MissingComponentError from fontTools.pens.pointPen import AbstractPointPen @@ -69,5 +70,8 @@ class HashPointPen(AbstractPointPen): ): tr = "".join([f"{t:+}" for t in transformation]) self.data.append("[") - self.glyphset[baseGlyphName].drawPoints(self) + try: + self.glyphset[baseGlyphName].drawPoints(self) + except KeyError: + raise MissingComponentError(baseGlyphName) self.data.append(f"({tr})]") diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index d78aa8a8..82605d51 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -2207,7 +2207,17 @@ def prune_pre_subset(self, font, options): @_add_method(ttLib.getTableClass('cmap')) def subset_glyphs(self, s): s.glyphs = None # We use s.glyphs_requested and s.unicodes_requested only + + tables_format12_bmp = [] + table_plat0_enc3 = {} # Unicode platform, Unicode BMP only, keyed by language + table_plat3_enc1 = {} # Windows platform, Unicode BMP, keyed by language + for t in self.tables: + if t.platformID == 0 and t.platEncID == 3: + table_plat0_enc3[t.language] = t + if t.platformID == 3 and t.platEncID == 1: + table_plat3_enc1[t.language] = t + if t.format == 14: # TODO(behdad) We drop all the default-UVS mappings # for glyphs_requested. So it's the caller's responsibility to make @@ -2219,16 +2229,38 @@ def subset_glyphs(self, s): elif t.isUnicode(): t.cmap = {u:g for u,g in t.cmap.items() if g in s.glyphs_requested or u in s.unicodes_requested} + # Collect format 12 tables that hold only basic multilingual plane + # codepoints. + if t.format == 12 and t.cmap and max(t.cmap.keys()) < 0x10000: + tables_format12_bmp.append(t) else: t.cmap = {u:g for u,g in t.cmap.items() if g in s.glyphs_requested} + + # Fomat 12 tables are redundant if they contain just the same BMP codepoints + # their little BMP-only encoding siblings contain. + for t in tables_format12_bmp: + if ( + t.platformID == 0 # Unicode platform + and t.platEncID == 4 # Unicode full repertoire + and t.language in table_plat0_enc3 # Have a BMP-only sibling? + and table_plat0_enc3[t.language].cmap == t.cmap + ): + t.cmap.clear() + elif ( + t.platformID == 3 # Windows platform + and t.platEncID == 10 # Unicode full repertoire + and t.language in table_plat3_enc1 # Have a BMP-only sibling? + and table_plat3_enc1[t.language].cmap == t.cmap + ): + t.cmap.clear() + self.tables = [t for t in self.tables if (t.cmap if t.format != 14 else t.uvsDict)] self.numSubTables = len(self.tables) # TODO(behdad) Convert formats when needed. # In particular, if we have a format=12 without non-BMP - # characters, either drop format=12 one or convert it - # to format=4 if there's not one. + # characters, convert it to format=4 if there's not one. return True # Required table @_add_method(ttLib.getTableClass('DSIG')) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 76e8cc4a..45f3d839 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -10,7 +10,7 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from collections import OrderedDict -from .errors import VarLibValidationError +from .errors import VarLibError, VarLibValidationError def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'): @@ -298,6 +298,11 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): varFeatureIndex = gsub.FeatureList.FeatureRecord.index(varFeature) for scriptRecord in gsub.ScriptList.ScriptRecord: + if scriptRecord.Script.DefaultLangSys is None: + raise VarLibError( + "Feature variations require that the script " + f"'{scriptRecord.ScriptTag}' defines a default language system." + ) langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: langSys.FeatureIndex.append(varFeatureIndex) @@ -10,16 +10,16 @@ third_party { } url { type: ARCHIVE - value: "https://github.com/fonttools/fonttools/archive/4.18.2.zip" + value: "https://github.com/fonttools/fonttools/archive/4.19.0.zip" } - version: "4.18.2" + version: "4.19.0" # would be NOTICE except for: # Doc/README.md # or RESTRICTED except for the SIL OFL 1.1 license license_type: BY_EXCEPTION_ONLY last_upgrade_date { - year: 2020 - month: 12 - day: 16 + year: 2021 + month: 1 + day: 25 } } @@ -1,3 +1,19 @@ +4.19.0 (released 2021-01-25) +---------------------------- + +- [codecs] Handle ``errors`` parameter different from 'strict' for the custom + extended mac encodings (#2137, #2132). +- [featureVars] Raise better error message when a script is missing the required + default language system (#2154). +- [COLRv1] Avoid abrupt change caused by rounding ``PaintRadialGradient.c0`` when + the start circle almost touches the end circle's perimeter (#2148). +- [COLRv1] Support building unlimited lists of paints as 255-ary trees of + ``PaintColrLayers`` tables (#2153). +- [subset] Prune redundant format-12 cmap subtables when all non-BMP characters + are dropped (#2146). +- [basePen] Raise ``MissingComponentError`` instead of bare ``KeyError`` when a + referenced component is missing (#2145). + 4.18.2 (released 2020-12-16) ---------------------------- diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index d1e94df9..43ec96a4 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -1,7 +1,8 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder -from fontTools.colorLib.builder import LayerV1ListBuilder +from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle +from fontTools.colorLib.builder import LayerV1ListBuilder, _build_n_ary_tree from fontTools.colorLib.errors import ColorLibError import pytest from typing import List @@ -673,6 +674,43 @@ def test_buildColrV1(): assert baseGlyphs.BaseGlyphV1Record[2].BaseGlyph == "g" +def test_buildColrV1_more_than_255_paints(): + num_paints = 364 + colorGlyphs = { + "a": [ + { + "format": 5, # PaintGlyph + "paint": 0, + "glyph": name, + } + for name in (f"glyph{i}" for i in range(num_paints)) + ], + } + layers, baseGlyphs = builder.buildColrV1(colorGlyphs) + paints = layers.Paint + + assert len(paints) == num_paints + 1 + + assert all(paints[i].Format == ot.Paint.Format.PaintGlyph for i in range(255)) + + assert paints[255].Format == ot.Paint.Format.PaintColrLayers + assert paints[255].FirstLayerIndex == 0 + assert paints[255].NumLayers == 255 + + assert all( + paints[i].Format == ot.Paint.Format.PaintGlyph + for i in range(256, num_paints + 1) + ) + + assert baseGlyphs.BaseGlyphCount == len(colorGlyphs) + assert baseGlyphs.BaseGlyphV1Record[0].BaseGlyph == "a" + assert ( + baseGlyphs.BaseGlyphV1Record[0].Paint.Format == ot.Paint.Format.PaintColrLayers + ) + assert baseGlyphs.BaseGlyphV1Record[0].Paint.FirstLayerIndex == 255 + assert baseGlyphs.BaseGlyphV1Record[0].Paint.NumLayers == num_paints + 1 - 255 + + def test_split_color_glyphs_by_version(): layerBuilder = LayerV1ListBuilder() colorGlyphs = { @@ -1055,3 +1093,130 @@ class BuildCOLRTest(object): assert hasattr(colr, "table") assert isinstance(colr.table, ot.COLR) assert colr.table.VarStore is None + + +class TrickyRadialGradientTest: + @staticmethod + def circle_inside_circle(c0, r0, c1, r1, rounded=False): + if rounded: + return Circle(c0, r0).round().inside(Circle(c1, r1).round()) + else: + return Circle(c0, r0).inside(Circle(c1, r1)) + + def round_start_circle(self, c0, r0, c1, r1, inside=True): + assert self.circle_inside_circle(c0, r0, c1, r1) is inside + assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside + r = round_start_circle_stable_containment(c0, r0, c1, r1) + assert ( + self.circle_inside_circle(r.centre, r.radius, c1, r1, rounded=True) + is inside + ) + return r.centre, r.radius + + def test_noto_emoji_mosquito_u1f99f(self): + # https://github.com/googlefonts/picosvg/issues/158 + c0 = (385.23508, 70.56727999999998) + r0 = 0 + c1 = (642.99108, 104.70327999999995) + r1 = 260.0072 + assert self.round_start_circle(c0, r0, c1, r1, inside=True) == ((386, 71), 0) + + @pytest.mark.parametrize( + "c0, r0, c1, r1, inside, expected", + [ + # inside before round, outside after round + ((1.4, 0), 0, (2.6, 0), 1.3, True, ((2, 0), 0)), + ((1, 0), 0.6, (2.8, 0), 2.45, True, ((2, 0), 1)), + ((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, ((5, 5), 0)), + # outside before round, inside after round + ((0, 0), 0, (2, 0), 1.5, False, ((-1, 0), 0)), + ((0, -0.5), 0, (0, -2.5), 1.5, False, ((0, 1), 0)), + # the following ones require two nudges to round correctly + ((0.5, 0), 0, (9.4, 0), 8.8, False, ((-1, 0), 0)), + ((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, ((0, 0), 0)), + # limit case when circle almost exactly overlap + ((0.5000001, 0), 0.5000001, (0.499999, 0), 0.4999999, True, ((0, 0), 0)), + # concentrical circles, r0 > r1 + ((0, 0), 1.49, (0, 0), 1, False, ((0, 0), 2)), + ], + ) + def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): + assert self.round_start_circle(c0, r0, c1, r1, inside) == expected + + +@pytest.mark.parametrize( + "lst, n, expected", + [ + ([0], 2, [0]), + ([0, 1], 2, [0, 1]), + ([0, 1, 2], 2, [[0, 1], 2]), + ([0, 1, 2], 3, [0, 1, 2]), + ([0, 1, 2, 3], 2, [[0, 1], [2, 3]]), + ([0, 1, 2, 3], 3, [[0, 1, 2], 3]), + ([0, 1, 2, 3, 4], 3, [[0, 1, 2], 3, 4]), + ([0, 1, 2, 3, 4, 5], 3, [[0, 1, 2], [3, 4, 5]]), + (list(range(7)), 3, [[0, 1, 2], [3, 4, 5], 6]), + (list(range(8)), 3, [[0, 1, 2], [3, 4, 5], [6, 7]]), + (list(range(9)), 3, [[0, 1, 2], [3, 4, 5], [6, 7, 8]]), + (list(range(10)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9]), + (list(range(11)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9, 10]), + (list(range(12)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11]]), + (list(range(13)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], 12]), + ( + list(range(14)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], 12, 13]], + ), + ( + list(range(15)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], [12, 13, 14]], + ), + ( + list(range(16)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], [12, 13, 14], 15]], + ), + ( + list(range(23)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], 21, 22], + ], + ), + ( + list(range(27)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + ), + ( + list(range(28)), + 3, + [ + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + 27, + ], + ), + (list(range(257)), 256, [list(range(256)), 256]), + (list(range(258)), 256, [list(range(256)), 256, 257]), + (list(range(512)), 256, [list(range(256)), list(range(256, 512))]), + (list(range(512 + 1)), 256, [list(range(256)), list(range(256, 512)), 512]), + ( + list(range(256 ** 2)), + 256, + [list(range(k * 256, k * 256 + 256)) for k in range(256)], + ), + ], +) +def test_build_n_ary_tree(lst, n, expected): + assert _build_n_ary_tree(lst, n) == expected diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index bdfc6450..3ea5a745 100644 --- a/Tests/otlLib/builder_test.py +++ b/Tests/otlLib/builder_test.py @@ -2,7 +2,7 @@ import io import struct from fontTools.misc.fixedTools import floatToFixed from fontTools.misc.testTools import getXML -from fontTools.otlLib import builder +from fontTools.otlLib import builder, error from fontTools import ttLib from fontTools.ttLib.tables import otTables import pytest @@ -1101,6 +1101,12 @@ class ClassDefBuilderTest(object): assert not b.canAdd({"d", "e", "f"}) assert not b.canAdd({"f"}) + def test_add_exception(self): + b = builder.ClassDefBuilder(useClass0=True) + b.add({"a", "b", "c"}) + with pytest.raises(error.OpenTypeLibError): + b.add({"a", "d"}) + buildStatTable_test_data = [ ([ diff --git a/Tests/subset/data/CmapSubsetTest.subset.ttx b/Tests/subset/data/CmapSubsetTest.subset.ttx new file mode 100644 index 00000000..10b94a34 --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.subset.ttx @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.18"> + + <cmap> + <tableVersion version="0"/> + <cmap_format_4 platformID="0" platEncID="3" language="0"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + </cmap_format_4> + <cmap_format_4 platformID="3" platEncID="1" language="0"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + </cmap_format_4> + </cmap> + +</ttFont> diff --git a/Tests/subset/data/CmapSubsetTest.ttx b/Tests/subset/data/CmapSubsetTest.ttx new file mode 100644 index 00000000..ffbfae7f --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.ttx @@ -0,0 +1,225 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.18"> + + <GlyphOrder> + <!-- The 'id' attribute is only for humans; it is ignored when parsed. --> + <GlyphID id="0" name=".notdef"/> + <GlyphID id="1" name="a"/> + <GlyphID id="2" name="basket"/> + </GlyphOrder> + + <head> + <!-- Most of this table will be recalculated by the compiler --> + <tableVersion value="1.0"/> + <fontRevision value="0.0"/> + <checkSumAdjustment value="0xc643119c"/> + <magicNumber value="0x5f0f3cf5"/> + <flags value="00000000 00000011"/> + <unitsPerEm value="1000"/> + <created value="Tue Jan 12 16:39:39 2021"/> + <modified value="Tue Jan 12 16:39:39 2021"/> + <xMin value="50"/> + <yMin value="-200"/> + <xMax value="450"/> + <yMax value="800"/> + <macStyle value="00000000 00000000"/> + <lowestRecPPEM value="6"/> + <fontDirectionHint value="2"/> + <indexToLocFormat value="0"/> + <glyphDataFormat value="0"/> + </head> + + <hhea> + <tableVersion value="0x00010000"/> + <ascent value="1000"/> + <descent value="-200"/> + <lineGap value="0"/> + <advanceWidthMax value="942"/> + <minLeftSideBearing value="50"/> + <minRightSideBearing value="50"/> + <xMaxExtent value="450"/> + <caretSlopeRise value="1"/> + <caretSlopeRun value="0"/> + <caretOffset value="0"/> + <reserved0 value="0"/> + <reserved1 value="0"/> + <reserved2 value="0"/> + <reserved3 value="0"/> + <metricDataFormat value="0"/> + <numberOfHMetrics value="3"/> + </hhea> + + <maxp> + <!-- Most of this table will be recalculated by the compiler --> + <tableVersion value="0x10000"/> + <numGlyphs value="3"/> + <maxPoints value="8"/> + <maxContours value="2"/> + <maxCompositePoints value="0"/> + <maxCompositeContours value="0"/> + <maxZones value="1"/> + <maxTwilightPoints value="0"/> + <maxStorage value="0"/> + <maxFunctionDefs value="0"/> + <maxInstructionDefs value="0"/> + <maxStackElements value="0"/> + <maxSizeOfInstructions value="0"/> + <maxComponentElements value="0"/> + <maxComponentDepth value="0"/> + </maxp> + + <OS_2> + <!-- The fields 'usFirstCharIndex' and 'usLastCharIndex' + will be recalculated by the compiler --> + <version value="4"/> + <xAvgCharWidth value="660"/> + <usWeightClass value="400"/> + <usWidthClass value="5"/> + <fsType value="00000000 00000100"/> + <ySubscriptXSize value="650"/> + <ySubscriptYSize value="600"/> + <ySubscriptXOffset value="0"/> + <ySubscriptYOffset value="75"/> + <ySuperscriptXSize value="650"/> + <ySuperscriptYSize value="600"/> + <ySuperscriptXOffset value="0"/> + <ySuperscriptYOffset value="350"/> + <yStrikeoutSize value="50"/> + <yStrikeoutPosition value="300"/> + <sFamilyClass value="0"/> + <panose> + <bFamilyType value="0"/> + <bSerifStyle value="0"/> + <bWeight value="0"/> + <bProportion value="0"/> + <bContrast value="0"/> + <bStrokeVariation value="0"/> + <bArmStyle value="0"/> + <bLetterForm value="0"/> + <bMidline value="0"/> + <bXHeight value="0"/> + </panose> + <ulUnicodeRange1 value="00000000 00000000 00000000 00000001"/> + <ulUnicodeRange2 value="00000010 00000000 00000000 00000000"/> + <ulUnicodeRange3 value="00000000 00000000 00000000 00000000"/> + <ulUnicodeRange4 value="00000000 00000000 00000000 00000000"/> + <achVendID value="NONE"/> + <fsSelection value="00000000 01000000"/> + <usFirstCharIndex value="97"/> + <usLastCharIndex value="65535"/> + <sTypoAscender value="800"/> + <sTypoDescender value="-200"/> + <sTypoLineGap value="200"/> + <usWinAscent value="1000"/> + <usWinDescent value="200"/> + <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/> + <ulCodePageRange2 value="00000000 00000000 00000000 00000000"/> + <sxHeight value="500"/> + <sCapHeight value="700"/> + <usDefaultChar value="0"/> + <usBreakChar value="32"/> + <usMaxContext value="0"/> + </OS_2> + + <hmtx> + <mtx name=".notdef" width="500" lsb="50"/> + <mtx name="a" width="538" lsb="0"/> + <mtx name="basket" width="942" lsb="0"/> + </hmtx> + + <cmap> + <tableVersion version="0"/> + <cmap_format_4 platformID="0" platEncID="3" language="0"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + </cmap_format_4> + <cmap_format_12 platformID="0" platEncID="4" format="12" reserved="0" length="40" language="0" nGroups="2"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + <map code="0x1f9fa" name="basket"/><!-- BASKET --> + </cmap_format_12> + <cmap_format_4 platformID="3" platEncID="1" language="0"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + </cmap_format_4> + <cmap_format_12 platformID="3" platEncID="10" format="12" reserved="0" length="40" language="0" nGroups="2"> + <map code="0x61" name="a"/><!-- LATIN SMALL LETTER A --> + <map code="0x1f9fa" name="basket"/><!-- BASKET --> + </cmap_format_12> + </cmap> + + <loca> + <!-- The 'loca' table will be calculated by the compiler --> + </loca> + + <glyf> + + <!-- The xMin, yMin, xMax and yMax values + will be recalculated by the compiler. --> + + <TTGlyph name=".notdef" xMin="50" yMin="-200" xMax="450" yMax="800"> + <contour> + <pt x="50" y="-200" on="1"/> + <pt x="50" y="800" on="1"/> + <pt x="450" y="800" on="1"/> + <pt x="450" y="-200" on="1"/> + </contour> + <contour> + <pt x="100" y="-150" on="1"/> + <pt x="400" y="-150" on="1"/> + <pt x="400" y="750" on="1"/> + <pt x="100" y="750" on="1"/> + </contour> + <instructions/> + </TTGlyph> + + <TTGlyph name="a"/><!-- contains no outline data --> + + <TTGlyph name="basket"/><!-- contains no outline data --> + + </glyf> + + <name> + <namerecord nameID="1" platformID="3" platEncID="1" langID="0x409"> + New Font + </namerecord> + <namerecord nameID="2" platformID="3" platEncID="1" langID="0x409"> + Regular + </namerecord> + <namerecord nameID="3" platformID="3" platEncID="1" langID="0x409"> + 0.000;NONE;NewFont-Regular + </namerecord> + <namerecord nameID="4" platformID="3" platEncID="1" langID="0x409"> + New Font Regular + </namerecord> + <namerecord nameID="5" platformID="3" platEncID="1" langID="0x409"> + Version 0.000 + </namerecord> + <namerecord nameID="6" platformID="3" platEncID="1" langID="0x409"> + NewFont-Regular + </namerecord> + </name> + + <post> + <formatType value="2.0"/> + <italicAngle value="0.0"/> + <underlinePosition value="-75"/> + <underlineThickness value="50"/> + <isFixedPitch value="0"/> + <minMemType42 value="0"/> + <maxMemType42 value="0"/> + <minMemType1 value="0"/> + <maxMemType1 value="0"/> + <psNames> + <!-- This file uses unique glyph names based on the information + found in the 'post' table. Since these names might not be unique, + we have to invent artificial names in case of clashes. In order to + be able to retain the original information, we need a name to + ps name mapping for those cases where they differ. That's what + you see below. + --> + </psNames> + <extraNames> + <!-- following are the name that are not taken from the standard Mac glyph order --> + <psName name="basket"/> + </extraNames> + </post> + +</ttFont> diff --git a/Tests/subset/data/TestContextSubstFormat3.ttx b/Tests/subset/data/TestContextSubstFormat3.ttx index 899b037e..3e9bfcd2 100644 --- a/Tests/subset/data/TestContextSubstFormat3.ttx +++ b/Tests/subset/data/TestContextSubstFormat3.ttx @@ -17,7 +17,7 @@ <!-- Most of this table will be recalculated by the compiler --> <tableVersion value="1.0"/> <fontRevision value="1.0"/> - <checkSumAdjustment value="0xa69ed898"/> + <checkSumAdjustment value="0xa6bcdc24"/> <magicNumber value="0x5f0f3cf5"/> <flags value="00000000 00001111"/> <unitsPerEm value="1000"/> @@ -142,15 +142,9 @@ <cmap_format_4 platformID="0" platEncID="3" language="0"> <map code="0x2b" name="plus"/><!-- PLUS SIGN --> </cmap_format_4> - <cmap_format_12 platformID="0" platEncID="4" format="12" reserved="0" length="28" language="0" nGroups="1"> - <map code="0x2b" name="plus"/><!-- PLUS SIGN --> - </cmap_format_12> <cmap_format_4 platformID="3" platEncID="1" language="0"> <map code="0x2b" name="plus"/><!-- PLUS SIGN --> </cmap_format_4> - <cmap_format_12 platformID="3" platEncID="10" format="12" reserved="0" length="28" language="0" nGroups="1"> - <map code="0x2b" name="plus"/><!-- PLUS SIGN --> - </cmap_format_12> </cmap> <loca> diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 0d2f9fe2..d37634f1 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -753,6 +753,13 @@ class SubsetTest(unittest.TestCase): # check all glyphs are kept via GSUB closure, no changes expected self.expect_ttx(subsetfont, ttx) + def test_cmap_prune_format12(self): + _, fontpath = self.compile_font(self.getpath("CmapSubsetTest.ttx"), ".ttf") + subsetpath = self.temp_path(".ttf") + subset.main([fontpath, "--glyphs=a", "--output-file=%s" % subsetpath]) + subsetfont = TTFont(subsetpath) + self.expect_ttx(subsetfont, self.getpath("CmapSubsetTest.subset.ttx"), ["cmap"]) + @pytest.fixture def featureVarsTestFont(): diff --git a/Tests/ttLib/tables/_n_a_m_e_test.py b/Tests/ttLib/tables/_n_a_m_e_test.py index bc4aab2f..11aeebae 100644 --- a/Tests/ttLib/tables/_n_a_m_e_test.py +++ b/Tests/ttLib/tables/_n_a_m_e_test.py @@ -432,6 +432,18 @@ class NameRecordTest(unittest.TestCase): name = makeName(b'\xfe', 123, 1, 1, 0) # Mac Japanese self.assertEqual(name.toUnicode(), unichr(0x2122)) + def test_extended_mac_encodings_errors(self): + s = "汉仪彩云体简" + name = makeName(s.encode("x_mac_simp_chinese_ttx"), 123, 1, 25, 0) + # first check we round-trip with 'strict' + self.assertEqual(name.toUnicode(errors="strict"), s) + + # append an incomplete invalid sequence and check that we handle + # errors with the requested error handler + name.string += b"\xba" + self.assertEqual(name.toUnicode(errors="backslashreplace"), s + "\\xba") + self.assertEqual(name.toUnicode(errors="replace"), s + "�") + def test_extended_unknown(self): name = makeName(b'\xfe', 123, 10, 11, 12) self.assertEqual(name.getEncoding(), "ascii") diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py index 661fd448..5923b7f2 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -203,7 +203,7 @@ def normalise_font(font, padding=4): # drop DSIG but keep a copy DSIG_copy = copy.deepcopy(font['DSIG']) del font['DSIG'] - # ovverride TTFont attributes + # override TTFont attributes origFlavor = font.flavor origRecalcBBoxes = font.recalcBBoxes origRecalcTimestamp = font.recalcTimestamp @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.18.2 +current_version = 4.19.0 commit = True tag = False tag_name = {new_version} @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.18.2", + version="4.19.0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", |