From df372ee1ca59eafd65c6415c54c47924bcba0521 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 13:06:34 +0800 Subject: [PATCH 1/2] 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, }); From d9704fda82bc020a30f0346d1e45d37838e15d62 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 13:43:07 +0800 Subject: [PATCH 2/2] feat(pdf-to-image): redesign with thumbnails, page selection, color mode, and expanded formats - Add preview endpoint for page thumbnails with clickable selection - Add 8 output formats (PNG, JPEG, WebP, AVIF, TIFF, GIF, HEIC, HEIF) - Add quality slider for lossy formats, color mode (Color/Grayscale/B&W) - Add custom DPI input (36-1200) alongside preset buttons - Replace reply.hijack() ZIP streaming with JSON response + server-side ZIP - Add dedicated Zustand store with bidirectional page range sync - Add ResultsPanel with per-page download, format/size badges - Update integration tests (16 cases) and e2e tests (3 cases) --- apps/api/src/routes/tools/pdf-to-image.ts | 245 +++++++++---- .../components/tools/pdf-to-image-preview.tsx | 226 ++++++++++++ .../tools/pdf-to-image-settings.tsx | 329 +++++++++--------- apps/web/src/lib/tool-registry.tsx | 10 +- apps/web/src/stores/pdf-to-image-store.ts | 255 ++++++++++++++ tests/e2e/pdf-to-image.spec.ts | 46 ++- tests/integration/pdf-to-image.test.ts | 194 ++++++++++- 7 files changed, 1035 insertions(+), 270 deletions(-) create mode 100644 apps/web/src/components/tools/pdf-to-image-preview.tsx create mode 100644 apps/web/src/stores/pdf-to-image-store.ts diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 7a6a5fff..9ee548db 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -1,17 +1,21 @@ import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import * as mupdf from "mupdf"; import sharp from "sharp"; import { z } from "zod"; +import { encodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; // ── Settings schema ────────────────────────────────────────────── const settingsSchema = z.object({ - format: z.enum(["png", "jpg", "webp", "avif", "tiff"]).default("png"), - dpi: z.union([z.literal(72), z.literal(150), z.literal(300), z.literal(600)]).default(150), + format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"), + dpi: z.number().min(36).max(1200).default(150), + quality: z.number().min(1).max(100).default(85), + colorMode: z.enum(["color", "grayscale", "bw"]).default("color"), pages: z.string().default("all"), }); @@ -77,19 +81,42 @@ const FORMAT_EXT: Record = { webp: ".webp", avif: ".avif", tiff: ".tiff", + gif: ".gif", + heic: ".heic", + heif: ".heif", }; -function convertWithSharp(pngBuffer: Uint8Array, format: string): Promise { - const s = sharp(Buffer.from(pngBuffer)); +async function convertWithSharp( + pngBuffer: Uint8Array, + format: string, + quality: number, + colorMode: string, +): Promise { + let s = sharp(Buffer.from(pngBuffer)); + + // Apply color mode before format conversion + if (colorMode === "grayscale") { + s = s.grayscale(); + } else if (colorMode === "bw") { + s = s.grayscale().threshold(128); + } + switch (format) { case "jpg": - return s.jpeg().toBuffer(); + return s.jpeg({ quality }).toBuffer(); case "webp": - return s.webp().toBuffer(); + return s.webp({ quality }).toBuffer(); case "avif": - return s.avif().toBuffer(); + return s.avif({ quality }).toBuffer(); case "tiff": return s.tiff().toBuffer(); + case "gif": + return s.gif().toBuffer(); + case "heic": + case "heif": { + const pngBuf = await s.png().toBuffer(); + return encodeHeic(pngBuf, quality); + } default: return s.png().toBuffer(); } @@ -116,23 +143,35 @@ function renderPage(doc: mupdf.Document, pageIndex: number, dpi: number): Uint8A } } +// ── Helper: read multipart PDF file ────────────────────────────── +async function readPdfFromParts( + request: import("fastify").FastifyRequest, +): Promise<{ fileBuffer: Buffer | null; settingsRaw: string | null }> { + let fileBuffer: Buffer | null = null; + let settingsRaw: string | null = null; + 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); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; + } + } + return { fileBuffer, settingsRaw }; +} + // ── Route registration ─────────────────────────────────────────── export function registerPdfToImage(app: FastifyInstance) { // ── Info endpoint ──────────────────────────────────────────── app.post("/api/v1/tools/pdf-to-image/info", 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); - } - } + const result = await readPdfFromParts(request); + fileBuffer = result.fileBuffer; } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", @@ -165,24 +204,76 @@ export function registerPdfToImage(app: FastifyInstance) { } }); + // ── Preview endpoint (thumbnails) ───────────────────────────── + app.post("/api/v1/tools/pdf-to-image/preview", async (request, reply) => { + let fileBuffer: Buffer | null = null; + try { + const result = await readPdfFromParts(request); + fileBuffer = result.fileBuffer; + } 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 PDF file provided" }); + } + + let doc: mupdf.Document | null = null; + try { + doc = mupdf.Document.openDocument(fileBuffer, "application/pdf"); + if (doc.needsPassword()) { + return reply.status(400).send({ error: "Password-protected PDFs are not supported" }); + } + const pageCount = doc.countPages(); + const maxPages = Math.min(pageCount, 200); + const thumbnails: Array<{ + page: number; + dataUrl: string; + width: number; + height: number; + }> = []; + + for (let i = 0; i < maxPages; i++) { + const pngBytes = renderPage(doc, i, 72); + const thumb = await sharp(Buffer.from(pngBytes)) + .resize({ width: 300, withoutEnlargement: true }) + .jpeg({ quality: 60 }) + .toBuffer(); + const meta = await sharp(thumb).metadata(); + thumbnails.push({ + page: i + 1, + dataUrl: `data:image/jpeg;base64,${thumb.toString("base64")}`, + width: meta.width ?? 0, + height: meta.height ?? 0, + }); + } + + return reply.send({ pageCount, thumbnails }); + } catch (err) { + if ( + err instanceof Error && + (err.message.includes("password") || err.message.includes("Password")) + ) { + return reply.status(400).send({ error: "Password-protected PDFs are not supported" }); + } + return reply.status(400).send({ error: "Invalid or corrupt PDF file" }); + } finally { + doc?.destroy(); + } + }); + // ── Main processing endpoint ───────────────────────────────── app.post("/api/v1/tools/pdf-to-image", async (request, reply) => { let fileBuffer: Buffer | null = null; 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); - } else if (part.fieldname === "settings") { - settingsRaw = part.value as string; - } - } + const result = await readPdfFromParts(request); + fileBuffer = result.fileBuffer; + settingsRaw = result.settingsRaw; } catch (err) { return reply.status(400).send({ error: "Failed to parse multipart request", @@ -225,61 +316,65 @@ export function registerPdfToImage(app: FastifyInstance) { } const ext = FORMAT_EXT[settings.format] ?? ".png"; - - // ── Single page: workspace + JSON response ───────────── - if (selectedPages.length === 1) { - const pageNum = selectedPages[0]; - const pngBytes = renderPage(doc, pageNum - 1, settings.dpi); - doc.destroy(); - doc = null; - - const imageBuffer = await convertWithSharp(pngBytes, settings.format); - - const jobId = randomUUID(); - const workspacePath = await createWorkspace(jobId); - const filename = `page-${pageNum}${ext}`; - await writeFile(join(workspacePath, "output", filename), imageBuffer); - - return reply.send({ - jobId, - downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`, - pageCount: totalPages, - selectedPages, - format: settings.format, - }); - } - - // ── Multiple pages: stream ZIP ───────────────────────── const jobId = randomUUID(); - - reply.hijack(); - reply.raw.writeHead(200, { - "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="pdf-pages-${jobId.slice(0, 8)}.zip"`, - "Transfer-Encoding": "chunked", - }); - - const archive = archiver("zip", { zlib: { level: 5 } }); - archive.pipe(reply.raw); + const workspacePath = await createWorkspace(jobId); + const outputDir = join(workspacePath, "output"); + const pages: Array<{ page: number; downloadUrl: string; size: number }> = []; for (const pageNum of selectedPages) { const pngBytes = renderPage(doc, pageNum - 1, settings.dpi); - const imageBuffer = await convertWithSharp(pngBytes, settings.format); - archive.append(imageBuffer, { name: `page-${pageNum}${ext}` }); + const imageBuffer = await convertWithSharp( + pngBytes, + settings.format, + settings.quality, + settings.colorMode, + ); + const filename = `page-${pageNum}${ext}`; + const filePath = join(outputDir, filename); + await writeFile(filePath, imageBuffer); + pages.push({ + page: pageNum, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`, + size: imageBuffer.length, + }); } doc.destroy(); doc = null; - await archive.finalize(); + // Generate ZIP + const zipFilename = "pdf-pages.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 p of pages) { + const fname = `page-${p.page}${ext}`; + archive.file(join(outputDir, fname), { name: fname }); + } + archive.finalize(); + }); + + const zipStat = await stat(zipPath); + + return reply.send({ + jobId, + pageCount: totalPages, + selectedPages, + format: settings.format, + pages, + zipUrl: `/api/v1/download/${jobId}/${encodeURIComponent(zipFilename)}`, + zipSize: zipStat.size, + }); } catch (err) { doc?.destroy(); - if (!reply.raw.headersSent) { - return reply.status(422).send({ - error: "PDF conversion failed", - details: err instanceof Error ? err.message : "Unknown error", - }); - } + return reply.status(422).send({ + error: "PDF conversion failed", + details: err instanceof Error ? err.message : "Unknown error", + }); } }); } diff --git a/apps/web/src/components/tools/pdf-to-image-preview.tsx b/apps/web/src/components/tools/pdf-to-image-preview.tsx new file mode 100644 index 00000000..fce2bd5b --- /dev/null +++ b/apps/web/src/components/tools/pdf-to-image-preview.tsx @@ -0,0 +1,226 @@ +import { Check, Download, FileOutput, Loader2 } from "lucide-react"; +import { usePdfToImageStore } from "@/stores/pdf-to-image-store"; + +const PREVIEWABLE_FORMATS = new Set(["png", "jpg", "webp", "gif", "avif"]); + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function PdfToImagePreview() { + const store = usePdfToImageStore(); + + // No file uploaded + if (!store.file) { + return ( +
+ +

Upload a PDF to get started

+
+ ); + } + + // Loading preview thumbnails + if (store.loadingPreview) { + return ( +
+ +

