@qrdx/cli 0.0.4 → 0.0.6

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -24
  3. package/dist/index.js +926 -204
  4. package/package.json +38 -21
package/dist/index.js CHANGED
@@ -1,39 +1,220 @@
1
1
  #!/usr/bin/env node
2
- 'use strict';
2
+ import { readFileSync, mkdirSync, writeFileSync, createReadStream } from 'fs';
3
+ import { join, resolve, extname } from 'path';
4
+ import { readFile } from 'fs/promises';
5
+ import { BODY_PATTERN, CORNER_EYE_PATTERNS, CORNER_EYE_DOT_PATTERNS, getTemplateIds, FRAME_STYLES, DEFAULT_FRAME_TEXT, FRAME_TEXT_MAX_LENGTH, DEFAULT_MARGIN, getFrameDimensions, colorConfigSchema, errorLevelSchema, QRCodeSVG, svgToEPS } from 'qrdx';
6
+ import { createElement } from 'react';
7
+ import { renderToStaticMarkup } from 'react-dom/server';
8
+ import * as readline from 'readline';
9
+ import * as p from '@clack/prompts';
3
10
 
4
- var fs = require('fs');
5
- var path = require('path');
6
- var p = require('@clack/prompts');
7
- var qrdx = require('qrdx');
8
- var react = require('react');
9
- var server = require('react-dom/server');
10
-
11
- function _interopNamespace(e) {
12
- if (e && e.__esModule) return e;
13
- var n = Object.create(null);
14
- if (e) {
15
- Object.keys(e).forEach(function (k) {
16
- if (k !== 'default') {
17
- var d = Object.getOwnPropertyDescriptor(e, k);
18
- Object.defineProperty(n, k, d.get ? d : {
19
- enumerable: true,
20
- get: function () { return e[k]; }
21
- });
11
+ var CsvParser = class {
12
+ row = [];
13
+ field = "";
14
+ quoted = false;
15
+ /** A quote seen inside a quoted field: the end, or the first of "". */
16
+ quote = false;
17
+ started = false;
18
+ first = true;
19
+ /** Rows completed by this chunk. */
20
+ push(chunk) {
21
+ const rows = [];
22
+ let text2 = chunk;
23
+ if (this.first) {
24
+ this.first = false;
25
+ if (text2.charCodeAt(0) === 65279) text2 = text2.slice(1);
26
+ }
27
+ for (const char of text2) {
28
+ if (this.quoted) {
29
+ if (this.quote) {
30
+ this.quote = false;
31
+ if (char === '"') {
32
+ this.field += '"';
33
+ continue;
34
+ }
35
+ this.quoted = false;
36
+ } else if (char === '"') {
37
+ this.quote = true;
38
+ continue;
39
+ } else {
40
+ this.field += char;
41
+ continue;
42
+ }
22
43
  }
23
- });
44
+ if (char === '"' && this.field === "" && !this.started) {
45
+ this.quoted = true;
46
+ this.started = true;
47
+ } else if (char === ",") {
48
+ this.endField();
49
+ } else if (char === "\n") {
50
+ this.endRow(rows);
51
+ } else if (char === "\r") ; else {
52
+ this.field += char;
53
+ this.started = true;
54
+ }
55
+ }
56
+ return rows;
57
+ }
58
+ /** The last row, when the file doesn't end with a line break. */
59
+ end() {
60
+ const rows = [];
61
+ if (this.quote) {
62
+ this.quote = false;
63
+ this.quoted = false;
64
+ }
65
+ if (this.started || this.row.length > 0) this.endRow(rows);
66
+ return rows;
67
+ }
68
+ endField() {
69
+ this.row.push(this.field);
70
+ this.field = "";
71
+ this.started = false;
72
+ }
73
+ endRow(rows) {
74
+ this.endField();
75
+ if (this.row.some((cell) => cell.trim() !== "")) rows.push(this.row);
76
+ this.row = [];
77
+ }
78
+ };
79
+ async function* readCsvRows(path) {
80
+ const parser = new CsvParser();
81
+ const stream = createReadStream(path, { encoding: "utf8" });
82
+ for await (const chunk of stream) {
83
+ yield* parser.push(chunk);
24
84
  }
25
- n.default = e;
26
- return Object.freeze(n);
85
+ yield* parser.end();
27
86
  }
28
-
29
- var p__namespace = /*#__PURE__*/_interopNamespace(p);
30
-
87
+ var VALUE_COLUMN = /^(value|url|link|data|content|text|qr|qr ?code)$/i;
88
+ var FILENAME_COLUMN = /^(file ?name|name|label|id|title|sku)$/i;
89
+ function guessValueColumn(columns) {
90
+ const index = columns.findIndex((name) => VALUE_COLUMN.test(name.trim()));
91
+ return index === -1 ? 0 : index;
92
+ }
93
+ function guessFilenameColumn(columns, valueColumn) {
94
+ return columns.findIndex(
95
+ (name, i) => i !== valueColumn && FILENAME_COLUMN.test(name.trim())
96
+ );
97
+ }
98
+ function resolveColumn(spec, columns, flag) {
99
+ if (/^\d+$/.test(spec)) {
100
+ const index = Number(spec) - 1;
101
+ if (index >= 0 && index < columns.length) return index;
102
+ } else {
103
+ const index = columns.findIndex(
104
+ (name) => name.trim().toLowerCase() === spec.trim().toLowerCase()
105
+ );
106
+ if (index >= 0) return index;
107
+ }
108
+ throw new Error(
109
+ `${flag} "${spec}" isn't a column of this file. Columns: ${columns.map((name, i) => `${i + 1}. ${name}`).join(", ")}`
110
+ );
111
+ }
112
+ function sanitizeFilename(raw) {
113
+ return raw.normalize("NFKC").replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, "-").replace(/\s+/g, " ").trim().replace(/^[.\-\s]+|[.\s]+$/g, "").slice(0, 100);
114
+ }
115
+ function createFilenameAllocator() {
116
+ const used = /* @__PURE__ */ new Set();
117
+ const next = /* @__PURE__ */ new Map();
118
+ return (base, extension) => {
119
+ const key = base.toLowerCase();
120
+ let n = next.get(key) ?? 1;
121
+ let candidate = n === 1 ? `${base}.${extension}` : `${base}-${n}.${extension}`;
122
+ while (used.has(candidate.toLowerCase())) {
123
+ n++;
124
+ candidate = `${base}-${n}.${extension}`;
125
+ }
126
+ next.set(key, n + 1);
127
+ used.add(candidate.toLowerCase());
128
+ return candidate;
129
+ };
130
+ }
131
+ function csvCell(value) {
132
+ const text2 = String(value);
133
+ return /[",\n\r]/.test(text2) ? `"${text2.replaceAll('"', '""')}"` : text2;
134
+ }
135
+ var OUTPUT_FORMATS = [
136
+ "svg",
137
+ "png",
138
+ "webp",
139
+ "eps"
140
+ ];
141
+ var encodeRaster = (image, format) => format === "webp" ? image.webp({ lossless: true }) : image.png();
31
142
  function buildSVGString(props) {
32
- const element = react.createElement(qrdx.QRCodeSVG, {
143
+ const element = createElement(QRCodeSVG, {
33
144
  size: props.size ?? 512,
34
145
  ...props
35
146
  });
36
- return server.renderToStaticMarkup(element);
147
+ return renderToStaticMarkup(element);
148
+ }
149
+ async function loadLogo(src) {
150
+ if (src.startsWith("data:")) {
151
+ const comma = src.indexOf(",");
152
+ const payload = src.slice(comma + 1);
153
+ return src.slice(0, comma).endsWith(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload));
154
+ }
155
+ if (/^https?:\/\//i.test(src)) {
156
+ const res = await fetch(src);
157
+ if (!res.ok) {
158
+ throw new Error(
159
+ `Could not download logo (${res.status} ${res.statusText}): ${src}`
160
+ );
161
+ }
162
+ return Buffer.from(await res.arrayBuffer());
163
+ }
164
+ return readFile(src);
165
+ }
166
+ async function logoAsEmbeddableDataUrl(src) {
167
+ const bytes = await loadLogo(src);
168
+ if (bytes[0] === 137 && bytes[1] === 80) {
169
+ return `data:image/png;base64,${bytes.toString("base64")}`;
170
+ }
171
+ if (bytes[0] === 255 && bytes[1] === 216) {
172
+ return `data:image/jpeg;base64,${bytes.toString("base64")}`;
173
+ }
174
+ try {
175
+ const mod = await import('sharp');
176
+ const sharp = mod.default;
177
+ const png = await sharp(bytes).png().toBuffer();
178
+ return `data:image/png;base64,${png.toString("base64")}`;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ async function buildEPSString(props) {
184
+ const src = props.imageSettings?.src;
185
+ let epsProps = props;
186
+ if (src && props.imageSettings) {
187
+ const dataUrl = await logoAsEmbeddableDataUrl(src);
188
+ if (dataUrl) {
189
+ epsProps = {
190
+ ...props,
191
+ imageSettings: { ...props.imageSettings, src: dataUrl }
192
+ };
193
+ } else {
194
+ console.warn(
195
+ "The logo could not be converted for EPS (PNG or JPEG work best), so it was left out."
196
+ );
197
+ }
198
+ }
199
+ return await svgToEPS(buildSVGString(epsProps));
200
+ }
201
+ async function findLogoBox(sharp, svgString) {
202
+ const imageTag = svgString.match(/<image\b[^>]*?(?:\/>|><\/image>)/)?.[0];
203
+ if (!imageTag) {
204
+ return null;
205
+ }
206
+ const markerTag = imageTag.replace(/^<image\b/, '<rect class="qrdx-logo-box"').replace(/\shref="[^"]*"/, "").replace(/(?:\/>|><\/image>)$/, ' fill="#000"/>');
207
+ const markerSvg = svgString.replace(imageTag, markerTag).replace(
208
+ /<svg\b[^>]*>/,
209
+ (open) => `${open}<style>*{visibility:hidden}.qrdx-logo-box{visibility:visible}</style>`
210
+ );
211
+ const { info } = await sharp(Buffer.from(markerSvg, "utf-8")).png().trim().toBuffer({ resolveWithObject: true });
212
+ return {
213
+ left: -(info.trimOffsetLeft ?? 0),
214
+ top: -(info.trimOffsetTop ?? 0),
215
+ width: info.width,
216
+ height: info.height
217
+ };
37
218
  }
38
219
  async function saveOutput(options) {
39
220
  const { outputPath, format, ...qrProps } = options;
@@ -41,93 +222,259 @@ async function saveOutput(options) {
41
222
  if (format === "svg") {
42
223
  const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
43
224
  ${svgString}`;
44
- fs.writeFileSync(outputPath, svgContent, "utf-8");
225
+ writeFileSync(outputPath, svgContent, "utf-8");
45
226
  return;
46
227
  }
47
- const sharp = (await import('sharp')).default;
228
+ if (format === "eps") {
229
+ writeFileSync(outputPath, await buildEPSString(qrProps), "utf-8");
230
+ return;
231
+ }
232
+ let sharp;
233
+ try {
234
+ const mod = await import('sharp');
235
+ sharp = mod.default;
236
+ } catch {
237
+ throw new Error(
238
+ `${format === "webp" ? "WebP" : "PNG"} output needs the sharp package (native binaries). It is optional and may not have installed; use SVG or reinstall: https://sharp.pixelplumbing.com/install`
239
+ );
240
+ }
48
241
  const svgBuffer = Buffer.from(svgString, "utf-8");
49
242
  const size = qrProps.size ?? 512;
50
- await sharp(svgBuffer).resize(size, size).png().toFile(outputPath);
243
+ const { width, height } = getFrameDimensions(size, qrProps.frame);
244
+ const qrImage = sharp(svgBuffer).resize(
245
+ Math.round(width),
246
+ Math.round(height)
247
+ );
248
+ const logoSrc = qrProps.imageSettings?.src;
249
+ const box = logoSrc ? await findLogoBox(sharp, svgString) : null;
250
+ if (!(logoSrc && box)) {
251
+ await encodeRaster(qrImage, format).toFile(outputPath);
252
+ return;
253
+ }
254
+ const logo = await sharp(await loadLogo(logoSrc)).resize(box.width, box.height, { fit: "fill" }).png().toBuffer();
255
+ const composited = sharp(await qrImage.png().toBuffer()).composite([
256
+ { input: logo, left: box.left, top: box.top }
257
+ ]);
258
+ await encodeRaster(composited, format).toFile(outputPath);
51
259
  }
52
-
53
- // src/commands/generate.ts
54
260
  var RESET = "\x1B[0m";
55
261
  var TEXT = "\x1B[38;5;145m";
56
262
  var GREEN = "\x1B[38;5;114m";
57
263
  var DIM = "\x1B[38;5;102m";
264
+ var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
265
+ var MAX_PNG_SIZE = 1e4;
58
266
  function parseColorFlag(value) {
59
267
  const trimmed = value.trim();
60
- if (trimmed.startsWith("{")) {
268
+ if (!trimmed.startsWith("{")) {
269
+ return trimmed;
270
+ }
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(trimmed);
274
+ } catch {
275
+ throw new Error(`Invalid gradient JSON: ${trimmed}`);
276
+ }
277
+ const result = colorConfigSchema.safeParse(parsed);
278
+ if (!result.success) {
279
+ throw new Error(
280
+ `Invalid gradient: ${trimmed}
281
+ Expected {"type":"linear"|"radial"|"angular","stops":[{"color":"#hex","offset":0-100}, ...]} with at least 2 stops.`
282
+ );
283
+ }
284
+ return result.data;
285
+ }
286
+ function assertOneOf(flag, value, allowed) {
287
+ if (value !== void 0 && !allowed.includes(value)) {
288
+ throw new Error(
289
+ `Invalid ${flag} "${value}". Expected one of: ${allowed.join(", ")}`
290
+ );
291
+ }
292
+ }
293
+ function parseIntegerFlag(flag, value, min) {
294
+ const parsed = Number(value);
295
+ if (!(value && Number.isInteger(parsed) && parsed >= min)) {
296
+ throw new Error(
297
+ `Invalid ${flag} "${value ?? ""}". Expected a whole number \u2265 ${min}.`
298
+ );
299
+ }
300
+ return parsed;
301
+ }
302
+ function assertHexColor(flag, value) {
303
+ if (value !== void 0 && !HEX_COLOR.test(value)) {
304
+ throw new Error(
305
+ `Invalid ${flag} "${value}". Expected a hex color like #000000.`
306
+ );
307
+ }
308
+ }
309
+ function requireValue(flag, value) {
310
+ if (value === void 0 || /^--?[a-z]/i.test(value)) {
311
+ throw new Error(`${flag} needs a value. Run qrdx --help to see options.`);
312
+ }
313
+ return value;
314
+ }
315
+ function frameFromFlags(flags) {
316
+ const style = flags.frame ?? (flags.frameText !== void 0 || flags.frameColor !== void 0 || flags.frameTextColor !== void 0 ? "banner-bottom" : void 0);
317
+ if (!style || style === "none") {
318
+ return void 0;
319
+ }
320
+ return {
321
+ style,
322
+ ...flags.frameText !== void 0 && { text: flags.frameText },
323
+ ...flags.frameColor && { color: flags.frameColor },
324
+ ...flags.frameTextColor && { textColor: flags.frameTextColor }
325
+ };
326
+ }
327
+ function validateFlags(flags) {
328
+ assertOneOf("--level", flags.level, errorLevelSchema.options);
329
+ assertOneOf("--body", flags.body, BODY_PATTERN);
330
+ assertOneOf("--eye", flags.eye, CORNER_EYE_PATTERNS);
331
+ assertOneOf("--dot", flags.dot, CORNER_EYE_DOT_PATTERNS);
332
+ assertOneOf(
333
+ "--template",
334
+ flags.template,
335
+ getTemplateIds().filter((id) => id !== "default")
336
+ );
337
+ assertOneOf("--frame", flags.frame, FRAME_STYLES);
338
+ if (flags.frameText !== void 0 && flags.frameText.length > FRAME_TEXT_MAX_LENGTH) {
339
+ throw new Error(
340
+ `Invalid --frame-text "${flags.frameText}". Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer.`
341
+ );
342
+ }
343
+ assertHexColor("--frame-color", flags.frameColor);
344
+ assertHexColor("--frame-text-color", flags.frameTextColor);
345
+ assertOneOf("--format", flags.format, OUTPUT_FORMATS);
346
+ if (flags.output !== void 0) {
347
+ const ext = extname(flags.output).toLowerCase();
348
+ if (!["", ".svg", ".png", ".webp", ".eps"].includes(ext)) {
349
+ throw new Error(
350
+ `Unsupported output format "${ext}". Use a .svg, .png, .webp or .eps file name.`
351
+ );
352
+ }
353
+ if (ext && flags.format && ext !== `.${flags.format}`) {
354
+ throw new Error(
355
+ `--format ${flags.format} does not match the output file "${flags.output}". Use a .${flags.format} file name or drop --format.`
356
+ );
357
+ }
358
+ }
359
+ const raster = [
360
+ extname(flags.output ?? "").toLowerCase().slice(1),
361
+ flags.format
362
+ ].find((format) => format === "png" || format === "webp");
363
+ if (raster && flags.size !== void 0 && flags.size > MAX_PNG_SIZE) {
364
+ throw new Error(
365
+ `Invalid --size "${flags.size}". ${raster === "webp" ? "WebP" : "PNG"} output is at most ${MAX_PNG_SIZE} px; use .svg or .eps for larger prints.`
366
+ );
367
+ }
368
+ }
369
+ var isCI = process.env.CI === "true";
370
+ var canUseClack = process.stdin.isTTY === true && process.stdout.isTTY === true && !isCI;
371
+ function readLineFromTTY(question) {
372
+ if (process.platform === "win32") {
373
+ return Promise.resolve(void 0);
374
+ }
375
+ return new Promise((resolve2) => {
376
+ let stream;
61
377
  try {
62
- return JSON.parse(trimmed);
378
+ stream = createReadStream("/dev/tty", { flags: "r" });
63
379
  } catch {
380
+ resolve2(void 0);
381
+ return;
64
382
  }
65
- }
66
- return trimmed;
383
+ const rl = readline.createInterface({
384
+ input: stream,
385
+ output: process.stdout
386
+ });
387
+ const finish = (answer) => {
388
+ rl.close();
389
+ stream.destroy();
390
+ resolve2(answer);
391
+ };
392
+ stream.once("error", () => finish(void 0));
393
+ rl.question(question, (answer) => finish(answer));
394
+ });
67
395
  }
68
- var IS_TTY = Boolean(process.stdin.isTTY);
69
396
  function parseFlags(args) {
70
397
  const flags = {};
71
398
  let i = 0;
72
399
  while (i < args.length) {
73
400
  const arg = args[i];
74
401
  if (!arg.startsWith("-")) {
75
- if (!flags.value) {
76
- flags.value = arg;
402
+ if (flags.value !== void 0) {
403
+ throw new Error(
404
+ `Unexpected argument "${arg}". Quote text with spaces: qrdx generate "${flags.value} ${arg}"`
405
+ );
77
406
  }
407
+ flags.value = arg;
78
408
  i++;
79
409
  continue;
80
410
  }
81
411
  const next = args[i + 1];
82
412
  switch (arg) {
413
+ case "-h":
414
+ case "--help":
415
+ flags.help = true;
416
+ i++;
417
+ break;
418
+ case "-v":
419
+ case "--version":
420
+ flags.version = true;
421
+ i++;
422
+ break;
83
423
  case "-o":
84
424
  case "--output":
425
+ if (!next || /^--?[a-z]/i.test(next)) {
426
+ throw new Error(`${arg} needs a file path, e.g. ${arg} qr.svg`);
427
+ }
85
428
  flags.output = next;
86
429
  i += 2;
87
430
  break;
431
+ case "--format":
432
+ flags.format = requireValue(arg, next).toLowerCase();
433
+ i += 2;
434
+ break;
88
435
  case "-l":
89
436
  case "--level":
90
- flags.level = next;
437
+ flags.level = requireValue(arg, next).toUpperCase();
91
438
  i += 2;
92
439
  break;
93
440
  case "--body":
94
- flags.body = next;
441
+ flags.body = requireValue(arg, next);
95
442
  i += 2;
96
443
  break;
97
444
  case "--eye":
98
- flags.eye = next;
445
+ flags.eye = requireValue(arg, next);
99
446
  i += 2;
100
447
  break;
101
448
  case "--dot":
102
- flags.dot = next;
449
+ flags.dot = requireValue(arg, next);
103
450
  i += 2;
104
451
  break;
105
452
  case "--fg":
106
- flags.fg = next ? parseColorFlag(next) : void 0;
453
+ flags.fg = parseColorFlag(requireValue(arg, next));
107
454
  i += 2;
108
455
  break;
109
456
  case "--bg":
110
- flags.bg = next ? parseColorFlag(next) : void 0;
457
+ flags.bg = parseColorFlag(requireValue(arg, next));
111
458
  i += 2;
112
459
  break;
113
460
  case "--eye-color":
114
- flags.eyeColor = next ? parseColorFlag(next) : void 0;
461
+ flags.eyeColor = parseColorFlag(requireValue(arg, next));
115
462
  i += 2;
116
463
  break;
117
464
  case "--dot-color":
118
- flags.dotColor = next ? parseColorFlag(next) : void 0;
465
+ flags.dotColor = parseColorFlag(requireValue(arg, next));
119
466
  i += 2;
120
467
  break;
121
468
  case "--logo":
122
- flags.logo = next;
469
+ flags.logo = requireValue(arg, next);
123
470
  i += 2;
124
471
  break;
125
472
  case "--margin":
126
- flags.margin = next ? Number.parseInt(next, 10) : qrdx.DEFAULT_MARGIN;
473
+ flags.margin = parseIntegerFlag(arg, next, 0);
127
474
  i += 2;
128
475
  break;
129
476
  case "--size":
130
- flags.size = next ? Number.parseInt(next, 10) : 512;
477
+ flags.size = parseIntegerFlag(arg, next, 1);
131
478
  i += 2;
132
479
  break;
133
480
  case "--no-logo":
@@ -135,77 +482,193 @@ function parseFlags(args) {
135
482
  i++;
136
483
  break;
137
484
  case "--template":
138
- flags.template = next;
485
+ flags.template = requireValue(arg, next);
486
+ i += 2;
487
+ break;
488
+ case "--frame":
489
+ flags.frame = requireValue(arg, next);
490
+ i += 2;
491
+ break;
492
+ case "--frame-text":
493
+ flags.frameText = requireValue(arg, next);
494
+ i += 2;
495
+ break;
496
+ case "--frame-color":
497
+ flags.frameColor = requireValue(arg, next);
498
+ i += 2;
499
+ break;
500
+ case "--frame-text-color":
501
+ flags.frameTextColor = requireValue(arg, next);
139
502
  i += 2;
140
503
  break;
141
504
  default:
142
- i++;
505
+ throw new Error(
506
+ `Unknown option "${arg}". Run qrdx --help to see all options.`
507
+ );
143
508
  }
144
509
  }
510
+ if (!(flags.help || flags.version)) validateFlags(flags);
145
511
  return flags;
146
512
  }
513
+ function buildQrProps(value, settings) {
514
+ const { size, logo, template, frame, eyeColor, dotColor } = settings;
515
+ return {
516
+ value,
517
+ level: settings.level,
518
+ bodyPattern: settings.body,
519
+ cornerEyePattern: settings.eye,
520
+ cornerEyeDotPattern: settings.dot,
521
+ fgColor: settings.fg,
522
+ bgColor: settings.bg,
523
+ ...eyeColor ? { eyeColor } : {},
524
+ ...dotColor ? { dotColor } : {},
525
+ margin: settings.margin,
526
+ size,
527
+ ...template ? { templateId: template } : {},
528
+ ...frame ? { frame } : {},
529
+ ...logo ? {
530
+ imageSettings: {
531
+ src: logo,
532
+ height: Math.round(size * 0.2),
533
+ width: Math.round(size * 0.2),
534
+ excavate: true
535
+ }
536
+ } : {}
537
+ };
538
+ }
539
+ function styleFromArgs(args) {
540
+ const flags = parseFlags(args);
541
+ return {
542
+ flags,
543
+ settings: {
544
+ level: flags.level ?? "Q",
545
+ body: flags.body ?? "square",
546
+ eye: flags.eye ?? "square",
547
+ dot: flags.dot ?? "square",
548
+ fg: flags.fg ?? "#000000",
549
+ bg: flags.bg ?? "#ffffff",
550
+ eyeColor: flags.eyeColor,
551
+ dotColor: flags.dotColor,
552
+ margin: flags.margin ?? DEFAULT_MARGIN,
553
+ size: flags.size ?? 512,
554
+ template: flags.template,
555
+ frame: frameFromFlags(flags),
556
+ logo: flags.logo
557
+ }
558
+ };
559
+ }
147
560
  function isCancel2(value) {
148
- return p__namespace.isCancel(value);
561
+ return p.isCancel(value);
149
562
  }
150
563
  function handleCancel() {
151
- p__namespace.cancel("Cancelled.");
564
+ p.cancel("Cancelled.");
565
+ console.error(
566
+ `${DIM}Tip:${RESET} ${TEXT}qrdx generate "https://\u2026" -o qr.svg${RESET} \u2014 local dev: ${TEXT}pnpm qrdx -- generate \u2026${RESET}`
567
+ );
152
568
  process.exit(0);
153
569
  }
154
- function inferFormat(outputPath) {
155
- const ext = path.extname(outputPath).toLowerCase();
570
+ function inferFormat(outputPath, format) {
571
+ const ext = extname(outputPath).toLowerCase();
156
572
  if (ext === ".png") {
157
573
  return "png";
158
574
  }
159
- return "svg";
575
+ if (ext === ".webp") {
576
+ return "webp";
577
+ }
578
+ if (ext === ".eps") {
579
+ return "eps";
580
+ }
581
+ return ext === ".svg" ? "svg" : format ?? "svg";
160
582
  }
161
- async function runGenerate(args) {
583
+ async function runGenerate(args, meta) {
162
584
  const flags = parseFlags(args);
163
- const nonInteractive = !IS_TTY;
585
+ if (meta && flags.help) return meta.help();
586
+ if (meta && flags.version) return meta.version();
587
+ meta?.start?.();
588
+ const nonInteractive = !canUseClack;
164
589
  const hasAdvancedFlags = Boolean(
165
- flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template
590
+ flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template || frameFromFlags(flags)
166
591
  );
167
- p__namespace.intro(`${TEXT}qrdx${RESET} ${DIM}\u2014 QR Code Generator${RESET}`);
592
+ p.intro(`${TEXT}qrdx${RESET} ${DIM}\u2014 QR Code Generator${RESET}`);
593
+ if (canUseClack) {
594
+ process.stdin.resume();
595
+ }
168
596
  let value = flags.value;
169
597
  if (!value) {
170
- if (nonInteractive) {
171
- p__namespace.log.error("No data provided. Pass a URL or text as an argument.");
172
- process.exit(1);
173
- }
174
- const res = await p__namespace.text({
175
- message: "URL or text to encode",
176
- placeholder: "https://qrdx.dev",
177
- validate(v) {
178
- if (!v.trim()) {
179
- return "Please enter a value to encode.";
598
+ if (canUseClack) {
599
+ const promptStart = performance.now();
600
+ const res = await p.text({
601
+ message: "URL or text to encode",
602
+ placeholder: "https://qrdx.dev",
603
+ validate(v) {
604
+ if (!v?.trim()) {
605
+ return "Please enter a value to encode.";
606
+ }
180
607
  }
608
+ });
609
+ if (isCancel2(res)) {
610
+ const instant = performance.now() - promptStart < 120 && process.platform !== "win32" && process.stdout.isTTY && !isCI;
611
+ if (instant) {
612
+ const line = await readLineFromTTY(
613
+ `${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
614
+ );
615
+ if (line?.trim()) {
616
+ value = line.trim();
617
+ } else {
618
+ handleCancel();
619
+ }
620
+ } else {
621
+ handleCancel();
622
+ }
623
+ } else {
624
+ value = res;
181
625
  }
182
- });
183
- if (isCancel2(res)) {
184
- handleCancel();
626
+ } else if (process.stdout.isTTY && !isCI) {
627
+ const line = await readLineFromTTY(
628
+ `${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
629
+ );
630
+ if (line?.trim()) {
631
+ value = line.trim();
632
+ }
633
+ }
634
+ if (!value) {
635
+ console.error(
636
+ 'No data to encode. Example: npx @qrdx/cli generate "https://example.com" -o qr.svg'
637
+ );
638
+ process.exit(1);
185
639
  }
186
- value = res;
187
640
  }
188
641
  let outputPath = flags.output;
189
642
  const size = flags.size ?? 512;
190
- if (!(outputPath || nonInteractive)) {
191
- const pathRes = await p__namespace.text({
192
- message: "Output file (.svg or .png)",
193
- placeholder: "qr.svg",
194
- defaultValue: "qr.svg",
195
- validate(v) {
196
- if (!v.trim()) {
197
- return "Please enter an output path.";
643
+ const defaultOutput = `qr.${flags.format ?? "svg"}`;
644
+ if (!outputPath) {
645
+ if (canUseClack) {
646
+ const pathRes = await p.text({
647
+ message: "Output file (.svg, .png, .webp or .eps)",
648
+ placeholder: defaultOutput,
649
+ defaultValue: defaultOutput,
650
+ validate(v) {
651
+ if (!v?.trim()) {
652
+ return "Please enter an output path.";
653
+ }
198
654
  }
655
+ });
656
+ if (isCancel2(pathRes)) {
657
+ handleCancel();
199
658
  }
200
- });
201
- if (isCancel2(pathRes)) {
202
- handleCancel();
659
+ outputPath = pathRes || defaultOutput;
660
+ } else if (process.stdout.isTTY && !isCI) {
661
+ const pathAns = await readLineFromTTY(
662
+ `Output file ${DIM}(.svg, .png, .webp or .eps, Enter = ${defaultOutput})${RESET}: `
663
+ );
664
+ outputPath = pathAns?.trim() || defaultOutput;
665
+ } else {
666
+ outputPath = defaultOutput;
203
667
  }
204
- outputPath = pathRes || "qr.svg";
205
668
  }
206
669
  let wantAdvanced = hasAdvancedFlags;
207
670
  if (!(hasAdvancedFlags || nonInteractive)) {
208
- const res = await p__namespace.confirm({
671
+ const res = await p.confirm({
209
672
  message: "Advanced customizations?",
210
673
  initialValue: false
211
674
  });
@@ -223,11 +686,12 @@ async function runGenerate(args) {
223
686
  let eyeColor = flags.eyeColor;
224
687
  let dotColor = flags.dotColor;
225
688
  let logo = flags.logo;
226
- let margin = flags.margin ?? qrdx.DEFAULT_MARGIN;
689
+ let margin = flags.margin ?? DEFAULT_MARGIN;
227
690
  let template = flags.template;
691
+ let frame = frameFromFlags(flags);
228
692
  if (wantAdvanced && !nonInteractive) {
229
693
  if (!flags.level) {
230
- const res = await p__namespace.select({
694
+ const res = await p.select({
231
695
  message: "Error correction level",
232
696
  options: [
233
697
  {
@@ -247,9 +711,9 @@ async function runGenerate(args) {
247
711
  level = res;
248
712
  }
249
713
  if (!flags.body) {
250
- const res = await p__namespace.select({
714
+ const res = await p.select({
251
715
  message: "Body dot pattern",
252
- options: qrdx.BODY_PATTERN.map((b) => ({ value: b, label: b })),
716
+ options: BODY_PATTERN.map((b) => ({ value: b, label: b })),
253
717
  initialValue: "square"
254
718
  });
255
719
  if (isCancel2(res)) {
@@ -258,9 +722,9 @@ async function runGenerate(args) {
258
722
  body = res;
259
723
  }
260
724
  if (!flags.eye) {
261
- const res = await p__namespace.select({
725
+ const res = await p.select({
262
726
  message: "Corner eye pattern",
263
- options: qrdx.CORNER_EYE_PATTERNS.map((e) => ({ value: e, label: e })),
727
+ options: CORNER_EYE_PATTERNS.map((e) => ({ value: e, label: e })),
264
728
  initialValue: "square"
265
729
  });
266
730
  if (isCancel2(res)) {
@@ -269,9 +733,9 @@ async function runGenerate(args) {
269
733
  eye = res;
270
734
  }
271
735
  if (!flags.dot) {
272
- const res = await p__namespace.select({
736
+ const res = await p.select({
273
737
  message: "Corner dot pattern",
274
- options: qrdx.CORNER_EYE_DOT_PATTERNS.map((d) => ({ value: d, label: d })),
738
+ options: CORNER_EYE_DOT_PATTERNS.map((d) => ({ value: d, label: d })),
275
739
  initialValue: "square"
276
740
  });
277
741
  if (isCancel2(res)) {
@@ -280,13 +744,13 @@ async function runGenerate(args) {
280
744
  dot = res;
281
745
  }
282
746
  if (!flags.fg) {
283
- const res = await p__namespace.text({
747
+ const res = await p.text({
284
748
  message: `Foreground color ${DIM}(hex or JSON gradient)${RESET}`,
285
749
  placeholder: "#000000",
286
750
  defaultValue: "#000000",
287
751
  validate(v) {
288
752
  if (!v) {
289
- return void 0;
753
+ return;
290
754
  }
291
755
  if (v.trim().startsWith("{")) {
292
756
  try {
@@ -294,7 +758,7 @@ async function runGenerate(args) {
294
758
  } catch {
295
759
  return "Enter a valid hex color or JSON gradient object";
296
760
  }
297
- return void 0;
761
+ return;
298
762
  }
299
763
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
300
764
  return "Enter a valid hex color (e.g. #000000) or JSON gradient";
@@ -307,13 +771,13 @@ async function runGenerate(args) {
307
771
  fg = parseColorFlag(res || "#000000");
308
772
  }
309
773
  if (!flags.bg) {
310
- const res = await p__namespace.text({
774
+ const res = await p.text({
311
775
  message: `Background color ${DIM}(hex or JSON gradient)${RESET}`,
312
776
  placeholder: "#ffffff",
313
777
  defaultValue: "#ffffff",
314
778
  validate(v) {
315
779
  if (!v) {
316
- return void 0;
780
+ return;
317
781
  }
318
782
  if (v.trim().startsWith("{")) {
319
783
  try {
@@ -321,7 +785,7 @@ async function runGenerate(args) {
321
785
  } catch {
322
786
  return "Enter a valid hex color or JSON gradient object";
323
787
  }
324
- return void 0;
788
+ return;
325
789
  }
326
790
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
327
791
  return "Enter a valid hex color (e.g. #ffffff) or JSON gradient";
@@ -334,12 +798,12 @@ async function runGenerate(args) {
334
798
  bg = parseColorFlag(res || "#ffffff");
335
799
  }
336
800
  if (eyeColor === void 0) {
337
- const res = await p__namespace.text({
801
+ const res = await p.text({
338
802
  message: `Corner eye color ${DIM}(leave blank to match foreground)${RESET}`,
339
803
  placeholder: "leave blank",
340
804
  validate(v) {
341
805
  if (!v) {
342
- return void 0;
806
+ return;
343
807
  }
344
808
  if (v.trim().startsWith("{")) {
345
809
  try {
@@ -347,7 +811,7 @@ async function runGenerate(args) {
347
811
  } catch {
348
812
  return "Enter a valid hex color or JSON gradient object";
349
813
  }
350
- return void 0;
814
+ return;
351
815
  }
352
816
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
353
817
  return "Enter a valid hex color or leave blank";
@@ -361,12 +825,12 @@ async function runGenerate(args) {
361
825
  eyeColor = eyeVal ? parseColorFlag(eyeVal) : void 0;
362
826
  }
363
827
  if (dotColor === void 0) {
364
- const res = await p__namespace.text({
828
+ const res = await p.text({
365
829
  message: `Corner dot color ${DIM}(leave blank to match foreground)${RESET}`,
366
830
  placeholder: "leave blank",
367
831
  validate(v) {
368
832
  if (!v) {
369
- return void 0;
833
+ return;
370
834
  }
371
835
  if (v.trim().startsWith("{")) {
372
836
  try {
@@ -374,7 +838,7 @@ async function runGenerate(args) {
374
838
  } catch {
375
839
  return "Enter a valid hex color or JSON gradient object";
376
840
  }
377
- return void 0;
841
+ return;
378
842
  }
379
843
  if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
380
844
  return "Enter a valid hex color or leave blank";
@@ -388,8 +852,8 @@ async function runGenerate(args) {
388
852
  dotColor = dotVal ? parseColorFlag(dotVal) : void 0;
389
853
  }
390
854
  if (!flags.template) {
391
- const templateIds = qrdx.getTemplateIds();
392
- const res = await p__namespace.select({
855
+ const templateIds = getTemplateIds();
856
+ const res = await p.select({
393
857
  message: `Template ${DIM}(decorative wrapper around the QR code)${RESET}`,
394
858
  options: [
395
859
  { value: "", label: "none" },
@@ -402,8 +866,37 @@ async function runGenerate(args) {
402
866
  }
403
867
  template = res || void 0;
404
868
  }
869
+ if (flags.frame === void 0 && !frame) {
870
+ const res = await p.select({
871
+ message: `Frame ${DIM}(call-to-action label around the code)${RESET}`,
872
+ options: FRAME_STYLES.map((style) => ({ value: style, label: style })),
873
+ initialValue: "none"
874
+ });
875
+ if (isCancel2(res)) {
876
+ handleCancel();
877
+ }
878
+ if (res !== "none") {
879
+ const text2 = await p.text({
880
+ message: `Frame text ${DIM}(up to ${FRAME_TEXT_MAX_LENGTH} characters)${RESET}`,
881
+ placeholder: DEFAULT_FRAME_TEXT,
882
+ defaultValue: DEFAULT_FRAME_TEXT,
883
+ validate(v) {
884
+ if (v && v.length > FRAME_TEXT_MAX_LENGTH) {
885
+ return `Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer`;
886
+ }
887
+ }
888
+ });
889
+ if (isCancel2(text2)) {
890
+ handleCancel();
891
+ }
892
+ frame = {
893
+ style: res,
894
+ text: text2 || DEFAULT_FRAME_TEXT
895
+ };
896
+ }
897
+ }
405
898
  if (logo === void 0 && !flags.noLogo) {
406
- const addLogo = await p__namespace.confirm({
899
+ const addLogo = await p.confirm({
407
900
  message: "Add a logo to the center?",
408
901
  initialValue: false
409
902
  });
@@ -411,16 +904,14 @@ async function runGenerate(args) {
411
904
  handleCancel();
412
905
  }
413
906
  if (addLogo) {
414
- const res = await p__namespace.text({
907
+ const res = await p.text({
415
908
  message: "Logo URL",
416
909
  placeholder: "https://qrdx.dev/logo.png",
417
910
  validate(v) {
418
- if (!v.trim()) {
911
+ if (!v?.trim()) {
419
912
  return "Please enter a URL for the logo.";
420
913
  }
421
- try {
422
- new URL(v);
423
- } catch {
914
+ if (!URL.canParse(v)) {
424
915
  return "Please enter a valid URL.";
425
916
  }
426
917
  }
@@ -432,10 +923,10 @@ async function runGenerate(args) {
432
923
  }
433
924
  }
434
925
  if (flags.margin === void 0) {
435
- const res = await p__namespace.text({
926
+ const res = await p.text({
436
927
  message: "Quiet zone margin (modules)",
437
- placeholder: String(qrdx.DEFAULT_MARGIN),
438
- defaultValue: String(qrdx.DEFAULT_MARGIN),
928
+ placeholder: String(DEFAULT_MARGIN),
929
+ defaultValue: String(DEFAULT_MARGIN),
439
930
  validate(v) {
440
931
  if (v && Number.isNaN(Number.parseInt(v, 10))) {
441
932
  return "Enter a number";
@@ -448,57 +939,247 @@ async function runGenerate(args) {
448
939
  if (isCancel2(res)) {
449
940
  handleCancel();
450
941
  }
451
- margin = Number.parseInt(res || String(qrdx.DEFAULT_MARGIN), 10);
942
+ margin = Number.parseInt(res || String(DEFAULT_MARGIN), 10);
452
943
  }
453
944
  }
454
- const spinner2 = p__namespace.spinner();
945
+ const spinner2 = p.spinner();
455
946
  spinner2.start("Generating QR code...");
456
947
  try {
457
- const qrProps = {
458
- value,
948
+ const qrProps = buildQrProps(value, {
459
949
  level,
460
- bodyPattern: body,
461
- cornerEyePattern: eye,
462
- cornerEyeDotPattern: dot,
463
- fgColor: fg,
464
- bgColor: bg,
465
- ...eyeColor ? { eyeColor } : {},
466
- ...dotColor ? { dotColor } : {},
950
+ body,
951
+ eye,
952
+ dot,
953
+ fg,
954
+ bg,
955
+ eyeColor,
956
+ dotColor,
467
957
  margin,
468
958
  size,
469
- ...template ? { templateId: template } : {},
470
- ...logo ? {
471
- imageSettings: {
472
- src: logo,
473
- height: Math.round(size * 0.2),
474
- width: Math.round(size * 0.2),
475
- excavate: true
476
- }
477
- } : {}
478
- };
959
+ template,
960
+ frame,
961
+ logo
962
+ });
479
963
  if (outputPath) {
480
- const format = inferFormat(outputPath);
964
+ const format = inferFormat(outputPath, flags.format);
481
965
  await saveOutput({ ...qrProps, outputPath, format });
966
+ const dimensions = getFrameDimensions(size, frame);
482
967
  spinner2.stop(
483
- `${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${size}\xD7${size})${RESET}`
968
+ `${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${Math.round(dimensions.width)}\xD7${Math.round(dimensions.height)})${RESET}`
484
969
  );
485
970
  } else {
486
971
  spinner2.stop("QR code ready");
487
972
  }
488
973
  } catch (err) {
489
- spinner2.stop("Failed to generate QR code");
974
+ spinner2.error("Failed to generate QR code");
490
975
  const msg = err instanceof Error ? err.message : String(err);
491
- p__namespace.log.error(msg);
976
+ p.log.error(msg);
492
977
  process.exit(1);
493
978
  }
494
- p__namespace.outro(`${DIM}Done!${RESET}`);
979
+ p.outro(`${DIM}Done!${RESET}`);
495
980
  }
496
981
 
497
- // src/index.ts
982
+ // src/commands/batch.ts
498
983
  var RESET2 = "\x1B[0m";
499
- var BOLD = "\x1B[1m";
500
- var DIM2 = "\x1B[38;5;102m";
501
984
  var TEXT2 = "\x1B[38;5;145m";
985
+ var GREEN2 = "\x1B[38;5;114m";
986
+ var DIM2 = "\x1B[38;5;102m";
987
+ var BATCH_FLAGS = /* @__PURE__ */ new Set([
988
+ "-o",
989
+ "--out",
990
+ "--output",
991
+ "--value-column",
992
+ "--name-column",
993
+ "--jobs"
994
+ ]);
995
+ function parseBatchArgs(args) {
996
+ const options = { out: "qr-codes", header: true, style: [] };
997
+ for (let i = 0; i < args.length; i++) {
998
+ const arg = args[i];
999
+ const next = args[i + 1];
1000
+ if (arg === "-h" || arg === "--help") {
1001
+ options.help = true;
1002
+ } else if (arg === "-v" || arg === "--version") {
1003
+ options.version = true;
1004
+ } else if (arg === "--no-header") {
1005
+ options.header = false;
1006
+ } else if (BATCH_FLAGS.has(arg)) {
1007
+ if (next === void 0 || /^--?[a-z]/i.test(next)) {
1008
+ throw new Error(`${arg} needs a value. Run qrdx batch --help.`);
1009
+ }
1010
+ i++;
1011
+ if (arg === "--value-column") options.valueColumn = next;
1012
+ else if (arg === "--name-column") options.nameColumn = next;
1013
+ else if (arg === "--jobs") {
1014
+ const jobs = Number(next);
1015
+ if (!(Number.isInteger(jobs) && jobs >= 1 && jobs <= 64)) {
1016
+ throw new Error(
1017
+ `Invalid --jobs "${next}". Use a number from 1 to 64.`
1018
+ );
1019
+ }
1020
+ options.jobs = jobs;
1021
+ } else options.out = next;
1022
+ } else if (!arg.startsWith("-") && options.file === void 0) {
1023
+ options.file = arg;
1024
+ } else {
1025
+ options.style.push(arg);
1026
+ if (next !== void 0 && !/^--?[a-z]/i.test(next) && !isSwitch(arg)) {
1027
+ options.style.push(next);
1028
+ i++;
1029
+ }
1030
+ }
1031
+ }
1032
+ return options;
1033
+ }
1034
+ var isSwitch = (arg) => arg === "--no-logo";
1035
+ function readableError(error) {
1036
+ const message = error instanceof Error ? error.message : String(error);
1037
+ return /too long|data too big|overflow/i.test(message) ? "Too much data for one QR code" : message || "Could not generate";
1038
+ }
1039
+ async function logoDataUrl(src) {
1040
+ if (src.startsWith("data:")) return src;
1041
+ let bytes;
1042
+ let type = "image/png";
1043
+ if (/^https?:\/\//i.test(src)) {
1044
+ const response = await fetch(src);
1045
+ if (!response.ok) {
1046
+ throw new Error(`Could not download logo (${response.status}): ${src}`);
1047
+ }
1048
+ type = response.headers.get("content-type")?.split(";")[0] ?? type;
1049
+ bytes = Buffer.from(await response.arrayBuffer());
1050
+ } else {
1051
+ bytes = readFileSync(src);
1052
+ if (/\.svg$/i.test(src)) type = "image/svg+xml";
1053
+ else if (/\.jpe?g$/i.test(src)) type = "image/jpeg";
1054
+ else if (/\.webp$/i.test(src)) type = "image/webp";
1055
+ }
1056
+ return `data:${type};base64,${bytes.toString("base64")}`;
1057
+ }
1058
+ async function runBatch(args, meta) {
1059
+ const options = parseBatchArgs(args);
1060
+ if (options.help) return meta.help();
1061
+ if (options.version) return meta.version();
1062
+ if (!options.file) {
1063
+ throw new Error(
1064
+ "Give a CSV file: qrdx batch codes.csv -o qr-codes (run qrdx batch --help)."
1065
+ );
1066
+ }
1067
+ const { flags, settings } = styleFromArgs(options.style);
1068
+ if (flags.value !== void 0) {
1069
+ throw new Error(
1070
+ `Unexpected argument "${flags.value}". qrdx batch takes one CSV file.`
1071
+ );
1072
+ }
1073
+ const format = flags.format ?? "svg";
1074
+ if (settings.logo) settings.logo = await logoDataUrl(settings.logo);
1075
+ const out = resolve(options.out);
1076
+ mkdirSync(out, { recursive: true });
1077
+ const jobs = options.jobs ?? (format === "svg" ? 4 : 8);
1078
+ const allocate = createFilenameAllocator();
1079
+ const failures = [];
1080
+ const running = /* @__PURE__ */ new Set();
1081
+ let columns = options.header ? null : [];
1082
+ let valueColumn = 0;
1083
+ let nameColumn = -1;
1084
+ let line = 0;
1085
+ let saved = 0;
1086
+ let index = 0;
1087
+ const started = performance.now();
1088
+ const tty = process.stderr.isTTY === true;
1089
+ let lastReport = 0;
1090
+ const report = (force = false) => {
1091
+ const now = performance.now();
1092
+ if (!force && now - lastReport < (tty ? 100 : 5e3)) return;
1093
+ lastReport = now;
1094
+ const text2 = `${index.toLocaleString("en-US")} rows \xB7 ${saved.toLocaleString("en-US")} saved${failures.length > 0 ? ` \xB7 ${failures.length.toLocaleString("en-US")} skipped` : ""}`;
1095
+ process.stderr.write(tty ? `\r${DIM2}${text2}${RESET2}\x1B[K` : `${text2}
1096
+ `);
1097
+ };
1098
+ const render = (value, file, row) => {
1099
+ const task = saveOutput({
1100
+ ...buildQrProps(value, settings),
1101
+ outputPath: join(out, file),
1102
+ format
1103
+ }).then(() => {
1104
+ saved++;
1105
+ }).catch((error) => {
1106
+ failures.push({ row, value, error: readableError(error) });
1107
+ }).finally(() => {
1108
+ running.delete(task);
1109
+ report();
1110
+ });
1111
+ running.add(task);
1112
+ };
1113
+ for await (const row of readCsvRows(options.file)) {
1114
+ line++;
1115
+ if (columns === null) {
1116
+ columns = row.map((name, i) => name.trim() || `Column ${i + 1}`);
1117
+ valueColumn = options.valueColumn ? resolveColumn(options.valueColumn, columns, "--value-column") : guessValueColumn(columns);
1118
+ nameColumn = options.nameColumn ? resolveColumn(options.nameColumn, columns, "--name-column") : guessFilenameColumn(columns, valueColumn);
1119
+ continue;
1120
+ }
1121
+ if (line === 1 && !options.header) {
1122
+ const width = row.length;
1123
+ const numbered = Array.from({ length: width }, (_, i) => String(i + 1));
1124
+ if (options.valueColumn) {
1125
+ valueColumn = resolveColumn(
1126
+ options.valueColumn,
1127
+ numbered,
1128
+ "--value-column"
1129
+ );
1130
+ }
1131
+ if (options.nameColumn) {
1132
+ nameColumn = resolveColumn(
1133
+ options.nameColumn,
1134
+ numbered,
1135
+ "--name-column"
1136
+ );
1137
+ }
1138
+ }
1139
+ index++;
1140
+ const value = row[valueColumn]?.trim() ?? "";
1141
+ if (!value) {
1142
+ failures.push({ row: line, value, error: "Empty value" });
1143
+ report();
1144
+ continue;
1145
+ }
1146
+ const base = sanitizeFilename(nameColumn >= 0 ? row[nameColumn] ?? "" : "") || `qr-${String(index).padStart(5, "0")}`;
1147
+ render(value, allocate(base, format), line);
1148
+ if (running.size >= jobs) await Promise.race(running);
1149
+ }
1150
+ await Promise.all(running);
1151
+ report(true);
1152
+ if (tty) process.stderr.write("\n");
1153
+ if (failures.length > 0) {
1154
+ failures.sort((a, b) => a.row - b.row);
1155
+ writeFileSync(
1156
+ join(out, "errors.csv"),
1157
+ `${["row,value,error", ...failures.map((f) => [f.row, f.value, f.error].map(csvCell).join(","))].join("\n")}
1158
+ `,
1159
+ "utf-8"
1160
+ );
1161
+ }
1162
+ const seconds = ((performance.now() - started) / 1e3).toFixed(1);
1163
+ if (saved === 0) {
1164
+ throw new Error(
1165
+ index === 0 ? "The CSV has no rows with codes." : `No codes could be made. See ${join(out, "errors.csv")}.`
1166
+ );
1167
+ }
1168
+ console.log(
1169
+ `${GREEN2}\u2714${RESET2} Saved ${saved.toLocaleString("en-US")} ${format.toUpperCase()} codes to ${TEXT2}${out}${RESET2} ${DIM2}in ${seconds} s${RESET2}`
1170
+ );
1171
+ if (failures.length > 0) {
1172
+ console.log(
1173
+ ` ${failures.length.toLocaleString("en-US")} rows skipped: ${TEXT2}${join(out, "errors.csv")}${RESET2}`
1174
+ );
1175
+ }
1176
+ }
1177
+
1178
+ // src/index.ts
1179
+ var RESET3 = "\x1B[0m";
1180
+ var BOLD = "\x1B[1m";
1181
+ var DIM3 = "\x1B[38;5;102m";
1182
+ var TEXT3 = "\x1B[38;5;145m";
502
1183
  var GRAYS = [
503
1184
  "\x1B[38;5;250m",
504
1185
  "\x1B[38;5;248m",
@@ -517,8 +1198,8 @@ var LOGO_LINES = [
517
1198
  ];
518
1199
  function getVersion() {
519
1200
  try {
520
- const pkgPath = path.join(__dirname, "..", "package.json");
521
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
1201
+ const pkgPath = join(import.meta.dirname, "..", "package.json");
1202
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
522
1203
  return pkg.version;
523
1204
  } catch {
524
1205
  return "0.0.1";
@@ -528,84 +1209,116 @@ var VERSION = getVersion();
528
1209
  function showLogo() {
529
1210
  console.log();
530
1211
  LOGO_LINES.forEach((line, i) => {
531
- console.log(`${GRAYS[i] ?? GRAYS[5]}${line}${RESET2}`);
1212
+ console.log(`${GRAYS[i] ?? GRAYS[5]}${line}${RESET3}`);
532
1213
  });
533
1214
  }
534
1215
  function showBanner() {
535
1216
  showLogo();
536
1217
  console.log();
537
- console.log(`${DIM2}QR code generator for developers${RESET2}`);
1218
+ console.log(`${DIM3}QR code generator for developers${RESET3}`);
538
1219
  console.log();
539
1220
  console.log(
540
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx generate${RESET2} ${DIM2}Generate a QR code interactively${RESET2}`
1221
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx generate${RESET3} ${DIM3}Generate a QR code interactively${RESET3}`
541
1222
  );
542
1223
  console.log(
543
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx generate${RESET2} ${DIM2}<url>${RESET2} ${DIM2}Generate from URL${RESET2}`
1224
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx generate${RESET3} ${DIM3}<url>${RESET3} ${DIM3}Generate from URL${RESET3}`
1225
+ );
1226
+ console.log(
1227
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx batch${RESET3} ${DIM3}<file.csv>${RESET3} ${DIM3}A code for every row of a CSV${RESET3}`
544
1228
  );
545
1229
  console.log();
546
1230
  console.log(
547
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx --help${RESET2} ${DIM2}Show all commands and options${RESET2}`
1231
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx --help${RESET3} ${DIM3}Show all commands and options${RESET3}`
548
1232
  );
549
1233
  console.log(
550
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx --version${RESET2} ${DIM2}Show version${RESET2}`
1234
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx --version${RESET3} ${DIM3}Show version${RESET3}`
551
1235
  );
552
1236
  console.log();
553
- console.log(` ${DIM2}try:${RESET2} npx @qrdx/cli generate "https://qrdx.dev"`);
1237
+ console.log(` ${DIM3}try:${RESET3} npx @qrdx/cli generate "https://qrdx.dev"`);
554
1238
  console.log();
555
1239
  }
556
1240
  function showHelp() {
557
1241
  showLogo();
558
1242
  console.log(`
559
- ${BOLD}Usage:${RESET2} qrdx <command> [options]
1243
+ ${BOLD}Usage:${RESET3} qrdx <command> [options]
560
1244
 
561
- ${BOLD}Commands:${RESET2}
1245
+ ${BOLD}Commands:${RESET3}
562
1246
  generate, gen, g Generate a QR code (fully interactive or via flags)
1247
+ batch, b A static QR code for every row of a CSV file
563
1248
 
564
- ${BOLD}Generate Options:${RESET2}
1249
+ ${BOLD}Generate Options:${RESET3}
565
1250
  <data> URL or text to encode (positional)
566
- -o, --output Output file path (.svg or .png)
567
- -l, --level Error correction: L, M, Q, H ${DIM2}(default: Q)${RESET2}
568
- --body Body dot pattern:
569
- ${DIM2}square, circle, circle-large, diamond,${RESET2}
570
- ${DIM2}circle-mixed, pacman, rounded, small-square, vertical-line${RESET2}
571
- --eye Corner eye pattern:
572
- ${DIM2}square, rounded, gear, circle, diya,${RESET2}
573
- ${DIM2}extra-rounded, message, pointy, curly${RESET2}
574
- --dot Corner dot pattern:
575
- ${DIM2}square, rounded, circle, diamond, message,${RESET2}
576
- ${DIM2}message-reverse, diya, diya-reverse,${RESET2}
577
- ${DIM2}rounded-triangle, star, banner${RESET2}
578
- --fg Foreground color \u2014 hex or JSON gradient ${DIM2}(default: #000000)${RESET2}
579
- --bg Background color \u2014 hex or JSON gradient ${DIM2}(default: #ffffff)${RESET2}
580
- --eye-color Corner eye color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
581
- --dot-color Corner dot color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
582
- --logo Logo URL for center image
583
- --margin Quiet zone margin in modules ${DIM2}(default: 0)${RESET2}
584
- --size Output pixel size for SVG/PNG ${DIM2}(default: 512)${RESET2}
1251
+ -o, --output Output file path (.svg, .png, .webp or .eps)
1252
+ --format Output format when the file name has no extension:
1253
+ ${DIM3}svg, png, webp, eps${RESET3} ${DIM3}(WebP is lossless; EPS is vector,${RESET3}
1254
+ ${DIM3}--size = width in points)${RESET3}
1255
+ -l, --level Error correction: L, M, Q, H ${DIM3}(default: Q)${RESET3}
1256
+ --body Body dot pattern, e.g.:
1257
+ ${DIM3}square, rounded, circle, circle-large, liquid,${RESET3}
1258
+ ${DIM3}classy, classy-rounded, leaf, hexagon, star, heart${RESET3}
1259
+ --eye Corner eye pattern, e.g.:
1260
+ ${DIM3}square, rounded, extra-rounded, gear, leaf, drop,${RESET3}
1261
+ ${DIM3}squircle, octagon, shield, petal${RESET3}
1262
+ --dot Corner dot pattern, e.g.:
1263
+ ${DIM3}square, rounded, circle, leaf, drop, squircle,${RESET3}
1264
+ ${DIM3}octagon, flower, heart, plus, dots${RESET3}
1265
+ ${DIM3}All options: https://qrdx.dev/docs/styles${RESET3}
1266
+ --fg Foreground color \u2014 hex or JSON gradient ${DIM3}(default: #000000)${RESET3}
1267
+ --bg Background color \u2014 hex or JSON gradient ${DIM3}(default: #ffffff)${RESET3}
1268
+ --eye-color Corner eye color \u2014 hex or JSON gradient ${DIM3}(default: matches --fg)${RESET3}
1269
+ --dot-color Corner dot color \u2014 hex or JSON gradient ${DIM3}(default: matches --fg)${RESET3}
1270
+ --logo Logo URL for center image (use with --level H)
1271
+ --no-logo Skip the logo prompt in interactive mode
1272
+ --margin Quiet zone margin in modules ${DIM3}(default: 0)${RESET3}
1273
+ --size Output pixel size for SVG/PNG/WebP ${DIM3}(default: 512)${RESET3}
585
1274
  --template Decorative template wrapper:
586
- ${DIM2}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET2}
1275
+ ${DIM3}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET3}
1276
+ --frame Call-to-action frame (adds space around the code), e.g.:
1277
+ ${DIM3}none, banner-bottom, banner-top, rounded-border,${RESET3}
1278
+ ${DIM3}speech-bubble, polaroid, phone, ticket, sticker${RESET3}
1279
+ ${DIM3}All frames: https://qrdx.dev/docs/styles${RESET3}
1280
+ --frame-text Frame label, up to 24 characters ${DIM3}(default: Scan me; "" hides it)${RESET3}
1281
+ --frame-color Frame color \u2014 hex ${DIM3}(default: #000000)${RESET3}
1282
+ --frame-text-color Frame label color \u2014 hex ${DIM3}(default: white on banners)${RESET3}
1283
+ ${DIM3}Frame text/colors without --frame use banner-bottom${RESET3}
1284
+
1285
+ ${BOLD}Batch Options:${RESET3} ${DIM3}qrdx batch <file.csv> [options]${RESET3}
1286
+ -o, --out Folder for the codes ${DIM3}(default: qr-codes)${RESET3}
1287
+ --format svg, png or eps ${DIM3}(default: svg; PNG needs sharp)${RESET3}
1288
+ --value-column Column with each code's content, by name or number
1289
+ ${DIM3}(default: value, url, link, data, content, text or qr; else the first)${RESET3}
1290
+ --name-column Column with file names, by name or number
1291
+ ${DIM3}(default: file name, name, label, id, title or sku; else qr-00001, ...)${RESET3}
1292
+ --no-header The first row is a code, not column names
1293
+ --jobs Codes rendered at a time ${DIM3}(default: 4 for SVG, 8 otherwise)${RESET3}
1294
+ Every style option above applies to all codes. Rows that fail are listed
1295
+ in errors.csv in the output folder. No row limit; nothing is uploaded.
587
1296
 
588
- ${BOLD}Gradient Colors:${RESET2}
1297
+ ${BOLD}Gradient Colors:${RESET3}
589
1298
  Pass a JSON object to --fg / --bg / --eye-color / --dot-color for gradients:
590
- ${DIM2}Linear: '{"type":"linear","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":45}'${RESET2}
591
- ${DIM2}Radial: '{"type":"radial","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}]}'${RESET2}
1299
+ ${DIM3}Linear: '{"type":"linear","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":45}'${RESET3}
1300
+ ${DIM3}Radial: '{"type":"radial","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}]}'${RESET3}
1301
+ ${DIM3}Angular: '{"type":"angular","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":0}'${RESET3}
592
1302
 
593
- ${BOLD}Global Options:${RESET2}
1303
+ ${BOLD}Global Options:${RESET3}
594
1304
  --help, -h Show this help message
595
1305
  --version, -v Show version number
596
1306
 
597
- ${BOLD}Examples:${RESET2}
598
- ${DIM2}$${RESET2} qrdx generate
599
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev"
600
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.svg
601
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot star
602
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg "#ff0000" --bg "#ffffff" --size 1024
603
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --logo "https://qrdx.dev/logo.png" --level H -o branded.svg
604
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
605
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Halloween --body circle --eye gear -o halloween.svg
606
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}' -o gradient.svg
1307
+ ${BOLD}Examples:${RESET3}
1308
+ ${DIM3}$${RESET3} qrdx generate
1309
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev"
1310
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" -o qr.svg
1311
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot flower
1312
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" -o qr.eps --size 144
1313
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --fg "#ff0000" --bg "#ffffff" --size 1024
1314
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --logo "https://qrdx.dev/logo.png" --level H -o branded.svg
1315
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
1316
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --template Halloween --body circle --eye gear -o halloween.svg
1317
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
1318
+ ${DIM3}$${RESET3} qrdx batch products.csv -o codes --format png --size 1024 --body circle
1319
+ ${DIM3}$${RESET3} qrdx generate "https://qrdx.dev" --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}' -o gradient.svg
607
1320
 
608
- Visit ${TEXT2}https://qrdx.dev${RESET2} for more.
1321
+ Visit ${TEXT3}https://qrdx.dev${RESET3} for more.
609
1322
  `);
610
1323
  }
611
1324
  async function main() {
@@ -616,13 +1329,23 @@ async function main() {
616
1329
  }
617
1330
  const command = args[0];
618
1331
  const restArgs = args.slice(1);
1332
+ const generateMeta = {
1333
+ help: showHelp,
1334
+ version: () => console.log(VERSION),
1335
+ start: () => {
1336
+ showLogo();
1337
+ console.log();
1338
+ }
1339
+ };
619
1340
  switch (command) {
620
1341
  case "generate":
621
1342
  case "gen":
622
1343
  case "g":
623
- showLogo();
624
- console.log();
625
- await runGenerate(restArgs);
1344
+ await runGenerate(restArgs, generateMeta);
1345
+ break;
1346
+ case "batch":
1347
+ case "b":
1348
+ await runBatch(restArgs, generateMeta);
626
1349
  break;
627
1350
  case "--help":
628
1351
  case "-h":
@@ -635,17 +1358,16 @@ async function main() {
635
1358
  break;
636
1359
  default:
637
1360
  if (command.startsWith("-")) {
638
- console.log(`Unknown command: ${command}`);
639
- console.log(`Run ${BOLD}qrdx --help${RESET2} for usage.`);
1361
+ console.error(`Unknown command: ${command}`);
1362
+ console.error(`Run ${BOLD}qrdx --help${RESET3} for usage.`);
1363
+ process.exitCode = 1;
640
1364
  } else {
641
- showLogo();
642
- console.log();
643
- await runGenerate(args);
1365
+ await runGenerate(args, generateMeta);
644
1366
  }
645
1367
  }
646
1368
  }
647
1369
  main().catch((err) => {
648
1370
  const msg = err instanceof Error ? err.message : String(err);
649
- console.error(`\x1B[38;5;203m\u2717 ${msg}${RESET2}`);
1371
+ console.error(`\x1B[38;5;203m\u2717 ${msg}${RESET3}`);
650
1372
  process.exit(1);
651
1373
  });