Files
WebDrop/web/src/lib/qr.ts
T
2026-10-02 00:59:37 +08:00

304 lines
9.7 KiB
TypeScript

/**
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
*/
const ECC_PER_BLOCK_M = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26] as const;
const NUM_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] as const;
const MAX_VERSION = 10;
const FORMAT_M = 0; // 纠错等级 M 的格式位值
type Grid = boolean[][];
function rawModules(ver: number): number {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
if (ver >= 7) n -= 36;
}
return n;
}
const rawCodewords = (ver: number) => Math.floor(rawModules(ver) / 8);
const dataCodewords = (ver: number) => rawCodewords(ver) - ECC_PER_BLOCK_M[ver]! * NUM_BLOCKS_M[ver]!;
// ---- GF(256) 与 Reed-Solomon ----
function gfMul(x: number, y: number): number {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
function rsDivisor(degree: number): number[] {
const result = new Array<number>(degree).fill(0);
result[degree - 1] = 1;
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = gfMul(result[j]!, root);
if (j + 1 < degree) result[j]! ^= result[j + 1]!;
}
root = gfMul(root, 2);
}
return result;
}
function rsRemainder(data: number[], divisor: number[]): number[] {
const result = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ result.shift()!;
result.push(0);
divisor.forEach((coef, i) => {
result[i]! ^= gfMul(coef, factor);
});
}
return result;
}
// ---- 数据编码 ----
function encodeData(bytes: Uint8Array): { ver: number; codewords: number[] } {
let ver = 1;
const bitsFor = (v: number) => 4 + (v < 10 ? 8 : 16) + bytes.length * 8;
while (ver <= MAX_VERSION && bitsFor(ver) > dataCodewords(ver) * 8) ver++;
if (ver > MAX_VERSION) throw new RangeError('内容太长,无法编码为二维码');
const bits: number[] = [];
const push = (value: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};
push(0b0100, 4);
push(bytes.length, ver < 10 ? 8 : 16);
for (const b of bytes) push(b, 8);
const capacity = dataCodewords(ver) * 8;
push(0, Math.min(4, capacity - bits.length));
push(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8);
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
}
return { ver, codewords: addEccAndInterleave(data, ver) };
}
function addEccAndInterleave(data: number[], ver: number): number[] {
const blocks = NUM_BLOCKS_M[ver]!;
const eccLen = ECC_PER_BLOCK_M[ver]!;
const raw = rawCodewords(ver);
const shortLen = Math.floor(raw / blocks);
const numShort = blocks - (raw % blocks);
const divisor = rsDivisor(eccLen);
const parts: number[][] = [];
for (let i = 0, k = 0; i < blocks; i++) {
const len = shortLen - eccLen + (i < numShort ? 0 : 1);
const dat = data.slice(k, k + len);
k += len;
const ecc = rsRemainder(dat, divisor);
if (i < numShort) dat.push(0);
parts.push(dat.concat(ecc));
}
const out: number[] = [];
for (let i = 0; i < parts[0]!.length; i++) {
parts.forEach((block, j) => {
if (i !== shortLen - eccLen || j >= numShort) out.push(block[i]!);
});
}
return out;
}
// ---- 矩阵 ----
class Matrix {
readonly size: number;
readonly modules: Grid;
readonly isFn: Grid;
constructor(readonly ver: number) {
this.size = ver * 4 + 17;
this.modules = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.isFn = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.drawFunctionPatterns();
}
private setFn(x: number, y: number, dark: boolean) {
this.modules[y]![x] = dark;
this.isFn[y]![x] = true;
}
private drawFunctionPatterns() {
const { size, ver } = this;
for (let i = 0; i < size; i++) {
this.setFn(6, i, i % 2 === 0);
this.setFn(i, 6, i % 2 === 0);
}
this.finder(3, 3);
this.finder(size - 4, 3);
this.finder(3, size - 4);
const pos = alignPositions(ver);
pos.forEach((cy, i) =>
pos.forEach((cx, j) => {
const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0);
if (!corner) this.align(cx, cy);
}),
);
this.drawFormat(0);
this.drawVersion();
}
private finder(cx: number, cy: number) {
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 x = cx + dx;
const y = cy + dy;
if (x >= 0 && x < this.size && y >= 0 && y < this.size) this.setFn(x, y, dist !== 2 && dist !== 4);
}
}
}
private align(cx: number, cy: number) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) this.setFn(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
drawFormat(mask: number) {
const data = (FORMAT_M << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
const bit = (i: number) => ((bits >>> i) & 1) !== 0;
const { size } = this;
for (let i = 0; i <= 5; i++) this.setFn(8, i, bit(i));
this.setFn(8, 7, bit(6));
this.setFn(8, 8, bit(7));
this.setFn(7, 8, bit(8));
for (let i = 9; i < 15; i++) this.setFn(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) this.setFn(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) this.setFn(8, size - 15 + i, bit(i));
this.setFn(8, size - 8, true);
}
private drawVersion() {
if (this.ver < 7) return;
let rem = this.ver;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (this.ver << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = this.size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.setFn(a, b, dark);
this.setFn(b, a, dark);
}
}
drawCodewords(data: number[]) {
const { size } = this;
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;
if (!this.isFn[y]![x] && i < data.length * 8) {
this.modules[y]![x] = ((data[i >>> 3]! >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
}
}
applyMask(mask: number) {
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
if (!this.isFn[y]![x] && maskCondition(mask, x, y)) this.modules[y]![x] = !this.modules[y]![x];
}
}
}
/** 简化的惩罚分:同色连续、同色 2×2 块、深浅失衡。只影响掩码选择,不影响可读性 */
penalty(): number {
const { size, modules } = this;
let score = 0;
for (let y = 0; y < size; y++) {
for (const horizontal of [true, false]) {
let run = 1;
for (let i = 1; i < size; i++) {
const cur = horizontal ? modules[y]![i] : modules[i]![y];
const prev = horizontal ? modules[y]![i - 1] : modules[i - 1]![y];
run = cur === prev ? run + 1 : 1;
if (run === 5) score += 3;
else if (run > 5) score += 1;
}
}
}
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const c = modules[y]![x];
if (c === modules[y]![x + 1] && c === modules[y + 1]![x] && c === modules[y + 1]![x + 1]) score += 3;
}
}
const dark = modules.reduce((n, row) => n + row.filter(Boolean).length, 0);
return score + Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
}
}
function alignPositions(ver: number): number[] {
if (ver === 1) return [];
const n = Math.floor(ver / 7) + 2;
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
const result = [6];
for (let pos = ver * 4 + 10; result.length < n; pos -= step) result.splice(1, 0, pos);
return result;
}
function maskCondition(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0: return (x + y) % 2 === 0;
case 1: return y % 2 === 0;
case 2: return x % 3 === 0;
case 3: return (x + y) % 3 === 0;
case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
case 5: return ((x * y) % 2) + ((x * y) % 3) === 0;
case 6: return ((((x * y) % 2) + ((x * y) % 3)) % 2) === 0;
default: return ((((x + y) % 2) + ((x * y) % 3)) % 2) === 0;
}
}
/** 编码为深浅矩阵(true = 深色)。mask 仅供测试固定掩码,默认自动选择 */
export function encodeQr(text: string, mask?: number): Grid {
const { ver, codewords } = encodeData(new TextEncoder().encode(text));
const m = new Matrix(ver);
m.drawCodewords(codewords);
let best = mask ?? 0;
if (mask === undefined) {
let bestScore = Infinity;
for (let candidate = 0; candidate < 8; candidate++) {
m.applyMask(candidate);
m.drawFormat(candidate);
const score = m.penalty();
if (score < bestScore) {
bestScore = score;
best = candidate;
}
m.applyMask(candidate); // 还原
}
}
m.applyMask(best);
m.drawFormat(best);
return m.modules;
}