feat(pdf-to-image): redesign with thumbnails, page selection, color mode, and expanded formats

- Add preview endpoint for page thumbnails with clickable selection
- Add 8 output formats (PNG, JPEG, WebP, AVIF, TIFF, GIF, HEIC, HEIF)
- Add quality slider for lossy formats, color mode (Color/Grayscale/B&W)
- Add custom DPI input (36-1200) alongside preset buttons
- Replace reply.hijack() ZIP streaming with JSON response + server-side ZIP
- Add dedicated Zustand store with bidirectional page range sync
- Add ResultsPanel with per-page download, format/size badges
- Update integration tests (16 cases) and e2e tests (3 cases)
This commit is contained in:
Siddharth Kumar Sah
2026-04-13 13:43:07 +08:00
parent df372ee1ca
commit d9704fda82
7 changed files with 1035 additions and 270 deletions
@@ -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 }),
}));