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.
@@ -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
+ }