diff --git a/e2e/qr-scanner.spec.ts b/e2e/qr-scanner.spec.ts new file mode 100644 index 0000000..3a63875 --- /dev/null +++ b/e2e/qr-scanner.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import QRCode from 'qrcode'; +import { waitForIslands, makePngBuffer } from './helpers'; + +async function qrPng(text: string): Promise { + return QRCode.toBuffer(text, { width: 320, margin: 2 }); +} + +test('上傳網址 QR → 解出網址並給開啟連結', async ({ page }) => { + await page.goto('/tools/qr-scanner'); + await waitForIslands(page); + + const png = await qrPng('https://tiglet.vercel.app/tools'); + await page.locator('input[type="file"]').setInputFiles({ name: 'url.png', mimeType: 'image/png', buffer: png }); + + const result = page.locator('div[role="status"]'); // CopyButton 內部也有 role=status 的 span,鎖 div 才唯一 + await expect(result).toContainText('網址'); + await expect(result).toContainText('https://tiglet.vercel.app/tools'); + const link = result.getByRole('link', { name: '開啟連結 ↗' }); + await expect(link).toHaveAttribute('href', 'https://tiglet.vercel.app/tools'); + await expect(link).toHaveAttribute('rel', 'noopener noreferrer'); +}); + +test('上傳 WiFi QR → 解析出網路名稱與密碼', async ({ page }) => { + await page.goto('/tools/qr-scanner'); + await waitForIslands(page); + + const png = await qrPng('WIFI:T:WPA;S:TigletCafe;P:pw123456;;'); + await page.locator('input[type="file"]').setInputFiles({ name: 'wifi.png', mimeType: 'image/png', buffer: png }); + + const result = page.locator('div[role="status"]'); // CopyButton 內部也有 role=status 的 span,鎖 div 才唯一 + await expect(result).toContainText('WiFi'); + await expect(result).toContainText('TigletCafe'); + await expect(result).toContainText('pw123456'); +}); + +test('沒有 QR 的圖片 → 明確告知找不到', async ({ page }) => { + await page.goto('/tools/qr-scanner'); + await waitForIslands(page); + + const png = await makePngBuffer(page, 320, 320, '#d97757'); + await page.locator('input[type="file"]').setInputFiles({ name: 'plain.png', mimeType: 'image/png', buffer: png }); + + await expect(page.getByText(/找不到 QR 碼/)).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 792a140..90be3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "astro": "^6.4.3", "dompurify": "^3.4.11", "fit-file-parser": "^3.0.2", + "jsqr": "^1.4.0", "marked": "^18.0.5", "qrcode": "^1.5.4", "react": "^19.2.7", @@ -9683,6 +9684,12 @@ "node": ">=0.10.0" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 3dd6901..e486290 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "astro": "^6.4.3", "dompurify": "^3.4.11", "fit-file-parser": "^3.0.2", + "jsqr": "^1.4.0", "marked": "^18.0.5", "qrcode": "^1.5.4", "react": "^19.2.7", diff --git a/public/og/en/qr-scanner.png b/public/og/en/qr-scanner.png new file mode 100644 index 0000000..d7157f2 Binary files /dev/null and b/public/og/en/qr-scanner.png differ diff --git a/public/og/zh/qr-scanner.png b/public/og/zh/qr-scanner.png new file mode 100644 index 0000000..9caaf72 Binary files /dev/null and b/public/og/zh/qr-scanner.png differ diff --git a/src/data/tools.ts b/src/data/tools.ts index e489396..d053e88 100644 --- a/src/data/tools.ts +++ b/src/data/tools.ts @@ -50,4 +50,5 @@ export const tools: Tool[] = [ { id: 'hash-id', title: '雜湊 / UUID', titleEn: 'Hash / UUID', description: '文字與檔案的 SHA 雜湊,加上 UUID 批次產生。', descriptionEn: 'SHA hashes for text and files, plus batch UUID generation.', category: '文字', path: '/tools/hash-id', icon: '🔏', status: 'available', keywords: ['hash', 'sha256', 'sha1', 'uuid', 'guid', '雜湊', 'checksum'] }, { id: 'url-audit', title: '網址健檢', titleEn: 'URL Audit', description: '檢查網址的 SEO、社群分享卡與 AI 引擎設定並評分。', descriptionEn: 'Audit a URL for SEO, social share cards and AI-engine readiness, with a score.', category: '實用工具', path: '/tools/url-audit', icon: '🔎', status: 'available', keywords: ['seo', 'og', 'open graph', 'geo', '健檢', 'meta', '分享', 'social', 'audit', 'twitter card'] }, { id: 'invoice-lottery', title: '發票對獎', titleEn: 'Invoice Lottery', description: '財政部最新開獎號碼,輸入末 3 碼立刻對獎。', descriptionEn: 'Check Taiwan uniform-invoice numbers against the latest official draw.', category: '實用工具', path: '/tools/invoice-lottery', icon: '🧧', status: 'available', keywords: ['發票', '統一發票', '對獎', '中獎號碼', 'invoice', 'lottery', 'receipt', '雲端發票', '財政部'] }, + { id: 'qr-scanner', title: 'QR 掃描', titleEn: 'QR Scanner', description: '相機或圖片掃描 QR 碼,網址、WiFi、名片自動解析。', descriptionEn: 'Scan QR codes from camera or images; URLs, WiFi and vCards parsed.', category: '實用工具', path: '/tools/qr-scanner', icon: '📷', status: 'available', keywords: ['qr', 'scan', 'scanner', '掃描', '掃碼', 'barcode', 'wifi', 'vcard', '解碼'] }, ]; diff --git a/src/lib/__tests__/qrScan.test.ts b/src/lib/__tests__/qrScan.test.ts new file mode 100644 index 0000000..7911ba7 --- /dev/null +++ b/src/lib/__tests__/qrScan.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { classifyQrContent } from '../qrScan'; +import { wifiQr, vcardQr } from '../qrFormats'; + +describe('classifyQrContent', () => { + it('http/https 連結 → url', () => { + expect(classifyQrContent('https://tiglet.vercel.app/tools')).toEqual({ type: 'url', url: 'https://tiglet.vercel.app/tools' }); + expect(classifyQrContent('HTTP://EXAMPLE.COM')).toMatchObject({ type: 'url' }); + // javascript: 之類的不當連結一律當純文字,不要給可點的 + expect(classifyQrContent('javascript:alert(1)')).toEqual({ type: 'text' }); + expect(classifyQrContent('ftp://example.com')).toEqual({ type: 'text' }); + }); + + it('WIFI: payload → 解析 ssid / 密碼 / 加密 / 隱藏', () => { + expect(classifyQrContent('WIFI:T:WPA;S:MyHome;P:secret123;;')).toEqual({ + type: 'wifi', + ssid: 'MyHome', + password: 'secret123', + security: 'WPA', + hidden: false, + }); + expect(classifyQrContent('WIFI:T:nopass;S:Cafe;H:true;;')).toMatchObject({ type: 'wifi', ssid: 'Cafe', password: '', hidden: true }); + }); + + it('WiFi 與產生器互為往返:跳脫字元要正確解回來', () => { + const payload = wifiQr({ ssid: 'A;B,C:D"E\\F', password: 'p;w,d', security: 'WPA' }); + const parsed = classifyQrContent(payload); + expect(parsed).toEqual({ type: 'wifi', ssid: 'A;B,C:D"E\\F', password: 'p;w,d', security: 'WPA', hidden: false }); + }); + + it('vCard payload → 解析常用欄位', () => { + const payload = vcardQr({ name: '王小明', phone: '0912345678', email: 'ming@example.com', org: 'Tiglet', title: '工程師', url: 'https://example.com' }); + expect(classifyQrContent(payload)).toEqual({ + type: 'vcard', + name: '王小明', + phone: '0912345678', + email: 'ming@example.com', + org: 'Tiglet', + title: '工程師', + url: 'https://example.com', + }); + }); + + it('vCard 與產生器互為往返:跳脫的逗號分號換行解得回來', () => { + const payload = vcardQr({ name: 'Wang, Xiao;Ming', org: 'A\nB' }); + expect(classifyQrContent(payload)).toMatchObject({ type: 'vcard', name: 'Wang, Xiao;Ming', org: 'A\nB' }); + }); + + it('vCard 缺欄位就略過,CRLF 與大小寫容忍', () => { + const raw = 'begin:vcard\r\nversion:3.0\r\nfn:Amy\r\ntel:123\r\nend:vcard'; + expect(classifyQrContent(raw)).toEqual({ type: 'vcard', name: 'Amy', phone: '123', email: '', org: '', title: '', url: '' }); + }); + + it('其他內容 → text', () => { + expect(classifyQrContent('hello world')).toEqual({ type: 'text' }); + expect(classifyQrContent('')).toEqual({ type: 'text' }); + expect(classifyQrContent('WIFI:')).toEqual({ type: 'text' }); // 缺 SSID 的殘缺 payload + }); +}); diff --git a/src/lib/qrScan.ts b/src/lib/qrScan.ts new file mode 100644 index 0000000..30bb352 --- /dev/null +++ b/src/lib/qrScan.ts @@ -0,0 +1,78 @@ +// QR 掃描結果的內容分類與解析:qrFormats.ts 產生器的反向操作。 +// 純函式;解碼本身(影像 → 字串)由 jsQR 在元件層處理。 + +export type QrContent = + | { type: 'url'; url: string } + | { type: 'wifi'; ssid: string; password: string; security: string; hidden: boolean } + | { type: 'vcard'; name: string; phone: string; email: string; org: string; title: string; url: string } + | { type: 'text' }; + +// WIFI: 規格:欄位以未跳脫的 ; 分隔,值內的 \ ; , " : 以反斜線跳脫 +function splitWifiFields(body: string): Map { + const fields = new Map(); + let key = ''; + let buf = ''; + let readingKey = true; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === '\\' && i + 1 < body.length) { + buf += body[i + 1]; + i++; + } else if (readingKey && ch === ':') { + key = buf; + buf = ''; + readingKey = false; + } else if (!readingKey && ch === ';') { + fields.set(key, buf); + buf = ''; + key = ''; + readingKey = true; + } else { + buf += ch; + } + } + if (key) fields.set(key, buf); + return fields; +} + +function unescapeVcard(s: string): string { + return s.replace(/\\(.)/g, (_, c: string) => (c === 'n' || c === 'N' ? '\n' : c)); +} + +export function classifyQrContent(text: string): QrContent { + if (/^https?:\/\/\S+$/i.test(text.trim())) return { type: 'url', url: text.trim() }; + + if (/^WIFI:/i.test(text)) { + const fields = splitWifiFields(text.slice(5)); + const ssid = fields.get('S') ?? ''; + if (ssid) { + return { + type: 'wifi', + ssid, + password: fields.get('P') ?? '', + security: fields.get('T') ?? '', + hidden: (fields.get('H') ?? '').toLowerCase() === 'true', + }; + } + } + + if (/^BEGIN:VCARD/i.test(text.trim())) { + // 展開 RFC 摺行(換行後接空白 = 前行延續),再逐行取欄位 + const unfolded = text.replace(/\r\n/g, '\n').replace(/\n[ \t]/g, ''); + const field = (key: string) => { + const m = unfolded.match(new RegExp(`^${key}(?:;[^:\\n]*)?:(.*)$`, 'im')); + return m ? unescapeVcard(m[1].trim()) : ''; + }; + return { + type: 'vcard', + name: field('FN'), + phone: field('TEL'), + email: field('EMAIL'), + org: field('ORG'), + title: field('TITLE'), + url: field('URL'), + }; + } + + return { type: 'text' }; +} diff --git a/src/pages/en/tools/qr-scanner.astro b/src/pages/en/tools/qr-scanner.astro new file mode 100644 index 0000000..f495d02 --- /dev/null +++ b/src/pages/en/tools/qr-scanner.astro @@ -0,0 +1,12 @@ +--- +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import QrScanner from '../../../tools/QrScanner.tsx'; +import { tools } from '../../../data/tools'; + +const tool = tools.find((t) => t.id === 'qr-scanner')!; +--- + + ← All tools +

{tool.titleEn}

+ + diff --git a/src/pages/tools/qr-scanner.astro b/src/pages/tools/qr-scanner.astro new file mode 100644 index 0000000..ae59e28 --- /dev/null +++ b/src/pages/tools/qr-scanner.astro @@ -0,0 +1,9 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import QrScanner from '../../tools/QrScanner.tsx'; +--- + + ← 回到所有工具 +

QR 掃描

+ +
diff --git a/src/tools/QrScanner.tsx b/src/tools/QrScanner.tsx new file mode 100644 index 0000000..288d6ff --- /dev/null +++ b/src/tools/QrScanner.tsx @@ -0,0 +1,328 @@ +import { useEffect, useRef, useState } from 'react'; +import jsQR from 'jsqr'; +import Tabs from '../components/Tabs'; +import CopyButton from '../components/CopyButton'; +import { classifyQrContent, type QrContent } from '../lib/qrScan'; +import type { Locale } from '../lib/i18n'; + +const L = { + zh: { + tabImage: '圖片掃描', + tabCamera: '相機掃描', + dropHint: '點擊選擇、拖放或貼上(Ctrl+V)含 QR 碼的圖片', + fileAria: '上傳 QR 圖片', + notFound: '這張圖片裡找不到 QR 碼,換張清晰一點的試試。', + decodeError: '圖片讀取失敗,請確認檔案是有效的圖片。', + camStart: '開啟相機掃描', + camStop: '停止相機', + camRescan: '重新掃描', + camAria: '相機預覽', + camDenied: '相機權限被拒絕。請在瀏覽器網址列的權限設定允許相機後重試。', + camMissing: '找不到可用的相機。', + camHint: '把 QR 碼對準畫面,對到就會自動停下來。', + resultLabel: '掃描結果', + typeNames: { url: '網址', wifi: 'WiFi', vcard: '名片', text: '文字' } as Record, + openLink: '開啟連結 ↗', + wifiSsid: '網路名稱', + wifiPassword: '密碼', + wifiSecurity: '加密方式', + wifiHidden: '隱藏網路', + yes: '是', + vName: '姓名', + vPhone: '電話', + vEmail: 'Email', + vOrg: '公司', + vTitle: '職稱', + vUrl: '網站', + rawLabel: '原始內容', + privacy: '解碼完全在你的瀏覽器本機進行,圖片與相機畫面都不會上傳。', + }, + en: { + tabImage: 'From image', + tabCamera: 'Camera', + dropHint: 'Click, drag & drop, or paste (Ctrl+V) an image containing a QR code', + fileAria: 'Upload QR image', + notFound: 'No QR code found in this image — try a sharper one.', + decodeError: 'Could not read the image. Make sure it is a valid image file.', + camStart: 'Start camera scan', + camStop: 'Stop camera', + camRescan: 'Scan again', + camAria: 'Camera preview', + camDenied: 'Camera permission denied. Allow camera access in your browser and retry.', + camMissing: 'No camera available.', + camHint: 'Point the QR code at the frame — it stops automatically on a hit.', + resultLabel: 'Scan result', + typeNames: { url: 'URL', wifi: 'WiFi', vcard: 'vCard', text: 'Text' } as Record, + openLink: 'Open link ↗', + wifiSsid: 'Network name', + wifiPassword: 'Password', + wifiSecurity: 'Security', + wifiHidden: 'Hidden network', + yes: 'Yes', + vName: 'Name', + vPhone: 'Phone', + vEmail: 'Email', + vOrg: 'Organization', + vTitle: 'Title', + vUrl: 'Website', + rawLabel: 'Raw content', + privacy: 'Decoding happens entirely in your browser — images and camera frames are never uploaded.', + }, +} as const; +type Dict = (typeof L)[Locale]; + +interface Scan { + text: string; + content: QrContent; +} + +const MAX_DECODE_SIZE = 1024; // 大圖先縮到這個邊長再解,太大反而讓 jsQR 變慢又難對焦 + +function decodeImageData(data: ImageData): string | null { + return jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })?.data ?? null; +} + +async function decodeImageFile(file: File): Promise { + const bitmap = await createImageBitmap(file); + try { + const scale = Math.min(1, MAX_DECODE_SIZE / 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 })!; + ctx.drawImage(bitmap, 0, 0, w, h); + return decodeImageData(ctx.getImageData(0, 0, w, h)); + } finally { + bitmap.close(); + } +} + +function Row({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) { + if (!value) return null; + return ( +
+
{label}
+
+ {value} + {copyable && } +
+
+ ); +} + +function ResultCard({ scan, t }: { scan: Scan; t: Dict }) { + const c = scan.content; + return ( +
+
+ {t.resultLabel} + {t.typeNames[c.type]} +
+ + {c.type === 'url' && ( + + {t.openLink} + + )} + {c.type === 'wifi' && ( +
+ + + + {c.hidden && } +
+ )} + {c.type === 'vcard' && ( +
+ + + + + + +
+ )} + +
+
+ {t.rawLabel} + +
+
{scan.text}
+
+
+ ); +} + +function ImagePanel({ t, onScan }: { t: Dict; onScan: (s: Scan | null) => void }) { + const [error, setError] = useState(''); + const [dragging, setDragging] = useState(false); + const inputRef = useRef(null); + + async function handleFile(file: File | undefined | null) { + if (!file) return; + setError(''); + onScan(null); + try { + const text = await decodeImageFile(file); + if (!text) { + setError(t.notFound); + return; + } + onScan({ text, content: classifyQrContent(text) }); + } catch { + setError(t.decodeError); + } + } + + useEffect(() => { + function onPaste(e: ClipboardEvent) { + const file = Array.from(e.clipboardData?.items ?? []) + .find((i) => i.type.startsWith('image/')) + ?.getAsFile(); + if (file) handleFile(file); + } + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }); + + return ( +
+ + { + handleFile(e.target.files?.[0]); + e.target.value = ''; + }} + /> + {error &&

{error}

} +
+ ); +} + +function CameraPanel({ t, onScan }: { t: Dict; onScan: (s: Scan | null) => void }) { + const [running, setRunning] = useState(false); + const [error, setError] = useState(''); + const videoRef = useRef(null); + const streamRef = useRef(null); + const rafRef = useRef(0); + const lastTickRef = useRef(0); + + function stop() { + cancelAnimationFrame(rafRef.current); + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + setRunning(false); + } + + async function start() { + setError(''); + onScan(null); + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); + streamRef.current = stream; + setRunning(true); + const video = videoRef.current!; + video.srcObject = stream; + await video.play(); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true })!; + const tick = (now: number) => { + // 每 ~150ms 解一次就夠了,全速跑只會燒電 + if (now - lastTickRef.current > 150 && video.videoWidth > 0) { + lastTickRef.current = now; + const scale = Math.min(1, MAX_DECODE_SIZE / Math.max(video.videoWidth, video.videoHeight)); + canvas.width = Math.round(video.videoWidth * scale); + canvas.height = Math.round(video.videoHeight * scale); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const text = decodeImageData(ctx.getImageData(0, 0, canvas.width, canvas.height)); + if (text) { + stop(); + onScan({ text, content: classifyQrContent(text) }); + return; + } + } + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + } catch (e) { + stop(); + setError(e instanceof DOMException && e.name === 'NotAllowedError' ? t.camDenied : t.camMissing); + } + } + + useEffect(() => stop, []); + + return ( +
+
+ ); +} + +export default function QrScanner({ locale = 'zh' }: { locale?: Locale }) { + const t = L[locale]; + const [tab, setTab] = useState('image'); + const [scan, setScan] = useState(null); + + return ( +
+
+ { + setTab(id); + setScan(null); + }} + /> +
+ {tab === 'image' && } + {tab === 'camera' && } + {scan && } +

{t.privacy}

+
+ ); +}