diff options
Diffstat (limited to 'Lib')
-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 |
9 files changed, 284 insertions, 44 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) |