diff --git a/astro.config.mjs b/astro.config.mjs index 16c02d5..b0efad4 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -137,6 +137,7 @@ export default defineConfig({ '**/libheif*.js', '**/terser*.js', '**/csso*.js', + '**/zxing*.js', 'og/*.png', ], runtimeCaching: [ diff --git a/package-lock.json b/package-lock.json index 7af50c2..c7f95bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,8 @@ "turndown": "^7.2.4", "upscaler": "^1.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zxing-wasm": "^3.1.2" }, "devDependencies": { "@fontsource/space-grotesk": "^5.3.0", @@ -7725,6 +7726,12 @@ "@types/ms": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -19611,6 +19618,18 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -22200,6 +22219,34 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.2.tgz", + "integrity": "sha512-yHloUw+h43r9QhFwVEbdkfmsl9h3rHRpgvvVd9WdhipM3TCYYzGVACmnT+i/YPrNK/eZRgs1ZMISsyJtAoimzA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.8.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index de0fbcd..84c5f85 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,8 @@ "turndown": "^7.2.4", "upscaler": "^1.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zxing-wasm": "^3.1.2" }, "devDependencies": { "@fontsource/space-grotesk": "^5.3.0", diff --git a/scripts/copy-wasm.mjs b/scripts/copy-wasm.mjs index 98778fb..2f3dde2 100644 --- a/scripts/copy-wasm.mjs +++ b/scripts/copy-wasm.mjs @@ -4,6 +4,7 @@ import { copyFileSync, mkdirSync, cpSync, existsSync } from 'node:fs'; mkdirSync('public/libarchive', { recursive: true }); copyFileSync('node_modules/mupdf/dist/mupdf-wasm.wasm', 'public/mupdf-wasm.wasm'); +copyFileSync('node_modules/zxing-wasm/dist/reader/zxing_reader.wasm', 'public/zxing_reader.wasm'); copyFileSync('node_modules/libarchive.js/dist/worker-bundle.js', 'public/libarchive/worker-bundle.js'); copyFileSync('node_modules/libarchive.js/dist/libarchive.wasm', 'public/libarchive/libarchive.wasm'); diff --git a/src/islands/dev/QrRead.tsx b/src/islands/dev/QrRead.tsx index e22e077..649d8af 100644 --- a/src/islands/dev/QrRead.tsx +++ b/src/islands/dev/QrRead.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import jsQR from 'jsqr'; +import { decodeQrFromFile } from '@/tools/image/qr-decode.lib'; import { Dropzone } from '@/components/ui/Dropzone'; import { CopyButton } from '@/components/ui/CopyButton'; import { Alert } from '@/components/ui/Alert'; @@ -29,19 +29,6 @@ const TR: Record { - const bitmap = await createImageBitmap(file); - const canvas = document.createElement('canvas'); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - ctx.drawImage(bitmap, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const result = jsQR(imageData.data, imageData.width, imageData.height); - return result?.data ?? null; -} - export default function QrRead({ lang = 'en' }: { lang?: Lang }) { const t = TR[lang] ?? TR.en; const [value, setValue] = useState(''); @@ -52,7 +39,7 @@ export default function QrRead({ lang = 'en' }: { lang?: Lang }) { setValue(''); if (files.length === 0) return; try { - const decoded = await decodeImage(files[0]); + const decoded = await decodeQrFromFile(files[0]); if (decoded) { setValue(decoded); } else { diff --git a/src/islands/dev/QrisDecoder.tsx b/src/islands/dev/QrisDecoder.tsx index a266763..d83c0c2 100644 --- a/src/islands/dev/QrisDecoder.tsx +++ b/src/islands/dev/QrisDecoder.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import jsQR from 'jsqr'; +import { decodeQrFromFile } from '@/tools/image/qr-decode.lib'; import { Dropzone } from '@/components/ui/Dropzone'; import { TextArea } from '@/components/ui/TextArea'; import { Alert } from '@/components/ui/Alert'; @@ -24,18 +24,6 @@ const EXAMPLE_BASE = '6304'; const EXAMPLE = EXAMPLE_BASE + crc16(EXAMPLE_BASE); -async function decodeImage(file: File): Promise { - const bitmap = await createImageBitmap(file); - const canvas = document.createElement('canvas'); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - ctx.drawImage(bitmap, 0, 0); - const img = ctx.getImageData(0, 0, canvas.width, canvas.height); - return jsQR(img.data, img.width, img.height)?.data ?? null; -} - const TR: Record { + it('retries at several downscaled sizes for a large photo', () => { + const t = qrScaleTargets(4080, 3060); + expect(t[0]).toBe(1600); // capped full-size pass + expect(t).toContain(1000); + expect(t).toContain(700 - 100); // 600 + // strictly de-duplicated and each within the image size + expect(new Set(t).size).toBe(t.length); + expect(Math.max(...t)).toBeLessThanOrEqual(4080); + }); + + it('never upscales a small image', () => { + const t = qrScaleTargets(480, 480); + expect(Math.max(...t)).toBeLessThanOrEqual(480); + }); + + it('always offers at least one target', () => { + expect(qrScaleTargets(300, 300).length).toBeGreaterThan(0); + }); +}); diff --git a/src/tools/image/qr-decode.lib.ts b/src/tools/image/qr-decode.lib.ts new file mode 100644 index 0000000..7b5886f --- /dev/null +++ b/src/tools/image/qr-decode.lib.ts @@ -0,0 +1,104 @@ +/** + * Robust QR decoding from an image File — tuned for real camera photos of + * QRIS standees and QR codes (glare, angle, small code in a big frame). + * + * Pipeline, fastest/cheapest first: + * 1. Native BarcodeDetector (great on Android/Chrome, no download). + * 2. zxing-wasm with tryHarder (ZXing C++ → wasm; robust on every browser, + * incl. iOS Safari; wasm served same-origin from /zxing_reader.wasm). + * 3. jsQR retried at several downscaled sizes (last-resort fallback). + */ +import jsQR from 'jsqr'; + +/** Candidate max-dimension targets to try with jsQR, de-duplicated. */ +export function qrScaleTargets(width: number, height: number): number[] { + const longest = Math.max(width, height); + const candidates = [Math.min(longest, 1600), 1000, 1300, 800, 600, 500]; + const seen = new Set(); + const out: number[] = []; + for (const t of candidates) { + const clamped = Math.min(t, longest); + if (clamped >= 100 && !seen.has(clamped)) { + seen.add(clamped); + out.push(clamped); + } + } + return out; +} + +interface DetectedBarcode { rawValue: string } +interface BarcodeDetectorLike { detect(source: CanvasImageSource): Promise } +type BarcodeDetectorCtor = new (opts?: { formats?: string[] }) => BarcodeDetectorLike; + +function drawScaled(bitmap: ImageBitmap, target: number): HTMLCanvasElement | null { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + const scale = target / Math.max(bitmap.width, bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + return canvas; +} + +async function tryBarcodeDetector(bitmap: ImageBitmap): Promise { + const Ctor = (globalThis as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector; + if (!Ctor) return null; + try { + const canvas = drawScaled(bitmap, Math.min(1600, Math.max(bitmap.width, bitmap.height))); + if (!canvas) return null; + const codes = await new Ctor({ formats: ['qr_code'] }).detect(canvas); + return codes[0]?.rawValue ?? null; + } catch { + return null; + } +} + +async function tryZxing(file: File): Promise { + try { + const { readBarcodes, prepareZXingModule } = await import('zxing-wasm/reader'); + prepareZXingModule({ + overrides: { + locateFile: (path: string, prefix: string) => + path.endsWith('.wasm') ? '/zxing_reader.wasm' : prefix + path, + }, + }); + const results = await readBarcodes(file, { + formats: ['QRCode'], + tryHarder: true, + maxNumberOfSymbols: 1, + }); + return results[0]?.text || null; + } catch { + return null; + } +} + +function tryJsQr(bitmap: ImageBitmap): string | null { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + const longest = Math.max(bitmap.width, bitmap.height); + for (const target of qrScaleTargets(bitmap.width, bitmap.height)) { + const scale = target / longest; + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + canvas.width = w; + canvas.height = h; + ctx.drawImage(bitmap, 0, 0, w, h); + const data = ctx.getImageData(0, 0, w, h); + const result = jsQR(data.data, w, h, { inversionAttempts: 'attemptBoth' }); + if (result?.data) return result.data; + } + return null; +} + +/** Decode the first QR code found in an image File, or null. Browser-only. */ +export async function decodeQrFromFile(file: File): Promise { + const bitmap = await createImageBitmap(file); + try { + return (await tryBarcodeDetector(bitmap)) ?? (await tryZxing(file)) ?? tryJsQr(bitmap); + } finally { + bitmap.close?.(); + } +}