fonttools 4.59.1__cp39-cp39-win_amd64.whl → 4.60.0__cp39-cp39-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fonttools might be problematic. Click here for more details.

Files changed (45) hide show
  1. fontTools/__init__.py +1 -1
  2. fontTools/annotations.py +30 -0
  3. fontTools/cu2qu/cu2qu.cp39-win_amd64.pyd +0 -0
  4. fontTools/cu2qu/cu2qu.py +36 -4
  5. fontTools/feaLib/builder.py +9 -3
  6. fontTools/feaLib/lexer.cp39-win_amd64.pyd +0 -0
  7. fontTools/feaLib/parser.py +11 -1
  8. fontTools/feaLib/variableScalar.py +6 -1
  9. fontTools/misc/bezierTools.cp39-win_amd64.pyd +0 -0
  10. fontTools/misc/enumTools.py +23 -0
  11. fontTools/misc/textTools.py +4 -2
  12. fontTools/pens/filterPen.py +218 -26
  13. fontTools/pens/momentsPen.cp39-win_amd64.pyd +0 -0
  14. fontTools/pens/pointPen.py +40 -6
  15. fontTools/qu2cu/qu2cu.cp39-win_amd64.pyd +0 -0
  16. fontTools/subset/__init__.py +1 -0
  17. fontTools/ttLib/tables/_a_v_a_r.py +4 -2
  18. fontTools/ttLib/tables/_n_a_m_e.py +11 -6
  19. fontTools/ttLib/tables/_p_o_s_t.py +5 -5
  20. fontTools/ufoLib/__init__.py +279 -176
  21. fontTools/ufoLib/converters.py +14 -5
  22. fontTools/ufoLib/filenames.py +16 -6
  23. fontTools/ufoLib/glifLib.py +286 -190
  24. fontTools/ufoLib/kerning.py +32 -12
  25. fontTools/ufoLib/utils.py +41 -13
  26. fontTools/ufoLib/validators.py +121 -97
  27. fontTools/varLib/__init__.py +80 -1
  28. fontTools/varLib/avar/__init__.py +0 -0
  29. fontTools/varLib/avar/__main__.py +72 -0
  30. fontTools/varLib/avar/build.py +79 -0
  31. fontTools/varLib/avar/map.py +108 -0
  32. fontTools/varLib/avar/plan.py +1004 -0
  33. fontTools/varLib/{avar.py → avar/unbuild.py} +70 -59
  34. fontTools/varLib/avarPlanner.py +3 -999
  35. fontTools/varLib/instancer/__init__.py +56 -18
  36. fontTools/varLib/interpolatableHelpers.py +3 -0
  37. fontTools/varLib/iup.cp39-win_amd64.pyd +0 -0
  38. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/METADATA +43 -2
  39. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/RECORD +45 -38
  40. {fonttools-4.59.1.data → fonttools-4.60.0.data}/data/share/man/man1/ttx.1 +0 -0
  41. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/WHEEL +0 -0
  42. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/entry_points.txt +0 -0
  43. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/licenses/LICENSE +0 -0
  44. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/licenses/LICENSE.external +0 -0
  45. {fonttools-4.59.1.dist-info → fonttools-4.60.0.dist-info}/top_level.txt +0 -0
fontTools/__init__.py CHANGED
@@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger
3
3
 
4
4
  log = logging.getLogger(__name__)
5
5
 
6
- version = __version__ = "4.59.1"
6
+ version = __version__ = "4.60.0"
7
7
 
