@overpunch/vf-clamp 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -23
- package/dist/index.cjs +427 -160
- package/dist/index.d.ts +34 -4
- package/dist/index.js +531 -388
- package/dist/naming.cjs +1 -0
- package/dist/naming.d.ts +149 -0
- package/dist/naming.js +121 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ npm install @overpunch/vf-clamp
|
|
|
19
19
|
| A type designer or foundry | [For type designers](#for-type-designers) — plugins for Glyphs and RoboFont, no code |
|
|
20
20
|
| Building a storefront's fulfilment step | [Selling named styles safely](#selling-named-styles-safely) and the [REST API](#rest-api) |
|
|
21
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/
|
|
22
|
+
| Curious why this matters | The talk and paper, [*Sell the Styles, Ship the Space*](https://vfclamp.com/paper) |
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
@@ -31,37 +31,38 @@ Takes a variable font (TTF, OTF, WOFF, or WOFF2) and produces one restricted var
|
|
|
31
31
|
|
|
32
32
|
## For foundries
|
|
33
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/
|
|
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
35
|
|
|
36
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
37
|
|
|
38
38
|
Why it matters:
|
|
39
39
|
|
|
40
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
|
|
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 is the same for every buyer of that range, so add a watermark or per-order ID at fulfilment if you need tracing.
|
|
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 shows what was bought. It isn't protection: the remaining data can be extrapolated past the range (in our test, Inter clamped to 400–700 reproduced Black to within about 2 font units), so the licence's terms and a per-order watermark 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
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
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
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
46
|
|
|
47
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
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:
|
|
49
|
+
**Real numbers** — Inter (wght 100–900) clamped to a Text weight range (400–700), optical size kept, same WOFF2 format so the delta is pure clamping (vf-clamp 2.3.0, 1 KB = 1,000 bytes):
|
|
50
50
|
|
|
51
51
|
| Font | Source TTF | Full WOFF2 | Text-clamped WOFF2 |
|
|
52
52
|
|---|---|---|---|
|
|
53
|
-
| Inter |
|
|
53
|
+
| Inter | 863 KB | 346 KB | **248 KB** — −28% vs full WOFF2 |
|
|
54
54
|
|
|
55
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
56
|
|
|
57
|
-
Against the static files
|
|
57
|
+
Against the static files the customer would otherwise get, for adjacent purchases (Inter 4, opsz pinned like the statics, WOFF2, October 2026 — [method](https://vfclamp.com/paper#method)):
|
|
58
58
|
|
|
59
|
-
| Inter
|
|
59
|
+
| Inter purchase | Statics | Clamped VF |
|
|
60
60
|
|---|---|---|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
61
|
+
| Regular + Medium | 226 KB | **163 KB** (−28%) |
|
|
62
|
+
| Regular to Bold (4 styles) | 456 KB | **173 KB** (−62%) |
|
|
63
|
+
| Latin subset, Regular + Medium | 64.0 KB | **46.9 KB** (−27%) |
|
|
63
64
|
|
|
64
|
-
At seven styles the clamped VF is 72% smaller than the statics. Keeping a free axis such as `opsz` variable costs size
|
|
65
|
+
At seven styles the clamped VF is 72% smaller than the statics. A non-adjacent purchase such as Regular + Bold comes back as two files from `planOutputs`, the same size as the statics, unless you choose to deliver (and price) the weights in between. Keeping a free axis such as `opsz` variable costs size.
|
|
65
66
|
|
|
66
67
|
### For type designers
|
|
67
68
|
|
|
@@ -70,7 +71,7 @@ You don't need to write code:
|
|
|
70
71
|
- **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
72
|
- **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
73
|
- **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/
|
|
74
|
+
- **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
|
|
|
75
76
|
---
|
|
76
77
|
|
|
@@ -264,10 +265,10 @@ async function clampFont(
|
|
|
264
265
|
- `input` — Source variable font binary (TTF, OTF, WOFF, or WOFF2).
|
|
265
266
|
- `options.outputs` — Array of `OutputConfig` entries, one per output variant.
|
|
266
267
|
- `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
|
|
268
|
+
- `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
269
|
- `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
|
|
|
270
|
-
**Throws** if an instance name is not found, if `strict` rejects an output, if `'otf'` is requested for a TrueType-outline font, or if any post-processing step (
|
|
271
|
+
**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
|
|
|
272
273
|
**Returns**
|
|
273
274
|
|
|
@@ -433,15 +434,16 @@ X-API-Key: <your-key>
|
|
|
433
434
|
|
|
434
435
|
| | `POST /api/clamp` | `POST /api/instances` |
|
|
435
436
|
|---|---|---|
|
|
436
|
-
| Body | `{ fontUrl, outputs, format? }`. Each output needs `instances`, `axes`, or both; `format` defaults to `'ttf'` | `{ fontUrl }` |
|
|
437
|
+
| 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
438
|
| `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, `outputs` is empty, an output has neither `instances` nor `axes`, or the font URL could not be fetched | Body isn't JSON, `fontUrl` is missing, or the font could not be fetched |
|
|
439
|
+
| `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
440
|
| `401` | `X-API-Key` header missing or wrong | Same |
|
|
440
|
-
| `
|
|
441
|
+
| `422` | `strict: true` and an output's range would include named instances that weren't listed; the message names them | — |
|
|
442
|
+
| `500` | Processing failed (a post-processing error). Nothing is returned for any output if one fails | Instance extraction failed |
|
|
441
443
|
|
|
442
444
|
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.
|
|
443
445
|
|
|
444
|
-
**Purchase-safe orders over HTTP.**
|
|
446
|
+
**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`:
|
|
445
447
|
|
|
446
448
|
```ts
|
|
447
449
|
import { planOutputs } from '@overpunch/vf-clamp'
|
|
@@ -450,7 +452,7 @@ const headers = { 'X-API-Key': KEY, 'Content-Type': 'application/json' }
|
|
|
450
452
|
const font = await (await fetch('https://vfclamp.com/api/instances', { method: 'POST', headers, body: JSON.stringify({ fontUrl }) })).json()
|
|
451
453
|
const bought = ['Regular', 'Medium', 'SemiBold', 'Bold'] // the styles on the order
|
|
452
454
|
const outputs = planOutputs(font, bought, 'Inter') // never includes a style that wasn't bought
|
|
453
|
-
const { results } = await (await fetch('https://vfclamp.com/api/clamp', { method: 'POST', headers, body: JSON.stringify({ fontUrl, outputs, format: 'woff2' }) })).json()
|
|
455
|
+
const { results } = await (await fetch('https://vfclamp.com/api/clamp', { method: 'POST', headers, body: JSON.stringify({ fontUrl, outputs, format: 'woff2', strict: true }) })).json()
|
|
454
456
|
const files = results.map((r) => ({ name: r.name, bytes: Buffer.from(r.data, 'base64') }))
|
|
455
457
|
```
|
|
456
458
|
|
|
@@ -481,13 +483,13 @@ For higher throughput, run **N worker processes** (each with its own warm Pyodid
|
|
|
481
483
|
For font engineers — the pipeline, in order, per output:
|
|
482
484
|
|
|
483
485
|
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.
|
|
484
|
-
2. **STAT** — the fontTools instancer drops axis values outside the range
|
|
486
|
+
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.)
|
|
485
487
|
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.
|
|
486
|
-
4. **OS/2 and head** — `usWeightClass`, `fsSelection` and `macStyle` follow the new default.
|
|
487
|
-
5. **Names** —
|
|
488
|
+
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.
|
|
489
|
+
5. **Names, style bits and STAT links** — one shared implementation, specified in [docs/NAMING.md](docs/NAMING.md). Unnamed outputs get one range per axis in the font's own style words, e.g. `Encode Sans SemiCondensed-Normal Thin-Light`.
|
|
488
490
|
6. **Encode** — WOFF or WOFF2 when requested.
|
|
489
491
|
|
|
490
|
-
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
|
|
492
|
+
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.
|
|
491
493
|
|
|
492
494
|
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:
|
|
493
495
|
|