rapier-math 0.0.0-stage → 1.0.2
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 +2953 -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.2.js +2952 -0
- package/src/math/FONT-LICENSE.txt +457 -0
- package/src/math/FONT-SOURCE.md +143 -0
- package/src/math/LICENSE-MIT +21 -0
- package/src/math/api.js +87 -0
- package/src/math/browser.js +181 -0
- package/src/math/build-font.py +622 -0
- package/src/math/fallbacks.js +111 -0
- package/src/math/font.js +547 -0
- package/src/math/layout.js +580 -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 +95 -0
- package/verify.mjs +40 -0
|
@@ -0,0 +1,622 @@
|
|
|
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
|
+
PAGE_BYTES = 16384
|
|
261
|
+
PAGE_STRIDE = 32768
|
|
262
|
+
PARENT_DEPTH = 4
|
|
263
|
+
SCALAR_MAPS = ("chars", "dotless", "flatAccents", "extendedShapes")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def pack_font(data):
|
|
267
|
+
"""Share translated contours and retain independently inflatable record pages."""
|
|
268
|
+
glyphs, profiles = data["glyphs"], data["edgeProfiles"]
|
|
269
|
+
commands = "MLHVCZ"
|
|
270
|
+
lengths = (2, 2, 1, 1, 6, 0)
|
|
271
|
+
|
|
272
|
+
def unsigned(value, target):
|
|
273
|
+
if value < 0 or value > 2**53 - 1:
|
|
274
|
+
raise ValueError("Font integer exceeds exact JavaScript arithmetic")
|
|
275
|
+
while value >= 128:
|
|
276
|
+
target.append((value & 127) | 128)
|
|
277
|
+
value >>= 7
|
|
278
|
+
target.append(value)
|
|
279
|
+
|
|
280
|
+
def signed(value, target):
|
|
281
|
+
value = value * 2 if value >= 0 else -value * 2 - 1
|
|
282
|
+
if value > 2**53 - 1:
|
|
283
|
+
raise ValueError("Font integer exceeds exact JavaScript arithmetic")
|
|
284
|
+
if value < 240:
|
|
285
|
+
target.append(value)
|
|
286
|
+
elif value < 4080:
|
|
287
|
+
value -= 240
|
|
288
|
+
target.extend((240 + (value >> 8), value & 255))
|
|
289
|
+
else:
|
|
290
|
+
target.append(255)
|
|
291
|
+
unsigned(value - 4080, target)
|
|
292
|
+
|
|
293
|
+
paths = [re.findall(r"[MLHVCZ]|-?\d+(?:\.\d+)?", glyph[7]) for glyph in glyphs]
|
|
294
|
+
unit = 1
|
|
295
|
+
for glyph, tokens in zip(glyphs, paths):
|
|
296
|
+
if "".join(tokens) != re.sub(r"[ ,]", "", glyph[7]):
|
|
297
|
+
raise ValueError("Font path has an unsupported coordinate or command")
|
|
298
|
+
for token in tokens:
|
|
299
|
+
if "." in token:
|
|
300
|
+
unit = math.lcm(unit, Fraction(token).denominator)
|
|
301
|
+
metric_unit = math.lcm(2, *(Fraction(value).denominator for glyph in glyphs for value in glyph[:7]))
|
|
302
|
+
metrics = [[int(value * metric_unit) for value in glyph[:7]] for glyph in glyphs]
|
|
303
|
+
for values in metrics:
|
|
304
|
+
values[3] -= values[0] - values[1]
|
|
305
|
+
values[6] -= values[0] // 2
|
|
306
|
+
metric_bases = [sorted(values[column] for values in metrics)[len(metrics) // 2] for column in range(7)]
|
|
307
|
+
contours, contour_ids, references = [], {}, []
|
|
308
|
+
for tokens in paths:
|
|
309
|
+
cursor, previous = 0, None
|
|
310
|
+
structure, coordinates, refs = [], [], []
|
|
311
|
+
first_x = first_y = x = y = 0
|
|
312
|
+
|
|
313
|
+
def keep_contour():
|
|
314
|
+
if not structure:
|
|
315
|
+
return
|
|
316
|
+
key = bytes(structure), tuple(coordinates)
|
|
317
|
+
if key not in contour_ids:
|
|
318
|
+
contour_ids[key] = len(contours)
|
|
319
|
+
contours.append(key)
|
|
320
|
+
refs.append((contour_ids[key], first_x, first_y))
|
|
321
|
+
|
|
322
|
+
while cursor < len(tokens):
|
|
323
|
+
if tokens[cursor] in commands:
|
|
324
|
+
command = commands.index(tokens[cursor])
|
|
325
|
+
cursor += 1
|
|
326
|
+
elif previous is not None and previous != 5:
|
|
327
|
+
command = 1 if previous == 0 else previous
|
|
328
|
+
else:
|
|
329
|
+
raise ValueError("Font path has an invalid repeated command")
|
|
330
|
+
count = lengths[command]
|
|
331
|
+
values = [int(Fraction(value) * unit) for value in tokens[cursor:cursor + count]]
|
|
332
|
+
if len(values) != count:
|
|
333
|
+
raise ValueError("Font path has an incomplete command")
|
|
334
|
+
if any(abs(value) * 4 > 2**53 - 1 for value in values):
|
|
335
|
+
raise ValueError("Font coordinates exceed exact JavaScript arithmetic")
|
|
336
|
+
cursor += count
|
|
337
|
+
if command == 0:
|
|
338
|
+
keep_contour()
|
|
339
|
+
structure, coordinates = [], []
|
|
340
|
+
first_x, first_y = x, y = values
|
|
341
|
+
else:
|
|
342
|
+
if previous is None:
|
|
343
|
+
raise ValueError("Font contour does not begin with a move")
|
|
344
|
+
structure.append(command)
|
|
345
|
+
for index, value in enumerate(values):
|
|
346
|
+
if command == 3 or (command not in (2, 3) and index % 2):
|
|
347
|
+
coordinates.append(value - y)
|
|
348
|
+
y = value
|
|
349
|
+
else:
|
|
350
|
+
coordinates.append(value - x)
|
|
351
|
+
x = value
|
|
352
|
+
if command == 5:
|
|
353
|
+
x, y = first_x, first_y
|
|
354
|
+
previous = command
|
|
355
|
+
keep_contour()
|
|
356
|
+
references.append(refs)
|
|
357
|
+
|
|
358
|
+
contour_records, shapes, depths = [], {}, []
|
|
359
|
+
for contour_id, (structure, coordinates) in enumerate(contours):
|
|
360
|
+
record = bytearray()
|
|
361
|
+
unsigned(len(structure) * 2, record)
|
|
362
|
+
record.extend(structure)
|
|
363
|
+
for value in coordinates:
|
|
364
|
+
signed(value, record)
|
|
365
|
+
# Small residuals compress better even when their first encoding costs another byte.
|
|
366
|
+
best = (len(record) + sum(abs(value).bit_length() for value in coordinates), len(record))
|
|
367
|
+
parent_depth = -1
|
|
368
|
+
for parent, previous_coordinates in shapes.setdefault(structure, []):
|
|
369
|
+
if depths[parent] >= PARENT_DEPTH:
|
|
370
|
+
continue
|
|
371
|
+
shared = bytearray()
|
|
372
|
+
unsigned(parent * 2 + 1, shared)
|
|
373
|
+
residuals = [value - previous_value for value, previous_value in zip(coordinates, previous_coordinates)]
|
|
374
|
+
for value in residuals:
|
|
375
|
+
signed(value, shared)
|
|
376
|
+
score = (len(shared) + sum(abs(value).bit_length() for value in residuals), len(shared))
|
|
377
|
+
if score < best:
|
|
378
|
+
best = score
|
|
379
|
+
record, parent_depth = shared, depths[parent]
|
|
380
|
+
depths.append(parent_depth + 1)
|
|
381
|
+
shapes[structure].append((contour_id, coordinates))
|
|
382
|
+
contour_records.append(record)
|
|
383
|
+
|
|
384
|
+
profile_records, profile_ids, profile_shapes, profile_depths = [], {}, {}, []
|
|
385
|
+
profile_values = {}
|
|
386
|
+
for glyph_id, profile in sorted(profiles.items()):
|
|
387
|
+
key = profile[0], tuple(None if strip is None else tuple(strip) for strip in profile[1:])
|
|
388
|
+
if key in profile_values:
|
|
389
|
+
profile_ids[glyph_id] = profile_values[key]
|
|
390
|
+
continue
|
|
391
|
+
profile_id = len(profile_records)
|
|
392
|
+
profile_ids[glyph_id] = profile_id
|
|
393
|
+
profile_values[key] = profile_id
|
|
394
|
+
record = bytearray()
|
|
395
|
+
unsigned((len(profile) - 1) * 2, record)
|
|
396
|
+
signed(profile[0], record)
|
|
397
|
+
left = right = 0
|
|
398
|
+
for strip in profile[1:]:
|
|
399
|
+
if strip is None:
|
|
400
|
+
unsigned(0, record)
|
|
401
|
+
continue
|
|
402
|
+
delta = strip[0] - left
|
|
403
|
+
unsigned((delta * 2 if delta >= 0 else -delta * 2 - 1) + 1, record)
|
|
404
|
+
signed(strip[1] - right, record)
|
|
405
|
+
left, right = strip
|
|
406
|
+
shape = profile[0], tuple(strip is not None for strip in profile[1:])
|
|
407
|
+
parent_depth = -1
|
|
408
|
+
for parent_id, parent in profile_shapes.setdefault(shape, []):
|
|
409
|
+
if profile_depths[parent_id] >= PARENT_DEPTH:
|
|
410
|
+
continue
|
|
411
|
+
shared = bytearray()
|
|
412
|
+
unsigned(parent_id * 2 + 1, shared)
|
|
413
|
+
left = right = 0
|
|
414
|
+
for strip, previous_strip in zip(profile[1:], parent[1:]):
|
|
415
|
+
if strip is None:
|
|
416
|
+
continue
|
|
417
|
+
next_left, next_right = strip[0] - previous_strip[0], strip[1] - previous_strip[1]
|
|
418
|
+
signed(next_left - left, shared)
|
|
419
|
+
signed(next_right - right, shared)
|
|
420
|
+
left, right = next_left, next_right
|
|
421
|
+
if len(shared) >= len(record):
|
|
422
|
+
break
|
|
423
|
+
if len(shared) < len(record):
|
|
424
|
+
record, parent_depth = shared, profile_depths[parent_id]
|
|
425
|
+
profile_depths.append(parent_depth + 1)
|
|
426
|
+
profile_shapes[shape].append((profile_id, profile))
|
|
427
|
+
profile_records.append(record)
|
|
428
|
+
|
|
429
|
+
glyph_records = []
|
|
430
|
+
for glyph_id, (values, refs) in enumerate(zip(metrics, references)):
|
|
431
|
+
record = bytearray()
|
|
432
|
+
for value, base in zip(values, metric_bases):
|
|
433
|
+
signed(value - base, record)
|
|
434
|
+
unsigned(profile_ids.get(glyph_id, -1) + 1, record)
|
|
435
|
+
unsigned(len(refs), record)
|
|
436
|
+
for contour_id, x, y in refs:
|
|
437
|
+
unsigned(contour_id, record)
|
|
438
|
+
signed(x, record)
|
|
439
|
+
signed(y, record)
|
|
440
|
+
glyph_records.append(record)
|
|
441
|
+
|
|
442
|
+
pages, indices = [], bytearray()
|
|
443
|
+
unsigned(unit, indices)
|
|
444
|
+
unsigned(metric_unit, indices)
|
|
445
|
+
for value in metric_bases:
|
|
446
|
+
signed(value, indices)
|
|
447
|
+
for name in SCALAR_MAPS:
|
|
448
|
+
unsigned(len(data[name]), indices)
|
|
449
|
+
previous_key = previous_value = 0
|
|
450
|
+
for key, value in sorted(data[name].items()):
|
|
451
|
+
unsigned(key - previous_key, indices)
|
|
452
|
+
signed(value - previous_value, indices)
|
|
453
|
+
previous_key, previous_value = key, value
|
|
454
|
+
unsigned(len(data["scriptAlternates"]), indices)
|
|
455
|
+
previous_key, previous_values = 0, [0, 0]
|
|
456
|
+
for key, values in sorted(data["scriptAlternates"].items()):
|
|
457
|
+
unsigned(key - previous_key, indices)
|
|
458
|
+
previous_key = key
|
|
459
|
+
unsigned(len(values), indices)
|
|
460
|
+
for index, value in enumerate(values):
|
|
461
|
+
signed(value - previous_values[index], indices)
|
|
462
|
+
previous_values[index] = value
|
|
463
|
+
for records in (glyph_records, contour_records, profile_records):
|
|
464
|
+
page, offsets = bytearray(), []
|
|
465
|
+
for record in records:
|
|
466
|
+
if len(record) > PAGE_BYTES:
|
|
467
|
+
raise ValueError("Font record exceeds its independent page bound")
|
|
468
|
+
if len(page) + len(record) > PAGE_BYTES:
|
|
469
|
+
pages.append(bytes(page))
|
|
470
|
+
page = bytearray()
|
|
471
|
+
offsets.append(len(pages) * PAGE_STRIDE + len(page))
|
|
472
|
+
page.extend(record)
|
|
473
|
+
offsets.append(len(pages) * PAGE_STRIDE + len(page))
|
|
474
|
+
pages.append(bytes(page))
|
|
475
|
+
unsigned(len(offsets), indices)
|
|
476
|
+
previous = 0
|
|
477
|
+
for offset in offsets:
|
|
478
|
+
unsigned(offset - previous, indices)
|
|
479
|
+
previous = offset
|
|
480
|
+
|
|
481
|
+
def compressed(data):
|
|
482
|
+
return base64.b64encode(zlib.compress(data, level=9, wbits=-15)).decode("ascii")
|
|
483
|
+
|
|
484
|
+
packed = [(len(page), compressed(page)) for page in pages]
|
|
485
|
+
return packed, compressed(indices), len(indices), {
|
|
486
|
+
"contours": len(contours), "contourReferences": sum(map(len, references)), "profileRecords": len(profile_records),
|
|
487
|
+
"pages": len(pages), "pageBytes": PAGE_BYTES, "indexBytes": len(indices),
|
|
488
|
+
"recordBytes": sum(map(len, pages)), "maxParentDepth": PARENT_DEPTH,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
PATH_DECODER = """{
|
|
493
|
+
// The font owns immutable pages and bounded hot records. No document data enters these caches.
|
|
494
|
+
const pages=__PAGES__,invalid=()=>{throw new Error('Invalid math font data');};
|
|
495
|
+
const inflate=(encoded,size)=>{
|
|
496
|
+
const source=atob(encoded),bytes=new Uint8Array(size);let position=0,held=0,available=0,written=0;
|
|
497
|
+
const fill=count=>{while(available<count&&position<source.length){held|=source.charCodeAt(position++)<<available;available+=8;}};
|
|
498
|
+
const bits=count=>{fill(count);if(available<count)invalid();const value=held&((1<<count)-1);held>>>=count;available-=count;return value;};
|
|
499
|
+
const tree=lengths=>{const width=Math.max(...lengths),counts=new Uint16Array(width+1),next=new Uint16Array(width+1),table=new Uint16Array(1<<width);
|
|
500
|
+
for(const length of lengths)if(length)counts[length]++;let code=0;
|
|
501
|
+
for(let length=1;length<=width;length++){code=(code+counts[length-1])*2;next[length]=code;}
|
|
502
|
+
if(code+counts[width]>table.length)invalid();
|
|
503
|
+
for(let symbol=0;symbol<lengths.length;symbol++){const length=lengths[symbol];if(!length)continue;let value=next[length]++,reversed=0;
|
|
504
|
+
for(let i=0;i<length;i++){reversed=reversed*2+(value&1);value>>>=1;}
|
|
505
|
+
for(let index=reversed;index<table.length;index+=1<<length)table[index]=(symbol<<4)|length;}
|
|
506
|
+
return {width,table};};
|
|
507
|
+
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;};
|
|
508
|
+
let final=0;
|
|
509
|
+
while(!final){final=bits(1);const kind=bits(2);if(kind===3)invalid();
|
|
510
|
+
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;}
|
|
511
|
+
let literal,distance;
|
|
512
|
+
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));}
|
|
513
|
+
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);
|
|
514
|
+
for(let i=0;i<count;i++)lengths[order[i]]=bits(3);const code=tree(lengths),values=[];
|
|
515
|
+
while(values.length<literals+distances){const value=symbol(code);if(value<16){values.push(value);continue;}
|
|
516
|
+
if(value===16&&!values.length)invalid();const repeat=value===16?bits(2)+3:value===17?bits(3)+3:bits(7)+11;
|
|
517
|
+
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);}
|
|
518
|
+
if(!values[256])invalid();literal=tree(values.slice(0,literals));distance=tree(values.slice(literals));}
|
|
519
|
+
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();
|
|
520
|
+
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);
|
|
521
|
+
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);
|
|
522
|
+
if(back>written||written+length>bytes.length)invalid();for(let i=0;i<length;i++){bytes[written]=bytes[written-back];written++;}}
|
|
523
|
+
}
|
|
524
|
+
if(written!==bytes.length||position-Math.floor(available/8)!==source.length)invalid();return bytes;
|
|
525
|
+
};
|
|
526
|
+
const unsigned=r=>{let value=0,shift=0,byte;do{if(r[1]>=r[2]||shift>49)invalid();byte=r[0][r[1]++];value+=(byte&127)*2**shift;shift+=7;}while(byte&128);if(!Number.isSafeInteger(value))invalid();return value;};
|
|
527
|
+
const signed=r=>{if(r[1]>=r[2])invalid();let value=r[0][r[1]++];if(value>=240){if(value===255){value=4080+unsigned(r);if(!Number.isSafeInteger(value))invalid();}else{if(r[1]>=r[2])invalid();value=240+(value-240)*256+r[0][r[1]++];}}return value%2?-(value+1)/2:value/2;};
|
|
528
|
+
const complete=r=>{if(r[1]!==r[2])invalid();};
|
|
529
|
+
const [unit,metricUnit,metricBases,glyphs,contours,profiles]=(()=>{
|
|
530
|
+
const bytes=inflate(__INDEX__,__SIZE__),r=[bytes,0,bytes.length],unit=unsigned(r),metricUnit=unsigned(r);if(!unit||!metricUnit)invalid();const metricBases=Array.from({length:7},()=>signed(r));
|
|
531
|
+
for(const name of __MAP_FIELDS__){const count=unsigned(r),map=MATH_FONT[name]={};if(count>bytes.length)invalid();let key=0,value=0;
|
|
532
|
+
for(let i=0;i<count;i++){key+=unsigned(r);value+=signed(r);map[key]=value;}}
|
|
533
|
+
const scripts=MATH_FONT.scriptAlternates={},scriptCount=unsigned(r),previous=[0,0];let key=0;if(scriptCount>bytes.length)invalid();
|
|
534
|
+
for(let i=0;i<scriptCount;i++){key+=unsigned(r);const count=unsigned(r);if(count<1||count>2)invalid();const values=[];for(let j=0;j<count;j++){previous[j]+=signed(r);values.push(previous[j]);}scripts[key]=values;}
|
|
535
|
+
const tables=Array.from({length:3},()=>{const count=unsigned(r);if(count<1||count>bytes.length)invalid();const offsets=new Uint32Array(count);let previous=0;
|
|
536
|
+
for(let i=0;i<count;i++){previous+=unsigned(r);if(previous>=pages.length*32768)invalid();offsets[i]=previous;}return offsets;});complete(r);return [unit,metricUnit,metricBases,...tables];
|
|
537
|
+
})();
|
|
538
|
+
const pageCache=new Map(),glyphCache=new Map(),profileCache=new Map();
|
|
539
|
+
const remember=(cache,id,value,limit)=>{if(cache.size>=limit)cache.delete(cache.keys().next().value);cache.set(id,value);return value;};
|
|
540
|
+
const touch=(cache,id)=>{const value=cache.get(id);if(value!==undefined){cache.delete(id);cache.set(id,value);}return value;};
|
|
541
|
+
const record=(offsets,id)=>{
|
|
542
|
+
if(!Number.isInteger(id)||id<0||id+1>=offsets.length)return;
|
|
543
|
+
const offset=offsets[id],page=offset>>>15,next=offsets[id+1];let bytes=touch(pageCache,page);
|
|
544
|
+
if(!bytes){const source=pages[page];if(!source||source[0]>16384)invalid();bytes=remember(pageCache,page,inflate(source[1],source[0]),8);}
|
|
545
|
+
const begin=offset&32767,end=next>>>15===page?next&32767:bytes.length;if(begin>=end||end>bytes.length)invalid();return [bytes,begin,end];
|
|
546
|
+
};
|
|
547
|
+
const commandLengths=[2,2,1,1,6,0],commandNames='MLHVCZ';
|
|
548
|
+
const contour=(id,local,depth=0)=>{
|
|
549
|
+
if(local.has(id))return local.get(id);if(depth>4)invalid();const r=record(contours,id);if(!r)invalid();const header=unsigned(r);let commands,coordinates;
|
|
550
|
+
if(header%2){const parentId=Math.floor(header/2);if(parentId>=id)invalid();const parent=contour(parentId,local,depth+1);commands=parent[0];coordinates=new Float64Array(parent[1].length);
|
|
551
|
+
for(let i=0;i<coordinates.length;i++)coordinates[i]=parent[1][i]+signed(r);}
|
|
552
|
+
else{const count=header/2;if(r[1]+count>r[2])invalid();commands=r[0].subarray(r[1],r[1]+count);r[1]+=count;let values=0;
|
|
553
|
+
for(const command of commands){if(command<1||command>5)invalid();values+=commandLengths[command];}if(values>r[2]-r[1])invalid();coordinates=new Float64Array(values);
|
|
554
|
+
for(let i=0;i<values;i++)coordinates[i]=signed(r);}
|
|
555
|
+
complete(r);const value=[commands,coordinates];local.set(id,value);return value;
|
|
556
|
+
};
|
|
557
|
+
const glyphRecord=id=>{
|
|
558
|
+
let cached=touch(glyphCache,id);if(cached)return cached;const r=record(glyphs,id);if(!r)return;
|
|
559
|
+
const glyph=[];for(let i=0;i<7;i++)glyph.push(signed(r)+metricBases[i]);glyph[3]+=glyph[0]-glyph[1];glyph[6]+=glyph[0]/2;for(let i=0;i<7;i++)glyph[i]/=metricUnit;const profile=unsigned(r)-1,count=unsigned(r),local=new Map();let path='';
|
|
560
|
+
if(profile>=profiles.length-1||count>r[2]-r[1])invalid();
|
|
561
|
+
for(let part=0;part<count;part++){const id=unsigned(r),firstX=signed(r),firstY=signed(r),[commands,coordinates]=contour(id,local);let x=firstX,y=firstY,cursor=0;
|
|
562
|
+
path+='M'+x/unit+' '+y/unit;
|
|
563
|
+
for(const command of commands){path+=commandNames[command];for(let index=0;index<commandLengths[command];index++){if(index)path+=' ';
|
|
564
|
+
if(command===3||(command!==2&&command!==3&&index%2)){y+=coordinates[cursor++];path+=y/unit;}else{x+=coordinates[cursor++];path+=x/unit;}}
|
|
565
|
+
if(command===5){x=firstX;y=firstY;}}
|
|
566
|
+
}complete(r);glyph.push(path);cached={glyph,profile};return remember(glyphCache,id,cached,256);
|
|
567
|
+
};
|
|
568
|
+
const profileRecord=(id,depth=0)=>{
|
|
569
|
+
const cached=touch(profileCache,id);if(cached)return cached;if(depth>4)invalid();const r=record(profiles,id);if(!r)invalid();const header=unsigned(r);let profile,left=0,right=0;
|
|
570
|
+
if(header%2){const parentId=Math.floor(header/2);if(parentId>=id)invalid();const parent=profileRecord(parentId,depth+1);profile=[parent[0]];
|
|
571
|
+
for(let i=1;i<parent.length;i++){const strip=parent[i];if(!strip){profile.push(null);continue;}left+=signed(r);right+=signed(r);profile.push([strip[0]+left,strip[1]+right]);}}
|
|
572
|
+
else{profile=[signed(r)];const count=header/2;if(count>r[2]-r[1])invalid();for(let i=0;i<count;i++){const token=unsigned(r);if(!token){profile.push(null);continue;}const value=token-1;left+=value%2?-(value+1)/2:value/2;right+=signed(r);profile.push([left,right]);}}
|
|
573
|
+
complete(r);return remember(profileCache,id,profile,128);
|
|
574
|
+
};
|
|
575
|
+
MATH_FONT.glyph=id=>glyphRecord(id)?.glyph;
|
|
576
|
+
MATH_FONT.profile=id=>{const glyph=glyphRecord(id);return glyph&&glyph.profile>=0?profileRecord(glyph.profile):undefined;};
|
|
577
|
+
}
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def main():
|
|
583
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
584
|
+
parser.add_argument("--font", type=Path, help="Optional pinned source font; otherwise fetch it")
|
|
585
|
+
parser.add_argument("--license", type=Path, help="Optional pinned GUST Font License; otherwise fetch it")
|
|
586
|
+
parser.add_argument("--lppl", type=Path, help="Optional pinned LPPL 1.3c; otherwise fetch it")
|
|
587
|
+
parser.add_argument("--text-font", type=Path, help="Optional pinned Geist calibration font; otherwise use the editor copy or fetch it")
|
|
588
|
+
parser.add_argument("--out", type=Path, default=Path(__file__).with_name("font.js"))
|
|
589
|
+
args = parser.parse_args()
|
|
590
|
+
font_bytes = source_bytes(args.font, SOURCE_URL, SOURCE_SHA256)
|
|
591
|
+
gust = source_bytes(args.license, LICENSE_URL, LICENSE_SHA256)
|
|
592
|
+
lppl = source_bytes(args.lppl, LPPL_URL, LPPL_SHA256)
|
|
593
|
+
local_text = Path(__file__).parents[1] / "shell/fonts/Geist.wght400-700.woff2"
|
|
594
|
+
text_bytes = source_bytes(args.text_font or (local_text if local_text.exists() else None), TEXT_URL, TEXT_SHA256)
|
|
595
|
+
font = TTFont(io.BytesIO(font_bytes))
|
|
596
|
+
text_font = TTFont(io.BytesIO(text_bytes))
|
|
597
|
+
data = extract(font)
|
|
598
|
+
data.update(textXHeight=text_font["OS/2"].sxHeight, textUnits=text_font["head"].unitsPerEm)
|
|
599
|
+
notice = (font["name"].getDebugName(0) + "\n\n"
|
|
600
|
+
"Rapier math font data, derived from Latin Modern Math 1.959.\n"
|
|
601
|
+
"Changes: SVG outline conversion, character and MATH data extraction,\n"
|
|
602
|
+
"optical-form closure, derived contour profiles and lossless path packing.\n"
|
|
603
|
+
"Original outlines are unchanged. Hinting and text shaping tables are omitted.\n"
|
|
604
|
+
"The font authors do not maintain this conversion.\n"
|
|
605
|
+
"Complete original work: https://mirrors.ctan.org/fonts/lm-math.zip\n"
|
|
606
|
+
"Conversion details: FONT-SOURCE.md.\n\n").encode("utf-8") + gust + b"\n" + lppl
|
|
607
|
+
pages, index, size, packing = pack_font(data)
|
|
608
|
+
glyph_count = len(data["glyphs"])
|
|
609
|
+
profile_count = len(data["edgeProfiles"])
|
|
610
|
+
character_count = len(data["chars"])
|
|
611
|
+
del data["glyphs"], data["edgeProfiles"]
|
|
612
|
+
for name in (*SCALAR_MAPS, "scriptAlternates"):
|
|
613
|
+
del data[name]
|
|
614
|
+
output = (b"/*!\n" + notice + b"\n*/\nconst MATH_FONT=" + json.dumps(data, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b";\n" + PATH_DECODER.replace("__PAGES__", json.dumps(pages, separators=(",", ":"))).replace("__INDEX__", json.dumps(index)).replace("__SIZE__", str(size)).replace("__MAP_FIELDS__", json.dumps(SCALAR_MAPS, separators=(",", ":"))).encode("utf-8"))
|
|
615
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
616
|
+
args.out.write_bytes(output)
|
|
617
|
+
args.out.with_name("FONT-LICENSE.txt").write_bytes(notice)
|
|
618
|
+
print(json.dumps({"sourceSha256": hashlib.sha256(font_bytes).hexdigest(), "licenseSha256": hashlib.sha256(notice).hexdigest(), "glyphs": glyph_count, "characters": character_count, "edgeProfiles": profile_count, "packing": packing, "bytes": len(output), "gzipBytes": len(gzip.compress(output, compresslevel=9, mtime=0))}, indent=2))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
if __name__ == "__main__":
|
|
622
|
+
main()
|