8
8
  __all__ = ["version", "log", "configLogger"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, Union
3
+ from collections.abc import Callable, Sequence
4
+ from fontTools.misc.filesystem._base import FS
5
+ from os import PathLike
6
+ from xml.etree.ElementTree import Element as ElementTreeElement
7
+
8
+ if TYPE_CHECKING:
9
+ from fontTools.ufoLib import UFOFormatVersion
10
+ from fontTools.ufoLib.glifLib import GLIFFormatVersion
11
+ from lxml.etree import _Element as LxmlElement
12
+
13
+
14
+ T = TypeVar("T") # Generic type
15
+ K = TypeVar("K") # Generic dict key type
16
+ V = TypeVar("V") # Generic dict value type
17
+
18
+ GlyphNameToFileNameFunc = Optional[Callable[[str, set[str]], str]]
19
+ ElementType = Union[ElementTreeElement, "LxmlElement"]
20
+ FormatVersion = Union[int, tuple[int, int]]
21
+ FormatVersions = Optional[Iterable[FormatVersion]]
22
+ GLIFFormatVersionInput = Optional[Union[int, tuple[int, int], "GLIFFormatVersion"]]
23
+ UFOFormatVersionInput = Optional[Union[int, tuple[int, int], "UFOFormatVersion"]]
24
+ IntFloat = Union[int, float]
25
+ KerningPair = tuple[str, str]
26
+ KerningDict = dict[KerningPair, IntFloat]
27
+ KerningGroups = dict[str, Sequence[str]]
28
+ KerningNested = dict[str, dict[str, IntFloat]]
29
+ PathStr = Union[str, PathLike[str]]
30
+ PathOrFS = Union[PathStr, FS]
Binary file
fontTools/cu2qu/cu2qu.py CHANGED
@@ -37,7 +37,7 @@ NAN = float("NaN")
37
37
  @cython.cfunc
38
38
  @cython.inline
39
39
  @cython.returns(cython.double)
40
- @cython.locals(v1=cython.complex, v2=cython.complex)
40
+ @cython.locals(v1=cython.complex, v2=cython.complex, result=cython.double)
41
41
  def dot(v1, v2):
42
42
  """Return the dot product of two vectors.
43
43
 
@@ -48,7 +48,33 @@ def dot(v1, v2):
48
48
  Returns:
49
49
  double: Dot product.
50
50
  """
51
- return (v1 * v2.conjugate()).real
51
+ result = (v1 * v2.conjugate()).real
52
+ # When vectors are perpendicular (i.e. dot product is 0), the above expression may
53
+ # yield slightly different results when running in pure Python vs C/Cython,
54
+ # both of which are correct within IEEE-754 floating-point precision.
55
+ # It's probably due to the different order of operations and roundings in each
56
+ # implementation. Because we are using the result in a denominator and catching
57
+ # ZeroDivisionError (see `calc_intersect`), it's best to normalize the result here.
58
+ if abs(result) < 1e-15:
59
+ result = 0.0
60
+ return result
61
+
62
+
63
+ @cython.cfunc
64
+ @cython.locals(z=cython.complex, den=cython.double)
65
+ @cython.locals(zr=cython.double, zi=cython.double)
66
+ def _complex_div_by_real(z, den):
67
+ """Divide complex by real using Python's method (two separate divisions).
68
+
69
+ This ensures bit-exact compatibility with Python's complex division,
70
+ avoiding C's multiply-by-reciprocal optimization that can cause 1 ULP differences
71
+ on some platforms/compilers (e.g. clang on macOS arm64).
72
+
73
+ https://github.com/fonttools/fonttools/issues/3928
74
+ """
75
+ zr = z.real
76
+ zi = z.imag
77
+ return complex(zr / den, zi / den)
52
78
 
53
79
 
54
80
  @cython.cfunc
@@ -59,8 +85,8 @@ def dot(v1, v2):
59
85
  )
60
86
  def calc_cubic_points(a, b, c, d):
61
87
  _1 = d
62
- _2 = (c / 3.0) + d
63
- _3 = (b + c) / 3.0 + _2
88
+ _2 = _complex_div_by_real(c, 3.0) + d
89
+ _3 = _complex_div_by_real(b + c, 3.0) + _2
64
90
  _4 = a + d + c + b
65
91
  return _1, _2, _3, _4
66
92
 
@@ -273,6 +299,12 @@ def calc_intersect(a, b, c, d):
273
299
  try:
