129 lines
5.1 KiB
JavaScript
129 lines
5.1 KiB
JavaScript
// Tiny self-contained QR encoder for the short-lived refund upload URL.
|
|
// It intentionally supports QR version 4-L (up to 78 UTF-8 bytes), which is
|
|
// enough for the local upload route and keeps the admin bundle dependency-free.
|
|
const EXP = new Array(512);
|
|
const LOG = new Array(256);
|
|
let value = 1;
|
|
for (let i = 0; i < 255; i += 1) {
|
|
EXP[i] = value;
|
|
LOG[value] = i;
|
|
value <<= 1;
|
|
if (value & 0x100) value ^= 0x11d;
|
|
}
|
|
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255];
|
|
|
|
function multiply(a, b) {
|
|
return a && b ? EXP[LOG[a] + LOG[b]] : 0;
|
|
}
|
|
|
|
function reedSolomon(data, count) {
|
|
let generator = [1];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const next = new Array(generator.length + 1).fill(0);
|
|
generator.forEach((coefficient, index) => {
|
|
next[index] ^= coefficient;
|
|
next[index + 1] ^= multiply(coefficient, EXP[i]);
|
|
});
|
|
generator = next;
|
|
}
|
|
const result = new Array(count).fill(0);
|
|
data.forEach((byte) => {
|
|
const factor = byte ^ result[0];
|
|
result.shift();
|
|
result.push(0);
|
|
for (let i = 0; i < count; i += 1) result[i] ^= multiply(generator[i + 1], factor);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function bchTypeInfo(data) {
|
|
let value = data << 10;
|
|
while (value && value.toString(2).length >= 11) {
|
|
value ^= 0x537 << (value.toString(2).length - 11);
|
|
}
|
|
return ((data << 10) | value) ^ 0x5412;
|
|
}
|
|
|
|
function setFinder(matrix, row, col) {
|
|
for (let r = -1; r <= 7; r += 1) {
|
|
for (let c = -1; c <= 7; c += 1) {
|
|
if (row + r < 0 || row + r >= matrix.length || col + c < 0 || col + c >= matrix.length) continue;
|
|
matrix[row + r][col + c] = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeMatrix(text) {
|
|
const bytes = Array.from(new TextEncoder().encode(text));
|
|
if (bytes.length > 78) throw new Error("二维码内容过长");
|
|
const bits = [];
|
|
const push = (value, length) => { for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1); };
|
|
push(0x4, 4);
|
|
push(bytes.length, 8);
|
|
bytes.forEach((byte) => push(byte, 8));
|
|
for (let i = 0; i < 4 && bits.length < 640; i += 1) bits.push(0);
|
|
while (bits.length % 8) bits.push(0);
|
|
const data = [];
|
|
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((sum, bit) => (sum << 1) | bit, 0));
|
|
// Version 4-L has 100 total codewords: one block of 80 data bytes and
|
|
// 20 Reed-Solomon correction bytes.
|
|
while (data.length < 80) data.push(data.length % 2 ? 0x11 : 0xec);
|
|
const codewords = data.concat(reedSolomon(data, 20));
|
|
const size = 33;
|
|
const matrix = Array.from({ length: size }, () => Array(size).fill(null));
|
|
setFinder(matrix, 0, 0); setFinder(matrix, size - 7, 0); setFinder(matrix, 0, size - 7);
|
|
for (let i = 8; i < size - 8; i += 1) {
|
|
if (matrix[i][6] === null) matrix[i][6] = i % 2 === 0;
|
|
if (matrix[6][i] === null) matrix[6][i] = i % 2 === 0;
|
|
}
|
|
// Version 4 has one alignment pattern at (26, 26).
|
|
for (let r = -2; r <= 2; r += 1) for (let c = -2; c <= 2; c += 1) {
|
|
if (matrix[26 + r][26 + c] === null) matrix[26 + r][26 + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1;
|
|
}
|
|
let bitIndex = 0;
|
|
const writeFormat = (format) => {
|
|
for (let i = 0; i < 15; i += 1) {
|
|
const dark = ((format >>> i) & 1) === 1;
|
|
if (i < 6) matrix[i][8] = dark;
|
|
else if (i < 8) matrix[i + 1][8] = dark;
|
|
else matrix[size - 15 + i][8] = dark;
|
|
if (i < 8) matrix[8][size - i - 1] = dark;
|
|
else if (i < 9) matrix[8][15 - i] = dark;
|
|
else matrix[8][15 - i - 1] = dark;
|
|
}
|
|
matrix[size - 8][8] = true;
|
|
};
|
|
// Reserve these cells before data placement. Otherwise the data walk
|
|
// would overwrite them and the resulting code could not be scanned.
|
|
writeFormat(0);
|
|
let upward = true;
|
|
for (let right = size - 1; right >= 1; right -= 2) {
|
|
if (right === 6) right -= 1;
|
|
for (let offset = 0; offset < size; offset += 1) {
|
|
const row = upward ? size - 1 - offset : offset;
|
|
for (const col of [right, right - 1]) {
|
|
if (matrix[row][col] !== null) continue;
|
|
const byte = codewords[Math.floor(bitIndex / 8)] || 0;
|
|
const raw = ((byte >>> (7 - (bitIndex % 8))) & 1) === 1;
|
|
matrix[row][col] = raw !== ((row + col) % 2 === 0); // mask 0
|
|
bitIndex += 1;
|
|
}
|
|
}
|
|
upward = !upward;
|
|
}
|
|
writeFormat(bchTypeInfo(8)); // error correction L, mask 0
|
|
return matrix;
|
|
}
|
|
|
|
export function qrDataUrl(text, moduleSize = 5) {
|
|
const matrix = makeMatrix(text);
|
|
const quiet = 4;
|
|
const dimension = (matrix.length + quiet * 2) * moduleSize;
|
|
const paths = [];
|
|
matrix.forEach((row, r) => row.forEach((dark, c) => {
|
|
if (dark) paths.push(`M${(c + quiet) * moduleSize} ${(r + quiet) * moduleSize}h${moduleSize}v${moduleSize}h-${moduleSize}z`);
|
|
}));
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dimension} ${dimension}" width="${dimension}" height="${dimension}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${paths.join(" ")}" fill="#0b1a35"/></svg>`;
|
|
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`;
|
|
}
|