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,112 @@
1
+ export const ERROR_CORRECTION_LEVELS = {
2
+ L: { key: "L", ordinal: 0, formatBits: 0b01 },
3
+ M: { key: "M", ordinal: 1, formatBits: 0b00 },
4
+ Q: { key: "Q", ordinal: 2, formatBits: 0b11 },
5
+ H: { key: "H", ordinal: 3, formatBits: 0b10 }
6
+ };
7
+
8
+ export const ERROR_CORRECTION_LEVEL_ORDER = ["L", "M", "Q", "H"];
9
+
10
+ export const ECC_CODEWORDS_PER_BLOCK = [
11
+ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
12
+ [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
13
+ [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
14
+ [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30]
15
+ ];
16
+
17
+ export const NUM_ERROR_CORRECTION_BLOCKS = [
18
+ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
19
+ [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
20
+ [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
21
+ [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81]
22
+ ];
23
+
24
+ export function getSize(version) {
25
+ assertVersion(version);
26
+ return version * 4 + 17;
27
+ }
28
+
29
+ export function getRawCodewordCount(version) {
30
+ assertVersion(version);
31
+
32
+ let result = (16 * version + 128) * version + 64;
33
+ if (version >= 2) {
34
+ const numAlign = Math.floor(version / 7) + 2;
35
+ result -= (25 * numAlign - 10) * numAlign - 55;
36
+ if (version >= 7) {
37
+ result -= 36;
38
+ }
39
+ }
40
+
41
+ return Math.floor(result / 8);
42
+ }
43
+
44
+ export function getDataCodewordCount(version, level) {
45
+ assertVersion(version);
46
+ const ecl = ERROR_CORRECTION_LEVELS[level];
47
+ if (!ecl) {
48
+ throw new RangeError(`Unsupported error correction level: ${level}`);
49
+ }
50
+
51
+ const raw = getRawCodewordCount(version);
52
+ const blocks = NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][version];
53
+ const eccPerBlock = ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][version];
54
+ return raw - blocks * eccPerBlock;
55
+ }
56
+
57
+ export function getErrorCorrectionBlockInfo(version, level) {
58
+ assertVersion(version);
59
+ const ecl = ERROR_CORRECTION_LEVELS[level];
60
+ if (!ecl) {
61
+ throw new RangeError(`Unsupported error correction level: ${level}`);
62
+ }
63
+
64
+ return {
65
+ blocks: NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][version],
66
+ eccPerBlock: ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][version],
67
+ rawCodewords: getRawCodewordCount(version),
68
+ dataCodewords: getDataCodewordCount(version, level)
69
+ };
70
+ }
71
+
72
+ export function getAlignmentPatternPositions(version) {
73
+ assertVersion(version);
74
+ if (version === 1) {
75
+ return [];
76
+ }
77
+
78
+ const size = getSize(version);
79
+ const numAlign = Math.floor(version / 7) + 2;
80
+ const step = version === 32
81
+ ? 26
82
+ : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2;
83
+
84
+ const positions = [6];
85
+ for (let pos = size - 7; positions.length < numAlign; pos -= step) {
86
+ positions.splice(1, 0, pos);
87
+ }
88
+ return positions;
89
+ }
90
+
91
+ export function getCharacterCountBitLength(version, mode) {
92
+ assertVersion(version);
93
+
94
+ switch (mode) {
95
+ case "numeric":
96
+ return version <= 9 ? 10 : version <= 26 ? 12 : 14;
97
+ case "alphanumeric":
98
+ return version <= 9 ? 9 : version <= 26 ? 11 : 13;
99
+ case "byte":
100
+ return version <= 9 ? 8 : 16;
101
+ case "kanji":
102
+ return version <= 9 ? 8 : version <= 26 ? 10 : 12;
103
+ default:
104
+ throw new RangeError(`Unsupported mode: ${mode}`);
105
+ }
106
+ }
107
+
108
+ function assertVersion(version) {
109
+ if (!Number.isInteger(version) || version < 1 || version > 40) {
110
+ throw new RangeError(`QR version must be an integer from 1 to 40; got ${version}`);
111
+ }
112
+ }
@@ -0,0 +1,208 @@
1
+ import { parseRgbaColor, getContrastRatio } from "./render/color.js";
2
+
3
+ const MIN_RECOMMENDED_CONTRAST = 4.5;
4
+ const STRONG_RECOMMENDED_CONTRAST = 7;
5
+ const MIN_PRINT_MODULE_MM = 0.25;
6
+
7
+ export function createDiagnostics({
8
+ plan,
9
+ built,
10
+ options,
11
+ inputBytes,
12
+ capacityBits,
13
+ interleaved,
14
+ getSize,
15
+ getDiagnosticMode,
16
+ getFirstEciAssignmentNumber,
17
+ getFirstFnc1Mode,
18
+ getSegmentDiagnostics
19
+ }) {
20
+ const remainingBits = capacityBits - plan.dataBitLength;
21
+ const contrast = getColorContrast(options);
22
+ const print = getPrintDiagnostics(plan, options);
23
+ const warnings = createWarnings({
24
+ options,
25
+ remainingBits,
26
+ capacityBits,
27
+ contrast,
28
+ print
29
+ });
30
+
31
+ return {
32
+ version: plan.version,
33
+ size: getSize(plan.version),
34
+ errorCorrectionLevel: plan.errorCorrectionLevel,
35
+ requestedErrorCorrectionLevel: plan.requestedErrorCorrectionLevel,
36
+ boostedErrorCorrection: plan.boostedErrorCorrection,
37
+ versionSelection: plan.versionSelection,
38
+ versionSelectionReason: getVersionSelectionReason(plan, options),
39
+ maskPattern: built.maskPattern,
40
+ maskPenalty: built.penalty,
41
+ maskPenalties: built.maskPenalties,
42
+ maskSelectionReason: getMaskSelectionReason(built, options),
43
+ mode: getDiagnosticMode(plan.segments),
44
+ eciAssignmentNumber: getFirstEciAssignmentNumber(plan.segments),
45
+ fnc1: getFirstFnc1Mode(plan.segments),
46
+ gs1: getFirstFnc1Mode(plan.segments) === "first-position",
47
+ segments: plan.segments.map(getSegmentDiagnostics),
48
+ dataBitLength: plan.dataBitLength,
49
+ capacityBits,
50
+ remainingBits,
51
+ capacityUtilization: plan.dataBitLength / capacityBits,
52
+ inputBytes,
53
+ dataCodewords: interleaved.dataCodewords,
54
+ errorCorrectionCodewords: interleaved.errorCorrectionCodewords,
55
+ totalCodewords: interleaved.totalCodewords,
56
+ quietZone: {
57
+ modules: options.margin,
58
+ recommendedModules: 4,
59
+ isSufficient: options.margin >= 4
60
+ },
61
+ colors: contrast,
62
+ print,
63
+ warnings
64
+ };
65
+ }
66
+
67
+ function getVersionSelectionReason(plan, options) {
68
+ if (plan.versionSelection === "fixed") {
69
+ return `Version ${plan.version} was requested explicitly.`;
70
+ }
71
+ return `Version ${plan.version} is the smallest version in ${options.minVersion}..${options.maxVersion} that fits the encoded data at error correction ${options.errorCorrectionLevel}.`;
72
+ }
73
+
74
+ function getMaskSelectionReason(built, options) {
75
+ if (options.maskPattern !== "auto") {
76
+ return `Mask pattern ${built.maskPattern} was requested explicitly.`;
77
+ }
78
+ return `Mask pattern ${built.maskPattern} had the lowest penalty score (${built.penalty}) among the evaluated masks.`;
79
+ }
80
+
81
+ function getColorContrast(options) {
82
+ const foreground = parseRgbaColor(options.foreground);
83
+ const background = parseRgbaColor(options.background);
84
+ if (!foreground || !background) {
85
+ return {
86
+ ratio: null,
87
+ foregroundAlpha: null,
88
+ backgroundAlpha: null,
89
+ isInspectable: false,
90
+ isStrong: false,
91
+ isSufficient: false
92
+ };
93
+ }
94
+
95
+ const ratio = getContrastRatio(foreground, background);
96
+ return {
97
+ ratio,
98
+ foregroundAlpha: foreground[3],
99
+ backgroundAlpha: background[3],
100
+ isInspectable: true,
101
+ isStrong: ratio >= STRONG_RECOMMENDED_CONTRAST,
102
+ isSufficient: ratio >= MIN_RECOMMENDED_CONTRAST && foreground[3] === 255 && background[3] === 255
103
+ };
104
+ }
105
+
106
+ function getPrintDiagnostics(plan, options) {
107
+ const moduleSizeMm = options.printDpi === null ? null : (options.scale / options.printDpi) * 25.4;
108
+ return {
109
+ dpi: options.printDpi,
110
+ modulePixels: options.scale,
111
+ moduleSizeMm,
112
+ symbolSizeMm: moduleSizeMm === null ? null : (plan.version * 4 + 17 + options.margin * 2) * moduleSizeMm,
113
+ recommendedMinimumModuleSizeMm: MIN_PRINT_MODULE_MM,
114
+ isModuleSizeSufficient: moduleSizeMm === null ? null : moduleSizeMm >= MIN_PRINT_MODULE_MM
115
+ };
116
+ }
117
+
118
+ function createWarnings({ options, remainingBits, capacityBits, contrast, print }) {
119
+ const warnings = [];
120
+
121
+ if (options.margin < 4) {
122
+ warnings.push({
123
+ code: "QUIET_ZONE_TOO_SMALL",
124
+ severity: "warning",
125
+ message: "QR Code Model 2 readers expect a quiet zone of at least 4 modules.",
126
+ details: { margin: options.margin, recommendedModules: 4 }
127
+ });
128
+ }
129
+
130
+ if (!contrast.isInspectable) {
131
+ warnings.push({
132
+ code: "COLOR_CONTRAST_UNKNOWN",
133
+ severity: "info",
134
+ message: "Color contrast could not be inspected because one or both colors are not simple hex/named colors.",
135
+ details: { foreground: options.foreground, background: options.background }
136
+ });
137
+ } else if (contrast.ratio < MIN_RECOMMENDED_CONTRAST) {
138
+ warnings.push({
139
+ code: "COLOR_CONTRAST_LOW",
140
+ severity: "warning",
141
+ message: "Foreground and background contrast is low for reliable scanning.",
142
+ details: { ratio: contrast.ratio, recommendedMinimumRatio: MIN_RECOMMENDED_CONTRAST }
143
+ });
144
+ } else if (contrast.ratio < STRONG_RECOMMENDED_CONTRAST) {
145
+ warnings.push({
146
+ code: "COLOR_CONTRAST_MODERATE",
147
+ severity: "info",
148
+ message: "Contrast is acceptable, but stronger contrast is recommended for damaged, small, or printed QR codes.",
149
+ details: { ratio: contrast.ratio, strongRecommendedRatio: STRONG_RECOMMENDED_CONTRAST }
150
+ });
151
+ }
152
+
153
+ if (contrast.isInspectable && (contrast.foregroundAlpha < 255 || contrast.backgroundAlpha < 255)) {
154
+ warnings.push({
155
+ code: "COLOR_ALPHA_USED",
156
+ severity: "warning",
157
+ message: "Transparent foreground or background colors can reduce scanner reliability.",
158
+ details: {
159
+ foregroundAlpha: contrast.foregroundAlpha,
160
+ backgroundAlpha: contrast.backgroundAlpha
161
+ }
162
+ });
163
+ }
164
+
165
+ if (remainingBits / capacityBits < 0.05) {
166
+ warnings.push({
167
+ code: "CAPACITY_NEAR_LIMIT",
168
+ severity: "info",
169
+ message: "The selected version is close to full capacity.",
170
+ details: { remainingBits, capacityBits }
171
+ });
172
+ }
173
+
174
+ if (print.dpi !== null && print.isModuleSizeSufficient === false) {
175
+ warnings.push({
176
+ code: "PRINT_MODULE_TOO_SMALL",
177
+ severity: "warning",
178
+ message: "The configured scale and DPI produce modules smaller than the print recommendation.",
179
+ details: {
180
+ dpi: print.dpi,
181
+ moduleSizeMm: print.moduleSizeMm,
182
+ recommendedMinimumModuleSizeMm: print.recommendedMinimumModuleSizeMm
183
+ }
184
+ });
185
+ }
186
+
187
+ if (["png", "png-data-url"].includes(options.output) && options.scale < 3) {
188
+ warnings.push({
189
+ code: "RASTER_SCALE_SMALL",
190
+ severity: "info",
191
+ message: "Raster output with fewer than 3 pixels per module may scan poorly after resizing.",
192
+ details: { scale: options.scale, recommendedMinimumScale: 3 }
193
+ });
194
+ }
195
+
196
+ if (warnings.some((warning) => warning.severity === "warning")) {
197
+ warnings.push({
198
+ code: "SCAN_RISK",
199
+ severity: "warning",
200
+ message: "One or more settings may reduce scan reliability.",
201
+ details: {
202
+ blockingWarnings: warnings.filter((warning) => warning.severity === "warning").map((warning) => warning.code)
203
+ }
204
+ });
205
+ }
206
+
207
+ return warnings;
208
+ }
@@ -0,0 +1,54 @@
1
+ export class BitBuffer {
2
+ #bits = [];
3
+
4
+ get length() {
5
+ return this.#bits.length;
6
+ }
7
+
8
+ append(value, bitLength) {
9
+ if (!Number.isInteger(bitLength) || bitLength < 0 || bitLength > 31) {
10
+ throw new RangeError(`Bit length must be from 0 to 31; got ${bitLength}`);
11
+ }
12
+ if (!Number.isInteger(value) || value < 0 || value >>> bitLength !== 0) {
13
+ throw new RangeError(`Value ${value} does not fit in ${bitLength} bits`);
14
+ }
15
+
16
+ for (let i = bitLength - 1; i >= 0; i -= 1) {
17
+ this.#bits.push(((value >>> i) & 1) !== 0);
18
+ }
19
+ }
20
+
21
+ appendBits(bits) {
22
+ for (const bit of bits) {
23
+ this.#bits.push(Boolean(bit));
24
+ }
25
+ }
26
+
27
+ getBit(index) {
28
+ if (!Number.isInteger(index) || index < 0 || index >= this.#bits.length) {
29
+ throw new RangeError(`Bit index out of range: ${index}`);
30
+ }
31
+ return this.#bits[index];
32
+ }
33
+
34
+ toBytes(targetLength) {
35
+ if (this.#bits.length % 8 !== 0) {
36
+ throw new Error("Bit length must be a multiple of 8 before converting to bytes");
37
+ }
38
+
39
+ const bytes = [];
40
+ for (let i = 0; i < this.#bits.length; i += 8) {
41
+ let value = 0;
42
+ for (let j = 0; j < 8; j += 1) {
43
+ value = (value << 1) | (this.#bits[i + j] ? 1 : 0);
44
+ }
45
+ bytes.push(value);
46
+ }
47
+
48
+ if (targetLength !== undefined && bytes.length !== targetLength) {
49
+ throw new Error(`Expected ${targetLength} bytes; got ${bytes.length}`);
50
+ }
51
+
52
+ return bytes;
53
+ }
54
+ }