rapier-math 0.0.0-stage → 1.0.1
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.
- package/LICENSE +484 -0
- package/LICENSE-MIT +21 -0
- package/README.md +110 -2
- package/check.mjs +97 -0
- package/index.mjs +2884 -0
- package/math-LICENSE.txt +21 -0
- package/math-components.json +71 -0
- package/math-font-LICENSE.txt +457 -0
- package/package.json +44 -4
- package/rapier-math-1.0.1.js +2883 -0
- package/src/math/FONT-LICENSE.txt +457 -0
- package/src/math/FONT-SOURCE.md +117 -0
- package/src/math/LICENSE-MIT +21 -0
- package/src/math/api.js +86 -0
- package/src/math/browser.js +150 -0
- package/src/math/build-font.py +480 -0
- package/src/math/fallbacks.js +111 -0
- package/src/math/font.js +514 -0
- package/src/math/layout.js +576 -0
- package/src/math/package-build.mjs +27 -0
- package/src/math/package-verify.mjs +40 -0
- package/src/math/parser.js +1064 -0
- package/src/math/responsive.js +251 -0
- package/src/math/symbols.js +98 -0
- package/src/package.json +9 -0
- package/src/shell/math-resources.json +105 -0
- package/src/tools/build-math.mjs +92 -0
- package/verify.mjs +40 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Extract SVG outlines and OpenType MATH records from the pinned font."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import base64
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from fractions import Fraction
|
|
11
|
+
import gzip
|
|
12
|
+
import hashlib
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import re
|
|
18
|
+
import unicodedata
|
|
19
|
+
import urllib.request
|
|
20
|
+
import zlib
|
|
21
|
+
|
|
22
|
+
from fontTools.misc.bezierTools import calcCubicParameters, solveQuadratic
|
|
23
|
+
from fontTools.pens.basePen import BasePen
|
|
24
|
+
from fontTools.pens.boundsPen import BoundsPen
|
|
25
|
+
from fontTools.pens.svgPathPen import SVGPathPen
|
|
26
|
+
from fontTools.ttLib import TTFont
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SOURCE_ROOT = "https://mirror.apps.cam.ac.uk/pub/tex-archive/fonts/lm-math/"
|
|
30
|
+
SOURCE_URL = SOURCE_ROOT + "opentype/latinmodern-math.otf"
|
|
31
|
+
SOURCE_SHA256 = "6075562b771f8b82f0c179e363389684f2dd09de30038269e2628e504bd7be0f"
|
|
32
|
+
LICENSE_URL = SOURCE_ROOT + "doc/GUST-FONT-LICENSE.txt"
|
|
33
|
+
LICENSE_SHA256 = "2bd69affc3da00715116f713f57eab9707e96daf3562ad0215987b15b9c16f73"
|
|
34
|
+
LPPL_URL = "https://www.latex-project.org/lppl/lppl-1-3c.txt"
|
|
35
|
+
LPPL_SHA256 = "3d262cdf34dafa6955f703c634a8c238ec44109bc8dd6ef34fb7aa54809f7e66"
|
|
36
|
+
TEXT_URL = "https://raw.githubusercontent.com/jackskip22/rapier/326a7387a190b011543fae5f5f2c6144f6163b3d/src/shell/fonts/Geist.wght400-700.woff2"
|
|
37
|
+
TEXT_SHA256 = "6e3a140e3812161e292fbe42a1fc90533b2407b699c65abc7a098b0979df4e82"
|
|
38
|
+
EDGE_STEP = 32
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def source_bytes(local: Path | None, remote: str, expected: str) -> bytes:
|
|
42
|
+
data = local.read_bytes() if local else urllib.request.urlopen(remote, timeout=60).read()
|
|
43
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
44
|
+
if digest != expected:
|
|
45
|
+
raise ValueError(f"Source mismatch for {remote}: expected SHA-256 {expected}, got {digest}")
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scalar(value):
|
|
50
|
+
return getattr(value, "Value", value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def number(value):
|
|
54
|
+
if value == int(value):
|
|
55
|
+
return str(int(value))
|
|
56
|
+
return format(Decimal(value), "f").rstrip("0").rstrip(".")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def feature_alternates(font, tag):
|
|
60
|
+
records = {}
|
|
61
|
+
gsub = font["GSUB"].table
|
|
62
|
+
for feature in gsub.FeatureList.FeatureRecord:
|
|
63
|
+
if feature.FeatureTag != tag:
|
|
64
|
+
continue
|
|
65
|
+
for index in feature.Feature.LookupListIndex:
|
|
66
|
+
lookup = gsub.LookupList.Lookup[index]
|
|
67
|
+
for table in lookup.SubTable:
|
|
68
|
+
kind = lookup.LookupType
|
|
69
|
+
if kind == 7:
|
|
70
|
+
kind, table = table.ExtensionLookupType, table.ExtSubTable
|
|
71
|
+
if kind == 1:
|
|
72
|
+
records.update((base, [alternate]) for base, alternate in table.mapping.items())
|
|
73
|
+
elif kind == 3:
|
|
74
|
+
records.update((base, alternates[:2]) for base, alternates in table.alternates.items())
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(f"Unsupported {tag} substitution type {kind}")
|
|
77
|
+
return records
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract(font: TTFont):
|
|
81
|
+
cmap = font.getBestCmap()
|
|
82
|
+
table = font["MATH"].table
|
|
83
|
+
variants = table.MathVariants
|
|
84
|
+
scripts, flattened, dotless = (feature_alternates(font, tag) for tag in ("ssty", "flac", "dtls"))
|
|
85
|
+
used = set(cmap.values())
|
|
86
|
+
while True:
|
|
87
|
+
previous = len(used)
|
|
88
|
+
for feature in (scripts, flattened, dotless):
|
|
89
|
+
for name, alternates in feature.items():
|
|
90
|
+
if name in used:
|
|
91
|
+
used.update(alternates)
|
|
92
|
+
for axis in ("Vert", "Horiz"):
|
|
93
|
+
coverage = getattr(variants, axis + "GlyphCoverage")
|
|
94
|
+
constructions = getattr(variants, axis + "GlyphConstruction")
|
|
95
|
+
for name, construction in zip(coverage.glyphs if coverage else [], constructions):
|
|
96
|
+
if name not in used:
|
|
97
|
+
continue
|
|
98
|
+
used.update(record.VariantGlyph for record in construction.MathGlyphVariantRecord)
|
|
99
|
+
if construction.GlyphAssembly:
|
|
100
|
+
used.update(record.glyph for record in construction.GlyphAssembly.PartRecords)
|
|
101
|
+
if len(used) == previous:
|
|
102
|
+
break
|
|
103
|
+
names = sorted(used, key=font.getGlyphID)
|
|
104
|
+
ids = {name: index for index, name in enumerate(names)}
|
|
105
|
+
info = table.MathGlyphInfo
|
|
106
|
+
corrections = info.MathItalicsCorrectionInfo
|
|
107
|
+
italic = dict(zip(corrections.Coverage.glyphs, map(scalar, corrections.ItalicsCorrection))) if corrections else {}
|
|
108
|
+
attachments = info.MathTopAccentAttachment
|
|
109
|
+
accent = dict(zip(attachments.TopAccentCoverage.glyphs, map(scalar, attachments.TopAccentAttachment))) if attachments else {}
|
|
110
|
+
glyph_set = font.getGlyphSet()
|
|
111
|
+
glyphs = []
|
|
112
|
+
for name in names:
|
|
113
|
+
glyph = glyph_set[name]
|
|
114
|
+
bounds = BoundsPen(glyph_set)
|
|
115
|
+
glyph.draw(bounds)
|
|
116
|
+
rect = bounds.bounds or (0, 0, 0, 0)
|
|
117
|
+
bounds_values = [math.floor(rect[0]), math.floor(rect[1]), math.ceil(rect[2]), math.ceil(rect[3])]
|
|
118
|
+
path = SVGPathPen(glyph_set, ntos=number)
|
|
119
|
+
glyph.draw(path)
|
|
120
|
+
advance = font["hmtx"].metrics[name][0]
|
|
121
|
+
glyphs.append([advance, *bounds_values, italic.get(name, 0), accent.get(name, advance / 2), path.getCommands()])
|
|
122
|
+
data = {
|
|
123
|
+
"units": font["head"].unitsPerEm,
|
|
124
|
+
"xHeight": font["OS/2"].sxHeight,
|
|
125
|
+
"glyphs": glyphs,
|
|
126
|
+
"chars": {cp: ids[name] for cp, name in sorted(cmap.items())},
|
|
127
|
+
"constants": {key: scalar(value) for key, value in vars(table.MathConstants).items() if not key.startswith("_")},
|
|
128
|
+
"vertical": {},
|
|
129
|
+
"horizontal": {},
|
|
130
|
+
"minConnectorOverlap": variants.MinConnectorOverlap,
|
|
131
|
+
"kern": {},
|
|
132
|
+
"scriptAlternates": {ids[name]: [ids[alternate] for alternate in alternates] for name, alternates in scripts.items() if name in ids},
|
|
133
|
+
"flatAccents": {ids[name]: ids[alternates[0]] for name, alternates in flattened.items() if name in ids},
|
|
134
|
+
"dotless": {ids[name]: ids[alternates[0]] for name, alternates in dotless.items() if name in ids},
|
|
135
|
+
"extendedShapes": {ids[name]: 1 for name in (info.ExtendedShapeCoverage.glyphs if info.ExtendedShapeCoverage else []) if name in ids},
|
|
136
|
+
"edgeStep": EDGE_STEP,
|
|
137
|
+
"edgeProfiles": {},
|
|
138
|
+
}
|
|
139
|
+
for axis, key in (("Vert", "vertical"), ("Horiz", "horizontal")):
|
|
140
|
+
coverage = getattr(variants, axis + "GlyphCoverage")
|
|
141
|
+
constructions = getattr(variants, axis + "GlyphConstruction")
|
|
142
|
+
for name, construction in zip(coverage.glyphs if coverage else [], constructions):
|
|
143
|
+
if name not in ids:
|
|
144
|
+
continue
|
|
145
|
+
record = {"variants": [[ids[v.VariantGlyph], v.AdvanceMeasurement] for v in construction.MathGlyphVariantRecord]}
|
|
146
|
+
assembly = construction.GlyphAssembly
|
|
147
|
+
if assembly:
|
|
148
|
+
record["assembly"] = {
|
|
149
|
+
"italic": scalar(assembly.ItalicsCorrection),
|
|
150
|
+
"parts": [[ids[p.glyph], p.StartConnectorLength, p.EndConnectorLength, p.FullAdvance, p.PartFlags & 1] for p in assembly.PartRecords],
|
|
151
|
+
}
|
|
152
|
+
data[key][ids[name]] = record
|
|
153
|
+
kern_info = info.MathKernInfo
|
|
154
|
+
if kern_info:
|
|
155
|
+
for name, record in zip(kern_info.MathKernCoverage.glyphs, kern_info.MathKernInfoRecords):
|
|
156
|
+
if name in ids:
|
|
157
|
+
corners = []
|
|
158
|
+
for key in ("TopRightMathKern", "TopLeftMathKern", "BottomRightMathKern", "BottomLeftMathKern"):
|
|
159
|
+
corner = getattr(record, key)
|
|
160
|
+
corners.append([list(map(scalar, corner.CorrectionHeight)), list(map(scalar, corner.KernValue))] if corner else None)
|
|
161
|
+
data["kern"][ids[name]] = corners
|
|
162
|
+
profiled = {
|
|
163
|
+
name for cp, name in cmap.items()
|
|
164
|
+
if (cp < 128 and chr(cp).isalnum())
|
|
165
|
+
or any(style in unicodedata.name(chr(cp), "") for style in ("ITALIC", "SCRIPT"))
|
|
166
|
+
}
|
|
167
|
+
for name in list(profiled):
|
|
168
|
+
profiled.update(scripts.get(name, ()))
|
|
169
|
+
profiled.update(dotless.get(name, ()))
|
|
170
|
+
for name in sorted(profiled, key=font.getGlyphID):
|
|
171
|
+
profile = edge_profile(glyph_set, name)
|
|
172
|
+
if profile:
|
|
173
|
+
data["edgeProfiles"][ids[name]] = profile
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class OutlinePen(BasePen):
|
|
178
|
+
def __init__(self, glyph_set):
|
|
179
|
+
super().__init__(glyph_set)
|
|
180
|
+
self.segments = []
|
|
181
|
+
self.start = None
|
|
182
|
+
|
|
183
|
+
def _moveTo(self, point):
|
|
184
|
+
self.start = point
|
|
185
|
+
|
|
186
|
+
def _lineTo(self, point):
|
|
187
|
+
self.segments.append((self._getCurrentPoint(), point))
|
|
188
|
+
|
|
189
|
+
def _curveToOne(self, first, second, end):
|
|
190
|
+
self.segments.append((self._getCurrentPoint(), first, second, end))
|
|
191
|
+
|
|
192
|
+
def _closePath(self):
|
|
193
|
+
if self._getCurrentPoint() != self.start:
|
|
194
|
+
self.segments.append((self._getCurrentPoint(), self.start))
|
|
195
|
+
|
|
196
|
+
def _endPath(self):
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def edge_profile(glyph_set, name):
|
|
201
|
+
"""Bound the original outline inside each horizontal strip."""
|
|
202
|
+
pen = OutlinePen(glyph_set)
|
|
203
|
+
glyph_set[name].draw(pen)
|
|
204
|
+
bounds = BoundsPen(glyph_set)
|
|
205
|
+
glyph_set[name].draw(bounds)
|
|
206
|
+
if not bounds.bounds:
|
|
207
|
+
return None
|
|
208
|
+
first = math.floor(bounds.bounds[1] / EDGE_STEP) * EDGE_STEP
|
|
209
|
+
count = max(1, math.ceil((bounds.bounds[3] - first) / EDGE_STEP))
|
|
210
|
+
strips = [[] for _ in range(count)]
|
|
211
|
+
for segment in pen.segments:
|
|
212
|
+
if len(segment) == 2:
|
|
213
|
+
(x, y), (end_x, end_y) = segment
|
|
214
|
+
for index, strip in enumerate(strips):
|
|
215
|
+
lower, upper = first + index * EDGE_STEP, first + (index + 1) * EDGE_STEP
|
|
216
|
+
values = [0, 1]
|
|
217
|
+
if end_y != y:
|
|
218
|
+
values.extend(((lower - y) / (end_y - y), (upper - y) / (end_y - y)))
|
|
219
|
+
strip.extend(x + t * (end_x - x) for t in values if 0 <= t <= 1 and lower - 1e-7 <= y + t * (end_y - y) <= upper + 1e-7)
|
|
220
|
+
continue
|
|
221
|
+
a, b, c, d = calcCubicParameters(*segment)
|
|
222
|
+
extrema = [0, 1, *solveQuadratic(3 * a[0], 2 * b[0], c[0])]
|
|
223
|
+
critical = sorted([0, 1, *(t for t in solveQuadratic(3 * a[1], 2 * b[1], c[1]) if 0 < t < 1)])
|
|
224
|
+
intersections = {}
|
|
225
|
+
|
|
226
|
+
def vertical(t):
|
|
227
|
+
return ((a[1] * t + b[1]) * t + c[1]) * t + d[1]
|
|
228
|
+
|
|
229
|
+
def crossing(height):
|
|
230
|
+
if height not in intersections:
|
|
231
|
+
roots = []
|
|
232
|
+
for low, high in zip(critical, critical[1:]):
|
|
233
|
+
y_low, y_high = vertical(low), vertical(high)
|
|
234
|
+
if not min(y_low, y_high) <= height <= max(y_low, y_high):
|
|
235
|
+
continue
|
|
236
|
+
for _ in range(45):
|
|
237
|
+
middle = (low + high) / 2
|
|
238
|
+
if (vertical(middle) < height) == (y_low < y_high):
|
|
239
|
+
low = middle
|
|
240
|
+
else:
|
|
241
|
+
high = middle
|
|
242
|
+
roots.append((low + high) / 2)
|
|
243
|
+
intersections[height] = roots
|
|
244
|
+
return intersections[height]
|
|
245
|
+
|
|
246
|
+
y_min, y_max = min(point[1] for point in segment), max(point[1] for point in segment)
|
|
247
|
+
for index, strip in enumerate(strips):
|
|
248
|
+
lower, upper = first + index * EDGE_STEP, first + (index + 1) * EDGE_STEP
|
|
249
|
+
if lower > y_max or upper < y_min:
|
|
250
|
+
continue
|
|
251
|
+
values = [*extrema, *crossing(lower), *crossing(upper)]
|
|
252
|
+
for t in values:
|
|
253
|
+
if 0 <= t <= 1:
|
|
254
|
+
y = ((a[1] * t + b[1]) * t + c[1]) * t + d[1]
|
|
255
|
+
if lower - 1e-7 <= y <= upper + 1e-7:
|
|
256
|
+
strip.append(((a[0] * t + b[0]) * t + c[0]) * t + d[0])
|
|
257
|
+
return [first, *([math.floor(min(strip) - 1e-7), math.ceil(max(strip) + 1e-7)] if strip else None for strip in strips)]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def pack_paths(glyphs, profiles):
|
|
261
|
+
"""Store exact coordinate deltas, sharing matching outline structures."""
|
|
262
|
+
output = bytearray()
|
|
263
|
+
|
|
264
|
+
def unsigned(value, target):
|
|
265
|
+
while value >= 128:
|
|
266
|
+
target.append((value & 127) | 128)
|
|
267
|
+
value >>= 7
|
|
268
|
+
target.append(value)
|
|
269
|
+
|
|
270
|
+
def signed(value, target):
|
|
271
|
+
unsigned(value * 2 if value >= 0 else -value * 2 - 1, target)
|
|
272
|
+
|
|
273
|
+
commands = "MLHVCZ"
|
|
274
|
+
lengths = (2, 2, 1, 1, 6, 0)
|
|
275
|
+
paths = [re.findall(r"[MLHVCZ]|-?\d+(?:\.\d+)?", glyph[7]) for glyph in glyphs]
|
|
276
|
+
unit = 1
|
|
277
|
+
for glyph, tokens in zip(glyphs, paths):
|
|
278
|
+
if "".join(tokens) != re.sub(r"[ ,]", "", glyph[7]):
|
|
279
|
+
raise ValueError("Font path has an unsupported coordinate or command")
|
|
280
|
+
for token in tokens:
|
|
281
|
+
if "." in token:
|
|
282
|
+
unit = math.lcm(unit, Fraction(token).denominator)
|
|
283
|
+
unsigned(unit, output)
|
|
284
|
+
unsigned(len(glyphs), output)
|
|
285
|
+
metric_unit = math.lcm(*(Fraction(value).denominator for glyph in glyphs for value in glyph[:7]))
|
|
286
|
+
unsigned(metric_unit, output)
|
|
287
|
+
previous_metrics = [0] * 7
|
|
288
|
+
for glyph in glyphs:
|
|
289
|
+
for index, value in enumerate(glyph[:7]):
|
|
290
|
+
value = int(value * metric_unit)
|
|
291
|
+
signed(value - previous_metrics[index], output)
|
|
292
|
+
previous_metrics[index] = value
|
|
293
|
+
structures = {}
|
|
294
|
+
for glyph_index, (glyph, tokens) in enumerate(zip(glyphs, paths)):
|
|
295
|
+
cursor = 0
|
|
296
|
+
segments = []
|
|
297
|
+
previous = None
|
|
298
|
+
while cursor < len(tokens):
|
|
299
|
+
if tokens[cursor] in commands:
|
|
300
|
+
command = commands.index(tokens[cursor])
|
|
301
|
+
cursor += 1
|
|
302
|
+
elif previous is not None and previous != 5:
|
|
303
|
+
command = 1 if previous == 0 else previous
|
|
304
|
+
else:
|
|
305
|
+
raise ValueError("Font path has an invalid repeated command")
|
|
306
|
+
length = lengths[command]
|
|
307
|
+
values = [int(Fraction(value) * unit) if "." in value else int(value) * unit for value in tokens[cursor:cursor + length]]
|
|
308
|
+
if len(values) != length:
|
|
309
|
+
raise ValueError("Font path has an incomplete command")
|
|
310
|
+
if any(abs(value) * 4 > 2**53 - 1 for value in values):
|
|
311
|
+
raise ValueError("Font coordinates exceed exact JavaScript integer arithmetic")
|
|
312
|
+
cursor += length
|
|
313
|
+
segments.append((command, values))
|
|
314
|
+
previous = command
|
|
315
|
+
record = bytearray()
|
|
316
|
+
unsigned(len(segments) * 2, record)
|
|
317
|
+
x = y = first_x = first_y = 0
|
|
318
|
+
for command, values in segments:
|
|
319
|
+
record.append(command)
|
|
320
|
+
for index, value in enumerate(values):
|
|
321
|
+
if command == 3 or (command not in (2, 3) and index % 2):
|
|
322
|
+
signed(value - y, record)
|
|
323
|
+
y = value
|
|
324
|
+
else:
|
|
325
|
+
signed(value - x, record)
|
|
326
|
+
x = value
|
|
327
|
+
if command == 0:
|
|
328
|
+
first_x, first_y = x, y
|
|
329
|
+
elif command == 5:
|
|
330
|
+
x, y = first_x, first_y
|
|
331
|
+
structure = bytes(command for command, _ in segments)
|
|
332
|
+
coordinates = tuple(value for _, values in segments for value in values)
|
|
333
|
+
candidates = structures.setdefault(structure, [])
|
|
334
|
+
for parent, previous_coordinates in candidates:
|
|
335
|
+
shared = bytearray()
|
|
336
|
+
unsigned(parent * 2 + 1, shared)
|
|
337
|
+
for value, previous in zip(coordinates, previous_coordinates):
|
|
338
|
+
signed(value - previous, shared)
|
|
339
|
+
if len(shared) >= len(record):
|
|
340
|
+
break
|
|
341
|
+
if len(shared) < len(record):
|
|
342
|
+
record = shared
|
|
343
|
+
candidates.append((glyph_index, coordinates))
|
|
344
|
+
output.extend(record)
|
|
345
|
+
glyph[7] = ""
|
|
346
|
+
unsigned(len(profiles), output)
|
|
347
|
+
previous_id = -1
|
|
348
|
+
profile_shapes = {}
|
|
349
|
+
for glyph_id, profile in sorted(profiles.items()):
|
|
350
|
+
unsigned(glyph_id - previous_id - 1, output)
|
|
351
|
+
previous_id = glyph_id
|
|
352
|
+
record = bytearray()
|
|
353
|
+
unsigned((len(profile) - 1) * 2, record)
|
|
354
|
+
signed(profile[0], record)
|
|
355
|
+
left = right = 0
|
|
356
|
+
for strip in profile[1:]:
|
|
357
|
+
if strip is None:
|
|
358
|
+
unsigned(0, record)
|
|
359
|
+
continue
|
|
360
|
+
delta = strip[0] - left
|
|
361
|
+
unsigned((delta * 2 if delta >= 0 else -delta * 2 - 1) + 1, record)
|
|
362
|
+
signed(strip[1] - right, record)
|
|
363
|
+
left, right = strip
|
|
364
|
+
shape = profile[0], tuple(strip is not None for strip in profile[1:])
|
|
365
|
+
for parent_id, parent in profile_shapes.setdefault(shape, []):
|
|
366
|
+
shared = bytearray()
|
|
367
|
+
unsigned(parent_id * 2 + 1, shared)
|
|
368
|
+
left = right = 0
|
|
369
|
+
for strip, previous_strip in zip(profile[1:], parent[1:]):
|
|
370
|
+
if strip is None:
|
|
371
|
+
continue
|
|
372
|
+
next_left, next_right = strip[0] - previous_strip[0], strip[1] - previous_strip[1]
|
|
373
|
+
signed(next_left - left, shared)
|
|
374
|
+
signed(next_right - right, shared)
|
|
375
|
+
left, right = next_left, next_right
|
|
376
|
+
if len(shared) >= len(record):
|
|
377
|
+
break
|
|
378
|
+
if len(shared) < len(record):
|
|
379
|
+
record = shared
|
|
380
|
+
profile_shapes[shape].append((glyph_id, profile))
|
|
381
|
+
output.extend(record)
|
|
382
|
+
packed = zlib.compress(output, level=9, wbits=-15)
|
|
383
|
+
return base64.b64encode(packed).decode("ascii"), len(output)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
PATH_DECODER = """{
|
|
387
|
+
const source=Uint8Array.from(atob(__PATHS__),value=>value.charCodeAt(0)),bytes=new Uint8Array(__SIZE__);
|
|
388
|
+
let position=0,held=0,available=0,written=0,cursor=0;
|
|
389
|
+
const invalid=()=>{throw new Error('Invalid math font data');};
|
|
390
|
+
const fill=count=>{while(available<count&&position<source.length){held|=source[position++]<<available;available+=8;}};
|
|
391
|
+
const bits=count=>{fill(count);if(available<count)invalid();const value=held&((1<<count)-1);held>>>=count;available-=count;return value;};
|
|
392
|
+
const tree=lengths=>{const width=Math.max(...lengths),counts=new Uint16Array(width+1),next=new Uint16Array(width+1),table=new Uint16Array(1<<width);
|
|
393
|
+
for(const length of lengths)if(length)counts[length]++;let code=0;
|
|
394
|
+
for(let length=1;length<=width;length++){code=(code+counts[length-1])*2;next[length]=code;}
|
|
395
|
+
if(code+counts[width]>table.length)invalid();
|
|
396
|
+
for(let symbol=0;symbol<lengths.length;symbol++){const length=lengths[symbol];if(!length)continue;let value=next[length]++,reversed=0;
|
|
397
|
+
for(let i=0;i<length;i++){reversed=reversed*2+(value&1);value>>>=1;}
|
|
398
|
+
for(let index=reversed;index<table.length;index+=1<<length)table[index]=(symbol<<4)|length;}
|
|
399
|
+
return {width,table};};
|
|
400
|
+
const symbol=code=>{fill(code.width);const entry=code.table[held&((1<<code.width)-1)],length=entry&15;if(!length||length>available)invalid();bits(length);return entry>>>4;};
|
|
401
|
+
let final=0;
|
|
402
|
+
while(!final){final=bits(1);const kind=bits(2);if(kind===3)invalid();
|
|
403
|
+
if(!kind){bits(available&7);const count=bits(16);if((count^bits(16))!==65535||written+count>bytes.length)invalid();for(let i=0;i<count;i++)bytes[written++]=bits(8);continue;}
|
|
404
|
+
let literal,distance;
|
|
405
|
+
if(kind===1){literal=tree(Array.from({length:288},(_,i)=>i<144?8:i<256?9:i<280?7:8));distance=tree(Array(32).fill(5));}
|
|
406
|
+
else{const literals=bits(5)+257,distances=bits(5)+1,count=bits(4)+4,order=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],lengths=Array(19).fill(0);
|
|
407
|
+
for(let i=0;i<count;i++)lengths[order[i]]=bits(3);const code=tree(lengths),values=[];
|
|
408
|
+
while(values.length<literals+distances){const value=symbol(code);if(value<16){values.push(value);continue;}
|
|
409
|
+
if(value===16&&!values.length)invalid();const repeat=value===16?bits(2)+3:value===17?bits(3)+3:bits(7)+11;
|
|
410
|
+
if(values.length+repeat>literals+distances)invalid();const length=value===16?values[values.length-1]:0;for(let i=0;i<repeat;i++)values.push(length);}
|
|
411
|
+
if(!values[256])invalid();literal=tree(values.slice(0,literals));distance=tree(values.slice(literals));}
|
|
412
|
+
for(;;){const value=symbol(literal);if(value<256){if(written===bytes.length)invalid();bytes[written++]=value;continue;}if(value===256)break;if(value>285)invalid();
|
|
413
|
+
const code=value-257,extra=code<8?0:(code>>>2)-1,length=code===28?258:code<8?code+3:((code&3)+4)*2**extra+3+bits(extra);
|
|
414
|
+
const offset=symbol(distance);if(offset>29)invalid();const shift=offset<4?0:(offset>>>1)-1,back=offset<4?offset+1:((offset&1)+2)*2**shift+1+bits(shift);
|
|
415
|
+
if(back>written||written+length>bytes.length)invalid();for(let i=0;i<length;i++){bytes[written]=bytes[written-back];written++;}}
|
|
416
|
+
}
|
|
417
|
+
if(written!==bytes.length)invalid();
|
|
418
|
+
const unsigned=()=>{let value=0,shift=0,byte;do{if(cursor>=bytes.length||shift>49)invalid();byte=bytes[cursor++];value+=(byte&127)*2**shift;shift+=7;}while(byte&128);return value;};
|
|
419
|
+
const unit=unsigned();if(!unit)invalid();
|
|
420
|
+
const signed=()=>{const value=unsigned();return value&1?-(value+1)/2:value/2;};
|
|
421
|
+
const glyphCount=unsigned(),metricUnit=unsigned(),metrics=Array(7).fill(0);if(!metricUnit)invalid();
|
|
422
|
+
for(let i=0;i<glyphCount;i++){const glyph=[];for(let j=0;j<7;j++){metrics[j]+=signed();glyph.push(metrics[j]/metricUnit);}glyph.push('');MATH_FONT.glyphs.push(glyph);}
|
|
423
|
+
const commands='MLHVCZ',lengths=[2,2,1,1,6,0];
|
|
424
|
+
for(const glyph of MATH_FONT.glyphs){const header=unsigned();
|
|
425
|
+
if(header&1){glyph[7]=MATH_FONT.glyphs[header>>>1][7].replace(/-?\\d+(?:\\.\\d+)?/g,value=>(Math.round(Number(value)*unit)+signed())/unit);continue;}
|
|
426
|
+
let x=0,y=0,firstX=0,firstY=0,path='';const count=header>>>1;
|
|
427
|
+
for(let segment=0;segment<count;segment++){const command=bytes[cursor++];if(command>5)invalid();path+=commands[command];
|
|
428
|
+
for(let index=0;index<lengths[command];index++){if(index)path+=' ';if(command===3||(command!==2&&command!==3&&index%2)){y+=signed();path+=y/unit;}else{x+=signed();path+=x/unit;}}
|
|
429
|
+
if(command===0){firstX=x;firstY=y;}else if(command===5){x=firstX;y=firstY;}}
|
|
430
|
+
glyph[7]=path;}
|
|
431
|
+
const profileCount=unsigned();let id=-1;
|
|
432
|
+
for(let i=0;i<profileCount;i++){id+=unsigned()+1;const header=unsigned();let profile,left=0,right=0;
|
|
433
|
+
if(header&1){const parent=MATH_FONT.edgeProfiles[header>>>1];if(!parent)invalid();profile=[parent[0]];
|
|
434
|
+
for(const strip of parent.slice(1)){if(!strip){profile.push(null);continue;}left+=signed();right+=signed();profile.push([strip[0]+left,strip[1]+right]);}}
|
|
435
|
+
else{profile=[signed()];for(let j=0;j<header>>>1;j++){const token=unsigned();if(!token){profile.push(null);continue;}const value=token-1;left+=value&1?-(value+1)/2:value/2;right+=signed();profile.push([left,right]);}}
|
|
436
|
+
MATH_FONT.edgeProfiles[id]=profile;}
|
|
437
|
+
if(cursor!==bytes.length)invalid();
|
|
438
|
+
}
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def main():
|
|
443
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
444
|
+
parser.add_argument("--font", type=Path, help="Optional pinned source font; otherwise fetch it")
|
|
445
|
+
parser.add_argument("--license", type=Path, help="Optional pinned GUST Font License; otherwise fetch it")
|
|
446
|
+
parser.add_argument("--lppl", type=Path, help="Optional pinned LPPL 1.3c; otherwise fetch it")
|
|
447
|
+
parser.add_argument("--text-font", type=Path, help="Optional pinned Geist calibration font; otherwise use the editor copy or fetch it")
|
|
448
|
+
parser.add_argument("--out", type=Path, default=Path(__file__).with_name("font.js"))
|
|
449
|
+
args = parser.parse_args()
|
|
450
|
+
font_bytes = source_bytes(args.font, SOURCE_URL, SOURCE_SHA256)
|
|
451
|
+
gust = source_bytes(args.license, LICENSE_URL, LICENSE_SHA256)
|
|
452
|
+
lppl = source_bytes(args.lppl, LPPL_URL, LPPL_SHA256)
|
|
453
|
+
local_text = Path(__file__).parents[1] / "shell/fonts/Geist.wght400-700.woff2"
|
|
454
|
+
text_bytes = source_bytes(args.text_font or (local_text if local_text.exists() else None), TEXT_URL, TEXT_SHA256)
|
|
455
|
+
font = TTFont(io.BytesIO(font_bytes))
|
|
456
|
+
text_font = TTFont(io.BytesIO(text_bytes))
|
|
457
|
+
data = extract(font)
|
|
458
|
+
data.update(textXHeight=text_font["OS/2"].sxHeight, textUnits=text_font["head"].unitsPerEm)
|
|
459
|
+
notice = (font["name"].getDebugName(0) + "\n\n"
|
|
460
|
+
"Rapier math font data, derived from Latin Modern Math 1.959.\n"
|
|
461
|
+
"Changes: SVG outline conversion, character and MATH data extraction,\n"
|
|
462
|
+
"optical-form closure, derived contour profiles and lossless path packing.\n"
|
|
463
|
+
"Original outlines are unchanged. Hinting and text shaping tables are omitted.\n"
|
|
464
|
+
"The font authors do not maintain this conversion.\n"
|
|
465
|
+
"Complete original work: https://mirrors.ctan.org/fonts/lm-math.zip\n"
|
|
466
|
+
"Conversion details: FONT-SOURCE.md.\n\n").encode("utf-8") + gust + b"\n" + lppl
|
|
467
|
+
paths, size = pack_paths(data["glyphs"], data["edgeProfiles"])
|
|
468
|
+
glyph_count = len(data["glyphs"])
|
|
469
|
+
profile_count = len(data["edgeProfiles"])
|
|
470
|
+
data["glyphs"] = []
|
|
471
|
+
data["edgeProfiles"] = {}
|
|
472
|
+
output = (b"/*!\n" + notice + b"\n*/\nconst MATH_FONT=" + json.dumps(data, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b";\n" + PATH_DECODER.replace("__PATHS__", json.dumps(paths)).replace("__SIZE__", str(size)).encode("utf-8"))
|
|
473
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
args.out.write_bytes(output)
|
|
475
|
+
args.out.with_name("FONT-LICENSE.txt").write_bytes(notice)
|
|
476
|
+
print(json.dumps({"sourceSha256": hashlib.sha256(font_bytes).hexdigest(), "licenseSha256": hashlib.sha256(notice).hexdigest(), "glyphs": glyph_count, "characters": len(data["chars"]), "edgeProfiles": profile_count, "bytes": len(output), "gzipBytes": len(gzip.compress(output, compresslevel=9, mtime=0))}, indent=2))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Compose missing mathematical symbols from this font's own outlines and rules.
|
|
3
|
+
function mathFallbackGlyph(value,c,extra={}) {
|
|
4
|
+
if(!'ϝ▴▾◇◆ⓈℲ⅁╱╲⩞⪷⪸⫅⫆⋔◂▸⪵⪶⪹⪺⫋⫌⇠⇢⨋⎰⎱'.includes(value))return null;
|
|
5
|
+
const s=mathScale(c),axis=mathConstant('AxisHeight',c,.25),line=mathConstant('FractionRuleThickness',c,.04),
|
|
6
|
+
draw=(ch,factor=1)=>mathGlyphIndex(MATH_FONT.chars[ch.codePointAt(0)],{...c,size:c.size*factor}),
|
|
7
|
+
centered=b=>mathCompose([[b,0,-axis-(b.inkTop+b.inkBottom)/2]],b.w,extra);
|
|
8
|
+
const contour=(width,low,high,path,factor=1)=>{
|
|
9
|
+
const size=s*factor,unit=size/MATH_FONT.units,stroke=mathScreenStroke({...c,size:c.size*factor}),radius=stroke/2;
|
|
10
|
+
return mathBox(width*unit,high*unit+radius,Math.max(0,-low*unit+radius),
|
|
11
|
+
'<path d="'+path+'" transform="scale('+mathNumber(unit)+' '+mathNumber(-unit)+')"'+
|
|
12
|
+
(c.screen?' data-math-ink="'+mathNumber(size)+'"':'')+
|
|
13
|
+
(stroke?' stroke="currentColor" stroke-width="'+mathNumber(stroke/unit)+'" stroke-linejoin="round" paint-order="stroke fill"':'')+'/>',
|
|
14
|
+
{left:-radius,right:width*unit+radius,inkTop:-high*unit-radius,inkBottom:-low*unit+radius,...extra});
|
|
15
|
+
};
|
|
16
|
+
if(value==='ϝ') {
|
|
17
|
+
const b=draw('𝑓'),g=MATH_FONT.glyphs[b.glyph],path=g[7],bars=[...path.matchAll(/H(\d+)/g)],
|
|
18
|
+
start=bars[1],end=bars[2],cut=Number(path.slice(0,start.index).match(/-?\d+/g).at(-1)),
|
|
19
|
+
left=Number(start[1]),right=Number(path.slice(start.index,end.index).match(/-?\d+/g).at(-2)),
|
|
20
|
+
body='M'+left+' '+cut+path.slice(start.index+start[0].length,end.index)+'Z',unit=b.unitScale,
|
|
21
|
+
barEnd=g[0],top=MATH_FONT.xHeight,thickness=top-cut,
|
|
22
|
+
upper=contour(g[0],cut,top,'M'+left+' '+cut+'H'+barEnd+'V'+top+'H'+left+'Z'),
|
|
23
|
+
middle=contour(g[0],170,170+thickness,'M'+(left-50)+' 170H'+(barEnd-45)+'V'+(170+thickness)+'H'+(left-50)+'Z');
|
|
24
|
+
const stem={...b,h:cut*unit+b.stroke/2,inkTop:-cut*unit-b.stroke/2,right:Math.max(b.w,right*unit),
|
|
25
|
+
svg:b.svg.replace('d="'+path+'"','d="'+body+'"'),glyph:undefined};
|
|
26
|
+
return mathCompose([[stem,0,0],[upper,0,0],[middle,0,0]],b.w,{...extra,italic:0});
|
|
27
|
+
}
|
|
28
|
+
if('Ⅎ⅁'.includes(value)) {
|
|
29
|
+
const b=draw(value==='Ⅎ'?'F':String.fromCodePoint(mathStyledCodePoint(71,'sans-serif'))),svg='<g transform="translate('+mathNumber(b.w)+' '+mathNumber(b.inkTop+b.inkBottom)+') scale(-1 -1)">'+b.svg+'</g>';
|
|
30
|
+
return {...b,svg,left:b.w-b.right,right:b.w-b.left,accent:b.w/2,italic:0,glyph:undefined,...extra};
|
|
31
|
+
}
|
|
32
|
+
if(value==='Ⓢ') {
|
|
33
|
+
const circle=draw('○'),letter=draw('S',.62),y=(circle.inkTop+circle.inkBottom-letter.inkTop-letter.inkBottom)/2;
|
|
34
|
+
return mathCompose([[circle,0,0],[letter,(circle.w-letter.w)/2,y]],circle.w,extra);
|
|
35
|
+
}
|
|
36
|
+
if('▴▾◂▸'.includes(value))return centered(draw({'▴':'▲','▾':'▼','◂':'◀','▸':'▶'}[value],.8));
|
|
37
|
+
if(value==='◇') {
|
|
38
|
+
const diamond=draw('⋄'),square=draw('□'),factor=(square.inkBottom-square.inkTop)/(diamond.inkBottom-diamond.inkTop);
|
|
39
|
+
return centered(draw('⋄',factor));
|
|
40
|
+
}
|
|
41
|
+
if(value==='◆') {
|
|
42
|
+
const b=draw('◊'),path=MATH_FONT.glyphs[b.glyph][7],contours=path.match(/M[^M]+/g);
|
|
43
|
+
const range=part=>{const numbers=part.match(/-?\d+(?:\.\d+)?/g).map(Number);return Math.max(...numbers)-Math.min(...numbers);};
|
|
44
|
+
const outer=contours.reduce((a,b)=>range(a)>range(b)?a:b);
|
|
45
|
+
return {...b,svg:b.svg.replace('d="'+path+'"','d="'+outer+'"'),glyph:undefined,...extra};
|
|
46
|
+
}
|
|
47
|
+
if(value==='╱'||value==='╲') {
|
|
48
|
+
const t=MATH_FONT.constants.FractionRuleThickness/Math.SQRT2;
|
|
49
|
+
const points=[[75,-75],[725,575],[725+t,575-t],[75+t,-75-t]];
|
|
50
|
+
if(value==='╲')for(const point of points)point[0]=800-point[0];
|
|
51
|
+
return contour(800,-75-t,575,'M'+points.map(point=>point.map(mathNumber).join(' ')).join('L')+'Z');
|
|
52
|
+
}
|
|
53
|
+
if(value==='⋔') {
|
|
54
|
+
const b=draw('∩',.86),g=MATH_FONT.glyphs[b.glyph],x=(g[1]+g[3])/2,t=MATH_FONT.constants.FractionRuleThickness,
|
|
55
|
+
stem=contour(g[0],g[2],g[4]+100,'M'+mathNumber(x-t/2)+' '+g[2]+'V'+(g[4]+100)+'H'+mathNumber(x+t/2)+'V'+g[2]+'Z',.86);
|
|
56
|
+
return centered(mathCompose([[b,0,0],[stem,0,0]],b.w));
|
|
57
|
+
}
|
|
58
|
+
const relations={'⩞':['=','∧'],'⪷':['≺','≈'],'⪸':['≻','≈'],'⫅':['⊂','='],'⫆':['⊃','='],
|
|
59
|
+
'⪵':['≺','=',1],'⪶':['≻','=',1],'⪹':['≺','≈',1],'⪺':['≻','≈',1],'⫋':['⊂','=',1],'⫌':['⊃','=',1]};
|
|
60
|
+
if(relations[value]) {
|
|
61
|
+
const [upper,lower,negated]=relations[value],a=draw(upper,.9),b=draw(lower,.85),gap=.065*s,
|
|
62
|
+
width=Math.max(draw(upper).w,draw(lower).w),extent=a.inkBottom-a.inkTop+b.inkBottom-b.inkTop+gap,
|
|
63
|
+
top=-axis-extent/2,ay=top-a.inkTop,by=top+a.inkBottom-a.inkTop+gap-b.inkTop,
|
|
64
|
+
items=[[a,(width-a.w)/2,ay],[b,(width-b.w)/2,by]];
|
|
65
|
+
if(negated) {
|
|
66
|
+
const height=b.inkBottom-b.inkTop+.12*s,half=.13*s,thickness=line*Math.SQRT2/2,
|
|
67
|
+
y=by+(b.inkTop+b.inkBottom)/2,x=width/2;
|
|
68
|
+
const path='M'+[x-half,y+height/2].map(mathNumber).join(' ')+'L'+[x+half,y-height/2].map(mathNumber).join(' ')+
|
|
69
|
+
'l'+[thickness,thickness].map(mathNumber).join(' ')+'L'+[x-half+thickness,y+height/2+thickness].map(mathNumber).join(' ')+'Z';
|
|
70
|
+
items.push([mathBox(0,0,0,'<path d="'+path+'"/>',{left:x-half,right:x+half+thickness,inkTop:y-height/2,inkBottom:y+height/2+thickness}),0,0]);
|
|
71
|
+
}
|
|
72
|
+
return mathCompose(items,width,extra);
|
|
73
|
+
}
|
|
74
|
+
if(value==='⇠'||value==='⇢') {
|
|
75
|
+
const right=value==='⇢',b=draw(right?'→':'←'),g=MATH_FONT.glyphs[b.glyph],unit=b.unitScale,
|
|
76
|
+
width=Math.max(b.w,extra.stretchAxis==='horizontal'?extra.stretchTarget || 0:0),added=width-b.w,
|
|
77
|
+
split=g[0]*(right?.65:.35);let command='',coordinate=0;
|
|
78
|
+
const path=g[7].replace(/[MLHVCZ]|-?\d+(?:\.\d+)?/g,token=>{
|
|
79
|
+
if(/[MLHVCZ]/.test(token)){command=token;coordinate=0;return token;}
|
|
80
|
+
const isX=command!=='V'&&(command==='H'||coordinate%2===0);coordinate++;
|
|
81
|
+
return isX?mathNumber(right?Math.max(split,Number(token)):Math.min(split,Number(token))):token;
|
|
82
|
+
});
|
|
83
|
+
const head={...b,svg:b.svg.replace('d="'+g[7]+'"','d="'+path+'"'),glyph:undefined},
|
|
84
|
+
start=right?g[1]*unit:split*unit,end=right?split*unit+added:g[3]*unit+added,
|
|
85
|
+
length=end-start,count=Math.max(2,Math.ceil(length/(.19*s)));
|
|
86
|
+
if(count>2048)throw Object.assign(new Error('Mathematical arrow is too large'),{code:'resource-limit'});
|
|
87
|
+
const dash=length/(count*1.7-.7),items=[[head,right?added:0,0]];
|
|
88
|
+
for(let i=0;i<count;i++)items.push([mathRule(dash,line,-axis-line/2),start+i*dash*1.7,0]);
|
|
89
|
+
return mathCompose(items,width,{stretchAxis:'horizontal',...extra});
|
|
90
|
+
}
|
|
91
|
+
if(value==='⨋') {
|
|
92
|
+
const target=extra.stretchAxis==='vertical'?extra.stretchTarget:0,
|
|
93
|
+
sum=target?mathStretch('∑',target,c):draw('∑'),integral=target?mathStretch('∫',target,c):draw('∫'),
|
|
94
|
+
x=(sum.w-integral.w)/2,sy=-axis-(sum.inkTop+sum.inkBottom)/2,iy=-axis-(integral.inkTop+integral.inkBottom)/2;
|
|
95
|
+
return mathCompose([[sum,0,sy],[integral,x,iy]],Math.max(sum.w,x+integral.w),{italic:integral.italic,extended:true,...extra});
|
|
96
|
+
}
|
|
97
|
+
if(value==='⎰'||value==='⎱') {
|
|
98
|
+
const target=Math.max(s,extra.stretchAxis==='vertical'?extra.stretchTarget || 0:s),factor=Math.min(1,target/(1.5*s)),
|
|
99
|
+
top=draw(value==='⎰'?'⎧':'⎫',factor),bottom=draw(value==='⎰'?'⎭':'⎩',factor),
|
|
100
|
+
total=Math.max(target,top.h+bottom.h),gap=Math.max(0,total-top.h-bottom.h),width=top.w,
|
|
101
|
+
ty=-axis-total/2-top.inkTop,by=ty+top.inkBottom+gap-bottom.inkTop,items=[[top,0,ty],[bottom,0,by]];
|
|
102
|
+
if(gap>0) {
|
|
103
|
+
const part=MATH_FONT.glyphs[MATH_FONT.chars['⎪'.codePointAt(0)]],unit=s*factor/MATH_FONT.units,
|
|
104
|
+
overlap=Math.max(MATH_FONT.minConnectorOverlap,40),height=gap/unit+2*overlap,
|
|
105
|
+
stem=contour(part[0],0,height,'M'+part[1]+' 0V'+mathNumber(height)+'H'+part[3]+'V0Z',factor);
|
|
106
|
+
items.push([stem,0,ty+top.inkBottom+gap+overlap*unit]);
|
|
107
|
+
}
|
|
108
|
+
return mathCompose(items,width,{extended:true,...extra});
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|