diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d43c8408e..ec6abe201 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,7 @@ on: push: # Sequence of patterns matched against refs/tags tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - '*.*.*' # e.g. 1.0.0 or 20.15.10 jobs: deploy: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c3025871..89d668d05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,15 @@ name: Test on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: lint: runs-on: ubuntu-latest + # https://github.community/t/github-actions-does-not-respect-skip-ci/17325/8 + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python 3.x @@ -22,6 +24,7 @@ jobs: test: runs-on: ${{ matrix.platform }} + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" strategy: matrix: python-version: [3.6, 3.7, 3.8, 3.9] @@ -61,6 +64,7 @@ jobs: test-cython: runs-on: ubuntu-latest + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python 3.x @@ -74,6 +78,7 @@ jobs: test-pypy3: runs-on: ubuntu-latest + if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" steps: - uses: actions/checkout@v2 - name: Set up Python pypy3 diff --git a/Doc/source/ttLib/ttFont.rst b/Doc/source/ttLib/ttFont.rst index 77882cd30..a571050c8 100644 --- a/Doc/source/ttLib/ttFont.rst +++ b/Doc/source/ttLib/ttFont.rst @@ -5,4 +5,5 @@ ttFont .. automodule:: fontTools.ttLib.ttFont :inherited-members: :members: - :undoc-members: \ No newline at end of file + :undoc-members: + :private-members: diff --git a/Doc/source/varLib/index.rst b/Doc/source/varLib/index.rst index 5394d6287..7b2249674 100644 --- a/Doc/source/varLib/index.rst +++ b/Doc/source/varLib/index.rst @@ -1,6 +1,94 @@ -###### -varLib -###### +################################## +varLib: OpenType Variation Support +################################## + +The ``fontTools.varLib`` package contains a number of classes and routines +for handling, building and interpolating variable font data. These routines +rely on a common set of concepts, many of which are equivalent to concepts +in the OpenType Specification, but some of which are unique to ``varLib``. + +Terminology +----------- + +axis + "A designer-determined variable in a font face design that can be used to + derive multiple, variant designs within a family." (OpenType Specification) + An axis has a minimum value, a maximum value and a default value. + +designspace + The n-dimensional space formed by the font's axes. (OpenType Specification + calls this the "design-variation space") + +scalar + A value which is able to be varied at different points in the designspace: + for example, the horizontal advance width of the glyph "a" is a scalar. + However, see also *support scalar* below. + +default location + A point in the designspace whose coordinates are the default value of + all axes. + +location + A point in the designspace, specified as a set of coordinates on one or + more axes. In the context of ``varLib``, a location is a dictionary with + the keys being the axis tags and the values being the coordinates on the + respective axis. A ``varLib`` location dictionary may be "sparse", in the + sense that axes defined in the font may be omitted from the location's + coordinates, in which case the default value of the axis is assumed. + For example, given a font having a ``wght`` axis ranging from 200-1000 + with default 400, and a ``wdth`` axis ranging 100-300 with default 150, + the location ``{"wdth": 200}`` represents the point ``wght=400,wdth=200``. + +master + The value of a scalar at a given location. **Note that this is a + considerably more general concept than the usual type design sense of + the term "master".** + +normalized location + While the range of an axis is determined by its minimum and maximum values + as set by the designer, locations are specified internally to the font binary + in the range -1 to 1, with 0 being the default, -1 being the minimum and + 1 being the maximum. A normalized location is one which is scaled to the + range (-1,1) on all of its axes. Note that as the range from minimum to + default and from default to maximum on a given axis may differ (for + example, given ``wght min=200 default=500 max=1000``, the difference + between a normalized location -1 of a normalized location of 0 represents a + difference of 300 units while the difference between a normalized location + of 0 and a normalized location of 1 represents a difference of 700 units), + a location is scaled by a different factor depending on whether it is above + or below the axis' default value. + +support + While designers tend to think in terms of masters - that is, a precise + location having a particular value - OpenType Variations specifies the + variation of scalars in terms of deltas which are themselves composed of + the combined contributions of a set of triangular regions, each having + a contribution value of 0 at its minimum value, rising linearly to its + full contribution at the *peak* and falling linearly to zero from the + peak to the maximum value. The OpenType Specification calls these "regions", + while ``varLib`` calls them "supports" (a mathematical term used in real + analysis) and expresses them as a dictionary mapping each axis tag to a + tuple ``(min, peak, max)``. + +box + ``varLib`` uses the term "box" to denote the minimum and maximum "corners" of + a support, ignoring its peak value. + +delta + The term "delta" is used in OpenType Variations in two senses. In the + more general sense, a delta is the difference between a scalar at a + given location and its value at the default location. Additionally, inside + the font, variation data is stored as a mapping between supports and deltas. + The delta (in the first sense) is computed by summing the product of the + delta of each support by a factor representing the support's contribution + at this location (see "support scalar" below). + +support scalar + When interpolating a set of variation data, the support scalar represents + the scalar multiplier of the support's contribution at this location. For + example, the support scalar will be 1 at the support's peak location, and + 0 below its minimum or above its maximum. + .. toctree:: :maxdepth: 2 diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index e0048bd39..e703895f5 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.17.2.dev0" +version = __version__ = "4.21.2.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 5e7d8c6eb..821244af0 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, @@ -24,7 +25,6 @@ from fontTools.misc.fixedTools import fixedToFloat from fontTools.ttLib.tables import C_O_L_R_ from fontTools.ttLib.tables import C_P_A_L_ from fontTools.ttLib.tables import _n_a_m_e -from fontTools.ttLib.tables.otBase import BaseTable from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables.otTables import ( ExtendMode, @@ -34,6 +34,12 @@ from fontTools.ttLib.tables.otTables import ( VariableInt, ) from .errors import ColorLibError +from .geometry import round_start_circle_stable_containment +from .table_builder import ( + convertTupleClass, + BuildCallback, + TableBuilder, +) # TODO move type aliases to colorLib.types? @@ -43,21 +49,87 @@ _PaintInput = Union[int, _Kwargs, ot.Paint, Tuple[str, "_PaintInput"]] _PaintInputList = Sequence[_PaintInput] _ColorGlyphsDict = Dict[str, Union[_PaintInputList, _PaintInput]] _ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]] -_Number = Union[int, float] -_ScalarInput = Union[_Number, VariableValue, Tuple[_Number, int]] -_ColorStopTuple = Tuple[_ScalarInput, int] -_ColorStopInput = Union[_ColorStopTuple, _Kwargs, ot.ColorStop] -_ColorStopsList = Sequence[_ColorStopInput] -_ExtendInput = Union[int, str, ExtendMode] -_CompositeInput = Union[int, str, CompositeMode] -_ColorLineInput = Union[_Kwargs, ot.ColorLine] -_PointTuple = Tuple[_ScalarInput, _ScalarInput] -_AffineTuple = Tuple[ - _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput -] -_AffineInput = Union[_AffineTuple, ot.Affine2x3] + MAX_PAINT_COLR_LAYER_COUNT = 255 +_DEFAULT_ALPHA = VariableFloat(1.0) +_MAX_REUSE_LEN = 32 + + +def _beforeBuildPaintVarRadialGradient(paint, source, srcMapFn=lambda v: v): + # normalize input types (which may or may not specify a varIdx) + x0 = convertTupleClass(VariableFloat, source["x0"]) + y0 = convertTupleClass(VariableFloat, source["y0"]) + r0 = convertTupleClass(VariableFloat, source["r0"]) + x1 = convertTupleClass(VariableFloat, source["x1"]) + y1 = convertTupleClass(VariableFloat, source["y1"]) + r1 = convertTupleClass(VariableFloat, source["r1"]) + + # TODO apparently no builder_test confirms this works (?) + + # 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) + + # update source to ensure paint is built with corrected values + source["x0"] = srcMapFn(x0) + source["y0"] = srcMapFn(y0) + source["r0"] = srcMapFn(r0) + source["x1"] = srcMapFn(x1) + source["y1"] = srcMapFn(y1) + source["r1"] = srcMapFn(r1) + + return paint, source + + +def _beforeBuildPaintRadialGradient(paint, source): + return _beforeBuildPaintVarRadialGradient(paint, source, lambda v: v.value) + + +def _defaultColorIndex(): + colorIndex = ot.ColorIndex() + colorIndex.Alpha = _DEFAULT_ALPHA.value + return colorIndex + + +def _defaultVarColorIndex(): + colorIndex = ot.VarColorIndex() + colorIndex.Alpha = _DEFAULT_ALPHA + return colorIndex + + +def _defaultColorLine(): + colorLine = ot.ColorLine() + colorLine.Extend = ExtendMode.PAD + return colorLine + + +def _defaultVarColorLine(): + colorLine = ot.VarColorLine() + colorLine.Extend = ExtendMode.PAD + return colorLine + + +def _buildPaintCallbacks(): + return { + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintRadialGradient, + ): _beforeBuildPaintRadialGradient, + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintVarRadialGradient, + ): _beforeBuildPaintVarRadialGradient, + (BuildCallback.CREATE_DEFAULT, ot.ColorIndex): _defaultColorIndex, + (BuildCallback.CREATE_DEFAULT, ot.VarColorIndex): _defaultVarColorIndex, + (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine, + (BuildCallback.CREATE_DEFAULT, ot.VarColorLine): _defaultVarColorLine, + } def populateCOLRv0( @@ -110,7 +182,6 @@ def buildCOLR( varStore: Optional[ot.VarStore] = None, ) -> C_O_L_R_.table_C_O_L_R_: """Build COLR table from color layers mapping. - Args: colorGlyphs: map of base glyph name to, either list of (layer glyph name, color palette index) tuples for COLRv0; or a single Paint (dict) or @@ -122,7 +193,6 @@ def buildCOLR( glyphMap: a map from glyph names to glyph indices, as returned from TTFont.getReverseGlyphMap(), to optionally sort base records by GID. varStore: Optional ItemVarationStore for deltas associated with v1 layer. - Return: A new COLR table. """ @@ -159,7 +229,7 @@ def buildCOLR( self.version = colr.Version = version if version == 0: - self._fromOTTable(colr) + self.ColorLayers = self._decompileColorLayersV0(colr) else: colr.VarStore = varStore self.table = colr @@ -293,8 +363,6 @@ def buildCPAL( # COLR v1 tables # See draft proposal at: https://github.com/googlefonts/colr-gradients-spec -_DEFAULT_ALPHA = VariableFloat(1.0) - def _is_colrv0_layer(layer: Any) -> bool: # Consider as COLRv0 layer any sequence of length 2 (be it tuple or list) in which @@ -326,138 +394,15 @@ def _split_color_glyphs_by_version( return colorGlyphsV0, colorGlyphsV1 -def _to_variable_value( - value: _ScalarInput, - minValue: _Number, - maxValue: _Number, - cls: Type[VariableValue], -) -> VariableValue: - if not isinstance(value, cls): - try: - it = iter(value) - except TypeError: # not iterable - value = cls(value) - else: - value = cls._make(it) - if value.value < minValue: - raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}") - if value.value > maxValue: - raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}") - return value - - -_to_variable_f16dot16_float = partial( - _to_variable_value, - cls=VariableFloat, - minValue=-(2 ** 15), - maxValue=fixedToFloat(2 ** 31 - 1, 16), -) -_to_variable_f2dot14_float = partial( - _to_variable_value, - cls=VariableFloat, - minValue=-2.0, - maxValue=fixedToFloat(2 ** 15 - 1, 14), -) -_to_variable_int16 = partial( - _to_variable_value, - cls=VariableInt, - minValue=-(2 ** 15), - maxValue=2 ** 15 - 1, -) -_to_variable_uint16 = partial( - _to_variable_value, - cls=VariableInt, - minValue=0, - maxValue=2 ** 16, -) - - -def buildColorIndex( - paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA -) -> ot.ColorIndex: - self = ot.ColorIndex() - self.PaletteIndex = int(paletteIndex) - self.Alpha = _to_variable_f2dot14_float(alpha) - return self - - -def buildColorStop( - offset: _ScalarInput, - paletteIndex: int, - alpha: _ScalarInput = _DEFAULT_ALPHA, -) -> ot.ColorStop: - self = ot.ColorStop() - self.StopOffset = _to_variable_f2dot14_float(offset) - self.Color = buildColorIndex(paletteIndex, alpha) - return self - - -def _to_enum_value(v: Union[str, int, T], enumClass: Type[T]) -> T: - if isinstance(v, enumClass): - return v - elif isinstance(v, str): - try: - return getattr(enumClass, v.upper()) - except AttributeError: - raise ValueError(f"{v!r} is not a valid {enumClass.__name__}") - return enumClass(v) - - -def _to_extend_mode(v: _ExtendInput) -> ExtendMode: - return _to_enum_value(v, ExtendMode) - - -def _to_composite_mode(v: _CompositeInput) -> CompositeMode: - return _to_enum_value(v, CompositeMode) - - -def buildColorLine( - stops: _ColorStopsList, extend: _ExtendInput = ExtendMode.PAD -) -> ot.ColorLine: - self = ot.ColorLine() - self.Extend = _to_extend_mode(extend) - self.StopCount = len(stops) - self.ColorStop = [ - stop - if isinstance(stop, ot.ColorStop) - else buildColorStop(**stop) - if isinstance(stop, collections.abc.Mapping) - else buildColorStop(*stop) - for stop in stops - ] - return self - - -def _to_color_line(obj): - if isinstance(obj, ot.ColorLine): - return obj - elif isinstance(obj, collections.abc.Mapping): - return buildColorLine(**obj) - raise TypeError(obj) - - -def _as_tuple(obj) -> Tuple[Any, ...]: - # start simple, who even cares about cyclic graphs or interesting field types - def _tuple_safe(value): - if isinstance(value, enum.Enum): - return value - elif hasattr(value, "__dict__"): - return tuple((k, _tuple_safe(v)) for k, v in value.__dict__.items()) - elif isinstance(value, collections.abc.MutableSequence): - return tuple(_tuple_safe(e) for e in value) - return value - - return tuple(_tuple_safe(obj)) - - def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: # TODO feels like something itertools might have already for lbound in range(num_layers): - # TODO may want a max length to limit scope of search # Reuse of very large #s of layers is relatively unlikely # +2: we want sequences of at least 2 # otData handles single-record duplication - for ubound in range(lbound + 2, num_layers + 1): + for ubound in range( + lbound + 2, min(num_layers + 1, lbound + 2 + _MAX_REUSE_LEN) + ): yield (lbound, ubound) @@ -465,162 +410,133 @@ class LayerV1ListBuilder: slices: List[ot.Paint] layers: List[ot.Paint] reusePool: Mapping[Tuple[Any, ...], int] + tuples: Mapping[int, Tuple[Any, ...]] + keepAlive: List[ot.Paint] # we need id to remain valid def __init__(self): self.slices = [] self.layers = [] self.reusePool = {} + self.tuples = {} + self.keepAlive = [] - def buildPaintSolid( - self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintSolid) - ot_paint.Color = buildColorIndex(paletteIndex, alpha) - return ot_paint + # We need to intercept construction of PaintColrLayers + callbacks = _buildPaintCallbacks() + callbacks[ + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ) + ] = self._beforeBuildPaintColrLayers + self.tableBuilder = TableBuilder(callbacks) - def buildPaintLinearGradient( - self, - colorLine: _ColorLineInput, - p0: _PointTuple, - p1: _PointTuple, - p2: Optional[_PointTuple] = None, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintLinearGradient) - ot_paint.ColorLine = _to_color_line(colorLine) + def _paint_tuple(self, paint: ot.Paint): + # start simple, who even cares about cyclic graphs or interesting field types + def _tuple_safe(value): + if isinstance(value, enum.Enum): + return value + elif hasattr(value, "__dict__"): + return tuple( + (k, _tuple_safe(v)) for k, v in sorted(value.__dict__.items()) + ) + elif isinstance(value, collections.abc.MutableSequence): + return tuple(_tuple_safe(e) for e in value) + return value - if p2 is None: - p2 = copy.copy(p1) - for i, (x, y) in enumerate((p0, p1, p2)): - setattr(ot_paint, f"x{i}", _to_variable_int16(x)) - setattr(ot_paint, f"y{i}", _to_variable_int16(y)) + # Cache the tuples for individual Paint instead of the whole sequence + # because the seq could be a transient slice + result = self.tuples.get(id(paint), None) + if result is None: + result = _tuple_safe(paint) + self.tuples[id(paint)] = result + self.keepAlive.append(paint) + return result - return ot_paint + def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: + return tuple(self._paint_tuple(p) for p in paints) - def buildPaintRadialGradient( - self, - colorLine: _ColorLineInput, - c0: _PointTuple, - c1: _PointTuple, - r0: _ScalarInput, - r1: _ScalarInput, - ) -> ot.Paint: + # COLR layers is unusual in that it modifies shared state + # so we need a callback into an object + def _beforeBuildPaintColrLayers(self, dest, source): + paint = ot.Paint() + paint.Format = int(ot.PaintFormat.PaintColrLayers) + self.slices.append(paint) - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient) - ot_paint.ColorLine = _to_color_line(colorLine) + # Sketchy gymnastics: a sequence input will have dropped it's layers + # into NumLayers; get it back + if isinstance(source.get("NumLayers", None), collections.abc.Sequence): + layers = source["NumLayers"] + else: + layers = source["Layers"] - for i, (x, y), r in [(0, c0, r0), (1, c1, r1)]: - 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)) + # Convert maps seqs or whatever into typed objects + layers = [self.buildPaint(l) for l in layers] - return ot_paint - - def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintGlyph) - ot_paint.Glyph = glyph - ot_paint.Paint = self.buildPaint(paint) - return ot_paint - - def buildPaintColrGlyph(self, glyph: str) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintColrGlyph) - ot_paint.Glyph = glyph - return ot_paint - - def buildPaintTransform( - self, transform: _AffineInput, paint: _PaintInput - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintTransform) - if not isinstance(transform, ot.Affine2x3): - transform = buildAffine2x3(transform) - ot_paint.Transform = transform - ot_paint.Paint = self.buildPaint(paint) - return ot_paint - - def buildPaintComposite( - self, - mode: _CompositeInput, - source: _PaintInput, - backdrop: _PaintInput, - ): - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintComposite) - ot_paint.SourcePaint = self.buildPaint(source) - ot_paint.CompositeMode = _to_composite_mode(mode) - ot_paint.BackdropPaint = self.buildPaint(backdrop) - return ot_paint - - def buildColrLayers(self, paints: List[_PaintInput]) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.Paint.Format.PaintColrLayers) - self.slices.append(ot_paint) - - paints = [self.buildPaint(p) for p in paints] + # No reason to have a colr layers with just one entry + if len(layers) == 1: + return layers[0], {} # Look for reuse, with preference to longer sequences + # This may make the layer list smaller found_reuse = True while found_reuse: found_reuse = False ranges = sorted( - _reuse_ranges(len(paints)), + _reuse_ranges(len(layers)), key=lambda t: (t[1] - t[0], t[1], t[0]), reverse=True, ) for lbound, ubound in ranges: - reuse_lbound = self.reusePool.get(_as_tuple(paints[lbound:ubound]), -1) + reuse_lbound = self.reusePool.get( + self._as_tuple(layers[lbound:ubound]), -1 + ) if reuse_lbound == -1: continue new_slice = ot.Paint() - new_slice.Format = int(ot.Paint.Format.PaintColrLayers) + new_slice.Format = int(ot.PaintFormat.PaintColrLayers) new_slice.NumLayers = ubound - lbound new_slice.FirstLayerIndex = reuse_lbound - paints = paints[:lbound] + [new_slice] + paints[ubound:] + layers = layers[:lbound] + [new_slice] + layers[ubound:] found_reuse = True break - ot_paint.NumLayers = len(paints) - ot_paint.FirstLayerIndex = len(self.layers) - self.layers.extend(paints) + # The layer list is now final; if it's too big we need to tree it + is_tree = len(layers) > MAX_PAINT_COLR_LAYER_COUNT + layers = _build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT) - # Register our parts for reuse - for lbound, ubound in _reuse_ranges(len(paints)): - self.reusePool[_as_tuple(paints[lbound:ubound])] = ( - lbound + ot_paint.FirstLayerIndex - ) + # We now have a tree of sequences with Paint leaves. + # Convert the sequences into PaintColrLayers. + def listToColrLayers(layer): + if isinstance(layer, collections.abc.Sequence): + return self.buildPaint( + { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [listToColrLayers(l) for l in layer], + } + ) + return layer - return ot_paint + layers = [listToColrLayers(l) for l in layers] + + paint.NumLayers = len(layers) + paint.FirstLayerIndex = len(self.layers) + self.layers.extend(layers) + + # Register our parts for reuse provided we aren't a tree + # If we are a tree the leaves registered for reuse and that will suffice + if not is_tree: + for lbound, ubound in _reuse_ranges(len(layers)): + self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( + lbound + paint.FirstLayerIndex + ) + + # we've fully built dest; empty source prevents generalized build from kicking in + return paint, {} def buildPaint(self, paint: _PaintInput) -> ot.Paint: - if isinstance(paint, ot.Paint): - return paint - elif isinstance(paint, int): - paletteIndex = paint - return self.buildPaintSolid(paletteIndex) - elif isinstance(paint, tuple): - layerGlyph, paint = paint - return self.buildPaintGlyph(layerGlyph, paint) - elif isinstance(paint, list): - # implicit PaintColrLayers for a list of > 1 - if len(paint) == 0: - raise ValueError("An empty list is hard to paint") - elif len(paint) == 1: - return self.buildPaint(paint[0]) - else: - return self.buildColrLayers(paint) - elif isinstance(paint, collections.abc.Mapping): - kwargs = dict(paint) - fmt = kwargs.pop("format") - try: - return LayerV1ListBuilder._buildFunctions[fmt](self, **kwargs) - except KeyError: - raise NotImplementedError(fmt) - raise TypeError(f"Not sure what to do with {type(paint).__name__}: {paint!r}") + return self.tableBuilder.build(ot.Paint, paint) def build(self) -> ot.LayerV1List: layers = ot.LayerV1List() @@ -629,31 +545,6 @@ class LayerV1ListBuilder: return layers -LayerV1ListBuilder._buildFunctions = { - pf.value: getattr(LayerV1ListBuilder, "build" + pf.name) - for pf in ot.Paint.Format - if pf != ot.Paint.Format.PaintColrLayers -} - - -def buildAffine2x3(transform: _AffineTuple) -> ot.Affine2x3: - if len(transform) != 6: - raise ValueError(f"Expected 6-tuple of floats, found: {transform!r}") - self = ot.Affine2x3() - # COLRv1 Affine2x3 uses the same column-major order to serialize a 2D - # Affine Transformation as the one used by fontTools.misc.transform. - # However, for historical reasons, the labels 'xy' and 'yx' are swapped. - # Their fundamental meaning is the same though. - # COLRv1 Affine2x3 follows the names found in FreeType and Cairo. - # In all case, the second element in the 6-tuple correspond to the - # y-part of the x basis vector, and the third to the x-part of the y - # basis vector. - # See https://github.com/googlefonts/colr-gradients-spec/pull/85 - for i, attr in enumerate(("xx", "yx", "xy", "yy", "dx", "dy")): - setattr(self, attr, _to_variable_f16dot16_float(transform[i])) - return self - - def buildBaseGlyphV1Record( baseGlyph: str, layerBuilder: LayerV1ListBuilder, paint: _PaintInput ) -> ot.BaseGlyphV1List: @@ -702,3 +593,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 000000000..ec6475357 --- /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/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py new file mode 100644 index 000000000..18e2de181 --- /dev/null +++ b/Lib/fontTools/colorLib/table_builder.py @@ -0,0 +1,234 @@ +""" +colorLib.table_builder: Generic helper for filling in BaseTable derivatives from tuples and maps and such. + +""" + +import collections +import enum +from fontTools.ttLib.tables.otBase import ( + BaseTable, + FormatSwitchingBaseTable, + UInt8FormatSwitchingBaseTable, +) +from fontTools.ttLib.tables.otConverters import ( + ComputedInt, + SimpleValue, + Struct, + Short, + UInt8, + UShort, + VarInt16, + VarUInt16, + IntValue, + FloatValue, +) +from fontTools.misc.fixedTools import otRound + + +class BuildCallback(enum.Enum): + """Keyed on (BEFORE_BUILD, class[, Format if available]). + Receives (dest, source). + Should return (dest, source), which can be new objects. + """ + + BEFORE_BUILD = enum.auto() + + """Keyed on (AFTER_BUILD, class[, Format if available]). + Receives (dest). + Should return dest, which can be a new object. + """ + AFTER_BUILD = enum.auto() + + """Keyed on (CREATE_DEFAULT, class). + Receives no arguments. + Should return a new instance of class. + """ + CREATE_DEFAULT = enum.auto() + + +def _assignable(convertersByName): + return {k: v for k, v in convertersByName.items() if not isinstance(v, ComputedInt)} + + +def convertTupleClass(tupleClass, value): + if isinstance(value, tupleClass): + return value + if isinstance(value, tuple): + return tupleClass(*value) + return tupleClass(value) + + +def _isNonStrSequence(value): + return isinstance(value, collections.abc.Sequence) and not isinstance(value, str) + + +def _set_format(dest, source): + if _isNonStrSequence(source): + assert len(source) > 0, f"{type(dest)} needs at least format from {source}" + dest.Format = source[0] + source = source[1:] + elif isinstance(source, collections.abc.Mapping): + assert "Format" in source, f"{type(dest)} needs at least Format from {source}" + dest.Format = source["Format"] + else: + raise ValueError(f"Not sure how to populate {type(dest)} from {source}") + + assert isinstance( + dest.Format, collections.abc.Hashable + ), f"{type(dest)} Format is not hashable: {dest.Format}" + assert ( + dest.Format in dest.convertersByName + ), f"{dest.Format} invalid Format of {cls}" + + return source + + +class TableBuilder: + """ + Helps to populate things derived from BaseTable from maps, tuples, etc. + + A table of lifecycle callbacks may be provided to add logic beyond what is possible + based on otData info for the target class. See BuildCallbacks. + """ + + def __init__(self, callbackTable=None): + if callbackTable is None: + callbackTable = {} + self._callbackTable = callbackTable + + def _convert(self, dest, field, converter, value): + tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + + if tupleClass: + value = convertTupleClass(tupleClass, value) + + elif enumClass: + if isinstance(value, enumClass): + pass + elif isinstance(value, str): + try: + value = getattr(enumClass, value.upper()) + except AttributeError: + raise ValueError(f"{value} is not a valid {enumClass}") + else: + value = enumClass(value) + + elif isinstance(converter, IntValue): + value = otRound(value) + elif isinstance(converter, FloatValue): + value = float(value) + + elif isinstance(converter, Struct): + if converter.repeat: + if _isNonStrSequence(value): + value = [self.build(converter.tableClass, v) for v in value] + else: + value = [self.build(converter.tableClass, value)] + setattr(dest, converter.repeat, len(value)) + else: + value = self.build(converter.tableClass, value) + elif callable(converter): + value = converter(value) + + setattr(dest, field, value) + + def build(self, cls, source): + assert issubclass(cls, BaseTable) + + if isinstance(source, cls): + return source + + callbackKey = (cls,) + dest = self._callbackTable.get( + (BuildCallback.CREATE_DEFAULT,) + callbackKey, lambda: cls() + )() + assert isinstance(dest, cls) + + convByName = _assignable(cls.convertersByName) + skippedFields = set() + + # For format switchers we need to resolve converters based on format + if issubclass(cls, FormatSwitchingBaseTable): + source = _set_format(dest, source) + + convByName = _assignable(convByName[dest.Format]) + skippedFields.add("Format") + callbackKey = (cls, dest.Format) + + # Convert sequence => mapping so before thunk only has to handle one format + if _isNonStrSequence(source): + # Sequence (typically list or tuple) assumed to match fields in declaration order + assert len(source) <= len( + convByName + ), f"Sequence of {len(source)} too long for {cls}; expected <= {len(convByName)} values" + source = dict(zip(convByName.keys(), source)) + + dest, source = self._callbackTable.get( + (BuildCallback.BEFORE_BUILD,) + callbackKey, lambda d, s: (d, s) + )(dest, source) + + if isinstance(source, collections.abc.Mapping): + for field, value in source.items(): + if field in skippedFields: + continue + converter = convByName.get(field, None) + if not converter: + raise ValueError( + f"Unrecognized field {field} for {cls}; expected one of {sorted(convByName.keys())}" + ) + self._convert(dest, field, converter, value) + else: + # let's try as a 1-tuple + dest = self.build(cls, (source,)) + + dest = self._callbackTable.get( + (BuildCallback.AFTER_BUILD,) + callbackKey, lambda d: d + )(dest) + + return dest + + +class TableUnbuilder: + def __init__(self, callbackTable=None): + if callbackTable is None: + callbackTable = {} + self._callbackTable = callbackTable + + def unbuild(self, table): + assert isinstance(table, BaseTable) + + source = {} + + callbackKey = (type(table),) + if isinstance(table, FormatSwitchingBaseTable): + source["Format"] = int(table.Format) + callbackKey += (table.Format,) + + for converter in table.getConverters(): + if isinstance(converter, ComputedInt): + continue + value = getattr(table, converter.name) + + tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + if tupleClass: + source[converter.name] = tuple(value) + elif enumClass: + source[converter.name] = value.name.lower() + elif isinstance(converter, Struct): + if converter.repeat: + source[converter.name] = [self.unbuild(v) for v in value] + else: + source[converter.name] = self.unbuild(value) + elif isinstance(converter, SimpleValue): + # "simple" values (e.g. int, float, str) need no further un-building + source[converter.name] = value + else: + raise NotImplementedError( + "Don't know how unbuild {value!r} with {converter!r}" + ) + + source = self._callbackTable.get(callbackKey, lambda s: s)(source) + + return source diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py new file mode 100644 index 000000000..43582bde3 --- /dev/null +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -0,0 +1,79 @@ +from fontTools.ttLib.tables import otTables as ot +from .table_builder import TableUnbuilder + + +def unbuildColrV1(layerV1List, baseGlyphV1List): + unbuilder = LayerV1ListUnbuilder(layerV1List.Paint) + return { + rec.BaseGlyph: unbuilder.unbuildPaint(rec.Paint) + for rec in baseGlyphV1List.BaseGlyphV1Record + } + + +def _flatten(lst): + for el in lst: + if isinstance(el, list): + yield from _flatten(el) + else: + yield el + + +class LayerV1ListUnbuilder: + def __init__(self, layers): + self.layers = layers + + callbacks = { + ( + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ): self._unbuildPaintColrLayers, + } + self.tableUnbuilder = TableUnbuilder(callbacks) + + def unbuildPaint(self, paint): + assert isinstance(paint, ot.Paint) + return self.tableUnbuilder.unbuild(paint) + + def _unbuildPaintColrLayers(self, source): + assert source["Format"] == ot.PaintFormat.PaintColrLayers + + layers = list( + _flatten( + [ + self.unbuildPaint(childPaint) + for childPaint in self.layers[ + source["FirstLayerIndex"] : source["FirstLayerIndex"] + + source["NumLayers"] + ] + ] + ) + ) + + if len(layers) == 1: + return layers[0] + + return {"Format": source["Format"], "Layers": layers} + + +if __name__ == "__main__": + from pprint import pprint + import sys + from fontTools.ttLib import TTFont + + try: + fontfile = sys.argv[1] + except IndexError: + sys.exit("usage: fonttools colorLib.unbuilder FONTFILE") + + font = TTFont(fontfile) + colr = font["COLR"] + if colr.version < 1: + sys.exit(f"error: No COLR table version=1 found in {fontfile}") + + colorGlyphs = unbuildColrV1( + colr.table.LayerV1List, + colr.table.BaseGlyphV1List, + ignoreVarIdx=not colr.table.VarStore, + ) + + pprint(colorGlyphs) diff --git a/Lib/fontTools/encodings/codecs.py b/Lib/fontTools/encodings/codecs.py index ac2b99094..c2288a777 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/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 7ef9afd92..5bf5bbcfa 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -4,6 +4,7 @@ from fontTools.feaLib.location import FeatureLibLocation from fontTools.misc.encodingTools import getEncoding from collections import OrderedDict import itertools +from typing import NamedTuple SHIFT = " " * 4 @@ -28,12 +29,15 @@ __all__ = [ "Anchor", "AnchorDefinition", "AttachStatement", + "AxisValueLocationStatement", "BaseAxis", "CVParametersNameStatement", "ChainContextPosStatement", "ChainContextSubstStatement", "CharacterStatement", "CursivePosStatement", + "ElidedFallbackName", + "ElidedFallbackNameID", "Expression", "FeatureNameStatement", "FeatureReferenceStatement", @@ -62,6 +66,9 @@ __all__ = [ "SingleSubstStatement", "SizeParameters", "Statement", + "STATAxisValueStatement", + "STATDesignAxisStatement", + "STATNameStatement", "SubtableStatement", "TableBlock", "ValueRecord", @@ -188,6 +195,21 @@ class Comment(Element): return self.text +class NullGlyph(Expression): + """The NULL glyph, used in glyph deletion substitutions.""" + + def __init__(self, location=None): + Expression.__init__(self, location) + #: The name itself as a string + + def glyphSet(self): + """The glyphs in this class as a tuple of :class:`GlyphName` objects.""" + return () + + def asFea(self, indent=""): + return "NULL" + + class GlyphName(Expression): """A single glyph name, such as ``cedilla``.""" @@ -237,7 +259,7 @@ class GlyphClass(Expression): def add_range(self, start, end, glyphs): """Add a range (e.g. ``A-Z``) to the class. ``start`` and ``end`` - are either :class:`GlyphName` objects or strings representing the + are either :class:`GlyphName` objects or strings representing the start and end glyphs in the class, and ``glyphs`` is the full list of :class:`GlyphName` objects in the range.""" if self.curr < len(self.glyphs): @@ -532,7 +554,7 @@ class MarkClass(object): class MarkClassDefinition(Statement): - """A single ``markClass`` statement. The ``markClass`` should be a + """A single ``markClass`` statement. The ``markClass`` should be a :class:`MarkClass` object, the ``anchor`` an :class:`Anchor` object, and the ``glyphs`` parameter should be a `glyph-containing object`_ . @@ -834,7 +856,7 @@ class IgnorePosStatement(Statement): """An ``ignore pos`` statement, containing `one or more` contexts to ignore. ``chainContexts`` should be a list of ``(prefix, glyphs, suffix)`` tuples, - with each of ``prefix``, ``glyphs`` and ``suffix`` being + with each of ``prefix``, ``glyphs`` and ``suffix`` being `glyph-containing objects`_ .""" def __init__(self, chainContexts, location=None): @@ -1131,7 +1153,7 @@ class MarkBasePosStatement(Statement): def asFea(self, indent=""): res = "pos base {}".format(self.base.asFea()) for a, m in self.marks: - res += " {} mark @{}".format(a.asFea(), m.name) + res += "\n" + indent + SHIFT + "{} mark @{}".format(a.asFea(), m.name) res += ";" return res @@ -1150,7 +1172,7 @@ class MarkLigPosStatement(Statement): # ... add definitions to mark classes... glyph = GlyphName("lam_meem_jeem") - marks = [ + marks = [ [ (Anchor(625,1800), m1) ], # Attachments on 1st component (lam) [ (Anchor(376,-378), m2) ], # Attachments on 2nd component (meem) [ ] # No attachments on the jeem @@ -1177,10 +1199,15 @@ class MarkLigPosStatement(Statement): for l in self.marks: temp = "" if l is None or not len(l): - temp = " " + temp = "\n" + indent + SHIFT * 2 + "" else: for a, m in l: - temp += " {} mark @{}".format(a.asFea(), m.name) + temp += ( + "\n" + + indent + + SHIFT * 2 + + "{} mark @{}".format(a.asFea(), m.name) + ) ligs.append(temp) res += ("\n" + indent + SHIFT + "ligComponent").join(ligs) res += ";" @@ -1203,7 +1230,7 @@ class MarkMarkPosStatement(Statement): def asFea(self, indent=""): res = "pos mark {}".format(self.baseMarks.asFea()) for a, m in self.marks: - res += " {} mark @{}".format(a.asFea(), m.name) + res += "\n" + indent + SHIFT + "{} mark @{}".format(a.asFea(), m.name) res += ";" return res @@ -1246,8 +1273,9 @@ class MultipleSubstStatement(Statement): res += " " + " ".join(map(asFea, self.suffix)) else: res += asFea(self.glyph) + replacement = self.replacement or [NullGlyph()] res += " by " - res += " ".join(map(asFea, self.replacement)) + res += " ".join(map(asFea, replacement)) res += ";" return res @@ -1683,6 +1711,16 @@ class FeatureNameStatement(NameRecord): return '{} {}"{}";'.format(tag, plat, self.string) +class STATNameStatement(NameRecord): + """Represents a STAT table ``name`` statement.""" + + def asFea(self, indent=""): + plat = simplify_name_attributes(self.platformID, self.platEncID, self.langID) + if plat != "": + plat += " " + return 'name {}"{}";'.format(plat, self.string) + + class SizeParameters(Statement): """A ``parameters`` statement.""" @@ -1861,3 +1899,132 @@ class VheaField(Statement): fields = ("VertTypoAscender", "VertTypoDescender", "VertTypoLineGap") keywords = dict([(x.lower(), x) for x in fields]) return "{} {};".format(keywords[self.key], self.value) + + +class STATDesignAxisStatement(Statement): + """A STAT table Design Axis + + Args: + tag (str): a 4 letter axis tag + axisOrder (int): an int + names (list): a list of :class:`STATNameStatement` objects + """ + + def __init__(self, tag, axisOrder, names, location=None): + Statement.__init__(self, location) + self.tag = tag + self.axisOrder = axisOrder + self.names = names + self.location = location + + def build(self, builder): + builder.addDesignAxis(self, self.location) + + def asFea(self, indent=""): + indent += SHIFT + res = f"DesignAxis {self.tag} {self.axisOrder} {{ \n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in self.names]) + "\n" + res += "};" + return res + + +class ElidedFallbackName(Statement): + """STAT table ElidedFallbackName + + Args: + names: a list of :class:`STATNameStatement` objects + """ + + def __init__(self, names, location=None): + Statement.__init__(self, location) + self.names = names + self.location = location + + def build(self, builder): + builder.setElidedFallbackName(self.names, self.location) + + def asFea(self, indent=""): + indent += SHIFT + res = "ElidedFallbackName { \n" + res += ("\n" + indent).join([s.asFea(indent=indent) for s in self.names]) + "\n" + res += "};" + return res + + +class ElidedFallbackNameID(Statement): + """STAT table ElidedFallbackNameID + + Args: + value: an int pointing to an existing name table name ID + """ + + def __init__(self, value, location=None): + Statement.__init__(self, location) + self.value = value + self.location = location + + def build(self, builder): + builder.setElidedFallbackName(self.value, self.location) + + def asFea(self, indent=""): + return f"ElidedFallbackNameID {self.value};" + + +class STATAxisValueStatement(Statement): + """A STAT table Axis Value Record + + Args: + names (list): a list of :class:`STATNameStatement` objects + locations (list): a list of :class:`AxisValueLocationStatement` objects + flags (int): an int + """ + + def __init__(self, names, locations, flags, location=None): + Statement.__init__(self, location) + self.names = names + self.locations = locations + self.flags = flags + + def build(self, builder): + builder.addAxisValueRecord(self, self.location) + + def asFea(self, indent=""): + res = "AxisValue {\n" + for location in self.locations: + res += location.asFea() + + for nameRecord in self.names: + res += nameRecord.asFea() + res += "\n" + + if self.flags: + flags = ["OlderSiblingFontAttribute", "ElidableAxisValueName"] + flagStrings = [] + curr = 1 + for i in range(len(flags)): + if self.flags & curr != 0: + flagStrings.append(flags[i]) + curr = curr << 1 + res += f"flag {' '.join(flagStrings)};\n" + res += "};" + return res + + +class AxisValueLocationStatement(Statement): + """ + A STAT table Axis Value Location + + Args: + tag (str): a 4 letter axis tag + values (list): a list of ints and/or floats + """ + + def __init__(self, tag, values, location=None): + Statement.__init__(self, location) + self.tag = tag + self.values = values + + def asFea(self, res=""): + res += f"location {self.tag} " + res += f"{' '.join(str(i) for i in self.values)};\n" + return res diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 30046bda8..ae81c9ff9 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -98,6 +98,7 @@ class Builder(object): "hhea", "name", "vhea", + "STAT", ] ) @@ -159,6 +160,8 @@ class Builder(object): self.hhea_ = {} # for table 'vhea' self.vhea_ = {} + # for table 'STAT' + self.stat_ = {} def build(self, tables=None, debug=False): if self.parseTree is None: @@ -188,6 +191,8 @@ class Builder(object): self.build_name() if "OS/2" in tables: self.build_OS_2() + if "STAT" in tables: + self.build_STAT() for tag in ("GPOS", "GSUB"): if tag not in tables: continue @@ -510,6 +515,140 @@ class Builder(object): if version >= 5: checkattr(table, ("usLowerOpticalPointSize", "usUpperOpticalPointSize")) + def setElidedFallbackName(self, value, location): + # ElidedFallbackName is a convenience method for setting + # ElidedFallbackNameID so only one can be allowed + for token in ("ElidedFallbackName", "ElidedFallbackNameID"): + if token in self.stat_: + raise FeatureLibError( + f"{token} is already set.", + location, + ) + if isinstance(value, int): + self.stat_["ElidedFallbackNameID"] = value + elif isinstance(value, list): + self.stat_["ElidedFallbackName"] = value + else: + raise AssertionError(value) + + def addDesignAxis(self, designAxis, location): + if "DesignAxes" not in self.stat_: + self.stat_["DesignAxes"] = [] + if designAxis.tag in (r.tag for r in self.stat_["DesignAxes"]): + raise FeatureLibError( + f'DesignAxis already defined for tag "{designAxis.tag}".', + location, + ) + if designAxis.axisOrder in (r.axisOrder for r in self.stat_["DesignAxes"]): + raise FeatureLibError( + f"DesignAxis already defined for axis number {designAxis.axisOrder}.", + location, + ) + self.stat_["DesignAxes"].append(designAxis) + + def addAxisValueRecord(self, axisValueRecord, location): + if "AxisValueRecords" not in self.stat_: + self.stat_["AxisValueRecords"] = [] + # Check for duplicate AxisValueRecords + for record_ in self.stat_["AxisValueRecords"]: + if ( + {n.asFea() for n in record_.names} + == {n.asFea() for n in axisValueRecord.names} + and {n.asFea() for n in record_.locations} + == {n.asFea() for n in axisValueRecord.locations} + and record_.flags == axisValueRecord.flags + ): + raise FeatureLibError( + "An AxisValueRecord with these values is already defined.", + location, + ) + self.stat_["AxisValueRecords"].append(axisValueRecord) + + def build_STAT(self): + if not self.stat_: + return + + axes = self.stat_.get("DesignAxes") + if not axes: + raise FeatureLibError("DesignAxes not defined", None) + axisValueRecords = self.stat_.get("AxisValueRecords") + axisValues = {} + format4_locations = [] + for tag in axes: + axisValues[tag.tag] = [] + if axisValueRecords is not None: + for avr in axisValueRecords: + valuesDict = {} + if avr.flags > 0: + valuesDict["flags"] = avr.flags + if len(avr.locations) == 1: + location = avr.locations[0] + values = location.values + if len(values) == 1: # format1 + valuesDict.update({"value": values[0], "name": avr.names}) + if len(values) == 2: # format3 + valuesDict.update( + { + "value": values[0], + "linkedValue": values[1], + "name": avr.names, + } + ) + if len(values) == 3: # format2 + nominal, minVal, maxVal = values + valuesDict.update( + { + "nominalValue": nominal, + "rangeMinValue": minVal, + "rangeMaxValue": maxVal, + "name": avr.names, + } + ) + axisValues[location.tag].append(valuesDict) + else: + valuesDict.update( + { + "location": {i.tag: i.values[0] for i in avr.locations}, + "name": avr.names, + } + ) + format4_locations.append(valuesDict) + + designAxes = [ + { + "ordering": a.axisOrder, + "tag": a.tag, + "name": a.names, + "values": axisValues[a.tag], + } + for a in axes + ] + + nameTable = self.font.get("name") + if not nameTable: # this only happens for unit tests + nameTable = self.font["name"] = newTable("name") + nameTable.names = [] + + if "ElidedFallbackNameID" in self.stat_: + nameID = self.stat_["ElidedFallbackNameID"] + name = nameTable.getDebugName(nameID) + if not name: + raise FeatureLibError( + f"ElidedFallbackNameID {nameID} points " + "to a nameID that does not exist in the " + '"name" table', + None, + ) + elif "ElidedFallbackName" in self.stat_: + nameID = self.stat_["ElidedFallbackName"] + + otl.buildStatTable( + self.font, + designAxes, + locations=format4_locations, + elidedFallbackName=nameID, + ) + def build_codepages_(self, pages): pages2bits = { 1252: 0, @@ -718,8 +857,10 @@ class Builder(object): str(ix) ]._replace(feature=key) except KeyError: - warnings.warn("feaLib.Builder subclass needs upgrading to " - "stash debug information. See fonttools#2065.") + warnings.warn( + "feaLib.Builder subclass needs upgrading to " + "stash debug information. See fonttools#2065." + ) feature_key = (feature_tag, lookup_indices) feature_index = feature_indices.get(feature_key) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 7439fbf34..c248c3409 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -314,10 +314,15 @@ class Parser(object): location, ) - def parse_glyphclass_(self, accept_glyphname): + def parse_glyphclass_(self, accept_glyphname, accept_null=False): # Parses a glyph class, either named or anonymous, or (if - # ``bool(accept_glyphname)``) a glyph name. + # ``bool(accept_glyphname)``) a glyph name. If ``bool(accept_null)`` then + # also accept the special NULL glyph. if accept_glyphname and self.next_token_type_ in (Lexer.NAME, Lexer.CID): + if accept_null and self.next_token_ == "NULL": + # If you want a glyph called NULL, you should escape it. + self.advance_lexer_() + return self.ast.NullGlyph(location=self.cur_token_location_) glyph = self.expect_glyph_() self.check_glyph_name_in_glyph_set(glyph) return self.ast.GlyphName(glyph, location=self.cur_token_location_) @@ -375,7 +380,8 @@ class Parser(object): self.expect_symbol_("-") range_end = self.expect_cid_() self.check_glyph_name_in_glyph_set( - f"cid{range_start:05d}", f"cid{range_end:05d}", + f"cid{range_start:05d}", + f"cid{range_end:05d}", ) glyphs.add_cid_range( range_start, @@ -804,7 +810,7 @@ class Parser(object): if self.next_token_ == "by": keyword = self.expect_keyword_("by") while self.next_token_ != ";": - gc = self.parse_glyphclass_(accept_glyphname=True) + gc = self.parse_glyphclass_(accept_glyphname=True, accept_null=True) new.append(gc) elif self.next_token_ == "from": keyword = self.expect_keyword_("from") @@ -837,6 +843,11 @@ class Parser(object): num_lookups = len([l for l in lookups if l is not None]) + is_deletion = False + if len(new) == 1 and len(new[0].glyphSet()) == 0: + new = [] # Deletion + is_deletion = True + # GSUB lookup type 1: Single substitution. # Format A: "substitute a by a.sc;" # Format B: "substitute [one.fitted one.oldstyle] by one;" @@ -863,8 +874,10 @@ class Parser(object): not reverse and len(old) == 1 and len(old[0].glyphSet()) == 1 - and len(new) > 1 - and max([len(n.glyphSet()) for n in new]) == 1 + and ( + (len(new) > 1 and max([len(n.glyphSet()) for n in new]) == 1) + or len(new) == 0 + ) and num_lookups == 0 ): return self.ast.MultipleSubstStatement( @@ -936,7 +949,7 @@ class Parser(object): ) # If there are remaining glyphs to parse, this is an invalid GSUB statement - if len(new) != 0: + if len(new) != 0 or is_deletion: raise FeatureLibError("Invalid substitution statement", location) # GSUB lookup type 6: Chaining contextual substitution. @@ -990,6 +1003,7 @@ class Parser(object): "name": self.parse_table_name_, "BASE": self.parse_table_BASE_, "OS/2": self.parse_table_OS_2_, + "STAT": self.parse_table_STAT_, }.get(name) if handler: handler(table) @@ -1149,6 +1163,35 @@ class Parser(object): unescaped = self.unescape_string_(string, encoding) return platformID, platEncID, langID, unescaped + def parse_stat_name_(self): + platEncID = None + langID = None + if self.next_token_type_ in Lexer.NUMBERS: + platformID = self.expect_any_number_() + location = self.cur_token_location_ + if platformID not in (1, 3): + raise FeatureLibError("Expected platform id 1 or 3", location) + if self.next_token_type_ in Lexer.NUMBERS: + platEncID = self.expect_any_number_() + langID = self.expect_any_number_() + else: + platformID = 3 + location = self.cur_token_location_ + + if platformID == 1: # Macintosh + platEncID = platEncID or 0 # Roman + langID = langID or 0 # English + else: # 3, Windows + platEncID = platEncID or 1 # Unicode + langID = langID or 0x0409 # English + + string = self.expect_string_() + encoding = getEncoding(platformID, platEncID, langID) + if encoding is None: + raise FeatureLibError("Unsupported encoding", location) + unescaped = self.unescape_string_(string, encoding) + return platformID, platEncID, langID, unescaped + def parse_nameid_(self): assert self.cur_token_ == "nameid", self.cur_token_ location, nameID = self.cur_token_location_, self.expect_any_number_() @@ -1270,6 +1313,198 @@ class Parser(object): elif self.cur_token_ == ";": continue + def parse_STAT_ElidedFallbackName(self): + assert self.is_cur_keyword_("ElidedFallbackName") + self.expect_symbol_("{") + names = [] + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_() + if self.is_cur_keyword_("name"): + platformID, platEncID, langID, string = self.parse_stat_name_() + nameRecord = self.ast.STATNameStatement( + "stat", + platformID, + platEncID, + langID, + string, + location=self.cur_token_location_, + ) + names.append(nameRecord) + else: + if self.cur_token_ != ";": + raise FeatureLibError( + f"Unexpected token {self.cur_token_} " f"in ElidedFallbackName", + self.cur_token_location_, + ) + self.expect_symbol_("}") + if not names: + raise FeatureLibError('Expected "name"', self.cur_token_location_) + return names + + def parse_STAT_design_axis(self): + assert self.is_cur_keyword_("DesignAxis") + names = [] + axisTag = self.expect_tag_() + if ( + axisTag not in ("ital", "opsz", "slnt", "wdth", "wght") + and not axisTag.isupper() + ): + log.warning(f"Unregistered axis tag {axisTag} should be uppercase.") + axisOrder = self.expect_number_() + self.expect_symbol_("{") + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_() + if self.cur_token_type_ is Lexer.COMMENT: + continue + elif self.is_cur_keyword_("name"): + location = self.cur_token_location_ + platformID, platEncID, langID, string = self.parse_stat_name_() + name = self.ast.STATNameStatement( + "stat", platformID, platEncID, langID, string, location=location + ) + names.append(name) + elif self.cur_token_ == ";": + continue + else: + raise FeatureLibError( + f'Expected "name", got {self.cur_token_}', self.cur_token_location_ + ) + + self.expect_symbol_("}") + return self.ast.STATDesignAxisStatement( + axisTag, axisOrder, names, self.cur_token_location_ + ) + + def parse_STAT_axis_value_(self): + assert self.is_cur_keyword_("AxisValue") + self.expect_symbol_("{") + locations = [] + names = [] + flags = 0 + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + continue + elif self.is_cur_keyword_("name"): + location = self.cur_token_location_ + platformID, platEncID, langID, string = self.parse_stat_name_() + name = self.ast.STATNameStatement( + "stat", platformID, platEncID, langID, string, location=location + ) + names.append(name) + elif self.is_cur_keyword_("location"): + location = self.parse_STAT_location() + locations.append(location) + elif self.is_cur_keyword_("flag"): + flags = self.expect_stat_flags() + elif self.cur_token_ == ";": + continue + else: + raise FeatureLibError( + f"Unexpected token {self.cur_token_} " f"in AxisValue", + self.cur_token_location_, + ) + self.expect_symbol_("}") + if not names: + raise FeatureLibError('Expected "Axis Name"', self.cur_token_location_) + if not locations: + raise FeatureLibError('Expected "Axis location"', self.cur_token_location_) + if len(locations) > 1: + for location in locations: + if len(location.values) > 1: + raise FeatureLibError( + "Only one value is allowed in a " + "Format 4 Axis Value Record, but " + f"{len(location.values)} were found.", + self.cur_token_location_, + ) + format4_tags = [] + for location in locations: + tag = location.tag + if tag in format4_tags: + raise FeatureLibError( + f"Axis tag {tag} already " "defined.", self.cur_token_location_ + ) + format4_tags.append(tag) + + return self.ast.STATAxisValueStatement( + names, locations, flags, self.cur_token_location_ + ) + + def parse_STAT_location(self): + values = [] + tag = self.expect_tag_() + if len(tag.strip()) != 4: + raise FeatureLibError( + f"Axis tag {self.cur_token_} must be 4 " "characters", + self.cur_token_location_, + ) + + while self.next_token_ != ";": + if self.next_token_type_ is Lexer.FLOAT: + value = self.expect_float_() + values.append(value) + elif self.next_token_type_ is Lexer.NUMBER: + value = self.expect_number_() + values.append(value) + else: + raise FeatureLibError( + f'Unexpected value "{self.next_token_}". ' + "Expected integer or float.", + self.next_token_location_, + ) + if len(values) == 3: + nominal, min_val, max_val = values + if nominal < min_val or nominal > max_val: + raise FeatureLibError( + f"Default value {nominal} is outside " + f"of specified range " + f"{min_val}-{max_val}.", + self.next_token_location_, + ) + return self.ast.AxisValueLocationStatement(tag, values) + + def parse_table_STAT_(self, table): + statements = table.statements + design_axes = [] + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + statements.append( + self.ast.Comment(self.cur_token_, location=self.cur_token_location_) + ) + elif self.cur_token_type_ is Lexer.NAME: + if self.is_cur_keyword_("ElidedFallbackName"): + names = self.parse_STAT_ElidedFallbackName() + statements.append(self.ast.ElidedFallbackName(names)) + elif self.is_cur_keyword_("ElidedFallbackNameID"): + value = self.expect_number_() + statements.append(self.ast.ElidedFallbackNameID(value)) + self.expect_symbol_(";") + elif self.is_cur_keyword_("DesignAxis"): + designAxis = self.parse_STAT_design_axis() + design_axes.append(designAxis.tag) + statements.append(designAxis) + self.expect_symbol_(";") + elif self.is_cur_keyword_("AxisValue"): + axisValueRecord = self.parse_STAT_axis_value_() + for location in axisValueRecord.locations: + if location.tag not in design_axes: + # Tag must be defined in a DesignAxis before it + # can be referenced + raise FeatureLibError( + "DesignAxis not defined for " f"{location.tag}.", + self.cur_token_location_, + ) + statements.append(axisValueRecord) + self.expect_symbol_(";") + else: + raise FeatureLibError( + f"Unexpected token {self.cur_token_}", self.cur_token_location_ + ) + elif self.cur_token_ == ";": + continue + def parse_base_tag_list_(self): # Parses BASE table entries. (See `section 9.a `_) assert self.cur_token_ in ( @@ -1771,7 +2006,7 @@ class Parser(object): raise FeatureLibError("Expected a tag", self.cur_token_location_) if len(self.cur_token_) > 4: raise FeatureLibError( - "Tags can not be longer than 4 characters", self.cur_token_location_ + "Tags cannot be longer than 4 characters", self.cur_token_location_ ) return (self.cur_token_ + " ")[:4] @@ -1843,6 +2078,32 @@ class Parser(object): "Expected an integer or floating-point number", self.cur_token_location_ ) + def expect_stat_flags(self): + value = 0 + flags = { + "OlderSiblingFontAttribute": 1, + "ElidableAxisValueName": 2, + } + while self.next_token_ != ";": + if self.next_token_ in flags: + name = self.expect_name_() + value = value | flags[name] + else: + raise FeatureLibError( + f"Unexpected STAT flag {self.cur_token_}", self.cur_token_location_ + ) + return value + + def expect_stat_values_(self): + if self.next_token_type_ == Lexer.FLOAT: + return self.expect_float_() + elif self.next_token_type_ is Lexer.NUMBER: + return self.expect_number_() + else: + raise FeatureLibError( + "Expected an integer or floating-point number", self.cur_token_location_ + ) + def expect_string_(self): self.advance_lexer_() if self.cur_token_type_ is Lexer.STRING: diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index f3fe92a83..f4c943f3d 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -1,4 +1,3 @@ - __all__ = ["FontBuilder"] """ @@ -136,192 +135,192 @@ from .ttLib.tables._c_m_a_p import cmap_classes from .ttLib.tables._n_a_m_e import NameRecord, makeName from .misc.timeTools import timestampNow import struct +from collections import OrderedDict _headDefaults = dict( - tableVersion = 1.0, - fontRevision = 1.0, - checkSumAdjustment = 0, - magicNumber = 0x5F0F3CF5, - flags = 0x0003, - unitsPerEm = 1000, - created = 0, - modified = 0, - xMin = 0, - yMin = 0, - xMax = 0, - yMax = 0, - macStyle = 0, - lowestRecPPEM = 3, - fontDirectionHint = 2, - indexToLocFormat = 0, - glyphDataFormat = 0, + tableVersion=1.0, + fontRevision=1.0, + checkSumAdjustment=0, + magicNumber=0x5F0F3CF5, + flags=0x0003, + unitsPerEm=1000, + created=0, + modified=0, + xMin=0, + yMin=0, + xMax=0, + yMax=0, + macStyle=0, + lowestRecPPEM=3, + fontDirectionHint=2, + indexToLocFormat=0, + glyphDataFormat=0, ) _maxpDefaultsTTF = dict( - tableVersion = 0x00010000, - numGlyphs = 0, - maxPoints = 0, - maxContours = 0, - maxCompositePoints = 0, - maxCompositeContours = 0, - maxZones = 2, - maxTwilightPoints = 0, - maxStorage = 0, - maxFunctionDefs = 0, - maxInstructionDefs = 0, - maxStackElements = 0, - maxSizeOfInstructions = 0, - maxComponentElements = 0, - maxComponentDepth = 0, + tableVersion=0x00010000, + numGlyphs=0, + maxPoints=0, + maxContours=0, + maxCompositePoints=0, + maxCompositeContours=0, + maxZones=2, + maxTwilightPoints=0, + maxStorage=0, + maxFunctionDefs=0, + maxInstructionDefs=0, + maxStackElements=0, + maxSizeOfInstructions=0, + maxComponentElements=0, + maxComponentDepth=0, ) _maxpDefaultsOTF = dict( - tableVersion = 0x00005000, - numGlyphs = 0, + tableVersion=0x00005000, + numGlyphs=0, ) _postDefaults = dict( - formatType = 3.0, - italicAngle = 0, - underlinePosition = 0, - underlineThickness = 0, - isFixedPitch = 0, - minMemType42 = 0, - maxMemType42 = 0, - minMemType1 = 0, - maxMemType1 = 0, + formatType=3.0, + italicAngle=0, + underlinePosition=0, + underlineThickness=0, + isFixedPitch=0, + minMemType42=0, + maxMemType42=0, + minMemType1=0, + maxMemType1=0, ) _hheaDefaults = dict( - tableVersion = 0x00010000, - ascent = 0, - descent = 0, - lineGap = 0, - advanceWidthMax = 0, - minLeftSideBearing = 0, - minRightSideBearing = 0, - xMaxExtent = 0, - caretSlopeRise = 1, - caretSlopeRun = 0, - caretOffset = 0, - reserved0 = 0, - reserved1 = 0, - reserved2 = 0, - reserved3 = 0, - metricDataFormat = 0, - numberOfHMetrics = 0, + tableVersion=0x00010000, + ascent=0, + descent=0, + lineGap=0, + advanceWidthMax=0, + minLeftSideBearing=0, + minRightSideBearing=0, + xMaxExtent=0, + caretSlopeRise=1, + caretSlopeRun=0, + caretOffset=0, + reserved0=0, + reserved1=0, + reserved2=0, + reserved3=0, + metricDataFormat=0, + numberOfHMetrics=0, ) _vheaDefaults = dict( - tableVersion = 0x00010000, - ascent = 0, - descent = 0, - lineGap = 0, - advanceHeightMax = 0, - minTopSideBearing = 0, - minBottomSideBearing = 0, - yMaxExtent = 0, - caretSlopeRise = 0, - caretSlopeRun = 0, - reserved0 = 0, - reserved1 = 0, - reserved2 = 0, - reserved3 = 0, - reserved4 = 0, - metricDataFormat = 0, - numberOfVMetrics = 0, + tableVersion=0x00010000, + ascent=0, + descent=0, + lineGap=0, + advanceHeightMax=0, + minTopSideBearing=0, + minBottomSideBearing=0, + yMaxExtent=0, + caretSlopeRise=0, + caretSlopeRun=0, + reserved0=0, + reserved1=0, + reserved2=0, + reserved3=0, + reserved4=0, + metricDataFormat=0, + numberOfVMetrics=0, ) _nameIDs = dict( - copyright = 0, - familyName = 1, - styleName = 2, - uniqueFontIdentifier = 3, - fullName = 4, - version = 5, - psName = 6, - trademark = 7, - manufacturer = 8, - designer = 9, - description = 10, - vendorURL = 11, - designerURL = 12, - licenseDescription = 13, - licenseInfoURL = 14, - # reserved = 15, - typographicFamily = 16, - typographicSubfamily = 17, - compatibleFullName = 18, - sampleText = 19, - postScriptCIDFindfontName = 20, - wwsFamilyName = 21, - wwsSubfamilyName = 22, - lightBackgroundPalette = 23, - darkBackgroundPalette = 24, - variationsPostScriptNamePrefix = 25, + copyright=0, + familyName=1, + styleName=2, + uniqueFontIdentifier=3, + fullName=4, + version=5, + psName=6, + trademark=7, + manufacturer=8, + designer=9, + description=10, + vendorURL=11, + designerURL=12, + licenseDescription=13, + licenseInfoURL=14, + # reserved = 15, + typographicFamily=16, + typographicSubfamily=17, + compatibleFullName=18, + sampleText=19, + postScriptCIDFindfontName=20, + wwsFamilyName=21, + wwsSubfamilyName=22, + lightBackgroundPalette=23, + darkBackgroundPalette=24, + variationsPostScriptNamePrefix=25, ) # to insert in setupNameTable doc string: # print("\n".join(("%s (nameID %s)" % (k, v)) for k, v in sorted(_nameIDs.items(), key=lambda x: x[1]))) _panoseDefaults = dict( - bFamilyType = 0, - bSerifStyle = 0, - bWeight = 0, - bProportion = 0, - bContrast = 0, - bStrokeVariation = 0, - bArmStyle = 0, - bLetterForm = 0, - bMidline = 0, - bXHeight = 0, + bFamilyType=0, + bSerifStyle=0, + bWeight=0, + bProportion=0, + bContrast=0, + bStrokeVariation=0, + bArmStyle=0, + bLetterForm=0, + bMidline=0, + bXHeight=0, ) _OS2Defaults = dict( - version = 3, - xAvgCharWidth = 0, - usWeightClass = 400, - usWidthClass = 5, - fsType = 0x0004, # default: Preview & Print embedding - ySubscriptXSize = 0, - ySubscriptYSize = 0, - ySubscriptXOffset = 0, - ySubscriptYOffset = 0, - ySuperscriptXSize = 0, - ySuperscriptYSize = 0, - ySuperscriptXOffset = 0, - ySuperscriptYOffset = 0, - yStrikeoutSize = 0, - yStrikeoutPosition = 0, - sFamilyClass = 0, - panose = _panoseDefaults, - ulUnicodeRange1 = 0, - ulUnicodeRange2 = 0, - ulUnicodeRange3 = 0, - ulUnicodeRange4 = 0, - achVendID = "????", - fsSelection = 0, - usFirstCharIndex = 0, - usLastCharIndex = 0, - sTypoAscender = 0, - sTypoDescender = 0, - sTypoLineGap = 0, - usWinAscent = 0, - usWinDescent = 0, - ulCodePageRange1 = 0, - ulCodePageRange2 = 0, - sxHeight = 0, - sCapHeight = 0, - usDefaultChar = 0, # .notdef - usBreakChar = 32, # space - usMaxContext = 0, - usLowerOpticalPointSize = 0, - usUpperOpticalPointSize = 0, + version=3, + xAvgCharWidth=0, + usWeightClass=400, + usWidthClass=5, + fsType=0x0004, # default: Preview & Print embedding + ySubscriptXSize=0, + ySubscriptYSize=0, + ySubscriptXOffset=0, + ySubscriptYOffset=0, + ySuperscriptXSize=0, + ySuperscriptYSize=0, + ySuperscriptXOffset=0, + ySuperscriptYOffset=0, + yStrikeoutSize=0, + yStrikeoutPosition=0, + sFamilyClass=0, + panose=_panoseDefaults, + ulUnicodeRange1=0, + ulUnicodeRange2=0, + ulUnicodeRange3=0, + ulUnicodeRange4=0, + achVendID="????", + fsSelection=0, + usFirstCharIndex=0, + usLastCharIndex=0, + sTypoAscender=0, + sTypoDescender=0, + sTypoLineGap=0, + usWinAscent=0, + usWinDescent=0, + ulCodePageRange1=0, + ulCodePageRange2=0, + sxHeight=0, + sCapHeight=0, + usDefaultChar=0, # .notdef + usBreakChar=32, # space + usMaxContext=0, + usLowerOpticalPointSize=0, + usUpperOpticalPointSize=0, ) class FontBuilder(object): - def __init__(self, unitsPerEm=None, font=None, isTTF=True): """Initialize a FontBuilder instance. @@ -395,7 +394,7 @@ class FontBuilder(object): """ subTables = [] highestUnicode = max(cmapping) - if highestUnicode > 0xffff: + if highestUnicode > 0xFFFF: cmapping_3_1 = dict((k, v) for k, v in cmapping.items() if k < 0x10000) subTable_3_10 = buildCmapSubTable(cmapping, 12, 3, 10) subTables.append(subTable_3_10) @@ -408,7 +407,9 @@ class FontBuilder(object): except struct.error: # format 4 overflowed, fall back to format 12 if not allowFallback: - raise ValueError("cmap format 4 subtable overflowed; sort glyph order by unicode to fix.") + raise ValueError( + "cmap format 4 subtable overflowed; sort glyph order by unicode to fix." + ) format = 12 subTable_3_1 = buildCmapSubTable(cmapping_3_1, format, 3, 1) subTables.append(subTable_3_1) @@ -489,17 +490,33 @@ class FontBuilder(object): """ if "xAvgCharWidth" not in values: gs = self.font.getGlyphSet() - widths = [gs[glyphName].width for glyphName in gs.keys() if gs[glyphName].width > 0] + widths = [ + gs[glyphName].width + for glyphName in gs.keys() + if gs[glyphName].width > 0 + ] values["xAvgCharWidth"] = int(round(sum(widths) / float(len(widths)))) self._initTableWithValues("OS/2", _OS2Defaults, values) - if not ("ulUnicodeRange1" in values or "ulUnicodeRange2" in values or - "ulUnicodeRange3" in values or "ulUnicodeRange3" in values): - assert "cmap" in self.font, "the 'cmap' table must be setup before the 'OS/2' table" + if not ( + "ulUnicodeRange1" in values + or "ulUnicodeRange2" in values + or "ulUnicodeRange3" in values + or "ulUnicodeRange3" in values + ): + assert ( + "cmap" in self.font + ), "the 'cmap' table must be setup before the 'OS/2' table" self.font["OS/2"].recalcUnicodeRanges(self.font) def setupCFF(self, psName, fontInfo, charStringsDict, privateDict): - from .cffLib import CFFFontSet, TopDictIndex, TopDict, CharStrings, \ - GlobalSubrsIndex, PrivateDict + from .cffLib import ( + CFFFontSet, + TopDictIndex, + TopDict, + CharStrings, + GlobalSubrsIndex, + PrivateDict, + ) assert not self.isTTF self.font.sfntVersion = "OTTO" @@ -528,7 +545,9 @@ class FontBuilder(object): scale = 1 / self.font["head"].unitsPerEm topDict.FontMatrix = [scale, 0, 0, scale, 0, 0] - charStrings = CharStrings(None, topDict.charset, globalSubrs, private, fdSelect, fdArray) + charStrings = CharStrings( + None, topDict.charset, globalSubrs, private, fdSelect, fdArray + ) for glyphName, charString in charStringsDict.items(): charString.private = private charString.globalSubrs = globalSubrs @@ -541,8 +560,16 @@ class FontBuilder(object): self.font["CFF "].cff = fontSet def setupCFF2(self, charStringsDict, fdArrayList=None, regions=None): - from .cffLib import CFFFontSet, TopDictIndex, TopDict, CharStrings, \ - GlobalSubrsIndex, PrivateDict, FDArrayIndex, FontDict + from .cffLib import ( + CFFFontSet, + TopDictIndex, + TopDict, + CharStrings, + GlobalSubrsIndex, + PrivateDict, + FDArrayIndex, + FontDict, + ) assert not self.isTTF self.font.sfntVersion = "OTTO" @@ -628,10 +655,40 @@ class FontBuilder(object): self.calcGlyphBounds() def setupFvar(self, axes, instances): + """Adds an font variations table to the font. + + Args: + axes (list): See below. + instances (list): See below. + + ``axes`` should be a list of axes, with each axis either supplied as + a py:class:`.designspaceLib.AxisDescriptor` object, or a tuple in the + format ```tupletag, minValue, defaultValue, maxValue, name``. + The ``name`` is either a string, or a dict, mapping language codes + to strings, to allow localized name table entries. + + ```instances`` should be a list of instances, with each instance either + supplied as a py:class:`.designspaceLib.InstanceDescriptor` object, or a + dict with keys ``location`` (mapping of axis tags to float values), + ``stylename`` and (optionally) ``postscriptfontname``. + The ``stylename`` is either a string, or a dict, mapping language codes + to strings, to allow localized name table entries. + """ + addFvar(self.font, axes, instances) + def setupAvar(self, axes): + """Adds an axis variations table to the font. + + Args: + axes (list): A list of py:class:`.designspaceLib.AxisDescriptor` objects. + """ + from .varLib import _add_avar + + _add_avar(self.font, OrderedDict(enumerate(axes))) # Only values are used + def setupGvar(self, variations): - gvar = self.font["gvar"] = newTable('gvar') + gvar = self.font["gvar"] = newTable("gvar") gvar.version = 1 gvar.reserved = 0 gvar.variations = variations @@ -650,7 +707,7 @@ class FontBuilder(object): The `metrics` argument must be a dict, mapping glyph names to `(width, leftSidebearing)` tuples. """ - self.setupMetrics('hmtx', metrics) + self.setupMetrics("hmtx", metrics) def setupVerticalMetrics(self, metrics): """Create a new `vmtx` table, for horizontal metrics. @@ -658,7 +715,7 @@ class FontBuilder(object): The `metrics` argument must be a dict, mapping glyph names to `(height, topSidebearing)` tuples. """ - self.setupMetrics('vmtx', metrics) + self.setupMetrics("vmtx", metrics) def setupMetrics(self, tableTag, metrics): """See `setupHorizontalMetrics()` and `setupVerticalMetrics()`.""" @@ -699,8 +756,14 @@ class FontBuilder(object): bag[vorg] = 1 else: bag[vorg] += 1 - defaultVerticalOrigin = sorted(bag, key=lambda vorg: bag[vorg], reverse=True)[0] - self._initTableWithValues("VORG", {}, dict(VOriginRecords={}, defaultVertOriginY=defaultVerticalOrigin)) + defaultVerticalOrigin = sorted( + bag, key=lambda vorg: bag[vorg], reverse=True + )[0] + self._initTableWithValues( + "VORG", + {}, + dict(VOriginRecords={}, defaultVertOriginY=defaultVerticalOrigin), + ) vorgTable = self.font["VORG"] vorgTable.majorVersion = 1 vorgTable.minorVersion = 0 @@ -711,7 +774,7 @@ class FontBuilder(object): """Create a new `post` table and initialize it with default values, which can be overridden by keyword arguments. """ - isCFF2 = 'CFF2' in self.font + isCFF2 = "CFF2" in self.font postTable = self._initTableWithValues("post", _postDefaults, values) if (self.isTTF or isCFF2) and keepGlyphNames: postTable.formatType = 2.0 @@ -735,10 +798,10 @@ class FontBuilder(object): happy. This does not properly sign the font. """ values = dict( - ulVersion = 1, - usFlag = 0, - usNumSigs = 0, - signatureRecords = [], + ulVersion=1, + usFlag=0, + usNumSigs=0, + signatureRecords=[], ) self._initTableWithValues("DSIG", {}, values) @@ -754,7 +817,10 @@ class FontBuilder(object): `fontTools.feaLib` for details. """ from .feaLib.builder import addOpenTypeFeaturesFromString - addOpenTypeFeaturesFromString(self.font, features, filename=filename, tables=tables) + + addOpenTypeFeaturesFromString( + self.font, features, filename=filename, tables=tables + ) def addFeatureVariations(self, conditionalSubstitutions, featureTag="rvrn"): """Add conditional substitutions to a Variable Font. @@ -770,14 +836,17 @@ class FontBuilder(object): self.font, conditionalSubstitutions, featureTag=featureTag ) - def setupCOLR(self, colorLayers): + def setupCOLR(self, colorLayers, version=None, varStore=None): """Build new COLR table using color layers dictionary. Cf. `fontTools.colorLib.builder.buildCOLR`. """ from fontTools.colorLib.builder import buildCOLR - self.font["COLR"] = buildCOLR(colorLayers) + glyphMap = self.font.getReverseGlyphMap() + self.font["COLR"] = buildCOLR( + colorLayers, version=version, glyphMap=glyphMap, varStore=varStore + ) def setupCPAL( self, @@ -800,7 +869,7 @@ class FontBuilder(object): paletteTypes=paletteTypes, paletteLabels=paletteLabels, paletteEntryLabels=paletteEntryLabels, - nameTable=self.font.get("name") + nameTable=self.font.get("name"), ) def setupStat(self, axes, locations=None, elidedFallbackName=2): @@ -810,6 +879,7 @@ class FontBuilder(object): the arguments. """ from .otlLib.builder import buildStatTable + buildStatTable(self.font, axes, locations, elidedFallbackName) @@ -823,32 +893,58 @@ def buildCmapSubTable(cmapping, format, platformID, platEncID): def addFvar(font, axes, instances): - from .misc.py23 import Tag, tounicode from .ttLib.tables._f_v_a_r import Axis, NamedInstance + from .designspaceLib import AxisDescriptor assert axes - fvar = newTable('fvar') - nameTable = font['name'] + fvar = newTable("fvar") + nameTable = font["name"] - for tag, minValue, defaultValue, maxValue, name in axes: + for axis_def in axes: axis = Axis() - axis.axisTag = Tag(tag) - axis.minValue, axis.defaultValue, axis.maxValue = minValue, defaultValue, maxValue - axis.axisNameID = nameTable.addName(tounicode(name)) + + if isinstance(axis_def, tuple): + ( + axis.axisTag, + axis.minValue, + axis.defaultValue, + axis.maxValue, + name, + ) = axis_def + else: + (axis.axisTag, axis.minValue, axis.defaultValue, axis.maxValue, name) = ( + axis_def.tag, + axis_def.minimum, + axis_def.default, + axis_def.maximum, + axis_def.name, + ) + + if isinstance(name, str): + name = dict(en=name) + + axis.axisNameID = nameTable.addMultilingualName(name, ttFont=font) fvar.axes.append(axis) for instance in instances: - coordinates = instance['location'] - name = tounicode(instance['stylename']) - psname = instance.get('postscriptfontname') + if isinstance(instance, dict): + coordinates = instance["location"] + name = instance["stylename"] + psname = instance.get("postscriptfontname") + else: + coordinates = instance.location + name = instance.localisedStyleName or instance.styleName + psname = instance.postScriptFontName + + if isinstance(name, str): + name = dict(en=name) inst = NamedInstance() - inst.subfamilyNameID = nameTable.addName(name) + inst.subfamilyNameID = nameTable.addMultilingualName(name, ttFont=font) if psname is not None: - psname = tounicode(psname) inst.postscriptNameID = nameTable.addName(psname) inst.coordinates = coordinates fvar.instances.append(inst) - font['fvar'] = fvar + font["fvar"] = fvar diff --git a/Lib/fontTools/misc/arrayTools.py b/Lib/fontTools/misc/arrayTools.py index 81b2418dc..4b5f08298 100644 --- a/Lib/fontTools/misc/arrayTools.py +++ b/Lib/fontTools/misc/arrayTools.py @@ -4,9 +4,10 @@ so on. from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound -from numbers import Number +from fontTools.misc.vector import Vector as _Vector import math -import operator +import warnings + def calcBounds(array): """Calculate the bounding rectangle of a 2D points array. @@ -228,6 +229,19 @@ def rectCenter(rect): (xMin, yMin, xMax, yMax) = rect return (xMin+xMax)/2, (yMin+yMax)/2 +def rectArea(rect): + """Determine rectangle area. + + Args: + rect: Bounding rectangle, expressed as tuples + ``(xMin, yMin, xMax, yMax)``. + + Returns: + The area of the rectangle. + """ + (xMin, yMin, xMax, yMax) = rect + return (yMax - yMin) * (xMax - xMin) + def intRect(rect): """Round a rectangle to integer values. @@ -248,107 +262,14 @@ def intRect(rect): return (xMin, yMin, xMax, yMax) -class Vector(object): - """A math-like vector. +class Vector(_Vector): - Represents an n-dimensional numeric vector. ``Vector`` objects support - vector addition and subtraction, scalar multiplication and division, - negation, rounding, and comparison tests. - - Attributes: - values: Sequence of values stored in the vector. - """ - - def __init__(self, values, keep=False): - """Initialize a vector. If ``keep`` is true, values will be copied.""" - self.values = values if keep else list(values) - - def __getitem__(self, index): - return self.values[index] - - def __len__(self): - return len(self.values) - - def __repr__(self): - return "Vector(%s)" % self.values - - def _vectorOp(self, other, op): - if isinstance(other, Vector): - assert len(self.values) == len(other.values) - a = self.values - b = other.values - return [op(a[i], b[i]) for i in range(len(self.values))] - if isinstance(other, Number): - return [op(v, other) for v in self.values] - raise NotImplementedError - - def _scalarOp(self, other, op): - if isinstance(other, Number): - return [op(v, other) for v in self.values] - raise NotImplementedError - - def _unaryOp(self, op): - return [op(v) for v in self.values] - - def __add__(self, other): - return Vector(self._vectorOp(other, operator.add), keep=True) - def __iadd__(self, other): - self.values = self._vectorOp(other, operator.add) - return self - __radd__ = __add__ - - def __sub__(self, other): - return Vector(self._vectorOp(other, operator.sub), keep=True) - def __isub__(self, other): - self.values = self._vectorOp(other, operator.sub) - return self - def __rsub__(self, other): - return other + (-self) - - def __mul__(self, other): - return Vector(self._scalarOp(other, operator.mul), keep=True) - def __imul__(self, other): - self.values = self._scalarOp(other, operator.mul) - return self - __rmul__ = __mul__ - - def __truediv__(self, other): - return Vector(self._scalarOp(other, operator.div), keep=True) - def __itruediv__(self, other): - self.values = self._scalarOp(other, operator.div) - return self - - def __pos__(self): - return Vector(self._unaryOp(operator.pos), keep=True) - def __neg__(self): - return Vector(self._unaryOp(operator.neg), keep=True) - def __round__(self): - return Vector(self._unaryOp(round), keep=True) - def toInt(self): - """Synonym for ``round``.""" - return self.__round__() - - def __eq__(self, other): - if type(other) == Vector: - return self.values == other.values - else: - return self.values == other - def __ne__(self, other): - return not self.__eq__(other) - - def __bool__(self): - return any(self.values) - __nonzero__ = __bool__ - - def __abs__(self): - return math.sqrt(sum([x*x for x in self.values])) - def dot(self, other): - """Performs vector dot product, returning sum of - ``a[0] * b[0], a[1] * b[1], ...``""" - a = self.values - b = other.values if type(other) == Vector else b - assert len(a) == len(b) - return sum([a[i] * b[i] for i in range(len(a))]) + def __init__(self, *args, **kwargs): + warnings.warn( + "fontTools.misc.arrayTools.Vector has been deprecated, please use " + "fontTools.misc.vector.Vector instead.", + DeprecationWarning, + ) def pairwise(iterable, reverse=False): diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 659de34e2..63bfb0903 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -2,9 +2,13 @@ """fontTools.misc.bezierTools.py -- tools for working with Bezier path segments. """ -from fontTools.misc.arrayTools import calcBounds +from fontTools.misc.arrayTools import calcBounds, sectRect, rectArea +from fontTools.misc.transform import Offset, Identity from fontTools.misc.py23 import * import math +from collections import namedtuple + +Intersection = namedtuple("Intersection", ["pt", "t1", "t2"]) __all__ = [ @@ -25,6 +29,14 @@ __all__ = [ "splitCubicAtT", "solveQuadratic", "solveCubic", + "quadraticPointAtT", + "cubicPointAtT", + "linePointAtT", + "segmentPointAtT", + "lineLineIntersections", + "curveLineIntersections", + "curveCurveIntersections", + "segmentSegmentIntersections", ] @@ -42,23 +54,31 @@ def calcCubicArcLength(pt1, pt2, pt3, pt4, tolerance=0.005): Returns: Arc length value. """ - return calcCubicArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4), tolerance) + return calcCubicArcLengthC( + complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4), tolerance + ) def _split_cubic_into_two(p0, p1, p2, p3): - mid = (p0 + 3 * (p1 + p2) + p3) * .125 - deriv3 = (p3 + p2 - p1 - p0) * .125 - return ((p0, (p0 + p1) * .5, mid - deriv3, mid), - (mid, mid + deriv3, (p2 + p3) * .5, p3)) + mid = (p0 + 3 * (p1 + p2) + p3) * 0.125 + deriv3 = (p3 + p2 - p1 - p0) * 0.125 + return ( + (p0, (p0 + p1) * 0.5, mid - deriv3, mid), + (mid, mid + deriv3, (p2 + p3) * 0.5, p3), + ) + def _calcCubicArcLengthCRecurse(mult, p0, p1, p2, p3): - arch = abs(p0-p3) - box = abs(p0-p1) + abs(p1-p2) + abs(p2-p3) - if arch * mult >= box: - return (arch + box) * .5 - else: - one,two = _split_cubic_into_two(p0,p1,p2,p3) - return _calcCubicArcLengthCRecurse(mult, *one) + _calcCubicArcLengthCRecurse(mult, *two) + arch = abs(p0 - p3) + box = abs(p0 - p1) + abs(p1 - p2) + abs(p2 - p3) + if arch * mult >= box: + return (arch + box) * 0.5 + else: + one, two = _split_cubic_into_two(p0, p1, p2, p3) + return _calcCubicArcLengthCRecurse(mult, *one) + _calcCubicArcLengthCRecurse( + mult, *two + ) + def calcCubicArcLengthC(pt1, pt2, pt3, pt4, tolerance=0.005): """Calculates the arc length for a cubic Bezier segment. @@ -70,7 +90,7 @@ def calcCubicArcLengthC(pt1, pt2, pt3, pt4, tolerance=0.005): Returns: Arc length value. """ - mult = 1. + 1.5 * tolerance # The 1.5 is a empirical hack; no math + mult = 1.0 + 1.5 * tolerance # The 1.5 is a empirical hack; no math return _calcCubicArcLengthCRecurse(mult, pt1, pt2, pt3, pt4) @@ -85,7 +105,7 @@ def _dot(v1, v2): def _intSecAtan(x): # In : sympy.integrate(sp.sec(sp.atan(x))) # Out: x*sqrt(x**2 + 1)/2 + asinh(x)/2 - return x * math.sqrt(x**2 + 1)/2 + math.asinh(x)/2 + return x * math.sqrt(x ** 2 + 1) / 2 + math.asinh(x) / 2 def calcQuadraticArcLength(pt1, pt2, pt3): @@ -141,16 +161,16 @@ def calcQuadraticArcLengthC(pt1, pt2, pt3): d = d1 - d0 n = d * 1j scale = abs(n) - if scale == 0.: - return abs(pt3-pt1) - origDist = _dot(n,d0) + if scale == 0.0: + return abs(pt3 - pt1) + origDist = _dot(n, d0) if abs(origDist) < epsilon: - if _dot(d0,d1) >= 0: - return abs(pt3-pt1) + if _dot(d0, d1) >= 0: + return abs(pt3 - pt1) a, b = abs(d0), abs(d1) - return (a*a + b*b) / (a+b) - x0 = _dot(d,d0) / origDist - x1 = _dot(d,d1) / origDist + return (a * a + b * b) / (a + b) + x0 = _dot(d, d0) / origDist + x1 = _dot(d, d1) / origDist Len = abs(2 * (_intSecAtan(x1) - _intSecAtan(x0)) * origDist / (scale * (x1 - x0))) return Len @@ -190,13 +210,17 @@ def approximateQuadraticArcLengthC(pt1, pt2, pt3): # to be integrated with the best-matching fifth-degree polynomial # approximation of it. # - #https://en.wikipedia.org/wiki/Gaussian_quadrature#Gauss.E2.80.93Legendre_quadrature + # https://en.wikipedia.org/wiki/Gaussian_quadrature#Gauss.E2.80.93Legendre_quadrature # abs(BezierCurveC[2].diff(t).subs({t:T})) for T in sorted(.5, .5±sqrt(3/5)/2), # weighted 5/18, 8/18, 5/18 respectively. - v0 = abs(-0.492943519233745*pt1 + 0.430331482911935*pt2 + 0.0626120363218102*pt3) - v1 = abs(pt3-pt1)*0.4444444444444444 - v2 = abs(-0.0626120363218102*pt1 - 0.430331482911935*pt2 + 0.492943519233745*pt3) + v0 = abs( + -0.492943519233745 * pt1 + 0.430331482911935 * pt2 + 0.0626120363218102 * pt3 + ) + v1 = abs(pt3 - pt1) * 0.4444444444444444 + v2 = abs( + -0.0626120363218102 * pt1 - 0.430331482911935 * pt2 + 0.492943519233745 * pt3 + ) return v0 + v1 + v2 @@ -220,14 +244,18 @@ def calcQuadraticBounds(pt1, pt2, pt3): (0.0, 0.0, 100, 100) """ (ax, ay), (bx, by), (cx, cy) = calcQuadraticParameters(pt1, pt2, pt3) - ax2 = ax*2.0 - ay2 = ay*2.0 + ax2 = ax * 2.0 + ay2 = ay * 2.0 roots = [] if ax2 != 0: - roots.append(-bx/ax2) + roots.append(-bx / ax2) if ay2 != 0: - roots.append(-by/ay2) - points = [(ax*t*t + bx*t + cx, ay*t*t + by*t + cy) for t in roots if 0 <= t < 1] + [pt1, pt3] + roots.append(-by / ay2) + points = [ + (ax * t * t + bx * t + cx, ay * t * t + by * t + cy) + for t in roots + if 0 <= t < 1 + ] + [pt1, pt3] return calcBounds(points) @@ -256,7 +284,9 @@ def approximateCubicArcLength(pt1, pt2, pt3, pt4): >>> approximateCubicArcLength((0, 0), (50, 0), (100, -50), (-50, 0)) # cusp 154.80848416537057 """ - return approximateCubicArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4)) + return approximateCubicArcLengthC( + complex(*pt1), complex(*pt2), complex(*pt3), complex(*pt4) + ) def approximateCubicArcLengthC(pt1, pt2, pt3, pt4): @@ -276,11 +306,21 @@ def approximateCubicArcLengthC(pt1, pt2, pt3, pt4): # abs(BezierCurveC[3].diff(t).subs({t:T})) for T in sorted(0, .5±(3/7)**.5/2, .5, 1), # weighted 1/20, 49/180, 32/90, 49/180, 1/20 respectively. - v0 = abs(pt2-pt1)*.15 - v1 = abs(-0.558983582205757*pt1 + 0.325650248872424*pt2 + 0.208983582205757*pt3 + 0.024349751127576*pt4) - v2 = abs(pt4-pt1+pt3-pt2)*0.26666666666666666 - v3 = abs(-0.024349751127576*pt1 - 0.208983582205757*pt2 - 0.325650248872424*pt3 + 0.558983582205757*pt4) - v4 = abs(pt4-pt3)*.15 + v0 = abs(pt2 - pt1) * 0.15 + v1 = abs( + -0.558983582205757 * pt1 + + 0.325650248872424 * pt2 + + 0.208983582205757 * pt3 + + 0.024349751127576 * pt4 + ) + v2 = abs(pt4 - pt1 + pt3 - pt2) * 0.26666666666666666 + v3 = abs( + -0.024349751127576 * pt1 + - 0.208983582205757 * pt2 + - 0.325650248872424 * pt3 + + 0.558983582205757 * pt4 + ) + v4 = abs(pt4 - pt3) * 0.15 return v0 + v1 + v2 + v3 + v4 @@ -313,7 +353,13 @@ def calcCubicBounds(pt1, pt2, pt3, pt4): yRoots = [t for t in solveQuadratic(ay3, by2, cy) if 0 <= t < 1] roots = xRoots + yRoots - points = [(ax*t*t*t + bx*t*t + cx * t + dx, ay*t*t*t + by*t*t + cy * t + dy) for t in roots] + [pt1, pt4] + points = [ + ( + ax * t * t * t + bx * t * t + cx * t + dx, + ay * t * t * t + by * t * t + cy * t + dy, + ) + for t in roots + ] + [pt1, pt4] return calcBounds(points) @@ -356,8 +402,8 @@ def splitLine(pt1, pt2, where, isHorizontal): pt1x, pt1y = pt1 pt2x, pt2y = pt2 - ax = (pt2x - pt1x) - ay = (pt2y - pt1y) + ax = pt2x - pt1x + ay = pt2y - pt1y bx = pt1x by = pt1y @@ -410,8 +456,9 @@ def splitQuadratic(pt1, pt2, pt3, where, isHorizontal): ((50, 50), (75, 50), (100, 0)) """ a, b, c = calcQuadraticParameters(pt1, pt2, pt3) - solutions = solveQuadratic(a[isHorizontal], b[isHorizontal], - c[isHorizontal] - where) + solutions = solveQuadratic( + a[isHorizontal], b[isHorizontal], c[isHorizontal] - where + ) solutions = sorted([t for t in solutions if 0 <= t < 1]) if not solutions: return [(pt1, pt2, pt3)] @@ -446,8 +493,9 @@ def splitCubic(pt1, pt2, pt3, pt4, where, isHorizontal): ((92.5259, 25), (95.202, 17.5085), (97.7062, 9.17517), (100, 1.77636e-15)) """ a, b, c, d = calcCubicParameters(pt1, pt2, pt3, pt4) - solutions = solveCubic(a[isHorizontal], b[isHorizontal], c[isHorizontal], - d[isHorizontal] - where) + solutions = solveCubic( + a[isHorizontal], b[isHorizontal], c[isHorizontal], d[isHorizontal] - where + ) solutions = sorted([t for t in solutions if 0 <= t < 1]) if not solutions: return [(pt1, pt2, pt3, pt4)] @@ -512,17 +560,17 @@ def _splitQuadraticAtT(a, b, c, *ts): cx, cy = c for i in range(len(ts) - 1): t1 = ts[i] - t2 = ts[i+1] - delta = (t2 - t1) + t2 = ts[i + 1] + delta = t2 - t1 # calc new a, b and c - delta_2 = delta*delta + delta_2 = delta * delta a1x = ax * delta_2 a1y = ay * delta_2 - b1x = (2*ax*t1 + bx) * delta - b1y = (2*ay*t1 + by) * delta - t1_2 = t1*t1 - c1x = ax*t1_2 + bx*t1 + cx - c1y = ay*t1_2 + by*t1 + cy + b1x = (2 * ax * t1 + bx) * delta + b1y = (2 * ay * t1 + by) * delta + t1_2 = t1 * t1 + c1x = ax * t1_2 + bx * t1 + cx + c1y = ay * t1_2 + by * t1 + cy pt1, pt2, pt3 = calcQuadraticPoints((a1x, a1y), (b1x, b1y), (c1x, c1y)) segments.append((pt1, pt2, pt3)) @@ -540,24 +588,26 @@ def _splitCubicAtT(a, b, c, d, *ts): dx, dy = d for i in range(len(ts) - 1): t1 = ts[i] - t2 = ts[i+1] - delta = (t2 - t1) + t2 = ts[i + 1] + delta = t2 - t1 - delta_2 = delta*delta - delta_3 = delta*delta_2 - t1_2 = t1*t1 - t1_3 = t1*t1_2 + delta_2 = delta * delta + delta_3 = delta * delta_2 + t1_2 = t1 * t1 + t1_3 = t1 * t1_2 # calc new a, b, c and d a1x = ax * delta_3 a1y = ay * delta_3 - b1x = (3*ax*t1 + bx) * delta_2 - b1y = (3*ay*t1 + by) * delta_2 - c1x = (2*bx*t1 + cx + 3*ax*t1_2) * delta - c1y = (2*by*t1 + cy + 3*ay*t1_2) * delta - d1x = ax*t1_3 + bx*t1_2 + cx*t1 + dx - d1y = ay*t1_3 + by*t1_2 + cy*t1 + dy - pt1, pt2, pt3, pt4 = calcCubicPoints((a1x, a1y), (b1x, b1y), (c1x, c1y), (d1x, d1y)) + b1x = (3 * ax * t1 + bx) * delta_2 + b1y = (3 * ay * t1 + by) * delta_2 + c1x = (2 * bx * t1 + cx + 3 * ax * t1_2) * delta + c1y = (2 * by * t1 + cy + 3 * ay * t1_2) * delta + d1x = ax * t1_3 + bx * t1_2 + cx * t1 + dx + d1y = ay * t1_3 + by * t1_2 + cy * t1 + dy + pt1, pt2, pt3, pt4 = calcCubicPoints( + (a1x, a1y), (b1x, b1y), (c1x, c1y), (d1x, d1y) + ) segments.append((pt1, pt2, pt3, pt4)) return segments @@ -569,8 +619,7 @@ def _splitCubicAtT(a, b, c, d, *ts): from math import sqrt, acos, cos, pi -def solveQuadratic(a, b, c, - sqrt=sqrt): +def solveQuadratic(a, b, c, sqrt=sqrt): """Solve a quadratic equation. Solves *a*x*x + b*x + c = 0* where a, b and c are real. @@ -590,13 +639,13 @@ def solveQuadratic(a, b, c, roots = [] else: # We have a linear equation with 1 root. - roots = [-c/b] + roots = [-c / b] else: # We have a true quadratic equation. Apply the quadratic formula to find two roots. - DD = b*b - 4.0*a*c + DD = b * b - 4.0 * a * c if DD >= 0.0: rDD = sqrt(DD) - roots = [(-b+rDD)/2.0/a, (-b-rDD)/2.0/a] + roots = [(-b + rDD) / 2.0 / a, (-b - rDD) / 2.0 / a] else: # complex roots, ignore roots = [] @@ -646,52 +695,52 @@ def solveCubic(a, b, c, d): # returns unreliable results, so we fall back to quad. return solveQuadratic(b, c, d) a = float(a) - a1 = b/a - a2 = c/a - a3 = d/a + a1 = b / a + a2 = c / a + a3 = d / a - Q = (a1*a1 - 3.0*a2)/9.0 - R = (2.0*a1*a1*a1 - 9.0*a1*a2 + 27.0*a3)/54.0 + Q = (a1 * a1 - 3.0 * a2) / 9.0 + R = (2.0 * a1 * a1 * a1 - 9.0 * a1 * a2 + 27.0 * a3) / 54.0 - R2 = R*R - Q3 = Q*Q*Q + R2 = R * R + Q3 = Q * Q * Q R2 = 0 if R2 < epsilon else R2 Q3 = 0 if abs(Q3) < epsilon else Q3 R2_Q3 = R2 - Q3 - if R2 == 0. and Q3 == 0.: - x = round(-a1/3.0, epsilonDigits) + if R2 == 0.0 and Q3 == 0.0: + x = round(-a1 / 3.0, epsilonDigits) return [x, x, x] - elif R2_Q3 <= epsilon * .5: + elif R2_Q3 <= epsilon * 0.5: # The epsilon * .5 above ensures that Q3 is not zero. - theta = acos(max(min(R/sqrt(Q3), 1.0), -1.0)) - rQ2 = -2.0*sqrt(Q) - a1_3 = a1/3.0 - x0 = rQ2*cos(theta/3.0) - a1_3 - x1 = rQ2*cos((theta+2.0*pi)/3.0) - a1_3 - x2 = rQ2*cos((theta+4.0*pi)/3.0) - a1_3 + theta = acos(max(min(R / sqrt(Q3), 1.0), -1.0)) + rQ2 = -2.0 * sqrt(Q) + a1_3 = a1 / 3.0 + x0 = rQ2 * cos(theta / 3.0) - a1_3 + x1 = rQ2 * cos((theta + 2.0 * pi) / 3.0) - a1_3 + x2 = rQ2 * cos((theta + 4.0 * pi) / 3.0) - a1_3 x0, x1, x2 = sorted([x0, x1, x2]) # Merge roots that are close-enough if x1 - x0 < epsilon and x2 - x1 < epsilon: - x0 = x1 = x2 = round((x0 + x1 + x2) / 3., epsilonDigits) + x0 = x1 = x2 = round((x0 + x1 + x2) / 3.0, epsilonDigits) elif x1 - x0 < epsilon: - x0 = x1 = round((x0 + x1) / 2., epsilonDigits) + x0 = x1 = round((x0 + x1) / 2.0, epsilonDigits) x2 = round(x2, epsilonDigits) elif x2 - x1 < epsilon: x0 = round(x0, epsilonDigits) - x1 = x2 = round((x1 + x2) / 2., epsilonDigits) + x1 = x2 = round((x1 + x2) / 2.0, epsilonDigits) else: x0 = round(x0, epsilonDigits) x1 = round(x1, epsilonDigits) x2 = round(x2, epsilonDigits) return [x0, x1, x2] else: - x = pow(sqrt(R2_Q3)+abs(R), 1/3.0) - x = x + Q/x + x = pow(sqrt(R2_Q3) + abs(R), 1 / 3.0) + x = x + Q / x if R >= 0.0: x = -x - x = round(x - a1/3.0, epsilonDigits) + x = round(x - a1 / 3.0, epsilonDigits) return [x] @@ -699,6 +748,7 @@ def solveCubic(a, b, c, d): # Conversion routines for points to parameters and vice versa # + def calcQuadraticParameters(pt1, pt2, pt3): x2, y2 = pt2 x3, y3 = pt3 @@ -753,10 +803,399 @@ def calcCubicPoints(a, b, c, d): return (x1, y1), (x2, y2), (x3, y3), (x4, y4) +# +# Point at time +# + + +def linePointAtT(pt1, pt2, t): + """Finds the point at time `t` on a line. + + Args: + pt1, pt2: Coordinates of the line as 2D tuples. + t: The time along the line. + + Returns: + A 2D tuple with the coordinates of the point. + """ + return ((pt1[0] * (1 - t) + pt2[0] * t), (pt1[1] * (1 - t) + pt2[1] * t)) + + +def quadraticPointAtT(pt1, pt2, pt3, t): + """Finds the point at time `t` on a quadratic curve. + + Args: + pt1, pt2, pt3: Coordinates of the curve as 2D tuples. + t: The time along the curve. + + Returns: + A 2D tuple with the coordinates of the point. + """ + x = (1 - t) * (1 - t) * pt1[0] + 2 * (1 - t) * t * pt2[0] + t * t * pt3[0] + y = (1 - t) * (1 - t) * pt1[1] + 2 * (1 - t) * t * pt2[1] + t * t * pt3[1] + return (x, y) + + +def cubicPointAtT(pt1, pt2, pt3, pt4, t): + """Finds the point at time `t` on a cubic curve. + + Args: + pt1, pt2, pt3, pt4: Coordinates of the curve as 2D tuples. + t: The time along the curve. + + Returns: + A 2D tuple with the coordinates of the point. + """ + x = ( + (1 - t) * (1 - t) * (1 - t) * pt1[0] + + 3 * (1 - t) * (1 - t) * t * pt2[0] + + 3 * (1 - t) * t * t * pt3[0] + + t * t * t * pt4[0] + ) + y = ( + (1 - t) * (1 - t) * (1 - t) * pt1[1] + + 3 * (1 - t) * (1 - t) * t * pt2[1] + + 3 * (1 - t) * t * t * pt3[1] + + t * t * t * pt4[1] + ) + return (x, y) + + +def segmentPointAtT(seg, t): + if len(seg) == 2: + return linePointAtT(*seg, t) + elif len(seg) == 3: + return quadraticPointAtT(*seg, t) + elif len(seg) == 4: + return cubicPointAtT(*seg, t) + raise ValueError("Unknown curve degree") + + +# +# Intersection finders +# + + +def _line_t_of_pt(s, e, pt): + sx, sy = s + ex, ey = e + px, py = pt + if not math.isclose(sx, ex): + return (px - sx) / (ex - sx) + if not math.isclose(sy, ey): + return (py - sy) / (ey - sy) + # Line is a point! + return -1 + + +def _both_points_are_on_same_side_of_origin(a, b, origin): + xDiff = (a[0] - origin[0]) * (b[0] - origin[0]) + yDiff = (a[1] - origin[1]) * (b[1] - origin[1]) + return not (xDiff <= 0.0 and yDiff <= 0.0) + + +def lineLineIntersections(s1, e1, s2, e2): + """Finds intersections between two line segments. + + Args: + s1, e1: Coordinates of the first line as 2D tuples. + s2, e2: Coordinates of the second line as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + + >>> a = lineLineIntersections( (310,389), (453, 222), (289, 251), (447, 367)) + >>> len(a) + 1 + >>> intersection = a[0] + >>> intersection.pt + (374.44882952482897, 313.73458370177315) + >>> (intersection.t1, intersection.t2) + (0.45069111555824454, 0.5408153767394238) + """ + s1x, s1y = s1 + e1x, e1y = e1 + s2x, s2y = s2 + e2x, e2y = e2 + if ( + math.isclose(s2x, e2x) and math.isclose(s1x, e1x) and not math.isclose(s1x, s2x) + ): # Parallel vertical + return [] + if ( + math.isclose(s2y, e2y) and math.isclose(s1y, e1y) and not math.isclose(s1y, s2y) + ): # Parallel horizontal + return [] + if math.isclose(s2x, e2x) and math.isclose(s2y, e2y): # Line segment is tiny + return [] + if math.isclose(s1x, e1x) and math.isclose(s1y, e1y): # Line segment is tiny + return [] + if math.isclose(e1x, s1x): + x = s1x + slope34 = (e2y - s2y) / (e2x - s2x) + y = slope34 * (x - s2x) + s2y + pt = (x, y) + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + if math.isclose(s2x, e2x): + x = s2x + slope12 = (e1y - s1y) / (e1x - s1x) + y = slope12 * (x - s1x) + s1y + pt = (x, y) + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + + slope12 = (e1y - s1y) / (e1x - s1x) + slope34 = (e2y - s2y) / (e2x - s2x) + if math.isclose(slope12, slope34): + return [] + x = (slope12 * s1x - s1y - slope34 * s2x + s2y) / (slope12 - slope34) + y = slope12 * (x - s1x) + s1y + pt = (x, y) + if _both_points_are_on_same_side_of_origin( + pt, e1, s1 + ) and _both_points_are_on_same_side_of_origin(pt, s2, e2): + return [ + Intersection( + pt=pt, t1=_line_t_of_pt(s1, e1, pt), t2=_line_t_of_pt(s2, e2, pt) + ) + ] + return [] + + +def _alignment_transformation(segment): + # Returns a transformation which aligns a segment horizontally at the + # origin. Apply this transformation to curves and root-find to find + # intersections with the segment. + start = segment[0] + end = segment[-1] + angle = math.atan2(end[1] - start[1], end[0] - start[0]) + return Identity.rotate(-angle).translate(-start[0], -start[1]) + + +def _curve_line_intersections_t(curve, line): + aligned_curve = _alignment_transformation(line).transformPoints(curve) + if len(curve) == 3: + a, b, c = calcQuadraticParameters(*aligned_curve) + intersections = solveQuadratic(a[1], b[1], c[1]) + elif len(curve) == 4: + a, b, c, d = calcCubicParameters(*aligned_curve) + intersections = solveCubic(a[1], b[1], c[1], d[1]) + else: + raise ValueError("Unknown curve degree") + return sorted([i for i in intersections if 0.0 <= i <= 1]) + + +def curveLineIntersections(curve, line): + """Finds intersections between a curve and a line. + + Args: + curve: List of coordinates of the curve segment as 2D tuples. + line: List of coordinates of the line segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve = [ (100, 240), (30, 60), (210, 230), (160, 30) ] + >>> line = [ (25, 260), (230, 20) ] + >>> intersections = curveLineIntersections(curve, line) + >>> len(intersections) + 3 + >>> intersections[0].pt + (84.90010344084885, 189.87306176459828) + """ + if len(curve) == 3: + pointFinder = quadraticPointAtT + elif len(curve) == 4: + pointFinder = cubicPointAtT + else: + raise ValueError("Unknown curve degree") + intersections = [] + for t in _curve_line_intersections_t(curve, line): + pt = pointFinder(*curve, t) + intersections.append(Intersection(pt=pt, t1=t, t2=_line_t_of_pt(*line, pt))) + return intersections + + +def _curve_bounds(c): + if len(c) == 3: + return calcQuadraticBounds(*c) + elif len(c) == 4: + return calcCubicBounds(*c) + raise ValueError("Unknown curve degree") + + +def _split_segment_at_t(c, t): + if len(c) == 2: + s, e = c + midpoint = linePointAtT(s, e, t) + return [(s, midpoint), (midpoint, e)] + if len(c) == 3: + return splitQuadraticAtT(*c, t) + elif len(c) == 4: + return splitCubicAtT(*c, t) + raise ValueError("Unknown curve degree") + + +def _curve_curve_intersections_t( + curve1, curve2, precision=1e-3, range1=None, range2=None +): + bounds1 = _curve_bounds(curve1) + bounds2 = _curve_bounds(curve2) + + if not range1: + range1 = (0.0, 1.0) + if not range2: + range2 = (0.0, 1.0) + + # If bounds don't intersect, go home + intersects, _ = sectRect(bounds1, bounds2) + if not intersects: + return [] + + def midpoint(r): + return 0.5 * (r[0] + r[1]) + + # If they do overlap but they're tiny, approximate + if rectArea(bounds1) < precision and rectArea(bounds2) < precision: + return [(midpoint(range1), midpoint(range2))] + + c11, c12 = _split_segment_at_t(curve1, 0.5) + c11_range = (range1[0], midpoint(range1)) + c12_range = (midpoint(range1), range1[1]) + + c21, c22 = _split_segment_at_t(curve2, 0.5) + c21_range = (range2[0], midpoint(range2)) + c22_range = (midpoint(range2), range2[1]) + + found = [] + found.extend( + _curve_curve_intersections_t( + c11, c21, precision, range1=c11_range, range2=c21_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c12, c21, precision, range1=c12_range, range2=c21_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c11, c22, precision, range1=c11_range, range2=c22_range + ) + ) + found.extend( + _curve_curve_intersections_t( + c12, c22, precision, range1=c12_range, range2=c22_range + ) + ) + + unique_key = lambda ts: (int(ts[0] / precision), int(ts[1] / precision)) + seen = set() + unique_values = [] + + for ts in found: + key = unique_key(ts) + if key in seen: + continue + seen.add(key) + unique_values.append(ts) + + return unique_values + + +def curveCurveIntersections(curve1, curve2): + """Finds intersections between a curve and a curve. + + Args: + curve1: List of coordinates of the first curve segment as 2D tuples. + curve2: List of coordinates of the second curve segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve1 = [ (10,100), (90,30), (40,140), (220,220) ] + >>> curve2 = [ (5,150), (180,20), (80,250), (210,190) ] + >>> intersections = curveCurveIntersections(curve1, curve2) + >>> len(intersections) + 3 + >>> intersections[0].pt + (81.7831487395506, 109.88904552375288) + """ + intersection_ts = _curve_curve_intersections_t(curve1, curve2) + return [ + Intersection(pt=segmentPointAtT(curve1, ts[0]), t1=ts[0], t2=ts[1]) + for ts in intersection_ts + ] + + +def segmentSegmentIntersections(seg1, seg2): + """Finds intersections between two segments. + + Args: + seg1: List of coordinates of the first segment as 2D tuples. + seg2: List of coordinates of the second segment as 2D tuples. + + Returns: + A list of ``Intersection`` objects, each object having ``pt``, ``t1`` + and ``t2`` attributes containing the intersection point, time on first + segment and time on second segment respectively. + + Examples:: + >>> curve1 = [ (10,100), (90,30), (40,140), (220,220) ] + >>> curve2 = [ (5,150), (180,20), (80,250), (210,190) ] + >>> intersections = segmentSegmentIntersections(curve1, curve2) + >>> len(intersections) + 3 + >>> intersections[0].pt + (81.7831487395506, 109.88904552375288) + >>> curve3 = [ (100, 240), (30, 60), (210, 230), (160, 30) ] + >>> line = [ (25, 260), (230, 20) ] + >>> intersections = segmentSegmentIntersections(curve3, line) + >>> len(intersections) + 3 + >>> intersections[0].pt + (84.90010344084885, 189.87306176459828) + + """ + # Arrange by degree + swapped = False + if len(seg2) > len(seg1): + seg2, seg1 = seg1, seg2 + swapped = True + if len(seg1) > 2: + if len(seg2) > 2: + intersections = curveCurveIntersections(seg1, seg2) + else: + intersections = curveLineIntersections(seg1, seg2) + elif len(seg1) == 2 and len(seg2) == 2: + intersections = lineLineIntersections(*seg1, *seg2) + else: + raise ValueError("Couldn't work out which intersection function to use") + if not swapped: + return intersections + return [Intersection(pt=i.pt, t1=i.t2, t2=i.t1) for i in intersections] + + def _segmentrepr(obj): """ - >>> _segmentrepr([1, [2, 3], [], [[2, [3, 4], [0.1, 2.2]]]]) - '(1, (2, 3), (), ((2, (3, 4), (0.1, 2.2))))' + >>> _segmentrepr([1, [2, 3], [], [[2, [3, 4], [0.1, 2.2]]]]) + '(1, (2, 3), (), ((2, (3, 4), (0.1, 2.2))))' """ try: it = iter(obj) @@ -773,7 +1212,9 @@ def printSegments(segments): for segment in segments: print(_segmentrepr(segment)) + if __name__ == "__main__": import sys import doctest + sys.exit(doctest.testmod().failed) diff --git a/Lib/fontTools/misc/plistlib/__init__.py b/Lib/fontTools/misc/plistlib/__init__.py index 1335e8cbe..d8391041d 100644 --- a/Lib/fontTools/misc/plistlib/__init__.py +++ b/Lib/fontTools/misc/plistlib/__init__.py @@ -543,7 +543,7 @@ def load( if not hasattr(fp, "read"): raise AttributeError("'%s' object has no attribute 'read'" % type(fp).__name__) target = PlistTarget(use_builtin_types=use_builtin_types, dict_type=dict_type) - parser = etree.XMLParser(target=target) # type: ignore + parser = etree.XMLParser(target=target) result = etree.parse(fp, parser=parser) # lxml returns the target object directly, while ElementTree wraps # it as the root of an ElementTree object diff --git a/Lib/fontTools/misc/vector.py b/Lib/fontTools/misc/vector.py new file mode 100644 index 000000000..995385c04 --- /dev/null +++ b/Lib/fontTools/misc/vector.py @@ -0,0 +1,138 @@ +from numbers import Number +import math +import operator +import warnings + + +class Vector(tuple): + + """A math-like vector. + + Represents an n-dimensional numeric vector. ``Vector`` objects support + vector addition and subtraction, scalar multiplication and division, + negation, rounding, and comparison tests. + """ + + def __new__(cls, values, keep=False): + if keep is not False: + warnings.warn( + "the 'keep' argument has been deprecated", + DeprecationWarning, + ) + if type(values) == Vector: + # No need to create a new object + return values + return super().__new__(cls, values) + + def __repr__(self): + return f"{self.__class__.__name__}({super().__repr__()})" + + def _vectorOp(self, other, op): + if isinstance(other, Vector): + assert len(self) == len(other) + return self.__class__(op(a, b) for a, b in zip(self, other)) + if isinstance(other, Number): + return self.__class__(op(v, other) for v in self) + raise NotImplementedError() + + def _scalarOp(self, other, op): + if isinstance(other, Number): + return self.__class__(op(v, other) for v in self) + raise NotImplementedError() + + def _unaryOp(self, op): + return self.__class__(op(v) for v in self) + + def __add__(self, other): + return self._vectorOp(other, operator.add) + + __radd__ = __add__ + + def __sub__(self, other): + return self._vectorOp(other, operator.sub) + + def __rsub__(self, other): + return self._vectorOp(other, _operator_rsub) + + def __mul__(self, other): + return self._scalarOp(other, operator.mul) + + __rmul__ = __mul__ + + def __truediv__(self, other): + return self._scalarOp(other, operator.truediv) + + def __rtruediv__(self, other): + return self._scalarOp(other, _operator_rtruediv) + + def __pos__(self): + return self._unaryOp(operator.pos) + + def __neg__(self): + return self._unaryOp(operator.neg) + + def __round__(self): + return self._unaryOp(round) + + def __eq__(self, other): + if isinstance(other, list): + # bw compat Vector([1, 2, 3]) == [1, 2, 3] + other = tuple(other) + return super().__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def __bool__(self): + return any(self) + + __nonzero__ = __bool__ + + def __abs__(self): + return math.sqrt(sum(x * x for x in self)) + + def length(self): + """Return the length of the vector. Equivalent to abs(vector).""" + return abs(self) + + def normalized(self): + """Return the normalized vector of the vector.""" + return self / abs(self) + + def dot(self, other): + """Performs vector dot product, returning the sum of + ``a[0] * b[0], a[1] * b[1], ...``""" + assert len(self) == len(other) + return sum(a * b for a, b in zip(self, other)) + + # Deprecated methods/properties + + def toInt(self): + warnings.warn( + "the 'toInt' method has been deprecated, use round(vector) instead", + DeprecationWarning, + ) + return self.__round__() + + @property + def values(self): + warnings.warn( + "the 'values' attribute has been deprecated, use " + "the vector object itself instead", + DeprecationWarning, + ) + return list(self) + + @values.setter + def values(self, values): + raise AttributeError( + "can't set attribute, the 'values' attribute has been deprecated", + ) + + +def _operator_rsub(a, b): + return operator.sub(b, a) + + +def _operator_rtruediv(a, b): + return operator.truediv(b, a) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 1ba63c35c..ca9e936d6 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -9,6 +9,7 @@ from fontTools.ttLib.tables.otBase import ( CountReference, ) from fontTools.ttLib.tables import otBase +from fontTools.feaLib.ast import STATNameStatement from fontTools.otlLib.error import OpenTypeLibError import logging import copy @@ -94,9 +95,10 @@ def buildLookup(subtables, flags=0, markFilterSet=None): subtables = [st for st in subtables if st is not None] if not subtables: return None - assert all(t.LookupType == subtables[0].LookupType for t in subtables), ( - "all subtables must have the same LookupType; got %s" - % repr([t.LookupType for t in subtables]) + assert all( + t.LookupType == subtables[0].LookupType for t in subtables + ), "all subtables must have the same LookupType; got %s" % repr( + [t.LookupType for t in subtables] ) self = ot.Lookup() self.LookupType = subtables[0].LookupType @@ -314,9 +316,10 @@ class ChainContextualRuleset: classdefbuilder = ClassDefBuilder(useClass0=False) for position in context: for glyphset in position: - if not classdefbuilder.canAdd(glyphset): + glyphs = set(glyphset) + if not classdefbuilder.canAdd(glyphs): return None - classdefbuilder.add(glyphset) + classdefbuilder.add(glyphs) return classdefbuilder @@ -2573,7 +2576,10 @@ 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): @@ -2685,8 +2691,8 @@ def buildStatTable(ttFont, axes, locations=None, elidedFallbackName=2): ] The optional 'elidedFallbackName' argument can be a name ID (int), - a string, or a dictionary containing multilingual names. It - translates to the ElidedFallbackNameID field. + a string, a dictionary containing multilingual names, or a list of + STATNameStatements. It translates to the ElidedFallbackNameID field. The 'ttFont' argument must be a TTFont instance that already has a 'name' table. If a 'STAT' table already exists, it will be @@ -2795,6 +2801,20 @@ def _addName(nameTable, value, minNameID=0): names = dict(en=value) elif isinstance(value, dict): names = value + elif isinstance(value, list): + nameID = nameTable._findUnusedNameID() + for nameRecord in value: + if isinstance(nameRecord, STATNameStatement): + nameTable.setName( + nameRecord.string, + nameID, + nameRecord.platformID, + nameRecord.platEncID, + nameRecord.langID, + ) + else: + raise TypeError("value must be a list of STATNameStatements") + return nameID else: - raise TypeError("value must be int, str or dict") + raise TypeError("value must be int, str, dict or list") return nameTable.addMultilingualName(names, minNameID=minNameID) diff --git a/Lib/fontTools/pens/__init__.py b/Lib/fontTools/pens/__init__.py index b1760311b..156cb232a 100644 --- a/Lib/fontTools/pens/__init__.py +++ b/Lib/fontTools/pens/__init__.py @@ -1,3 +1 @@ """Empty __init__.py file to signal Python this directory is a package.""" - -from fontTools.misc.py23 import * diff --git a/Lib/fontTools/pens/areaPen.py b/Lib/fontTools/pens/areaPen.py index c9301542e..403afe7bc 100644 --- a/Lib/fontTools/pens/areaPen.py +++ b/Lib/fontTools/pens/areaPen.py @@ -1,6 +1,5 @@ """Calculate the area of a glyph.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 1593024f3..34f89f8d2 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -36,26 +36,27 @@ Coordinates are usually expressed as (x, y) tuples, but generally any sequence of length 2 will do. """ -from fontTools.misc.py23 import * +from typing import Any, Tuple + from fontTools.misc.loggingTools import LogMixin __all__ = ["AbstractPen", "NullPen", "BasePen", "decomposeSuperBezierSegment", "decomposeQuadraticSegment"] -class AbstractPen(object): +class AbstractPen: - def moveTo(self, pt): + def moveTo(self, pt: Tuple[float, float]) -> None: """Begin a new sub path, set the current point to 'pt'. You must end each sub path with a call to pen.closePath() or pen.endPath(). """ raise NotImplementedError - def lineTo(self, pt): + def lineTo(self, pt: Tuple[float, float]) -> None: """Draw a straight line from the current point to 'pt'.""" raise NotImplementedError - def curveTo(self, *points): + def curveTo(self, *points: Tuple[float, float]) -> None: """Draw a cubic bezier with an arbitrary number of control points. The last point specified is on-curve, all others are off-curve @@ -76,7 +77,7 @@ class AbstractPen(object): """ raise NotImplementedError - def qCurveTo(self, *points): + def qCurveTo(self, *points: Tuple[float, float]) -> None: """Draw a whole string of quadratic curve segments. The last point specified is on-curve, all others are off-curve @@ -93,19 +94,23 @@ class AbstractPen(object): """ raise NotImplementedError - def closePath(self): + def closePath(self) -> None: """Close the current sub path. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - def endPath(self): + def endPath(self) -> None: """End the current sub path, but don't close it. You must call either pen.closePath() or pen.endPath() after each sub path. """ pass - def addComponent(self, glyphName, transformation): + def addComponent( + self, + glyphName: str, + transformation: Tuple[float, float, float, float, float, float] + ) -> None: """Add a sub glyph. The 'transformation' argument must be a 6-tuple containing an affine transformation, or a Transform object from the fontTools.misc.transform module. More precisely: it should be a @@ -114,7 +119,7 @@ class AbstractPen(object): raise NotImplementedError -class NullPen(object): +class NullPen(AbstractPen): """A pen that does nothing. """ @@ -147,6 +152,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 +164,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 +187,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/boundsPen.py b/Lib/fontTools/pens/boundsPen.py index c76efdfb8..810715caa 100644 --- a/Lib/fontTools/pens/boundsPen.py +++ b/Lib/fontTools/pens/boundsPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.misc.arrayTools import updateBounds, pointInRect, unionRect from fontTools.misc.bezierTools import calcCubicBounds, calcQuadraticBounds from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/cocoaPen.py b/Lib/fontTools/pens/cocoaPen.py index 9ca6f3bb6..67482b4df 100644 --- a/Lib/fontTools/pens/cocoaPen.py +++ b/Lib/fontTools/pens/cocoaPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py index 7539efb5c..4355ba41e 100644 --- a/Lib/fontTools/pens/filterPen.py +++ b/Lib/fontTools/pens/filterPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen from fontTools.pens.pointPen import AbstractPointPen from fontTools.pens.recordingPen import RecordingPen diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py index f3276f701..9aef5d870 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/pens/momentsPen.py b/Lib/fontTools/pens/momentsPen.py index 694d6b029..8c90f70ac 100644 --- a/Lib/fontTools/pens/momentsPen.py +++ b/Lib/fontTools/pens/momentsPen.py @@ -1,6 +1,5 @@ """Pen calculating 0th, 1st, and 2nd moments of area of glyph shapes. This is low-level, autogenerated pen. Use statisticsPen instead.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/perimeterPen.py b/Lib/fontTools/pens/perimeterPen.py index 36c7edb4d..9a09cb8f0 100644 --- a/Lib/fontTools/pens/perimeterPen.py +++ b/Lib/fontTools/pens/perimeterPen.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Calculate the perimeter of a glyph.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from fontTools.misc.bezierTools import approximateQuadraticArcLengthC, calcQuadraticArcLengthC, approximateCubicArcLengthC, calcCubicArcLengthC import math diff --git a/Lib/fontTools/pens/pointInsidePen.py b/Lib/fontTools/pens/pointInsidePen.py index 8de077c97..34597f406 100644 --- a/Lib/fontTools/pens/pointInsidePen.py +++ b/Lib/fontTools/pens/pointInsidePen.py @@ -2,7 +2,6 @@ for shapes. """ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from fontTools.misc.bezierTools import solveQuadratic, solveCubic diff --git a/Lib/fontTools/pens/pointPen.py b/Lib/fontTools/pens/pointPen.py index 558321814..92846d315 100644 --- a/Lib/fontTools/pens/pointPen.py +++ b/Lib/fontTools/pens/pointPen.py @@ -11,8 +11,11 @@ steps through all the points in a call from glyph.drawPoints(). This allows the caller to provide more data for each point. For instance, whether or not a point is smooth, and its name. """ -from fontTools.pens.basePen import AbstractPen + import math +from typing import Any, List, Optional, Tuple + +from fontTools.pens.basePen import AbstractPen __all__ = [ "AbstractPointPen", @@ -24,26 +27,36 @@ __all__ = [ ] -class AbstractPointPen(object): - """ - Baseclass for all PointPens. - """ +class AbstractPointPen: + """Baseclass for all PointPens.""" - def beginPath(self, identifier=None, **kwargs): + def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None: """Start a new sub path.""" raise NotImplementedError - def endPath(self): + def endPath(self) -> None: """End the current sub path.""" raise NotImplementedError - def addPoint(self, pt, segmentType=None, smooth=False, name=None, - identifier=None, **kwargs): + def addPoint( + self, + pt: Tuple[float, float], + segmentType: Optional[str] = None, + smooth: bool = False, + name: Optional[str] = None, + identifier: Optional[str] = None, + **kwargs: Any + ) -> None: """Add a point to the current sub path.""" raise NotImplementedError - def addComponent(self, baseGlyphName, transformation, identifier=None, - **kwargs): + def addComponent( + self, + baseGlyphName: str, + transformation: Tuple[float, float, float, float, float, float], + identifier: Optional[str] = None, + **kwargs: Any + ) -> None: """Add a sub glyph.""" raise NotImplementedError diff --git a/Lib/fontTools/pens/qtPen.py b/Lib/fontTools/pens/qtPen.py index 20d7e23a4..34736453c 100644 --- a/Lib/fontTools/pens/qtPen.py +++ b/Lib/fontTools/pens/qtPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/quartzPen.py b/Lib/fontTools/pens/quartzPen.py index d35a993bb..16b9c2d84 100644 --- a/Lib/fontTools/pens/quartzPen.py +++ b/Lib/fontTools/pens/quartzPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from Quartz.CoreGraphics import CGPathCreateMutable, CGPathMoveToPoint diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py index b25011d6d..abce1ad76 100644 --- a/Lib/fontTools/pens/recordingPen.py +++ b/Lib/fontTools/pens/recordingPen.py @@ -1,5 +1,4 @@ """Pen recording operations that can be accessed or replayed.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen, DecomposingPen from fontTools.pens.pointPen import AbstractPointPen diff --git a/Lib/fontTools/pens/reportLabPen.py b/Lib/fontTools/pens/reportLabPen.py index 51d213f73..c0a4610b7 100644 --- a/Lib/fontTools/pens/reportLabPen.py +++ b/Lib/fontTools/pens/reportLabPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen from reportlab.graphics.shapes import Path diff --git a/Lib/fontTools/pens/reverseContourPen.py b/Lib/fontTools/pens/reverseContourPen.py index abc0fa290..9b3241b6b 100644 --- a/Lib/fontTools/pens/reverseContourPen.py +++ b/Lib/fontTools/pens/reverseContourPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.misc.arrayTools import pairwise from fontTools.pens.filterPen import ContourFilterPen diff --git a/Lib/fontTools/pens/statisticsPen.py b/Lib/fontTools/pens/statisticsPen.py index 7d602067c..abd6ff5e7 100644 --- a/Lib/fontTools/pens/statisticsPen.py +++ b/Lib/fontTools/pens/statisticsPen.py @@ -1,6 +1,5 @@ """Pen calculating area, center of mass, variance and standard-deviation, covariance and correlation, and slant, of glyph shapes.""" -from fontTools.misc.py23 import * import math from fontTools.pens.momentsPen import MomentsPen diff --git a/Lib/fontTools/pens/svgPathPen.py b/Lib/fontTools/pens/svgPathPen.py index 803f3935e..4352ba478 100644 --- a/Lib/fontTools/pens/svgPathPen.py +++ b/Lib/fontTools/pens/svgPathPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/t2CharStringPen.py b/Lib/fontTools/pens/t2CharStringPen.py index 89340d1ee..a4b1d8f67 100644 --- a/Lib/fontTools/pens/t2CharStringPen.py +++ b/Lib/fontTools/pens/t2CharStringPen.py @@ -1,7 +1,6 @@ # Copyright (c) 2009 Type Supply LLC # Author: Tal Leming -from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound from fontTools.misc.psCharStrings import T2CharString from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/pens/teePen.py b/Lib/fontTools/pens/teePen.py index 49420dca5..2f30e922a 100644 --- a/Lib/fontTools/pens/teePen.py +++ b/Lib/fontTools/pens/teePen.py @@ -1,5 +1,4 @@ """Pen multiplexing drawing to one or more pens.""" -from fontTools.misc.py23 import * from fontTools.pens.basePen import AbstractPen diff --git a/Lib/fontTools/pens/transformPen.py b/Lib/fontTools/pens/transformPen.py index 6619ba739..2dcf83b1a 100644 --- a/Lib/fontTools/pens/transformPen.py +++ b/Lib/fontTools/pens/transformPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.filterPen import FilterPen, FilterPointPen diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index 0b64cb380..f493f1acc 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from array import array from fontTools.misc.fixedTools import MAX_F2DOT14, otRound, floatToFixedToFloat from fontTools.pens.basePen import LoggingPen @@ -15,22 +14,34 @@ __all__ = ["TTGlyphPen"] class TTGlyphPen(LoggingPen): """Pen used for drawing to a TrueType glyph. - If `handleOverflowingTransforms` is True, the components' transform values - are checked that they don't overflow the limits of a F2Dot14 number: - -2.0 <= v < +2.0. If any transform value exceeds these, the composite - glyph is decomposed. - An exception to this rule is done for values that are very close to +2.0 - (both for consistency with the -2.0 case, and for the relative frequency - these occur in real fonts). When almost +2.0 values occur (and all other - values are within the range -2.0 <= x <= +2.0), they are clamped to the - maximum positive value that can still be encoded as an F2Dot14: i.e. - 1.99993896484375. - If False, no check is done and all components are translated unmodified - into the glyf table, followed by an inevitable `struct.error` once an - attempt is made to compile them. + This pen can be used to construct or modify glyphs in a TrueType format + font. After using the pen to draw, use the ``.glyph()`` method to retrieve + a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. """ def __init__(self, glyphSet, handleOverflowingTransforms=True): + """Construct a new pen. + + Args: + glyphSet (ttLib._TTGlyphSet): A glyphset object, used to resolve components. + handleOverflowingTransforms (bool): See below. + + If ``handleOverflowingTransforms`` is True, the components' transform values + are checked that they don't overflow the limits of a F2Dot14 number: + -2.0 <= v < +2.0. If any transform value exceeds these, the composite + glyph is decomposed. + + An exception to this rule is done for values that are very close to +2.0 + (both for consistency with the -2.0 case, and for the relative frequency + these occur in real fonts). When almost +2.0 values occur (and all other + values are within the range -2.0 <= x <= +2.0), they are clamped to the + maximum positive value that can still be encoded as an F2Dot14: i.e. + 1.99993896484375. + + If False, no check is done and all components are translated unmodified + into the glyf table, followed by an inevitable ``struct.error`` once an + attempt is made to compile them. + """ self.glyphSet = glyphSet self.handleOverflowingTransforms = handleOverflowingTransforms self.init() @@ -61,6 +72,9 @@ class TTGlyphPen(LoggingPen): assert self._isClosed(), '"move"-type point must begin a new contour.' self._addPoint(pt, 1) + def curveTo(self, *points): + raise NotImplementedError + def qCurveTo(self, *points): assert len(points) >= 1 for pt in points[:-1]: @@ -136,6 +150,7 @@ class TTGlyphPen(LoggingPen): return components def glyph(self, componentFlags=0x4): + """Returns a :py:class:`~._g_l_y_f.Glyph` object representing the glyph.""" assert self._isClosed(), "Didn't close last contour." components = self._buildComponents(componentFlags) diff --git a/Lib/fontTools/pens/wxPen.py b/Lib/fontTools/pens/wxPen.py index 5ff6c4719..1504f0890 100644 --- a/Lib/fontTools/pens/wxPen.py +++ b/Lib/fontTools/pens/wxPen.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import BasePen diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index d78aa8a8c..8162c09c2 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -14,7 +14,7 @@ import sys import struct import array import logging -from collections import Counter +from collections import Counter, defaultdict from types import MethodType __usage__ = "pyftsubset font-file [glyph...] [--option=value]..." @@ -1983,27 +1983,130 @@ def subset_glyphs(self, s): else: assert False, "unknown 'prop' format %s" % prop.Format +def _paint_glyph_names(paint, colr): + result = set() + + def callback(paint): + if paint.Format in { + otTables.PaintFormat.PaintGlyph, + otTables.PaintFormat.PaintColrGlyph, + }: + result.add(paint.Glyph) + + paint.traverse(colr, callback) + return result + @_add_method(ttLib.getTableClass('COLR')) def closure_glyphs(self, s): + if self.version > 0: + # on decompiling COLRv1, we only keep around the raw otTables + # but for subsetting we need dicts with fully decompiled layers; + # we store them temporarily in the C_O_L_R_ instance and delete + # them after we have finished subsetting. + self.ColorLayers = self._decompileColorLayersV0(self.table) + self.ColorLayersV1 = { + rec.BaseGlyph: rec.Paint + for rec in self.table.BaseGlyphV1List.BaseGlyphV1Record + } + decompose = s.glyphs while decompose: layers = set() for g in decompose: - for l in self.ColorLayers.get(g, []): - layers.add(l.name) + for layer in self.ColorLayers.get(g, []): + layers.add(layer.name) + + if self.version > 0: + paint = self.ColorLayersV1.get(g) + if paint is not None: + layers.update(_paint_glyph_names(paint, self.table)) + layers -= s.glyphs s.glyphs.update(layers) decompose = layers @_add_method(ttLib.getTableClass('COLR')) def subset_glyphs(self, s): - self.ColorLayers = {g: self.ColorLayers[g] for g in s.glyphs if g in self.ColorLayers} - return bool(self.ColorLayers) + from fontTools.colorLib.unbuilder import unbuildColrV1 + from fontTools.colorLib.builder import buildColrV1, populateCOLRv0 + + self.ColorLayers = {g: self.ColorLayers[g] for g in s.glyphs if g in self.ColorLayers} + if self.version == 0: + return bool(self.ColorLayers) + + colorGlyphsV1 = unbuildColrV1(self.table.LayerV1List, self.table.BaseGlyphV1List) + self.table.LayerV1List, self.table.BaseGlyphV1List = buildColrV1( + {g: colorGlyphsV1[g] for g in colorGlyphsV1 if g in s.glyphs} + ) + del self.ColorLayersV1 + + layersV0 = self.ColorLayers + if not self.table.BaseGlyphV1List.BaseGlyphV1Record: + # no more COLRv1 glyphs: downgrade to version 0 + self.version = 0 + del self.table + return bool(layersV0) + + if layersV0: + populateCOLRv0( + self.table, + { + g: [(layer.name, layer.colorID) for layer in layersV0[g]] + for g in layersV0 + }, + ) + del self.ColorLayers + + # TODO: also prune ununsed varIndices in COLR.VarStore + return True -# TODO: prune unused palettes @_add_method(ttLib.getTableClass('CPAL')) def prune_post_subset(self, font, options): - return True + colr = font.get("COLR") + if not colr: # drop CPAL if COLR was subsetted to empty + return False + + colors_by_index = defaultdict(list) + + def collect_colors_by_index(paint): + if hasattr(paint, "Color"): # either solid colors... + colors_by_index[paint.Color.PaletteIndex].append(paint.Color) + elif hasattr(paint, "ColorLine"): # ... or gradient color stops + for stop in paint.ColorLine.ColorStop: + colors_by_index[stop.Color.PaletteIndex].append(stop.Color) + + if colr.version == 0: + for layers in colr.ColorLayers.values(): + for layer in layers: + colors_by_index[layer.colorID].append(layer) + else: + if colr.table.LayerRecordArray: + for layer in colr.table.LayerRecordArray.LayerRecord: + colors_by_index[layer.PaletteIndex].append(layer) + for record in colr.table.BaseGlyphV1List.BaseGlyphV1Record: + record.Paint.traverse(colr.table, collect_colors_by_index) + + retained_palette_indices = set(colors_by_index.keys()) + for palette in self.palettes: + palette[:] = [c for i, c in enumerate(palette) if i in retained_palette_indices] + assert len(palette) == len(retained_palette_indices) + + for new_index, old_index in enumerate(sorted(retained_palette_indices)): + for record in colors_by_index[old_index]: + if hasattr(record, "colorID"): # v0 + record.colorID = new_index + elif hasattr(record, "PaletteIndex"): # v1 + record.PaletteIndex = new_index + else: + raise AssertionError(record) + + self.numPaletteEntries = len(self.palettes[0]) + + if self.version == 1: + self.paletteEntryLabels = [ + label for i, label in self.paletteEntryLabels if i in retained_palette_indices + ] + return bool(self.numPaletteEntries) @_add_method(otTables.MathGlyphConstruction) def closure_glyphs(self, glyphs): @@ -2207,7 +2310,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 +2332,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/ttLib/tables/C_O_L_R_.py b/Lib/fontTools/ttLib/tables/C_O_L_R_.py index 7a9442ded..db490520c 100644 --- a/Lib/fontTools/ttLib/tables/C_O_L_R_.py +++ b/Lib/fontTools/ttLib/tables/C_O_L_R_.py @@ -14,9 +14,11 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): ttFont['COLR'][] = will set the color layers for any glyph. """ - def _fromOTTable(self, table): - self.version = 0 - self.ColorLayers = colorLayerLists = {} + @staticmethod + def _decompileColorLayersV0(table): + if not table.LayerRecordArray: + return {} + colorLayerLists = {} layerRecords = table.LayerRecordArray.LayerRecord numLayerRecords = len(layerRecords) for baseRec in table.BaseGlyphRecordArray.BaseGlyphRecord: @@ -31,6 +33,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): LayerRecord(layerRec.LayerGlyph, layerRec.PaletteIndex) ) colorLayerLists[baseGlyph] = layers + return colorLayerLists def _toOTTable(self, ttFont): from . import otTables @@ -61,12 +64,12 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): table = tableClass() table.decompile(reader, ttFont) - if table.Version == 0: - self._fromOTTable(table) + self.version = table.Version + if self.version == 0: + self.ColorLayers = self._decompileColorLayersV0(table) else: # for new versions, keep the raw otTables around self.table = table - self.version = table.Version def compile(self, ttFont): from .otBase import OTTableWriter @@ -120,6 +123,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): self.table = tableClass() self.table.fromXML(name, attrs, content, ttFont) self.table.populateDefaults() + self.version = self.table.Version def __getitem__(self, glyphName): if not isinstance(glyphName, str): diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 1b278410b..28c8cc876 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -59,14 +59,20 @@ def buildConverters(tableSpec, tableNamespace): converterClass = Struct else: converterClass = eval(tp, tableNamespace, converterMapping) - if tp in ('MortChain', 'MortSubtable', 'MorxChain'): + + conv = converterClass(name, repeat, aux) + + if conv.tableClass: + # A "template" such as OffsetTo(AType) knowss the table class already + tableClass = conv.tableClass + elif tp in ('MortChain', 'MortSubtable', 'MorxChain'): tableClass = tableNamespace.get(tp) else: tableClass = tableNamespace.get(tableName) - if tableClass is not None: - conv = converterClass(name, repeat, aux, tableClass=tableClass) - else: - conv = converterClass(name, repeat, aux) + + if not conv.tableClass: + conv.tableClass = tableClass + if name in ["SubTable", "ExtSubTable", "SubStruct"]: conv.lookupTypes = tableNamespace['lookupTypes'] # also create reverse mapping @@ -332,6 +338,18 @@ class NameID(UShort): log.warning("name id %d missing from name table" % value) xmlWriter.newline() +class STATFlags(UShort): + def xmlWrite(self, xmlWriter, font, value, name, attrs): + xmlWriter.simpletag(name, attrs + [("value", value)]) + flags = [] + if value & 0x01: + flags.append("OlderSiblingFontAttribute") + if value & 0x02: + flags.append("ElidableAxisValueName") + if flags: + xmlWriter.write(" ") + xmlWriter.comment(" ".join(flags)) + xmlWriter.newline() class FloatValue(SimpleValue): @staticmethod @@ -1739,7 +1757,6 @@ converterMapping = { "int8": Int8, "int16": Short, "uint8": UInt8, - "uint8": UInt8, "uint16": UShort, "uint24": UInt24, "uint32": ULong, @@ -1764,6 +1781,7 @@ converterMapping = { "LookupFlag": LookupFlag, "ExtendMode": ExtendMode, "CompositeMode": CompositeMode, + "STATFlags": STATFlags, # AAT "CIDGlyphMap": CIDGlyphMap, diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index f260a542a..f1c2c5665 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -872,7 +872,7 @@ otData = [ ('AxisValueFormat1', [ ('uint16', 'Format', None, None, 'Format, = 1'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'Value', None, None, ''), ]), @@ -880,7 +880,7 @@ otData = [ ('AxisValueFormat2', [ ('uint16', 'Format', None, None, 'Format, = 2'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'NominalValue', None, None, ''), ('Fixed', 'RangeMinValue', None, None, ''), @@ -890,7 +890,7 @@ otData = [ ('AxisValueFormat3', [ ('uint16', 'Format', None, None, 'Format, = 3'), ('uint16', 'AxisIndex', None, None, 'Index into the axis record array identifying the axis of design variation to which the axis value record applies.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('Fixed', 'Value', None, None, ''), ('Fixed', 'LinkedValue', None, None, ''), @@ -899,7 +899,7 @@ otData = [ ('AxisValueFormat4', [ ('uint16', 'Format', None, None, 'Format, = 4'), ('uint16', 'AxisCount', None, None, 'The total number of axes contributing to this axis-values combination.'), - ('uint16', 'Flags', None, None, 'Flags.'), + ('STATFlags', 'Flags', None, None, 'Flags.'), ('NameID', 'ValueNameID', None, None, ''), ('struct', 'AxisValueRecord', 'AxisCount', 0, 'Array of AxisValue records that provide the combination of axis values, one for each contributing axis. '), ]), @@ -1588,7 +1588,24 @@ otData = [ ('LOffset', 'Paint', 'LayerCount', 0, 'Array of offsets to Paint tables, from the start of the LayerV1List table.'), ]), + # COLRv1 Affine2x3 uses the same column-major order to serialize a 2D + # Affine Transformation as the one used by fontTools.misc.transform. + # However, for historical reasons, the labels 'xy' and 'yx' are swapped. + # Their fundamental meaning is the same though. + # COLRv1 Affine2x3 follows the names found in FreeType and Cairo. + # In all case, the second element in the 6-tuple correspond to the + # y-part of the x basis vector, and the third to the x-part of the y + # basis vector. + # See https://github.com/googlefonts/colr-gradients-spec/pull/85 ('Affine2x3', [ + ('Fixed', 'xx', None, None, 'x-part of x basis vector'), + ('Fixed', 'yx', None, None, 'y-part of x basis vector'), + ('Fixed', 'xy', None, None, 'x-part of y basis vector'), + ('Fixed', 'yy', None, None, 'y-part of y basis vector'), + ('Fixed', 'dx', None, None, 'Translation in x direction'), + ('Fixed', 'dy', None, None, 'Translation in y direction'), + ]), + ('VarAffine2x3', [ ('VarFixed', 'xx', None, None, 'x-part of x basis vector'), ('VarFixed', 'yx', None, None, 'y-part of x basis vector'), ('VarFixed', 'xy', None, None, 'x-part of y basis vector'), @@ -1598,35 +1615,67 @@ otData = [ ]), ('ColorIndex', [ + ('uint16', 'PaletteIndex', None, None, 'Index value to use with a selected color palette.'), + ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved'), + ]), + ('VarColorIndex', [ ('uint16', 'PaletteIndex', None, None, 'Index value to use with a selected color palette.'), ('VarF2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved'), ]), ('ColorStop', [ - ('VarF2Dot14', 'StopOffset', None, None, ''), + ('F2Dot14', 'StopOffset', None, None, ''), ('ColorIndex', 'Color', None, None, ''), ]), + ('VarColorStop', [ + ('VarF2Dot14', 'StopOffset', None, None, ''), + ('VarColorIndex', 'Color', None, None, ''), + ]), ('ColorLine', [ ('ExtendMode', 'Extend', None, None, 'Enum {PAD = 0, REPEAT = 1, REFLECT = 2}'), ('uint16', 'StopCount', None, None, 'Number of Color stops.'), ('ColorStop', 'ColorStop', 'StopCount', 0, 'Array of Color stops.'), ]), + ('VarColorLine', [ + ('ExtendMode', 'Extend', None, None, 'Enum {PAD = 0, REPEAT = 1, REFLECT = 2}'), + ('uint16', 'StopCount', None, None, 'Number of Color stops.'), + ('VarColorStop', 'ColorStop', 'StopCount', 0, 'Array of Color stops.'), + ]), + # PaintColrLayers ('PaintFormat1', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 1'), ('uint8', 'NumLayers', None, None, 'Number of offsets to Paint to read from LayerV1List.'), ('uint32', 'FirstLayerIndex', None, None, 'Index into LayerV1List.'), ]), + # PaintSolid ('PaintFormat2', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 2'), ('ColorIndex', 'Color', None, None, 'A solid color paint.'), ]), - + # PaintVarSolid ('PaintFormat3', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 3'), - ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of Paint table) to ColorLine subtable.'), + ('VarColorIndex', 'Color', None, None, 'A solid color paint.'), + ]), + + # PaintLinearGradient + ('PaintFormat4', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 4'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintLinearGradient table) to ColorLine subtable.'), + ('int16', 'x0', None, None, ''), + ('int16', 'y0', None, None, ''), + ('int16', 'x1', None, None, ''), + ('int16', 'y1', None, None, ''), + ('int16', 'x2', None, None, ''), + ('int16', 'y2', None, None, ''), + ]), + # PaintVarLinearGradient + ('PaintFormat5', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarLinearGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarInt16', 'x1', None, None, ''), @@ -1635,9 +1684,21 @@ otData = [ ('VarInt16', 'y2', None, None, ''), ]), - ('PaintFormat4', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 4'), - ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of Paint table) to ColorLine subtable.'), + # PaintRadialGradient + ('PaintFormat6', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintRadialGradient table) to ColorLine subtable.'), + ('int16', 'x0', None, None, ''), + ('int16', 'y0', None, None, ''), + ('uint16', 'r0', None, None, ''), + ('int16', 'x1', None, None, ''), + ('int16', 'y1', None, None, ''), + ('uint16', 'r1', None, None, ''), + ]), + # PaintVarRadialGradient + ('PaintFormat7', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarRadialGradient table) to VarColorLine subtable.'), ('VarInt16', 'x0', None, None, ''), ('VarInt16', 'y0', None, None, ''), ('VarUInt16', 'r0', None, None, ''), @@ -1646,25 +1707,105 @@ otData = [ ('VarUInt16', 'r1', None, None, ''), ]), - ('PaintFormat5', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), + # PaintSweepGradient + ('PaintFormat8', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + ('Offset24', 'ColorLine', None, None, 'Offset (from beginning of PaintSweepGradient table) to ColorLine subtable.'), + ('int16', 'centerX', None, None, 'Center x coordinate.'), + ('int16', 'centerY', None, None, 'Center y coordinate.'), + ('Fixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), + ('Fixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), + ]), + # PaintVarSweepGradient + ('PaintFormat9', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), + ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarSweepGradient table) to VarColorLine subtable.'), + ('VarInt16', 'centerX', None, None, 'Center x coordinate.'), + ('VarInt16', 'centerY', None, None, 'Center y coordinate.'), + ('VarFixed', 'startAngle', None, None, 'Start of the angular range of the gradient.'), + ('VarFixed', 'endAngle', None, None, 'End of the angular range of the gradient.'), + ]), + + # PaintGlyph + ('PaintFormat10', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 10'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintGlyph table) to Paint subtable.'), ('GlyphID', 'Glyph', None, None, 'Glyph ID for the source outline.'), ]), - ('PaintFormat6', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 6'), + # PaintColrGlyph + ('PaintFormat11', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 11'), ('GlyphID', 'Glyph', None, None, 'Virtual glyph ID for a BaseGlyphV1List base glyph.'), ]), - ('PaintFormat7', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), - ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransformed table) to Paint subtable.'), - ('Affine2x3', 'Transform', None, None, 'Offset (from beginning of PaintTrasformed table) to Affine2x3 subtable.'), + # PaintTransform + ('PaintFormat12', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 12'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTransform table) to Paint subtable.'), + ('Affine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), + ]), + # PaintVarTransform + ('PaintFormat13', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 13'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarTransform table) to Paint subtable.'), + ('VarAffine2x3', 'Transform', None, None, '2x3 matrix for 2D affine transformations.'), ]), - ('PaintFormat8', [ - ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 8'), + # PaintTranslate + ('PaintFormat14', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 14'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintTranslate table) to Paint subtable.'), + ('Fixed', 'dx', None, None, 'Translation in x direction.'), + ('Fixed', 'dy', None, None, 'Translation in y direction.'), + ]), + # PaintVarTranslate + ('PaintFormat15', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 15'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarTranslate table) to Paint subtable.'), + ('VarFixed', 'dx', None, None, 'Translation in x direction.'), + ('VarFixed', 'dy', None, None, 'Translation in y direction.'), + ]), + + # PaintRotate + ('PaintFormat16', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 16'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintRotate table) to Paint subtable.'), + ('Fixed', 'angle', None, None, ''), + ('Fixed', 'centerX', None, None, ''), + ('Fixed', 'centerY', None, None, ''), + ]), + # PaintVarRotate + ('PaintFormat17', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 17'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarRotate table) to Paint subtable.'), + ('VarFixed', 'angle', None, None, ''), + ('VarFixed', 'centerX', None, None, ''), + ('VarFixed', 'centerY', None, None, ''), + ]), + + # PaintSkew + ('PaintFormat18', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 18'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintSkew table) to Paint subtable.'), + ('Fixed', 'xSkewAngle', None, None, ''), + ('Fixed', 'ySkewAngle', None, None, ''), + ('Fixed', 'centerX', None, None, ''), + ('Fixed', 'centerY', None, None, ''), + ]), + # PaintVarSkew + ('PaintFormat19', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 19'), + ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarSkew table) to Paint subtable.'), + ('VarFixed', 'xSkewAngle', None, None, ''), + ('VarFixed', 'ySkewAngle', None, None, ''), + ('VarFixed', 'centerX', None, None, ''), + ('VarFixed', 'centerY', None, None, ''), + ]), + + # PaintComposite + ('PaintFormat20', [ + ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 20'), ('LOffset24To(Paint)', 'SourcePaint', None, None, 'Offset (from beginning of PaintComposite table) to source Paint subtable.'), ('CompositeMode', 'CompositeMode', None, None, 'A CompositeMode enumeration value.'), ('LOffset24To(Paint)', 'BackdropPaint', None, None, 'Offset (from beginning of PaintComposite table) to backdrop Paint subtable.'), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index 7a04d5aa1..008909bdd 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1324,21 +1324,34 @@ class CompositeMode(IntEnum): HSL_LUMINOSITY = 26 -class Paint(getFormatSwitchingBaseTableClass("uint8")): +class PaintFormat(IntEnum): + PaintColrLayers = 1 + PaintSolid = 2 + PaintVarSolid = 3, + PaintLinearGradient = 4 + PaintVarLinearGradient = 5 + PaintRadialGradient = 6 + PaintVarRadialGradient = 7 + PaintSweepGradient = 8 + PaintVarSweepGradient = 9 + PaintGlyph = 10 + PaintColrGlyph = 11 + PaintTransform = 12 + PaintVarTransform = 13 + PaintTranslate = 14 + PaintVarTranslate = 15 + PaintRotate = 16 + PaintVarRotate = 17 + PaintSkew = 18 + PaintVarSkew = 19 + PaintComposite = 20 - class Format(IntEnum): - PaintColrLayers = 1 - PaintSolid = 2 - PaintLinearGradient = 3 - PaintRadialGradient = 4 - PaintGlyph = 5 - PaintColrGlyph = 6 - PaintTransform = 7 - PaintComposite = 8 + +class Paint(getFormatSwitchingBaseTableClass("uint8")): def getFormatName(self): try: - return self.__class__.Format(self.Format).name + return PaintFormat(self.Format).name except ValueError: raise NotImplementedError(f"Unknown Paint format: {self.Format}") @@ -1354,6 +1367,40 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): xmlWriter.endtag(tableName) xmlWriter.newline() + def getChildren(self, colr): + if self.Format == PaintFormat.PaintColrLayers: + return colr.LayerV1List.Paint[ + self.FirstLayerIndex : self.FirstLayerIndex + self.NumLayers + ] + + if self.Format == PaintFormat.PaintColrGlyph: + for record in colr.BaseGlyphV1List.BaseGlyphV1Record: + if record.BaseGlyph == self.Glyph: + return [record.Paint] + else: + raise KeyError(f"{self.Glyph!r} not in colr.BaseGlyphV1List") + + children = [] + for conv in self.getConverters(): + if conv.tableClass is not None and issubclass(conv.tableClass, type(self)): + children.append(getattr(self, conv.name)) + + return children + + def traverse(self, colr: COLR, callback): + """Depth-first traversal of graph rooted at self, callback on each node.""" + if not callable(callback): + raise TypeError("callback must be callable") + stack = [self] + visited = set() + while stack: + current = stack.pop() + if id(current) in visited: + continue + callback(current) + visited.add(id(current)) + stack.extend(reversed(current.getChildren(colr))) + # For each subtable format there is a class. However, we don't really distinguish # between "field name" and "format name": often these are the same. Yet there's diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index ed1ec5e29..811cf003a 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -700,6 +700,13 @@ class _TTGlyphSet(object): """ def __init__(self, ttFont, glyphs, glyphType): + """Construct a new glyphset. + + Args: + font (TTFont): The font object (used to get metrics). + glyphs (dict): A dictionary mapping glyph names to ``_TTGlyph`` objects. + glyphType (class): Either ``_TTGlyphCFF`` or ``_TTGlyphGlyf``. + """ self._glyphs = glyphs self._hmtx = ttFont['hmtx'] self._vmtx = ttFont['vmtx'] if 'vmtx' in ttFont else None @@ -740,6 +747,13 @@ class _TTGlyph(object): """ def __init__(self, glyphset, glyph, horizontalMetrics, verticalMetrics=None): + """Construct a new _TTGlyph. + + Args: + glyphset (_TTGlyphSet): A glyphset object used to resolve components. + glyph (ttLib.tables._g_l_y_f.Glyph): The glyph object. + horizontalMetrics (int, int): The glyph's width and left sidebearing. + """ self._glyphset = glyphset self._glyph = glyph self.width, self.lsb = horizontalMetrics @@ -749,7 +763,7 @@ class _TTGlyph(object): self.height, self.tsb = None, None def draw(self, pen): - """Draw the glyph onto Pen. See fontTools.pens.basePen for details + """Draw the glyph onto ``pen``. See fontTools.pens.basePen for details how that works. """ self._glyph.draw(pen) diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py index 67b1d1c10..d088b70f6 100644 --- a/Lib/fontTools/ttLib/woff2.py +++ b/Lib/fontTools/ttLib/woff2.py @@ -11,7 +11,7 @@ from fontTools.ttLib import (TTFont, TTLibError, getTableModule, getTableClass, from fontTools.ttLib.sfnt import (SFNTReader, SFNTWriter, DirectoryEntry, WOFFFlavorData, sfntDirectoryFormat, sfntDirectorySize, SFNTDirectoryEntry, sfntDirectoryEntrySize, calcChecksum) -from fontTools.ttLib.tables import ttProgram +from fontTools.ttLib.tables import ttProgram, _g_l_y_f import logging @@ -19,7 +19,10 @@ log = logging.getLogger("fontTools.ttLib.woff2") haveBrotli = False try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli haveBrotli = True except ImportError: pass @@ -931,7 +934,7 @@ class WOFF2GlyfTable(getTableClass('glyf')): flags = array.array('B') triplets = array.array('B') for i in range(len(coordinates)): - onCurve = glyph.flags[i] + onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve x, y = coordinates[i] absX = abs(x) absY = abs(y) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 605fda2a7..dd320b040 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -20,7 +20,7 @@ API *will* change in near future. """ from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound -from fontTools.misc.arrayTools import Vector +from fontTools.misc.vector import Vector from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates diff --git a/Lib/fontTools/varLib/cff.py b/Lib/fontTools/varLib/cff.py index 4e2672b3b..0a6ba220b 100644 --- a/Lib/fontTools/varLib/cff.py +++ b/Lib/fontTools/varLib/cff.py @@ -413,7 +413,7 @@ def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel): # in the PrivatDict, so we will build the default data for vsindex = 0. if not vsindex_dict: key = (True,) * num_masters - _add_new_vsindex(model, key, masterSupports, vsindex_dict, + _add_new_vsindex(masterModel, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList) cvData = CVarData(varDataList=varDataList, masterSupports=masterSupports, vsindex_dict=vsindex_dict) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 76e8cc4af..45f3d8399 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) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer/__init__.py similarity index 96% rename from Lib/fontTools/varLib/instancer.py rename to Lib/fontTools/varLib/instancer/__init__.py index a49b14622..b8f532808 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -84,6 +84,7 @@ from fontTools import subset # noqa: F401 from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger +from fontTools.varLib.instancer import names from contextlib import contextmanager import collections from copy import deepcopy @@ -1008,6 +1009,13 @@ def instantiateSTAT(varfont, axisLimits): ): return # STAT table empty, nothing to do + log.info("Instantiating STAT table") + newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits) + stat.AxisValueArray.AxisValue = newAxisValueTables + stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) + + +def axisValuesFromAxisLimits(stat, axisLimits): location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) def isAxisValueOutsideLimits(axisTag, axisValue): @@ -1019,8 +1027,6 @@ def instantiateSTAT(varfont, axisLimits): return True return False - log.info("Instantiating STAT table") - # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the # exact (nominal) value, or is restricted but the value is within the new range designAxes = stat.DesignAxisRecord.Axis @@ -1050,53 +1056,7 @@ def instantiateSTAT(varfont, axisLimits): else: log.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat) newAxisValueTables.append(axisValueTable) - - stat.AxisValueArray.AxisValue = newAxisValueTables - stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) - - -def getVariationNameIDs(varfont): - used = [] - if "fvar" in varfont: - fvar = varfont["fvar"] - for axis in fvar.axes: - used.append(axis.axisNameID) - for instance in fvar.instances: - used.append(instance.subfamilyNameID) - if instance.postscriptNameID != 0xFFFF: - used.append(instance.postscriptNameID) - if "STAT" in varfont: - stat = varfont["STAT"].table - for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): - used.append(axis.AxisNameID) - for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): - used.append(value.ValueNameID) - # nameIDs <= 255 are reserved by OT spec so we don't touch them - return {nameID for nameID in used if nameID > 255} - - -@contextmanager -def pruningUnusedNames(varfont): - origNameIDs = getVariationNameIDs(varfont) - - yield - - log.info("Pruning name table") - exclude = origNameIDs - getVariationNameIDs(varfont) - varfont["name"].names[:] = [ - record for record in varfont["name"].names if record.nameID not in exclude - ] - if "ltag" in varfont: - # Drop the whole 'ltag' table if all the language-dependent Unicode name - # records that reference it have been dropped. - # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. - # Note ltag can also be used by feat or morx tables, so check those too. - if not any( - record - for record in varfont["name"].names - if record.platformID == 0 and record.langID != 0xFFFF - ): - del varfont["ltag"] + return newAxisValueTables def setMacOverlapFlags(glyfTable): @@ -1187,6 +1147,7 @@ def instantiateVariableFont( inplace=False, optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, + updateFontNames=False, ): """Instantiate variable font, either fully or partially. @@ -1219,6 +1180,11 @@ def instantiateVariableFont( 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. + updateFontNames (bool): if True, update the instantiated font's name table using + the Axis Value Tables from the STAT table. The name table will be updated so + it conforms to the R/I/B/BI model. If the STAT table is missing or + an Axis Value table is missing for a given axis coordinate, a ValueError will + be raised. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool overlap = OverlapMode(int(overlap)) @@ -1234,6 +1200,10 @@ def instantiateVariableFont( if not inplace: varfont = deepcopy(varfont) + if updateFontNames: + log.info("Updating name table") + names.updateNameTable(varfont, axisLimits) + if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1256,7 +1226,7 @@ def instantiateVariableFont( if "avar" in varfont: instantiateAvar(varfont, axisLimits) - with pruningUnusedNames(varfont): + with names.pruningUnusedNames(varfont): if "STAT" in varfont: instantiateSTAT(varfont, axisLimits) @@ -1377,6 +1347,12 @@ def parseArgs(args): help="Merge overlapping contours and components (only applicable " "when generating a full instance). Requires skia-pathops", ) + parser.add_argument( + "--update-name-table", + action="store_true", + help="Update the instantiated font's `name` table. Input font must have " + "a STAT table with Axis Value Tables", + ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." @@ -1428,6 +1404,7 @@ def main(args=None): inplace=True, optimize=options.optimize, overlap=options.overlap, + updateFontNames=options.update_name_table, ) outfile = ( @@ -1443,9 +1420,3 @@ def main(args=None): outfile, ) varfont.save(outfile) - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/__main__.py b/Lib/fontTools/varLib/instancer/__main__.py new file mode 100644 index 000000000..64ffff2b9 --- /dev/null +++ b/Lib/fontTools/varLib/instancer/__main__.py @@ -0,0 +1,5 @@ +import sys +from fontTools.varLib.instancer import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py new file mode 100644 index 000000000..cfe12a94d --- /dev/null +++ b/Lib/fontTools/varLib/instancer/names.py @@ -0,0 +1,379 @@ +"""Helpers for instantiating name table records.""" + +from contextlib import contextmanager +from copy import deepcopy +from enum import IntEnum +import re + + +class NameID(IntEnum): + FAMILY_NAME = 1 + SUBFAMILY_NAME = 2 + UNIQUE_FONT_IDENTIFIER = 3 + FULL_FONT_NAME = 4 + VERSION_STRING = 5 + POSTSCRIPT_NAME = 6 + TYPOGRAPHIC_FAMILY_NAME = 16 + TYPOGRAPHIC_SUBFAMILY_NAME = 17 + VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25 + + +ELIDABLE_AXIS_VALUE_NAME = 2 + + +def getVariationNameIDs(varfont): + used = [] + if "fvar" in varfont: + fvar = varfont["fvar"] + for axis in fvar.axes: + used.append(axis.axisNameID) + for instance in fvar.instances: + used.append(instance.subfamilyNameID) + if instance.postscriptNameID != 0xFFFF: + used.append(instance.postscriptNameID) + if "STAT" in varfont: + stat = varfont["STAT"].table + for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): + used.append(axis.AxisNameID) + for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): + used.append(value.ValueNameID) + # nameIDs <= 255 are reserved by OT spec so we don't touch them + return {nameID for nameID in used if nameID > 255} + + +@contextmanager +def pruningUnusedNames(varfont): + from . import log + + origNameIDs = getVariationNameIDs(varfont) + + yield + + log.info("Pruning name table") + exclude = origNameIDs - getVariationNameIDs(varfont) + varfont["name"].names[:] = [ + record for record in varfont["name"].names if record.nameID not in exclude + ] + if "ltag" in varfont: + # Drop the whole 'ltag' table if all the language-dependent Unicode name + # records that reference it have been dropped. + # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. + # Note ltag can also be used by feat or morx tables, so check those too. + if not any( + record + for record in varfont["name"].names + if record.platformID == 0 and record.langID != 0xFFFF + ): + del varfont["ltag"] + + +def updateNameTable(varfont, axisLimits): + """Update instatiated variable font's name table using STAT AxisValues. + + Raises ValueError if the STAT table is missing or an Axis Value table is + missing for requested axis locations. + + First, collect all STAT AxisValues that match the new default axis locations + (excluding "elided" ones); concatenate the strings in design axis order, + while giving priority to "synthetic" values (Format 4), to form the + typographic subfamily name associated with the new default instance. + Finally, update all related records in the name table, making sure that + legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic, + Bold, Bold Italic) naming model. + + Example: Updating a partial variable font: + | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf") + | >>> updateNameTable(ttFont, {"wght": AxisRange(400, 900), "wdth": 75}) + + The name table records will be updated in the following manner: + NameID 1 familyName: "Open Sans" --> "Open Sans Condensed" + NameID 2 subFamilyName: "Regular" --> "Regular" + NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \ + "3.000;GOOG;OpenSans-Condensed" + NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed" + NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed" + NameID 16 Typographic Family name: None --> "Open Sans" + NameID 17 Typographic Subfamily name: None --> "Condensed" + + References: + https://docs.microsoft.com/en-us/typography/opentype/spec/stat + https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + """ + from . import AxisRange, axisValuesFromAxisLimits + + if "STAT" not in varfont: + raise ValueError("Cannot update name table since there is no STAT table.") + stat = varfont["STAT"].table + if not stat.AxisValueArray: + raise ValueError("Cannot update name table since there are no STAT Axis Values") + fvar = varfont["fvar"] + + # The updated name table will reflect the new 'zero origin' of the font. + # If we're instantiating a partial font, we will populate the unpinned + # axes with their default axis values. + fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} + defaultAxisCoords = deepcopy(axisLimits) + for axisTag, val in fvarDefaults.items(): + if axisTag not in defaultAxisCoords or isinstance( + defaultAxisCoords[axisTag], AxisRange + ): + defaultAxisCoords[axisTag] = val + + axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) + checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) + + # ignore "elidable" axis values, should be omitted in application font menus. + axisValueTables = [ + v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME + ] + axisValueTables = _sortAxisValues(axisValueTables) + _updateNameRecords(varfont, axisValueTables) + + +def checkAxisValuesExist(stat, axisValues, axisCoords): + seen = set() + designAxes = stat.DesignAxisRecord.Axis + for axisValueTable in axisValues: + axisValueFormat = axisValueTable.Format + if axisValueTable.Format in (1, 2, 3): + axisTag = designAxes[axisValueTable.AxisIndex].AxisTag + if axisValueFormat == 2: + axisValue = axisValueTable.NominalValue + else: + axisValue = axisValueTable.Value + if axisTag in axisCoords and axisValue == axisCoords[axisTag]: + seen.add(axisTag) + elif axisValueTable.Format == 4: + for rec in axisValueTable.AxisValueRecord: + axisTag = designAxes[rec.AxisIndex].AxisTag + if axisTag in axisCoords and rec.Value == axisCoords[axisTag]: + seen.add(axisTag) + + missingAxes = set(axisCoords) - seen + if missingAxes: + missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) + raise ValueError(f"Cannot find Axis Values [{missing}]") + + +def _sortAxisValues(axisValues): + # Sort by axis index, remove duplicates and ensure that format 4 AxisValues + # are dominant. + # The MS Spec states: "if a format 1, format 2 or format 3 table has a + # (nominal) value used in a format 4 table that also has values for + # other axes, the format 4 table, being the more specific match, is used", + # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4 + results = [] + seenAxes = set() + # Sort format 4 axes so the tables with the most AxisValueRecords are first + format4 = sorted( + [v for v in axisValues if v.Format == 4], + key=lambda v: len(v.AxisValueRecord), + reverse=True, + ) + + for val in format4: + axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord) + minIndex = min(axisIndexes) + if not seenAxes & axisIndexes: + seenAxes |= axisIndexes + results.append((minIndex, val)) + + for val in axisValues: + if val in format4: + continue + axisIndex = val.AxisIndex + if axisIndex not in seenAxes: + seenAxes.add(axisIndex) + results.append((axisIndex, val)) + + return [axisValue for _, axisValue in sorted(results)] + + +def _updateNameRecords(varfont, axisValues): + # Update nametable based on the axisValues using the R/I/B/BI model. + nametable = varfont["name"] + stat = varfont["STAT"].table + + axisValueNameIDs = [a.ValueNameID for a in axisValues] + ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)] + nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs] + elidedNameID = stat.ElidedFallbackNameID + elidedNameIsRibbi = _isRibbi(nametable, elidedNameID) + + getName = nametable.getName + platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names) + for platform in platforms: + if not all(getName(i, *platform) for i in (1, 2, elidedNameID)): + # Since no family name and subfamily name records were found, + # we cannot update this set of name Records. + continue + + subFamilyName = " ".join( + getName(n, *platform).toUnicode() for n in ribbiNameIDs + ) + if nonRibbiNameIDs: + typoSubFamilyName = " ".join( + getName(n, *platform).toUnicode() for n in axisValueNameIDs + ) + else: + typoSubFamilyName = None + + # If neither subFamilyName and typographic SubFamilyName exist, + # we will use the STAT's elidedFallbackName + if not typoSubFamilyName and not subFamilyName: + if elidedNameIsRibbi: + subFamilyName = getName(elidedNameID, *platform).toUnicode() + else: + typoSubFamilyName = getName(elidedNameID, *platform).toUnicode() + + familyNameSuffix = " ".join( + getName(n, *platform).toUnicode() for n in nonRibbiNameIDs + ) + + _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + *platform, + ) + + +def _isRibbi(nametable, nameID): + englishRecord = nametable.getName(nameID, 3, 1, 0x409) + return ( + True + if englishRecord is not None + and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic") + else False + ) + + +def _updateNameTableStyleRecords( + varfont, + familyNameSuffix, + subFamilyName, + typoSubFamilyName, + platformID=3, + platEncID=1, + langID=0x409, +): + # TODO (Marc F) It may be nice to make this part a standalone + # font renamer in the future. + nametable = varfont["name"] + platform = (platformID, platEncID, langID) + + currentFamilyName = nametable.getName( + NameID.TYPOGRAPHIC_FAMILY_NAME, *platform + ) or nametable.getName(NameID.FAMILY_NAME, *platform) + + currentStyleName = nametable.getName( + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform + ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform) + + if not all([currentFamilyName, currentStyleName]): + raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}") + + currentFamilyName = currentFamilyName.toUnicode() + currentStyleName = currentStyleName.toUnicode() + + nameIDs = { + NameID.FAMILY_NAME: currentFamilyName, + NameID.SUBFAMILY_NAME: subFamilyName or "Regular", + } + if typoSubFamilyName: + nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip() + nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName + nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName + else: + # Remove previous Typographic Family and SubFamily names since they're + # no longer required + for nameID in ( + NameID.TYPOGRAPHIC_FAMILY_NAME, + NameID.TYPOGRAPHIC_SUBFAMILY_NAME, + ): + nametable.removeNames(nameID=nameID) + + newFamilyName = ( + nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME] + ) + newStyleName = ( + nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME] + ) + + nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}" + nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord( + varfont, newFamilyName, newStyleName, platform + ) + + uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform) + if uniqueID: + nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID + + for nameID, string in nameIDs.items(): + assert string, nameID + nametable.setName(string, nameID, *platform) + + if "fvar" not in varfont: + nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX) + + +def _updatePSNameRecord(varfont, familyName, styleName, platform): + # Implementation based on Adobe Technical Note #5902 : + # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf + nametable = varfont["name"] + + family_prefix = nametable.getName( + NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform + ) + if family_prefix: + family_prefix = family_prefix.toUnicode() + else: + family_prefix = familyName + + psName = f"{family_prefix}-{styleName}" + # Remove any characters other than uppercase Latin letters, lowercase + # Latin letters, digits and hyphens. + psName = re.sub(r"[^A-Za-z0-9-]", r"", psName) + + if len(psName) > 127: + # Abbreviating the stylename so it fits within 127 characters whilst + # conforming to every vendor's specification is too complex. Instead + # we simply truncate the psname and add the required "..." + return f"{psName[:124]}..." + return psName + + +def _updateUniqueIdNameRecord(varfont, nameIDs, platform): + nametable = varfont["name"] + currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform) + if not currentRecord: + return None + + # Check if full name and postscript name are a substring of currentRecord + for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME): + nameRecord = nametable.getName(nameID, *platform) + if not nameRecord: + continue + if nameRecord.toUnicode() in currentRecord.toUnicode(): + return currentRecord.toUnicode().replace( + nameRecord.toUnicode(), nameIDs[nameRecord.nameID] + ) + + # Create a new string since we couldn't find any substrings. + fontVersion = _fontVersion(varfont, platform) + achVendID = varfont["OS/2"].achVendID + # Remove non-ASCII characers and trailing spaces + vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip() + psName = nameIDs[NameID.POSTSCRIPT_NAME] + return f"{fontVersion};{vendor};{psName}" + + +def _fontVersion(font, platform=(3, 1, 0x409)): + nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform) + if nameRecord is None: + return f'{font["head"].fontRevision:.3f}' + # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101" + # Also works fine with inputs "Version 1.101" or "1.101" etc + versionNumber = nameRecord.toUnicode().split(";")[0] + return versionNumber.lstrip("Version ").strip() diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py index 04e3f9a1a..39755ad02 100644 --- a/Lib/fontTools/varLib/mutator.py +++ b/Lib/fontTools/varLib/mutator.py @@ -138,7 +138,7 @@ def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc): lsb_delta = 0 else: lsb = boundsPen.bounds[0] - lsb_delta = entry[1] - lsb + lsb_delta = entry[1] - lsb if lsb_delta or width_delta: if width_delta: diff --git a/MANIFEST.in b/MANIFEST.in index 5c4d1274b..31a9c256b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,6 +11,7 @@ include Lib/fontTools/ttLib/tables/table_API_readme.txt include *requirements.txt include tox.ini +include mypy.ini include run-tests.sh recursive-include Lib/fontTools py.typed @@ -39,3 +40,5 @@ recursive-include Tests *.txt README recursive-include Tests *.lwfn *.pfa *.pfb recursive-include Tests *.xml *.designspace *.bin recursive-include Tests *.afm +recursive-include Tests *.json +recursive-include Tests *.ufoz diff --git a/NEWS.rst b/NEWS.rst index be8fb7823..4b0563999 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,100 @@ +4.21.1 (released 2021-02-26) +---------------------------- + +- [pens] Reverted breaking change that turned ``AbstractPen`` and ``AbstractPointPen`` + into abstract base classes (#2164, #2198). + +4.21.0 (released 2021-02-26) +---------------------------- + +- [feaLib] Indent anchor statements in ``asFea()`` to make them more legible and + diff-able (#2193). +- [pens] Turn ``AbstractPen`` and ``AbstractPointPen`` into abstract base classes + (#2164). +- [feaLib] Added support for parsing and building ``STAT`` table from AFDKO feature + files (#2039). +- [instancer] Added option to update name table of generated instance using ``STAT`` + table's axis values (#2189). +- [bezierTools] Added functions to compute bezier point-at-time, as well as line-line, + curve-line and curve-curve intersections (#2192). + +4.20.0 (released 2021-02-15) +---------------------------- + +- [COLRv1] Added ``unbuildColrV1`` to deconstruct COLRv1 otTables to raw json-able + data structure; it does the reverse of ``buildColrV1`` (#2171). +- [feaLib] Allow ``sub X by NULL`` sequence to delete a glyph (#2170). +- [arrayTools] Fixed ``Vector`` division (#2173). +- [COLRv1] Define new ``PaintSweepGradient`` (#2172). +- [otTables] Moved ``Paint.Format`` enum class outside of ``Paint`` class definition, + now named ``PaintFormat``. It was clashing with paint instance ``Format`` attribute + and thus was breaking lazy load of COLR table which relies on magic ``__getattr__`` + (#2175). +- [COLRv1] Replace hand-coded builder functions with otData-driven dynamic + implementation (#2181). +- [COLRv1] Define additional static (non-variable) Paint formats (#2181). +- [subset] Added support for subsetting COLR v1 and CPAL tables (#2174, #2177). +- [fontBuilder] Allow ``setupFvar`` to optionally take ``designspaceLib.AxisDescriptor`` + objects. Added new ``setupAvar`` method. Support localised names for axes and + named instances (#2185). + +4.19.1 (released 2021-01-28) +---------------------------- + +- [woff2] An initial off-curve point with an overlap flag now stays an off-curve + point after compression. + +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) +---------------------------- + +- [COLRv1] Implemented ``PaintTranslate`` paint format (#2129). +- [varLib.cff] Fixed unbound local variable error (#1787). +- [otlLib] Don't crash when creating OpenType class definitions if some glyphs + occur more than once (#2125). + +4.18.1 (released 2020-12-09) +---------------------------- + +- [colorLib] Speed optimization for ``LayerV1ListBuilder`` (#2119). +- [mutator] Fixed missing tab in ``interpolate_cff2_metrics`` (0957dc7a). + +4.18.0 (released 2020-12-04) +---------------------------- + +- [COLRv1] Update to latest draft: added ``PaintRotate`` and ``PaintSkew`` (#2118). +- [woff2] Support new ``brotlicffi`` bindings for PyPy (#2117). +- [glifLib] Added ``expectContentsFile`` parameter to ``GlyphSet``, for use when + reading existing UFOs, to comply with the specification stating that a + ``contents.plist`` file must exist in a glyph set (#2114). +- [subset] Allow ``LangSys`` tags in ``--layout-scripts`` option (#2112). For example: + ``--layout-scripts=arab.dflt,arab.URD,latn``; this will keep ``DefaultLangSys`` + and ``URD`` language for ``arab`` script, and all languages for ``latn`` script. +- [varLib.interpolatable] Allow UFOs to be checked; report open paths, non existant + glyphs; add a ``--json`` option to produce a machine-readable list of + incompatibilities +- [pens] Added ``QuartzPen`` to create ``CGPath`` from glyph outlines on macOS. + Requires pyobjc (#2107). +- [feaLib] You can export ``FONTTOOLS_LOOKUP_DEBUGGING=1`` to enable feature file + debugging info stored in ``Debg`` table (#2106). +- [otlLib] Build more efficient format 1 and format 2 contextual lookups whenever + possible (#2101). + 4.17.1 (released 2020-11-16) ---------------------------- diff --git a/README.rst b/README.rst index 4bc7a3d5d..97d23e4bf 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Travis Build Status| |Appveyor Build status| |Coverage Status| |PyPI| |Gitter Chat| +|CI Build Status| |Coverage Status| |PyPI| |Gitter Chat| What is this? ~~~~~~~~~~~~~ @@ -240,10 +240,8 @@ Rights Reserved. Have fun! -.. |Travis Build Status| image:: https://travis-ci.org/fonttools/fonttools.svg - :target: https://travis-ci.org/fonttools/fonttools -.. |Appveyor Build status| image:: https://ci.appveyor.com/api/projects/status/0f7fmee9as744sl7/branch/master?svg=true - :target: https://ci.appveyor.com/project/fonttools/fonttools/branch/master +.. |CI Build Status| image:: https://github.com/fonttools/fonttools/workflows/Test/badge.svg + :target: https://github.com/fonttools/fonttools/actions?query=workflow%3ATest .. |Coverage Status| image:: https://codecov.io/gh/fonttools/fonttools/branch/master/graph/badge.svg :target: https://codecov.io/gh/fonttools/fonttools .. |PyPI| image:: https://img.shields.io/pypi/v/fonttools.svg diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 86b5f9e92..81da28180 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -1,12 +1,22 @@ 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.table_builder import TableBuilder from fontTools.colorLib.errors import ColorLibError import pytest from typing import List +def _build(cls, source): + return LayerV1ListBuilder().tableBuilder.build(cls, source) + + +def _buildPaint(source): + return LayerV1ListBuilder().buildPaint(source) + + def test_buildCOLR_v0(): color_layer_lists = { "a": [("a.color0", 0), ("a.color1", 1)], @@ -221,191 +231,361 @@ def test_buildCPAL_invalid_color(): builder.buildCPAL([[(0, 0, 0, 0)], [(1, 1, -1, 2)]]) -def test_buildColorIndex(): - c = builder.buildColorIndex(0) - assert c.PaletteIndex == 0 +def test_buildColorIndex_Minimal(): + c = _build(ot.ColorIndex, 1) + assert c.PaletteIndex == 1 + assert c.Alpha == 1.0 + + +def test_buildVarColorIndex_Minimal(): + c = _build(ot.VarColorIndex, 1) + assert c.PaletteIndex == 1 assert c.Alpha.value == 1.0 assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(1, alpha=0.5) - assert c.PaletteIndex == 1 - assert c.Alpha.value == 0.5 - assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(3, alpha=builder.VariableFloat(0.5, varIdx=2)) +def test_buildColorIndex(): + c = _build(ot.ColorIndex, (1, 0.5)) + assert c.PaletteIndex == 1 + assert c.Alpha == 0.5 + + +def test_buildVarColorIndex(): + c = _build(ot.VarColorIndex, (3, builder.VariableFloat(0.5, varIdx=2))) assert c.PaletteIndex == 3 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 2 def test_buildPaintSolid(): - p = LayerV1ListBuilder().buildPaintSolid(0) - assert p.Format == ot.Paint.Format.PaintSolid + p = _buildPaint((ot.PaintFormat.PaintSolid, 0)) + assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 0 - assert p.Color.Alpha.value == 1.0 - assert p.Color.Alpha.varIdx == 0 + assert p.Color.Alpha == 1.0 - p = LayerV1ListBuilder().buildPaintSolid(1, alpha=0.5) - assert p.Format == ot.Paint.Format.PaintSolid + +def test_buildPaintSolid_Alpha(): + p = _buildPaint((ot.PaintFormat.PaintSolid, (1, 0.5))) + assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 1 - assert p.Color.Alpha.value == 0.5 - assert p.Color.Alpha.varIdx == 0 + assert p.Color.Alpha == 0.5 - p = LayerV1ListBuilder().buildPaintSolid( - 3, alpha=builder.VariableFloat(0.5, varIdx=2) + +def test_buildPaintVarSolid(): + p = _buildPaint( + (ot.PaintFormat.PaintVarSolid, (3, builder.VariableFloat(0.5, varIdx=2))) ) - assert p.Format == ot.Paint.Format.PaintSolid + assert p.Format == ot.PaintFormat.PaintVarSolid assert p.Color.PaletteIndex == 3 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 2 -def test_buildColorStop(): - s = builder.buildColorStop(0.1, 2) +def test_buildVarColorStop_DefaultAlpha(): + s = _build(ot.ColorStop, (0.1, 2)) + assert s.StopOffset == 0.1 + assert s.Color.PaletteIndex == 2 + assert s.Color.Alpha == builder._DEFAULT_ALPHA.value + + +def test_buildVarColorStop_DefaultAlpha(): + s = _build(ot.VarColorStop, (0.1, 2)) assert s.StopOffset == builder.VariableFloat(0.1) assert s.Color.PaletteIndex == 2 assert s.Color.Alpha == builder._DEFAULT_ALPHA - s = builder.buildColorStop(offset=0.2, paletteIndex=3, alpha=0.4) - assert s.StopOffset == builder.VariableFloat(0.2) - assert s.Color == builder.buildColorIndex(3, alpha=0.4) - s = builder.buildColorStop( - offset=builder.VariableFloat(0.0, varIdx=1), - paletteIndex=0, - alpha=builder.VariableFloat(0.3, varIdx=2), +def test_buildColorStop(): + s = _build( + ot.ColorStop, {"StopOffset": 0.2, "Color": {"PaletteIndex": 3, "Alpha": 0.4}} + ) + assert s.StopOffset == 0.2 + assert s.Color == _build(ot.ColorIndex, (3, 0.4)) + + +def test_buildColorStop_Variable(): + s = _build( + ot.VarColorStop, + { + "StopOffset": builder.VariableFloat(0.0, varIdx=1), + "Color": { + "PaletteIndex": 0, + "Alpha": builder.VariableFloat(0.3, varIdx=2), + }, + }, ) assert s.StopOffset == builder.VariableFloat(0.0, varIdx=1) assert s.Color.PaletteIndex == 0 assert s.Color.Alpha == builder.VariableFloat(0.3, varIdx=2) -def test_buildColorLine(): +def test_buildColorLine_StopList(): stops = [(0.0, 0), (0.5, 1), (1.0, 2)] - cline = builder.buildColorLine(stops) + cline = _build(ot.ColorLine, {"ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD assert cline.StopCount == 3 - assert [ - (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop - ] == stops + assert [(cs.StopOffset, cs.Color.PaletteIndex) for cs in cline.ColorStop] == stops - cline = builder.buildColorLine(stops, extend="pad") + cline = _build(ot.ColorLine, {"Extend": "pad", "ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REPEAT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REPEAT} + ) assert cline.Extend == builder.ExtendMode.REPEAT - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REFLECT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REFLECT} + ) assert cline.Extend == builder.ExtendMode.REFLECT - cline = builder.buildColorLine([builder.buildColorStop(*s) for s in stops]) - assert [ - (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop - ] == stops + cline = _build( + ot.ColorLine, {"ColorStop": [_build(ot.ColorStop, s) for s in stops]} + ) + assert [(cs.StopOffset, cs.Color.PaletteIndex) for cs in cline.ColorStop] == stops + +def test_buildVarColorLine_StopMap(): stops = [ - {"offset": (0.0, 1), "paletteIndex": 0, "alpha": (0.5, 2)}, - {"offset": (1.0, 3), "paletteIndex": 1, "alpha": (0.3, 4)}, + {"StopOffset": (0.0, (1,)), "Color": {"PaletteIndex": 0, "Alpha": (0.5, 2)}}, + {"StopOffset": (1.0, (3,)), "Color": {"PaletteIndex": 1, "Alpha": (0.3, 4)}}, ] - cline = builder.buildColorLine(stops) + cline = _build(ot.VarColorLine, {"ColorStop": stops}) assert [ { - "offset": cs.StopOffset, - "paletteIndex": cs.Color.PaletteIndex, - "alpha": cs.Color.Alpha, + "StopOffset": cs.StopOffset, + "Color": { + "PaletteIndex": cs.Color.PaletteIndex, + "Alpha": cs.Color.Alpha, + }, } for cs in cline.ColorStop ] == stops +def checkBuildAffine2x3(cls, resultMapFn): + matrix = _build(cls, (1.5, 0, 0.5, 2.0, 1.0, -3.0)) + assert matrix.xx == resultMapFn(1.5) + assert matrix.yx == resultMapFn(0.0) + assert matrix.xy == resultMapFn(0.5) + assert matrix.yy == resultMapFn(2.0) + assert matrix.dx == resultMapFn(1.0) + assert matrix.dy == resultMapFn(-3.0) + + def test_buildAffine2x3(): - matrix = builder.buildAffine2x3((1.5, 0, 0.5, 2.0, 1.0, -3.0)) - assert matrix.xx == builder.VariableFloat(1.5) - assert matrix.yx == builder.VariableFloat(0.0) - assert matrix.xy == builder.VariableFloat(0.5) - assert matrix.yy == builder.VariableFloat(2.0) - assert matrix.dx == builder.VariableFloat(1.0) - assert matrix.dy == builder.VariableFloat(-3.0) + checkBuildAffine2x3(ot.Affine2x3, lambda v: v) + + +def test_buildVarAffine2x3(): + checkBuildAffine2x3(ot.VarAffine2x3, builder.VariableFloat) + + +def _sample_stops(cls): + return [ + _build(cls, (0.0, 0)), + _build(cls, (0.5, 1)), + _build(cls, (1.0, (2, 0.8))), + ] + + +def _is_var(fmt): + return fmt.name.startswith("PaintVar") + + +def checkBuildPaintLinearGradient(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + color_stops = _sample_stops(ot.VarColorStop) + else: + inputMapFn = outputMapFn = lambda v: v + color_stops = _sample_stops(ot.ColorStop) + + x0, y0, x1, y1, x2, y2 = tuple(inputMapFn(v) for v in (1, 2, 3, 4, 5, 6)) + gradient = _buildPaint( + { + "Format": fmt, + "ColorLine": {"ColorStop": color_stops}, + "x0": x0, + "y0": y0, + "x1": x1, + "y1": y1, + "x2": x2, + "y2": y2, + }, + ) + assert gradient.ColorLine.Extend == builder.ExtendMode.PAD + assert gradient.ColorLine.ColorStop == color_stops + + gradient = _buildPaint(gradient) + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == (1, 2) + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == (3, 4) + assert (outputMapFn(gradient.x2), outputMapFn(gradient.y2)) == (5, 6) def test_buildPaintLinearGradient(): - layerBuilder = LayerV1ListBuilder() - color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), - ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) - p0 = (builder.VariableInt(100), builder.VariableInt(200)) - p1 = (builder.VariableInt(150), builder.VariableInt(250)) + assert not _is_var(ot.PaintFormat.PaintLinearGradient) + checkBuildPaintLinearGradient(ot.PaintFormat.PaintLinearGradient) - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1) - assert gradient.Format == 3 + +def test_buildVarPaintLinearGradient(): + assert _is_var(ot.PaintFormat.PaintVarLinearGradient) + checkBuildPaintLinearGradient(ot.PaintFormat.PaintVarLinearGradient) + + +def checkBuildPaintRadialGradient(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v + color_stops = _sample_stops(ot.VarColorStop) + line_cls = ot.VarColorLine + else: + inputMapFn = outputMapFn = lambda v: v + color_stops = _sample_stops(ot.ColorStop) + line_cls = ot.ColorLine + + color_line = _build( + line_cls, {"ColorStop": color_stops, "Extend": builder.ExtendMode.REPEAT} + ) + c0 = (inputMapFn(100), inputMapFn(200)) + c1 = (inputMapFn(150), inputMapFn(250)) + r0 = inputMapFn(10) + r1 = inputMapFn(5) + + gradient = _build(ot.Paint, (fmt, color_line, *c0, r0, *c1, r1)) + assert gradient.Format == fmt assert gradient.ColorLine == color_line - assert (gradient.x0, gradient.y0) == p0 - assert (gradient.x1, gradient.y1) == p1 - assert (gradient.x2, gradient.y2) == p1 + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == c0 + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == c1 + assert outputMapFn(gradient.r0) == r0 + assert outputMapFn(gradient.r1) == r1 - gradient = layerBuilder.buildPaintLinearGradient({"stops": color_stops}, p0, p1) + gradient = _build( + ot.Paint, + { + "Format": fmt, + "ColorLine": {"ColorStop": color_stops}, + "x0": c0[0], + "y0": c0[1], + "x1": c1[0], + "y1": c1[1], + "r0": r0, + "r1": r1, + }, + ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops - - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1, p2=(150, 230)) - assert (gradient.x2.value, gradient.y2.value) == (150, 230) - assert (gradient.x2, gradient.y2) != (gradient.x1, gradient.y1) + assert (outputMapFn(gradient.x0), outputMapFn(gradient.y0)) == c0 + assert (outputMapFn(gradient.x1), outputMapFn(gradient.y1)) == c1 + assert outputMapFn(gradient.r0) == r0 + assert outputMapFn(gradient.r1) == r1 def test_buildPaintRadialGradient(): - layerBuilder = LayerV1ListBuilder() - color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), - ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) - c0 = (builder.VariableInt(100), builder.VariableInt(200)) - c1 = (builder.VariableInt(150), builder.VariableInt(250)) - r0 = builder.VariableInt(10) - r1 = builder.VariableInt(5) + assert not _is_var(ot.PaintFormat.PaintRadialGradient) + checkBuildPaintRadialGradient(ot.PaintFormat.PaintRadialGradient) - gradient = layerBuilder.buildPaintRadialGradient(color_line, c0, c1, r0, r1) - assert gradient.Format == ot.Paint.Format.PaintRadialGradient - assert gradient.ColorLine == color_line - assert (gradient.x0, gradient.y0) == c0 - assert (gradient.x1, gradient.y1) == c1 - assert gradient.r0 == r0 - assert gradient.r1 == r1 - gradient = layerBuilder.buildPaintRadialGradient( - {"stops": color_stops}, c0, c1, r0, r1 +def test_buildPaintVarRadialGradient(): + assert _is_var(ot.PaintFormat.PaintVarRadialGradient) + checkBuildPaintRadialGradient(ot.PaintFormat.PaintVarRadialGradient) + + +def checkPaintSweepGradient(fmt): + if _is_var(fmt): + outputMapFn = lambda v: v.value + else: + outputMapFn = lambda v: v + + paint = _buildPaint( + { + "Format": fmt, + "ColorLine": { + "ColorStop": ( + (0.0, 0), + (0.5, 1), + (1.0, (2, 0.8)), + ) + }, + "centerX": 127, + "centerY": 129, + "startAngle": 15, + "endAngle": 42, + } ) - assert gradient.ColorLine.Extend == builder.ExtendMode.PAD - assert gradient.ColorLine.ColorStop == color_stops + + assert paint.Format == fmt + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 + assert outputMapFn(paint.startAngle) == 15 + assert outputMapFn(paint.endAngle) == 42 + + +def test_buildPaintSweepGradient(): + assert not _is_var(ot.PaintFormat.PaintSweepGradient) + checkPaintSweepGradient(ot.PaintFormat.PaintSweepGradient) + + +def test_buildPaintVarSweepGradient(): + assert _is_var(ot.PaintFormat.PaintVarSweepGradient) + checkPaintSweepGradient(ot.PaintFormat.PaintVarSweepGradient) def test_buildPaintGlyph_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", 2) - assert layer.Glyph == "a" - assert layer.Paint.Format == ot.Paint.Format.PaintSolid - assert layer.Paint.Color.PaletteIndex == 2 - - layer = layerBuilder.buildPaintGlyph("a", layerBuilder.buildPaintSolid(3, 0.9)) - assert layer.Paint.Format == ot.Paint.Format.PaintSolid - assert layer.Paint.Color.PaletteIndex == 3 - assert layer.Paint.Color.Alpha.value == 0.9 - - -def test_buildPaintGlyph_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintLinearGradient( - {"stops": [(0.0, 3), (1.0, 4)]}, (100, 200), (150, 250) + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + 2, + ), + "a", ), ) - assert layer.Paint.Format == ot.Paint.Format.PaintLinearGradient + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" + assert layer.Paint.Format == ot.PaintFormat.PaintSolid + assert layer.Paint.Color.PaletteIndex == 2 + + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + (3, 0.9), + ), + "a", + ), + ) + assert layer.Paint.Format == ot.PaintFormat.PaintSolid + assert layer.Paint.Color.PaletteIndex == 3 + assert layer.Paint.Color.Alpha == 0.9 + + +def test_buildPaintGlyph_VarLinearGradient(): + layer = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintVarLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 3), (1.0, 4)]}, + "x0": 100, + "y0": 200, + "x1": 150, + "y1": 250, + }, + }, + ) + + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" + assert layer.Paint.Format == ot.PaintFormat.PaintVarLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 3 assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 1.0 @@ -417,181 +597,387 @@ def test_buildPaintGlyph_LinearGradient(): def test_buildPaintGlyph_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintRadialGradient( - { - "stops": [ - (0.0, 5), - {"offset": 0.5, "paletteIndex": 6, "alpha": 0.8}, - (1.0, 7), - ] - }, - (50, 50), - (75, 75), - 30, - 10, + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + ( + ot.PaintFormat.PaintRadialGradient, + ( + "pad", + [ + (0.0, 5), + {"StopOffset": 0.5, "Color": {"PaletteIndex": 6, "Alpha": 0.8}}, + (1.0, 7), + ], + ), + 50, + 50, + 30, + 75, + 75, + 10, + ), + "a", ), ) - assert layer.Paint.Format == ot.Paint.Format.PaintRadialGradient - assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient + assert layer.Paint.ColorLine.ColorStop[0].StopOffset == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 5 - assert layer.Paint.ColorLine.ColorStop[1].StopOffset.value == 0.5 + assert layer.Paint.ColorLine.ColorStop[1].StopOffset == 0.5 assert layer.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 6 - assert layer.Paint.ColorLine.ColorStop[1].Color.Alpha.value == 0.8 - assert layer.Paint.ColorLine.ColorStop[2].StopOffset.value == 1.0 + assert layer.Paint.ColorLine.ColorStop[1].Color.Alpha == 0.8 + assert layer.Paint.ColorLine.ColorStop[2].StopOffset == 1.0 assert layer.Paint.ColorLine.ColorStop[2].Color.PaletteIndex == 7 - assert layer.Paint.x0.value == 50 - assert layer.Paint.y0.value == 50 - assert layer.Paint.r0.value == 30 - assert layer.Paint.x1.value == 75 - assert layer.Paint.y1.value == 75 - assert layer.Paint.r1.value == 10 + assert layer.Paint.x0 == 50 + assert layer.Paint.y0 == 50 + assert layer.Paint.r0 == 30 + assert layer.Paint.x1 == 75 + assert layer.Paint.y1 == 75 + assert layer.Paint.r1 == 10 def test_buildPaintGlyph_Dict_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", {"format": 2, "paletteIndex": 0}) + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + (int(ot.PaintFormat.PaintSolid), 1), + "a", + ), + ) + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" - assert layer.Paint.Format == ot.Paint.Format.PaintSolid - assert layer.Paint.Color.PaletteIndex == 0 + assert layer.Paint.Format == ot.PaintFormat.PaintSolid + assert layer.Paint.Color.PaletteIndex == 1 -def test_buildPaintGlyph_Dict_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", +def test_buildPaintGlyph_Dict_VarLinearGradient(): + layer = _build( + ot.Paint, { - "format": 3, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "p0": (0, 0), - "p1": (10, 10), + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": int(ot.PaintFormat.PaintVarLinearGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "x1": 10, + "y1": 10, + }, }, ) - assert layer.Paint.Format == ot.Paint.Format.PaintLinearGradient + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" + assert layer.Paint.Format == ot.PaintFormat.PaintVarLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 def test_buildPaintGlyph_Dict_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", + layer = _buildPaint( { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (0, 0), - "c1": (10, 10), - "r0": 4, - "r1": 0, + "Glyph": "a", + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "r0": 4, + "x1": 10, + "y1": 10, + "r1": 0, + }, + "Format": int(ot.PaintFormat.PaintGlyph), }, ) - assert layer.Paint.Format == ot.Paint.Format.PaintRadialGradient - assert layer.Paint.r0.value == 4 + assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient + assert layer.Paint.r0 == 4 def test_buildPaintColrGlyph(): - paint = LayerV1ListBuilder().buildPaintColrGlyph("a") - assert paint.Format == ot.Paint.Format.PaintColrGlyph + paint = _buildPaint((int(ot.PaintFormat.PaintColrGlyph), "a")) + assert paint.Format == ot.PaintFormat.PaintColrGlyph assert paint.Glyph == "a" -def test_buildPaintTransform(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintTransform( - transform=builder.buildAffine2x3((1, 2, 3, 4, 5, 6)), - paint=layerBuilder.buildPaintGlyph( - glyph="a", - paint=layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0), +def checkBuildPaintTransform(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableFloat + outputMapFn = lambda v: v.value + affine_cls = ot.VarAffine2x3 + else: + inputMapFn = outputMapFn = lambda v: v + affine_cls = ot.Affine2x3 + + paint = _buildPaint( + ( + int(fmt), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), "a"), + _build(affine_cls, (1, 2, 3, 4, 5, 6)), ), ) - assert paint.Format == ot.Paint.Format.PaintTransform - assert paint.Paint.Format == ot.Paint.Format.PaintGlyph - assert paint.Paint.Paint.Format == ot.Paint.Format.PaintSolid + assert paint.Format == fmt + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph + assert paint.Paint.Paint.Format == ot.PaintFormat.PaintSolid - assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 2.0 - assert paint.Transform.xy.value == 3.0 - assert paint.Transform.yy.value == 4.0 - assert paint.Transform.dx.value == 5.0 - assert paint.Transform.dy.value == 6.0 + assert outputMapFn(paint.Transform.xx) == 1.0 + assert outputMapFn(paint.Transform.yx) == 2.0 + assert outputMapFn(paint.Transform.xy) == 3.0 + assert outputMapFn(paint.Transform.yy) == 4.0 + assert outputMapFn(paint.Transform.dx) == 5.0 + assert outputMapFn(paint.Transform.dy) == 6.0 - paint = layerBuilder.buildPaintTransform( - (1, 0, 0, 0.3333, 10, 10), + paint = _build( + ot.Paint, { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (100, 100), - "c1": (100, 100), - "r0": 0, - "r1": 50, + "Format": fmt, + "Transform": (1, 2, 3, 0.3333, 10, 10), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 100, + "y0": 101, + "x1": 102, + "y1": 103, + "r0": 0, + "r1": 50, + }, }, ) - assert paint.Format == ot.Paint.Format.PaintTransform - assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 0.0 - assert paint.Transform.xy.value == 0.0 - assert paint.Transform.yy.value == 0.3333 - assert paint.Transform.dx.value == 10 - assert paint.Transform.dy.value == 10 - assert paint.Paint.Format == ot.Paint.Format.PaintRadialGradient + assert paint.Format == fmt + assert outputMapFn(paint.Transform.xx) == 1.0 + assert outputMapFn(paint.Transform.yx) == 2.0 + assert outputMapFn(paint.Transform.xy) == 3.0 + assert outputMapFn(paint.Transform.yy) == 0.3333 + assert outputMapFn(paint.Transform.dx) == 10 + assert outputMapFn(paint.Transform.dy) == 10 + assert paint.Paint.Format == ot.PaintFormat.PaintRadialGradient + + +def test_buildPaintTransform(): + assert not _is_var(ot.PaintFormat.PaintTransform) + checkBuildPaintTransform(ot.PaintFormat.PaintTransform) + + +def test_buildPaintVarTransform(): + assert _is_var(ot.PaintFormat.PaintVarTransform) + checkBuildPaintTransform(ot.PaintFormat.PaintVarTransform) def test_buildPaintComposite(): - layerBuilder = LayerV1ListBuilder() - composite = layerBuilder.buildPaintComposite( - mode=ot.CompositeMode.SRC_OVER, - source={ - "format": 8, - "mode": "src_over", - "source": {"format": 5, "glyph": "c", "paint": 2}, - "backdrop": {"format": 5, "glyph": "b", "paint": 1}, + composite = _build( + ot.Paint, + { + "Format": int(ot.PaintFormat.PaintComposite), + "CompositeMode": "src_over", + "SourcePaint": { + "Format": ot.PaintFormat.PaintComposite, + "CompositeMode": "src_over", + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "c", + "Paint": (ot.PaintFormat.PaintSolid, 2), + }, + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "b", + "Paint": (ot.PaintFormat.PaintSolid, 1), + }, + }, + "BackdropPaint": { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": (0, 1.0), + }, + }, }, - backdrop=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), ) - assert composite.Format == ot.Paint.Format.PaintComposite - assert composite.SourcePaint.Format == ot.Paint.Format.PaintComposite - assert composite.SourcePaint.SourcePaint.Format == ot.Paint.Format.PaintGlyph + assert composite.Format == ot.PaintFormat.PaintComposite + assert composite.SourcePaint.Format == ot.PaintFormat.PaintComposite + assert composite.SourcePaint.SourcePaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.SourcePaint.Glyph == "c" - assert composite.SourcePaint.SourcePaint.Paint.Format == ot.Paint.Format.PaintSolid + assert composite.SourcePaint.SourcePaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.SourcePaint.SourcePaint.Paint.Color.PaletteIndex == 2 assert composite.SourcePaint.CompositeMode == ot.CompositeMode.SRC_OVER - assert composite.SourcePaint.BackdropPaint.Format == ot.Paint.Format.PaintGlyph + assert composite.SourcePaint.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.BackdropPaint.Glyph == "b" - assert ( - composite.SourcePaint.BackdropPaint.Paint.Format == ot.Paint.Format.PaintSolid - ) + assert composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.SourcePaint.BackdropPaint.Paint.Color.PaletteIndex == 1 assert composite.CompositeMode == ot.CompositeMode.SRC_OVER - assert composite.BackdropPaint.Format == ot.Paint.Format.PaintGlyph + assert composite.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.BackdropPaint.Glyph == "a" - assert composite.BackdropPaint.Paint.Format == ot.Paint.Format.PaintSolid + assert composite.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.BackdropPaint.Paint.Color.PaletteIndex == 0 +def checkBuildPaintTranslate(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + + paint = _build( + ot.Paint, + { + "Format": fmt, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "dx": 123, + "dy": -345, + }, + ) + + assert paint.Format == fmt + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph + assert outputMapFn(paint.dx) == 123 + assert outputMapFn(paint.dy) == -345 + + +def test_buildPaintTranslate(): + assert not _is_var(ot.PaintFormat.PaintTranslate) + checkBuildPaintTranslate(ot.PaintFormat.PaintTranslate) + + +def test_buildPaintVarTranslate(): + assert _is_var(ot.PaintFormat.PaintVarTranslate) + checkBuildPaintTranslate(ot.PaintFormat.PaintVarTranslate) + + +def checkBuildPaintRotate(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + + paint = _build( + ot.Paint, + { + "Format": fmt, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "angle": 15, + "centerX": 127, + "centerY": 129, + }, + ) + + assert paint.Format == fmt + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph + assert outputMapFn(paint.angle) == 15 + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 + + +def test_buildPaintRotate(): + assert not _is_var(ot.PaintFormat.PaintRotate) + checkBuildPaintRotate(ot.PaintFormat.PaintRotate) + + +def test_buildPaintVarRotate(): + assert _is_var(ot.PaintFormat.PaintVarRotate) + checkBuildPaintRotate(ot.PaintFormat.PaintVarRotate) + + +def checkBuildPaintSkew(fmt): + if _is_var(fmt): + inputMapFn = builder.VariableInt + outputMapFn = lambda v: v.value + else: + inputMapFn = outputMapFn = lambda v: v + + paint = _build( + ot.Paint, + { + "Format": fmt, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "xSkewAngle": 15, + "ySkewAngle": 42, + "centerX": 127, + "centerY": 129, + }, + ) + + assert paint.Format == fmt + assert paint.Paint.Format == ot.PaintFormat.PaintGlyph + assert outputMapFn(paint.xSkewAngle) == 15 + assert outputMapFn(paint.ySkewAngle) == 42 + assert outputMapFn(paint.centerX) == 127 + assert outputMapFn(paint.centerY) == 129 + + +def test_buildPaintSkew(): + assert not _is_var(ot.PaintFormat.PaintSkew) + checkBuildPaintSkew(ot.PaintFormat.PaintSkew) + + +def test_buildPaintVarSkew(): + assert _is_var(ot.PaintFormat.PaintVarSkew) + checkBuildPaintSkew(ot.PaintFormat.PaintVarSkew) + + def test_buildColrV1(): colorGlyphs = { - "a": [("b", 0), ("c", 1)], - "d": [ - ("e", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ( - "f", - { - "format": 4, - "colorLine": {"stops": [(0.0, 3), (1.0, 4)], "extend": "reflect"}, - "c0": (0, 0), - "c1": (0, 0), - "r0": 10, - "r1": 0, - }, - ), - ], - "g": [("h", 5)], + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintVarSolid, 1), "c"), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": 0.8}, + }, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + { + "Format": int(ot.PaintFormat.PaintVarRadialGradient), + "ColorLine": { + "ColorStop": [(0.0, 3), (1.0, 4)], + "Extend": "reflect", + }, + "x0": 0, + "y0": 0, + "x1": 0, + "y1": 0, + "r0": 10, + "r1": 0, + }, + "f", + ), + ], + ), + "g": ( + ot.PaintFormat.PaintColrLayers, + [(ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 5), "h")], + ), } glyphMap = { ".notdef": 0, @@ -619,6 +1005,46 @@ def test_buildColrV1(): assert baseGlyphs.BaseGlyphV1Record[2].BaseGlyph == "g" +def test_buildColrV1_more_than_255_paints(): + num_paints = 364 + colorGlyphs = { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": (ot.PaintFormat.PaintSolid, 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.PaintFormat.PaintGlyph for i in range(255)) + + assert paints[255].Format == ot.PaintFormat.PaintColrLayers + assert paints[255].FirstLayerIndex == 0 + assert paints[255].NumLayers == 255 + + assert all( + paints[i].Format == ot.PaintFormat.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.PaintFormat.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 = { @@ -635,9 +1061,7 @@ def test_split_color_glyphs_by_version(): assert colorGlyphsV0 == {"a": [("b", 0), ("c", 1), ("d", 2), ("e", 3)]} assert not colorGlyphsV1 - colorGlyphs = { - "a": [("b", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=0.0))] - } + colorGlyphs = {"a": (ot.PaintFormat.PaintGlyph, 0, "b")} colorGlyphsV0, colorGlyphsV1 = builder._split_color_glyphs_by_version(colorGlyphs) @@ -683,32 +1107,35 @@ def assertNoV0Content(colr): def test_build_layerv1list_empty(): - # Nobody uses PaintColrLayers (format 8), no layerlist + # Nobody uses PaintColrLayers, no layerlist colr = builder.buildCOLR( { - "a": { - "format": 5, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": "b", - }, - # A list of 1 shouldn't become a PaintColrLayers - "b": [ - { - "format": 5, # PaintGlyph - "paint": { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", - }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), + # BaseGlyph, tuple form + "a": ( + int(ot.PaintFormat.PaintGlyph), + (2, (2, 0.8)), + "b", + ), + # BaseGlyph, map form + "b": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", }, - "glyph": "bb", - } - ], - } + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "Glyph": "bb", + }, + }, + version=1, ) assertIsColrV1(colr) @@ -726,9 +1153,9 @@ def _paint_names(paints) -> List[str]: # semi-readable assertions on a LayerV1List order. result = [] for paint in paints: - if paint.Format == int(ot.Paint.Format.PaintGlyph): + if paint.Format == int(ot.PaintFormat.PaintGlyph): result.append(paint.Glyph) - elif paint.Format == int(ot.Paint.Format.PaintColrLayers): + elif paint.Format == int(ot.PaintFormat.PaintColrLayers): result.append( f"Layers[{paint.FirstLayerIndex}:{paint.FirstLayerIndex+paint.NumLayers}]" ) @@ -738,35 +1165,42 @@ def _paint_names(paints) -> List[str]: def test_build_layerv1list_simple(): # Two colr glyphs, each with two layers the first of which is common # All layers use the same solid paint - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}} backdrop = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "back", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back", } a_foreground = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "a_fore", } b_foreground = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "b_fore", } - # list => PaintColrLayers, which means contents should be in LayerV1List + # list => PaintColrLayers, contents should land in LayerV1List colr = builder.buildCOLR( { - "a": [ - backdrop, - a_foreground, - ], - "b": [ - backdrop, - b_foreground, - ], - } + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + backdrop, + a_foreground, + ], + ), + "b": { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [ + backdrop, + b_foreground, + ], + }, + }, + version=1, ) assertIsColrV1(colr) @@ -787,47 +1221,51 @@ def test_build_layerv1list_simple(): def test_build_layerv1list_with_sharing(): # Three colr glyphs, each with two layers in common - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": (2, 0.8)} backdrop = [ { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "back1", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back1", }, { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "back2", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "back2", }, ] a_foreground = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "a_fore", } b_background = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "b_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_back", } b_foreground = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_fore", } c_background = { - "format": 5, # PaintGlyph - "paint": solid_paint, - "glyph": "c_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "c_back", } # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": backdrop + [a_foreground], - "b": [b_background] + backdrop + [b_foreground], - "c": [c_background] + backdrop, - } + "a": (ot.PaintFormat.PaintColrLayers, backdrop + [a_foreground]), + "b": ( + ot.PaintFormat.PaintColrLayers, + [b_background] + backdrop + [b_foreground], + ), + "c": (ot.PaintFormat.PaintColrLayers, [c_background] + backdrop), + }, + version=1, ) assertIsColrV1(colr) @@ -859,9 +1297,12 @@ def test_build_layerv1list_with_sharing(): def test_build_layerv1list_with_overlaps(): paints = [ { - "format": 5, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": c, + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": {"PaletteIndex": 2, "Alpha": 0.8}, + }, + "Glyph": c, } for c in "abcdefghi" ] @@ -869,10 +1310,11 @@ def test_build_layerv1list_with_overlaps(): # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": paints[0:4], - "b": paints[0:6], - "c": paints[2:8], - } + "a": (ot.PaintFormat.PaintColrLayers, paints[0:4]), + "b": (ot.PaintFormat.PaintColrLayers, paints[0:6]), + "c": (ot.PaintFormat.PaintColrLayers, paints[2:8]), + }, + version=1, ) assertIsColrV1(colr) @@ -902,6 +1344,26 @@ def test_build_layerv1list_with_overlaps(): assert colr.table.LayerV1List.LayerCount == 11 +def test_explicit_version_1(): + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + ], + ) + }, + version=1, + ) + assert colr.version == 1 + assert not hasattr(colr, "ColorLayers") + assert hasattr(colr, "table") + assert isinstance(colr.table, ot.COLR) + assert colr.table.VarStore is None + + class BuildCOLRTest(object): def test_automatic_version_all_solid_color_glyphs(self): colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}) @@ -913,38 +1375,55 @@ class BuildCOLRTest(object): def test_automatic_version_no_solid_color_glyphs(self): colr = builder.buildCOLR( { - "a": [ - ( - "b", - { - "format": 4, - "colorLine": { - "stops": [(0.0, 0), (1.0, 1)], - "extend": "repeat", + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "ColorStop": [(0.0, 0), (1.0, 1)], + "Extend": "repeat", + }, + "x0": 1, + "y0": 0, + "x1": 10, + "y1": 0, + "r0": 4, + "r1": 2, }, - "c0": (1, 0), - "c1": (10, 0), - "r0": 4, - "r1": 2, - }, - ), - ("c", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], - "d": [ - ( - "e", + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + "c", + ), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "e", + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", + }, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ], + } + ], + ), } ) assertIsColrV1(colr) @@ -957,19 +1436,30 @@ class BuildCOLRTest(object): colr = builder.buildCOLR( { "a": [("b", 0), ("c", 1)], - "d": [ - ( - "e", - { - "format": 3, - "colorLine": {"stops": [(0.0, 2), (1.0, 3)]}, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ("f", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 2), (1.0, 3)]}, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (2, 0.8)), + "f", + ), + ], + ), } ) assertIsColrV1(colr) @@ -995,9 +1485,178 @@ class BuildCOLRTest(object): assert hasattr(colr, "ColorLayers") def test_explicit_version_1(self): - colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}, version=1) + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 1), + "c", + ), + ], + ) + }, + version=1, + ) assert colr.version == 1 assert not hasattr(colr, "ColorLayers") assert hasattr(colr, "table") assert isinstance(colr.table, ot.COLR) assert colr.table.VarStore is None + + def test_paint_one_colr_layers(self): + # A set of one layers should flip to just that layer + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ], + ) + }, + ) + + assert len(colr.table.LayerV1List.Paint) == 0, "PaintColrLayers should be gone" + assert colr.table.BaseGlyphV1List.BaseGlyphCount == 1 + paint = colr.table.BaseGlyphV1List.BaseGlyphV1Record[0].Paint + assert paint.Format == ot.PaintFormat.PaintGlyph + assert paint.Paint.Format == ot.PaintFormat.PaintSolid + + +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/colorLib/table_builder_test.py b/Tests/colorLib/table_builder_test.py new file mode 100644 index 000000000..d0a76f5ad --- /dev/null +++ b/Tests/colorLib/table_builder_test.py @@ -0,0 +1,15 @@ +from fontTools.ttLib.tables import otTables # trigger setup to occur +from fontTools.ttLib.tables.otConverters import UShort +from fontTools.colorLib.table_builder import TableBuilder +import pytest + + +class WriteMe: + value = None + + +def test_intValue_otRound(): + dest = WriteMe() + converter = UShort("value", None, None) + TableBuilder()._convert(dest, "value", converter, 85.6) + assert dest.value == 86, "Should have used otRound" diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py new file mode 100644 index 000000000..81169e03a --- /dev/null +++ b/Tests/colorLib/unbuilder_test.py @@ -0,0 +1,210 @@ +from fontTools.ttLib.tables import otTables as ot +from fontTools.colorLib.builder import buildColrV1 +from fontTools.colorLib.unbuilder import unbuildColrV1 +import pytest + + +TEST_COLOR_GLYPHS = { + "glyph00010": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": 0.5}, + }, + "Glyph": "glyph00011", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintVarLinearGradient), + "ColorLine": { + "Extend": "repeat", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + }, + ], + }, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), + }, + "Glyph": "glyph00012", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintVarTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": "pad", + "ColorStop": [ + { + "StopOffset": 0, + "Color": {"PaletteIndex": 6, "Alpha": 1.0}, + }, + { + "StopOffset": 1.0, + "Color": {"PaletteIndex": 7, "Alpha": 0.4}, + }, + ], + }, + "x0": 7, + "y0": 8, + "r0": 9, + "x1": 10, + "y1": 11, + "r1": 12, + }, + "Transform": { + "xx": (-13.0, 0), + "yx": (14.0, 0), + "xy": (15.0, 0), + "yy": (-17.0, 0), + "dx": (18.0, 0), + "dy": (19.0, 0), + }, + }, + "Glyph": "glyph00013", + }, + { + "Format": int(ot.PaintFormat.PaintVarTranslate), + "Paint": { + "Format": int(ot.PaintFormat.PaintRotate), + "Paint": { + "Format": int(ot.PaintFormat.PaintVarSkew), + "Paint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": 0.5}, + }, + "Glyph": "glyph00011", + }, + "xSkewAngle": (-11.0, 0), + "ySkewAngle": (5.0, 0), + "centerX": (253.0, 0), + "centerY": (254.0, 0), + }, + "angle": 45.0, + "centerX": 255.0, + "centerY": 256.0, + }, + "dx": (257.0, 0), + "dy": (258.0, 0), + }, + ], + }, + "glyph00014": { + "Format": int(ot.PaintFormat.PaintComposite), + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", + }, + "CompositeMode": "src_over", + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", + }, + "Transform": { + "xx": 1.0, + "yx": 0.0, + "xy": 0.0, + "yy": 1.0, + "dx": 300.0, + "dy": 0.0, + }, + }, + }, + "glyph00015": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": "pad", + "ColorStop": [ + { + "StopOffset": 0.0, + "Color": {"PaletteIndex": 3, "Alpha": 1.0}, + }, + { + "StopOffset": 1.0, + "Color": {"PaletteIndex": 5, "Alpha": 1.0}, + }, + ], + }, + "centerX": 259, + "centerY": 300, + "startAngle": 45.0, + "endAngle": 135.0, + }, + "Glyph": "glyph00011", + }, + "glyph00016": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintVarSolid), + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, + }, + "Glyph": "glyph00011", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintVarLinearGradient), + "ColorLine": { + "Extend": "repeat", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + }, + ], + }, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), + }, + "Glyph": "glyph00012", + }, + ], + }, +} + + +def test_unbuildColrV1(): + layersV1, baseGlyphsV1 = buildColrV1(TEST_COLOR_GLYPHS) + colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1) + assert colorGlyphs == TEST_COLOR_GLYPHS diff --git a/Tests/feaLib/STAT2.fea b/Tests/feaLib/STAT2.fea new file mode 100644 index 000000000..2595a9a4a --- /dev/null +++ b/Tests/feaLib/STAT2.fea @@ -0,0 +1,4 @@ +table STAT { + ElidedFallbackName { name "Roman"; }; + DesignAxis zonk 0 { name "Zonkey"; };' +} STAT; diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 151cd896a..2f6319e62 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -9,6 +9,7 @@ from fontTools.feaLib import ast from fontTools.feaLib.lexer import Lexer import difflib import os +import re import shutil import sys import tempfile @@ -73,7 +74,7 @@ class BuilderTest(unittest.TestCase): LigatureSubtable AlternateSubtable MultipleSubstSubtable SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats - GSUB_5_formats + GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID """.split() def __init__(self, methodName): @@ -118,7 +119,7 @@ class BuilderTest(unittest.TestCase): def expect_ttx(self, font, expected_ttx, replace=None): path = self.temp_path(suffix=".ttx") font.saveXML(path, tables=['head', 'name', 'BASE', 'GDEF', 'GSUB', - 'GPOS', 'OS/2', 'hhea', 'vhea']) + 'GPOS', 'OS/2', 'STAT', 'hhea', 'vhea']) actual = self.read_ttx(path) expected = self.read_ttx(expected_ttx) if replace: @@ -463,6 +464,201 @@ class BuilderTest(unittest.TestCase): "} test;" ) + def test_STAT_elidedfallbackname_already_defined(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackName is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' ElidedFallbackNameID 256;' + '} STAT;') + + def test_STAT_elidedfallbackname_set_twice(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackName is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' ElidedFallbackName { name "Italic"; };' + '} STAT;') + + def test_STAT_elidedfallbacknameID_already_defined(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackNameID is already set.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackNameID 256;' + ' ElidedFallbackName { name "Roman"; };' + '} STAT;') + + def test_STAT_elidedfallbacknameID_not_in_name_table(self): + self.assertRaisesRegex( + FeatureLibError, + 'ElidedFallbackNameID 256 points to a nameID that does not ' + 'exist in the "name" table', + self.build, + 'table name {' + ' nameid 257 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackNameID 256;' + ' DesignAxis opsz 1 { name "Optical Size"; };' + '} STAT;') + + def test_STAT_design_axis_name(self): + self.assertRaisesRegex( + FeatureLibError, + 'Expected "name"', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { badtag "Optical Size"; };' + '} STAT;') + + def test_STAT_duplicate_design_axis_name(self): + self.assertRaisesRegex( + FeatureLibError, + 'DesignAxis already defined for tag "opsz".', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis opsz 1 { name "Optical Size"; };' + '} STAT;') + + def test_STAT_design_axis_duplicate_order(self): + self.assertRaisesRegex( + FeatureLibError, + "DesignAxis already defined for axis number 0.", + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 0 { name "Width"; };' + ' AxisValue {' + ' location opsz 8;' + ' location wdth 400;' + ' name "Caption";' + ' };' + '} STAT;') + + def test_STAT_undefined_tag(self): + self.assertRaisesRegex( + FeatureLibError, + 'DesignAxis not defined for wdth.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' AxisValue { ' + ' location wdth 125; ' + ' name "Wide"; ' + ' };' + '} STAT;') + + def test_STAT_axis_value_format4(self): + self.assertRaisesRegex( + FeatureLibError, + 'Axis tag wdth already defined.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 1 { name "Width"; };' + ' DesignAxis wght 2 { name "Weight"; };' + ' AxisValue { ' + ' location opsz 8; ' + ' location wdth 125; ' + ' location wdth 125; ' + ' location wght 500; ' + ' name "Caption Medium Wide"; ' + ' };' + '} STAT;') + + def test_STAT_duplicate_axis_value_record(self): + # Test for Duplicate AxisValueRecords even when the definition order + # is different. + self.assertRaisesRegex( + FeatureLibError, + 'An AxisValueRecord with these values is already defined.', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; };' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' DesignAxis wdth 1 { name "Width"; };' + ' AxisValue {' + ' location opsz 8;' + ' location wdth 400;' + ' name "Caption";' + ' };' + ' AxisValue {' + ' location wdth 400;' + ' location opsz 8;' + ' name "Caption";' + ' };' + '} STAT;') + + def test_STAT_axis_value_missing_location(self): + self.assertRaisesRegex( + FeatureLibError, + 'Expected "Axis location"', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; ' + '};' + ' DesignAxis opsz 0 { name "Optical Size"; };' + ' AxisValue { ' + ' name "Wide"; ' + ' };' + '} STAT;') + + def test_STAT_invalid_location_tag(self): + self.assertRaisesRegex( + FeatureLibError, + 'Tags cannot be longer than 4 characters', + self.build, + 'table name {' + ' nameid 256 "Roman"; ' + '} name;' + 'table STAT {' + ' ElidedFallbackName { name "Roman"; ' + ' name 3 1 0x0411 "ローマン"; }; ' + ' DesignAxis width 0 { name "Width"; };' + '} STAT;') + def test_extensions(self): class ast_BaseClass(ast.MarkClass): def asFea(self, indent=""): diff --git a/Tests/feaLib/data/GPOS_4.fea b/Tests/feaLib/data/GPOS_4.fea index cfd2d757c..7c90ab631 100644 --- a/Tests/feaLib/data/GPOS_4.fea +++ b/Tests/feaLib/data/GPOS_4.fea @@ -6,7 +6,11 @@ markClass [cedilla] @BOTTOM_MARKS; markClass [ogonek] @SIDE_MARKS; feature test { - pos base a mark @TOP_MARKS mark @BOTTOM_MARKS; - pos base [b c] mark @BOTTOM_MARKS; - pos base d mark @SIDE_MARKS; + pos base a + mark @TOP_MARKS + mark @BOTTOM_MARKS; + pos base [b c] + mark @BOTTOM_MARKS; + pos base d + mark @SIDE_MARKS; } test; diff --git a/Tests/feaLib/data/GPOS_5.fea b/Tests/feaLib/data/GPOS_5.fea index b116539aa..a8f8536e2 100644 --- a/Tests/feaLib/data/GPOS_5.fea +++ b/Tests/feaLib/data/GPOS_5.fea @@ -5,14 +5,29 @@ markClass [ogonek] @OGONEK; feature test { - pos ligature [c_t s_t] mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS mark @OGONEK; + pos ligature [c_t s_t] + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS + mark @OGONEK; - pos ligature f_l mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS; + pos ligature f_l + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS; - pos ligature [f_f_l] mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS - ligComponent mark @TOP_MARKS mark @BOTTOM_MARKS; + pos ligature [f_f_l] + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS + ligComponent + mark @TOP_MARKS + mark @BOTTOM_MARKS; } test; diff --git a/Tests/feaLib/data/GPOS_6.fea b/Tests/feaLib/data/GPOS_6.fea index 37b293659..e54ff6e3e 100644 --- a/Tests/feaLib/data/GPOS_6.fea +++ b/Tests/feaLib/data/GPOS_6.fea @@ -5,6 +5,9 @@ markClass macron @TOP_MARKS; markClass [cedilla] @BOTTOM_MARKS; feature test { - pos mark [acute grave macron ogonek] mark @TOP_MARKS mark @BOTTOM_MARKS; - pos mark [dieresis caron] mark @TOP_MARKS; + pos mark [acute grave macron ogonek] + mark @TOP_MARKS + mark @BOTTOM_MARKS; + pos mark [dieresis caron] + mark @TOP_MARKS; } test; diff --git a/Tests/feaLib/data/STAT_bad.fea b/Tests/feaLib/data/STAT_bad.fea new file mode 100644 index 000000000..8ec887f0e --- /dev/null +++ b/Tests/feaLib/data/STAT_bad.fea @@ -0,0 +1,96 @@ +# bad fea file: Testing DesignAxis tag with incorrect label +table name { + nameid 25 "TestFont"; +} name; + + +table STAT { + + ElidedFallbackName { name "Roman"; }; + + DesignAxis opsz 0 { badtag "Optical Size"; }; #'badtag' instead of 'name' is incorrect + DesignAxis wdth 1 { name "Width"; }; + DesignAxis wght 2 { name "Weight"; }; + DesignAxis ital 3 { name "Italic"; }; + + AxisValue { + location opsz 8 5 9; + location wdth 300 350 450; + name "Caption"; + }; + + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName ; + }; + + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + + AxisValue { + location wght 300 300 349; + name "Light"; + }; + + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + + AxisValue { + location wght 900 750 900; + name "Black"; + }; + + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; + }; + +} STAT; diff --git a/Tests/feaLib/data/STAT_test.fea b/Tests/feaLib/data/STAT_test.fea new file mode 100644 index 000000000..010363764 --- /dev/null +++ b/Tests/feaLib/data/STAT_test.fea @@ -0,0 +1,109 @@ +table name { + nameid 25 "TestFont"; +} name; + + +table STAT { + + ElidedFallbackName { + name "Roman"; + name 3 1 1041 "ローマン"; + }; + + DesignAxis opsz 0 { + name "Optical Size"; + }; + + DesignAxis wdth 1 { + name "Width"; + }; + + DesignAxis wght 2 { + name "Weight"; + }; + + DesignAxis ital 3 { + name "Italic"; + }; # here comment + + AxisValue { + location opsz 8; # comment here + location wdth 400; # another comment + name "Caption"; # more comments + }; + + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName; + }; + + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + + AxisValue { + location wght 300 300 349; + name "Light"; + }; + + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + + AxisValue { + location wght 900 750 900; + name "Black"; + }; + + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; # flag comment + }; + +} STAT; diff --git a/Tests/feaLib/data/STAT_test.ttx b/Tests/feaLib/data/STAT_test.ttx new file mode 100644 index 000000000..d1b2b6970 --- /dev/null +++ b/Tests/feaLib/data/STAT_test.ttx @@ -0,0 +1,228 @@ + + + + + + TestFont + + + Roman + + + ローマン + + + Optical Size + + + Text + + + Subhead + + + Display + + + Width + + + Condensed + + + Semicondensed + + + Normal + + + Extended + + + Weight + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Black + + + Italic + + + Roman + + + Caption + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea new file mode 100644 index 000000000..5a1418037 --- /dev/null +++ b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.fea @@ -0,0 +1,84 @@ +table name { + nameid 25 "TestFont"; + nameid 256 "Roman"; +} name; +table STAT { + ElidedFallbackNameID 256; + DesignAxis opsz 0 { + name "Optical Size"; + }; + DesignAxis wdth 1 { + name "Width"; + }; + DesignAxis wght 2 { + name "Weight"; + }; + DesignAxis ital 3 { + name "Italic"; + }; # here comment + AxisValue { + location opsz 8; # comment here + location wdth 400; # another comment + name "Caption"; # more comments + }; + AxisValue { + location opsz 11 9 12; + name "Text"; + flag OlderSiblingFontAttribute ElidableAxisValueName; + }; + AxisValue { + location opsz 16.7 12 24; + name "Subhead"; + }; + AxisValue { + location opsz 72 24 72; + name "Display"; + }; + AxisValue { + location wdth 80 80 89; + name "Condensed"; + }; + AxisValue { + location wdth 90 90 96; + name "Semicondensed"; + }; + AxisValue { + location wdth 100 97 101; + name "Normal"; + flag ElidableAxisValueName; + }; + AxisValue { + location wdth 125 102 125; + name "Extended"; + }; + AxisValue { + location wght 300 300 349; + name "Light"; + }; + AxisValue { + location wght 400 350 449; + name "Regular"; + flag ElidableAxisValueName; + }; + AxisValue { + location wght 500 450 549; + name "Medium"; + }; + AxisValue { + location wght 600 550 649; + name "Semibold"; + }; + AxisValue { + location wght 700 650 749; + name "Bold"; + }; + AxisValue { + location wght 900 750 900; + name "Black"; + }; + AxisValue { + location ital 0; + name "Roman"; + flag ElidableAxisValueName; # flag comment + }; +} STAT; \ No newline at end of file diff --git a/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx new file mode 100644 index 000000000..32802e0fe --- /dev/null +++ b/Tests/feaLib/data/STAT_test_elidedFallbackNameID.ttx @@ -0,0 +1,225 @@ + + + + + + TestFont + + + Roman + + + Optical Size + + + Text + + + Subhead + + + Display + + + Width + + + Condensed + + + Semicondensed + + + Normal + + + Extended + + + Weight + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Black + + + Italic + + + Roman + + + Caption + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/data/bug453.fea b/Tests/feaLib/data/bug453.fea index 486632ee2..ed0e6f943 100644 --- a/Tests/feaLib/data/bug453.fea +++ b/Tests/feaLib/data/bug453.fea @@ -2,10 +2,12 @@ feature mark { lookup mark1 { markClass [acute] @TOP_MARKS; - pos base [e] mark @TOP_MARKS; + pos base [e] + mark @TOP_MARKS; } mark1; lookup mark2 { markClass [acute] @TOP_MARKS_2; - pos base [e] mark @TOP_MARKS_2; + pos base [e] + mark @TOP_MARKS_2; } mark2; } mark; diff --git a/Tests/feaLib/data/delete_glyph.fea b/Tests/feaLib/data/delete_glyph.fea new file mode 100644 index 000000000..36e0f0f9a --- /dev/null +++ b/Tests/feaLib/data/delete_glyph.fea @@ -0,0 +1,3 @@ +feature test { + sub a by NULL; +} test; diff --git a/Tests/feaLib/data/delete_glyph.ttx b/Tests/feaLib/data/delete_glyph.ttx new file mode 100644 index 000000000..777f6e364 --- /dev/null +++ b/Tests/feaLib/data/delete_glyph.ttx @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/data/spec6d2.fea b/Tests/feaLib/data/spec6d2.fea index ead224fea..5c2620d2f 100644 --- a/Tests/feaLib/data/spec6d2.fea +++ b/Tests/feaLib/data/spec6d2.fea @@ -9,7 +9,11 @@ markClass [dieresis umlaut] @TOP_MARKS; markClass [cedilla] @BOTTOM_MARKS; feature test { - pos base [e o] mark @TOP_MARKS mark @BOTTOM_MARKS; -#test-fea2fea: pos base [a u] mark @TOP_MARKS mark @BOTTOM_MARKS; - position base [a u] mark @TOP_MARKS mark @BOTTOM_MARKS; + pos base [e o] + mark @TOP_MARKS + mark @BOTTOM_MARKS; +#test-fea2fea: pos base [a u] + position base [a u] + mark @TOP_MARKS + mark @BOTTOM_MARKS; } test; diff --git a/Tests/feaLib/data/spec6e.fea b/Tests/feaLib/data/spec6e.fea index ed956c8f3..646122326 100644 --- a/Tests/feaLib/data/spec6e.fea +++ b/Tests/feaLib/data/spec6e.fea @@ -4,7 +4,10 @@ markClass sukun @TOP_MARKS; markClass kasratan @BOTTOM_MARKS; feature test { - pos ligature lam_meem_jeem mark @TOP_MARKS # mark above lam - ligComponent mark @BOTTOM_MARKS # mark below meem - ligComponent ; # jeem has no marks + pos ligature lam_meem_jeem + mark @TOP_MARKS # mark above lam + ligComponent + mark @BOTTOM_MARKS # mark below meem + ligComponent + ; # jeem has no marks } test; diff --git a/Tests/feaLib/data/spec6f.fea b/Tests/feaLib/data/spec6f.fea index 8d32008cb..277bdb464 100644 --- a/Tests/feaLib/data/spec6f.fea +++ b/Tests/feaLib/data/spec6f.fea @@ -2,5 +2,6 @@ languagesystem DFLT dflt; feature test { markClass damma @MARK_CLASS_1; - pos mark hamza mark @MARK_CLASS_1; + pos mark hamza + mark @MARK_CLASS_1; } test; diff --git a/Tests/feaLib/data/spec6h_ii.fea b/Tests/feaLib/data/spec6h_ii.fea index 36a1f032f..690d2a353 100644 --- a/Tests/feaLib/data/spec6h_ii.fea +++ b/Tests/feaLib/data/spec6h_ii.fea @@ -12,8 +12,10 @@ lookup CNTXT_PAIR_POS { } CNTXT_PAIR_POS; lookup CNTXT_MARK_TO_BASE { - pos base o mark @ALL_MARKS; - pos base c mark @ALL_MARKS; + pos base o + mark @ALL_MARKS; + pos base c + mark @ALL_MARKS; } CNTXT_MARK_TO_BASE; feature test { diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index db505950c..de2bc3ca8 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1280,6 +1280,76 @@ class ParserTest(unittest.TestCase): '"dflt" is not a valid script tag; use "DFLT" instead', self.parse, "feature test {script dflt;} test;") + def test_stat_design_axis(self): # STAT DesignAxis + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; } STAT;') + da = doc.statements[0].statements[0] + self.assertIsInstance(da, ast.STATDesignAxisStatement) + self.assertEqual(da.tag, 'opsz') + self.assertEqual(da.axisOrder, 0) + self.assertEqual(da.names[0].string, 'Optical Size') + + def test_stat_axis_value_format1(self): # STAT AxisValue + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8; name "Caption";}; } ' + 'STAT;') + avr = doc.statements[0].statements[1] + self.assertIsInstance(avr, ast.STATAxisValueStatement) + self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].values[0], 8) + self.assertEqual(avr.names[0].string, 'Caption') + + def test_stat_axis_value_format2(self): # STAT AxisValue + doc = self.parse('table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8 6 10; name "Caption";}; } ' + 'STAT;') + avr = doc.statements[0].statements[1] + self.assertIsInstance(avr, ast.STATAxisValueStatement) + self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].values, [8, 6, 10]) + self.assertEqual(avr.names[0].string, 'Caption') + + def test_stat_axis_value_format2_bad_range(self): # STAT AxisValue + self.assertRaisesRegex( + FeatureLibError, + 'Default value 5 is outside of specified range 6-10.', + self.parse, 'table STAT { DesignAxis opsz 0 ' + '{name "Optical Size";}; ' + 'AxisValue {location opsz 5 6 10; name "Caption";}; } ' + 'STAT;') + + def test_stat_axis_value_format4(self): # STAT AxisValue + self.assertRaisesRegex( + FeatureLibError, + 'Only one value is allowed in a Format 4 Axis Value Record, but 3 were found.', + self.parse, 'table STAT { ' + 'DesignAxis opsz 0 {name "Optical Size";}; ' + 'DesignAxis wdth 0 {name "Width";}; ' + 'AxisValue {' + 'location opsz 8 6 10; ' + 'location wdth 400; ' + 'name "Caption";}; } ' + 'STAT;') + + def test_stat_elidedfallbackname(self): # STAT ElidedFallbackName + doc = self.parse('table STAT { ElidedFallbackName {name "Roman"; ' + 'name 3 1 0x0411 "ローマン"; }; ' + '} STAT;') + nameRecord = doc.statements[0].statements[0] + self.assertIsInstance(nameRecord, ast.ElidedFallbackName) + self.assertEqual(nameRecord.names[0].string, 'Roman') + self.assertEqual(nameRecord.names[1].string, 'ローマン') + + def test_stat_elidedfallbacknameid(self): # STAT ElidedFallbackNameID + doc = self.parse('table name { nameid 278 "Roman"; } name; ' + 'table STAT { ElidedFallbackNameID 278; ' + '} STAT;') + nameRecord = doc.statements[0].statements[0] + self.assertIsInstance(nameRecord, ast.NameRecord) + self.assertEqual(nameRecord.string, 'Roman') + def test_sub_single_format_a(self): # GSUB LookupType 1 doc = self.parse("feature smcp {substitute a by a.sc;} smcp;") sub = doc.statements[0].statements[0] diff --git a/Tests/fontBuilder/data/test_var.otf.ttx b/Tests/fontBuilder/data/test_var.otf.ttx index ccf64dc62..09246e5bd 100644 --- a/Tests/fontBuilder/data/test_var.otf.ttx +++ b/Tests/fontBuilder/data/test_var.otf.ttx @@ -141,9 +141,6 @@ Test Axis - TotallyNormal - - TotallyTested @@ -165,9 +162,6 @@ Test Axis - TotallyNormal - - TotallyTested @@ -290,12 +284,12 @@ - + - + diff --git a/Tests/fontBuilder/data/test_var.ttf.ttx b/Tests/fontBuilder/data/test_var.ttf.ttx index ed8fd3075..c3585bed5 100644 --- a/Tests/fontBuilder/data/test_var.ttf.ttx +++ b/Tests/fontBuilder/data/test_var.ttf.ttx @@ -199,12 +199,9 @@ Down - TotallyNormal - - Right Up - + Neutral @@ -235,12 +232,9 @@ Down - TotallyNormal - - Right Up - + Neutral @@ -399,8 +393,8 @@ - - + + @@ -411,8 +405,8 @@ - - + + @@ -423,8 +417,8 @@ - - + + @@ -435,8 +429,8 @@ - - + + @@ -492,7 +486,7 @@ - + @@ -500,7 +494,7 @@ - + diff --git a/Tests/misc/vector_test.py b/Tests/misc/vector_test.py new file mode 100644 index 000000000..7448cef1d --- /dev/null +++ b/Tests/misc/vector_test.py @@ -0,0 +1,66 @@ +import math +import pytest +from fontTools.misc.arrayTools import Vector as ArrayVector +from fontTools.misc.vector import Vector + + +def test_Vector(): + v = Vector((100, 200)) + assert repr(v) == "Vector((100, 200))" + assert v == Vector((100, 200)) + assert v == Vector([100, 200]) + assert v == (100, 200) + assert (100, 200) == v + assert v == [100, 200] + assert [100, 200] == v + assert v is Vector(v) + assert v + 10 == (110, 210) + assert 10 + v == (110, 210) + assert v + Vector((1, 2)) == (101, 202) + assert v - Vector((1, 2)) == (99, 198) + assert v * 2 == (200, 400) + assert 2 * v == (200, 400) + assert v * 0.5 == (50, 100) + assert v / 2 == (50, 100) + assert 2 / v == (0.02, 0.01) + v = Vector((3, 4)) + assert abs(v) == 5 # length + assert v.length() == 5 + assert v.normalized() == Vector((0.6, 0.8)) + assert abs(Vector((1, 1, 1))) == math.sqrt(3) + assert bool(Vector((0, 0, 1))) + assert not bool(Vector((0, 0, 0))) + v1 = Vector((2, 3)) + v2 = Vector((3, 4)) + assert v1.dot(v2) == 18 + v = Vector((2, 4)) + assert round(v / 3) == (1, 1) + + +def test_deprecated(): + with pytest.warns( + DeprecationWarning, + match="fontTools.misc.arrayTools.Vector has been deprecated", + ): + ArrayVector((1, 2)) + with pytest.warns( + DeprecationWarning, + match="the 'keep' argument has been deprecated", + ): + Vector((1, 2), keep=True) + v = Vector((1, 2)) + with pytest.warns( + DeprecationWarning, + match="the 'toInt' method has been deprecated", + ): + v.toInt() + with pytest.warns( + DeprecationWarning, + match="the 'values' attribute has been deprecated", + ): + v.values + with pytest.raises( + AttributeError, + match="the 'values' attribute has been deprecated", + ): + v.values = [12, 23] diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py index bdfc64509..01d6895cd 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 = [ ([ @@ -1132,7 +1138,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1187,7 +1193,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1205,7 +1211,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1240,7 +1246,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1285,7 +1291,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -1348,7 +1354,7 @@ buildStatTable_test_data = [ ' ', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', diff --git a/Tests/pens/areaPen_test.py b/Tests/pens/areaPen_test.py index f99e9fc73..c3f3f80c4 100644 --- a/Tests/pens/areaPen_test.py +++ b/Tests/pens/areaPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.areaPen import AreaPen import unittest diff --git a/Tests/pens/basePen_test.py b/Tests/pens/basePen_test.py index 05ef4c625..db57e80e8 100644 --- a/Tests/pens/basePen_test.py +++ b/Tests/pens/basePen_test.py @@ -1,6 +1,6 @@ -from fontTools.misc.py23 import * from fontTools.pens.basePen import \ - BasePen, decomposeSuperBezierSegment, decomposeQuadraticSegment + AbstractPen, BasePen, decomposeSuperBezierSegment, decomposeQuadraticSegment +from fontTools.pens.pointPen import AbstractPointPen from fontTools.misc.loggingTools import CapturingLogHandler import unittest diff --git a/Tests/pens/boundsPen_test.py b/Tests/pens/boundsPen_test.py index 1d4b45e46..c0c56108b 100644 --- a/Tests/pens/boundsPen_test.py +++ b/Tests/pens/boundsPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen import unittest diff --git a/Tests/pens/cocoaPen_test.py b/Tests/pens/cocoaPen_test.py index 51795e121..11077c0b5 100644 --- a/Tests/pens/cocoaPen_test.py +++ b/Tests/pens/cocoaPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * import unittest try: @@ -48,7 +47,7 @@ class CocoaPenTest(unittest.TestCase): "moveto 50.0 0.0 lineto 50.0 500.0 lineto 200.0 500.0 curveto 350.0 500.0 450.0 400.0 450.0 250.0 curveto 450.0 100.0 350.0 0.0 200.0 0.0 close ", cocoaPathToString(pen.path) ) - + def test_empty(self): pen = CocoaPen(None) self.assertEqual("", cocoaPathToString(pen.path)) diff --git a/Tests/pens/perimeterPen_test.py b/Tests/pens/perimeterPen_test.py index 9feff18c6..1b6453451 100644 --- a/Tests/pens/perimeterPen_test.py +++ b/Tests/pens/perimeterPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.perimeterPen import PerimeterPen import unittest diff --git a/Tests/pens/pointInsidePen_test.py b/Tests/pens/pointInsidePen_test.py index 4f27210f7..b561c43f9 100644 --- a/Tests/pens/pointInsidePen_test.py +++ b/Tests/pens/pointInsidePen_test.py @@ -1,4 +1,4 @@ -from fontTools.misc.py23 import * +from io import StringIO from fontTools.pens.pointInsidePen import PointInsidePen import unittest @@ -72,16 +72,16 @@ class PointInsidePenTest(unittest.TestCase): @staticmethod def render(draw_function, even_odd): - result = BytesIO() + result = StringIO() for y in range(5): for x in range(10): pen = PointInsidePen(None, (x + 0.5, y + 0.5), even_odd) draw_function(pen) if pen.getResult(): - result.write(b"*") + result.write("*") else: - result.write(b" ") - return tounicode(result.getvalue()) + result.write(" ") + return result.getvalue() def test_contour_no_solutions(self): diff --git a/Tests/pens/pointPen_test.py b/Tests/pens/pointPen_test.py index 7dfdf594b..07261d039 100644 --- a/Tests/pens/pointPen_test.py +++ b/Tests/pens/pointPen_test.py @@ -1,5 +1,3 @@ -from fontTools.misc.py23 import * -from fontTools.misc.loggingTools import CapturingLogHandler import unittest from fontTools.pens.basePen import AbstractPen @@ -43,7 +41,7 @@ def _reprKwargs(kwargs): items = [] for key in sorted(kwargs): value = kwargs[key] - if isinstance(value, basestring): + if isinstance(value, str): items.append("%s='%s'" % (key, value)) else: items.append("%s=%s" % (key, value)) diff --git a/Tests/pens/quartzPen_test.py b/Tests/pens/quartzPen_test.py index 12fbd2921..3a81d97f7 100644 --- a/Tests/pens/quartzPen_test.py +++ b/Tests/pens/quartzPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * import unittest try: @@ -68,7 +67,7 @@ class QuartzPenTest(unittest.TestCase): "moveto 50.0 0.0 lineto 50.0 500.0 lineto 200.0 500.0 curveto 350.0 500.0 450.0 400.0 450.0 250.0 curveto 450.0 100.0 350.0 0.0 200.0 0.0 close ", quartzPathToString(pen.path) ) - + def test_empty(self): pen = QuartzPen(None) self.assertEqual("", quartzPathToString(pen.path)) diff --git a/Tests/pens/t2CharStringPen_test.py b/Tests/pens/t2CharStringPen_test.py index 5de700ae7..b710df55b 100644 --- a/Tests/pens/t2CharStringPen_test.py +++ b/Tests/pens/t2CharStringPen_test.py @@ -1,4 +1,3 @@ -from fontTools.misc.py23 import * from fontTools.pens.t2CharStringPen import T2CharStringPen import unittest @@ -7,16 +6,12 @@ class T2CharStringPenTest(unittest.TestCase): def __init__(self, methodName): unittest.TestCase.__init__(self, methodName) - # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, - # and fires deprecation warnings if a program uses the old name. - if not hasattr(self, "assertRaisesRegex"): - self.assertRaisesRegex = self.assertRaisesRegexp def assertAlmostEqualProgram(self, expected, actual): self.assertEqual(len(expected), len(actual)) for i1, i2 in zip(expected, actual): - if isinstance(i1, basestring): - self.assertIsInstance(i2, basestring) + if isinstance(i1, str): + self.assertIsInstance(i2, str) self.assertEqual(i1, i2) else: self.assertAlmostEqual(i1, i2) diff --git a/Tests/pens/ttGlyphPen_test.py b/Tests/pens/ttGlyphPen_test.py index f6ad84859..53db025ce 100644 --- a/Tests/pens/ttGlyphPen_test.py +++ b/Tests/pens/ttGlyphPen_test.py @@ -1,5 +1,3 @@ -from fontTools.misc.py23 import * - import os import unittest import struct diff --git a/Tests/pens/utils.py b/Tests/pens/utils.py index 05f438c88..dced3c1be 100644 --- a/Tests/pens/utils.py +++ b/Tests/pens/utils.py @@ -14,7 +14,7 @@ from . import CUBIC_GLYPHS from fontTools.pens.pointPen import PointToSegmentPen, SegmentToPointPen -from fontTools.misc.py23 import isclose +from math import isclose import unittest diff --git a/Tests/subset/data/CmapSubsetTest.subset.ttx b/Tests/subset/data/CmapSubsetTest.subset.ttx new file mode 100644 index 000000000..10b94a346 --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.subset.ttx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Tests/subset/data/CmapSubsetTest.ttx b/Tests/subset/data/CmapSubsetTest.ttx new file mode 100644 index 000000000..ffbfae7fc --- /dev/null +++ b/Tests/subset/data/CmapSubsetTest.ttx @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New Font + + + Regular + + + 0.000;NONE;NewFont-Regular + + + New Font Regular + + + Version 0.000 + + + NewFont-Regular + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/subset/data/TestContextSubstFormat3.ttx b/Tests/subset/data/TestContextSubstFormat3.ttx index 899b037e3..3e9bfcd2e 100644 --- a/Tests/subset/data/TestContextSubstFormat3.ttx +++ b/Tests/subset/data/TestContextSubstFormat3.ttx @@ -17,7 +17,7 @@ - + @@ -142,15 +142,9 @@ - - - - - - diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 0d2f9fe2d..370f9b626 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -3,7 +3,9 @@ from fontTools.misc.py23 import * from fontTools.misc.testTools import getXML from fontTools import subset from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont, newTable +from fontTools.ttLib.tables import otTables as ot from fontTools.misc.loggingTools import CapturingLogHandler import difflib import logging @@ -753,6 +755,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(): @@ -923,5 +932,256 @@ def test_subset_empty_glyf(tmp_path, ttf_path): assert all(loc == 0 for loc in loca) +@pytest.fixture +def colrv1_path(tmp_path): + base_glyph_names = ["uni%04X" % i for i in range(0xE000, 0xE000 + 10)] + layer_glyph_names = ["glyph%05d" % i for i in range(10, 20)] + glyph_order = [".notdef"] + base_glyph_names + layer_glyph_names + + pen = TTGlyphPen(glyphSet=None) + pen.moveTo((0, 0)) + pen.lineTo((0, 500)) + pen.lineTo((500, 500)) + pen.lineTo((500, 0)) + pen.closePath() + glyph = pen.glyph() + glyphs = {g: glyph for g in glyph_order} + + fb = FontBuilder(unitsPerEm=1024, isTTF=True) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap({int(name[3:], 16): name for name in base_glyph_names}) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order}) + fb.setupHorizontalHeader() + fb.setupOS2() + fb.setupPost() + fb.setupNameTable({"familyName": "TestCOLRv1", "styleName": "Regular"}) + + fb.setupCOLR( + { + "uniE000": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 0), + "Glyph": "glyph00010", + }, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (2, 0.3)), + "Glyph": "glyph00011", + }, + ], + ), + "uniE001": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintRadialGradient, + "x0": 250, + "y0": 250, + "r0": 250, + "x1": 200, + "y1": 200, + "r1": 0, + "ColorLine": { + "ColorStop": [(0.0, 1), (1.0, 2)], + "Extend": "repeat", + }, + }, + "Glyph": "glyph00012", + }, + "Transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0), + }, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (1, 0.5)), + "Glyph": "glyph00013", + }, + ], + ), + "uniE002": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "x0": 0, + "y0": 0, + "x1": 500, + "y1": 500, + "x2": -500, + "y2": 500, + "ColorLine": {"ColorStop": [(0.0, 1), (1.0, 2)]}, + }, + "Glyph": "glyph00014", + }, + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 1), + "Glyph": "glyph00015", + }, + "Transform": (1, 0, 0, 1, 400, 400), + }, + ], + ), + "uniE003": { + "Format": ot.PaintFormat.PaintRotate, + "Paint": { + "Format": ot.PaintFormat.PaintColrGlyph, + "Glyph": "uniE001", + }, + "angle": 45, + "centerX": 250, + "centerY": 250, + }, + "uniE004": [ + ("glyph00016", 1), + ("glyph00017", 2), + ], + }, + ) + fb.setupCPAL( + [ + [ + (1.0, 0.0, 0.0, 1.0), # red + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ], + ], + ) + + output_path = tmp_path / "TestCOLRv1.ttf" + fb.save(output_path) + + return output_path + + +def test_subset_COLRv1_and_CPAL(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E002,E003,E004", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + # uniE000 and its children are excluded from subset + assert "uniE000" not in glyph_set + assert "glyph00010" not in glyph_set + assert "glyph00011" not in glyph_set + + # uniE001 and children are pulled in indirectly as PaintColrGlyph by uniE003 + assert "uniE001" in glyph_set + assert "glyph00012" in glyph_set + assert "glyph00013" in glyph_set + + assert "uniE002" in glyph_set + assert "glyph00014" in glyph_set + assert "glyph00015" in glyph_set + + assert "uniE003" in glyph_set + + assert "uniE004" in glyph_set + assert "glyph00016" in glyph_set + assert "glyph00017" in glyph_set + + assert "COLR" in subset_font + colr = subset_font["COLR"].table + assert colr.Version == 1 + assert len(colr.BaseGlyphRecordArray.BaseGlyphRecord) == 1 + assert len(colr.BaseGlyphV1List.BaseGlyphV1Record) == 3 # was 4 + + base = colr.BaseGlyphV1List.BaseGlyphV1Record[0] + assert base.BaseGlyph == "uniE001" + layers = colr.LayerV1List.Paint[ + base.Paint.FirstLayerIndex: base.Paint.FirstLayerIndex + base.Paint.NumLayers + ] + assert len(layers) == 2 + # check v1 palette indices were remapped + assert layers[0].Paint.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 0 + assert layers[0].Paint.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 1 + assert layers[1].Paint.Color.PaletteIndex == 0 + + baseRecV0 = colr.BaseGlyphRecordArray.BaseGlyphRecord[0] + assert baseRecV0.BaseGlyph == "uniE004" + layersV0 = colr.LayerRecordArray.LayerRecord + assert len(layersV0) == 2 + # check v0 palette indices were remapped + assert layersV0[0].PaletteIndex == 0 + assert layersV0[1].PaletteIndex == 1 + + assert "CPAL" in subset_font + cpal = subset_font["CPAL"] + assert [ + tuple(v / 255 for v in (c.red, c.green, c.blue, c.alpha)) + for c in cpal.palettes[0] + ] == [ + # the first color 'red' was pruned + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ] + + +def test_subset_COLRv1_and_CPAL_drop_empty(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--glyphs=glyph00010", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + assert "glyph00010" in glyph_set + assert "uniE000" not in glyph_set + + assert "COLR" not in subset_font + assert "CPAL" not in subset_font + + +def test_subset_COLRv1_downgrade_version(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E004", + ] + ) + subset_font = TTFont(subset_path) + + assert set(subset_font.getGlyphOrder()) == { + ".notdef", + "uniE004", + "glyph00016", + "glyph00017", + } + + assert "COLR" in subset_font + assert subset_font["COLR"].version == 0 + + if __name__ == "__main__": sys.exit(unittest.main()) diff --git a/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx b/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx new file mode 100644 index 000000000..a36dbf50d --- /dev/null +++ b/Tests/ttLib/data/woff2_overlap_offcurve_in.ttx @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Weight + + + Unnamed + + + Regular + + + 1.000;NONE;Unnamed-Regular + + + Unnamed Regular + + + Version 1.000 + + + Unnamed-Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 400.0 + 400.0 + 700.0 + 256 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index 0a1d9df9d..4855f58f5 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -2,26 +2,32 @@ from fontTools import ttLib from fontTools.misc.testTools import getXML, parseXML from fontTools.ttLib.tables.C_O_L_R_ import table_C_O_L_R_ +import binascii import pytest -COLR_V0_DATA = ( - b"\x00\x00" # Version (0) - b"\x00\x01" # BaseGlyphRecordCount (1) - b"\x00\x00\x00\x0e" # Offset to BaseGlyphRecordArray from beginning of table (14) - b"\x00\x00\x00\x14" # Offset to LayerRecordArray from beginning of table (20) - b"\x00\x03" # LayerRecordCount (3) - b"\x00\x06" # BaseGlyphRecord[0].BaseGlyph (6) - b"\x00\x00" # BaseGlyphRecord[0].FirstLayerIndex (0) - b"\x00\x03" # BaseGlyphRecord[0].NumLayers (3) - b"\x00\x07" # LayerRecord[0].LayerGlyph (7) - b"\x00\x00" # LayerRecord[0].PaletteIndex (0) - b"\x00\x08" # LayerRecord[1].LayerGlyph (8) - b"\x00\x01" # LayerRecord[1].PaletteIndex (1) - b"\x00\t" # LayerRecord[2].LayerGlyph (9) - b"\x00\x02" # LayerRecord[3].PaletteIndex (2) +COLR_V0_SAMPLE = ( + (b"\x00\x00", "Version (0)"), + (b"\x00\x01", "BaseGlyphRecordCount (1)"), + ( + b"\x00\x00\x00\x0e", + "Offset to BaseGlyphRecordArray from beginning of table (14)", + ), + (b"\x00\x00\x00\x14", "Offset to LayerRecordArray from beginning of table (20)"), + (b"\x00\x03", "LayerRecordCount (3)"), + (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), + (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), + (b"\x00\x03", "BaseGlyphRecord[0].NumLayers (3)"), + (b"\x00\x07", "LayerRecord[0].LayerGlyph (7)"), + (b"\x00\x00", "LayerRecord[0].PaletteIndex (0)"), + (b"\x00\x08", "LayerRecord[1].LayerGlyph (8)"), + (b"\x00\x01", "LayerRecord[1].PaletteIndex (1)"), + (b"\x00\t", "LayerRecord[2].LayerGlyph (9)"), + (b"\x00\x02", "LayerRecord[3].PaletteIndex (2)"), ) +COLR_V0_DATA = b"".join(t[0] for t in COLR_V0_SAMPLE) + COLR_V0_XML = [ '', @@ -37,6 +43,23 @@ def dump(table, ttFont=None): print("\n".join(getXML(table.toXML, ttFont))) +def diff_binary_fragments(font_bytes, expected_fragments): + pos = 0 + prev_desc = "" + errors = 0 + for expected_bytes, description in expected_fragments: + actual_bytes = font_bytes[pos : pos + len(expected_bytes)] + if actual_bytes != expected_bytes: + print(f'{description} (previous "{prev_desc}", actual_bytes: {"".join("%02x" % v for v in actual_bytes)} bytes: {str(font_bytes[pos:pos+16])}') + errors += 1 + pos += len(expected_bytes) + prev_desc = description + assert errors == 0 + assert pos == len( + font_bytes + ), f"Leftover font bytes, used {pos} of {len(font_bytes)}" + + @pytest.fixture def font(): font = ttLib.TTFont() @@ -48,7 +71,7 @@ class COLR_V0_Test(object): def test_decompile_and_compile(self, font): colr = table_C_O_L_R_() colr.decompile(COLR_V0_DATA, font) - assert colr.compile(font) == COLR_V0_DATA + diff_binary_fragments(colr.compile(font), COLR_V0_SAMPLE) def test_decompile_and_dump_xml(self, font): colr = table_C_O_L_R_() @@ -62,145 +85,201 @@ class COLR_V0_Test(object): for name, attrs, content in parseXML(COLR_V0_XML): colr.fromXML(name, attrs, content, font) - assert colr.compile(font) == COLR_V0_DATA + diff_binary_fragments(colr.compile(font), COLR_V0_SAMPLE) + + def test_round_trip_xml(self, font): + colr = table_C_O_L_R_() + for name, attrs, content in parseXML(COLR_V0_XML): + colr.fromXML(name, attrs, content, font) + compiled = colr.compile(font) + + colr = table_C_O_L_R_() + colr.decompile(compiled, font) + assert getXML(colr.toXML, font) == COLR_V0_XML -COLR_V1_DATA = ( - b"\x00\x01" # Version (1) - b"\x00\x01" # BaseGlyphRecordCount (1) - b"\x00\x00\x00\x1a" # Offset to BaseGlyphRecordArray from beginning of table (26) - b"\x00\x00\x00 " # Offset to LayerRecordArray from beginning of table (32) - b"\x00\x03" # LayerRecordCount (3) - b"\x00\x00\x00," # Offset to BaseGlyphV1List from beginning of table (44) - b"\x00\x00\x00\x81" # Offset to LayerV1List from beginning of table (129) - b"\x00\x00\x00\x00" # Offset to VarStore (NULL) - b"\x00\x06" # BaseGlyphRecord[0].BaseGlyph (6) - b"\x00\x00" # BaseGlyphRecord[0].FirstLayerIndex (0) - b"\x00\x03" # BaseGlyphRecord[0].NumLayers (3) - b"\x00\x07" # LayerRecord[0].LayerGlyph (7) - b"\x00\x00" # LayerRecord[0].PaletteIndex (0) - b"\x00\x08" # LayerRecord[1].LayerGlyph (8) - b"\x00\x01" # LayerRecord[1].PaletteIndex (1) - b"\x00\t" # LayerRecord[2].LayerGlyph (9) - b"\x00\x02" # LayerRecord[2].PaletteIndex (2) - b"\x00\x00\x00\x02" # BaseGlyphV1List.BaseGlyphCount (2) - b"\x00\n" # BaseGlyphV1List.BaseGlyphV1Record[0].BaseGlyph (10) - b"\x00\x00\x00\x10" # Offset to Paint table from beginning of BaseGlyphV1List (16) - b"\x00\x0e" # BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14) - b"\x00\x00\x00\x16" # Offset to Paint table from beginning of BaseGlyphV1List (22) - b"\x01" # BaseGlyphV1Record[0].Paint.Format (1) - b"\x03" # BaseGlyphV1Record[0].Paint.NumLayers (3) - b"\x00\x00\x00\x00" # BaseGlyphV1Record[0].Paint.FirstLayerIndex (0) - b"\x08" # BaseGlyphV1Record[1].Paint.Format (8) - b"\x00\x00<" # Offset to SourcePaint from beginning of PaintComposite (60) - b"\x03" # BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3) - b"\x00\x00\x08" # Offset to BackdropPaint from beginning of PaintComposite (8) - b"\x07" # BaseGlyphV1Record[1].Paint.BackdropPaint.Format (7) - b"\x00\x004" # Offset to Paint from beginning of PaintTransform (52) - b"\x00\x01\x00\x00" # Affine2x3.xx.value (1.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.xy.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.yx.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x01\x00\x00" # Affine2x3.yy.value (1.0) - b"\x00\x00\x00\x00" - b"\x01,\x00\x00" # Affine2x3.dx.value (300.0) - b"\x00\x00\x00\x00" - b"\x00\x00\x00\x00" # Affine2x3.dy.value (0.0) - b"\x00\x00\x00\x00" - b"\x06" # BaseGlyphV1Record[1].Paint.SourcePaint.Format (6) - b"\x00\n" # BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10) - b"\x00\x00\x00\x03" # LayerV1List.LayerCount (3) - b"\x00\x00\x00\x10" # Offset to Paint table from beginning of LayerV1List (16) - b"\x00\x00\x00\x1f" # Offset to Paint table from beginning of LayerV1List (31) - b"\x00\x00\x00z" # Offset to Paint table from beginning of LayerV1List (122) - b"\x05" # LayerV1List.Paint[0].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\x0b" # LayerV1List.Paint[0].Glyph (11) - b"\x02" # LayerV1List.Paint[0].Paint.Format (2) - b"\x00\x02" # Paint.Color.PaletteIndex (2) - b" \x00" # Paint.Color.Alpha.value (0.5) - b"\x00\x00\x00\x00" # Paint.Color.Alpha.varIdx (0) - b"\x05" # LayerV1List.Paint[1].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\x0c" # LayerV1List.Paint[1].Glyph (12) - b"\x03" # LayerV1List.Paint[1].Paint.Format (3) - b"\x00\x00(" # Offset to ColorLine from beginning of PaintLinearGradient (40) - b"\x00\x01" # Paint.x0.value (1) - b"\x00\x00\x00\x00" # Paint.x0.varIdx (0) - b"\x00\x02" # Paint.y0.value (2) - b"\x00\x00\x00\x00" # Paint.y0.varIdx (0) - b"\xff\xfd" # Paint.x1.value (-3) - b"\x00\x00\x00\x00" # Paint.x1.varIdx (0) - b"\xff\xfc" # Paint.y1.value (-4) - b"\x00\x00\x00\x00" # Paint.y1.varIdx (0) - b"\x00\x05" # Paint.x2.value (5) - b"\x00\x00\x00\x00" # Paint.x2.varIdx (0) - b"\x00\x06" # Paint.y2.value (6) - b"\x00\x00\x00\x00" # Paint.y2.varIdx (0) - b"\x01" # ColorLine.Extend (1 or "repeat") - b"\x00\x03" # ColorLine.StopCount (3) - b"\x00\x00" # ColorLine.ColorStop[0].StopOffset.value (0.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[0].StopOffset.varIdx (0) - b"\x00\x03" # ColorLine.ColorStop[0].Color.PaletteIndex (3) - b"@\x00" # ColorLine.ColorStop[0].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[0].Color.Alpha.varIdx (0) - b" \x00" # ColorLine.ColorStop[1].StopOffset.value (0.5) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[1].StopOffset.varIdx (0) - b"\x00\x04" # ColorLine.ColorStop[1].Color.PaletteIndex (4) - b"@\x00" # ColorLine.ColorStop[1].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[1].Color.Alpha.varIdx (0) - b"@\x00" # ColorLine.ColorStop[2].StopOffset.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[2].StopOffset.varIdx (0) - b"\x00\x05" # ColorLine.ColorStop[2].Color.PaletteIndex (5) - b"@\x00" # ColorLine.ColorStop[2].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" # ColorLine.ColorStop[2].Color.Alpha.varIdx (0) - b"\x05" # LayerV1List.Paint[2].Format (5) - b"\x00\x00\x06" # Offset to Paint subtable from beginning of PaintGlyph (6) - b"\x00\r" # LayerV1List.Paint[2].Glyph (13) - b"\x07" # LayerV1List.Paint[2].Paint.Format (5) - b"\x00\x004" # Offset to Paint subtable from beginning of PaintTransform (52) - b"\xff\xf3\x00\x00" # Affine2x3.xx.value (-13) - b"\x00\x00\x00\x00" - b"\x00\x0e\x00\x00" # Affine2x3.xy.value (14) - b"\x00\x00\x00\x00" - b"\x00\x0f\x00\x00" # Affine2x3.yx.value (15) - b"\x00\x00\x00\x00" - b"\xff\xef\x00\x00" # Affine2x3.yy.value (-17) - b"\x00\x00\x00\x00" - b"\x00\x12\x00\x00" # Affine2x3.yy.value (18) - b"\x00\x00\x00\x00" - b"\x00\x13\x00\x00" # Affine2x3.yy.value (19) - b"\x00\x00\x00\x00" - b"\x04" # LayerV1List.Paint[2].Paint.Paint.Format (4) - b"\x00\x00(" # Offset to ColorLine from beginning of PaintRadialGradient (40) - b"\x00\x07" # Paint.x0.value (7) - b"\x00\x00\x00\x00" - b"\x00\x08" # Paint.y0.value (8) - b"\x00\x00\x00\x00" - b"\x00\t" # Paint.r0.value (9) - b"\x00\x00\x00\x00" - b"\x00\n" # Paint.x1.value (10) - b"\x00\x00\x00\x00" - b"\x00\x0b" # Paint.y1.value (11) - b"\x00\x00\x00\x00" - b"\x00\x0c" # Paint.r1.value (12) - b"\x00\x00\x00\x00" - b"\x00" # ColorLine.Extend (0 or "pad") - b"\x00\x02" # ColorLine.StopCount (2) - b"\x00\x00" # ColorLine.ColorStop[0].StopOffset.value (0.0) - b"\x00\x00\x00\x00" - b"\x00\x06" # ColorLine.ColorStop[0].Color.PaletteIndex (6) - b"@\x00" # ColorLine.ColorStop[0].Color.Alpha.value (1.0) - b"\x00\x00\x00\x00" - b"@\x00" # ColorLine.ColorStop[1].StopOffset.value (1.0) - b"\x00\x00\x00\x00" - b"\x00\x07" # ColorLine.ColorStop[1].Color.PaletteIndex (7) - b"\x19\x9a" # ColorLine.ColorStop[1].Color.Alpha.value (0.4) - b"\x00\x00\x00\x00" +COLR_V1_SAMPLE = ( + (b"\x00\x01", "Version (1)"), + (b"\x00\x01", "BaseGlyphRecordCount (1)"), + ( + b"\x00\x00\x00\x1a", + "Offset to BaseGlyphRecordArray from beginning of table (26)", + ), + (b"\x00\x00\x00 ", "Offset to LayerRecordArray from beginning of table (32)"), + (b"\x00\x03", "LayerRecordCount (3)"), + (b"\x00\x00\x00,", "Offset to BaseGlyphV1List from beginning of table (44)"), + (b"\x00\x00\x00\xac", "Offset to LayerV1List from beginning of table (172)"), + (b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"), + (b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"), + (b"\x00\x00", "BaseGlyphRecord[0].FirstLayerIndex (0)"), + (b"\x00\x03", "BaseGlyphRecord[0].NumLayers (3)"), + (b"\x00\x07", "LayerRecord[0].LayerGlyph (7)"), + (b"\x00\x00", "LayerRecord[0].PaletteIndex (0)"), + (b"\x00\x08", "LayerRecord[1].LayerGlyph (8)"), + (b"\x00\x01", "LayerRecord[1].PaletteIndex (1)"), + (b"\x00\t", "LayerRecord[2].LayerGlyph (9)"), + (b"\x00\x02", "LayerRecord[2].PaletteIndex (2)"), + # BaseGlyphV1List + (b"\x00\x00\x00\x03", "BaseGlyphV1List.BaseGlyphCount (3)"), + (b"\x00\n", "BaseGlyphV1List.BaseGlyphV1Record[0].BaseGlyph (10)"), + ( + b"\x00\x00\x00\x16", + "Offset to Paint table from beginning of BaseGlyphV1List (22)", + ), + (b"\x00\x0e", "BaseGlyphV1List.BaseGlyphV1Record[1].BaseGlyph (14)"), + ( + b"\x00\x00\x00\x1c", + "Offset to Paint table from beginning of BaseGlyphV1List (28)", + ), + (b"\x00\x0f", "BaseGlyphV1List.BaseGlyphV1Record[2].BaseGlyph (15)"), + ( + b"\x00\x00\x00\x5b", + "Offset to Paint table from beginning of BaseGlyphV1List (91)", + ), + # BaseGlyphV1Record[0] + (b"\x01", "BaseGlyphV1Record[0].Paint.Format (1)"), + (b"\x04", "BaseGlyphV1Record[0].Paint.NumLayers (4)"), + (b"\x00\x00\x00\x00", "BaseGlyphV1Record[0].Paint.FirstLayerIndex (0)"), + # BaseGlyphV1Record[1] + (b"\x14", "BaseGlyphV1Record[1].Paint.Format (20)"), + (b"\x00\x00<", "Offset to SourcePaint from beginning of PaintComposite (60)"), + (b"\x03", "BaseGlyphV1Record[1].Paint.CompositeMode [SRC_OVER] (3)"), + (b"\x00\x00\x08", "Offset to BackdropPaint from beginning of PaintComposite (8)"), + (b"\x0d", "BaseGlyphV1Record[1].Paint.BackdropPaint.Format (13)"), + (b"\x00\x00\x34", "Offset to Paint from beginning of PaintVarTransform (52)"), + (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.xx.value (1.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.xy.value (0.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.yx.value (0.0)"), + (b"\x00\x01\x00\x00\x00\x00\x00\x00", "Affine2x3.yy.value (1.0)"), + (b"\x01\x2c\x00\x00\x00\x00\x00\x00", "Affine2x3.dx.value (300.0)"), + (b"\x00\x00\x00\x00\x00\x00\x00\x00", "Affine2x3.dy.value (0.0)"), + (b"\x0b", "BaseGlyphV1Record[1].Paint.SourcePaint.Format (11)"), + (b"\x00\n", "BaseGlyphV1Record[1].Paint.SourcePaint.Glyph (10)"), + # BaseGlyphV1Record[2] + (b"\x0a", "BaseGlyphV1Record[2].Paint.Format (10)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0b", "BaseGlyphV1Record[2].Paint.Glyph (11)"), + (b"\x08", "BaseGlyphV1Record[2].Paint.Paint.Format (8)"), + (b"\x00\x00\x10", "Offset to ColorLine from beginning of PaintSweepGradient (16)"), + (b"\x01\x03", "centerX (259)"), + (b"\x01\x2c", "centerY (300)"), + (b"\x00\x2d\x00\x00", "startAngle (45.0)"), + (b"\x00\x87\x00\x00", "endAngle (135.0)"), + (b"\x00", "ColorLine.Extend (0; pad)"), + (b"\x00\x02", "ColorLine.StopCount (2)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset (0.0)"), + (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha (1.0)"), + (b"@\x00", "ColorLine.ColorStop[1].StopOffset (1.0)"), + (b"\x00\x05", "ColorLine.ColorStop[1].Color.PaletteIndex (5)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha (1.0)"), + # LayerV1List + (b"\x00\x00\x00\x04", "LayerV1List.LayerCount (4)"), + ( + b"\x00\x00\x00\x14", + "First Offset to Paint table from beginning of LayerV1List (20)", + ), + ( + b"\x00\x00\x00\x23", + "Second Offset to Paint table from beginning of LayerV1List (35)", + ), + ( + b"\x00\x00\x00\x4e", + "Third Offset to Paint table from beginning of LayerV1List (78)", + ), + ( + b"\x00\x00\x00\xb7", + "Fourth Offset to Paint table from beginning of LayerV1List (183)", + ), + # PaintGlyph glyph00011 + (b"\x0a", "LayerV1List.Paint[0].Format (10)"), + (b"\x00\x00\x06", "Offset24 to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0b", "LayerV1List.Paint[0].Glyph (glyph00011)"), + # PaintVarSolid + (b"\x03", "LayerV1List.Paint[0].Paint.Format (3)"), + (b"\x00\x02", "Paint.Color.PaletteIndex (2)"), + (b" \x00", "Paint.Color.Alpha.value (0.5)"), + (b"\x00\x00\x00\x00", "Paint.Color.Alpha.varIdx (0)"), + # PaintGlyph glyph00012 + (b"\x0a", "LayerV1List.Paint[1].Format (10)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0c", "LayerV1List.Paint[1].Glyph (glyph00012)"), + (b"\x04", "LayerV1List.Paint[1].Paint.Format (4)"), + (b"\x00\x00\x10", "Offset to ColorLine from beginning of PaintLinearGradient (16)"), + (b"\x00\x01", "Paint.x0 (1)"), + (b"\x00\x02", "Paint.y0 (2)"), + (b"\xff\xfd", "Paint.x1 (-3)"), + (b"\xff\xfc", "Paint.y1 (-4)"), + (b"\x00\x05", "Paint.x2 (5)"), + (b"\x00\x06", "Paint.y2 (6)"), + (b"\x01", "ColorLine.Extend (1; repeat)"), + (b"\x00\x03", "ColorLine.StopCount (3)"), + (b"\x00\x00", "ColorLine.ColorStop[0].StopOffset (0.0)"), + (b"\x00\x03", "ColorLine.ColorStop[0].Color.PaletteIndex (3)"), + (b"@\x00", "ColorLine.ColorStop[0].Color.Alpha (1.0)"), + (b" \x00", "ColorLine.ColorStop[1].StopOffset (0.5)"), + (b"\x00\x04", "ColorLine.ColorStop[1].Color.PaletteIndex (4)"), + (b"@\x00", "ColorLine.ColorStop[1].Color.Alpha (1.0)"), + (b"@\x00", "ColorLine.ColorStop[2].StopOffset (1.0)"), + (b"\x00\x05", "ColorLine.ColorStop[2].Color.PaletteIndex (5)"), + (b"@\x00", "ColorLine.ColorStop[2].Color.Alpha (1.0)"), + # PaintGlyph glyph00013 + (b"\x0a", "LayerV1List.Paint[2].Format (10)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\r", "LayerV1List.Paint[2].Glyph (13)"), + (b"\x0c", "LayerV1List.Paint[2].Paint.Format (12)"), + (b"\x00\x00\x1c", "Offset to Paint subtable from beginning of PaintTransform (28)"), + (b"\xff\xf3\x00\x00", "Affine2x3.xx (-13)"), + (b"\x00\x0e\x00\x00", "Affine2x3.xy (14)"), + (b"\x00\x0f\x00\x00", "Affine2x3.yx (15)"), + (b"\xff\xef\x00\x00", "Affine2x3.yy (-17)"), + (b"\x00\x12\x00\x00", "Affine2x3.yy (18)"), + (b"\x00\x13\x00\x00", "Affine2x3.yy (19)"), + (b"\x07", "LayerV1List.Paint[2].Paint.Paint.Format (7)"), + (b"\x00\x00(", "Offset to ColorLine from beginning of PaintVarRadialGradient (40)"), + (b"\x00\x07\x00\x00\x00\x00", "Paint.x0.value (7)"), + (b"\x00\x08\x00\x00\x00\x00", "Paint.y0.value (8)"), + (b"\x00\t\x00\x00\x00\x00", "Paint.r0.value (9)"), + (b"\x00\n\x00\x00\x00\x00", "Paint.x1.value (10)"), + (b"\x00\x0b\x00\x00\x00\x00", "Paint.y1.value (11)"), + (b"\x00\x0c\x00\x00\x00\x00", "Paint.r1.value (12)"), + (b"\x00", "ColorLine.Extend (0; pad)"), + (b"\x00\x02", "ColorLine.StopCount (2)"), + (b"\x00\x00\x00\x00\x00\x00", "ColorLine.ColorStop[0].StopOffset.value (0.0)"), + (b"\x00\x06", "ColorLine.ColorStop[0].Color.PaletteIndex (6)"), + (b"@\x00\x00\x00\x00\x00", "ColorLine.ColorStop[0].Color.Alpha.value (1.0)"), + (b"@\x00\x00\x00\x00\x00", "ColorLine.ColorStop[1].StopOffset.value (1.0)"), + (b"\x00\x07", "ColorLine.ColorStop[1].Color.PaletteIndex (7)"), + (b"\x19\x9a\x00\x00\x00\x00", "ColorLine.ColorStop[1].Color.Alpha.value (0.4)"), + # PaintTranslate + (b"\x0e", "LayerV1List.Paint[3].Format (14)"), + (b"\x00\x00\x0c", "Offset to Paint subtable from beginning of PaintTranslate (12)"), + (b"\x01\x01\x00\x00", "dx (257)"), + (b"\x01\x02\x00\x00", "dy (258)"), + # PaintRotate + (b"\x10", "LayerV1List.Paint[3].Paint.Format (16)"), + (b"\x00\x00\x10", "Offset to Paint subtable from beginning of PaintRotate (16)"), + (b"\x00\x2d\x00\x00", "angle (45)"), + (b"\x00\xff\x00\x00", "centerX (255)"), + (b"\x01\x00\x00\x00", "centerY (256)"), + # PaintSkew + (b"\x12", "LayerV1List.Paint[3].Paint.Paint.Format (18)"), + (b"\x00\x00\x14", "Offset to Paint subtable from beginning of PaintSkew (20)"), + (b"\xff\xf5\x00\x00", "xSkewAngle (-11)"), + (b"\x00\x05\x00\x00", "ySkewAngle (5)"), + (b"\x00\xfd\x00\x00", "centerX.value (253)"), + (b"\x00\xfe\x00\x00", "centerY.value (254)"), + # PaintGlyph + (b"\x0a", "LayerV1List.Paint[3].Paint.Paint.Paint.Format (10)"), + (b"\x00\x00\x06", "Offset to Paint subtable from beginning of PaintGlyph (6)"), + (b"\x00\x0b", "LayerV1List.Paint[2].Glyph (11)"), + # PaintSolid + (b"\x02", "LayerV1List.Paint[0].Paint.Paint.Paint.Paint.Format (2)"), + (b"\x00\x02", "Paint.Color.PaletteIndex (2)"), + (b" \x00", "Paint.Color.Alpha (0.5)"), ) +COLR_V1_DATA = b"".join(t[0] for t in COLR_V1_SAMPLE) COLR_V1_XML = [ '', @@ -228,23 +307,23 @@ COLR_V1_XML = [ "", "", "", - " ", + " ", ' ', ' ', ' ', - ' ', + ' ', ' ', " ", " ", ' ', ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", ' ', - ' ', - ' ', + ' ', + ' ', ' ', " ", " ", @@ -258,11 +337,41 @@ COLR_V1_XML = [ " ", " ", " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", "", "", - " ", - ' ', - ' ', + " ", + ' ', + ' ', " ", ' ', ' ', @@ -270,8 +379,8 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', + ' ', + ' ', " ", ' ', " ", @@ -306,9 +415,9 @@ COLR_V1_XML = [ " ", ' ', " ", - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', " ", ' ', " ", @@ -345,6 +454,30 @@ COLR_V1_XML = [ " ", ' ', " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + " ", "", ] @@ -353,7 +486,7 @@ class COLR_V1_Test(object): def test_decompile_and_compile(self, font): colr = table_C_O_L_R_() colr.decompile(COLR_V1_DATA, font) - assert colr.compile(font) == COLR_V1_DATA + diff_binary_fragments(colr.compile(font), COLR_V1_SAMPLE) def test_decompile_and_dump_xml(self, font): colr = table_C_O_L_R_() @@ -366,5 +499,14 @@ class COLR_V1_Test(object): colr = table_C_O_L_R_() for name, attrs, content in parseXML(COLR_V1_XML): colr.fromXML(name, attrs, content, font) + diff_binary_fragments(colr.compile(font), COLR_V1_SAMPLE) - assert colr.compile(font) == COLR_V1_DATA + def test_round_trip_xml(self, font): + colr = table_C_O_L_R_() + for name, attrs, content in parseXML(COLR_V1_XML): + colr.fromXML(name, attrs, content, font) + compiled = colr.compile(font) + + colr = table_C_O_L_R_() + colr.decompile(compiled, font) + assert getXML(colr.toXML, font) == COLR_V1_XML diff --git a/Tests/ttLib/tables/S_T_A_T_test.py b/Tests/ttLib/tables/S_T_A_T_test.py index d8c1b7dca..5366e8a26 100644 --- a/Tests/ttLib/tables/S_T_A_T_test.py +++ b/Tests/ttLib/tables/S_T_A_T_test.py @@ -147,7 +147,7 @@ STAT_XML_AXIS_VALUE_FORMAT3 = [ '', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', @@ -191,7 +191,7 @@ STAT_XML_VERSION_1_1 = [ '', ' ', ' ', - ' ', + ' ', ' ', ' ', ' ', diff --git a/Tests/ttLib/tables/_n_a_m_e_test.py b/Tests/ttLib/tables/_n_a_m_e_test.py index bc4aab2f1..11aeebae9 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 2651e8085..23aab4aa9 100644 --- a/Tests/ttLib/woff2_test.py +++ b/Tests/ttLib/woff2_test.py @@ -1,6 +1,7 @@ from fontTools.misc.py23 import * from fontTools import ttLib from fontTools.ttLib import woff2 +from fontTools.ttLib.tables import _g_l_y_f from fontTools.ttLib.woff2 import ( WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat, woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry, @@ -21,7 +22,10 @@ import pytest haveBrotli = False try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli haveBrotli = True except ImportError: pass @@ -200,7 +204,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 @@ -1217,6 +1221,20 @@ class WOFF2RoundtripTest(object): assert tmp.getvalue() == tmp2.getvalue() assert ttFont.flavor == "woff2" + def test_roundtrip_off_curve_despite_overlap_bit(self): + ttx = os.path.join(data_dir, "woff2_overlap_offcurve_in.ttx") + ttFont = ttLib.TTFont() + ttFont.importXML(ttx) + + assert ttFont["glyf"]["A"].flags[0] == _g_l_y_f.flagOverlapSimple + + ttFont.flavor = "woff2" + tmp = BytesIO() + ttFont.save(tmp) + + _, ttFont2 = self.roundtrip(tmp) + assert ttFont2.flavor == "woff2" + assert ttFont2["glyf"]["A"].flags[0] == 0 class MainTest(object): diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index 753cc9ce2..3d4c3f92b 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -18,7 +18,10 @@ try: except ImportError: zopfli = None try: - import brotli + try: + import brotlicffi as brotli + except ImportError: + import brotli except ImportError: brotli = None diff --git a/Tests/varLib/instancer/conftest.py b/Tests/varLib/instancer/conftest.py new file mode 100644 index 000000000..0ac8091df --- /dev/null +++ b/Tests/varLib/instancer/conftest.py @@ -0,0 +1,13 @@ +import os +from fontTools import ttLib +import pytest + + +TESTDATA = os.path.join(os.path.dirname(__file__), "data") + + +@pytest.fixture +def varfont(): + f = ttLib.TTFont() + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) + return f diff --git a/Tests/varLib/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx similarity index 98% rename from Tests/varLib/data/PartialInstancerTest-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx index 92540e03e..268b5068b 100644 --- a/Tests/varLib/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx @@ -479,6 +479,9 @@ TestVariableFont-XCdBd + + Normal + @@ -764,6 +767,15 @@ + + + + + + + + + diff --git a/Tests/varLib/data/PartialInstancerTest2-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest2-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest2-VF.ttx diff --git a/Tests/varLib/data/PartialInstancerTest3-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx similarity index 100% rename from Tests/varLib/data/PartialInstancerTest3-VF.ttx rename to Tests/varLib/instancer/data/PartialInstancerTest3-VF.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx similarity index 99% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx index 5e1107ce3..af840387e 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx +++ b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx @@ -531,7 +531,7 @@ - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx similarity index 99% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx index add86a67b..63d23240e 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx +++ b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx @@ -519,14 +519,14 @@ - + - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx similarity index 99% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx index 1405904af..6cf837dcb 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx +++ b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx @@ -525,7 +525,7 @@ - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx similarity index 99% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx index e48936a85..f8f94ba7b 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx +++ b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx @@ -531,7 +531,7 @@ - + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlap-flags.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-400-no-overlaps.ttx diff --git a/Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx b/Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx similarity index 100% rename from Tests/varLib/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx rename to Tests/varLib/instancer/data/test_results/PartialInstancerTest3-VF-instance-700-no-overlaps.ttx diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer/instancer_test.py similarity index 98% rename from Tests/varLib/instancer_test.py rename to Tests/varLib/instancer/instancer_test.py index 5e999dc83..c3e0729be 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -20,16 +20,11 @@ import re import pytest +# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition + TESTDATA = os.path.join(os.path.dirname(__file__), "data") -@pytest.fixture -def varfont(): - f = ttLib.TTFont() - f.importXML(os.path.join(TESTDATA, "PartialInstancerTest-VF.ttx")) - return f - - @pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) def optimize(request): return request.param @@ -144,7 +139,7 @@ class InstantiateGvarTest(object): assert "gvar" not in varfont def test_composite_glyph_not_in_gvar(self, varfont): - """ The 'minus' glyph is a composite glyph, which references 'hyphen' as a + """The 'minus' glyph is a composite glyph, which references 'hyphen' as a component, but has no tuple variations in gvar table, so the component offset and the phantom points do not change; however the sidebearings and bounding box do change as a result of the parent glyph 'hyphen' changing. @@ -1209,8 +1204,8 @@ class InstantiateSTATTest(object): @pytest.mark.parametrize( "location, expected", [ - ({"wght": 400}, ["Regular", "Condensed", "Upright"]), - ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright"]), + ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]), + ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]), ], ) def test_pin_and_drop_axis(self, varfont, location, expected): @@ -1341,30 +1336,6 @@ class InstantiateSTATTest(object): assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue -def test_pruningUnusedNames(varfont): - varNameIDs = instancer.getVariationNameIDs(varfont) - - assert varNameIDs == set(range(256, 296 + 1)) - - fvar = varfont["fvar"] - stat = varfont["STAT"].table - - with instancer.pruningUnusedNames(varfont): - del fvar.axes[0] # Weight (nameID=256) - del fvar.instances[0] # Thin (nameID=258) - del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) - del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) - - assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) - - with instancer.pruningUnusedNames(varfont): - del varfont["fvar"] - del varfont["STAT"] - - assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) - assert "ltag" not in varfont - - def test_setMacOverlapFlags(): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py new file mode 100644 index 000000000..9774458a9 --- /dev/null +++ b/Tests/varLib/instancer/names_test.py @@ -0,0 +1,322 @@ +from fontTools.ttLib.tables import otTables +from fontTools.otlLib.builder import buildStatTable +from fontTools.varLib import instancer + +import pytest + + +def test_pruningUnusedNames(varfont): + varNameIDs = instancer.names.getVariationNameIDs(varfont) + + assert varNameIDs == set(range(256, 297 + 1)) + + fvar = varfont["fvar"] + stat = varfont["STAT"].table + + with instancer.names.pruningUnusedNames(varfont): + del fvar.axes[0] # Weight (nameID=256) + del fvar.instances[0] # Thin (nameID=258) + del stat.DesignAxisRecord.Axis[0] # Weight (nameID=256) + del stat.AxisValueArray.AxisValue[0] # Thin (nameID=258) + + assert not any(n for n in varfont["name"].names if n.nameID in {256, 258}) + + with instancer.names.pruningUnusedNames(varfont): + del varfont["fvar"] + del varfont["STAT"] + + assert not any(n for n in varfont["name"].names if n.nameID in varNameIDs) + assert "ltag" not in varfont + + +def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): + nametable = varfont["name"] + font_names = { + (r.nameID, r.platformID, r.platEncID, r.langID): r.toUnicode() + for r in nametable.names + } + for k in expected: + if k[-1] not in platforms: + continue + assert font_names[k] == expected[k] + + font_nameids = set(i[0] for i in font_names) + if isNonRIBBI: + assert 16 in font_nameids + assert 17 in font_nameids + + if "fvar" not in varfont: + assert 25 not in font_nameids + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Regular Normal (width axis Normal isn't included since it is elided) + ( + {"wght": 400, "wdth": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Regular", + (6, 3, 1, 0x409): "TestVariableFont-Regular", + }, + False, + ), + # Black + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Black", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Black", + (6, 3, 1, 0x409): "TestVariableFont-Black", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black", + }, + True, + ), + # Thin + ( + {"wght": 100}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Thin", + (6, 3, 1, 0x409): "TestVariableFont-Thin", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin", + }, + True, + ), + # Thin Condensed + ( + {"wght": 100, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Thin Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-ThinCondensed", + (6, 3, 1, 0x409): "TestVariableFont-ThinCondensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Thin Condensed", + }, + True, + ), + # Condensed with unpinned weights + ( + {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, + { + (1, 3, 1, 0x409): "Test Variable Font Condensed", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Condensed", + (6, 3, 1, 0x409): "TestVariableFont-Condensed", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Condensed", + }, + True, + ), + ], +) +def test_updateNameTable_with_registered_axes_ribbi( + varfont, limits, expected, isNonRIBBI +): + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updatetNameTable_axis_order(varfont): + axes = [ + dict( + tag="wght", + name="Weight", + values=[ + dict(value=400, name="Regular"), + ], + ), + dict( + tag="wdth", + name="Width", + values=[ + dict(value=75, name="Condensed"), + ], + ), + ] + nametable = varfont["name"] + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Regular Condensed" + + # Swap the axes so the names get swapped + axes[0], axes[1] = axes[1], axes[0] + + buildStatTable(varfont, axes) + instancer.names.updateNameTable(varfont, {"wdth": 75, "wght": 400}) + assert nametable.getName(17, 3, 1, 0x409).toUnicode() == "Condensed Regular" + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Normal", + }, + False, + ), + # Black | Negreta + ( + {"wght": 900}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta", + }, + True, + ), + # Black Condensed | Negreta Zhuštěné + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Negreta Zhuštěné", + (2, 3, 1, 0x409): "Normal", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Negreta Zhuštěné", + }, + True, + ), + ], +) +def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNonRIBBI): + name = varfont["name"] + # langID 0x405 is the Czech Windows langID + name.setName("Test Variable Font", 1, 3, 1, 0x405) + name.setName("Normal", 2, 3, 1, 0x405) + name.setName("Normal", 261, 3, 1, 0x405) # nameID 261=Regular STAT entry + name.setName("Negreta", 266, 3, 1, 0x405) # nameID 266=Black STAT entry + name.setName("Zhuštěné", 279, 3, 1, 0x405) # nameID 279=Condensed STAT entry + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x405]) + + +def test_updateNameTable_missing_axisValues(varfont): + with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): + instancer.names.updateNameTable(varfont, {"wght": 200}) + + +def test_updateNameTable_missing_stat(varfont): + del varfont["STAT"] + with pytest.raises( + ValueError, match="Cannot update name table since there is no STAT table." + ): + instancer.names.updateNameTable(varfont, {"wght": 400}) + + +@pytest.mark.parametrize( + "limits, expected, isNonRIBBI", + [ + # Regular | Normal + ( + {"wght": 400}, + { + (1, 3, 1, 0x409): "Test Variable Font", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-Italic", + }, + False, + ), + # Black Condensed Italic + ( + {"wght": 900, "wdth": 79}, + { + (1, 3, 1, 0x409): "Test Variable Font Black Condensed", + (2, 3, 1, 0x409): "Italic", + (6, 3, 1, 0x409): "TestVariableFont-BlackCondensedItalic", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Black Condensed Italic", + }, + True, + ), + ], +) +def test_updateNameTable_vf_with_italic_attribute( + varfont, limits, expected, isNonRIBBI +): + font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] + # Unset ELIDABLE_AXIS_VALUE_NAME flag + font_link_axisValue.Flags &= ~instancer.names.ELIDABLE_AXIS_VALUE_NAME + font_link_axisValue.ValueNameID = 294 # Roman --> Italic + + instancer.names.updateNameTable(varfont, limits) + _test_name_records(varfont, expected, isNonRIBBI) + + +def test_updateNameTable_format4_axisValues(varfont): + # format 4 axisValues should dominate the other axisValues + stat = varfont["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.Flags = 0 + varfont["name"].setName("Dominant Value", 297, 3, 1, 0x409) + axisValue.ValueNameID = 297 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 900), ("wdth", 79)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.names.updateNameTable(varfont, {"wdth": 79, "wght": 900}) + expected = { + (1, 3, 1, 0x409): "Test Variable Font Dominant Value", + (2, 3, 1, 0x409): "Regular", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Dominant Value", + } + _test_name_records(varfont, expected, isNonRIBBI=True) + + +def test_updateNameTable_elided_axisValues(varfont): + stat = varfont["STAT"].table + # set ELIDABLE_AXIS_VALUE_NAME flag for all axisValues + for axisValue in stat.AxisValueArray.AxisValue: + axisValue.Flags |= instancer.names.ELIDABLE_AXIS_VALUE_NAME + + stat.ElidedFallbackNameID = 266 # Regular --> Black + instancer.names.updateNameTable(varfont, {"wght": 400}) + # Since all axis values are elided, the elided fallback name + # must be used to construct the style names. Since we + # changed it to Black, we need both a typoSubFamilyName and + # the subFamilyName set so it conforms to the RIBBI model. + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Black"} + _test_name_records(varfont, expected, isNonRIBBI=True) + + +def test_updateNameTable_existing_subfamily_name_is_not_regular(varfont): + # Check the subFamily name will be set to Regular when we update a name + # table to a non-RIBBI style and the current subFamily name is a RIBBI + # style which isn't Regular. + varfont["name"].setName("Bold", 2, 3, 1, 0x409) # subFamily Regular --> Bold + + instancer.names.updateNameTable(varfont, {"wght": 100}) + expected = {(2, 3, 1, 0x409): "Regular", (17, 3, 1, 0x409): "Thin"} + _test_name_records(varfont, expected, isNonRIBBI=True) diff --git a/setup.cfg b/setup.cfg index d53704c75..358fa8421 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.17.2.dev0 +current_version = 4.21.2.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 2cd2ccb0c..fbf6117a5 100755 --- a/setup.py +++ b/setup.py @@ -82,8 +82,8 @@ extras_require = { # for fontTools.sfnt and fontTools.woff2: to compress/uncompress # WOFF 1.0 and WOFF 2.0 webfonts. "woff": [ - "brotli >= 1.0.1; platform_python_implementation != 'PyPy'", - "brotlipy >= 0.7.0; platform_python_implementation == 'PyPy'", + "brotli >= 1.0.1; platform_python_implementation == 'CPython'", + "brotlicffi >= 0.8.0; platform_python_implementation != 'CPython'", "zopfli >= 0.1.4", ], # for fontTools.unicode and fontTools.unicodedata: to use the latest version @@ -441,7 +441,7 @@ if ext_modules: setup_params = dict( name="fonttools", - version="4.17.2.dev0", + version="4.21.2.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", diff --git a/tox.ini b/tox.ini index 8ced886b8..bcbeeeddf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.0 -envlist = mypy, py3{6,7,8}-cov, htmlcov +envlist = mypy, py3{6,7,8,9}-cov, htmlcov skip_missing_interpreters=true [testenv]