diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 63a6c9a5..027c7426 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -1,9 +1,12 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; +import archiver from "archiver"; import type { FastifyInstance } from "fastify"; +import PQueue from "p-queue"; import sharp from "sharp"; import { z } from "zod"; +import { env } from "../../config.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; import { sanitizeSvg } from "../../lib/svg-sanitize.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -22,11 +25,224 @@ const settingsSchema = z.object({ outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"), }); +interface ParsedSvgFile { + buffer: Buffer; + filename: string; +} + +/** Convert a sanitized SVG buffer to the requested raster format. */ +async function convertSvg( + svgBuffer: Buffer, + filename: string, + settings: z.infer, +): Promise<{ buffer: Buffer; filename: string; ext: string }> { + let image = sharp(svgBuffer, { density: settings.dpi }); + + if (settings.width || settings.height) { + image = image.resize(settings.width, settings.height, { fit: "inside" }); + } + + if (settings.backgroundColor !== "#00000000") { + const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16); + const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16); + const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16); + image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } }); + } + + let buffer: Buffer; + let ext: string; + + switch (settings.outputFormat) { + case "jpg": + buffer = await image.jpeg({ quality: settings.quality }).toBuffer(); + ext = "jpg"; + break; + case "webp": + buffer = await image.webp({ quality: settings.quality }).toBuffer(); + ext = "webp"; + break; + case "avif": + buffer = await image.avif({ quality: settings.quality }).toBuffer(); + ext = "avif"; + break; + case "tiff": + buffer = await image.tiff({ quality: settings.quality }).toBuffer(); + ext = "tiff"; + break; + case "gif": + buffer = await image.gif().toBuffer(); + ext = "gif"; + break; + case "heif": { + const pngBuffer = await image.png().toBuffer(); + buffer = await encodeHeic(pngBuffer, settings.quality); + ext = "heif"; + break; + } + default: + buffer = await image.png().toBuffer(); + ext = "png"; + break; + } + + const baseName = basename(filename).replace(/\.svg$/i, ""); + return { buffer, filename: `${baseName}.${ext}`, ext }; +} + /** * SVG to raster conversion. * Custom route since input is SVG (not validated as image by magic bytes). */ export function registerSvgToRaster(app: FastifyInstance) { + // --- Batch endpoint (registered first for route priority) --- + app.post("/api/v1/tools/svg-to-raster/batch", async (request, reply) => { + const files: ParsedSvgFile[] = []; + let settingsRaw: string | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + const buf = Buffer.concat(chunks); + if (buf.length > 0) { + files.push({ + buffer: buf, + filename: basename(part.filename ?? "output"), + }); + } + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (files.length === 0) { + return reply.status(400).send({ error: "No SVG files provided" }); + } + + if (files.length > env.MAX_BATCH_SIZE) { + return reply.status(400).send({ + error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, + }); + } + + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply.status(400).send({ error: "Invalid settings", details: result.error.issues }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); + const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( + null, + ); + let failedCount = 0; + + const tasks = files.map((file, index) => + queue.add(async () => { + // Sanitize each SVG individually + let sanitized: Buffer; + try { + sanitized = sanitizeSvg(file.buffer); + } catch { + failedCount++; + return; + } + + try { + const result = await convertSvg(sanitized, file.filename, settings); + results[index] = { buffer: result.buffer, filename: result.filename }; + } catch { + failedCount++; + } + }), + ); + + await Promise.all(tasks); + + // If every file failed, return an error instead of an empty ZIP + if (failedCount === files.length) { + return reply.status(422).send({ error: "All files failed processing" }); + } + + // Deduplicate filenames and build X-File-Results header + const usedNames = new Set(); + function getUniqueName(name: string): string { + if (!usedNames.has(name)) { + usedNames.add(name); + return name; + } + const dotIdx = name.lastIndexOf("."); + const base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + let counter = 1; + let candidate = `${base}_${counter}${ext}`; + while (usedNames.has(candidate)) { + counter++; + candidate = `${base}_${counter}${ext}`; + } + usedNames.add(candidate); + return candidate; + } + + const fileResultsMap: Record = {}; + for (let i = 0; i < results.length; i++) { + const entry = results[i]; + if (entry) { + const uniqueName = getUniqueName(entry.filename); + entry.filename = uniqueName; + fileResultsMap[String(i)] = uniqueName; + } + } + + const jobId = randomUUID(); + + // Hijack and stream the ZIP response + reply.hijack(); + reply.raw.writeHead(200, { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="batch-svg-to-raster-${jobId.slice(0, 8)}.zip"`, + "Transfer-Encoding": "chunked", + "X-Job-Id": jobId, + "X-File-Results": JSON.stringify(fileResultsMap), + }); + + const archive = archiver("zip", { zlib: { level: 5 } }); + + archive.on("error", (err) => { + request.log.error({ err }, "Archiver error during SVG batch processing"); + if (!reply.raw.writableEnded) { + reply.raw.end(); + } + }); + + archive.pipe(reply.raw); + + for (const result of results) { + if (result) { + archive.append(result.buffer, { name: result.filename }); + } + } + + await archive.finalize(); + }); + + // --- Single-file endpoint --- app.post("/api/v1/tools/svg-to-raster", async (request, reply) => { let fileBuffer: Buffer | null = null; let filename = "output"; @@ -79,57 +295,11 @@ export function registerSvgToRaster(app: FastifyInstance) { } try { - let image = sharp(fileBuffer, { density: settings.dpi }); - - if (settings.width || settings.height) { - image = image.resize(settings.width, settings.height, { fit: "inside" }); - } - - if (settings.backgroundColor !== "#00000000") { - const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16); - const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16); - const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16); - image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } }); - } - - let buffer: Buffer; - let ext: string; - - switch (settings.outputFormat) { - case "jpg": - buffer = await image.jpeg({ quality: settings.quality }).toBuffer(); - ext = "jpg"; - break; - case "webp": - buffer = await image.webp({ quality: settings.quality }).toBuffer(); - ext = "webp"; - break; - case "avif": - buffer = await image.avif({ quality: settings.quality }).toBuffer(); - ext = "avif"; - break; - case "tiff": - buffer = await image.tiff({ quality: settings.quality }).toBuffer(); - ext = "tiff"; - break; - case "gif": - buffer = await image.gif().toBuffer(); - ext = "gif"; - break; - case "heif": { - // Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc. - const pngBuffer = await image.png().toBuffer(); - buffer = await encodeHeic(pngBuffer, settings.quality); - ext = "heif"; - break; - } - default: - buffer = await image.png().toBuffer(); - ext = "png"; - break; - } - - const outFilename = `${filename}.${ext}`; + const { + buffer, + filename: outFilename, + ext, + } = await convertSvg(fileBuffer, filename, settings); const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); const outputPath = join(workspacePath, "output", outFilename);