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); });