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.
@@ -0,0 +1,97 @@
1
+ import { InvalidModeError } from "../errors.js";
2
+
3
+ let kanjiModeMap = null;
4
+ let decoderAvailable = true;
5
+ const SHIFT_JIS_TRAIL_BYTES = createShiftJisTrailBytes();
6
+
7
+ export function canEncodeKanjiModeCharacter(character) {
8
+ if (character.length === 0 || character.charCodeAt(0) < 0x80) {
9
+ return false;
10
+ }
11
+ return getKanjiModeMap().has(character);
12
+ }
13
+
14
+ export function assertKanjiModeText(text) {
15
+ for (const character of Array.from(text)) {
16
+ if (!canEncodeKanjiModeCharacter(character)) {
17
+ throw new InvalidModeError(`kanji mode cannot encode character: ${character}`);
18
+ }
19
+ }
20
+ }
21
+
22
+ export function getKanjiModeValue(character) {
23
+ const code = getKanjiModeMap().get(character);
24
+ if (code === undefined) {
25
+ throw new InvalidModeError(`kanji mode cannot encode character: ${character}`);
26
+ }
27
+
28
+ const adjusted = code <= 0x9FFC ? code - 0x8140 : code - 0xC140;
29
+ return ((adjusted >>> 8) * 0xC0) + (adjusted & 0xFF);
30
+ }
31
+
32
+ export function getKanjiModeByteCount(text) {
33
+ assertKanjiModeText(text);
34
+ return Array.from(text).length * 2;
35
+ }
36
+
37
+ function getKanjiModeMap() {
38
+ if (kanjiModeMap) {
39
+ return kanjiModeMap;
40
+ }
41
+
42
+ const decoder = createShiftJisDecoder();
43
+ if (!decoder) {
44
+ return new Map();
45
+ }
46
+
47
+ kanjiModeMap = new Map();
48
+ addRange(kanjiModeMap, decoder, 0x81, 0x9F);
49
+ addRange(kanjiModeMap, decoder, 0xE0, 0xEB);
50
+ return kanjiModeMap;
51
+ }
52
+
53
+ function createShiftJisDecoder() {
54
+ if (!decoderAvailable || typeof TextDecoder !== "function") {
55
+ return null;
56
+ }
57
+
58
+ try {
59
+ return new TextDecoder("shift_jis", { fatal: true });
60
+ } catch {
61
+ decoderAvailable = false;
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function addRange(map, decoder, leadStart, leadEnd) {
67
+ for (let lead = leadStart; lead <= leadEnd; lead += 1) {
68
+ for (const trail of SHIFT_JIS_TRAIL_BYTES) {
69
+ const code = (lead << 8) | trail;
70
+ const character = decodeCharacter(decoder, lead, trail);
71
+ if (character && !map.has(character)) {
72
+ map.set(character, code);
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ function decodeCharacter(decoder, lead, trail) {
79
+ try {
80
+ const decoded = decoder.decode(Uint8Array.from([lead, trail]));
81
+ const characters = Array.from(decoded);
82
+ return characters.length === 1 && characters[0] !== "\uFFFD" ? characters[0] : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function createShiftJisTrailBytes() {
89
+ const bytes = [];
90
+ for (let value = 0x40; value <= 0x7E; value += 1) {
91
+ bytes.push(value);
92
+ }
93
+ for (let value = 0x80; value <= 0xFC; value += 1) {
94
+ bytes.push(value);
95
+ }
96
+ return bytes;
97
+ }
package/src/errors.js ADDED
@@ -0,0 +1,61 @@
1
+ export class SpecQRError extends Error {
2
+ constructor(message, code) {
3
+ super(message);
4
+ this.name = new.target.name;
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ export class DataTooLongError extends SpecQRError {
10
+ constructor(message) {
11
+ super(message, "DATA_TOO_LONG");
12
+ }
13
+ }
14
+
15
+ export class InvalidInputError extends SpecQRError {
16
+ constructor(message) {
17
+ super(message, "INVALID_INPUT");
18
+ }
19
+ }
20
+
21
+ export class InvalidVersionError extends SpecQRError {
22
+ constructor(message) {
23
+ super(message, "INVALID_VERSION");
24
+ }
25
+ }
26
+
27
+ export class InvalidModeError extends SpecQRError {
28
+ constructor(message) {
29
+ super(message, "INVALID_MODE");
30
+ }
31
+ }
32
+
33
+ export class InvalidColorError extends SpecQRError {
34
+ constructor(message) {
35
+ super(message, "INVALID_COLOR");
36
+ }
37
+ }
38
+
39
+ export class InvalidEciError extends SpecQRError {
40
+ constructor(message) {
41
+ super(message, "INVALID_ECI");
42
+ }
43
+ }
44
+
45
+ export class InvalidGs1Error extends SpecQRError {
46
+ constructor(message) {
47
+ super(message, "INVALID_GS1");
48
+ }
49
+ }
50
+
51
+ export class InvalidOutputError extends SpecQRError {
52
+ constructor(message) {
53
+ super(message, "INVALID_OUTPUT");
54
+ }
55
+ }
56
+
57
+ export class InvalidCanvasTargetError extends SpecQRError {
58
+ constructor(message) {
59
+ super(message, "INVALID_CANVAS_TARGET");
60
+ }
61
+ }
package/src/gs1.js ADDED
@@ -0,0 +1,167 @@
1
+ import { InvalidGs1Error } from "./errors.js";
2
+
3
+ export const GS1_FNC1_SEPARATOR = "\x1D";
4
+
5
+ const FIXED_LENGTH_AIS = new Map([
6
+ ["00", { length: 18, content: "numeric" }],
7
+ ["01", { length: 14, content: "numeric" }],
8
+ ["02", { length: 14, content: "numeric" }],
9
+ ["11", { length: 6, content: "numeric" }],
10
+ ["12", { length: 6, content: "numeric" }],
11
+ ["13", { length: 6, content: "numeric" }],
12
+ ["15", { length: 6, content: "numeric" }],
13
+ ["16", { length: 6, content: "numeric" }],
14
+ ["17", { length: 6, content: "numeric" }],
15
+ ["20", { length: 2, content: "numeric" }],
16
+ ["410", { length: 13, content: "numeric" }],
17
+ ["411", { length: 13, content: "numeric" }],
18
+ ["412", { length: 13, content: "numeric" }],
19
+ ["413", { length: 13, content: "numeric" }],
20
+ ["414", { length: 13, content: "numeric" }],
21
+ ["415", { length: 13, content: "numeric" }]
22
+ ]);
23
+
24
+ const VARIABLE_LENGTH_AIS = new Map([
25
+ ["10", { maxLength: 20, content: "text" }],
26
+ ["21", { maxLength: 20, content: "text" }],
27
+ ["22", { maxLength: 20, content: "text" }],
28
+ ["30", { maxLength: 8, content: "numeric" }],
29
+ ["37", { maxLength: 8, content: "numeric" }],
30
+ ["240", { maxLength: 30, content: "text" }],
31
+ ["241", { maxLength: 30, content: "text" }],
32
+ ["400", { maxLength: 30, content: "text" }],
33
+ ["420", { maxLength: 20, content: "text" }]
34
+ ]);
35
+
36
+ export function parseGs1HumanReadable(input) {
37
+ if (typeof input !== "string") {
38
+ throw new InvalidGs1Error("GS1 human-readable input must be a string");
39
+ }
40
+ if (input.length === 0) {
41
+ throw new InvalidGs1Error("GS1 human-readable input must not be empty");
42
+ }
43
+
44
+ const elements = [];
45
+ let position = 0;
46
+
47
+ while (position < input.length) {
48
+ if (input[position] !== "(") {
49
+ throw new InvalidGs1Error(
50
+ `GS1 human-readable input must contain an AI in parentheses at offset ${position}`
51
+ );
52
+ }
53
+
54
+ const close = input.indexOf(")", position + 1);
55
+ if (close === -1) {
56
+ throw new InvalidGs1Error(`GS1 AI starting at offset ${position} is missing a closing parenthesis`);
57
+ }
58
+
59
+ const ai = input.slice(position + 1, close);
60
+ const valueStart = close + 1;
61
+ const nextAiStart = input.indexOf("(", valueStart);
62
+ const valueEnd = nextAiStart === -1 ? input.length : nextAiStart;
63
+ const value = input.slice(valueStart, valueEnd);
64
+ const normalized = normalizeGs1Element({ ai, value }, elements.length);
65
+
66
+ elements.push({ ai: normalized.ai, value: normalized.value });
67
+ position = valueEnd;
68
+ }
69
+
70
+ return elements;
71
+ }
72
+
73
+ export function createGs1ElementString(elements) {
74
+ if (!Array.isArray(elements)) {
75
+ throw new InvalidGs1Error("GS1 elements must be an array of { ai, value } objects");
76
+ }
77
+ if (elements.length === 0) {
78
+ throw new InvalidGs1Error("GS1 elements must not be empty");
79
+ }
80
+
81
+ return elements
82
+ .map((element, index) => {
83
+ const normalized = normalizeGs1Element(element, index);
84
+ const needsSeparator = normalized.spec.variable && index < elements.length - 1;
85
+ return `${normalized.ai}${normalized.value}${needsSeparator ? GS1_FNC1_SEPARATOR : ""}`;
86
+ })
87
+ .join("");
88
+ }
89
+
90
+ function normalizeGs1Element(element, index) {
91
+ if (!element || typeof element !== "object") {
92
+ throw new InvalidGs1Error(`GS1 element ${index} must be an object`);
93
+ }
94
+
95
+ if (typeof element.ai !== "string") {
96
+ throw new InvalidGs1Error(`GS1 element ${index} AI must be a string`);
97
+ }
98
+ if (typeof element.value !== "string") {
99
+ throw new InvalidGs1Error(`GS1 element ${index} value must be a string to preserve leading zeroes`);
100
+ }
101
+
102
+ const ai = element.ai;
103
+ const value = element.value;
104
+ validateAi(ai, index);
105
+
106
+ const spec = getAiSpec(ai);
107
+ if (!spec) {
108
+ throw new InvalidGs1Error(`Unsupported GS1 AI ${ai}. Add explicit support before using it.`);
109
+ }
110
+
111
+ validateValue(ai, value, spec);
112
+ return { ai, value, spec };
113
+ }
114
+
115
+ function validateAi(ai, index) {
116
+ if (!/^\d{2,4}$/.test(ai)) {
117
+ throw new InvalidGs1Error(`GS1 element ${index} has invalid AI ${JSON.stringify(ai)}; expected 2 to 4 digits`);
118
+ }
119
+ }
120
+
121
+ function getAiSpec(ai) {
122
+ const fixed = FIXED_LENGTH_AIS.get(ai);
123
+ if (fixed) {
124
+ return { ...fixed, variable: false };
125
+ }
126
+
127
+ const variable = VARIABLE_LENGTH_AIS.get(ai);
128
+ if (variable) {
129
+ return { ...variable, variable: true };
130
+ }
131
+
132
+ if (/^310[0-5]$/.test(ai) || /^320[0-5]$/.test(ai)) {
133
+ return { length: 6, content: "numeric", variable: false };
134
+ }
135
+
136
+ if (/^9[1-9]$/.test(ai)) {
137
+ return { maxLength: 90, content: "text", variable: true };
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ function validateValue(ai, value, spec) {
144
+ if (value.length === 0) {
145
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must not be empty`);
146
+ }
147
+ if (value.includes(GS1_FNC1_SEPARATOR)) {
148
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must not contain the FNC1 separator`);
149
+ }
150
+ if (/[()]/u.test(value)) {
151
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must be raw data without human-readable parentheses`);
152
+ }
153
+ if (!/^[\x20-\x7E]+$/u.test(value)) {
154
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must use printable ASCII characters`);
155
+ }
156
+ if (spec.content === "numeric" && !/^\d+$/u.test(value)) {
157
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must contain digits only`);
158
+ }
159
+
160
+ if (spec.variable) {
161
+ if (value.length > spec.maxLength) {
162
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must be at most ${spec.maxLength} characters`);
163
+ }
164
+ } else if (value.length !== spec.length) {
165
+ throw new InvalidGs1Error(`GS1 AI ${ai} value must be exactly ${spec.length} characters`);
166
+ }
167
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,215 @@
1
+ export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H";
2
+ export type Version = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
3
+ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
4
+ 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
5
+ 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40;
6
+
7
+ export type QRMatrix = boolean[][];
8
+ export type QROutput = "matrix" | "svg" | "svg-data-url" | "png" | "png-data-url";
9
+ export type QRBinaryInput = Uint8Array | ArrayBuffer | ArrayBufferView | readonly number[];
10
+ export type QRInput = string | QRBinaryInput;
11
+
12
+ export type QRTextSegmentMode = "numeric" | "alphanumeric" | "kanji";
13
+ export type QRSegmentInput =
14
+ | { mode: "fnc1" }
15
+ | { mode: "eci"; assignmentNumber: number }
16
+ | { mode: QRTextSegmentMode; data: string }
17
+ | { mode: QRTextSegmentMode; text: string }
18
+ | { mode: "byte"; data: string | QRBinaryInput | readonly number[] }
19
+ | { mode: "byte"; text: string }
20
+ | { mode: "byte"; bytes: QRBinaryInput | readonly number[] };
21
+
22
+ export interface QRCanvasLike {
23
+ width: number;
24
+ height: number;
25
+ getContext(contextId: "2d"): QRCanvasContextLike | null;
26
+ }
27
+
28
+ export interface QRCanvasContextLike {
29
+ canvas?: QRCanvasLike;
30
+ fillStyle: string;
31
+ fillRect(x: number, y: number, width: number, height: number): void;
32
+ }
33
+
34
+ export type QRCanvasTarget = QRCanvasLike | QRCanvasContextLike;
35
+
36
+ export interface QRCodeOptions {
37
+ errorCorrectionLevel?: ErrorCorrectionLevel;
38
+ version?: Version | "auto";
39
+ minVersion?: Version;
40
+ maxVersion?: Version;
41
+ maskPattern?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | "auto";
42
+ mode?: "auto" | "numeric" | "alphanumeric" | "byte" | "kanji";
43
+ encoding?: "utf-8";
44
+ margin?: number;
45
+ scale?: number;
46
+ foreground?: string;
47
+ background?: string;
48
+ output?: QROutput;
49
+ optimizeSegments?: boolean;
50
+ boostErrorCorrection?: boolean;
51
+ eci?: boolean | number;
52
+ gs1?: boolean;
53
+ diagnostics?: boolean;
54
+ printDpi?: number | null;
55
+ }
56
+
57
+ export interface QRSegmentDiagnostics {
58
+ mode: "fnc1" | "eci" | "numeric" | "alphanumeric" | "byte" | "kanji";
59
+ assignmentNumber?: number;
60
+ characterCount: number;
61
+ byteCount: number;
62
+ bitLength: number;
63
+ }
64
+
65
+ export interface QRWarning {
66
+ code:
67
+ | "QUIET_ZONE_TOO_SMALL"
68
+ | "COLOR_CONTRAST_UNKNOWN"
69
+ | "COLOR_CONTRAST_LOW"
70
+ | "COLOR_CONTRAST_MODERATE"
71
+ | "COLOR_ALPHA_USED"
72
+ | "CAPACITY_NEAR_LIMIT"
73
+ | "PRINT_MODULE_TOO_SMALL"
74
+ | "RASTER_SCALE_SMALL"
75
+ | "SCAN_RISK";
76
+ severity: "info" | "warning";
77
+ message: string;
78
+ details?: Record<string, unknown>;
79
+ }
80
+
81
+ export interface QRColorDiagnostics {
82
+ ratio: number | null;
83
+ foregroundAlpha: number | null;
84
+ backgroundAlpha: number | null;
85
+ isInspectable: boolean;
86
+ isStrong: boolean;
87
+ isSufficient: boolean;
88
+ }
89
+
90
+ export interface QRPrintDiagnostics {
91
+ dpi: number | null;
92
+ modulePixels: number;
93
+ moduleSizeMm: number | null;
94
+ symbolSizeMm: number | null;
95
+ recommendedMinimumModuleSizeMm: number;
96
+ isModuleSizeSufficient: boolean | null;
97
+ }
98
+
99
+ export interface QRQuietZoneDiagnostics {
100
+ modules: number;
101
+ recommendedModules: number;
102
+ isSufficient: boolean;
103
+ }
104
+
105
+ export interface QRDiagnostics {
106
+ version: Version;
107
+ size: number;
108
+ errorCorrectionLevel: ErrorCorrectionLevel;
109
+ requestedErrorCorrectionLevel: ErrorCorrectionLevel;
110
+ boostedErrorCorrection: boolean;
111
+ versionSelection: "fixed" | "auto-minimum";
112
+ versionSelectionReason: string;
113
+ maskPattern: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
114
+ maskPenalty: number;
115
+ maskPenalties: Array<{ maskPattern: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; penalty: number }>;
116
+ maskSelectionReason: string;
117
+ mode: "numeric" | "alphanumeric" | "byte" | "kanji" | "mixed";
118
+ eciAssignmentNumber: number | null;
119
+ fnc1: "first-position" | null;
120
+ gs1: boolean;
121
+ segments: QRSegmentDiagnostics[];
122
+ dataBitLength: number;
123
+ capacityBits: number;
124
+ remainingBits: number;
125
+ capacityUtilization: number;
126
+ inputBytes: number;
127
+ dataCodewords: number;
128
+ errorCorrectionCodewords: number;
129
+ totalCodewords: number;
130
+ quietZone: QRQuietZoneDiagnostics;
131
+ colors: QRColorDiagnostics;
132
+ print: QRPrintDiagnostics;
133
+ warnings: QRWarning[];
134
+ }
135
+
136
+ export interface QRCodeDiagnosticResult {
137
+ matrix: QRMatrix;
138
+ svg: string;
139
+ diagnostics: QRDiagnostics;
140
+ }
141
+
142
+ export function generate(input: QRInput, options?: QRCodeOptions & { diagnostics: true }): QRCodeDiagnosticResult;
143
+ export function generate(input: QRInput, options?: QRCodeOptions & { output: "matrix"; diagnostics?: false }): QRMatrix;
144
+ export function generate(input: QRInput, options?: QRCodeOptions & { output: "png"; diagnostics?: false }): Uint8Array;
145
+ export function generate(input: QRInput, options?: QRCodeOptions & { output?: "svg" | "svg-data-url" | "png-data-url"; diagnostics?: false }): string;
146
+
147
+ export function generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { diagnostics: true }): QRCodeDiagnosticResult;
148
+ export function generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output: "matrix"; diagnostics?: false }): QRMatrix;
149
+ export function generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output: "png"; diagnostics?: false }): Uint8Array;
150
+ export function generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output?: "svg" | "svg-data-url" | "png-data-url"; diagnostics?: false }): string;
151
+
152
+ export function drawToCanvas<T extends QRCanvasTarget>(target: T, input: QRInput, options?: QRCodeOptions): T;
153
+
154
+ export interface GS1Element {
155
+ ai: string;
156
+ value: string;
157
+ }
158
+
159
+ export const GS1_FNC1_SEPARATOR: "\x1D";
160
+ export function createGs1ElementString(elements: GS1Element[]): string;
161
+ export function parseGs1HumanReadable(input: string): GS1Element[];
162
+
163
+ export class SpecQRError extends Error {
164
+ readonly code: string;
165
+ }
166
+
167
+ export class DataTooLongError extends SpecQRError {
168
+ readonly code: "DATA_TOO_LONG";
169
+ }
170
+
171
+ export class InvalidInputError extends SpecQRError {
172
+ readonly code: "INVALID_INPUT";
173
+ }
174
+
175
+ export class InvalidVersionError extends SpecQRError {
176
+ readonly code: "INVALID_VERSION";
177
+ }
178
+
179
+ export class InvalidModeError extends SpecQRError {
180
+ readonly code: "INVALID_MODE";
181
+ }
182
+
183
+ export class InvalidColorError extends SpecQRError {
184
+ readonly code: "INVALID_COLOR";
185
+ }
186
+
187
+ export class InvalidEciError extends SpecQRError {
188
+ readonly code: "INVALID_ECI";
189
+ }
190
+
191
+ export class InvalidGs1Error extends SpecQRError {
192
+ readonly code: "INVALID_GS1";
193
+ }
194
+
195
+ export class InvalidOutputError extends SpecQRError {
196
+ readonly code: "INVALID_OUTPUT";
197
+ }
198
+
199
+ export class InvalidCanvasTargetError extends SpecQRError {
200
+ readonly code: "INVALID_CANVAS_TARGET";
201
+ }
202
+
203
+ export class QRCode {
204
+ static generate(input: QRInput, options?: QRCodeOptions & { diagnostics: true }): QRCodeDiagnosticResult;
205
+ static generate(input: QRInput, options?: QRCodeOptions & { output: "matrix"; diagnostics?: false }): QRMatrix;
206
+ static generate(input: QRInput, options?: QRCodeOptions & { output: "png"; diagnostics?: false }): Uint8Array;
207
+ static generate(input: QRInput, options?: QRCodeOptions & { output?: "svg" | "svg-data-url" | "png-data-url"; diagnostics?: false }): string;
208
+ static generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { diagnostics: true }): QRCodeDiagnosticResult;
209
+ static generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output: "matrix"; diagnostics?: false }): QRMatrix;
210
+ static generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output: "png"; diagnostics?: false }): Uint8Array;
211
+ static generateSegments(segments: QRSegmentInput[], options?: QRCodeOptions & { output?: "svg" | "svg-data-url" | "png-data-url"; diagnostics?: false }): string;
212
+ static drawToCanvas<T extends QRCanvasTarget>(target: T, input: QRInput, options?: QRCodeOptions): T;
213
+ static createGs1ElementString(elements: GS1Element[]): string;
214
+ static parseGs1HumanReadable(input: string): GS1Element[];
215
+ }