aboutsummaryrefslogtreecommitdiff
path: root/Lib/fontTools
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/fontTools')
-rw-r--r--Lib/fontTools/__init__.py2
-rw-r--r--Lib/fontTools/colorLib/builder.py75
-rw-r--r--Lib/fontTools/colorLib/geometry.py145
-rw-r--r--Lib/fontTools/encodings/codecs.py42
-rw-r--r--Lib/fontTools/otlLib/builder.py3
-rw-r--r--Lib/fontTools/pens/basePen.py12
-rw-r--r--Lib/fontTools/pens/hashPointPen.py6
-rw-r--r--Lib/fontTools/subset/__init__.py36
-rw-r--r--Lib/fontTools/varLib/featureVars.py7
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)