274
300
  h = dot(p, a - c) / dot(p, cd)
275
301
  except ZeroDivisionError:
302
+ # if 3 or 4 points are equal, we do have an intersection despite the zero-div:
303
+ # return one of the off-curves so that the algorithm can attempt a one-curve
304
+ # solution if it's within tolerance:
305
+ # https://github.com/linebender/kurbo/pull/484
306
+ if b == c and (a == b or c == d):
307
+ return b
276
308
  return complex(NAN, NAN)
277
309
  return c + cd * h
278
310
 
@@ -32,6 +32,7 @@ from fontTools.otlLib.builder import (
32
32
  AnySubstBuilder,
33
33
  )
34
34
  from fontTools.otlLib.error import OpenTypeLibError
35
+ from fontTools.varLib.errors import VarLibError
35
36
  from fontTools.varLib.varStore import OnlineVarStoreBuilder
36
37
  from fontTools.varLib.builder import buildVarDevTable
37
38
  from fontTools.varLib.featureVars import addFeatureVariationsRaw
@@ -1728,9 +1729,14 @@ class Builder(object):
1728
1729
  if not varscalar.does_vary:
1729
1730
  return varscalar.default, None
1730
1731
 
1731
- default, index = varscalar.add_to_variation_store(
1732
- self.varstorebuilder, self.model_cache, self.font.get("avar")
1733
- )
1732
+ try:
1733
+ default, index = varscalar.add_to_variation_store(
1734
+ self.varstorebuilder, self.model_cache, self.font.get("avar")
1735
+ )
1736
+ except VarLibError as e:
1737
+ raise FeatureLibError(
1738
+ "Failed to compute deltas for variable scalar", location
1739
+ ) from e
1734
1740
 
1735
1741
  device = None
1736
1742
  if index is not None and index != 0xFFFFFFFF:
Binary file
@@ -2198,7 +2198,7 @@ class Parser(object):
2198
2198
  raise FeatureLibError(
2199
2199
  "Expected an equals sign", self.cur_token_location_
2200
2200
  )
2201
- value = self.expect_number_()
2201
+ value = self.expect_integer_or_float_()
2202
2202
  location[axis] = value
2203
2203
  if self.next_token_type_ is Lexer.NAME and self.next_token_[0] == ":":
2204
2204
  # Lexer has just read the value as a glyph name. We'll correct it later
@@ -2230,6 +2230,16 @@ class Parser(object):
2230
2230
  "Expected a floating-point number", self.cur_token_location_
2231
2231
  )
2232
2232
 
2233
+ def expect_integer_or_float_(self):
2234
+ if self.next_token_type_ == Lexer.FLOAT:
2235
+ return self.expect_float_()
2236
+ elif self.next_token_type_ is Lexer.NUMBER:
2237
+ return self.expect_number_()
2238
+ else:
2239
+ raise FeatureLibError(
2240
+ "Expected an integer or floating-point number", self.cur_token_location_
2241
+ )
2242
+
2233
2243
  def expect_decipoint_(self):
2234
2244
  if self.next_token_type_ == Lexer.FLOAT:
2235
2245
  return self.expect_float_()
@@ -17,7 +17,12 @@ class VariableScalar:
17
17
  def __repr__(self):
18
18
  items = []
19
19
  for location, value in self.values.items():
20
- loc = ",".join(["%s=%i" % (ax, loc) for ax, loc in location])
20
+ loc = ",".join(
21
+ [
22
+ f"{ax}={int(coord) if float(coord).is_integer() else coord}"
23
+ for ax, coord in location
24
+ ]
25
+ )
21
26
  items.append("%s:%i" % (loc, value))
22
27
  return "(" + (" ".join(items)) + ")"
23
28
 
