@qrdx/cli 0.0.5 → 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 (3) hide show
  1. package/README.md +19 -6
  2. package/dist/index.js +496 -99
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # @qrdx/cli
2
2
 
3
- QR code generator CLI for developers. Generate styled, scannable QR codes as SVG, PNG or vector EPS. It runs non-interactively in scripts and CI, and guides you with prompts when run in a terminal. It renders with the same [`qrdx`](https://www.npmjs.com/package/qrdx) library as the [QRdx](https://qrdx.dev) web app.
3
+ QR code generator CLI for developers. Generate styled, scannable QR codes as SVG, PNG, WebP or vector EPS. It runs non-interactively in scripts and CI, and guides you with prompts when run in a terminal. It renders with the same [`qrdx`](https://www.npmjs.com/package/qrdx) library as the [QRdx](https://qrdx.dev) web app.
4
4
 
5
5
  > [!NOTE]
6
- > This README documents `@qrdx/cli` 0.0.5, the version in this repository. npm still has 0.0.4, so `npx @qrdx/cli` runs 0.0.4 until 0.0.5 is published. 0.0.4 has the qrdx 0.0.46 styles and no `--format` or `--frame` options, and it writes SVG into a file named `qr.eps`. For EPS today, use the [image API](https://qrdx.dev/docs/developers/image-api): `curl -o qr.eps "https://api.qrdx.dev/v1/qr?data=https://example.com&format=eps"`. For frames, download from the [playground](https://qrdx.dev/playground). Run `npm view @qrdx/cli version` to see what npm installs.
6
+ > This README documents `@qrdx/cli` 0.0.6, the version in this repository. WebP output is new in 0.0.6; 0.0.5 has every other option here. Run `npm view @qrdx/cli version` to see what npm installs. Until it is 0.0.6, get WebP from the [image API](https://qrdx.dev/docs/developers/image-api): `curl -o qr.webp "https://api.qrdx.dev/v1/qr?data=https://example.com&format=webp"`.
7
7
 
8
8
  ## Install
9
9
 
@@ -20,7 +20,7 @@ npm install -g @qrdx/cli
20
20
  qrdx generate "https://example.com" -o qr.svg
21
21
  ```
22
22
 
23
- PNG output uses [sharp](https://sharp.pixelplumbing.com). It is an optional dependency that npm installs automatically when your platform supports it; if it is missing, SVG and EPS output still work and the CLI tells you how to fix PNG.
23
+ PNG and WebP output use [sharp](https://sharp.pixelplumbing.com). It is an optional dependency that npm installs automatically when your platform supports it; if it is missing, SVG and EPS output still work and the CLI tells you how to fix PNG and WebP.
24
24
 
25
25
  > The `qrdx` npm package is the React library. The CLI lives only in `@qrdx/cli`.
26
26
 
@@ -36,9 +36,9 @@ qrdx generate <data> [options]
36
36
 
37
37
  | Flag | Short | Default | Description |
38
38
  |------|-------|---------|-------------|
39
- | `--output` | `-o` | `qr.svg` | Output path; the extension sets the format (`.svg`, `.png` or `.eps`) |
40
- | `--format` | | from the extension | `svg`, `png` or `eps`, for a file name without an extension |
41
- | `--size` | | `512` | Output size: pixels for SVG and PNG, points (72 per inch) for EPS |
39
+ | `--output` | `-o` | `qr.svg` | Output path; the extension sets the format (`.svg`, `.png`, `.webp` or `.eps`) |
40
+ | `--format` | | from the extension | `svg`, `png`, `webp` or `eps`, for a file name without an extension |
41
+ | `--size` | | `512` | Output size: pixels for SVG, PNG and WebP, points (72 per inch) for EPS |
42
42
  | `--level` | `-l` | `Q` | Error correction: `L` `M` `Q` `H` |
43
43
  | `--body` | | `square` | Body dot pattern |
44
44
  | `--eye` | | `square` | Corner eye pattern |
@@ -69,6 +69,9 @@ qrdx generate "https://qrdx.dev" -o qr.svg
69
69
  # High-res PNG for print
70
70
  qrdx generate "https://qrdx.dev" -o qr.png --size 2048 --level H
71
71
 
72
+ # Lossless WebP for a website: the PNG's pixels in a smaller file
73
+ qrdx generate "https://qrdx.dev" -o qr.webp --size 1024
74
+
72
75
  # Vector EPS for a print shop or Illustrator, 2 inches wide
73
76
  qrdx generate "https://qrdx.dev" -o qr.eps --size 144
74
77
 
@@ -100,6 +103,16 @@ qrdx generate "WIFI:T:WPA;S:MyNetwork;P:MyPassword;;" -o wifi.svg
100
103
  qrdx generate
101
104
  ```
102
105
 
106
+ ## Batch: a code for every row of a CSV
107
+
108
+ ```bash
109
+ qrdx batch products.csv -o codes --format png --size 1024 --body circle
110
+ ```
111
+
112
+ `qrdx batch` (or `qrdx b`) reads a CSV and writes one static QR code per row into a folder, with the same columns as the free [bulk generator](https://qrdx.dev/tools/bulk). The content comes from a column named `value`, `url`, `link`, `data`, `content`, `text` or `qr` (else the first column), and file names from one named `file name`, `name`, `label`, `id`, `title` or `sku` (else `qr-00001.svg`, `qr-00002.svg` and so on). Pick others with `--value-column` and `--name-column`, by header name or column number, and use `--no-header` when the first row is a code.
113
+
114
+ Every style option of `generate` applies to all the codes. The file is read as a stream and each code is written as soon as it's made, so there's no row limit; nothing is uploaded and no account is needed. Rows that can't be encoded are skipped and listed, with the reason, in `errors.csv` in the output folder. `--jobs` sets how many codes render at a time (4 for SVG, 8 for PNG and EPS by default).
115
+
103
116
  ## Patterns
104
117
 
105
118
  **Body** (`--body`): <!-- catalogue:BODY_PATTERN -->`circle`, `circle-large`, `square`, `diamond`, `circle-mixed`, `pacman`, `rounded`, `small-square`, `vertical-line`, `horizontal-line`, `liquid`, `liquid-horizontal`, `liquid-vertical`, `classy`, `classy-rounded`, `leaf`, `plus`, `hexagon`, `star`, `heart`, `random-dots`, `mosaic`, `octagon`<!-- /catalogue -->
package/dist/index.js CHANGED
@@ -1,14 +1,144 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, createReadStream, writeFileSync } from 'fs';
3
- import { join, extname } from 'path';
4
- import * as readline from 'readline';
5
- import * as p from '@clack/prompts';
6
- 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';
2
+ import { readFileSync, mkdirSync, writeFileSync, createReadStream } from 'fs';
3
+ import { join, resolve, extname } from 'path';
7
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';
8
6
  import { createElement } from 'react';
9
7
  import { renderToStaticMarkup } from 'react-dom/server';
8
+ import * as readline from 'readline';
9
+ import * as p from '@clack/prompts';
10
10
 
11
- var OUTPUT_FORMATS = ["svg", "png", "eps"];
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
+ }
43
+ }
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);
84
+ }
85
+ yield* parser.end();
86
+ }
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();
12
142
  function buildSVGString(props) {
13
143
  const element = createElement(QRCodeSVG, {
14
144
  size: props.size ?? 512,
@@ -105,24 +235,28 @@ ${svgString}`;
105
235
  sharp = mod.default;
106
236
  } catch {
107
237
  throw new Error(
108
- "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"
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`
109
239
  );
110
240
  }
111
241
  const svgBuffer = Buffer.from(svgString, "utf-8");
112
242
  const size = qrProps.size ?? 512;
113
243
  const { width, height } = getFrameDimensions(size, qrProps.frame);
114
- const qrImage = sharp(svgBuffer).resize(Math.round(width), Math.round(height)).png();
244
+ const qrImage = sharp(svgBuffer).resize(
245
+ Math.round(width),
246
+ Math.round(height)
247
+ );
115
248
  const logoSrc = qrProps.imageSettings?.src;
116
249
  const box = logoSrc ? await findLogoBox(sharp, svgString) : null;
117
250
  if (!(logoSrc && box)) {
118
- await qrImage.toFile(outputPath);
251
+ await encodeRaster(qrImage, format).toFile(outputPath);
119
252
  return;
120
253
  }
121
254
  const logo = await sharp(await loadLogo(logoSrc)).resize(box.width, box.height, { fit: "fill" }).png().toBuffer();
122
- await sharp(await qrImage.toBuffer()).composite([{ input: logo, left: box.left, top: box.top }]).png().toFile(outputPath);
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);
123
259
  }
