mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user