Merge pull request #50 from stirling-image/feat/pdf-to-image-v2

feat(pdf-to-image): redesign with thumbnails, page selection, color mode
This commit is contained in:
stirling-image
2026-04-13 13:43:40 +08:00
committed by GitHub
7 changed files with 1035 additions and 270 deletions
+170 -75
View File
@@ -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<string, string> = {
webp: ".webp",
avif: ".avif",
tiff: ".tiff",
gif: ".gif",
heic: ".heic",
heif: ".heif",
};
function convertWithSharp(pngBuffer: Uint8Array, format: string): Promise<Buffer> {
const s = sharp(Buffer.from(pngBuffer));
async function convertWithSharp(
pngBuffer: Uint8Array,
format: string,
quality: number,
colorMode: string,
): Promise<Buffer> {
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<void>((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",
});
}
});
}
@@ -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 (
<div className="flex flex-col items-center justify-center h-full w-full gap-3 text-center">
<FileOutput className="h-12 w-12 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Upload a PDF to get started</p>
</div>
);
}
// Loading preview thumbnails
if (store.loadingPreview) {
return (
<div className="flex flex-col items-center justify-center h-full w-full gap-3">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Generating page previews...</p>
</div>
);
}
// Converting - show thumbnails with progress overlay
if (store.processing) {
return (
<div className="h-full w-full overflow-y-auto p-4">
<div className="flex items-center gap-2 mb-3">
<Loader2 className="h-4 w-4 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">
Converting {store.selectedPages.size} page
{store.selectedPages.size !== 1 ? "s" : ""}...
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{store.thumbnails.map((thumb) => {
const isSelected = store.selectedPages.has(thumb.page);
return (
<div
key={thumb.page}
className={`relative rounded-lg border overflow-hidden ${
isSelected ? "border-primary/50 opacity-100" : "border-border opacity-30"
}`}
>
<img src={thumb.dataUrl} alt={`Page ${thumb.page}`} className="w-full h-auto" />
{isSelected && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Loader2 className="h-6 w-6 text-white animate-spin" />
</div>
)}
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
{thumb.page}
</div>
</div>
);
})}
</div>
</div>
);
}
// 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 (
<div className="h-full w-full overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-foreground">
{store.results.length} page
{store.results.length !== 1 ? "s" : ""} converted
{totalSize > 0 && (
<span className="text-muted-foreground font-normal ml-1">
({formatSize(totalSize)})
</span>
)}
</p>
<span className="text-xs text-muted-foreground uppercase font-mono">{store.format}</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{store.results.map((result) => {
const thumb = store.thumbnails.find((t) => t.page === result.page);
return (
<div
key={result.page}
className="group relative rounded-lg border border-border overflow-hidden bg-muted/30"
>
{isPreviewable ? (
<img
src={result.downloadUrl}
alt={`Page ${result.page}`}
className="w-full h-auto"
loading="lazy"
/>
) : thumb?.dataUrl ? (
<img src={thumb.dataUrl} alt={`Page ${result.page}`} className="w-full h-auto" />
) : (
<div className="w-full aspect-[3/4] flex flex-col items-center justify-center gap-1">
<FileOutput className="h-8 w-8 text-muted-foreground/40" />
</div>
)}
{/* Page number badge */}
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
{result.page}
</div>
{/* Format badge */}
<div className="absolute top-1.5 right-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground uppercase font-mono">
{store.format}
</div>
{/* File size badge */}
{result.size > 0 && (
<div className="absolute bottom-1.5 right-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
{formatSize(result.size)}
</div>
)}
{/* Download overlay */}
<a
href={result.downloadUrl}
download={`page-${result.page}.${store.format}`}
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors"
>
<Download className="h-5 w-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
);
})}
</div>
</div>
);
}
// 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 (
<div className="h-full w-full overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-muted-foreground">
{store.selectedPages.size} of {store.pageCount} page
{store.pageCount !== 1 ? "s" : ""} selected
</p>
<div className="flex gap-2">
{!allSelected && (
<button
type="button"
onClick={() => store.selectAllPages()}
className="text-xs text-primary hover:underline"
>
Select All
</button>
)}
{!noneSelected && (
<button
type="button"
onClick={() => store.deselectAllPages()}
className="text-xs text-primary hover:underline"
>
Deselect All
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{store.thumbnails.map((thumb) => {
const isSelected = store.selectedPages.has(thumb.page);
return (
<button
key={thumb.page}
type="button"
onClick={() => store.togglePage(thumb.page)}
className={`relative rounded-lg border overflow-hidden text-left transition-all ${
isSelected
? "border-primary ring-1 ring-primary/30"
: "border-border opacity-50 hover:opacity-75"
}`}
>
<img src={thumb.dataUrl} alt={`Page ${thumb.page}`} className="w-full h-auto" />
{/* Page number badge */}
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
{thumb.page}
</div>
{/* Selection checkbox */}
<div
className={`absolute top-1.5 right-1.5 w-5 h-5 rounded border flex items-center justify-center transition-colors ${
isSelected ? "bg-primary border-primary" : "bg-background/80 border-border"
}`}
>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
</button>
);
})}
</div>
</div>
);
}
// Fallback
return (
<div className="flex flex-col items-center justify-center h-full w-full gap-3 text-center">
<FileOutput className="h-12 w-12 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Upload a PDF to get started</p>
</div>
);
}
@@ -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<number, string> = {
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<File | null>(null);
const [pageCount, setPageCount] = useState<number | null>(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<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadName, setDownloadName] = useState<string>("");
const store = usePdfToImageStore();
const fileInputRef = useRef<HTMLInputElement>(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<number>();
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 (
<div className="space-y-4">
{/* PDF upload area */}
{!file ? (
{!store.file ? (
<div
role="button"
tabIndex={0}
@@ -166,7 +82,7 @@ export function PdfToImageSettings() {
fileInputRef.current?.click();
}
}}
className="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
>
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Drop a PDF here or click to select</p>
@@ -181,15 +97,15 @@ export function PdfToImageSettings() {
) : (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-sm font-medium truncate">{store.file.name}</p>
<p className="text-xs text-muted-foreground">
{loadingInfo ? (
{store.loadingPreview ? (
<span className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Reading PDF...
</span>
) : pageCount !== null ? (
`${pageCount} page${pageCount !== 1 ? "s" : ""}`
) : store.pageCount !== null ? (
`${store.pageCount} page${store.pageCount !== 1 ? "s" : ""}`
) : null}
</p>
</div>
@@ -203,42 +119,114 @@ export function PdfToImageSettings() {
</div>
)}
{/* Format dropdown */}
{/* Output Format - grid buttons */}
<div>
<label htmlFor="pdf-format" className="text-xs text-muted-foreground">
Output Format
</label>
<select
id="pdf-format"
value={format}
onChange={(e) => setFormat(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<p className="text-xs text-muted-foreground mb-1.5">Output Format</p>
<div className="grid grid-cols-4 gap-1">
{FORMAT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
<button
key={opt.value}
type="button"
onClick={() => store.setFormat(opt.value)}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
store.format === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{opt.label}
</option>
</button>
))}
</select>
</div>
</div>
{/* DPI dropdown */}
{/* Quality slider (lossy formats only) */}
{isLossy && (
<div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Quality</p>
<span className="text-xs font-mono text-foreground">{store.quality}</span>
</div>
<input
type="range"
min={1}
max={100}
value={store.quality}
onChange={(e) => store.setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
)}
{/* DPI presets + custom */}
<div>
<label htmlFor="pdf-dpi" className="text-xs text-muted-foreground">
Resolution (DPI)
</label>
<select
id="pdf-dpi"
value={dpi}
onChange={(e) => setDpi(Number(e.target.value))}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{DPI_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
<p className="text-xs text-muted-foreground mb-1.5">Resolution (DPI)</p>
<div className="grid grid-cols-5 gap-1">
{DPI_PRESETS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
store.setDpi(opt.value);
store.setCustomDpi(false);
}}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
store.dpi === opt.value && !store.customDpi
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{opt.label}
</option>
</button>
))}
</select>
<button
type="button"
onClick={() => store.setCustomDpi(true)}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
store.customDpi
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
Custom
</button>
</div>
{store.customDpi ? (
<input
type="number"
min={36}
max={1200}
value={store.dpi}
onChange={(e) => {
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"
/>
) : (
<p className="text-xs text-muted-foreground/60 mt-1">{DPI_LABELS[store.dpi] ?? ""}</p>
)}
</div>
{/* Color Mode */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Color Mode</p>
<div className="grid grid-cols-3 gap-1">
{COLOR_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => store.setColorMode(opt.value)}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
store.colorMode === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Page range input */}
@@ -249,45 +237,52 @@ export function PdfToImageSettings() {
<input
id="pdf-pages"
type="text"
value={pages}
onChange={(e) => 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 && (
<p className="text-xs text-muted-foreground mt-1">
e.g. 1-3, 5, 8-10 (document has {pageCount} pages)
e.g. 1-3, 5, 8-10 (document has {store.pageCount} pages)
</p>
)}
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{store.error && <p className="text-xs text-red-500">{store.error}</p>}
{/* Convert button */}
<button
type="button"
data-testid="pdf-to-image-submit"
onClick={handleProcess}
disabled={!file || !pageCount || processing}
onClick={() => store.convert()}
disabled={!store.file || !store.pageCount || store.processing || selectedCount === 0}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
{store.processing
? "Converting..."
: `Convert${pageCount ? ` ${selectedCount} page${selectedCount !== 1 ? "s" : ""}` : ""}`}
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
</button>
{/* Download link */}
{downloadUrl && (
{/* Download ZIP */}
{store.zipUrl && (
<a
href={downloadUrl}
download={downloadName}
href={store.zipUrl}
download="pdf-pages.zip"
data-testid="pdf-to-image-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
{isMultiPage ? `Download ZIP (${selectedCount} pages)` : "Download Image"}
Download All (ZIP)
{store.zipSize != null && (
<span className="text-xs opacity-70">
{store.zipSize < 1024 * 1024
? `${(store.zipSize / 1024).toFixed(0)} KB`
: `${(store.zipSize / (1024 * 1024)).toFixed(1)} MB`}
</span>
)}
</a>
)}
</div>
+9 -1
View File
@@ -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<string, ToolRegistryEntry>([
["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 }],
+255
View File
@@ -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<number>;
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<void>;
convert: () => Promise<void>;
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<number>, 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<number> | 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<number>();
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<number>(),
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<PdfToImageState>((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<number>(),
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<number>(), 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 }),
}));
+38 -8
View File
@@ -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();
});
});
+175 -19
View File
@@ -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);
});