@overpunch/vf-clamp 2.1.4 → 2.3.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/README.md CHANGED
@@ -1,372 +1,539 @@
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)
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 variation outside the purchase is removed](https://raw.githubusercontent.com/over-punch/vf-clamp/main/assets/design-space.png?v=2)
14
+
15
+ **Choose your path**
16
+
17
+ | You are… | Start here |
18
+ |---|---|
19
+ | A type designer or foundry | [For type designers](#for-type-designers) — plugins for Glyphs and RoboFont, no code |
20
+ | Building a storefront's fulfilment step | [Selling named styles safely](#selling-named-styles-safely) and the [REST API](#rest-api) |
21
+ | A web developer trimming fonts | [Quickstart](#quickstart) |
22
+ | Curious why this matters | The talk and paper, [*Sell the Styles, Ship the Space*](https://vfclamp.com/paper) |
23
+
24
+ ---
25
+
26
+ ## What it does
27
+
28
+ Takes a variable font (TTF, OTF, WOFF, or WOFF2) and produces one restricted variant per configured output. Each variant is a valid variable font whose axis ranges are restricted (variation data outside the range is dropped and the rest rescaled), whose named instances and STAT entries outside the range are removed, and whose name table is updated to reflect the restricted instance range. See [What it does to the font](#what-it-does-to-the-font) for the exact changes. No Python required — powered by [fonttools](https://github.com/fonttools/fonttools) compiled to WASM via [Pyodide](https://pyodide.org).
29
+
30
+ ---
31
+
32
+ ## For foundries
33
+
34
+ A variable font is usually all-or-nothing: customers buy the whole family to get one, or they buy statics and lose interpolation. A survey of 394 foundries found 22 that sell subfamily variable fonts and none that scope one to the styles a customer bought ([paper](https://vfclamp.com/paper), [data](https://vfclamp.com/paper/data)). vf-clamp adds the tier in between — a variable font scoped to exactly the named instances a customer purchased, generated and delivered at checkout.
35
+
36
+ **Purchase → Clamp → Deliver.** A customer buys two or more adjacent styles ([`planOutputs`](#selling-named-styles-safely) splits any other selection so no unbought style is handed over); 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.
37
+
38
+ Why it matters:
39
+
40
+ - **A new revenue tier** — two adjacent styles become a variable purchase, not just two statics. Price a ladder: two-style VF → subfamily → full family.
41
+ - **Licence scope you can see** — a full VF exposes every weight, including ones the customer never paid for. A clamped VF's axes, named instances and STAT entries stop at the purchased range, so the file matches the invoice. (A determined user could still extrapolate simple two-master designs past the range; the licence's terms do the enforcing.)
42
+ - **Named for the purchase** — the name table (family, full name, PostScript name) is rewritten to the purchased range, so the file is identifiable as that range. It does not identify the order: the unique ID (name ID 3) is rewritten to `version;PostScriptName;family`, which depends only on the output name and is the same for every buyer of that range, so add a watermark or per-order ID at fulfilment if you need tracing.
43
+ - **Lighter files for the web** — a site that uses only Medium–Black shouldn't ship Thin–Light deadweight. Clamping drops the variation data outside the licensed range: variation across what they bought, at a smaller download — from two styles up, smaller than the statics themselves (see below).
44
+ - **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.
45
+ - **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.
46
+
47
+ 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.
48
+
49
+ **Real numbers** — Inter (wght 100–900) clamped to a Text weight range (400–700), same WOFF2 format so the delta is pure clamping:
50
+
51
+ | Font | Source TTF | Full WOFF2 | Text-clamped WOFF2 |
52
+ |---|---|---|---|
53
+ | Inter | 843 KB | 337 KB | **243 KB** — −28% vs full WOFF2 |
54
+
55
+ Pinning an axis outright (e.g. a fixed width or optical size) removes that axis and its variation data entirely and saves more.
56
+
57
+ Against the static files a two-style buyer would otherwise get (Inter 4, fontTools instancer, WOFF2, October 2026 — [method](https://vfclamp.com/paper#method)):
58
+
59
+ | Inter, Regular + Bold | Two statics | Clamped VF (wght 400–700, opsz pinned) |
60
+ |---|---|---|
61
+ | Full character set | 226 KB | **173 KB** |
62
+ | Latin subset | 64,104 B | **50,168 B** (−22%) |
63
+
64
+ At seven styles the clamped VF is 72% smaller than the statics. Keeping a free axis such as `opsz` variable costs size: worth it from about three styles up.
65
+
66
+ ### For type designers
67
+
68
+ You don't need to write code:
69
+
70
+ - **Glyphs.app or RoboFont** — install the [Glyphs plugin](https://github.com/over-punch/vf-clamp-glyphs#installation) or [RoboFont extension](https://github.com/over-punch/vf-clamp-robofont) (install steps are in each repo's README; screenshots on [vfclamp.com](https://vfclamp.com/integrations/glyphs-robofont)). Open your source or any exported variable font, tick the named instances a customer licensed, and export: the plugin names the file for the range (e.g. *Encode Sans Light-Bold*) and writes TTF, OTF, WOFF or WOFF2. Doing this automatically at checkout needs the [REST API](#rest-api) or the npm package, which means a developer.
71
+ - **Try it in a browser** — the [demo at vfclamp.com](https://vfclamp.com) loads Encode Sans or any variable font you drop in, lets you pick styles as if placing an order, and downloads the result.
72
+ - **What your customer sees** — on macOS (CoreText, which Pages and Keynote use), a file clamped to Regular–Bold lists Regular, Medium, SemiBold and Bold in the font menu, with a 400–700 weight axis; apps with sliders (InDesign, Figma) show a 400–700 slider. Word on Windows is not yet tested. Ship the statics alongside — many apps still prefer them.
73
+ - **Licensing** — most licences don't mention variable fonts yet. The paper's [*Licensing language*](https://vfclamp.com/paper#licensing-language) section sets out a four-part range licence (scope on the invoice, a grant for instances inside it, an optimisation right, and a fence against widening) with real clauses from BAL, Displaay, NaN and Dalton Maag.
74
+
75
+ ---
76
+
77
+ ## Quickstart
78
+
79
+ Node.js only (tested on Node 24), at build time or on a server — never in the browser. The first call starts the Pyodide runtime (~10–20 s); later calls in the same process take ~1–2 s.
80
+
81
+ ```ts
82
+ import { clampFont } from '@overpunch/vf-clamp'
83
+ import { readFile, writeFile } from 'fs/promises'
84
+
85
+ // Inter is free (Google Fonts); the repo's fixtures/Inter-Variable.ttf works too
86
+ const source = await readFile('Inter-Variable.ttf')
87
+
88
+ const [text] = await clampFont(source, {
89
+ format: 'woff2',
90
+ outputs: [{ name: 'Inter Text', axes: { wght: { min: 400, max: 700 } } }],
91
+ })
92
+
93
+ await writeFile('Inter-Text.woff2', text.buffer)
94
+ ```
95
+
96
+ ```css
97
+ @font-face {
98
+ font-family: 'Inter Text';
99
+ src: url('/fonts/Inter-Text.woff2') format('woff2');
100
+ font-weight: 400 700; /* declare the clamped range: font-weight: 900 then renders at Bold instead of a synthesised bold */
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Usage
107
+
108
+ ### Inspect a font first
109
+
110
+ ```ts
111
+ import { getInstances } from '@overpunch/vf-clamp'
112
+ import { readFile } from 'fs/promises'
113
+
114
+ const font = await readFile('MyFont-VF.ttf')
115
+ const { axes, instances } = await getInstances(font)
116
+
117
+ // axes: [{ tag: 'wght', minimum: 100, default: 400, maximum: 900, name: 'Weight' }, ...]
118
+ // instances:[{ name: 'Regular', coordinates: { wght: 400 } }, ...]
119
+ ```
120
+
121
+ Use the named instances to figure out what to clamp — adjacent instances naturally define the bounds for each output. Instance names must match exactly (case-sensitive); an unknown name throws `Named instance "X" not found in font`.
122
+
123
+ ### Clamp from named instances
124
+
125
+ ```ts
126
+ import { clampFont } from '@overpunch/vf-clamp'
127
+ import { readFile, writeFile } from 'fs/promises'
128
+
129
+ const source = await readFile('Omnes-VF.ttf')
130
+
131
+ const results = await clampFont(source, {
132
+ outputs: [
133
+ // one VF spanning the full weight range for Condensed
134
+ {
135
+ name: 'Omnes Condensed', // written into the name table as the family name — include the family
136
+ instances: ['Condensed Thin', 'Condensed Black'],
137
+ },
138
+ // one VF for a narrower weight slice of SemiCondensed
139
+ {
140
+ name: 'Omnes SemiCondensed Text',
141
+ instances: ['SemiCondensed Light', 'SemiCondensed Bold'],
142
+ },
143
+ ],
144
+ })
145
+
146
+ for (const result of results) {
147
+ await writeFile(`${result.name.replace(/ /g, '-')}-VF.ttf`, result.buffer)
148
+ }
149
+ ```
150
+
151
+ ### Selling named styles safely
152
+
153
+ `clampFont` hulls whatever instances you pass: give it Light and Black and the output spans everything between, including styles nobody paid for. For a storefront, turn the customer's selection into outputs with `planOutputs`, which merges styles into one file only when no unselected named instance falls inside the combined range:
154
+
155
+ ```ts
156
+ import { clampFont, getInstances, planOutputs } from '@overpunch/vf-clamp'
157
+
158
+ const font = await getInstances(source)
159
+
160
+ planOutputs(font, ['Regular', 'Medium', 'SemiBold', 'Bold'], 'Inter')
161
+ // → [{ name: 'Inter Regular-Bold', instances: ['Regular', 'Medium', 'SemiBold', 'Bold'] }] one VF
162
+
163
+ planOutputs(font, ['Regular', 'Bold'], 'Inter')
164
+ // → [{ name: 'Inter Regular', … }, { name: 'Inter Bold', … }] two files — Medium and SemiBold were not bought
165
+
166
+ const bought = ['Regular', 'Medium', 'SemiBold', 'Bold'] // the styles on the order
167
+ const results = await clampFont(source, { outputs: planOutputs(font, bought, 'Inter'), strict: true })
168
+ ```
169
+
170
+ `strict: true` makes `clampFont` throw rather than build an output that would include unselected named instances, as a backstop for hand-written configs. `unboughtInstances(font, names)` lists what a given set would give away (`['Medium', 'SemiBold']` for Regular + Bold) if you would rather price the span than split it.
171
+
172
+ ### Clamp with explicit axis constraints
173
+
174
+ ```ts
175
+ const results = await clampFont(source, {
176
+ outputs: [
177
+ // Pin wdth to 75 — axis is removed from the output font
178
+ { name: 'Condensed', axes: { wdth: 75 } },
179
+
180
+ // Restrict wdth to a range — axis stays variable within [87.5, 100]
181
+ { name: 'SemiCondensed', axes: { wdth: { min: 87.5, max: 100 } } },
182
+ ],
183
+ })
184
+ ```
185
+
186
+ ### Mix instances and explicit axes
187
+
188
+ ```ts
189
+ const results = await clampFont(source, {
190
+ format: 'woff2',
191
+ outputs: [
192
+ {
193
+ name: 'Condensed Text',
194
+ instances: ['Condensed Light', 'Condensed Bold'],
195
+ // Clamp opsz independently of the named instance range
196
+ axes: { opsz: { min: 8, max: 24 } },
197
+ },
198
+ ],
199
+ })
200
+
201
+ // result.buffer is a valid WOFF2 file — Brotli-compressed
202
+ await writeFile('Omnes-Condensed-Text-VF.woff2', results[0].buffer)
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Axis value types
208
+
209
+ | Value | Effect |
210
+ |---|---|
211
+ | `number` | Pin the axis to that value — axis is locked and removed from the output |
212
+ | `{ min, max }` | Restrict to a range — axis stays variable within those bounds |
213
+ | `null` | Keep the full original range — same as omitting the axis entirely |
214
+ | *(omitted)* | Keep the full original range — axis is unchanged |
215
+
216
+ ---
217
+
218
+ ## Verifying output
219
+
220
+ 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:
221
+
222
+ ```ts
223
+ const [text] = await clampFont(source, {
224
+ outputs: [{ name: 'Text', axes: { wght: { min: 400, max: 700 } } }],
225
+ })
226
+
227
+ const { axes, instances } = await getInstances(text.buffer)
228
+ // axes: wght 400 → 700 (was 100 → 900)
229
+ // instances: Regular, Medium, SemiBold, Bold (the 5 outside the range are gone)
230
+ ```
231
+
232
+ The output is a valid variable font you can drop into a build or hand to a customer: variation data is restricted to the range, and named instances and STAT values outside it are removed, so font menus show only what was bought.
233
+
234
+ ---
235
+
236
+ ## API
237
+
238
+ ### `getInstances(input)`
239
+
240
+ ```ts
241
+ async function getInstances(
242
+ input: ArrayBuffer | Uint8Array | Buffer
243
+ ): Promise<FontInstancesResult>
244
+
245
+ interface FontInstancesResult {
246
+ axes: AxisDefinition[]
247
+ instances: FontInstance[]
248
+ }
249
+ ```
250
+
251
+ 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.
252
+
253
+ ### `clampFont(input, options)`
254
+
255
+ ```ts
256
+ async function clampFont(
257
+ input: ArrayBuffer | Uint8Array | Buffer,
258
+ options: ClampOptions
259
+ ): Promise<ClampResult[]>
260
+ ```
261
+
262
+ **Parameters**
263
+
264
+ - `input` — Source variable font binary (TTF, OTF, WOFF, or WOFF2).
265
+ - `options.outputs` — Array of `OutputConfig` entries, one per output variant.
266
+ - `options.format` — `'ttf'` (default), `'otf'`, `'woff'`, or `'woff2'`. `'otf'` does not convert outlines: it requires a CFF/CFF2 source and throws for a TrueType-outline font.
267
+ - `options.strict` — When `true`, throw instead of building an output whose range would include unselected named instances. An axes-only output selects none, so under `strict` it passes only if its range holds no named instance. Defaults to `false`.
268
+ - `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`.
269
+
270
+ **Throws** if an instance name is not found or is shared by several instances (ambiguous), if `strict` rejects an output, if `'otf'` is requested for a TrueType-outline font, if the font uses avar version 2 or VARC, or if any post-processing step (OS/2 update, weight normalisation, name patching) fails — a half-processed file that still carries the retail family name is never returned.
271
+
272
+ **Returns**
273
+
274
+ Array of `ClampResult` in the same order as `options.outputs`:
275
+
276
+ ```ts
277
+ interface ClampResult {
278
+ name: string // matches OutputConfig.name (or auto-derived instance range)
279
+ buffer: Uint8Array // restricted font binary
280
+ format: OutputFormat
281
+ }
282
+ ```
283
+
284
+ ### `planOutputs(font, selected, family?)`
285
+
286
+ ```ts
287
+ function planOutputs(
288
+ font: FontInstancesResult, // from getInstances()
289
+ selected: string[], // instance names the customer bought
290
+ family?: string // optional prefix for output names, e.g. 'Inter'
291
+ ): OutputConfig[]
292
+ ```
293
+
294
+ Groups selected named instances into outputs so that no output's range contains an unselected named instance. Outputs are sorted along the font's widest axis and named with `compactName`. Throws on an unknown instance name. See [Selling named styles safely](#selling-named-styles-safely).
295
+
296
+ ### `unboughtInstances(font, names)`
297
+
298
+ ```ts
299
+ function unboughtInstances(font: FontInstancesResult, names: string[]): string[]
300
+ ```
301
+
302
+ Returns the named instances a single output built from `names` would include without their being listed. Empty means the output is purchase-safe.
303
+
304
+ ### `convertToWoff2(input)`
305
+
306
+ ```ts
307
+ async function convertToWoff2(
308
+ input: Uint8Array | Buffer
309
+ ): Promise<Uint8Array>
310
+ ```
311
+
312
+ Standalone WOFF2 encoder. Wraps the same Brotli-based pipeline used internally by `clampFont`. Useful for converting any TTF/OTF to WOFF2 without clamping.
313
+
314
+ ### `convertToWoff(input)`
315
+
316
+ ```ts
317
+ async function convertToWoff(
318
+ input: Uint8Array | Buffer
319
+ ): Promise<Uint8Array>
320
+ ```
321
+
322
+ Standalone WOFF encoder. Wraps the same zlib-based pipeline used internally by `clampFont`. Useful for converting any TTF/OTF to WOFF without clamping.
323
+
324
+ ### `compactName(first, last)`
325
+
326
+ ```ts
327
+ function compactName(first: string, last: string): string
328
+ ```
329
+
330
+ 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.
331
+
332
+ ```ts
333
+ compactName('Inter Light', 'Inter Bold') // → 'Inter Light-Bold'
334
+ compactName('Condensed Thin', 'Condensed Black') // → 'Condensed Thin-Black'
335
+ compactName('Regular', 'Regular') // → 'Regular'
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Types
341
+
342
+ ```ts
343
+ type AxisValue = number | AxisRange | null
344
+
345
+ interface AxisRange {
346
+ min: number
347
+ max: number
348
+ }
349
+
350
+ interface OutputConfig {
351
+ name?: string // label for this output — written into the name table
352
+ instances?: string[] // named instances to hull; hull derived automatically
353
+ axes?: Record<string, AxisValue> // explicit axis constraints; override hull per-tag
354
+ }
355
+
356
+ interface ClampOptions {
357
+ outputs: OutputConfig[]
358
+ format?: 'ttf' | 'otf' | 'woff' | 'woff2' // defaults to 'ttf'
359
+ normalizeWeightAxis?: boolean // remap wght min to 100 for CSS compatibility
360
+ strict?: boolean // throw if an output would include unselected instances
361
+ }
362
+
363
+ interface ClampResult {
364
+ name: string
365
+ buffer: Uint8Array
366
+ format: OutputFormat
367
+ }
368
+
369
+ interface AxisDefinition {
370
+ tag: string
371
+ name: string
372
+ minimum: number
373
+ default: number
374
+ maximum: number
375
+ }
376
+
377
+ interface FontInstance {
378
+ name: string
379
+ coordinates: Record<string, number>
380
+ }
381
+
382
+ interface FontInstancesResult {
383
+ axes: AxisDefinition[]
384
+ instances: FontInstance[]
385
+ }
386
+
387
+ // Deprecated alias — use OutputConfig
388
+ type SubfamilyConfig = OutputConfig
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Notes
394
+
395
+ - **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).
396
+ - **Input format**: TTF, OTF, WOFF, and WOFF2 are all accepted as input.
397
+ - **Outputs are processed sequentially** — Pyodide is single-threaded.
398
+ - **Name table patching**: each output font's family (IDs 1 and 16), full name (4), PostScript name (6) and variations PostScript prefix (25) are set from the output's name; the subfamily (2) is reset to `Regular` and the unique ID (3) becomes `version;PostScriptName;family` — the same for every buyer of that range, so add a watermark or order ID yourself if you need per-order tracing. Version (5) and the legal/designer IDs (7–14) are untouched.
399
+ - **Next.js**: add `@overpunch/vf-clamp` to `serverExternalPackages` in `next.config.ts` to prevent webpack bundling the Pyodide runtime.
400
+ - **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.
401
+
402
+ ---
403
+
404
+ ## REST API
405
+
406
+ 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@overpunch.ca](mailto:hello@overpunch.ca) to request an API key.
407
+
408
+ ```
409
+ POST https://vfclamp.com/api/clamp
410
+ X-API-Key: <your-key>
411
+ Content-Type: application/json
412
+
413
+ {
414
+ "fontUrl": "https://cdn.example.com/MyFont-VF.ttf",
415
+ "format": "woff2",
416
+ "outputs": [
417
+ { "name": "Text", "instances": ["Light", "Bold"] },
418
+ { "name": "Condensed", "axes": { "wdth": 75 } }
419
+ ]
420
+ }
421
+ // → { results: [{ name, data, format, size }] }
422
+
423
+ POST https://vfclamp.com/api/instances
424
+ X-API-Key: <your-key>
425
+
426
+ { "fontUrl": "https://cdn.example.com/MyFont-VF.ttf" }
427
+ // → { axes: [...], instances: [...] }
428
+ ```
429
+
430
+ **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.
431
+
432
+ **Response contract.**
433
+
434
+ | | `POST /api/clamp` | `POST /api/instances` |
435
+ |---|---|---|
436
+ | Body | `{ fontUrl, outputs, format?, strict? }`. Each output needs `instances`, `axes`, or both; `format` defaults to `'ttf'`; `strict` (boolean) works as in [`clampFont`](#clampfontinput-options) | `{ fontUrl }` |
437
+ | `200` | `{ results: [{ name, data, format, size }] }`: `data` is the font **base64-encoded**, `size` is its length in **bytes** (decoded) | `{ axes: [...], instances: [...] }`, the same shape as `getInstances()` |
438
+ | `400` | Body isn't JSON, `fontUrl` is missing, isn't `https`, points to a private host or is over 20 MB, `outputs` is empty, an output has neither `instances` nor `axes`, `strict` isn't a boolean, an instance name isn't in the font or is ambiguous, `'otf'` was requested for a TrueType font, the font uses avar2/VARC, or the font URL could not be fetched (15 s timeout, no redirects) | Body isn't JSON, `fontUrl` is missing, or the font could not be fetched |
439
+ | `401` | `X-API-Key` header missing or wrong | Same |
440
+ | `422` | `strict: true` and an output's range would include named instances that weren't listed; the message names them | — |
441
+ | `500` | Processing failed (a post-processing error). Nothing is returned for any output if one fails | Instance extraction failed |
442
+
443
+ Every error body is `{ "error": "<message>" }`. `fontUrl` is fetched server-side, so it must be reachable without cookies; a signed, expiring URL works. The service does not store your font.
444
+
445
+ **Purchase-safe orders over HTTP.** Send `strict: true` and the endpoint refuses (`422`) any output that would hand over an unbought style. To split a customer's selection into safe outputs, get the font's instances from `/api/instances`, group them locally with [`planOutputs`](#selling-named-styles-safely) (a pure function, no Pyodide needed), then send those outputs to `/api/clamp`:
446
+
447
+ ```ts
448
+ import { planOutputs } from '@overpunch/vf-clamp'
449
+
450
+ const headers = { 'X-API-Key': KEY, 'Content-Type': 'application/json' }
451
+ const font = await (await fetch('https://vfclamp.com/api/instances', { method: 'POST', headers, body: JSON.stringify({ fontUrl }) })).json()
452
+ const bought = ['Regular', 'Medium', 'SemiBold', 'Bold'] // the styles on the order
453
+ const outputs = planOutputs(font, bought, 'Inter') // never includes a style that wasn't bought
454
+ const { results } = await (await fetch('https://vfclamp.com/api/clamp', { method: 'POST', headers, body: JSON.stringify({ fontUrl, outputs, format: 'woff2', strict: true }) })).json()
455
+ const files = results.map((r) => ({ name: r.name, bytes: Buffer.from(r.data, 'base64') }))
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Running at scale
461
+
462
+ 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:
463
+
464
+ ```ts
465
+ import PQueue from 'p-queue'
466
+ import { clampFont } from '@overpunch/vf-clamp'
467
+
468
+ // one warm engine, requests queued — the checkout response isn't blocked on the clamp
469
+ const queue = new PQueue({ concurrency: 1 }) // the engine is single-threaded
470
+
471
+ export function enqueueClamp(source, options) {
472
+ return queue.add(() => clampFont(source, options))
473
+ }
474
+ ```
475
+
476
+ 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).
477
+
478
+ ---
479
+
480
+ ## What it does to the font
481
+
482
+ For font engineers — the pipeline, in order, per output:
483
+
484
+ 1. **Instance** — fontTools [`varLib.instancer.instantiateVariableFont`](https://fonttools.readthedocs.io/en/latest/varLib/instancer.html) with each axis pinned (`number`) or restricted (`{ min, max }`, a range instance). Variation data outside the range is dropped and the rest renormalised; there are no "masters" in a binary VF to remove. If the range excludes an axis's default, the default moves to the nearest edge (vf-clamp logs a warning for every output), which re-bases the default outlines and metrics; such files save little over the full VF.
485
+ 2. **STAT** — left to the fontTools instancer, which drops axis values outside the range. STAT design axes that aren't in `fvar` (such as Inter's `ital`) are kept, because upright/italic linking needs them. (Before 2.3.0, vf-clamp also pruned those axes; that broke the linking.)
486
+ 3. **Weight normalisation** (only with `normalizeWeightAxis`) — the wght user-space range is remapped to start at 100; avar is unchanged because normalised values are preserved. This changes registered-axis semantics, so use it only when CSS `font-weight` must reach the lightest weight.
487
+ 4. **OS/2 and head** — `usWeightClass`, `fsSelection` and `macStyle` follow the new default (or the pinned weight). REGULAR is set only when the file is neither bold nor italic, and the italic bit is kept.
488
+ 5. **Names** — see the name table note under [Notes](#notes).
489
+ 6. **Encode** — WOFF or WOFF2 when requested.
490
+
491
+ The engine is fontTools **4.56.0** (via [`@web-alchemy/fonttools`](https://www.npmjs.com/package/@web-alchemy/fonttools)) on **Pyodide 0.29.3** — about 15 MB on disk, none of it shipped to browsers. Fonts with avar version 2 or a VARC table are refused, because this fontTools version can't restrict them correctly. Hinting is whatever the instancer keeps; most VFs ship unhinted.
492
+
493
+ If you already run Python, the core step is one command — vf-clamp adds the STAT, OS/2 and name handling, and runs it from Node:
494
+
495
+ ```sh
496
+ fonttools varLib.instancer Inter-Variable.ttf wght=400:700 opsz=14 -o Inter-Text.ttf
497
+ ```
498
+
499
+ Clamping limits what a file contains, not what can be computed: in a simple two-master design the remaining data can be extrapolated past the range. The file makes the licensed scope visible; the licence's terms do the enforcing.
500
+
501
+ ---
502
+
503
+ ## Development
504
+
505
+ ```sh
506
+ git clone --recurse-submodules https://github.com/over-punch/vf-clamp.git # plugins/ are git submodules
507
+ cd vf-clamp
508
+ npm install
509
+ npm run test:run # unit tests + Pyodide integration tests (~70 s; the first test warms the runtime)
510
+ npm run lint # tsc --noEmit
511
+ npm run build # vite → dist/ (ESM + CJS + types)
512
+ ```
513
+
514
+ Layout: `src/core/` (the package: `clamp.ts`, `instances.ts`, `plan.ts`, `convert.ts`, `pyodide.ts`, `types.ts`, `utils.ts`), `src/__tests__/` (vitest), `fixtures/Inter-Variable.ttf` (test font: Inter 4, wght 100–900 + opsz 14–32), `site/` (vfclamp.com, Next.js), `plugins/` (CLI, Glyphs, RoboFont and VS Code submodules), `shared/plugin-views/` (canonical NSView files synced into the Glyphs and RoboFont plugins with `npm run sync-plugin-views`).
515
+
516
+ Report bugs and requests in [GitHub issues](https://github.com/over-punch/vf-clamp/issues).
517
+
518
+ ---
519
+
520
+ ## Integrations
521
+
522
+ 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.
523
+
524
+ | Integration | Distribution |
525
+ |---|---|
526
+ | [vf-clamp-cli](https://github.com/over-punch/vf-clamp-cli) | `npm install -g @overpunch/vf-clamp-cli` |
527
+ | [vf-clamp-glyphs](https://github.com/over-punch/vf-clamp-glyphs) | `.glyphsPlugin` download |
528
+ | [vf-clamp-robofont](https://github.com/over-punch/vf-clamp-robofont) | `.roboFontExt` download |
529
+ | [vf-clamp-vscode](https://github.com/over-punch/vf-clamp-vscode) | `.vsix` download / VS Code Marketplace |
530
+
531
+ The CLI in action — inspect a font, then clamp it:
532
+
533
+ ![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)
534
+
535
+ ---
536
+
537
+ ## License
538
+
539
+ MIT — [Liiift Studio](https://overpunch.ca). See [LICENSE](LICENSE). The CLI, Glyphs and VS Code plugins are MIT too; the RoboFont extension has its own proprietary licence.