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.
- font_converter/__init__.py +4 -0
- font_converter/__main__.py +45 -0
- font_converter/assets/ico.png +0 -0
- font_converter/assets/notyafc-1024.png +0 -0
- font_converter/assets/notyafc.ico +0 -0
- font_converter/assets/notyafc.svg +15 -0
- font_converter/cli.py +543 -0
- font_converter/constants.py +27 -0
- font_converter/contours.py +336 -0
- font_converter/css_kit.py +77 -0
- font_converter/finder.py +213 -0
- font_converter/font.py +530 -0
- font_converter/gui/__init__.py +1 -0
- font_converter/gui/conversion_dialog.py +110 -0
- font_converter/gui/file_list.py +411 -0
- font_converter/gui/instance_tree.py +583 -0
- font_converter/gui/logs.py +51 -0
- font_converter/gui/organization_dialog.py +417 -0
- font_converter/gui/profile_options.py +316 -0
- font_converter/gui/profiles.py +17 -0
- font_converter/gui/scanner.py +42 -0
- font_converter/gui/styles.py +658 -0
- font_converter/gui/widgets.py +495 -0
- font_converter/gui/window.py +1768 -0
- font_converter/inspection.py +169 -0
- font_converter/merge_curves.py +499 -0
- font_converter/otf_builder.py +144 -0
- font_converter/qu2cu.py +117 -0
- font_converter/service.py +729 -0
- font_converter/ttf_builder.py +107 -0
- notyafc-1.0.0.dist-info/METADATA +256 -0
- notyafc-1.0.0.dist-info/RECORD +34 -0
- notyafc-1.0.0.dist-info/WHEEL +4 -0
- notyafc-1.0.0.dist-info/entry_points.txt +6 -0
|
@@ -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
|