@overpunch/vf-clamp 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -23
- package/dist/index.cjs +427 -160
- package/dist/index.d.ts +34 -4
- package/dist/index.js +531 -388
- package/dist/naming.cjs +1 -0
- package/dist/naming.d.ts +149 -0
- package/dist/naming.js +121 -0
- package/package.json +6 -1
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const M=require("node:module"),p=require("./naming.cjs");var v=typeof document<"u"?document.currentScript:null;const q=M.createRequire(typeof document>"u"?require("url").pathToFileURL(__filename).href:v&&v.tagName.toUpperCase()==="SCRIPT"&&v.src||new URL("index.cjs",document.baseURI).href),{preparePyodide:f,PyodideFile:h}=q("@web-alchemy/fonttools/src/pyodide.js");let b=null;async function W(){return b||(b=f().then(t=>t.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
|
|
10
|
+
`))),b}async function C(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename],["flavor",o]]);return(await W())(i),e.download()}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}async function B(t){return C(t,"woff")}async function j(t){return C(t,"woff2")}let T=null;async function z(){return T||(T=f().then(t=>t.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,63 +40,413 @@ def get_instances_fn(input_file):
|
|
|
39
40
|
for i, inst in enumerate(fvar.instances)
|
|
40
41
|
]
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
labels = {}
|
|
44
|
+
if 'STAT' in font and font['STAT'].table.AxisValueArray:
|
|
45
|
+
stat = font['STAT'].table
|
|
46
|
+
tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
|
|
47
|
+
for av in stat.AxisValueArray.AxisValue:
|
|
48
|
+
if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
|
|
49
|
+
value = av.NominalValue if av.Format == 2 else av.Value
|
|
50
|
+
key = ('%f' % float(value)).rstrip('0').rstrip('.')
|
|
51
|
+
labels.setdefault(tags[av.AxisIndex], {})[key] = name_table.getDebugName(av.ValueNameID) or ''
|
|
43
52
|
|
|
44
|
-
|
|
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
|
-
from fontTools import ttLib
|
|
53
|
+
return json.dumps({'axes': axes, 'instances': instances, 'family': family, 'labels': labels})
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
55
|
+
get_instances_fn
|
|
56
|
+
`))),T}async function E(t){const o=await f(),a=t instanceof ArrayBuffer?new Uint8Array(t):t,n=new h({pyodide:o});try{await n.upload(a);const i=(await z())(n.filename);return JSON.parse(i)}finally{try{n.delete()}catch{}}}const $=`# vfclamp_naming.py — the single implementation of vf-clamp's naming, style-bit and STAT clean-up rules (docs/NAMING.md), shared by the npm package (run in Pyodide), the Glyphs plugin and the RoboFont extension.
|
|
57
|
+
#
|
|
58
|
+
# Canonical copy: vfClamp/shared/plugin-views/vfclamp_naming.py. The plugins get byte-identical copies via
|
|
59
|
+
# \`npm run sync-plugin-views\`; the npm package imports this file's text at build time. Never edit a copy.
|
|
60
|
+
# Pure fontTools: no Glyphs, RoboFont or AppKit imports.
|
|
61
|
+
|
|
62
|
+
import re
|
|
63
|
+
import unicodedata
|
|
64
|
+
|
|
65
|
+
#: OS/2.fsSelection bits this module owns.
|
|
66
|
+
FS_ITALIC = 0x01
|
|
67
|
+
FS_BOLD = 0x20
|
|
68
|
+
FS_REGULAR = 0x40
|
|
69
|
+
FS_OBLIQUE = 0x200
|
|
70
|
+
#: head.macStyle bits this module owns.
|
|
71
|
+
MAC_BOLD = 0x01
|
|
72
|
+
MAC_ITALIC = 0x02
|
|
73
|
+
|
|
74
|
+
#: Label for an axis value that has no word in the font's style names (e.g. wdth 100 in "Light").
|
|
75
|
+
DEFAULT_AXIS_WORDS = {'wght': 'Regular', 'wdth': 'Normal', 'ital': 'Upright', 'slnt': 'Upright'}
|
|
76
|
+
|
|
77
|
+
#: The four style-linking (RIBBI) subfamily names.
|
|
78
|
+
RIBBI = ('Regular', 'Italic', 'Bold', 'Bold Italic')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def short_hash(text):
|
|
82
|
+
"""Six-character FNV-1a hash of a string, base 36; identical to shortHash() in the npm package."""
|
|
83
|
+
h = 0x811c9dc5
|
|
84
|
+
for ch in text:
|
|
85
|
+
h ^= ord(ch)
|
|
86
|
+
h = (h * 0x01000193) & 0xFFFFFFFF
|
|
87
|
+
digits = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
88
|
+
out = ''
|
|
89
|
+
while h:
|
|
90
|
+
h, rem = divmod(h, 36)
|
|
91
|
+
out = digits[rem] + out
|
|
92
|
+
return (out or '0').rjust(6, '0')[-6:]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ps_name(text, max_len=63):
|
|
96
|
+
"""PostScript name (nameID 6): accents transliterated, [A-Za-z0-9-] only, at most 63 characters.
|
|
97
|
+
|
|
98
|
+
Over 63 characters, a readable prefix plus a hash of the full text keeps names unique. Text with
|
|
99
|
+
letters in another script becomes 'Font-<hash>' (never the source font's name); text with no
|
|
100
|
+
letters at all becomes 'Font'.
|
|
101
|
+
"""
|
|
102
|
+
ascii_text = unicodedata.normalize('NFKD', text or '').encode('ascii', 'ignore').decode('ascii')
|
|
103
|
+
s = re.sub(r'[^A-Za-z0-9 -]', '', ascii_text).strip()
|
|
104
|
+
s = re.sub(r'\\s+', '-', s)
|
|
105
|
+
s = re.sub(r'-+', '-', s).strip('-')
|
|
106
|
+
if not s:
|
|
107
|
+
return f'Font-{short_hash(text)}' if any(c.isalpha() for c in (text or '')) else 'Font'
|
|
108
|
+
if len(s) > max_len:
|
|
109
|
+
s = f'{s[:max_len - 7].rstrip("-")}-{short_hash(text)}'
|
|
110
|
+
return s
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def vf_prefix(ps, family):
|
|
114
|
+
"""Variations PostScript Name Prefix (nameID 25): ASCII letters and digits only (OpenType spec), at most 27."""
|
|
115
|
+
alnum = re.sub(r'[^A-Za-z0-9]', '', ps)
|
|
116
|
+
if not alnum:
|
|
117
|
+
return 'Font'
|
|
118
|
+
if len(alnum) > 27:
|
|
119
|
+
return alnum[:21] + short_hash(family)
|
|
120
|
+
return alnum
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def full_name(family, style):
|
|
124
|
+
"""Full name (nameID 4): family plus style, leaving out 'Regular' and a style the family name already ends with."""
|
|
125
|
+
if not style or style == 'Regular' or family.lower().endswith(style.lower()):
|
|
126
|
+
return family
|
|
127
|
+
return f'{family} {style}'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def number_key(value):
|
|
131
|
+
"""Stable text key for an axis value, identical in Python and JavaScript ('100', '87.5')."""
|
|
132
|
+
return ('%f' % float(value)).rstrip('0').rstrip('.')
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Inputs read from the source font (before instancing)
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def source_style_info(font):
|
|
140
|
+
"""What the style rules need to know about the source: its italic/oblique bits and whether ital/slnt exist."""
|
|
141
|
+
fs = font['OS/2'].fsSelection if 'OS/2' in font else 0
|
|
142
|
+
tags = {ax.axisTag for ax in font['fvar'].axes} if 'fvar' in font else set()
|
|
143
|
+
return {'italic': bool(fs & FS_ITALIC), 'oblique': bool(fs & FS_OBLIQUE), 'has_ital_slnt': bool(tags & {'ital', 'slnt'})}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def naming_inputs(font):
|
|
147
|
+
"""Instances, axes and STAT labels of a source font, in the shape range_name() takes."""
|
|
148
|
+
name = font['name']
|
|
149
|
+
axes = [{'tag': ax.axisTag, 'default': ax.defaultValue} for ax in font['fvar'].axes] if 'fvar' in font else []
|
|
150
|
+
instances = [
|
|
151
|
+
{'name': name.getDebugName(inst.subfamilyNameID) or '', 'coordinates': dict(inst.coordinates)}
|
|
152
|
+
for inst in (font['fvar'].instances if 'fvar' in font else [])
|
|
153
|
+
]
|
|
154
|
+
labels = {}
|
|
155
|
+
if 'STAT' in font and font['STAT'].table.AxisValueArray:
|
|
156
|
+
stat = font['STAT'].table
|
|
157
|
+
tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
|
|
158
|
+
for av in stat.AxisValueArray.AxisValue:
|
|
159
|
+
if av.Format in (1, 2, 3) and av.AxisIndex < len(tags):
|
|
160
|
+
value = av.NominalValue if av.Format == 2 else av.Value
|
|
161
|
+
labels.setdefault(tags[av.AxisIndex], {})[number_key(value)] = name.getDebugName(av.ValueNameID) or ''
|
|
162
|
+
return {'instances': instances, 'axes': axes, 'labels': labels}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Default output name for a selection
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _words(text):
|
|
170
|
+
"""Whitespace-separated words of a style name."""
|
|
171
|
+
return [w for w in (text or '').split() if w]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def range_name(selected, instances, axes, labels=None):
|
|
175
|
+
"""Default name for a selection of named instances: one range per axis, in the font's own style words.
|
|
176
|
+
|
|
177
|
+
Each word in the font's style names is attributed to the one axis that is constant wherever the word
|
|
178
|
+
appears and varies across the font ("SemiCondensed" -> wdth, "Thin" -> wght). An axis that varies in
|
|
179
|
+
the selection becomes "low-high" ("SemiCondensed-Normal", "Thin-Light"); an axis that doesn't shows
|
|
180
|
+
its word once, or nothing when that value has no word. A value with no word (wdth 100 in "Light")
|
|
181
|
+
takes its STAT name ("Normal"), else a default word, else "<tag><value>". Parts follow the order the
|
|
182
|
+
words appear in the font's names; words shared by every selected name and owned by no axis (a family prefix,
|
|
183
|
+
"Italic" in an italic-only font) stay in place before or after them.
|
|
184
|
+
|
|
185
|
+
\`\`selected\`\`/\`\`instances\`\`: dicts with 'name' and 'coordinates'; \`\`axes\`\`: dicts with 'tag' and
|
|
186
|
+
'default'; \`\`labels\`\`: {tag: {number_key(value): name}} from STAT. Mirrored by rangeName() in
|
|
187
|
+
TypeScript; both are checked against shared/naming-cases.json.
|
|
188
|
+
"""
|
|
189
|
+
if not selected:
|
|
190
|
+
return ''
|
|
191
|
+
if len(selected) == 1:
|
|
192
|
+
return selected[0]['name']
|
|
193
|
+
labels = labels or {}
|
|
194
|
+
defaults = {a['tag']: a['default'] for a in axes}
|
|
195
|
+
tags = [a['tag'] for a in axes]
|
|
196
|
+
|
|
197
|
+
def coord(inst, tag):
|
|
198
|
+
return inst['coordinates'].get(tag, defaults.get(tag))
|
|
199
|
+
|
|
200
|
+
varying = [t for t in tags if len({coord(i, t) for i in instances}) > 1]
|
|
201
|
+
|
|
202
|
+
owner_cache = {}
|
|
203
|
+
|
|
204
|
+
def owner(word):
|
|
205
|
+
if word not in owner_cache:
|
|
206
|
+
having = [i for i in instances if word in _words(i['name'])]
|
|
207
|
+
cands = [t for t in varying if len({coord(i, t) for i in having}) == 1] if having else []
|
|
208
|
+
owner_cache[word] = cands[0] if len(cands) == 1 else None
|
|
209
|
+
return owner_cache[word]
|
|
210
|
+
|
|
211
|
+
def label(tag, value):
|
|
212
|
+
at = [i for i in instances if coord(i, tag) == value]
|
|
213
|
+
if not at:
|
|
214
|
+
return ''
|
|
215
|
+
common = [w for w in _words(at[0]['name']) if owner(w) == tag and all(w in _words(i['name']) for i in at)]
|
|
216
|
+
return ' '.join(common)
|
|
217
|
+
|
|
218
|
+
def fallback(tag, value):
|
|
219
|
+
stat = labels.get(tag, {}).get(number_key(value))
|
|
220
|
+
if stat:
|
|
221
|
+
return stat
|
|
222
|
+
return DEFAULT_AXIS_WORDS.get(tag) or f'{tag}{number_key(value)}'
|
|
223
|
+
|
|
224
|
+
# Axis order follows the font's own names: positions come from names carrying words for two or more
|
|
225
|
+
# axes ("SemiCondensed Thin" puts width before weight); a plain "Thin" says nothing about order.
|
|
226
|
+
def owned_axes(inst):
|
|
227
|
+
return {owner(w) for w in _words(inst['name'])} - {None}
|
|
228
|
+
|
|
229
|
+
rich = [i for i in instances if len(owned_axes(i)) > 1] or instances
|
|
230
|
+
|
|
231
|
+
def axis_position(tag):
|
|
232
|
+
found = [k for i in rich for k, w in enumerate(_words(i['name'])) if owner(w) == tag]
|
|
233
|
+
return min(found) if found else 10_000
|
|
234
|
+
|
|
235
|
+
parts = []
|
|
236
|
+
for order, tag in enumerate(tags):
|
|
237
|
+
values = sorted({coord(i, tag) for i in selected})
|
|
238
|
+
lo, hi = values[0], values[-1]
|
|
239
|
+
if lo == hi:
|
|
240
|
+
text = label(tag, lo)
|
|
241
|
+
if not text:
|
|
242
|
+
continue
|
|
243
|
+
else:
|
|
244
|
+
a = label(tag, lo) or fallback(tag, lo)
|
|
245
|
+
b = label(tag, hi) or fallback(tag, hi)
|
|
246
|
+
text = a if a == b else f'{a}-{b}'
|
|
247
|
+
parts.append((axis_position(tag), order, text))
|
|
248
|
+
parts.sort()
|
|
249
|
+
|
|
250
|
+
first_words = _words(selected[0]['name'])
|
|
251
|
+
shared = [w for w in first_words if owner(w) is None and all(w in _words(i['name']) for i in selected)]
|
|
252
|
+
owned_positions = [k for k, w in enumerate(first_words) if owner(w) is not None]
|
|
253
|
+
first_owned = min(owned_positions) if owned_positions else len(first_words)
|
|
254
|
+
before = [w for w in shared if first_words.index(w) < first_owned]
|
|
255
|
+
after = [w for w in shared if first_words.index(w) >= first_owned]
|
|
256
|
+
name = ' '.join(before + [p[2] for p in parts] + after).strip()
|
|
257
|
+
return name or f'{selected[0]["name"]}-{selected[-1]["name"]}'
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Rules applied to the clamped font (after instancing)
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
_WIDTH_CLASSES = ((62.5, 1), (75.0, 2), (87.5, 3), (100.0, 4), (112.5, 5), (125.0, 6), (150.0, 7), (200.0, 8))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def location_after(font, pinned):
|
|
268
|
+
"""The clamped font's default location: remaining fvar defaults plus the values of pinned axes."""
|
|
269
|
+
loc = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes} if 'fvar' in font else {}
|
|
270
|
+
for tag, value in (pinned or {}).items():
|
|
271
|
+
loc.setdefault(tag, value)
|
|
272
|
+
return loc
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def unlink_out_of_range_stat(font, location):
|
|
276
|
+
"""Turn STAT Format 3 values whose LinkedValue the clamped font can't reach into Format 1 (same name, no link)."""
|
|
277
|
+
if 'STAT' not in font or not font['STAT'].table.AxisValueArray:
|
|
278
|
+
return
|
|
279
|
+
stat = font['STAT'].table
|
|
280
|
+
tags = [a.AxisTag for a in stat.DesignAxisRecord.Axis]
|
|
281
|
+
ranges = {ax.axisTag: (ax.minValue, ax.maxValue) for ax in font['fvar'].axes} if 'fvar' in font else {}
|
|
282
|
+
for av in stat.AxisValueArray.AxisValue:
|
|
283
|
+
if av.Format != 3 or av.AxisIndex >= len(tags):
|
|
284
|
+
continue
|
|
285
|
+
tag = tags[av.AxisIndex]
|
|
286
|
+
if tag in ranges:
|
|
287
|
+
lo, hi = ranges[tag]
|
|
288
|
+
reachable = lo <= av.LinkedValue <= hi
|
|
289
|
+
elif tag in location:
|
|
290
|
+
reachable = av.LinkedValue == location[tag]
|
|
291
|
+
else:
|
|
292
|
+
continue
|
|
293
|
+
if not reachable:
|
|
294
|
+
av.Format = 1
|
|
295
|
+
del av.LinkedValue
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def apply_style_bits(font, location, source):
|
|
299
|
+
"""Set usWeightClass/usWidthClass, fsSelection and macStyle from the clamped font's default location.
|
|
300
|
+
|
|
301
|
+
BOLD from weight 700; ITALIC from ital >= 0.5 or slnt < 0 (OBLIQUE as well for a slant without ital);
|
|
302
|
+
with neither axis in the source, the source's italic/oblique bits are kept. REGULAR only when none of
|
|
303
|
+
BOLD, ITALIC or OBLIQUE is set. Returns (is_bold, is_italic).
|
|
304
|
+
"""
|
|
305
|
+
if 'OS/2' not in font:
|
|
306
|
+
return False, False
|
|
307
|
+
os2 = font['OS/2']
|
|
308
|
+
wght, wdth = location.get('wght'), location.get('wdth')
|
|
309
|
+
if wght is not None:
|
|
310
|
+
os2.usWeightClass = max(1, min(1000, int(round(wght))))
|
|
311
|
+
if wdth is not None:
|
|
312
|
+
os2.usWidthClass = next((cls for edge, cls in _WIDTH_CLASSES if wdth < edge), 9)
|
|
313
|
+
if source.get('has_ital_slnt'):
|
|
314
|
+
ital, slnt = location.get('ital'), location.get('slnt')
|
|
315
|
+
is_italic = (ital is not None and ital >= 0.5) or (slnt is not None and slnt < 0)
|
|
316
|
+
is_oblique = slnt is not None and slnt < 0 and not (ital is not None and ital >= 0.5)
|
|
317
|
+
else:
|
|
318
|
+
is_italic, is_oblique = source.get('italic', False), source.get('oblique', False)
|
|
319
|
+
is_bold = os2.usWeightClass >= 700
|
|
320
|
+
fs = os2.fsSelection & ~(FS_ITALIC | FS_BOLD | FS_REGULAR | FS_OBLIQUE)
|
|
321
|
+
if is_italic or is_oblique:
|
|
322
|
+
fs |= FS_ITALIC
|
|
323
|
+
if is_oblique:
|
|
324
|
+
fs |= FS_OBLIQUE
|
|
325
|
+
if is_bold:
|
|
326
|
+
fs |= FS_BOLD
|
|
327
|
+
if not (is_bold or is_italic or is_oblique):
|
|
328
|
+
fs |= FS_REGULAR
|
|
329
|
+
os2.fsSelection = fs
|
|
330
|
+
if 'head' in font:
|
|
331
|
+
mac = font['head'].macStyle & ~(MAC_BOLD | MAC_ITALIC)
|
|
332
|
+
font['head'].macStyle = mac | (MAC_BOLD if is_bold else 0) | (MAC_ITALIC if (is_italic or is_oblique) else 0)
|
|
333
|
+
return is_bold, (is_italic or is_oblique)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def default_instance_style(font):
|
|
337
|
+
"""Subfamily name of the named instance at the clamped font's default location, or None."""
|
|
338
|
+
if 'fvar' not in font:
|
|
339
|
+
return None
|
|
340
|
+
defaults = {ax.axisTag: ax.defaultValue for ax in font['fvar'].axes}
|
|
341
|
+
for inst in font['fvar'].instances:
|
|
342
|
+
if all(abs(inst.coordinates.get(t, v) - v) < 0.01 for t, v in defaults.items()):
|
|
343
|
+
return font['name'].getDebugName(inst.subfamilyNameID)
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def apply_names(font, family, style=None, keep_mac=True, version=None):
|
|
348
|
+
"""Write name IDs 1, 2, 3, 4, 6, 16/17, 25 and the named instances' PostScript names. Run after apply_style_bits.
|
|
349
|
+
|
|
350
|
+
\`\`family\`\` is the output name; blank raises ValueError. \`\`style\`\` is the picked instance's name for a
|
|
351
|
+
single-style output; for a range it defaults to the instance at the new default location. nameID 2 is
|
|
352
|
+
the RIBBI style matching OS/2; a non-RIBBI style (SemiBold) goes into 16/17, and for a static file
|
|
353
|
+
into nameID 1 too ("Family SemiBold" + "Regular"), as the OpenType spec describes. A static file's
|
|
354
|
+
PostScript name includes its style so two pins never collide.
|
|
355
|
+
"""
|
|
356
|
+
family = (family or '').strip()
|
|
357
|
+
if not family:
|
|
358
|
+
raise ValueError('vf-clamp: the output name is empty')
|
|
359
|
+
name_table = font['name']
|
|
360
|
+
existing = {r.nameID for r in name_table.names}
|
|
361
|
+
static = 'fvar' not in font
|
|
362
|
+
fs = font['OS/2'].fsSelection if 'OS/2' in font else FS_REGULAR
|
|
363
|
+
is_bold, is_italic = bool(fs & FS_BOLD), bool(fs & FS_ITALIC)
|
|
364
|
+
ribbi = ('Bold Italic' if is_italic else 'Bold') if is_bold else ('Italic' if is_italic else 'Regular')
|
|
365
|
+
|
|
366
|
+
typo = (style or '').strip() or default_instance_style(font) or ribbi
|
|
367
|
+
if is_italic and 'Italic' not in typo and 'Oblique' not in typo:
|
|
368
|
+
typo = 'Italic' if typo == 'Regular' else f'{typo} Italic'
|
|
369
|
+
core = ' '.join(w for w in typo.split() if w not in ('Italic', 'Oblique'))
|
|
370
|
+
non_ribbi = core not in ('', 'Regular', 'Bold')
|
|
371
|
+
|
|
372
|
+
name1 = family
|
|
373
|
+
if static and non_ribbi and not family.lower().endswith(core.lower()):
|
|
374
|
+
name1 = f'{family} {core}'
|
|
375
|
+
ps_base = family if not static or typo == 'Regular' or family.lower().endswith(typo.lower()) else f'{family} {typo}'
|
|
376
|
+
ps = ps_name(ps_base)
|
|
377
|
+
prefix = vf_prefix(ps, family)
|
|
378
|
+
if version is None:
|
|
379
|
+
try:
|
|
380
|
+
version = '%.3f' % font['head'].fontRevision
|
|
381
|
+
except Exception:
|
|
382
|
+
version = '1.000'
|
|
383
|
+
|
|
384
|
+
updates = {
|
|
385
|
+
1: name1,
|
|
386
|
+
2: ribbi,
|
|
387
|
+
3: f'{version};{ps};{family}',
|
|
388
|
+
4: full_name(family, typo if static else ribbi),
|
|
389
|
+
6: ps,
|
|
390
|
+
}
|
|
391
|
+
if 16 in existing or 17 in existing or non_ribbi:
|
|
392
|
+
updates[16] = family
|
|
393
|
+
updates[17] = typo
|
|
394
|
+
if 25 in existing:
|
|
395
|
+
updates[25] = prefix
|
|
396
|
+
if 'fvar' in font:
|
|
397
|
+
for inst in font['fvar'].instances:
|
|
398
|
+
pid = getattr(inst, 'postscriptNameID', 0xFFFF)
|
|
399
|
+
if pid in (None, 0xFFFF) or pid in updates:
|
|
400
|
+
continue
|
|
401
|
+
inst_style = re.sub(r'[^A-Za-z0-9-]', '', (name_table.getDebugName(inst.subfamilyNameID) or '').replace(' ', ''))
|
|
402
|
+
updates[pid] = f'{prefix}-{inst_style}'[:63]
|
|
403
|
+
|
|
404
|
+
english = {0, 0x0409}
|
|
405
|
+
kept = []
|
|
406
|
+
written = set()
|
|
407
|
+
for record in name_table.names:
|
|
408
|
+
if record.nameID not in updates:
|
|
409
|
+
kept.append(record)
|
|
410
|
+
continue
|
|
411
|
+
if record.platformID in (1, 3) and record.langID not in english:
|
|
412
|
+
continue # stale localised name for an ID we're rewriting
|
|
413
|
+
value = updates[record.nameID]
|
|
414
|
+
if record.platformID in (0, 3):
|
|
415
|
+
record.string = value.encode('utf-16-be')
|
|
416
|
+
elif record.platformID == 1:
|
|
417
|
+
if not keep_mac:
|
|
418
|
+
continue
|
|
419
|
+
try:
|
|
420
|
+
record.string = value.encode('mac_roman')
|
|
421
|
+
except (UnicodeEncodeError, LookupError):
|
|
422
|
+
continue # drop rather than write '?'
|
|
423
|
+
kept.append(record)
|
|
424
|
+
written.add((record.nameID, record.platformID))
|
|
425
|
+
name_table.names = kept
|
|
426
|
+
for name_id, value in updates.items():
|
|
427
|
+
if (name_id, 3) not in written:
|
|
428
|
+
name_table.setName(value, name_id, 3, 1, 0x0409)
|
|
429
|
+
return updates
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def finish(font, family, style, pinned, source, keep_mac=True):
|
|
433
|
+
"""Everything after instancing, in order: STAT links, style bits, names. Returns the name updates."""
|
|
434
|
+
location = location_after(font, pinned)
|
|
435
|
+
unlink_out_of_range_stat(font, location)
|
|
436
|
+
apply_style_bits(font, location, source)
|
|
437
|
+
return apply_names(font, family, style, keep_mac=keep_mac)
|
|
438
|
+
`;let A=null,I=null,F=null,S=null;function P(){return A||(A=f().then(async t=>{await t.runPythonAsync($)})),A}async function G(){return I||(I=P().then(()=>f()).then(t=>t.runPythonAsync(`
|
|
439
|
+
import json
|
|
440
|
+
from fontTools.ttLib import TTFont
|
|
94
441
|
|
|
442
|
+
def vf_clamp_finish(file_options, opts_json):
|
|
443
|
+
opts = json.loads(opts_json)
|
|
444
|
+
font = TTFont(file_options['input-file'])
|
|
445
|
+
finish(font, opts['family'], opts.get('style'), opts.get('pinned') or {}, opts['source'])
|
|
95
446
|
font.save(file_options['output-file'])
|
|
96
447
|
|
|
97
|
-
|
|
98
|
-
`))),
|
|
448
|
+
vf_clamp_finish
|
|
449
|
+
`))),I}async function Q(){return F||(F=P().then(()=>f()).then(t=>t.runPythonAsync(`
|
|
99
450
|
from fontTools.ttLib import TTFont
|
|
100
451
|
from fontTools.varLib import instancer
|
|
101
452
|
import json
|
|
@@ -104,6 +455,12 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
104
455
|
font = TTFont(file_options['input-file'])
|
|
105
456
|
axes_spec = json.loads(axes_json)
|
|
106
457
|
|
|
458
|
+
# The bundled fontTools can't restrict avar version 2 or VARC fonts correctly: refuse instead of mis-cutting.
|
|
459
|
+
if 'avar' in font and getattr(font['avar'], 'majorVersion', 1) >= 2:
|
|
460
|
+
raise ValueError('vf-clamp: avar version 2 fonts are not supported yet')
|
|
461
|
+
if 'VARC' in font:
|
|
462
|
+
raise ValueError('vf-clamp: fonts with a VARC table are not supported yet')
|
|
463
|
+
|
|
107
464
|
limits = {}
|
|
108
465
|
axes_by_tag = {ax.axisTag: ax for ax in font['fvar'].axes}
|
|
109
466
|
|
|
@@ -120,11 +477,15 @@ def vf_clamp_instantiate(file_options, axes_json):
|
|
|
120
477
|
else:
|
|
121
478
|
limits[tag] = float(spec)
|
|
122
479
|
|
|
480
|
+
# The style rules need the source's italic bits and which axes were pinned (they leave fvar).
|
|
481
|
+
source = source_style_info(font)
|
|
482
|
+
pinned = {tag: value for tag, value in limits.items() if isinstance(value, float)}
|
|
123
483
|
partial = instancer.instantiateVariableFont(font, limits)
|
|
124
484
|
partial.save(file_options['output-file'])
|
|
485
|
+
return json.dumps({'source': source, 'pinned': pinned})
|
|
125
486
|
|
|
126
487
|
vf_clamp_instantiate
|
|
127
|
-
`))),
|
|
488
|
+
`))),F}async function J(){return S||(S=f().then(t=>t.runPythonAsync(`
|
|
128
489
|
from fontTools.ttLib import TTFont
|
|
129
490
|
|
|
130
491
|
def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
@@ -144,6 +505,10 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
144
505
|
|
|
145
506
|
old_min = wght_axis.minValue
|
|
146
507
|
default = wght_axis.defaultValue
|
|
508
|
+
# With the default at the minimum there is no below-default range to stretch; leave the font as is.
|
|
509
|
+
if default <= old_min:
|
|
510
|
+
font.save(file_options['output-file'])
|
|
511
|
+
return 'skipped'
|
|
147
512
|
scale = (default - new_min) / (default - old_min)
|
|
148
513
|
|
|
149
514
|
def remap(v):
|
|
@@ -177,111 +542,13 @@ def vf_clamp_normalize_wght(file_options, new_min_str):
|
|
|
177
542
|
av.NominalValue = remap(av.NominalValue)
|
|
178
543
|
av.RangeMinValue = remap(av.RangeMinValue)
|
|
179
544
|
av.RangeMaxValue = remap(av.RangeMaxValue)
|
|
545
|
+
elif fmt == 4:
|
|
546
|
+
for rec in getattr(av, 'AxisValueRecord', []) or []:
|
|
547
|
+
if rec.AxisIndex == wght_idx:
|
|
548
|
+
rec.Value = remap(rec.Value)
|
|
180
549
|
|
|
181
550
|
font.save(file_options['output-file'])
|
|
551
|
+
return 'done'
|
|
182
552
|
|
|
183
553
|
vf_clamp_normalize_wght
|
|
184
|
-
`))),
|
|
185
|
-
from fontTools.ttLib import TTFont
|
|
186
|
-
|
|
187
|
-
def vf_clamp_prune_stat(file_options):
|
|
188
|
-
font = TTFont(file_options['input-file'])
|
|
189
|
-
|
|
190
|
-
if 'STAT' not in font or 'fvar' not in font:
|
|
191
|
-
font.save(file_options['output-file'])
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
stat = font['STAT'].table
|
|
195
|
-
if not getattr(stat, 'DesignAxisRecord', None) or not stat.DesignAxisRecord.Axis:
|
|
196
|
-
font.save(file_options['output-file'])
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
fvar_tags = {ax.axisTag for ax in font['fvar'].axes}
|
|
200
|
-
stat_axes = list(stat.DesignAxisRecord.Axis)
|
|
201
|
-
|
|
202
|
-
# Map old STAT AxisIndex -> new index after pruning STAT axes that vanish from fvar.
|
|
203
|
-
surviving_indices = [i for i, ax in enumerate(stat_axes) if ax.AxisTag in fvar_tags]
|
|
204
|
-
if len(surviving_indices) == len(stat_axes):
|
|
205
|
-
# No axes removed — nothing to prune.
|
|
206
|
-
font.save(file_options['output-file'])
|
|
207
|
-
return
|
|
208
|
-
old_to_new = {old: new for new, old in enumerate(surviving_indices)}
|
|
209
|
-
|
|
210
|
-
# Rewrite the DesignAxisRecord.Axis list to surviving axes only.
|
|
211
|
-
stat.DesignAxisRecord.Axis = [stat_axes[i] for i in surviving_indices]
|
|
212
|
-
if hasattr(stat, 'DesignAxisCount'):
|
|
213
|
-
stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis)
|
|
214
|
-
|
|
215
|
-
# Prune AxisValueArray entries that reference removed axes.
|
|
216
|
-
if stat.AxisValueArray and stat.AxisValueArray.AxisValue:
|
|
217
|
-
kept = []
|
|
218
|
-
for av in stat.AxisValueArray.AxisValue:
|
|
219
|
-
fmt = av.Format
|
|
220
|
-
if fmt in (1, 2, 3):
|
|
221
|
-
if av.AxisIndex not in old_to_new:
|
|
222
|
-
continue # references a removed axis
|
|
223
|
-
av.AxisIndex = old_to_new[av.AxisIndex]
|
|
224
|
-
kept.append(av)
|
|
225
|
-
elif fmt == 4:
|
|
226
|
-
inner = list(getattr(av, 'AxisValueRecord', []) or [])
|
|
227
|
-
if any(rec.AxisIndex not in old_to_new for rec in inner):
|
|
228
|
-
continue # whole record dies if any inner ref is dead
|
|
229
|
-
for rec in inner:
|
|
230
|
-
rec.AxisIndex = old_to_new[rec.AxisIndex]
|
|
231
|
-
kept.append(av)
|
|
232
|
-
else:
|
|
233
|
-
# Unknown format — keep as-is to be safe.
|
|
234
|
-
kept.append(av)
|
|
235
|
-
stat.AxisValueArray.AxisValue = kept
|
|
236
|
-
if hasattr(stat, 'AxisValueCount'):
|
|
237
|
-
stat.AxisValueCount = len(kept)
|
|
238
|
-
|
|
239
|
-
font.save(file_options['output-file'])
|
|
240
|
-
|
|
241
|
-
vf_clamp_prune_stat
|
|
242
|
-
`))),D}async function G(){return O||(O=d().then(t=>t.runPythonAsync(`
|
|
243
|
-
from fontTools.ttLib import TTFont
|
|
244
|
-
|
|
245
|
-
def vf_clamp_update_os2(file_options):
|
|
246
|
-
font = TTFont(file_options['input-file'])
|
|
247
|
-
|
|
248
|
-
if 'fvar' not in font:
|
|
249
|
-
font.save(file_options['output-file'])
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
wght_axis = next((ax for ax in font['fvar'].axes if ax.axisTag == 'wght'), None)
|
|
253
|
-
if wght_axis is None:
|
|
254
|
-
font.save(file_options['output-file'])
|
|
255
|
-
return
|
|
256
|
-
|
|
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
|
-
if 'OS/2' in font:
|
|
262
|
-
os2 = font['OS/2']
|
|
263
|
-
os2.usWeightClass = weight_class
|
|
264
|
-
# fsSelection bits: 0x20 = BOLD, 0x40 = REGULAR.
|
|
265
|
-
# Mirror the convention: REGULAR when usWeightClass < 600, BOLD when >= 700.
|
|
266
|
-
fs = os2.fsSelection
|
|
267
|
-
fs &= ~(0x20 | 0x40)
|
|
268
|
-
if weight_class >= 700:
|
|
269
|
-
fs |= 0x20 # BOLD
|
|
270
|
-
elif weight_class < 600:
|
|
271
|
-
fs |= 0x40 # REGULAR
|
|
272
|
-
os2.fsSelection = fs
|
|
273
|
-
|
|
274
|
-
if 'head' in font:
|
|
275
|
-
head = font['head']
|
|
276
|
-
# macStyle bit 0 = bold.
|
|
277
|
-
ms = head.macStyle
|
|
278
|
-
if weight_class >= 700:
|
|
279
|
-
ms |= 0x01
|
|
280
|
-
else:
|
|
281
|
-
ms &= ~0x01
|
|
282
|
-
head.macStyle = ms
|
|
283
|
-
|
|
284
|
-
font.save(file_options['output-file'])
|
|
285
|
-
|
|
286
|
-
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;
|
|
554
|
+
`))),S}async function H(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]);return(await G())(i,JSON.stringify(o)),e.download()}catch(i){throw Object.assign(new Error(`vf-clamp: name table patching failed for "${o.family}"`),{cause:i})}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}function K(t){return typeof t=="number"?t:t===null?null:[t.min,t.max]}async function Z(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]),s=await Q(),d=JSON.parse(s(i,JSON.stringify(o)));return{buffer:e.download(),info:d}}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}async function X(t,o){const a=await f(),n=new h({pyodide:a}),e=new h({pyodide:a});try{await n.upload(t);const i=new Map([["input-file",n.filename],["output-file",e.filename]]);return(await J())(i,String(o))==="skipped"&&console.warn("vf-clamp: normalizeWeightAxis skipped: the weight default is the axis minimum, so there is no lower range to remap"),e.download()}catch(i){throw Object.assign(new Error("vf-clamp: wght normalisation failed"),{cause:i})}finally{try{n.delete()}catch{}try{e.delete()}catch{}}}function Y(t,o){const a={};for(const e of t){const i=p.findInstance(e,o);for(const[s,d]of Object.entries(i.coordinates))a[s]?(a[s].min=Math.min(a[s].min,d),a[s].max=Math.max(a[s].max,d)):a[s]={min:d,max:d}}const n={};for(const[e,{min:i,max:s}]of Object.entries(a))n[e]=i===s?i:{min:i,max:s};return n}function tt(t){return t.length>=4&&t[0]===79&&t[1]===84&&t[2]===84&&t[3]===79}async function nt(t,o){var V,D;if(o.outputs.length===0)return[];const a=t instanceof ArrayBuffer?new Uint8Array(t):t,n=o.format??"ttf",{instances:e,axes:i,family:s,labels:d}=await E(a),N=[];for(const m of o.outputs){let g={};if((V=m.instances)!=null&&V.length&&(g=Y(m.instances,e)),m.axes&&(g={...g,...m.axes}),o.strict){const l=new Set((m.instances??[]).map(r=>p.findInstance(r,e))),u=e.filter(r=>!l.has(r)&&i.every(x=>{const y=r.coordinates[x.tag]??x.default,_=g[x.tag];return _==null?!0:typeof _=="number"?y===_:y>=_.min&&y<=_.max})).map(r=>r.name);if(u.length)throw new Error(`vf-clamp: output "${m.name||(m.instances??[]).join(", ")||"axes-only"}" would include unselected instances (${u.join(", ")}); use planOutputs() to split the selection`)}for(const l of i){const u=g[l.tag];if(u!=null&&typeof u=="object"){const r=u;if(l.default<r.min||l.default>r.max){const x=Math.max(r.min,Math.min(r.max,l.default));console.warn(`vf-clamp: axis "${l.tag}" default (${l.default}) is outside restricted range [${r.min}, ${r.max}] — will be clamped to ${x}`)}}}const k={};for(const[l,u]of Object.entries(g))u!==null&&(k[l]=K(u));const w=(m.instances??[]).map(l=>p.findInstance(l,e)),L=w.length?p.rangeName(w,e,i,d??{}):"Clamped",R=((D=m.name)==null?void 0:D.trim())||(s?`${s} ${L}`:L),{buffer:U,info:O}=await Z(a,k);let c=U;if(n==="otf"&&!tt(c))throw new Error("vf-clamp: format 'otf' needs a CFF/CFF2 source font; this font has TrueType outlines — use 'ttf'");o.normalizeWeightAxis&&(c=await X(c,100)),c=await H(c,{family:R,style:w.length===1?w[0].name:null,pinned:O.pinned,source:O.source}),n==="woff2"?c=await j(c):n==="woff"&&(c=await B(c)),N.push({name:R,buffer:c,format:n})}return N}exports.compactName=p.compactName;exports.numberKey=p.numberKey;exports.planOutputs=p.planOutputs;exports.rangeName=p.rangeName;exports.unboughtInstances=p.unboughtInstances;exports.clampFont=nt;exports.convertToWoff=B;exports.convertToWoff2=j;exports.getInstances=E;
|