@overpunch/vf-clamp 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createRequire as j } from "node:module";
2
- const R = j(import.meta.url), { preparePyodide: d, PyodideFile: x } = R("@web-alchemy/fonttools/src/pyodide.js");
3
- let T = null;
2
+ import { findInstance as F, rangeName as E } from "./naming.js";
3
+ import { compactName as st, numberKey as rt, planOutputs as lt, unboughtInstances as ft } from "./naming.js";
4
+ const P = j(import.meta.url), { preparePyodide: f, PyodideFile: p } = P("@web-alchemy/fonttools/src/pyodide.js");
5
+ let y = null;
4
6
  async function M() {
5
- return T || (T = d().then(
7
+ return y || (y = f().then(
6
8
  (t) => t.runPythonAsync(`
7
9
  from fontTools.ttLib import TTFont
8
10
 
@@ -13,38 +15,38 @@ def vf_convert_flavor(file_options):
13
15
 
14
16
  vf_convert_flavor
15
17
  `)
16
- )), T;
18
+ )), y;
17
19
  }
18
- async function P(t, o) {
19
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
20
+ async function O(t, o) {
21
+ const a = await f(), n = new p({ pyodide: a }), e = new p({ pyodide: a });
20
22
  try {
21
- await e.upload(t);
23
+ await n.upload(t);
22
24
  const i = /* @__PURE__ */ new Map([
23
- ["input-file", e.filename],
24
- ["output-file", n.filename],
25
+ ["input-file", n.filename],
26
+ ["output-file", e.filename],
25
27
  ["flavor", o]
26
28
  ]);
27
- return (await M())(i), n.download();
29
+ return (await M())(i), e.download();
28
30
  } finally {
29
31
  try {
30
- e.delete();
32
+ n.delete();
31
33
  } catch {
32
34
  }
33
35
  try {
34
- n.delete();
36
+ e.delete();
35
37
  } catch {
36
38
  }
37
39
  }
38
40
  }
39
- async function L(t) {
40
- return P(t, "woff");
41
+ async function U(t) {
42
+ return O(t, "woff");
41
43
  }
42
- async function $(t) {
43
- return P(t, "woff2");
44
+ async function q(t) {
45
+ return O(t, "woff2");
44
46
  }
45
- let b = null;
46
- async function k() {
47
- return b || (b = d().then(
47
+ let v = null;
48
+ async function W() {
49
+ return v || (v = f().then(
48
50
  (t) => t.runPythonAsync(`
49
51
  import json
50
52
  from fontTools.ttLib import TTFont
@@ -52,8 +54,9 @@ from fontTools.ttLib import TTFont
52
54
  def get_instances_fn(input_file):
53
55
  font = TTFont(input_file)
54
56
 
57
+ family = font['name'].getDebugName(16) or font['name'].getDebugName(1) or ''
55
58
  if 'fvar' not in font:
56
- return json.dumps({'axes': [], 'instances': []})
59
+ return json.dumps({'axes': [], 'instances': [], 'family': family})
57
60
 
58
61
  fvar = font['fvar']
59
62
  name_table = font['name']
@@ -77,86 +80,442 @@ def get_instances_fn(input_file):
77
80
  for i, inst in enumerate(fvar.instances)
78
81
  ]
79
82
 
80
- return json.dumps({'axes': axes, 'instances': instances})
83
+ labels = {}
84
+ if 'STAT' in font and font['STAT'].table.AxisValueArray:
85
+ stat = font['STAT'].table
86
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
87
+ for av in stat.AxisValueArray.AxisValue:
88
+ if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
89
+ value = av.NominalValue if av.Format == 2 else av.Value
90
+ key = ('%f' % float(value)).rstrip('0').rstrip('.')
91
+ labels.setdefault(tags[av.AxisIndex], {})[key] = name_table.getDebugName(av.ValueNameID) or ''
92
+
93
+ return json.dumps({'axes': axes, 'instances': instances, 'family': family, 'labels': labels})
81
94
 
82
95
  get_instances_fn
83
96
  `)
84
- )), b;
97
+ )), v;
85
98
  }
86
- async function E(t) {
87
- const o = await d(), a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, e = new x({ pyodide: o });
99
+ async function z(t) {
100
+ const o = await f(), a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, n = new p({ pyodide: o });
88
101
  try {
89
- await e.upload(a);
90
- const i = (await k())(e.filename);
102
+ await n.upload(a);
103
+ const i = (await W())(n.filename);
91
104
  return JSON.parse(i);
92
105
  } finally {
93
106
  try {
94
- e.delete();
107
+ n.delete();
95
108
  } catch {
96
109
  }
97
110
  }
98
111
  }
99
- let F = null, S = null, V = null, I = null, D = null;
100
- async function U() {
101
- return F || (F = d().then(
112
+ const $ = `# vfclamp_naming.py — the single implementation of vf-clamp's naming, style-bit and STAT clean-up rules (docs/NAMING.md), shared by the npm package (run in Pyodide), the Glyphs plugin and the RoboFont extension.
113
+ #
114
+ # Canonical copy: vfClamp/shared/plugin-views/vfclamp_naming.py. The plugins get byte-identical copies via
115
+ # \`npm run sync-plugin-views\`; the npm package imports this file's text at build time. Never edit a copy.
116
+ # Pure fontTools: no Glyphs, RoboFont or AppKit imports.
117
+
118
+ import re
119
+ import unicodedata
120
+
121
+ #: OS/2.fsSelection bits this module owns.
122
+ FS_ITALIC = 0x01
123
+ FS_BOLD = 0x20
124
+ FS_REGULAR = 0x40
125
+ FS_OBLIQUE = 0x200
126
+ #: head.macStyle bits this module owns.
127
+ MAC_BOLD = 0x01
128
+ MAC_ITALIC = 0x02
129
+
130
+ #: Label for an axis value that has no word in the font's style names (e.g. wdth 100 in "Light").
131
+ DEFAULT_AXIS_WORDS = {'wght': 'Regular', 'wdth': 'Normal', 'ital': 'Upright', 'slnt': 'Upright'}
132
+
133
+ #: The four style-linking (RIBBI) subfamily names.
134
+ RIBBI = ('Regular', 'Italic', 'Bold', 'Bold Italic')
135
+
136
+
137
+ def short_hash(text):
138
+ """Six-character FNV-1a hash of a string, base 36; identical to shortHash() in the npm package."""
139
+ h = 0x811c9dc5
140
+ for ch in text:
141
+ h ^= ord(ch)
142
+ h = (h * 0x01000193) & 0xFFFFFFFF
143
+ digits = '0123456789abcdefghijklmnopqrstuvwxyz'
144
+ out = ''
145
+ while h:
146
+ h, rem = divmod(h, 36)
147
+ out = digits[rem] + out
148
+ return (out or '0').rjust(6, '0')[-6:]
149
+
150
+
151
+ def ps_name(text, max_len=63):
152
+ """PostScript name (nameID 6): accents transliterated, [A-Za-z0-9-] only, at most 63 characters.
153
+
154
+ Over 63 characters, a readable prefix plus a hash of the full text keeps names unique. Text with
155
+ letters in another script becomes 'Font-<hash>' (never the source font's name); text with no
156
+ letters at all becomes 'Font'.
157
+ """
158
+ ascii_text = unicodedata.normalize('NFKD', text or '').encode('ascii', 'ignore').decode('ascii')
159
+ s = re.sub(r'[^A-Za-z0-9 -]', '', ascii_text).strip()
160
+ s = re.sub(r'\\s+', '-', s)
161
+ s = re.sub(r'-+', '-', s).strip('-')
162
+ if not s:
163
+ return f'Font-{short_hash(text)}' if any(c.isalpha() for c in (text or '')) else 'Font'
164
+ if len(s) > max_len:
165
+ s = f'{s[:max_len - 7].rstrip("-")}-{short_hash(text)}'
166
+ return s
167
+
168
+
169
+ def vf_prefix(ps, family):
170
+ """Variations PostScript Name Prefix (nameID 25): ASCII letters and digits only (OpenType spec), at most 27."""
171
+ alnum = re.sub(r'[^A-Za-z0-9]', '', ps)
172
+ if not alnum:
173
+ return 'Font'
174
+ if len(alnum) > 27:
175
+ return alnum[:21] + short_hash(family)
176
+ return alnum
177
+
178
+
179
+ def full_name(family, style):
180
+ """Full name (nameID 4): family plus style, leaving out 'Regular' and a style the family name already ends with."""
181
+ if not style or style == 'Regular' or family.lower().endswith(style.lower()):
182
+ return family
183
+ return f'{family} {style}'
184
+
185
+
186
+ def number_key(value):
187
+ """Stable text key for an axis value, identical in Python and JavaScript ('100', '87.5')."""
188
+ return ('%f' % float(value)).rstrip('0').rstrip('.')
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Inputs read from the source font (before instancing)
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def source_style_info(font):
196
+ """What the style rules need to know about the source: its italic/oblique bits and whether ital/slnt exist."""
197
+ fs = font['OS/2'].fsSelection if 'OS/2' in font else 0
198
+ tags = {ax.axisTag for ax in font['fvar'].axes} if 'fvar' in font else set()
199
+ return {'italic': bool(fs & FS_ITALIC), 'oblique': bool(fs & FS_OBLIQUE), 'has_ital_slnt': bool(tags & {'ital', 'slnt'})}
200
+
201
+
202
+ def naming_inputs(font):
203
+ """Instances, axes and STAT labels of a source font, in the shape range_name() takes."""
204
+ name = font['name']
205
+ axes = [{'tag': ax.axisTag, 'default': ax.defaultValue} for ax in font['fvar'].axes] if 'fvar' in font else []
206
+ instances = [
207
+ {'name': name.getDebugName(inst.subfamilyNameID) or '', 'coordinates': dict(inst.coordinates)}
208
+ for inst in (font['fvar'].instances if 'fvar' in font else [])
209
+ ]
210
+ labels = {}
211
+ if 'STAT' in font and font['STAT'].table.AxisValueArray:
212
+ stat = font['STAT'].table
213
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
214
+ for av in stat.AxisValueArray.AxisValue:
215
+ if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
216
+ value = av.NominalValue if av.Format == 2 else av.Value
217
+ labels.setdefault(tags[av.AxisIndex], {})[number_key(value)] = name.getDebugName(av.ValueNameID) or ''
218
+ return {'instances': instances, 'axes': axes, 'labels': labels}
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Default output name for a selection
223
+ # ---------------------------------------------------------------------------
224
+
225
+ def _words(text):
226
+ """Whitespace-separated words of a style name."""
227
+ return [w for w in (text or '').split() if w]
228
+
229
+
230
+ def range_name(selected, instances, axes, labels=None):
231
+ """Default name for a selection of named instances: one range per axis, in the font's own style words.
232
+
233
+ Each word in the font's style names is attributed to the one axis that is constant wherever the word
234
+ appears and varies across the font ("SemiCondensed" -> wdth, "Thin" -> wght). An axis that varies in
235
+ the selection becomes "low-high" ("SemiCondensed-Normal", "Thin-Light"); an axis that doesn't shows
236
+ its word once, or nothing when that value has no word. A value with no word (wdth 100 in "Light")
237
+ takes its STAT name ("Normal"), else a default word, else "<tag><value>". Parts follow the order the
238
+ words appear in the font's names; words shared by every selected name and owned by no axis (a family prefix,
239
+ "Italic" in an italic-only font) stay in place before or after them.
240
+
241
+ \`\`selected\`\`/\`\`instances\`\`: dicts with 'name' and 'coordinates'; \`\`axes\`\`: dicts with 'tag' and
242
+ 'default'; \`\`labels\`\`: {tag: {number_key(value): name}} from STAT. Mirrored by rangeName() in
243
+ TypeScript; both are checked against shared/naming-cases.json.
244
+ """
245
+ if not selected:
246
+ return ''
247
+ if len(selected) == 1:
248
+ return selected[0]['name']
249
+ labels = labels or {}
250
+ defaults = {a['tag']: a['default'] for a in axes}
251
+ tags = [a['tag'] for a in axes]
252
+
253
+ def coord(inst, tag):
254
+ return inst['coordinates'].get(tag, defaults.get(tag))
255
+
256
+ varying = [t for t in tags if len({coord(i, t) for i in instances}) > 1]
257
+
258
+ owner_cache = {}
259
+
260
+ def owner(word):
261
+ if word not in owner_cache:
262
+ having = [i for i in instances if word in _words(i['name'])]
263
+ cands = [t for t in varying if len({coord(i, t) for i in having}) == 1] if having else []
264
+ owner_cache[word] = cands[0] if len(cands) == 1 else None
265
+ return owner_cache[word]
266
+
267
+ def label(tag, value):
268
+ at = [i for i in instances if coord(i, tag) == value]
269
+ if not at:
270
+ return ''
271
+ common = [w for w in _words(at[0]['name']) if owner(w) == tag and all(w in _words(i['name']) for i in at)]
272
+ return ' '.join(common)
273
+
274
+ def fallback(tag, value):
275
+ stat = labels.get(tag, {}).get(number_key(value))
276
+ if stat:
277
+ return stat
278
+ return DEFAULT_AXIS_WORDS.get(tag) or f'{tag}{number_key(value)}'
279
+
280
+ # Axis order follows the font's own names: positions come from names carrying words for two or more
281
+ # axes ("SemiCondensed Thin" puts width before weight); a plain "Thin" says nothing about order.
282
+ def owned_axes(inst):
283
+ return {owner(w) for w in _words(inst['name'])} - {None}
284
+
285
+ rich = [i for i in instances if len(owned_axes(i)) > 1] or instances
286
+
287
+ def axis_position(tag):
288
+ found = [k for i in rich for k, w in enumerate(_words(i['name'])) if owner(w) == tag]
289
+ return min(found) if found else 10_000
290
+
291
+ parts = []
292
+ for order, tag in enumerate(tags):
293
+ values = sorted({coord(i, tag) for i in selected})
294
+ lo, hi = values[0], values[-1]
295
+ if lo == hi:
296
+ text = label(tag, lo)
297
+ if not text:
298
+ continue
299
+ else:
300
+ a = label(tag, lo) or fallback(tag, lo)
301
+ b = label(tag, hi) or fallback(tag, hi)
302
+ text = a if a == b else f'{a}-{b}'
303
+ parts.append((axis_position(tag), order, text))
304
+ parts.sort()
305
+
306
+ first_words = _words(selected[0]['name'])
307
+ shared = [w for w in first_words if owner(w) is None and all(w in _words(i['name']) for i in selected)]
308
+ owned_positions = [k for k, w in enumerate(first_words) if owner(w) is not None]
309
+ first_owned = min(owned_positions) if owned_positions else len(first_words)
310
+ before = [w for w in shared if first_words.index(w) < first_owned]
311
+ after = [w for w in shared if first_words.index(w) >= first_owned]
312
+ name = ' '.join(before + [p[2] for p in parts] + after).strip()
313
+ return name or f'{selected[0]["name"]}-{selected[-1]["name"]}'
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Rules applied to the clamped font (after instancing)
318
+ # ---------------------------------------------------------------------------
319
+
320
+ _WIDTH_CLASSES = ((62.5, 1), (75.0, 2), (87.5, 3), (100.0, 4), (112.5, 5), (125.0, 6), (150.0, 7), (200.0, 8))
321
+
322
+
323
+ def location_after(font, pinned):
324
+ """The clamped font's default location: remaining fvar defaults plus the values of pinned axes."""
325
+ loc = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes} if 'fvar' in font else {}
326
+ for tag, value in (pinned or {}).items():
327
+ loc.setdefault(tag, value)
328
+ return loc
329
+
330
+
331
+ def unlink_out_of_range_stat(font, location):
332
+ """Turn STAT Format 3 values whose LinkedValue the clamped font can't reach into Format 1 (same name, no link)."""
333
+ if 'STAT' not in font or not font['STAT'].table.AxisValueArray:
334
+ return
335
+ stat = font['STAT'].table
336
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
337
+ ranges = {ax.axisTag: (ax.minValue, ax.maxValue) for ax in font['fvar'].axes} if 'fvar' in font else {}
338
+ for av in stat.AxisValueArray.AxisValue:
339
+ if av.Format != 3 or av.AxisIndex >= len(tags):
340
+ continue
341
+ tag = tags[av.AxisIndex]
342
+ if tag in ranges:
343
+ lo, hi = ranges[tag]
344
+ reachable = lo <= av.LinkedValue <= hi
345
+ elif tag in location:
346
+ reachable = av.LinkedValue == location[tag]
347
+ else:
348
+ continue
349
+ if not reachable:
350
+ av.Format = 1
351
+ del av.LinkedValue
352
+
353
+
354
+ def apply_style_bits(font, location, source):
355
+ """Set usWeightClass/usWidthClass, fsSelection and macStyle from the clamped font's default location.
356
+
357
+ BOLD from weight 700; ITALIC from ital >= 0.5 or slnt < 0 (OBLIQUE as well for a slant without ital);
358
+ with neither axis in the source, the source's italic/oblique bits are kept. REGULAR only when none of
359
+ BOLD, ITALIC or OBLIQUE is set. Returns (is_bold, is_italic).
360
+ """
361
+ if 'OS/2' not in font:
362
+ return False, False
363
+ os2 = font['OS/2']
364
+ wght, wdth = location.get('wght'), location.get('wdth')
365
+ if wght is not None:
366
+ os2.usWeightClass = max(1, min(1000, int(round(wght))))
367
+ if wdth is not None:
368
+ os2.usWidthClass = next((cls for edge, cls in _WIDTH_CLASSES if wdth < edge), 9)
369
+ if source.get('has_ital_slnt'):
370
+ ital, slnt = location.get('ital'), location.get('slnt')
371
+ is_italic = (ital is not None and ital >= 0.5) or (slnt is not None and slnt < 0)
372
+ is_oblique = slnt is not None and slnt < 0 and not (ital is not None and ital >= 0.5)
373
+ else:
374
+ is_italic, is_oblique = source.get('italic', False), source.get('oblique', False)
375
+ is_bold = os2.usWeightClass >= 700
376
+ fs = os2.fsSelection & ~(FS_ITALIC | FS_BOLD | FS_REGULAR | FS_OBLIQUE)
377
+ if is_italic or is_oblique:
378
+ fs |= FS_ITALIC
379
+ if is_oblique:
380
+ fs |= FS_OBLIQUE
381
+ if is_bold:
382
+ fs |= FS_BOLD
383
+ if not (is_bold or is_italic or is_oblique):
384
+ fs |= FS_REGULAR
385
+ os2.fsSelection = fs
386
+ if 'head' in font:
387
+ mac = font['head'].macStyle & ~(MAC_BOLD | MAC_ITALIC)
388
+ font['head'].macStyle = mac | (MAC_BOLD if is_bold else 0) | (MAC_ITALIC if (is_italic or is_oblique) else 0)
389
+ return is_bold, (is_italic or is_oblique)
390
+
391
+
392
+ def default_instance_style(font):
393
+ """Subfamily name of the named instance at the clamped font's default location, or None."""
394
+ if 'fvar' not in font:
395
+ return None
396
+ defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
397
+ for inst in font['fvar'].instances:
398
+ if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
399
+ return font['name'].getDebugName(inst.subfamilyNameID)
400
+ return None
401
+
402
+
403
+ def apply_names(font, family, style=None, keep_mac=True, version=None):
404
+ """Write name IDs 1, 2, 3, 4, 6, 16/17, 25 and the named instances' PostScript names. Run after apply_style_bits.
405
+
406
+ \`\`family\`\` is the output name; blank raises ValueError. \`\`style\`\` is the picked instance's name for a
407
+ single-style output; for a range it defaults to the instance at the new default location. nameID 2 is
408
+ the RIBBI style matching OS/2; a non-RIBBI style (SemiBold) goes into 16/17, and for a static file
409
+ into nameID 1 too ("Family SemiBold" + "Regular"), as the OpenType spec describes. A static file's
410
+ PostScript name includes its style so two pins never collide.
411
+ """
412
+ family = (family or '').strip()
413
+ if not family:
414
+ raise ValueError('vf-clamp: the output name is empty')
415
+ name_table = font['name']
416
+ existing = {r.nameID for r in name_table.names}
417
+ static = 'fvar' not in font
418
+ fs = font['OS/2'].fsSelection if 'OS/2' in font else FS_REGULAR
419
+ is_bold, is_italic = bool(fs & FS_BOLD), bool(fs & FS_ITALIC)
420
+ ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
421
+
422
+ typo = (style or '').strip() or default_instance_style(font) or ribbi
423
+ if is_italic and 'Italic' not in typo and 'Oblique' not in typo:
424
+ typo = 'Italic' if typo == 'Regular' else f'{typo} Italic'
425
+ core = ' '.join(w for w in typo.split() if w not in ('Italic', 'Oblique'))
426
+ non_ribbi = core not in ('', 'Regular', 'Bold')
427
+
428
+ name1 = family
429
+ if static and non_ribbi and not family.lower().endswith(core.lower()):
430
+ name1 = f'{family} {core}'
431
+ ps_base = family if not static or typo == 'Regular' or family.lower().endswith(typo.lower()) else f'{family} {typo}'
432
+ ps = ps_name(ps_base)
433
+ prefix = vf_prefix(ps, family)
434
+ if version is None:
435
+ try:
436
+ version = '%.3f' % font['head'].fontRevision
437
+ except Exception:
438
+ version = '1.000'
439
+
440
+ updates = {
441
+ 1: name1,
442
+ 2: ribbi,
443
+ 3: f'{version};{ps};{family}',
444
+ 4: full_name(family, typo if static else ribbi),
445
+ 6: ps,
446
+ }
447
+ if 16 in existing or 17 in existing or non_ribbi:
448
+ updates[16] = family
449
+ updates[17] = typo
450
+ if 25 in existing:
451
+ updates[25] = prefix
452
+ if 'fvar' in font:
453
+ for inst in font['fvar'].instances:
454
+ pid = getattr(inst, 'postscriptNameID', 0xFFFF)
455
+ if pid in (None, 0xFFFF) or pid in updates:
456
+ continue
457
+ inst_style = re.sub(r'[^A-Za-z0-9-]', '', (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', ''))
458
+ updates[pid] = f'{prefix}-{inst_style}'[:63]
459
+
460
+ english = {0, 0x0409}
461
+ kept = []
462
+ written = set()
463
+ for record in name_table.names:
464
+ if record.nameID not in updates:
465
+ kept.append(record)
466
+ continue
467
+ if record.platformID in (1, 3) and record.langID not in english:
468
+ continue # stale localised name for an ID we're rewriting
469
+ value = updates[record.nameID]
470
+ if record.platformID in (0, 3):
471
+ record.string = value.encode('utf-16-be')
472
+ elif record.platformID == 1:
473
+ if not keep_mac:
474
+ continue
475
+ try:
476
+ record.string = value.encode('mac_roman')
477
+ except (UnicodeEncodeError, LookupError):
478
+ continue # drop rather than write '?'
479
+ kept.append(record)
480
+ written.add((record.nameID, record.platformID))
481
+ name_table.names = kept
482
+ for name_id, value in updates.items():
483
+ if (name_id, 3) not in written:
484
+ name_table.setName(value, name_id, 3, 1, 0x0409)
485
+ return updates
486
+
487
+
488
+ def finish(font, family, style, pinned, source, keep_mac=True):
489
+ """Everything after instancing, in order: STAT links, style bits, names. Returns the name updates."""
490
+ location = location_after(font, pinned)
491
+ unlink_out_of_range_stat(font, location)
492
+ apply_style_bits(font, location, source)
493
+ return apply_names(font, family, style, keep_mac=keep_mac)
494
+ `;
495
+ let b = null, A = null, T = null, I = null;
496
+ function C() {
497
+ return b || (b = f().then(async (t) => {
498
+ await t.runPythonAsync($);
499
+ })), b;
500
+ }
501
+ async function G() {
502
+ return A || (A = C().then(() => f()).then(
102
503
  (t) => t.runPythonAsync(`
103
- from fontTools import ttLib
104
-
105
- def patch_font_names_fn(file_options):
106
- font = ttLib.TTFont(file_options['input-file'])
107
- name_table = font['name']
108
- family_name = file_options['family-name']
109
- ps_name = file_options['postscript-name']
110
-
111
- existing_ids = {r.nameID for r in name_table.names}
112
-
113
- # nameID 1 = Family, 4 = Full name, 6 = PostScript name
114
- # nameID 2 = Subfamily — reset to 'Regular' so the restricted file does not
115
- # collide with the source font's Subfamily in OS font caches.
116
- # nameID 3 = Unique ID — regenerate so it is distinct from the source font.
117
- # nameID 16 = Preferred family (update only if present)
118
- # nameID 25 = Variations PS Name Prefix (update only if present)
119
- #
120
- # head.fontRevision is a Fixed value (e.g. 1.000). Fall back to '1.000' if
121
- # the head table is unreadable.
122
- try:
123
- version = '%.3f' % font['head'].fontRevision
124
- except Exception:
125
- version = '1.000'
126
- unique_id = '%s;%s;%s' % (version, ps_name, family_name)
127
-
128
- updates = {
129
- 1: family_name,
130
- 2: 'Regular',
131
- 3: unique_id,
132
- 4: family_name,
133
- 6: ps_name,
134
- }
135
- if 16 in existing_ids:
136
- updates[16] = family_name
137
- if 25 in existing_ids:
138
- updates[25] = ps_name
139
-
140
- for record in name_table.names:
141
- if record.nameID not in updates:
142
- continue
143
- value = updates[record.nameID]
144
- if record.platformID == 3:
145
- record.string = value.encode('utf-16-be')
146
- elif record.platformID == 1:
147
- try:
148
- record.string = value.encode('mac_roman')
149
- except Exception:
150
- record.string = value.encode('ascii', errors='replace')
504
+ import json
505
+ from fontTools.ttLib import TTFont
151
506
 
507
+ def vf_clamp_finish(file_options, opts_json):
508
+ opts = json.loads(opts_json)
509
+ font = TTFont(file_options['input-file'])
510
+ finish(font, opts['family'], opts.get('style'), opts.get('pinned') or {}, opts['source'])
152
511
  font.save(file_options['output-file'])
153
512
 
154
- patch_font_names_fn
513
+ vf_clamp_finish
155
514
  `)
156
- )), F;
515
+ )), A;
157
516
  }
158
- async function C() {
159
- return S || (S = d().then(
517
+ async function Q() {
518
+ return T || (T = C().then(() => f()).then(
160
519
  (t) => t.runPythonAsync(`
161
520
  from fontTools.ttLib import TTFont
162
521
  from fontTools.varLib import instancer
@@ -166,6 +525,12 @@ def vf_clamp_instantiate(file_options, axes_json):
166
525
  font = TTFont(file_options['input-file'])
167
526
  axes_spec = json.loads(axes_json)
168
527
 
528
+ # The bundled fontTools can't restrict avar version 2 or VARC fonts correctly: refuse instead of mis-cutting.
529
+ if 'avar' in font and getattr(font['avar'], 'majorVersion', 1) >= 2:
530
+ raise ValueError('vf-clamp: avar version 2 fonts are not supported yet')
531
+ if 'VARC' in font:
532
+ raise ValueError('vf-clamp: fonts with a VARC table are not supported yet')
533
+
169
534
  limits = {}
170
535
  axes_by_tag = {ax.axisTag: ax for ax in font['fvar'].axes}
171
536
 
@@ -182,15 +547,19 @@ def vf_clamp_instantiate(file_options, axes_json):
182
547
  else:
183
548
  limits[tag] = float(spec)
184
549
 
550
+ # The style rules need the source's italic bits and which axes were pinned (they leave fvar).
551
+ source = source_style_info(font)
552
+ pinned = {tag: value for tag, value in limits.items() if isinstance(value, float)}
185
553
  partial = instancer.instantiateVariableFont(font, limits)
186
554
  partial.save(file_options['output-file'])
555
+ return json.dumps({'source': source, 'pinned': pinned})
187
556
 
188
557
  vf_clamp_instantiate
189
558
  `)
190
- )), S;
559
+ )), T;
191
560
  }
192
- async function z() {
193
- return V || (V = d().then(
561
+ async function J() {
562
+ return I || (I = f().then(
194
563
  (t) => t.runPythonAsync(`
195
564
  from fontTools.ttLib import TTFont
196
565
 
@@ -211,6 +580,10 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
211
580
 
212
581
  old_min = wght_axis.minValue
213
582
  default = wght_axis.defaultValue
583
+ # With the default at the minimum there is no below-default range to stretch; leave the font as is.
584
+ if default <= old_min:
585
+ font.save(file_options['output-file'])
586
+ return 'skipped'
214
587
  scale = (default - new_min) / (default - old_min)
215
588
 
216
589
  def remap(v):
@@ -244,214 +617,55 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
244
617
  av.NominalValue = remap(av.NominalValue)
245
618
  av.RangeMinValue = remap(av.RangeMinValue)
246
619
  av.RangeMaxValue = remap(av.RangeMaxValue)
620
+ elif fmt == 4:
621
+ for rec in getattr(av, 'AxisValueRecord', []) or []:
622
+ if rec.AxisIndex == wght_idx:
623
+ rec.Value = remap(rec.Value)
247
624
 
248
625
  font.save(file_options['output-file'])
626
+ return 'done'
249
627
 
250
628
  vf_clamp_normalize_wght
251
- `)
252
- )), V;
253
- }
254
- async function B() {
255
- return I || (I = d().then(
256
- (t) => t.runPythonAsync(`
257
- from fontTools.ttLib import TTFont
258
-
259
- def vf_clamp_prune_stat(file_options):
260
- font = TTFont(file_options['input-file'])
261
-
262
- if 'STAT' not in font or 'fvar' not in font:
263
- font.save(file_options['output-file'])
264
- return
265
-
266
- stat = font['STAT'].table
267
- if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
268
- font.save(file_options['output-file'])
269
- return
270
-
271
- fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
272
- stat_axes = list(stat.DesignAxisRecord.Axis)
273
-
274
- # Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
275
- surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
276
- if len(surviving_indices) == len(stat_axes):
277
- # No axes removed — nothing to prune.
278
- font.save(file_options['output-file'])
279
- return
280
- old_to_new = {old: new for new, old in enumerate(surviving_indices)}
281
-
282
- # Rewrite the DesignAxisRecord.Axis list to surviving axes only.
283
- stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
284
- if hasattr(stat, 'DesignAxisCount'):
285
- stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
286
-
287
- # Prune AxisValueArray entries that reference removed axes.
288
- if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
289
- kept = []
290
- for av in stat.AxisValueArray.AxisValue:
291
- fmt = av.Format
292
- if fmt in (1, 2, 3):
293
- if av.AxisIndex not in old_to_new:
294
- continue # references a removed axis
295
- av.AxisIndex = old_to_new[av.AxisIndex]
296
- kept.append(av)
297
- elif fmt == 4:
298
- inner = list(getattr(av, 'AxisValueRecord', []) or [])
299
- if any(rec.AxisIndex not in old_to_new for rec in inner):
300
- continue # whole record dies if any inner ref is dead
301
- for rec in inner:
302
- rec.AxisIndex = old_to_new[rec.AxisIndex]
303
- kept.append(av)
304
- else:
305
- # Unknown format — keep as-is to be safe.
306
- kept.append(av)
307
- stat.AxisValueArray.AxisValue = kept
308
- if hasattr(stat, 'AxisValueCount'):
309
- stat.AxisValueCount = len(kept)
310
-
311
- font.save(file_options['output-file'])
312
-
313
- vf_clamp_prune_stat
314
629
  `)
315
630
  )), I;
316
631
  }
317
- async function W() {
318
- return D || (D = d().then(
319
- (t) => t.runPythonAsync(`
320
- from fontTools.ttLib import TTFont
321
-
322
- def vf_clamp_update_os2(file_options):
323
- font = TTFont(file_options['input-file'])
324
-
325
- if 'fvar' not in font:
326
- font.save(file_options['output-file'])
327
- return
328
-
329
- wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
330
- if wght_axis is None:
331
- font.save(file_options['output-file'])
332
- return
333
-
334
- new_default = wght_axis.defaultValue
335
- # OS/2.usWeightClass valid range is 1..1000.
336
- weight_class = int(round(max(1, min(1000, new_default))))
337
-
338
- if 'OS/2' in font:
339
- os2 = font['OS/2']
340
- os2.usWeightClass = weight_class
341
- # fsSelection bits: 0x20 = BOLD, 0x40 = REGULAR.
342
- # Mirror the convention: REGULAR when usWeightClass < 600, BOLD when >= 700.
343
- fs = os2.fsSelection
344
- fs &= ~(0x20 | 0x40)
345
- if weight_class >= 700:
346
- fs |= 0x20 # BOLD
347
- elif weight_class < 600:
348
- fs |= 0x40 # REGULAR
349
- os2.fsSelection = fs
350
-
351
- if 'head' in font:
352
- head = font['head']
353
- # macStyle bit 0 = bold.
354
- ms = head.macStyle
355
- if weight_class >= 700:
356
- ms |= 0x01
357
- else:
358
- ms &= ~0x01
359
- head.macStyle = ms
360
-
361
- font.save(file_options['output-file'])
362
-
363
- vf_clamp_update_os2
364
- `)
365
- )), D;
366
- }
367
- function q(t) {
368
- return t.replace(/[^A-Za-z0-9 -]/g, "").trim().split(/\s+/).join("-").replace(/^-+|-+$/g, "").slice(0, 63);
369
- }
370
- async function G(t, o) {
371
- if (!o) return t;
372
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
632
+ async function H(t, o) {
633
+ const a = await f(), n = new p({ pyodide: a }), e = new p({ pyodide: a });
373
634
  try {
374
- const i = q(o);
375
- await e.upload(t);
376
- const s = /* @__PURE__ */ new Map([
377
- ["input-file", e.filename],
378
- ["output-file", n.filename],
379
- ["family-name", o],
380
- ["postscript-name", i]
635
+ await n.upload(t);
636
+ const i = /* @__PURE__ */ new Map([
637
+ ["input-file", n.filename],
638
+ ["output-file", e.filename]
381
639
  ]);
382
- return (await U())(s), n.download();
640
+ return (await G())(i, JSON.stringify(o)), e.download();
383
641
  } catch (i) {
384
- throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o}"`), { cause: i });
642
+ throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o.family}"`), { cause: i });
385
643
  } finally {
386
- try {
387
- e.delete();
388
- } catch {
389
- }
390
644
  try {
391
645
  n.delete();
392
646
  } catch {
393
647
  }
394
- }
395
- }
396
- function H(t) {
397
- return typeof t == "number" ? t : t === null ? null : [t.min, t.max];
398
- }
399
- async function J(t, o) {
400
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
401
- try {
402
- await e.upload(t);
403
- const i = /* @__PURE__ */ new Map([
404
- ["input-file", e.filename],
405
- ["output-file", n.filename]
406
- ]);
407
- return (await C())(i, JSON.stringify(o)), n.download();
408
- } finally {
409
648
  try {
410
649
  e.delete();
411
650
  } catch {
412
651
  }
413
- try {
414
- n.delete();
415
- } catch {
416
- }
417
652
  }
418
653
  }
419
- async function Z(t) {
420
- const o = await d(), a = new x({ pyodide: o }), e = new x({ pyodide: o });
421
- try {
422
- await a.upload(t);
423
- const n = /* @__PURE__ */ new Map([
424
- ["input-file", a.filename],
425
- ["output-file", e.filename]
426
- ]);
427
- return (await B())(n), e.download();
428
- } catch (n) {
429
- throw Object.assign(new Error("vf-clamp: STAT pruning failed"), { cause: n });
430
- } finally {
431
- try {
432
- a.delete();
433
- } catch {
434
- }
435
- try {
436
- e.delete();
437
- } catch {
438
- }
439
- }
654
+ function Z(t) {
655
+ return typeof t == "number" ? t : t === null ? null : [t.min, t.max];
440
656
  }
441
- async function K(t) {
442
- const o = await d(), a = new x({ pyodide: o }), e = new x({ pyodide: o });
657
+ async function K(t, o) {
658
+ const a = await f(), n = new p({ pyodide: a }), e = new p({ pyodide: a });
443
659
  try {
444
- await a.upload(t);
445
- const n = /* @__PURE__ */ new Map([
446
- ["input-file", a.filename],
660
+ await n.upload(t);
661
+ const i = /* @__PURE__ */ new Map([
662
+ ["input-file", n.filename],
447
663
  ["output-file", e.filename]
448
- ]);
449
- return (await W())(n), e.download();
450
- } catch (n) {
451
- throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"), { cause: n });
664
+ ]), s = await Q(), d = JSON.parse(s(i, JSON.stringify(o)));
665
+ return { buffer: e.download(), info: d };
452
666
  } finally {
453
667
  try {
454
- a.delete();
668
+ n.delete();
455
669
  } catch {
456
670
  }
457
671
  try {
@@ -460,159 +674,88 @@ async function K(t) {
460
674
  }
461
675
  }
462
676
  }
463
- async function Q(t, o) {
464
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
677
+ async function X(t, o) {
678
+ const a = await f(), n = new p({ pyodide: a }), e = new p({ pyodide: a });
465
679
  try {
466
- await e.upload(t);
680
+ await n.upload(t);
467
681
  const i = /* @__PURE__ */ new Map([
468
- ["input-file", e.filename],
469
- ["output-file", n.filename]
682
+ ["input-file", n.filename],
683
+ ["output-file", e.filename]
470
684
  ]);
471
- return (await z())(i, String(o)), n.download();
685
+ return (await J())(i, String(o)) === "skipped" && console.warn("vf-clamp: normalizeWeightAxis skipped: the weight default is the axis minimum, so there is no lower range to remap"), e.download();
472
686
  } catch (i) {
473
687
  throw Object.assign(new Error("vf-clamp: wght normalisation failed"), { cause: i });
474
688
  } finally {
475
689
  try {
476
- e.delete();
690
+ n.delete();
477
691
  } catch {
478
692
  }
479
693
  try {
480
- n.delete();
694
+ e.delete();
481
695
  } catch {
482
696
  }
483
697
  }
484
698
  }
485
- function X(t, o) {
486
- const a = new Map(o.map((i) => [i.name, i])), e = {};
487
- for (const i of t) {
488
- const s = a.get(i);
489
- if (!s) throw new Error(`Named instance "${i}" not found in font`);
490
- for (const [f, c] of Object.entries(s.coordinates))
491
- e[f] ? (e[f].min = Math.min(e[f].min, c), e[f].max = Math.max(e[f].max, c)) : e[f] = { min: c, max: c };
699
+ function Y(t, o) {
700
+ const a = {};
701
+ for (const e of t) {
702
+ const i = F(e, o);
703
+ for (const [s, d] of Object.entries(i.coordinates))
704
+ a[s] ? (a[s].min = Math.min(a[s].min, d), a[s].max = Math.max(a[s].max, d)) : a[s] = { min: d, max: d };
492
705
  }
493
706
  const n = {};
494
- for (const [i, { min: s, max: f }] of Object.entries(e))
495
- n[i] = s === f ? s : { min: s, max: f };
707
+ for (const [e, { min: i, max: s }] of Object.entries(a))
708
+ n[e] = i === s ? i : { min: i, max: s };
496
709
  return n;
497
710
  }
498
- function Y(t) {
711
+ function tt(t) {
499
712
  return t.length >= 4 && t[0] === 79 && t[1] === 84 && t[2] === 84 && t[3] === 79;
500
713
  }
501
714
  async function at(t, o) {
502
- var f, c, w;
715
+ var N, V;
503
716
  if (o.outputs.length === 0) return [];
504
- const a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, e = o.format ?? "ttf", { instances: n, axes: i } = await E(a), s = [];
717
+ const a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, n = o.format ?? "ttf", { instances: e, axes: i, family: s, labels: d } = await z(a), S = [];
505
718
  for (const m of o.outputs) {
506
- let _ = {};
507
- if ((f = m.instances) != null && f.length && (_ = X(m.instances, n)), m.axes && (_ = { ..._, ...m.axes }), o.strict && ((c = m.instances) != null && c.length)) {
508
- const p = new Set(m.instances), g = n.filter((h) => !p.has(h.name) && i.every((y) => {
509
- const A = h.coordinates[y.tag] ?? y.default, v = _[y.tag];
510
- return v == null ? !0 : typeof v == "number" ? A === v : A >= v.min && A <= v.max;
511
- })).map((h) => h.name);
512
- if (g.length)
513
- throw new Error(`vf-clamp: output "${m.name || m.instances.join(", ")}" would include unselected instances (${g.join(", ")}); use planOutputs() to split the selection`);
719
+ let h = {};
720
+ if ((N = m.instances) != null && N.length && (h = Y(m.instances, e)), m.axes && (h = { ...h, ...m.axes }), o.strict) {
721
+ const l = new Set((m.instances ?? []).map((r) => F(r, e))), u = e.filter((r) => !l.has(r) && i.every((_) => {
722
+ const w = r.coordinates[_.tag] ?? _.default, g = h[_.tag];
723
+ return g == null ? !0 : typeof g == "number" ? w === g : w >= g.min && w <= g.max;
724
+ })).map((r) => r.name);
725
+ if (u.length)
726
+ throw new Error(`vf-clamp: output "${m.name || (m.instances ?? []).join(", ") || "axes-only"}" would include unselected instances (${u.join(", ")}); use planOutputs() to split the selection`);
514
727
  }
515
- for (const p of i) {
516
- const g = _[p.tag];
517
- if (g != null && typeof g == "object") {
518
- const h = g;
519
- if (p.default < h.min || p.default > h.max) {
520
- const y = Math.max(h.min, Math.min(h.max, p.default));
728
+ for (const l of i) {
729
+ const u = h[l.tag];
730
+ if (u != null && typeof u == "object") {
731
+ const r = u;
732
+ if (l.default < r.min || l.default > r.max) {
733
+ const _ = Math.max(r.min, Math.min(r.max, l.default));
521
734
  console.warn(
522
- `vf-clamp: axis "${p.tag}" default (${p.default}) is outside restricted range [${h.min}, ${h.max}] — will be clamped to ${y}`
735
+ `vf-clamp: axis "${l.tag}" default (${l.default}) is outside restricted range [${r.min}, ${r.max}] — will be clamped to ${_}`
523
736
  );
524
737
  }
525
738
  }
526
739
  }
527
- const r = {};
528
- for (const [p, g] of Object.entries(_))
529
- g !== null && (r[p] = H(g));
530
- const u = m.name || ((w = m.instances) != null && w.length ? m.instances.length === 1 ? m.instances[0] : `${m.instances[0]}-${m.instances[m.instances.length - 1]}` : "output");
531
- let l = await J(a, r);
532
- if (e === "otf" && !Y(l))
740
+ const D = {};
741
+ for (const [l, u] of Object.entries(h))
742
+ u !== null && (D[l] = Z(u));
743
+ const x = (m.instances ?? []).map((l) => F(l, e)), k = x.length ? E(x, e, i, d ?? {}) : "Clamped", L = ((V = m.name) == null ? void 0 : V.trim()) || (s ? `${s} ${k}` : k), { buffer: B, info: R } = await K(a, D);
744
+ let c = B;
745
+ if (n === "otf" && !tt(c))
533
746
  throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");
534
- l = await Z(l), o.normalizeWeightAxis && (l = await Q(l, 100)), l = await K(l), l = await G(l, u), e === "woff2" ? l = await $(l) : e === "woff" && (l = await L(l)), s.push({ name: u, buffer: l, format: e });
535
- }
536
- return s;
537
- }
538
- function tt(t, o) {
539
- if (t === o) return t;
540
- const a = t.split(" "), e = o.split(" ");
541
- let n = 0;
542
- for (; n < a.length && n < e.length && a[n] === e[n]; )
543
- n++;
544
- let i = 0;
545
- for (; i < a.length - n && i < e.length - n && a[a.length - 1 - i] === e[e.length - 1 - i]; )
546
- i++;
547
- const s = a.slice(0, n).join(" "), f = a.slice(n, a.length - (i || 0)).join(" "), c = e.slice(n, e.length - (i || 0)).join(" "), w = i > 0 ? a.slice(a.length - i).join(" ") : "", m = f && c ? `${f}-${c}` : f || c;
548
- return [s, m, w].filter(Boolean).join(" ");
549
- }
550
- function O(t, o) {
551
- const a = {};
552
- for (const e of o) {
553
- const n = t.map((i) => i.coordinates[e.tag] ?? e.default);
554
- a[e.tag] = { min: Math.min(...n), max: Math.max(...n) };
747
+ o.normalizeWeightAxis && (c = await X(c, 100)), c = await H(c, { family: L, style: x.length === 1 ? x[0].name : null, pinned: R.pinned, source: R.source }), n === "woff2" ? c = await q(c) : n === "woff" && (c = await U(c)), S.push({ name: L, buffer: c, format: n });
555
748
  }
556
- return a;
557
- }
558
- function N(t, o, a, e) {
559
- return o.filter((n) => a.has(n.name) ? !1 : e.every((i) => {
560
- const s = n.coordinates[i.tag] ?? i.default, f = t[i.tag];
561
- return !f || s >= f.min && s <= f.max;
562
- }));
563
- }
564
- function et(t) {
565
- return t.reduce((o, a) => !o || a.maximum - a.minimum > o.maximum - o.minimum ? a : o, void 0);
566
- }
567
- function it(t, o, a) {
568
- const { axes: e, instances: n } = t, i = new Map(n.map((r) => [r.name, r])), s = [];
569
- for (const r of new Set(o)) {
570
- const u = i.get(r);
571
- if (!u) throw new Error(`Named instance "${r}" not found in font`);
572
- s.push(u);
573
- }
574
- if (!s.length) return [];
575
- const f = new Set(s.map((r) => r.name));
576
- let c;
577
- if (!N(O(s, e), n, f, e).length)
578
- c = [s];
579
- else {
580
- c = s.map((u) => [u]);
581
- let r = !0;
582
- for (; r; ) {
583
- r = !1;
584
- t: for (let u = 0; u < c.length; u++)
585
- for (let l = u + 1; l < c.length; l++) {
586
- const p = [...c[u], ...c[l]];
587
- if (!N(O(p, e), n, f, e).length) {
588
- c[u] = p, c.splice(l, 1), r = !0;
589
- break t;
590
- }
591
- }
592
- }
593
- }
594
- const w = et(e), m = w ? [w, ...e.filter((r) => r.tag !== w.tag)] : e, _ = (r, u) => {
595
- for (const l of m) {
596
- const p = (r.coordinates[l.tag] ?? l.default) - (u.coordinates[l.tag] ?? l.default);
597
- if (p) return p;
598
- }
599
- return 0;
600
- };
601
- return c = c.map((r) => [...r].sort(_)).sort((r, u) => _(r[0], u[0])), c.map((r) => {
602
- const u = r[0], l = r[r.length - 1], p = r.length === 1 ? u.name : tt(u.name, l.name);
603
- return { name: a ? `${a} ${p}` : p, instances: r.map((g) => g.name) };
604
- });
605
- }
606
- function ot(t, o) {
607
- const a = new Map(t.instances.map((n) => [n.name, n])), e = o.map((n) => a.get(n)).filter((n) => !!n);
608
- return e.length ? N(O(e, t.axes), t.instances, new Set(o), t.axes).map((n) => n.name) : [];
749
+ return S;
609
750
  }
610
751
  export {
611
752
  at as clampFont,
612
- tt as compactName,
613
- L as convertToWoff,
614
- $ as convertToWoff2,
615
- E as getInstances,
616
- it as planOutputs,
617
- ot as unboughtInstances
753
+ st as compactName,
754
+ U as convertToWoff,
755
+ q as convertToWoff2,
756
+ z as getInstances,
757
+ rt as numberKey,
758
+ lt as planOutputs,
759
+ E as rangeName,
760
+ ft as unboughtInstances
618
761
  };