1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 19:04:46 +00:00

feat(monorepo): migrate vue packages and apply oxlint refactors

This commit is contained in:
2026-03-07 18:07:22 +07:00
parent abd6605db3
commit 41d5e18f6b
286 changed files with 10295 additions and 5028 deletions

View File

@@ -0,0 +1,2 @@
export * from './reed-solomon';
export * from './qr';

View File

@@ -0,0 +1,67 @@
import type { QrCodeEcc, QrSegmentMode } from './types';
/* -- ECC Levels -- */
export const LOW: QrCodeEcc = [0, 1]; // ~7% recovery
export const MEDIUM: QrCodeEcc = [1, 0]; // ~15% recovery
export const QUARTILE: QrCodeEcc = [2, 3]; // ~25% recovery
export const HIGH: QrCodeEcc = [3, 2]; // ~30% recovery
export const EccMap = {
L: LOW,
M: MEDIUM,
Q: QUARTILE,
H: HIGH,
} as const;
/* -- Segment Modes -- */
export const MODE_NUMERIC: QrSegmentMode = [0x1, 10, 12, 14];
export const MODE_ALPHANUMERIC: QrSegmentMode = [0x2, 9, 11, 13];
export const MODE_BYTE: QrSegmentMode = [0x4, 8, 16, 16];
/* -- Version Limits -- */
export const MIN_VERSION = 1;
export const MAX_VERSION = 40;
/* -- Penalty Constants -- */
export const PENALTY_N1 = 3;
export const PENALTY_N2 = 3;
export const PENALTY_N3 = 40;
export const PENALTY_N4 = 10;
/* -- Character Sets & Patterns -- */
export const NUMERIC_REGEX = /^[0-9]*$/;
export const ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+./:_-]*$/;
export const ALPHANUMERIC_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' as const;
/** Pre-computed charCode → alphanumeric index lookup (0xFF = invalid). O(1) instead of O(45) indexOf. */
export const ALPHANUMERIC_MAP = /* @__PURE__ */ (() => {
const map = new Uint8Array(128).fill(0xFF);
for (let i = 0; i < ALPHANUMERIC_CHARSET.length; i++)
map[ALPHANUMERIC_CHARSET.charCodeAt(i)] = i;
return map;
})();
/* -- ECC Lookup Tables -- */
// prettier-ignore
export const ECC_CODEWORDS_PER_BLOCK: number[][] = [
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
[-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], // Low
[-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], // Medium
[-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], // Quartile
[-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], // High
];
// prettier-ignore
export const NUM_ERROR_CORRECTION_BLOCKS: number[][] = [
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
[-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], // Low
[-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], // Medium
[-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], // Quartile
[-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], // High
];

View File

