NotYAFC 1.0.0__py3-none-any.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.
@@ -0,0 +1,169 @@
1
+ """Font metadata inspection and conversion compatibility checks."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from fontTools.ttLib import TTFont
7
+
8
+ from font_converter.constants import PS_SFNT_VERSION, T_FVAR, T_HEAD, T_NAME
9
+ from font_converter.finder import FinderOptions, FontFinder
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AxisInfo:
14
+ tag: str
15
+ minimum: float
16
+ default: float
17
+ maximum: float
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NamedInstanceInfo:
22
+ name: str
23
+ coordinates: tuple[tuple[str, float], ...]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FontInfo:
28
+ path: Path
29
+ format: str
30
+ outlines: str
31
+ upm: int | None
32
+ variable: bool = False
33
+ collection: bool = False
34
+ num_fonts: int = 1
35
+ axes: tuple[AxisInfo, ...] = ()
36
+ instances: tuple[NamedInstanceInfo, ...] = ()
37
+ family: str = ""
38
+ style: str = ""
39
+
40
+
41
+ def incompatibility(info: FontInfo, conversion: str) -> str | None:
42
+ """Return why the font cannot be used with the profile, or ``None`` if it can."""
43
+ if conversion == "ttc2sfnt":
44
+ return None if info.collection else "Not a font collection"
45
+ if info.collection:
46
+ return "Font collections require the TTC → SFNT profile"
47
+ if conversion == "var2static":
48
+ return None if info.variable else "Not a variable font"
49
+ if conversion == "ttf2otf":
50
+ if info.outlines != "TrueType":
51
+ return "Not a TrueType font"
52
+ if info.variable:
53
+ return "Variable fonts are not supported"
54
+ elif conversion == "otf2ttf":
55
+ if info.outlines != "PostScript":
56
+ return "Not a PostScript font"
57
+ if info.variable:
58
+ return "Variable fonts are not supported"
59
+ elif conversion == "ft2wf":
60
+ if info.format in ("WOFF", "WOFF2"):
61
+ return "Already a web font"
62
+ elif conversion in ("woff2ft", "woff22ft"):
63
+ required = "WOFF" if conversion == "woff2ft" else "WOFF2"
64
+ if info.format not in ("WOFF", "WOFF2"):
65
+ return "Not a web font"
66
+ if info.format != required:
67
+ return f"Not a {required} font"
68
+ return None
69
+
70
+
71
+ _cache: dict[tuple[Path, int, int], FontInfo | None] = {}
72
+
73
+
74
+ def inspect_font(path: Path) -> FontInfo | None:
75
+ """Return the font metadata, or ``None`` if the file is not a readable font."""
76
+ try:
77
+ stat = path.stat()
78
+ except OSError:
79
+ return None
80
+ key = (path, stat.st_mtime_ns, stat.st_size)
81
+ if key not in _cache:
82
+ _cache[key] = _read_font_info(path)
83
+ return _cache[key]
84
+
85
+
86
+ def _font_names(ttfont: TTFont) -> tuple[str, str]:
87
+ name_table = ttfont.get(T_NAME)
88
+ if name_table is None:
89
+ return "", ""
90
+ return name_table.getBestFamilyName() or "", name_table.getBestSubFamilyName() or ""
91
+
92
+
93
+ def _read_font_info(path: Path) -> FontInfo | None:
94
+ try:
95
+ for font in FontFinder(path, options=FinderOptions(lazy=True)).generate_fonts():
96
+ with font:
97
+ if font.is_woff2:
98
+ format_ = "WOFF2"
99
+ elif font.is_woff:
100
+ format_ = "WOFF"
101
+ elif font.is_ps:
102
+ format_ = "OTF"
103
+ else:
104
+ format_ = "TTF"
105
+ outlines = "PostScript" if font.is_ps else "TrueType"
106
+ family, style = _font_names(font.ttfont)
107
+ axes: tuple[AxisInfo, ...] = ()
108
+ instances: tuple[NamedInstanceInfo, ...] = ()
109
+ if font.is_variable:
110
+ fvar = font.ttfont[T_FVAR]
111
+ axes = tuple(
112
+ AxisInfo(a.axisTag, a.minValue, a.defaultValue, a.maxValue)
113
+ for a in fvar.axes
114
+ )
115
+ name_table = font.ttfont[T_NAME]
116
+ instances = tuple(
117
+ NamedInstanceInfo(
118
+ name_table.getDebugName(i.subfamilyNameID) or "Unnamed",
119
+ tuple(i.coordinates.items()),
120
+ )
121
+ for i in fvar.instances
122
+ )
123
+ return FontInfo(
124
+ path,
125
+ format_,
126
+ outlines,
127
+ font.units_per_em,
128
+ font.is_variable,
129
+ axes=axes,
130
+ instances=instances,
131
+ family=family,
132
+ style=style,
133
+ )
134
+ except Exception:
135
+ return None
136
+ return _read_collection_info(path)
137
+
138
+
139
+ def _read_collection_info(path: Path) -> FontInfo | None:
140
+ try:
141
+ for collection in FontFinder(path, options=FinderOptions(lazy=True)).generate_collections():
142
+ try:
143
+ members = collection.fonts
144
+ if not members:
145
+ return None
146
+ outlines = {
147
+ "PostScript" if ttfont.sfntVersion == PS_SFNT_VERSION else "TrueType"
148
+ for ttfont in members
149
+ }
150
+ upms = {int(ttfont[T_HEAD].unitsPerEm) for ttfont in members}
151
+ names = [_font_names(ttfont) for ttfont in members]
152
+ families = {family for family, _ in names}
153
+ styles = {style for _, style in names}
154
+ return FontInfo(
155
+ path,
156
+ "TTC",
157
+ outlines.pop() if len(outlines) == 1 else "Mixed",
158
+ upms.pop() if len(upms) == 1 else None,
159
+ any(ttfont.get(T_FVAR) is not None for ttfont in members),
160
+ collection=True,
161
+ num_fonts=len(members),
162
+ family=families.pop() if len(families) == 1 else "Mixed",
163
+ style=styles.pop() if len(styles) == 1 else "Mixed",
164
+ )
165
+ finally:
166
+ collection.close()
167
+ except Exception:
168
+ return None
169
+ return None
@@ -0,0 +1,499 @@
1
+ """Merge adjacent smooth cubic Bézier segments into a single segment, within a tolerance."""
2
+
3
+ import math
4
+
5
+ import pathops
6
+ from fontTools.misc.bezierTools import splitCubicAtT
7
+ from fontTools.pens.recordingPen import RecordingPen
8
+
9
+ __all__ = ["merge_cubic_segments"]
10
+
11
+ Point = tuple[float, float]
12
+ # ("l", (p,)) for lines, ("c", (c1, c2, p)) for cubic curves
13
+ _Segment = tuple[str, tuple[Point, ...]]
14
+ _Cubic = tuple[Point, Point, Point]
15
+
16
+ _SAMPLES_PER_SEGMENT = 16
17
+ _POLYLINE_STEPS = 48
18
+ _REPARAMETERIZE_ITERATIONS = 5
19
+ _MAX_TANGENT_ANGLE = math.radians(3.0)
20
+ _AXIS_EPSILON = math.sin(_MAX_TANGENT_ANGLE)
21
+ _EXTREMUM_MARGIN = 0.01
22
+
23
+
24
+ def _quadratic_run_to_cubics(start: Point, pts: tuple[Point, ...]) -> list[_Cubic]:
25
+ """Elevate a TrueType-style qCurveTo (off-curve points + end point) to cubics."""
26
+ result: list[_Cubic] = []
27
+ p0 = start
28
+ offs, end = pts[:-1], pts[-1]
29
+ for i, off in enumerate(offs):
30
+ if i < len(offs) - 1:
31
+ nxt = offs[i + 1]
32
+ p2 = ((off[0] + nxt[0]) / 2, (off[1] + nxt[1]) / 2)
33
+ else:
34
+ p2 = end
35
+ c1 = (p0[0] + 2 / 3 * (off[0] - p0[0]), p0[1] + 2 / 3 * (off[1] - p0[1]))
36
+ c2 = (p2[0] + 2 / 3 * (off[0] - p2[0]), p2[1] + 2 / 3 * (off[1] - p2[1]))
37
+ result.append((c1, c2, p2))
38
+ p0 = p2
39
+ return result
40
+
41
+
42
+ def _sub(a: Point, b: Point) -> Point:
43
+ return a[0] - b[0], a[1] - b[1]
44
+
45
+
46
+ def _length(v: Point) -> float:
47
+ return math.hypot(v[0], v[1])
48
+
49
+
50
+ def _unit(v: Point) -> Point | None:
51
+ n = _length(v)
52
+ return None if n < 1e-9 else (v[0] / n, v[1] / n)
53
+
54
+
55
+ def _cubic_at(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
56
+ mt = 1 - t
57
+ a, b, c, d = mt**3, 3 * mt * mt * t, 3 * mt * t * t, t**3
58
+ return (
59
+ a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
60
+ a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1],
61
+ )
62
+
63
+
64
+ def _start_tangent(p0: Point, c1: Point, c2: Point, p3: Point) -> Point | None:
65
+ return _unit(_sub(c1, p0)) or _unit(_sub(c2, p0)) or _unit(_sub(p3, p0))
66
+
67
+
68
+ def _end_tangent(p0: Point, c1: Point, c2: Point, p3: Point) -> Point | None:
69
+ """Unit vector at the end point, pointing along the direction of travel."""
70
+ return _unit(_sub(p3, c2)) or _unit(_sub(p3, c1)) or _unit(_sub(p3, p0))
71
+
72
+
73
+ def _within_tolerance(p: Point, polyline: list[Point], guess: int, tolerance: float) -> bool:
74
+ """
75
+ True if ``p`` is within ``tolerance`` of ``polyline``. Segments are visited outwards from
76
+ ``guess`` and the search stops at the first close enough one.
77
+ """
78
+ tol2 = tolerance * tolerance
79
+ px, py = p
80
+ count = len(polyline) - 1
81
+ guess = min(max(guess, 0), count - 1)
82
+ for offset in range(count):
83
+ for index in {guess - offset, guess + offset}:
84
+ if not 0 <= index < count:
85
+ continue
86
+ ax, ay = polyline[index]
87
+ bx, by = polyline[index + 1]
88
+ abx, aby = bx - ax, by - ay
89
+ denom = abx * abx + aby * aby
90
+ t = 0.0
91
+ if denom:
92
+ t = max(0.0, min(1.0, ((px - ax) * abx + (py - ay) * aby) / denom))
93
+ dx, dy = px - (ax + t * abx), py - (ay + t * aby)
94
+ if dx * dx + dy * dy <= tol2:
95
+ return True
96
+ return False
97
+
98
+
99
+ def _is_smooth_non_extremum(a_end_tangent: Point, b_start_tangent: Point) -> bool:
100
+ """True if the join is G1-continuous and its tangent is neither horizontal nor vertical."""
101
+ cross = a_end_tangent[0] * b_start_tangent[1] - a_end_tangent[1] * b_start_tangent[0]
102
+ dot = a_end_tangent[0] * b_start_tangent[0] + a_end_tangent[1] * b_start_tangent[1]
103
+ if dot <= 0 or abs(math.atan2(cross, dot)) > _MAX_TANGENT_ANGLE:
104
+ return False
105
+ return abs(a_end_tangent[0]) > _AXIS_EPSILON and abs(a_end_tangent[1]) > _AXIS_EPSILON
106
+
107
+
108
+ def _fit_handles(
109
+ samples: list[Point],
110
+ params: list[float],
111
+ p0: Point,
112
+ p3: Point,
113
+ t0: Point,
114
+ t1: Point,
115
+ ) -> tuple[Point, Point] | None:
116
+ """Least squares for the two handle lengths; ``t1`` points from ``p3`` back into the curve."""
117
+ c00 = c01 = c11 = x0 = x1 = 0.0
118
+ for pt, u in zip(samples, params, strict=False):
119
+ mu = 1 - u
120
+ w0, w1, w2, w3 = mu**3, 3 * mu * mu * u, 3 * mu * u * u, u**3
121
+ a_vec = (t0[0] * w1, t0[1] * w1)
122
+ b_vec = (t1[0] * w2, t1[1] * w2)
123
+ c00 += a_vec[0] ** 2 + a_vec[1] ** 2
124
+ c01 += a_vec[0] * b_vec[0] + a_vec[1] * b_vec[1]
125
+ c11 += b_vec[0] ** 2 + b_vec[1] ** 2
126
+ tmp = (
127
+ pt[0] - (w0 + w1) * p0[0] - (w2 + w3) * p3[0],
128
+ pt[1] - (w0 + w1) * p0[1] - (w2 + w3) * p3[1],
129
+ )
130
+ x0 += a_vec[0] * tmp[0] + a_vec[1] * tmp[1]
131
+ x1 += b_vec[0] * tmp[0] + b_vec[1] * tmp[1]
132
+
133
+ det = c00 * c11 - c01 * c01
134
+ if abs(det) < 1e-12:
135
+ return None
136
+ alpha = (x0 * c11 - x1 * c01) / det
137
+ beta = (c00 * x1 - c01 * x0) / det
138
+ if alpha <= 0 or beta <= 0:
139
+ return None
140
+ return (
141
+ (p0[0] + alpha * t0[0], p0[1] + alpha * t0[1]),
142
+ (p3[0] + beta * t1[0], p3[1] + beta * t1[1]),
143
+ )
144
+
145
+
146
+ def _newton_step(p0: Point, c1: Point, c2: Point, p3: Point, pt: Point, u: float) -> float:
147
+ """One Newton-Raphson step moving ``u`` towards the closest point of the curve to ``pt``."""
148
+ q = _cubic_at(p0, c1, c2, p3, u)
149
+ mu = 1 - u
150
+ d1 = tuple(
151
+ 3 * (mu * mu * (c1[i] - p0[i]) + 2 * mu * u * (c2[i] - c1[i]) + u * u * (p3[i] - c2[i]))
152
+ for i in (0, 1)
153
+ )
154
+ d2 = tuple(
155
+ 6 * (mu * (c2[i] - 2 * c1[i] + p0[i]) + u * (p3[i] - 2 * c2[i] + c1[i])) for i in (0, 1)
156
+ )
157
+ diff = (q[0] - pt[0], q[1] - pt[1])
158
+ num = diff[0] * d1[0] + diff[1] * d1[1]
159
+ den = d1[0] ** 2 + d1[1] ** 2 + diff[0] * d2[0] + diff[1] * d2[1]
160
+ if abs(den) < 1e-12:
161
+ return u
162
+ return min(1.0, max(0.0, u - num / den))
163
+
164
+
165
+ def _try_merge(
166
+ p0: Point,
167
+ cubics: list[_Cubic],
168
+ tolerance: float,
169
+ ) -> _Cubic | None:
170
+ """Fit one cubic to the consecutive cubics ``cubics`` starting at ``p0``."""
171
+ p_end = cubics[-1][2]
172
+ t0 = _start_tangent(p0, *cubics[0])
173
+ t1 = _end_tangent(_cubic_start(p0, cubics, len(cubics) - 1), *cubics[-1])
174
+ if t0 is None or t1 is None:
175
+ return None
176
+ back = (-t1[0], -t1[1])
177
+
178
+ samples: list[Point] = []
179
+ start = p0
180
+ for c1, c2, p3 in cubics:
181
+ samples.extend(
182
+ _cubic_at(start, c1, c2, p3, i / _SAMPLES_PER_SEGMENT)
183
+ for i in range(_SAMPLES_PER_SEGMENT)
184
+ )
185
+ start = p3
186
+ samples.append(p_end)
187
+ # Chord-length parameterization of the sampled points
188
+ cumulative = [0.0]
189
+ for s, e in zip(samples, samples[1:], strict=False):
190
+ cumulative.append(cumulative[-1] + _length(_sub(e, s)))
191
+ total = cumulative[-1]
192
+ if total == 0:
193
+ return None
194
+ params = [c / total for c in cumulative]
195
+
196
+ fitted: tuple[Point, Point] | None = None
197
+ for _ in range(_REPARAMETERIZE_ITERATIONS):
198
+ fitted = _fit_handles(samples, params, p0, p_end, t0, back)
199
+ if fitted is None:
200
+ return None
201
+ c1, c2 = fitted
202
+ params = [
203
+ _newton_step(p0, c1, c2, p_end, pt, u) for pt, u in zip(samples, params, strict=False)
204
+ ]
205
+ if fitted is None:
206
+ return None
207
+
208
+ # Round the control points, as the final outline has integer coordinates
209
+ c1 = (round(fitted[0][0]), round(fitted[0][1]))
210
+ c2 = (round(fitted[1][0]), round(fitted[1][1]))
211
+ polyline = [
212
+ _cubic_at(p0, c1, c2, p_end, i / _POLYLINE_STEPS) for i in range(_POLYLINE_STEPS + 1)
213
+ ]
214
+ last = len(samples) - 1
215
+ for s, u in zip(samples, params, strict=False):
216
+ if not _within_tolerance(s, polyline, round(u * _POLYLINE_STEPS), tolerance):
217
+ return None
218
+
219
+ # The merged curve must not stray from the original outline in the opposite direction either
220
+ for i, pt in enumerate(polyline):
221
+ if not _within_tolerance(pt, samples, round(i / _POLYLINE_STEPS * last), tolerance):
222
+ return None
223
+ return c1, c2, p_end
224
+
225
+
226
+ def _cubic_start(p0: Point, cubics: list[_Cubic], index: int) -> Point:
227
+ return p0 if index == 0 else cubics[index - 1][2]
228
+
229
+
230
+ def _as_cubic(start: Point, segment: _Segment) -> _Cubic:
231
+ kind, pts = segment
232
+ if kind == "c":
233
+ return pts # type: ignore[return-value]
234
+ end = pts[0]
235
+ d = _sub(end, start)
236
+ return (
237
+ (start[0] + d[0] / 3, start[1] + d[1] / 3),
238
+ (start[0] + 2 * d[0] / 3, start[1] + 2 * d[1] / 3),
239
+ end,
240
+ )
241
+
242
+
243
+ def _junction_removable(prev_start: Point, prev: _Cubic, nxt: _Cubic) -> bool:
244
+ end_tangent = _end_tangent(prev_start, *prev)
245
+ start_tangent = _start_tangent(prev[2], *nxt)
246
+ return (
247
+ end_tangent is not None
248
+ and start_tangent is not None
249
+ and _is_smooth_non_extremum(end_tangent, start_tangent)
250
+ )
251
+
252
+
253
+ def _extrema_parameters(p0: Point, c1: Point, c2: Point, p3: Point) -> list[tuple[float, int]]:
254
+ """Parameters in (0, 1) where the tangent is horizontal (axis 1) or vertical (axis 0)."""
255
+ found: list[tuple[float, int]] = []
256
+ for axis in (0, 1):
257
+ a = -p0[axis] + 3 * c1[axis] - 3 * c2[axis] + p3[axis]
258
+ b = 3 * (p0[axis] - 2 * c1[axis] + c2[axis])
259
+ c = 3 * (c1[axis] - p0[axis])
260
+ if abs(a) < 1e-9:
261
+ roots = [-c / (2 * b)] if abs(b) > 1e-9 else []
262
+ else:
263
+ disc = b * b - 3 * a * c
264
+ roots = [] if disc < 0 else [(-b + s * math.sqrt(disc)) / (3 * a) for s in (1, -1)]
265
+ found.extend((t, axis) for t in roots if _EXTREMUM_MARGIN < t < 1 - _EXTREMUM_MARGIN)
266
+ return sorted(found)
267
+
268
+
269
+ def _insert_extrema(start: Point, segments: list[_Segment], min_distance: float) -> list[_Segment]:
270
+ """Split cubics at their horizontal and vertical extrema, unless a node is already there."""
271
+ result: list[_Segment] = []
272
+ current = start
273
+ for kind, pts in segments:
274
+ if kind != "c":
275
+ result.append((kind, pts))
276
+ current = pts[-1]
277
+ continue
278
+
279
+ c1, c2, p3 = pts
280
+ found = _extrema_parameters(current, c1, c2, p3)
281
+ ts: list[float] = []
282
+ axes: list[int] = []
283
+ for t, axis in found:
284
+ if ts and abs(t - ts[-1]) < 1e-3:
285
+ continue
286
+ point = _cubic_at(current, c1, c2, p3, t)
287
+ if min(_length(_sub(point, current)), _length(_sub(point, p3))) < min_distance:
288
+ continue
289
+ ts.append(t)
290
+ axes.append(axis)
291
+ if not ts:
292
+ result.append((kind, pts))
293
+ current = p3
294
+ continue
295
+
296
+ pieces = [[list(p) for p in piece] for piece in splitCubicAtT(current, c1, c2, p3, *ts)]
297
+ # Round the new nodes and keep their handles on the extremum line
298
+ for i, axis in enumerate(axes):
299
+ node = (round(pieces[i][3][0]), round(pieces[i][3][1]))
300
+ for handle in (pieces[i][2], pieces[i + 1][1]):
301
+ handle[axis] = node[axis]
302
+ pieces[i][3] = pieces[i + 1][0] = list(node)
303
+ for piece in pieces:
304
+ result.append(("c", tuple((round(p[0]), round(p[1])) for p in piece[1:])))
305
+ current = p3
306
+ return result
307
+
308
+
309
+ def _merge_contour(
310
+ start: Point, segments: list[_Segment], tolerance: float
311
+ ) -> tuple[Point, list[_Segment]]:
312
+ segs = list(segments)
313
+ if not segs:
314
+ return start, segs
315
+ if segs[-1][1][-1] != start:
316
+ segs.append(("l", (start,)))
317
+
318
+ # Rotate the contour so that it starts at a node that must be kept
319
+ starts = [start] + [s[1][-1] for s in segs[:-1]]
320
+ cubics = [_as_cubic(p, s) for p, s in zip(starts, segs, strict=True)]
321
+ rotation = next(
322
+ (
323
+ j
324
+ for j in range(len(segs))
325
+ if not _junction_removable(starts[j - 1], cubics[j - 1], cubics[j])
326
+ ),
327
+ None,
328
+ )
329
+ if rotation is None:
330
+ return start, list(segments)
331
+ segs = segs[rotation:] + segs[:rotation]
332
+ start = starts[rotation]
333
+
334
+ origs: list[list[_Cubic]] = [[c] for c in cubics[rotation:] + cubics[:rotation]]
335
+
336
+ changed = True
337
+ while changed:
338
+ changed = False
339
+ current = start
340
+ i = 0
341
+ while i < len(segs):
342
+ merged = False
343
+ for run in (3, 2):
344
+ group = segs[i : i + run]
345
+ if len(group) < run or not any(k == "c" for k, _ in group):
346
+ continue
347
+ cubic_run: list[_Cubic] = []
348
+ point = current
349
+ for seg in group:
350
+ cubic_run.append(_as_cubic(point, seg))
351
+ point = seg[1][-1]
352
+ if not all(
353
+ _junction_removable(
354
+ _cubic_start(current, cubic_run, j - 1), cubic_run[j - 1], cubic_run[j]
355
+ )
356
+ for j in range(1, run)
357
+ ):
358
+ continue
359
+ # Fit against the original cubics, so that errors do not accumulate
360
+ originals = [c for part in origs[i : i + run] for c in part]
361
+ result = _try_merge(current, originals, tolerance)
362
+ if result is not None:
363
+ segs[i : i + run] = [("c", result)]
364
+ origs[i : i + run] = [originals]
365
+ merged = changed = True
366
+ break
367
+ if merged:
368
+ continue
369
+ current = segs[i][1][-1]
370
+ i += 1
371
+
372
+ if segs and segs[-1][0] == "l" and segs[-1][1][-1] == start:
373
+ segs.pop()
374
+ return start, segs
375
+
376
+
377
+ def _is_flat(p0: Point, seg: _Cubic, tolerance: float) -> bool:
378
+ """True if both handles lie within ``tolerance`` of the chord and between its ends."""
379
+ c1, c2, p3 = seg
380
+ chord = _sub(p3, p0)
381
+ length = _length(chord)
382
+ if length == 0:
383
+ return False
384
+ for h in (c1, c2):
385
+ v = _sub(h, p0)
386
+ along = (v[0] * chord[0] + v[1] * chord[1]) / length
387
+ across = abs(v[0] * chord[1] - v[1] * chord[0]) / length
388
+ if across > tolerance or along < 0 or along > length:
389
+ return False
390
+ return True
391
+
392
+
393
+ def _flat_curves_to_lines(
394
+ start: Point, segments: list[_Segment], tolerance: float
395
+ ) -> list[_Segment]:
396
+ result: list[_Segment] = []
397
+ prev = start
398
+ for kind, pts in segments:
399
+ if kind == "c" and _is_flat(prev, _as_cubic(prev, (kind, pts)), tolerance):
400
+ kind, pts = "l", (pts[-1],)
401
+ result.append((kind, pts))
402
+ prev = pts[-1]
403
+ return result
404
+
405
+
406
+ def _snap_axis(node: Point, handle: Point, tolerance: float) -> Point:
407
+ dx, dy = handle[0] - node[0], handle[1] - node[1]
408
+ dist = math.hypot(dx, dy)
409
+ if dist == 0:
410
+ return handle
411
+ if 0 < abs(dx) <= tolerance and abs(dx) / dist <= _AXIS_EPSILON:
412
+ return node[0], handle[1]
413
+ if 0 < abs(dy) <= tolerance and abs(dy) / dist <= _AXIS_EPSILON:
414
+ return handle[0], node[1]
415
+ return handle
416
+
417
+
418
+ def _snap_extremum_handles(
419
+ start: Point, segments: list[_Segment], tolerance: float
420
+ ) -> list[_Segment]:
421
+ """Make handles that are almost horizontal/vertical exactly so."""
422
+ result: list[_Segment] = []
423
+ prev = start
424
+ for kind, pts in segments:
425
+ if kind == "c":
426
+ c1, c2, p3 = pts
427
+ pts = (_snap_axis(prev, c1, tolerance), _snap_axis(p3, c2, tolerance), p3)
428
+ result.append((kind, pts))
429
+ prev = pts[-1]
430
+ return result
431
+
432
+
433
+ def merge_cubic_segments(
434
+ path: pathops.Path, tolerance: float, min_extremum_distance: float = 1.0
435
+ ) -> pathops.Path:
436
+ """
437
+ Merges runs of up to three segments (curves, and non-axis-aligned lines between curves) joined
438
+ by smooth, non-extremum on-curve points into a single cubic, when the result deviates from the
439
+ original outline by no more than ``tolerance`` font units. Extrema and corners are never
440
+ removed, and the contour start point is moved to one of them if necessary. Extrema that lack
441
+ a node are first restored by splitting the curve there.
442
+
443
+ Contours containing anything other than lines and cubic curves are copied unchanged.
444
+
445
+ :param path: The path to process.
446
+ :type path: pathops.Path
447
+ :param tolerance: The maximum allowed deviation, in font units.
448
+ :type tolerance: float
449
+ :param min_extremum_distance: An extremum is not restored if an existing node is closer than
450
+ this distance, in font units.
451
+ :type min_extremum_distance: float
452
+ :return: A new path.
453
+ :rtype: pathops.Path
454
+ """
455
+ recording = RecordingPen()
456
+ path.draw(recording)
457
+
458
+ result = pathops.Path()
459
+ pen = result.getPen(glyphSet=None)
460
+
461
+ contour: list[tuple[str, tuple]] = []
462
+ for op, args in recording.value:
463
+ contour.append((op, args))
464
+ if op not in ("closePath", "endPath"):
465
+ continue
466
+
467
+ ops = [o for o, _ in contour]
468
+ if (
469
+ ops[0] == "moveTo"
470
+ and all(o in ("lineTo", "curveTo", "qCurveTo") for o in ops[1:-1])
471
+ and all(a[-1] is not None for _, a in contour[1:-1])
472
+ ):
473
+ start = contour[0][1][0]
474
+ segments: list[_Segment] = []
475
+ cur = start
476
+ for o, a in contour[1:-1]:
477
+ if o == "lineTo":
478
+ segments.append(("l", tuple(a)))
479
+ cur = a[-1]
480
+ elif o == "curveTo":
481
+ segments.append(("c", tuple(a)))
482
+ cur = a[-1]
483
+ else:
484
+ for c1, c2, p3 in _quadratic_run_to_cubics(cur, a):
485
+ segments.append(("c", (c1, c2, p3)))
486
+ cur = p3
487
+ segments = _insert_extrema(start, segments, min_extremum_distance)
488
+ start, segments = _merge_contour(start, segments, tolerance)
489
+ segments = _flat_curves_to_lines(start, segments, tolerance)
490
+ segments = _snap_extremum_handles(start, segments, tolerance)
491
+ contour = [("moveTo", (start,))]
492
+ contour += [("lineTo" if k == "l" else "curveTo", pts) for k, pts in segments]
493
+ contour.append((op, ()))
494
+
495
+ for o, a in contour:
496
+ getattr(pen, o)(*a)
497
+ contour = []
498
+
499
+ return result