Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
14 changes: 13 additions & 1 deletion src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
62 changes: 62 additions & 0 deletions src/lib/__tests__/qr-decode.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
52 changes: 52 additions & 0 deletions src/lib/qr-decode.ts
Original file line number Diff line number Diff line change
@@ -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<QrDecodeResult | null> {
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()
}
}
103 changes: 102 additions & 1 deletion src/pages/QrCode.tsx
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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<View>('generate')
const [mode, setMode] = useState<Mode>('text')
// Decode side — local file → canvas → jsQR; nothing leaves the browser.
const [decoded, setDecoded] = useState<DecodeState>({ kind: 'idle' })

// Text / URL
const [text, setText] = useState('https://toolbox.seansun.net')
Expand Down Expand Up @@ -214,13 +232,94 @@ 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 (
<div className="mx-auto max-w-5xl px-8 py-12">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">{t('tools.qr-code.name')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('pages.qr.description')}</p>
</header>

<div className="mb-4 flex rounded-md border border-input bg-transparent text-sm w-fit">
{(['generate', 'decode'] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={`px-3 py-1.5 transition-colors ${
view === v
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{v === 'generate' ? t('pages.qr.viewGenerate') : t('pages.qr.viewDecode')}
</button>
))}
</div>

{view === 'decode' ? (
<div className="space-y-4">
<FileDrop
onFile={handleDecodeFile}
accept="image/*"
label={t('pages.qr.decode.drop')}
hint={t('pages.qr.decode.hint')}
/>
{decoded.kind === 'decoding' ? (
<p className="text-sm text-muted-foreground">{t('pages.qr.decode.decoding')}</p>
) : null}
{decoded.kind === 'empty' ? (
<p className="text-sm text-destructive">⚠ {t('pages.qr.decode.notFound')}</p>
) : null}
{decoded.kind === 'error' ? (
<p className="text-sm text-destructive">⚠ {decoded.message}</p>
) : null}
{decoded.kind === 'done' ? (
<div className="rounded-lg border border-border bg-card/40 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<ScanLine className="h-4 w-4 text-muted-foreground" />
{t('pages.qr.decode.result')}
</div>
<Button size="sm" variant="ghost" onClick={() => handleCopyDecoded(decoded.text)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<pre className="whitespace-pre-wrap break-all font-mono text-sm">{decoded.text}</pre>
{looksLikeUrl(decoded.text) ? (
<a
href={decoded.text}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{t('pages.qr.decode.open')}
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
) : null}
</div>
) : (
<>
<Tabs value={mode} onValueChange={(v) => setMode(v as Mode)}>
<TabsList>
<TabsTrigger value="text">{t('pages.qr.tabText')}</TabsTrigger>
Expand Down Expand Up @@ -430,6 +529,8 @@ export function QrCodePage() {
</div>

<a ref={linkRef} className="hidden" />
</>
)}
</div>
)
}
Expand Down
Loading