|
1 | | -export * from "./sparkline.js"; |
2 | | -export * from "./webhook.js"; |
3 | | -export * from "./social.js"; |
4 | | -export * from "./qrcode.js"; |
| 1 | +import type { QRImageOptions, ASCIIQROptions, QRCodeErrorCorrectionLevel } from "./types.js"; |
| 2 | + |
| 3 | +// QR Code capacity constants |
| 4 | +const MODE_NUMERIC = 1; |
| 5 | +const MODE_ALPHANUMERIC = 2; |
| 6 | +const MODE_BYTE = 4; |
| 7 | + |
| 8 | +// Error correction levels |
| 9 | +const ERROR_CORRECTION_BITS: Record<QRCodeErrorCorrectionLevel, number> = { |
| 10 | + 'L': 0, |
| 11 | + 'M': 1, |
| 12 | + 'Q': 2, |
| 13 | + 'H': 3 |
| 14 | +}; |
| 15 | + |
| 16 | +// Version 1 QR Code constants (21x21 modules) |
| 17 | +const VERSION_1_SIZE = 21; |
| 18 | +const VERSION_1_DATA_BITS: Record<QRCodeErrorCorrectionLevel, number> = { |
| 19 | + 'L': 152, |
| 20 | + 'M': 128, |
| 21 | + 'Q': 104, |
| 22 | + 'H': 72 |
| 23 | +}; |
| 24 | + |
| 25 | +// Alphanumeric character set |
| 26 | +const ALPHANUMERIC_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; |
| 27 | + |
| 28 | +interface QRMatrix { |
| 29 | + modules: boolean[][]; |
| 30 | + size: number; |
| 31 | +} |
| 32 | + |
| 33 | +function getMode(data: string): number { |
| 34 | + if (/^[0-9]*$/.test(data)) return MODE_NUMERIC; |
| 35 | + if (/^[0-9A-Z $%*+\-./:]*$/.test(data)) return MODE_ALPHANUMERIC; |
| 36 | + return MODE_BYTE; |
| 37 | +} |
| 38 | + |
| 39 | +function encodeData(data: string, mode: number): number[] { |
| 40 | + const bits: number[] = []; |
| 41 | + |
| 42 | + // Mode indicator (4 bits) |
| 43 | + for (let i = 3; i >= 0; i--) { |
| 44 | + bits.push((mode >> i) & 1); |
| 45 | + } |
| 46 | + |
| 47 | + // Character count indicator (Version 1: 10 bits for numeric, 9 for alphanumeric, 8 for byte) |
| 48 | + let countBits = 8; |
| 49 | + if (mode === MODE_NUMERIC) countBits = 10; |
| 50 | + else if (mode === MODE_ALPHANUMERIC) countBits = 9; |
| 51 | + |
| 52 | + for (let i = countBits - 1; i >= 0; i--) { |
| 53 | + bits.push((data.length >> i) & 1); |
| 54 | + } |
| 55 | + |
| 56 | + // Data encoding |
| 57 | + if (mode === MODE_NUMERIC) { |
| 58 | + for (let i = 0; i < data.length; i += 3) { |
| 59 | + const chunk = data.slice(i, i + 3); |
| 60 | + const num = parseInt(chunk, 10); |
| 61 | + const bitLength = chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4; |
| 62 | + for (let j = bitLength - 1; j >= 0; j--) { |
| 63 | + bits.push((num >> j) & 1); |
| 64 | + } |
| 65 | + } |
| 66 | + } else if (mode === MODE_ALPHANUMERIC) { |
| 67 | + for (let i = 0; i < data.length; i += 2) { |
| 68 | + if (i + 1 < data.length) { |
| 69 | + const val1 = ALPHANUMERIC_CHARS.indexOf(data[i]); |
| 70 | + const val2 = ALPHANUMERIC_CHARS.indexOf(data[i + 1]); |
| 71 | + const combined = val1 * 45 + val2; |
| 72 | + for (let j = 10; j >= 0; j--) { |
| 73 | + bits.push((combined >> j) & 1); |
| 74 | + } |
| 75 | + } else { |
| 76 | + const val = ALPHANUMERIC_CHARS.indexOf(data[i]); |
| 77 | + for (let j = 5; j >= 0; j--) { |
| 78 | + bits.push((val >> j) & 1); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + } else { |
| 83 | + // Byte mode |
| 84 | + for (const char of data) { |
| 85 | + const code = char.charCodeAt(0); |
| 86 | + for (let j = 7; j >= 0; j--) { |
| 87 | + bits.push((code >> j) & 1); |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + // Terminator (4 zeros) |
| 93 | + for (let i = 0; i < 4 && bits.length < VERSION_1_DATA_BITS['M']; i++) { |
| 94 | + bits.push(0); |
| 95 | + } |
| 96 | + |
| 97 | + // Pad to byte boundary |
| 98 | + while (bits.length % 8 !== 0) { |
| 99 | + bits.push(0); |
| 100 | + } |
| 101 | + |
| 102 | + // Pad bytes (alternating 11101100 and 00010001) |
| 103 | + const padBytes = [0xEC, 0x11]; |
| 104 | + let padIdx = 0; |
| 105 | + while (bits.length < VERSION_1_DATA_BITS['M']) { |
| 106 | + const byte = padBytes[padIdx % 2]; |
| 107 | + for (let j = 7; j >= 0; j--) { |
| 108 | + bits.push((byte >> j) & 1); |
| 109 | + } |
| 110 | + padIdx++; |
| 111 | + } |
| 112 | + |
| 113 | + return bits; |
| 114 | +} |
| 115 | + |
| 116 | +function createMatrix(data: string): QRMatrix { |
| 117 | + const size = VERSION_1_SIZE; |
| 118 | + const modules: boolean[][] = Array(size).fill(null).map(() => Array(size).fill(false)); |
| 119 | + |
| 120 | + // Add finder patterns (corners) |
| 121 | + const addFinderPattern = (row: number, col: number) => { |
| 122 | + for (let r = 0; r < 7; r++) { |
| 123 | + for (let c = 0; c < 7; c++) { |
| 124 | + const isBorder = r === 0 || r === 6 || c === 0 || c === 6; |
| 125 | + const isInner = r >= 2 && r <= 4 && c >= 2 && c <= 4; |
| 126 | + modules[row + r][col + c] = isBorder || isInner; |
| 127 | + } |
| 128 | + } |
| 129 | + }; |
| 130 | + |
| 131 | + addFinderPattern(0, 0); // Top-left |
| 132 | + addFinderPattern(0, size - 7); // Top-right |
| 133 | + addFinderPattern(size - 7, 0); // Bottom-left |
| 134 | + |
| 135 | + // Add separators (white borders around finders) |
| 136 | + const addSeparator = (row: number, col: number, w: number, h: number) => { |
| 137 | + for (let r = -1; r <= h; r++) { |
| 138 | + for (let c = -1; c <= w; c++) { |
| 139 | + const mr = row + r; |
| 140 | + const mc = col + c; |
| 141 | + if (mr >= 0 && mr < size && mc >= 0 && mc < size) { |
| 142 | + if (r === -1 || r === h || c === -1 || c === w) { |
| 143 | + modules[mr][mc] = false; |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + }; |
| 149 | + |
| 150 | + addSeparator(0, 0, 7, 7); |
| 151 | + addSeparator(0, size - 7, 7, 7); |
| 152 | + addSeparator(size - 7, 0, 7, 7); |
| 153 | + |
| 154 | + // Add timing patterns |
| 155 | + for (let i = 8; i < size - 8; i++) { |
| 156 | + modules[6][i] = i % 2 === 0; |
| 157 | + modules[i][6] = i % 2 === 0; |
| 158 | + } |
| 159 | + |
| 160 | + // Add dark module |
| 161 | + modules[size - 8][8] = true; |
| 162 | + |
| 163 | + // Add format info (simplified - uses mask pattern 000) |
| 164 | + const formatInfo = 0b0101000000011100; // Mask 000, Error correction M |
| 165 | + for (let i = 0; i < 15; i++) { |
| 166 | + const bit = (formatInfo >> i) & 1; |
| 167 | + if (i < 6) { |
| 168 | + modules[8][i] = !!bit; |
| 169 | + modules[size - 1 - i][8] = !!bit; |
| 170 | + } else if (i < 8) { |
| 171 | + modules[8][i + 1] = !!bit; |
| 172 | + modules[size - 7 + i][8] = !!bit; |
| 173 | + } else if (i < 9) { |
| 174 | + modules[7][8] = !!bit; |
| 175 | + modules[8][size - 8] = !!bit; |
| 176 | + } else { |
| 177 | + modules[14 - i][8] = !!bit; |
| 178 | + modules[8][size - 15 + i] = !!bit; |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + // Place data bits |
| 183 | + const mode = getMode(data); |
| 184 | + const bits = encodeData(data, mode); |
| 185 | + let bitIdx = 0; |
| 186 | + |
| 187 | + // Upward columns |
| 188 | + for (let col = size - 1; col > 0; col -= 2) { |
| 189 | + if (col === 6) col--; // Skip timing column |
| 190 | + |
| 191 | + for (let row = 0; row < size; row++) { |
| 192 | + const actualRow = (col < size - 8) ? size - 1 - row : size - 1 - row; |
| 193 | + |
| 194 | + for (let c = 0; c < 2; c++) { |
| 195 | + const currentCol = col - c; |
| 196 | + if (currentCol < 0) continue; |
| 197 | + |
| 198 | + // Skip function patterns |
| 199 | + if (modules[actualRow][currentCol] !== false && |
| 200 | + !isFunctionPattern(actualRow, currentCol, size)) { |
| 201 | + if (bitIdx < bits.length) { |
| 202 | + modules[actualRow][currentCol] = !!bits[bitIdx]; |
| 203 | + bitIdx++; |
| 204 | + } |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + return { modules, size }; |
| 211 | +} |
| 212 | + |
| 213 | +function isFunctionPattern(row: number, col: number, size: number): boolean { |
| 214 | + // Finder patterns and separators |
| 215 | + if ((row < 9 && col < 9) || (row < 9 && col >= size - 8) || (row >= size - 8 && col < 9)) { |
| 216 | + return true; |
| 217 | + } |
| 218 | + // Timing patterns |
| 219 | + if (row === 6 || col === 6) return true; |
| 220 | + // Dark module |
| 221 | + if (row === size - 8 && col === 8) return true; |
| 222 | + // Format info |
| 223 | + if ((row === 8 && col < 9) || (row === 8 && col >= size - 8) || |
| 224 | + (col === 8 && row < 9) || (col === 8 && row >= size - 7)) return true; |
| 225 | + return false; |
| 226 | +} |
| 227 | + |
| 228 | +export function generateQRCode(text: string, options: QRImageOptions = {}): string { |
| 229 | + if (!text || text.length === 0) { |
| 230 | + throw new Error("Input text cannot be empty"); |
| 231 | + } |
| 232 | + |
| 233 | + const { |
| 234 | + errorCorrection = 'M', |
| 235 | + margin = 4, |
| 236 | + size = 21, |
| 237 | + darkColor = '#000000', |
| 238 | + lightColor = '#ffffff' |
| 239 | + } = options; |
| 240 | + |
| 241 | + const matrix = createMatrix(text); |
| 242 | + const moduleCount = matrix.size; |
| 243 | + const moduleSize = Math.max(1, Math.floor(size / moduleCount)); |
| 244 | + const actualSize = moduleCount * moduleSize; |
| 245 | + const totalSize = actualSize + (margin * 2 * moduleSize); |
| 246 | + |
| 247 | + let svg = `<svg width="${totalSize}" height="${totalSize}" viewBox="0 0 ${totalSize} ${totalSize}" xmlns="http://www.w3.org/2000/svg">`; |
| 248 | + |
| 249 | + // Background |
| 250 | + svg += `<rect width="100%" height="100%" fill="${lightColor}"/>`; |
| 251 | + |
| 252 | + // QR modules |
| 253 | + const offset = margin * moduleSize; |
| 254 | + for (let y = 0; y < moduleCount; y++) { |
| 255 | + for (let x = 0; x < moduleCount; x++) { |
| 256 | + if (matrix.modules[y][x]) { |
| 257 | + svg += `<rect x="${offset + x * moduleSize}" y="${offset + y * moduleSize}" width="${moduleSize}" height="${moduleSize}" fill="${darkColor}"/>`; |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + svg += '</svg>'; |
| 263 | + return svg; |
| 264 | +} |
| 265 | + |
| 266 | +export function generateASCIIQR(text: string, options: ASCIIQROptions = {}): string { |
| 267 | + if (!text || text.length === 0) { |
| 268 | + throw new Error("Input text cannot be empty"); |
| 269 | + } |
| 270 | + |
| 271 | + const { |
| 272 | + errorCorrection = 'M', |
| 273 | + margin = 4, |
| 274 | + inverted = false, |
| 275 | + blockChar = '██', |
| 276 | + whiteChar = ' ' |
| 277 | + } = options; |
| 278 | + |
| 279 | + const matrix = createMatrix(text); |
| 280 | + const { modules, size } = matrix; |
| 281 | + |
| 282 | + const dark = inverted ? whiteChar : blockChar; |
| 283 | + const light = inverted ? blockChar : whiteChar; |
| 284 | + |
| 285 | + let result = ''; |
| 286 | + |
| 287 | + // Top margin |
| 288 | + for (let i = 0; i < margin; i++) { |
| 289 | + result += light.repeat(size + margin * 2) + '\n'; |
| 290 | + } |
| 291 | + |
| 292 | + // QR content |
| 293 | + for (let y = 0; y < size; y++) { |
| 294 | + result += light.repeat(margin); |
| 295 | + for (let x = 0; x < size; x++) { |
| 296 | + result += modules[y][x] ? dark : light; |
| 297 | + } |
| 298 | + result += light.repeat(margin) + '\n'; |
| 299 | + } |
| 300 | + |
| 301 | + // Bottom margin |
| 302 | + for (let i = 0; i < margin; i++) { |
| 303 | + result += light.repeat(size + margin * 2) + '\n'; |
| 304 | + } |
| 305 | + |
| 306 | + return result.slice(0, -1); // Remove last newline |
| 307 | +} |
0 commit comments