@@ -0,0 +1,23 @@
1
+ """Enum-related utilities, including backports for older Python versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ __all__ = ["StrEnum"]
9
+
10
+ # StrEnum is only available in Python 3.11+
11
+ try:
12
+ from enum import StrEnum
13
+ except ImportError:
14
+
15
+ class StrEnum(str, Enum):
16
+ """
17
+ Minimal backport of Python 3.11's StrEnum for older versions.
18
+
19
+ An Enum where all members are also strings.
20
+ """
21
+
22
+ def __str__(self) -> str:
23
+ return self.value
@@ -1,5 +1,7 @@
1
1
  """fontTools.misc.textTools.py -- miscellaneous routines."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import ast
4
6
  import string
5
7
 
@@ -118,14 +120,14 @@ def pad(data, size):
118
120
  return data
119
121
 
120
122
 
121
- def tostr(s, encoding="ascii", errors="strict"):
123
+ def tostr(s: str | bytes, encoding: str = "ascii", errors: str = "strict") -> str:
122
124
  if not isinstance(s, str):
123
125
  return s.decode(encoding, errors)
124
126
  else:
125
127
  return s
126
128
 
127
129
 
128
- def tobytes(s, encoding="ascii", errors="strict"):
130
+ def tobytes(s: str | bytes, encoding: str = "ascii", errors: str = "strict") -> bytes:
129
131
  if isinstance(s, str):
130
132
  return s.encode(encoding, errors)
131
133
  else:
@@ -1,7 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from fontTools.pens.basePen import AbstractPen, DecomposingPen
4
- from fontTools.pens.pointPen import AbstractPointPen, DecomposingPointPen
4
+ from fontTools.pens.pointPen import (
5
+ AbstractPointPen,
6
+ DecomposingPointPen,
7
+ ReverseFlipped,
8
+ )
5
9
  from fontTools.pens.recordingPen import RecordingPen
6
10
 
7
11
 
@@ -155,26 +159,61 @@ class FilterPointPen(_PassThruComponentsMixin, AbstractPointPen):
155
159
  def __init__(self, outPen):
156
160
  self._outPen = outPen
157
161
 
158
- def beginPath(self, **kwargs):
162
+ def beginPath(self, identifier=None, **kwargs):
163
+ kwargs = dict(kwargs)
164
+ if identifier is not None:
165
+ kwargs["identifier"] = identifier
159
166
  self._outPen.beginPath(**kwargs)
160
167
 
161
168
  def endPath(self):
162
169
  self._outPen.endPath()
163
170
 
164
- def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs):
171
+ def addPoint(
172
+ self,
173
+ pt,
174
+ segmentType=None,
175
+ smooth=False,
176
+ name=None,
177
+ identifier=None,
178
+ **kwargs,
179
+ ):
180
+ kwargs = dict(kwargs)
181
+ if identifier is not None:
182
+ kwargs["identifier"] = identifier
165
183
  self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
166
184
 
167
185
 
168
- class _DecomposingFilterPenMixin:
169
- """Mixin class that decomposes components as regular contours.
186
+ class _DecomposingFilterMixinBase:
187
+ """Base mixin class with common `addComponent` logic for decomposing filter pens."""
188
+
189
+ def addComponent(self, baseGlyphName, transformation, **kwargs):
190
+ # only decompose the component if it's included in the set
191
+ if self.include is None or baseGlyphName in self.include:
192
+ # if we're decomposing nested components, temporarily set include to None
193
+ include_bak = self.include
194
+ if self.decomposeNested and self.include:
195
+ self.include = None
196
+ try:
197
+ super().addComponent(baseGlyphName, transformation, **kwargs)
198
+ finally:
199
+ if self.include != include_bak:
200
+ self.include = include_bak
201
+ else:
202
+ _PassThruComponentsMixin.addComponent(
203
+ self, baseGlyphName, transformation, **kwargs
204
+ )
205
+
206
+
207
+ class _DecomposingFilterPenMixin(_DecomposingFilterMixinBase):
208
+ """Mixin class that decomposes components as regular contours for segment pens.
170
209
 