124
-
125
- // src/commands/generate.ts
126
260
  var RESET = "\x1B[0m";
127
261
  var TEXT = "\x1B[38;5;145m";
128
262
  var GREEN = "\x1B[38;5;114m";
@@ -211,9 +345,9 @@ function validateFlags(flags) {
211
345
  assertOneOf("--format", flags.format, OUTPUT_FORMATS);
212
346
  if (flags.output !== void 0) {
213
347
  const ext = extname(flags.output).toLowerCase();
214
- if (!["", ".svg", ".png", ".eps"].includes(ext)) {
348
+ if (!["", ".svg", ".png", ".webp", ".eps"].includes(ext)) {
215
349
  throw new Error(
216
- `Unsupported output format "${ext}". Use a .svg, .png or .eps file name.`
350
+ `Unsupported output format "${ext}". Use a .svg, .png, .webp or .eps file name.`
217
351
  );
218
352
  }
219
353
  if (ext && flags.format && ext !== `.${flags.format}`) {
@@ -222,10 +356,13 @@ function validateFlags(flags) {
222
356
  );
223
357
  }
224
358
  }
225
- const isPng = extname(flags.output ?? "").toLowerCase() === ".png" || flags.format === "png";
226
- if (isPng && flags.size !== void 0 && flags.size > MAX_PNG_SIZE) {
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) {
227
364
  throw new Error(
228
- `Invalid --size "${flags.size}". PNG output is at most ${MAX_PNG_SIZE} px; use .svg or .eps for larger prints.`
365
+ `Invalid --size "${flags.size}". ${raster === "webp" ? "WebP" : "PNG"} output is at most ${MAX_PNG_SIZE} px; use .svg or .eps for larger prints.`
229
366
  );
230
367
  }
231
368
  }
@@ -235,12 +372,12 @@ function readLineFromTTY(question) {
235
372
  if (process.platform === "win32") {
236
373
  return Promise.resolve(void 0);
237
374
  }
238
- return new Promise((resolve) => {
375
+ return new Promise((resolve2) => {
239
376
  let stream;
240
377
  try {
241
378
  stream = createReadStream("/dev/tty", { flags: "r" });
242
379
  } catch {
243
- resolve(void 0);
380
+ resolve2(void 0);
244
381
  return;
245
382
  }
246
383
  const rl = readline.createInterface({
@@ -250,7 +387,7 @@ function readLineFromTTY(question) {
250
387
  const finish = (answer) => {
251
388
  rl.close();
252
389
  stream.destroy();
253
- resolve(answer);
390
+ resolve2(answer);
254
391
  };
255
392
  stream.once("error", () => finish(void 0));
256
393
  rl.question(question, (answer) => finish(answer));
@@ -373,6 +510,53 @@ function parseFlags(args) {
373
510
  if (!(flags.help || flags.version)) validateFlags(flags);
374
511
  return flags;
375
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
+ }
376
560
  function isCancel2(value) {
377
561
  return p.isCancel(value);
378
562
  }
@@ -388,6 +572,9 @@ function inferFormat(outputPath, format) {
388
572
  if (ext === ".png") {
389
573
  return "png";
390
574
  }
575
+ if (ext === ".webp") {
576
+ return "webp";
577
+ }
391
578
  if (ext === ".eps") {
392
579
  return "eps";
393
580
  }
@@ -457,7 +644,7 @@ async function runGenerate(args, meta) {
457
644
  if (!outputPath) {
458
645
  if (canUseClack) {
459
646
  const pathRes = await p.text({
460
- message: "Output file (.svg, .png or .eps)",
647
+ message: "Output file (.svg, .png, .webp or .eps)",
461
648
  placeholder: defaultOutput,
462
649
  defaultValue: defaultOutput,
463
650
  validate(v) {
@@ -472,7 +659,7 @@ async function runGenerate(args, meta) {
472
659
  outputPath = pathRes || defaultOutput;
473
660
  } else if (process.stdout.isTTY && !isCI) {
474
661
  const pathAns = await readLineFromTTY(
475
- `Output file ${DIM}(.svg, .png or .eps, Enter = ${defaultOutput})${RESET}: `
662
+ `Output file ${DIM}(.svg, .png, .webp or .eps, Enter = ${defaultOutput})${RESET}: `
476
663
  );
477
664
  outputPath = pathAns?.trim() || defaultOutput;
478
665
  } else {
@@ -758,29 +945,21 @@ async function runGenerate(args, meta) {
758
945
  const spinner2 = p.spinner();
759
946
  spinner2.start("Generating QR code...");
760
947
  try {
761
- const qrProps = {
762
- value,
948
+ const qrProps = buildQrProps(value, {
763
949
  level,
764
- bodyPattern: body,
765
- cornerEyePattern: eye,
766
- cornerEyeDotPattern: dot,
767
- fgColor: fg,
768
- bgColor: bg,
769
- ...eyeColor ? { eyeColor } : {},
770
- ...dotColor ? { dotColor } : {},
950
+ body,
951
+ eye,
952
+ dot,
953
+ fg,
954
+ bg,
955
+ eyeColor,
956
+ dotColor,
771
957
  margin,
772
958
  size,
773
- ...template ? { templateId: template } : {},
774
- ...frame ? { frame } : {},
775
- ...logo ? {
776
- imageSettings: {
777
- src: logo,
778
- height: Math.round(size * 0.2),
779
- width: Math.round(size * 0.2),
780
- excavate: true
781
- }
782
- } : {}
783
- };
959
+ template,
960
+ frame,
961
+ logo
962
+ });
784
963
  if (outputPath) {
785
964
  const format = inferFormat(outputPath, flags.format);
786
965
  await saveOutput({ ...qrProps, outputPath, format });
@@ -800,11 +979,207 @@ async function runGenerate(args, meta) {
800
979
  p.outro(`${DIM}Done!${RESET}`);
801
980
  }
802
981
 
803
- // src/index.ts
982
+ // src/commands/batch.ts
804
983
  var RESET2 = "\x1B[0m";
805
- var BOLD = "\x1B[1m";
806
- var DIM2 = "\x1B[38;5;102m";
807
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";
808
1183
  var GRAYS = [
809
1184
  "\x1B[38;5;250m",
810
1185
  "\x1B[38;5;248m",
@@ -834,98 +1209,116 @@ var VERSION = getVersion();
834
1209
  function showLogo() {
835
1210
  console.log();
836
1211
  LOGO_LINES.forEach((line, i) => {
837
- console.log(`${GRAYS[i] ?? GRAYS[5]}${line}${RESET2}`);
1212
+ console.log(`${GRAYS[i] ?? GRAYS[5]}${line}${RESET3}`);
838
1213
  });
839
1214
  }
840
1215
  function showBanner() {
841
1216
  showLogo();
842
1217
  console.log();
843
- console.log(`${DIM2}QR code generator for developers${RESET2}`);
1218
+ console.log(`${DIM3}QR code generator for developers${RESET3}`);
844
1219
  console.log();
845
1220
  console.log(
846
- ` ${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}`
1222
+ );
1223
+ console.log(
1224
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx generate${RESET3} ${DIM3}<url>${RESET3} ${DIM3}Generate from URL${RESET3}`
847
1225
  );
848
1226
  console.log(
849
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx generate${RESET2} ${DIM2}<url>${RESET2} ${DIM2}Generate from URL${RESET2}`
1227
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx batch${RESET3} ${DIM3}<file.csv>${RESET3} ${DIM3}A code for every row of a CSV${RESET3}`
850
1228
  );
851
1229
  console.log();
852
1230
  console.log(
853
- ` ${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}`
854
1232
  );
855
1233
  console.log(
856
- ` ${DIM2}$${RESET2} ${TEXT2}qrdx --version${RESET2} ${DIM2}Show version${RESET2}`
1234
+ ` ${DIM3}$${RESET3} ${TEXT3}qrdx --version${RESET3} ${DIM3}Show version${RESET3}`
857
1235
  );
858
1236
  console.log();
859
- 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"`);
860
1238
  console.log();
861
1239
  }
862
1240
  function showHelp() {
863
1241
  showLogo();
864
1242
  console.log(`
865
- ${BOLD}Usage:${RESET2} qrdx <command> [options]
1243
+ ${BOLD}Usage:${RESET3} qrdx <command> [options]
866
1244
 
867
- ${BOLD}Commands:${RESET2}
1245
+ ${BOLD}Commands:${RESET3}
868
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
869
1248
 
870
- ${BOLD}Generate Options:${RESET2}
1249
+ ${BOLD}Generate Options:${RESET3}
871
1250
  <data> URL or text to encode (positional)
872
- -o, --output Output file path (.svg, .png or .eps)
1251
+ -o, --output Output file path (.svg, .png, .webp or .eps)
873
1252
  --format Output format when the file name has no extension:
874
- ${DIM2}svg, png, eps${RESET2} ${DIM2}(EPS is vector; --size = width in points)${RESET2}
875
- -l, --level Error correction: L, M, Q, H ${DIM2}(default: Q)${RESET2}
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}
876
1256
  --body Body dot pattern, e.g.:
877
- ${DIM2}square, rounded, circle, circle-large, liquid,${RESET2}
878
- ${DIM2}classy, classy-rounded, leaf, hexagon, star, heart${RESET2}
1257
+ ${DIM3}square, rounded, circle, circle-large, liquid,${RESET3}
1258
+ ${DIM3}classy, classy-rounded, leaf, hexagon, star, heart${RESET3}
879
1259
  --eye Corner eye pattern, e.g.:
880
- ${DIM2}square, rounded, extra-rounded, gear, leaf, drop,${RESET2}
881
- ${DIM2}squircle, octagon, shield, petal${RESET2}
1260
+ ${DIM3}square, rounded, extra-rounded, gear, leaf, drop,${RESET3}
1261
+ ${DIM3}squircle, octagon, shield, petal${RESET3}
882
1262
  --dot Corner dot pattern, e.g.:
883
- ${DIM2}square, rounded, circle, leaf, drop, squircle,${RESET2}
884
- ${DIM2}octagon, flower, heart, plus, dots${RESET2}
885
- ${DIM2}All options: https://qrdx.dev/docs/styles${RESET2}
886
- --fg Foreground color \u2014 hex or JSON gradient ${DIM2}(default: #000000)${RESET2}
887
- --bg Background color \u2014 hex or JSON gradient ${DIM2}(default: #ffffff)${RESET2}
888
- --eye-color Corner eye color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
889
- --dot-color Corner dot color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
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}
890
1270
  --logo Logo URL for center image (use with --level H)
891
1271
  --no-logo Skip the logo prompt in interactive mode
892
- --margin Quiet zone margin in modules ${DIM2}(default: 0)${RESET2}
893
- --size Output pixel size for SVG/PNG ${DIM2}(default: 512)${RESET2}
1272
+ --margin Quiet zone margin in modules ${DIM3}(default: 0)${RESET3}
1273
+ --size Output pixel size for SVG/PNG/WebP ${DIM3}(default: 512)${RESET3}
894
1274
  --template Decorative template wrapper:
895
- ${DIM2}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET2}
1275
+ ${DIM3}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET3}
896
1276
  --frame Call-to-action frame (adds space around the code), e.g.:
897
- ${DIM2}none, banner-bottom, banner-top, rounded-border,${RESET2}
898
- ${DIM2}speech-bubble, polaroid, phone, ticket, sticker${RESET2}
899
- ${DIM2}All frames: https://qrdx.dev/docs/styles${RESET2}
900
- --frame-text Frame label, up to 24 characters ${DIM2}(default: Scan me; "" hides it)${RESET2}
901
- --frame-color Frame color \u2014 hex ${DIM2}(default: #000000)${RESET2}
902
- --frame-text-color Frame label color \u2014 hex ${DIM2}(default: white on banners)${RESET2}
903
- ${DIM2}Frame text/colors without --frame use banner-bottom${RESET2}
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.
904
1296
 
905
- ${BOLD}Gradient Colors:${RESET2}
1297
+ ${BOLD}Gradient Colors:${RESET3}
906
1298
  Pass a JSON object to --fg / --bg / --eye-color / --dot-color for gradients:
907
- ${DIM2}Linear: '{"type":"linear","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":45}'${RESET2}
908
- ${DIM2}Radial: '{"type":"radial","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}]}'${RESET2}
909
- ${DIM2}Angular: '{"type":"angular","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":0}'${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}
910
1302
 
911
- ${BOLD}Global Options:${RESET2}
1303
+ ${BOLD}Global Options:${RESET3}
912
1304
  --help, -h Show this help message
913
1305
  --version, -v Show version number
914
1306
 
915
- ${BOLD}Examples:${RESET2}
916
- ${DIM2}$${RESET2} qrdx generate
917
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev"
918
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.svg
919
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot flower
920
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.eps --size 144
921
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg "#ff0000" --bg "#ffffff" --size 1024
922
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --logo "https://qrdx.dev/logo.png" --level H -o branded.svg
923
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
924
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Halloween --body circle --eye gear -o halloween.svg
925
- ${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
926
- ${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
927
1320
 
928
- Visit ${TEXT2}https://qrdx.dev${RESET2} for more.
1321
+ Visit ${TEXT3}https://qrdx.dev${RESET3} for more.
929
1322
  `);
930
1323
  }
931
1324
  async function main() {
@@ -950,6 +1343,10 @@ async function main() {
950
1343
  case "g":
951
1344
  await runGenerate(restArgs, generateMeta);
952
1345
  break;
1346
+ case "batch":
1347
+ case "b":
1348
+ await runBatch(restArgs, generateMeta);
1349
+ break;
953
1350
  case "--help":
954
1351
  case "-h":
955
1352
  case "help":
@@ -962,7 +1359,7 @@ async function main() {
962
1359
  default:
963
1360
  if (command.startsWith("-")) {
964
1361
  console.error(`Unknown command: ${command}`);
965
- console.error(`Run ${BOLD}qrdx --help${RESET2} for usage.`);
1362
+ console.error(`Run ${BOLD}qrdx --help${RESET3} for usage.`);
966
1363
  process.exitCode = 1;
967
1364
  } else {
968
1365
  await runGenerate(args, generateMeta);
@@ -971,6 +1368,6 @@ async function main() {
971
1368
  }
972
1369
  main().catch((err) => {
973
1370
  const msg = err instanceof Error ? err.message : String(err);
974
- console.error(`\x1B[38;5;203m\u2717 ${msg}${RESET2}`);
1371
+ console.error(`\x1B[38;5;203m\u2717 ${msg}${RESET3}`);
975
1372
  process.exit(1);
976
1373
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qrdx/cli",
3
- "version": "0.0.5",
4
- "description": "QR code generator CLI for developers: styled SVG, PNG and vector EPS QR codes from the command line",
3
+ "version": "0.0.6",
4
+ "description": "QR code generator CLI for developers: styled SVG, PNG, WebP and vector EPS QR codes from the command line",
5
5
  "keywords": [
6
6
  "qr-code",
7
7
  "qrcode",
@@ -9,6 +9,7 @@
9
9
  "cli",
10
10
  "svg",
11
11
  "png",
12
+ "webp",
12
13
  "eps",
13
14
  "qr-code-cli"
14
15
  ],
@@ -36,7 +37,7 @@
36
37
  "picocolors": "^1.1.1",
37
38
  "react": "19.3.1",
38
39
  "react-dom": "19.3.1",
39
- "qrdx": "0.0.47"
40
+ "qrdx": "0.0.48"
40
41
  },
41
42
  "optionalDependencies": {
42
43
  "sharp": "^0.35.5"