@overpunch/vf-clamp 2.1.4

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/README.md ADDED
@@ -0,0 +1,372 @@
1
+ # vf-clamp
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40overpunch%2Fvf-clamp.svg)](https://www.npmjs.com/package/@overpunch/vf-clamp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![part of liiift type-tools](https://img.shields.io/badge/liiift-type--tools-blueviolet)](https://github.com/over-punch/type-tools)
4
+
5
+ The delivery layer for per-purchase micro-VFs. Restrict a variable font's axis ranges to exactly the named instances a customer bought — like CSS `clamp()` for design space.
6
+
7
+ ```
8
+ npm install @overpunch/vf-clamp
9
+ ```
10
+
11
+ **[Interactive demo at vfclamp.com →](https://vfclamp.com)**
12
+
13
+ ![Clamp to the styles a customer bought: a weight axis showing a full family's nine named instances (Thin–Black), and a clamped output that keeps only Light–Bold (wght 300–700) as a variable range while the masters outside the purchase are removed](https://raw.githubusercontent.com/over-punch/vf-clamp/main/assets/design-space.png?v=2)
14
+
15
+ ---
16
+
17
+ ## What it does
18
+
19
+ Takes a variable font (TTF, OTF, WOFF, or WOFF2) and produces one restricted variant per configured output. Each variant is a valid variable font with unused axis ranges trimmed, gvar deltas pruned, and the name table updated to reflect the restricted instance range. No Python required — powered by [fonttools](https://github.com/fonttools/fonttools) compiled to WASM via [Pyodide](https://pyodide.org).
20
+
21
+ ---
22
+
23
+ ## For foundries
24
+
25
+ A variable font is usually all-or-nothing: customers buy the whole family to get one, or they buy statics and lose interpolation. vf-clamp adds the tier in between — a variable font scoped to exactly the named instances a customer purchased, generated and delivered at checkout.
26
+
27
+ **Purchase → Clamp → Deliver.** A customer buys two or more adjacent styles; your store POSTs the order to the [REST API](#rest-api); a scoped VF comes back in seconds with its name table rewritten to the purchased range, in the format the licence calls for.
28
+
29
+ Why it matters:
30
+
31
+ - **A new revenue tier** — two adjacent styles become a variable purchase, not just two statics. Price a ladder: two-style VF → subfamily → full family.
32
+ - **Licence containment** — a full VF ships every master, so customers can reach weights they never paid for. A clamped VF physically contains only the purchased range — nothing outside the licence is left in the file to leak.
33
+ - **Branded, traceable files** — the name table (family, full name, PostScript name) is rewritten to the purchased range, so every delivered file is identifiable as that specific order.
34
+ - **Lighter files for the web** — a site that uses only Medium–Black shouldn't ship Thin–Light deadweight. Clamping prunes masters outside the licensed range: variation across what they bought, at a smaller download.
35
+ - **Sell bespoke cuts** — pin an axis to a coordinate that was never a named instance (a custom optical size or width) and sell that exact cut, without shipping it in the retail family.
36
+ - **Ready for `opsz` demand** — browsers drive the optical-size axis automatically via `font-optical-sizing: auto`, keyed off the rendered point size. Delivering `opsz` clamped to a usable range keeps files small as that axis matters more.
37
+
38
+ The npm package, CLI, and editor plugins all share the same axis-constraint model, so the same delivery logic runs in your build pipeline, your storefront, or a designer's font editor.
39
+
40
+ **Real numbers** — Inter (wght 100–900) clamped to a Text weight range (400–700), same WOFF2 format so the delta is pure clamping:
41
+
42
+ | Font | Source TTF | Full WOFF2 | Text-clamped WOFF2 |
43
+ |---|---|---|---|
44
+ | Inter | 843 KB | 337 KB | **243 KB** — −28% vs full WOFF2 |
45
+
46
+ Pinning an axis outright (e.g. a fixed width or optical size) removes its masters entirely and saves more.
47
+
48
+ ---
49
+
50
+ ## Usage
51
+
52
+ ### Inspect a font first
53
+
54
+ ```ts
55
+ import { getInstances } from '@overpunch/vf-clamp'
56
+ import { readFile } from 'fs/promises'
57
+
58
+ const font = await readFile('MyFont-VF.ttf')
59
+ const { axes, instances } = await getInstances(font)
60
+
61
+ // axes: [{ tag: 'wght', minimum: 100, default: 400, maximum: 900, name: 'Weight' }, ...]
62
+ // instances:[{ name: 'Regular', coordinates: { wght: 400 } }, ...]
63
+ ```
64
+
65
+ Use the named instances to figure out what to clamp — adjacent instances naturally define the bounds for each output.
66
+
67
+ ### Clamp from named instances
68
+
69
+ ```ts
70
+ import { clampFont } from '@overpunch/vf-clamp'
71
+ import { readFile, writeFile } from 'fs/promises'
72
+
73
+ const source = await readFile('Omnes-VF.ttf')
74
+
75
+ const results = await clampFont(source, {
76
+ outputs: [
77
+ // one VF spanning the full weight range for Condensed
78
+ {
79
+ name: 'Condensed',
80
+ instances: ['Condensed Thin', 'Condensed Black'],
81
+ },
82
+ // one VF for a narrower weight slice of SemiCondensed
83
+ {
84
+ name: 'SemiCondensed Text',
85
+ instances: ['SemiCondensed Light', 'SemiCondensed Bold'],
86
+ },
87
+ ],
88
+ })
89
+
90
+ for (const result of results) {
91
+ await writeFile(`Omnes-${result.name}-VF.ttf`, result.buffer)
92
+ }
93
+ ```
94
+
95
+ ### Clamp with explicit axis constraints
96
+
97
+ ```ts
98
+ const results = await clampFont(source, {
99
+ outputs: [
100
+ // Pin wdth to 75 — axis is removed from the output font
101
+ { name: 'Condensed', axes: { wdth: 75 } },
102
+
103
+ // Restrict wdth to a range — axis stays variable within [87.5, 100]
104
+ { name: 'SemiCondensed', axes: { wdth: { min: 87.5, max: 100 } } },
105
+ ],
106
+ })
107
+ ```
108
+
109
+ ### Mix instances and explicit axes
110
+
111
+ ```ts
112
+ const results = await clampFont(source, {
113
+ format: 'woff2',
114
+ outputs: [
115
+ {
116
+ name: 'Condensed Text',
117
+ instances: ['Condensed Light', 'Condensed Bold'],
118
+ // Clamp opsz independently of the named instance range
119
+ axes: { opsz: { min: 8, max: 24 } },
120
+ },
121
+ ],
122
+ })
123
+
124
+ // result.buffer is a valid WOFF2 file — Brotli-compressed
125
+ await writeFile('Omnes-Condensed-Text-VF.woff2', results[0].buffer)
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Axis value types
131
+
132
+ | Value | Effect |
133
+ |---|---|
134
+ | `number` | Pin the axis to that value — axis is locked and removed from the output |
135
+ | `{ min, max }` | Restrict to a range — axis stays variable within those bounds |
136
+ | `null` | Keep the full original range — same as omitting the axis entirely |
137
+ | *(omitted)* | Keep the full original range — axis is unchanged |
138
+
139
+ ---
140
+
141
+ ## Verifying output
142
+
143
+ Clamping is inspectable — read the result back with `getInstances` and the fvar table reflects the restricted range. Clamping Inter (wght 100–900, 9 instances) to a Text weight range:
144
+
145
+ ```ts
146
+ const [text] = await clampFont(source, {
147
+ outputs: [{ name: 'Text', axes: { wght: { min: 400, max: 700 } } }],
148
+ })
149
+
150
+ const { axes, instances } = await getInstances(text.buffer)
151
+ // axes: wght 400 → 700 (was 100 → 900)
152
+ // instances: Regular, Medium, SemiBold, Bold (the 5 outside the range are gone)
153
+ ```
154
+
155
+ The output is a valid variable font you can drop into a build or hand to a customer: masters outside the range are physically removed, so nothing past the licence is reachable in the file.
156
+
157
+ ---
158
+
159
+ ## API
160
+
161
+ ### `getInstances(input)`
162
+
163
+ ```ts
164
+ async function getInstances(
165
+ input: ArrayBuffer | Uint8Array | Buffer
166
+ ): Promise<FontInstancesResult>
167
+
168
+ interface FontInstancesResult {
169
+ axes: AxisDefinition[]
170
+ instances: FontInstance[]
171
+ }
172
+ ```
173
+
174
+ Reads the fvar table and returns every axis and named instance defined in the font. Use this to discover what can be clamped before building an output config.
175
+
176
+ ### `clampFont(input, options)`
177
+
178
+ ```ts
179
+ async function clampFont(
180
+ input: ArrayBuffer | Uint8Array | Buffer,
181
+ options: ClampOptions
182
+ ): Promise<ClampResult[]>
183
+ ```
184
+
185
+ **Parameters**
186
+
187
+ - `input` — Source variable font binary (TTF, OTF, WOFF, or WOFF2).
188
+ - `options.outputs` — Array of `OutputConfig` entries, one per output variant.
189
+ - `options.format` — `'ttf'` (default), `'otf'`, `'woff'`, or `'woff2'`.
190
+ - `options.normalizeWeightAxis` — When `true`, remaps the wght axis minimum to 100 so that CSS `font-weight: 100` reaches the lightest weight. Useful for fonts whose design space starts above wght 100 (e.g. 250). Defaults to `false`.
191
+
192
+ **Returns**
193
+
194
+ Array of `ClampResult` in the same order as `options.outputs`:
195
+
196
+ ```ts
197
+ interface ClampResult {
198
+ name: string // matches OutputConfig.name (or auto-derived instance range)
199
+ buffer: Uint8Array // restricted font binary
200
+ format: OutputFormat
201
+ }
202
+ ```
203
+
204
+ ### `convertToWoff2(input)`
205
+
206
+ ```ts
207
+ async function convertToWoff2(
208
+ input: Uint8Array | Buffer
209
+ ): Promise<Uint8Array>
210
+ ```
211
+
212
+ Standalone WOFF2 encoder. Wraps the same Brotli-based pipeline used internally by `clampFont`. Useful for converting any TTF/OTF to WOFF2 without clamping.
213
+
214
+ ### `convertToWoff(input)`
215
+
216
+ ```ts
217
+ async function convertToWoff(
218
+ input: Uint8Array | Buffer
219
+ ): Promise<Uint8Array>
220
+ ```
221
+
222
+ Standalone WOFF encoder. Wraps the same zlib-based pipeline used internally by `clampFont`. Useful for converting any TTF/OTF to WOFF without clamping.
223
+
224
+ ### `compactName(first, last)`
225
+
226
+ ```ts
227
+ function compactName(first: string, last: string): string
228
+ ```
229
+
230
+ Produces a compact display name from the first and last selected instance names. Strips shared leading prefix and trailing suffix tokens, joins differing parts with a hyphen.
231
+
232
+ ```ts
233
+ compactName('Inter Light', 'Inter Bold') // → 'Inter Light-Bold'
234
+ compactName('Condensed Thin', 'Condensed Black') // → 'Condensed Thin-Black'
235
+ compactName('Regular', 'Regular') // → 'Regular'
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Types
241
+
242
+ ```ts
243
+ type AxisValue = number | AxisRange | null
244
+
245
+ interface AxisRange {
246
+ min: number
247
+ max: number
248
+ }
249
+
250
+ interface OutputConfig {
251
+ name?: string // label for this output — written into the name table
252
+ instances?: string[] // named instances to hull; hull derived automatically
253
+ axes?: Record<string, AxisValue> // explicit axis constraints; override hull per-tag
254
+ }
255
+
256
+ interface ClampOptions {
257
+ outputs: OutputConfig[]
258
+ format?: 'ttf' | 'otf' | 'woff' | 'woff2' // defaults to 'ttf'
259
+ normalizeWeightAxis?: boolean // remap wght min to 100 for CSS compatibility
260
+ }
261
+
262
+ interface ClampResult {
263
+ name: string
264
+ buffer: Uint8Array
265
+ format: OutputFormat
266
+ }
267
+
268
+ interface AxisDefinition {
269
+ tag: string
270
+ name: string
271
+ minimum: number
272
+ default: number
273
+ maximum: number
274
+ }
275
+
276
+ interface FontInstance {
277
+ name: string
278
+ coordinates: Record<string, number>
279
+ }
280
+
281
+ interface FontInstancesResult {
282
+ axes: AxisDefinition[]
283
+ instances: FontInstance[]
284
+ }
285
+
286
+ // Deprecated alias — use OutputConfig
287
+ type SubfamilyConfig = OutputConfig
288
+ ```
289
+
290
+ ---
291
+
292
+ ## Notes
293
+
294
+ - **Pyodide cold start**: first call initialises the Python WASM runtime (~10–20 s on first use per process). Subsequent calls in the same process reuse the singleton — warm calls are fast (~1–2 s).
295
+ - **Input format**: TTF, OTF, WOFF, and WOFF2 are all accepted as input.
296
+ - **Outputs are processed sequentially** — Pyodide is single-threaded.
297
+ - **Name table patching**: each output font's family name, full name, and PostScript name are updated to reflect the output's name.
298
+ - **Next.js**: add `@overpunch/vf-clamp` to `serverExternalPackages` in `next.config.ts` to prevent webpack bundling the Pyodide runtime.
299
+ - **Vite / other bundlers**: externalise `@overpunch/vf-clamp` and run it server-side or at build time, so the multi-MB Pyodide runtime isn't shipped to the browser.
300
+
301
+ ---
302
+
303
+ ## REST API
304
+
305
+ The delivery layer: wire vf-clamp into a storefront so a purchase event becomes a delivered file. vfclamp.com exposes hosted endpoints — one to read a font's instances, one to clamp and return scoped fonts by URL. Useful for server-side workflows where the font is fetched by URL. Contact [hello@liiift.studio](mailto:hello@liiift.studio) to request an API key.
306
+
307
+ ```
308
+ POST https://vfclamp.com/api/clamp
309
+ X-API-Key: <your-key>
310
+ Content-Type: application/json
311
+
312
+ {
313
+ "fontUrl": "https://cdn.example.com/MyFont-VF.ttf",
314
+ "format": "woff2",
315
+ "outputs": [
316
+ { "name": "Text", "instances": ["Light", "Bold"] },
317
+ { "name": "Condensed", "axes": { "wdth": 75 } }
318
+ ]
319
+ }
320
+ // → { results: [{ name, data, format, size }] }
321
+
322
+ POST https://vfclamp.com/api/instances
323
+ X-API-Key: <your-key>
324
+
325
+ { "fontUrl": "https://cdn.example.com/MyFont-VF.ttf" }
326
+ // → { axes: [...], instances: [...] }
327
+ ```
328
+
329
+ **Performance & limits.** The hosted endpoints keep the runtime warm, so a typical clamp returns in ~1–2 s (a cold instance adds the one-time ~10–20 s Pyodide init). Rate limits, concurrency, and uptime depend on your API plan — ask when you request a key.
330
+
331
+ ---
332
+
333
+ ## Running at scale
334
+
335
+ Pyodide is single-threaded and warms up once per process (~10–20 s cold, then ~1–2 s per call). In a storefront, **don't clamp inside the request handler** and don't drive one instance from parallel requests — serialise through a warm worker, and scale out with a pool of processes:
336
+
337
+ ```ts
338
+ import PQueue from 'p-queue'
339
+ import { clampFont } from '@overpunch/vf-clamp'
340
+
341
+ // one warm engine, requests queued — the checkout response isn't blocked on the clamp
342
+ const queue = new PQueue({ concurrency: 1 }) // the engine is single-threaded
343
+
344
+ export function enqueueClamp(source, options) {
345
+ return queue.add(() => clampFont(source, options))
346
+ }
347
+ ```
348
+
349
+ For higher throughput, run **N worker processes** (each with its own warm Pyodide) behind a job queue or round-robin — concurrency scales with processes, not threads — or offload entirely to the [hosted REST API](#rest-api).
350
+
351
+ ---
352
+
353
+ ## Integrations
354
+
355
+ vf-clamp is available as a CLI and as native plugins for Glyphs.app, RoboFont, and VS Code — all using the same axis-constraint model as the npm package.
356
+
357
+ | Integration | Distribution |
358
+ |---|---|
359
+ | [vf-clamp-cli](https://github.com/over-punch/vf-clamp-cli) | `npm install -g @overpunch/vf-clamp-cli` |
360
+ | [vf-clamp-glyphs](https://github.com/over-punch/vf-clamp-glyphs) | `.glyphsPlugin` download |
361
+ | [vf-clamp-robofont](https://github.com/over-punch/vf-clamp-robofont) | `.roboFontExt` download |
362
+ | [vf-clamp-vscode](https://github.com/over-punch/vf-clamp-vscode) | `.vsix` download / VS Code Marketplace |
363
+
364
+ The CLI in action — inspect a font, then clamp it:
365
+
366
+ ![vf-clamp CLI: inspecting Inter's axes and 9 named instances, then clamping Regular–Bold to a Text WOFF2](https://raw.githubusercontent.com/over-punch/vf-clamp-cli/main/assets/demo.gif?v=1)
367
+
368
+ ---
369
+
370
+ ## License
371
+
372
+ MIT — [Liiift Studio](https://liiift.studio)
package/dist/index.cjs ADDED
@@ -0,0 +1,287 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const N=require("node:module");var g=typeof document<"u"?document.currentScript:null;const O=N.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:g&&g.tagName.toUpperCase()==="SCRIPT"&&g.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:l,PyodideFile:u}=O("@web-alchemy/fonttools/src/pyodide.js");let h=null;async function j(){return h||(h=l().then(e=>e.runPythonAsync(`
2
+ from fontTools.ttLib import TTFont
3
+
4
+ def vf_convert_flavor(file_options):
5
+ font = TTFont(file_options['input-file'])
6
+ font.flavor = file_options['flavor']
7
+ font.save(file_options['output-file'])
8
+
9
+ vf_convert_flavor
10
+ `))),h}async function V(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename],["flavor",o]]);return(await j())(i),n.download()}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function I(e){return V(e,"woff")}async function D(e){return V(e,"woff2")}let w=null;async function L(){return w||(w=l().then(e=>e.runPythonAsync(`
11
+ import json
12
+ from fontTools.ttLib import TTFont
13
+
14
+ def get_instances_fn(input_file):
15
+ font = TTFont(input_file)
16
+
17
+ if 'fvar' not in font:
18
+ return json.dumps({'axes': [], 'instances': []})
19
+
20
+ fvar = font['fvar']
21
+ name_table = font['name']
22
+
23
+ axes = [
24
+ {
25
+ 'tag': ax.axisTag,
26
+ 'name': name_table.getDebugName(ax.axisNameID) or ax.axisTag,
27
+ 'minimum': ax.minValue,
28
+ 'default': ax.defaultValue,
29
+ 'maximum': ax.maxValue,
30
+ }
31
+ for ax in fvar.axes
32
+ ]
33
+
34
+ instances = [
35
+ {
36
+ 'name': name_table.getDebugName(inst.subfamilyNameID) or f'Instance {i}',
37
+ 'coordinates': dict(inst.coordinates),
38
+ }
39
+ for i, inst in enumerate(fvar.instances)
40
+ ]
41
+
42
+ return json.dumps({'axes': axes, 'instances': instances})
43
+
44
+ get_instances_fn
45
+ `))),w}async function R(e){const o=await l(),a=e instanceof ArrayBuffer?new Uint8Array(e):e,t=new u({pyodide:o});try{await t.upload(a);const i=(await L())(t.filename);return JSON.parse(i)}finally{try{t.delete()}catch{}}}let v=null,y=null,A=null,T=null,b=null;async function M(){return v||(v=l().then(e=>e.runPythonAsync(`
46
+ from fontTools import ttLib
47
+
48
+ def patch_font_names_fn(file_options):
49
+ font = ttLib.TTFont(file_options['input-file'])
50
+ name_table = font['name']
51
+ family_name = file_options['family-name']
52
+ ps_name = file_options['postscript-name']
53
+
54
+ existing_ids = {r.nameID for r in name_table.names}
55
+
56
+ # nameID 1 = Family, 4 = Full name, 6 = PostScript name
57
+ # nameID 2 = Subfamily — reset to 'Regular' so the restricted file does not
58
+ # collide with the source font's Subfamily in OS font caches.
59
+ # nameID 3 = Unique ID — regenerate so it is distinct from the source font.
60
+ # nameID 16 = Preferred family (update only if present)
61
+ # nameID 25 = Variations PS Name Prefix (update only if present)
62
+ #
63
+ # head.fontRevision is a Fixed value (e.g. 1.000). Fall back to '1.000' if
64
+ # the head table is unreadable.
65
+ try:
66
+ version = '%.3f' % font['head'].fontRevision
67
+ except Exception:
68
+ version = '1.000'
69
+ unique_id = '%s;%s;%s' % (version, ps_name, family_name)
70
+
71
+ updates = {
72
+ 1: family_name,
73
+ 2: 'Regular',
74
+ 3: unique_id,
75
+ 4: family_name,
76
+ 6: ps_name,
77
+ }
78
+ if 16 in existing_ids:
79
+ updates[16] = family_name
80
+ if 25 in existing_ids:
81
+ updates[25] = ps_name
82
+
83
+ for record in name_table.names:
84
+ if record.nameID not in updates:
85
+ continue
86
+ value = updates[record.nameID]
87
+ if record.platformID == 3:
88
+ record.string = value.encode('utf-16-be')
89
+ elif record.platformID == 1:
90
+ try:
91
+ record.string = value.encode('mac_roman')
92
+ except Exception:
93
+ record.string = value.encode('ascii', errors='replace')
94
+
95
+ font.save(file_options['output-file'])
96
+
97
+ patch_font_names_fn
98
+ `))),v}async function U(){return y||(y=l().then(e=>e.runPythonAsync(`
99
+ from fontTools.ttLib import TTFont
100
+ from fontTools.varLib import instancer
101
+ import json
102
+
103
+ def vf_clamp_instantiate(file_options, axes_json):
104
+ font = TTFont(file_options['input-file'])
105
+ axes_spec = json.loads(axes_json)
106
+
107
+ limits = {}
108
+ axes_by_tag = {ax.axisTag: ax for ax in font['fvar'].axes}
109
+
110
+ for tag, spec in axes_spec.items():
111
+ if spec is None:
112
+ continue
113
+ ax = axes_by_tag.get(tag)
114
+ if isinstance(spec, list):
115
+ mn = float(spec[0])
116
+ mx = float(spec[1])
117
+ default = ax.defaultValue if ax else (mn + mx) / 2
118
+ default = max(mn, min(mx, default))
119
+ limits[tag] = instancer.AxisTriple(mn, default, mx)
120
+ else:
121
+ limits[tag] = float(spec)
122
+
123
+ partial = instancer.instantiateVariableFont(font, limits)
124
+ partial.save(file_options['output-file'])
125
+
126
+ vf_clamp_instantiate
127
+ `))),y}async function k(){return A||(A=l().then(e=>e.runPythonAsync(`
128
+ from fontTools.ttLib import TTFont
129
+
130
+ def vf_clamp_normalize_wght(file_options, new_min_str):
131
+ new_min = float(new_min_str)
132
+ font = TTFont(file_options['input-file'])
133
+
134
+ if 'fvar' not in font:
135
+ font.save(file_options['output-file'])
136
+ return
137
+
138
+ wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
139
+
140
+ # Only normalize when the font's minimum is above the target (e.g. 251 > 100)
141
+ if wght_axis is None or wght_axis.minValue <= new_min:
142
+ font.save(file_options['output-file'])
143
+ return
144
+
145
+ old_min = wght_axis.minValue
146
+ default = wght_axis.defaultValue
147
+ scale = (default - new_min) / (default - old_min)
148
+
149
+ def remap(v):
150
+ return (default + (v - default) * scale) if v < default else v
151
+
152
+ # Update fvar axis minimum
153
+ wght_axis.minValue = new_min
154
+
155
+ # Update named instance wght coordinates
156
+ for inst in font['fvar'].instances:
157
+ if 'wght' in inst.coordinates:
158
+ inst.coordinates['wght'] = remap(inst.coordinates['wght'])
159
+
160
+ # Update STAT axis values that reference wght
161
+ if 'STAT' in font and font['STAT'].table.AxisValueArray:
162
+ stat = font['STAT'].table
163
+ wght_idx = None
164
+ if hasattr(stat, 'DesignAxisRecord') and stat.DesignAxisRecord:
165
+ for i, ax in enumerate(stat.DesignAxisRecord.Axis):
166
+ if ax.AxisTag == 'wght':
167
+ wght_idx = i
168
+ break
169
+ if wght_idx is not None:
170
+ for av in stat.AxisValueArray.AxisValue:
171
+ fmt = av.Format
172
+ if fmt in (1, 3) and av.AxisIndex == wght_idx:
173
+ av.Value = remap(av.Value)
174
+ if fmt == 3:
175
+ av.LinkedValue = remap(av.LinkedValue)
176
+ elif fmt == 2 and av.AxisIndex == wght_idx:
177
+ av.NominalValue = remap(av.NominalValue)
178
+ av.RangeMinValue = remap(av.RangeMinValue)
179
+ av.RangeMaxValue = remap(av.RangeMaxValue)
180
+
181
+ font.save(file_options['output-file'])
182
+
183
+ vf_clamp_normalize_wght
184
+ `))),A}async function C(){return T||(T=l().then(e=>e.runPythonAsync(`
185
+ from fontTools.ttLib import TTFont
186
+
187
+ def vf_clamp_prune_stat(file_options):
188
+ font = TTFont(file_options['input-file'])
189
+
190
+ if 'STAT' not in font or 'fvar' not in font:
191
+ font.save(file_options['output-file'])
192
+ return
193
+
194
+ stat = font['STAT'].table
195
+ if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
196
+ font.save(file_options['output-file'])
197
+ return
198
+
199
+ fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
200
+ stat_axes = list(stat.DesignAxisRecord.Axis)
201
+
202
+ # Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
203
+ surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
204
+ if len(surviving_indices) == len(stat_axes):
205
+ # No axes removed — nothing to prune.
206
+ font.save(file_options['output-file'])
207
+ return
208
+ old_to_new = {old: new for new, old in enumerate(surviving_indices)}
209
+
210
+ # Rewrite the DesignAxisRecord.Axis list to surviving axes only.
211
+ stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
212
+ if hasattr(stat, 'DesignAxisCount'):
213
+ stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
214
+
215
+ # Prune AxisValueArray entries that reference removed axes.
216
+ if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
217
+ kept = []
218
+ for av in stat.AxisValueArray.AxisValue:
219
+ fmt = av.Format
220
+ if fmt in (1, 2, 3):
221
+ if av.AxisIndex not in old_to_new:
222
+ continue # references a removed axis
223
+ av.AxisIndex = old_to_new[av.AxisIndex]
224
+ kept.append(av)
225
+ elif fmt == 4:
226
+ inner = list(getattr(av, 'AxisValueRecord', []) or [])
227
+ if any(rec.AxisIndex not in old_to_new for rec in inner):
228
+ continue # whole record dies if any inner ref is dead
229
+ for rec in inner:
230
+ rec.AxisIndex = old_to_new[rec.AxisIndex]
231
+ kept.append(av)
232
+ else:
233
+ # Unknown format — keep as-is to be safe.
234
+ kept.append(av)
235
+ stat.AxisValueArray.AxisValue = kept
236
+ if hasattr(stat, 'AxisValueCount'):
237
+ stat.AxisValueCount = len(kept)
238
+
239
+ font.save(file_options['output-file'])
240
+
241
+ vf_clamp_prune_stat
242
+ `))),T}async function $(){return b||(b=l().then(e=>e.runPythonAsync(`
243
+ from fontTools.ttLib import TTFont
244
+
245
+ def vf_clamp_update_os2(file_options):
246
+ font = TTFont(file_options['input-file'])
247
+
248
+ if 'fvar' not in font:
249
+ font.save(file_options['output-file'])
250
+ return
251
+
252
+ wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
253
+ if wght_axis is None:
254
+ font.save(file_options['output-file'])
255
+ return
256
+
257
+ new_default = wght_axis.defaultValue
258
+ # OS/2.usWeightClass valid range is 1..1000.
259
+ weight_class = int(round(max(1, min(1000, new_default))))
260
+
261
+ if 'OS/2' in font:
262
+ os2 = font['OS/2']
263
+ os2.usWeightClass = weight_class
264
+ # fsSelection bits: 0x20 = BOLD, 0x40 = REGULAR.
265
+ # Mirror the convention: REGULAR when usWeightClass < 600, BOLD when >= 700.
266
+ fs = os2.fsSelection
267
+ fs &= ~(0x20 | 0x40)
268
+ if weight_class >= 700:
269
+ fs |= 0x20 # BOLD
270
+ elif weight_class < 600:
271
+ fs |= 0x40 # REGULAR
272
+ os2.fsSelection = fs
273
+
274
+ if 'head' in font:
275
+ head = font['head']
276
+ # macStyle bit 0 = bold.
277
+ ms = head.macStyle
278
+ if weight_class >= 700:
279
+ ms |= 0x01
280
+ else:
281
+ ms &= ~0x01
282
+ head.macStyle = ms
283
+
284
+ font.save(file_options['output-file'])
285
+
286
+ vf_clamp_update_os2
287
+ `))),b}function z(e){return e.replace(/[^A-Za-z0-9 -]/g,"").trim().split(/\s+/).join("-").replace(/^-+|-+$/g,"").slice(0,63)}async function W(e,o){if(!o)return e;const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{const i=z(o);await t.upload(e);const r=new Map([["input-file",t.filename],["output-file",n.filename],["family-name",o],["postscript-name",i]]);return(await M())(r),n.download()}catch(i){return console.warn(`vf-clamp: name table patching failed for "${o}" — returning unpatched buffer`,i),e}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}function q(e){return typeof e=="number"?e:e===null?null:[e.min,e.max]}async function B(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await U())(i,JSON.stringify(o)),n.download()}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function E(e){const o=await l(),a=new u({pyodide:o}),t=new u({pyodide:o});try{await a.upload(e);const n=new Map([["input-file",a.filename],["output-file",t.filename]]);return(await C())(n),t.download()}catch(n){return console.warn("vf-clamp: STAT prune failed — returning unpruned buffer",n),e}finally{try{a.delete()}catch{}try{t.delete()}catch{}}}async function G(e){const o=await l(),a=new u({pyodide:o}),t=new u({pyodide:o});try{await a.upload(e);const n=new Map([["input-file",a.filename],["output-file",t.filename]]);return(await $())(n),t.download()}catch(n){return console.warn("vf-clamp: OS/2 + macStyle update failed — returning original buffer",n),e}finally{try{a.delete()}catch{}try{t.delete()}catch{}}}async function J(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await k())(i,String(o)),n.download()}catch(i){return console.warn("vf-clamp: wght normalisation failed — returning unnormalised buffer",i),e}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}function H(e,o){const a=new Map(o.map(i=>[i.name,i])),t={};for(const i of e){const r=a.get(i);if(!r)throw new Error(`Named instance "${i}" not found in font`);for(const[s,m]of Object.entries(r.coordinates))t[s]?(t[s].min=Math.min(t[s].min,m),t[s].max=Math.max(t[s].max,m)):t[s]={min:m,max:m}}const n={};for(const[i,{min:r,max:s}]of Object.entries(t))n[i]=r===s?r:{min:r,max:s};return n}async function Z(e,o){var s,m;if(o.outputs.length===0)return[];const a=e instanceof ArrayBuffer?new Uint8Array(e):e,t=o.format??"ttf";let n=[],i=[];if(o.outputs.some(f=>{var p;return(p=f.instances)==null?void 0:p.length})){const f=await R(a);n=f.instances,i=f.axes}const r=[];for(const f of o.outputs){let p={};(s=f.instances)!=null&&s.length&&(p=H(f.instances,n)),f.axes&&(p={...p,...f.axes});for(const d of i){const x=p[d.tag];if(x!=null&&typeof x=="object"){const _=x;if(d.default<_.min||d.default>_.max){const P=Math.max(_.min,Math.min(_.max,d.default));console.warn(`vf-clamp: axis "${d.tag}" default (${d.default}) is outside restricted range [${_.min}, ${_.max}] — will be clamped to ${P}`)}}}const F={};for(const[d,x]of Object.entries(p))x!==null&&(F[d]=q(x));const S=f.name??((m=f.instances)!=null&&m.length?f.instances.length===1?f.instances[0]:`${f.instances[0]}-${f.instances[f.instances.length-1]}`:"output");let c=await B(a,F);c=await E(c),o.normalizeWeightAxis&&(c=await J(c,100)),c=await G(c),c=await W(c,S),t==="woff2"?c=await D(c):t==="woff"&&(c=await I(c)),r.push({name:S,buffer:c,format:t})}return r}function K(e,o){if(e===o)return e;const a=e.split(" "),t=o.split(" ");let n=0;for(;n<a.length&&n<t.length&&a[n]===t[n];)n++;let i=0;for(;i<a.length-n&&i<t.length-n&&a[a.length-1-i]===t[t.length-1-i];)i++;const r=a.slice(0,n).join(" "),s=a.slice(n,a.length-(i||0)).join(" "),m=t.slice(n,t.length-(i||0)).join(" "),f=i>0?a.slice(a.length-i).join(" "):"",p=s&&m?`${s}-${m}`:s||m;return[r,p,f].filter(Boolean).join(" ")}exports.clampFont=Z;exports.compactName=K;exports.convertToWoff=I;exports.convertToWoff2=D;exports.getInstances=R;