|
48 | 48 | window.open(tweetUrl, '_blank', 'width=550,height=420'); |
49 | 49 | } |
50 | 50 |
|
51 | | - function generateQrMatrix(input: string): boolean[][] { |
52 | | - const EC_CODEWORDS: Record<number, number> = { 1: 7, 2: 10, 3: 15, 4: 20, 5: 26, 6: 36, 7: 40, 8: 48, 9: 60, 10: 72 }; |
53 | | - const TOTAL_CODEWORDS: Record<number, number> = { 1: 26, 2: 44, 3: 70, 4: 100, 5: 134, 6: 172, 7: 196, 8: 242, 9: 292, 10: 346 }; |
54 | | - const DATA_CAPACITY: Record<number, number> = {}; |
55 | | - for (let v = 1; v <= 10; v++) DATA_CAPACITY[v] = TOTAL_CODEWORDS[v] - EC_CODEWORDS[v]; |
56 | | -
|
57 | | - const bytes: number[] = []; |
58 | | - for (let i = 0; i < input.length; i++) { |
59 | | - const c = input.charCodeAt(i); |
60 | | - if (c < 128) bytes.push(c); |
61 | | - else if (c < 2048) { bytes.push(192 | (c >> 6)); bytes.push(128 | (c & 63)); } |
62 | | - else { bytes.push(224 | (c >> 12)); bytes.push(128 | ((c >> 6) & 63)); bytes.push(128 | (c & 63)); } |
63 | | - } |
64 | | -
|
65 | | - let version = 1; |
66 | | - for (let v = 1; v <= 10; v++) { |
67 | | - const dataCap = DATA_CAPACITY[v]; |
68 | | - const bitCap = dataCap * 8; |
69 | | - const needed = 4 + 8 + bytes.length * 8; |
70 | | - if (needed <= bitCap) { version = v; break; } |
71 | | - if (v === 10) version = 10; |
72 | | - } |
73 | | -
|
74 | | - const size = 17 + version * 4; |
75 | | - const modules: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); |
76 | | - const reserved: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); |
77 | | -
|
78 | | - function setModule(r: number, c: number, val: boolean, reserve = true) { |
79 | | - if (r >= 0 && r < size && c >= 0 && c < size) { |
80 | | - modules[r][c] = val; |
81 | | - if (reserve) reserved[r][c] = true; |
82 | | - } |
83 | | - } |
84 | | -
|
85 | | - function addFinderPattern(row: number, col: number) { |
86 | | - for (let r = -1; r <= 7; r++) { |
87 | | - for (let c = -1; c <= 7; c++) { |
88 | | - const rr = row + r, cc = col + c; |
89 | | - if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; |
90 | | - const inOuter = r === 0 || r === 6 || c === 0 || c === 6; |
91 | | - const inInner = r >= 2 && r <= 4 && c >= 2 && c <= 4; |
92 | | - const inSep = r === -1 || r === 7 || c === -1 || c === 7; |
93 | | - setModule(rr, cc, (inOuter || inInner) && !inSep); |
94 | | - } |
95 | | - } |
96 | | - } |
97 | | -
|
98 | | - addFinderPattern(0, 0); |
99 | | - addFinderPattern(0, size - 7); |
100 | | - addFinderPattern(size - 7, 0); |
101 | | -
|
102 | | - for (let i = 8; i < size - 8; i++) { |
103 | | - setModule(6, i, i % 2 === 0); |
104 | | - setModule(i, 6, i % 2 === 0); |
105 | | - } |
106 | 51 |
|
107 | | - setModule(size - 8, 8, true); |
108 | | -
|
109 | | - if (version >= 2) { |
110 | | - const alignPos = [6, size - 7]; |
111 | | - for (const ar of alignPos) { |
112 | | - for (const ac of alignPos) { |
113 | | - if (reserved[ar][ac]) continue; |
114 | | - for (let r = -2; r <= 2; r++) { |
115 | | - for (let c = -2; c <= 2; c++) { |
116 | | - const val = Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0); |
117 | | - setModule(ar + r, ac + c, val); |
118 | | - } |
119 | | - } |
120 | | - } |
121 | | - } |
122 | | - } |
123 | | -
|
124 | | - for (let i = 0; i < 15; i++) { |
125 | | - const r1 = i < 6 ? i : i < 8 ? i + 1 : size - 15 + i; |
126 | | - const c1 = 8; |
127 | | - reserved[r1][c1] = true; |
128 | | - const r2 = 8; |
129 | | - const c2 = i < 8 ? size - 1 - i : i < 9 ? 15 - i : 14 - i; |
130 | | - reserved[r2][c2] = true; |
131 | | - } |
132 | | -
|
133 | | - const dataCap = DATA_CAPACITY[version]; |
134 | | - const bits: number[] = []; |
135 | | - function pushBits(val: number, len: number) { |
136 | | - for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); |
137 | | - } |
138 | | -
|
139 | | - pushBits(0b0100, 4); |
140 | | - pushBits(bytes.length, 8); |
141 | | - for (const b of bytes) pushBits(b, 8); |
142 | | - pushBits(0, Math.min(4, dataCap * 8 - bits.length)); |
143 | | - while (bits.length % 8 !== 0) bits.push(0); |
144 | | - const pads = [0xEC, 0x11]; |
145 | | - let padIdx = 0; |
146 | | - while (bits.length < dataCap * 8) { |
147 | | - pushBits(pads[padIdx % 2], 8); |
148 | | - padIdx++; |
149 | | - } |
150 | | -
|
151 | | - const dataCodewords: number[] = []; |
152 | | - for (let i = 0; i < bits.length; i += 8) { |
153 | | - let val = 0; |
154 | | - for (let j = 0; j < 8; j++) val = (val << 1) | (bits[i + j] || 0); |
155 | | - dataCodewords.push(val); |
156 | | - } |
157 | | -
|
158 | | - const numEc = EC_CODEWORDS[version]; |
159 | | - const gfExp: number[] = new Array(256); |
160 | | - const gfLog: number[] = new Array(256); |
161 | | - let x = 1; |
162 | | - for (let i = 0; i < 255; i++) { |
163 | | - gfExp[i] = x; |
164 | | - gfLog[x] = i; |
165 | | - x <<= 1; |
166 | | - if (x >= 256) x ^= 0x11D; |
167 | | - } |
168 | | - gfExp[255] = gfExp[0]; |
169 | | -
|
170 | | - function gfMul(a: number, b: number): number { |
171 | | - if (a === 0 || b === 0) return 0; |
172 | | - return gfExp[(gfLog[a] + gfLog[b]) % 255]; |
173 | | - } |
174 | | -
|
175 | | - const gen: number[] = [1]; |
176 | | - for (let i = 0; i < numEc; i++) { |
177 | | - const newGen = new Array(gen.length + 1).fill(0); |
178 | | - for (let j = 0; j < gen.length; j++) { |
179 | | - newGen[j] ^= gfMul(gen[j], gfExp[i]); |
180 | | - newGen[j + 1] ^= gen[j]; |
181 | | - } |
182 | | - gen.length = 0; |
183 | | - gen.push(...newGen); |
184 | | - } |
185 | | -
|
186 | | - const msgPoly = new Array(dataCodewords.length + numEc).fill(0); |
187 | | - for (let i = 0; i < dataCodewords.length; i++) msgPoly[i] = dataCodewords[i]; |
188 | | - for (let i = 0; i < dataCodewords.length; i++) { |
189 | | - const coef = msgPoly[i]; |
190 | | - if (coef !== 0) { |
191 | | - for (let j = 0; j < gen.length; j++) { |
192 | | - msgPoly[i + j] ^= gfMul(gen[j], coef); |
193 | | - } |
194 | | - } |
195 | | - } |
196 | | - const ecCodewords = msgPoly.slice(dataCodewords.length); |
197 | | -
|
198 | | - const allCodewords = [...dataCodewords, ...ecCodewords]; |
199 | | - const allBits: number[] = []; |
200 | | - for (const cw of allCodewords) { |
201 | | - for (let i = 7; i >= 0; i--) allBits.push((cw >> i) & 1); |
202 | | - } |
203 | | -
|
204 | | - let bitIndex = 0; |
205 | | - let upward = true; |
206 | | - for (let col = size - 1; col >= 0; col -= 2) { |
207 | | - if (col === 6) col = 5; |
208 | | - const rows = upward ? Array.from({ length: size }, (_, i) => size - 1 - i) : Array.from({ length: size }, (_, i) => i); |
209 | | - for (const row of rows) { |
210 | | - for (const dc of [0, -1]) { |
211 | | - const cc = col + dc; |
212 | | - if (cc < 0 || cc >= size) continue; |
213 | | - if (reserved[row][cc]) continue; |
214 | | - modules[row][cc] = bitIndex < allBits.length ? allBits[bitIndex] === 1 : false; |
215 | | - bitIndex++; |
216 | | - } |
217 | | - } |
218 | | - upward = !upward; |
219 | | - } |
220 | | -
|
221 | | - for (let r = 0; r < size; r++) { |
222 | | - for (let c = 0; c < size; c++) { |
223 | | - if (reserved[r][c]) continue; |
224 | | - if ((r + c) % 2 === 0) modules[r][c] = !modules[r][c]; |
225 | | - } |
226 | | - } |
227 | | -
|
228 | | - const formatBits = (1 << 10) | (0 << 3); |
229 | | - let rem = formatBits; |
230 | | - for (let i = 0; i < 5; i++) { |
231 | | - if (rem & (1 << (14 - i))) rem ^= 0x537 << (4 - i); |
232 | | - } |
233 | | - let formatInfo = (formatBits | rem) ^ 0x5412; |
234 | | -
|
235 | | - for (let i = 0; i < 15; i++) { |
236 | | - const bit = ((formatInfo >> (14 - i)) & 1) === 1; |
237 | | - const r1 = i < 6 ? i : i < 8 ? i + 1 : size - 15 + i; |
238 | | - modules[r1][8] = bit; |
239 | | - const c2 = i < 8 ? size - 1 - i : i < 9 ? 15 - i : 14 - i; |
240 | | - modules[8][c2] = bit; |
241 | | - } |
242 | | -
|
243 | | - return modules; |
244 | | - } |
245 | | -
|
246 | | - function qrToSvg(url: string, cellSize = 8, margin = 16): string { |
247 | | - const matrix = generateQrMatrix(url); |
248 | | - const qrSize = matrix.length; |
249 | | - const svgSize = qrSize * cellSize + margin * 2; |
250 | | -
|
251 | | - let rects = ''; |
252 | | - for (let r = 0; r < qrSize; r++) { |
253 | | - for (let c = 0; c < qrSize; c++) { |
254 | | - if (matrix[r][c]) { |
255 | | - rects += `<rect x="${margin + c * cellSize}" y="${margin + r * cellSize}" width="${cellSize}" height="${cellSize}" rx="1.5"/>`; |
256 | | - } |
257 | | - } |
258 | | - } |
259 | | -
|
260 | | - return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgSize} ${svgSize}" width="${svgSize}" height="${svgSize}"> |
261 | | -<rect width="${svgSize}" height="${svgSize}" fill="#ffffff" rx="12"/> |
262 | | -${rects} |
263 | | -</svg>`; |
264 | | - } |
265 | | -
|
266 | | - function downloadQrPng() { |
267 | | - const svg = qrToSvg(getShareUrl(), 10, 20); |
268 | | - const blob = new Blob([svg], { type: 'image/svg+xml' }); |
269 | | - const url = URL.createObjectURL(blob); |
270 | | - const img = new Image(); |
271 | | - img.onload = () => { |
272 | | - const canvas = document.createElement('canvas'); |
273 | | - canvas.width = img.width * 2; |
274 | | - canvas.height = img.height * 2; |
275 | | - const ctx = canvas.getContext('2d')!; |
276 | | - ctx.scale(2, 2); |
277 | | - ctx.drawImage(img, 0, 0); |
278 | | - URL.revokeObjectURL(url); |
279 | | - canvas.toBlob((pngBlob) => { |
280 | | - if (!pngBlob) return; |
281 | | - const a = document.createElement('a'); |
282 | | - a.href = URL.createObjectURL(pngBlob); |
283 | | - a.download = `${data.config.name.replace(/\s+/g, '-').toLowerCase()}-qr.png`; |
284 | | - a.click(); |
285 | | - URL.revokeObjectURL(a.href); |
286 | | - }, 'image/png'); |
287 | | - }; |
288 | | - img.src = url; |
289 | | - } |
290 | 52 |
|
291 | 53 | async function forkConfig() { |
292 | 54 | forking = true; |
@@ -708,22 +470,7 @@ ${rects} |
708 | 470 | <span class="share-option-label">Share on X</span> |
709 | 471 | </button> |
710 | 472 |
|
711 | | - <div class="share-qr-section"> |
712 | | - <div class="share-qr-header"> |
713 | | - <span class="share-option-icon"> |
714 | | - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/><line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/></svg> |
715 | | - </span> |
716 | | - <span class="share-qr-title">QR Code</span> |
717 | | - </div> |
718 | | - <div class="share-qr-code"> |
719 | | - {@html qrToSvg(getShareUrl())} |
720 | | - </div> |
721 | | - <button class="share-qr-download" onclick={downloadQrPng}> |
722 | | - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
723 | | - Download PNG |
724 | | - </button> |
725 | 473 | </div> |
726 | | - </div> |
727 | 474 | </div> |
728 | 475 | </div> |
729 | 476 | </div> |
@@ -1498,64 +1245,6 @@ ${rects} |
1498 | 1245 | font-weight: 500; |
1499 | 1246 | } |
1500 | 1247 |
|
1501 | | - .share-qr-section { |
1502 | | - display: flex; |
1503 | | - flex-direction: column; |
1504 | | - align-items: center; |
1505 | | - gap: 12px; |
1506 | | - padding: 16px; |
1507 | | - background: var(--bg-tertiary); |
1508 | | - border: 1px solid var(--border); |
1509 | | - border-radius: 10px; |
1510 | | - } |
1511 | | -
|
1512 | | - .share-qr-header { |
1513 | | - display: flex; |
1514 | | - align-items: center; |
1515 | | - gap: 12px; |
1516 | | - align-self: flex-start; |
1517 | | - } |
1518 | | -
|
1519 | | - .share-qr-title { |
1520 | | - font-size: 0.9rem; |
1521 | | - font-weight: 500; |
1522 | | - color: var(--text-primary); |
1523 | | - } |
1524 | | -
|
1525 | | - .share-qr-code { |
1526 | | - background: #ffffff; |
1527 | | - border-radius: 12px; |
1528 | | - padding: 8px; |
1529 | | - display: flex; |
1530 | | - align-items: center; |
1531 | | - justify-content: center; |
1532 | | - } |
1533 | | -
|
1534 | | - .share-qr-code :global(svg) { |
1535 | | - width: 180px; |
1536 | | - height: 180px; |
1537 | | - } |
1538 | | -
|
1539 | | - .share-qr-download { |
1540 | | - display: flex; |
1541 | | - align-items: center; |
1542 | | - gap: 6px; |
1543 | | - padding: 8px 16px; |
1544 | | - background: var(--bg-secondary); |
1545 | | - border: 1px solid var(--border); |
1546 | | - border-radius: 6px; |
1547 | | - color: var(--text-secondary); |
1548 | | - font-size: 0.8rem; |
1549 | | - font-family: inherit; |
1550 | | - cursor: pointer; |
1551 | | - transition: all 0.2s; |
1552 | | - } |
1553 | | -
|
1554 | | - .share-qr-download:hover { |
1555 | | - border-color: var(--accent); |
1556 | | - color: var(--accent); |
1557 | | - } |
1558 | | -
|
1559 | 1248 | @media (max-width: 600px) { |
1560 | 1249 | .config-name { |
1561 | 1250 | font-size: 1.5rem; |
|
0 commit comments