@@ -0,0 +1,94 @@
import type { QrCodeEcc } from './types';
import { HIGH, MAX_VERSION, MEDIUM, MIN_VERSION, QUARTILE } from './constants';
import { QrCode } from './qr-code';
import { makeBytes, makeSegments } from './segment';
import type { QrSegment } from './segment';
import { appendBits, assert, getNumDataCodewords, getTotalBits, numCharCountBits } from './utils';
/**
* Returns a QR Code representing the given Unicode text string at the given error correction level.
* As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
* Unicode code points (not UTF-16 code units) if the low error correction level is used.
* The smallest possible QR Code version is automatically chosen for the output.
*/
export function encodeText(text: string, ecl: QrCodeEcc): QrCode {
const segs = makeSegments(text);
return encodeSegments(segs, ecl);
}
/**
* Returns a QR Code representing the given binary data at the given error correction level.
* This function always encodes using the binary segment mode, not any text mode.
* The maximum number of bytes allowed is 2953.
*/
export function encodeBinary(data: Readonly<number[]>, ecl: QrCodeEcc): QrCode {
const seg = makeBytes(data);
return encodeSegments([seg], ecl);
}
/**
* Returns a QR Code representing the given segments with the given encoding parameters.
* The smallest possible QR Code version within the given range is automatically chosen for the output.
* This is a mid-level API; the high-level API is encodeText() and encodeBinary().
*/
export function encodeSegments(
segs: Readonly<QrSegment[]>,
ecl: QrCodeEcc,
minVersion = 1,
maxVersion = 40,
mask = -1,
boostEcl = true,
): QrCode {
if (!(MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= MAX_VERSION)
|| mask < -1 || mask > 7)
throw new RangeError('Invalid value');
// Find the minimal version number to use
let version: number;
let dataUsedBits: number;
for (version = minVersion; ; version++) {
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
const usedBits = getTotalBits(segs, version);
if (usedBits <= dataCapacityBits) {
dataUsedBits = usedBits;
break;
}
if (version >= maxVersion)
throw new RangeError('Data too long');
}
// Increase the error correction level while the data still fits in the current version number
for (const newEcl of [MEDIUM, QUARTILE, HIGH]) {
if (boostEcl && dataUsedBits! <= getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
const bb: number[] = [];
for (const seg of segs) {
appendBits(seg.mode[0], 4, bb);
appendBits(seg.numChars, numCharCountBits(seg.mode, version), bb);
for (const b of seg.bitData)
bb.push(b);
}
assert(bb.length === dataUsedBits!);
// Add terminator and pad up to a byte if applicable
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
assert(bb.length <= dataCapacityBits);
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
appendBits(0, (8 - bb.length % 8) % 8, bb);
assert(bb.length % 8 === 0);
// Pad with alternating bytes until data capacity is reached
for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
appendBits(padByte, 8, bb);
// Pack bits into bytes in big endian
const dataCodewords = Array.from({ length: Math.ceil(bb.length / 8) }, () => 0);
for (let i = 0; i < bb.length; i++)
dataCodewords[i >>> 3]! |= bb[i]! << (7 - (i & 7));
// Create the QR Code object
return new QrCode(version, ecl, dataCodewords, mask);
}

View File

@@ -0,0 +1,96 @@
import { bench, describe } from 'vitest';
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '.';
/* -- Test data -- */
const SHORT_TEXT = 'Hello';
const MEDIUM_TEXT = 'https://example.com/path?query=value&foo=bar';
const LONG_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.';
const NUMERIC_TEXT = '314159265358979323846264338327950288419716939937510';
const ALPHANUMERIC_TEXT = 'HELLO WORLD 12345';
const SMALL_BINARY = Array.from({ length: 32 }, (_, i) => i);
const MEDIUM_BINARY = Array.from({ length: 256 }, (_, i) => i % 256);
/* -- Precomputed segments for isolated encodeSegments benchmark -- */
const precomputedSegs = makeSegments(MEDIUM_TEXT);
/* -- encodeText benchmarks -- */
describe('encodeText', () => {
bench('short text (5 chars)', () => {
encodeText(SHORT_TEXT, LOW);
});
bench('medium text (URL ~44 chars)', () => {
encodeText(MEDIUM_TEXT, LOW);
});
bench('long text (~270 chars)', () => {
encodeText(LONG_TEXT, LOW);
});
bench('numeric text (50 digits)', () => {
encodeText(NUMERIC_TEXT, LOW);
});
bench('alphanumeric text (17 chars)', () => {
encodeText(ALPHANUMERIC_TEXT, LOW);
});
});
/* -- ECC level impact -- */
describe('encodeText — ECC levels', () => {
bench('LOW (L)', () => {
encodeText(MEDIUM_TEXT, EccMap.L);
});
bench('MEDIUM (M)', () => {
encodeText(MEDIUM_TEXT, EccMap.M);
});
bench('QUARTILE (Q)', () => {
encodeText(MEDIUM_TEXT, EccMap.Q);
});
bench('HIGH (H)', () => {
encodeText(MEDIUM_TEXT, EccMap.H);
});
});
/* -- encodeBinary benchmarks -- */
describe('encodeBinary', () => {
bench('small binary (32 bytes)', () => {
encodeBinary(SMALL_BINARY, LOW);
});
bench('medium binary (256 bytes)', () => {
encodeBinary(MEDIUM_BINARY, LOW);
});
});
/* -- makeSegments benchmarks -- */
describe('makeSegments', () => {
bench('numeric classification', () => {
makeSegments(NUMERIC_TEXT);
});
bench('alphanumeric classification', () => {
makeSegments(ALPHANUMERIC_TEXT);
});
bench('byte mode classification', () => {
makeSegments(MEDIUM_TEXT);
});
});
/* -- encodeSegments (pre-built segments) -- */
describe('encodeSegments', () => {
bench('from pre-built segments', () => {
encodeSegments(precomputedSegs, LOW);
});
});

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import { encodeText, encodeBinary, makeSegments, isNumeric, isAlphanumeric, QrCode, EccMap, LOW, MEDIUM, HIGH } from '.';
describe('isNumeric', () => {
it('accepts pure digit strings', () => {
expect(isNumeric('0123456789')).toBe(true);
expect(isNumeric('0')).toBe(true);
expect(isNumeric('')).toBe(true);
});
it('rejects non-digit characters', () => {
expect(isNumeric('12a3')).toBe(false);
expect(isNumeric('HELLO')).toBe(false);
expect(isNumeric('12 34')).toBe(false);
});
});
describe('isAlphanumeric', () => {
it('accepts valid alphanumeric strings', () => {
expect(isAlphanumeric('HELLO WORLD')).toBe(true);
expect(isAlphanumeric('0123456789')).toBe(true);
expect(isAlphanumeric('ABC123')).toBe(true);
expect(isAlphanumeric('')).toBe(true);
});
it('rejects lowercase and special characters', () => {
expect(isAlphanumeric('hello')).toBe(false);
expect(isAlphanumeric('Hello')).toBe(false);
expect(isAlphanumeric('test@email')).toBe(false);
});
});
describe('makeSegments', () => {
it('returns empty array for empty string', () => {
expect(makeSegments('')).toEqual([]);
});
it('selects numeric mode for digit strings', () => {
const segs = makeSegments('12345');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x1); // MODE_NUMERIC
});
it('selects alphanumeric mode for uppercase strings', () => {
const segs = makeSegments('HELLO WORLD');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x2); // MODE_ALPHANUMERIC
});
it('selects byte mode for general text', () => {
const segs = makeSegments('Hello, World!');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x4); // MODE_BYTE
});
});
describe('encodeText', () => {
it('encodes short text at LOW ECC', () => {
const qr = encodeText('Hello', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.version).toBeGreaterThanOrEqual(1);
expect(qr.size).toBe(qr.version * 4 + 17);
expect(qr.mask).toBeGreaterThanOrEqual(0);
expect(qr.mask).toBeLessThanOrEqual(7);
});
it('encodes text at different ECC levels', () => {
const qrL = encodeText('Test', LOW);
const qrM = encodeText('Test', MEDIUM);
const qrH = encodeText('Test', HIGH);
// Higher ECC needs same or higher version
expect(qrH.version).toBeGreaterThanOrEqual(qrL.version);
// All produce valid sizes
for (const qr of [qrL, qrM, qrH]) {
expect(qr.size).toBe(qr.version * 4 + 17);
}
});
it('encodes numeric-only text', () => {
const qr = encodeText('123456789012345', LOW);
expect(qr.version).toBe(1); // Numeric mode is compact
});
it('encodes a URL', () => {
const qr = encodeText('https://example.com/path?query=value', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.size).toBeGreaterThanOrEqual(21);
});
it('encodes long text', () => {
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.';
const qr = encodeText(longText, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
it('throws for data too long', () => {
const tooLong = 'A'.repeat(10000);
expect(() => encodeText(tooLong, HIGH)).toThrow(RangeError);
});
});
describe('encodeBinary', () => {
it('encodes binary data', () => {
const data = [0x00, 0xFF, 0x48, 0x65, 0x6C, 0x6C, 0x6F];
const qr = encodeBinary(data, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('QrCode', () => {
it('modules grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Uint8Array grid, verify via getModule
expect(qr.size).toBeGreaterThanOrEqual(21);
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const mod = qr.getModule(x, y);
expect(typeof mod).toBe('boolean');
}
}
});
it('types grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Int8Array grid, verify via getType
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const t = qr.getType(x, y);
expect(typeof t).toBe('number');
}
}
});
it('getModule returns false for out of bounds', () => {
const qr = encodeText('Test', LOW);
expect(qr.getModule(-1, 0)).toBe(false);
expect(qr.getModule(0, -1)).toBe(false);
expect(qr.getModule(qr.size, 0)).toBe(false);
expect(qr.getModule(0, qr.size)).toBe(false);
});
it('produces deterministic output', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('Hello', LOW);
expect(qr1.version).toBe(qr2.version);
expect(qr1.mask).toBe(qr2.mask);
for (let y = 0; y < qr1.size; y++) {
for (let x = 0; x < qr1.size; x++) {
expect(qr1.getModule(x, y)).toBe(qr2.getModule(x, y));
}
}
});
it('different inputs produce different outputs', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('World', LOW);
// They might have the same version/size but different modules
let hasDiff = false;
for (let y = 0; y < qr1.size && !hasDiff; y++) {
for (let x = 0; x < qr1.size && !hasDiff; x++) {
if (qr1.getModule(x, y) !== qr2.getModule(x, y))
hasDiff = true;
}
}
expect(hasDiff).toBe(true);
});
});
describe('EccMap', () => {
it('has all four levels', () => {
expect(EccMap.L).toBeDefined();
expect(EccMap.M).toBeDefined();
expect(EccMap.Q).toBeDefined();
expect(EccMap.H).toBeDefined();
});
it('works with encodeText', () => {
const qr = encodeText('Test', EccMap.L);
expect(qr).toBeInstanceOf(QrCode);
});
});

