specqr 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,255 @@
1
+ import { interleaveCodewords } from "./core/codewords.js";
2
+ import { buildMatrix } from "./core/matrix.js";
3
+ import {
4
+ createSegments,
5
+ encodeSegments,
6
+ encodeUtf8,
7
+ getSegmentByteCount,
8
+ getSegmentTextCharacterCount,
9
+ getSegmentsBitLength,
10
+ isBinaryInput,
11
+ normalizeManualSegments,
12
+ prependEciSegment,
13
+ prependFnc1Segment,
14
+ toByteArray
15
+ } from "./encoding/modes.js";
16
+ import { ERROR_CORRECTION_LEVEL_ORDER, getDataCodewordCount, getSize } from "./core/tables.js";
17
+ import { createDiagnostics } from "./diagnostics.js";
18
+ import { createGs1ElementString, GS1_FNC1_SEPARATOR, parseGs1HumanReadable } from "./gs1.js";
19
+ import { DataTooLongError, InvalidGs1Error, InvalidOutputError } from "./errors.js";
20
+ import { normalizeOptions } from "./options.js";
21
+ import { renderCanvas } from "./render/canvas.js";
22
+ import { renderPng, renderPngDataUrl } from "./render/png.js";
23
+ import { renderSvg, renderSvgDataUrl } from "./render/svg.js";
24
+
25
+ export { createGs1ElementString, GS1_FNC1_SEPARATOR, parseGs1HumanReadable } from "./gs1.js";
26
+
27
+ export {
28
+ DataTooLongError,
29
+ InvalidCanvasTargetError,
30
+ InvalidColorError,
31
+ InvalidEciError,
32
+ InvalidGs1Error,
33
+ InvalidInputError,
34
+ InvalidModeError,
35
+ InvalidOutputError,
36
+ InvalidVersionError,
37
+ SpecQRError
38
+ } from "./errors.js";
39
+
40
+ export class QRCode {
41
+ static generate(input, options = {}) {
42
+ return generate(input, options);
43
+ }
44
+
45
+ static generateSegments(segments, options = {}) {
46
+ return generateSegments(segments, options);
47
+ }
48
+
49
+ static drawToCanvas(target, input, options = {}) {
50
+ return drawToCanvas(target, input, options);
51
+ }
52
+
53
+ static createGs1ElementString(elements) {
54
+ return createGs1ElementString(elements);
55
+ }
56
+
57
+ static parseGs1HumanReadable(input) {
58
+ return parseGs1HumanReadable(input);
59
+ }
60
+ }
61
+
62
+ export function generate(input, options = {}) {
63
+ const normalized = normalizeOptions(options);
64
+ const plan = selectPlanForInput(input, normalized);
65
+ return renderResult(plan, normalized, getInputByteCount(input));
66
+ }
67
+
68
+ export function generateSegments(segments, options = {}) {
69
+ const normalized = normalizeOptions(options);
70
+ const normalizedSegments = normalizeManualSegments(segments);
71
+ const plan = selectPlanForManualSegments(normalizedSegments, normalized);
72
+ return renderResult(plan, normalized, getSegmentsInputByteCount(normalizedSegments));
73
+ }
74
+
75
+ export function drawToCanvas(target, input, options = {}) {
76
+ const normalized = normalizeOptions({
77
+ ...options,
78
+ output: "matrix",
79
+ diagnostics: false
80
+ });
81
+ const matrix = generate(input, {
82
+ ...normalized,
83
+ output: "matrix",
84
+ diagnostics: false
85
+ });
86
+
87
+ return renderCanvas(target, matrix, normalized);
88
+ }
89
+
90
+ function renderResult(plan, normalized, inputBytes) {
91
+ const capacityBits = getDataCodewordCount(plan.version, plan.errorCorrectionLevel) * 8;
92
+ const data = encodeSegments(plan.segments, plan.version, plan.errorCorrectionLevel);
93
+ const interleaved = interleaveCodewords(data, plan.version, plan.errorCorrectionLevel);
94
+ const built = buildMatrix(
95
+ interleaved.codewords,
96
+ plan.version,
97
+ plan.errorCorrectionLevel,
98
+ normalized.maskPattern
99
+ );
100
+ const svg = normalized.output === "svg" || normalized.output === "svg-data-url" || normalized.diagnostics
101
+ ? renderSvg(built.matrix, normalized)
102
+ : undefined;
103
+
104
+ if (normalized.diagnostics) {
105
+ return {
106
+ matrix: built.matrix,
107
+ svg,
108
+ diagnostics: createDiagnostics({
109
+ plan,
110
+ built,
111
+ options: normalized,
112
+ inputBytes,
113
+ capacityBits,
114
+ interleaved,
115
+ getSize,
116
+ getDiagnosticMode,
117
+ getFirstEciAssignmentNumber,
118
+ getFirstFnc1Mode,
119
+ getSegmentDiagnostics: (segment) => ({
120
+ mode: segment.mode,
121
+ assignmentNumber: segment.assignmentNumber,
122
+ characterCount: getSegmentTextCharacterCount(segment),
123
+ byteCount: getSegmentByteCount(segment),
124
+ bitLength: getSegmentsBitLength([segment], plan.version)
125
+ })
126
+ })
127
+ };
128
+ }
129
+
130
+ switch (normalized.output) {
131
+ case "matrix":
132
+ return built.matrix;
133
+ case "svg":
134
+ return svg;
135
+ case "svg-data-url":
136
+ return renderSvgDataUrl(built.matrix, normalized);
137
+ case "png":
138
+ return renderPng(built.matrix, normalized);
139
+ case "png-data-url":
140
+ return renderPngDataUrl(built.matrix, normalized);
141
+ default:
142
+ throw new InvalidOutputError(`Unsupported output: ${normalized.output}`);
143
+ }
144
+ }
145
+
146
+ function selectPlanForInput(input, options) {
147
+ if (options.gs1 && isBinaryInput(input)) {
148
+ throw new InvalidGs1Error("gs1 input must be a GS1 element string, not binary input");
149
+ }
150
+ return selectPlan(
151
+ (version) => prependFnc1Segment(
152
+ createSegments(input, options.mode, version, options.optimizeSegments, options.eci),
153
+ options.gs1
154
+ ),
155
+ options
156
+ );
157
+ }
158
+
159
+ function selectPlanForManualSegments(segments, options) {
160
+ return selectPlan(
161
+ () => prependFnc1Segment(prependEciSegment(segments, options.eci), options.gs1),
162
+ options
163
+ );
164
+ }
165
+
166
+ function selectPlan(createSegmentsForVersion, options) {
167
+ if (options.version !== "auto") {
168
+ return withBoostedErrorCorrection({
169
+ ...ensureFits(createSegmentsForVersion, options.version, options),
170
+ versionSelection: "fixed"
171
+ }, options);
172
+ }
173
+
174
+ for (let version = options.minVersion; version <= options.maxVersion; version += 1) {
175
+ const plan = createPlan(createSegmentsForVersion, version, options);
176
+ if (plan.dataBitLength <= getDataCodewordCount(version, options.errorCorrectionLevel) * 8) {
177
+ return withBoostedErrorCorrection({
178
+ ...plan,
179
+ versionSelection: "auto-minimum"
180
+ }, options);
181
+ }
182
+ }
183
+
184
+ throw new DataTooLongError(
185
+ `Input requires more capacity than versions ${options.minVersion}..${options.maxVersion} at error correction ${options.errorCorrectionLevel}`
186
+ );
187
+ }
188
+
189
+ function ensureFits(createSegmentsForVersion, version, options) {
190
+ const plan = createPlan(createSegmentsForVersion, version, options);
191
+ if (plan.dataBitLength > getDataCodewordCount(version, options.errorCorrectionLevel) * 8) {
192
+ const capacityBits = getDataCodewordCount(version, options.errorCorrectionLevel) * 8;
193
+ throw new DataTooLongError(
194
+ `Input requires ${plan.dataBitLength} bits, but version ${version}-${options.errorCorrectionLevel} has ${capacityBits} data bits`
195
+ );
196
+ }
197
+
198
+ return plan;
199
+ }
200
+
201
+ function createPlan(createSegmentsForVersion, version, options) {
202
+ const segments = createSegmentsForVersion(version);
203
+ return {
204
+ version,
205
+ segments,
206
+ dataBitLength: getSegmentsBitLength(segments, version),
207
+ errorCorrectionLevel: options.errorCorrectionLevel,
208
+ requestedErrorCorrectionLevel: options.errorCorrectionLevel,
209
+ boostedErrorCorrection: false
210
+ };
211
+ }
212
+
213
+ function withBoostedErrorCorrection(plan, options) {
214
+ if (!options.boostErrorCorrection) {
215
+ return plan;
216
+ }
217
+
218
+ let errorCorrectionLevel = options.errorCorrectionLevel;
219
+ const startIndex = ERROR_CORRECTION_LEVEL_ORDER.indexOf(errorCorrectionLevel);
220
+
221
+ for (let index = startIndex + 1; index < ERROR_CORRECTION_LEVEL_ORDER.length; index += 1) {
222
+ const candidate = ERROR_CORRECTION_LEVEL_ORDER[index];
223
+ if (plan.dataBitLength <= getDataCodewordCount(plan.version, candidate) * 8) {
224
+ errorCorrectionLevel = candidate;
225
+ }
226
+ }
227
+
228
+ return {
229
+ ...plan,
230
+ errorCorrectionLevel,
231
+ boostedErrorCorrection: errorCorrectionLevel !== options.errorCorrectionLevel
232
+ };
233
+ }
234
+
235
+ function getDiagnosticMode(segments) {
236
+ const dataSegments = segments.filter((segment) => !["eci", "fnc1"].includes(segment.mode));
237
+ const mode = dataSegments[0]?.mode ?? "byte";
238
+ return dataSegments.every((segment) => segment.mode === mode) ? mode : "mixed";
239
+ }
240
+
241
+ function getFirstEciAssignmentNumber(segments) {
242
+ return segments.find((segment) => segment.mode === "eci")?.assignmentNumber ?? null;
243
+ }
244
+
245
+ function getFirstFnc1Mode(segments) {
246
+ return segments.some((segment) => segment.mode === "fnc1") ? "first-position" : null;
247
+ }
248
+
249
+ function getInputByteCount(input) {
250
+ return isBinaryInput(input) ? toByteArray(input).length : encodeUtf8(input).length;
251
+ }
252
+
253
+ function getSegmentsInputByteCount(segments) {
254
+ return segments.reduce((total, segment) => total + getSegmentByteCount(segment), 0);
255
+ }
package/src/node.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { Buffer } from "node:buffer";
2
+ import type { QRCodeOptions, QRInput, QRSegmentInput } from "./index.js";
3
+
4
+ export function toPngBuffer(input: QRInput, options?: QRCodeOptions): Buffer;
5
+ export function toPngBufferFromSegments(segments: QRSegmentInput[], options?: QRCodeOptions): Buffer;
6
+ export function writePngFile(filePath: string, input: QRInput, options?: QRCodeOptions): Promise<void>;
7
+ export function writePngFileFromSegments(filePath: string, segments: QRSegmentInput[], options?: QRCodeOptions): Promise<void>;
package/src/node.js ADDED
@@ -0,0 +1,26 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { generate, generateSegments } from "./index.js";
3
+
4
+ export function toPngBuffer(input, options = {}) {
5
+ return Buffer.from(generate(input, {
6
+ ...options,
7
+ output: "png",
8
+ diagnostics: false
9
+ }));
10
+ }
11
+
12
+ export function toPngBufferFromSegments(segments, options = {}) {
13
+ return Buffer.from(generateSegments(segments, {
14
+ ...options,
15
+ output: "png",
16
+ diagnostics: false
17
+ }));
18
+ }
19
+
20
+ export async function writePngFile(filePath, input, options = {}) {
21
+ await writeFile(filePath, toPngBuffer(input, options));
22
+ }
23
+
24
+ export async function writePngFileFromSegments(filePath, segments, options = {}) {
25
+ await writeFile(filePath, toPngBufferFromSegments(segments, options));
26
+ }
package/src/options.js ADDED
@@ -0,0 +1,117 @@
1
+ import { ERROR_CORRECTION_LEVELS } from "./core/tables.js";
2
+ import {
3
+ InvalidEciError,
4
+ InvalidGs1Error,
5
+ InvalidInputError,
6
+ InvalidModeError,
7
+ InvalidOutputError,
8
+ InvalidVersionError
9
+ } from "./errors.js";
10
+
11
+ const DEFAULT_OPTIONS = {
12
+ errorCorrectionLevel: "M",
13
+ version: "auto",
14
+ minVersion: 1,
15
+ maxVersion: 40,
16
+ maskPattern: "auto",
17
+ mode: "auto",
18
+ encoding: "utf-8",
19
+ margin: 4,
20
+ scale: 8,
21
+ foreground: "#000000",
22
+ background: "#ffffff",
23
+ output: "svg",
24
+ optimizeSegments: true,
25
+ boostErrorCorrection: false,
26
+ eci: false,
27
+ gs1: false,
28
+ diagnostics: false,
29
+ printDpi: null
30
+ };
31
+
32
+ export function normalizeOptions(options = {}) {
33
+ const normalized = { ...DEFAULT_OPTIONS, ...options };
34
+
35
+ if (!ERROR_CORRECTION_LEVELS[normalized.errorCorrectionLevel]) {
36
+ throw new InvalidInputError(
37
+ `errorCorrectionLevel must be one of L, M, Q, H; got ${normalized.errorCorrectionLevel}`
38
+ );
39
+ }
40
+
41
+ if (!["auto", "numeric", "alphanumeric", "byte", "kanji"].includes(normalized.mode)) {
42
+ throw new InvalidModeError(
43
+ `mode must be "auto", "numeric", "alphanumeric", "byte", or "kanji"; got ${normalized.mode}`
44
+ );
45
+ }
46
+
47
+ if (typeof normalized.encoding !== "string" || normalized.encoding.toLowerCase() !== "utf-8") {
48
+ throw new InvalidInputError(`Only utf-8 encoding is implemented in P0; got ${normalized.encoding}`);
49
+ }
50
+ normalized.encoding = "utf-8";
51
+
52
+ if (normalized.eci === true) {
53
+ normalized.eci = 26;
54
+ } else if (normalized.eci !== false) {
55
+ if (!Number.isInteger(normalized.eci) || normalized.eci < 0 || normalized.eci >= 1000000) {
56
+ throw new InvalidEciError(`eci must be false, true, or an integer from 0 to 999999; got ${normalized.eci}`);
57
+ }
58
+ }
59
+
60
+ if (typeof normalized.gs1 !== "boolean") {
61
+ throw new InvalidGs1Error(`gs1 must be a boolean; got ${typeof normalized.gs1}`);
62
+ }
63
+ if (normalized.gs1 && normalized.eci !== false) {
64
+ throw new InvalidGs1Error("gs1 and eci cannot be combined in this FNC1 first position implementation");
65
+ }
66
+
67
+ validateVersionBound("minVersion", normalized.minVersion);
68
+ validateVersionBound("maxVersion", normalized.maxVersion);
69
+ if (normalized.minVersion > normalized.maxVersion) {
70
+ throw new InvalidVersionError("minVersion must be less than or equal to maxVersion");
71
+ }
72
+
73
+ if (normalized.version !== "auto") {
74
+ validateVersionBound("version", normalized.version);
75
+ }
76
+
77
+ if (normalized.maskPattern !== "auto") {
78
+ if (!Number.isInteger(normalized.maskPattern) || normalized.maskPattern < 0 || normalized.maskPattern > 7) {
79
+ throw new InvalidInputError(`maskPattern must be "auto" or an integer from 0 to 7; got ${normalized.maskPattern}`);
80
+ }
81
+ }
82
+
83
+ if (!Number.isInteger(normalized.margin) || normalized.margin < 0) {
84
+ throw new InvalidInputError(`margin must be a non-negative integer; got ${normalized.margin}`);
85
+ }
86
+
87
+ if (!Number.isInteger(normalized.scale) || normalized.scale < 1) {
88
+ throw new InvalidInputError(`scale must be a positive integer; got ${normalized.scale}`);
89
+ }
90
+
91
+ const outputs = ["matrix", "svg", "svg-data-url", "png", "png-data-url"];
92
+ if (!outputs.includes(normalized.output)) {
93
+ throw new InvalidOutputError(`output must be one of ${outputs.join(", ")}; got ${normalized.output}`);
94
+ }
95
+
96
+ if (typeof normalized.optimizeSegments !== "boolean") {
97
+ throw new InvalidInputError(`optimizeSegments must be a boolean; got ${typeof normalized.optimizeSegments}`);
98
+ }
99
+
100
+ if (typeof normalized.boostErrorCorrection !== "boolean") {
101
+ throw new InvalidInputError(`boostErrorCorrection must be a boolean; got ${typeof normalized.boostErrorCorrection}`);
102
+ }
103
+
104
+ if (normalized.printDpi !== null) {
105
+ if (!Number.isFinite(normalized.printDpi) || normalized.printDpi <= 0) {
106
+ throw new InvalidInputError(`printDpi must be a positive number or null; got ${normalized.printDpi}`);
107
+ }
108
+ }
109
+
110
+ return normalized;
111
+ }
112
+
113
+ function validateVersionBound(name, value) {
114
+ if (!Number.isInteger(value) || value < 1 || value > 40) {
115
+ throw new InvalidVersionError(`${name} must be an integer from 1 to 40; got ${value}`);
116
+ }
117
+ }
@@ -0,0 +1,25 @@
1
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+
3
+ export function bytesToBase64(bytes) {
4
+ let result = "";
5
+ let index = 0;
6
+
7
+ for (; index + 2 < bytes.length; index += 3) {
8
+ const value = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
9
+ result += BASE64_ALPHABET[(value >>> 18) & 0x3F];
10
+ result += BASE64_ALPHABET[(value >>> 12) & 0x3F];
11
+ result += BASE64_ALPHABET[(value >>> 6) & 0x3F];
12
+ result += BASE64_ALPHABET[value & 0x3F];
13
+ }
14
+
15
+ if (index < bytes.length) {
16
+ const remaining = bytes.length - index;
17
+ const value = bytes[index] << 16 | (remaining === 2 ? bytes[index + 1] << 8 : 0);
18
+ result += BASE64_ALPHABET[(value >>> 18) & 0x3F];
19
+ result += BASE64_ALPHABET[(value >>> 12) & 0x3F];
20
+ result += remaining === 2 ? BASE64_ALPHABET[(value >>> 6) & 0x3F] : "=";
21
+ result += "=";
22
+ }
23
+
24
+ return result;
25
+ }
@@ -0,0 +1,51 @@
1
+ import { InvalidCanvasTargetError } from "../errors.js";
2
+
3
+ export function renderCanvas(target, matrix, options) {
4
+ const context = getCanvasContext(target);
5
+ const canvas = context.canvas ?? target;
6
+ const size = matrix.length;
7
+ const margin = options.margin;
8
+ const scale = options.scale;
9
+ const dimension = (size + margin * 2) * scale;
10
+
11
+ if (canvas && "width" in canvas) {
12
+ canvas.width = dimension;
13
+ }
14
+ if (canvas && "height" in canvas) {
15
+ canvas.height = dimension;
16
+ }
17
+
18
+ context.fillStyle = options.background;
19
+ context.fillRect(0, 0, dimension, dimension);
20
+ context.fillStyle = options.foreground;
21
+
22
+ for (let y = 0; y < size; y += 1) {
23
+ for (let x = 0; x < size; x += 1) {
24
+ if (matrix[y][x]) {
25
+ context.fillRect((x + margin) * scale, (y + margin) * scale, scale, scale);
26
+ }
27
+ }
28
+ }
29
+
30
+ return canvas ?? target;
31
+ }
32
+
33
+ function getCanvasContext(target) {
34
+ if (!target) {
35
+ throw new InvalidCanvasTargetError("drawToCanvas target is required");
36
+ }
37
+
38
+ if (typeof target.getContext === "function") {
39
+ const context = target.getContext("2d");
40
+ if (!context) {
41
+ throw new InvalidCanvasTargetError("Canvas target did not provide a 2D rendering context");
42
+ }
43
+ return context;
44
+ }
45
+
46
+ if (typeof target.fillRect === "function") {
47
+ return target;
48
+ }
49
+
50
+ throw new InvalidCanvasTargetError("drawToCanvas target must be a canvas element or 2D rendering context");
51
+ }
@@ -0,0 +1,68 @@
1
+ import { InvalidColorError } from "../errors.js";
2
+
3
+ const NAMED_COLORS = new Map([
4
+ ["black", [0, 0, 0, 255]],
5
+ ["white", [255, 255, 255, 255]],
6
+ ["transparent", [0, 0, 0, 0]]
7
+ ]);
8
+
9
+ export function parseRgbaColor(value, label = "color", strict = false) {
10
+ const text = String(value).trim().toLowerCase();
11
+ const named = NAMED_COLORS.get(text);
12
+ if (named) {
13
+ return named.slice();
14
+ }
15
+
16
+ if (!text.startsWith("#")) {
17
+ if (strict) {
18
+ throw new InvalidColorError(`${label} must be a hex color, "black", "white", or "transparent"`);
19
+ }
20
+ return null;
21
+ }
22
+
23
+ const hex = text.slice(1);
24
+ if (!/^[0-9a-f]+$/u.test(hex)) {
25
+ if (strict) {
26
+ throw new InvalidColorError(`${label} has unsupported color format: ${value}`);
27
+ }
28
+ return null;
29
+ }
30
+
31
+ if (hex.length === 3 || hex.length === 4) {
32
+ return [
33
+ Number.parseInt(hex[0] + hex[0], 16),
34
+ Number.parseInt(hex[1] + hex[1], 16),
35
+ Number.parseInt(hex[2] + hex[2], 16),
36
+ hex.length === 4 ? Number.parseInt(hex[3] + hex[3], 16) : 255
37
+ ];
38
+ }
39
+ if (hex.length === 6 || hex.length === 8) {
40
+ return [
41
+ Number.parseInt(hex.slice(0, 2), 16),
42
+ Number.parseInt(hex.slice(2, 4), 16),
43
+ Number.parseInt(hex.slice(4, 6), 16),
44
+ hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) : 255
45
+ ];
46
+ }
47
+
48
+ if (strict) {
49
+ throw new InvalidColorError(`${label} has unsupported color format: ${value}`);
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function getContrastRatio(foreground, background) {
55
+ const light = relativeLuminance(foreground);
56
+ const dark = relativeLuminance(background);
57
+ const lighter = Math.max(light, dark);
58
+ const darker = Math.min(light, dark);
59
+ return (lighter + 0.05) / (darker + 0.05);
60
+ }
61
+
62
+ function relativeLuminance(color) {
63
+ const [red, green, blue] = color.map((channel) => {
64
+ const value = channel / 255;
65
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
66
+ });
67
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
68
+ }