171
- Shared by both DecomposingFilterPen and DecomposingFilterPointPen.
210
+ Used by DecomposingFilterPen.
172
211
 
173
- Takes two required parameters, another (segment or point) pen 'outPen' to draw
212
+ Takes two required parameters, another segment pen 'outPen' to draw
174
213
  with, and a 'glyphSet' dict of drawable glyph objects to draw components from.
175
214
 
176
215
  The 'skipMissingComponents' and 'reverseFlipped' optional arguments work the
177
- same as in the DecomposingPen/DecomposingPointPen. Both are False by default.
216
+ same as in the DecomposingPen. reverseFlipped is bool only (True/False).
178
217
 
179
218
  In addition, the decomposing filter pens also take the following two options:
180
219
 
@@ -196,35 +235,69 @@ class _DecomposingFilterPenMixin:
196
235
  outPen,
197
236
  glyphSet,
198
237
  skipMissingComponents=None,
199
- reverseFlipped=False,
238
+ reverseFlipped: bool = False,
200
239
  include: set[str] | None = None,
201
240
  decomposeNested: bool = True,
241
+ **kwargs,
202
242
  ):
243
+ assert isinstance(
244
+ reverseFlipped, bool
245
+ ), f"Expected bool, got {type(reverseFlipped).__name__}"
203
246
  super().__init__(
204
247
  outPen=outPen,
205
248
  glyphSet=glyphSet,
206
249
  skipMissingComponents=skipMissingComponents,
207
250
  reverseFlipped=reverseFlipped,
251
+ **kwargs,
208
252
  )
209
253
  self.include = include
210
254
  self.decomposeNested = decomposeNested
211
255
 
212
- def addComponent(self, baseGlyphName, transformation, **kwargs):
213
- # only decompose the component if it's included in the set
214
- if self.include is None or baseGlyphName in self.include:
215
- # if we're decomposing nested components, temporarily set include to None
216
- include_bak = self.include
217
- if self.decomposeNested and self.include:
218
- self.include = None
219
- try:
220
- super().addComponent(baseGlyphName, transformation, **kwargs)
221
- finally:
222
- if self.include != include_bak:
223
- self.include = include_bak
224
- else:
225
- _PassThruComponentsMixin.addComponent(
226
- self, baseGlyphName, transformation, **kwargs
227
- )
256
+
257
+ class _DecomposingFilterPointPenMixin(_DecomposingFilterMixinBase):
258
+ """Mixin class that decomposes components as regular contours for point pens.
259
+
260
+ Takes two required parameters, another point pen 'outPen' to draw
261
+ with, and a 'glyphSet' dict of drawable glyph objects to draw components from.
262
+
263
+ The 'skipMissingComponents' and 'reverseFlipped' optional arguments work the
264
+ same as in the DecomposingPointPen. reverseFlipped accepts bool | ReverseFlipped
265
+ (see DecomposingPointPen).
266
+
267
+ In addition, the decomposing filter pens also take the following two options:
268
+
269
+ 'include' is an optional set of component base glyph names to consider for
270
+ decomposition; the default include=None means decompose all components no matter
271
+ the base glyph name).
272
+
273
+ 'decomposeNested' (bool) controls whether to recurse decomposition into nested
274
+ components of components (this only matters when 'include' was also provided);
275
+ if False, only decompose top-level components included in the set, but not
276
+ also their children.
277
+ """
278
+
279
+ # raises MissingComponentError if base glyph is not found in glyphSet
280
+ skipMissingComponents = False
281
+
282
+ def __init__(
283
+ self,
284
+ outPen,
285
+ glyphSet,
286
+ skipMissingComponents=None,
287
+ reverseFlipped: bool | ReverseFlipped = False,
288
+ include: set[str] | None = None,
289
+ decomposeNested: bool = True,
290
+ **kwargs,
291
+ ):
292
+ super().__init__(
293
+ outPen=outPen,
294
+ glyphSet=glyphSet,
295
+ skipMissingComponents=skipMissingComponents,
296
+ reverseFlipped=reverseFlipped,
297
+ **kwargs,
298
+ )
299
+ self.include = include
300
+ self.decomposeNested = decomposeNested
228
301
 
