@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 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/talk/paper) |
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/talk/paper), [data](https://vfclamp.com/talk/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.
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/talk/paper#method)):
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/talk/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.
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 instances-based output whose range would include unselected named instances. Defaults to `false`.
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 (STAT pruning, OS/2 update, weight normalisation, name patching) fails — a half-processed file that still carries the retail family name is never returned.
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
- | `500` | Processing failed: unknown instance name, `'otf'` for a TrueType font, a post-processing error. Nothing is returned for any output if one fails | Instance extraction failed |
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.** The endpoint clamps exactly the outputs you send; it doesn't accept `strict` yet. Get the font's instances from `/api/instances`, group the customer's styles locally with [`planOutputs`](#selling-named-styles-safely) (a pure function, no Pyodide needed), then send those outputs to `/api/clamp`:
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; vf-clamp then prunes STAT records for axes that were pinned out of `fvar`, so OS font menus don't surface unlicensed names.
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 that rely on newer formats (avar2, VARC) may be refused by this fontTools version. Hinting is whatever the instancer keeps; most VFs ship unhinted.
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 $=require("node:module");var T=typeof document<"u"?document.currentScript:null;const k=$.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:T&&T.tagName.toUpperCase()==="SCRIPT"&&T.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:d,PyodideFile:x}=k("@web-alchemy/fonttools/src/pyodide.js");let b=null;async function C(){return b||(b=d().then(t=>t.runPythonAsync(`
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
- `))),b}async function P(t,o){const a=await d(),e=new x({pyodide:a}),n=new x({pyodide:a});try{await e.upload(t);const i=new Map([["input-file",e.filename],["output-file",n.filename],["flavor",o]]);return(await C())(i),n.download()}finally{try{e.delete()}catch{}try{n.delete()}catch{}}}async function j(t){return P(t,"woff")}async function M(t){return P(t,"woff2")}let F=null;async function E(){return F||(F=d().then(t=>t.runPythonAsync(`
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
- `))),F}async function L(t){const o=await d(),a=t instanceof ArrayBuffer?new Uint8Array(t):t,e=new x({pyodide:o});try{await e.upload(a);const i=(await E())(e.filename);return JSON.parse(i)}finally{try{e.delete()}catch{}}}let S=null,I=null,V=null,D=null,O=null;async function z(){return S||(S=d().then(t=>t.runPythonAsync(`
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: 'Regular',
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 == 3:
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
- record.string = value.encode('ascii', errors='replace')
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
- `))),S}async function W(){return I||(I=d().then(t=>t.runPythonAsync(`
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
- `))),I}async function q(){return V||(V=d().then(t=>t.runPythonAsync(`
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
- `))),V}async function B(){return D||(D=d().then(t=>t.runPythonAsync(`
185
- from fontTools.ttLib import TTFont
186
-
187
- def vf_clamp_prune_stat(file_options):
188
- font = TTFont(file_options['input-file'])
189
-
190
- if 'STAT' not in font or 'fvar' not in font:
191
- font.save(file_options['output-file'])
192
- return
193
-
194
- stat = font['STAT'].table
195
- if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
196
- font.save(file_options['output-file'])
197
- return
198
-
199
- fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
200
- stat_axes = list(stat.DesignAxisRecord.Axis)
201
-
202
- # Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
203
- surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
204
- if len(surviving_indices) == len(stat_axes):
205
- # No axes removed — nothing to prune.
206
- font.save(file_options['output-file'])
207
- return
208
- old_to_new = {old: new for new, old in enumerate(surviving_indices)}
209
-
210
- # Rewrite the DesignAxisRecord.Axis list to surviving axes only.
211
- stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
212
- if hasattr(stat, 'DesignAxisCount'):
213
- stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
214
-
215
- # Prune AxisValueArray entries that reference removed axes.
216
- if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
217
- kept = []
218
- for av in stat.AxisValueArray.AxisValue:
219
- fmt = av.Format
220
- if fmt in (1, 2, 3):
221
- if av.AxisIndex not in old_to_new:
222
- continue # references a removed axis
223
- av.AxisIndex = old_to_new[av.AxisIndex]
224
- kept.append(av)
225
- elif fmt == 4:
226
- inner = list(getattr(av, 'AxisValueRecord', []) or [])
227
- if any(rec.AxisIndex not in old_to_new for rec in inner):
228
- continue # whole record dies if any inner ref is dead
229
- for rec in inner:
230
- rec.AxisIndex = old_to_new[rec.AxisIndex]
231
- kept.append(av)
232
- else:
233
- # Unknown format — keep as-is to be safe.
234
- kept.append(av)
235
- stat.AxisValueArray.AxisValue = kept
236
- if hasattr(stat, 'AxisValueCount'):
237
- stat.AxisValueCount = len(kept)
238
-
239
- font.save(file_options['output-file'])
240
-
241
- vf_clamp_prune_stat
242
- `))),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 bits: 0x20 = BOLD, 0x40 = REGULAR.
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 # BOLD
270
- elif weight_class < 600:
271
- fs |= 0x40 # REGULAR
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 H(t){return t.replace(/[^A-Za-z0-9 -]/g,"").trim().split(/\s+/).join("-").replace(/^-+|-+$/g,"").slice(0,63)}async function J(t,o){if(!o)return t;const a=await d(),e=new x({pyodide:a}),n=new x({pyodide:a});try{const i=H(o);await e.upload(t);const s=new Map([["input-file",e.filename],["output-file",n.filename],["family-name",o],["postscript-name",i]]);return(await z())(s),n.download()}catch(i){throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o}"`),{cause:i})}finally{try{e.delete()}catch{}try{n.delete()}catch{}}}function Z(t){return typeof t=="number"?t:t===null?null:[t.min,t.max]}async function K(t,o){const a=await d(),e=new x({pyodide:a}),n=new x({pyodide:a});try{await e.upload(t);const i=new Map([["input-file",e.filename],["output-file",n.filename]]);return(await W())(i,JSON.stringify(o)),n.download()}finally{try{e.delete()}catch{}try{n.delete()}catch{}}}async function Q(t){const o=await d(),a=new x({pyodide:o}),e=new x({pyodide:o});try{await a.upload(t);const n=new Map([["input-file",a.filename],["output-file",e.filename]]);return(await B())(n),e.download()}catch(n){throw Object.assign(new Error("vf-clamp: STAT pruning failed"),{cause:n})}finally{try{a.delete()}catch{}try{e.delete()}catch{}}}async function X(t){const o=await d(),a=new x({pyodide:o}),e=new x({pyodide:o});try{await a.upload(t);const n=new Map([["input-file",a.filename],["output-file",e.filename]]);return(await G())(n),e.download()}catch(n){throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"),{cause:n})}finally{try{a.delete()}catch{}try{e.delete()}catch{}}}async function Y(t,o){const a=await d(),e=new x({pyodide:a}),n=new x({pyodide:a});try{await e.upload(t);const i=new Map([["input-file",e.filename],["output-file",n.filename]]);return(await q())(i,String(o)),n.download()}catch(i){throw Object.assign(new Error("vf-clamp: wght normalisation failed"),{cause:i})}finally{try{e.delete()}catch{}try{n.delete()}catch{}}}function tt(t,o){const a=new Map(o.map(i=>[i.name,i])),e={};for(const i of t){const s=a.get(i);if(!s)throw new Error(`Named instance "${i}" not found in font`);for(const[f,c]of Object.entries(s.coordinates))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}}const n={};for(const[i,{min:s,max:f}]of Object.entries(e))n[i]=s===f?s:{min:s,max:f};return n}function et(t){return t.length>=4&&t[0]===79&&t[1]===84&&t[2]===84&&t[3]===79}async function nt(t,o){var f,c,w;if(o.outputs.length===0)return[];const a=t instanceof ArrayBuffer?new Uint8Array(t):t,e=o.format??"ttf",{instances:n,axes:i}=await L(a),s=[];for(const m of o.outputs){let _={};if((f=m.instances)!=null&&f.length&&(_=tt(m.instances,n)),m.axes&&(_={..._,...m.axes}),o.strict&&((c=m.instances)!=null&&c.length)){const p=new Set(m.instances),g=n.filter(h=>!p.has(h.name)&&i.every(y=>{const A=h.coordinates[y.tag]??y.default,v=_[y.tag];return v==null?!0:typeof v=="number"?A===v:A>=v.min&&A<=v.max})).map(h=>h.name);if(g.length)throw new Error(`vf-clamp: output "${m.name||m.instances.join(", ")}" would include unselected instances (${g.join(", ")}); use planOutputs() to split the selection`)}for(const p of i){const g=_[p.tag];if(g!=null&&typeof g=="object"){const h=g;if(p.default<h.min||p.default>h.max){const y=Math.max(h.min,Math.min(h.max,p.default));console.warn(`vf-clamp: axis "${p.tag}" default (${p.default}) is outside restricted range [${h.min}, ${h.max}] — will be clamped to ${y}`)}}}const r={};for(const[p,g]of Object.entries(_))g!==null&&(r[p]=Z(g));const u=m.name||((w=m.instances)!=null&&w.length?m.instances.length===1?m.instances[0]:`${m.instances[0]}-${m.instances[m.instances.length-1]}`:"output");let l=await K(a,r);if(e==="otf"&&!et(l))throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");l=await Q(l),o.normalizeWeightAxis&&(l=await Y(l,100)),l=await X(l),l=await J(l,u),e==="woff2"?l=await M(l):e==="woff"&&(l=await j(l)),s.push({name:u,buffer:l,format:e})}return s}function U(t,o){if(t===o)return t;const a=t.split(" "),e=o.split(" ");let n=0;for(;n<a.length&&n<e.length&&a[n]===e[n];)n++;let i=0;for(;i<a.length-n&&i<e.length-n&&a[a.length-1-i]===e[e.length-1-i];)i++;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;return[s,m,w].filter(Boolean).join(" ")}function R(t,o){const a={};for(const e of o){const n=t.map(i=>i.coordinates[e.tag]??e.default);a[e.tag]={min:Math.min(...n),max:Math.max(...n)}}return a}function N(t,o,a,e){return o.filter(n=>a.has(n.name)?!1:e.every(i=>{const s=n.coordinates[i.tag]??i.default,f=t[i.tag];return!f||s>=f.min&&s<=f.max}))}function at(t){return t.reduce((o,a)=>!o||a.maximum-a.minimum>o.maximum-o.minimum?a:o,void 0)}function it(t,o,a){const{axes:e,instances:n}=t,i=new Map(n.map(r=>[r.name,r])),s=[];for(const r of new Set(o)){const u=i.get(r);if(!u)throw new Error(`Named instance "${r}" not found in font`);s.push(u)}if(!s.length)return[];const f=new Set(s.map(r=>r.name));let c;if(!N(R(s,e),n,f,e).length)c=[s];else{c=s.map(u=>[u]);let r=!0;for(;r;){r=!1;t:for(let u=0;u<c.length;u++)for(let l=u+1;l<c.length;l++){const p=[...c[u],...c[l]];if(!N(R(p,e),n,f,e).length){c[u]=p,c.splice(l,1),r=!0;break t}}}}const w=at(e),m=w?[w,...e.filter(r=>r.tag!==w.tag)]:e,_=(r,u)=>{for(const l of m){const p=(r.coordinates[l.tag]??l.default)-(u.coordinates[l.tag]??l.default);if(p)return p}return 0};return c=c.map(r=>[...r].sort(_)).sort((r,u)=>_(r[0],u[0])),c.map(r=>{const u=r[0],l=r[r.length-1],p=r.length===1?u.name:U(u.name,l.name);return{name:a?`${a} ${p}`:p,instances:r.map(g=>g.name)}})}function ot(t,o){const a=new Map(t.instances.map(n=>[n.name,n])),e=o.map(n=>a.get(n)).filter(n=>!!n);return e.length?N(R(e,t.axes),t.instances,new Set(o),t.axes).map(n=>n.name):[]}exports.clampFont=nt;exports.compactName=U;exports.convertToWoff=j;exports.convertToWoff2=M;exports.getInstances=L;exports.planOutputs=it;exports.unboughtInstances=ot;
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 instances-based output whose range would include a named instance that was not listed
60
- * (e.g. Regular + Bold would also hand over Medium and SemiBold). Throws instead of clamping.
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 j } from "node:module";
2
- const R = j(import.meta.url), { preparePyodide: d, PyodideFile: x } = R("@web-alchemy/fonttools/src/pyodide.js");
3
- let T = null;
4
- async function M() {
5
- return T || (T = d().then(
6
- (t) => t.runPythonAsync(`
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
- )), T;
16
+ )), A;
17
17
  }
18
- async function P(t, o) {
19
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
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 e.upload(t);
22
- const i = /* @__PURE__ */ new Map([
23
- ["input-file", e.filename],
24
- ["output-file", n.filename],
25
- ["flavor", o]
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 M())(i), n.download();
27
+ return (await L())(o), i.download();
28
28
  } finally {
29
29
  try {
30
- e.delete();
30
+ n.delete();
31
31
  } catch {
32
32
  }
33
33
  try {
34
- n.delete();
34
+ i.delete();
35
35
  } catch {
36
36
  }
37
37
  }
38
38
  }
39
- async function L(t) {
40
- return P(t, "woff");
39
+ async function k(e) {
40
+ return R(e, "woff");
41
41
  }
42
- async function $(t) {
43
- return P(t, "woff2");
42
+ async function C(e) {
43
+ return R(e, "woff2");
44
44
  }
45
- let b = null;
46
- async function k() {
47
- return b || (b = d().then(
48
- (t) => t.runPythonAsync(`
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
- )), b;
85
+ )), I;
85
86
  }
