@overpunch/vf-clamp 2.1.4 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const N=require("node:module");var g=typeof document<"u"?document.currentScript:null;const O=N.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:g&&g.tagName.toUpperCase()==="SCRIPT"&&g.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:l,PyodideFile:u}=O("@web-alchemy/fonttools/src/pyodide.js");let h=null;async function j(){return h||(h=l().then(e=>e.runPythonAsync(`
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
- `))),h}async function V(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename],["flavor",o]]);return(await j())(i),n.download()}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function I(e){return V(e,"woff")}async function D(e){return V(e,"woff2")}let w=null;async function L(){return w||(w=l().then(e=>e.runPythonAsync(`
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
- `))),w}async function R(e){const o=await l(),a=e instanceof ArrayBuffer?new Uint8Array(e):e,t=new u({pyodide:o});try{await t.upload(a);const i=(await L())(t.filename);return JSON.parse(i)}finally{try{t.delete()}catch{}}}let v=null,y=null,A=null,T=null,b=null;async function M(){return v||(v=l().then(e=>e.runPythonAsync(`
46
+ `))),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
- `))),v}async function U(){return y||(y=l().then(e=>e.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
- `))),y}async function k(){return A||(A=l().then(e=>e.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
- `))),A}async function C(){return T||(T=l().then(e=>e.runPythonAsync(`
185
- from fontTools.ttLib import TTFont
186
-
187
- def vf_clamp_prune_stat(file_options):
188
- font = TTFont(file_options['input-file'])
189
-
190
- if 'STAT' not in font or 'fvar' not in font:
191
- font.save(file_options['output-file'])
192
- return
193
-
194
- stat = font['STAT'].table
195
- if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
196
- font.save(file_options['output-file'])
197
- return
198
-
199
- fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
200
- stat_axes = list(stat.DesignAxisRecord.Axis)
201
-
202
- # Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
203
- surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
204
- if len(surviving_indices) == len(stat_axes):
205
- # No axes removed — nothing to prune.
206
- font.save(file_options['output-file'])
207
- return
208
- old_to_new = {old: new for new, old in enumerate(surviving_indices)}
209
-
210
- # Rewrite the DesignAxisRecord.Axis list to surviving axes only.
211
- stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
212
- if hasattr(stat, 'DesignAxisCount'):
213
- stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
214
-
215
- # Prune AxisValueArray entries that reference removed axes.
216
- if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
217
- kept = []
218
- for av in stat.AxisValueArray.AxisValue:
219
- fmt = av.Format
220
- if fmt in (1, 2, 3):
221
- if av.AxisIndex not in old_to_new:
222
- continue # references a removed axis
223
- av.AxisIndex = old_to_new[av.AxisIndex]
224
- kept.append(av)
225
- elif fmt == 4:
226
- inner = list(getattr(av, 'AxisValueRecord', []) or [])
227
- if any(rec.AxisIndex not in old_to_new for rec in inner):
228
- continue # whole record dies if any inner ref is dead
229
- for rec in inner:
230
- rec.AxisIndex = old_to_new[rec.AxisIndex]
231
- kept.append(av)
232
- else:
233
- # Unknown format — keep as-is to be safe.
234
- kept.append(av)
235
- stat.AxisValueArray.AxisValue = kept
236
- if hasattr(stat, 'AxisValueCount'):
237
- stat.AxisValueCount = len(kept)
238
-
239
- font.save(file_options['output-file'])
240
-
241
- vf_clamp_prune_stat
242
- `))),T}async function $(){return b||(b=l().then(e=>e.runPythonAsync(`
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
- `))),b}function z(e){return e.replace(/[^A-Za-z0-9 -]/g,"").trim().split(/\s+/).join("-").replace(/^-+|-+$/g,"").slice(0,63)}async function W(e,o){if(!o)return e;const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{const i=z(o);await t.upload(e);const r=new Map([["input-file",t.filename],["output-file",n.filename],["family-name",o],["postscript-name",i]]);return(await M())(r),n.download()}catch(i){return console.warn(`vf-clamp: name table patching failed for "${o}" — returning unpatched buffer`,i),e}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}function q(e){return typeof e=="number"?e:e===null?null:[e.min,e.max]}async function B(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await U())(i,JSON.stringify(o)),n.download()}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}async function E(e){const o=await l(),a=new u({pyodide:o}),t=new u({pyodide:o});try{await a.upload(e);const n=new Map([["input-file",a.filename],["output-file",t.filename]]);return(await C())(n),t.download()}catch(n){return console.warn("vf-clamp: STAT prune failed — returning unpruned buffer",n),e}finally{try{a.delete()}catch{}try{t.delete()}catch{}}}async function G(e){const o=await l(),a=new u({pyodide:o}),t=new u({pyodide:o});try{await a.upload(e);const n=new Map([["input-file",a.filename],["output-file",t.filename]]);return(await $())(n),t.download()}catch(n){return console.warn("vf-clamp: OS/2 + macStyle update failed — returning original buffer",n),e}finally{try{a.delete()}catch{}try{t.delete()}catch{}}}async function J(e,o){const a=await l(),t=new u({pyodide:a}),n=new u({pyodide:a});try{await t.upload(e);const i=new Map([["input-file",t.filename],["output-file",n.filename]]);return(await k())(i,String(o)),n.download()}catch(i){return console.warn("vf-clamp: wght normalisation failed — returning unnormalised buffer",i),e}finally{try{t.delete()}catch{}try{n.delete()}catch{}}}function H(e,o){const a=new Map(o.map(i=>[i.name,i])),t={};for(const i of e){const r=a.get(i);if(!r)throw new Error(`Named instance "${i}" not found in font`);for(const[s,m]of Object.entries(r.coordinates))t[s]?(t[s].min=Math.min(t[s].min,m),t[s].max=Math.max(t[s].max,m)):t[s]={min:m,max:m}}const n={};for(const[i,{min:r,max:s}]of Object.entries(t))n[i]=r===s?r:{min:r,max:s};return n}async function Z(e,o){var s,m;if(o.outputs.length===0)return[];const a=e instanceof ArrayBuffer?new Uint8Array(e):e,t=o.format??"ttf";let n=[],i=[];if(o.outputs.some(f=>{var p;return(p=f.instances)==null?void 0:p.length})){const f=await R(a);n=f.instances,i=f.axes}const r=[];for(const f of o.outputs){let p={};(s=f.instances)!=null&&s.length&&(p=H(f.instances,n)),f.axes&&(p={...p,...f.axes});for(const d of i){const x=p[d.tag];if(x!=null&&typeof x=="object"){const _=x;if(d.default<_.min||d.default>_.max){const P=Math.max(_.min,Math.min(_.max,d.default));console.warn(`vf-clamp: axis "${d.tag}" default (${d.default}) is outside restricted range [${_.min}, ${_.max}] — will be clamped to ${P}`)}}}const F={};for(const[d,x]of Object.entries(p))x!==null&&(F[d]=q(x));const S=f.name??((m=f.instances)!=null&&m.length?f.instances.length===1?f.instances[0]:`${f.instances[0]}-${f.instances[f.instances.length-1]}`:"output");let c=await B(a,F);c=await E(c),o.normalizeWeightAxis&&(c=await J(c,100)),c=await G(c),c=await W(c,S),t==="woff2"?c=await D(c):t==="woff"&&(c=await I(c)),r.push({name:S,buffer:c,format:t})}return r}function K(e,o){if(e===o)return e;const a=e.split(" "),t=o.split(" ");let n=0;for(;n<a.length&&n<t.length&&a[n]===t[n];)n++;let i=0;for(;i<a.length-n&&i<t.length-n&&a[a.length-1-i]===t[t.length-1-i];)i++;const r=a.slice(0,n).join(" "),s=a.slice(n,a.length-(i||0)).join(" "),m=t.slice(n,t.length-(i||0)).join(" "),f=i>0?a.slice(a.length-i).join(" "):"",p=s&&m?`${s}-${m}`:s||m;return[r,p,f].filter(Boolean).join(" ")}exports.clampFont=Z;exports.compactName=K;exports.convertToWoff=I;exports.convertToWoff2=D;exports.getInstances=R;
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
@@ -52,9 +52,16 @@ export declare interface ClampOptions {
52
52
  /**
53
53
  * Output format — defaults to 'ttf'.
54
54
  * 'woff' and 'woff2' transcode the result to a web-compressed flavour.
55
- * 'otf' is a passthrough — the instancer preserves the input outline format.
55
+ * 'otf' is a label, not a conversion — it requires a CFF/CFF2 source and throws for TrueType outlines.
56
56
  */
57
57
  format?: OutputFormat;
58
+ /**
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.
62
+ * Use planOutputs() to split a customer's selection into safe outputs. Defaults to false.
63
+ */
64
+ strict?: boolean;
58
65
  /**
59
66
  * Remap the wght axis so its minimum becomes 100, making CSS font-weight values
60
67
  * (100–900) work as expected. Instance and STAT axis value coordinates are
@@ -111,6 +118,8 @@ export declare interface FontInstancesResult {
111
118
  axes: AxisDefinition[];
112
119
  /** All named instances defined in the font */
113
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;
114
123
  }
115
124
 
116
125
  /**
@@ -147,14 +156,40 @@ export declare interface OutputConfig {
147
156
  /**
148
157
  * Output format for clampFont() — defaults to 'ttf'.
149
158
  * 'woff' and 'woff2' transcode to web-compressed flavours.
150
- * 'otf' is a passthrough (no outline conversion); the instancer preserves the input flavour.
159
+ * 'otf' requires a CFF/CFF2 source (no outline conversion is done) and throws for TrueType outlines.
151
160
  */
152
161
  export declare type OutputFormat = 'ttf' | 'otf' | 'woff' | 'woff2';
153
162
 
154
163
  /** Pin an axis at a single value — the axis is locked and removed from the output font's design space */
155
164
  export declare type PinnedAxis = number;
156
165
 
166
+ /**
167
+ * Groups a customer's selected named instances into clampFont outputs so that no output's range
168
+ * contains a named instance the customer did not select.
169
+ *
170
+ * Selections merge into one output only when their combined range holds no unselected named
171
+ * instance: Regular + Medium + SemiBold + Bold → one output; Regular + Bold alone → two outputs
172
+ * (each pinned to its own instance). Output names come from compactName(), optionally prefixed
173
+ * with `family`.
174
+ *
175
+ * @param font - The font's axes and named instances, from getInstances()
176
+ * @param selected - Names of the instances the customer bought (must match exactly)
177
+ * @param family - Optional family name to prefix each output name with, e.g. "Encode Sans"
178
+ * @returns OutputConfig entries ready for clampFont(), sorted along the primary axis
179
+ * @throws If a selected name is not a named instance of the font, or names more than one (ambiguous)
180
+ */
181
+ export declare function planOutputs(font: FontInstancesResult, selected: string[], family?: string): OutputConfig[];
182
+
157
183
  /** @deprecated Use OutputConfig instead */
158
184
  export declare type SubfamilyConfig = OutputConfig;
159
185
 
186
+ /**
187
+ * Lists the named instances that a single output built from `names` would include without being selected,
188
+ * e.g. ['Medium', 'SemiBold'] for Regular + Bold. An empty array means the output is purchase-safe.
189
+ *
190
+ * @param font - The font's axes and named instances, from getInstances()
191
+ * @param names - The instance names one output would hull
192
+ */
193
+ export declare function unboughtInstances(font: FontInstancesResult, names: string[]): string[];
194
+
160
195
  export { }