229
302
 
230
303
  class DecomposingFilterPen(_DecomposingFilterPenMixin, DecomposingPen, FilterPen):
@@ -234,8 +307,127 @@ class DecomposingFilterPen(_DecomposingFilterPenMixin, DecomposingPen, FilterPen
234
307
 
235
308
 
236
309
  class DecomposingFilterPointPen(
237
- _DecomposingFilterPenMixin, DecomposingPointPen, FilterPointPen
310
+ _DecomposingFilterPointPenMixin, DecomposingPointPen, FilterPointPen
238
311
  ):
239
312
  """Filter point pen that draws components as regular contours."""
240
313
 
241
314
  pass
315
+
316
+
317
+ class ContourFilterPointPen(_PassThruComponentsMixin, AbstractPointPen):
318
+ """A "buffered" filter point pen that accumulates contour data, passes
319
+ it through a ``filterContour`` method when the contour is closed or ended,
320
+ and finally draws the result with the output point pen.
321
+
322
+ Components are passed through unchanged.
323
+
324
+ The ``filterContour`` method can modify the contour in-place (return None)
325
+ or return a new contour to replace it.
326
+ """
327
+
328
+ def __init__(self, outPen):
329
+ self._outPen = outPen
330
+ self.currentContour = None
331
+ self.currentContourKwargs = None
332
+
333
+ def beginPath(self, identifier=None, **kwargs):
334
+ if self.currentContour is not None:
335
+ raise ValueError("Path already begun")
336
+ kwargs = dict(kwargs)
337
+ if identifier is not None:
338
+ kwargs["identifier"] = identifier
339
+ self.currentContour = []
340
+ self.currentContourKwargs = kwargs
341
+
342
+ def endPath(self):
343
+ if self.currentContour is None:
344
+ raise ValueError("Path not begun")
345
+ self._flushContour()
346
+ self.currentContour = None
347
+ self.currentContourKwargs = None
348
+
349
+ def _flushContour(self):
350
+ """Flush the current contour to the output pen."""
351
+ result = self.filterContour(self.currentContour)
352
+ if result is not None:
353
+ self.currentContour = result
354
+
355
+ # Draw the filtered contour
356
+ self._outPen.beginPath(**self.currentContourKwargs)
357
+ for pt, segmentType, smooth, name, kwargs in self.currentContour:
358
+ self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
359
+ self._outPen.endPath()
360
+
361
+ def filterContour(self, contour):
362
+ """Subclasses must override this to perform the filtering.
363
+
364
+ The contour is a list of (pt, segmentType, smooth, name, kwargs) tuples.
365
+ If the method doesn't return a value (i.e. returns None), it's
366
+ assumed that the contour was modified in-place.
367
+ Otherwise, the return value replaces the original contour.
368
+ """
369
+ return # or return contour
370
+
371
+ def addPoint(
372
+ self,
373
+ pt,
374
+ segmentType=None,
375
+ smooth=False,
376
+ name=None,
377
+ identifier=None,
378
+ **kwargs,
379
+ ):
380
+ if self.currentContour is None:
381
+ raise ValueError("Path not begun")
382
+ kwargs = dict(kwargs)
383
+ if identifier is not None:
384
+ kwargs["identifier"] = identifier
385
+ self.currentContour.append((pt, segmentType, smooth, name, kwargs))
386
+
387
+
388
+ class OnCurveFirstPointPen(ContourFilterPointPen):
389
+ """Filter point pen that ensures closed contours start with an on-curve point.
390
+
391
+ If a closed contour starts with an off-curve point (segmentType=None), it rotates
392
+ the points list so that the first on-curve point (segmentType != None) becomes
393
+ the start point. Open contours and contours already starting with on-curve points
394
+ are passed through unchanged.
395
+
396
+ >>> from fontTools.pens.recordingPen import RecordingPointPen
397
+ >>> rec = RecordingPointPen()
398
+ >>> pen = OnCurveFirstPointPen(rec)
399
+ >>> # Closed contour starting with off-curve - will be rotated
400
+ >>> pen.beginPath()
401
+ >>> pen.addPoint((0, 0), None) # off-curve
402
+ >>> pen.addPoint((100, 100), "line") # on-curve - will become start
403
+ >>> pen.addPoint((200, 0), None) # off-curve
404
+ >>> pen.addPoint((300, 100), "curve") # on-curve
405
+ >>> pen.endPath()
406
+ >>> # The contour should now start with (100, 100) "line"
407
+ >>> rec.value[0]
408
+ ('beginPath', (), {})
409
+ >>> rec.value[1]
410
+ ('addPoint', ((100, 100), 'line', False, None), {})
411
+ >>> rec.value[2]
412
+ ('addPoint', ((200, 0), None, False, None), {})
413
+ >>> rec.value[3]
414
+ ('addPoint', ((300, 100), 'curve', False, None), {})
415
+ >>> rec.value[4]
416
+ ('addPoint', ((0, 0), None, False, None), {})
417
+ """
418
+
419
+ def filterContour(self, contour):
420
+ """Rotate closed contour to start with first on-curve point if needed."""
421
+ if not contour:
422
+ return
423
+
424
+ # Check if it's a closed contour (no "move" segmentType)
425
+ is_closed = contour[0][1] != "move"
426
+
427
+ if is_closed and contour[0][1] is None:
428
+ # Closed contour starting with off-curve - need to rotate
429
+ # Find the first on-curve point
430
+ for i, (pt, segmentType, smooth, name, kwargs) in enumerate(contour):
431
+ if segmentType is not None:
432
+ # Rotate the points list so it starts with the first on-curve point
433
+ return contour[i:] + contour[:i]
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  import math
18
18
  from typing import Any, Dict, List, Optional, Tuple
19
19
 
20
+ from fontTools.misc.enumTools import StrEnum
20
21
  from fontTools.misc.loggingTools import LogMixin
21
22
  from fontTools.misc.transform import DecomposedTransform, Identity
22
23
  from fontTools.pens.basePen import AbstractPen, MissingComponentError, PenError
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "SegmentToPointPen",
29
30
  "GuessSmoothPointPen",
30
31
  "ReverseContourPointPen",
32
+ "ReverseFlipped",
31
33
  ]