86
- async function E(t) {
87
- const o = await d(), a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, e = new x({ pyodide: o });
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 e.upload(a);
90
- const i = (await k())(e.filename);
91
- return JSON.parse(i);
90
+ await n.upload(t);
91
+ const o = (await E())(n.filename);
92
+ return JSON.parse(o);
92
93
  } finally {
93
94
  try {
94
- e.delete();
95
+ n.delete();
95
96
  } catch {
96
97
  }
97
98
  }
98
99
  }
99
- let F = null, S = null, V = null, I = null, D = null;
100
- async function U() {
101
- return F || (F = d().then(
102
- (t) => t.runPythonAsync(`
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: 'Regular',
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 == 3:
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
- record.string = value.encode('ascii', errors='replace')
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
- )), F;
214
+ )), S;
157
215
  }
158
- async function C() {
159
- return S || (S = d().then(
160
- (t) => t.runPythonAsync(`
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
- )), S;
254
+ )), V;
191
255
  }
192
- async function z() {
193
- return V || (V = d().then(
194
- (t) => t.runPythonAsync(`
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
- )), V;
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 W() {
318
- return D || (D = d().then(
319
- (t) => t.runPythonAsync(`
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 bits: 0x20 = BOLD, 0x40 = REGULAR.
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 # BOLD
347
- elif weight_class < 600:
348
- fs |= 0x40 # REGULAR
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
- )), D;
376
+ )), N;
366
377
  }
