@overpunch/vf-clamp 2.3.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.cjs CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const U=require("node:module");var I=typeof document<"u"?document.currentScript:null;const E=U.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:I&&I.tagName.toUpperCase()==="SCRIPT"&&I.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:d,PyodideFile:x}=E("@web-alchemy/fonttools/src/pyodide.js");let S=null;async function B(){return S||(S=d().then(e=>e.runPythonAsync(`
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const M=require("node:module"),p=require("./naming.cjs");var v=typeof document<"u"?document.currentScript:null;const q=M.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:v&&v.tagName.toUpperCase()==="SCRIPT"&&v.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:f,PyodideFile:h}=q("@web-alchemy/fonttools/src/pyodide.js");let b=null;async function W(){return b||(b=f().then(t=>t.runPythonAsync(`
2
2
  from fontTools.ttLib import TTFont
3
3
 
4
4
  def vf_convert_flavor(file_options):
@@ -7,7 +7,7 @@ def vf_convert_flavor(file_options):
7
7
  font.save(file_options['output-file'])
8
8
 
9
9
  vf_convert_flavor
10
- `))),S}async function P(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename],["flavor",a]]);return(await B())(o),i.download()}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}async function $(e){return P(e,"woff")}async function L(e){return P(e,"woff2")}let A=null;async function W(){return A||(A=d().then(e=>e.runPythonAsync(`
10
+ `))),b}async function C(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename],["flavor",o]]);return(await W())(i),e.download()}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}async function B(t){return C(t,"woff")}async function j(t){return C(t,"woff2")}let T=null;async function z(){return T||(T=f().then(t=>t.runPythonAsync(`
11
11
  import json
12
12
  from fontTools.ttLib import TTFont
13
13
 
@@ -40,102 +40,413 @@ def get_instances_fn(input_file):
40
40
  for i, inst in enumerate(fvar.instances)
41
41
  ]
42
42
 
43
- return json.dumps({'axes': axes, 'instances': instances, 'family': family})
43
+ labels = {}
44
+ if 'STAT' in font and font['STAT'].table.AxisValueArray:
45
+ stat = font['STAT'].table
46
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
47
+ for av in stat.AxisValueArray.AxisValue:
48
+ if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
49
+ value = av.NominalValue if av.Format == 2 else av.Value
50
+ key = ('%f' % float(value)).rstrip('0').rstrip('.')
51
+ labels.setdefault(tags[av.AxisIndex], {})[key] = name_table.getDebugName(av.ValueNameID) or ''
44
52
 
45
- get_instances_fn
46
- `))),A}async function M(e){const a=await d(),t=e instanceof ArrayBuffer?new Uint8Array(e):e,n=new x({pyodide:a});try{await n.upload(t);const o=(await W())(n.filename);return JSON.parse(o)}finally{try{n.delete()}catch{}}}function C(e,a){if(e===a)return e;const t=e.split(" "),n=a.split(" ");let i=0;for(;i<t.length&&i<n.length&&t[i]===n[i];)i++;let o=0;for(;o<t.length-i&&o<n.length-i&&t[t.length-1-o]===n[n.length-1-o];)o++;const r=t.slice(0,i).join(" "),s=t.slice(i,t.length-(o||0)).join(" "),h=n.slice(i,n.length-(o||0)).join(" "),y=o>0?t.slice(t.length-o).join(" "):"",f=s&&h?`${s}-${h}`:s||h;return[r,f,y].filter(Boolean).join(" ")}function T(e,a){const t=a.filter(n=>n.name===e);if(!t.length)throw new Error(`Named instance "${e}" not found in font`);if(t.length>1)throw new Error(`Named instance "${e}" is ambiguous: ${t.length} instances share that name`);return t[0]}let V=null,N=null,D=null,O=null;async function z(){return V||(V=d().then(e=>e.runPythonAsync(`
47
- from fontTools import ttLib
53
+ return json.dumps({'axes': axes, 'instances': instances, 'family': family, 'labels': labels})
48
54
 
49
- def patch_font_names_fn(file_options):
50
- font = ttLib.TTFont(file_options['input-file'])
51
- name_table = font['name']
52
- family_name = file_options['family-name']
53
- ps_name = file_options['postscript-name']
54
-
55
- existing_ids = {r.nameID for r in name_table.names}
56
-
57
- # A family name with no ASCII letters gives an empty PostScript name: fall back to the
58
- # source PostScript name plus a short hash of the family name (passed in from JS).
59
- if not ps_name:
60
- source_ps = name_table.getDebugName(6) or 'Font'
61
- ps_name = ('%s-%s' % (source_ps[:52], file_options['postscript-suffix']))[:63]
62
-
63
- # nameID 2 must be a RIBBI style that matches OS/2 (already updated): Regular, Italic, Bold or Bold Italic.
64
- fs = font['OS/2'].fsSelection if 'OS/2' in font else 0x40
65
- is_italic = bool(fs & 0x01)
66
- is_bold = bool(fs & 0x20)
67
- ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
68
-
69
- # nameID 17 (typographic subfamily): the name of the named instance at the new default, if any.
70
- default_style = None
71
- if 'fvar' in font:
72
- defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
73
- for inst in font['fvar'].instances:
74
- if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
75
- default_style = name_table.getDebugName(inst.subfamilyNameID)
76
- break
77
-
78
- # nameID 1 = Family, 4 = Full name, 6 = PostScript name
79
- # nameID 2 = Subfamily — reset to 'Regular' so the restricted file does not
80
- # collide with the source font's Subfamily in OS font caches.
81
- # nameID 3 = Unique ID — regenerate so it is distinct from the source font.
82
- # nameID 16 = Preferred family (update only if present)
83
- # nameID 25 = Variations PS Name Prefix (update only if present)
84
- #
85
- # head.fontRevision is a Fixed value (e.g. 1.000). Fall back to '1.000' if
86
- # the head table is unreadable.
87
- try:
88
- version = '%.3f' % font['head'].fontRevision
89
- except Exception:
90
- version = '1.000'
91
- unique_id = '%s;%s;%s' % (version, ps_name, family_name)
92
-
93
- updates = {
94
- 1: family_name,
95
- 2: ribbi,
96
- 3: unique_id,
97
- 4: family_name if ribbi == 'Regular' else '%s %s' % (family_name, ribbi),
98
- 6: ps_name,
99
- }
100
- if 16 in existing_ids:
101
- updates[16] = family_name
102
- if 17 in existing_ids and default_style:
103
- updates[17] = default_style
104
- if 25 in existing_ids:
105
- updates[25] = ps_name
106
-
107
- # Named instances' PostScript names must follow the new prefix, e.g. Inter-Regular-Bold-Medium.
108
- if 'fvar' in font:
109
- for inst in font['fvar'].instances:
110
- pid = getattr(inst, 'postscriptNameID', 0xFFFF)
111
- if pid in (None, 0xFFFF):
112
- continue
113
- style = (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', '')
114
- style = ''.join(c for c in style if c.isascii() and (c.isalnum() or c == '-'))
115
- updates[pid] = ('%s-%s' % (ps_name, style))[:63]
116
-
117
- # Rewrite every platform. A Mac (platform 1) record that can't be encoded in Mac Roman is dropped
118
- # rather than filled with '?'; Windows and Unicode records always carry the full name.
119
- keep = []
120
- for record in name_table.names:
121
- if record.nameID not in updates:
122
- keep.append(record)
123
- continue
124
- value = updates[record.nameID]
125
- if record.platformID in (0, 3):
126
- record.string = value.encode('utf-16-be')
127
- elif record.platformID == 1:
128
- try:
129
- record.string = value.encode('mac_roman')
130
- except Exception:
131
- continue
132
- keep.append(record)
133
- name_table.names = keep
55
+ get_instances_fn
56
+ `))),T}async function E(t){const o=await f(),a=t instanceof ArrayBuffer?new Uint8Array(t):t,n=new h({pyodide:o});try{await n.upload(a);const i=(await z())(n.filename);return JSON.parse(i)}finally{try{n.delete()}catch{}}}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.
57
+ #
58
+ # Canonical copy: vfClamp/shared/plugin-views/vfclamp_naming.py. The plugins get byte-identical copies via
59
+ # \`npm run sync-plugin-views\`; the npm package imports this file's text at build time. Never edit a copy.
60
+ # Pure fontTools: no Glyphs, RoboFont or AppKit imports.
61
+
62
+ import re
63
+ import unicodedata
64
+
65
+ #: OS/2.fsSelection bits this module owns.
66
+ FS_ITALIC = 0x01
67
+ FS_BOLD = 0x20
68
+ FS_REGULAR = 0x40
69
+ FS_OBLIQUE = 0x200
70
+ #: head.macStyle bits this module owns.
71
+ MAC_BOLD = 0x01
72
+ MAC_ITALIC = 0x02
73
+
74
+ #: Label for an axis value that has no word in the font's style names (e.g. wdth 100 in "Light").
75
+ DEFAULT_AXIS_WORDS = {'wght': 'Regular', 'wdth': 'Normal', 'ital': 'Upright', 'slnt': 'Upright'}
76
+
77
+ #: The four style-linking (RIBBI) subfamily names.
78
+ RIBBI = ('Regular', 'Italic', 'Bold', 'Bold Italic')
79
+
80
+
81
+ def short_hash(text):
82
+ """Six-character FNV-1a hash of a string, base 36; identical to shortHash() in the npm package."""
83
+ h = 0x811c9dc5
84
+ for ch in text:
85
+ h ^= ord(ch)
86
+ h = (h * 0x01000193) & 0xFFFFFFFF
87
+ digits = '0123456789abcdefghijklmnopqrstuvwxyz'
88
+ out = ''
89
+ while h:
90
+ h, rem = divmod(h, 36)
91
+ out = digits[rem] + out
92
+ return (out or '0').rjust(6, '0')[-6:]
93
+
94
+
95
+ def ps_name(text, max_len=63):
96
+ """PostScript name (nameID 6): accents transliterated, [A-Za-z0-9-] only, at most 63 characters.
97
+
98
+ Over 63 characters, a readable prefix plus a hash of the full text keeps names unique. Text with
99
+ letters in another script becomes 'Font-<hash>' (never the source font's name); text with no
100
+ letters at all becomes 'Font'.
101
+ """
102
+ ascii_text = unicodedata.normalize('NFKD', text or '').encode('ascii', 'ignore').decode('ascii')
103
+ s = re.sub(r'[^A-Za-z0-9 -]', '', ascii_text).strip()
104
+ s = re.sub(r'\\s+', '-', s)
105
+ s = re.sub(r'-+', '-', s).strip('-')
106
+ if not s:
107
+ return f'Font-{short_hash(text)}' if any(c.isalpha() for c in (text or '')) else 'Font'
108
+ if len(s) > max_len:
109
+ s = f'{s[:max_len - 7].rstrip("-")}-{short_hash(text)}'
110
+ return s
111
+
112
+
113
+ def vf_prefix(ps, family):
114
+ """Variations PostScript Name Prefix (nameID 25): ASCII letters and digits only (OpenType spec), at most 27."""
115
+ alnum = re.sub(r'[^A-Za-z0-9]', '', ps)
116
+ if not alnum:
117
+ return 'Font'
118
+ if len(alnum) > 27:
119
+ return alnum[:21] + short_hash(family)
120
+ return alnum
121
+
122
+
123
+ def full_name(family, style):
124
+ """Full name (nameID 4): family plus style, leaving out 'Regular' and a style the family name already ends with."""
125
+ if not style or style == 'Regular' or family.lower().endswith(style.lower()):
126
+ return family
127
+ return f'{family} {style}'
128
+
129
+
130
+ def number_key(value):
131
+ """Stable text key for an axis value, identical in Python and JavaScript ('100', '87.5')."""
132
+ return ('%f' % float(value)).rstrip('0').rstrip('.')
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Inputs read from the source font (before instancing)
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def source_style_info(font):
140
+ """What the style rules need to know about the source: its italic/oblique bits and whether ital/slnt exist."""
141
+ fs = font['OS/2'].fsSelection if 'OS/2' in font else 0
142
+ tags = {ax.axisTag for ax in font['fvar'].axes} if 'fvar' in font else set()
143
+ return {'italic': bool(fs & FS_ITALIC), 'oblique': bool(fs & FS_OBLIQUE), 'has_ital_slnt': bool(tags & {'ital', 'slnt'})}
144
+
145
+
146
+ def naming_inputs(font):
147
+ """Instances, axes and STAT labels of a source font, in the shape range_name() takes."""
148
+ name = font['name']
149
+ axes = [{'tag': ax.axisTag, 'default': ax.defaultValue} for ax in font['fvar'].axes] if 'fvar' in font else []
150
+ instances = [
151
+ {'name': name.getDebugName(inst.subfamilyNameID) or '', 'coordinates': dict(inst.coordinates)}
152
+ for inst in (font['fvar'].instances if 'fvar' in font else [])
153
+ ]
154
+ labels = {}
155
+ if 'STAT' in font and font['STAT'].table.AxisValueArray:
156
+ stat = font['STAT'].table
157
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
158
+ for av in stat.AxisValueArray.AxisValue:
159
+ if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
160
+ value = av.NominalValue if av.Format == 2 else av.Value
161
+ labels.setdefault(tags[av.AxisIndex], {})[number_key(value)] = name.getDebugName(av.ValueNameID) or ''
162
+ return {'instances': instances, 'axes': axes, 'labels': labels}
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Default output name for a selection
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def _words(text):
170
+ """Whitespace-separated words of a style name."""
171
+ return [w for w in (text or '').split() if w]
172
+
173
+
174
+ def range_name(selected, instances, axes, labels=None):
175
+ """Default name for a selection of named instances: one range per axis, in the font's own style words.
176
+
177
+ Each word in the font's style names is attributed to the one axis that is constant wherever the word
178
+ appears and varies across the font ("SemiCondensed" -> wdth, "Thin" -> wght). An axis that varies in
179
+ the selection becomes "low-high" ("SemiCondensed-Normal", "Thin-Light"); an axis that doesn't shows
180
+ its word once, or nothing when that value has no word. A value with no word (wdth 100 in "Light")
181
+ takes its STAT name ("Normal"), else a default word, else "<tag><value>". Parts follow the order the
182
+ words appear in the font's names; words shared by every selected name and owned by no axis (a family prefix,
183
+ "Italic" in an italic-only font) stay in place before or after them.
184
+
185
+ \`\`selected\`\`/\`\`instances\`\`: dicts with 'name' and 'coordinates'; \`\`axes\`\`: dicts with 'tag' and
186
+ 'default'; \`\`labels\`\`: {tag: {number_key(value): name}} from STAT. Mirrored by rangeName() in
187
+ TypeScript; both are checked against shared/naming-cases.json.
188
+ """
189
+ if not selected:
190
+ return ''
191
+ if len(selected) == 1:
192
+ return selected[0]['name']
193
+ labels = labels or {}
194
+ defaults = {a['tag']: a['default'] for a in axes}
195
+ tags = [a['tag'] for a in axes]
196
+
197
+ def coord(inst, tag):
198
+ return inst['coordinates'].get(tag, defaults.get(tag))
199
+
200
+ varying = [t for t in tags if len({coord(i, t) for i in instances}) > 1]
201
+
202
+ owner_cache = {}
203
+
204
+ def owner(word):
205
+ if word not in owner_cache:
206
+ having = [i for i in instances if word in _words(i['name'])]
207
+ cands = [t for t in varying if len({coord(i, t) for i in having}) == 1] if having else []
208
+ owner_cache[word] = cands[0] if len(cands) == 1 else None
209
+ return owner_cache[word]
210
+
211
+ def label(tag, value):
212
+ at = [i for i in instances if coord(i, tag) == value]
213
+ if not at:
214
+ return ''
215
+ common = [w for w in _words(at[0]['name']) if owner(w) == tag and all(w in _words(i['name']) for i in at)]
216
+ return ' '.join(common)
217
+
218
+ def fallback(tag, value):
219
+ stat = labels.get(tag, {}).get(number_key(value))
220
+ if stat:
221
+ return stat
222
+ return DEFAULT_AXIS_WORDS.get(tag) or f'{tag}{number_key(value)}'
223
+
224
+ # Axis order follows the font's own names: positions come from names carrying words for two or more
225
+ # axes ("SemiCondensed Thin" puts width before weight); a plain "Thin" says nothing about order.
226
+ def owned_axes(inst):
227
+ return {owner(w) for w in _words(inst['name'])} - {None}
228
+
229
+ rich = [i for i in instances if len(owned_axes(i)) > 1] or instances
230
+
231
+ def axis_position(tag):
232
+ found = [k for i in rich for k, w in enumerate(_words(i['name'])) if owner(w) == tag]
233
+ return min(found) if found else 10_000
234
+
235
+ parts = []
236
+ for order, tag in enumerate(tags):
237
+ values = sorted({coord(i, tag) for i in selected})
238
+ lo, hi = values[0], values[-1]
239
+ if lo == hi:
240
+ text = label(tag, lo)
241
+ if not text:
242
+ continue
243
+ else:
244
+ a = label(tag, lo) or fallback(tag, lo)
245
+ b = label(tag, hi) or fallback(tag, hi)
246
+ text = a if a == b else f'{a}-{b}'
247
+ parts.append((axis_position(tag), order, text))
248
+ parts.sort()
249
+
250
+ first_words = _words(selected[0]['name'])
251
+ shared = [w for w in first_words if owner(w) is None and all(w in _words(i['name']) for i in selected)]
252
+ owned_positions = [k for k, w in enumerate(first_words) if owner(w) is not None]
253
+ first_owned = min(owned_positions) if owned_positions else len(first_words)
254
+ before = [w for w in shared if first_words.index(w) < first_owned]
255
+ after = [w for w in shared if first_words.index(w) >= first_owned]
256
+ name = ' '.join(before + [p[2] for p in parts] + after).strip()
257
+ return name or f'{selected[0]["name"]}-{selected[-1]["name"]}'
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Rules applied to the clamped font (after instancing)
262
+ # ---------------------------------------------------------------------------
263
+
264
+ _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))
265
+
266
+
267
+ def location_after(font, pinned):
268
+ """The clamped font's default location: remaining fvar defaults plus the values of pinned axes."""
269
+ loc = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes} if 'fvar' in font else {}
270
+ for tag, value in (pinned or {}).items():
271
+ loc.setdefault(tag, value)
272
+ return loc
273
+
274
+
275
+ def unlink_out_of_range_stat(font, location):
276
+ """Turn STAT Format 3 values whose LinkedValue the clamped font can't reach into Format 1 (same name, no link)."""
277
+ if 'STAT' not in font or not font['STAT'].table.AxisValueArray:
278
+ return
279
+ stat = font['STAT'].table
280
+ tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
281
+ ranges = {ax.axisTag: (ax.minValue, ax.maxValue) for ax in font['fvar'].axes} if 'fvar' in font else {}
282
+ for av in stat.AxisValueArray.AxisValue:
283
+ if av.Format != 3 or av.AxisIndex >= len(tags):
284
+ continue
285
+ tag = tags[av.AxisIndex]
286
+ if tag in ranges:
287
+ lo, hi = ranges[tag]
288
+ reachable = lo <= av.LinkedValue <= hi
289
+ elif tag in location:
290
+ reachable = av.LinkedValue == location[tag]
291
+ else:
292
+ continue
293
+ if not reachable:
294
+ av.Format = 1
295
+ del av.LinkedValue
296
+
297
+
298
+ def apply_style_bits(font, location, source):
299
+ """Set usWeightClass/usWidthClass, fsSelection and macStyle from the clamped font's default location.
300
+
301
+ BOLD from weight 700; ITALIC from ital >= 0.5 or slnt < 0 (OBLIQUE as well for a slant without ital);
302
+ with neither axis in the source, the source's italic/oblique bits are kept. REGULAR only when none of
303
+ BOLD, ITALIC or OBLIQUE is set. Returns (is_bold, is_italic).
304
+ """
305
+ if 'OS/2' not in font:
306
+ return False, False
307
+ os2 = font['OS/2']
308
+ wght, wdth = location.get('wght'), location.get('wdth')
309
+ if wght is not None:
310
+ os2.usWeightClass = max(1, min(1000, int(round(wght))))
311
+ if wdth is not None:
312
+ os2.usWidthClass = next((cls for edge, cls in _WIDTH_CLASSES if wdth < edge), 9)
313
+ if source.get('has_ital_slnt'):
314
+ ital, slnt = location.get('ital'), location.get('slnt')
315
+ is_italic = (ital is not None and ital >= 0.5) or (slnt is not None and slnt < 0)
316
+ is_oblique = slnt is not None and slnt < 0 and not (ital is not None and ital >= 0.5)
317
+ else:
318
+ is_italic, is_oblique = source.get('italic', False), source.get('oblique', False)
319
+ is_bold = os2.usWeightClass >= 700
320
+ fs = os2.fsSelection & ~(FS_ITALIC | FS_BOLD | FS_REGULAR | FS_OBLIQUE)
321
+ if is_italic or is_oblique:
322
+ fs |= FS_ITALIC
323
+ if is_oblique:
324
+ fs |= FS_OBLIQUE
325
+ if is_bold:
326
+ fs |= FS_BOLD
327
+ if not (is_bold or is_italic or is_oblique):
328
+ fs |= FS_REGULAR
329
+ os2.fsSelection = fs
330
+ if 'head' in font:
331
+ mac = font['head'].macStyle & ~(MAC_BOLD | MAC_ITALIC)
332
+ font['head'].macStyle = mac | (MAC_BOLD if is_bold else 0) | (MAC_ITALIC if (is_italic or is_oblique) else 0)
333
+ return is_bold, (is_italic or is_oblique)
334
+
335
+
336
+ def default_instance_style(font):
337
+ """Subfamily name of the named instance at the clamped font's default location, or None."""
338
+ if 'fvar' not in font:
339
+ return None
340
+ defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
341
+ for inst in font['fvar'].instances:
342
+ if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
343
+ return font['name'].getDebugName(inst.subfamilyNameID)
344
+ return None
345
+
346
+
347
+ def apply_names(font, family, style=None, keep_mac=True, version=None):
348
+ """Write name IDs 1, 2, 3, 4, 6, 16/17, 25 and the named instances' PostScript names. Run after apply_style_bits.
349
+
350
+ \`\`family\`\` is the output name; blank raises ValueError. \`\`style\`\` is the picked instance's name for a
351
+ single-style output; for a range it defaults to the instance at the new default location. nameID 2 is
352
+ the RIBBI style matching OS/2; a non-RIBBI style (SemiBold) goes into 16/17, and for a static file
353
+ into nameID 1 too ("Family SemiBold" + "Regular"), as the OpenType spec describes. A static file's
354
+ PostScript name includes its style so two pins never collide.
355
+ """
356
+ family = (family or '').strip()
357
+ if not family:
358
+ raise ValueError('vf-clamp: the output name is empty')
359
+ name_table = font['name']
360
+ existing = {r.nameID for r in name_table.names}
361
+ static = 'fvar' not in font
362
+ fs = font['OS/2'].fsSelection if 'OS/2' in font else FS_REGULAR
363
+ is_bold, is_italic = bool(fs & FS_BOLD), bool(fs & FS_ITALIC)
364
+ ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
365
+
366
+ typo = (style or '').strip() or default_instance_style(font) or ribbi
367
+ if is_italic and 'Italic' not in typo and 'Oblique' not in typo:
368
+ typo = 'Italic' if typo == 'Regular' else f'{typo} Italic'
369
+ core = ' '.join(w for w in typo.split() if w not in ('Italic', 'Oblique'))
370
+ non_ribbi = core not in ('', 'Regular', 'Bold')
371
+
372
+ name1 = family
373
+ if static and non_ribbi and not family.lower().endswith(core.lower()):
374
+ name1 = f'{family} {core}'
375
+ ps_base = family if not static or typo == 'Regular' or family.lower().endswith(typo.lower()) else f'{family} {typo}'
376
+ ps = ps_name(ps_base)
377
+ prefix = vf_prefix(ps, family)
378
+ if version is None:
379
+ try:
380
+ version = '%.3f' % font['head'].fontRevision
381
+ except Exception:
382
+ version = '1.000'
383
+
384
+ updates = {
385
+ 1: name1,
386
+ 2: ribbi,
387
+ 3: f'{version};{ps};{family}',
388
+ 4: full_name(family, typo if static else ribbi),
389
+ 6: ps,
390
+ }
391
+ if 16 in existing or 17 in existing or non_ribbi:
392
+ updates[16] = family
393
+ updates[17] = typo
394
+ if 25 in existing:
395
+ updates[25] = prefix
396
+ if 'fvar' in font:
397
+ for inst in font['fvar'].instances:
398
+ pid = getattr(inst, 'postscriptNameID', 0xFFFF)
399
+ if pid in (None, 0xFFFF) or pid in updates:
400
+ continue
401
+ inst_style = re.sub(r'[^A-Za-z0-9-]', '', (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', ''))
402
+ updates[pid] = f'{prefix}-{inst_style}'[:63]
403
+
404
+ english = {0, 0x0409}
405
+ kept = []
406
+ written = set()
407
+ for record in name_table.names:
408
+ if record.nameID not in updates:
409
+ kept.append(record)
410
+ continue
411
+ if record.platformID in (1, 3) and record.langID not in english:
412
+ continue # stale localised name for an ID we're rewriting
413
+ value = updates[record.nameID]
414
+ if record.platformID in (0, 3):
415
+ record.string = value.encode('utf-16-be')
416
+ elif record.platformID == 1:
417
+ if not keep_mac:
418
+ continue
419
+ try:
420
+ record.string = value.encode('mac_roman')
421
+ except (UnicodeEncodeError, LookupError):
422
+ continue # drop rather than write '?'
423
+ kept.append(record)
424
+ written.add((record.nameID, record.platformID))
425
+ name_table.names = kept
426
+ for name_id, value in updates.items():
427
+ if (name_id, 3) not in written:
428
+ name_table.setName(value, name_id, 3, 1, 0x0409)
429
+ return updates
430
+
431
+
432
+ def finish(font, family, style, pinned, source, keep_mac=True):
433
+ """Everything after instancing, in order: STAT links, style bits, names. Returns the name updates."""
434
+ location = location_after(font, pinned)
435
+ unlink_out_of_range_stat(font, location)
436
+ apply_style_bits(font, location, source)
437
+ return apply_names(font, family, style, keep_mac=keep_mac)
438
+ `;let A=null,I=null,F=null,S=null;function P(){return A||(A=f().then(async t=>{await t.runPythonAsync($)})),A}async function G(){return I||(I=P().then(()=>f()).then(t=>t.runPythonAsync(`
439
+ import json
440
+ from fontTools.ttLib import TTFont
134
441
 
442
+ def vf_clamp_finish(file_options, opts_json):
443
+ opts = json.loads(opts_json)
444
+ font = TTFont(file_options['input-file'])
445
+ finish(font, opts['family'], opts.get('style'), opts.get('pinned') or {}, opts['source'])
135
446
  font.save(file_options['output-file'])
136
447
 
137
- patch_font_names_fn
138
- `))),V}async function q(){return N||(N=d().then(e=>e.runPythonAsync(`
448
+ vf_clamp_finish
449
+ `))),I}async function Q(){return F||(F=P().then(()=>f()).then(t=>t.runPythonAsync(`
139
450
  from fontTools.ttLib import TTFont
140
451
  from fontTools.varLib import instancer
141
452
  import json
@@ -166,11 +477,15 @@ def vf_clamp_instantiate(file_options, axes_json):
166
477
  else:
167
478
  limits[tag] = float(spec)
168
479
 
480
+ # The style rules need the source's italic bits and which axes were pinned (they leave fvar).
481
+ source = source_style_info(font)
482
+ pinned = {tag: value for tag, value in limits.items() if isinstance(value, float)}
169
483
  partial = instancer.instantiateVariableFont(font, limits)
170
484
  partial.save(file_options['output-file'])
485
+ return json.dumps({'source': source, 'pinned': pinned})
171
486
 
172
487
  vf_clamp_instantiate
173
- `))),N}async function H(){return D||(D=d().then(e=>e.runPythonAsync(`
488
+ `))),F}async function J(){return S||(S=f().then(t=>t.runPythonAsync(`
174
489
  from fontTools.ttLib import TTFont
175
490
 
176
491
  def vf_clamp_normalize_wght(file_options, new_min_str):
@@ -236,50 +551,4 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
236
551
  return 'done'
237
552
 
238
553
  vf_clamp_normalize_wght
239
- `))),D}async function J(){return O||(O=d().then(e=>e.runPythonAsync(`
240
- from fontTools.ttLib import TTFont
241
-
242
- def vf_clamp_update_os2(file_options):
243
- font = TTFont(file_options['input-file'])
244
-
245
- if 'fvar' not in font:
246
- font.save(file_options['output-file'])
247
- return
248
-
249
- wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
250
- if wght_axis is not None:
251
- # OS/2.usWeightClass valid range is 1..1000.
252
- weight_class = int(round(max(1, min(1000, wght_axis.defaultValue))))
253
- elif 'OS/2' in font:
254
- # wght was pinned: fontTools has already set usWeightClass to the pinned weight.
255
- weight_class = font['OS/2'].usWeightClass
256
- else:
257
- font.save(file_options['output-file'])
258
- return
259
-
260
- if 'OS/2' in font:
261
- os2 = font['OS/2']
262
- os2.usWeightClass = weight_class
263
- # fsSelection: 0x01 ITALIC (kept), 0x20 BOLD, 0x40 REGULAR. REGULAR only when neither ITALIC nor BOLD.
264
- fs = os2.fsSelection
265
- fs &= ~(0x20 | 0x40)
266
- if weight_class >= 700:
267
- fs |= 0x20
268
- elif not fs & 0x01:
269
- fs |= 0x40
270
- os2.fsSelection = fs
271
-
272
- if 'head' in font:
273
- head = font['head']
274
- # macStyle bit 0 = bold.
275
- ms = head.macStyle
276
- if weight_class >= 700:
277
- ms |= 0x01
278
- else:
279
- ms &= ~0x01
280
- head.macStyle = ms
281
-
282
- font.save(file_options['output-file'])
283
-
284
- vf_clamp_update_os2
285
- `))),O}function G(e){const a=e.normalize("NFKD").replace(/[\u0300-\u036f]/g,"").replace(/[^A-Za-z0-9 -]/g,"").trim().split(/\s+/).join("-").replace(/-+/g,"-").replace(/^-+|-+$/g,"");return a.length<=63?a:`${a.slice(0,56).replace(/-+$/,"")}-${k(e)}`}function k(e){let a=2166136261;for(const t of e)a^=t.codePointAt(0),a=Math.imul(a,16777619)>>>0;return a.toString(36).padStart(6,"0").slice(-6)}async function K(e,a){if(!a)return e;const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{const o=G(a);await n.upload(e);const r=new Map([["input-file",n.filename],["output-file",i.filename],["family-name",a],["postscript-name",o],["postscript-suffix",k(a)]]);return(await z())(r),i.download()}catch(o){throw Object.assign(new Error(`vf-clamp: name table patching failed for "${a}"`),{cause:o})}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}function Z(e){return typeof e=="number"?e:e===null?null:[e.min,e.max]}async function Q(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename]]);return(await q())(o,JSON.stringify(a)),i.download()}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}async function X(e){const a=await d(),t=new x({pyodide:a}),n=new x({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await J())(i),n.download()}catch(i){throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"),{cause:i})}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function Y(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename]]);return(await H())(o,String(a))==="skipped"&&console.warn("vf-clamp: normalizeWeightAxis skipped: the weight default is the axis minimum, so there is no lower range to remap"),i.download()}catch(o){throw Object.assign(new Error("vf-clamp: wght normalisation failed"),{cause:o})}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}function ee(e,a){const t={};for(const i of e){const o=T(i,a);for(const[r,s]of Object.entries(o.coordinates))t[r]?(t[r].min=Math.min(t[r].min,s),t[r].max=Math.max(t[r].max,s)):t[r]={min:s,max:s}}const n={};for(const[i,{min:o,max:r}]of Object.entries(t))n[i]=o===r?o:{min:o,max:r};return n}function te(e){return e.length>=4&&e[0]===79&&e[1]===84&&e[2]===84&&e[3]===79}async function ne(e,a){var h,y;if(a.outputs.length===0)return[];const t=e instanceof ArrayBuffer?new Uint8Array(e):e,n=a.format??"ttf",{instances:i,axes:o,family:r}=await M(t),s=[];for(const f of a.outputs){let l={};if((h=f.instances)!=null&&h.length&&(l=ee(f.instances,i)),f.axes&&(l={...l,...f.axes}),a.strict){const _=new Set((f.instances??[]).map(p=>T(p,i))),w=i.filter(p=>!_.has(p)&&o.every(b=>{const F=p.coordinates[b.tag]??b.default,v=l[b.tag];return v==null?!0:typeof v=="number"?F===v:F>=v.min&&F<=v.max})).map(p=>p.name);if(w.length)throw new Error(`vf-clamp: output "${f.name||(f.instances??[]).join(", ")||"axes-only"}" would include unselected instances (${w.join(", ")}); use planOutputs() to split the selection`)}for(const _ of o){const w=l[_.tag];if(w!=null&&typeof w=="object"){const p=w;if(_.default<p.min||_.default>p.max){const b=Math.max(p.min,Math.min(p.max,_.default));console.warn(`vf-clamp: axis "${_.tag}" default (${_.default}) is outside restricted range [${p.min}, ${p.max}] — will be clamped to ${b}`)}}}const c={};for(const[_,w]of Object.entries(l))w!==null&&(c[_]=Z(w));const u=(y=f.instances)!=null&&y.length?f.instances.length===1?f.instances[0]:`${f.instances[0]}-${f.instances[f.instances.length-1]}`:"Clamped",g=f.name||(r?`${r} ${u}`:u);let m=await Q(t,c);if(n==="otf"&&!te(m))throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");a.normalizeWeightAxis&&(m=await Y(m,100)),m=await X(m),m=await K(m,g),n==="woff2"?m=await L(m):n==="woff"&&(m=await $(m)),s.push({name:g,buffer:m,format:n})}return s}function R(e,a){const t={};for(const n of a){const i=e.map(o=>o.coordinates[n.tag]??n.default);t[n.tag]={min:Math.min(...i),max:Math.max(...i)}}return t}function j(e,a,t,n){return a.filter(i=>t.has(i)?!1:n.every(o=>{const r=i.coordinates[o.tag]??o.default,s=e[o.tag];return!s||r>=s.min&&r<=s.max}))}function ae(e){return e.reduce((a,t)=>!a||t.maximum-t.minimum>a.maximum-a.minimum?t:a,void 0)}function ie(e,a,t){const{axes:n,instances:i}=e,o=[...new Set(a)].map(l=>T(l,i));if(!o.length)return[];const r=new Set(o);let s;if(!j(R(o,n),i,r,n).length)s=[o];else{s=o.map(c=>[c]);let l=!0;for(;l;){l=!1;e:for(let c=0;c<s.length;c++)for(let u=c+1;u<s.length;u++){const g=[...s[c],...s[u]];if(!j(R(g,n),i,r,n).length){s[c]=g,s.splice(u,1),l=!0;break e}}}}const h=ae(n),y=h?[h,...n.filter(l=>l.tag!==h.tag)]:n,f=(l,c)=>{for(const u of y){const g=(l.coordinates[u.tag]??u.default)-(c.coordinates[u.tag]??u.default);if(g)return g}return 0};return s=s.map(l=>[...l].sort(f)).sort((l,c)=>f(l[0],c[0])),s.map(l=>{const c=l[0],u=l[l.length-1],g=l.length===1?c.name:C(c.name,u.name);return{name:t?`${t} ${g}`:g,instances:l.map(m=>m.name)}})}function oe(e,a){const t=a.map(n=>T(n,e.instances));return t.length?j(R(t,e.axes),e.instances,new Set(t),e.axes).map(n=>n.name):[]}exports.clampFont=ne;exports.compactName=C;exports.convertToWoff=$;exports.convertToWoff2=L;exports.getInstances=M;exports.planOutputs=ie;exports.unboughtInstances=oe;
554
+ `))),S}async function H(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]);return(await G())(i,JSON.stringify(o)),e.download()}catch(i){throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o.family}"`),{cause:i})}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}function K(t){return typeof t=="number"?t:t===null?null:[t.min,t.max]}async function Z(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]),s=await Q(),d=JSON.parse(s(i,JSON.stringify(o)));return{buffer:e.download(),info:d}}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}async function X(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]);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()}catch(i){throw Object.assign(new Error("vf-clamp: wght normalisation failed"),{cause:i})}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}function Y(t,o){const a={};for(const e of t){const i=p.findInstance(e,o);for(const[s,d]of Object.entries(i.coordinates))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}}const n={};for(const[e,{min:i,max:s}]of Object.entries(a))n[e]=i===s?i:{min:i,max:s};return n}function tt(t){return t.length>=4&&t[0]===79&&t[1]===84&&t[2]===84&&t[3]===79}async function nt(t,o){var V,D;if(o.outputs.length===0)return[];const a=t instanceof ArrayBuffer?new Uint8Array(t):t,n=o.format??"ttf",{instances:e,axes:i,family:s,labels:d}=await E(a),N=[];for(const m of o.outputs){let g={};if((V=m.instances)!=null&&V.length&&(g=Y(m.instances,e)),m.axes&&(g={...g,...m.axes}),o.strict){const l=new Set((m.instances??[]).map(r=>p.findInstance(r,e))),u=e.filter(r=>!l.has(r)&&i.every(x=>{const y=r.coordinates[x.tag]??x.default,_=g[x.tag];return _==null?!0:typeof _=="number"?y===_:y>=_.min&&y<=_.max})).map(r=>r.name);if(u.length)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`)}for(const l of i){const u=g[l.tag];if(u!=null&&typeof u=="object"){const r=u;if(l.default<r.min||l.default>r.max){const x=Math.max(r.min,Math.min(r.max,l.default));console.warn(`vf-clamp: axis "${l.tag}" default (${l.default}) is outside restricted range [${r.min}, ${r.max}] — will be clamped to ${x}`)}}}const k={};for(const[l,u]of Object.entries(g))u!==null&&(k[l]=K(u));const w=(m.instances??[]).map(l=>p.findInstance(l,e)),L=w.length?p.rangeName(w,e,i,d??{}):"Clamped",R=((D=m.name)==null?void 0:D.trim())||(s?`${s} ${L}`:L),{buffer:U,info:O}=await Z(a,k);let c=U;if(n==="otf"&&!tt(c))throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");o.normalizeWeightAxis&&(c=await X(c,100)),c=await H(c,{family:R,style:w.length===1?w[0].name:null,pinned:O.pinned,source:O.source}),n==="woff2"?c=await j(c):n==="woff"&&(c=await B(c)),N.push({name:R,buffer:c,format:n})}return N}exports.compactName=p.compactName;exports.numberKey=p.numberKey;exports.planOutputs=p.planOutputs;exports.rangeName=p.rangeName;exports.unboughtInstances=p.unboughtInstances;exports.clampFont=nt;exports.convertToWoff=B;exports.convertToWoff2=j;exports.getInstances=E;