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 astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export default defineConfig({
'**/libheif*.js',
'**/terser*.js',
'**/csso*.js',
'**/zxing*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
49 changes: 48 additions & 1 deletion package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/copy-wasm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
17 changes: 2 additions & 15 deletions src/islands/dev/QrRead.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,19 +29,6 @@ const TR: Record<Lang, {
},
};

async function decodeImage(file: File): Promise<string | null> {
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('');
Expand All @@ -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 {
Expand Down
16 changes: 2 additions & 14 deletions src/islands/dev/QrisDecoder.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,18 +24,6 @@ const EXAMPLE_BASE =
'6304';
const EXAMPLE = EXAMPLE_BASE + crc16(EXAMPLE_BASE);

async function decodeImage(file: File): Promise<string | null> {
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<Lang, {
intro: string;
dropPrompt: string;
Expand Down Expand Up @@ -167,7 +155,7 @@ export default function QrisDecoder({ lang = 'en' }: { lang?: Lang }) {
setImgError('');
if (files.length === 0) return;
try {
const decoded = await decodeImage(files[0]);
const decoded = await decodeQrFromFile(files[0]);
if (decoded) setPayload(decoded);
else setImgError(t.noQrFound);
} catch {
Expand Down
23 changes: 23 additions & 0 deletions src/tools/image/qr-decode.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { qrScaleTargets } from './qr-decode.lib';

describe('qrScaleTargets', () => {
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);
});
});
104 changes: 104 additions & 0 deletions src/tools/image/qr-decode.lib.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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<DetectedBarcode[]> }
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<string | null> {
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<string | null> {
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<string | null> {
const bitmap = await createImageBitmap(file);
try {
return (await tryBarcodeDetector(bitmap)) ?? (await tryZxing(file)) ?? tryJsQr(bitmap);
} finally {
bitmap.close?.();
}
}
Loading