From 8637105bcee2e6ceacf5d358be966dd1cc09e454 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 13 May 2026 10:39:38 +0800 Subject: [PATCH] fix: add exotic format decoding to image-to-pdf tool --- apps/api/src/routes/tools/image-to-pdf.ts | 119 ++++++++++++------ .../tools/image-to-pdf-settings.tsx | 24 +++- 2 files changed, 101 insertions(+), 42 deletions(-) diff --git a/apps/api/src/routes/tools/image-to-pdf.ts b/apps/api/src/routes/tools/image-to-pdf.ts index e4e8ea60..463be23a 100644 --- a/apps/api/src/routes/tools/image-to-pdf.ts +++ b/apps/api/src/routes/tools/image-to-pdf.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; +import { createWriteStream } from "node:fs"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import PDFDocument from "pdfkit"; import sharp from "sharp"; @@ -24,6 +26,7 @@ const settingsSchema = z.object({ orientation: z.enum(["portrait", "landscape"]).default("portrait"), margin: z.number().min(0).max(500).default(20), targetSize: targetSizeSchema.optional(), + collate: z.boolean().default(true), }); const PAGE_SIZES: Record = { @@ -166,20 +169,6 @@ export function registerImageToPdf(app: FastifyInstance) { const contentW = pageW - margin * 2; const contentH = pageH - margin * 2; - const doc = new PDFDocument({ - size: [pageW, pageH], - margin, - autoFirstPage: false, - compress: targetBytes !== null, - }); - - const pdfChunks: Buffer[] = []; - doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk)); - - const pdfDone = new Promise((resolve) => { - doc.on("end", () => resolve(Buffer.concat(pdfChunks))); - }); - const preparedBuffers: Buffer[] = []; for (const file of files) { let buf = file.buffer; @@ -247,43 +236,99 @@ export function registerImageToPdf(app: FastifyInstance) { imageBuffers = await Promise.all(preparedBuffers.map((buf) => sharp(buf).png().toBuffer())); } - for (let i = 0; i < imageBuffers.length; i++) { - doc.addPage({ size: [pageW, pageH], margin }); + async function buildPdf(buffers: Buffer[]): Promise { + const doc = new PDFDocument({ + size: [pageW, pageH], + margin, + autoFirstPage: false, + compress: targetBytes !== null, + }); - const imgBuf = imageBuffers[i]; - const meta = await sharp(imgBuf).metadata(); - const imgW = meta.width ?? 100; - const imgH = meta.height ?? 100; + const pdfChunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk)); - const scale = Math.min(contentW / imgW, contentH / imgH, 1); - const scaledW = imgW * scale; - const scaledH = imgH * scale; + const pdfDone = new Promise((resolve) => { + doc.on("end", () => resolve(Buffer.concat(pdfChunks))); + }); - const x = margin + (contentW - scaledW) / 2; - const y = margin + (contentH - scaledH) / 2; + for (const imgBuf of buffers) { + doc.addPage({ size: [pageW, pageH], margin }); + const meta = await sharp(imgBuf).metadata(); + const imgW = meta.width ?? 100; + const imgH = meta.height ?? 100; - doc.image(imgBuf, x, y, { width: scaledW, height: scaledH }); - } + const scale = Math.min(contentW / imgW, contentH / imgH, 1); + const scaledW = imgW * scale; + const scaledH = imgH * scale; - doc.end(); - const pdfBuffer = await pdfDone; + const x = margin + (contentW - scaledW) / 2; + const y = margin + (contentH - scaledH) / 2; - if (compression && targetBytes !== null) { - compression.targetMet = pdfBuffer.length <= targetBytes; + doc.image(imgBuf, x, y, { width: scaledW, height: scaledH }); + } + + doc.end(); + return pdfDone; } const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); - const filename = "images.pdf"; - const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, pdfBuffer); + const outputDir = join(workspacePath, "output"); + const originalSize = files.reduce((s, f) => s + f.buffer.length, 0); + + if (settings.collate) { + const pdfBuffer = await buildPdf(imageBuffers); + + if (compression && targetBytes !== null) { + compression.targetMet = pdfBuffer.length <= targetBytes; + } + + const filename = "images.pdf"; + await writeFile(join(outputDir, filename), pdfBuffer); + + return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${filename}`, + originalSize, + processedSize: pdfBuffer.length, + pages: files.length, + ...(compression ? { compression } : {}), + }); + } + + let totalProcessedSize = 0; + const pdfFilenames: string[] = []; + + for (let i = 0; i < imageBuffers.length; i++) { + const pdfBuffer = await buildPdf([imageBuffers[i]]); + const baseName = files[i].filename.replace(/\.[^.]+$/, ""); + const pdfName = `${baseName}.pdf`; + await writeFile(join(outputDir, pdfName), pdfBuffer); + pdfFilenames.push(pdfName); + totalProcessedSize += pdfBuffer.length; + } + + const zipFilename = "images.zip"; + const zipPath = join(outputDir, zipFilename); + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 5 } }); + output.on("close", resolve); + archive.on("error", reject); + archive.pipe(output); + for (const name of pdfFilenames) { + archive.file(join(outputDir, name), { name }); + } + archive.finalize(); + }); return reply.send({ jobId, - downloadUrl: `/api/v1/download/${jobId}/${filename}`, - originalSize: files.reduce((s, f) => s + f.buffer.length, 0), - processedSize: pdfBuffer.length, + downloadUrl: `/api/v1/download/${jobId}/${zipFilename}`, + originalSize, + processedSize: totalProcessedSize, pages: files.length, + collated: false, ...(compression ? { compression } : {}), }); } catch (err) { diff --git a/apps/web/src/components/tools/image-to-pdf-settings.tsx b/apps/web/src/components/tools/image-to-pdf-settings.tsx index 62be4eb2..9dea27da 100644 --- a/apps/web/src/components/tools/image-to-pdf-settings.tsx +++ b/apps/web/src/components/tools/image-to-pdf-settings.tsx @@ -110,6 +110,7 @@ export function ImageToPdfSettings() { const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4"); const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); const [margin, setMargin] = useState(20); + const [collate, setCollate] = useState(true); const [downloadUrl, setDownloadUrl] = useState(null); const [targetSizeValue, setTargetSizeValue] = useState(""); const [targetSizeUnit, setTargetSizeUnit] = useState<"KB" | "MB">("MB"); @@ -171,7 +172,7 @@ export function ImageToPdfSettings() { for (const file of files) { formData.append("file", file); } - const settings: Record = { pageSize, orientation, margin }; + const settings: Record = { pageSize, orientation, margin, collate }; if (targetSizeValue.trim() !== "") { const numVal = Number.parseFloat(targetSizeValue); if (!Number.isNaN(numVal) && numVal > 0) { @@ -245,6 +246,7 @@ export function ImageToPdfSettings() { pageSize, orientation, margin, + collate, targetSizeValue, targetSizeUnit, setProcessing, @@ -256,10 +258,22 @@ export function ImageToPdfSettings() { return (

- {files.length} image{files.length !== 1 ? "s" : ""} will be combined into a PDF, one image - per page. + {files.length} image{files.length !== 1 ? "s" : ""} will be converted to{" "} + {collate ? "a single PDF, one image per page" : "separate PDFs, one per image"}.

+ {files.length > 1 && ( + + )} +