feat: add format tools (SVG-to-raster, vectorize, GIF) and optimization (rename, favicon, image-to-PDF)

Add 6 tools for format conversion and optimization extras:
- svg-to-raster: SVG to PNG/JPG/WebP at custom resolution
- vectorize: raster to SVG via potrace (B&W and color modes)
- gif-tools: animated GIF resize, frame extraction, optimization
- bulk-rename: pattern-based file renaming with ZIP output
- favicon: generate all favicon/app icon sizes with manifest.json
- image-to-pdf: combine images into PDF using pdfkit
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:20:54 +08:00
parent b8aba1719c
commit b88d0d0af8
12 changed files with 1374 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"),
startIndex: z.number().min(0).default(1),
});
/**
* Bulk rename files with a pattern and return as ZIP.
* No image processing - just renames.
*/
export function registerBulkRename(app: FastifyInstance) {
app.post(
"/api/v1/tools/bulk-rename",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
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 ?? `file-${files.length}`),
});
}
} 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 files provided" });
}
let settings: z.infer<typeof settingsSchema>;
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" });
}
try {
const jobId = randomUUID();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
for (let i = 0; i < files.length; i++) {
const ext = extname(files[i].filename);
const index = settings.startIndex + i;
const padded = String(index).padStart(String(files.length + settings.startIndex).length, "0");
const newName =
settings.pattern
.replace(/\{\{index\}\}/g, String(index))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) +
ext;
archive.append(files[i].buffer, { name: newName });
}
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Rename failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
},
);
}
+110
View File
@@ -0,0 +1,110 @@
import { z } from "zod";
import sharp from "sharp";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
const FAVICON_SIZES = [
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
{ name: "favicon-32x32.png", size: 32, format: "png" as const },
{ name: "favicon-48x48.png", size: 48, format: "png" as const },
{ name: "apple-touch-icon.png", size: 180, format: "png" as const },
{ name: "android-chrome-192x192.png", size: 192, format: "png" as const },
{ name: "android-chrome-512x512.png", size: 512, format: "png" as const },
];
export function registerFavicon(app: FastifyInstance) {
app.post(
"/api/v1/tools/favicon",
async (request, reply) => {
let fileBuffer: Buffer | 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);
}
fileBuffer = Buffer.concat(chunks);
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const jobId = randomUUID();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
// Generate each size
for (const icon of FAVICON_SIZES) {
const buffer = await sharp(fileBuffer)
.resize(icon.size, icon.size, { fit: "cover" })
.png()
.toBuffer();
archive.append(buffer, { name: icon.name });
}
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
// Simple ICO format: just include the 32x32 PNG as an ICO
const ico32 = await sharp(fileBuffer)
.resize(32, 32, { fit: "cover" })
.png()
.toBuffer();
archive.append(ico32, { name: "favicon.ico" });
// Generate manifest.json (for PWA)
const manifest = {
name: "App",
short_name: "App",
icons: [
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
// Generate HTML snippet
const htmlSnippet = `<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
`;
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
await archive.finalize();
} catch (err) {
if (!reply.raw.headersSent) {
return reply.status(422).send({
error: "Favicon generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}
},
);
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().min(1).max(4096).optional(),
height: z.number().min(1).max(4096).optional(),
extractFrame: z.number().min(0).optional(),
optimize: z.boolean().default(false),
});
export function registerGifTools(app: FastifyInstance) {
createToolRoute(app, {
toolId: "gif-tools",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
if (settings.extractFrame !== undefined) {
// Extract a single frame from animated GIF
const image = sharp(inputBuffer, { page: settings.extractFrame });
if (settings.width || settings.height) {
image.resize(settings.width, settings.height, { fit: "inside" });
}
const buffer = await image.png().toBuffer();
const outName = filename.replace(/\.gif$/i, "") + `_frame${settings.extractFrame}.png`;
return { buffer, filename: outName, contentType: "image/png" };
}
// Process animated GIF (preserve animation)
const image = sharp(inputBuffer, { animated: true });
if (settings.width || settings.height) {
image.resize(settings.width, settings.height, { fit: "inside" });
}
if (settings.optimize) {
// Reduce colors for optimization
image.gif({ effort: 10 });
}
const buffer = await image.gif().toBuffer();
return { buffer, filename, contentType: "image/gif" };
},
});
}
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import sharp from "sharp";
import PDFDocument from "pdfkit";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"),
orientation: z.enum(["portrait", "landscape"]).default("portrait"),
margin: z.number().min(0).max(100).default(20),
});
const PAGE_SIZES: Record<string, [number, number]> = {
A4: [595.28, 841.89],
Letter: [612, 792],
A3: [841.89, 1190.55],
A5: [419.53, 595.28],
};
export function registerImageToPdf(app: FastifyInstance) {
app.post(
"/api/v1/tools/image-to-pdf",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
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 ?? `image-${files.length}`),
});
}
} 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 image files provided" });
}
let settings: z.infer<typeof settingsSchema>;
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" });
}
try {
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
if (settings.orientation === "landscape") {
[pageW, pageH] = [pageH, pageW];
}
const margin = settings.margin;
const contentW = pageW - margin * 2;
const contentH = pageH - margin * 2;
// Create PDF
const doc = new PDFDocument({
size: [pageW, pageH],
margin,
autoFirstPage: false,
});
const pdfChunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
const pdfDone = new Promise<Buffer>((resolve) => {
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
});
for (const file of files) {
doc.addPage({ size: [pageW, pageH], margin });
// Convert to PNG for PDFKit compatibility
const pngBuffer = await sharp(file.buffer)
.png()
.toBuffer();
const meta = await sharp(pngBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
// Scale to fit within content area
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
const scaledW = imgW * scale;
const scaledH = imgH * scale;
// Center on page
const x = margin + (contentW - scaledW) / 2;
const y = margin + (contentH - scaledH) / 2;
doc.image(pngBuffer, x, y, {
width: scaledW,
height: scaledH,
});
}
doc.end();
const pdfBuffer = await pdfDone;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "images.pdf";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, pdfBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: pdfBuffer.length,
pages: files.length,
});
} catch (err) {
return reply.status(422).send({
error: "PDF creation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
+123
View File
@@ -0,0 +1,123 @@
import { z } from "zod";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
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(),
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6,8}$/).default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
});
/**
* SVG to raster conversion.
* Custom route since input is SVG (not validated as image by magic bytes).
*/
export function registerSvgToRaster(app: FastifyInstance) {
app.post(
"/api/v1/tools/svg-to-raster",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
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);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
} 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 (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No SVG file provided" });
}
let settings: z.infer<typeof settingsSchema>;
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" });
}
try {
let image = sharp(fileBuffer, { density: 300 }).resize(
settings.width,
settings.height ?? undefined,
{ 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);
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;
let contentType: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: 90 }).toBuffer();
ext = "jpg";
contentType = "image/jpeg";
break;
case "webp":
buffer = await image.webp({ quality: 90 }).toBuffer();
ext = "webp";
contentType = "image/webp";
break;
case "png":
default:
buffer = await image.png().toBuffer();
ext = "png";
contentType = "image/png";
break;
}
const outFilename = `${filename}.${ext}`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "SVG conversion failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
+132
View File
@@ -0,0 +1,132 @@
import { z } from "zod";
import sharp from "sharp";
import potrace from "potrace";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
colorMode: z.enum(["bw", "color"]).default("bw"),
threshold: z.number().min(0).max(255).default(128),
detail: z.enum(["low", "medium", "high"]).default("medium"),
});
function traceImage(
buffer: Buffer,
options: { threshold: number; turdSize: number; color?: string },
): Promise<string> {
return new Promise((resolve, reject) => {
potrace.trace(buffer, options, (err: Error | null, svg: string) => {
if (err) reject(err);
else resolve(svg);
});
});
}
function posterize(
buffer: Buffer,
options: { steps: number; threshold: number },
): Promise<string> {
return new Promise((resolve, reject) => {
potrace.posterize(buffer, options, (err: Error | null, svg: string) => {
if (err) reject(err);
else resolve(svg);
});
});
}
export function registerVectorize(app: FastifyInstance) {
app.post(
"/api/v1/tools/vectorize",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
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);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} 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 (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
let settings: z.infer<typeof settingsSchema>;
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" });
}
try {
// Convert to BMP-compatible format for potrace (PNG)
const pngBuffer = await sharp(fileBuffer)
.grayscale()
.png()
.toBuffer();
const turdSize = settings.detail === "low" ? 10 : settings.detail === "high" ? 1 : 4;
let svg: string;
if (settings.colorMode === "color") {
// Color mode: posterize
svg = await posterize(pngBuffer, {
steps: settings.detail === "low" ? 3 : settings.detail === "high" ? 8 : 5,
threshold: settings.threshold,
});
} else {
// B&W mode: simple trace
svg = await traceImage(pngBuffer, {
threshold: settings.threshold,
turdSize,
});
}
const svgBuffer = Buffer.from(svg, "utf-8");
const outFilename = `${filename}.svg`;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, svgBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
originalSize: fileBuffer.length,
processedSize: svgBuffer.length,
svgPreview: svg.length < 50000 ? svg : undefined,
});
} catch (err) {
return reply.status(422).send({
error: "Vectorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}