Generating page previews...

+
+ ); + } + + // Converting - show thumbnails with progress overlay + if (store.processing) { + return ( +
+
+ +

+ Converting {store.selectedPages.size} page + {store.selectedPages.size !== 1 ? "s" : ""}... +

+
+
+ {store.thumbnails.map((thumb) => { + const isSelected = store.selectedPages.has(thumb.page); + return ( +
+ {`Page + {isSelected && ( +
+ +
+ )} +
+ {thumb.page} +
+
+ ); + })} +
+
+ ); + } + + // Results ready - show converted images + if (store.results && store.results.length > 0) { + const isPreviewable = PREVIEWABLE_FORMATS.has(store.format); + const totalSize = store.results.reduce((sum, r) => sum + (r.size ?? 0), 0); + + return ( +
+
+

+ {store.results.length} page + {store.results.length !== 1 ? "s" : ""} converted + {totalSize > 0 && ( + + ({formatSize(totalSize)}) + + )} +

+ {store.format} +
+
+ {store.results.map((result) => { + const thumb = store.thumbnails.find((t) => t.page === result.page); + + return ( +
+ {isPreviewable ? ( + {`Page + ) : thumb?.dataUrl ? ( + {`Page + ) : ( +
+ +
+ )} + + {/* Page number badge */} +
+ {result.page} +
+ + {/* Format badge */} +
+ {store.format} +
+ + {/* File size badge */} + {result.size > 0 && ( +
+ {formatSize(result.size)} +
+ )} + + {/* Download overlay */} + + + +
+ ); + })} +
+
+ ); + } + + // Thumbnails loaded - show selectable page previews + if (store.thumbnails.length > 0) { + const allSelected = store.pageCount !== null && store.selectedPages.size === store.pageCount; + const noneSelected = store.selectedPages.size === 0; + + return ( +
+
+

+ {store.selectedPages.size} of {store.pageCount} page + {store.pageCount !== 1 ? "s" : ""} selected +

+
+ {!allSelected && ( + + )} + {!noneSelected && ( + + )} +
+
+
+ {store.thumbnails.map((thumb) => { + const isSelected = store.selectedPages.has(thumb.page); + return ( + + ); + })} +
+
+ ); + } + + // Fallback + return ( +
+ +

Upload a PDF to get started

+
+ ); +} diff --git a/apps/web/src/components/tools/pdf-to-image-settings.tsx b/apps/web/src/components/tools/pdf-to-image-settings.tsx index ac316a8e..5612b72d 100644 --- a/apps/web/src/components/tools/pdf-to-image-settings.tsx +++ b/apps/web/src/components/tools/pdf-to-image-settings.tsx @@ -1,13 +1,6 @@ import { Download, FileUp, Loader2, X } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; -import { formatHeaders } from "@/lib/api"; - -const DPI_OPTIONS = [ - { value: 72, label: "72 (Screen)" }, - { value: 150, label: "150 (Standard)" }, - { value: 300, label: "300 (Print)" }, - { value: 600, label: "600 (High Quality)" }, -]; +import { useCallback, useRef } from "react"; +import { usePdfToImageStore } from "@/stores/pdf-to-image-store"; const FORMAT_OPTIONS = [ { value: "png", label: "PNG" }, @@ -15,58 +8,47 @@ const FORMAT_OPTIONS = [ { value: "webp", label: "WebP" }, { value: "avif", label: "AVIF" }, { value: "tiff", label: "TIFF" }, + { value: "gif", label: "GIF" }, + { value: "heic", label: "HEIC" }, + { value: "heif", label: "HEIF" }, ]; +const DPI_PRESETS = [ + { value: 72, label: "72" }, + { value: 150, label: "150" }, + { value: 300, label: "300" }, + { value: 600, label: "600" }, +]; + +const DPI_LABELS: Record = { + 72: "Screen", + 150: "Standard", + 300: "Print", + 600: "High Quality", +}; + +const COLOR_MODE_OPTIONS = [ + { value: "color", label: "Color" }, + { value: "grayscale", label: "Grayscale" }, + { value: "bw", label: "B&W" }, +] as const; + +const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif"]; + export function PdfToImageSettings() { - const [file, setFile] = useState(null); - const [pageCount, setPageCount] = useState(null); - const [format, setFormat] = useState("png"); - const [dpi, setDpi] = useState(150); - const [pages, setPages] = useState(""); - const [processing, setProcessing] = useState(false); - const [loadingInfo, setLoadingInfo] = useState(false); - const [error, setError] = useState(null); - const [downloadUrl, setDownloadUrl] = useState(null); - const [downloadName, setDownloadName] = useState(""); + const store = usePdfToImageStore(); const fileInputRef = useRef(null); - const fetchPageCount = useCallback(async (pdfFile: File) => { - setLoadingInfo(true); - setError(null); - try { - const formData = new FormData(); - formData.append("file", pdfFile); - const res = await fetch("/api/v1/tools/pdf-to-image/info", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Failed: ${res.status}`); - } - const data = await res.json(); - setPageCount(data.pageCount); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to read PDF"); - setFile(null); - setPageCount(null); - } finally { - setLoadingInfo(false); - } - }, []); + const isLossy = LOSSY_FORMATS.includes(store.format); const handleFileChange = useCallback( (files: FileList | null) => { const pdfFile = files?.[0]; if (!pdfFile) return; - setFile(pdfFile); - setPageCount(null); - setDownloadUrl(null); - setError(null); - fetchPageCount(pdfFile); + store.setFile(pdfFile); + store.loadPreview(pdfFile); }, - [fetchPageCount], + [store], ); const handleDrop = useCallback( @@ -78,82 +60,16 @@ export function PdfToImageSettings() { ); const handleRemoveFile = useCallback(() => { - setFile(null); - setPageCount(null); - setDownloadUrl(null); - setError(null); + store.setFile(null); if (fileInputRef.current) fileInputRef.current.value = ""; - }, []); + }, [store]); - const getSelectedPageCount = (): number => { - if (!pageCount) return 0; - const trimmed = pages.trim(); - if (trimmed === "" || trimmed.toLowerCase() === "all") return pageCount; - try { - const nums = new Set(); - for (const seg of trimmed.split(",")) { - const s = seg.trim(); - if (s.includes("-")) { - const [a, b] = s.split("-").map((x) => Number(x.trim())); - for (let i = a; i <= b; i++) nums.add(i); - } else { - nums.add(Number(s)); - } - } - return nums.size; - } catch { - return pageCount; - } - }; - - const handleProcess = async () => { - if (!file) return; - setProcessing(true); - setError(null); - setDownloadUrl(null); - - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("settings", JSON.stringify({ format, dpi, pages: pages || "all" })); - - const res = await fetch("/api/v1/tools/pdf-to-image", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Conversion failed: ${res.status}`); - } - - const selectedCount = getSelectedPageCount(); - - if (selectedCount === 1) { - const data = await res.json(); - setDownloadUrl(data.downloadUrl); - setDownloadName(`page.${format === "jpg" ? "jpg" : format}`); - } else { - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - setDownloadUrl(url); - setDownloadName("pdf-pages.zip"); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Conversion failed"); - } finally { - setProcessing(false); - } - }; - - const selectedCount = getSelectedPageCount(); - const isMultiPage = selectedCount > 1; + const selectedCount = store.selectedPages.size; return (
{/* PDF upload area */} - {!file ? ( + {!store.file ? (

Drop a PDF here or click to select

@@ -181,15 +97,15 @@ export function PdfToImageSettings() { ) : (
-

{file.name}

+

{store.file.name}

- {loadingInfo ? ( + {store.loadingPreview ? ( Reading PDF... - ) : pageCount !== null ? ( - `${pageCount} page${pageCount !== 1 ? "s" : ""}` + ) : store.pageCount !== null ? ( + `${store.pageCount} page${store.pageCount !== 1 ? "s" : ""}` ) : null}

@@ -203,42 +119,114 @@ export function PdfToImageSettings() {
)} - {/* Format dropdown */} + {/* Output Format - grid buttons */}
- - +
- {/* DPI dropdown */} + {/* Quality slider (lossy formats only) */} + {isLossy && ( +
+
+

Quality

+ {store.quality} +
+ store.setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} + + {/* DPI presets + custom */}
- - + +
+ {store.customDpi ? ( + { + const v = Number(e.target.value); + if (v >= 36 && v <= 1200) store.setDpi(v); + }} + className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> + ) : ( +

{DPI_LABELS[store.dpi] ?? ""}

+ )} +
+ + {/* Color Mode */} +
+

Color Mode

+
+ {COLOR_MODE_OPTIONS.map((opt) => ( + + ))} +
{/* Page range input */} @@ -249,45 +237,52 @@ export function PdfToImageSettings() { setPages(e.target.value)} + value={store.pages} + onChange={(e) => store.setPages(e.target.value)} placeholder="All pages" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground" /> - {pageCount !== null && ( + {store.pageCount !== null && (

- e.g. 1-3, 5, 8-10 (document has {pageCount} pages) + e.g. 1-3, 5, 8-10 (document has {store.pageCount} pages)

)} {/* Error */} - {error &&

{error}

} + {store.error &&

{store.error}

} {/* Convert button */} - {/* Download link */} - {downloadUrl && ( + {/* Download ZIP */} + {store.zipUrl && ( - {isMultiPage ? `Download ZIP (${selectedCount} pages)` : "Download Image"} + Download All (ZIP) + {store.zipSize != null && ( + + {store.zipSize < 1024 * 1024 + ? `${(store.zipSize / 1024).toFixed(0)} KB` + : `${(store.zipSize / (1024 * 1024)).toFixed(1)} MB`} + + )} )} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 05d0f288..22d52635 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -187,6 +187,11 @@ const PdfToImageSettings = lazy(() => default: m.PdfToImageSettings, })), ); +const PdfToImagePreview = lazy(() => + import("@/components/tools/pdf-to-image-preview").then((m) => ({ + default: m.PdfToImagePreview, + })), +); const ReplaceColorSettings = lazy(() => import("@/components/tools/replace-color-settings").then((m) => ({ default: m.ReplaceColorSettings, @@ -304,7 +309,10 @@ export const toolRegistry = new Map([ ["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }], ["favicon", { displayMode: "before-after", Settings: FaviconSettings }], ["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }], - ["pdf-to-image", { displayMode: "no-dropzone", Settings: PdfToImageSettings }], + [ + "pdf-to-image", + { displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }, + ], // Adjustments extra ["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }], diff --git a/apps/web/src/stores/pdf-to-image-store.ts b/apps/web/src/stores/pdf-to-image-store.ts new file mode 100644 index 00000000..028f631f --- /dev/null +++ b/apps/web/src/stores/pdf-to-image-store.ts @@ -0,0 +1,255 @@ +import { create } from "zustand"; +import { formatHeaders } from "@/lib/api"; + +interface PageResult { + page: number; + downloadUrl: string; + size: number; +} + +interface Thumbnail { + page: number; + dataUrl: string; + width: number; + height: number; +} + +type ColorMode = "color" | "grayscale" | "bw"; + +interface PdfToImageState { + file: File | null; + pageCount: number | null; + thumbnails: Thumbnail[]; + format: string; + dpi: number; + customDpi: boolean; + quality: number; + colorMode: ColorMode; + pages: string; + selectedPages: Set; + processing: boolean; + loadingPreview: boolean; + error: string | null; + results: PageResult[] | null; + zipUrl: string | null; + zipSize: number | null; + setFormat: (format: string) => void; + setDpi: (dpi: number) => void; + setCustomDpi: (custom: boolean) => void; + setQuality: (quality: number) => void; + setColorMode: (mode: ColorMode) => void; + setPages: (pages: string) => void; + setFile: (file: File | null) => void; + togglePage: (page: number) => void; + selectAllPages: () => void; + deselectAllPages: () => void; + loadPreview: (file: File) => Promise; + convert: () => Promise; + reset: () => void; +} + +/** + * Compress a set of page numbers into a compact range string. + * e.g. {1,2,3,5,7,8,9} -> "1-3, 5, 7-9" + */ +function compressPageRange(pages: Set, totalPages: number): string { + if (pages.size === 0 || pages.size === totalPages) return ""; + const sorted = [...pages].sort((a, b) => a - b); + const ranges: string[] = []; + let start = sorted[0]; + let end = sorted[0]; + + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === end + 1) { + end = sorted[i]; + } else { + ranges.push(start === end ? `${start}` : `${start}-${end}`); + start = sorted[i]; + end = sorted[i]; + } + } + ranges.push(start === end ? `${start}` : `${start}-${end}`); + return ranges.join(", "); +} + +/** + * Parse a page range string into a Set of page numbers. + * Returns null if the string is invalid. + */ +function parsePageRangeToSet(input: string, totalPages: number): Set | null { + const trimmed = input.trim(); + if (trimmed === "" || trimmed.toLowerCase() === "all") { + return new Set(Array.from({ length: totalPages }, (_, i) => i + 1)); + } + try { + const pages = new Set(); + for (const seg of trimmed.split(",")) { + const s = seg.trim(); + if (s === "") continue; + if (s.includes("-")) { + const [a, b] = s.split("-").map((x) => Number(x.trim())); + if (Number.isNaN(a) || Number.isNaN(b) || a < 1 || b < 1 || a > b) return null; + for (let i = a; i <= Math.min(b, totalPages); i++) pages.add(i); + } else { + const n = Number(s); + if (Number.isNaN(n) || n < 1 || n > totalPages) return null; + pages.add(n); + } + } + return pages.size > 0 ? pages : null; + } catch { + return null; + } +} + +const initialState = { + file: null as File | null, + pageCount: null as number | null, + thumbnails: [] as Thumbnail[], + format: "png", + dpi: 150, + customDpi: false, + quality: 85, + colorMode: "color" as ColorMode, + pages: "", + selectedPages: new Set(), + processing: false, + loadingPreview: false, + error: null as string | null, + results: null as PageResult[] | null, + zipUrl: null as string | null, + zipSize: null as number | null, +}; + +export const usePdfToImageStore = create((set, get) => ({ + ...initialState, + + setFormat: (format) => set({ format }), + setDpi: (dpi) => set({ dpi }), + setCustomDpi: (customDpi) => set({ customDpi }), + setQuality: (quality) => set({ quality }), + setColorMode: (colorMode) => set({ colorMode }), + + setPages: (pages) => { + const { pageCount } = get(); + const parsed = pageCount ? parsePageRangeToSet(pages, pageCount) : null; + set({ + pages, + selectedPages: parsed ?? new Set(Array.from({ length: pageCount ?? 0 }, (_, i) => i + 1)), + }); + }, + + setFile: (file) => { + if (!file) { + set({ ...initialState }); + return; + } + set({ + file, + pageCount: null, + thumbnails: [], + results: null, + zipUrl: null, + zipSize: null, + error: null, + selectedPages: new Set(), + pages: "", + }); + }, + + togglePage: (page) => { + const { selectedPages, pageCount } = get(); + const next = new Set(selectedPages); + if (next.has(page)) { + next.delete(page); + } else { + next.add(page); + } + set({ + selectedPages: next, + pages: compressPageRange(next, pageCount ?? 0), + }); + }, + + selectAllPages: () => { + const { pageCount } = get(); + if (!pageCount) return; + set({ + selectedPages: new Set(Array.from({ length: pageCount }, (_, i) => i + 1)), + pages: "", + }); + }, + + deselectAllPages: () => { + set({ selectedPages: new Set(), pages: "none" }); + }, + + loadPreview: async (file) => { + set({ loadingPreview: true, error: null }); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/v1/tools/pdf-to-image/preview", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + const data = await res.json(); + set({ + pageCount: data.pageCount, + thumbnails: data.thumbnails, + selectedPages: new Set(Array.from({ length: data.pageCount }, (_, i) => i + 1)), + }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Failed to read PDF", + file: null, + pageCount: null, + thumbnails: [], + }); + } finally { + set({ loadingPreview: false }); + } + }, + + convert: async () => { + const { file, format, dpi, quality, colorMode, pages, selectedPages } = get(); + if (!file) return; + set({ processing: true, error: null, results: null, zipUrl: null, zipSize: null }); + try { + const formData = new FormData(); + formData.append("file", file); + const pagesValue = + pages.trim() === "" || pages.trim().toLowerCase() === "all" + ? "all" + : compressPageRange(selectedPages, get().pageCount ?? 0) || "all"; + formData.append( + "settings", + JSON.stringify({ format, dpi, quality, colorMode, pages: pagesValue }), + ); + const res = await fetch("/api/v1/tools/pdf-to-image", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Conversion failed: ${res.status}`); + } + const data = await res.json(); + set({ results: data.pages, zipUrl: data.zipUrl, zipSize: data.zipSize }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Conversion failed", + }); + } finally { + set({ processing: false }); + } + }, + + reset: () => set({ ...initialState }), +})); diff --git a/tests/e2e/pdf-to-image.spec.ts b/tests/e2e/pdf-to-image.spec.ts index 45149717..9fec9fea 100644 --- a/tests/e2e/pdf-to-image.spec.ts +++ b/tests/e2e/pdf-to-image.spec.ts @@ -4,17 +4,29 @@ import { expect, test, waitForProcessing } from "./helpers"; const PDF_FIXTURE = path.join(process.cwd(), "tests", "fixtures", "test-3page.pdf"); test.describe("PDF to Image tool", () => { - test("converts a PDF page to an image", async ({ loggedInPage: page }) => { + test("shows page thumbnails after uploading a PDF", async ({ loggedInPage: page }) => { await page.goto("/pdf-to-image"); // Upload PDF via file input const fileInput = page.locator("input[type='file'][accept='application/pdf']"); await fileInput.setInputFiles(PDF_FIXTURE); - // Wait for page count to appear in the file info area - await expect(page.locator(".bg-muted").getByText("3 pages")).toBeVisible({ timeout: 10_000 }); + // Wait for page count to appear + await expect(page.locator(".bg-muted").getByText("3 pages")).toBeVisible({ timeout: 15_000 }); - // Set pages to just page 1 for a single-image response + // Wait for thumbnails to appear in the results panel + await expect(page.locator("text=3 of 3 pages selected")).toBeVisible({ timeout: 15_000 }); + }); + + test("converts a PDF page to an image and shows results", async ({ loggedInPage: page }) => { + await page.goto("/pdf-to-image"); + + // Upload PDF + const fileInput = page.locator("input[type='file'][accept='application/pdf']"); + await fileInput.setInputFiles(PDF_FIXTURE); + await expect(page.locator(".bg-muted").getByText("3 pages")).toBeVisible({ timeout: 15_000 }); + + // Set pages to just page 1 await page.fill("#pdf-pages", "1"); // Click convert @@ -24,9 +36,27 @@ test.describe("PDF to Image tool", () => { await waitForProcessing(page); // Verify download link appears - await expect(page.getByTestId("pdf-to-image-download")).toBeVisible({ - timeout: 15_000, - }); - await expect(page.getByTestId("pdf-to-image-download")).toContainText("Download Image"); + await expect(page.getByTestId("pdf-to-image-download")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("pdf-to-image-download")).toContainText("Download All"); + + // Verify results panel shows converted page + await expect(page.locator("text=1 page converted")).toBeVisible(); + }); + + test("can select and deselect pages via thumbnails", async ({ loggedInPage: page }) => { + await page.goto("/pdf-to-image"); + + // Upload PDF + const fileInput = page.locator("input[type='file'][accept='application/pdf']"); + await fileInput.setInputFiles(PDF_FIXTURE); + await expect(page.locator("text=3 of 3 pages selected")).toBeVisible({ timeout: 15_000 }); + + // Click "Deselect All" + await page.locator("text=Deselect All").click(); + await expect(page.locator("text=0 of 3 pages selected")).toBeVisible(); + + // Click "Select All" + await page.locator("text=Select All").click(); + await expect(page.locator("text=3 of 3 pages selected")).toBeVisible(); }); }); diff --git a/tests/integration/pdf-to-image.test.ts b/tests/integration/pdf-to-image.test.ts index 741d917e..1c0d73fe 100644 --- a/tests/integration/pdf-to-image.test.ts +++ b/tests/integration/pdf-to-image.test.ts @@ -80,8 +80,59 @@ describe("POST /api/v1/tools/pdf-to-image/info", () => { }); }); +describe("POST /api/v1/tools/pdf-to-image/preview", () => { + it("returns thumbnails for all pages", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF_3PAGE, + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image/preview", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pageCount).toBe(3); + expect(data.thumbnails).toHaveLength(3); + expect(data.thumbnails[0].page).toBe(1); + expect(data.thumbnails[0].dataUrl).toMatch(/^data:image\/jpeg;base64,/); + expect(data.thumbnails[0].width).toBeGreaterThan(0); + expect(data.thumbnails[0].height).toBeGreaterThan(0); + }); + + it("returns 400 for invalid PDF", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "bad.pdf", + contentType: "application/pdf", + content: Buffer.from("not a pdf"), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image/preview", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); +}); + describe("POST /api/v1/tools/pdf-to-image", () => { - it("converts a single page to PNG and returns a download URL", async () => { + it("converts a single page to PNG with per-page URLs and ZIP", async () => { const { body, contentType } = createMultipartPayload([ { name: "file", @@ -105,8 +156,10 @@ describe("POST /api/v1/tools/pdf-to-image", () => { }); expect(res.statusCode).toBe(200); const data = JSON.parse(res.body); - expect(data.downloadUrl).toContain("/api/v1/download/"); - expect(data.downloadUrl).toContain("page-1.png"); + expect(data.pages).toHaveLength(1); + expect(data.pages[0].downloadUrl).toContain("page-1.png"); + expect(data.pages[0].size).toBeGreaterThan(0); + expect(data.zipUrl).toContain("pdf-pages.zip"); expect(data.pageCount).toBe(3); expect(data.selectedPages).toEqual([1]); expect(data.format).toBe("png"); @@ -122,7 +175,7 @@ describe("POST /api/v1/tools/pdf-to-image", () => { }, { name: "settings", - content: JSON.stringify({ format: "jpg", dpi: 72, pages: "2" }), + content: JSON.stringify({ format: "jpg", dpi: 72, quality: 80, pages: "2" }), }, ]); const res = await app.inject({ @@ -136,10 +189,10 @@ describe("POST /api/v1/tools/pdf-to-image", () => { }); expect(res.statusCode).toBe(200); const data = JSON.parse(res.body); - expect(data.downloadUrl).toContain("page-2.jpg"); + expect(data.pages[0].downloadUrl).toContain("page-2.jpg"); }); - it("returns a ZIP for multiple pages", async () => { + it("converts multiple pages and returns JSON with ZIP URL", async () => { const { body, contentType } = createMultipartPayload([ { name: "file", @@ -161,13 +214,12 @@ describe("POST /api/v1/tools/pdf-to-image", () => { authorization: `Bearer ${adminToken}`, }, }); - // reply.hijack() bypasses Fastify's normal response handling, so - // app.inject() returns statusCode 200 and the raw ZIP payload. expect(res.statusCode).toBe(200); - expect(res.rawPayload.length).toBeGreaterThan(0); - // ZIP files start with the PK magic bytes (0x50, 0x4B) - expect(res.rawPayload[0]).toBe(0x50); - expect(res.rawPayload[1]).toBe(0x4b); + const data = JSON.parse(res.body); + expect(data.pages).toHaveLength(3); + expect(data.zipUrl).toContain("pdf-pages.zip"); + expect(data.zipSize).toBeGreaterThan(0); + expect(data.selectedPages).toEqual([1, 2, 3]); }); it("uses defaults when no settings provided", async () => { @@ -188,12 +240,118 @@ describe("POST /api/v1/tools/pdf-to-image", () => { authorization: `Bearer ${adminToken}`, }, }); - // Default pages="all" means 3 pages, which triggers ZIP streaming expect(res.statusCode).toBe(200); - expect(res.rawPayload.length).toBeGreaterThan(0); - // Verify ZIP magic bytes - expect(res.rawPayload[0]).toBe(0x50); - expect(res.rawPayload[1]).toBe(0x4b); + const data = JSON.parse(res.body); + expect(data.pages).toHaveLength(3); + expect(data.format).toBe("png"); + expect(data.zipUrl).toBeTruthy(); + }); + + it("applies grayscale color mode", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF_3PAGE, + }, + { + name: "settings", + content: JSON.stringify({ format: "png", dpi: 72, colorMode: "grayscale", pages: "1" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pages).toHaveLength(1); + expect(data.pages[0].size).toBeGreaterThan(0); + }); + + it("applies black and white color mode", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF_3PAGE, + }, + { + name: "settings", + content: JSON.stringify({ format: "png", dpi: 72, colorMode: "bw", pages: "1" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pages).toHaveLength(1); + }); + + it("accepts custom DPI values", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF_3PAGE, + }, + { + name: "settings", + content: JSON.stringify({ format: "png", dpi: 200, pages: "1" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body); + expect(data.pages).toHaveLength(1); + }); + + it("rejects DPI below minimum", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF_3PAGE, + }, + { + name: "settings", + content: JSON.stringify({ format: "png", dpi: 10, pages: "1" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/pdf-to-image", + body, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); }); it("returns 400 for invalid page range", async () => { @@ -245,8 +403,6 @@ describe("POST /api/v1/tools/pdf-to-image", () => { authorization: `Bearer ${adminToken}`, }, }); - // mupdf may attempt to repair the broken file, so the error can surface - // during rendering rather than at open time, resulting in a 422. expect([400, 422]).toContain(res.statusCode); });