View File

@@ -0,0 +1,8 @@
export { QrCodeDataType } from './types';
export type { QrCodeEcc, QrSegmentMode } from './types';
export { EccMap, LOW, MEDIUM, QUARTILE, HIGH } from './constants';
export { QrCode } from './qr-code';
export { QrSegment, makeBytes, makeSegments, isNumeric, isAlphanumeric } from './segment';
export { encodeText, encodeBinary, encodeSegments } from './encode';

View File

@@ -0,0 +1,436 @@
/*
* QR Code generator — core QrCode class
*
* Based on Project Nayuki's QR Code generator library (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*/
import type { QrCodeEcc } from './types';
import { QrCodeDataType } from './types';
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants';
import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils';
import { computeDivisor, computeRemainder } from '../reed-solomon';
const PENALTY_N1 = 3;
const PENALTY_N2 = 3;
const PENALTY_N3 = 40;
const PENALTY_N4 = 10;
/**
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
*/
export class QrCode {
/** The width and height of this QR Code, measured in modules, between 21 and 177 (inclusive). */
public readonly size: number;
/** The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). */
public readonly mask: number;
/** The modules of this QR Code (0 = light, 1 = dark). Flat row-major Uint8Array. */
private readonly modules: Uint8Array;
/** Data type of each module. Flat row-major Int8Array. */
private readonly types: Int8Array;
/**
* Creates a new QR Code with the given version number, error correction level, data codeword bytes, and mask number.
* This is a low-level API that most users should not use directly.
*/
public constructor(
/** The version number of this QR Code, which is between 1 and 40 (inclusive). */
public readonly version: number,
/** The error correction level used in this QR Code. */
public readonly ecc: QrCodeEcc,
dataCodewords: Readonly<number[]>,
msk: number,
) {
if (version < MIN_VERSION || version > MAX_VERSION)
throw new RangeError('Version value out of range');
if (msk < -1 || msk > 7)
throw new RangeError('Mask value out of range');
this.size = version * 4 + 17;
const totalModules = this.size * this.size;
this.modules = new Uint8Array(totalModules);
this.types = new Int8Array(totalModules); // 0 = QrCodeDataType.Data
// Compute ECC, draw modules
this.drawFunctionPatterns();
const allCodewords = this.addEccAndInterleave(dataCodewords);
this.drawCodewords(allCodewords);
// Do masking
if (msk === -1) {
let minPenalty = 1_000_000_000;
for (let i = 0; i < 8; i++) {
this.applyMask(i);
this.drawFormatBits(i);
const penalty = this.getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
this.applyMask(i); // Undoes the mask due to XOR
}
}
assert(msk >= 0 && msk <= 7);
this.mask = msk;
this.applyMask(msk);
this.drawFormatBits(msk);
}
/**
* Returns the color of the module (pixel) at the given coordinates.
* false for light, true for dark. Out of bounds returns false (light).
*/
public getModule(x: number, y: number): boolean {
return x >= 0 && x < this.size && y >= 0 && y < this.size
&& this.modules[y * this.size + x] === 1;
}
/** Returns the data type of the module at the given coordinates. */
public getType(x: number, y: number): QrCodeDataType {
return this.types[y * this.size + x] as QrCodeDataType;
}
/* -- Private helper methods for constructor: Drawing function modules -- */
private drawFunctionPatterns(): void {
const size = this.size;
// Draw horizontal and vertical timing patterns
for (let i = 0; i < size; i++) {
const dark = i % 2 === 0 ? 1 : 0;
this.setFunctionModule(6, i, dark, QrCodeDataType.Timing);
this.setFunctionModule(i, 6, dark, QrCodeDataType.Timing);
}
// Draw 3 finder patterns (all corners except bottom right)
this.drawFinderPattern(3, 3);
this.drawFinderPattern(size - 4, 3);
this.drawFinderPattern(3, size - 4);
// Draw numerous alignment patterns
const alignPatPos = this.getAlignmentPatternPositions();
const numAlign = alignPatPos.length;
for (let i = 0; i < numAlign; i++) {
for (let j = 0; j < numAlign; j++) {
if (!(i === 0 && j === 0 || i === 0 && j === numAlign - 1 || i === numAlign - 1 && j === 0))
this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!);
}
}
// Draw configuration data
this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
this.drawVersion();
}
private drawFormatBits(mask: number): void {
const data = this.ecc[1] << 3 | mask;
let rem = data;
for (let i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = (data << 10 | rem) ^ 0x5412;
assert(bits >>> 15 === 0);
const size = this.size;
// Draw first copy
for (let i = 0; i <= 5; i++)
this.setFunctionModule(8, i, getBit(bits, i) ? 1 : 0);
this.setFunctionModule(8, 7, getBit(bits, 6) ? 1 : 0);
this.setFunctionModule(8, 8, getBit(bits, 7) ? 1 : 0);
this.setFunctionModule(7, 8, getBit(bits, 8) ? 1 : 0);
for (let i = 9; i < 15; i++)
this.setFunctionModule(14 - i, 8, getBit(bits, i) ? 1 : 0);
// Draw second copy
for (let i = 0; i < 8; i++)
this.setFunctionModule(size - 1 - i, 8, getBit(bits, i) ? 1 : 0);
for (let i = 8; i < 15; i++)
this.setFunctionModule(8, size - 15 + i, getBit(bits, i) ? 1 : 0);
this.setFunctionModule(8, size - 8, 1);
}
private drawVersion(): void {
if (this.version < 7)
return;
let rem = this.version;
for (let i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
const bits = this.version << 12 | rem;
assert(bits >>> 18 === 0);
const size = this.size;
for (let i = 0; i < 18; i++) {
const color = getBit(bits, i) ? 1 : 0;
const a = size - 11 + i % 3;
const b = (i / 3) | 0;
this.setFunctionModule(a, b, color);
this.setFunctionModule(b, a, color);
}
}
private drawFinderPattern(x: number, y: number): void {
const size = this.size;
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const xx = x + dx;
const yy = y + dy;
if (xx >= 0 && xx < size && yy >= 0 && yy < size)
this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4 ? 1 : 0, QrCodeDataType.Position);
}
}
}
private drawAlignmentPattern(x: number, y: number): void {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
this.setFunctionModule(
x + dx,
y + dy,
Math.max(Math.abs(dx), Math.abs(dy)) !== 1 ? 1 : 0,
QrCodeDataType.Alignment,
);
}
}
}
private setFunctionModule(x: number, y: number, isDark: number, type: QrCodeDataType = QrCodeDataType.Function): void {
const idx = y * this.size + x;
this.modules[idx] = isDark;
this.types[idx] = type;
}
/* -- Private helper methods for constructor: Codewords and masking -- */
private addEccAndInterleave(data: Readonly<number[]>): number[] {
const ver = this.version;
const ecl = this.ecc;
if (data.length !== getNumDataCodewords(ver, ecl))
throw new RangeError('Invalid argument');
const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
const blockEccLen = ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!;
const rawCodewords = (getNumRawDataModules(ver) / 8) | 0;
const numShortBlocks = numBlocks - rawCodewords % numBlocks;
const shortBlockLen = (rawCodewords / numBlocks) | 0;
// Split data into blocks and append ECC to each block
const blocks: number[][] = [];
const rsDiv = computeDivisor(blockEccLen);
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat: number[] = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)) as number[];
k += dat.length;
const ecc = computeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push(0);
blocks.push([...dat, ...ecc]);
}
// Interleave (not concatenate) the bytes from every block into a single sequence
const result: number[] = [];
const blockLen = blocks[0]!.length;
for (let i = 0; i < blockLen; i++) {
for (let j = 0; j < blocks.length; j++) {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks)
result.push(blocks[j]![i]!);
}
}
assert(result.length === rawCodewords);
return result;
}
private drawCodewords(data: Readonly<number[]>): void {
if (data.length !== ((getNumRawDataModules(this.version) / 8) | 0))
throw new RangeError('Invalid argument');
const size = this.size;
const modules = this.modules;
const types = this.types;
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6)
right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
const idx = y * size + x;
if (types[idx] === QrCodeDataType.Data && i < data.length * 8) {
modules[idx] = (data[i >>> 3]! >>> (7 - (i & 7))) & 1;
i++;
}
}
}
}
assert(i === data.length * 8);
}
private applyMask(mask: number): void {
if (mask < 0 || mask > 7)
throw new RangeError('Mask value out of range');
const size = this.size;
const modules = this.modules;
const types = this.types;
for (let y = 0; y < size; y++) {
const yOffset = y * size;
for (let x = 0; x < size; x++) {
const idx = yOffset + x;
if (types[idx] !== QrCodeDataType.Data)
continue;
let invert: boolean;
switch (mask) {
case 0: invert = (x + y) % 2 === 0; break;
case 1: invert = y % 2 === 0; break;
case 2: invert = x % 3 === 0; break;
case 3: invert = (x + y) % 3 === 0; break;
case 4: invert = (((x / 3) | 0) + ((y / 2) | 0)) % 2 === 0; break;
case 5: invert = x * y % 2 + x * y % 3 === 0; break;
case 6: invert = (x * y % 2 + x * y % 3) % 2 === 0; break;
case 7: invert = ((x + y) % 2 + x * y % 3) % 2 === 0; break;
default: throw new Error('Unreachable');
}
if (invert)
modules[idx]! ^= 1;
}
}
}
private getPenaltyScore(): number {
const size = this.size;
const modules = this.modules;
let result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (let y = 0; y < size; y++) {
const yOffset = y * size;
let runColor = 0;
let runX = 0;
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
for (let x = 0; x < size; x++) {
const mod = modules[yOffset + x]!;
if (mod === runColor) {
runX++;
if (runX === 5)
result += PENALTY_N1;
else if (runX > 5)
result++;
}
else {
// finderPenaltyAddHistory inlined
if (h0 === 0) runX += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX;
// finderPenaltyCountPatterns inlined (only when runColor is light = 0)
if (runColor === 0) {
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
runColor = mod;
runX = 1;
}
}
// finderPenaltyTerminateAndCount inlined
{
let currentRunLength = runX;
if (runColor === 1) {
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
currentRunLength = 0;
}
currentRunLength += size;
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
}
// Adjacent modules in column having same color, and finder-like patterns
for (let x = 0; x < size; x++) {
let runColor = 0;
let runY = 0;
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
for (let y = 0; y < size; y++) {
const mod = modules[y * size + x]!;
if (mod === runColor) {
runY++;
if (runY === 5)
result += PENALTY_N1;
else if (runY > 5)
result++;
}
else {
if (h0 === 0) runY += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runY; h0 = runY;
if (runColor === 0) {
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
runColor = mod;
runY = 1;
}
}
{
let currentRunLength = runY;
if (runColor === 1) {
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
currentRunLength = 0;
}
currentRunLength += size;
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
}
// 2*2 blocks of modules having same color
for (let y = 0; y < size - 1; y++) {
const yOffset = y * size;
const nextYOffset = yOffset + size;
for (let x = 0; x < size - 1; x++) {
const color = modules[yOffset + x]!;
if (color === modules[yOffset + x + 1]
&& color === modules[nextYOffset + x]
&& color === modules[nextYOffset + x + 1])
result += PENALTY_N2;
}
}
// Balance of dark and light modules
let dark = 0;
const total = size * size;
for (let i = 0; i < total; i++)
dark += modules[i]!;
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
assert(k >= 0 && k <= 9);
result += k * PENALTY_N4;
assert(result >= 0 && result <= 2568888);
return result;
}
private getAlignmentPatternPositions(): number[] {
if (this.version === 1)
return [];
const numAlign = ((this.version / 7) | 0)
+ 2;
const step = (this.version === 32)
? 26
: Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const result = [6];
for (let pos = this.size - 7; result.length < numAlign; pos -= step)
result.splice(1, 0, pos);
return result;
}
}

View File

@@ -0,0 +1,82 @@
import type { QrSegmentMode } from './types';
import { ALPHANUMERIC_MAP, ALPHANUMERIC_REGEX, MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC, NUMERIC_REGEX } from './constants';
import { appendBits, toUtf8ByteArray } from './utils';
/**
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
*/
export class QrSegment {
public constructor(
/** The mode indicator of this segment. */
public readonly mode: QrSegmentMode,
/** The length of this segment's unencoded data. */
public readonly numChars: number,
/** The data bits of this segment. */
public readonly bitData: readonly number[],
) {
if (numChars < 0)
throw new RangeError('Invalid argument');
}
}
/** Returns a segment representing the given binary data encoded in byte mode. */
export function makeBytes(data: ArrayLike<number>): QrSegment {
const bb: number[] = [];
for (let i = 0, len = data.length; i < len; i++)
appendBits(data[i]!, 8, bb);
return new QrSegment(MODE_BYTE, data.length, bb);
}
/** Returns a segment representing the given string of decimal digits encoded in numeric mode. */
export function makeNumeric(digits: string): QrSegment {
if (!isNumeric(digits))
throw new RangeError('String contains non-numeric characters');
const bb: number[] = [];
for (let i = 0; i < digits.length;) {
const n = Math.min(digits.length - i, 3);
appendBits(Number.parseInt(digits.slice(i, i + n), 10), n * 3 + 1, bb);
i += n;
}
return new QrSegment(MODE_NUMERIC, digits.length, bb);
}
/** Returns a segment representing the given text string encoded in alphanumeric mode. */
export function makeAlphanumeric(text: string): QrSegment {
if (!isAlphanumeric(text))
throw new RangeError('String contains unencodable characters in alphanumeric mode');
const bb: number[] = [];
let i: number;
for (i = 0; i + 2 <= text.length; i += 2) {
let temp = ALPHANUMERIC_MAP[text.charCodeAt(i)]! * 45;
temp += ALPHANUMERIC_MAP[text.charCodeAt(i + 1)]!;
appendBits(temp, 11, bb);
}
if (i < text.length)
appendBits(ALPHANUMERIC_MAP[text.charCodeAt(i)]!, 6, bb);
return new QrSegment(MODE_ALPHANUMERIC, text.length, bb);
}
/**
* Returns a new mutable list of zero or more segments to represent the given Unicode text string.
* The result may use various segment modes and switch modes to optimize the length of the bit stream.
*/
export function makeSegments(text: string): QrSegment[] {
if (text === '')
return [];
if (isNumeric(text))
return [makeNumeric(text)];
if (isAlphanumeric(text))
return [makeAlphanumeric(text)];
return [makeBytes(toUtf8ByteArray(text))];
}
/** Tests whether the given string can be encoded as a segment in numeric mode. */
export function isNumeric(text: string): boolean {
return NUMERIC_REGEX.test(text);
}
/** Tests whether the given string can be encoded as a segment in alphanumeric mode. */
export function isAlphanumeric(text: string): boolean {
return ALPHANUMERIC_REGEX.test(text);
}

View File

@@ -0,0 +1,17 @@
export type QrCodeEcc = readonly [ordinal: number, formatBits: number];
export type QrSegmentMode = [
modeBits: number,
numBitsCharCount1: number,
numBitsCharCount2: number,
numBitsCharCount3: number,
];
export enum QrCodeDataType {
Border = -1,
Data = 0,
Function = 1,
Position = 2,
Timing = 3,
Alignment = 4,
}

View File

@@ -0,0 +1,79 @@
import type { QrCodeEcc, QrSegmentMode } from './types';
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants';
import type { QrSegment } from './segment';
const utf8Encoder = new TextEncoder();
/** Appends the given number of low-order bits of the given value to the buffer. */
export function appendBits(val: number, len: number, bb: number[]): void {
if (len < 0 || len > 31 || val >>> len !== 0)
throw new RangeError('Value out of range');
for (let i = len - 1; i >= 0; i--)
bb.push((val >>> i) & 1);
}
/** Returns true iff the i'th bit of x is set to 1. */
export function getBit(x: number, i: number): boolean {
return ((x >>> i) & 1) !== 0;
}
/** Throws an exception if the given condition is false. */
export function assert(cond: boolean): asserts cond {
if (!cond)
throw new Error('Assertion error');
}
/** Returns a Uint8Array representing the given string encoded in UTF-8. */
export function toUtf8ByteArray(str: string): Uint8Array {
return utf8Encoder.encode(str);
}
/** Returns the bit width of the character count field for a segment in this mode at the given version number. */
export function numCharCountBits(mode: QrSegmentMode, ver: number): number {
return mode[((ver + 7) / 17 | 0) + 1]!;
}
/**
* Returns the number of data bits that can be stored in a QR Code of the given version number,
* after all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
* The result is in the range [208, 29648].
*/
export function getNumRawDataModules(ver: number): number {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw new RangeError('Version number out of range');
let result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const numAlign = (ver / 7 | 0) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(result >= 208 && result <= 29648);
return result;
}
/**
* Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
* QR Code of the given version number and error correction level, with remainder bits discarded.
*/
export function getNumDataCodewords(ver: number, ecl: QrCodeEcc): number {
return (getNumRawDataModules(ver) / 8 | 0)
- ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!
* NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
}
/**
* Calculates and returns the number of bits needed to encode the given segments at the given version.
* The result is infinity if a segment has too many characters to fit its length field.
*/
export function getTotalBits(segs: Readonly<QrSegment[]>, version: number): number {
let result = 0;
for (const seg of segs) {
const ccbits = numCharCountBits(seg.mode, version);
if (seg.numChars >= (1 << ccbits))
return Number.POSITIVE_INFINITY;
result += 4 + ccbits + seg.bitData.length;
}
return result;
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { computeDivisor, computeRemainder, multiply } from '.';
describe('multiply', () => {
it('multiplies zero by anything to get zero', () => {
expect(multiply(0, 0)).toBe(0);
expect(multiply(0, 1)).toBe(0);
expect(multiply(0, 255)).toBe(0);
expect(multiply(1, 0)).toBe(0);
});
it('multiplies by one (identity)', () => {
expect(multiply(1, 1)).toBe(1);
expect(multiply(1, 42)).toBe(42);
expect(multiply(42, 1)).toBe(42);
expect(multiply(1, 255)).toBe(255);
});
it('is commutative', () => {
expect(multiply(5, 7)).toBe(multiply(7, 5));
expect(multiply(0x53, 0xCA)).toBe(multiply(0xCA, 0x53));
expect(multiply(100, 200)).toBe(multiply(200, 100));
});
it('produces known GF(2^8) products', () => {
expect(multiply(2, 2)).toBe(4);
expect(multiply(2, 0x80)).toBe(0x1D);
});
it('throws on out of range inputs', () => {
expect(() => multiply(256, 0)).toThrow(RangeError);
expect(() => multiply(0, 256)).toThrow(RangeError);
expect(() => multiply(1000, 1000)).toThrow(RangeError);
});
});
describe('computeDivisor', () => {
it('computes a degree-1 divisor', () => {
expect(computeDivisor(1)).toEqual(Uint8Array.from([1]));
});
it('computes a degree-2 divisor', () => {
const result = computeDivisor(2);
expect(result).toHaveLength(2);
expect(result).toEqual(Uint8Array.from([3, 2]));
});
it('has correct length for arbitrary degrees', () => {
expect(computeDivisor(7)).toHaveLength(7);
expect(computeDivisor(10)).toHaveLength(10);
expect(computeDivisor(30)).toHaveLength(30);
});
it('returns Uint8Array', () => {
expect(computeDivisor(5)).toBeInstanceOf(Uint8Array);
});
it('throws on degree out of range', () => {
expect(() => computeDivisor(0)).toThrow(RangeError);
expect(() => computeDivisor(256)).toThrow(RangeError);
expect(() => computeDivisor(-1)).toThrow(RangeError);
});
});
describe('computeRemainder', () => {
it('returns zero remainder for empty data', () => {
const divisor = computeDivisor(4);
const result = computeRemainder([], divisor);
expect(result).toEqual(new Uint8Array(4));
});
it('produces non-zero remainder for non-empty data', () => {
const divisor = computeDivisor(7);
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(7);
expect(result).toBeInstanceOf(Uint8Array);
for (const b of result) {
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThanOrEqual(255);
}
});
it('accepts Uint8Array as data input', () => {
const divisor = computeDivisor(7);
const data = Uint8Array.from([0x40, 0xD2, 0x75, 0x47]);
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(7);
expect(result).toBeInstanceOf(Uint8Array);
});
it('remainder length matches divisor length', () => {
for (const degree of [1, 5, 10, 20]) {
const divisor = computeDivisor(degree);
const data = [1, 2, 3, 4, 5];
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(degree);
}
});
});

View File

@@ -0,0 +1,92 @@
/*
* Reed-Solomon error correction over GF(2^8/0x11D)
*
* Based on Project Nayuki's QR Code generator library (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*/
/* -- GF(2^8) exp/log lookup tables (generator α=0x02, primitive polynomial 0x11D) -- */
const GF_EXP = new Uint8Array(256);
const GF_LOG = new Uint8Array(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x = (x << 1) ^ ((x >>> 7) * 0x11D);
}
GF_EXP[255] = GF_EXP[0]!;
}
/**
* Returns the product of the two given field elements modulo GF(2^8/0x11D).
* The arguments and result are unsigned 8-bit integers.
*/
export function multiply(x: number, y: number): number {
if (x >>> 8 !== 0 || y >>> 8 !== 0)
throw new RangeError('Byte out of range');
if (x === 0 || y === 0)
return 0;
return GF_EXP[(GF_LOG[x]! + GF_LOG[y]!) % 255]!;
}
/**
* Returns a Reed-Solomon ECC generator polynomial for the given degree.
*
* Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
* For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
*/
export function computeDivisor(degree: number): Uint8Array {
if (degree < 1 || degree > 255)
throw new RangeError('Degree out of range');
const result = new Uint8Array(degree);
result[degree - 1] = 1;
// Compute the product polynomial (x - r^0) * (x - r^1) * ... * (x - r^{degree-1}),
// dropping the leading term which is always 1x^degree.
// r = 0x02, a generator element of GF(2^8/0x11D).
let root = 0; // GF_LOG[1] = 0, i.e. α^0 = 1
for (let i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (let j = 0; j < degree; j++) {
// result[j] = multiply(result[j], α^root) — inlined for performance
if (result[j] !== 0)
result[j] = GF_EXP[(GF_LOG[result[j]!]! + root) % 255]!;
if (j + 1 < degree)
result[j]! ^= result[j + 1]!;
}
root = (root + 1) % 255; // root tracks log(α^i) = i mod 255
}
return result;
}
/**
* Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
*/
export function computeRemainder(data: ArrayLike<number>, divisor: Uint8Array): Uint8Array {
const len = divisor.length;
const result = new Uint8Array(len);
for (let d = 0, dLen = data.length; d < dLen; d++) {
const factor = data[d]! ^ result[0]!;
// Shift left by 1 position (native memcpy)
result.copyWithin(0, 1);
result[len - 1] = 0;
// XOR with divisor scaled by factor — inlined GF multiply for performance
if (factor !== 0) {
const logFactor = GF_LOG[factor]!;
for (let i = 0; i < len; i++) {
if (divisor[i] !== 0)
result[i]! ^= GF_EXP[(GF_LOG[divisor[i]!]! + logFactor) % 255]!;
}
}
}
return result;
}