@liiift-studio/vf-clamp-cli 0.1.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/LICENSE +21 -0
- package/README.md +194 -0
- package/dist/commands/clamp.d.ts +9 -0
- package/dist/commands/clamp.js +202 -0
- package/dist/commands/instances.d.ts +6 -0
- package/dist/commands/instances.js +89 -0
- package/dist/core/axisSpecs.d.ts +15 -0
- package/dist/core/axisSpecs.js +85 -0
- package/dist/core/config.d.ts +35 -0
- package/dist/core/config.js +133 -0
- package/dist/core/exitCodes.d.ts +15 -0
- package/dist/core/exitCodes.js +47 -0
- package/dist/core/format.d.ts +25 -0
- package/dist/core/format.js +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +66 -0
- package/dist/utils/font.d.ts +62 -0
- package/dist/utils/font.js +196 -0
- package/dist/utils/format.d.ts +14 -0
- package/dist/utils/format.js +36 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Liiift Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @liiift-studio/vf-clamp-cli
|
|
2
|
+
|
|
3
|
+
A command-line interface for [`@liiift-studio/vf-clamp`](https://www.npmjs.com/package/@liiift-studio/vf-clamp) — restrict variable font axis ranges from the terminal without writing any JavaScript.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires **Node.js 18 or newer**.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @liiift-studio/vf-clamp-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Discovery
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
vf-clamp --help # top-level overview, both subcommands
|
|
17
|
+
vf-clamp --version # also: -v
|
|
18
|
+
vf-clamp clamp --help # subcommand-specific help, examples, axis spec guide
|
|
19
|
+
vf-clamp instances --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### Inspect a font
|
|
25
|
+
|
|
26
|
+
List all variable axes and named instances in a font file (TTF, OTF, WOFF, or WOFF2):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
vf-clamp instances MyFont.ttf
|
|
30
|
+
vf-clamp instances MyFont.ttf --json # machine-readable
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Example output (exact column spacing depends on your font):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
Axes:
|
|
37
|
+
wght Weight 100 → 900 (default: 400)
|
|
38
|
+
slnt Slant -10 → 0 (default: 0)
|
|
39
|
+
|
|
40
|
+
Instances (18):
|
|
41
|
+
Thin wght=100 slnt=0
|
|
42
|
+
ExtraLight wght=200 slnt=0
|
|
43
|
+
Light wght=300 slnt=0
|
|
44
|
+
Regular wght=400 slnt=0
|
|
45
|
+
Medium wght=500 slnt=0
|
|
46
|
+
SemiBold wght=600 slnt=0
|
|
47
|
+
Bold wght=700 slnt=0
|
|
48
|
+
...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Clamp a font
|
|
52
|
+
|
|
53
|
+
Produce a restricted VF spanning the hull of named instances:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
vf-clamp clamp MyFont.ttf --instance Light --instance Bold --name Light-Bold
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Short flags are accepted for every option:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
vf-clamp clamp MyFont.ttf -i Light -i Bold -n Light-Bold
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Restrict using explicit axis ranges:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
vf-clamp clamp MyFont.ttf -a wght:300:700 -n Custom -f woff2
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Pin a single axis value, or keep an axis at its full original range:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
vf-clamp clamp MyFont.ttf -a wght:400 -n Regular-Only
|
|
75
|
+
vf-clamp clamp MyFont.ttf -i Light -i Bold -a wdth:* # keep wdth untouched
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Combine instances and axis overrides:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
vf-clamp clamp MyFont.ttf -i Light -i Bold -a slnt:-5:0 -n LightBold-Slanted
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Read source font from stdin and pipe results:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cat MyFont.ttf | vf-clamp clamp - -i Bold -n Bold-only -o ./out
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Write output to a specific directory:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
vf-clamp clamp MyFont.ttf -i Light -i Bold -o ./dist/fonts
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
When `--name` is omitted, the output filename is derived from the instance names (`Light-Bold`) or axis tags. All names are sanitised for filesystem safety: characters like `/`, `\`, `:`, `..`, ASCII control codes, and Windows reserved device names are stripped or rejected.
|
|
97
|
+
|
|
98
|
+
### Clamp using a config file
|
|
99
|
+
|
|
100
|
+
For multiple outputs in one pass, use a JSON config file. All outputs are produced in a single engine invocation — the font is parsed once, not N times.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
vf-clamp clamp MyFont.ttf -c clamp.config.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**clamp.config.json:**
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"format": "ttf",
|
|
111
|
+
"outputDir": "./output",
|
|
112
|
+
"outputs": [
|
|
113
|
+
{
|
|
114
|
+
"name": "Light-Bold",
|
|
115
|
+
"instances": ["Light", "Bold"]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "Condensed",
|
|
119
|
+
"axes": { "wdth": { "min": 50, "max": 75 } }
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "WeightRange-KeepWidth",
|
|
123
|
+
"axes": { "wght": { "min": 100, "max": 700 }, "wdth": null }
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each output must specify either `instances`, `axes` (non-empty), or both. Axis values may be a number (pin), `{ "min": …, "max": … }` (range), or `null` (keep full original range).
|
|
130
|
+
|
|
131
|
+
CLI flags `--instance`, `--axis`, and `--name` are ignored when `--config` is used; a warning is printed to stderr.
|
|
132
|
+
|
|
133
|
+
## Options
|
|
134
|
+
|
|
135
|
+
### `vf-clamp instances <font>`
|
|
136
|
+
|
|
137
|
+
| Argument / Option | Description |
|
|
138
|
+
|----------|-------------|
|
|
139
|
+
| `<font>` | Path to TTF, OTF, WOFF, or WOFF2 file (or `-` for stdin) |
|
|
140
|
+
| `--json` | Print results as JSON to stdout |
|
|
141
|
+
| `-q, --quiet` | Suppress progress messages on stderr |
|
|
142
|
+
| `--verbose` | Print Python tracebacks on engine errors |
|
|
143
|
+
|
|
144
|
+
### `vf-clamp clamp <font> [options]`
|
|
145
|
+
|
|
146
|
+
| Option | Description |
|
|
147
|
+
|--------|-------------|
|
|
148
|
+
| `<font>` | Path to TTF, OTF, WOFF, or WOFF2 file (or `-` for stdin) |
|
|
149
|
+
| `-i, --instance <name>` | Named instance to include (repeatable) |
|
|
150
|
+
| `-a, --axis <tag:value>` | Pin an axis to a fixed value |
|
|
151
|
+
| `-a, --axis <tag:min:max>` | Restrict an axis to a range |
|
|
152
|
+
| `-a, --axis <tag:*>` (or `tag:keep`) | Keep axis at its full original range |
|
|
153
|
+
| `-n, --name <name>` | Output name (filename stem; sanitised for filesystem safety) |
|
|
154
|
+
| `-f, --format <fmt>` | Output format: `ttf` (default), `otf`, `woff`, `woff2` |
|
|
155
|
+
| `-o, --output <dir>` | Output directory (default: current directory) |
|
|
156
|
+
| `-c, --config <file>` | JSON config file (for multiple outputs) |
|
|
157
|
+
| `--no-force` | Refuse to overwrite existing output files |
|
|
158
|
+
| `--dry-run` | Validate inputs without invoking the engine or writing files |
|
|
159
|
+
| `--json` | Print results as JSON to stdout |
|
|
160
|
+
| `-q, --quiet` | Suppress progress messages on stderr |
|
|
161
|
+
| `--verbose` | Print Python tracebacks on engine errors |
|
|
162
|
+
|
|
163
|
+
## Output and exit codes
|
|
164
|
+
|
|
165
|
+
For scripting, output paths are written one-per-line to **stdout**; all progress and error messages go to **stderr**:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
vf-clamp clamp MyFont.ttf -i Light -i Bold | xargs -n1 woff2_compress
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Exit codes follow BSD `sysexits` conventions:
|
|
172
|
+
|
|
173
|
+
| Code | Meaning |
|
|
174
|
+
|------|---------|
|
|
175
|
+
| 0 | Success |
|
|
176
|
+
| 2 | Usage / validation error (bad flags, malformed `--axis`, missing inputs) |
|
|
177
|
+
| 65 | Input data error (bad font, bad JSON config) |
|
|
178
|
+
| 66 | Input file missing or unreadable |
|
|
179
|
+
| 70 | Internal engine error |
|
|
180
|
+
| 78 | Invalid configuration |
|
|
181
|
+
| 130 | Aborted by Ctrl-C (SIGINT) |
|
|
182
|
+
|
|
183
|
+
## Performance Note
|
|
184
|
+
|
|
185
|
+
The underlying engine uses Pyodide (Python WASM). The first run in a process takes approximately **10–20 seconds** to initialise. Subsequent calls in the same process are **1–2 seconds**. This is expected — plan config-file batching for large workflows so the engine pays cold-start only once.
|
|
186
|
+
|
|
187
|
+
## Related
|
|
188
|
+
|
|
189
|
+
- [`@liiift-studio/vf-clamp`](https://www.npmjs.com/package/@liiift-studio/vf-clamp) — the core library
|
|
190
|
+
- [vfclamp.com](https://vfclamp.com) — web interface
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT © [Liiift Studio](https://liiift.studio)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE } from '../core/exitCodes.js';
|
|
3
|
+
export { parseAxisSpecs } from '../core/axisSpecs.js';
|
|
4
|
+
/**
|
|
5
|
+
* Registers the `clamp` subcommand on the given commander program.
|
|
6
|
+
* Usage: vf-clamp clamp <font> [options]
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerClampCommand(program: Command): void;
|
|
9
|
+
export { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// `vf-clamp clamp <font>` — produce one or more restricted variable font files.
|
|
2
|
+
// Wires commander flags onto the pure config/parser/format-validator layer in
|
|
3
|
+
// `src/core/*` and the IO helpers in `src/utils/*`.
|
|
4
|
+
import { readFontFile, writeOutputs, assertFontExtension, sanitizeFilename } from '../utils/font.js';
|
|
5
|
+
import { ellipsisGlyph } from '../utils/format.js';
|
|
6
|
+
import { SUPPORTED_FORMATS, assertFormat } from '../core/format.js';
|
|
7
|
+
import { parseAxisSpecs } from '../core/axisSpecs.js';
|
|
8
|
+
import { readConfig } from '../core/config.js';
|
|
9
|
+
import { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE, classifyError } from '../core/exitCodes.js';
|
|
10
|
+
// Re-export parseAxisSpecs for backward compatibility with existing tests
|
|
11
|
+
// while keeping the canonical home in `src/core/axisSpecs.ts`.
|
|
12
|
+
export { parseAxisSpecs } from '../core/axisSpecs.js';
|
|
13
|
+
/**
|
|
14
|
+
* Registers the `clamp` subcommand on the given commander program.
|
|
15
|
+
* Usage: vf-clamp clamp <font> [options]
|
|
16
|
+
*/
|
|
17
|
+
export function registerClampCommand(program) {
|
|
18
|
+
program
|
|
19
|
+
.command('clamp <font>')
|
|
20
|
+
.description('Produce one or more restricted variable font files.\n' +
|
|
21
|
+
'Pass "-" for <font> to read the source font from stdin.')
|
|
22
|
+
.option('-i, --instance <name>', 'Named instance to include in hull (repeatable)', collect, [])
|
|
23
|
+
.option('-a, --axis <spec>', 'Pin or restrict an axis: tag:value, tag:min:max, or tag:* (repeatable)', collect, [])
|
|
24
|
+
.option('-n, --name <name>', 'Output name (filename stem)')
|
|
25
|
+
.option('-f, --format <fmt>', `Output format: ${SUPPORTED_FORMATS.join(' | ')}`, 'ttf')
|
|
26
|
+
.option('-o, --output <dir>', 'Output directory', '.')
|
|
27
|
+
.option('-c, --config <file>', 'JSON config file for multiple outputs')
|
|
28
|
+
.option('--force', 'Overwrite existing output files without warning', true)
|
|
29
|
+
.option('--no-force', 'Refuse to overwrite existing output files')
|
|
30
|
+
.option('--dry-run', 'Validate inputs without invoking the engine or writing files')
|
|
31
|
+
.option('--json', 'Print results as JSON to stdout')
|
|
32
|
+
.option('-q, --quiet', 'Suppress progress messages on stderr')
|
|
33
|
+
.option('--verbose', 'Print extra diagnostic output (Python tracebacks on error)')
|
|
34
|
+
.addHelpText('after', `
|
|
35
|
+
Examples:
|
|
36
|
+
$ vf-clamp clamp MyFont.ttf -i Light -i Bold -n Light-Bold
|
|
37
|
+
$ vf-clamp clamp MyFont.ttf -a wght:300:700 -f woff2
|
|
38
|
+
$ vf-clamp clamp MyFont.ttf -a wght:400 -n Regular-Only
|
|
39
|
+
$ vf-clamp clamp MyFont.ttf -i Light -i Bold -a slnt:-5:0 -n LightBold-Slanted
|
|
40
|
+
$ vf-clamp clamp MyFont.ttf -c clamp.config.json
|
|
41
|
+
$ cat MyFont.ttf | vf-clamp clamp - -i Bold -n Bold-only
|
|
42
|
+
|
|
43
|
+
Axis spec forms:
|
|
44
|
+
tag:value pin the axis to a single value
|
|
45
|
+
tag:min:max restrict the axis to a sub-range
|
|
46
|
+
tag:* keep the axis at its full original range
|
|
47
|
+
tag:keep alias for tag:*
|
|
48
|
+
|
|
49
|
+
Exit codes:
|
|
50
|
+
0 success
|
|
51
|
+
2 usage / validation error (bad flags, malformed --axis, missing inputs)
|
|
52
|
+
65 input data error (bad font, bad JSON config)
|
|
53
|
+
66 input file missing or unreadable
|
|
54
|
+
70 internal engine error
|
|
55
|
+
78 invalid configuration
|
|
56
|
+
|
|
57
|
+
First run in a fresh process takes 10–20s while Pyodide initialises.
|
|
58
|
+
`)
|
|
59
|
+
.action(async (fontPath, opts) => {
|
|
60
|
+
try {
|
|
61
|
+
await runClamp(fontPath, opts);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
+
const code = classifyError(err);
|
|
66
|
+
const detail = opts.verbose && err instanceof Error && err.stack ? `\n${err.stack}` : '';
|
|
67
|
+
// Set exitCode and write to stderr; the entry point's parseAsync
|
|
68
|
+
// handler will exit with the correct code after the event loop drains.
|
|
69
|
+
process.exitCode = code;
|
|
70
|
+
process.stderr.write(`vf-clamp clamp: ${message}${detail}\n`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Core clamp action — separated from the commander handler so it can be
|
|
76
|
+
* driven directly from tests.
|
|
77
|
+
*/
|
|
78
|
+
async function runClamp(fontPath, opts) {
|
|
79
|
+
assertFontExtension(fontPath);
|
|
80
|
+
// Build request list and resolve effective format/outputDir.
|
|
81
|
+
let outputRequests;
|
|
82
|
+
let format;
|
|
83
|
+
let outputDir;
|
|
84
|
+
if (opts.config) {
|
|
85
|
+
// Warn loudly when CLI flags conflict with config-supplied values.
|
|
86
|
+
warnIfFlagsIgnored(opts);
|
|
87
|
+
const config = await readConfig(opts.config);
|
|
88
|
+
outputRequests = config.outputs.map((output, idx) => ({
|
|
89
|
+
name: sanitizeFilename(output.name ?? `output-${idx + 1}`),
|
|
90
|
+
...(output.instances ? { instances: output.instances } : {}),
|
|
91
|
+
...(output.axes ? { axes: output.axes } : {}),
|
|
92
|
+
}));
|
|
93
|
+
const candidateFormat = config.format ?? opts.format;
|
|
94
|
+
assertFormat(candidateFormat);
|
|
95
|
+
format = candidateFormat;
|
|
96
|
+
outputDir = config.outputDir ?? opts.output;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
assertFormat(opts.format);
|
|
100
|
+
format = opts.format;
|
|
101
|
+
outputDir = opts.output;
|
|
102
|
+
outputRequests = buildRequestFromFlags(opts);
|
|
103
|
+
}
|
|
104
|
+
if (opts.dryRun) {
|
|
105
|
+
const lines = outputRequests.map((r, i) => ` [${i + 1}] ${r.name}.${format}`).join('\n');
|
|
106
|
+
writeLog(opts, `Dry run: ${outputRequests.length} output(s) would be written to ${outputDir}\n${lines}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Read source font (after dry-run check so dry-run does not read the file).
|
|
110
|
+
const buffer = await readFontFile(fontPath);
|
|
111
|
+
writeLog(opts, `Processing ${outputRequests.length} output(s)${ellipsisGlyph()} (first run may take 10–20s)\n`);
|
|
112
|
+
// Dynamic import so `--help`/`--version`/dry-run do not pay the engine's
|
|
113
|
+
// ESM resolution and Pyodide bootstrap cost.
|
|
114
|
+
const { clampFont } = await import('@liiift-studio/vf-clamp');
|
|
115
|
+
// Batch ALL outputs into a single clampFont call so Pyodide is initialised
|
|
116
|
+
// once, the font buffer crosses the WASM bridge once, and fontTools parses
|
|
117
|
+
// the TTFont once. The engine's `outputs` array is designed for this.
|
|
118
|
+
const clampOutputs = outputRequests.map(buildClampOutput);
|
|
119
|
+
const results = await clampFont(buffer, {
|
|
120
|
+
outputs: clampOutputs,
|
|
121
|
+
format,
|
|
122
|
+
});
|
|
123
|
+
// Pair engine results back with the names we requested so writeOutputs can
|
|
124
|
+
// compose filenames using our sanitised names rather than whatever the
|
|
125
|
+
// engine echoed back.
|
|
126
|
+
const writeInputs = results.map((result, i) => ({
|
|
127
|
+
name: outputRequests[i]?.name ?? result.name,
|
|
128
|
+
buffer: result.buffer,
|
|
129
|
+
format: result.format ?? format,
|
|
130
|
+
}));
|
|
131
|
+
const written = await writeOutputs(writeInputs, outputDir, { force: opts.force !== false });
|
|
132
|
+
if (opts.json) {
|
|
133
|
+
process.stdout.write(JSON.stringify({ written }) + '\n');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
for (const filePath of written) {
|
|
137
|
+
// Bare paths on stdout so consumers can `... | xargs` them.
|
|
138
|
+
process.stdout.write(`${filePath}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Commander value collector — appends each repeated flag value into an array. */
|
|
143
|
+
function collect(value, previous) {
|
|
144
|
+
return [...previous, value];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Emits stderr warnings when --config is combined with flags whose values
|
|
148
|
+
* would have been used in flag-mode. Avoids the silent-override footgun.
|
|
149
|
+
*/
|
|
150
|
+
function warnIfFlagsIgnored(opts) {
|
|
151
|
+
const ignored = [];
|
|
152
|
+
if (opts.instance.length > 0)
|
|
153
|
+
ignored.push('--instance');
|
|
154
|
+
if (opts.axis.length > 0)
|
|
155
|
+
ignored.push('--axis');
|
|
156
|
+
if (opts.name)
|
|
157
|
+
ignored.push('--name');
|
|
158
|
+
if (ignored.length > 0 && !opts.quiet) {
|
|
159
|
+
process.stderr.write(`vf-clamp clamp: warning — ${ignored.join(', ')} ignored when --config is used\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Builds a single output request from CLI flags.
|
|
164
|
+
* Requires at least one --instance or --axis flag.
|
|
165
|
+
*/
|
|
166
|
+
function buildRequestFromFlags(opts) {
|
|
167
|
+
const { instance: instances, axis: axisSpecs, name } = opts;
|
|
168
|
+
if (instances.length === 0 && axisSpecs.length === 0) {
|
|
169
|
+
const err = new Error('Provide at least one --instance or --axis flag, or use --config for a config file.');
|
|
170
|
+
// Mark for the exit-code classifier.
|
|
171
|
+
err.code = 'USAGE';
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
const axes = parseAxisSpecs(axisSpecs);
|
|
175
|
+
const rawName = name ?? (instances.length > 0 ? instances.join('-') : Object.keys(axes).join('-'));
|
|
176
|
+
const safeName = sanitizeFilename(rawName);
|
|
177
|
+
const req = { name: safeName };
|
|
178
|
+
if (instances.length > 0)
|
|
179
|
+
req.instances = instances;
|
|
180
|
+
if (Object.keys(axes).length > 0)
|
|
181
|
+
req.axes = axes;
|
|
182
|
+
return [req];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Converts an OutputRequest into the shape expected by clampFont's `outputs` array.
|
|
186
|
+
*/
|
|
187
|
+
function buildClampOutput(req) {
|
|
188
|
+
return {
|
|
189
|
+
name: req.name,
|
|
190
|
+
...(req.instances ? { instances: req.instances } : {}),
|
|
191
|
+
...(req.axes ? { axes: req.axes } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Writes a progress/log line to stderr unless quiet or stderr is closed. */
|
|
195
|
+
function writeLog(opts, message) {
|
|
196
|
+
if (opts.quiet || opts.json)
|
|
197
|
+
return;
|
|
198
|
+
process.stderr.write(message);
|
|
199
|
+
}
|
|
200
|
+
// Re-export typed exit codes so other modules can use them without
|
|
201
|
+
// reaching into core/.
|
|
202
|
+
export { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `vf-clamp instances <font>` — print all variable axes and named instances in a font.
|
|
2
|
+
import { readFontFile, assertFontExtension } from '../utils/font.js';
|
|
3
|
+
import { formatTable, arrowGlyph, ellipsisGlyph } from '../utils/format.js';
|
|
4
|
+
import { classifyError } from '../core/exitCodes.js';
|
|
5
|
+
/**
|
|
6
|
+
* Registers the `instances` subcommand on the given commander program.
|
|
7
|
+
* Usage: vf-clamp instances <font>
|
|
8
|
+
*/
|
|
9
|
+
export function registerInstancesCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('instances <font>')
|
|
12
|
+
.description('List all variable axes and named instances in a font file.\n' +
|
|
13
|
+
'Pass "-" for <font> to read the source font from stdin.')
|
|
14
|
+
.option('--json', 'Print results as JSON to stdout')
|
|
15
|
+
.option('-q, --quiet', 'Suppress progress messages on stderr')
|
|
16
|
+
.option('--verbose', 'Print extra diagnostic output (Python tracebacks on error)')
|
|
17
|
+
.addHelpText('after', '\nFirst run in a fresh process takes 10–20s while Pyodide initialises.\n')
|
|
18
|
+
.action(async (fontPath, opts) => {
|
|
19
|
+
try {
|
|
20
|
+
await runInstances(fontPath, opts);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
const code = classifyError(err);
|
|
25
|
+
const detail = opts.verbose && err instanceof Error && err.stack ? `\n${err.stack}` : '';
|
|
26
|
+
process.exitCode = code;
|
|
27
|
+
process.stderr.write(`vf-clamp instances: ${message}${detail}\n`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Core action — extracted so tests can drive it directly. */
|
|
32
|
+
async function runInstances(fontPath, opts) {
|
|
33
|
+
assertFontExtension(fontPath);
|
|
34
|
+
const buffer = await readFontFile(fontPath);
|
|
35
|
+
if (!opts.quiet && !opts.json) {
|
|
36
|
+
process.stderr.write(`Reading font${ellipsisGlyph()} (first run may take 10–20s)\n`);
|
|
37
|
+
}
|
|
38
|
+
// Dynamic import so --help/--version do not pay engine ESM resolution cost.
|
|
39
|
+
const { getInstances } = await import('@liiift-studio/vf-clamp');
|
|
40
|
+
const { axes, instances } = await getInstances(buffer);
|
|
41
|
+
if (axes.length === 0 && !opts.quiet && !opts.json) {
|
|
42
|
+
process.stderr.write(`Warning: "${fontPath}" has no variable axes — this appears to be a static font.\n`);
|
|
43
|
+
}
|
|
44
|
+
if (opts.json) {
|
|
45
|
+
process.stdout.write(JSON.stringify({ axes, instances }) + '\n');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
printAxes(axes);
|
|
49
|
+
printInstances(instances);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Prints the axes section to stdout.
|
|
53
|
+
* Format: tag Name min → max (default: N)
|
|
54
|
+
*/
|
|
55
|
+
function printAxes(axes) {
|
|
56
|
+
process.stdout.write('\nAxes:\n');
|
|
57
|
+
if (axes.length === 0) {
|
|
58
|
+
process.stdout.write(' (none — this may not be a variable font)\n');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const TAG_WIDTH = Math.max(4, ...axes.map((a) => a.tag.length)) + 2;
|
|
62
|
+
const NAME_WIDTH = Math.max(4, ...axes.map((a) => a.name.length)) + 2;
|
|
63
|
+
const arrow = arrowGlyph();
|
|
64
|
+
for (const axis of axes) {
|
|
65
|
+
const tag = axis.tag.padEnd(TAG_WIDTH);
|
|
66
|
+
const name = axis.name.padEnd(NAME_WIDTH);
|
|
67
|
+
const range = `${axis.minimum} ${arrow} ${axis.maximum}`;
|
|
68
|
+
process.stdout.write(` ${tag}${name}${range} (default: ${axis.default})\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Prints the named instances section to stdout.
|
|
73
|
+
* Format: Name wght=300 slnt=0 …
|
|
74
|
+
*/
|
|
75
|
+
function printInstances(instances) {
|
|
76
|
+
process.stdout.write(`\nInstances (${instances.length}):\n`);
|
|
77
|
+
if (instances.length === 0) {
|
|
78
|
+
process.stdout.write(' (none)\n');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const NAME_WIDTH = Math.max(4, ...instances.map((i) => i.name.length)) + 2;
|
|
82
|
+
const rows = instances.map((inst) => {
|
|
83
|
+
const coords = Object.entries(inst.coordinates)
|
|
84
|
+
.map(([tag, val]) => `${tag}=${val}`)
|
|
85
|
+
.join(' ');
|
|
86
|
+
return [inst.name, coords];
|
|
87
|
+
});
|
|
88
|
+
process.stdout.write(formatTable(rows, NAME_WIDTH) + '\n');
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AxisValue } from '@liiift-studio/vf-clamp';
|
|
2
|
+
/**
|
|
3
|
+
* Parses `--axis` flag values into a structured axes map.
|
|
4
|
+
* Accepted forms:
|
|
5
|
+
* tag:value → pin to exact value (stored as a number)
|
|
6
|
+
* tag:min:max → restrict to range (both min and max are required)
|
|
7
|
+
* tag:* / tag:keep → keep axis at its full original range (stored as null)
|
|
8
|
+
*
|
|
9
|
+
* Rejects non-finite numbers (Infinity, NaN), empty strings, and tags that
|
|
10
|
+
* are not the OpenType 4-character printable-ASCII form.
|
|
11
|
+
*
|
|
12
|
+
* Negative values are supported: slnt:-10:0 parses correctly because `-10`
|
|
13
|
+
* contains no colon.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseAxisSpecs(specs: string[]): Record<string, AxisValue>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Axis spec parser — pure logic, no commander/IO dependencies.
|
|
2
|
+
// Moved out of `commands/clamp.ts` so the test suite can import it without
|
|
3
|
+
// pulling commander or the engine.
|
|
4
|
+
/** Regex for OpenType axis tags — exactly 4 printable ASCII characters. */
|
|
5
|
+
const AXIS_TAG_REGEX = /^[\x20-\x7E]{4}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Parses `--axis` flag values into a structured axes map.
|
|
8
|
+
* Accepted forms:
|
|
9
|
+
* tag:value → pin to exact value (stored as a number)
|
|
10
|
+
* tag:min:max → restrict to range (both min and max are required)
|
|
11
|
+
* tag:* / tag:keep → keep axis at its full original range (stored as null)
|
|
12
|
+
*
|
|
13
|
+
* Rejects non-finite numbers (Infinity, NaN), empty strings, and tags that
|
|
14
|
+
* are not the OpenType 4-character printable-ASCII form.
|
|
15
|
+
*
|
|
16
|
+
* Negative values are supported: slnt:-10:0 parses correctly because `-10`
|
|
17
|
+
* contains no colon.
|
|
18
|
+
*/
|
|
19
|
+
export function parseAxisSpecs(specs) {
|
|
20
|
+
const axes = {};
|
|
21
|
+
for (const spec of specs) {
|
|
22
|
+
const colonIdx = spec.indexOf(':');
|
|
23
|
+
if (colonIdx === -1) {
|
|
24
|
+
throw new Error(`Invalid --axis value "${spec}". Expected tag:value or tag:min:max`);
|
|
25
|
+
}
|
|
26
|
+
const tag = spec.slice(0, colonIdx).trim();
|
|
27
|
+
if (!tag) {
|
|
28
|
+
throw new Error(`Invalid --axis value "${spec}": axis tag is empty`);
|
|
29
|
+
}
|
|
30
|
+
// OpenType axis tags are exactly 4 printable ASCII characters. Reject
|
|
31
|
+
// anything else early — the engine error otherwise is cryptic.
|
|
32
|
+
if (!AXIS_TAG_REGEX.test(tag)) {
|
|
33
|
+
throw new Error(`Invalid --axis value "${spec}": tag "${tag}" must be exactly 4 printable ASCII characters`);
|
|
34
|
+
}
|
|
35
|
+
const rest = spec.slice(colonIdx + 1);
|
|
36
|
+
// Null form: tag:* or tag:keep — keep axis at its full original range.
|
|
37
|
+
if (rest === '*' || rest.toLowerCase() === 'keep') {
|
|
38
|
+
axes[tag] = null;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const secondColon = rest.indexOf(':');
|
|
42
|
+
if (secondColon === -1) {
|
|
43
|
+
// Pin form: tag:value
|
|
44
|
+
const value = parseFiniteNumber(rest);
|
|
45
|
+
if (value === null) {
|
|
46
|
+
throw new Error(`Invalid --axis value "${spec}": "${rest}" is not a finite number`);
|
|
47
|
+
}
|
|
48
|
+
axes[tag] = value;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Range form: tag:min:max
|
|
52
|
+
const minStr = rest.slice(0, secondColon);
|
|
53
|
+
const maxStr = rest.slice(secondColon + 1);
|
|
54
|
+
const min = parseFiniteNumber(minStr);
|
|
55
|
+
const max = parseFiniteNumber(maxStr);
|
|
56
|
+
if (min === null) {
|
|
57
|
+
throw new Error(`Invalid --axis value "${spec}": min "${minStr}" is not a finite number`);
|
|
58
|
+
}
|
|
59
|
+
if (max === null) {
|
|
60
|
+
throw new Error(`Invalid --axis value "${spec}": max "${maxStr}" is not a finite number`);
|
|
61
|
+
}
|
|
62
|
+
if (min > max) {
|
|
63
|
+
throw new Error(`Invalid --axis "${spec}": min (${min}) must not exceed max (${max})`);
|
|
64
|
+
}
|
|
65
|
+
axes[tag] = { min, max };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return axes;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parses a string as a finite number, returning `null` for any value that
|
|
72
|
+
* is not a finite, fully-numeric string. Unlike global `isNaN`, this:
|
|
73
|
+
* - rejects the empty string (`Number('')` returns 0)
|
|
74
|
+
* - rejects `'Infinity'` and `'-Infinity'`
|
|
75
|
+
* - rejects whitespace-only or trailing-garbage strings
|
|
76
|
+
*/
|
|
77
|
+
function parseFiniteNumber(raw) {
|
|
78
|
+
const trimmed = raw.trim();
|
|
79
|
+
if (trimmed === '')
|
|
80
|
+
return null;
|
|
81
|
+
const n = Number(trimmed);
|
|
82
|
+
if (!Number.isFinite(n))
|
|
83
|
+
return null;
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AxisValue } from '@liiift-studio/vf-clamp';
|
|
2
|
+
import { type Format } from './format.js';
|
|
3
|
+
/** Shape of a single output entry in a config file. */
|
|
4
|
+
export interface ConfigOutput {
|
|
5
|
+
name?: string;
|
|
6
|
+
instances?: string[];
|
|
7
|
+
/** Axis constraints: number to pin, {min,max} to restrict, null to keep full range. */
|
|
8
|
+
axes?: Record<string, AxisValue>;
|
|
9
|
+
}
|
|
10
|
+
/** Validated shape of a config file. */
|
|
11
|
+
export interface ClampConfig {
|
|
12
|
+
format?: Format;
|
|
13
|
+
outputDir?: string;
|
|
14
|
+
outputs: ConfigOutput[];
|
|
15
|
+
}
|
|
16
|
+
/** A resolved output request passed to clampFont. */
|
|
17
|
+
export interface OutputRequest {
|
|
18
|
+
name: string;
|
|
19
|
+
instances?: string[];
|
|
20
|
+
axes?: Record<string, AxisValue>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reads and parses a JSON config file from disk, then validates every field
|
|
24
|
+
* before returning a typed object. Throws a descriptive error if the file
|
|
25
|
+
* cannot be read, is not valid JSON, or contains invalid fields.
|
|
26
|
+
*
|
|
27
|
+
* Error messages echo the user-supplied path (not the resolved absolute path)
|
|
28
|
+
* to avoid leaking server-side filesystem layout when invoked from services.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readConfig(configPath: string): Promise<ClampConfig>;
|
|
31
|
+
/**
|
|
32
|
+
* Validates a parsed JSON value against the ClampConfig schema.
|
|
33
|
+
* Exported separately so tests can exercise validation without disk I/O.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateConfig(input: unknown): ClampConfig;
|