32
34
 
33
35
  # Some type aliases to make it easier below
@@ -39,6 +41,19 @@ SegmentType = Optional[str]
39
41
  SegmentList = List[Tuple[SegmentType, SegmentPointList]]
40
42
 
41
43
 
44
+ class ReverseFlipped(StrEnum):
45
+ """How to handle flipped components during decomposition.
46
+
47
+ NO: Don't reverse flipped components
48
+ KEEP_START: Reverse flipped components, keeping original starting point
49
+ ON_CURVE_FIRST: Reverse flipped components, ensuring first point is on-curve
50
+ """
51
+
52
+ NO = "no"
53
+ KEEP_START = "keep_start"
54
+ ON_CURVE_FIRST = "on_curve_first"
55
+
56
+
42
57
  class AbstractPointPen:
43
58
  """Baseclass for all PointPens."""
44
59
 
@@ -559,15 +574,20 @@ class DecomposingPointPen(LogMixin, AbstractPointPen):
559
574
  glyphSet,
560
575
  *args,
561
576
  skipMissingComponents=None,
562
- reverseFlipped=False,
577
+ reverseFlipped: bool | ReverseFlipped = False,
563
578
  **kwargs,
564
579
  ):
565
580
  """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced
566
581
  as components are looked up by their name.
567
582
 
568
- If the optional 'reverseFlipped' argument is True, components whose transformation
569
- matrix has a negative determinant will be decomposed with a reversed path direction
570
- to compensate for the flip.
583
+ If the optional 'reverseFlipped' argument is True or a ReverseFlipped enum value,
584
+ components whose transformation matrix has a negative determinant will be decomposed
585
+ with a reversed path direction to compensate for the flip.
586
+
587
+ The reverseFlipped parameter can be:
588
+ - False or ReverseFlipped.NO: Don't reverse flipped components
589
+ - True or ReverseFlipped.KEEP_START: Reverse, keeping original starting point
590
+ - ReverseFlipped.ON_CURVE_FIRST: Reverse, ensuring first point is on-curve
571
591
 
572
592
  The optional 'skipMissingComponents' argument can be set to True/False to
573
593
  override the homonymous class attribute for a given pen instance.
@@ -579,7 +599,13 @@ class DecomposingPointPen(LogMixin, AbstractPointPen):
579
599
  if skipMissingComponents is None
580
600
  else skipMissingComponents
581
601
  )
582
- self.reverseFlipped = reverseFlipped
602
+ # Handle backward compatibility and validate string inputs
603
+ if reverseFlipped is False:
604
+ self.reverseFlipped = ReverseFlipped.NO
605
+ elif reverseFlipped is True:
606
+ self.reverseFlipped = ReverseFlipped.KEEP_START
607
+ else:
608
+ self.reverseFlipped = ReverseFlipped(reverseFlipped)
583
609
 
584
610
  def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs):
585
611
  """Transform the points of the base glyph and draw it onto self.
