rapier-math 1.0.1 → 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.
@@ -82,7 +82,7 @@ vertical axis. Measurements are in design units unless specified otherwise.
82
82
  | `units`, `xHeight` | Math units per em and nominal x-height. |
83
83
  | `textUnits`, `textXHeight` | Geist units per em and nominal x-height. |
84
84
  | `chars` | Decimal Unicode code point to glyph index. |
85
- | `glyphs` | `[advance, xMin, yMin, xMax, yMax, italicCorrection, topAccent, path]`. |
85
+ | `glyph(id)` | Demand-decoded `[advance, xMin, yMin, xMax, yMax, italicCorrection, topAccent, path]`. Missing IDs return `undefined`. |
86
86
  | `constants` | All 56 original OpenType `MathConstants`; percentage constants remain percentages. |
87
87
  | `vertical`, `horizontal` | Glyph index to `{variants, assembly?}`. |
88
88
  | `variants` | `[glyphIndex, advanceMeasurement]` pairs in font order. |
@@ -94,7 +94,7 @@ vertical axis. Measurements are in design units unless specified otherwise.
94
94
  | `flatAccents`, `dotless` | Alternate glyph indices for `flac` and `dtls`. |
95
95
  | `extendedShapes` | Glyphs marked by `MathGlyphInfo.ExtendedShapeCoverage`. |
96
96
  | `edgeStep` | Height of each contour strip: 32 design units. |
97
- | `edgeProfiles` | Glyph index to `[y0, [xMin, xMax], null, ...]`; each following entry describes one strip. |
97
+ | `profile(id)` | Demand-decoded `[y0, [xMin, xMax], null, ...]` for a glyph, or `undefined` when absent; each following entry describes one strip. |
98
98
 
99
99
  Outline bounds round outward to whole units. The outlines themselves are unchanged, including exact fractional
100
100
  coordinates if present. Missing italic corrections are zero; missing accent attachments use half the advance.