367
- function q(t) {
368
- return t.replace(/[^A-Za-z0-9 -]/g, "").trim().split(/\s+/).join("-").replace(/^-+|-+$/g, "").slice(0, 63);
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
- async function G(t, o) {
371
- if (!o) return t;
372
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
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 i = q(o);
375
- await e.upload(t);
376
- const s = /* @__PURE__ */ new Map([
377
- ["input-file", e.filename],
378
- ["output-file", n.filename],
379
- ["family-name", o],
380
- ["postscript-name", i]
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 U())(s), n.download();
383
- } catch (i) {
384
- throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o}"`), { cause: i });
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
- e.delete();
406
+ n.delete();
388
407
  } catch {
389
408
  }
390
409
  try {
391
- n.delete();
410
+ i.delete();
392
411
  } catch {
393
412
  }
394
413
  }
395
414
  }
396
- function H(t) {
397
- return typeof t == "number" ? t : t === null ? null : [t.min, t.max];
415
+ function K(e) {
416
+ return typeof e == "number" ? e : e === null ? null : [e.min, e.max];
398
417
  }
399
- async function J(t, o) {
400
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
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 e.upload(t);
403
- const i = /* @__PURE__ */ new Map([
404
- ["input-file", e.filename],
405
- ["output-file", n.filename]
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 C())(i, JSON.stringify(o)), n.download();
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
- e.delete();
433
+ i.delete();
437
434
  } catch {
438
435
  }
439
436
  }
440
437
  }
