diff --git a/IMAGE_RESIZE_MODULE.md b/IMAGE_RESIZE_MODULE.md
new file mode 100644
index 0000000..5b36bcf
--- /dev/null
+++ b/IMAGE_RESIZE_MODULE.md
@@ -0,0 +1,63 @@
+# Image Resize Module
+
+Module de redimensionnement d'images pour AdaTools.
+
+## Features
+
+### Redimensionnement
+- Dimensions personnalisées (largeur/hauteur)
+- Mise à l'échelle par pourcentage (25%, 50%, 75%, 100%, 150%, 200%)
+- Verrouillage/déverrouillage du ratio d'aspect
+- Rotation (90°)
+- Retournement horizontal/vertical
+
+### Presets
+- **Réseaux sociaux**: Instagram, Facebook, Twitter, LinkedIn, YouTube
+- **Tailles courantes**: Thumbnail, HD, Full HD, 4K, Icon, Favicon
+- Presets personnalisés sauvegardés en localStorage
+
+### Algorithmes de redimensionnement
+| Algorithme | Description | Usage recommandé |
+|------------|-------------|------------------|
+| Ultra Quality (Pica) | Vrai Lanczos3 via pica.js | Meilleure qualité pour downscaling photos |
+| High Quality | Lanczos natif navigateur | Photos et images détaillées |
+| Standard | Bicubic | Équilibre qualité/vitesse |
+| Fast | Bilinear | Traitement rapide |
+| Pixel Perfect | Nearest neighbor | Pixel art |
+
+### Formats de sortie
+- PNG (lossless)
+- JPEG (avec slider qualité)
+- WebP (avec slider qualité)
+
+### Fonctionnalités additionnelles
+- Preview en temps réel avec debouncing
+- Contrôles de zoom
+- Comparaison avant/après
+- Estimation de la taille du fichier
+- Téléchargement et copie dans le presse-papiers
+- Upload par drag-and-drop
+- Coller depuis le presse-papiers
+- Mode batch (fichiers multiples)
+
+## Structure des fichiers
+
+```
+src/lib/
+├── image-resize-utils.ts # Fonctions utilitaires
+└── image-resize-presets.ts # Définitions des presets
+
+components/modules/
+├── image-resize-module.tsx # Module principal
+└── image-resize/
+ ├── algorithm-selector.tsx # Sélection algorithme
+ ├── batch-processor.tsx # Traitement batch
+ ├── image-preview.tsx # Aperçu canvas
+ ├── output-settings.tsx # Paramètres sortie
+ ├── preset-selector.tsx # Sélection presets
+ └── resize-controls.tsx # Contrôles dimensions
+```
+
+## Dépendances
+
+- `pica` - Redimensionnement haute qualité (Lanczos3)
diff --git a/app/test-trpc/page.tsx b/app/test-trpc/page.tsx
deleted file mode 100644
index 2fa8b4f..0000000
--- a/app/test-trpc/page.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { api } from "@/src/lib/trpc/client";
-
-export default function TestTRPCPage() {
- const greeting = api.test.greeting.useQuery();
-
- return (
-
-
Test tRPC
-
-
- {greeting ? (
-
-
✅ tRPC fonctionne !
-
- Réponse du serveur :{" "}
- {greeting.data}
-
-
- ) : (
-
TRPC NOT WORKING
- )}
-
-
- );
-}
diff --git a/components/modules/image-resize-module.tsx b/components/modules/image-resize-module.tsx
new file mode 100644
index 0000000..2134cb1
--- /dev/null
+++ b/components/modules/image-resize-module.tsx
@@ -0,0 +1,540 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import { Module } from "../dashboard/module";
+import { Button } from "../ui/button";
+import {
+ ImageIcon,
+ Upload,
+ RotateCw,
+ FlipHorizontal,
+ FlipVertical,
+ Layers,
+} from "lucide-react";
+import { ResizeControls } from "./image-resize/resize-controls";
+import { PresetSelector } from "./image-resize/preset-selector";
+import { AlgorithmSelector } from "./image-resize/algorithm-selector";
+import { ImagePreview } from "./image-resize/image-preview";
+import { OutputSettings } from "./image-resize/output-settings";
+import { BatchProcessor, type BatchFile } from "./image-resize/batch-processor";
+import {
+ loadImageFromFile,
+ resizeImage,
+ convertToFormat,
+ estimateFileSize,
+ rotateImage,
+ flipImage,
+ type ImageFormat,
+ type ResizeAlgorithm,
+} from "@/src/lib/image-resize-utils";
+
+interface ImageResizeModuleProps {
+ isPinned?: boolean;
+ onTogglePin?: () => void;
+ isAuthenticated?: boolean;
+ onAuthRequired?: () => void;
+}
+
+export function ImageResizeModule({
+ isPinned,
+ onTogglePin,
+ isAuthenticated = true,
+}: ImageResizeModuleProps) {
+ // Mode state
+ const [batchMode, setBatchMode] = useState(false);
+
+ // Single mode state
+ const [originalImage, setOriginalImage] = useState(
+ null,
+ );
+ const [originalWidth, setOriginalWidth] = useState(0);
+ const [originalHeight, setOriginalHeight] = useState(0);
+ const [width, setWidth] = useState(0);
+ const [height, setHeight] = useState(0);
+ const [aspectRatioLocked, setAspectRatioLocked] = useState(true);
+ const [algorithm, setAlgorithm] = useState("bicubic");
+ const [format, setFormat] = useState("png");
+ const [quality, setQuality] = useState(92);
+ const [processedCanvas, setProcessedCanvas] =
+ useState(null);
+ const [estimatedSize, setEstimatedSize] = useState(0);
+ const [originalFileSize, setOriginalFileSize] = useState(0);
+ const [isCopied, setIsCopied] = useState(false);
+
+ // Batch mode state
+ const [batchFiles, setBatchFiles] = useState([]);
+ const [isProcessingBatch, setIsProcessingBatch] = useState(false);
+ const [currentBatchIndex, setCurrentBatchIndex] = useState(0);
+
+ // Refs
+ const fileInputRef = useRef(null);
+ const debounceTimerRef = useRef(null);
+
+ // Process image (with debouncing)
+ const processImage = useCallback(
+ async (
+ img: HTMLImageElement,
+ newWidth: number,
+ newHeight: number,
+ outputFormat: ImageFormat,
+ outputQuality: number,
+ algorithmValue: ResizeAlgorithm,
+ ) => {
+ try {
+ const canvas = await resizeImage(img, {
+ width: newWidth,
+ height: newHeight,
+ maintainAspectRatio: false,
+ algorithm: algorithmValue,
+ });
+
+ const qualityDecimal = outputQuality / 100;
+ const dataUrl = await convertToFormat(
+ canvas,
+ outputFormat,
+ qualityDecimal,
+ );
+ const size = estimateFileSize(dataUrl);
+
+ setProcessedCanvas(canvas);
+ setEstimatedSize(size);
+ } catch (error) {
+ console.error("Error processing image:", error);
+ }
+ },
+ [],
+ );
+
+ // Debounced image processing
+ useEffect(() => {
+ if (!originalImage || width <= 0 || height <= 0) return;
+
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+
+ debounceTimerRef.current = setTimeout(() => {
+ processImage(originalImage, width, height, format, quality, algorithm);
+ }, 300);
+
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, [originalImage, width, height, format, quality, algorithm, processImage]);
+
+ // Handle file selection
+ const handleFileChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const {
+ image,
+ width: imgWidth,
+ height: imgHeight,
+ } = await loadImageFromFile(file);
+
+ setOriginalImage(image);
+ setOriginalWidth(imgWidth);
+ setOriginalHeight(imgHeight);
+ setWidth(imgWidth);
+ setHeight(imgHeight);
+ setOriginalFileSize(file.size);
+ } catch {
+ console.error("Error loading image");
+ }
+ };
+
+ // Handle drag and drop
+ const handleDrop = async (event: React.DragEvent) => {
+ event.preventDefault();
+ const file = event.dataTransfer.files[0];
+ if (!file || !file.type.startsWith("image/")) return;
+
+ try {
+ const {
+ image,
+ width: imgWidth,
+ height: imgHeight,
+ } = await loadImageFromFile(file);
+
+ setOriginalImage(image);
+ setOriginalWidth(imgWidth);
+ setOriginalHeight(imgHeight);
+ setWidth(imgWidth);
+ setHeight(imgHeight);
+ setOriginalFileSize(file.size);
+ } catch {
+ console.error("Error loading image");
+ }
+ };
+
+ // Handle paste from clipboard
+ useEffect(() => {
+ const handlePaste = async (event: ClipboardEvent) => {
+ const items = event.clipboardData?.items;
+ if (!items) return;
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.startsWith("image/")) {
+ const file = items[i].getAsFile();
+ if (!file) continue;
+
+ try {
+ const {
+ image,
+ width: imgWidth,
+ height: imgHeight,
+ } = await loadImageFromFile(file);
+
+ setOriginalImage(image);
+ setOriginalWidth(imgWidth);
+ setOriginalHeight(imgHeight);
+ setWidth(imgWidth);
+ setHeight(imgHeight);
+ setOriginalFileSize(file.size);
+ } catch (error) {
+ console.error("Error loading pasted image:", error);
+ }
+ break;
+ }
+ }
+ };
+
+ document.addEventListener("paste", handlePaste);
+ return () => document.removeEventListener("paste", handlePaste);
+ }, []);
+
+ // Handle rotation
+ const handleRotate = (degrees: 90 | 180 | 270) => {
+ if (!processedCanvas) return;
+
+ try {
+ const rotatedCanvas = rotateImage(processedCanvas, degrees);
+ setProcessedCanvas(rotatedCanvas);
+
+ // Update dimensions if rotated 90 or 270
+ if (degrees === 90 || degrees === 270) {
+ const newWidth = height;
+ const newHeight = width;
+ setWidth(newWidth);
+ setHeight(newHeight);
+ }
+ } catch (error) {
+ console.error("Error rotating image:", error);
+ }
+ };
+
+ // Handle flip
+ const handleFlip = (direction: "horizontal" | "vertical") => {
+ if (!processedCanvas) return;
+
+ try {
+ const flippedCanvas = flipImage(processedCanvas, direction);
+ setProcessedCanvas(flippedCanvas);
+ } catch (error) {
+ console.error("Error flipping image:", error);
+ }
+ };
+
+ // Handle download
+ const handleDownload = async () => {
+ if (!processedCanvas) return;
+
+ try {
+ const qualityDecimal = quality / 100;
+ const dataUrl = await convertToFormat(
+ processedCanvas,
+ format,
+ qualityDecimal,
+ );
+
+ const link = document.createElement("a");
+ link.download = `resized-${Date.now()}.${format}`;
+ link.href = dataUrl;
+ link.click();
+ } catch (error) {
+ console.error("Error downloading image:", error);
+ }
+ };
+
+ // Handle copy to clipboard
+ const handleCopyToClipboard = async () => {
+ if (!processedCanvas) return;
+
+ try {
+ processedCanvas.toBlob(async (blob) => {
+ if (!blob) return;
+
+ await navigator.clipboard.write([
+ new ClipboardItem({ "image/png": blob }),
+ ]);
+
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 2000);
+ });
+ } catch (error) {
+ console.error("Error copying to clipboard:", error);
+ }
+ };
+
+ // Handle reset
+ const handleReset = () => {
+ setOriginalImage(null);
+ setOriginalWidth(0);
+ setOriginalHeight(0);
+ setWidth(0);
+ setHeight(0);
+ setProcessedCanvas(null);
+ setEstimatedSize(0);
+ setOriginalFileSize(0);
+ setFormat("png");
+ setQuality(92);
+ setAspectRatioLocked(true);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ // Handle preset selection
+ const handlePresetSelect = (presetWidth: number, presetHeight: number) => {
+ setWidth(presetWidth);
+ setHeight(presetHeight);
+ };
+
+ // Batch mode handlers
+ const handleRemoveBatchFile = (fileId: string) => {
+ setBatchFiles(batchFiles.filter((f) => f.id !== fileId));
+ };
+
+ const handleClearAllBatch = () => {
+ setBatchFiles([]);
+ };
+
+ const handleProcessAllBatch = async () => {
+ setIsProcessingBatch(true);
+
+ for (let i = 0; i < batchFiles.length; i++) {
+ setCurrentBatchIndex(i);
+ const file = batchFiles[i];
+
+ try {
+ // Update status to processing
+ setBatchFiles((prev) =>
+ prev.map((f) =>
+ f.id === file.id ? { ...f, status: "processing" as const } : f,
+ ),
+ );
+
+ // Process the file
+ const { image } = await loadImageFromFile(file.file);
+ const canvas = await resizeImage(image, {
+ width,
+ height,
+ maintainAspectRatio: aspectRatioLocked,
+ algorithm,
+ });
+
+ const qualityDecimal = quality / 100;
+ const dataUrl = await convertToFormat(canvas, format, qualityDecimal);
+ const size = estimateFileSize(dataUrl);
+
+ // Update status to completed
+ setBatchFiles((prev) =>
+ prev.map((f) =>
+ f.id === file.id
+ ? {
+ ...f,
+ status: "completed" as const,
+ processedDataUrl: dataUrl,
+ processedSize: size,
+ }
+ : f,
+ ),
+ );
+ } catch {
+ // Update status to error
+ setBatchFiles((prev) =>
+ prev.map((f) =>
+ f.id === file.id
+ ? {
+ ...f,
+ status: "error" as const,
+ error: "Failed to process",
+ }
+ : f,
+ ),
+ );
+ }
+ }
+
+ setIsProcessingBatch(false);
+ };
+
+ const handleDownloadBatchFile = (fileId: string) => {
+ const file = batchFiles.find((f) => f.id === fileId);
+ if (!file || !file.processedDataUrl) return;
+
+ const link = document.createElement("a");
+ link.download = `resized-${file.name}`;
+ link.href = file.processedDataUrl;
+ link.click();
+ };
+
+ const handleDownloadAllAsZip = async () => {
+ // This would require a zip library like JSZip
+ console.log("Download all as ZIP - not yet implemented");
+ };
+
+ const imageLoaded = originalImage !== null;
+
+ return (
+ }
+ isPinned={isPinned}
+ onTogglePin={onTogglePin}
+ isAuthenticated={isAuthenticated}
+ >
+
+ {/* Mode Toggle */}
+
+
+
+
+
+ {/* Upload Zone (when no image and not in batch mode) */}
+ {!imageLoaded && !batchMode && (
+
fileInputRef.current?.click()}
+ onDrop={handleDrop}
+ onDragOver={(e) => e.preventDefault()}
+ >
+
+
+ Drop image here or click to upload
+
+
+ PNG, JPG, WebP, GIF supported
+
+
+
+ )}
+
+ {/* Main Content (when image loaded and not in batch mode) */}
+ {imageLoaded && !batchMode && (
+
+ {/* Transformation Controls */}
+
+
+
+
+
+
+ {/* Preview */}
+
+
+ {/* Controls */}
+
+
+ {/* Preset Selector */}
+
+
+ {/* Algorithm Selector */}
+
+
+ {/* Output Settings */}
+
+
+ )}
+
+ {/* Batch Mode */}
+ {batchMode && (
+
+ )}
+
+
+ );
+}
diff --git a/components/modules/image-resize/algorithm-selector.tsx b/components/modules/image-resize/algorithm-selector.tsx
new file mode 100644
index 0000000..d0b64d1
--- /dev/null
+++ b/components/modules/image-resize/algorithm-selector.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Label } from "../../ui/label";
+import { RadioGroup, RadioGroupItem } from "../../ui/radio-group";
+import {
+ ResizeAlgorithm,
+ RESIZE_ALGORITHMS,
+} from "@/src/lib/image-resize-utils";
+
+interface AlgorithmSelectorProps {
+ value: ResizeAlgorithm;
+ onChange: (value: ResizeAlgorithm) => void;
+ disabled?: boolean;
+}
+
+export function AlgorithmSelector({
+ value,
+ onChange,
+ disabled = false,
+}: AlgorithmSelectorProps) {
+ return (
+
+
+
onChange(val as ResizeAlgorithm)}
+ disabled={disabled}
+ >
+ {RESIZE_ALGORITHMS.map((algorithm) => (
+
+
+
+
+
+ {algorithm.recommendedUse}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/modules/image-resize/batch-processor.tsx b/components/modules/image-resize/batch-processor.tsx
new file mode 100644
index 0000000..d400394
--- /dev/null
+++ b/components/modules/image-resize/batch-processor.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { Button } from "../../ui/button";
+import {
+ X,
+ Trash2,
+ Download,
+ Archive,
+ Loader2,
+ CheckCircle,
+ XCircle,
+} from "lucide-react";
+import { type ResizeAlgorithm } from "@/src/lib/image-resize-utils";
+
+export interface BatchFile {
+ id: string;
+ file: File;
+ name: string;
+ originalSize: number;
+ processedSize?: number;
+ processedDataUrl?: string;
+ status: "pending" | "processing" | "completed" | "error";
+ error?: string;
+}
+
+export interface BatchProcessorProps {
+ files: BatchFile[];
+ onRemoveFile: (fileId: string) => void;
+ onClearAll: () => void;
+ onProcessAll: () => void;
+ onDownloadFile: (fileId: string) => void;
+ onDownloadAllAsZip: () => void;
+ isProcessing: boolean;
+ currentProcessingIndex: number;
+ disabled?: boolean;
+ algorithm?: ResizeAlgorithm;
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function BatchProcessor({
+ files,
+ onRemoveFile,
+ onClearAll,
+ onProcessAll,
+ onDownloadFile,
+ onDownloadAllAsZip,
+ isProcessing,
+ currentProcessingIndex,
+ disabled = false,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ algorithm, // Prop accepted for interface completeness; actual processing uses parent's algorithm
+}: BatchProcessorProps) {
+ return (
+
+ {/* Header with Clear All */}
+
+
+ {files.length} file{files.length !== 1 ? "s" : ""} selected
+
+
+
+
+ {/* Progress Bar (when processing) */}
+ {isProcessing && (
+
+
+ Processing...
+
+ {currentProcessingIndex + 1} of {files.length}
+
+
+
+
+ )}
+
+ {/* File List */}
+
+ {files.map((file) => (
+
+ {/* Thumbnail placeholder */}
+
+ {file.status === "processing" && (
+
+ )}
+ {file.status === "completed" && (
+
+ )}
+ {file.status === "error" && (
+
+ )}
+
+
+ {/* File info */}
+
+
{file.name}
+
+ {formatFileSize(file.originalSize)}
+ {file.processedSize &&
+ ` → ${formatFileSize(file.processedSize)}`}
+
+
+
+ {/* Actions */}
+
+ {file.status === "completed" && (
+
+ )}
+
+
+
+ ))}
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+ );
+}
diff --git a/components/modules/image-resize/image-preview.tsx b/components/modules/image-resize/image-preview.tsx
new file mode 100644
index 0000000..1aaaaf8
--- /dev/null
+++ b/components/modules/image-resize/image-preview.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+import { Button } from "../../ui/button";
+import { ZoomIn, ZoomOut, Maximize2, Eye, EyeOff } from "lucide-react";
+
+interface ImagePreviewProps {
+ canvas: HTMLCanvasElement | null;
+ originalImage: HTMLImageElement | null;
+ originalWidth: number;
+ originalHeight: number;
+ newWidth: number;
+ newHeight: number;
+ originalFileSize?: number;
+ estimatedFileSize?: number;
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function ImagePreview({
+ canvas,
+ originalImage,
+ originalWidth,
+ originalHeight,
+ newWidth,
+ newHeight,
+ originalFileSize,
+ estimatedFileSize,
+}: ImagePreviewProps) {
+ const previewCanvasRef = useRef(null);
+ const [zoom, setZoom] = useState(100);
+ const [showOriginal, setShowOriginal] = useState(false);
+
+ const zoomLevels = [25, 50, 75, 100, 150, 200];
+
+ const zoomIn = () => {
+ const currentIndex = zoomLevels.indexOf(zoom);
+ if (currentIndex < zoomLevels.length - 1) {
+ setZoom(zoomLevels[currentIndex + 1]);
+ }
+ };
+
+ const zoomOut = () => {
+ const currentIndex = zoomLevels.indexOf(zoom);
+ if (currentIndex > 0) {
+ setZoom(zoomLevels[currentIndex - 1]);
+ }
+ };
+
+ const fitToView = () => {
+ setZoom(100);
+ };
+
+ const toggleView = () => {
+ setShowOriginal(!showOriginal);
+ };
+
+ useEffect(() => {
+ if (!previewCanvasRef.current) return;
+
+ const previewCanvas = previewCanvasRef.current;
+ const ctx = previewCanvas.getContext("2d");
+ if (!ctx) return;
+
+ if (showOriginal && originalImage) {
+ // Show original image
+ previewCanvas.width = originalWidth;
+ previewCanvas.height = originalHeight;
+ ctx.drawImage(originalImage, 0, 0);
+ } else if (canvas) {
+ // Show resized image
+ previewCanvas.width = newWidth;
+ previewCanvas.height = newHeight;
+ ctx.drawImage(canvas, 0, 0);
+ }
+ }, [
+ canvas,
+ originalImage,
+ showOriginal,
+ originalWidth,
+ originalHeight,
+ newWidth,
+ newHeight,
+ ]);
+
+ return (
+
+ {/* Preview Container */}
+
+ {/* Zoom controls - positioned top-right */}
+
+
+ {zoom}%
+
+
+
+
+ {/* Canvas container - scrollable if zoomed */}
+
+
+
+ {/* Controls and Info */}
+
+
+
+
+
+ Original: {originalWidth}×{originalHeight}
+
+
+ New: {newWidth}×{newHeight}
+
+
+
+
+ {/* File size comparison */}
+ {originalFileSize && estimatedFileSize && (
+
+ Size: {formatFileSize(originalFileSize)} →{" "}
+ {formatFileSize(estimatedFileSize)}
+
+ )}
+
+ );
+}
diff --git a/components/modules/image-resize/output-settings.tsx b/components/modules/image-resize/output-settings.tsx
new file mode 100644
index 0000000..a72cef8
--- /dev/null
+++ b/components/modules/image-resize/output-settings.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { Button } from "../../ui/button";
+import { Slider } from "../../ui/slider";
+import { Label } from "../../ui/label";
+import { Download, Copy, Check, RefreshCw } from "lucide-react";
+
+interface OutputSettingsProps {
+ format: "png" | "jpeg" | "webp";
+ quality: number;
+ onFormatChange: (format: "png" | "jpeg" | "webp") => void;
+ onQualityChange: (quality: number) => void;
+ estimatedFileSize: number;
+ originalFileSize: number;
+ onDownload: () => void;
+ onCopyToClipboard: () => void;
+ onReset: () => void;
+ disabled?: boolean;
+ isCopied?: boolean;
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function OutputSettings({
+ format,
+ quality,
+ onFormatChange,
+ onQualityChange,
+ estimatedFileSize,
+ originalFileSize,
+ onDownload,
+ onCopyToClipboard,
+ onReset,
+ disabled = false,
+ isCopied = false,
+}: OutputSettingsProps) {
+ const reduction = Math.round(
+ ((originalFileSize - estimatedFileSize) / originalFileSize) * 100,
+ );
+
+ const formatDescriptions = {
+ png: "Lossless compression, supports transparency",
+ jpeg: "Lossy compression, smaller file size",
+ webp: "Modern format, best compression",
+ };
+
+ return (
+
+ {/* Format Selector */}
+
+
+
+
+
+
+
+
+ {formatDescriptions[format]}
+
+
+
+ {/* Quality Slider */}
+
+
+
+ {quality}%
+
+ {format === "png" ? (
+
+ PNG is lossless - no quality adjustment needed
+
+ ) : (
+
onQualityChange(val)}
+ min={1}
+ max={100}
+ disabled={disabled}
+ />
+ )}
+
+
+ {/* File Size Display */}
+
+
Estimated Size
+
+ {formatFileSize(estimatedFileSize)}
+
+
+ Original: {formatFileSize(originalFileSize)} (
+ {reduction > 0 ? `${reduction}% smaller` : `${-reduction}% larger`})
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ );
+}
diff --git a/components/modules/image-resize/preset-selector.tsx b/components/modules/image-resize/preset-selector.tsx
new file mode 100644
index 0000000..a313c72
--- /dev/null
+++ b/components/modules/image-resize/preset-selector.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "../../ui/button";
+import { Input } from "../../ui/input";
+import { Label } from "../../ui/label";
+import { Plus, Trash2 } from "lucide-react";
+import {
+ SOCIAL_MEDIA_PRESETS,
+ COMMON_PRESETS,
+ loadCustomPresets,
+ saveCustomPreset,
+ deleteCustomPreset,
+ type ImagePreset,
+} from "@/src/lib/image-resize-presets";
+
+interface PresetSelectorProps {
+ onPresetSelect: (width: number, height: number) => void;
+ selectedPresetId?: string;
+ disabled?: boolean;
+}
+
+export function PresetSelector({
+ onPresetSelect,
+ selectedPresetId,
+ disabled = false,
+}: PresetSelectorProps) {
+ const [customPresets, setCustomPresets] = useState(() =>
+ loadCustomPresets(),
+ );
+ const [showAddDialog, setShowAddDialog] = useState(false);
+ const [newPresetName, setNewPresetName] = useState("");
+ const [newPresetWidth, setNewPresetWidth] = useState("");
+ const [newPresetHeight, setNewPresetHeight] = useState("");
+
+ const handlePresetClick = (preset: ImagePreset) => {
+ if (disabled) return;
+ onPresetSelect(preset.width, preset.height);
+ };
+
+ const handleSaveCustomPreset = () => {
+ const width = parseInt(newPresetWidth, 10);
+ const height = parseInt(newPresetHeight, 10);
+
+ if (
+ !newPresetName.trim() ||
+ isNaN(width) ||
+ isNaN(height) ||
+ width <= 0 ||
+ height <= 0
+ ) {
+ return;
+ }
+
+ const newPreset = saveCustomPreset({
+ name: newPresetName.trim(),
+ width,
+ height,
+ });
+
+ setCustomPresets([...customPresets, newPreset]);
+ setNewPresetName("");
+ setNewPresetWidth("");
+ setNewPresetHeight("");
+ setShowAddDialog(false);
+ };
+
+ const handleDeleteCustomPreset = (presetId: string) => {
+ deleteCustomPreset(presetId);
+ setCustomPresets(customPresets.filter((p) => p.id !== presetId));
+ };
+
+ const renderPresetButton = (preset: ImagePreset) => {
+ const aspectRatio = preset.width / preset.height;
+ const visualWidth = Math.min(40, 40 * aspectRatio);
+ const visualHeight = Math.min(40, 40 / aspectRatio);
+
+ return (
+
+ );
+ };
+
+ return (
+
+ {/* Social Media Section */}
+
+
Social Media
+
+ {SOCIAL_MEDIA_PRESETS.map(renderPresetButton)}
+
+
+
+ {/* Common Sizes Section */}
+
+
Common Sizes
+
+ {COMMON_PRESETS.map(renderPresetButton)}
+
+
+
+ {/* Custom Presets Section */}
+
+
Custom Presets
+
+ {customPresets.length > 0 && (
+
+ {customPresets.map((preset) => (
+
+
{renderPresetButton(preset)}
+
+
+ ))}
+
+ )}
+
+ {!showAddDialog ? (
+
+ ) : (
+
+
+
+ setNewPresetName(e.target.value)}
+ placeholder="My Custom Size"
+ className="mt-1"
+ />
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/components/modules/image-resize/resize-controls.tsx b/components/modules/image-resize/resize-controls.tsx
new file mode 100644
index 0000000..5f8c28f
--- /dev/null
+++ b/components/modules/image-resize/resize-controls.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "../../ui/button";
+import { Input } from "../../ui/input";
+import { Label } from "../../ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
+import { Link2, Link2Off, ArrowDownUp } from "lucide-react";
+
+interface ResizeControlsProps {
+ width: number;
+ height: number;
+ originalWidth: number;
+ originalHeight: number;
+ onWidthChange: (width: number) => void;
+ onHeightChange: (height: number) => void;
+ aspectRatioLocked: boolean;
+ onAspectRatioLockedChange: (locked: boolean) => void;
+ disabled?: boolean;
+}
+
+export function ResizeControls({
+ width,
+ height,
+ originalWidth,
+ originalHeight,
+ onWidthChange,
+ onHeightChange,
+ aspectRatioLocked,
+ onAspectRatioLockedChange,
+ disabled = false,
+}: ResizeControlsProps) {
+ const [selectedPercentage, setSelectedPercentage] = useState(
+ null,
+ );
+
+ const aspectRatio = originalWidth / originalHeight;
+
+ const handleWidthChange = (newWidth: number) => {
+ onWidthChange(newWidth);
+ if (aspectRatioLocked) {
+ const newHeight = Math.round(newWidth / aspectRatio);
+ onHeightChange(newHeight);
+ }
+ };
+
+ const handleHeightChange = (newHeight: number) => {
+ onHeightChange(newHeight);
+ if (aspectRatioLocked) {
+ const newWidth = Math.round(newHeight * aspectRatio);
+ onWidthChange(newWidth);
+ }
+ };
+
+ const handleSwapDimensions = () => {
+ const tempWidth = width;
+ onWidthChange(height);
+ onHeightChange(tempWidth);
+ };
+
+ const handlePercentageChange = (percentage: number) => {
+ setSelectedPercentage(percentage);
+ const newWidth = Math.round((originalWidth * percentage) / 100);
+ const newHeight = Math.round((originalHeight * percentage) / 100);
+ onWidthChange(newWidth);
+ onHeightChange(newHeight);
+ };
+
+ const percentages = [25, 50, 75, 100, 150, 200];
+
+ return (
+
+
+
+ Dimensions
+ Percentage
+ Presets
+
+
+
+
+
+
+
+
+ handleWidthChange(parseInt(e.target.value) || 0)
+ }
+ disabled={disabled}
+ className="flex-1"
+ />
+ px
+
+
+
+
+
+
+
+
+
+ handleHeightChange(parseInt(e.target.value) || 0)
+ }
+ disabled={disabled}
+ className="flex-1"
+ />
+ px
+
+
+
+
+
+
+
+ Aspect ratio {aspectRatioLocked ? "locked" : "unlocked"}
+
+
+
+
+
+
+
+
+ {percentages.map((percentage) => (
+
+ ))}
+
+
+
+ {selectedPercentage !== null && (
+
+
Calculated dimensions:
+
+ Width: {Math.round((originalWidth * selectedPercentage) / 100)}
+ px
+
+
+ Height:{" "}
+ {Math.round((originalHeight * selectedPercentage) / 100)}px
+
+
+ )}
+
+
+
+
+ Select a preset from below
+
+
+
+
+ );
+}
diff --git a/package.json b/package.json
index 501c97d..1a5ea35 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"next-themes": "^0.4.6",
"node-vibrant": "^4.0.3",
"pg": "^8.16.3",
+ "pica": "^9.0.1",
"postcss": "^8.5.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
@@ -66,6 +67,7 @@
"@types/chroma-js": "^3.1.2",
"@types/node": "^20",
"@types/pg": "^8.16.0",
+ "@types/pica": "^9.0.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0c40383..0572423 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,6 +86,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.16.3
+ pica:
+ specifier: ^9.0.1
+ version: 9.0.1
postcss:
specifier: ^8.5.6
version: 8.5.6
@@ -147,6 +150,9 @@ importers:
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
+ '@types/pica':
+ specifier: ^9.0.5
+ version: 9.0.5
'@types/react':
specifier: ^19
version: 19.2.7
@@ -2175,6 +2181,9 @@ packages:
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
+ '@types/pica@9.0.5':
+ resolution: {integrity: sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw==}
+
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
@@ -3564,6 +3573,9 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
+ glur@1.1.2:
+ resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -4248,6 +4260,9 @@ packages:
typescript:
optional: true
+ multimath@2.0.0:
+ resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
+
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -4578,6 +4593,9 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+ pica@9.0.1:
+ resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5442,6 +5460,9 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ webworkify@1.5.0:
+ resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==}
+
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
@@ -7554,6 +7575,8 @@ snapshots:
pg-protocol: 1.10.3
pg-types: 2.2.0
+ '@types/pica@9.0.5': {}
+
'@types/prismjs@1.26.5': {}
'@types/react-dom@19.2.3(@types/react@19.2.7)':
@@ -9123,6 +9146,8 @@ snapshots:
define-properties: 1.2.1
gopd: 1.2.0
+ glur@1.1.2: {}
+
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -9721,6 +9746,11 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
+ multimath@2.0.0:
+ dependencies:
+ glur: 1.1.2
+ object-assign: 4.1.1
+
mute-stream@2.0.0: {}
mysql2@3.15.3:
@@ -10069,6 +10099,13 @@ snapshots:
dependencies:
split2: 4.2.0
+ pica@9.0.1:
+ dependencies:
+ glur: 1.1.2
+ multimath: 2.0.0
+ object-assign: 4.1.1
+ webworkify: 1.5.0
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -11151,6 +11188,8 @@ snapshots:
webidl-conversions@3.0.1: {}
+ webworkify@1.5.0: {}
+
whatwg-fetch@3.6.20: {}
whatwg-url@5.0.0:
diff --git a/src/config/modules.tsx b/src/config/modules.tsx
index 4614eb3..b2001cf 100644
--- a/src/config/modules.tsx
+++ b/src/config/modules.tsx
@@ -2,6 +2,7 @@ import { Base64Module } from "@/components/modules/base64-module";
import { ColorConverterModule } from "@/components/modules/color-converter-module";
import { ColorPaletteModule } from "@/components/modules/color-palette-module";
import { DomainNamesModule } from "@/components/modules/domain-names-module";
+import { ImageResizeModule } from "@/components/modules/image-resize-module";
import { LoremIpsumModule } from "@/components/modules/lorem-ipsum-module";
import { PomodoroTimerModule } from "@/components/modules/pomodoro-timer-module";
import { RemoveBgModule } from "@/components/modules/removeBg-module";
@@ -19,6 +20,7 @@ import {
Code2,
FileText,
Globe,
+ ImageIcon,
Images,
KeyRound,
Palette,
@@ -91,6 +93,15 @@ export const AVAILABLE_MODULES: ModuleConfig[] = [
category: "Image processing",
requiresAuth: false,
},
+ {
+ id: "image-resize",
+ name: "Image Resize",
+ description: "Resize images with presets and custom dimensions",
+ icon: ,
+ component: ImageResizeModule,
+ category: "Image processing",
+ requiresAuth: false,
+ },
{
id: "translation",
name: "Translator",
diff --git a/src/lib/image-resize-presets.ts b/src/lib/image-resize-presets.ts
new file mode 100644
index 0000000..cc15621
--- /dev/null
+++ b/src/lib/image-resize-presets.ts
@@ -0,0 +1,166 @@
+export interface ImagePreset {
+ id: string;
+ name: string;
+ width: number;
+ height: number;
+ category: PresetCategory;
+}
+
+export type PresetCategory = "social" | "common" | "custom";
+
+const STORAGE_KEY = "image-resize-custom-presets";
+
+export const SOCIAL_MEDIA_PRESETS: ImagePreset[] = [
+ {
+ id: "instagram-post",
+ name: "Instagram Post",
+ width: 1080,
+ height: 1080,
+ category: "social",
+ },
+ {
+ id: "instagram-story",
+ name: "Instagram Story",
+ width: 1080,
+ height: 1920,
+ category: "social",
+ },
+ {
+ id: "facebook-post",
+ name: "Facebook Post",
+ width: 1200,
+ height: 630,
+ category: "social",
+ },
+ {
+ id: "twitter-post",
+ name: "Twitter/X Post",
+ width: 1600,
+ height: 900,
+ category: "social",
+ },
+ {
+ id: "linkedin-post",
+ name: "LinkedIn Post",
+ width: 1200,
+ height: 627,
+ category: "social",
+ },
+ {
+ id: "youtube-thumbnail",
+ name: "YouTube Thumbnail",
+ width: 1280,
+ height: 720,
+ category: "social",
+ },
+];
+
+export const COMMON_PRESETS: ImagePreset[] = [
+ {
+ id: "thumbnail-small",
+ name: "Thumbnail Small",
+ width: 150,
+ height: 150,
+ category: "common",
+ },
+ {
+ id: "thumbnail-medium",
+ name: "Thumbnail Medium",
+ width: 300,
+ height: 300,
+ category: "common",
+ },
+ {
+ id: "hd",
+ name: "HD",
+ width: 1280,
+ height: 720,
+ category: "common",
+ },
+ {
+ id: "full-hd",
+ name: "Full HD",
+ width: 1920,
+ height: 1080,
+ category: "common",
+ },
+ {
+ id: "4k",
+ name: "4K",
+ width: 3840,
+ height: 2160,
+ category: "common",
+ },
+ {
+ id: "icon",
+ name: "Icon",
+ width: 64,
+ height: 64,
+ category: "common",
+ },
+ {
+ id: "favicon",
+ name: "Favicon",
+ width: 32,
+ height: 32,
+ category: "common",
+ },
+];
+
+export const ALL_PRESETS: ImagePreset[] = [
+ ...SOCIAL_MEDIA_PRESETS,
+ ...COMMON_PRESETS,
+];
+
+export function saveCustomPreset(
+ preset: Omit,
+): ImagePreset {
+ const newPreset: ImagePreset = {
+ ...preset,
+ id: `custom-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ category: "custom",
+ };
+
+ const existingPresets = loadCustomPresets();
+ const updatedPresets = [...existingPresets, newPreset];
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPresets));
+ } catch (error) {
+ console.error("Failed to save custom preset:", error);
+ }
+
+ return newPreset;
+}
+
+export function loadCustomPresets(): ImagePreset[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (!stored) {
+ return [];
+ }
+ const parsed = JSON.parse(stored);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (error) {
+ console.error("Failed to load custom presets:", error);
+ return [];
+ }
+}
+
+export function deleteCustomPreset(presetId: string): void {
+ const existingPresets = loadCustomPresets();
+ const updatedPresets = existingPresets.filter(
+ (preset) => preset.id !== presetId,
+ );
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPresets));
+ } catch (error) {
+ console.error("Failed to delete custom preset:", error);
+ }
+}
+
+export function getPresetById(presetId: string): ImagePreset | undefined {
+ const allPresets = [...ALL_PRESETS, ...loadCustomPresets()];
+ return allPresets.find((preset) => preset.id === presetId);
+}
diff --git a/src/lib/image-resize-utils.ts b/src/lib/image-resize-utils.ts
new file mode 100644
index 0000000..81f5886
--- /dev/null
+++ b/src/lib/image-resize-utils.ts
@@ -0,0 +1,479 @@
+import Pica from "pica";
+
+export interface ResizeOptions {
+ width: number;
+ height: number;
+ maintainAspectRatio?: boolean;
+ algorithm?: ResizeAlgorithm;
+}
+
+export interface CalculateDimensionsOptions {
+ originalWidth: number;
+ originalHeight: number;
+ targetWidth?: number;
+ targetHeight?: number;
+ percentage?: number;
+ maxWidth?: number;
+ maxHeight?: number;
+ maintainAspectRatio?: boolean;
+}
+
+export type ImageFormat = "png" | "jpeg" | "webp";
+
+export type ResizeAlgorithm = "lanczos" | "bicubic" | "bilinear" | "nearest" | "pica";
+
+export const RESIZE_ALGORITHMS = [
+ {
+ id: "pica" as const,
+ name: "Ultra Quality (Pica)",
+ description: "True Lanczos3 resampling, best for downscaling photos",
+ quality: "ultra" as const,
+ recommendedUse: "Meilleure qualité pour le downscaling de photos - utilise un vrai algorithme Lanczos3",
+ },
+ {
+ id: "lanczos" as const,
+ name: "High Quality",
+ description: "Best for photos, slower processing",
+ quality: "high" as const,
+ recommendedUse:
+ "Best for photos and detailed images where quality is paramount",
+ },
+ {
+ id: "bicubic" as const,
+ name: "Standard",
+ description: "Good balance of quality and speed, default choice",
+ quality: "medium" as const,
+ recommendedUse:
+ "Recommended for most use cases, provides good quality with reasonable speed",
+ },
+ {
+ id: "bilinear" as const,
+ name: "Fast",
+ description: "Quick processing, acceptable quality",
+ quality: "low" as const,
+ recommendedUse: "Ideal when speed is more important than maximum quality",
+ },
+ {
+ id: "nearest" as const,
+ name: "Pixel Perfect",
+ description: "No interpolation, ideal for pixel art",
+ quality: "pixelated" as const,
+ recommendedUse:
+ "Perfect for pixel art and images that need sharp, unblurred edges",
+ },
+] as const;
+
+/**
+ * Performs multi-step downscaling for better quality when resizing to less than 50% of original size.
+ * Uses iterative halving technique to produce sharper results than single-step resize.
+ *
+ * @param image - Source image or canvas to resize
+ * @param targetWidth - Final target width
+ * @param targetHeight - Final target height
+ * @returns Canvas with the resized image
+ */
+function stepDownResize(
+ image: HTMLImageElement | HTMLCanvasElement,
+ targetWidth: number,
+ targetHeight: number,
+): HTMLCanvasElement {
+ let currentWidth = image.width;
+ let currentHeight = image.height;
+ let currentImage: HTMLImageElement | HTMLCanvasElement = image;
+
+ // Iteratively halve dimensions until within 2x of target size
+ while (currentWidth > targetWidth * 2 || currentHeight > targetHeight * 2) {
+ const stepWidth = Math.max(Math.floor(currentWidth / 2), targetWidth);
+ const stepHeight = Math.max(Math.floor(currentHeight / 2), targetHeight);
+
+ const stepCanvas = document.createElement("canvas");
+ const stepCtx = stepCanvas.getContext("2d");
+
+ if (!stepCtx) {
+ throw new Error("Failed to get canvas context for step-down resize");
+ }
+
+ stepCanvas.width = stepWidth;
+ stepCanvas.height = stepHeight;
+
+ stepCtx.imageSmoothingEnabled = true;
+ stepCtx.imageSmoothingQuality = "high";
+ stepCtx.drawImage(currentImage, 0, 0, stepWidth, stepHeight);
+
+ currentImage = stepCanvas;
+ currentWidth = stepWidth;
+ currentHeight = stepHeight;
+ }
+
+ // Final step: resize to exact target dimensions
+ const finalCanvas = document.createElement("canvas");
+ const finalCtx = finalCanvas.getContext("2d");
+
+ if (!finalCtx) {
+ throw new Error("Failed to get canvas context for final resize");
+ }
+
+ finalCanvas.width = targetWidth;
+ finalCanvas.height = targetHeight;
+
+ finalCtx.imageSmoothingEnabled = true;
+ finalCtx.imageSmoothingQuality = "high";
+ finalCtx.drawImage(currentImage, 0, 0, targetWidth, targetHeight);
+
+ return finalCanvas;
+}
+
+export async function resizeImage(
+ image: HTMLImageElement,
+ options: ResizeOptions,
+): Promise {
+ const { algorithm = "bicubic" } = options;
+ let { width, height } = options;
+
+ if (options.maintainAspectRatio) {
+ const aspectRatio = image.width / image.height;
+ if (width / height > aspectRatio) {
+ width = height * aspectRatio;
+ } else {
+ height = width / aspectRatio;
+ }
+ }
+
+ // Use pica for ultra-high quality resizing with true Lanczos3
+ if (algorithm === "pica") {
+ const pica = new Pica({
+ features: ['js', 'wasm', 'ww'],
+ });
+
+ // Create source canvas from image
+ const sourceCanvas = document.createElement("canvas");
+ sourceCanvas.width = image.width;
+ sourceCanvas.height = image.height;
+ const sourceCtx = sourceCanvas.getContext("2d");
+ if (!sourceCtx) throw new Error("Failed to get source canvas context");
+ sourceCtx.drawImage(image, 0, 0);
+
+ // Create destination canvas
+ const destCanvas = document.createElement("canvas");
+ destCanvas.width = width;
+ destCanvas.height = height;
+
+ // Resize with pica (Lanczos3 by default)
+ await pica.resize(sourceCanvas, destCanvas, {
+ quality: 3, // 0-3, 3 is highest (Lanczos3)
+ alpha: true,
+ unsharpAmount: 80,
+ unsharpRadius: 0.6,
+ unsharpThreshold: 2,
+ });
+
+ return destCanvas;
+ }
+
+ // For lanczos algorithm with large downscale (target < 50% of original),
+ // use multi-step downscaling for better quality
+ if (algorithm === "lanczos") {
+ const scaleFactorWidth = width / image.width;
+ const scaleFactorHeight = height / image.height;
+
+ if (scaleFactorWidth < 0.5 || scaleFactorHeight < 0.5) {
+ return stepDownResize(image, width, height);
+ }
+ }
+
+ // Standard single-step resize for other cases
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ throw new Error("Failed to get canvas context");
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // Apply algorithm-specific canvas smoothing settings
+ if (algorithm === "nearest") {
+ ctx.imageSmoothingEnabled = false;
+ } else {
+ ctx.imageSmoothingEnabled = true;
+
+ // Map algorithm to imageSmoothingQuality
+ if (algorithm === "lanczos") {
+ ctx.imageSmoothingQuality = "high";
+ } else if (algorithm === "bicubic") {
+ ctx.imageSmoothingQuality = "medium";
+ } else if (algorithm === "bilinear") {
+ ctx.imageSmoothingQuality = "low";
+ }
+ }
+
+ ctx.drawImage(image, 0, 0, width, height);
+
+ return canvas;
+}
+
+export async function loadImageFromFile(file: File): Promise<{
+ image: HTMLImageElement;
+ width: number;
+ height: number;
+}> {
+ // Validate file is an image
+ if (!file.type.startsWith("image/")) {
+ throw new Error("File is not an image");
+ }
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ const img = new Image();
+
+ img.onload = () => {
+ resolve({
+ image: img,
+ width: img.width,
+ height: img.height,
+ });
+ };
+
+ img.onerror = () => {
+ reject(new Error("Failed to load image"));
+ };
+
+ img.src = e.target?.result as string;
+ };
+
+ reader.onerror = () => {
+ reject(new Error("Failed to read file"));
+ };
+
+ reader.readAsDataURL(file);
+ });
+}
+
+export async function getImageDimensions(source: File | string): Promise<{
+ width: number;
+ height: number;
+}> {
+ if (source instanceof File) {
+ const { width, height } = await loadImageFromFile(source);
+ return { width, height };
+ }
+
+ // Source is a URL or base64 string
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+
+ img.onload = () => {
+ resolve({
+ width: img.width,
+ height: img.height,
+ });
+ };
+
+ img.onerror = () => {
+ reject(new Error("Failed to load image from URL"));
+ };
+
+ img.src = source;
+ });
+}
+
+export function calculateDimensions(options: CalculateDimensionsOptions): {
+ width: number;
+ height: number;
+} {
+ const {
+ originalWidth,
+ originalHeight,
+ targetWidth,
+ targetHeight,
+ percentage,
+ maxWidth,
+ maxHeight,
+ maintainAspectRatio = true,
+ } = options;
+
+ const aspectRatio = originalWidth / originalHeight;
+
+ // Calculate from percentage
+ if (percentage !== undefined) {
+ return {
+ width: Math.round(originalWidth * (percentage / 100)),
+ height: Math.round(originalHeight * (percentage / 100)),
+ };
+ }
+
+ // Both dimensions specified
+ if (targetWidth !== undefined && targetHeight !== undefined) {
+ if (maintainAspectRatio) {
+ // Fit within target dimensions
+ const targetRatio = targetWidth / targetHeight;
+ if (aspectRatio > targetRatio) {
+ return {
+ width: targetWidth,
+ height: Math.round(targetWidth / aspectRatio),
+ };
+ } else {
+ return {
+ width: Math.round(targetHeight * aspectRatio),
+ height: targetHeight,
+ };
+ }
+ } else {
+ return { width: targetWidth, height: targetHeight };
+ }
+ }
+
+ // Only width specified
+ if (targetWidth !== undefined) {
+ return {
+ width: targetWidth,
+ height: maintainAspectRatio
+ ? Math.round(targetWidth / aspectRatio)
+ : originalHeight,
+ };
+ }
+
+ // Only height specified
+ if (targetHeight !== undefined) {
+ return {
+ width: maintainAspectRatio
+ ? Math.round(targetHeight * aspectRatio)
+ : originalWidth,
+ height: targetHeight,
+ };
+ }
+
+ // Fit to max dimensions
+ if (maxWidth !== undefined || maxHeight !== undefined) {
+ let newWidth = originalWidth;
+ let newHeight = originalHeight;
+
+ if (maxWidth !== undefined && newWidth > maxWidth) {
+ newWidth = maxWidth;
+ newHeight = maintainAspectRatio
+ ? Math.round(maxWidth / aspectRatio)
+ : newHeight;
+ }
+
+ if (maxHeight !== undefined && newHeight > maxHeight) {
+ newHeight = maxHeight;
+ newWidth = maintainAspectRatio
+ ? Math.round(maxHeight * aspectRatio)
+ : newWidth;
+ }
+
+ return { width: newWidth, height: newHeight };
+ }
+
+ // No constraints, return original dimensions
+ return { width: originalWidth, height: originalHeight };
+}
+
+export async function convertToFormat(
+ canvas: HTMLCanvasElement,
+ format: ImageFormat,
+ quality: number = 0.92,
+): Promise {
+ // PNG is lossless, quality parameter is ignored
+ if (format === "png") {
+ return canvas.toDataURL("image/png");
+ }
+
+ // JPEG and WebP support quality parameter (0.0 - 1.0)
+ if (format === "jpeg") {
+ return canvas.toDataURL("image/jpeg", quality);
+ }
+
+ if (format === "webp") {
+ return canvas.toDataURL("image/webp", quality);
+ }
+
+ throw new Error(`Unsupported format: ${format}`);
+}
+
+export function estimateFileSize(dataUrl: string): number {
+ // Remove data URL prefix (e.g., "data:image/png;base64,")
+ const base64String = dataUrl.split(",")[1] || dataUrl;
+
+ // Calculate size: each base64 character represents 6 bits
+ // The actual byte size is (base64Length * 6) / 8
+ // Account for padding characters (=)
+ const padding = (base64String.match(/=/g) || []).length;
+ const base64Length = base64String.length;
+
+ return Math.floor((base64Length * 3) / 4 - padding);
+}
+
+export function rotateImage(
+ canvas: HTMLCanvasElement,
+ degrees: 90 | 180 | 270,
+): HTMLCanvasElement {
+ const newCanvas = document.createElement("canvas");
+ const ctx = newCanvas.getContext("2d");
+
+ if (!ctx) {
+ throw new Error("Failed to get canvas context");
+ }
+
+ const radians = (degrees * Math.PI) / 180;
+
+ // Adjust canvas size for 90 and 270 degree rotations
+ if (degrees === 90 || degrees === 270) {
+ newCanvas.width = canvas.height;
+ newCanvas.height = canvas.width;
+ } else {
+ newCanvas.width = canvas.width;
+ newCanvas.height = canvas.height;
+ }
+
+ // Set transform origin and rotate
+ ctx.save();
+
+ if (degrees === 90) {
+ ctx.translate(newCanvas.width, 0);
+ ctx.rotate(radians);
+ } else if (degrees === 180) {
+ ctx.translate(newCanvas.width, newCanvas.height);
+ ctx.rotate(radians);
+ } else if (degrees === 270) {
+ ctx.translate(0, newCanvas.height);
+ ctx.rotate(radians);
+ }
+
+ ctx.drawImage(canvas, 0, 0);
+ ctx.restore();
+
+ return newCanvas;
+}
+
+export function flipImage(
+ canvas: HTMLCanvasElement,
+ direction: "horizontal" | "vertical",
+): HTMLCanvasElement {
+ const newCanvas = document.createElement("canvas");
+ const ctx = newCanvas.getContext("2d");
+
+ if (!ctx) {
+ throw new Error("Failed to get canvas context");
+ }
+
+ newCanvas.width = canvas.width;
+ newCanvas.height = canvas.height;
+
+ ctx.save();
+
+ if (direction === "horizontal") {
+ ctx.scale(-1, 1);
+ ctx.drawImage(canvas, -canvas.width, 0);
+ } else {
+ ctx.scale(1, -1);
+ ctx.drawImage(canvas, 0, -canvas.height);
+ }
+
+ ctx.restore();
+
+ return newCanvas;
+}