@@ -107,10 +107,36 @@ dotless forms. Entry `i + 1` bounds the original contour within `[y0 + i * edgeS
107
107
  with a small numerical guard. Profiles are derived spacing data; they do not replace outlines or pretend to be
108
108
  an original MATH kern table. Layout compares intersecting strips from both glyphs when placing scripts.
109
109
 
110
- Glyph metrics use signed coordinate deltas. Outlines share command structures and exact coordinate differences.
111
- Profiles share matching strip structures and encode edge differences. A final raw DEFLATE stream stores these
112
- bytes. The small synchronous decoder reconstructs the original arrays and SVG paths once, checking input and
113
- output bounds. It requires no network, font installation, font parser or platform font shaping at runtime.
110
+ Character mappings, optical substitutions and record offsets occupy a compact index decoded at module load.
111
+ Glyph metrics, outlines and contour profiles stay in independent raw DEFLATE pages until requested. Every page
112
+ ends at a record boundary and contains at most 16,384 decoded bytes. The complete set of pages travels in the
113
+ same asset; rare characters, stretch parts and optical forms require no additional download.
114
+
115
+ Outlines share exactly matching contours after translation. The 8,115 ordered contour references use 4,970
116
+ unique contour records. A record either stores its original command structure and relative coordinates, or
117
+ exact differences from an earlier contour with the same structure. Parent chains have at most four links.
118
+ The generator prefers small coordinate differences as well as short records, because the smallest initial
119
+ record does not necessarily produce the smallest compressed page. Glyph assembly restores the original
120
+ absolute `M/L/H/V/C/Z` paths, including contour order; existing outline-part consumers use those commands.
121
+
122
+ Glyph metrics use reversible bearing and half-advance predictors with a median origin per metric field.
123
+ They retain the complete original bounds, advance, italic and accent measurements. Signed record integers
124
+ use zigzag values: values below 240 take one byte, the next 3,840 values take two bytes, and larger values use
125
+ an escape followed by unsigned LEB128. The coordinate unit is exact; this encoding performs no quantization.
126
+
127
+ Profiles retain the original left/right strip coordinates and exact differences from matching strip structures.
128
+ Identical arrays share storage: all 1,187 glyph-to-profile mappings are represented by 1,080 unique records.
129
+ The renderer only asks for profiles when side-script placement can use them.
130
+
131
+ The font owns three bounded least-recently-used caches: eight inflated pages, 256 completed glyph records and
132
+ 128 contour profiles. Temporary parent coordinates and page views are discarded after a glyph is assembled;
133
+ cached glyphs contain the completed path and metrics. Reusing an evicted record reconstructs the same data.
134
+ These caches contain font data only. They retain no formula source, parse tree, layout result, SVG element or
135
+ document. Returned internal records are read-only to consumers.
136
+
137
+ The synchronous decoder checks compressed input, output size, record boundaries and parent depth. It requires
138
+ no network, font installation, font parser or platform font shaping at runtime. `glyph` and `profile` are
139
+ internal font accessors; the public parser and rendering API remain independent of this storage format.
114
140
 
115
141
  The format follows the [OpenType MATH specification](https://learn.microsoft.com/en-us/typography/opentype/spec/math)
116
142
  and [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951). Three retained constants have no active consumer:
package/src/math/api.js CHANGED
@@ -6,7 +6,8 @@ function mathSvg(box,source,error,utf16,options={}) {
6
6
  const viewBox=[left*1000,-height*1000,width*1000,(height+depth)*1000];
7
7
  const opacity=options.screen?.dark ? .94 : 1;
8
8
  if (![width,height,depth,left,...viewBox].every(Number.isFinite)) throw new Error('Mathematical dimensions are not finite');
9
- const svg='<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="'+mathEscape(source)+'"'+
9
+ const escapedSource=mathEscape(source);
10
+ const svg='<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="'+escapedSource+'"'+
10
11
  ' data-rapier-math="'+(options.displayMode?'display':'inline')+'" data-math-lines="'+(box.lines || 1)+'"'+
11
12
  ' data-math-natural-width="'+mathNumber(options.naturalWidth ?? width)+'"'+
12
13
  (options.screen?' data-math-screen="'+mathNumber(options.screen.fontSize)+'|'+mathNumber(options.screen.pixelRatio)+'|'+options.screen.dark+'"':'')+
@@ -16,7 +17,7 @@ function mathSvg(box,source,error,utf16,options={}) {
16
17
  ' viewBox="'+viewBox.map(mathNumber).join(' ')+'"'+
17
18
  ' style="font-size:'+mathNumber(mathFontRatio)+'em;max-width:100%;height:auto;vertical-align:-'+mathNumber(depth)+'em;'+
18
19
  'fill-opacity:'+opacity+';stroke-opacity:'+opacity+'" fill="currentColor">'+
19
- '<title>'+mathEscape(source)+'</title>'+(error?'<desc>'+mathEscape(error.message)+'</desc>':'')+
20
+ '<title>'+escapedSource+'</title>'+(error?'<desc>'+mathEscape(error.message)+'</desc>':'')+
20
21
  '<g transform="scale(1000)">'+box.svg+'</g></svg>';
21
22
  return {svg,width,height,depth,lines:box.lines || 1,fitted:!!box.fitted,errors:error?[error]:[]};
22
23
  }
@@ -77,7 +78,7 @@ function renderMath(source,options={}) {
77
78
  }
78
79
 
79
80
  globalThis.RapierMath=Object.freeze({
80
- version:'1.0.1',
81
+ version:'1.0.2',
81
82
  parse:parseMathTex,
82
83
  render:renderMath,
83
84
  fit:mathFit,
@@ -116,35 +116,66 @@ function mathFit(root) {
116
116
  return nodes.length;
117
117
  }
118
118
 
119
+ function mathObserveDocument(doc) {
120
+ const view=doc.defaultView,nodes=new Map(),columns=new Set();
121
+ let frame=null,active=true,printing=view.matchMedia('print').matches;
122
+ const refresh=()=> {
123
+ if(!active)return;
124
+ if(frame!==null)view.cancelAnimationFrame(frame);
125
+ frame=null;
126
+ const wanted=new Set();
127
+ for(const svg of nodes.keys()) {
128
+ if(!svg.isConnected || svg.ownerDocument!==doc || !svg.matches(mathSvgSelector))continue;
129
+ const column=mathFitElement(svg,printing);if(column)wanted.add(column);
130
+ }
131
+ for(const column of columns)if(!wanted.has(column)) {resize.unobserve(column);columns.delete(column);}
132
+ for(const column of wanted)if(!columns.has(column)) {resize.observe(column);columns.add(column);}
133
+ };
134
+ const schedule=()=>{if(active && frame===null)frame=view.requestAnimationFrame(refresh);};
135
+ const resize=new view.ResizeObserver(schedule),theme=new view.MutationObserver(records=>{
136
+ for(const {target} of records)for(const svg of nodes.keys())
137
+ if(target!==svg && target.contains(svg)) {schedule();return;}
138
+ });
139
+ const beforePrint=()=>{printing=true;refresh();},afterPrint=()=>{printing=false;schedule();};
140
+ const media=['(forced-colors: active)','(prefers-contrast: more)','(prefers-color-scheme: dark)'].map(query=>view.matchMedia(query));
141
+ const fonts=doc.fonts;
142
+ theme.observe(doc.documentElement,{subtree:true,attributes:true,attributeFilter:['class','style','data-rapier-theme','data-md-theme']});
143
+ view.addEventListener('resize',schedule,{passive:true});
144
+ view.addEventListener('beforeprint',beforePrint);view.addEventListener('afterprint',afterPrint);
145
+ for(const query of media)query.addEventListener('change',schedule);
146
+ fonts?.addEventListener('loadingdone',schedule);
147
+ const state={nodes,schedule,dispose:()=>{
148
+ active=false;
149
+ if(frame!==null)view.cancelAnimationFrame(frame);
150
+ frame=null;
151
+ resize.disconnect();theme.disconnect();columns.clear();nodes.clear();
152
+ view.removeEventListener('resize',schedule);
153
+ view.removeEventListener('beforeprint',beforePrint);view.removeEventListener('afterprint',afterPrint);
154
+ for(const query of media)query.removeEventListener('change',schedule);
155
+ fonts?.removeEventListener('loadingdone',schedule);
156
+ mathBrowserDocuments.delete(doc);
157
+ }};
158
+ mathBrowserDocuments.set(doc,state);
159
+ return state;
160
+ }
161
+
119
162
  function mathObserve(root) {
120
- const doc=root?.ownerDocument || root,view=doc?.defaultView;
121
- if(!view)return ()=>{};
122
- let state=mathBrowserDocuments.get(doc);
123
- if(!state) {
124
- const nodes=new Map(),columns=new Set();let frame=0,printing=false;
125
- const refresh=()=> {
126
- frame=0;
127
- for(const [svg,column] of nodes) {
128
- if(!svg.isConnected) {nodes.delete(svg);continue;}
129
- const next=mathFitElement(svg,printing);if(next)nodes.set(svg,next);
130
- }
131
- const wanted=new Set(nodes.values());
132
- for(const column of columns)if(!wanted.has(column)) {resize.unobserve(column);columns.delete(column);}
133
- for(const column of wanted)if(!columns.has(column)) {resize.observe(column);columns.add(column);}
134
- };
135
- const schedule=()=>{if(!frame)frame=view.requestAnimationFrame(refresh);};
136
- const resize=new view.ResizeObserver(schedule),theme=new view.MutationObserver(records=>{
137
- if(records.some(({target})=>[...nodes.keys()].some(svg=>target!==svg && target.contains(svg))))schedule();
138
- });
139
- theme.observe(doc.documentElement,{subtree:true,attributes:true,attributeFilter:['class','style','data-rapier-theme','data-md-theme']});
140
- view.addEventListener('resize',schedule,{passive:true});
141
- view.addEventListener('beforeprint',()=>{printing=true;refresh();});view.addEventListener('afterprint',()=>{printing=false;schedule();});
142
- for(const query of ['(forced-colors: active)','(prefers-contrast: more)','(prefers-color-scheme: dark)'])view.matchMedia(query).addEventListener('change',schedule);
143
- doc.fonts?.addEventListener('loadingdone',schedule);
144
- state={nodes,schedule};mathBrowserDocuments.set(doc,state);
145
- }
146
- const nodes=mathElements(root);
147
- for(const svg of nodes)state.nodes.set(svg,mathColumn(svg));
163
+ const doc=root?.ownerDocument || root;
164
+ if(!doc?.defaultView)return ()=>{};
165
+ let owned=mathElements(root);
166
+ if(!owned.length)return ()=>{};
167
+ let state=mathBrowserDocuments.get(doc) || mathObserveDocument(doc);
168
+ // Each returned cleanup owns its subscription, including overlap and detached nodes.
169
+ for(const svg of owned)state.nodes.set(svg,(state.nodes.get(svg) || 0)+1);
148
170
  state.schedule();
149
- return ()=>{for(const svg of nodes)state.nodes.delete(svg);state.schedule();};
171
+ return ()=>{
172
+ if(!owned)return;
173
+ const current=state;state=null;
174
+ for(const svg of owned) {
175
+ const remaining=current.nodes.get(svg)-1;
176
+ if(remaining)current.nodes.set(svg,remaining);else current.nodes.delete(svg);
177
+ }
178
+ owned=null;
179
+ if(current.nodes.size)current.schedule();else current.dispose();
180
+ };
150
181
  }
@@ -257,21 +257,39 @@ def edge_profile(glyph_set, name):
257
257
  return [first, *([math.floor(min(strip) - 1e-7), math.ceil(max(strip) + 1e-7)] if strip else None for strip in strips)]
258
258
 
259
259
 
260
- def pack_paths(glyphs, profiles):
261
- """Store exact coordinate deltas, sharing matching outline structures."""
262
- output = bytearray()
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)
263
271
 
264
272
  def unsigned(value, target):
273
+ if value < 0 or value > 2**53 - 1:
274
+ raise ValueError("Font integer exceeds exact JavaScript arithmetic")
265
275
  while value >= 128:
266
276
  target.append((value & 127) | 128)
267
277
  value >>= 7
268
278
  target.append(value)
269
279
 
270
280
  def signed(value, target):
271
- unsigned(value * 2 if value >= 0 else -value * 2 - 1, 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)
272
292
 
273
- commands = "MLHVCZ"
274
- lengths = (2, 2, 1, 1, 6, 0)
275
293
  paths = [re.findall(r"[MLHVCZ]|-?\d+(?:\.\d+)?", glyph[7]) for glyph in glyphs]
276
294
  unit = 1
277
295
  for glyph, tokens in zip(glyphs, paths):
@@ -280,21 +298,27 @@ def pack_paths(glyphs, profiles):
280
298
  for token in tokens:
281
299
  if "." in token:
282
300
  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
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
+
298
322
  while cursor < len(tokens):
299
323
  if tokens[cursor] in commands:
300
324
  command = commands.index(tokens[cursor])
@@ -303,52 +327,70 @@ def pack_paths(glyphs, profiles):
303
327
  command = 1 if previous == 0 else previous
304
328
  else:
305
329
  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:
330
+ count = lengths[command]
331
+ values = [int(Fraction(value) * unit) for value in tokens[cursor:cursor + count]]
332
+ if len(values) != count:
309
333
  raise ValueError("Font path has an incomplete command")
310
334
  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))
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
314
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):
315
360
  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:
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
335
371
  shared = bytearray()
336
372
  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 = {}
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 = {}
349
386
  for glyph_id, profile in sorted(profiles.items()):
350
- unsigned(glyph_id - previous_id - 1, output)
351
- previous_id = glyph_id
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
352
394
  record = bytearray()
353
395
  unsigned((len(profile) - 1) * 2, record)
354
396
  signed(profile[0], record)
@@ -362,7 +404,10 @@ def pack_paths(glyphs, profiles):
362
404
  signed(strip[1] - right, record)
363
405
  left, right = strip
364
406
  shape = profile[0], tuple(strip is not None for strip in profile[1:])
407
+ parent_depth = -1
365
408
  for parent_id, parent in profile_shapes.setdefault(shape, []):
409
+ if profile_depths[parent_id] >= PARENT_DEPTH:
410
+ continue
366
411
  shared = bytearray()
367
412
  unsigned(parent_id * 2 + 1, shared)
368
413
  left = right = 0
@@ -376,18 +421,80 @@ def pack_paths(glyphs, profiles):
376
421
  if len(shared) >= len(record):
377
422
  break
378
423
  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)
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
+ }
384
490
 
385
491
 
386
492
  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;}};
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;}};
391
498
  const bits=count=>{fill(count);if(available<count)invalid();const value=held&((1<<count)-1);held>>>=count;available-=count;return value;};
392
499
  const tree=lengths=>{const width=Math.max(...lengths),counts=new Uint16Array(width+1),next=new Uint16Array(width+1),table=new Uint16Array(1<<width);
393
500
  for(const length of lengths)if(length)counts[length]++;let code=0;
@@ -414,31 +521,64 @@ const code=value-257,extra=code<8?0:(code>>>2)-1,length=code===28?258:code<8?cod
414
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);
415
522
  if(back>written||written+length>bytes.length)invalid();for(let i=0;i<length;i++){bytes[written]=bytes[written-back];written++;}}
416
523
  }
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();
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;};
438
577
  }
439
578
  """
440
579
 
441
580
 
581
+
442
582
  def main():
443
583
  parser = argparse.ArgumentParser(description=__doc__)
444
584
  parser.add_argument("--font", type=Path, help="Optional pinned source font; otherwise fetch it")
@@ -464,16 +604,18 @@ def main():
464
604
  "The font authors do not maintain this conversion.\n"
465
605
  "Complete original work: https://mirrors.ctan.org/fonts/lm-math.zip\n"
466
606
  "Conversion details: FONT-SOURCE.md.\n\n").encode("utf-8") + gust + b"\n" + lppl
467
- paths, size = pack_paths(data["glyphs"], data["edgeProfiles"])
607
+ pages, index, size, packing = pack_font(data)
468
608
  glyph_count = len(data["glyphs"])
469
609
  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"))
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"))
473
615
  args.out.parent.mkdir(parents=True, exist_ok=True)
474
616
  args.out.write_bytes(output)
475
617
  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))
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))
477
619
 
478
620
 
479
621
  if __name__ == "__main__":