From 69e31b7aea339f67d160e23526ebe9d4b111e808 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:34:09 +0800 Subject: [PATCH] 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",