From df372ee1ca59eafd65c6415c54c47924bcba0521 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 13:06:34 +0800 Subject: [PATCH] feat(svg-to-raster): extend settings schema with DPI, quality, and 4 new output formats (#49) Add user-configurable DPI (36-1200) and quality (1-100) instead of hardcoded values. Support avif, tiff, gif, heif output in addition to png, jpg, webp. Width is now optional, defaulting to SVG intrinsic size at the given DPI. Generate browser-previewable webp thumbnails for non-browser formats (tiff, heif). Remove unused _contentType variable. Co-authored-by: Siddharth Kumar Sah --- apps/api/src/routes/tools/svg-to-raster.ts | 61 ++++++++++++++++------ 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index e68d25a9..471bb85d 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -8,13 +8,15 @@ import { sanitizeSvg } from "../../lib/svg-sanitize.js"; import { createWorkspace } from "../../lib/workspace.js"; const settingsSchema = z.object({ - width: z.number().min(1).max(8192).default(1024), - height: z.number().min(1).max(8192).optional(), + width: z.number().min(1).max(16384).optional(), + height: z.number().min(1).max(16384).optional(), + dpi: z.number().min(36).max(1200).default(300), + quality: z.number().min(1).max(100).default(90), backgroundColor: z .string() .regex(/^#[0-9a-fA-F]{6,8}$/) .default("#00000000"), - outputFormat: z.enum(["png", "jpg", "webp"]).default("png"), + outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"), }); /** @@ -74,13 +76,12 @@ export function registerSvgToRaster(app: FastifyInstance) { } try { - let image = sharp(fileBuffer, { density: 300 }).resize( - settings.width, - settings.height ?? undefined, - { fit: "inside" }, - ); + let image = sharp(fileBuffer, { density: settings.dpi }); + + if (settings.width || settings.height) { + image = image.resize(settings.width, settings.height, { fit: "inside" }); + } - // Apply background if not transparent if (settings.backgroundColor !== "#00000000") { const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16); const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16); @@ -90,23 +91,35 @@ export function registerSvgToRaster(app: FastifyInstance) { let buffer: Buffer; let ext: string; - let _contentType: string; switch (settings.outputFormat) { case "jpg": - buffer = await image.jpeg({ quality: 90 }).toBuffer(); + buffer = await image.jpeg({ quality: settings.quality }).toBuffer(); ext = "jpg"; - _contentType = "image/jpeg"; break; case "webp": - buffer = await image.webp({ quality: 90 }).toBuffer(); + buffer = await image.webp({ quality: settings.quality }).toBuffer(); ext = "webp"; - _contentType = "image/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": + buffer = await image.heif({ quality: settings.quality }).toBuffer(); + ext = "heif"; break; default: buffer = await image.png().toBuffer(); ext = "png"; - _contentType = "image/png"; break; } @@ -116,9 +129,27 @@ export function registerSvgToRaster(app: FastifyInstance) { const outputPath = join(workspacePath, "output", outFilename); await writeFile(outputPath, buffer); + // Generate browser-previewable thumbnail for non-browser formats + const NON_PREVIEWABLE = new Set(["tiff", "heif"]); + let previewUrl: string | undefined; + if (NON_PREVIEWABLE.has(ext)) { + try { + const previewBuffer = await sharp(buffer) + .resize(1200, 1200, { fit: "inside" }) + .webp({ quality: 80 }) + .toBuffer(); + const previewPath = join(workspacePath, "output", "preview.webp"); + await writeFile(previewPath, previewBuffer); + previewUrl = `/api/v1/download/${jobId}/preview.webp`; + } catch { + // Non-fatal - frontend shows success card fallback + } + } + return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`, + previewUrl, originalSize: fileBuffer.length, processedSize: buffer.length, });