@@ -600,10 +626,18 @@ class DecomposingPointPen(LogMixin, AbstractPointPen):
600
626
  pen = self
601
627
  if transformation != Identity:
602
628
  pen = TransformPointPen(pen, transformation)
603
- if self.reverseFlipped:
629
+ if self.reverseFlipped != ReverseFlipped.NO:
604
630
  # if the transformation has a negative determinant, it will
605
631
  # reverse the contour direction of the component
606
632
  a, b, c, d = transformation[:4]
607
633
  if a * d - b * c < 0:
608
634
  pen = ReverseContourPointPen(pen)
635
+
636
+ if self.reverseFlipped == ReverseFlipped.ON_CURVE_FIRST:
637
+ from fontTools.pens.filterPen import OnCurveFirstPointPen
638
+
639
+ # Ensure the starting point is an on-curve.
640
+ # Wrap last so this filter runs first during drawPoints
641
+ pen = OnCurveFirstPointPen(pen)
642
+
609
643
  glyph.drawPoints(pen)
Binary file
@@ -1530,6 +1530,7 @@ def subset_glyphs(self, s):
1530
1530
  if self.MarkFilteringSet not in s.used_mark_sets:
1531
1531
  self.MarkFilteringSet = None
1532
1532
  self.LookupFlag &= ~0x10
1533
+ self.LookupFlag |= 0x8
1533
1534
  else:
1534
1535
  self.MarkFilteringSet = s.used_mark_sets.index(self.MarkFilteringSet)
1535
1536
  return bool(self.SubTableCount)
@@ -143,7 +143,7 @@ class table__a_v_a_r(BaseTTXConverter):
143
143
  else:
144
144
  super().fromXML(name, attrs, content, ttFont)
145
145
 
146
- def renormalizeLocation(self, location, font):
146
+ def renormalizeLocation(self, location, font, dropZeroes=True):
147
147
 
148
148
  majorVersion = getattr(self, "majorVersion", 1)
149
149
 
@@ -185,7 +185,9 @@ class table__a_v_a_r(BaseTTXConverter):
185
185
  out.append(v)
186
186
 
187
187
  mappedLocation = {
188
- axis.axisTag: fi2fl(v, 14) for v, axis in zip(out, axes) if v != 0
188
+ axis.axisTag: fi2fl(v, 14)
189
+ for v, axis in zip(out, axes)
190
+ if v != 0 or not dropZeroes
189
191
  }
190
192
 
191
193
  return mappedLocation