quadqr-js 0.7.0

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,371 @@
1
+ /**
2
+ * Reed-Solomon codec over GF(2^8) = GF(256).
3
+ *
4
+ * Primitive polynomial: x^8 + x^4 + x^3 + x^2 + 1 (0x11d).
5
+ * Each field symbol is one byte. QuadQR serializes every byte as
6
+ * four 2-bit color cells, so RS symbol boundaries stay naturally aligned.
7
+ */
8
+
9
+ export const FIELD_SIZE = 256;
10
+ export const FIELD_ORDER = 255;
11
+ export const MAX_CODEWORD_SYMBOLS = 255;
12
+
13
+ const PRIMITIVE_POLYNOMIAL = 0x11d;
14
+ const EXP = new Uint16Array(FIELD_ORDER * 2);
15
+ const LOG = new Int16Array(FIELD_SIZE);
16
+ LOG.fill(-1);
17
+
18
+ (function initTables() {
19
+ let value = 1;
20
+ for (let i = 0; i < FIELD_ORDER; i++) {
21
+ EXP[i] = value;
22
+ LOG[value] = i;
23
+ value <<= 1;
24
+ if (value & 0x100) value ^= PRIMITIVE_POLYNOMIAL;
25
+ }
26
+ for (let i = FIELD_ORDER; i < EXP.length; i++) {
27
+ EXP[i] = EXP[i - FIELD_ORDER];
28
+ }
29
+ })();
30
+
31
+ function assertFieldValue(value) {
32
+ if (!Number.isInteger(value) || value < 0 || value >= FIELD_SIZE) {
33
+ throw new Error("GF(256) symbol must be an integer from 0 to 255.");
34
+ }
35
+ }
36
+
37
+ export function gfAdd(a, b) {
38
+ return a ^ b;
39
+ }
40
+
41
+ export function gfNeg(a) {
42
+ return a;
43
+ }
44
+
45
+ export function gfSub(a, b) {
46
+ return a ^ b;
47
+ }
48
+
49
+ export function gfMul(a, b) {
50
+ if (a === 0 || b === 0) return 0;
51
+ return EXP[(LOG[a] + LOG[b]) % FIELD_ORDER];
52
+ }
53
+
54
+ export function gfDiv(a, b) {
55
+ if (b === 0) throw new Error("Division by zero in GF(256).");
56
+ if (a === 0) return 0;
57
+ let power = LOG[a] - LOG[b];
58
+ if (power < 0) power += FIELD_ORDER;
59
+ return EXP[power];
60
+ }
61
+
62
+ export function gfPow(a, power) {
63
+ if (power === 0) return 1;
64
+ if (a === 0) return 0;
65
+ let p = (LOG[a] * power) % FIELD_ORDER;
66
+ if (p < 0) p += FIELD_ORDER;
67
+ return EXP[p];
68
+ }
69
+
70
+ export function gfAlphaPow(power) {
71
+ let p = power % FIELD_ORDER;
72
+ if (p < 0) p += FIELD_ORDER;
73
+ return EXP[p];
74
+ }
75
+
76
+ function polyEvalHigh(coeffs, x) {
77
+ let y = 0;
78
+ for (const coeff of coeffs) y = gfAdd(gfMul(y, x), coeff);
79
+ return y;
80
+ }
81
+
82
+ function locatorEvalLow(coeffs, x) {
83
+ let y = 0;
84
+ let xp = 1;
85
+ for (const coeff of coeffs) {
86
+ y = gfAdd(y, gfMul(coeff, xp));
87
+ xp = gfMul(xp, x);
88
+ }
89
+ return y;
90
+ }
91
+
92
+ function buildGenerator(paritySymbols, firstConsecutiveRoot = 0) {
93
+ let generator = [1];
94
+ for (let i = 0; i < paritySymbols; i++) {
95
+ const root = gfAlphaPow(firstConsecutiveRoot + i);
96
+ const factor = [1, root]; // x - root === x + root in characteristic 2
97
+ const next = new Array(generator.length + 1).fill(0);
98
+ for (let a = 0; a < generator.length; a++) {
99
+ for (let b = 0; b < factor.length; b++) {
100
+ next[a + b] = gfAdd(next[a + b], gfMul(generator[a], factor[b]));
101
+ }
102
+ }
103
+ generator = next;
104
+ }
105
+ return generator;
106
+ }
107
+
108
+ const GENERATOR_CACHE = new Map();
109
+ function getGenerator(paritySymbols, firstConsecutiveRoot = 0) {
110
+ const key = `${paritySymbols}:${firstConsecutiveRoot}`;
111
+ if (!GENERATOR_CACHE.has(key)) {
112
+ GENERATOR_CACHE.set(key, buildGenerator(paritySymbols, firstConsecutiveRoot));
113
+ }
114
+ return GENERATOR_CACHE.get(key);
115
+ }
116
+
117
+ export function rsEncode(dataSymbols, paritySymbols, options = {}) {
118
+ const data = Array.from(dataSymbols);
119
+ const fcr = options.firstConsecutiveRoot ?? 0;
120
+
121
+ if (!Number.isInteger(paritySymbols) || paritySymbols <= 0) {
122
+ throw new Error("paritySymbols must be a positive integer.");
123
+ }
124
+ if (data.length + paritySymbols > MAX_CODEWORD_SYMBOLS) {
125
+ throw new Error(`RS codeword exceeds ${MAX_CODEWORD_SYMBOLS} GF(256) symbols.`);
126
+ }
127
+ for (const value of data) assertFieldValue(value);
128
+
129
+ const generator = getGenerator(paritySymbols, fcr);
130
+ const work = data.concat(new Array(paritySymbols).fill(0));
131
+
132
+ for (let i = 0; i < data.length; i++) {
133
+ const coef = work[i];
134
+ if (coef === 0) continue;
135
+ for (let j = 0; j < generator.length; j++) {
136
+ work[i + j] = gfSub(work[i + j], gfMul(coef, generator[j]));
137
+ }
138
+ }
139
+
140
+ const parity = work.slice(data.length).map(gfNeg);
141
+ return data.concat(parity);
142
+ }
143
+
144
+ export function rsSyndromes(codeword, paritySymbols, options = {}) {
145
+ const fcr = options.firstConsecutiveRoot ?? 0;
146
+ const values = Array.from(codeword);
147
+ return Array.from({ length: paritySymbols }, (_, i) =>
148
+ polyEvalHigh(values, gfAlphaPow(fcr + i))
149
+ );
150
+ }
151
+
152
+ function allZero(values) {
153
+ return values.every((value) => value === 0);
154
+ }
155
+
156
+ function berlekampMassey(syndromes) {
157
+ const n = syndromes.length;
158
+ const C = new Array(n + 1).fill(0);
159
+ const B = new Array(n + 1).fill(0);
160
+ C[0] = 1;
161
+ B[0] = 1;
162
+
163
+ let L = 0;
164
+ let m = 1;
165
+ let b = 1;
166
+
167
+ for (let index = 0; index < n; index++) {
168
+ let discrepancy = syndromes[index];
169
+ for (let i = 1; i <= L; i++) {
170
+ discrepancy = gfAdd(discrepancy, gfMul(C[i], syndromes[index - i]));
171
+ }
172
+
173
+ if (discrepancy === 0) {
174
+ m++;
175
+ continue;
176
+ }
177
+
178
+ const T = C.slice();
179
+ const scale = gfDiv(discrepancy, b);
180
+ for (let i = 0; i + m < C.length; i++) {
181
+ if (B[i] !== 0) C[i + m] = gfSub(C[i + m], gfMul(scale, B[i]));
182
+ }
183
+
184
+ if (2 * L <= index) {
185
+ L = index + 1 - L;
186
+ for (let i = 0; i < B.length; i++) B[i] = T[i];
187
+ b = discrepancy;
188
+ m = 1;
189
+ } else {
190
+ m++;
191
+ }
192
+ }
193
+
194
+ return { locator: C.slice(0, L + 1), errorCount: L };
195
+ }
196
+
197
+ function findErrorPositions(locator, codewordLength) {
198
+ const positions = [];
199
+ for (let power = 0; power < codewordLength; power++) {
200
+ const x = gfAlphaPow(-power);
201
+ if (locatorEvalLow(locator, x) === 0) {
202
+ positions.push(codewordLength - 1 - power);
203
+ }
204
+ }
205
+ return positions;
206
+ }
207
+
208
+ function solveLinearSystem(matrix, vector) {
209
+ const n = vector.length;
210
+ const a = matrix.map((row, r) => row.slice().concat([vector[r]]));
211
+
212
+ for (let col = 0; col < n; col++) {
213
+ let pivot = col;
214
+ while (pivot < n && a[pivot][col] === 0) pivot++;
215
+ if (pivot === n) throw new Error("Singular GF(256) system while solving RS magnitudes.");
216
+
217
+ if (pivot !== col) [a[col], a[pivot]] = [a[pivot], a[col]];
218
+
219
+ const invPivot = gfDiv(1, a[col][col]);
220
+ for (let j = col; j <= n; j++) a[col][j] = gfMul(a[col][j], invPivot);
221
+
222
+ for (let row = 0; row < n; row++) {
223
+ if (row === col || a[row][col] === 0) continue;
224
+ const factor = a[row][col];
225
+ for (let j = col; j <= n; j++) {
226
+ a[row][j] = gfSub(a[row][j], gfMul(factor, a[col][j]));
227
+ }
228
+ }
229
+ }
230
+
231
+ return a.map((row) => row[n]);
232
+ }
233
+
234
+ function solveErrorMagnitudes(syndromes, positions, codewordLength, firstConsecutiveRoot) {
235
+ const count = positions.length;
236
+ const matrix = Array.from({ length: count }, () => new Array(count).fill(0));
237
+ const vector = syndromes.slice(0, count);
238
+
239
+ for (let row = 0; row < count; row++) {
240
+ const rootPower = firstConsecutiveRoot + row;
241
+ for (let col = 0; col < count; col++) {
242
+ const polynomialPower = codewordLength - 1 - positions[col];
243
+ matrix[row][col] = gfAlphaPow(rootPower * polynomialPower);
244
+ }
245
+ }
246
+
247
+ return solveLinearSystem(matrix, vector);
248
+ }
249
+
250
+ function normalizeErasurePositions(positions, codewordLength) {
251
+ if (positions == null) return [];
252
+ if (!Array.isArray(positions)) throw new Error("erasurePositions must be an array of symbol indexes.");
253
+ const unique = [...new Set(positions)];
254
+ for (const position of unique) {
255
+ if (!Number.isInteger(position) || position < 0 || position >= codewordLength) {
256
+ throw new Error(`Invalid Reed-Solomon erasure position ${position}.`);
257
+ }
258
+ }
259
+ return unique.sort((a, b) => a - b);
260
+ }
261
+
262
+ // Remove the contribution of known erasure locations from the syndrome
263
+ // sequence. Berlekamp-Massey can then solve only for the remaining unknown
264
+ // errors. This is the standard Forney-syndrome reduction, expressed using the
265
+ // same coefficient/root convention as the rest of this small codec.
266
+ function forneySyndromes(syndromes, erasurePositions, codewordLength) {
267
+ const reduced = syndromes.slice();
268
+ for (const position of erasurePositions) {
269
+ const polynomialPower = codewordLength - 1 - position;
270
+ const x = gfAlphaPow(polynomialPower);
271
+ for (let i = 0; i < reduced.length - 1; i++) {
272
+ reduced[i] = gfAdd(gfMul(reduced[i], x), reduced[i + 1]);
273
+ }
274
+ reduced.pop();
275
+ }
276
+ return reduced;
277
+ }
278
+
279
+ export function rsDecode(codeword, paritySymbols, options = {}) {
280
+ const values = Array.from(codeword);
281
+ const fcr = options.firstConsecutiveRoot ?? 0;
282
+
283
+ if (values.length > MAX_CODEWORD_SYMBOLS) {
284
+ throw new Error(`RS codeword exceeds ${MAX_CODEWORD_SYMBOLS} GF(256) symbols.`);
285
+ }
286
+ if (paritySymbols <= 0 || paritySymbols >= values.length) {
287
+ throw new Error("Invalid Reed-Solomon parity length.");
288
+ }
289
+ for (const value of values) assertFieldValue(value);
290
+
291
+ const erasurePositions = normalizeErasurePositions(options.erasurePositions, values.length);
292
+ if (erasurePositions.length > paritySymbols) {
293
+ throw new Error(
294
+ `Reed-Solomon has ${erasurePositions.length} erasures but only ${paritySymbols} parity symbols.`
295
+ );
296
+ }
297
+
298
+ const syndromes = rsSyndromes(values, paritySymbols, { firstConsecutiveRoot: fcr });
299
+ if (allZero(syndromes)) {
300
+ return {
301
+ corrected: values,
302
+ data: values.slice(0, values.length - paritySymbols),
303
+ correctedSymbols: 0,
304
+ erasureSymbols: 0,
305
+ unknownErrorSymbols: 0,
306
+ errorPositions: []
307
+ };
308
+ }
309
+
310
+ let unknownPositions = [];
311
+ if (erasurePositions.length < paritySymbols) {
312
+ const reducedSyndromes = erasurePositions.length
313
+ ? forneySyndromes(syndromes, erasurePositions, values.length)
314
+ : syndromes;
315
+
316
+ if (!allZero(reducedSyndromes)) {
317
+ const { locator, errorCount } = berlekampMassey(reducedSyndromes);
318
+ const unknownCorrectionLimit = Math.floor((paritySymbols - erasurePositions.length) / 2);
319
+ if (errorCount <= 0 || errorCount > unknownCorrectionLimit) {
320
+ throw new Error(
321
+ `Reed-Solomon found ${errorCount} unknown symbol errors with ${erasurePositions.length} erasures; ` +
322
+ `limit is ${unknownCorrectionLimit} unknown errors.`
323
+ );
324
+ }
325
+
326
+ unknownPositions = findErrorPositions(locator, values.length)
327
+ .filter((position) => !erasurePositions.includes(position));
328
+ if (unknownPositions.length !== errorCount) {
329
+ throw new Error(`Reed-Solomon locator found ${unknownPositions.length}/${errorCount} unknown error positions.`);
330
+ }
331
+ }
332
+ }
333
+
334
+ const positions = [...new Set(erasurePositions.concat(unknownPositions))].sort((a, b) => a - b);
335
+ if (positions.length === 0) {
336
+ throw new Error("Reed-Solomon syndromes are non-zero but no correction positions were found.");
337
+ }
338
+ if (2 * unknownPositions.length + erasurePositions.length > paritySymbols) {
339
+ throw new Error(
340
+ `Reed-Solomon error/erasure budget exceeded: 2*${unknownPositions.length} + ` +
341
+ `${erasurePositions.length} > ${paritySymbols}.`
342
+ );
343
+ }
344
+
345
+ const magnitudes = solveErrorMagnitudes(syndromes, positions, values.length, fcr);
346
+ const corrected = values.slice();
347
+ for (let i = 0; i < positions.length; i++) {
348
+ corrected[positions[i]] = gfSub(corrected[positions[i]], magnitudes[i]);
349
+ }
350
+
351
+ const check = rsSyndromes(corrected, paritySymbols, { firstConsecutiveRoot: fcr });
352
+ if (!allZero(check)) throw new Error("Reed-Solomon correction failed syndrome verification.");
353
+
354
+ return {
355
+ corrected,
356
+ data: corrected.slice(0, corrected.length - paritySymbols),
357
+ correctedSymbols: positions.length,
358
+ erasureSymbols: erasurePositions.length,
359
+ unknownErrorSymbols: unknownPositions.length,
360
+ errorPositions: positions
361
+ };
362
+ }
363
+
364
+ export const gf256Internals = Object.freeze({
365
+ EXP,
366
+ LOG,
367
+ PRIMITIVE_POLYNOMIAL,
368
+ buildGenerator,
369
+ berlekampMassey,
370
+ findErrorPositions
371
+ });