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
45 changes: 45 additions & 0 deletions e2e/qr-scanner.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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();
});
7 changes: 7 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added public/og/en/qr-scanner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/zh/qr-scanner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/data/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '解碼'] },
];
59 changes: 59 additions & 0 deletions src/lib/__tests__/qrScan.test.ts
Original file line number Diff line number Diff line change
@@ -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: 之類的不當連結一律當純文字,不要給可點的 <a>
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
});
});
78 changes: 78 additions & 0 deletions src/lib/qrScan.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const fields = new Map<string, string>();
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' };
}
12 changes: 12 additions & 0 deletions src/pages/en/tools/qr-scanner.astro
Original file line number Diff line number Diff line change
@@ -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')!;
---
<BaseLayout title={`${tool.titleEn} — Tiglet`} description={tool.descriptionEn} toolId={tool.id}>
<a href="/en/" class="text-sm text-muted hover:text-accent">← All tools</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">{tool.titleEn}</h1>
<QrScanner client:visible locale="en" />
</BaseLayout>
9 changes: 9 additions & 0 deletions src/pages/tools/qr-scanner.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import QrScanner from '../../tools/QrScanner.tsx';
---
<BaseLayout title="QR 掃描 — Tiglet" description="用相機或圖片掃描 QR 碼:網址、WiFi、名片自動解析,解碼完全在瀏覽器本機進行,不會上傳任何影像。" toolId="qr-scanner">
<a href="/" class="text-sm text-muted hover:text-accent">← 回到所有工具</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">QR 掃描</h1>
<QrScanner client:visible />
</BaseLayout>
Loading
Loading