Merge remote-tracking branch 'origin/main' into mutator-trivial-fixes

This commit is contained in:
Cosimo Lupo 2021-03-04 10:49:49 +00:00
commit 53b13263e9
No known key found for this signature in database
GPG Key ID: 179A8F0895A02F4F
123 changed files with 7401 additions and 1588 deletions

View File

@ -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:

View File

@ -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

View File

@ -5,4 +5,5 @@ ttFont
.. automodule:: fontTools.ttLib.ttFont
:inherited-members:
:members:
:undoc-members:
:undoc-members:
:private-members:

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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 = " <anchor NULL>"
temp = "\n" + indent + SHIFT * 2 + "<anchor NULL>"
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

View File

@ -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)

View File

@ -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 <https://adobe-type-tools.github.io/afdko/OpenTypeFeatureFileSpecification.html#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:

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -1,3 +1 @@
"""Empty __init__.py file to signal Python this directory is a package."""
from fontTools.misc.py23 import *

View File

@ -1,6 +1,5 @@
"""Calculate the area of a glyph."""
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen

View File

@ -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:

View File

@ -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

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen

View File

@ -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

View File

@ -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})]")

View File

@ -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

View File

@ -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

View File

@ -2,7 +2,6 @@
for shapes.
"""
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen
from fontTools.misc.bezierTools import solveQuadratic, solveCubic

View File

@ -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

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen
from Quartz.CoreGraphics import CGPathCreateMutable, CGPathMoveToPoint

View File

@ -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

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen
from reportlab.graphics.shapes import Path

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.misc.arrayTools import pairwise
from fontTools.pens.filterPen import ContourFilterPen

View File

@ -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

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen

View File

@ -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

View File

@ -1,5 +1,4 @@
"""Pen multiplexing drawing to one or more pens."""
from fontTools.misc.py23 import *
from fontTools.pens.basePen import AbstractPen

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.filterPen import FilterPen, FilterPointPen

View File

@ -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)

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.basePen import BasePen

View File

@ -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'))

View File

@ -14,9 +14,11 @@ class table_C_O_L_R_(DefaultTable.DefaultTable):
ttFont['COLR'][<glyphName>] = <value> 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):

View File

@ -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,

View File

@ -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.'),

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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())

View File

@ -0,0 +1,5 @@
import sys
from fontTools.varLib.instancer import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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)
----------------------------

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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

4
Tests/feaLib/STAT2.fea Normal file
View File

@ -0,0 +1,4 @@
table STAT {
ElidedFallbackName { name "Roman"; };
DesignAxis zonk 0 { name "Zonkey"; };'
} STAT;

View File

@ -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=""):

View File

@ -6,7 +6,11 @@ markClass [cedilla] <anchor 222 22> @BOTTOM_MARKS;
markClass [ogonek] <anchor 333 33> @SIDE_MARKS;
feature test {
pos base a <anchor 11 1> mark @TOP_MARKS <anchor 12 -1> mark @BOTTOM_MARKS;
pos base [b c] <anchor 22 -2> mark @BOTTOM_MARKS;
pos base d <anchor 33 3> mark @SIDE_MARKS;
pos base a
<anchor 11 1> mark @TOP_MARKS
<anchor 12 -1> mark @BOTTOM_MARKS;
pos base [b c]
<anchor 22 -2> mark @BOTTOM_MARKS;
pos base d
<anchor 33 3> mark @SIDE_MARKS;
} test;

View File

@ -5,14 +5,29 @@ markClass [ogonek] <anchor 800 -10> @OGONEK;
feature test {
pos ligature [c_t s_t] <anchor 500 800> mark @TOP_MARKS <anchor 500 -200> mark @BOTTOM_MARKS
ligComponent <anchor 1500 800> mark @TOP_MARKS <anchor 1500 -200> mark @BOTTOM_MARKS <anchor 1550 0> mark @OGONEK;
pos ligature [c_t s_t]
<anchor 500 800> mark @TOP_MARKS
<anchor 500 -200> mark @BOTTOM_MARKS
ligComponent
<anchor 1500 800> mark @TOP_MARKS
<anchor 1500 -200> mark @BOTTOM_MARKS
<anchor 1550 0> mark @OGONEK;
pos ligature f_l <anchor 300 800> mark @TOP_MARKS <anchor 300 -200> mark @BOTTOM_MARKS
ligComponent <anchor 600 800> mark @TOP_MARKS <anchor 600 -200> mark @BOTTOM_MARKS;
pos ligature f_l
<anchor 300 800> mark @TOP_MARKS
<anchor 300 -200> mark @BOTTOM_MARKS
ligComponent
<anchor 600 800> mark @TOP_MARKS
<anchor 600 -200> mark @BOTTOM_MARKS;
pos ligature [f_f_l] <anchor 300 800> mark @TOP_MARKS <anchor 300 -200> mark @BOTTOM_MARKS
ligComponent <anchor 600 800> mark @TOP_MARKS <anchor 600 -200> mark @BOTTOM_MARKS
ligComponent <anchor 900 800> mark @TOP_MARKS <anchor 900 -200> mark @BOTTOM_MARKS;
pos ligature [f_f_l]
<anchor 300 800> mark @TOP_MARKS
<anchor 300 -200> mark @BOTTOM_MARKS
ligComponent
<anchor 600 800> mark @TOP_MARKS
<anchor 600 -200> mark @BOTTOM_MARKS
ligComponent
<anchor 900 800> mark @TOP_MARKS
<anchor 900 -200> mark @BOTTOM_MARKS;
} test;

View File

@ -5,6 +5,9 @@ markClass macron <anchor 2 2 contourpoint 22> @TOP_MARKS;
markClass [cedilla] <anchor 3 3 contourpoint 33> @BOTTOM_MARKS;
feature test {
pos mark [acute grave macron ogonek] <anchor 500 200> mark @TOP_MARKS <anchor 500 -80> mark @BOTTOM_MARKS;
pos mark [dieresis caron] <anchor 500 200> mark @TOP_MARKS;
pos mark [acute grave macron ogonek]
<anchor 500 200> mark @TOP_MARKS
<anchor 500 -80> mark @BOTTOM_MARKS;
pos mark [dieresis caron]
<anchor 500 200> mark @TOP_MARKS;
} test;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,228 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.20">
<name>
<namerecord nameID="25" platformID="3" platEncID="1" langID="0x409">
TestFont
</namerecord>
<namerecord nameID="256" platformID="3" platEncID="1" langID="0x409">
Roman
</namerecord>
<namerecord nameID="256" platformID="3" platEncID="1" langID="0x411">
ローマン
</namerecord>
<namerecord nameID="257" platformID="3" platEncID="1" langID="0x409">
Optical Size
</namerecord>
<namerecord nameID="258" platformID="3" platEncID="1" langID="0x409">
Text
</namerecord>
<namerecord nameID="259" platformID="3" platEncID="1" langID="0x409">
Subhead
</namerecord>
<namerecord nameID="260" platformID="3" platEncID="1" langID="0x409">
Display
</namerecord>
<namerecord nameID="261" platformID="3" platEncID="1" langID="0x409">
Width
</namerecord>
<namerecord nameID="262" platformID="3" platEncID="1" langID="0x409">
Condensed
</namerecord>
<namerecord nameID="263" platformID="3" platEncID="1" langID="0x409">
Semicondensed
</namerecord>
<namerecord nameID="264" platformID="3" platEncID="1" langID="0x409">
Normal
</namerecord>
<namerecord nameID="265" platformID="3" platEncID="1" langID="0x409">
Extended
</namerecord>
<namerecord nameID="266" platformID="3" platEncID="1" langID="0x409">
Weight
</namerecord>
<namerecord nameID="267" platformID="3" platEncID="1" langID="0x409">
Light
</namerecord>
<namerecord nameID="268" platformID="3" platEncID="1" langID="0x409">
Regular
</namerecord>
<namerecord nameID="269" platformID="3" platEncID="1" langID="0x409">
Medium
</namerecord>
<namerecord nameID="270" platformID="3" platEncID="1" langID="0x409">
Semibold
</namerecord>
<namerecord nameID="271" platformID="3" platEncID="1" langID="0x409">
Bold
</namerecord>
<namerecord nameID="272" platformID="3" platEncID="1" langID="0x409">
Black
</namerecord>
<namerecord nameID="273" platformID="3" platEncID="1" langID="0x409">
Italic
</namerecord>
<namerecord nameID="274" platformID="3" platEncID="1" langID="0x409">
Roman
</namerecord>
<namerecord nameID="275" platformID="3" platEncID="1" langID="0x409">
Caption
</namerecord>
</name>
<STAT>
<Version value="0x00010002"/>
<DesignAxisRecordSize value="8"/>
<!-- DesignAxisCount=4 -->
<DesignAxisRecord>
<Axis index="0">
<AxisTag value="opsz"/>
<AxisNameID value="257"/> <!-- Optical Size -->
<AxisOrdering value="0"/>
</Axis>
<Axis index="1">
<AxisTag value="wdth"/>
<AxisNameID value="261"/> <!-- Width -->
<AxisOrdering value="1"/>
</Axis>
<Axis index="2">
<AxisTag value="wght"/>
<AxisNameID value="266"/> <!-- Weight -->
<AxisOrdering value="2"/>
</Axis>
<Axis index="3">
<AxisTag value="ital"/>
<AxisNameID value="273"/> <!-- Italic -->
<AxisOrdering value="3"/>
</Axis>
</DesignAxisRecord>
<!-- AxisValueCount=15 -->
<AxisValueArray>
<AxisValue index="0" Format="4">
<!-- AxisCount=2 -->
<Flags value="0"/>
<ValueNameID value="275"/> <!-- Caption -->
<AxisValueRecord index="0">
<AxisIndex value="0"/>
<Value value="8.0"/>
</AxisValueRecord>
<AxisValueRecord index="1">
<AxisIndex value="1"/>
<Value value="400.0"/>
</AxisValueRecord>
</AxisValue>
<AxisValue index="1" Format="2">
<AxisIndex value="0"/>
<Flags value="3"/> <!-- OlderSiblingFontAttribute ElidableAxisValueName -->
<ValueNameID value="258"/> <!-- Text -->
<NominalValue value="11.0"/>
<RangeMinValue value="9.0"/>
<RangeMaxValue value="12.0"/>
</AxisValue>
<AxisValue index="2" Format="2">
<AxisIndex value="0"/>
<Flags value="0"/>
<ValueNameID value="259"/> <!-- Subhead -->
<NominalValue value="16.7"/>
<RangeMinValue value="12.0"/>
<RangeMaxValue value="24.0"/>
</AxisValue>
<AxisValue index="3" Format="2">
<AxisIndex value="0"/>
<Flags value="0"/>
<ValueNameID value="260"/> <!-- Display -->
<NominalValue value="72.0"/>
<RangeMinValue value="24.0"/>
<RangeMaxValue value="72.0"/>
</AxisValue>
<AxisValue index="4" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="262"/> <!-- Condensed -->
<NominalValue value="80.0"/>
<RangeMinValue value="80.0"/>
<RangeMaxValue value="89.0"/>
</AxisValue>
<AxisValue index="5" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="263"/> <!-- Semicondensed -->
<NominalValue value="90.0"/>
<RangeMinValue value="90.0"/>
<RangeMaxValue value="96.0"/>
</AxisValue>
<AxisValue index="6" Format="2">
<AxisIndex value="1"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="264"/> <!-- Normal -->
<NominalValue value="100.0"/>
<RangeMinValue value="97.0"/>
<RangeMaxValue value="101.0"/>
</AxisValue>
<AxisValue index="7" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="265"/> <!-- Extended -->
<NominalValue value="125.0"/>
<RangeMinValue value="102.0"/>
<RangeMaxValue value="125.0"/>
</AxisValue>
<AxisValue index="8" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="267"/> <!-- Light -->
<NominalValue value="300.0"/>
<RangeMinValue value="300.0"/>
<RangeMaxValue value="349.0"/>
</AxisValue>
<AxisValue index="9" Format="2">
<AxisIndex value="2"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="268"/> <!-- Regular -->
<NominalValue value="400.0"/>
<RangeMinValue value="350.0"/>
<RangeMaxValue value="449.0"/>
</AxisValue>
<AxisValue index="10" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="269"/> <!-- Medium -->
<NominalValue value="500.0"/>
<RangeMinValue value="450.0"/>
<RangeMaxValue value="549.0"/>
</AxisValue>
<AxisValue index="11" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="270"/> <!-- Semibold -->
<NominalValue value="600.0"/>
<RangeMinValue value="550.0"/>
<RangeMaxValue value="649.0"/>
</AxisValue>
<AxisValue index="12" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="271"/> <!-- Bold -->
<NominalValue value="700.0"/>
<RangeMinValue value="650.0"/>
<RangeMaxValue value="749.0"/>
</AxisValue>
<AxisValue index="13" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="272"/> <!-- Black -->
<NominalValue value="900.0"/>
<RangeMinValue value="750.0"/>
<RangeMaxValue value="900.0"/>
</AxisValue>
<AxisValue index="14" Format="1">
<AxisIndex value="3"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="274"/> <!-- Roman -->
<Value value="0.0"/>
</AxisValue>
</AxisValueArray>
<ElidedFallbackNameID value="256"/> <!-- Roman -->
</STAT>
</ttFont>

View File

@ -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;

View File

@ -0,0 +1,225 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.20">
<name>
<namerecord nameID="25" platformID="3" platEncID="1" langID="0x409">
TestFont
</namerecord>
<namerecord nameID="256" platformID="3" platEncID="1" langID="0x409">
Roman
</namerecord>
<namerecord nameID="257" platformID="3" platEncID="1" langID="0x409">
Optical Size
</namerecord>
<namerecord nameID="258" platformID="3" platEncID="1" langID="0x409">
Text
</namerecord>
<namerecord nameID="259" platformID="3" platEncID="1" langID="0x409">
Subhead
</namerecord>
<namerecord nameID="260" platformID="3" platEncID="1" langID="0x409">
Display
</namerecord>
<namerecord nameID="261" platformID="3" platEncID="1" langID="0x409">
Width
</namerecord>
<namerecord nameID="262" platformID="3" platEncID="1" langID="0x409">
Condensed
</namerecord>
<namerecord nameID="263" platformID="3" platEncID="1" langID="0x409">
Semicondensed
</namerecord>
<namerecord nameID="264" platformID="3" platEncID="1" langID="0x409">
Normal
</namerecord>
<namerecord nameID="265" platformID="3" platEncID="1" langID="0x409">
Extended
</namerecord>
<namerecord nameID="266" platformID="3" platEncID="1" langID="0x409">
Weight
</namerecord>
<namerecord nameID="267" platformID="3" platEncID="1" langID="0x409">
Light
</namerecord>
<namerecord nameID="268" platformID="3" platEncID="1" langID="0x409">
Regular
</namerecord>
<namerecord nameID="269" platformID="3" platEncID="1" langID="0x409">
Medium
</namerecord>
<namerecord nameID="270" platformID="3" platEncID="1" langID="0x409">
Semibold
</namerecord>
<namerecord nameID="271" platformID="3" platEncID="1" langID="0x409">
Bold
</namerecord>
<namerecord nameID="272" platformID="3" platEncID="1" langID="0x409">
Black
</namerecord>
<namerecord nameID="273" platformID="3" platEncID="1" langID="0x409">
Italic
</namerecord>
<namerecord nameID="274" platformID="3" platEncID="1" langID="0x409">
Roman
</namerecord>
<namerecord nameID="275" platformID="3" platEncID="1" langID="0x409">
Caption
</namerecord>
</name>
<STAT>
<Version value="0x00010002"/>
<DesignAxisRecordSize value="8"/>
<!-- DesignAxisCount=4 -->
<DesignAxisRecord>
<Axis index="0">
<AxisTag value="opsz"/>
<AxisNameID value="257"/> <!-- Optical Size -->
<AxisOrdering value="0"/>
</Axis>
<Axis index="1">
<AxisTag value="wdth"/>
<AxisNameID value="261"/> <!-- Width -->
<AxisOrdering value="1"/>
</Axis>
<Axis index="2">
<AxisTag value="wght"/>
<AxisNameID value="266"/> <!-- Weight -->
<AxisOrdering value="2"/>
</Axis>
<Axis index="3">
<AxisTag value="ital"/>
<AxisNameID value="273"/> <!-- Italic -->
<AxisOrdering value="3"/>
</Axis>
</DesignAxisRecord>
<!-- AxisValueCount=15 -->
<AxisValueArray>
<AxisValue index="0" Format="4">
<!-- AxisCount=2 -->
<Flags value="0"/>
<ValueNameID value="275"/> <!-- Caption -->
<AxisValueRecord index="0">
<AxisIndex value="0"/>
<Value value="8.0"/>
</AxisValueRecord>
<AxisValueRecord index="1">
<AxisIndex value="1"/>
<Value value="400.0"/>
</AxisValueRecord>
</AxisValue>
<AxisValue index="1" Format="2">
<AxisIndex value="0"/>
<Flags value="3"/> <!-- OlderSiblingFontAttribute ElidableAxisValueName -->
<ValueNameID value="258"/> <!-- Text -->
<NominalValue value="11.0"/>
<RangeMinValue value="9.0"/>
<RangeMaxValue value="12.0"/>
</AxisValue>
<AxisValue index="2" Format="2">
<AxisIndex value="0"/>
<Flags value="0"/>
<ValueNameID value="259"/> <!-- Subhead -->
<NominalValue value="16.7"/>
<RangeMinValue value="12.0"/>
<RangeMaxValue value="24.0"/>
</AxisValue>
<AxisValue index="3" Format="2">
<AxisIndex value="0"/>
<Flags value="0"/>
<ValueNameID value="260"/> <!-- Display -->
<NominalValue value="72.0"/>
<RangeMinValue value="24.0"/>
<RangeMaxValue value="72.0"/>
</AxisValue>
<AxisValue index="4" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="262"/> <!-- Condensed -->
<NominalValue value="80.0"/>
<RangeMinValue value="80.0"/>
<RangeMaxValue value="89.0"/>
</AxisValue>
<AxisValue index="5" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="263"/> <!-- Semicondensed -->
<NominalValue value="90.0"/>
<RangeMinValue value="90.0"/>
<RangeMaxValue value="96.0"/>
</AxisValue>
<AxisValue index="6" Format="2">
<AxisIndex value="1"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="264"/> <!-- Normal -->
<NominalValue value="100.0"/>
<RangeMinValue value="97.0"/>
<RangeMaxValue value="101.0"/>
</AxisValue>
<AxisValue index="7" Format="2">
<AxisIndex value="1"/>
<Flags value="0"/>
<ValueNameID value="265"/> <!-- Extended -->
<NominalValue value="125.0"/>
<RangeMinValue value="102.0"/>
<RangeMaxValue value="125.0"/>
</AxisValue>
<AxisValue index="8" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="267"/> <!-- Light -->
<NominalValue value="300.0"/>
<RangeMinValue value="300.0"/>
<RangeMaxValue value="349.0"/>
</AxisValue>
<AxisValue index="9" Format="2">
<AxisIndex value="2"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="268"/> <!-- Regular -->
<NominalValue value="400.0"/>
<RangeMinValue value="350.0"/>
<RangeMaxValue value="449.0"/>
</AxisValue>
<AxisValue index="10" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="269"/> <!-- Medium -->
<NominalValue value="500.0"/>
<RangeMinValue value="450.0"/>
<RangeMaxValue value="549.0"/>
</AxisValue>
<AxisValue index="11" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="270"/> <!-- Semibold -->
<NominalValue value="600.0"/>
<RangeMinValue value="550.0"/>
<RangeMaxValue value="649.0"/>
</AxisValue>
<AxisValue index="12" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="271"/> <!-- Bold -->
<NominalValue value="700.0"/>
<RangeMinValue value="650.0"/>
<RangeMaxValue value="749.0"/>
</AxisValue>
<AxisValue index="13" Format="2">
<AxisIndex value="2"/>
<Flags value="0"/>
<ValueNameID value="272"/> <!-- Black -->
<NominalValue value="900.0"/>
<RangeMinValue value="750.0"/>
<RangeMaxValue value="900.0"/>
</AxisValue>
<AxisValue index="14" Format="1">
<AxisIndex value="3"/>
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="274"/> <!-- Roman -->
<Value value="0.0"/>
</AxisValue>
</AxisValueArray>
<ElidedFallbackNameID value="256"/> <!-- Roman -->
</STAT>
</ttFont>

View File

@ -2,10 +2,12 @@
feature mark {
lookup mark1 {
markClass [acute] <anchor 150 -10> @TOP_MARKS;
pos base [e] <anchor 250 450> mark @TOP_MARKS;
pos base [e]
<anchor 250 450> mark @TOP_MARKS;
} mark1;
lookup mark2 {
markClass [acute] <anchor 150 -20> @TOP_MARKS_2;
pos base [e] <anchor 250 450> mark @TOP_MARKS_2;
pos base [e]
<anchor 250 450> mark @TOP_MARKS_2;
} mark2;
} mark;

View File

@ -0,0 +1,3 @@
feature test {
sub a by NULL;
} test;

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont>
<GSUB>
<Version value="0x00010000"/>
<ScriptList>
<!-- ScriptCount=1 -->
<ScriptRecord index="0">
<ScriptTag value="DFLT"/>
<Script>
<DefaultLangSys>
<ReqFeatureIndex value="65535"/>
<!-- FeatureCount=1 -->
<FeatureIndex index="0" value="0"/>
</DefaultLangSys>
<!-- LangSysCount=0 -->
</Script>
</ScriptRecord>
</ScriptList>
<FeatureList>
<!-- FeatureCount=1 -->
<FeatureRecord index="0">
<FeatureTag value="test"/>
<Feature>
<!-- LookupCount=1 -->
<LookupListIndex index="0" value="0"/>
</Feature>
</FeatureRecord>
</FeatureList>
<LookupList>
<!-- LookupCount=1 -->
<Lookup index="0">
<LookupType value="2"/>
<LookupFlag value="0"/>
<!-- SubTableCount=1 -->
<MultipleSubst index="0">
<Substitution in="a" out=""/>
</MultipleSubst>
</Lookup>
</LookupList>
</GSUB>
</ttFont>

View File

@ -9,7 +9,11 @@ markClass [dieresis umlaut] <anchor 300 -10> @TOP_MARKS;
markClass [cedilla] <anchor 300 600> @BOTTOM_MARKS;
feature test {
pos base [e o] <anchor 250 450> mark @TOP_MARKS <anchor 250 -12> mark @BOTTOM_MARKS;
#test-fea2fea: pos base [a u] <anchor 265 450> mark @TOP_MARKS <anchor 250 -10> mark @BOTTOM_MARKS;
position base [a u] <anchor 265 450> mark @TOP_MARKS <anchor 250-10> mark @BOTTOM_MARKS;
pos base [e o]
<anchor 250 450> mark @TOP_MARKS
<anchor 250 -12> mark @BOTTOM_MARKS;
#test-fea2fea: pos base [a u]
position base [a u]
<anchor 265 450> mark @TOP_MARKS
<anchor 250 -10> mark @BOTTOM_MARKS;
} test;

View File

@ -4,7 +4,10 @@ markClass sukun <anchor 261 488> @TOP_MARKS;
markClass kasratan <anchor 346 -98> @BOTTOM_MARKS;
feature test {
pos ligature lam_meem_jeem <anchor 625 1800> mark @TOP_MARKS # mark above lam
ligComponent <anchor 376 -368> mark @BOTTOM_MARKS # mark below meem
ligComponent <anchor NULL>; # jeem has no marks
pos ligature lam_meem_jeem
<anchor 625 1800> mark @TOP_MARKS # mark above lam
ligComponent
<anchor 376 -368> mark @BOTTOM_MARKS # mark below meem
ligComponent
<anchor NULL>; # jeem has no marks
} test;

View File

@ -2,5 +2,6 @@ languagesystem DFLT dflt;
feature test {
markClass damma <anchor 189 -103> @MARK_CLASS_1;
pos mark hamza <anchor 221 301> mark @MARK_CLASS_1;
pos mark hamza
<anchor 221 301> mark @MARK_CLASS_1;
} test;

View File

@ -12,8 +12,10 @@ lookup CNTXT_PAIR_POS {
} CNTXT_PAIR_POS;
lookup CNTXT_MARK_TO_BASE {
pos base o <anchor 250 450> mark @ALL_MARKS;
pos base c <anchor 250 450> mark @ALL_MARKS;
pos base o
<anchor 250 450> mark @ALL_MARKS;
pos base c
<anchor 250 450> mark @ALL_MARKS;
} CNTXT_MARK_TO_BASE;
feature test {

View File

@ -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]

View File

@ -141,9 +141,6 @@
Test Axis
</namerecord>
<namerecord nameID="257" platformID="1" platEncID="0" langID="0x0" unicode="True">
TotallyNormal
</namerecord>
<namerecord nameID="258" platformID="1" platEncID="0" langID="0x0" unicode="True">
TotallyTested
</namerecord>
<namerecord nameID="1" platformID="1" platEncID="0" langID="0x4" unicode="True">
@ -165,9 +162,6 @@
Test Axis
</namerecord>
<namerecord nameID="257" platformID="3" platEncID="1" langID="0x409">
TotallyNormal
</namerecord>
<namerecord nameID="258" platformID="3" platEncID="1" langID="0x409">
TotallyTested
</namerecord>
<namerecord nameID="1" platformID="3" platEncID="1" langID="0x413">
@ -290,12 +284,12 @@
</Axis>
<!-- TotallyNormal -->
<NamedInstance flags="0x0" subfamilyNameID="257">
<NamedInstance flags="0x0" subfamilyNameID="2">
<coord axis="TEST" value="0.0"/>
</NamedInstance>
<!-- TotallyTested -->
<NamedInstance flags="0x0" subfamilyNameID="258">
<NamedInstance flags="0x0" subfamilyNameID="257">
<coord axis="TEST" value="100.0"/>
</NamedInstance>
</fvar>

View File

@ -199,12 +199,9 @@
Down
</namerecord>
<namerecord nameID="260" platformID="1" platEncID="0" langID="0x0" unicode="True">
TotallyNormal
</namerecord>
<namerecord nameID="261" platformID="1" platEncID="0" langID="0x0" unicode="True">
Right Up
</namerecord>
<namerecord nameID="262" platformID="1" platEncID="0" langID="0x0" unicode="True">
<namerecord nameID="261" platformID="1" platEncID="0" langID="0x0" unicode="True">
Neutral
</namerecord>
<namerecord nameID="1" platformID="1" platEncID="0" langID="0x4" unicode="True">
@ -235,12 +232,9 @@
Down
</namerecord>
<namerecord nameID="260" platformID="3" platEncID="1" langID="0x409">
TotallyNormal
</namerecord>
<namerecord nameID="261" platformID="3" platEncID="1" langID="0x409">
Right Up
</namerecord>
<namerecord nameID="262" platformID="3" platEncID="1" langID="0x409">
<namerecord nameID="261" platformID="3" platEncID="1" langID="0x409">
Neutral
</namerecord>
<namerecord nameID="1" platformID="3" platEncID="1" langID="0x413">
@ -399,8 +393,8 @@
<AxisValueArray>
<AxisValue index="0" Format="1">
<AxisIndex value="0"/>
<Flags value="2"/>
<ValueNameID value="262"/> <!-- Neutral -->
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="261"/> <!-- Neutral -->
<Value value="0.0"/>
</AxisValue>
<AxisValue index="1" Format="1">
@ -411,8 +405,8 @@
</AxisValue>
<AxisValue index="2" Format="1">
<AxisIndex value="1"/>
<Flags value="2"/>
<ValueNameID value="262"/> <!-- Neutral -->
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="261"/> <!-- Neutral -->
<Value value="0.0"/>
</AxisValue>
<AxisValue index="3" Format="1">
@ -423,8 +417,8 @@
</AxisValue>
<AxisValue index="4" Format="1">
<AxisIndex value="2"/>
<Flags value="2"/>
<ValueNameID value="262"/> <!-- Neutral -->
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="261"/> <!-- Neutral -->
<Value value="0.0"/>
</AxisValue>
<AxisValue index="5" Format="1">
@ -435,8 +429,8 @@
</AxisValue>
<AxisValue index="6" Format="1">
<AxisIndex value="3"/>
<Flags value="2"/>
<ValueNameID value="262"/> <!-- Neutral -->
<Flags value="2"/> <!-- ElidableAxisValueName -->
<ValueNameID value="261"/> <!-- Neutral -->
<Value value="0.0"/>
</AxisValue>
<AxisValue index="7" Format="1">
@ -492,7 +486,7 @@
</Axis>
<!-- TotallyNormal -->
<NamedInstance flags="0x0" subfamilyNameID="260">
<NamedInstance flags="0x0" subfamilyNameID="2">
<coord axis="LEFT" value="0.0"/>
<coord axis="RGHT" value="0.0"/>
<coord axis="UPPP" value="0.0"/>
@ -500,7 +494,7 @@
</NamedInstance>
<!-- Right Up -->
<NamedInstance flags="0x0" subfamilyNameID="261">
<NamedInstance flags="0x0" subfamilyNameID="260">
<coord axis="LEFT" value="0.0"/>
<coord axis="RGHT" value="100.0"/>
<coord axis="UPPP" value="100.0"/>

66
Tests/misc/vector_test.py Normal file
View File

@ -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]

View File

@ -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 = [
' </AxisValue>',
' <AxisValue index="1" Format="1">',
' <AxisIndex value="0"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="256"/> <!-- Regular -->',
' <Value value="400.0"/>',
' </AxisValue>',
@ -1187,7 +1193,7 @@ buildStatTable_test_data = [
' </AxisValue>',
' <AxisValue index="1" Format="1">',
' <AxisIndex value="0"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="258"/> <!-- Regular -->',
' <Value value="400.0"/>',
' </AxisValue>',
@ -1205,7 +1211,7 @@ buildStatTable_test_data = [
' </AxisValue>',
' <AxisValue index="4" Format="1">',
' <AxisIndex value="1"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="258"/> <!-- Regular -->',
' <Value value="100.0"/>',
' </AxisValue>',
@ -1240,7 +1246,7 @@ buildStatTable_test_data = [
' <AxisValueArray>',
' <AxisValue index="0" Format="1">',
' <AxisIndex value="0"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="257"/> <!-- Regular -->',
' <Value value="400.0"/>',
' </AxisValue>',
@ -1285,7 +1291,7 @@ buildStatTable_test_data = [
' </AxisValue>',
' <AxisValue index="1" Format="2">',
' <AxisIndex value="0"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="258"/> <!-- Text -->',
' <NominalValue value="14.0"/>',
' <RangeMinValue value="10.0"/>',
@ -1348,7 +1354,7 @@ buildStatTable_test_data = [
' </AxisValue>',
' <AxisValue index="1" Format="1">',
' <AxisIndex value="1"/>',
' <Flags value="2"/>',
' <Flags value="2"/> <!-- ElidableAxisValueName -->',
' <ValueNameID value="258"/> <!-- Regular -->',
' <Value value="100.0"/>',
' </AxisValue>',

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.areaPen import AreaPen
import unittest

View File

@ -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

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen
import unittest

View File

@ -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))

View File

@ -1,4 +1,3 @@
from fontTools.misc.py23 import *
from fontTools.pens.perimeterPen import PerimeterPen
import unittest

View File

@ -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):

View File

@ -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))

View File

@ -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))

View File

@ -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)

View File

@ -1,5 +1,3 @@
from fontTools.misc.py23 import *
import os
import unittest
import struct

View File

@ -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

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.18">
<cmap>
<tableVersion version="0"/>
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
</cmap_format_4>
<cmap_format_4 platformID="3" platEncID="1" language="0">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
</cmap_format_4>
</cmap>
</ttFont>

View File

@ -0,0 +1,225 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.18">
<GlyphOrder>
<!-- The 'id' attribute is only for humans; it is ignored when parsed. -->
<GlyphID id="0" name=".notdef"/>
<GlyphID id="1" name="a"/>
<GlyphID id="2" name="basket"/>
</GlyphOrder>
<head>
<!-- Most of this table will be recalculated by the compiler -->
<tableVersion value="1.0"/>
<fontRevision value="0.0"/>
<checkSumAdjustment value="0xc643119c"/>
<magicNumber value="0x5f0f3cf5"/>
<flags value="00000000 00000011"/>
<unitsPerEm value="1000"/>
<created value="Tue Jan 12 16:39:39 2021"/>
<modified value="Tue Jan 12 16:39:39 2021"/>
<xMin value="50"/>
<yMin value="-200"/>
<xMax value="450"/>
<yMax value="800"/>
<macStyle value="00000000 00000000"/>
<lowestRecPPEM value="6"/>
<fontDirectionHint value="2"/>
<indexToLocFormat value="0"/>
<glyphDataFormat value="0"/>
</head>
<hhea>
<tableVersion value="0x00010000"/>
<ascent value="1000"/>
<descent value="-200"/>
<lineGap value="0"/>
<advanceWidthMax value="942"/>
<minLeftSideBearing value="50"/>
<minRightSideBearing value="50"/>
<xMaxExtent value="450"/>
<caretSlopeRise value="1"/>
<caretSlopeRun value="0"/>
<caretOffset value="0"/>
<reserved0 value="0"/>
<reserved1 value="0"/>
<reserved2 value="0"/>
<reserved3 value="0"/>
<metricDataFormat value="0"/>
<numberOfHMetrics value="3"/>
</hhea>
<maxp>
<!-- Most of this table will be recalculated by the compiler -->
<tableVersion value="0x10000"/>
<numGlyphs value="3"/>
<maxPoints value="8"/>
<maxContours value="2"/>
<maxCompositePoints value="0"/>
<maxCompositeContours value="0"/>
<maxZones value="1"/>
<maxTwilightPoints value="0"/>
<maxStorage value="0"/>
<maxFunctionDefs value="0"/>
<maxInstructionDefs value="0"/>
<maxStackElements value="0"/>
<maxSizeOfInstructions value="0"/>
<maxComponentElements value="0"/>
<maxComponentDepth value="0"/>
</maxp>
<OS_2>
<!-- The fields 'usFirstCharIndex' and 'usLastCharIndex'
will be recalculated by the compiler -->
<version value="4"/>
<xAvgCharWidth value="660"/>
<usWeightClass value="400"/>
<usWidthClass value="5"/>
<fsType value="00000000 00000100"/>
<ySubscriptXSize value="650"/>
<ySubscriptYSize value="600"/>
<ySubscriptXOffset value="0"/>
<ySubscriptYOffset value="75"/>
<ySuperscriptXSize value="650"/>
<ySuperscriptYSize value="600"/>
<ySuperscriptXOffset value="0"/>
<ySuperscriptYOffset value="350"/>
<yStrikeoutSize value="50"/>
<yStrikeoutPosition value="300"/>
<sFamilyClass value="0"/>
<panose>
<bFamilyType value="0"/>
<bSerifStyle value="0"/>
<bWeight value="0"/>
<bProportion value="0"/>
<bContrast value="0"/>
<bStrokeVariation value="0"/>
<bArmStyle value="0"/>
<bLetterForm value="0"/>
<bMidline value="0"/>
<bXHeight value="0"/>
</panose>
<ulUnicodeRange1 value="00000000 00000000 00000000 00000001"/>
<ulUnicodeRange2 value="00000010 00000000 00000000 00000000"/>
<ulUnicodeRange3 value="00000000 00000000 00000000 00000000"/>
<ulUnicodeRange4 value="00000000 00000000 00000000 00000000"/>
<achVendID value="NONE"/>
<fsSelection value="00000000 01000000"/>
<usFirstCharIndex value="97"/>
<usLastCharIndex value="65535"/>
<sTypoAscender value="800"/>
<sTypoDescender value="-200"/>
<sTypoLineGap value="200"/>
<usWinAscent value="1000"/>
<usWinDescent value="200"/>
<ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
<ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
<sxHeight value="500"/>
<sCapHeight value="700"/>
<usDefaultChar value="0"/>
<usBreakChar value="32"/>
<usMaxContext value="0"/>
</OS_2>
<hmtx>
<mtx name=".notdef" width="500" lsb="50"/>
<mtx name="a" width="538" lsb="0"/>
<mtx name="basket" width="942" lsb="0"/>
</hmtx>
<cmap>
<tableVersion version="0"/>
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
</cmap_format_4>
<cmap_format_12 platformID="0" platEncID="4" format="12" reserved="0" length="40" language="0" nGroups="2">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
<map code="0x1f9fa" name="basket"/><!-- BASKET -->
</cmap_format_12>
<cmap_format_4 platformID="3" platEncID="1" language="0">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
</cmap_format_4>
<cmap_format_12 platformID="3" platEncID="10" format="12" reserved="0" length="40" language="0" nGroups="2">
<map code="0x61" name="a"/><!-- LATIN SMALL LETTER A -->
<map code="0x1f9fa" name="basket"/><!-- BASKET -->
</cmap_format_12>
</cmap>
<loca>
<!-- The 'loca' table will be calculated by the compiler -->
</loca>
<glyf>
<!-- The xMin, yMin, xMax and yMax values
will be recalculated by the compiler. -->
<TTGlyph name=".notdef" xMin="50" yMin="-200" xMax="450" yMax="800">
<contour>
<pt x="50" y="-200" on="1"/>
<pt x="50" y="800" on="1"/>
<pt x="450" y="800" on="1"/>
<pt x="450" y="-200" on="1"/>
</contour>
<contour>
<pt x="100" y="-150" on="1"/>
<pt x="400" y="-150" on="1"/>
<pt x="400" y="750" on="1"/>
<pt x="100" y="750" on="1"/>
</contour>
<instructions/>
</TTGlyph>
<TTGlyph name="a"/><!-- contains no outline data -->
<TTGlyph name="basket"/><!-- contains no outline data -->
</glyf>
<name>
<namerecord nameID="1" platformID="3" platEncID="1" langID="0x409">
New Font
</namerecord>
<namerecord nameID="2" platformID="3" platEncID="1" langID="0x409">
Regular
</namerecord>
<namerecord nameID="3" platformID="3" platEncID="1" langID="0x409">
0.000;NONE;NewFont-Regular
</namerecord>
<namerecord nameID="4" platformID="3" platEncID="1" langID="0x409">
New Font Regular
</namerecord>
<namerecord nameID="5" platformID="3" platEncID="1" langID="0x409">
Version 0.000
</namerecord>
<namerecord nameID="6" platformID="3" platEncID="1" langID="0x409">
NewFont-Regular
</namerecord>
</name>
<post>
<formatType value="2.0"/>
<italicAngle value="0.0"/>
<underlinePosition value="-75"/>
<underlineThickness value="50"/>
<isFixedPitch value="0"/>
<minMemType42 value="0"/>
<maxMemType42 value="0"/>
<minMemType1 value="0"/>
<maxMemType1 value="0"/>
<psNames>
<!-- This file uses unique glyph names based on the information
found in the 'post' table. Since these names might not be unique,
we have to invent artificial names in case of clashes. In order to
be able to retain the original information, we need a name to
ps name mapping for those cases where they differ. That's what
you see below.
-->
</psNames>
<extraNames>
<!-- following are the name that are not taken from the standard Mac glyph order -->
<psName name="basket"/>
</extraNames>
</post>
</ttFont>

View File

@ -17,7 +17,7 @@
<!-- Most of this table will be recalculated by the compiler -->
<tableVersion value="1.0"/>
<fontRevision value="1.0"/>
<checkSumAdjustment value="0xa69ed898"/>
<checkSumAdjustment value="0xa6bcdc24"/>
<magicNumber value="0x5f0f3cf5"/>
<flags value="00000000 00001111"/>
<unitsPerEm value="1000"/>
@ -142,15 +142,9 @@
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x2b" name="plus"/><!-- PLUS SIGN -->
</cmap_format_4>
<cmap_format_12 platformID="0" platEncID="4" format="12" reserved="0" length="28" language="0" nGroups="1">
<map code="0x2b" name="plus"/><!-- PLUS SIGN -->
</cmap_format_12>
<cmap_format_4 platformID="3" platEncID="1" language="0">
<map code="0x2b" name="plus"/><!-- PLUS SIGN -->
</cmap_format_4>
<cmap_format_12 platformID="3" platEncID="10" format="12" reserved="0" length="28" language="0" nGroups="1">
<map code="0x2b" name="plus"/><!-- PLUS SIGN -->
</cmap_format_12>
</cmap>
<loca>

View File

@ -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())

View File

@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.18">
<GlyphOrder>
<!-- The 'id' attribute is only for humans; it is ignored when parsed. -->
<GlyphID id="0" name=".notdef"/>
<GlyphID id="1" name="A"/>
</GlyphOrder>
<head>
<!-- Most of this table will be recalculated by the compiler -->
<tableVersion value="1.0"/>
<fontRevision value="1.0"/>
<checkSumAdjustment value="0x9aec19bb"/>
<magicNumber value="0x5f0f3cf5"/>
<flags value="00000000 00000010"/>
<unitsPerEm value="1000"/>
<created value="Thu Jan 28 15:17:57 2021"/>
<modified value="Thu Jan 28 15:52:10 2021"/>
<xMin value="178"/>
<yMin value="72"/>
<xMax value="586"/>
<yMax value="480"/>
<macStyle value="00000000 00000000"/>
<lowestRecPPEM value="6"/>
<fontDirectionHint value="2"/>
<indexToLocFormat value="0"/>
<glyphDataFormat value="0"/>
</head>
<hhea>
<tableVersion value="0x00010000"/>
<ascent value="750"/>
<descent value="-250"/>
<lineGap value="100"/>
<advanceWidthMax value="639"/>
<minLeftSideBearing value="178"/>
<minRightSideBearing value="53"/>
<xMaxExtent value="586"/>
<caretSlopeRise value="1"/>
<caretSlopeRun value="0"/>
<caretOffset value="0"/>
<reserved0 value="0"/>
<reserved1 value="0"/>
<reserved2 value="0"/>
<reserved3 value="0"/>
<metricDataFormat value="0"/>
<numberOfHMetrics value="2"/>
</hhea>
<maxp>
<!-- Most of this table will be recalculated by the compiler -->
<tableVersion value="0x10000"/>
<numGlyphs value="2"/>
<maxPoints value="20"/>
<maxContours value="1"/>
<maxCompositePoints value="0"/>
<maxCompositeContours value="0"/>
<maxZones value="1"/>
<maxTwilightPoints value="0"/>
<maxStorage value="0"/>
<maxFunctionDefs value="0"/>
<maxInstructionDefs value="0"/>
<maxStackElements value="0"/>
<maxSizeOfInstructions value="0"/>
<maxComponentElements value="0"/>
<maxComponentDepth value="0"/>
</maxp>
<OS_2>
<!-- The fields 'usFirstCharIndex' and 'usLastCharIndex'
will be recalculated by the compiler -->
<version value="4"/>
<xAvgCharWidth value="445"/>
<usWeightClass value="400"/>
<usWidthClass value="5"/>
<fsType value="00000000 00000100"/>
<ySubscriptXSize value="650"/>
<ySubscriptYSize value="600"/>
<ySubscriptXOffset value="0"/>
<ySubscriptYOffset value="75"/>
<ySuperscriptXSize value="650"/>
<ySuperscriptYSize value="600"/>
<ySuperscriptXOffset value="0"/>
<ySuperscriptYOffset value="350"/>
<yStrikeoutSize value="50"/>
<yStrikeoutPosition value="300"/>
<sFamilyClass value="0"/>
<panose>
<bFamilyType value="0"/>
<bSerifStyle value="0"/>
<bWeight value="0"/>
<bProportion value="0"/>
<bContrast value="0"/>
<bStrokeVariation value="0"/>
<bArmStyle value="0"/>
<bLetterForm value="0"/>
<bMidline value="0"/>
<bXHeight value="0"/>
</panose>
<ulUnicodeRange1 value="00000000 00000000 00000000 00000001"/>
<ulUnicodeRange2 value="00000000 00000000 00000000 00000000"/>
<ulUnicodeRange3 value="00000000 00000000 00000000 00000000"/>
<ulUnicodeRange4 value="00000000 00000000 00000000 00000000"/>
<achVendID value="NONE"/>
<fsSelection value="00000000 11000000"/>
<usFirstCharIndex value="65"/>
<usLastCharIndex value="65"/>
<sTypoAscender value="750"/>
<sTypoDescender value="-250"/>
<sTypoLineGap value="100"/>
<usWinAscent value="750"/>
<usWinDescent value="250"/>
<ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
<ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
<sxHeight value="500"/>
<sCapHeight value="700"/>
<usDefaultChar value="0"/>
<usBreakChar value="32"/>
<usMaxContext value="0"/>
</OS_2>
<hmtx>
<mtx name=".notdef" width="250" lsb="0"/>
<mtx name="A" width="639" lsb="178"/>
</hmtx>
<cmap>
<tableVersion version="0"/>
<cmap_format_4 platformID="0" platEncID="3" language="0">
<map code="0x41" name="A"/><!-- LATIN CAPITAL LETTER A -->
</cmap_format_4>
<cmap_format_4 platformID="3" platEncID="1" language="0">
<map code="0x41" name="A"/><!-- LATIN CAPITAL LETTER A -->
</cmap_format_4>
</cmap>
<loca>
<!-- The 'loca' table will be calculated by the compiler -->
</loca>
<glyf>
<!-- The xMin, yMin, xMax and yMax values
will be recalculated by the compiler. -->
<TTGlyph name=".notdef"/><!-- contains no outline data -->
<TTGlyph name="A" xMin="178" yMin="72" xMax="586" yMax="480">
<contour>
<pt x="382" y="72" on="0" overlap="1"/>
<pt x="336" y="72" on="1"/>
<pt x="261" y="101" on="0"/>
<pt x="207" y="155" on="0"/>
<pt x="178" y="230" on="0"/>
<pt x="178" y="276" on="1"/>
<pt x="178" y="322" on="0"/>
<pt x="207" y="397" on="0"/>
<pt x="261" y="451" on="0"/>
<pt x="336" y="480" on="0"/>
<pt x="382" y="480" on="1"/>
<pt x="428" y="480" on="0"/>
<pt x="503" y="451" on="0"/>
<pt x="557" y="397" on="0"/>
<pt x="586" y="322" on="0"/>
<pt x="586" y="276" on="1"/>
<pt x="586" y="230" on="0"/>
<pt x="557" y="155" on="0"/>
<pt x="503" y="101" on="0"/>
<pt x="428" y="72" on="0"/>
</contour>
<instructions/>
</TTGlyph>
</glyf>
<name>
<namerecord nameID="256" platformID="1" platEncID="0" langID="0x0" unicode="True">
Weight
</namerecord>
<namerecord nameID="1" platformID="3" platEncID="1" langID="0x409">
Unnamed
</namerecord>
<namerecord nameID="2" platformID="3" platEncID="1" langID="0x409">
Regular
</namerecord>
<namerecord nameID="3" platformID="3" platEncID="1" langID="0x409">
1.000;NONE;Unnamed-Regular
</namerecord>
<namerecord nameID="4" platformID="3" platEncID="1" langID="0x409">
Unnamed Regular
</namerecord>
<namerecord nameID="5" platformID="3" platEncID="1" langID="0x409">
Version 1.000
</namerecord>
<namerecord nameID="6" platformID="3" platEncID="1" langID="0x409">
Unnamed-Regular
</namerecord>
<namerecord nameID="256" platformID="3" platEncID="1" langID="0x409">
Weight
</namerecord>
</name>
<post>
<formatType value="2.0"/>
<italicAngle value="0.0"/>
<underlinePosition value="-100"/>
<underlineThickness value="50"/>
<isFixedPitch value="0"/>
<minMemType42 value="0"/>
<maxMemType42 value="0"/>
<minMemType1 value="0"/>
<maxMemType1 value="0"/>
<psNames>
<!-- This file uses unique glyph names based on the information
found in the 'post' table. Since these names might not be unique,
we have to invent artificial names in case of clashes. In order to
be able to retain the original information, we need a name to
ps name mapping for those cases where they differ. That's what
you see below.
-->
</psNames>
<extraNames>
<!-- following are the name that are not taken from the standard Mac glyph order -->
</extraNames>
</post>
<gasp>
<gaspRange rangeMaxPPEM="65535" rangeGaspBehavior="15"/>
</gasp>
<HVAR>
<Version value="0x00010000"/>
<VarStore Format="1">
<Format value="1"/>
<VarRegionList>
<!-- RegionAxisCount=1 -->
<!-- RegionCount=1 -->
<Region index="0">
<VarRegionAxis index="0">
<StartCoord value="0.0"/>
<PeakCoord value="1.0"/>
<EndCoord value="1.0"/>
</VarRegionAxis>
</Region>
</VarRegionList>
<!-- VarDataCount=1 -->
<VarData index="0">
<!-- ItemCount=2 -->
<NumShorts value="0"/>
<!-- VarRegionCount=0 -->
<Item index="0" value="[]"/>
<Item index="1" value="[]"/>
</VarData>
</VarStore>
</HVAR>
<STAT>
<Version value="0x00010001"/>
<DesignAxisRecordSize value="8"/>
<!-- DesignAxisCount=1 -->
<DesignAxisRecord>
<Axis index="0">
<AxisTag value="wght"/>
<AxisNameID value="256"/> <!-- Weight -->
<AxisOrdering value="0"/>
</Axis>
</DesignAxisRecord>
<!-- AxisValueCount=0 -->
<ElidedFallbackNameID value="2"/> <!-- Regular -->
</STAT>
<fvar>
<!-- Weight -->
<Axis>
<AxisTag>wght</AxisTag>
<Flags>0x0</Flags>
<MinValue>400.0</MinValue>
<DefaultValue>400.0</DefaultValue>
<MaxValue>700.0</MaxValue>
<AxisNameID>256</AxisNameID>
</Axis>
</fvar>
<gvar>
<version value="1"/>
<reserved value="0"/>
<glyphVariations glyph="A">
<tuple>
<coord axis="wght" value="1.0"/>
<delta pt="0" x="-64" y="-44"/>
<delta pt="5" x="-138" y="30"/>
<delta pt="7" x="-127" y="73"/>
<delta pt="8" x="-108" y="92"/>
<delta pt="10" x="-64" y="103"/>
<delta pt="12" x="-21" y="92"/>
<delta pt="13" x="-2" y="73"/>
<delta pt="16" x="9" y="13"/>
<delta pt="17" x="-2" y="-14"/>
<delta pt="18" x="-21" y="-33"/>
</tuple>
</glyphVariations>
</gvar>
</ttFont>

Some files were not shown because too many files have changed in this diff Show More