441
- async function K(t) {
442
- const o = await d(), a = new x({ pyodide: o }), e = new x({ pyodide: o });
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 a.upload(t);
445
- const n = /* @__PURE__ */ new Map([
446
- ["input-file", a.filename],
447
- ["output-file", e.filename]
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 W())(n), e.download();
450
- } catch (n) {
451
- throw Object.assign(new Error("vf-clamp: OS/2 and macStyle update failed"), { cause: n });
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
- a.delete();
451
+ t.delete();
455
452
  } catch {
456
453
  }
457
454
  try {
458
- e.delete();
455
+ n.delete();
459
456
  } catch {
460
457
  }
461
458
  }
462
459
  }
463
- async function Q(t, o) {
464
- const a = await d(), e = new x({ pyodide: a }), n = new x({ pyodide: a });
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 e.upload(t);
467
- const i = /* @__PURE__ */ new Map([
468
- ["input-file", e.filename],
469
- ["output-file", n.filename]
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 z())(i, String(o)), n.download();
472
- } catch (i) {
473
- throw Object.assign(new Error("vf-clamp: wght normalisation failed"), { cause: i });
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
- e.delete();
473
+ n.delete();
477
474
  } catch {
478
475
  }
479
476
  try {
480
- n.delete();
477
+ i.delete();
481
478
  } catch {
482
479
  }
483
480
  }
