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.
@@ -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()