diff --git a/package.json b/package.json index fa4e5fd..20db167 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "js-md5": "^0.8.3", "js-yaml": "^4.1.1", "jsonpath-plus": "^10.4.0", + "jsqr": "^1.4.0", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 715ac0f..fc266e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: jsonpath-plus: specifier: ^10.4.0 version: 10.4.0 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 lucide-react: specifier: ^1.8.0 version: 1.8.0(react@19.2.5) @@ -1896,6 +1899,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4315,6 +4321,8 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 + jsqr@1.4.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 diff --git a/src/i18n/en.json b/src/i18n/en.json index 786d11b..4c4f261 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -503,7 +503,19 @@ "includeNumber": "Append a number" }, "qr": { - "description": "Generate a QR code (SVG / PNG). Rendered locally; content never leaves your browser. Type tabs compose URL / WiFi / vCard / Email / SMS / GeoURI payloads.", + "description": "Generate a QR code (SVG / PNG) or decode one from an image. Everything runs locally; content never leaves your browser. Type tabs compose URL / WiFi / vCard / Email / SMS / GeoURI payloads.", + "viewGenerate": "Generate", + "viewDecode": "Decode", + "decode": { + "drop": "Drop a QR image here, or click to choose", + "hint": "PNG / JPEG / WebP / GIF — decoded locally with no upload", + "decoding": "Decoding…", + "result": "Decoded content", + "copied": "Copied decoded content", + "open": "Open link", + "notFound": "No QR code found in this image.", + "notImage": "Please choose an image file." + }, "content": "Content", "contentPlaceholder": "URL / text / vCard…", "size": "Size (px)", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index c5e59ce..ebf8fa1 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -503,7 +503,19 @@ "includeNumber": "附加一位数字" }, "qr": { - "description": "生成二维码(SVG / PNG)。本地渲染,输入内容不出浏览器。类型选项卡可组合 URL / WiFi / vCard / Email / SMS / GeoURI 等载荷。", + "description": "生成二维码(SVG / PNG),或从图片中解码二维码。全部在本地完成,内容不出浏览器。类型选项卡可组合 URL / WiFi / vCard / Email / SMS / GeoURI 等载荷。", + "viewGenerate": "生成", + "viewDecode": "解码", + "decode": { + "drop": "将二维码图片拖到这里,或点击选择", + "hint": "PNG / JPEG / WebP / GIF —— 本地解码,不会上传", + "decoding": "解码中…", + "result": "解码内容", + "copied": "已复制解码内容", + "open": "打开链接", + "notFound": "未在图片中识别到二维码。", + "notImage": "请选择图片文件。" + }, "content": "内容", "contentPlaceholder": "URL / 文本 / vCard…", "size": "尺寸 (px)", diff --git a/src/lib/__tests__/qr-decode.test.ts b/src/lib/__tests__/qr-decode.test.ts new file mode 100644 index 0000000..bc2d5a1 --- /dev/null +++ b/src/lib/__tests__/qr-decode.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import QRCode from 'qrcode' +import { decodeQrFromImageData } from '@/lib/qr-decode' + +/** + * Round-trip test with no browser: render a QR to a bit matrix via the same + * `qrcode` lib the generator uses, rasterise it into an RGBA buffer (scaled up + * with a quiet zone so jsQR can lock on), then decode it back. + */ +function renderQrToImageData( + text: string, + scale = 10, + quietModules = 4, +): { data: Uint8ClampedArray; width: number; height: number } { + const qr = QRCode.create(text, { errorCorrectionLevel: 'M' }) + const size = qr.modules.size + const bits = qr.modules.data // 1 = dark module + const dim = (size + quietModules * 2) * scale + const data = new Uint8ClampedArray(dim * dim * 4) + // Start all-white. + data.fill(255) + for (let my = 0; my < size; my++) { + for (let mx = 0; mx < size; mx++) { + if (!bits[my * size + mx]) continue // light module → leave white + const x0 = (mx + quietModules) * scale + const y0 = (my + quietModules) * scale + for (let py = 0; py < scale; py++) { + for (let px = 0; px < scale; px++) { + const idx = ((y0 + py) * dim + (x0 + px)) * 4 + data[idx] = 0 + data[idx + 1] = 0 + data[idx + 2] = 0 + data[idx + 3] = 255 + } + } + } + } + return { data, width: dim, height: dim } +} + +describe('decodeQrFromImageData', () => { + it('round-trips a URL payload', () => { + const url = 'https://toolbox.seansun.net' + const { data, width, height } = renderQrToImageData(url) + const result = decodeQrFromImageData(data, width, height) + expect(result?.text).toBe(url) + }) + + it('round-trips arbitrary text', () => { + const text = 'Hello, 世界 — QR 解码测试 123' + const { data, width, height } = renderQrToImageData(text) + const result = decodeQrFromImageData(data, width, height) + expect(result?.text).toBe(text) + }) + + it('returns null for a blank (all-white) image', () => { + const dim = 200 + const data = new Uint8ClampedArray(dim * dim * 4) + data.fill(255) + expect(decodeQrFromImageData(data, dim, dim)).toBeNull() + }) +}) diff --git a/src/lib/qr-decode.ts b/src/lib/qr-decode.ts new file mode 100644 index 0000000..24d3c6c --- /dev/null +++ b/src/lib/qr-decode.ts @@ -0,0 +1,52 @@ +import jsQR from 'jsqr' + +/** + * QR decoding — pure client-side. The raw pixel decode runs on an `ImageData` + * buffer (no network, no canvas dependency), so it's unit-testable in Node. + * The File/Blob entry point uses an offscreen canvas to rasterise the image, + * which only exists in the browser. + */ + +export type QrDecodeResult = { + /** The decoded text payload. */ + text: string +} + +/** + * Decode a QR code from raw RGBA pixels. Returns `null` when no QR is found. + * Thin wrapper over jsQR so callers don't depend on its option shape. + */ +export function decodeQrFromImageData( + data: Uint8ClampedArray, + width: number, + height: number, +): QrDecodeResult | null { + const found = jsQR(data, width, height, { inversionAttempts: 'attemptBoth' }) + if (!found || !found.data) return null + return { text: found.data } +} + +/** + * Rasterise a Blob/File image and decode any QR in it. Downscales very large + * images to keep the decode fast (QR detection doesn't need full resolution). + * Browser-only — depends on `createImageBitmap` + canvas. + */ +export async function decodeQrFromBlob(blob: Blob): Promise { + const bitmap = await createImageBitmap(blob) + try { + const MAX = 1600 + const scale = Math.min(1, MAX / Math.max(bitmap.width, bitmap.height)) + const w = Math.max(1, Math.round(bitmap.width * scale)) + const h = Math.max(1, Math.round(bitmap.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) throw new Error('Canvas 2D context unavailable') + ctx.drawImage(bitmap, 0, 0, w, h) + const img = ctx.getImageData(0, 0, w, h) + return decodeQrFromImageData(img.data, w, h) + } finally { + bitmap.close() + } +} diff --git a/src/pages/QrCode.tsx b/src/pages/QrCode.tsx index 2c3e0ce..3f1571f 100644 --- a/src/pages/QrCode.tsx +++ b/src/pages/QrCode.tsx @@ -1,13 +1,15 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import QRCode, { type QRCodeErrorCorrectionLevel } from 'qrcode' -import { Download } from 'lucide-react' +import { Copy, Download, ExternalLink, ScanLine } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { toast } from 'sonner' +import { FileDrop } from '@/components/FileDrop' +import { decodeQrFromBlob } from '@/lib/qr-decode' const ERROR_LEVELS = ['L', 'M', 'Q', 'H'] as const satisfies readonly QRCodeErrorCorrectionLevel[] type ShortLevel = (typeof ERROR_LEVELS)[number] @@ -78,9 +80,25 @@ function buildGeo(lat: string, lng: string): string { return `geo:${lat},${lng}` } +type View = 'generate' | 'decode' + +type DecodeState = + | { kind: 'idle' } + | { kind: 'decoding' } + | { kind: 'done'; text: string } + | { kind: 'empty' } // image decoded, but no QR found + | { kind: 'error'; message: string } + +function looksLikeUrl(s: string): boolean { + return /^https?:\/\/\S+$/i.test(s.trim()) +} + export function QrCodePage() { const { t } = useTranslation() + const [view, setView] = useState('generate') const [mode, setMode] = useState('text') + // Decode side — local file → canvas → jsQR; nothing leaves the browser. + const [decoded, setDecoded] = useState({ kind: 'idle' }) // Text / URL const [text, setText] = useState('https://toolbox.seansun.net') @@ -214,6 +232,25 @@ export function QrCodePage() { } } + const handleDecodeFile = async (file: File) => { + if (!file.type.startsWith('image/')) { + setDecoded({ kind: 'error', message: t('pages.qr.decode.notImage') }) + return + } + setDecoded({ kind: 'decoding' }) + try { + const result = await decodeQrFromBlob(file) + setDecoded(result ? { kind: 'done', text: result.text } : { kind: 'empty' }) + } catch (err) { + setDecoded({ kind: 'error', message: err instanceof Error ? err.message : String(err) }) + } + } + + const handleCopyDecoded = async (value: string) => { + await navigator.clipboard.writeText(value) + toast.success(t('pages.qr.decode.copied')) + } + return (
@@ -221,6 +258,68 @@ export function QrCodePage() {

{t('pages.qr.description')}

+
+ {(['generate', 'decode'] as View[]).map((v) => ( + + ))} +
+ + {view === 'decode' ? ( +
+ + {decoded.kind === 'decoding' ? ( +

{t('pages.qr.decode.decoding')}

+ ) : null} + {decoded.kind === 'empty' ? ( +

⚠ {t('pages.qr.decode.notFound')}

+ ) : null} + {decoded.kind === 'error' ? ( +

⚠ {decoded.message}

+ ) : null} + {decoded.kind === 'done' ? ( +
+
+
+ + {t('pages.qr.decode.result')} +
+ +
+
{decoded.text}
+ {looksLikeUrl(decoded.text) ? ( + + {t('pages.qr.decode.open')} + + + ) : null} +
+ ) : null} +
+ ) : ( + <> setMode(v as Mode)}> {t('pages.qr.tabText')} @@ -430,6 +529,8 @@ export function QrCodePage() {
+ + )} ) }