@overpunch/vf-clamp 2.2.0 → 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 +16 -15
- package/dist/index.cjs +79 -81
- package/dist/index.d.ts +6 -3
- package/dist/index.js +279 -300
- package/package.json +1 -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,7 +31,7 @@ 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
|
|
|
@@ -39,7 +39,7 @@ 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
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 is the same for every buyer of that range, so add a watermark or per-order ID at fulfilment if you need tracing.
|
|
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.
|
|
@@ -54,7 +54,7 @@ The npm package, CLI, and editor plugins all share the same axis-constraint mode
|
|
|
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 a two-style buyer would otherwise get (Inter 4, fontTools instancer, WOFF2, October 2026 — [method](https://vfclamp.com/
|
|
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
58
|
|
|
59
59
|
| Inter, Regular + Bold | Two statics | Clamped VF (wght 400–700, opsz pinned) |
|
|
60
60
|
|---|---|---|
|
|
@@ -70,7 +70,7 @@ You don't need to write code:
|
|
|
70
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
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
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/
|
|
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
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
@@ -264,10 +264,10 @@ async function clampFont(
|
|
|
264
264
|
- `input` — Source variable font binary (TTF, OTF, WOFF, or WOFF2).
|
|
265
265
|
- `options.outputs` — Array of `OutputConfig` entries, one per output variant.
|
|
266
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
|
|
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
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
269
|
|
|
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 (
|
|
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
271
|
|
|
272
272
|
**Returns**
|
|
273
273
|
|
|
@@ -433,15 +433,16 @@ X-API-Key: <your-key>
|
|
|
433
433
|
|
|
434
434
|
| | `POST /api/clamp` | `POST /api/instances` |
|
|
435
435
|
|---|---|---|
|
|
436
|
-
| Body | `{ fontUrl, outputs, format? }`. Each output needs `instances`, `axes`, or both; `format` defaults to `'ttf'` | `{ fontUrl }` |
|
|
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
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, `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 |
|
|
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
439
|
| `401` | `X-API-Key` header missing or wrong | Same |
|
|
440
|
-
| `
|
|
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 |
|
|
441
442
|
|
|
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.
|
|
443
444
|
|
|
444
|
-
**Purchase-safe orders over HTTP.**
|
|
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`:
|
|
445
446
|
|
|
446
447
|
```ts
|
|
447
448
|
import { planOutputs } from '@overpunch/vf-clamp'
|
|
@@ -450,7 +451,7 @@ const headers = { 'X-API-Key': KEY, 'Content-Type': 'application/json' }
|
|
|
450
451
|
const font = await (await fetch('https://vfclamp.com/api/instances', { method: 'POST', headers, body: JSON.stringify({ fontUrl }) })).json()
|
|
451
452
|
const bought = ['Regular', 'Medium', 'SemiBold', 'Bold'] // the styles on the order
|
|
452
453
|
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()
|
|
454
|
+
const { results } = await (await fetch('https://vfclamp.com/api/clamp', { method: 'POST', headers, body: JSON.stringify({ fontUrl, outputs, format: 'woff2', strict: true }) })).json()
|
|
454
455
|
const files = results.map((r) => ({ name: r.name, bytes: Buffer.from(r.data, 'base64') }))
|
|
455
456
|
```
|
|
456
457
|
|
|
@@ -481,13 +482,13 @@ For higher throughput, run **N worker processes** (each with its own warm Pyodid
|
|
|
481
482
|
For font engineers — the pipeline, in order, per output:
|
|
482
483
|
|
|
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.
|
|
484
|
-
2. **STAT** — the fontTools instancer drops axis values outside the range
|
|
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.)
|
|
485
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.
|
|
486
|
-
4. **OS/2 and head** — `usWeightClass`, `fsSelection` and `macStyle` follow the new default.
|
|
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.
|
|
487
488
|
5. **Names** — see the name table note under [Notes](#notes).
|
|
488
489
|
6. **Encode** — WOFF or WOFF2 when requested.
|
|
489
490
|
|
|
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
|
|
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.
|
|
491
492
|
|
|
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:
|
|
493
494
|
|
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const U=require("node:module");var I=typeof document<"u"?document.currentScript:null;const E=U.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:I&&I.tagName.toUpperCase()==="SCRIPT"&&I.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:d,PyodideFile:x}=E("@web-alchemy/fonttools/src/pyodide.js");let S=null;async function B(){return S||(S=d().then(e=>e.runPythonAsync(`
|
|
2
2
|
from fontTools.ttLib import TTFont
|
|
3
3
|
|
|
4
4
|
def vf_convert_flavor(file_options):
|
|
@@ -7,15 +7,16 @@ def vf_convert_flavor(file_options):
|
|
|
7
7
|
font.save(file_options['output-file'])
|
|
8
8
|
|
|
9
9
|
vf_convert_flavor
|
|
10
|
-
`))),
|
|
10
|
+
`))),S}async function P(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename],["flavor",a]]);return(await B())(o),i.download()}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}async function $(e){return P(e,"woff")}async function L(e){return P(e,"woff2")}let A=null;async function W(){return A||(A=d().then(e=>e.runPythonAsync(`
|
|
11
11
|
import json
|
|
12
12
|
from fontTools.ttLib import TTFont
|
|
13
13
|
|
|
14
14
|
def get_instances_fn(input_file):
|
|
15
15
|
font = TTFont(input_file)
|
|
16
16
|
|
|
17
|
+
family = font['name'].getDebugName(16) or font['name'].getDebugName(1) or ''
|
|
17
18
|
if 'fvar' not in font:
|
|
18
|
-
return json.dumps({'axes': [], 'instances': []})
|
|
19
|
+
return json.dumps({'axes': [], 'instances': [], 'family': family})
|
|
19
20
|
|
|
20
21
|
fvar = font['fvar']
|
|
21
22
|
name_table = font['name']
|
|
@@ -39,10 +40,10 @@ def get_instances_fn(input_file):
|
|
|
39
40
|
for i, inst in enumerate(fvar.instances)
|
|
40
41
|
]
|
|
41
42
|
|
|
42
|
-
return json.dumps({'axes': axes, 'instances': instances})
|
|
43
|
+
return json.dumps({'axes': axes, 'instances': instances, 'family': family})
|
|
43
44
|
|
|
44
45
|
get_instances_fn
|
|
45
|
-
`))),
|
|
46
|
+
`))),A}async function M(e){const a=await d(),t=e instanceof ArrayBuffer?new Uint8Array(e):e,n=new x({pyodide:a});try{await n.upload(t);const o=(await W())(n.filename);return JSON.parse(o)}finally{try{n.delete()}catch{}}}function C(e,a){if(e===a)return e;const t=e.split(" "),n=a.split(" ");let i=0;for(;i<t.length&&i<n.length&&t[i]===n[i];)i++;let o=0;for(;o<t.length-i&&o<n.length-i&&t[t.length-1-o]===n[n.length-1-o];)o++;const r=t.slice(0,i).join(" "),s=t.slice(i,t.length-(o||0)).join(" "),h=n.slice(i,n.length-(o||0)).join(" "),y=o>0?t.slice(t.length-o).join(" "):"",f=s&&h?`${s}-${h}`:s||h;return[r,f,y].filter(Boolean).join(" ")}function T(e,a){const t=a.filter(n=>n.name===e);if(!t.length)throw new Error(`Named instance "${e}" not found in font`);if(t.length>1)throw new Error(`Named instance "${e}" is ambiguous: ${t.length} instances share that name`);return t[0]}let V=null,N=null,D=null,O=null;async function z(){return V||(V=d().then(e=>e.runPythonAsync(`
|
|
46
47
|
from fontTools import ttLib
|
|
47
48
|
|
|
48
49
|
def patch_font_names_fn(file_options):
|
|
@@ -53,6 +54,27 @@ def patch_font_names_fn(file_options):
|
|
|
53
54
|
|
|
54
55
|
existing_ids = {r.nameID for r in name_table.names}
|
|
55
56
|
|
|
57
|
+
# A family name with no ASCII letters gives an empty PostScript name: fall back to the
|
|
58
|
+
# source PostScript name plus a short hash of the family name (passed in from JS).
|
|
59
|
+
if not ps_name:
|
|
60
|
+
source_ps = name_table.getDebugName(6) or 'Font'
|
|
61
|
+
ps_name = ('%s-%s' % (source_ps[:52], file_options['postscript-suffix']))[:63]
|
|
62
|
+
|
|
63
|
+
# nameID 2 must be a RIBBI style that matches OS/2 (already updated): Regular, Italic, Bold or Bold Italic.
|
|
64
|
+
fs = font['OS/2'].fsSelection if 'OS/2' in font else 0x40
|
|
65
|
+
is_italic = bool(fs & 0x01)
|
|
66
|
+
is_bold = bool(fs & 0x20)
|
|
67
|
+
ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
|
|
68
|
+
|
|
69
|
+
# nameID 17 (typographic subfamily): the name of the named instance at the new default, if any.
|
|
70
|
+
default_style = None
|
|
71
|
+
if 'fvar' in font:
|
|
72
|
+
defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
|
|
73
|
+
for inst in font['fvar'].instances:
|
|
74
|
+
if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
|
|
75
|
+
default_style = name_table.getDebugName(inst.subfamilyNameID)
|
|
76
|
+
break
|
|
77
|
+
|
|
56
78
|
# nameID 1 = Family, 4 = Full name, 6 = PostScript name
|
|
57
79
|
# nameID 2 = Subfamily — reset to 'Regular' so the restricted file does not
|
|
58
80
|
# collide with the source font's Subfamily in OS font caches.
|
|
@@ -70,32 +92,50 @@ def patch_font_names_fn(file_options):
|
|
|
70
92
|
|
|
71
93
|
updates = {
|
|
72
94
|
1: family_name,
|
|
73
|
-
2:
|
|
95
|
+
2: ribbi,
|
|
74
96
|
3: unique_id,
|
|
75
|
-
4: family_name,
|
|
97
|
+
4: family_name if ribbi == 'Regular' else '%s %s' % (family_name, ribbi),
|
|
76
98
|
6: ps_name,
|
|
77
99
|
}
|
|
78
100
|
if 16 in existing_ids:
|
|
79
101
|
updates[16] = family_name
|
|
102
|
+
if 17 in existing_ids and default_style:
|
|
103
|
+
updates[17] = default_style
|
|
80
104
|
if 25 in existing_ids:
|
|
81
105
|
updates[25] = ps_name
|
|
82
106
|
|
|
107
|
+
# Named instances' PostScript names must follow the new prefix, e.g. Inter-Regular-Bold-Medium.
|
|
108
|
+
if 'fvar' in font:
|
|
109
|
+
for inst in font['fvar'].instances:
|
|
110
|
+
pid = getattr(inst, 'postscriptNameID', 0xFFFF)
|
|
111
|
+
if pid in (None, 0xFFFF):
|
|
112
|
+
continue
|
|
113
|
+
style = (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', '')
|
|
114
|
+
style = ''.join(c for c in style if c.isascii() and (c.isalnum() or c == '-'))
|
|
115
|
+
updates[pid] = ('%s-%s' % (ps_name, style))[:63]
|
|
116
|
+
|
|
117
|
+
# Rewrite every platform. A Mac (platform 1) record that can't be encoded in Mac Roman is dropped
|
|
118
|
+
# rather than filled with '?'; Windows and Unicode records always carry the full name.
|
|
119
|
+
keep = []
|
|
83
120
|
for record in name_table.names:
|
|
84
121
|
if record.nameID not in updates:
|
|
122
|
+
keep.append(record)
|
|
85
123
|
continue
|
|
86
124
|
value = updates[record.nameID]
|
|
87
|
-
if record.platformID
|
|
125
|
+
if record.platformID in (0, 3):
|
|
88
126
|
record.string = value.encode('utf-16-be')
|
|
89
127
|
elif record.platformID == 1:
|
|
90
128
|
try:
|
|
91
129
|
record.string = value.encode('mac_roman')
|
|
92
130
|
except Exception:
|
|
93
|
-
|
|
131
|
+
continue
|
|
132
|
+
keep.append(record)
|
|
133
|
+
name_table.names = keep
|
|
94
134
|
|
|
95
135
|
font.save(file_options['output-file'])
|
|
96
136
|
|
|
97
137
|
patch_font_names_fn
|
|
98
|
-
`))),
|
|
138
|
+
`))),V}async function q(){return N||(N=d().then(e=>e.runPythonAsync(`
|
|
99
139
|
from fontTools.ttLib import TTFont
|
|
100
140
|
from fontTools.varLib import instancer
|
|
101
141
|
import json
|
|
@@ -104,6 +144,12 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
104
144
|
font = TTFont(file_options['input-file'])
|
|
105
145
|
axes_spec = json.loads(axes_json)
|
|
106
146
|
|
|
147
|
+
# The bundled fontTools can't restrict avar version 2 or VARC fonts correctly: refuse instead of mis-cutting.
|
|
148
|
+
if 'avar' in font and getattr(font['avar'], 'majorVersion', 1) >= 2:
|
|
149
|
+
raise ValueError('vf-clamp: avar version 2 fonts are not supported yet')
|
|
150
|
+
if 'VARC' in font:
|
|
151
|
+
raise ValueError('vf-clamp: fonts with a VARC table are not supported yet')
|
|
152
|
+
|
|
107
153
|
limits = {}
|
|
108
154
|
axes_by_tag = {ax.axisTag: ax for ax in font['fvar'].axes}
|
|
109
155
|
|
|
@@ -124,7 +170,7 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
124
170
|
partial.save(file_options['output-file'])
|
|
125
171
|
|
|
126
172
|
vf_clamp_instantiate
|
|
127
|
-
`))),
|
|
173
|
+
`))),N}async function H(){return D||(D=d().then(e=>e.runPythonAsync(`
|
|
128
174
|
from fontTools.ttLib import TTFont
|
|
129
175
|
|
|
130
176
|
def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
@@ -144,6 +190,10 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
144
190
|
|
|
145
191
|
old_min = wght_axis.minValue
|
|
146
192
|
default = wght_axis.defaultValue
|
|
193
|
+
# With the default at the minimum there is no below-default range to stretch; leave the font as is.
|
|
194
|
+
if default <= old_min:
|
|
195
|
+
font.save(file_options['output-file'])
|
|
196
|
+
return 'skipped'
|
|
147
197
|
scale = (default - new_min) / (default - old_min)
|
|
148
198
|
|
|
149
199
|
def remap(v):
|
|
@@ -177,69 +227,16 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
177
227
|
av.NominalValue = remap(av.NominalValue)
|
|
178
228
|
av.RangeMinValue = remap(av.RangeMinValue)
|
|
179
229
|
av.RangeMaxValue = remap(av.RangeMaxValue)
|
|
230
|
+
elif fmt == 4:
|
|
231
|
+
for rec in getattr(av, 'AxisValueRecord', []) or []:
|
|
232
|
+
if rec.AxisIndex == wght_idx:
|
|
233
|
+
rec.Value = remap(rec.Value)
|
|
180
234
|
|
|
181
235
|
font.save(file_options['output-file'])
|
|
236
|
+
return 'done'
|
|
182
237
|
|
|
183
238
|
vf_clamp_normalize_wght
|
|
184
|
-
`))),
|
|
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
|
-
`))),D}async function G(){return O||(O=d().then(t=>t.runPythonAsync(`
|
|
239
|
+
`))),D}async function J(){return O||(O=d().then(e=>e.runPythonAsync(`
|
|
243
240
|
from fontTools.ttLib import TTFont
|
|
244
241
|
|
|
245
242
|
def vf_clamp_update_os2(file_options):
|
|
@@ -250,25 +247,26 @@ def vf_clamp_update_os2(file_options):
|
|
|
250
247
|
return
|
|
251
248
|
|
|
252
249
|
wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
|
|
253
|
-
if wght_axis is None:
|
|
250
|
+
if wght_axis is not None:
|
|
251
|
+
# OS/2.usWeightClass valid range is 1..1000.
|
|
252
|
+
weight_class = int(round(max(1, min(1000, wght_axis.defaultValue))))
|
|
253
|
+
elif 'OS/2' in font:
|
|
254
|
+
# wght was pinned: fontTools has already set usWeightClass to the pinned weight.
|
|
255
|
+
weight_class = font['OS/2'].usWeightClass
|
|
256
|
+
else:
|
|
254
257
|
font.save(file_options['output-file'])
|
|
255
258
|
return
|
|
256
259
|
|
|
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
260
|
if 'OS/2' in font:
|
|
262
261
|
os2 = font['OS/2']
|
|
263
262
|
os2.usWeightClass = weight_class
|
|
264
|
-
# fsSelection
|
|
265
|
-
# Mirror the convention: REGULAR when usWeightClass < 600, BOLD when >= 700.
|
|
263
|
+
# fsSelection: 0x01 ITALIC (kept), 0x20 BOLD, 0x40 REGULAR. REGULAR only when neither ITALIC nor BOLD.
|
|
266
264
|
fs = os2.fsSelection
|
|
267
265
|
fs &= ~(0x20 | 0x40)
|
|
268
266
|
if weight_class >= 700:
|
|
269
|
-
fs |= 0x20
|
|
270
|
-
elif
|
|
271
|
-
fs |= 0x40
|
|
267
|
+
fs |= 0x20
|
|
268
|
+
elif not fs & 0x01:
|
|
269
|
+
fs |= 0x40
|
|
272
270
|
os2.fsSelection = fs
|
|
273
271
|
|
|
274
272
|
if 'head' in font:
|
|
@@ -284,4 +282,4 @@ def vf_clamp_update_os2(file_options):
|
|
|
284
282
|
font.save(file_options['output-file'])
|
|
285
283
|
|
|
286
284
|
vf_clamp_update_os2
|
|
287
|
-
`))),O}function
|
|
285
|
+
`))),O}function G(e){const a=e.normalize("NFKD").replace(/[\u0300-\u036f]/g,"").replace(/[^A-Za-z0-9 -]/g,"").trim().split(/\s+/).join("-").replace(/-+/g,"-").replace(/^-+|-+$/g,"");return a.length<=63?a:`${a.slice(0,56).replace(/-+$/,"")}-${k(e)}`}function k(e){let a=2166136261;for(const t of e)a^=t.codePointAt(0),a=Math.imul(a,16777619)>>>0;return a.toString(36).padStart(6,"0").slice(-6)}async function K(e,a){if(!a)return e;const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{const o=G(a);await n.upload(e);const r=new Map([["input-file",n.filename],["output-file",i.filename],["family-name",a],["postscript-name",o],["postscript-suffix",k(a)]]);return(await z())(r),i.download()}catch(o){throw Object.assign(new Error(`vf-clamp: name table patching failed for "${a}"`),{cause:o})}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}function Z(e){return typeof e=="number"?e:e===null?null:[e.min,e.max]}async function Q(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename]]);return(await q())(o,JSON.stringify(a)),i.download()}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}async function X(e){const a=await d(),t=new x({pyodide:a}),n=new x({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await J())(i),n.download()}catch(i){throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"),{cause:i})}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function Y(e,a){const t=await d(),n=new x({pyodide:t}),i=new x({pyodide:t});try{await n.upload(e);const o=new Map([["input-file",n.filename],["output-file",i.filename]]);return(await H())(o,String(a))==="skipped"&&console.warn("vf-clamp: normalizeWeightAxis skipped: the weight default is the axis minimum, so there is no lower range to remap"),i.download()}catch(o){throw Object.assign(new Error("vf-clamp: wght normalisation failed"),{cause:o})}finally{try{n.delete()}catch{}try{i.delete()}catch{}}}function ee(e,a){const t={};for(const i of e){const o=T(i,a);for(const[r,s]of Object.entries(o.coordinates))t[r]?(t[r].min=Math.min(t[r].min,s),t[r].max=Math.max(t[r].max,s)):t[r]={min:s,max:s}}const n={};for(const[i,{min:o,max:r}]of Object.entries(t))n[i]=o===r?o:{min:o,max:r};return n}function te(e){return e.length>=4&&e[0]===79&&e[1]===84&&e[2]===84&&e[3]===79}async function ne(e,a){var h,y;if(a.outputs.length===0)return[];const t=e instanceof ArrayBuffer?new Uint8Array(e):e,n=a.format??"ttf",{instances:i,axes:o,family:r}=await M(t),s=[];for(const f of a.outputs){let l={};if((h=f.instances)!=null&&h.length&&(l=ee(f.instances,i)),f.axes&&(l={...l,...f.axes}),a.strict){const _=new Set((f.instances??[]).map(p=>T(p,i))),w=i.filter(p=>!_.has(p)&&o.every(b=>{const F=p.coordinates[b.tag]??b.default,v=l[b.tag];return v==null?!0:typeof v=="number"?F===v:F>=v.min&&F<=v.max})).map(p=>p.name);if(w.length)throw new Error(`vf-clamp: output "${f.name||(f.instances??[]).join(", ")||"axes-only"}" would include unselected instances (${w.join(", ")}); use planOutputs() to split the selection`)}for(const _ of o){const w=l[_.tag];if(w!=null&&typeof w=="object"){const p=w;if(_.default<p.min||_.default>p.max){const b=Math.max(p.min,Math.min(p.max,_.default));console.warn(`vf-clamp: axis "${_.tag}" default (${_.default}) is outside restricted range [${p.min}, ${p.max}] — will be clamped to ${b}`)}}}const c={};for(const[_,w]of Object.entries(l))w!==null&&(c[_]=Z(w));const u=(y=f.instances)!=null&&y.length?f.instances.length===1?f.instances[0]:`${f.instances[0]}-${f.instances[f.instances.length-1]}`:"Clamped",g=f.name||(r?`${r} ${u}`:u);let m=await Q(t,c);if(n==="otf"&&!te(m))throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");a.normalizeWeightAxis&&(m=await Y(m,100)),m=await X(m),m=await K(m,g),n==="woff2"?m=await L(m):n==="woff"&&(m=await $(m)),s.push({name:g,buffer:m,format:n})}return s}function R(e,a){const t={};for(const n of a){const i=e.map(o=>o.coordinates[n.tag]??n.default);t[n.tag]={min:Math.min(...i),max:Math.max(...i)}}return t}function j(e,a,t,n){return a.filter(i=>t.has(i)?!1:n.every(o=>{const r=i.coordinates[o.tag]??o.default,s=e[o.tag];return!s||r>=s.min&&r<=s.max}))}function ae(e){return e.reduce((a,t)=>!a||t.maximum-t.minimum>a.maximum-a.minimum?t:a,void 0)}function ie(e,a,t){const{axes:n,instances:i}=e,o=[...new Set(a)].map(l=>T(l,i));if(!o.length)return[];const r=new Set(o);let s;if(!j(R(o,n),i,r,n).length)s=[o];else{s=o.map(c=>[c]);let l=!0;for(;l;){l=!1;e:for(let c=0;c<s.length;c++)for(let u=c+1;u<s.length;u++){const g=[...s[c],...s[u]];if(!j(R(g,n),i,r,n).length){s[c]=g,s.splice(u,1),l=!0;break e}}}}const h=ae(n),y=h?[h,...n.filter(l=>l.tag!==h.tag)]:n,f=(l,c)=>{for(const u of y){const g=(l.coordinates[u.tag]??u.default)-(c.coordinates[u.tag]??u.default);if(g)return g}return 0};return s=s.map(l=>[...l].sort(f)).sort((l,c)=>f(l[0],c[0])),s.map(l=>{const c=l[0],u=l[l.length-1],g=l.length===1?c.name:C(c.name,u.name);return{name:t?`${t} ${g}`:g,instances:l.map(m=>m.name)}})}function oe(e,a){const t=a.map(n=>T(n,e.instances));return t.length?j(R(t,e.axes),e.instances,new Set(t),e.axes).map(n=>n.name):[]}exports.clampFont=ne;exports.compactName=C;exports.convertToWoff=$;exports.convertToWoff2=L;exports.getInstances=M;exports.planOutputs=ie;exports.unboughtInstances=oe;
|
package/dist/index.d.ts
CHANGED
|
@@ -56,8 +56,9 @@ export declare interface ClampOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
format?: OutputFormat;
|
|
58
58
|
/**
|
|
59
|
-
* Refuse any
|
|
60
|
-
* (e.g. Regular + Bold would also hand over Medium and SemiBold).
|
|
59
|
+
* Refuse any output whose range would include a named instance that was not listed
|
|
60
|
+
* (e.g. Regular + Bold would also hand over Medium and SemiBold). Axes-only outputs list none,
|
|
61
|
+
* so they pass only if their range holds no named instance. Throws instead of clamping.
|
|
61
62
|
* Use planOutputs() to split a customer's selection into safe outputs. Defaults to false.
|
|
62
63
|
*/
|
|
63
64
|
strict?: boolean;
|
|
@@ -117,6 +118,8 @@ export declare interface FontInstancesResult {
|
|
|
117
118
|
axes: AxisDefinition[];
|
|
118
119
|
/** All named instances defined in the font */
|
|
119
120
|
instances: FontInstance[];
|
|
121
|
+
/** The font's family name (typographic family, nameID 16, else nameID 1); used to name outputs that set no name */
|
|
122
|
+
family?: string;
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/**
|
|
@@ -173,7 +176,7 @@ export declare type PinnedAxis = number;
|
|
|
173
176
|
* @param selected - Names of the instances the customer bought (must match exactly)
|
|
174
177
|
* @param family - Optional family name to prefix each output name with, e.g. "Encode Sans"
|
|
175
178
|
* @returns OutputConfig entries ready for clampFont(), sorted along the primary axis
|
|
176
|
-
* @throws If a selected name is not a named instance of the font
|
|
179
|
+
* @throws If a selected name is not a named instance of the font, or names more than one (ambiguous)
|
|
177
180
|
*/
|
|
178
181
|
export declare function planOutputs(font: FontInstancesResult, selected: string[], family?: string): OutputConfig[];
|
|
179
182
|
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createRequire as
|
|
2
|
-
const
|
|
3
|
-
let
|
|
4
|
-
async function
|
|
5
|
-
return
|
|
6
|
-
(
|
|
1
|
+
import { createRequire as $ } from "node:module";
|
|
2
|
+
const M = $(import.meta.url), { preparePyodide: d, PyodideFile: x } = M("@web-alchemy/fonttools/src/pyodide.js");
|
|
3
|
+
let A = null;
|
|
4
|
+
async function L() {
|
|
5
|
+
return A || (A = d().then(
|
|
6
|
+
(e) => e.runPythonAsync(`
|
|
7
7
|
from fontTools.ttLib import TTFont
|
|
8
8
|
|
|
9
9
|
def vf_convert_flavor(file_options):
|
|
@@ -13,47 +13,48 @@ def vf_convert_flavor(file_options):
|
|
|
13
13
|
|
|
14
14
|
vf_convert_flavor
|
|
15
15
|
`)
|
|
16
|
-
)),
|
|
16
|
+
)), A;
|
|
17
17
|
}
|
|
18
|
-
async function
|
|
19
|
-
const
|
|
18
|
+
async function R(e, a) {
|
|
19
|
+
const t = await d(), n = new x({ pyodide: t }), i = new x({ pyodide: t });
|
|
20
20
|
try {
|
|
21
|
-
await
|
|
22
|
-
const
|
|
23
|
-
["input-file",
|
|
24
|
-
["output-file",
|
|
25
|
-
["flavor",
|
|
21
|
+
await n.upload(e);
|
|
22
|
+
const o = /* @__PURE__ */ new Map([
|
|
23
|
+
["input-file", n.filename],
|
|
24
|
+
["output-file", i.filename],
|
|
25
|
+
["flavor", a]
|
|
26
26
|
]);
|
|
27
|
-
return (await
|
|
27
|
+
return (await L())(o), i.download();
|
|
28
28
|
} finally {
|
|
29
29
|
try {
|
|
30
|
-
|
|
30
|
+
n.delete();
|
|
31
31
|
} catch {
|
|
32
32
|
}
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
i.delete();
|
|
35
35
|
} catch {
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
async function
|
|
40
|
-
return
|
|
39
|
+
async function k(e) {
|
|
40
|
+
return R(e, "woff");
|
|
41
41
|
}
|
|
42
|
-
async function
|
|
43
|
-
return
|
|
42
|
+
async function C(e) {
|
|
43
|
+
return R(e, "woff2");
|
|
44
44
|
}
|
|
45
|
-
let
|
|
46
|
-
async function
|
|
47
|
-
return
|
|
48
|
-
(
|
|
45
|
+
let I = null;
|
|
46
|
+
async function E() {
|
|
47
|
+
return I || (I = d().then(
|
|
48
|
+
(e) => e.runPythonAsync(`
|
|
49
49
|
import json
|
|
50
50
|
from fontTools.ttLib import TTFont
|
|
51
51
|
|
|
52
52
|
def get_instances_fn(input_file):
|
|
53
53
|
font = TTFont(input_file)
|
|
54
54
|
|
|
55
|
+
family = font['name'].getDebugName(16) or font['name'].getDebugName(1) or ''
|
|
55
56
|
if 'fvar' not in font:
|
|
56
|
-
return json.dumps({'axes': [], 'instances': []})
|
|
57
|
+
return json.dumps({'axes': [], 'instances': [], 'family': family})
|
|
57
58
|
|
|
58
59
|
fvar = font['fvar']
|
|
59
60
|
name_table = font['name']
|
|
@@ -77,29 +78,47 @@ def get_instances_fn(input_file):
|
|
|
77
78
|
for i, inst in enumerate(fvar.instances)
|
|
78
79
|
]
|
|
79
80
|
|
|
80
|
-
return json.dumps({'axes': axes, 'instances': instances})
|
|
81
|
+
return json.dumps({'axes': axes, 'instances': instances, 'family': family})
|
|
81
82
|
|
|
82
83
|
get_instances_fn
|
|
83
84
|
`)
|
|
84
|
-
)),
|
|
85
|
+
)), I;
|
|
85
86
|
}
|
|
86
|
-
async function
|
|
87
|
-
const
|
|
87
|
+
async function B(e) {
|
|
88
|
+
const a = await d(), t = e instanceof ArrayBuffer ? new Uint8Array(e) : e, n = new x({ pyodide: a });
|
|
88
89
|
try {
|
|
89
|
-
await
|
|
90
|
-
const
|
|
91
|
-
return JSON.parse(
|
|
90
|
+
await n.upload(t);
|
|
91
|
+
const o = (await E())(n.filename);
|
|
92
|
+
return JSON.parse(o);
|
|
92
93
|
} finally {
|
|
93
94
|
try {
|
|
94
|
-
|
|
95
|
+
n.delete();
|
|
95
96
|
} catch {
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
function U(e, a) {
|
|
101
|
+
if (e === a) return e;
|
|
102
|
+
const t = e.split(" "), n = a.split(" ");
|
|
103
|
+
let i = 0;
|
|
104
|
+
for (; i < t.length && i < n.length && t[i] === n[i]; )
|
|
105
|
+
i++;
|
|
106
|
+
let o = 0;
|
|
107
|
+
for (; o < t.length - i && o < n.length - i && t[t.length - 1 - o] === n[n.length - 1 - o]; )
|
|
108
|
+
o++;
|
|
109
|
+
const r = t.slice(0, i).join(" "), s = t.slice(i, t.length - (o || 0)).join(" "), h = n.slice(i, n.length - (o || 0)).join(" "), y = o > 0 ? t.slice(t.length - o).join(" ") : "", f = s && h ? `${s}-${h}` : s || h;
|
|
110
|
+
return [r, f, y].filter(Boolean).join(" ");
|
|
111
|
+
}
|
|
112
|
+
function T(e, a) {
|
|
113
|
+
const t = a.filter((n) => n.name === e);
|
|
114
|
+
if (!t.length) throw new Error(`Named instance "${e}" not found in font`);
|
|
115
|
+
if (t.length > 1) throw new Error(`Named instance "${e}" is ambiguous: ${t.length} instances share that name`);
|
|
116
|
+
return t[0];
|
|
117
|
+
}
|
|
118
|
+
let S = null, V = null, D = null, N = null;
|
|
119
|
+
async function z() {
|
|
120
|
+
return S || (S = d().then(
|
|
121
|
+
(e) => e.runPythonAsync(`
|
|
103
122
|
from fontTools import ttLib
|
|
104
123
|
|
|
105
124
|
def patch_font_names_fn(file_options):
|
|
@@ -110,6 +129,27 @@ def patch_font_names_fn(file_options):
|
|
|
110
129
|
|
|
111
130
|
existing_ids = {r.nameID for r in name_table.names}
|
|
112
131
|
|
|
132
|
+
# A family name with no ASCII letters gives an empty PostScript name: fall back to the
|
|
133
|
+
# source PostScript name plus a short hash of the family name (passed in from JS).
|
|
134
|
+
if not ps_name:
|
|
135
|
+
source_ps = name_table.getDebugName(6) or 'Font'
|
|
136
|
+
ps_name = ('%s-%s' % (source_ps[:52], file_options['postscript-suffix']))[:63]
|
|
137
|
+
|
|
138
|
+
# nameID 2 must be a RIBBI style that matches OS/2 (already updated): Regular, Italic, Bold or Bold Italic.
|
|
139
|
+
fs = font['OS/2'].fsSelection if 'OS/2' in font else 0x40
|
|
140
|
+
is_italic = bool(fs & 0x01)
|
|
141
|
+
is_bold = bool(fs & 0x20)
|
|
142
|
+
ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
|
|
143
|
+
|
|
144
|
+
# nameID 17 (typographic subfamily): the name of the named instance at the new default, if any.
|
|
145
|
+
default_style = None
|
|
146
|
+
if 'fvar' in font:
|
|
147
|
+
defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
|
|
148
|
+
for inst in font['fvar'].instances:
|
|
149
|
+
if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
|
|
150
|
+
default_style = name_table.getDebugName(inst.subfamilyNameID)
|
|
151
|
+
break
|
|
152
|
+
|
|
113
153
|
# nameID 1 = Family, 4 = Full name, 6 = PostScript name
|
|
114
154
|
# nameID 2 = Subfamily — reset to 'Regular' so the restricted file does not
|
|
115
155
|
# collide with the source font's Subfamily in OS font caches.
|
|
@@ -127,37 +167,55 @@ def patch_font_names_fn(file_options):
|
|
|
127
167
|
|
|
128
168
|
updates = {
|
|
129
169
|
1: family_name,
|
|
130
|
-
2:
|
|
170
|
+
2: ribbi,
|
|
131
171
|
3: unique_id,
|
|
132
|
-
4: family_name,
|
|
172
|
+
4: family_name if ribbi == 'Regular' else '%s %s' % (family_name, ribbi),
|
|
133
173
|
6: ps_name,
|
|
134
174
|
}
|
|
135
175
|
if 16 in existing_ids:
|
|
136
176
|
updates[16] = family_name
|
|
177
|
+
if 17 in existing_ids and default_style:
|
|
178
|
+
updates[17] = default_style
|
|
137
179
|
if 25 in existing_ids:
|
|
138
180
|
updates[25] = ps_name
|
|
139
181
|
|
|
182
|
+
# Named instances' PostScript names must follow the new prefix, e.g. Inter-Regular-Bold-Medium.
|
|
183
|
+
if 'fvar' in font:
|
|
184
|
+
for inst in font['fvar'].instances:
|
|
185
|
+
pid = getattr(inst, 'postscriptNameID', 0xFFFF)
|
|
186
|
+
if pid in (None, 0xFFFF):
|
|
187
|
+
continue
|
|
188
|
+
style = (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', '')
|
|
189
|
+
style = ''.join(c for c in style if c.isascii() and (c.isalnum() or c == '-'))
|
|
190
|
+
updates[pid] = ('%s-%s' % (ps_name, style))[:63]
|
|
191
|
+
|
|
192
|
+
# Rewrite every platform. A Mac (platform 1) record that can't be encoded in Mac Roman is dropped
|
|
193
|
+
# rather than filled with '?'; Windows and Unicode records always carry the full name.
|
|
194
|
+
keep = []
|
|
140
195
|
for record in name_table.names:
|
|
141
196
|
if record.nameID not in updates:
|
|
197
|
+
keep.append(record)
|
|
142
198
|
continue
|
|
143
199
|
value = updates[record.nameID]
|
|
144
|
-
if record.platformID
|
|
200
|
+
if record.platformID in (0, 3):
|
|
145
201
|
record.string = value.encode('utf-16-be')
|
|
146
202
|
elif record.platformID == 1:
|
|
147
203
|
try:
|
|
148
204
|
record.string = value.encode('mac_roman')
|
|
149
205
|
except Exception:
|
|
150
|
-
|
|
206
|
+
continue
|
|
207
|
+
keep.append(record)
|
|
208
|
+
name_table.names = keep
|
|
151
209
|
|
|
152
210
|
font.save(file_options['output-file'])
|
|
153
211
|
|
|
154
212
|
patch_font_names_fn
|
|
155
213
|
`)
|
|
156
|
-
)),
|
|
214
|
+
)), S;
|
|
157
215
|
}
|
|
158
|
-
async function
|
|
159
|
-
return
|
|
160
|
-
(
|
|
216
|
+
async function W() {
|
|
217
|
+
return V || (V = d().then(
|
|
218
|
+
(e) => e.runPythonAsync(`
|
|
161
219
|
from fontTools.ttLib import TTFont
|
|
162
220
|
from fontTools.varLib import instancer
|
|
163
221
|
import json
|
|
@@ -166,6 +224,12 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
166
224
|
font = TTFont(file_options['input-file'])
|
|
167
225
|
axes_spec = json.loads(axes_json)
|
|
168
226
|
|
|
227
|
+
# The bundled fontTools can't restrict avar version 2 or VARC fonts correctly: refuse instead of mis-cutting.
|
|
228
|
+
if 'avar' in font and getattr(font['avar'], 'majorVersion', 1) >= 2:
|
|
229
|
+
raise ValueError('vf-clamp: avar version 2 fonts are not supported yet')
|
|
230
|
+
if 'VARC' in font:
|
|
231
|
+
raise ValueError('vf-clamp: fonts with a VARC table are not supported yet')
|
|
232
|
+
|
|
169
233
|
limits = {}
|
|
170
234
|
axes_by_tag = {ax.axisTag: ax for ax in font['fvar'].axes}
|
|
171
235
|
|
|
@@ -187,11 +251,11 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
187
251
|
|
|
188
252
|
vf_clamp_instantiate
|
|
189
253
|
`)
|
|
190
|
-
)),
|
|
254
|
+
)), V;
|
|
191
255
|
}
|
|
192
|
-
async function
|
|
193
|
-
return
|
|
194
|
-
(
|
|
256
|
+
async function q() {
|
|
257
|
+
return D || (D = d().then(
|
|
258
|
+
(e) => e.runPythonAsync(`
|
|
195
259
|
from fontTools.ttLib import TTFont
|
|
196
260
|
|
|
197
261
|
def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
@@ -211,6 +275,10 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
211
275
|
|
|
212
276
|
old_min = wght_axis.minValue
|
|
213
277
|
default = wght_axis.defaultValue
|
|
278
|
+
# With the default at the minimum there is no below-default range to stretch; leave the font as is.
|
|
279
|
+
if default <= old_min:
|
|
280
|
+
font.save(file_options['output-file'])
|
|
281
|
+
return 'skipped'
|
|
214
282
|
scale = (default - new_min) / (default - old_min)
|
|
215
283
|
|
|
216
284
|
def remap(v):
|
|
@@ -244,79 +312,21 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
244
312
|
av.NominalValue = remap(av.NominalValue)
|
|
245
313
|
av.RangeMinValue = remap(av.RangeMinValue)
|
|
246
314
|
av.RangeMaxValue = remap(av.RangeMaxValue)
|
|
315
|
+
elif fmt == 4:
|
|
316
|
+
for rec in getattr(av, 'AxisValueRecord', []) or []:
|
|
317
|
+
if rec.AxisIndex == wght_idx:
|
|
318
|
+
rec.Value = remap(rec.Value)
|
|
247
319
|
|
|
248
320
|
font.save(file_options['output-file'])
|
|
321
|
+
return 'done'
|
|
249
322
|
|
|
250
323
|
vf_clamp_normalize_wght
|
|
251
324
|
`)
|
|
252
|
-
)),
|
|
253
|
-
}
|
|
254
|
-
async function B() {
|
|
255
|
-
return I || (I = d().then(
|
|
256
|
-
(t) => t.runPythonAsync(`
|
|
257
|
-
from fontTools.ttLib import TTFont
|
|
258
|
-
|
|
259
|
-
def vf_clamp_prune_stat(file_options):
|
|
260
|
-
font = TTFont(file_options['input-file'])
|
|
261
|
-
|
|
262
|
-
if 'STAT' not in font or 'fvar' not in font:
|
|
263
|
-
font.save(file_options['output-file'])
|
|
264
|
-
return
|
|
265
|
-
|
|
266
|
-
stat = font['STAT'].table
|
|
267
|
-
if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
|
|
268
|
-
font.save(file_options['output-file'])
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
|
|
272
|
-
stat_axes = list(stat.DesignAxisRecord.Axis)
|
|
273
|
-
|
|
274
|
-
# Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
|
|
275
|
-
surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
|
|
276
|
-
if len(surviving_indices) == len(stat_axes):
|
|
277
|
-
# No axes removed — nothing to prune.
|
|
278
|
-
font.save(file_options['output-file'])
|
|
279
|
-
return
|
|
280
|
-
old_to_new = {old: new for new, old in enumerate(surviving_indices)}
|
|
281
|
-
|
|
282
|
-
# Rewrite the DesignAxisRecord.Axis list to surviving axes only.
|
|
283
|
-
stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
|
|
284
|
-
if hasattr(stat, 'DesignAxisCount'):
|
|
285
|
-
stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
|
|
286
|
-
|
|
287
|
-
# Prune AxisValueArray entries that reference removed axes.
|
|
288
|
-
if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
|
|
289
|
-
kept = []
|
|
290
|
-
for av in stat.AxisValueArray.AxisValue:
|
|
291
|
-
fmt = av.Format
|
|
292
|
-
if fmt in (1, 2, 3):
|
|
293
|
-
if av.AxisIndex not in old_to_new:
|
|
294
|
-
continue # references a removed axis
|
|
295
|
-
av.AxisIndex = old_to_new[av.AxisIndex]
|
|
296
|
-
kept.append(av)
|
|
297
|
-
elif fmt == 4:
|
|
298
|
-
inner = list(getattr(av, 'AxisValueRecord', []) or [])
|
|
299
|
-
if any(rec.AxisIndex not in old_to_new for rec in inner):
|
|
300
|
-
continue # whole record dies if any inner ref is dead
|
|
301
|
-
for rec in inner:
|
|
302
|
-
rec.AxisIndex = old_to_new[rec.AxisIndex]
|
|
303
|
-
kept.append(av)
|
|
304
|
-
else:
|
|
305
|
-
# Unknown format — keep as-is to be safe.
|
|
306
|
-
kept.append(av)
|
|
307
|
-
stat.AxisValueArray.AxisValue = kept
|
|
308
|
-
if hasattr(stat, 'AxisValueCount'):
|
|
309
|
-
stat.AxisValueCount = len(kept)
|
|
310
|
-
|
|
311
|
-
font.save(file_options['output-file'])
|
|
312
|
-
|
|
313
|
-
vf_clamp_prune_stat
|
|
314
|
-
`)
|
|
315
|
-
)), I;
|
|
325
|
+
)), D;
|
|
316
326
|
}
|
|
317
|
-
async function
|
|
318
|
-
return
|
|
319
|
-
(
|
|
327
|
+
async function H() {
|
|
328
|
+
return N || (N = d().then(
|
|
329
|
+
(e) => e.runPythonAsync(`
|
|
320
330
|
from fontTools.ttLib import TTFont
|
|
321
331
|
|
|
322
332
|
def vf_clamp_update_os2(file_options):
|
|
@@ -327,25 +337,26 @@ def vf_clamp_update_os2(file_options):
|
|
|
327
337
|
return
|
|
328
338
|
|
|
329
339
|
wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
|
|
330
|
-
if wght_axis is None:
|
|
340
|
+
if wght_axis is not None:
|
|
341
|
+
# OS/2.usWeightClass valid range is 1..1000.
|
|
342
|
+
weight_class = int(round(max(1, min(1000, wght_axis.defaultValue))))
|
|
343
|
+
elif 'OS/2' in font:
|
|
344
|
+
# wght was pinned: fontTools has already set usWeightClass to the pinned weight.
|
|
345
|
+
weight_class = font['OS/2'].usWeightClass
|
|
346
|
+
else:
|
|
331
347
|
font.save(file_options['output-file'])
|
|
332
348
|
return
|
|
333
349
|
|
|
334
|
-
new_default = wght_axis.defaultValue
|
|
335
|
-
# OS/2.usWeightClass valid range is 1..1000.
|
|
336
|
-
weight_class = int(round(max(1, min(1000, new_default))))
|
|
337
|
-
|
|
338
350
|
if 'OS/2' in font:
|
|
339
351
|
os2 = font['OS/2']
|
|
340
352
|
os2.usWeightClass = weight_class
|
|
341
|
-
# fsSelection
|
|
342
|
-
# Mirror the convention: REGULAR when usWeightClass < 600, BOLD when >= 700.
|
|
353
|
+
# fsSelection: 0x01 ITALIC (kept), 0x20 BOLD, 0x40 REGULAR. REGULAR only when neither ITALIC nor BOLD.
|
|
343
354
|
fs = os2.fsSelection
|
|
344
355
|
fs &= ~(0x20 | 0x40)
|
|
345
356
|
if weight_class >= 700:
|
|
346
|
-
fs |= 0x20
|
|
347
|
-
elif
|
|
348
|
-
fs |= 0x40
|
|
357
|
+
fs |= 0x20
|
|
358
|
+
elif not fs & 0x01:
|
|
359
|
+
fs |= 0x40
|
|
349
360
|
os2.fsSelection = fs
|
|
350
361
|
|
|
351
362
|
if 'head' in font:
|
|
@@ -362,257 +373,225 @@ def vf_clamp_update_os2(file_options):
|
|
|
362
373
|
|
|
363
374
|
vf_clamp_update_os2
|
|
364
375
|
`)
|
|
365
|
-
)),
|
|
376
|
+
)), N;
|
|
366
377
|
}
|
|
367
|
-
function
|
|
368
|
-
|
|
378
|
+
function J(e) {
|
|
379
|
+
const a = e.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 -]/g, "").trim().split(/\s+/).join("-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
380
|
+
return a.length <= 63 ? a : `${a.slice(0, 56).replace(/-+$/, "")}-${P(e)}`;
|
|
369
381
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
382
|
+
function P(e) {
|
|
383
|
+
let a = 2166136261;
|
|
384
|
+
for (const t of e)
|
|
385
|
+
a ^= t.codePointAt(0), a = Math.imul(a, 16777619) >>> 0;
|
|
386
|
+
return a.toString(36).padStart(6, "0").slice(-6);
|
|
387
|
+
}
|
|
388
|
+
async function G(e, a) {
|
|
389
|
+
if (!a) return e;
|
|
390
|
+
const t = await d(), n = new x({ pyodide: t }), i = new x({ pyodide: t });
|
|
373
391
|
try {
|
|
374
|
-
const
|
|
375
|
-
await
|
|
376
|
-
const
|
|
377
|
-
["input-file",
|
|
378
|
-
["output-file",
|
|
379
|
-
["family-name",
|
|
380
|
-
["postscript-name",
|
|
392
|
+
const o = J(a);
|
|
393
|
+
await n.upload(e);
|
|
394
|
+
const r = /* @__PURE__ */ new Map([
|
|
395
|
+
["input-file", n.filename],
|
|
396
|
+
["output-file", i.filename],
|
|
397
|
+
["family-name", a],
|
|
398
|
+
["postscript-name", o],
|
|
399
|
+
["postscript-suffix", P(a)]
|
|
381
400
|
]);
|
|
382
|
-
return (await
|
|
383
|
-
} catch (
|
|
384
|
-
throw Object.assign(new Error(`vf-clamp: name table patching failed for "${
|
|
401
|
+
return (await z())(r), i.download();
|
|
402
|
+
} catch (o) {
|
|
403
|
+
throw Object.assign(new Error(`vf-clamp: name table patching failed for "${a}"`), { cause: o });
|
|
385
404
|
} finally {
|
|
386
405
|
try {
|
|
387
|
-
|
|
406
|
+
n.delete();
|
|
388
407
|
} catch {
|
|
389
408
|
}
|
|
390
409
|
try {
|
|
391
|
-
|
|
410
|
+
i.delete();
|
|
392
411
|
} catch {
|
|
393
412
|
}
|
|
394
413
|
}
|
|
395
414
|
}
|
|
396
|
-
function
|
|
397
|
-
return typeof
|
|
415
|
+
function K(e) {
|
|
416
|
+
return typeof e == "number" ? e : e === null ? null : [e.min, e.max];
|
|
398
417
|
}
|
|
399
|
-
async function
|
|
400
|
-
const
|
|
418
|
+
async function Z(e, a) {
|
|
419
|
+
const t = await d(), n = new x({ pyodide: t }), i = new x({ pyodide: t });
|
|
401
420
|
try {
|
|
402
|
-
await
|
|
403
|
-
const
|
|
404
|
-
["input-file",
|
|
405
|
-
["output-file",
|
|
421
|
+
await n.upload(e);
|
|
422
|
+
const o = /* @__PURE__ */ new Map([
|
|
423
|
+
["input-file", n.filename],
|
|
424
|
+
["output-file", i.filename]
|
|
406
425
|
]);
|
|
407
|
-
return (await
|
|
426
|
+
return (await W())(o, JSON.stringify(a)), i.download();
|
|
408
427
|
} finally {
|
|
409
|
-
try {
|
|
410
|
-
e.delete();
|
|
411
|
-
} catch {
|
|
412
|
-
}
|
|
413
428
|
try {
|
|
414
429
|
n.delete();
|
|
415
430
|
} catch {
|
|
416
431
|
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
async function Z(t) {
|
|
420
|
-
const o = await d(), a = new x({ pyodide: o }), e = new x({ pyodide: o });
|
|
421
|
-
try {
|
|
422
|
-
await a.upload(t);
|
|
423
|
-
const n = /* @__PURE__ */ new Map([
|
|
424
|
-
["input-file", a.filename],
|
|
425
|
-
["output-file", e.filename]
|
|
426
|
-
]);
|
|
427
|
-
return (await B())(n), e.download();
|
|
428
|
-
} catch (n) {
|
|
429
|
-
throw Object.assign(new Error("vf-clamp: STAT pruning failed"), { cause: n });
|
|
430
|
-
} finally {
|
|
431
|
-
try {
|
|
432
|
-
a.delete();
|
|
433
|
-
} catch {
|
|
434
|
-
}
|
|
435
432
|
try {
|
|
436
|
-
|
|
433
|
+
i.delete();
|
|
437
434
|
} catch {
|
|
438
435
|
}
|
|
439
436
|
}
|
|
440
437
|
}
|
|
441
|
-
async function
|
|
442
|
-
const
|
|
438
|
+
async function Q(e) {
|
|
439
|
+
const a = await d(), t = new x({ pyodide: a }), n = new x({ pyodide: a });
|
|
443
440
|
try {
|
|
444
|
-
await
|
|
445
|
-
const
|
|
446
|
-
["input-file",
|
|
447
|
-
["output-file",
|
|
441
|
+
await t.upload(e);
|
|
442
|
+
const i = /* @__PURE__ */ new Map([
|
|
443
|
+
["input-file", t.filename],
|
|
444
|
+
["output-file", n.filename]
|
|
448
445
|
]);
|
|
449
|
-
return (await
|
|
450
|
-
} catch (
|
|
451
|
-
throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"), { cause:
|
|
446
|
+
return (await H())(i), n.download();
|
|
447
|
+
} catch (i) {
|
|
448
|
+
throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"), { cause: i });
|
|
452
449
|
} finally {
|
|
453
450
|
try {
|
|
454
|
-
|
|
451
|
+
t.delete();
|
|
455
452
|
} catch {
|
|
456
453
|
}
|
|
457
454
|
try {
|
|
458
|
-
|
|
455
|
+
n.delete();
|
|
459
456
|
} catch {
|
|
460
457
|
}
|
|
461
458
|
}
|
|
462
459
|
}
|
|
463
|
-
async function
|
|
464
|
-
const
|
|
460
|
+
async function X(e, a) {
|
|
461
|
+
const t = await d(), n = new x({ pyodide: t }), i = new x({ pyodide: t });
|
|
465
462
|
try {
|
|
466
|
-
await
|
|
467
|
-
const
|
|
468
|
-
["input-file",
|
|
469
|
-
["output-file",
|
|
463
|
+
await n.upload(e);
|
|
464
|
+
const o = /* @__PURE__ */ new Map([
|
|
465
|
+
["input-file", n.filename],
|
|
466
|
+
["output-file", i.filename]
|
|
470
467
|
]);
|
|
471
|
-
return (await
|
|
472
|
-
} catch (
|
|
473
|
-
throw Object.assign(new Error("vf-clamp: wght normalisation failed"), { cause:
|
|
468
|
+
return (await q())(o, String(a)) === "skipped" && console.warn("vf-clamp: normalizeWeightAxis skipped: the weight default is the axis minimum, so there is no lower range to remap"), i.download();
|
|
469
|
+
} catch (o) {
|
|
470
|
+
throw Object.assign(new Error("vf-clamp: wght normalisation failed"), { cause: o });
|
|
474
471
|
} finally {
|
|
475
472
|
try {
|
|
476
|
-
|
|
473
|
+
n.delete();
|
|
477
474
|
} catch {
|
|
478
475
|
}
|
|
479
476
|
try {
|
|
480
|
-
|
|
477
|
+
i.delete();
|
|
481
478
|
} catch {
|
|
482
479
|
}
|
|
483
480
|
}
|
|
484
481
|
}
|
|
485
|
-
function
|
|
486
|
-
const
|
|
487
|
-
for (const i of
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
e[f] ? (e[f].min = Math.min(e[f].min, c), e[f].max = Math.max(e[f].max, c)) : e[f] = { min: c, max: c };
|
|
482
|
+
function Y(e, a) {
|
|
483
|
+
const t = {};
|
|
484
|
+
for (const i of e) {
|
|
485
|
+
const o = T(i, a);
|
|
486
|
+
for (const [r, s] of Object.entries(o.coordinates))
|
|
487
|
+
t[r] ? (t[r].min = Math.min(t[r].min, s), t[r].max = Math.max(t[r].max, s)) : t[r] = { min: s, max: s };
|
|
492
488
|
}
|
|
493
489
|
const n = {};
|
|
494
|
-
for (const [i, { min:
|
|
495
|
-
n[i] =
|
|
490
|
+
for (const [i, { min: o, max: r }] of Object.entries(t))
|
|
491
|
+
n[i] = o === r ? o : { min: o, max: r };
|
|
496
492
|
return n;
|
|
497
493
|
}
|
|
498
|
-
function
|
|
499
|
-
return
|
|
494
|
+
function ee(e) {
|
|
495
|
+
return e.length >= 4 && e[0] === 79 && e[1] === 84 && e[2] === 84 && e[3] === 79;
|
|
500
496
|
}
|
|
501
|
-
async function
|
|
502
|
-
var
|
|
503
|
-
if (
|
|
504
|
-
const
|
|
505
|
-
for (const
|
|
506
|
-
let
|
|
507
|
-
if ((
|
|
508
|
-
const
|
|
509
|
-
const
|
|
510
|
-
return v == null ? !0 : typeof v == "number" ?
|
|
511
|
-
})).map((
|
|
512
|
-
if (
|
|
513
|
-
throw new Error(`vf-clamp: output "${
|
|
497
|
+
async function ae(e, a) {
|
|
498
|
+
var h, y;
|
|
499
|
+
if (a.outputs.length === 0) return [];
|
|
500
|
+
const t = e instanceof ArrayBuffer ? new Uint8Array(e) : e, n = a.format ?? "ttf", { instances: i, axes: o, family: r } = await B(t), s = [];
|
|
501
|
+
for (const f of a.outputs) {
|
|
502
|
+
let l = {};
|
|
503
|
+
if ((h = f.instances) != null && h.length && (l = Y(f.instances, i)), f.axes && (l = { ...l, ...f.axes }), a.strict) {
|
|
504
|
+
const _ = new Set((f.instances ?? []).map((p) => T(p, i))), w = i.filter((p) => !_.has(p) && o.every((b) => {
|
|
505
|
+
const F = p.coordinates[b.tag] ?? b.default, v = l[b.tag];
|
|
506
|
+
return v == null ? !0 : typeof v == "number" ? F === v : F >= v.min && F <= v.max;
|
|
507
|
+
})).map((p) => p.name);
|
|
508
|
+
if (w.length)
|
|
509
|
+
throw new Error(`vf-clamp: output "${f.name || (f.instances ?? []).join(", ") || "axes-only"}" would include unselected instances (${w.join(", ")}); use planOutputs() to split the selection`);
|
|
514
510
|
}
|
|
515
|
-
for (const
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
518
|
-
const
|
|
519
|
-
if (
|
|
520
|
-
const
|
|
511
|
+
for (const _ of o) {
|
|
512
|
+
const w = l[_.tag];
|
|
513
|
+
if (w != null && typeof w == "object") {
|
|
514
|
+
const p = w;
|
|
515
|
+
if (_.default < p.min || _.default > p.max) {
|
|
516
|
+
const b = Math.max(p.min, Math.min(p.max, _.default));
|
|
521
517
|
console.warn(
|
|
522
|
-
`vf-clamp: axis "${
|
|
518
|
+
`vf-clamp: axis "${_.tag}" default (${_.default}) is outside restricted range [${p.min}, ${p.max}] — will be clamped to ${b}`
|
|
523
519
|
);
|
|
524
520
|
}
|
|
525
521
|
}
|
|
526
522
|
}
|
|
527
|
-
const
|
|
528
|
-
for (const [
|
|
529
|
-
|
|
530
|
-
const u =
|
|
531
|
-
let
|
|
532
|
-
if (
|
|
523
|
+
const c = {};
|
|
524
|
+
for (const [_, w] of Object.entries(l))
|
|
525
|
+
w !== null && (c[_] = K(w));
|
|
526
|
+
const u = (y = f.instances) != null && y.length ? f.instances.length === 1 ? f.instances[0] : `${f.instances[0]}-${f.instances[f.instances.length - 1]}` : "Clamped", g = f.name || (r ? `${r} ${u}` : u);
|
|
527
|
+
let m = await Z(t, c);
|
|
528
|
+
if (n === "otf" && !ee(m))
|
|
533
529
|
throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");
|
|
534
|
-
|
|
530
|
+
a.normalizeWeightAxis && (m = await X(m, 100)), m = await Q(m), m = await G(m, g), n === "woff2" ? m = await C(m) : n === "woff" && (m = await k(m)), s.push({ name: g, buffer: m, format: n });
|
|
535
531
|
}
|
|
536
532
|
return s;
|
|
537
533
|
}
|
|
538
|
-
function
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
n++;
|
|
544
|
-
let i = 0;
|
|
545
|
-
for (; i < a.length - n && i < e.length - n && a[a.length - 1 - i] === e[e.length - 1 - i]; )
|
|
546
|
-
i++;
|
|
547
|
-
const s = a.slice(0, n).join(" "), f = a.slice(n, a.length - (i || 0)).join(" "), c = e.slice(n, e.length - (i || 0)).join(" "), w = i > 0 ? a.slice(a.length - i).join(" ") : "", m = f && c ? `${f}-${c}` : f || c;
|
|
548
|
-
return [s, m, w].filter(Boolean).join(" ");
|
|
549
|
-
}
|
|
550
|
-
function O(t, o) {
|
|
551
|
-
const a = {};
|
|
552
|
-
for (const e of o) {
|
|
553
|
-
const n = t.map((i) => i.coordinates[e.tag] ?? e.default);
|
|
554
|
-
a[e.tag] = { min: Math.min(...n), max: Math.max(...n) };
|
|
534
|
+
function O(e, a) {
|
|
535
|
+
const t = {};
|
|
536
|
+
for (const n of a) {
|
|
537
|
+
const i = e.map((o) => o.coordinates[n.tag] ?? n.default);
|
|
538
|
+
t[n.tag] = { min: Math.min(...i), max: Math.max(...i) };
|
|
555
539
|
}
|
|
556
|
-
return
|
|
540
|
+
return t;
|
|
557
541
|
}
|
|
558
|
-
function
|
|
559
|
-
return
|
|
560
|
-
const
|
|
561
|
-
return !
|
|
542
|
+
function j(e, a, t, n) {
|
|
543
|
+
return a.filter((i) => t.has(i) ? !1 : n.every((o) => {
|
|
544
|
+
const r = i.coordinates[o.tag] ?? o.default, s = e[o.tag];
|
|
545
|
+
return !s || r >= s.min && r <= s.max;
|
|
562
546
|
}));
|
|
563
547
|
}
|
|
564
|
-
function
|
|
565
|
-
return
|
|
548
|
+
function te(e) {
|
|
549
|
+
return e.reduce((a, t) => !a || t.maximum - t.minimum > a.maximum - a.minimum ? t : a, void 0);
|
|
566
550
|
}
|
|
567
|
-
function
|
|
568
|
-
const { axes:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (!s.length) return [];
|
|
575
|
-
const f = new Set(s.map((r) => r.name));
|
|
576
|
-
let c;
|
|
577
|
-
if (!N(O(s, e), n, f, e).length)
|
|
578
|
-
c = [s];
|
|
551
|
+
function ie(e, a, t) {
|
|
552
|
+
const { axes: n, instances: i } = e, o = [...new Set(a)].map((l) => T(l, i));
|
|
553
|
+
if (!o.length) return [];
|
|
554
|
+
const r = new Set(o);
|
|
555
|
+
let s;
|
|
556
|
+
if (!j(O(o, n), i, r, n).length)
|
|
557
|
+
s = [o];
|
|
579
558
|
else {
|
|
580
|
-
|
|
581
|
-
let
|
|
582
|
-
for (;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
for (let
|
|
586
|
-
const
|
|
587
|
-
if (!
|
|
588
|
-
c
|
|
589
|
-
break
|
|
559
|
+
s = o.map((c) => [c]);
|
|
560
|
+
let l = !0;
|
|
561
|
+
for (; l; ) {
|
|
562
|
+
l = !1;
|
|
563
|
+
e: for (let c = 0; c < s.length; c++)
|
|
564
|
+
for (let u = c + 1; u < s.length; u++) {
|
|
565
|
+
const g = [...s[c], ...s[u]];
|
|
566
|
+
if (!j(O(g, n), i, r, n).length) {
|
|
567
|
+
s[c] = g, s.splice(u, 1), l = !0;
|
|
568
|
+
break e;
|
|
590
569
|
}
|
|
591
570
|
}
|
|
592
571
|
}
|
|
593
572
|
}
|
|
594
|
-
const
|
|
595
|
-
for (const
|
|
596
|
-
const
|
|
597
|
-
if (
|
|
573
|
+
const h = te(n), y = h ? [h, ...n.filter((l) => l.tag !== h.tag)] : n, f = (l, c) => {
|
|
574
|
+
for (const u of y) {
|
|
575
|
+
const g = (l.coordinates[u.tag] ?? u.default) - (c.coordinates[u.tag] ?? u.default);
|
|
576
|
+
if (g) return g;
|
|
598
577
|
}
|
|
599
578
|
return 0;
|
|
600
579
|
};
|
|
601
|
-
return
|
|
602
|
-
const
|
|
603
|
-
return { name:
|
|
580
|
+
return s = s.map((l) => [...l].sort(f)).sort((l, c) => f(l[0], c[0])), s.map((l) => {
|
|
581
|
+
const c = l[0], u = l[l.length - 1], g = l.length === 1 ? c.name : U(c.name, u.name);
|
|
582
|
+
return { name: t ? `${t} ${g}` : g, instances: l.map((m) => m.name) };
|
|
604
583
|
});
|
|
605
584
|
}
|
|
606
|
-
function
|
|
607
|
-
const
|
|
608
|
-
return
|
|
585
|
+
function oe(e, a) {
|
|
586
|
+
const t = a.map((n) => T(n, e.instances));
|
|
587
|
+
return t.length ? j(O(t, e.axes), e.instances, new Set(t), e.axes).map((n) => n.name) : [];
|
|
609
588
|
}
|
|
610
589
|
export {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
590
|
+
ae as clampFont,
|
|
591
|
+
U as compactName,
|
|
592
|
+
k as convertToWoff,
|
|
593
|
+
C as convertToWoff2,
|
|
594
|
+
B as getInstances,
|
|
595
|
+
ie as planOutputs,
|
|
596
|
+
oe as unboughtInstances
|
|
618
597
|
};
|
package/package.json
CHANGED