From ac7a6448ef9be96484a68da029883cd4ec0e1f2f Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:31:58 +0800 Subject: [PATCH 1/7] chore: replace jsqr with zxing-wasm for barcode reading --- apps/api/package.json | 4 ++-- pnpm-lock.yaml | 26 ++++++++++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index db33daf3..ae258c97 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,7 +27,6 @@ "exif-reader": "^2.0.3", "fastify": "^5.2.0", "js-yaml": "^4.1.1", - "jsqr": "^1.4.0", "mupdf": "^1.27.0", "p-queue": "^9.1.0", "pdfkit": "^0.18.0", @@ -36,7 +35,8 @@ "qrcode": "^1.5.4", "sharp": "^0.33.0", "tsx": "^4.19.0", - "zod": "^3.24.0" + "zod": "^3.24.0", + "zxing-wasm": "^3.0.2" }, "devDependencies": { "@types/archiver": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06e93930..dd5e6611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,6 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 - jsqr: - specifier: ^1.4.0 - version: 1.4.0 mupdf: specifier: ^1.27.0 version: 1.27.0 @@ -146,6 +143,9 @@ importers: zod: specifier: ^3.24.0 version: 3.25.76 + zxing-wasm: + specifier: ^3.0.2 + version: 3.0.2(@types/emscripten@1.41.5) devDependencies: '@types/archiver': specifier: ^7.0.0 @@ -2495,6 +2495,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -4011,9 +4014,6 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} - jsqr@1.4.0: - resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} - kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -5878,6 +5878,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zxing-wasm@3.0.2: + resolution: {integrity: sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@actions/core@3.0.0': @@ -7911,6 +7916,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/emscripten@1.41.5': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -9499,8 +9506,6 @@ snapshots: jsonpointer@5.0.1: {} - jsqr@1.4.0: {} - kind-of@6.0.3: {} lazystream@1.0.1: @@ -11485,3 +11490,8 @@ snapshots: react: 19.2.4 zwitch@2.0.4: {} + + zxing-wasm@3.0.2(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.5.0 From 69e31b7aea339f67d160e23526ebe9d4b111e808 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:34:09 +0800 Subject: [PATCH 2/7] feat(barcode-read): rewrite backend with zxing-wasm for all barcode types --- apps/api/src/routes/tools/barcode-read.ts | 186 ++++++++++++++++++---- 1 file changed, 156 insertions(+), 30 deletions(-) diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index 6232de2a..71fab569 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -1,18 +1,83 @@ -import { basename } from "node:path"; +import { randomUUID } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import jsQR from "jsqr"; import sharp from "sharp"; +import { readBarcodes } from "zxing-wasm/reader"; +import { autoOrient } from "../../lib/auto-orient.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; +import { createWorkspace } from "../../lib/workspace.js"; /** - * Read QR codes and barcodes from uploaded images. + * Color palette for bounding-box overlays. + * Semi-transparent fills paired with solid strokes. + */ +const BOX_COLORS = [ + { fill: "rgba(59,130,246,0.18)", stroke: "rgba(59,130,246,0.9)" }, // blue + { fill: "rgba(34,197,94,0.18)", stroke: "rgba(34,197,94,0.9)" }, // green + { fill: "rgba(245,158,11,0.18)", stroke: "rgba(245,158,11,0.9)" }, // amber + { fill: "rgba(239,68,68,0.18)", stroke: "rgba(239,68,68,0.9)" }, // red + { fill: "rgba(168,85,247,0.18)", stroke: "rgba(168,85,247,0.9)" }, // purple + { fill: "rgba(236,72,153,0.18)", stroke: "rgba(236,72,153,0.9)" }, // pink +]; + +/** + * Build an SVG overlay with numbered polygon bounding boxes for each barcode. + */ +function buildOverlaySvg( + width: number, + height: number, + barcodes: { + position: { + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + }; + }[], +): string { + const shortSide = Math.min(width, height); + const strokeWidth = Math.max(2, Math.round(shortSide / 200)); + const fontSize = Math.max(14, Math.round(shortSide / 40)); + const labelPad = Math.round(fontSize * 0.4); + + let elements = ""; + + for (let i = 0; i < barcodes.length; i++) { + const { position: pos } = barcodes[i]; + const color = BOX_COLORS[i % BOX_COLORS.length]; + + // Polygon points: TL -> TR -> BR -> BL + const points = [ + `${pos.topLeft.x},${pos.topLeft.y}`, + `${pos.topRight.x},${pos.topRight.y}`, + `${pos.bottomRight.x},${pos.bottomRight.y}`, + `${pos.bottomLeft.x},${pos.bottomLeft.y}`, + ].join(" "); + + elements += ``; + + // Numbered label above top-left corner + const labelX = pos.topLeft.x; + const labelY = Math.max(pos.topLeft.y - labelPad, fontSize + labelPad); + + elements += `${i + 1}`; + } + + return `${elements}`; +} + +/** + * Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm. */ export function registerBarcodeRead(app: FastifyInstance) { app.post("/api/v1/tools/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => { let fileBuffer: Buffer | null = null; let filename = "image"; + let settingsRaw: string | null = null; + // --- Parse multipart --- try { const parts = request.parts(); for await (const part of parts) { @@ -23,6 +88,8 @@ export function registerBarcodeRead(app: FastifyInstance) { } fileBuffer = Buffer.concat(chunks); filename = basename(part.filename ?? "image"); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; } } } catch (err) { @@ -36,51 +103,110 @@ export function registerBarcodeRead(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } - // Validate the uploaded image + // --- Validate --- const validation = await validateImageBuffer(fileBuffer); if (!validation.valid) { - return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + return reply.status(400).send({ + error: `Invalid image: ${validation.reason}`, + }); } try { - // Decode HEIC/HEIF if needed - fileBuffer = await ensureSharpCompat(fileBuffer); + const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + const tryHarder = settings.tryHarder !== false; // default true - // Convert to RGBA raw pixel data for jsQR + // Decode HEIC/HEIF if needed, then auto-orient + fileBuffer = await ensureSharpCompat(fileBuffer); + fileBuffer = await autoOrient(fileBuffer); + + // Convert to raw RGBA pixel data const image = sharp(fileBuffer); const metadata = await image.metadata(); const width = metadata.width ?? 0; const height = metadata.height ?? 0; - const rawData = await image.ensureAlpha().raw().toBuffer(); - - const code = jsQR( - new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length), - width, - height, - ); - - if (!code) { - return reply.send({ - filename, - found: false, - text: null, - message: "No QR code found in the image", + if (width === 0 || height === 0) { + return reply.status(422).send({ + error: "Could not determine image dimensions", }); } + const rawData = await image.ensureAlpha().raw().toBuffer(); + + // --- Detect barcodes via zxing-wasm --- + const imageData = { + data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length), + width, + height, + }; + + const results = await readBarcodes(imageData, { + tryHarder, + maxNumberOfSymbols: 255, + }); + + const validResults = results.filter((r) => r.isValid); + + // Map to the response shape + const barcodes = validResults.map((r) => ({ + type: r.format, + text: r.text, + position: { + topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y }, + topRight: { x: r.position.topRight.x, y: r.position.topRight.y }, + bottomLeft: { + x: r.position.bottomLeft.x, + y: r.position.bottomLeft.y, + }, + bottomRight: { + x: r.position.bottomRight.x, + y: r.position.bottomRight.y, + }, + }, + })); + + // No barcodes found - return early + if (barcodes.length === 0) { + return reply.send({ + filename, + barcodes: [], + annotatedUrl: null, + previewUrl: null, + }); + } + + // --- Generate annotated image --- + const jobId = randomUUID(); + const workspacePath = await createWorkspace(jobId); + + // Save original input + const inputPath = join(workspacePath, "input", filename); + await writeFile(inputPath, fileBuffer); + + // Build SVG overlay with bounding boxes + const overlaySvg = buildOverlaySvg(width, height, barcodes); + + const stem = filename.replace(/\.[^.]+$/, ""); + const outputFilename = `annotated-${stem}.png`; + const outputPath = join(workspacePath, "output", outputFilename); + + const annotatedBuffer = await sharp(fileBuffer) + .composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }]) + .png() + .toBuffer(); + + await writeFile(outputPath, annotatedBuffer); + + const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`; + return reply.send({ filename, - found: true, - text: code.data, - location: { - topLeft: code.location.topLeftCorner, - topRight: code.location.topRightCorner, - bottomLeft: code.location.bottomLeftCorner, - bottomRight: code.location.bottomRightCorner, - }, + barcodes, + annotatedUrl: downloadUrl, + previewUrl: downloadUrl, }); } catch (err) { + request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed"); return reply.status(422).send({ error: "Barcode reading failed", details: err instanceof Error ? err.message : "Unknown error", From c5e952119a18b964d8f1598aa5544f3d6a284c45 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:36:05 +0800 Subject: [PATCH 3/7] feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n --- .../tools/barcode-read-settings.tsx | 407 +++++++++++++++--- packages/shared/src/constants.ts | 2 +- packages/shared/src/i18n/en.ts | 2 +- 3 files changed, 344 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/tools/barcode-read-settings.tsx b/apps/web/src/components/tools/barcode-read-settings.tsx index c4ef2efa..9502c24d 100644 --- a/apps/web/src/components/tools/barcode-read-settings.tsx +++ b/apps/web/src/components/tools/barcode-read-settings.tsx @@ -1,99 +1,376 @@ -import { Check, Copy, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { Check, Copy, Download, Search } from "lucide-react"; +import { useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; import { formatHeaders } from "@/lib/api"; import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; + +interface BarcodeResult { + type: string; + text: string; + position: { + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + }; +} + +interface FileResult { + filename: string; + barcodes: BarcodeResult[]; +} + +/** Human-readable barcode type labels. */ +const FORMAT_LABELS: Record = { + QRCode: "QR Code", + Code128: "Code 128", + Code39: "Code 39", + Code93: "Code 93", + Codabar: "Codabar", + DataMatrix: "Data Matrix", + EAN8: "EAN-8", + EAN13: "EAN-13", + ITF: "ITF", + PDF417: "PDF417", + UPCA: "UPC-A", + UPCE: "UPC-E", + Aztec: "Aztec", + MaxiCode: "MaxiCode", + MicroQRCode: "Micro QR", + DataBar: "DataBar", + DataBarExpanded: "DataBar Exp", +}; + +/** Badge colors by barcode family. */ +function getBadgeColor(type: string): string { + if (type.includes("QR") || type === "Aztec" || type === "DataMatrix" || type === "MaxiCode") + return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; + if (type.includes("EAN") || type.includes("UPC") || type.includes("DataBar")) + return "bg-green-500/15 text-green-600 dark:text-green-400"; + if (type === "PDF417") return "bg-purple-500/15 text-purple-600 dark:text-purple-400"; + return "bg-amber-500/15 text-amber-600 dark:text-amber-400"; +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Send one file to the barcode-read API. */ +function scanOneFile( + file: File, + tryHarder: boolean, + onUploadProgress: (pct: number) => void, +): Promise<{ filename: string; barcodes: BarcodeResult[]; annotatedUrl: string | null }> { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("settings", JSON.stringify({ tryHarder })); + + const xhr = new XMLHttpRequest(); + xhr.timeout = 60_000; + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error("Invalid response")); + } + } else { + try { + const body = JSON.parse(xhr.responseText); + reject(new Error(body.error || `Failed: ${xhr.status}`)); + } catch { + reject(new Error(`Scanning failed: ${xhr.status}`)); + } + } + }; + xhr.onerror = () => reject(new Error("Network error")); + xhr.ontimeout = () => reject(new Error("Request timed out")); + + xhr.open("POST", "/api/v1/tools/barcode-read"); + for (const [key, value] of formatHeaders()) { + xhr.setRequestHeader(key, value); + } + xhr.send(formData); + }); +} + export function BarcodeReadSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); - const [result, setResult] = useState<{ found: boolean; text: string | null } | null>(null); - const [copied, setCopied] = useState(false); + + const [tryHarder, setTryHarder] = useState(false); + const [results, setResults] = useState([]); + const [copiedIndex, setCopiedIndex] = useState(null); + const [copiedAll, setCopiedAll] = useState(false); + const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle"); + const [progressPercent, setProgressPercent] = useState(0); + const [progressStage, setProgressStage] = useState(); + const [elapsed, setElapsed] = useState(0); + const elapsedRef = useRef | null>(null); const handleProcess = async () => { if (files.length === 0) return; - setProcessing(true); setError(null); - setResult(null); + setResults([]); + setProcessing(true); + setProgressPhase("uploading"); + setProgressPercent(0); + setProgressStage(undefined); + setElapsed(0); - try { - const formData = new FormData(); - formData.append("file", files[0]); + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTime) / 1000)); + }, 1000); - const res = await fetch("/api/v1/tools/barcode-read", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); + const total = files.length; + const allResults: FileResult[] = []; + const errors: string[] = []; + const { updateEntry } = useFileStore.getState(); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Failed: ${res.status}`); + for (let i = 0; i < total; i++) { + const file = files[i]; + const prefix = total > 1 ? `[${i + 1}/${total}] ` : ""; + const fileBase = (i / total) * 100; + const fileShare = 100 / total; + + try { + setProgressStage(`${prefix}Scanning ${file.name}...`); + + const result = await scanOneFile(file, tryHarder, (pct) => { + setProgressPhase("uploading"); + setProgressPercent(fileBase + (pct / 100) * fileShare * 0.5); + }); + + setProgressPhase("processing"); + setProgressPercent(fileBase + fileShare); + + allResults.push({ + filename: result.filename, + barcodes: result.barcodes, + }); + + // Set annotated image as processedUrl for before/after view + if (result.annotatedUrl) { + updateEntry(i, { + processedUrl: result.annotatedUrl, + processedPreviewUrl: result.annotatedUrl, + processedFilename: `annotated-${file.name.replace(/\.[^.]+$/, "")}.png`, + status: "completed", + processedSize: null, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${file.name}: ${msg}`); + allResults.push({ filename: file.name, barcodes: [] }); } + } - const data = await res.json(); - setResult(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Reading failed"); - } finally { - setProcessing(false); + if (elapsedRef.current) clearInterval(elapsedRef.current); + + if (errors.length === total) { + setError(errors.join("; ")); + } else if (errors.length > 0) { + setError(`${errors.length} of ${total} files failed`); + } + + setResults(allResults); + setProcessing(false); + setProgressPhase("idle"); + }; + + // Total barcode count across all files + const totalBarcodes = results.reduce((sum, r) => sum + r.barcodes.length, 0); + + const handleCopyOne = async (text: string, globalIdx: number) => { + const ok = await copyToClipboard(text); + if (ok) { + setCopiedIndex(globalIdx); + setTimeout(() => setCopiedIndex(null), 1500); } }; - const copyText = async () => { - if (!result?.text) return; - const ok = await copyToClipboard(result.text); + const handleCopyAll = async () => { + const allText = results + .flatMap((r) => r.barcodes.map((b) => `${FORMAT_LABELS[b.type] ?? b.type}: ${b.text}`)) + .join("\n"); + const ok = await copyToClipboard(allText); if (ok) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); } }; + const handleExportCsv = () => { + const header = "File,Type,Value\n"; + const rows = results + .flatMap((r) => + r.barcodes.map( + (b) => + `"${r.filename.replace(/"/g, '""')}","${FORMAT_LABELS[b.type] ?? b.type}","${b.text.replace(/"/g, '""')}"`, + ), + ) + .join("\n"); + const csv = header + rows; + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "barcode-results.csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + const hasFile = files.length > 0; + let globalIndex = 0; return ( -
+

- Upload an image containing a QR code to decode its content. + Scan images for QR codes, barcodes (Code 128, EAN, UPC, etc.), and 2D codes (DataMatrix, + PDF417, Aztec).

- + {/* Thorough scan toggle */} + Options + + {/* Error */} {error &&

{error}

} - {result && ( -
- {result.found ? ( - <> -

Decoded Text:

-

{result.text}

- - - ) : ( -

No QR code found in the image.

- )} + {/* Process button / progress */} + {processing ? ( + + ) : ( + + )} + + {/* Results */} + {results.length > 0 && ( +
+ {/* Summary badge */} +
+ + {totalBarcodes === 0 + ? "No barcodes found" + : `Found ${totalBarcodes} barcode${totalBarcodes !== 1 ? "s" : ""}`} + + {totalBarcodes > 0 && ( +
+ + +
+ )} +
+ + {/* Per-file results */} + {results.map((fileResult) => ( +
+ {/* Show filename header only when multiple files */} + {results.length > 1 && ( +

+ {fileResult.filename} +

+ )} + + {fileResult.barcodes.length === 0 ? ( +

No barcodes found

+ ) : ( + fileResult.barcodes.map((barcode) => { + const idx = globalIndex++; + const label = FORMAT_LABELS[barcode.type] ?? barcode.type; + const badgeColor = getBadgeColor(barcode.type); + return ( +
+ {/* Barcode type badge */} +
+ + {label} + +
+ {/* Decoded value */} +

+ {barcode.text} +

+ {/* Copy button */} + +
+ ); + }) + )} +
+ ))}
)}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 88223972..2499c751 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -238,7 +238,7 @@ export const TOOLS: Tool[] = [ { id: "barcode-read", name: "Barcode Reader", - description: "Read QR codes and barcodes from images", + description: "Scan images for QR codes, barcodes, and 2D codes", category: "utilities", icon: "ScanLine", route: "/barcode-read", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index b8061c8c..fe241567 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -92,7 +92,7 @@ export const en = { }, "barcode-read": { name: "Barcode Reader", - description: "Read QR codes and barcodes from images", + description: "Scan images for QR codes, barcodes, and 2D codes", }, collage: { name: "Collage / Grid", description: "Combine images into a grid layout" }, stitch: { name: "Stitch", description: "Join images side by side or top to bottom" }, From e50b0d5310b72143971dabef098848aefba2aca6 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:40:07 +0800 Subject: [PATCH 4/7] feat(stitch): update tool name and description for redesign --- packages/shared/src/constants.ts | 6 +++--- packages/shared/src/i18n/en.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2499c751..9cde57e1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -247,15 +247,15 @@ export const TOOLS: Tool[] = [ { id: "collage", name: "Collage / Grid", - description: "Combine images into a grid layout", + description: "Combine images into beautiful grid collages with 25+ templates", category: "layout", icon: "LayoutGrid", route: "/collage", }, { id: "stitch", - name: "Stitch", - description: "Join images side by side or top to bottom", + name: "Stitch / Combine", + description: "Join images side by side, stacked, or in a grid", category: "layout", icon: "Columns", route: "/stitch", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index fe241567..705588dc 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -94,8 +94,14 @@ export const en = { name: "Barcode Reader", description: "Scan images for QR codes, barcodes, and 2D codes", }, - collage: { name: "Collage / Grid", description: "Combine images into a grid layout" }, - stitch: { name: "Stitch", description: "Join images side by side or top to bottom" }, + collage: { + name: "Collage / Grid", + description: "Combine images into beautiful grid collages with 25+ templates", + }, + stitch: { + name: "Stitch / Combine", + description: "Join images side by side, stacked, or in a grid", + }, split: { name: "Image Splitting", description: "Split image into grid parts" }, border: { name: "Border & Frame", description: "Add borders, rounded corners, shadows" }, "svg-to-raster": { From c4d298ac3ef5a4302afc782e0aa425b447a94980 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:43:16 +0800 Subject: [PATCH 5/7] feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes --- apps/api/src/routes/tools/stitch.ts | 296 +++++++++++++++++++++------- tests/integration/api.test.ts | 125 ++++++++++++ 2 files changed, 351 insertions(+), 70 deletions(-) diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 0cd0f555..a0951471 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -12,14 +12,19 @@ import { createWorkspace } from "../../lib/workspace.js"; const MAX_CANVAS_PIXELS = 100_000_000; const settingsSchema = z.object({ - direction: z.enum(["horizontal", "vertical"]).default("horizontal"), - resize: z.enum(["fit", "original"]).default("fit"), - gap: z.number().min(0).max(100).default(0), + direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"), + gridColumns: z.number().int().min(2).max(10).default(2), + resizeMode: z.enum(["fit", "original", "stretch", "crop"]).default("fit"), + alignment: z.enum(["start", "center", "end"]).default("center"), + gap: z.number().min(0).max(200).default(0), + border: z.number().min(0).max(50).default(0), + cornerRadius: z.number().min(0).max(50).default(0), backgroundColor: z .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#FFFFFF"), format: z.enum(["png", "jpeg", "webp"]).default("png"), + quality: z.number().min(1).max(100).default(90), }); function parseHexColor(hex: string): { r: number; g: number; b: number } { @@ -30,6 +35,12 @@ function parseHexColor(hex: string): { r: number; g: number; b: number } { }; } +interface PreparedImage { + buffer: Buffer; + width: number; + height: number; +} + export function registerStitch(app: FastifyInstance) { app.post("/api/v1/tools/stitch", async (request, reply) => { const files: Array<{ buffer: Buffer; filename: string }> = []; @@ -65,7 +76,6 @@ export function registerStitch(app: FastifyInstance) { return reply.status(400).send({ error: "At least 2 images are required for stitching" }); } - // Validate all files and decode HEIC/HEIF for (const file of files) { const validation = await validateImageBuffer(file.buffer); if (!validation.valid) { @@ -89,7 +99,6 @@ export function registerStitch(app: FastifyInstance) { } try { - // Read metadata for all images const imageMetas = await Promise.all( files.map(async (file) => { const meta = await sharp(file.buffer).metadata(); @@ -101,85 +110,80 @@ export function registerStitch(app: FastifyInstance) { }), ); - // Resize images if needed const isHorizontal = settings.direction === "horizontal"; - let prepared: Array<{ buffer: Buffer; width: number; height: number }>; + const isGrid = settings.direction === "grid"; - if (settings.resize === "fit") { - if (isHorizontal) { - // Find min height, scale taller images down - const minHeight = Math.min(...imageMetas.map((m) => m.height)); - prepared = await Promise.all( - imageMetas.map(async (img) => { - if (img.height > minHeight) { - const scaledWidth = Math.round((img.width * minHeight) / img.height); - const resized = await sharp(img.buffer).resize(scaledWidth, minHeight).toBuffer(); - return { buffer: resized, width: scaledWidth, height: minHeight }; - } - return img; - }), - ); - } else { - // Find min width, scale wider images down - const minWidth = Math.min(...imageMetas.map((m) => m.width)); - prepared = await Promise.all( - imageMetas.map(async (img) => { - if (img.width > minWidth) { - const scaledHeight = Math.round((img.height * minWidth) / img.width); - const resized = await sharp(img.buffer).resize(minWidth, scaledHeight).toBuffer(); - return { buffer: resized, width: minWidth, height: scaledHeight }; - } - return img; - }), - ); - } + let prepared: PreparedImage[]; + + if (isGrid) { + prepared = await prepareForGrid(imageMetas, settings); + } else if (isHorizontal) { + prepared = await prepareForHorizontal(imageMetas, settings.resizeMode); } else { - prepared = imageMetas; + prepared = await prepareForVertical(imageMetas, settings.resizeMode); } - // Calculate canvas dimensions - const n = prepared.length; let canvasWidth: number; let canvasHeight: number; + const composites: sharp.OverlayOptions[] = []; - if (isHorizontal) { - canvasWidth = prepared.reduce((sum, img) => sum + img.width, 0) + settings.gap * (n - 1); - canvasHeight = Math.max(...prepared.map((img) => img.height)); + if (isGrid) { + const cols = Math.min(settings.gridColumns, prepared.length); + const rows = Math.ceil(prepared.length / cols); + const cellWidth = Math.max(...prepared.map((img) => img.width)); + const cellHeight = Math.max(...prepared.map((img) => img.height)); + + canvasWidth = cols * cellWidth + (cols - 1) * settings.gap + 2 * settings.border; + canvasHeight = rows * cellHeight + (rows - 1) * settings.gap + 2 * settings.border; + + for (let i = 0; i < prepared.length; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const img = prepared[i]; + + const cellLeft = settings.border + col * (cellWidth + settings.gap); + const cellTop = settings.border + row * (cellHeight + settings.gap); + + const left = cellLeft + alignOffset(cellWidth, img.width, settings.alignment); + const top = cellTop + alignOffset(cellHeight, img.height, settings.alignment); + + composites.push({ input: img.buffer, left, top }); + } + } else if (isHorizontal) { + const totalImgWidth = prepared.reduce((sum, img) => sum + img.width, 0); + const maxHeight = Math.max(...prepared.map((img) => img.height)); + + canvasWidth = totalImgWidth + (prepared.length - 1) * settings.gap + 2 * settings.border; + canvasHeight = maxHeight + 2 * settings.border; + + let offset = settings.border; + for (const img of prepared) { + const top = settings.border + alignOffset(maxHeight, img.height, settings.alignment); + composites.push({ input: img.buffer, left: offset, top }); + offset += img.width + settings.gap; + } } else { - canvasWidth = Math.max(...prepared.map((img) => img.width)); - canvasHeight = prepared.reduce((sum, img) => sum + img.height, 0) + settings.gap * (n - 1); + const maxWidth = Math.max(...prepared.map((img) => img.width)); + const totalImgHeight = prepared.reduce((sum, img) => sum + img.height, 0); + + canvasWidth = maxWidth + 2 * settings.border; + canvasHeight = totalImgHeight + (prepared.length - 1) * settings.gap + 2 * settings.border; + + let offset = settings.border; + for (const img of prepared) { + const left = settings.border + alignOffset(maxWidth, img.width, settings.alignment); + composites.push({ input: img.buffer, left, top: offset }); + offset += img.height + settings.gap; + } } - // Canvas size check if (canvasWidth * canvasHeight > MAX_CANVAS_PIXELS) { return reply.status(422).send({ error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds 100MP limit)`, }); } - // Build composites const background = parseHexColor(settings.backgroundColor); - const composites: sharp.OverlayOptions[] = []; - let offset = 0; - - for (const img of prepared) { - let left: number; - let top: number; - - if (isHorizontal) { - left = offset; - top = Math.round((canvasHeight - img.height) / 2); - offset += img.width + settings.gap; - } else { - left = Math.round((canvasWidth - img.width) / 2); - top = offset; - offset += img.height + settings.gap; - } - - composites.push({ input: img.buffer, left, top }); - } - - // Create canvas and composite let pipeline = sharp({ create: { width: canvasWidth, @@ -189,16 +193,41 @@ export function registerStitch(app: FastifyInstance) { }, }).composite(composites); - // Output in requested format if (settings.format === "jpeg") { - pipeline = pipeline.jpeg({ quality: 90 }); + pipeline = pipeline.jpeg({ quality: settings.quality }); } else if (settings.format === "webp") { - pipeline = pipeline.webp({ quality: 90 }); + pipeline = pipeline.webp({ quality: settings.quality }); } else { pipeline = pipeline.png(); } - const result = await pipeline.toBuffer(); + let result = await pipeline.toBuffer(); + + if (settings.cornerRadius > 0) { + const meta = await sharp(result).metadata(); + const w = meta.width!; + const h = meta.height!; + const r = Math.min(settings.cornerRadius, Math.floor(Math.min(w, h) / 2)); + + const mask = Buffer.from( + ``, + ); + + result = await sharp(result) + .ensureAlpha() + .composite([{ input: mask, blend: "dest-in" }]) + .png() + .toBuffer(); + + if (settings.format === "jpeg") { + result = await sharp(result) + .flatten({ background: { r: background.r, g: background.g, b: background.b } }) + .jpeg({ quality: settings.quality }) + .toBuffer(); + } else if (settings.format === "webp") { + result = await sharp(result).webp({ quality: settings.quality }).toBuffer(); + } + } const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); @@ -220,3 +249,130 @@ export function registerStitch(app: FastifyInstance) { } }); } + +function alignOffset(containerSize: number, itemSize: number, alignment: string): number { + if (alignment === "start") return 0; + if (alignment === "end") return containerSize - itemSize; + return Math.round((containerSize - itemSize) / 2); +} + +async function prepareForHorizontal( + images: PreparedImage[], + resizeMode: string, +): Promise { + if (resizeMode === "original") return images; + + const minHeight = Math.min(...images.map((m) => m.height)); + + return Promise.all( + images.map(async (img) => { + if (img.height === minHeight && resizeMode === "fit") return img; + + if (resizeMode === "fit") { + const scaledWidth = Math.round((img.width * minHeight) / img.height); + const resized = await sharp(img.buffer).resize(scaledWidth, minHeight).toBuffer(); + return { buffer: resized, width: scaledWidth, height: minHeight }; + } + + if (resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(img.width, minHeight, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: img.width, height: minHeight }; + } + + if (resizeMode === "crop") { + const scaledWidth = Math.round((img.width * minHeight) / img.height); + const resized = await sharp(img.buffer) + .resize(scaledWidth, minHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: scaledWidth, height: minHeight }; + } + + return img; + }), + ); +} + +async function prepareForVertical( + images: PreparedImage[], + resizeMode: string, +): Promise { + if (resizeMode === "original") return images; + + const minWidth = Math.min(...images.map((m) => m.width)); + + return Promise.all( + images.map(async (img) => { + if (img.width === minWidth && resizeMode === "fit") return img; + + if (resizeMode === "fit") { + const scaledHeight = Math.round((img.height * minWidth) / img.width); + const resized = await sharp(img.buffer).resize(minWidth, scaledHeight).toBuffer(); + return { buffer: resized, width: minWidth, height: scaledHeight }; + } + + if (resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(minWidth, img.height, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: minWidth, height: img.height }; + } + + if (resizeMode === "crop") { + const scaledHeight = Math.round((img.height * minWidth) / img.width); + const resized = await sharp(img.buffer) + .resize(minWidth, scaledHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: minWidth, height: scaledHeight }; + } + + return img; + }), + ); +} + +async function prepareForGrid( + images: PreparedImage[], + settings: { gridColumns: number; resizeMode: string }, +): Promise { + if (settings.resizeMode === "original") return images; + + const medianWidth = median(images.map((m) => m.width)); + const medianHeight = median(images.map((m) => m.height)); + + return Promise.all( + images.map(async (img) => { + if (settings.resizeMode === "fit") { + const scale = Math.min(medianWidth / img.width, medianHeight / img.height); + if (scale >= 1) return img; + const newW = Math.round(img.width * scale); + const newH = Math.round(img.height * scale); + const resized = await sharp(img.buffer).resize(newW, newH).toBuffer(); + return { buffer: resized, width: newW, height: newH }; + } + + if (settings.resizeMode === "stretch") { + const resized = await sharp(img.buffer) + .resize(medianWidth, medianHeight, { fit: "fill" }) + .toBuffer(); + return { buffer: resized, width: medianWidth, height: medianHeight }; + } + + if (settings.resizeMode === "crop") { + const resized = await sharp(img.buffer) + .resize(medianWidth, medianHeight, { fit: "cover" }) + .toBuffer(); + return { buffer: resized, width: medianWidth, height: medianHeight }; + } + + return img; + }), + ); +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid]; +} diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index c3054071..a5d2353b 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -1754,6 +1754,131 @@ describe("POST /api/v1/tools/stitch", () => { expect(res.statusCode).toBe(200); }); + + it("stitches in grid mode with 2 columns", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { name: "file", filename: "c.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "d.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { name: "settings", content: JSON.stringify({ direction: "grid", gridColumns: 2 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.jobId).toBeDefined(); + expect(body.downloadUrl).toMatch(/\/api\/v1\/download\//); + }); + + it("applies alignment setting", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { name: "settings", content: JSON.stringify({ alignment: "start" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + }); + + it("applies border and cornerRadius", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG_200x150 }, + { name: "settings", content: JSON.stringify({ border: 20, cornerRadius: 10 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + }); + + it("respects quality setting for jpeg", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG_200x150 }, + { name: "settings", content: JSON.stringify({ format: "jpeg", quality: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + }); + + it("handles resizeMode stretch", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { name: "settings", content: JSON.stringify({ resizeMode: "stretch" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + }); + + it("handles resizeMode crop", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { name: "settings", content: JSON.stringify({ resizeMode: "crop" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).toBe(200); + }); }); // ═══════════════════════════════════════════════════════════════════════════ From d67dd18a30063af7938634c66742da55967bf18f Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:46:10 +0800 Subject: [PATCH 6/7] feat(stitch): redesign settings UI with grid, alignment, border, radius, quality --- .../src/components/tools/stitch-settings.tsx | 258 ++++++++++++------ 1 file changed, 182 insertions(+), 76 deletions(-) diff --git a/apps/web/src/components/tools/stitch-settings.tsx b/apps/web/src/components/tools/stitch-settings.tsx index 79e985a8..5697d579 100644 --- a/apps/web/src/components/tools/stitch-settings.tsx +++ b/apps/web/src/components/tools/stitch-settings.tsx @@ -1,21 +1,27 @@ -import { ChevronDown, ChevronUp, Download, Loader2 } from "lucide-react"; +import { Download, Loader2 } from "lucide-react"; import { useState } from "react"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; -type Direction = "horizontal" | "vertical"; -type ResizeMode = "fit" | "original"; +type Direction = "horizontal" | "vertical" | "grid"; +type ResizeMode = "fit" | "original" | "stretch" | "crop"; +type Alignment = "start" | "center" | "end"; type OutputFormat = "png" | "jpeg" | "webp"; export function StitchSettings() { const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); + const [direction, setDirection] = useState("horizontal"); - const [showAdvanced, setShowAdvanced] = useState(false); + const [gridColumns, setGridColumns] = useState(3); const [resizeMode, setResizeMode] = useState("fit"); + const [alignment, setAlignment] = useState("center"); const [gap, setGap] = useState(0); + const [border, setBorder] = useState(0); + const [cornerRadius, setCornerRadius] = useState(0); const [backgroundColor, setBackgroundColor] = useState("#FFFFFF"); const [format, setFormat] = useState("png"); + const [quality, setQuality] = useState(90); const [downloadUrl, setDownloadUrl] = useState(null); const handleProcess = async () => { @@ -32,7 +38,18 @@ export function StitchSettings() { } formData.append( "settings", - JSON.stringify({ direction, resizeMode, gap, backgroundColor, format }), + JSON.stringify({ + direction, + gridColumns, + resizeMode, + alignment, + gap, + border, + cornerRadius, + backgroundColor, + format, + quality, + }), ); const res = await fetch("/api/v1/tools/stitch", { @@ -62,10 +79,11 @@ export function StitchSettings() { return (
+ {/* Layout */}

Direction

-
- {(["horizontal", "vertical"] as const).map((d) => ( +
+ {(["horizontal", "vertical", "grid"] as const).map((d) => (
- - - {showAdvanced && ( -
-
-

Resize Mode

-
- {(["fit", "original"] as const).map((m) => ( - - ))} -
-
- -
-
- - {gap}px -
- setGap(Number(e.target.value))} - className="w-full mt-1" - /> -
- -
-