484
481
  }
485
- function X(t, o) {
486
- const a = new Map(o.map((i) => [i.name, i])), e = {};
487
- for (const i of t) {
488
- const s = a.get(i);
489
- if (!s) throw new Error(`Named instance "${i}" not found in font`);
490
- for (const [f, c] of Object.entries(s.coordinates))
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: s, max: f }] of Object.entries(e))
495
- n[i] = s === f ? s : { min: s, max: f };
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 Y(t) {
499
- return t.length >= 4 && t[0] === 79 && t[1] === 84 && t[2] === 84 && t[3] === 79;
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 at(t, o) {
502
- var f, c, w;
503
- if (o.outputs.length === 0) return [];
504
- const a = t instanceof ArrayBuffer ? new Uint8Array(t) : t, e = o.format ?? "ttf", { instances: n, axes: i } = await E(a), s = [];
505
- for (const m of o.outputs) {
506
- let _ = {};
507
- if ((f = m.instances) != null && f.length && (_ = X(m.instances, n)), m.axes && (_ = { ..._, ...m.axes }), o.strict && ((c = m.instances) != null && c.length)) {
508
- const p = new Set(m.instances), g = n.filter((h) => !p.has(h.name) && i.every((y) => {
509
- const A = h.coordinates[y.tag] ?? y.default, v = _[y.tag];
510
- return v == null ? !0 : typeof v == "number" ? A === v : A >= v.min && A <= v.max;
511
- })).map((h) => h.name);
512
- if (g.length)
513
- throw new Error(`vf-clamp: output "${m.name || m.instances.join(", ")}" would include unselected instances (${g.join(", ")}); use planOutputs() to split the selection`);
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 p of i) {
516
- const g = _[p.tag];
517
- if (g != null && typeof g == "object") {
518
- const h = g;
519
- if (p.default < h.min || p.default > h.max) {
520
- const y = Math.max(h.min, Math.min(h.max, p.default));
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 "${p.tag}" default (${p.default}) is outside restricted range [${h.min}, ${h.max}] — will be clamped to ${y}`
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 r = {};
528
- for (const [p, g] of Object.entries(_))
529
- g !== null && (r[p] = H(g));
530
- const u = m.name || ((w = m.instances) != null && w.length ? m.instances.length === 1 ? m.instances[0] : `${m.instances[0]}-${m.instances[m.instances.length - 1]}` : "output");
531
- let l = await J(a, r);
532
- if (e === "otf" && !Y(l))
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
- l = await Z(l), o.normalizeWeightAxis && (l = await Q(l, 100)), l = await K(l), l = await G(l, u), e === "woff2" ? l = await $(l) : e === "woff" && (l = await L(l)), s.push({ name: u, buffer: l, format: e });
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 tt(t, o) {
539
- if (t === o) return t;
540
- const a = t.split(" "), e = o.split(" ");
541
- let n = 0;
542
- for (; n < a.length && n < e.length && a[n] === e[n]; )
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 a;
540
+ return t;
557
541
  }
558
- function N(t, o, a, e) {
559
- return o.filter((n) => a.has(n.name) ? !1 : e.every((i) => {
560
- const s = n.coordinates[i.tag] ?? i.default, f = t[i.tag];
561
- return !f || s >= f.min && s <= f.max;
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 et(t) {
565
- return t.reduce((o, a) => !o || a.maximum - a.minimum > o.maximum - o.minimum ? a : o, void 0);
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 it(t, o, a) {
568
- const { axes: e, instances: n } = t, i = new Map(n.map((r) => [r.name, r])), s = [];
569
- for (const r of new Set(o)) {
570
- const u = i.get(r);
571
- if (!u) throw new Error(`Named instance "${r}" not found in font`);
572
- s.push(u);
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
- c = s.map((u) => [u]);
581
- let r = !0;
582
- for (; r; ) {
583
- r = !1;
584
- t: for (let u = 0; u < c.length; u++)
585
- for (let l = u + 1; l < c.length; l++) {
586
- const p = [...c[u], ...c[l]];
587
- if (!N(O(p, e), n, f, e).length) {
588
- c[u] = p, c.splice(l, 1), r = !0;
589
- break t;
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 w = et(e), m = w ? [w, ...e.filter((r) => r.tag !== w.tag)] : e, _ = (r, u) => {
595
- for (const l of m) {
596
- const p = (r.coordinates[l.tag] ?? l.default) - (u.coordinates[l.tag] ?? l.default);
597
- if (p) return p;
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 c = c.map((r) => [...r].sort(_)).sort((r, u) => _(r[0], u[0])), c.map((r) => {
602
- const u = r[0], l = r[r.length - 1], p = r.length === 1 ? u.name : tt(u.name, l.name);
603
- return { name: a ? `${a} ${p}` : p, instances: r.map((g) => g.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 ot(t, o) {
607
- const a = new Map(t.instances.map((n) => [n.name, n])), e = o.map((n) => a.get(n)).filter((n) => !!n);
608
- return e.length ? N(O(e, t.axes), t.instances, new Set(o), t.axes).map((n) => n.name) : [];
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
- at as clampFont,
612
- tt as compactName,
613
- L as convertToWoff,
614
- $ as convertToWoff2,
615
- E as getInstances,
616
- it as planOutputs,
617
- ot as unboughtInstances
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpunch/vf-clamp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Restrict a variable font's axis ranges to the named styles a customer bought — like CSS clamp() for design space.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",