From 759e255e9d5e52a8356dfb6d6973330099ed54d6 Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Fri, 5 Jun 2026 23:24:16 -0300 Subject: [PATCH 01/10] feat(image): add rotate and flip image tool --- src/features/tools/data/tools.ts | 102 +++ .../tools/renderers/toolRenderers.tsx | 3 + src/shared/utils/imageFiles.ts | 38 ++ .../image/image-rotator/ImageRotatorTool.tsx | 606 ++++++++++++++++++ src/tools/image/image-rotator/README.md | 17 + .../imageRotator.service.test.ts | 70 ++ .../image-rotator/imageRotator.service.ts | 236 +++++++ .../image/image-rotator/imageRotator.types.ts | 50 ++ src/tools/image/image-rotator/index.ts | 1 + 9 files changed, 1123 insertions(+) create mode 100644 src/tools/image/image-rotator/ImageRotatorTool.tsx create mode 100644 src/tools/image/image-rotator/README.md create mode 100644 src/tools/image/image-rotator/imageRotator.service.test.ts create mode 100644 src/tools/image/image-rotator/imageRotator.service.ts create mode 100644 src/tools/image/image-rotator/imageRotator.types.ts create mode 100644 src/tools/image/image-rotator/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 80b9d14..4787685 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -568,6 +568,108 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-rotator", + slug: "rotar-imagen", + slugEn: "rotate-image", + name: { es: "Rotar imagen", en: "Rotate image" }, + description: { + es: "Rota o voltea una imagen directamente en tu navegador.", + en: "Rotate or flip an image directly in your browser.", + }, + category: "image", + tags: { + es: ["imagen", "rotar", "voltear", "png", "jpg", "webp"], + en: ["image", "rotate", "flip", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Rotar imagen online gratis", + description: + "Rota o voltea una imagen PNG, JPG o WebP desde tu navegador. Sin cuenta y sin subir archivos a Modulaq.", + }, + en: { + title: "Rotate image online free", + description: + "Rotate or flip a PNG, JPG or WebP image from your browser. No account and no uploads to Modulaq.", + }, + }, + doc: { + es: { + summary: + "Rotar imagen aplica rotaciones y volteos a una imagen local usando canvas, sin backend y sin subir archivos.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa el tipo, peso y dimensiones originales.", + "Aplica una o varias acciones: rotar 90 grados, rotar 180 grados o voltear.", + "Elige PNG, JPG o WebP si el navegador lo permite.", + "Prepara la imagen y descarga el resultado.", + ], + useCases: [ + "Corregir la orientacion de una foto.", + "Reflejar una imagen horizontal o verticalmente.", + "Preparar una captura o recurso visual sin subirlo a un servidor.", + ], + limits: [ + "JPG no conserva transparencia.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + "Tamano maximo por imagen: 15 MB.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para rotarla.", + commonErrors: [ + "Imagen danada o que el navegador no puede decodificar.", + "Archivo que no es PNG, JPG o WebP.", + "Formato de salida no disponible en el navegador actual.", + ], + technicalNotes: [ + "La transformacion usa canvas en el navegador.", + "La rotacion 90/270 intercambia ancho y alto; 0/180 conserva dimensiones.", + "Los volteos horizontal y vertical conservan las dimensiones.", + ], + }, + en: { + summary: + "Rotate image applies rotations and flips to a local image with canvas, without backend and without uploads.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the detected type, size and dimensions.", + "Apply one or more actions: rotate 90 degrees, rotate 180 degrees or flip.", + "Choose PNG, JPG or WebP if the browser supports it.", + "Prepare the image and download the result.", + ], + useCases: [ + "Fix a photo orientation.", + "Mirror an image horizontally or vertically.", + "Prepare a screenshot or visual asset without uploading it to a server.", + ], + limits: [ + "JPG does not preserve transparency.", + "WebP appears if your browser can export it correctly.", + "Maximum image size: 15 MB.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to rotate it.", + commonErrors: [ + "A damaged image or one the browser cannot decode.", + "A file that is not PNG, JPG or WebP.", + "Output format unavailable in the current browser.", + ], + technicalNotes: [ + "The transformation uses browser canvas.", + "Rotation by 90/270 swaps width and height; 0/180 keeps dimensions.", + "Horizontal and vertical flips keep dimensions.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index f246d11..ea73043 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -17,6 +17,9 @@ const toolRenderers: Partial> = { "image-converter": lazy(() => import("../../../tools/image/image-converter").then((module) => ({ default: module.ImageConverterTool })), ), + "image-rotator": lazy(() => + import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), + ), "image-resizer": lazy(() => import("../../../tools/image/image-resizer").then((module) => ({ default: module.ImageResizerTool })), ), diff --git a/src/shared/utils/imageFiles.ts b/src/shared/utils/imageFiles.ts index 9396f22..5784e5a 100644 --- a/src/shared/utils/imageFiles.ts +++ b/src/shared/utils/imageFiles.ts @@ -13,6 +13,12 @@ export type BrowserImageExportOptions = { width?: number; }; +export type BrowserCanvasExportOptions = { + errorMessage?: string; + mimeType: BrowserImageMimeType; + quality?: number; +}; + export type BrowserImageExportResult = { bytes: ArrayBuffer; height: number; @@ -176,6 +182,38 @@ export async function loadBrowserImage(file: File, errorMessage = "No se pudo le } } +export async function exportBrowserCanvas( + canvas: HTMLCanvasElement, + { errorMessage = "No se pudo convertir la imagen.", mimeType, quality }: BrowserCanvasExportOptions, +): Promise { + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (nextBlob) => { + if (nextBlob) { + resolve(nextBlob); + return; + } + + reject(new Error(errorMessage)); + }, + mimeType, + mimeType === "image/png" ? undefined : normalizeImageQuality(quality), + ); + }); + + if (blob.type && blob.type !== mimeType) { + throw new Error("El navegador no generó el formato de imagen esperado."); + } + + return { + bytes: await blob.arrayBuffer(), + height: canvas.height, + mimeType, + size: blob.size, + width: canvas.width, + }; +} + export async function exportBrowserImageFile( file: File, { backgroundColor, errorMessage, height, mimeType, quality, width }: BrowserImageExportOptions, diff --git a/src/tools/image/image-rotator/ImageRotatorTool.tsx b/src/tools/image/image-rotator/ImageRotatorTool.tsx new file mode 100644 index 0000000..eeeccfa --- /dev/null +++ b/src/tools/image/image-rotator/ImageRotatorTool.tsx @@ -0,0 +1,606 @@ +import { + Download, + FileImage, + FlipHorizontal2, + FlipVertical2, + Loader2, + RotateCcw, + RotateCw, + Upload, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + applyImageRotatorAction, + buildRotatedImageFileName, + defaultOutputBaseName, + defaultTransform, + formatFileSize, + getImageFormatLabel, + getImageMimeLabel, + getRotatedImageOutputBaseName, + getTransformedImageDimensions, + hasImageTransform, + readImageMetadata, + rotateImageFile, +} from "./imageRotator.service"; +import type { + ImageRotatorAction, + ImageRotatorMetadata, + ImageRotatorOutputFormat, + ImageRotatorResult, + ImageRotatorStatus, + ImageRotatorTransform, +} from "./imageRotator.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type DownloadableResult = ImageRotatorResult & { + url: string; +}; + +const baseOutputFormats: ImageRotatorOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const actionOrder: ImageRotatorAction[] = [ + "rotate-right", + "rotate-left", + "rotate-180", + "flip-horizontal", + "flip-vertical", +]; + +const actionIcons: Record = { + "flip-horizontal": FlipHorizontal2, + "flip-vertical": FlipVertical2, + "rotate-180": RotateCw, + "rotate-left": RotateCcw, + "rotate-right": RotateCw, +}; + +const copy = { + es: { + actionsTitle: "Acciones", + applyCta: "Preparar imagen", + downloadReady: "Imagen lista", + flipHorizontal: "Voltear horizontal", + flipVertical: "Voltear vertical", + jpgTransparency: "JPG no conserva transparencia.", + noImage: "Selecciona una imagen para habilitar las acciones.", + outputIntro: "El resultado se genera desde un canvas local.", + outputTitle: "Vista previa y salida", + previewAlt: "Vista previa de la imagen transformada", + processing: "Preparando imagen...", + resetActions: "Restablecer acciones", + rotate180: "Rotar 180", + rotateLeft: "90 izquierda", + rotateRight: "90 derecha", + sourceIntro: "Rota o voltea una imagen directamente en tu navegador.", + sourceTitle: "Archivo y rotacion", + transformLabel: "Transformacion", + unchanged: "Sin cambios", + webpHint: "WebP aparece si tu navegador permite exportarlo correctamente.", + }, + en: { + actionsTitle: "Actions", + applyCta: "Prepare image", + downloadReady: "Image ready", + flipHorizontal: "Flip horizontal", + flipVertical: "Flip vertical", + jpgTransparency: "JPG does not preserve transparency.", + noImage: "Select an image to enable actions.", + outputIntro: "The result is generated from a local canvas.", + outputTitle: "Preview and output", + previewAlt: "Preview of the transformed image", + processing: "Preparing image...", + resetActions: "Reset actions", + rotate180: "Rotate 180", + rotateLeft: "90 left", + rotateRight: "90 right", + sourceIntro: "Rotate or flip an image directly in your browser.", + sourceTitle: "File and rotation", + transformLabel: "Transform", + unchanged: "No changes", + webpHint: "WebP appears if your browser can export it correctly.", + }, +} as const; + +function getPreviewTransform(transform: ImageRotatorTransform) { + return `rotate(${transform.rotation}deg) scale(${transform.flipHorizontal ? -1 : 1}, ${ + transform.flipVertical ? -1 : 1 + })`; +} + +function getTransformSummary(transform: ImageRotatorTransform, labels: (typeof copy)["es" | "en"]) { + const parts: string[] = []; + + if (transform.rotation !== 0) { + parts.push(`${transform.rotation}deg`); + } + + if (transform.flipHorizontal) { + parts.push(labels.flipHorizontal); + } + + if (transform.flipVertical) { + parts.push(labels.flipVertical); + } + + return parts.length > 0 ? parts.join(" + ") : labels.unchanged; +} + +export function ImageRotatorTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const resultUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [transform, setTransform] = useState(defaultTransform); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const outputDimensions = metadata + ? getTransformedImageDimensions({ height: metadata.height, width: metadata.width }, transform) + : null; + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const finalOutputFileName = buildRotatedImageFileName( + outputFileName, + outputFormat, + metadata ? getRotatedImageOutputBaseName(metadata.fileName) : defaultOutputBaseName, + ); + const transparencyWarning = outputFormat === "jpeg" && metadata?.mimeType !== "image/jpeg" ? labels.jpgTransparency : null; + const canRotate = Boolean(file && metadata) && status !== "reading" && status !== "processing"; + + const actionLabels: Record = { + "flip-horizontal": labels.flipHorizontal, + "flip-vertical": labels.flipVertical, + "rotate-180": labels.rotate180, + "rotate-left": labels.rotateLeft, + "rotate-right": labels.rotateRight, + }; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setError(null); + setTransform(defaultTransform); + clearResult(); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + if (!hasCustomOutputFileName) { + setOutputFileName(getRotatedImageOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + clearResult(); + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setTransform(defaultTransform); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const applyAction = (action: ImageRotatorAction) => { + setTransform((current) => applyImageRotatorAction(current, action)); + resetFeedback(); + }; + + const rotateImage = async () => { + if (!file || !canRotate) return; + setStatus("processing"); + setError(null); + clearResult(); + try { + const nextResult = await rotateImageFile(file, { + outputBaseName: outputFileName, + outputFormat, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + transform, + }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo exportar la imagen."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+

{labels.actionsTitle}

+
+ {actionOrder.map((action) => { + const Icon = actionIcons[action]; + return ( + + ); + })} +
+

+ {labels.transformLabel}:{" "} + {getTransformSummary(transform, labels)} +

+ +
+ + {status === "reading" ? ( +

+ + {t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {previewUrl ? ( + {labels.previewAlt} + ) : ( +
+ +

{labels.noImage}

+
+ )} +
+ +
+

{t("imageUi.finalDimensions")}

+

+ {outputDimensions ? `${outputDimensions.width} x ${outputDimensions.height}px` : "-"} +

+
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ +

{labels.webpHint}

+ + {shouldShowQuality ? ( + + ) : null} + + {transparencyWarning ? ( +

+ {transparencyWarning} +

+ ) : null} + + + + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{t("imageUi.finalDimensions")}
+
+ {result.width} x {result.height}px +
+
+
+
{t("imageUi.finalFormat")}
+
{getImageFormatLabel(result.format)}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-rotator/README.md b/src/tools/image/image-rotator/README.md new file mode 100644 index 0000000..af188bc --- /dev/null +++ b/src/tools/image/image-rotator/README.md @@ -0,0 +1,17 @@ +# Rotar imagen + +Herramienta frontend-only para rotar o voltear una imagen localmente en el navegador. + +## Soporte + +- Entrada: PNG, JPG/JPEG y WebP. +- Salida: PNG, JPG y WebP cuando el navegador puede exportarlo correctamente. +- Acciones combinables: rotar 90 grados a derecha o izquierda, rotar 180 grados, voltear horizontal y voltear vertical. + +## Privacidad + +El archivo se procesa en el navegador. No usa backend ni sube archivos. + +## Notas tecnicas + +Reutiliza `src/shared/utils/imageFiles.ts` para validacion de imagen, deteccion de WebP, nombres de salida, calidad, carga browser y exportacion de canvas. diff --git a/src/tools/image/image-rotator/imageRotator.service.test.ts b/src/tools/image/image-rotator/imageRotator.service.test.ts new file mode 100644 index 0000000..fbc4598 --- /dev/null +++ b/src/tools/image/image-rotator/imageRotator.service.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + applyImageRotatorAction, + buildRotatedImageFileName, + defaultTransform, + getRotatedImageOutputBaseName, + getTransformedImageDimensions, + isImageRotatorAction, +} from "./imageRotator.service"; + +describe("imageRotator.service", () => { + it("swaps width and height after a 90 degree rotation", () => { + const transform = applyImageRotatorAction(defaultTransform, "rotate-right"); + + expect(getTransformedImageDimensions({ width: 1200, height: 800 }, transform)).toEqual({ + height: 1200, + width: 800, + }); + }); + + it("keeps dimensions after a 180 degree rotation", () => { + const transform = applyImageRotatorAction(defaultTransform, "rotate-180"); + + expect(getTransformedImageDimensions({ width: 1200, height: 800 }, transform)).toEqual({ + height: 800, + width: 1200, + }); + }); + + it("keeps dimensions after horizontal and vertical flips", () => { + const horizontal = applyImageRotatorAction(defaultTransform, "flip-horizontal"); + const vertical = applyImageRotatorAction(defaultTransform, "flip-vertical"); + + expect(getTransformedImageDimensions({ width: 640, height: 480 }, horizontal)).toEqual({ + height: 480, + width: 640, + }); + expect(getTransformedImageDimensions({ width: 640, height: 480 }, vertical)).toEqual({ + height: 480, + width: 640, + }); + }); + + it("validates known actions", () => { + expect(isImageRotatorAction("rotate-left")).toBe(true); + expect(isImageRotatorAction("flip-vertical")).toBe(true); + expect(isImageRotatorAction("crop")).toBe(false); + }); + + it("builds output names with the selected format", () => { + expect(getRotatedImageOutputBaseName("foto original.png")).toBe("foto original-rotada"); + expect(buildRotatedImageFileName("foto original-rotada", "jpeg")).toBe("foto original-rotada.jpg"); + expect(buildRotatedImageFileName("foto original-rotada", "webp")).toBe("foto original-rotada.webp"); + }); + + it("combines rotation and flips cleanly", () => { + const rotated = applyImageRotatorAction(defaultTransform, "rotate-right"); + const transformed = applyImageRotatorAction(rotated, "flip-horizontal"); + + expect(transformed).toEqual({ + flipHorizontal: true, + flipVertical: false, + rotation: 90, + }); + expect(getTransformedImageDimensions({ width: 300, height: 500 }, transformed)).toEqual({ + height: 300, + width: 500, + }); + }); +}); diff --git a/src/tools/image/image-rotator/imageRotator.service.ts b/src/tools/image/image-rotator/imageRotator.service.ts new file mode 100644 index 0000000..0671c93 --- /dev/null +++ b/src/tools/image/image-rotator/imageRotator.service.ts @@ -0,0 +1,236 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildBrowserImageDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageMimeType, + getBrowserImageOutputMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + ImageDimensions, + ImageRotatorAction, + ImageRotatorMetadata, + ImageRotatorOutputFormat, + ImageRotatorResult, + ImageRotatorTransform, + RotateImageOptions, +} from "./imageRotator.types"; + +export const defaultOutputBaseName = "imagen-rotada"; +export const defaultTransform: ImageRotatorTransform = { + flipHorizontal: false, + flipVertical: false, + rotation: 0, +}; + +const validActions: readonly ImageRotatorAction[] = [ + "rotate-right", + "rotate-left", + "rotate-180", + "flip-horizontal", + "flip-vertical", +]; + +export { formatFileSize }; + +export function isRotatableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageFormatLabel(format: ImageRotatorOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function isImageRotatorAction(action: string): action is ImageRotatorAction { + return validActions.includes(action as ImageRotatorAction); +} + +function normalizeRotation(rotation: number): ImageRotatorTransform["rotation"] { + const normalized = ((rotation % 360) + 360) % 360; + + if (normalized === 90 || normalized === 180 || normalized === 270) { + return normalized; + } + + return 0; +} + +export function applyImageRotatorAction( + transform: ImageRotatorTransform, + action: ImageRotatorAction, +): ImageRotatorTransform { + if (action === "rotate-right") { + return { ...transform, rotation: normalizeRotation(transform.rotation + 90) }; + } + + if (action === "rotate-left") { + return { ...transform, rotation: normalizeRotation(transform.rotation - 90) }; + } + + if (action === "rotate-180") { + return { ...transform, rotation: normalizeRotation(transform.rotation + 180) }; + } + + if (action === "flip-horizontal") { + return { ...transform, flipHorizontal: !transform.flipHorizontal }; + } + + return { ...transform, flipVertical: !transform.flipVertical }; +} + +export function getTransformedImageDimensions( + dimensions: ImageDimensions, + transform: ImageRotatorTransform, +): ImageDimensions { + if (transform.rotation === 90 || transform.rotation === 270) { + return { + height: dimensions.width, + width: dimensions.height, + }; + } + + return dimensions; +} + +export function hasImageTransform(transform: ImageRotatorTransform) { + return transform.rotation !== 0 || transform.flipHorizontal || transform.flipVertical; +} + +export function getRotatedImageOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-rotada`; +} + +export function buildRotatedImageFileName( + baseName: string, + outputFormat: ImageRotatorOutputFormat, + fallbackBaseName = defaultOutputBaseName, +) { + return buildBrowserImageDownloadFileName(baseName, outputFormat, fallbackBaseName); +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +function ensureOutputFormatSupported(outputFormat: ImageRotatorOutputFormat) { + if (!canExportBrowserImageFormat(outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(outputFormat)}.`); + } +} + +function drawTransformedImage( + canvas: HTMLCanvasElement, + image: HTMLImageElement, + transform: ImageRotatorTransform, + outputFormat: ImageRotatorOutputFormat, +) { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar la imagen de salida."); + } + + if (outputFormat === "jpeg") { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + context.save(); + context.translate(canvas.width / 2, canvas.height / 2); + context.rotate((transform.rotation * Math.PI) / 180); + context.scale(transform.flipHorizontal ? -1 : 1, transform.flipVertical ? -1 : 1); + context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2); + context.restore(); +} + +export async function rotateImageFile( + file: File, + { outputBaseName, outputFormat, quality, transform }: RotateImageOptions, +): Promise { + if (!isRotatableImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + ensureOutputFormatSupported(outputFormat); + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const outputDimensions = getTransformedImageDimensions( + { height: image.naturalHeight, width: image.naturalWidth }, + transform, + ); + const canvas = document.createElement("canvas"); + canvas.width = outputDimensions.width; + canvas.height = outputDimensions.height; + drawTransformedImage(canvas, image, transform, outputFormat); + + const mimeType = getBrowserImageOutputMimeType(outputFormat); + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar la imagen.", + mimeType, + quality, + }); + + return { + bytes: result.bytes, + fileName: buildRotatedImageFileName(outputBaseName, outputFormat, getRotatedImageOutputBaseName(file.name)), + format: outputFormat, + height: result.height, + mimeType, + size: result.size, + width: result.width, + }; +} diff --git a/src/tools/image/image-rotator/imageRotator.types.ts b/src/tools/image/image-rotator/imageRotator.types.ts new file mode 100644 index 0000000..91d4107 --- /dev/null +++ b/src/tools/image/image-rotator/imageRotator.types.ts @@ -0,0 +1,50 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImageRotationDegrees = 0 | 90 | 180 | 270; + +export type ImageRotatorAction = + | "rotate-right" + | "rotate-left" + | "rotate-180" + | "flip-horizontal" + | "flip-vertical"; + +export type ImageRotatorTransform = { + flipHorizontal: boolean; + flipVertical: boolean; + rotation: ImageRotationDegrees; +}; + +export type ImageDimensions = { + height: number; + width: number; +}; + +export type ImageRotatorMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type ImageRotatorOutputFormat = BrowserImageOutputFormat; + +export type RotateImageOptions = { + outputBaseName: string; + outputFormat: ImageRotatorOutputFormat; + quality?: number; + transform: ImageRotatorTransform; +}; + +export type ImageRotatorResult = { + bytes: ArrayBuffer; + fileName: string; + format: ImageRotatorOutputFormat; + height: number; + mimeType: string; + size: number; + width: number; +}; + +export type ImageRotatorStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-rotator/index.ts b/src/tools/image/image-rotator/index.ts new file mode 100644 index 0000000..44011a7 --- /dev/null +++ b/src/tools/image/image-rotator/index.ts @@ -0,0 +1 @@ +export { ImageRotatorTool } from "./ImageRotatorTool"; From 667a4de0f7bbece668879770d3f3089ee37cc187 Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Fri, 5 Jun 2026 23:44:42 -0300 Subject: [PATCH 02/10] feat(image): add crop image tool --- src/features/tools/data/tools.ts | 104 +++ .../tools/renderers/toolRenderers.tsx | 3 + .../image/image-cropper/ImageCropperTool.tsx | 602 ++++++++++++++++++ src/tools/image/image-cropper/README.md | 18 + .../imageCropper.service.test.ts | 55 ++ .../image-cropper/imageCropper.service.ts | 250 ++++++++ .../image/image-cropper/imageCropper.types.ts | 42 ++ src/tools/image/image-cropper/index.ts | 1 + 8 files changed, 1075 insertions(+) create mode 100644 src/tools/image/image-cropper/ImageCropperTool.tsx create mode 100644 src/tools/image/image-cropper/README.md create mode 100644 src/tools/image/image-cropper/imageCropper.service.test.ts create mode 100644 src/tools/image/image-cropper/imageCropper.service.ts create mode 100644 src/tools/image/image-cropper/imageCropper.types.ts create mode 100644 src/tools/image/image-cropper/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 4787685..7c478d0 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -670,6 +670,110 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-cropper", + slug: "recortar-imagen", + slugEn: "crop-image", + name: { es: "Recortar imagen", en: "Crop image" }, + description: { + es: "Recorta una imagen definiendo el area exacta.", + en: "Crop an image by defining the exact area.", + }, + category: "image", + tags: { + es: ["imagen", "recortar", "crop", "png", "jpg", "webp"], + en: ["image", "crop", "trim", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Recortar imagen online gratis", + description: + "Recorta una imagen PNG, JPG o WebP definiendo X, Y, ancho y alto en tu navegador. Sin cuenta y sin subir archivos a Modulaq.", + }, + en: { + title: "Crop image online free", + description: + "Crop a PNG, JPG or WebP image by setting X, Y, width and height in your browser. No account and no uploads to Modulaq.", + }, + }, + doc: { + es: { + summary: + "Recortar imagen permite definir un area exacta por valores numericos y exportar el recorte localmente desde el navegador.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa el tipo, peso y dimensiones originales.", + "Define X, Y, ancho y alto. X/Y empiezan desde la esquina superior izquierda.", + "Usa los accesos rapidos si quieres imagen completa, recorte centrado o cuadrado centrado.", + "Elige PNG, JPG o WebP si el navegador lo permite y descarga el resultado.", + ], + useCases: [ + "Recortar una zona exacta de una captura.", + "Preparar una miniatura cuadrada.", + "Extraer una parte de una foto sin subirla a un servidor.", + ], + limits: [ + "JPG no conserva transparencia.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + "Tamano maximo por imagen: 15 MB.", + "El area de recorte no puede salirse de la imagen.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para recortarla.", + commonErrors: [ + "Valores no numericos o negativos.", + "Ancho o alto igual a cero.", + "Area de recorte fuera de los limites de la imagen.", + ], + technicalNotes: [ + "La exportacion usa canvas en el navegador.", + "El recorte final tiene las dimensiones indicadas por ancho y alto.", + "La salida WebP depende del soporte real del navegador.", + ], + }, + en: { + summary: + "Crop image lets you define an exact area with numeric values and export the crop locally from the browser.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the detected type, size and dimensions.", + "Set X, Y, width and height. X/Y start from the top-left corner.", + "Use quick actions for full image, centered crop or centered square.", + "Choose PNG, JPG or WebP if the browser supports it and download the result.", + ], + useCases: [ + "Crop an exact area from a screenshot.", + "Prepare a square thumbnail.", + "Extract part of a photo without uploading it to a server.", + ], + limits: [ + "JPG does not preserve transparency.", + "WebP appears if your browser can export it correctly.", + "Maximum image size: 15 MB.", + "The crop area cannot go outside the image.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to crop it.", + commonErrors: [ + "Non-numeric or negative values.", + "Width or height equal to zero.", + "Crop area outside the image bounds.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "The final crop has the width and height you set.", + "WebP output depends on actual browser support.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index ea73043..40ceeb6 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -17,6 +17,9 @@ const toolRenderers: Partial> = { "image-converter": lazy(() => import("../../../tools/image/image-converter").then((module) => ({ default: module.ImageConverterTool })), ), + "image-cropper": lazy(() => + import("../../../tools/image/image-cropper").then((module) => ({ default: module.ImageCropperTool })), + ), "image-rotator": lazy(() => import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), ), diff --git a/src/tools/image/image-cropper/ImageCropperTool.tsx b/src/tools/image/image-cropper/ImageCropperTool.tsx new file mode 100644 index 0000000..3d7e382 --- /dev/null +++ b/src/tools/image/image-cropper/ImageCropperTool.tsx @@ -0,0 +1,602 @@ +import { Crop, Download, FileImage, Loader2, Maximize2, RotateCcw, Scan, Upload } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + buildCroppedImageFileName, + createCenteredCropRect, + createCenteredSquareCropRect, + createFullImageCropRect, + cropImageFile, + defaultOutputBaseName, + formatFileSize, + getCroppedImageOutputBaseName, + getCropOutputDimensions, + getImageFormatLabel, + getImageMimeLabel, + readImageMetadata, + validateCropRect, +} from "./imageCropper.service"; +import type { + ImageCropperMetadata, + ImageCropperOutputFormat, + ImageCropperResult, + ImageCropperStatus, + ImageCropRect, +} from "./imageCropper.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type DownloadableResult = ImageCropperResult & { + url: string; +}; + +const baseOutputFormats: ImageCropperOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const copy = { + es: { + centerCrop: "Centrar recorte", + cropHelp: "Los valores X/Y empiezan desde la esquina superior izquierda.", + cropTitle: "Area de recorte", + downloadReady: "Imagen recortada lista", + fullImage: "Imagen completa", + invalidPreview: "Ajusta el recorte para ver la vista previa.", + jpgTransparency: "JPG no conserva transparencia.", + outputIntro: "El resultado se genera desde un canvas local.", + outputTitle: "Vista previa y salida", + previewAlt: "Vista previa del recorte", + processing: "Recortando imagen...", + sourceIntro: "Recorta una imagen definiendo el area exacta.", + sourceTitle: "Archivo y recorte", + squareCrop: "Cuadrado centrado", + webpHint: "WebP aparece si tu navegador permite exportarlo correctamente.", + }, + en: { + centerCrop: "Center crop", + cropHelp: "X/Y values start from the top-left corner.", + cropTitle: "Crop area", + downloadReady: "Cropped image ready", + fullImage: "Full image", + invalidPreview: "Adjust the crop to see the preview.", + jpgTransparency: "JPG does not preserve transparency.", + outputIntro: "The result is generated from a local canvas.", + outputTitle: "Preview and output", + previewAlt: "Crop preview", + processing: "Cropping image...", + sourceIntro: "Crop an image by defining the exact area.", + sourceTitle: "File and crop", + squareCrop: "Centered square", + webpHint: "WebP appears if your browser can export it correctly.", + }, +} as const; + +function parseNumberInput(value: string) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : Number.NaN; +} + +function stringifyCropValue(value: number) { + return String(Math.max(0, Math.round(value))); +} + +function getCropPreviewImageStyle(metadata: ImageCropperMetadata, cropRect: ImageCropRect) { + return { + maxWidth: "none", + transform: `translate(-${(cropRect.x / metadata.width) * 100}%, -${(cropRect.y / metadata.height) * 100}%)`, + width: `${(metadata.width / cropRect.width) * 100}%`, + }; +} + +export function ImageCropperTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const resultUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [xInput, setXInput] = useState("0"); + const [yInput, setYInput] = useState("0"); + const [widthInput, setWidthInput] = useState(""); + const [heightInput, setHeightInput] = useState(""); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const cropRect = useMemo( + () => ({ + height: parseNumberInput(heightInput), + width: parseNumberInput(widthInput), + x: parseNumberInput(xInput), + y: parseNumberInput(yInput), + }), + [heightInput, widthInput, xInput, yInput], + ); + const cropError = metadata ? validateCropRect(cropRect, metadata) : null; + const outputDimensions = metadata && !cropError ? getCropOutputDimensions(cropRect) : null; + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const finalOutputFileName = buildCroppedImageFileName( + outputFileName, + outputFormat, + metadata ? getCroppedImageOutputBaseName(metadata.fileName) : defaultOutputBaseName, + ); + const transparencyWarning = outputFormat === "jpeg" && metadata?.mimeType !== "image/jpeg" ? labels.jpgTransparency : null; + const canCrop = Boolean(file && metadata) && !cropError && status !== "reading" && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const applyCropRect = (nextCropRect: ImageCropRect) => { + setXInput(stringifyCropValue(nextCropRect.x)); + setYInput(stringifyCropValue(nextCropRect.y)); + setWidthInput(stringifyCropValue(nextCropRect.width)); + setHeightInput(stringifyCropValue(nextCropRect.height)); + resetFeedback(); + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setError(null); + clearResult(); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + setXInput("0"); + setYInput("0"); + setWidthInput(String(nextMetadata.width)); + setHeightInput(String(nextMetadata.height)); + if (!hasCustomOutputFileName) { + setOutputFileName(getCroppedImageOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + clearResult(); + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setXInput("0"); + setYInput("0"); + setWidthInput(""); + setHeightInput(""); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const cropImage = async () => { + if (!file || !canCrop) return; + setStatus("processing"); + setError(null); + clearResult(); + try { + const nextResult = await cropImageFile(file, { + cropRect, + outputBaseName: outputFileName, + outputFormat, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo exportar la imagen."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+
+

{labels.cropTitle}

+

{labels.cropHelp}

+
+
+ + + + +
+
+ + + +
+ {cropError ? ( +

+ {cropError} +

+ ) : null} +
+ + {status === "reading" ? ( +

+ + {t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {previewUrl && metadata && !cropError ? ( +
+ {labels.previewAlt} +
+ ) : ( +
+ +

{metadata ? labels.invalidPreview : t("imageUi.selectImage")}

+
+ )} +
+ +
+

{t("imageUi.finalDimensions")}

+

+ {outputDimensions ? `${outputDimensions.width} x ${outputDimensions.height}px` : "-"} +

+
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ +

{labels.webpHint}

+ + {shouldShowQuality ? ( + + ) : null} + + {transparencyWarning ? ( +

+ {transparencyWarning} +

+ ) : null} + + + + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{t("imageUi.finalDimensions")}
+
+ {result.width} x {result.height}px +
+
+
+
{t("imageUi.finalFormat")}
+
{getImageFormatLabel(result.format)}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-cropper/README.md b/src/tools/image/image-cropper/README.md new file mode 100644 index 0000000..a8e3863 --- /dev/null +++ b/src/tools/image/image-cropper/README.md @@ -0,0 +1,18 @@ +# Recortar imagen + +Herramienta frontend-only para recortar una imagen con valores numericos. + +## Soporte + +- Entrada: PNG, JPG/JPEG y WebP. +- Recorte por X, Y, ancho y alto. +- Accesos rapidos: imagen completa, centrar recorte actual y cuadrado centrado. +- Salida: PNG, JPG y WebP cuando el navegador puede exportarlo correctamente. + +## Privacidad + +El archivo se procesa en el navegador. No usa backend ni sube archivos. + +## Notas tecnicas + +Reutiliza `src/shared/utils/imageFiles.ts` para validacion de imagen, deteccion de WebP, nombres de salida, calidad, carga browser y exportacion de canvas. diff --git a/src/tools/image/image-cropper/imageCropper.service.test.ts b/src/tools/image/image-cropper/imageCropper.service.test.ts new file mode 100644 index 0000000..3358454 --- /dev/null +++ b/src/tools/image/image-cropper/imageCropper.service.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + createCenteredCropRect, + createCenteredSquareCropRect, + getCropOutputDimensions, + validateCropRect, +} from "./imageCropper.service"; + +const imageDimensions = { width: 1200, height: 800 }; + +describe("imageCropper.service", () => { + it("accepts a valid crop rectangle", () => { + expect(validateCropRect({ x: 100, y: 50, width: 400, height: 300 }, imageDimensions)).toBeNull(); + }); + + it("rejects negative X or Y", () => { + expect(validateCropRect({ x: -1, y: 0, width: 400, height: 300 }, imageDimensions)).toMatch(/negativos/); + expect(validateCropRect({ x: 0, y: -1, width: 400, height: 300 }, imageDimensions)).toMatch(/negativos/); + }); + + it("rejects zero width or height", () => { + expect(validateCropRect({ x: 0, y: 0, width: 0, height: 300 }, imageDimensions)).toMatch(/mayores que cero/); + expect(validateCropRect({ x: 0, y: 0, width: 400, height: 0 }, imageDimensions)).toMatch(/mayores que cero/); + }); + + it("rejects crop rectangles outside image bounds", () => { + expect(validateCropRect({ x: 900, y: 0, width: 400, height: 300 }, imageDimensions)).toMatch(/salirse/); + expect(validateCropRect({ x: 0, y: 700, width: 400, height: 300 }, imageDimensions)).toMatch(/salirse/); + }); + + it("creates a centered square crop", () => { + expect(createCenteredSquareCropRect(imageDimensions)).toEqual({ + height: 800, + width: 800, + x: 200, + y: 0, + }); + }); + + it("creates a centered crop with custom dimensions", () => { + expect(createCenteredCropRect(imageDimensions, { width: 600, height: 400 })).toEqual({ + height: 400, + width: 600, + x: 300, + y: 200, + }); + }); + + it("returns final dimensions from the crop rectangle", () => { + expect(getCropOutputDimensions({ x: 10, y: 20, width: 320, height: 240 })).toEqual({ + height: 240, + width: 320, + }); + }); +}); diff --git a/src/tools/image/image-cropper/imageCropper.service.ts b/src/tools/image/image-cropper/imageCropper.service.ts new file mode 100644 index 0000000..360330a --- /dev/null +++ b/src/tools/image/image-cropper/imageCropper.service.ts @@ -0,0 +1,250 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildBrowserImageDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageMimeType, + getBrowserImageOutputMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + CropImageOptions, + ImageCropperMetadata, + ImageCropperOutputFormat, + ImageCropperResult, + ImageCropRect, + ImageDimensions, +} from "./imageCropper.types"; + +export const defaultOutputBaseName = "imagen-recortada"; +export const maxCropPixels = 64_000_000; + +export { formatFileSize }; + +export function isCroppableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageFormatLabel(format: ImageCropperOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function getCroppedImageOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-recortada`; +} + +export function buildCroppedImageFileName( + baseName: string, + outputFormat: ImageCropperOutputFormat, + fallbackBaseName = defaultOutputBaseName, +) { + return buildBrowserImageDownloadFileName(baseName, outputFormat, fallbackBaseName); +} + +export function getCropOutputDimensions(cropRect: ImageCropRect): ImageDimensions { + return { + height: cropRect.height, + width: cropRect.width, + }; +} + +function hasOnlyFiniteNumbers(cropRect: ImageCropRect) { + return [cropRect.x, cropRect.y, cropRect.width, cropRect.height].every(Number.isFinite); +} + +function hasOnlyIntegers(cropRect: ImageCropRect) { + return [cropRect.x, cropRect.y, cropRect.width, cropRect.height].every(Number.isInteger); +} + +export function validateCropRect(cropRect: ImageCropRect, imageDimensions: ImageDimensions) { + if (!hasOnlyFiniteNumbers(cropRect)) { + return "Usa valores numericos validos."; + } + + if (!hasOnlyIntegers(cropRect)) { + return "Usa valores enteros en pixeles."; + } + + if (cropRect.x < 0 || cropRect.y < 0) { + return "X e Y no pueden ser negativos."; + } + + if (cropRect.width <= 0 || cropRect.height <= 0) { + return "El ancho y el alto deben ser mayores que cero."; + } + + if (cropRect.x + cropRect.width > imageDimensions.width || cropRect.y + cropRect.height > imageDimensions.height) { + return "El area de recorte no puede salirse de la imagen."; + } + + if (cropRect.width * cropRect.height > maxCropPixels) { + return "El recorte supera el limite de 64 megapixeles."; + } + + return null; +} + +export function createFullImageCropRect(imageDimensions: ImageDimensions): ImageCropRect { + return { + height: imageDimensions.height, + width: imageDimensions.width, + x: 0, + y: 0, + }; +} + +export function createCenteredCropRect( + imageDimensions: ImageDimensions, + cropDimensions: ImageDimensions, +): ImageCropRect { + const width = Math.min(Math.max(1, Math.round(cropDimensions.width)), imageDimensions.width); + const height = Math.min(Math.max(1, Math.round(cropDimensions.height)), imageDimensions.height); + + return { + height, + width, + x: Math.floor((imageDimensions.width - width) / 2), + y: Math.floor((imageDimensions.height - height) / 2), + }; +} + +export function createCenteredSquareCropRect(imageDimensions: ImageDimensions): ImageCropRect { + const side = Math.min(imageDimensions.width, imageDimensions.height); + return createCenteredCropRect(imageDimensions, { height: side, width: side }); +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +function ensureOutputFormatSupported(outputFormat: ImageCropperOutputFormat) { + if (!canExportBrowserImageFormat(outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(outputFormat)}.`); + } +} + +function drawCroppedImage( + canvas: HTMLCanvasElement, + image: HTMLImageElement, + cropRect: ImageCropRect, + outputFormat: ImageCropperOutputFormat, +) { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar la imagen de salida."); + } + + if (outputFormat === "jpeg") { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + context.drawImage( + image, + cropRect.x, + cropRect.y, + cropRect.width, + cropRect.height, + 0, + 0, + cropRect.width, + cropRect.height, + ); +} + +export async function cropImageFile( + file: File, + { cropRect, outputBaseName, outputFormat, quality }: CropImageOptions, +): Promise { + if (!isCroppableImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + ensureOutputFormatSupported(outputFormat); + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const validationError = validateCropRect(cropRect, { + height: image.naturalHeight, + width: image.naturalWidth, + }); + + if (validationError) { + throw new Error(validationError); + } + + const canvas = document.createElement("canvas"); + canvas.width = cropRect.width; + canvas.height = cropRect.height; + drawCroppedImage(canvas, image, cropRect, outputFormat); + + const mimeType = getBrowserImageOutputMimeType(outputFormat); + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar la imagen.", + mimeType, + quality, + }); + + return { + bytes: result.bytes, + fileName: buildCroppedImageFileName(outputBaseName, outputFormat, getCroppedImageOutputBaseName(file.name)), + format: outputFormat, + height: result.height, + mimeType, + size: result.size, + width: result.width, + }; +} diff --git a/src/tools/image/image-cropper/imageCropper.types.ts b/src/tools/image/image-cropper/imageCropper.types.ts new file mode 100644 index 0000000..d47c41c --- /dev/null +++ b/src/tools/image/image-cropper/imageCropper.types.ts @@ -0,0 +1,42 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImageCropRect = { + height: number; + width: number; + x: number; + y: number; +}; + +export type ImageDimensions = { + height: number; + width: number; +}; + +export type ImageCropperMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type ImageCropperOutputFormat = BrowserImageOutputFormat; + +export type CropImageOptions = { + cropRect: ImageCropRect; + outputBaseName: string; + outputFormat: ImageCropperOutputFormat; + quality?: number; +}; + +export type ImageCropperResult = { + bytes: ArrayBuffer; + fileName: string; + format: ImageCropperOutputFormat; + height: number; + mimeType: string; + size: number; + width: number; +}; + +export type ImageCropperStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-cropper/index.ts b/src/tools/image/image-cropper/index.ts new file mode 100644 index 0000000..fbd33ce --- /dev/null +++ b/src/tools/image/image-cropper/index.ts @@ -0,0 +1 @@ +export { ImageCropperTool } from "./ImageCropperTool"; From e172df7decbcd98ba27e0ba14dd650df6f248bd9 Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 00:02:16 -0300 Subject: [PATCH 03/10] feat(image): add favicon pack generator --- src/features/tools/data/tools.ts | 102 +++++ .../tools/renderers/toolRenderers.tsx | 3 + .../image-to-favicon/ImageToFaviconTool.tsx | 419 ++++++++++++++++++ src/tools/image/image-to-favicon/README.md | 22 + .../imageToFavicon.service.test.ts | 51 +++ .../imageToFavicon.service.ts | 218 +++++++++ .../image-to-favicon/imageToFavicon.types.ts | 37 ++ src/tools/image/image-to-favicon/index.ts | 1 + 8 files changed, 853 insertions(+) create mode 100644 src/tools/image/image-to-favicon/ImageToFaviconTool.tsx create mode 100644 src/tools/image/image-to-favicon/README.md create mode 100644 src/tools/image/image-to-favicon/imageToFavicon.service.test.ts create mode 100644 src/tools/image/image-to-favicon/imageToFavicon.service.ts create mode 100644 src/tools/image/image-to-favicon/imageToFavicon.types.ts create mode 100644 src/tools/image/image-to-favicon/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 7c478d0..fd2c9bb 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -774,6 +774,108 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-to-favicon", + slug: "imagen-a-favicon", + slugEn: "image-to-favicon", + name: { es: "Imagen a favicon", en: "Image to favicon" }, + description: { + es: "Genera un pack de iconos PNG para favicon, Apple touch icon y PWA.", + en: "Generate a PNG icon pack for favicon, Apple touch icon and PWA.", + }, + category: "image", + tags: { + es: ["imagen", "favicon", "iconos", "png", "pwa"], + en: ["image", "favicon", "icons", "png", "pwa"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Imagen a favicon online gratis", + description: + "Genera un ZIP con iconos PNG para favicon, Apple touch icon y PWA desde tu navegador. Sin cuenta y sin subir archivos a Modulaq.", + }, + en: { + title: "Image to favicon online free", + description: + "Generate a ZIP with PNG icons for favicon, Apple touch icon and PWA from your browser. No account and no uploads to Modulaq.", + }, + }, + doc: { + es: { + summary: + "Imagen a favicon crea un pack ZIP con iconos PNG en tamanos comunes para web y PWA. No genera un archivo .ico clasico.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa el tipo, peso y dimensiones originales.", + "La herramienta prepara iconos PNG cuadrados en 16, 32, 48, 180, 192 y 512 px.", + "Opcional: ajusta el nombre del ZIP.", + "Genera el pack y descarga el ZIP.", + ], + useCases: [ + "Preparar favicons PNG para un sitio web.", + "Crear apple-touch-icon.png desde una imagen de marca.", + "Generar iconos base para el manifest de una PWA.", + ], + limits: [ + "No genera archivo .ico clasico.", + "Usa ajuste cover centrado: una imagen rectangular puede recortarse para llenar el cuadrado.", + "Tamano maximo por imagen: 15 MB.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para crear favicons.", + commonErrors: [ + "Archivo que no es una imagen PNG, JPG o WebP.", + "Imagen danada o que el navegador no puede decodificar.", + "Imagen con dimensiones invalidas o demasiado grande.", + ], + technicalNotes: [ + "La exportacion usa canvas en el navegador.", + "Los iconos se exportan como PNG y se empaquetan con jszip.", + "El ZIP incluye un README.txt con ejemplos HTML.", + ], + }, + en: { + summary: + "Image to favicon creates a ZIP pack with PNG icons in common web and PWA sizes. It does not generate a classic .ico file.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the detected type, size and dimensions.", + "The tool prepares square PNG icons at 16, 32, 48, 180, 192 and 512 px.", + "Optionally adjust the ZIP name.", + "Generate the pack and download the ZIP.", + ], + useCases: [ + "Prepare PNG favicons for a website.", + "Create apple-touch-icon.png from a brand image.", + "Generate base icons for a PWA manifest.", + ], + limits: [ + "It does not generate a classic .ico file.", + "It uses centered cover fit: a rectangular image may be cropped to fill the square.", + "Maximum image size: 15 MB.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to create favicons.", + commonErrors: [ + "A file that is not PNG, JPG or WebP.", + "A damaged image or one the browser cannot decode.", + "An image with invalid dimensions or too many pixels.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "Icons are exported as PNG and bundled with jszip.", + "The ZIP includes a README.txt with HTML examples.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index 40ceeb6..e47d31f 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -20,6 +20,9 @@ const toolRenderers: Partial> = { "image-cropper": lazy(() => import("../../../tools/image/image-cropper").then((module) => ({ default: module.ImageCropperTool })), ), + "image-to-favicon": lazy(() => + import("../../../tools/image/image-to-favicon").then((module) => ({ default: module.ImageToFaviconTool })), + ), "image-rotator": lazy(() => import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), ), diff --git a/src/tools/image/image-to-favicon/ImageToFaviconTool.tsx b/src/tools/image/image-to-favicon/ImageToFaviconTool.tsx new file mode 100644 index 0000000..8af088c --- /dev/null +++ b/src/tools/image/image-to-favicon/ImageToFaviconTool.tsx @@ -0,0 +1,419 @@ +import { Download, FileArchive, FileImage, Loader2, Package, RotateCcw, Upload } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + buildFaviconZipFileName, + defaultOutputBaseName, + formatFileSize, + generateFaviconPack, + getFaviconIconSpecs, + getFaviconOutputBaseName, + getImageMimeLabel, + readImageMetadata, +} from "./imageToFavicon.service"; +import type { ImageToFaviconMetadata, ImageToFaviconResult, ImageToFaviconStatus } from "./imageToFavicon.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; +const iconSpecs = getFaviconIconSpecs(); + +type DownloadableResult = ImageToFaviconResult & { + url: string; +}; + +const copy = { + es: { + downloadPack: "Descargar ZIP", + downloadReady: "Pack de favicon listo", + outputIntro: "No genera archivo .ico clasico; descarga iconos PNG listos para usar.", + outputName: "Nombre del ZIP", + outputTitle: "Pack generado", + previewAlt: "Vista previa del icono", + processing: "Generando pack...", + readmeIncluded: "Incluye README.txt con ejemplos HTML.", + sourceIntro: "Genera un pack de iconos PNG para favicon, Apple touch icon y PWA.", + sourceTitle: "Imagen de origen", + transparencyHint: "Los PNG conservan transparencia cuando la imagen de origen la tiene.", + webLocal: "Todo se procesa localmente en tu navegador.", + willPrepareAs: "Se preparara como {{name}}", + zipContents: "Archivos dentro del ZIP", + }, + en: { + downloadPack: "Download ZIP", + downloadReady: "Favicon pack ready", + outputIntro: "It does not create a classic .ico file; it downloads ready-to-use PNG icons.", + outputName: "ZIP name", + outputTitle: "Generated pack", + previewAlt: "Icon preview", + processing: "Generating pack...", + readmeIncluded: "Includes README.txt with HTML examples.", + sourceIntro: "Generate a PNG icon pack for favicon, Apple touch icon and PWA.", + sourceTitle: "Source image", + transparencyHint: "PNG output preserves transparency when the source image has it.", + webLocal: "Everything is processed locally in your browser.", + willPrepareAs: "Will prepare as {{name}}", + zipContents: "Files inside the ZIP", + }, +} as const; + +function formatTemplate(template: string, values: Record) { + return Object.entries(values).reduce((result, [key, value]) => result.replace(`{{${key}}}`, value), template); +} + +export function ImageToFaviconTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const resultUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const finalOutputFileName = buildFaviconZipFileName( + outputFileName, + metadata ? getFaviconOutputBaseName(metadata.fileName) : defaultOutputBaseName, + ); + const canGenerate = Boolean(file && metadata) && status !== "reading" && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setError(null); + clearResult(); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + if (!hasCustomOutputFileName) { + setOutputFileName(getFaviconOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + clearResult(); + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const generatePack = async () => { + if (!file || !canGenerate) return; + setStatus("processing"); + setError(null); + clearResult(); + + try { + const nextResult = await generateFaviconPack(file, { outputBaseName: outputFileName }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo generar el pack."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+

{labels.outputIntro}

+

{labels.webLocal}

+

{labels.transparencyHint}

+
+ + {status === "reading" ? ( +

+ + {t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.readmeIncluded}

+
+ +
+
+ {previewUrl ? ( +
+
+ {labels.previewAlt} +
+

Cover centrado

+
+ ) : ( +
+ +

{t("imageUi.selectImage")}

+
+ )} +
+ +
+

{labels.zipContents}

+
    + {iconSpecs.map((icon) => ( +
  • + {icon.fileName} + + {icon.size} x {icon.size} + +
  • + ))} +
  • + README.txt + HTML +
  • +
+
+ + + + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {result ? ( +
+

{labels.downloadReady}

+
+
+
ZIP
+
{result.fileName}
+
+
+
{language === "en" ? "Icons" : "Iconos"}
+
{result.iconCount}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-to-favicon/README.md b/src/tools/image/image-to-favicon/README.md new file mode 100644 index 0000000..706d5b8 --- /dev/null +++ b/src/tools/image/image-to-favicon/README.md @@ -0,0 +1,22 @@ +# Imagen a favicon + +Herramienta local para generar un pack ZIP de iconos PNG para favicon, Apple touch icon y PWA. + +## Comportamiento + +- Acepta imagenes PNG, JPG/JPEG o WebP que el navegador pueda decodificar. +- Genera PNG cuadrados en 16x16, 32x32, 48x48, 180x180, 192x192 y 512x512. +- Usa ajuste `cover` centrado para que el icono llene el cuadrado. +- Descarga un ZIP con nombres fijos y un `README.txt` con ejemplos HTML. +- No genera archivo `.ico` clasico. +- Todo el procesamiento ocurre en el navegador. + +## Archivos del ZIP + +- `favicon-16x16.png` +- `favicon-32x32.png` +- `favicon-48x48.png` +- `apple-touch-icon.png` +- `icon-192.png` +- `icon-512.png` +- `README.txt` diff --git a/src/tools/image/image-to-favicon/imageToFavicon.service.test.ts b/src/tools/image/image-to-favicon/imageToFavicon.service.test.ts new file mode 100644 index 0000000..b14a655 --- /dev/null +++ b/src/tools/image/image-to-favicon/imageToFavicon.service.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + buildFaviconZipFileName, + createFaviconPackReadme, + getFaviconIconSpecs, + getFaviconOutputBaseName, + validateFaviconSourceDimensions, +} from "./imageToFavicon.service"; + +describe("imageToFavicon.service", () => { + it("returns the generated icon sizes", () => { + expect(getFaviconIconSpecs().map((icon) => icon.size)).toEqual([16, 32, 48, 180, 192, 512]); + }); + + it("returns the expected file names", () => { + expect(getFaviconIconSpecs().map((icon) => icon.fileName)).toEqual([ + "favicon-16x16.png", + "favicon-32x32.png", + "favicon-48x48.png", + "apple-touch-icon.png", + "icon-192.png", + "icon-512.png", + ]); + }); + + it("accepts a valid source size", () => { + expect(validateFaviconSourceDimensions({ width: 1200, height: 800 })).toBeNull(); + }); + + it("rejects invalid source dimensions", () => { + expect(validateFaviconSourceDimensions({ width: 0, height: 800 })).toMatch(/mayores que cero/); + expect(validateFaviconSourceDimensions({ width: 1200, height: -1 })).toMatch(/mayores que cero/); + expect(validateFaviconSourceDimensions({ width: Number.NaN, height: 800 })).toMatch(/validas/); + }); + + it("rejects unreasonably large source dimensions", () => { + expect(validateFaviconSourceDimensions({ width: 10000, height: 10000 })).toMatch(/64 megapixeles/); + }); + + it("builds output names derived from the original file", () => { + expect(getFaviconOutputBaseName("marca.png")).toBe("marca-favicon"); + expect(buildFaviconZipFileName("marca-favicon")).toBe("marca-favicon.zip"); + }); + + it("generates README metadata for the pack", () => { + const readme = createFaviconPackReadme(); + expect(readme).toContain("favicon-32x32.png"); + expect(readme).toContain(" ({ ...icon })); +} + +export function validateFaviconSourceDimensions(imageDimensions: ImageDimensions) { + if (!Number.isFinite(imageDimensions.width) || !Number.isFinite(imageDimensions.height)) { + return "La imagen debe tener dimensiones validas."; + } + + if (!Number.isInteger(imageDimensions.width) || !Number.isInteger(imageDimensions.height)) { + return "La imagen debe tener dimensiones enteras en pixeles."; + } + + if (imageDimensions.width <= 0 || imageDimensions.height <= 0) { + return "La imagen debe tener ancho y alto mayores que cero."; + } + + if (imageDimensions.width * imageDimensions.height > maxFaviconSourcePixels) { + return "La imagen supera el limite de 64 megapixeles."; + } + + return null; +} + +export function createFaviconPackReadme(iconSpecs: readonly FaviconIconSpec[] = faviconIconSpecs) { + const iconList = iconSpecs.map((icon) => `- ${icon.fileName}: ${icon.size}x${icon.size}px`).join("\n"); + + return [ + "Imagen a favicon - Modulaq", + "", + "Este ZIP contiene iconos PNG. No incluye un archivo .ico clasico.", + "", + "Archivos:", + iconList, + "", + "Ejemplos HTML:", + '', + '', + "", + "Manifest/PWA:", + '{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }', + '{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }', + ].join("\n"); +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const validationError = validateFaviconSourceDimensions({ + height: image.naturalHeight, + width: image.naturalWidth, + }); + + if (validationError) { + throw new Error(validationError); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +function drawCoveredSquareImage(canvas: HTMLCanvasElement, image: HTMLImageElement, size: number) { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar el icono."); + } + + canvas.width = size; + canvas.height = size; + context.clearRect(0, 0, size, size); + + const scale = Math.max(size / image.naturalWidth, size / image.naturalHeight); + const drawWidth = image.naturalWidth * scale; + const drawHeight = image.naturalHeight * scale; + const drawX = (size - drawWidth) / 2; + const drawY = (size - drawHeight) / 2; + + context.drawImage(image, drawX, drawY, drawWidth, drawHeight); +} + +async function createFaviconIcon(image: HTMLImageElement, iconSpec: FaviconIconSpec): Promise { + const canvas = document.createElement("canvas"); + drawCoveredSquareImage(canvas, image, iconSpec.size); + + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar un icono PNG.", + mimeType: "image/png", + }); + + return { + ...iconSpec, + byteSize: result.size, + bytes: result.bytes, + }; +} + +export async function generateFaviconPack( + file: File, + { outputBaseName }: ImageToFaviconOptions, +): Promise { + if (!isFaviconSourceImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const validationError = validateFaviconSourceDimensions({ + height: image.naturalHeight, + width: image.naturalWidth, + }); + + if (validationError) { + throw new Error(validationError); + } + + const zip = new JSZip(); + const iconResults = await Promise.all(faviconIconSpecs.map((iconSpec) => createFaviconIcon(image, iconSpec))); + + for (const icon of iconResults) { + zip.file(icon.fileName, icon.bytes); + } + + zip.file("README.txt", createFaviconPackReadme()); + + try { + const bytes = await zip.generateAsync({ type: "arraybuffer" }); + return { + bytes, + fileName: buildFaviconZipFileName(outputBaseName, getFaviconOutputBaseName(file.name)), + iconCount: faviconIconSpecs.length, + icons: iconResults.map(({ bytes: _bytes, ...icon }) => icon), + mimeType: "application/zip", + size: bytes.byteLength, + }; + } catch { + throw new Error("No se pudo preparar la descarga ZIP."); + } +} diff --git a/src/tools/image/image-to-favicon/imageToFavicon.types.ts b/src/tools/image/image-to-favicon/imageToFavicon.types.ts new file mode 100644 index 0000000..ea7effb --- /dev/null +++ b/src/tools/image/image-to-favicon/imageToFavicon.types.ts @@ -0,0 +1,37 @@ +export type FaviconIconSpec = { + fileName: string; + label: string; + size: number; +}; + +export type ImageToFaviconMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type ImageToFaviconResultIcon = FaviconIconSpec & { + byteSize: number; +}; + +export type ImageToFaviconResult = { + bytes: ArrayBuffer; + fileName: string; + iconCount: number; + icons: ImageToFaviconResultIcon[]; + mimeType: "application/zip"; + size: number; +}; + +export type ImageToFaviconOptions = { + outputBaseName: string; +}; + +export type ImageToFaviconStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; + +export type ImageDimensions = { + height: number; + width: number; +}; diff --git a/src/tools/image/image-to-favicon/index.ts b/src/tools/image/image-to-favicon/index.ts new file mode 100644 index 0000000..b569dc1 --- /dev/null +++ b/src/tools/image/image-to-favicon/index.ts @@ -0,0 +1 @@ +export { ImageToFaviconTool } from "./ImageToFaviconTool"; From 53b77c5fe5dafa994d3c9c94163eb65a6cc5f16e Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 00:31:40 -0300 Subject: [PATCH 04/10] feat(image): add join images tool --- src/features/tools/data/tools.ts | 106 +++ .../tools/renderers/toolRenderers.tsx | 3 + .../image/image-joiner/ImageJoinerTool.tsx | 764 ++++++++++++++++++ src/tools/image/image-joiner/README.md | 18 + .../image-joiner/imageJoiner.service.test.ts | 74 ++ .../image/image-joiner/imageJoiner.service.ts | 358 ++++++++ .../image/image-joiner/imageJoiner.types.ts | 63 ++ src/tools/image/image-joiner/index.ts | 1 + 8 files changed, 1387 insertions(+) create mode 100644 src/tools/image/image-joiner/ImageJoinerTool.tsx create mode 100644 src/tools/image/image-joiner/README.md create mode 100644 src/tools/image/image-joiner/imageJoiner.service.test.ts create mode 100644 src/tools/image/image-joiner/imageJoiner.service.ts create mode 100644 src/tools/image/image-joiner/imageJoiner.types.ts create mode 100644 src/tools/image/image-joiner/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index fd2c9bb..1a7d7aa 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -876,6 +876,112 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-joiner", + slug: "unir-imagenes", + slugEn: "join-images", + name: { es: "Unir imágenes", en: "Join images" }, + description: { + es: "Une varias imágenes en una sola imagen vertical, horizontal o en cuadrícula.", + en: "Join multiple images into one vertical, horizontal or grid image.", + }, + category: "image", + tags: { + es: ["imagen", "unir", "combinar", "cuadricula", "png", "jpg", "webp"], + en: ["image", "join", "combine", "grid", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Unir imágenes online gratis", + description: + "Une imágenes PNG, JPG o WebP en una sola imagen vertical, horizontal o en cuadrícula desde tu navegador. Sin subir archivos a Modulaq.", + }, + en: { + title: "Join images online free", + description: + "Join PNG, JPG or WebP images into one vertical, horizontal or grid image from your browser. No uploads to Modulaq.", + }, + }, + doc: { + es: { + summary: + "Unir imágenes combina varias imágenes locales en un solo canvas con modo vertical, horizontal o cuadrícula.", + howTo: [ + "Agrega dos o más imágenes PNG, JPG/JPEG o WebP.", + "Revisa nombre, tipo, peso y dimensiones de cada imagen.", + "Ordena la lista con subir, bajar o eliminar.", + "Elige modo vertical, horizontal o cuadrícula. En cuadrícula, define columnas.", + "Ajusta separación, padding, color de fondo y formato de salida.", + "Une las imágenes y descarga el resultado.", + ], + useCases: [ + "Crear una tira vertical de capturas.", + "Poner imágenes lado a lado.", + "Armar una cuadrícula simple sin subir archivos.", + ], + limits: [ + "JPG no conserva transparencia.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + "Hasta 30 imágenes, 15 MB por imagen y 100 MB en total.", + "Las imágenes se dibujan en sus dimensiones originales.", + ], + privacy: + "El procesamiento de estas imágenes ocurre en tu navegador; no las subimos a Modulaq para unirlas.", + commonErrors: [ + "Archivo que no es una imagen PNG, JPG o WebP.", + "Imagen dañada o que el navegador no puede decodificar.", + "Canvas final demasiado grande para procesarlo localmente.", + ], + technicalNotes: [ + "La exportación usa canvas en el navegador.", + "Vertical y horizontal centran cada imagen en el eje secundario.", + "La cuadrícula usa celdas uniformes y calcula filas automáticamente.", + ], + }, + en: { + summary: + "Join images combines multiple local images into one canvas with vertical, horizontal or grid mode.", + howTo: [ + "Add two or more PNG, JPG/JPEG or WebP images.", + "Review each image name, type, size and dimensions.", + "Order the list with move up, move down or remove.", + "Choose vertical, horizontal or grid mode. In grid mode, set columns.", + "Adjust spacing, padding, background color and output format.", + "Join the images and download the result.", + ], + useCases: [ + "Create a vertical strip of screenshots.", + "Place images side by side.", + "Build a simple grid without uploading files.", + ], + limits: [ + "JPG does not preserve transparency.", + "WebP appears if your browser can export it correctly.", + "Up to 30 images, 15 MB each and 100 MB total.", + "Images are drawn at their original dimensions.", + ], + privacy: + "Processing happens in your browser; we don't upload these images to Modulaq to join them.", + commonErrors: [ + "A file that is not PNG, JPG or WebP.", + "A damaged image or one the browser cannot decode.", + "Final canvas too large to process locally.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "Vertical and horizontal modes center each image on the secondary axis.", + "Grid mode uses uniform cells and calculates rows automatically.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index e47d31f..237ee1a 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -23,6 +23,9 @@ const toolRenderers: Partial> = { "image-to-favicon": lazy(() => import("../../../tools/image/image-to-favicon").then((module) => ({ default: module.ImageToFaviconTool })), ), + "image-joiner": lazy(() => + import("../../../tools/image/image-joiner").then((module) => ({ default: module.ImageJoinerTool })), + ), "image-rotator": lazy(() => import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), ), diff --git a/src/tools/image/image-joiner/ImageJoinerTool.tsx b/src/tools/image/image-joiner/ImageJoinerTool.tsx new file mode 100644 index 0000000..14fa3ca --- /dev/null +++ b/src/tools/image/image-joiner/ImageJoinerTool.tsx @@ -0,0 +1,764 @@ +import { + ArrowDown, + ArrowUp, + Columns3, + Download, + FileImage, + Grid3X3, + Images, + Loader2, + RotateCcw, + Rows3, + Trash2, + Upload, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { + fileProcessingLimits, + getImageFileSizeLimitError, + getTotalImageSizeLimitError, +} from "../../../shared/utils/fileProcessingLimits"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + buildJoinedImageFileName, + calculateImageJoinerLayout, + defaultOutputBaseName, + formatFileSize, + getImageFormatLabel, + getImageMimeLabel, + getJoinedImageOutputBaseName, + isJoinableImageFile, + joinImageFiles, + readImageMetadata, + validateImageJoinerOptions, +} from "./imageJoiner.service"; +import type { + ImageJoinerLayoutOptions, + ImageJoinerMetadata, + ImageJoinerMode, + ImageJoinerOutputFormat, + ImageJoinerResult, + ImageJoinerSource, + ImageJoinerStatus, +} from "./imageJoiner.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type ImageJoinerItem = { + file: File; + metadata: ImageJoinerMetadata; + previewUrl: string; +}; + +type DownloadableResult = ImageJoinerResult & { + url: string; +}; + +const baseOutputFormats: ImageJoinerOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const copy = { + es: { + addImages: "Agregar imagenes", + background: "Color de fondo", + columns: "Columnas", + downloadReady: "Imagen unida lista", + emptyList: "Agrega al menos dos imagenes para preparar la union.", + grid: "Cuadricula", + horizontal: "Horizontal", + jpgTransparency: "JPG no conserva transparencia.", + layoutTitle: "Orden y composicion", + mode: "Modo de union", + outputIntro: "Todo se procesa localmente en tu navegador.", + outputTitle: "Vista previa y salida", + padding: "Padding exterior", + previewAlt: "Vista previa de imagenes unidas", + processing: "Uniendo imagenes...", + remove: "Eliminar", + sourceIntro: "Une varias imagenes en una sola imagen vertical, horizontal o en cuadricula.", + sourceTitle: "Imagenes", + spacing: "Separacion", + vertical: "Vertical", + webpHint: "WebP aparece si tu navegador permite exportarlo correctamente.", + }, + en: { + addImages: "Add images", + background: "Background color", + columns: "Columns", + downloadReady: "Joined image ready", + emptyList: "Add at least two images to prepare the join.", + grid: "Grid", + horizontal: "Horizontal", + jpgTransparency: "JPG does not preserve transparency.", + layoutTitle: "Order and composition", + mode: "Join mode", + outputIntro: "Everything is processed locally in your browser.", + outputTitle: "Preview and output", + padding: "Outer padding", + previewAlt: "Joined images preview", + processing: "Joining images...", + remove: "Remove", + sourceIntro: "Join multiple images into one vertical, horizontal or grid image.", + sourceTitle: "Images", + spacing: "Spacing", + vertical: "Vertical", + webpHint: "WebP appears if your browser can export it correctly.", + }, +} as const; + +const modeConfig = { + vertical: { icon: Rows3, key: "vertical" }, + horizontal: { icon: Columns3, key: "horizontal" }, + grid: { icon: Grid3X3, key: "grid" }, +} as const satisfies Record; + +function parseNumberInput(value: string) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : Number.NaN; +} + +function stringifyNumber(value: number) { + return String(Math.max(0, Math.round(value))); +} + +function createEntryId(file: File, index: number) { + return `${Date.now()}-${index}-${file.name}`; +} + +function getPreviewImageStyle(position: { height: number; width: number; x: number; y: number }, layoutWidth: number, layoutHeight: number) { + return { + height: `${(position.height / layoutHeight) * 100}%`, + left: `${(position.x / layoutWidth) * 100}%`, + top: `${(position.y / layoutHeight) * 100}%`, + width: `${(position.width / layoutWidth) * 100}%`, + }; +} + +export function ImageJoinerTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const resultUrlRef = useRef(null); + const itemsRef = useRef([]); + const [items, setItems] = useState([]); + const [mode, setMode] = useState("vertical"); + const [columnsInput, setColumnsInput] = useState("2"); + const [spacingInput, setSpacingInput] = useState("0"); + const [paddingInput, setPaddingInput] = useState("0"); + const [backgroundColor, setBackgroundColor] = useState("#ffffff"); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + itemsRef.current = items; + }, [items]); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + for (const item of itemsRef.current) { + URL.revokeObjectURL(item.previewUrl); + } + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const sources = useMemo( + () => + items.map((item) => ({ + height: item.metadata.height, + id: item.metadata.id, + width: item.metadata.width, + })), + [items], + ); + const layoutOptions = useMemo( + () => ({ + backgroundColor, + columns: parseNumberInput(columnsInput), + mode, + padding: parseNumberInput(paddingInput), + spacing: parseNumberInput(spacingInput), + }), + [backgroundColor, columnsInput, mode, paddingInput, spacingInput], + ); + const layoutError = validateImageJoinerOptions(sources, layoutOptions); + const layout = !layoutError ? calculateImageJoinerLayout(sources, layoutOptions) : null; + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const fallbackBaseName = items[0] ? getJoinedImageOutputBaseName(items[0].metadata.fileName) : defaultOutputBaseName; + const finalOutputFileName = buildJoinedImageFileName(outputFileName, outputFormat, fallbackBaseName); + const transparencyWarning = outputFormat === "jpeg" ? labels.jpgTransparency : null; + const canJoin = items.length >= 2 && !layoutError && status !== "reading" && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(items.length > 0 ? "ready" : "idle"); + } + }; + + const processFiles = async (fileList: FileList | File[]) => { + const nextFiles = Array.from(fileList); + if (nextFiles.length === 0) return; + + setStatus("reading"); + setError(null); + clearResult(); + + const totalCount = items.length + nextFiles.length; + if (totalCount > fileProcessingLimits.maxImageFileCount) { + setStatus(items.length > 0 ? "ready" : "error"); + setError("Puedes cargar hasta 30 imagenes."); + return; + } + + const nextTotalSize = items.reduce((total, item) => total + item.file.size, 0) + nextFiles.reduce((total, file) => total + file.size, 0); + const totalSizeError = getTotalImageSizeLimitError(nextTotalSize); + if (totalSizeError) { + setStatus(items.length > 0 ? "ready" : "error"); + setError(totalSizeError); + return; + } + + const preparedItems: ImageJoinerItem[] = []; + try { + for (const [index, file] of nextFiles.entries()) { + const fileLimitError = getImageFileSizeLimitError(file); + if (fileLimitError) { + throw new Error(fileLimitError); + } + + if (!isJoinableImageFile(file)) { + throw new Error("Selecciona imagenes PNG, JPG o WebP validas."); + } + + const id = createEntryId(file, items.length + index); + const metadata = await readImageMetadata(file, id); + preparedItems.push({ + file, + metadata, + previewUrl: URL.createObjectURL(file), + }); + } + + setItems((currentItems) => { + const nextItems = [...currentItems, ...preparedItems]; + if (!hasCustomOutputFileName && nextItems[0]) { + setOutputFileName(getJoinedImageOutputBaseName(nextItems[0].metadata.fileName)); + } + return nextItems; + }); + setStatus("ready"); + } catch (nextError) { + for (const item of preparedItems) { + URL.revokeObjectURL(item.previewUrl); + } + setStatus(items.length > 0 ? "ready" : "error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const moveItem = (index: number, direction: -1 | 1) => { + setItems((currentItems) => { + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= currentItems.length) return currentItems; + const nextItems = [...currentItems]; + const [item] = nextItems.splice(index, 1); + nextItems.splice(nextIndex, 0, item); + return nextItems; + }); + resetFeedback(); + }; + + const removeItem = (index: number) => { + setItems((currentItems) => { + const nextItems = [...currentItems]; + const [removedItem] = nextItems.splice(index, 1); + if (removedItem) { + URL.revokeObjectURL(removedItem.previewUrl); + } + return nextItems; + }); + resetFeedback(); + }; + + const clearSelection = () => { + for (const item of items) { + URL.revokeObjectURL(item.previewUrl); + } + clearResult(); + setItems([]); + setMode("vertical"); + setColumnsInput("2"); + setSpacingInput("0"); + setPaddingInput("0"); + setBackgroundColor("#ffffff"); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const joinImages = async () => { + if (!canJoin) return; + setStatus("processing"); + setError(null); + clearResult(); + + try { + const nextResult = await joinImageFiles( + items.map((item) => item.file), + { + ...layoutOptions, + outputBaseName: outputFileName, + outputFormat, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + }, + ); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo unir las imagenes."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + if (event.target.files) void processFiles(event.target.files); + event.target.value = ""; + }} + /> + +
+ {items.length === 0 ? ( +
+ +

{labels.emptyList}

+
+ ) : ( + items.map((item, index) => ( +
+ +
+
+
{t("imageUi.fileName")}
+
{item.metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(item.metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(item.metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {item.metadata.width} x {item.metadata.height}px +
+
+
+
+
+ + + +
+
+ )) + )} +
+ +
+

{labels.layoutTitle}

+
+ {(Object.keys(modeConfig) as ImageJoinerMode[]).map((nextMode) => { + const Icon = modeConfig[nextMode].icon; + return ( + + ); + })} +
+ +
+ {mode === "grid" ? ( + + ) : null} + + + +
+ + {layoutError ? ( +

+ {layoutError} +

+ ) : null} +
+ + {status === "reading" ? ( +

+ + {t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {layout ? ( +
+ {layout.positions.map((position) => { + const item = items.find((nextItem) => nextItem.metadata.id === position.id); + if (!item) return null; + + return ( + + ); + })} +
+ ) : ( +
+ +

{items.length > 0 ? labels.emptyList : labels.addImages}

+
+ )} +
+ +
+

{t("imageUi.finalDimensions")}

+

{layout ? `${layout.width} x ${layout.height}px` : "-"}

+
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ +

{labels.webpHint}

+ + {shouldShowQuality ? ( + + ) : null} + + {transparencyWarning ? ( +

+ {transparencyWarning} +

+ ) : null} + + + + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{t("imageUi.finalDimensions")}
+
+ {result.width} x {result.height}px +
+
+
+
{t("imageUi.finalFormat")}
+
{getImageFormatLabel(result.format)}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-joiner/README.md b/src/tools/image/image-joiner/README.md new file mode 100644 index 0000000..d13785e --- /dev/null +++ b/src/tools/image/image-joiner/README.md @@ -0,0 +1,18 @@ +# Unir imagenes + +Herramienta local para unir varias imagenes en una sola imagen final. + +## Comportamiento + +- Acepta imagenes PNG, JPG/JPEG o WebP que el navegador pueda decodificar. +- Permite reordenar, subir, bajar y eliminar imagenes. +- Modos de union: vertical, horizontal y cuadrícula. +- La cuadrícula usa un numero configurable de columnas y calcula las filas automaticamente. +- Permite ajustar separacion, padding exterior y color de fondo. +- Exporta PNG, JPG o WebP si el navegador lo soporta. +- JPG no conserva transparencia. +- Todo el procesamiento ocurre en el navegador. + +## Layout + +Las imagenes se dibujan en sus dimensiones originales. Vertical y horizontal centran cada imagen en el eje secundario. La cuadrícula usa celdas uniformes basadas en la imagen mas grande para mantener una composicion simple y predecible. diff --git a/src/tools/image/image-joiner/imageJoiner.service.test.ts b/src/tools/image/image-joiner/imageJoiner.service.test.ts new file mode 100644 index 0000000..5bfac52 --- /dev/null +++ b/src/tools/image/image-joiner/imageJoiner.service.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinedImageFileName, + calculateImageJoinerLayout, + getJoinedImageOutputBaseName, + validateImageJoinerOptions, +} from "./imageJoiner.service"; +import type { ImageJoinerLayoutOptions, ImageJoinerSource } from "./imageJoiner.types"; + +const images: ImageJoinerSource[] = [ + { id: "a", width: 100, height: 80 }, + { id: "b", width: 60, height: 120 }, + { id: "c", width: 140, height: 50 }, +]; + +const baseOptions: ImageJoinerLayoutOptions = { + backgroundColor: "#ffffff", + columns: 2, + mode: "vertical", + padding: 10, + spacing: 5, +}; + +describe("imageJoiner.service", () => { + it("calculates a vertical canvas", () => { + expect(calculateImageJoinerLayout(images, baseOptions)).toMatchObject({ + width: 160, + height: 280, + }); + }); + + it("calculates a horizontal canvas", () => { + expect(calculateImageJoinerLayout(images, { ...baseOptions, mode: "horizontal" })).toMatchObject({ + width: 330, + height: 140, + }); + }); + + it("calculates a grid canvas", () => { + expect(calculateImageJoinerLayout(images, { ...baseOptions, mode: "grid", columns: 2 })).toMatchObject({ + width: 305, + height: 265, + }); + }); + + it("returns image positions", () => { + expect(calculateImageJoinerLayout(images, baseOptions).positions).toEqual([ + { id: "a", x: 30, y: 10, width: 100, height: 80 }, + { id: "b", x: 50, y: 95, width: 60, height: 120 }, + { id: "c", x: 10, y: 220, width: 140, height: 50 }, + ]); + }); + + it("validates grid columns", () => { + expect(validateImageJoinerOptions(images, { ...baseOptions, mode: "grid", columns: 0 })).toMatch(/columna/); + expect(validateImageJoinerOptions(images, { ...baseOptions, mode: "grid", columns: 4 })).toMatch(/cantidad/); + expect(validateImageJoinerOptions(images, { ...baseOptions, mode: "grid", columns: 2 })).toBeNull(); + }); + + it("validates padding and spacing", () => { + expect(validateImageJoinerOptions(images, { ...baseOptions, spacing: -1 })).toMatch(/separacion/); + expect(validateImageJoinerOptions(images, { ...baseOptions, padding: -1 })).toMatch(/padding/); + expect(validateImageJoinerOptions(images, { ...baseOptions, spacing: 1.5 })).toMatch(/entero/); + }); + + it("validates the background color", () => { + expect(validateImageJoinerOptions(images, { ...baseOptions, backgroundColor: "white" })).toMatch(/hexadecimal/); + }); + + it("builds the output name with the correct extension", () => { + expect(getJoinedImageOutputBaseName("foto.png")).toBe("foto-unidas"); + expect(buildJoinedImageFileName("foto-unidas", "jpeg")).toBe("foto-unidas.jpg"); + }); +}); diff --git a/src/tools/image/image-joiner/imageJoiner.service.ts b/src/tools/image/image-joiner/imageJoiner.service.ts new file mode 100644 index 0000000..5b00f4c --- /dev/null +++ b/src/tools/image/image-joiner/imageJoiner.service.ts @@ -0,0 +1,358 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildBrowserImageDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageMimeType, + getBrowserImageOutputMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + ImageDimensions, + ImageJoinerLayout, + ImageJoinerLayoutOptions, + ImageJoinerMetadata, + ImageJoinerMode, + ImageJoinerOutputFormat, + ImageJoinerSource, + JoinImagesOptions, + ImageJoinerResult, +} from "./imageJoiner.types"; + +export const defaultOutputBaseName = "imagenes-unidas"; +export const maxJoinedCanvasPixels = 100_000_000; +export const maxCanvasSide = 32_000; +export const maxSpacing = 10_000; +export const maxPadding = 10_000; + +export { formatFileSize }; + +export function isJoinableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageFormatLabel(format: ImageJoinerOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function getJoinedImageOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-unidas`; +} + +export function buildJoinedImageFileName( + baseName: string, + outputFormat: ImageJoinerOutputFormat, + fallbackBaseName = defaultOutputBaseName, +) { + return buildBrowserImageDownloadFileName(baseName, outputFormat, fallbackBaseName); +} + +function hasValidImageDimensions(image: ImageDimensions) { + return ( + Number.isInteger(image.width) && + Number.isInteger(image.height) && + image.width > 0 && + image.height > 0 + ); +} + +function validateNonNegativeInteger(value: number, label: string, maxValue: number) { + if (!Number.isFinite(value)) { + return `${label} debe ser un numero valido.`; + } + + if (!Number.isInteger(value)) { + return `${label} debe ser un numero entero.`; + } + + if (value < 0) { + return `${label} no puede ser negativo.`; + } + + if (value > maxValue) { + return `${label} supera el limite permitido.`; + } + + return null; +} + +export function validateImageJoinerOptions( + images: readonly ImageJoinerSource[], + { backgroundColor, columns, mode, padding, spacing }: ImageJoinerLayoutOptions, +) { + if (images.length < 2) { + return "Agrega al menos dos imagenes para unir."; + } + + if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(backgroundColor)) { + return "El color de fondo debe ser hexadecimal."; + } + + if (images.some((image) => !hasValidImageDimensions(image))) { + return "Todas las imagenes deben tener dimensiones validas."; + } + + const spacingError = validateNonNegativeInteger(spacing, "La separacion", maxSpacing); + if (spacingError) return spacingError; + + const paddingError = validateNonNegativeInteger(padding, "El padding", maxPadding); + if (paddingError) return paddingError; + + if (mode === "grid") { + if (!Number.isFinite(columns) || !Number.isInteger(columns)) { + return "Las columnas deben ser un numero entero."; + } + + if (columns < 1) { + return "La cuadricula necesita al menos una columna."; + } + + if (columns > images.length) { + return "Las columnas no pueden superar la cantidad de imagenes."; + } + } + + const layout = calculateImageJoinerLayout(images, { columns, mode, padding, spacing, backgroundColor: "#ffffff" }); + + if (layout.width > maxCanvasSide || layout.height > maxCanvasSide) { + return "La imagen final supera el tamano maximo de canvas."; + } + + if (layout.width * layout.height > maxJoinedCanvasPixels) { + return "La imagen final supera el limite de 100 megapixeles."; + } + + return null; +} + +function getVerticalLayout(images: readonly ImageJoinerSource[], padding: number, spacing: number): ImageJoinerLayout { + const contentWidth = Math.max(...images.map((image) => image.width)); + let y = padding; + const positions = images.map((image) => { + const position = { + height: image.height, + id: image.id, + width: image.width, + x: padding + Math.floor((contentWidth - image.width) / 2), + y, + }; + y += image.height + spacing; + return position; + }); + + return { + height: images.reduce((total, image) => total + image.height, padding * 2 + spacing * (images.length - 1)), + positions, + width: contentWidth + padding * 2, + }; +} + +function getHorizontalLayout(images: readonly ImageJoinerSource[], padding: number, spacing: number): ImageJoinerLayout { + const contentHeight = Math.max(...images.map((image) => image.height)); + let x = padding; + const positions = images.map((image) => { + const position = { + height: image.height, + id: image.id, + width: image.width, + x, + y: padding + Math.floor((contentHeight - image.height) / 2), + }; + x += image.width + spacing; + return position; + }); + + return { + height: contentHeight + padding * 2, + positions, + width: images.reduce((total, image) => total + image.width, padding * 2 + spacing * (images.length - 1)), + }; +} + +function getGridLayout( + images: readonly ImageJoinerSource[], + columns: number, + padding: number, + spacing: number, +): ImageJoinerLayout { + const rows = Math.ceil(images.length / columns); + const cellWidth = Math.max(...images.map((image) => image.width)); + const cellHeight = Math.max(...images.map((image) => image.height)); + const positions = images.map((image, index) => { + const column = index % columns; + const row = Math.floor(index / columns); + const cellX = padding + column * (cellWidth + spacing); + const cellY = padding + row * (cellHeight + spacing); + + return { + height: image.height, + id: image.id, + width: image.width, + x: cellX + Math.floor((cellWidth - image.width) / 2), + y: cellY + Math.floor((cellHeight - image.height) / 2), + }; + }); + + return { + height: rows * cellHeight + Math.max(0, rows - 1) * spacing + padding * 2, + positions, + width: columns * cellWidth + Math.max(0, columns - 1) * spacing + padding * 2, + }; +} + +export function calculateImageJoinerLayout( + images: readonly ImageJoinerSource[], + { columns, mode, padding, spacing }: ImageJoinerLayoutOptions, +): ImageJoinerLayout { + if (images.length === 0) { + return { height: padding * 2, positions: [], width: padding * 2 }; + } + + if (mode === "horizontal") { + return getHorizontalLayout(images, padding, spacing); + } + + if (mode === "grid") { + return getGridLayout(images, columns, padding, spacing); + } + + return getVerticalLayout(images, padding, spacing); +} + +export async function readImageMetadata(file: File, id: string): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona imagenes PNG, JPG o WebP validas."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar una imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + id, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer una imagen."); + } +} + +function ensureOutputFormatSupported(outputFormat: ImageJoinerOutputFormat) { + if (!canExportBrowserImageFormat(outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(outputFormat)}.`); + } +} + +function fillCanvasBackground(canvas: HTMLCanvasElement, backgroundColor: string) { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar la imagen final."); + } + + context.fillStyle = backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + return context; +} + +export async function joinImageFiles(files: readonly File[], options: JoinImagesOptions): Promise { + if (files.length < 2) { + throw new Error("Agrega al menos dos imagenes para unir."); + } + + for (const file of files) { + if (!isJoinableImageFile(file)) { + throw new Error("Selecciona imagenes PNG, JPG o WebP validas."); + } + } + + ensureOutputFormatSupported(options.outputFormat); + + const loadedImages = await Promise.all( + files.map(async (file, index) => { + const image = await loadBrowserImage(file, "No se pudo decodificar una imagen en este navegador."); + return { + file, + id: String(index), + image, + height: image.naturalHeight, + width: image.naturalWidth, + }; + }), + ); + const sources = loadedImages.map(({ height, id, width }) => ({ height, id, width })); + const validationError = validateImageJoinerOptions(sources, options); + + if (validationError) { + throw new Error(validationError); + } + + const layout = calculateImageJoinerLayout(sources, options); + const canvas = document.createElement("canvas"); + canvas.width = layout.width; + canvas.height = layout.height; + const context = fillCanvasBackground(canvas, options.backgroundColor); + + for (const position of layout.positions) { + const loadedImage = loadedImages.find((image) => image.id === position.id); + if (!loadedImage) continue; + context.drawImage(loadedImage.image, position.x, position.y, position.width, position.height); + } + + const mimeType = getBrowserImageOutputMimeType(options.outputFormat); + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar la imagen unida.", + mimeType, + quality: options.quality, + }); + + return { + bytes: result.bytes, + fileName: buildJoinedImageFileName(options.outputBaseName, options.outputFormat, getJoinedImageOutputBaseName(files[0].name)), + format: options.outputFormat, + height: result.height, + mimeType, + size: result.size, + width: result.width, + }; +} diff --git a/src/tools/image/image-joiner/imageJoiner.types.ts b/src/tools/image/image-joiner/imageJoiner.types.ts new file mode 100644 index 0000000..e072477 --- /dev/null +++ b/src/tools/image/image-joiner/imageJoiner.types.ts @@ -0,0 +1,63 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImageJoinerMode = "vertical" | "horizontal" | "grid"; + +export type ImageDimensions = { + height: number; + width: number; +}; + +export type ImageJoinerSource = ImageDimensions & { + id: string; +}; + +export type ImageJoinerLayoutOptions = { + backgroundColor: string; + columns: number; + mode: ImageJoinerMode; + padding: number; + spacing: number; +}; + +export type ImageJoinerPosition = { + height: number; + id: string; + width: number; + x: number; + y: number; +}; + +export type ImageJoinerLayout = { + height: number; + positions: ImageJoinerPosition[]; + width: number; +}; + +export type ImageJoinerMetadata = { + fileName: string; + fileSize: number; + height: number; + id: string; + mimeType: string; + width: number; +}; + +export type ImageJoinerOutputFormat = BrowserImageOutputFormat; + +export type JoinImagesOptions = ImageJoinerLayoutOptions & { + outputBaseName: string; + outputFormat: ImageJoinerOutputFormat; + quality?: number; +}; + +export type ImageJoinerResult = { + bytes: ArrayBuffer; + fileName: string; + format: ImageJoinerOutputFormat; + height: number; + mimeType: string; + size: number; + width: number; +}; + +export type ImageJoinerStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-joiner/index.ts b/src/tools/image/image-joiner/index.ts new file mode 100644 index 0000000..5787026 --- /dev/null +++ b/src/tools/image/image-joiner/index.ts @@ -0,0 +1 @@ +export { ImageJoinerTool } from "./ImageJoinerTool"; From abfb71c91bd626ab5ae04c2b2ba0c0c056eddcad Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 00:53:45 -0300 Subject: [PATCH 05/10] feat(image): add split image tool --- src/features/tools/data/tools.ts | 106 ++++ .../tools/renderers/toolRenderers.tsx | 3 + .../image-splitter/ImageSplitterTool.tsx | 599 ++++++++++++++++++ src/tools/image/image-splitter/README.md | 14 + .../imageSplitter.service.test.ts | 63 ++ .../image-splitter/imageSplitter.service.ts | 294 +++++++++ .../image-splitter/imageSplitter.types.ts | 60 ++ src/tools/image/image-splitter/index.ts | 1 + 8 files changed, 1140 insertions(+) create mode 100644 src/tools/image/image-splitter/ImageSplitterTool.tsx create mode 100644 src/tools/image/image-splitter/README.md create mode 100644 src/tools/image/image-splitter/imageSplitter.service.test.ts create mode 100644 src/tools/image/image-splitter/imageSplitter.service.ts create mode 100644 src/tools/image/image-splitter/imageSplitter.types.ts create mode 100644 src/tools/image/image-splitter/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 1a7d7aa..94044b6 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -982,6 +982,112 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-splitter", + slug: "dividir-imagen", + slugEn: "split-image", + name: { es: "Dividir imagen", en: "Split image" }, + description: { + es: "Divide una imagen en varias partes por filas y columnas o por tamano fijo.", + en: "Split one image into multiple parts by rows and columns or by fixed size.", + }, + category: "image", + tags: { + es: ["imagen", "dividir", "partes", "cuadricula", "zip", "png", "jpg", "webp"], + en: ["image", "split", "parts", "grid", "zip", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Dividir imagen online gratis", + description: + "Divide una imagen PNG, JPG o WebP en partes por filas y columnas o por tamano fijo. Descarga el resultado como ZIP sin subir archivos.", + }, + en: { + title: "Split image online free", + description: + "Split a PNG, JPG or WebP image into parts by rows and columns or fixed size. Download the result as a ZIP with no uploads.", + }, + }, + doc: { + es: { + summary: + "Dividir imagen separa una imagen local en varias partes y descarga los resultados en un ZIP.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa el tipo, peso y dimensiones originales.", + "Elige filas y columnas, o define ancho y alto fijo para cada parte.", + "Revisa la cantidad de partes y la vista previa.", + "Elige PNG, JPG o WebP si el navegador lo permite.", + "Divide la imagen y descarga el ZIP.", + ], + useCases: [ + "Cortar una imagen grande en una cuadricula.", + "Preparar tiles de tamano fijo.", + "Separar una captura larga en partes manejables.", + ], + limits: [ + "Maximo 100 partes por operacion.", + "JPG no conserva transparencia.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + "Tamano maximo por imagen: 15 MB.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para dividirla.", + commonErrors: [ + "Filas, columnas, ancho o alto con valores no validos.", + "La division supera el limite de 100 partes.", + "Imagen danada o que el navegador no puede decodificar.", + ], + technicalNotes: [ + "La exportacion usa canvas en el navegador.", + "Las partes se empaquetan en ZIP con jszip.", + "Si el tamano fijo no calza exacto, el borde derecho o inferior genera partes mas pequenas.", + ], + }, + en: { + summary: + "Split image separates a local image into multiple parts and downloads the results in a ZIP.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the detected type, size and dimensions.", + "Choose rows and columns, or set a fixed width and height for each part.", + "Review the part count and preview.", + "Choose PNG, JPG or WebP if the browser supports it.", + "Split the image and download the ZIP.", + ], + useCases: [ + "Cut a large image into a grid.", + "Prepare fixed-size tiles.", + "Separate a long screenshot into manageable parts.", + ], + limits: [ + "Maximum 100 parts per operation.", + "JPG does not preserve transparency.", + "WebP appears if your browser can export it correctly.", + "Maximum image size: 15 MB.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to split it.", + commonErrors: [ + "Rows, columns, width or height with invalid values.", + "The split exceeds the 100-part limit.", + "A damaged image or one the browser cannot decode.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "Parts are bundled into a ZIP with jszip.", + "If fixed size does not fit exactly, the right or bottom edge creates smaller parts.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index 237ee1a..df88c80 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -26,6 +26,9 @@ const toolRenderers: Partial> = { "image-joiner": lazy(() => import("../../../tools/image/image-joiner").then((module) => ({ default: module.ImageJoinerTool })), ), + "image-splitter": lazy(() => + import("../../../tools/image/image-splitter").then((module) => ({ default: module.ImageSplitterTool })), + ), "image-rotator": lazy(() => import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), ), diff --git a/src/tools/image/image-splitter/ImageSplitterTool.tsx b/src/tools/image/image-splitter/ImageSplitterTool.tsx new file mode 100644 index 0000000..1876c47 --- /dev/null +++ b/src/tools/image/image-splitter/ImageSplitterTool.tsx @@ -0,0 +1,599 @@ +import { Download, FileArchive, FileImage, Grid3X3, Loader2, Ruler, RotateCcw, Upload } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + buildSplitImageZipFileName, + calculateImageSplitParts, + defaultOutputBaseName, + formatFileSize, + getImageFormatLabel, + getImageMimeLabel, + getSplitImageOutputBaseName, + readImageMetadata, + splitImageFile, + validateImageSplitterOptions, +} from "./imageSplitter.service"; +import type { + ImageSplitPart, + ImageSplitterMetadata, + ImageSplitterMode, + ImageSplitterOptions, + ImageSplitterOutputFormat, + ImageSplitterResult, + ImageSplitterStatus, +} from "./imageSplitter.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type DownloadableResult = ImageSplitterResult & { + url: string; +}; + +const baseOutputFormats: ImageSplitterOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const copy = { + es: { + columns: "Columnas", + downloadReady: "Partes listas", + fixedHeight: "Alto de cada parte", + fixedMode: "Tamano fijo", + fixedWidth: "Ancho de cada parte", + gridMode: "Filas y columnas", + jpgTransparency: "JPG no conserva transparencia.", + modeTitle: "Modo de division", + outputIntro: "El resultado se descarga como ZIP.", + outputTitle: "Vista previa y salida", + partCount: "Partes", + previewAlt: "Vista previa de la division", + processing: "Dividiendo imagen...", + rows: "Filas", + sourceIntro: "Divide una imagen en varias partes por filas y columnas o por tamano fijo.", + sourceTitle: "Imagen de origen", + webpHint: "WebP aparece si tu navegador permite exportarlo correctamente.", + zipName: "Nombre del ZIP", + }, + en: { + columns: "Columns", + downloadReady: "Parts ready", + fixedHeight: "Part height", + fixedMode: "Fixed size", + fixedWidth: "Part width", + gridMode: "Rows and columns", + jpgTransparency: "JPG does not preserve transparency.", + modeTitle: "Split mode", + outputIntro: "The result downloads as a ZIP.", + outputTitle: "Preview and output", + partCount: "Parts", + previewAlt: "Split preview", + processing: "Splitting image...", + rows: "Rows", + sourceIntro: "Split one image into multiple parts by rows and columns or by fixed size.", + sourceTitle: "Source image", + webpHint: "WebP appears if your browser can export it correctly.", + zipName: "ZIP name", + }, +} as const; + +function parseNumberInput(value: string) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : Number.NaN; +} + +function getPartPreviewStyle(part: ImageSplitPart, metadata: ImageSplitterMetadata) { + return { + height: `${(part.height / metadata.height) * 100}%`, + left: `${(part.x / metadata.width) * 100}%`, + top: `${(part.y / metadata.height) * 100}%`, + width: `${(part.width / metadata.width) * 100}%`, + }; +} + +export function ImageSplitterTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const resultUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [mode, setMode] = useState("grid"); + const [rowsInput, setRowsInput] = useState("2"); + const [columnsInput, setColumnsInput] = useState("2"); + const [partWidthInput, setPartWidthInput] = useState("512"); + const [partHeightInput, setPartHeightInput] = useState("512"); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const splitOptions = useMemo( + () => + mode === "fixed-size" + ? { + mode: "fixed-size", + partHeight: parseNumberInput(partHeightInput), + partWidth: parseNumberInput(partWidthInput), + } + : { + columns: parseNumberInput(columnsInput), + mode: "grid", + rows: parseNumberInput(rowsInput), + }, + [columnsInput, mode, partHeightInput, partWidthInput, rowsInput], + ); + const splitError = metadata ? validateImageSplitterOptions(metadata, splitOptions) : null; + const parts = metadata && !splitError ? calculateImageSplitParts(metadata, splitOptions, outputFormat) : []; + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const fallbackBaseName = metadata ? getSplitImageOutputBaseName(metadata.fileName) : defaultOutputBaseName; + const finalOutputFileName = buildSplitImageZipFileName(outputFileName, fallbackBaseName); + const canSplit = Boolean(file && metadata) && !splitError && status !== "reading" && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setError(null); + clearResult(); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + if (!hasCustomOutputFileName) { + setOutputFileName(getSplitImageOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + clearResult(); + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setMode("grid"); + setRowsInput("2"); + setColumnsInput("2"); + setPartWidthInput("512"); + setPartHeightInput("512"); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const splitImage = async () => { + if (!file || !canSplit) return; + setStatus("processing"); + setError(null); + clearResult(); + + try { + const nextResult = await splitImageFile(file, { + ...splitOptions, + outputBaseName: outputFileName, + outputFormat, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo dividir la imagen."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+

{labels.modeTitle}

+
+ + +
+ + {mode === "grid" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + + {splitError ? ( +

+ {splitError} +

+ ) : null} +
+ + {status === "reading" ? ( +

+ + {t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {previewUrl && metadata && parts.length > 0 ? ( +
+ {labels.previewAlt} + {parts.map((part) => ( + + ))} +
+ ) : ( +
+ +

{metadata ? "-" : t("imageUi.selectImage")}

+
+ )} +
+ +
+
+

{labels.partCount}

+

{parts.length || "-"}

+
+
+

ZIP

+

{finalOutputFileName}

+
+
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ +

{labels.webpHint}

+ + {shouldShowQuality ? ( + + ) : null} + + {outputFormat === "jpeg" ? ( +

+ {labels.jpgTransparency} +

+ ) : null} + + + + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{labels.partCount}
+
{result.partCount}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-splitter/README.md b/src/tools/image/image-splitter/README.md new file mode 100644 index 0000000..9e11085 --- /dev/null +++ b/src/tools/image/image-splitter/README.md @@ -0,0 +1,14 @@ +# Dividir imagen + +Herramienta local para dividir una imagen en varias partes y descargarlas como ZIP. + +## Comportamiento + +- Acepta imagenes PNG, JPG/JPEG o WebP que el navegador pueda decodificar. +- Divide por filas/columnas o por tamano fijo de cada parte. +- En modo tamano fijo, calcula automaticamente filas y columnas. +- Si el borde derecho o inferior no calza exacto, genera una parte mas pequena. +- Exporta partes como PNG, JPG o WebP si el navegador lo soporta. +- Empaqueta las partes en un ZIP con nombres `parte-f1-c1.png`. +- Limite inicial: 100 partes. +- Todo el procesamiento ocurre en el navegador. diff --git a/src/tools/image/image-splitter/imageSplitter.service.test.ts b/src/tools/image/image-splitter/imageSplitter.service.test.ts new file mode 100644 index 0000000..849876c --- /dev/null +++ b/src/tools/image/image-splitter/imageSplitter.service.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + buildSplitImageZipFileName, + calculateImageSplitParts, + getSplitImageOutputBaseName, + getSplitPartFileName, + validateImageSplitterOptions, +} from "./imageSplitter.service"; + +const imageDimensions = { width: 1000, height: 600 }; + +describe("imageSplitter.service", () => { + it("calculates parts by rows and columns", () => { + const parts = calculateImageSplitParts(imageDimensions, { mode: "grid", rows: 2, columns: 2 }); + + expect(parts).toHaveLength(4); + expect(parts[0]).toMatchObject({ x: 0, y: 0, width: 500, height: 300, row: 1, column: 1 }); + expect(parts[3]).toMatchObject({ x: 500, y: 300, width: 500, height: 300, row: 2, column: 2 }); + }); + + it("calculates parts by fixed size", () => { + const parts = calculateImageSplitParts({ width: 1000, height: 650 }, { mode: "fixed-size", partWidth: 400, partHeight: 300 }); + + expect(parts).toHaveLength(9); + expect(parts.at(-1)).toMatchObject({ x: 800, y: 600, width: 200, height: 50, row: 3, column: 3 }); + }); + + it("validates rows and columns", () => { + expect(validateImageSplitterOptions(imageDimensions, { mode: "grid", rows: 0, columns: 2 })).toMatch(/filas/i); + expect(validateImageSplitterOptions(imageDimensions, { mode: "grid", rows: 2, columns: 0 })).toMatch(/columnas/i); + expect(validateImageSplitterOptions(imageDimensions, { mode: "grid", rows: 2, columns: 2 })).toBeNull(); + }); + + it("validates fixed width and height", () => { + expect(validateImageSplitterOptions(imageDimensions, { mode: "fixed-size", partWidth: 0, partHeight: 300 })).toMatch(/ancho/i); + expect(validateImageSplitterOptions(imageDimensions, { mode: "fixed-size", partWidth: 300, partHeight: 0 })).toMatch(/alto/i); + expect(validateImageSplitterOptions(imageDimensions, { mode: "fixed-size", partWidth: 300, partHeight: 300 })).toBeNull(); + }); + + it("limits the maximum number of parts", () => { + expect(validateImageSplitterOptions(imageDimensions, { mode: "grid", rows: 10, columns: 11 })).toMatch(/100 partes/); + expect(validateImageSplitterOptions(imageDimensions, { mode: "fixed-size", partWidth: 10, partHeight: 10 })).toMatch(/100 partes/); + }); + + it("generates part names with the correct extension", () => { + expect(getSplitPartFileName(1, 2, "png")).toBe("parte-f1-c2.png"); + expect(getSplitPartFileName(3, 4, "jpeg")).toBe("parte-f3-c4.jpg"); + expect(getSplitPartFileName(3, 4, "webp")).toBe("parte-f3-c4.webp"); + }); + + it("keeps edge dimensions for non-divisible grid splits", () => { + const parts = calculateImageSplitParts({ width: 1001, height: 601 }, { mode: "grid", rows: 2, columns: 3 }); + + expect(parts[0]).toMatchObject({ width: 333, height: 300 }); + expect(parts[2]).toMatchObject({ x: 667, width: 334 }); + expect(parts[5]).toMatchObject({ y: 300, height: 301 }); + }); + + it("builds the output ZIP name", () => { + expect(getSplitImageOutputBaseName("foto.png")).toBe("foto-partes"); + expect(buildSplitImageZipFileName("foto-partes")).toBe("foto-partes.zip"); + }); +}); diff --git a/src/tools/image/image-splitter/imageSplitter.service.ts b/src/tools/image/image-splitter/imageSplitter.service.ts new file mode 100644 index 0000000..964bb90 --- /dev/null +++ b/src/tools/image/image-splitter/imageSplitter.service.ts @@ -0,0 +1,294 @@ +import JSZip from "jszip"; +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildImageZipDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageMimeType, + getBrowserImageOutputExtension, + getBrowserImageOutputMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + ImageDimensions, + ImageSplitPart, + ImageSplitterMetadata, + ImageSplitterOptions, + ImageSplitterOutputFormat, + SplitImageOptions, + ImageSplitterResult, +} from "./imageSplitter.types"; + +export const defaultOutputBaseName = "imagen-dividida"; +export const maxSplitParts = 100; + +export { formatFileSize }; + +export function isSplittableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageFormatLabel(format: ImageSplitterOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function getSplitImageOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-partes`; +} + +export function buildSplitImageZipFileName(baseName: string, fallbackBaseName = defaultOutputBaseName) { + return buildImageZipDownloadFileName(baseName, fallbackBaseName); +} + +function hasValidImageDimensions(imageDimensions: ImageDimensions) { + return ( + Number.isInteger(imageDimensions.width) && + Number.isInteger(imageDimensions.height) && + imageDimensions.width > 0 && + imageDimensions.height > 0 + ); +} + +function validatePositiveInteger(value: number, label: string) { + if (!Number.isFinite(value)) { + return `${label} debe ser un numero valido.`; + } + + if (!Number.isInteger(value)) { + return `${label} debe ser un numero entero.`; + } + + if (value <= 0) { + return `${label} debe ser mayor que cero.`; + } + + return null; +} + +export function getSplitPartFileName(row: number, column: number, outputFormat: ImageSplitterOutputFormat) { + return `parte-f${row}-c${column}.${getBrowserImageOutputExtension(outputFormat)}`; +} + +function calculateGridParts( + imageDimensions: ImageDimensions, + rows: number, + columns: number, + outputFormat: ImageSplitterOutputFormat, +) { + const parts: ImageSplitPart[] = []; + + for (let row = 0; row < rows; row += 1) { + const y = Math.floor((row * imageDimensions.height) / rows); + const nextY = Math.floor(((row + 1) * imageDimensions.height) / rows); + + for (let column = 0; column < columns; column += 1) { + const x = Math.floor((column * imageDimensions.width) / columns); + const nextX = Math.floor(((column + 1) * imageDimensions.width) / columns); + + parts.push({ + column: column + 1, + fileName: getSplitPartFileName(row + 1, column + 1, outputFormat), + height: nextY - y, + index: parts.length + 1, + row: row + 1, + width: nextX - x, + x, + y, + }); + } + } + + return parts; +} + +function calculateFixedSizeParts( + imageDimensions: ImageDimensions, + partWidth: number, + partHeight: number, + outputFormat: ImageSplitterOutputFormat, +) { + const rows = Math.ceil(imageDimensions.height / partHeight); + const columns = Math.ceil(imageDimensions.width / partWidth); + const parts: ImageSplitPart[] = []; + + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const x = column * partWidth; + const y = row * partHeight; + + parts.push({ + column: column + 1, + fileName: getSplitPartFileName(row + 1, column + 1, outputFormat), + height: Math.min(partHeight, imageDimensions.height - y), + index: parts.length + 1, + row: row + 1, + width: Math.min(partWidth, imageDimensions.width - x), + x, + y, + }); + } + } + + return parts; +} + +export function calculateImageSplitParts( + imageDimensions: ImageDimensions, + options: ImageSplitterOptions, + outputFormat: ImageSplitterOutputFormat = "png", +) { + if (options.mode === "fixed-size") { + return calculateFixedSizeParts(imageDimensions, options.partWidth, options.partHeight, outputFormat); + } + + return calculateGridParts(imageDimensions, options.rows, options.columns, outputFormat); +} + +export function validateImageSplitterOptions(imageDimensions: ImageDimensions, options: ImageSplitterOptions) { + if (!hasValidImageDimensions(imageDimensions)) { + return "La imagen debe tener dimensiones validas."; + } + + if (options.mode === "grid") { + const rowsError = validatePositiveInteger(options.rows, "Las filas"); + if (rowsError) return rowsError; + + const columnsError = validatePositiveInteger(options.columns, "Las columnas"); + if (columnsError) return columnsError; + } else { + const widthError = validatePositiveInteger(options.partWidth, "El ancho de cada parte"); + if (widthError) return widthError; + + const heightError = validatePositiveInteger(options.partHeight, "El alto de cada parte"); + if (heightError) return heightError; + } + + const parts = calculateImageSplitParts(imageDimensions, options); + if (parts.some((part) => part.width <= 0 || part.height <= 0)) { + return "Cada parte debe tener ancho y alto mayores que cero."; + } + + if (parts.length > maxSplitParts) { + return `La division supera el limite de ${maxSplitParts} partes.`; + } + + return null; +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +export async function splitImageFile(file: File, options: SplitImageOptions): Promise { + if (!isSplittableImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + if (!canExportBrowserImageFormat(options.outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(options.outputFormat)}.`); + } + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const imageDimensions = { height: image.naturalHeight, width: image.naturalWidth }; + const validationError = validateImageSplitterOptions(imageDimensions, options); + + if (validationError) { + throw new Error(validationError); + } + + const parts = calculateImageSplitParts(imageDimensions, options, options.outputFormat); + const mimeType = getBrowserImageOutputMimeType(options.outputFormat); + const zip = new JSZip(); + + for (const part of parts) { + const canvas = document.createElement("canvas"); + canvas.width = part.width; + canvas.height = part.height; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar una parte de la imagen."); + } + + if (options.outputFormat === "jpeg") { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + context.drawImage(image, part.x, part.y, part.width, part.height, 0, 0, part.width, part.height); + + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar una parte de la imagen.", + mimeType, + quality: options.quality, + }); + zip.file(part.fileName, result.bytes); + } + + try { + const bytes = await zip.generateAsync({ type: "arraybuffer" }); + return { + bytes, + fileName: buildSplitImageZipFileName(options.outputBaseName, getSplitImageOutputBaseName(file.name)), + mimeType: "application/zip", + partCount: parts.length, + parts, + size: bytes.byteLength, + }; + } catch { + throw new Error("No se pudo preparar la descarga ZIP."); + } +} diff --git a/src/tools/image/image-splitter/imageSplitter.types.ts b/src/tools/image/image-splitter/imageSplitter.types.ts new file mode 100644 index 0000000..520d015 --- /dev/null +++ b/src/tools/image/image-splitter/imageSplitter.types.ts @@ -0,0 +1,60 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImageSplitterMode = "grid" | "fixed-size"; + +export type ImageDimensions = { + height: number; + width: number; +}; + +export type ImageSplitterGridOptions = { + columns: number; + mode: "grid"; + rows: number; +}; + +export type ImageSplitterFixedSizeOptions = { + mode: "fixed-size"; + partHeight: number; + partWidth: number; +}; + +export type ImageSplitterOptions = ImageSplitterGridOptions | ImageSplitterFixedSizeOptions; + +export type ImageSplitPart = { + column: number; + fileName: string; + height: number; + index: number; + row: number; + width: number; + x: number; + y: number; +}; + +export type ImageSplitterMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type ImageSplitterOutputFormat = BrowserImageOutputFormat; + +export type SplitImageOptions = ImageSplitterOptions & { + outputBaseName: string; + outputFormat: ImageSplitterOutputFormat; + quality?: number; +}; + +export type ImageSplitterResult = { + bytes: ArrayBuffer; + fileName: string; + mimeType: "application/zip"; + partCount: number; + parts: ImageSplitPart[]; + size: number; +}; + +export type ImageSplitterStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-splitter/index.ts b/src/tools/image/image-splitter/index.ts new file mode 100644 index 0000000..dcbbf50 --- /dev/null +++ b/src/tools/image/image-splitter/index.ts @@ -0,0 +1 @@ +export { ImageSplitterTool } from "./ImageSplitterTool"; From 67bb79b6c68a38bb81e26a5cbfc63cceb43b6c92 Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 04:01:32 -0300 Subject: [PATCH 06/10] feat(image): add image color extractor tool --- src/features/tools/data/tools.ts | 104 ++++ .../tools/renderers/toolRenderers.tsx | 5 + .../ImageColorExtractorTool.tsx | 444 ++++++++++++++++++ .../image/image-color-extractor/README.md | 17 + .../imageColorExtractor.service.test.ts | 86 ++++ .../imageColorExtractor.service.ts | 239 ++++++++++ .../imageColorExtractor.types.ts | 31 ++ .../image/image-color-extractor/index.ts | 1 + 8 files changed, 927 insertions(+) create mode 100644 src/tools/image/image-color-extractor/ImageColorExtractorTool.tsx create mode 100644 src/tools/image/image-color-extractor/README.md create mode 100644 src/tools/image/image-color-extractor/imageColorExtractor.service.test.ts create mode 100644 src/tools/image/image-color-extractor/imageColorExtractor.service.ts create mode 100644 src/tools/image/image-color-extractor/imageColorExtractor.types.ts create mode 100644 src/tools/image/image-color-extractor/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 94044b6..0e53482 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -1088,6 +1088,110 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-color-extractor", + slug: "extraer-colores-imagen", + slugEn: "extract-image-colors", + name: { es: "Extraer colores de imagen", en: "Extract image colors" }, + description: { + es: "Extrae una paleta aproximada de colores dominantes desde una imagen.", + en: "Extract an approximate palette of dominant colors from an image.", + }, + category: "image", + tags: { + es: ["imagen", "colores", "paleta", "hex", "rgb"], + en: ["image", "colors", "palette", "hex", "rgb"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Extraer colores de imagen online gratis", + description: + "Extrae una paleta aproximada de colores dominantes desde una imagen PNG, JPG o WebP. Copia HEX/RGB y descarga TXT o JSON sin subir archivos.", + }, + en: { + title: "Extract image colors online free", + description: + "Extract an approximate dominant color palette from a PNG, JPG or WebP image. Copy HEX/RGB and download TXT or JSON with no uploads.", + }, + }, + doc: { + es: { + summary: + "Extraer colores de imagen analiza una imagen local y devuelve una paleta aproximada de colores dominantes.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa el tipo, peso y dimensiones originales.", + "Elige la cantidad de colores: 4, 6, 8 o 12.", + "Extrae la paleta y revisa HEX, RGB y porcentaje aproximado.", + "Copia valores individuales o descarga la paleta como TXT o JSON.", + ], + useCases: [ + "Obtener colores base para una interfaz.", + "Tomar referencias HEX/RGB desde una imagen de marca.", + "Crear una paleta rapida desde una foto o captura.", + ], + limits: [ + "Los colores son una estimacion basada en muestreo local.", + "No promete precision perfecta ni una paleta exacta.", + "Ignora pixeles totalmente transparentes.", + "Tamano maximo por imagen: 15 MB.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para extraer colores.", + commonErrors: [ + "Archivo que no es una imagen PNG, JPG o WebP.", + "Imagen danada o que el navegador no puede decodificar.", + "Imagen sin pixeles visibles luego de ignorar transparencia total.", + ], + technicalNotes: [ + "El analisis usa canvas en el navegador.", + "Se muestrean hasta 50.000 pixeles.", + "La paleta se obtiene con cuantizacion RGB simple y orden por frecuencia.", + ], + }, + en: { + summary: + "Extract image colors analyzes a local image and returns an approximate dominant color palette.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the detected type, size and dimensions.", + "Choose the color count: 4, 6, 8 or 12.", + "Extract the palette and review HEX, RGB and approximate percentage.", + "Copy individual values or download the palette as TXT or JSON.", + ], + useCases: [ + "Get base colors for an interface.", + "Pull HEX/RGB references from a brand image.", + "Create a quick palette from a photo or screenshot.", + ], + limits: [ + "Colors are an estimate based on local sampling.", + "It does not promise perfect precision or an exact palette.", + "Fully transparent pixels are ignored.", + "Maximum image size: 15 MB.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to extract colors.", + commonErrors: [ + "A file that is not PNG, JPG or WebP.", + "A damaged image or one the browser cannot decode.", + "An image with no visible pixels after fully transparent pixels are ignored.", + ], + technicalNotes: [ + "Analysis uses browser canvas.", + "Up to 50,000 pixels are sampled.", + "The palette uses simple RGB quantization and frequency ordering.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index df88c80..af28faf 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -14,6 +14,11 @@ const toolRenderers: Partial> = { "image-compressor": lazy(() => import("../../../tools/image/image-compressor").then((module) => ({ default: module.ImageCompressorTool })), ), + "image-color-extractor": lazy(() => + import("../../../tools/image/image-color-extractor").then((module) => ({ + default: module.ImageColorExtractorTool, + })), + ), "image-converter": lazy(() => import("../../../tools/image/image-converter").then((module) => ({ default: module.ImageConverterTool })), ), diff --git a/src/tools/image/image-color-extractor/ImageColorExtractorTool.tsx b/src/tools/image/image-color-extractor/ImageColorExtractorTool.tsx new file mode 100644 index 0000000..5e5fb0e --- /dev/null +++ b/src/tools/image/image-color-extractor/ImageColorExtractorTool.tsx @@ -0,0 +1,444 @@ +import { Copy, Download, FileImage, Loader2, Palette, RotateCcw, Upload } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + buildPaletteFileName, + colorCountOptions, + defaultColorCount, + defaultOutputBaseName, + exportPaletteAsJson, + exportPaletteAsTxt, + extractImageColors, + formatFileSize, + formatRgb, + getImageMimeLabel, + getPaletteOutputBaseName, + readImageMetadata, +} from "./imageColorExtractor.service"; +import type { + ExtractedImageColor, + ImageColorExtractorMetadata, + ImageColorExtractorResult, + ImageColorExtractorStatus, +} from "./imageColorExtractor.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; + +type PaletteExportFormat = "json" | "txt"; + +const copy = { + es: { + analyze: "Extraer colores", + copied: "Copiado", + copyHex: "Copiar HEX", + copyRgb: "Copiar RGB", + downloadJson: "Descargar JSON", + downloadTxt: "Descargar TXT", + emptyPalette: "Extrae colores para ver la paleta.", + estimation: "Los colores son una estimacion basada en muestreo local.", + outputName: "Nombre de la paleta", + outputTitle: "Paleta", + paletteReady: "Paleta lista", + processing: "Analizando imagen...", + samplePixels: "Pixeles muestreados", + sourceIntro: "Extrae una paleta aproximada de colores dominantes desde una imagen.", + sourceTitle: "Imagen de origen", + swatch: "Muestra de color", + totalLocal: "Todo se procesa en tu navegador.", + }, + en: { + analyze: "Extract colors", + copied: "Copied", + copyHex: "Copy HEX", + copyRgb: "Copy RGB", + downloadJson: "Download JSON", + downloadTxt: "Download TXT", + emptyPalette: "Extract colors to see the palette.", + estimation: "Colors are an estimate based on local sampling.", + outputName: "Palette name", + outputTitle: "Palette", + paletteReady: "Palette ready", + processing: "Analyzing image...", + samplePixels: "Sampled pixels", + sourceIntro: "Extract an approximate palette of dominant colors from an image.", + sourceTitle: "Source image", + swatch: "Color swatch", + totalLocal: "Everything is processed in your browser.", + }, +} as const; + +function downloadTextFile(fileName: string, text: string, type: string) { + const url = URL.createObjectURL(new Blob([text], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +export function ImageColorExtractorTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [colorCount, setColorCount] = useState(defaultColorCount); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [copiedValue, setCopiedValue] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + }; + }, []); + + const fallbackBaseName = metadata ? getPaletteOutputBaseName(metadata.fileName) : defaultOutputBaseName; + const txtFileName = buildPaletteFileName(outputFileName, "txt", fallbackBaseName); + const jsonFileName = buildPaletteFileName(outputFileName, "json", fallbackBaseName); + const canAnalyze = Boolean(file && metadata) && status !== "reading" && status !== "processing"; + const colors = result?.colors ?? []; + + const resetFeedback = () => { + setError(null); + setCopiedValue(null); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setResult(null); + setError(null); + setCopiedValue(null); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + if (!hasCustomOutputFileName) { + setOutputFileName(getPaletteOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setColorCount(defaultColorCount); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setResult(null); + setStatus("idle"); + setError(null); + setCopiedValue(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const analyzeImage = async () => { + if (!file || !canAnalyze) return; + setStatus("processing"); + setError(null); + setCopiedValue(null); + + try { + const nextResult = await extractImageColors(file, { colorCount }); + setResult(nextResult); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo extraer la paleta."); + } + }; + + const copyValue = async (value: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedValue(value); + } catch { + setError(language === "en" ? "Could not copy to clipboard." : "No se pudo copiar al portapapeles."); + } + }; + + const downloadPalette = (format: PaletteExportFormat) => { + if (colors.length === 0) return; + + if (format === "json") { + downloadTextFile(jsonFileName, exportPaletteAsJson(colors), "application/json"); + return; + } + + downloadTextFile(txtFileName, exportPaletteAsTxt(colors), "text/plain"); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+
+ {colorCountOptions.map((nextCount) => ( + + ))} +
+

{labels.estimation}

+

{labels.totalLocal}

+
+ + {status === "reading" || status === "processing" ? ( +

+ + {status === "processing" ? labels.processing : t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ +
+
+

{labels.outputTitle}

+

{labels.estimation}

+
+ +
+ {colors.length > 0 ? ( +
+
+

{labels.paletteReady}

+

+ {labels.samplePixels}: {result?.sampledPixels ?? 0} +

+
+ + {colors.map((color: ExtractedImageColor) => { + const rgb = formatRgb(color.rgb); + return ( +
+
+
+
+
+

{color.hex}

+

{rgb}

+
+

+ {color.percentage.toFixed(1)}% +

+
+
+ + +
+
+
+ ); + })} +
+ ) : ( +
+ + + {labels.emptyPalette} + +
+ )} + + + +
+ + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-color-extractor/README.md b/src/tools/image/image-color-extractor/README.md new file mode 100644 index 0000000..fd5ee76 --- /dev/null +++ b/src/tools/image/image-color-extractor/README.md @@ -0,0 +1,17 @@ +# Extraer colores de imagen + +Herramienta local para extraer una paleta aproximada de colores dominantes desde una imagen. + +## Comportamiento + +- Acepta imagenes PNG, JPG/JPEG o WebP que el navegador pueda decodificar. +- Analiza la imagen con canvas en el navegador. +- Usa muestreo de hasta 50.000 pixeles. +- Agrupa colores por cuantizacion RGB simple. +- Ignora pixeles totalmente transparentes. +- Permite exportar la paleta como TXT o JSON. +- Todo el procesamiento ocurre en el navegador. + +## Limites + +La paleta es una estimacion basada en muestreo y buckets de color. No promete una paleta exacta ni usa IA/backend. diff --git a/src/tools/image/image-color-extractor/imageColorExtractor.service.test.ts b/src/tools/image/image-color-extractor/imageColorExtractor.service.test.ts new file mode 100644 index 0000000..f1d5451 --- /dev/null +++ b/src/tools/image/image-color-extractor/imageColorExtractor.service.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + buildPaletteFileName, + calculateSampleDimensions, + exportPaletteAsJson, + exportPaletteAsTxt, + extractDominantColorsFromImageData, + formatRgb, + getPaletteOutputBaseName, + quantizeRgb, + rgbToHex, +} from "./imageColorExtractor.service"; + +function pixel(r: number, g: number, b: number, a = 255) { + return [r, g, b, a]; +} + +describe("imageColorExtractor.service", () => { + it("converts RGB to HEX", () => { + expect(rgbToHex({ r: 255, g: 128, b: 0 })).toBe("#FF8000"); + expect(rgbToHex({ r: 0, g: 1, b: 15 })).toBe("#00010F"); + }); + + it("formats RGB", () => { + expect(formatRgb({ r: 12, g: 34, b: 56 })).toBe("rgb(12, 34, 56)"); + }); + + it("bucketizes RGB values", () => { + expect(quantizeRgb({ r: 31, g: 32, b: 255 })).toEqual({ r: 0, g: 1, b: 7 }); + }); + + it("orders colors by frequency", () => { + const data = new Uint8ClampedArray([ + ...pixel(255, 0, 0), + ...pixel(255, 5, 5), + ...pixel(0, 0, 255), + ]); + + const result = extractDominantColorsFromImageData(data, { colorCount: 2 }); + expect(result.colors).toHaveLength(2); + expect(result.colors[0].hex).toBe("#FF0303"); + expect(result.colors[0].count).toBe(2); + }); + + it("ignores fully transparent pixels", () => { + const data = new Uint8ClampedArray([ + ...pixel(255, 0, 0, 0), + ...pixel(0, 0, 255, 255), + ]); + + const result = extractDominantColorsFromImageData(data, { colorCount: 4 }); + expect(result.sampledPixels).toBe(1); + expect(result.colors[0].hex).toBe("#0000FF"); + }); + + it("limits the requested color count", () => { + const data = new Uint8ClampedArray([ + ...pixel(255, 0, 0), + ...pixel(0, 255, 0), + ...pixel(0, 0, 255), + ...pixel(255, 255, 0), + ...pixel(0, 255, 255), + ]); + + expect(extractDominantColorsFromImageData(data, { colorCount: 4 }).colors).toHaveLength(4); + }); + + it("exports TXT and JSON palettes", () => { + const colors = [ + { count: 2, hex: "#FF0000", percentage: 66.666, rgb: { r: 255, g: 0, b: 0 } }, + ]; + + expect(exportPaletteAsTxt(colors)).toContain("#FF0000 | rgb(255, 0, 0) | 66.7%"); + expect(exportPaletteAsJson(colors)).toContain('"percentage": 66.67'); + }); + + it("calculates sample dimensions under the pixel limit", () => { + expect(calculateSampleDimensions(1000, 1000, 10_000)).toEqual({ width: 100, height: 100 }); + expect(calculateSampleDimensions(80, 60, 10_000)).toEqual({ width: 80, height: 60 }); + }); + + it("builds palette output names", () => { + expect(getPaletteOutputBaseName("marca.png")).toBe("marca-colores"); + expect(buildPaletteFileName("marca-colores", "json")).toBe("marca-colores.json"); + }); +}); diff --git a/src/tools/image/image-color-extractor/imageColorExtractor.service.ts b/src/tools/image/image-color-extractor/imageColorExtractor.service.ts new file mode 100644 index 0000000..b11bb01 --- /dev/null +++ b/src/tools/image/image-color-extractor/imageColorExtractor.service.ts @@ -0,0 +1,239 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + getBrowserImageMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + ExtractedImageColor, + ExtractImageColorsOptions, + ImageColorExtractorMetadata, + ImageColorExtractorResult, + RgbColor, +} from "./imageColorExtractor.types"; + +export const defaultOutputBaseName = "paleta-imagen"; +export const defaultColorCount = 6; +export const colorCountOptions = [4, 6, 8, 12] as const; +export const maxSamplePixels = 50_000; +const bucketSize = 32; + +type ColorBucket = { + b: number; + count: number; + g: number; + r: number; +}; + +export { formatFileSize }; + +export function isColorExtractableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function getPaletteOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-colores`; +} + +export function buildPaletteFileName(baseName: string, extension: "json" | "txt", fallbackBaseName = defaultOutputBaseName) { + return `${getImageDownloadBaseName(baseName, fallbackBaseName)}.${extension}`; +} + +function clampByte(value: number) { + return Math.min(255, Math.max(0, Math.round(value))); +} + +function byteToHex(value: number) { + return clampByte(value).toString(16).padStart(2, "0").toUpperCase(); +} + +export function rgbToHex({ b, g, r }: RgbColor) { + return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`; +} + +export function formatRgb({ b, g, r }: RgbColor) { + return `rgb(${clampByte(r)}, ${clampByte(g)}, ${clampByte(b)})`; +} + +export function quantizeRgb({ b, g, r }: RgbColor) { + return { + b: Math.floor(clampByte(b) / bucketSize), + g: Math.floor(clampByte(g) / bucketSize), + r: Math.floor(clampByte(r) / bucketSize), + }; +} + +function getBucketKey(color: RgbColor) { + const quantized = quantizeRgb(color); + return `${quantized.r}:${quantized.g}:${quantized.b}`; +} + +function normalizeColorCount(colorCount: number) { + return colorCountOptions.includes(colorCount as (typeof colorCountOptions)[number]) + ? colorCount + : defaultColorCount; +} + +function toExtractedColor(bucket: ColorBucket, visiblePixelCount: number): ExtractedImageColor { + const rgb = { + b: Math.round(bucket.b / bucket.count), + g: Math.round(bucket.g / bucket.count), + r: Math.round(bucket.r / bucket.count), + }; + + return { + count: bucket.count, + hex: rgbToHex(rgb), + percentage: visiblePixelCount > 0 ? (bucket.count / visiblePixelCount) * 100 : 0, + rgb, + }; +} + +export function extractDominantColorsFromImageData( + data: Uint8ClampedArray, + { colorCount }: ExtractImageColorsOptions, +): ImageColorExtractorResult { + const buckets = new Map(); + let visiblePixelCount = 0; + + for (let index = 0; index < data.length; index += 4) { + const alpha = data[index + 3] ?? 255; + if (alpha === 0) continue; + + const color = { + b: data[index + 2] ?? 0, + g: data[index + 1] ?? 0, + r: data[index] ?? 0, + }; + const key = getBucketKey(color); + const bucket = buckets.get(key) ?? { b: 0, count: 0, g: 0, r: 0 }; + + bucket.b += color.b; + bucket.count += 1; + bucket.g += color.g; + bucket.r += color.r; + buckets.set(key, bucket); + visiblePixelCount += 1; + } + + const colors = Array.from(buckets.values()) + .sort((first, second) => second.count - first.count) + .slice(0, normalizeColorCount(colorCount)) + .map((bucket) => toExtractedColor(bucket, visiblePixelCount)); + + return { + colors, + sampledPixels: visiblePixelCount, + }; +} + +export function calculateSampleDimensions(width: number, height: number, maxPixels = maxSamplePixels) { + if (width <= 0 || height <= 0) { + return { height: 0, width: 0 }; + } + + const pixelCount = width * height; + if (pixelCount <= maxPixels) { + return { height, width }; + } + + const scale = Math.sqrt(maxPixels / pixelCount); + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +export function exportPaletteAsTxt(colors: readonly ExtractedImageColor[]) { + return colors + .map((color, index) => { + const percentage = `${color.percentage.toFixed(1)}%`; + return `${index + 1}. ${color.hex} | ${formatRgb(color.rgb)} | ${percentage}`; + }) + .join("\n"); +} + +export function exportPaletteAsJson(colors: readonly ExtractedImageColor[]) { + return JSON.stringify( + colors.map((color) => ({ + count: color.count, + hex: color.hex, + percentage: Number(color.percentage.toFixed(2)), + rgb: color.rgb, + })), + null, + 2, + ); +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +export async function extractImageColors( + file: File, + { colorCount }: ExtractImageColorsOptions, +): Promise { + if (!isColorExtractableImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const sampleDimensions = calculateSampleDimensions(image.naturalWidth, image.naturalHeight); + const canvas = document.createElement("canvas"); + canvas.width = sampleDimensions.width; + canvas.height = sampleDimensions.height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + + if (!context) { + throw new Error("No se pudo analizar la imagen."); + } + + context.drawImage(image, 0, 0, sampleDimensions.width, sampleDimensions.height); + const imageData = context.getImageData(0, 0, sampleDimensions.width, sampleDimensions.height); + return extractDominantColorsFromImageData(imageData.data, { colorCount }); +} diff --git a/src/tools/image/image-color-extractor/imageColorExtractor.types.ts b/src/tools/image/image-color-extractor/imageColorExtractor.types.ts new file mode 100644 index 0000000..7d71427 --- /dev/null +++ b/src/tools/image/image-color-extractor/imageColorExtractor.types.ts @@ -0,0 +1,31 @@ +export type ImageColorExtractorMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type RgbColor = { + b: number; + g: number; + r: number; +}; + +export type ExtractedImageColor = { + count: number; + hex: string; + percentage: number; + rgb: RgbColor; +}; + +export type ExtractImageColorsOptions = { + colorCount: number; +}; + +export type ImageColorExtractorResult = { + colors: ExtractedImageColor[]; + sampledPixels: number; +}; + +export type ImageColorExtractorStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-color-extractor/index.ts b/src/tools/image/image-color-extractor/index.ts new file mode 100644 index 0000000..b66f3ad --- /dev/null +++ b/src/tools/image/image-color-extractor/index.ts @@ -0,0 +1 @@ +export { ImageColorExtractorTool } from "./ImageColorExtractorTool"; From 1252bcf6c6ed468dfe118152e738c9964f3431cc Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 04:12:37 -0300 Subject: [PATCH 07/10] feat(image): add placeholder image generator --- src/features/tools/data/tools.ts | 104 ++++ .../tools/renderers/toolRenderers.tsx | 3 + .../ImagePlaceholderTool.tsx | 468 ++++++++++++++++++ src/tools/image/image-placeholder/README.md | 17 + .../imagePlaceholder.service.test.ts | 40 ++ .../imagePlaceholder.service.ts | 188 +++++++ .../imagePlaceholder.types.ts | 26 + src/tools/image/image-placeholder/index.ts | 1 + 8 files changed, 847 insertions(+) create mode 100644 src/tools/image/image-placeholder/ImagePlaceholderTool.tsx create mode 100644 src/tools/image/image-placeholder/README.md create mode 100644 src/tools/image/image-placeholder/imagePlaceholder.service.test.ts create mode 100644 src/tools/image/image-placeholder/imagePlaceholder.service.ts create mode 100644 src/tools/image/image-placeholder/imagePlaceholder.types.ts create mode 100644 src/tools/image/image-placeholder/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 0e53482..8188a16 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -1192,6 +1192,110 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-placeholder", + slug: "generar-placeholder-imagen", + slugEn: "generate-placeholder-image", + name: { es: "Generar imagen placeholder", en: "Generate placeholder image" }, + description: { + es: "Genera una imagen placeholder para disenos, pruebas o desarrollo web.", + en: "Generate a placeholder image for layouts, tests or web development.", + }, + category: "image", + tags: { + es: ["imagen", "placeholder", "diseno", "desarrollo", "png", "jpg", "webp"], + en: ["image", "placeholder", "design", "development", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Generar imagen placeholder online gratis", + description: + "Genera una imagen placeholder configurando tamano, texto y colores. Exporta PNG, JPG o WebP desde tu navegador, sin backend.", + }, + en: { + title: "Generate placeholder image online free", + description: + "Generate a placeholder image by setting size, text and colors. Export PNG, JPG or WebP from your browser, with no backend.", + }, + }, + doc: { + es: { + summary: + "Generar imagen placeholder crea una imagen simple con tamano, texto y colores configurables desde el navegador.", + howTo: [ + "Define ancho y alto.", + "Edita el texto o deja el texto automatico basado en las dimensiones.", + "Elige color de fondo y color de texto.", + "Selecciona PNG, JPG o WebP si el navegador lo permite.", + "Genera la imagen y descargala.", + ], + useCases: [ + "Crear imagenes temporales para maquetas.", + "Probar layouts web con dimensiones reales.", + "Generar placeholders simples para desarrollo.", + ], + limits: [ + "Maximo 8000 px por lado.", + "Maximo 64 megapixeles.", + "No es un editor avanzado: genera texto centrado sobre fondo plano.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + ], + privacy: + "La imagen se genera localmente en tu navegador; no se sube nada a Modulaq.", + commonErrors: [ + "Ancho o alto con valores no validos.", + "Dimensiones demasiado grandes para canvas.", + "Formato de salida no disponible en el navegador actual.", + ], + technicalNotes: [ + "La exportacion usa canvas en el navegador.", + "La calidad aplica a JPG y WebP, no a PNG.", + "El nombre de salida se deriva de las dimensiones.", + ], + }, + en: { + summary: + "Generate placeholder image creates a simple image with configurable size, text and colors from the browser.", + howTo: [ + "Set width and height.", + "Edit the text or keep the automatic text based on dimensions.", + "Choose background color and text color.", + "Select PNG, JPG or WebP if the browser supports it.", + "Generate the image and download it.", + ], + useCases: [ + "Create temporary images for mockups.", + "Test web layouts with real dimensions.", + "Generate simple placeholders for development.", + ], + limits: [ + "Maximum 8000 px per side.", + "Maximum 64 megapixels.", + "It is not an advanced editor: it creates centered text over a flat background.", + "WebP appears if your browser can export it correctly.", + ], + privacy: + "The image is generated locally in your browser; nothing is uploaded to Modulaq.", + commonErrors: [ + "Width or height with invalid values.", + "Dimensions too large for canvas.", + "Output format unavailable in the current browser.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "Quality applies to JPG and WebP, not PNG.", + "The output name is derived from dimensions.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index af28faf..a14cbad 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -31,6 +31,9 @@ const toolRenderers: Partial> = { "image-joiner": lazy(() => import("../../../tools/image/image-joiner").then((module) => ({ default: module.ImageJoinerTool })), ), + "image-placeholder": lazy(() => + import("../../../tools/image/image-placeholder").then((module) => ({ default: module.ImagePlaceholderTool })), + ), "image-splitter": lazy(() => import("../../../tools/image/image-splitter").then((module) => ({ default: module.ImageSplitterTool })), ), diff --git a/src/tools/image/image-placeholder/ImagePlaceholderTool.tsx b/src/tools/image/image-placeholder/ImagePlaceholderTool.tsx new file mode 100644 index 0000000..ad1b94e --- /dev/null +++ b/src/tools/image/image-placeholder/ImagePlaceholderTool.tsx @@ -0,0 +1,468 @@ +import { Download, Image as ImageIcon, Loader2, RotateCcw, Type } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + buildPlaceholderFileName, + defaultBackgroundColor, + defaultPlaceholderHeight, + defaultPlaceholderWidth, + defaultTextColor, + formatFileSize, + generatePlaceholderImage, + getDefaultPlaceholderText, + getImageFormatLabel, + getPlaceholderOutputBaseName, + normalizeHexColor, + validatePlaceholderDimensions, +} from "./imagePlaceholder.service"; +import type { + ImagePlaceholderOutputFormat, + ImagePlaceholderResult, + ImagePlaceholderStatus, +} from "./imagePlaceholder.types"; + +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type DownloadableResult = ImagePlaceholderResult & { + url: string; +}; + +const baseOutputFormats: ImagePlaceholderOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const copy = { + es: { + background: "Color de fondo", + dimensions: "Tamano", + downloadReady: "Placeholder listo", + height: "Alto", + outputIntro: "Todo se genera localmente en tu navegador.", + outputTitle: "Vista previa y salida", + placeholderAlt: "Vista previa del placeholder", + processing: "Generando placeholder...", + sourceIntro: "Genera una imagen placeholder para disenos, pruebas o desarrollo web.", + sourceTitle: "Configuracion", + text: "Texto", + textColor: "Color de texto", + textHelp: "Configura tamano, texto y colores.", + width: "Ancho", + }, + en: { + background: "Background color", + dimensions: "Size", + downloadReady: "Placeholder ready", + height: "Height", + outputIntro: "Everything is generated locally in your browser.", + outputTitle: "Preview and output", + placeholderAlt: "Placeholder preview", + processing: "Generating placeholder...", + sourceIntro: "Generate a placeholder image for layouts, tests or web development.", + sourceTitle: "Settings", + text: "Text", + textColor: "Text color", + textHelp: "Configure size, text and colors.", + width: "Width", + }, +} as const; + +function parseNumberInput(value: string) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : Number.NaN; +} + +export function ImagePlaceholderTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const resultUrlRef = useRef(null); + const [widthInput, setWidthInput] = useState(String(defaultPlaceholderWidth)); + const [heightInput, setHeightInput] = useState(String(defaultPlaceholderHeight)); + const [textInput, setTextInput] = useState(getDefaultPlaceholderText(defaultPlaceholderWidth, defaultPlaceholderHeight)); + const [hasCustomText, setHasCustomText] = useState(false); + const [backgroundColor, setBackgroundColor] = useState(defaultBackgroundColor); + const [textColor, setTextColor] = useState(defaultTextColor); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(getPlaceholderOutputBaseName(defaultPlaceholderWidth, defaultPlaceholderHeight)); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const width = parseNumberInput(widthInput); + const height = parseNumberInput(heightInput); + const normalizedBackgroundColor = normalizeHexColor(backgroundColor, defaultBackgroundColor); + const normalizedTextColor = normalizeHexColor(textColor, defaultTextColor); + const dimensionError = validatePlaceholderDimensions(width, height); + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const fallbackBaseName = !dimensionError + ? getPlaceholderOutputBaseName(width, height) + : getPlaceholderOutputBaseName(defaultPlaceholderWidth, defaultPlaceholderHeight); + const finalOutputFileName = buildPlaceholderFileName(outputFileName, outputFormat, fallbackBaseName); + const canGenerate = !dimensionError && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus("idle"); + } + }; + + const updateDimensions = (nextWidthInput: string, nextHeightInput: string) => { + const nextWidth = parseNumberInput(nextWidthInput); + const nextHeight = parseNumberInput(nextHeightInput); + if (Number.isInteger(nextWidth) && Number.isInteger(nextHeight) && nextWidth > 0 && nextHeight > 0) { + if (!hasCustomText) { + setTextInput(getDefaultPlaceholderText(nextWidth, nextHeight)); + } + + if (!hasCustomOutputFileName) { + setOutputFileName(getPlaceholderOutputBaseName(nextWidth, nextHeight)); + } + } + }; + + const clearSettings = () => { + clearResult(); + setWidthInput(String(defaultPlaceholderWidth)); + setHeightInput(String(defaultPlaceholderHeight)); + setTextInput(getDefaultPlaceholderText(defaultPlaceholderWidth, defaultPlaceholderHeight)); + setHasCustomText(false); + setBackgroundColor(defaultBackgroundColor); + setTextColor(defaultTextColor); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(getPlaceholderOutputBaseName(defaultPlaceholderWidth, defaultPlaceholderHeight)); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + }; + + const generateImage = async () => { + if (!canGenerate) return; + setStatus("processing"); + setError(null); + clearResult(); + + try { + const nextResult = await generatePlaceholderImage({ + backgroundColor: normalizedBackgroundColor, + height, + outputBaseName: outputFileName, + outputFormat, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + text: textInput, + textColor: normalizedTextColor, + width, + }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo generar la imagen placeholder."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ +
+

{labels.dimensions}

+
+ + +
+ {dimensionError ? ( +

+ {dimensionError} +

+ ) : null} +
+ +
+

{labels.textHelp}

+ +
+ + +
+
+ + {status === "processing" ? ( +

+ + {labels.processing} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {!dimensionError ? ( +
+ {textInput} +
+ ) : ( +
+ +

-

+
+ )} +
+ +
+

{t("imageUi.finalDimensions")}

+

{dimensionError ? "-" : `${width} x ${height}px`}

+
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ + {shouldShowQuality ? ( + + ) : null} + + + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{t("imageUi.finalDimensions")}
+
+ {result.width} x {result.height}px +
+
+
+
{t("imageUi.finalFormat")}
+
{getImageFormatLabel(result.format)}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-placeholder/README.md b/src/tools/image/image-placeholder/README.md new file mode 100644 index 0000000..e41ad9e --- /dev/null +++ b/src/tools/image/image-placeholder/README.md @@ -0,0 +1,17 @@ +# Generar imagen placeholder + +Herramienta local para generar una imagen placeholder simple para disenos, pruebas o desarrollo web. + +## Comportamiento + +- Configura ancho, alto, texto opcional, color de fondo y color de texto. +- Actualiza el texto por defecto cuando cambian las dimensiones, salvo que el usuario haya escrito texto personalizado. +- Exporta PNG, JPG o WebP si el navegador lo soporta. +- Usa canvas y descarga la imagen generada localmente. +- No usa backend ni dependencias nuevas. + +## Limites + +- Maximo 8000 px por lado. +- Maximo 64 megapixeles. +- No es un editor avanzado: genera una composicion simple con texto centrado. diff --git a/src/tools/image/image-placeholder/imagePlaceholder.service.test.ts b/src/tools/image/image-placeholder/imagePlaceholder.service.test.ts new file mode 100644 index 0000000..b3455a8 --- /dev/null +++ b/src/tools/image/image-placeholder/imagePlaceholder.service.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildPlaceholderFileName, + getDefaultPlaceholderText, + getPlaceholderOutputBaseName, + normalizeHexColor, + validatePlaceholderDimensions, +} from "./imagePlaceholder.service"; + +describe("imagePlaceholder.service", () => { + it("validates dimensions", () => { + expect(validatePlaceholderDimensions(800, 400)).toBeNull(); + expect(validatePlaceholderDimensions(0, 400)).toMatch(/mayores que cero/); + expect(validatePlaceholderDimensions(800, -1)).toMatch(/mayores que cero/); + expect(validatePlaceholderDimensions(Number.NaN, 400)).toMatch(/validos/); + }); + + it("generates default text from dimensions", () => { + expect(getDefaultPlaceholderText(800, 400)).toBe("800 x 400"); + expect(getDefaultPlaceholderText(1200, 630)).toBe("1200 x 630"); + }); + + it("builds output file names", () => { + expect(getPlaceholderOutputBaseName(800, 400)).toBe("placeholder-800x400"); + expect(buildPlaceholderFileName("placeholder-800x400", "png")).toBe("placeholder-800x400.png"); + expect(buildPlaceholderFileName("placeholder-800x400", "jpeg")).toBe("placeholder-800x400.jpg"); + expect(buildPlaceholderFileName("placeholder-800x400", "webp")).toBe("placeholder-800x400.webp"); + }); + + it("normalizes hex colors", () => { + expect(normalizeHexColor("#abc", "#000000")).toBe("#AABBCC"); + expect(normalizeHexColor("1a2b3c", "#000000")).toBe("#1A2B3C"); + expect(normalizeHexColor("not-a-color", "#000000")).toBe("#000000"); + }); + + it("enforces side and pixel limits", () => { + expect(validatePlaceholderDimensions(8001, 400)).toMatch(/8000px/); + expect(validatePlaceholderDimensions(8000, 8000)).toBeNull(); + }); +}); diff --git a/src/tools/image/image-placeholder/imagePlaceholder.service.ts b/src/tools/image/image-placeholder/imagePlaceholder.service.ts new file mode 100644 index 0000000..4c97596 --- /dev/null +++ b/src/tools/image/image-placeholder/imagePlaceholder.service.ts @@ -0,0 +1,188 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildBrowserImageDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageOutputMimeType, +} from "../../../shared/utils/imageFiles"; +import type { + ImagePlaceholderOptions, + ImagePlaceholderOutputFormat, + ImagePlaceholderResult, +} from "./imagePlaceholder.types"; + +export const defaultPlaceholderWidth = 800; +export const defaultPlaceholderHeight = 400; +export const defaultBackgroundColor = "#E5E7EB"; +export const defaultTextColor = "#374151"; +export const maxPlaceholderSide = 8000; +export const maxPlaceholderPixels = 64_000_000; + +export { formatFileSize }; + +export function getDefaultPlaceholderText(width: number, height: number) { + return `${width} x ${height}`; +} + +export function getPlaceholderOutputBaseName(width: number, height: number) { + return `placeholder-${width}x${height}`; +} + +export function buildPlaceholderFileName( + baseName: string, + outputFormat: ImagePlaceholderOutputFormat, + fallbackBaseName = getPlaceholderOutputBaseName(defaultPlaceholderWidth, defaultPlaceholderHeight), +) { + return buildBrowserImageDownloadFileName(baseName, outputFormat, fallbackBaseName); +} + +export function getImageFormatLabel(format: ImagePlaceholderOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function normalizeHexColor(value: string, fallback: string) { + const trimmedValue = value.trim(); + const shortMatch = trimmedValue.match(/^#?([0-9a-f]{3})$/i); + + if (shortMatch) { + return `#${shortMatch[1] + .split("") + .map((character) => `${character}${character}`) + .join("") + .toUpperCase()}`; + } + + const fullMatch = trimmedValue.match(/^#?([0-9a-f]{6})$/i); + if (fullMatch) { + return `#${fullMatch[1].toUpperCase()}`; + } + + return fallback; +} + +export function validatePlaceholderDimensions(width: number, height: number) { + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return "El ancho y el alto deben ser numeros validos."; + } + + if (!Number.isInteger(width) || !Number.isInteger(height)) { + return "El ancho y el alto deben ser numeros enteros."; + } + + if (width <= 0 || height <= 0) { + return "El ancho y el alto deben ser mayores que cero."; + } + + if (width > maxPlaceholderSide || height > maxPlaceholderSide) { + return `El ancho y el alto no pueden superar ${maxPlaceholderSide}px.`; + } + + if (width * height > maxPlaceholderPixels) { + return "La imagen supera el limite de 64 megapixeles."; + } + + return null; +} + +export function validatePlaceholderOptions({ backgroundColor, height, textColor, width }: ImagePlaceholderOptions) { + const dimensionError = validatePlaceholderDimensions(width, height); + if (dimensionError) return dimensionError; + + if (normalizeHexColor(backgroundColor, "") === "") { + return "El color de fondo debe ser hexadecimal."; + } + + if (normalizeHexColor(textColor, "") === "") { + return "El color de texto debe ser hexadecimal."; + } + + return null; +} + +function fitFontSize(context: CanvasRenderingContext2D, text: string, width: number, height: number) { + if (!text.trim()) { + return 0; + } + + const maxFontSize = Math.max(12, Math.floor(Math.min(width, height) / 5)); + const minFontSize = 10; + const maxTextWidth = Math.max(1, width * 0.82); + + for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1) { + context.font = `700 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`; + if (context.measureText(text).width <= maxTextWidth) { + return fontSize; + } + } + + return minFontSize; +} + +function drawPlaceholder(canvas: HTMLCanvasElement, options: ImagePlaceholderOptions) { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar la imagen placeholder."); + } + + const backgroundColor = normalizeHexColor(options.backgroundColor, defaultBackgroundColor); + const textColor = normalizeHexColor(options.textColor, defaultTextColor); + canvas.width = options.width; + canvas.height = options.height; + + context.fillStyle = backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + + const text = options.text.trim(); + if (!text) return; + + const fontSize = fitFontSize(context, text, canvas.width, canvas.height); + context.font = `700 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`; + context.fillStyle = textColor; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(text, canvas.width / 2, canvas.height / 2); +} + +export async function generatePlaceholderImage(options: ImagePlaceholderOptions): Promise { + const validationError = validatePlaceholderOptions(options); + if (validationError) { + throw new Error(validationError); + } + + if (!canExportBrowserImageFormat(options.outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(options.outputFormat)}.`); + } + + const canvas = document.createElement("canvas"); + drawPlaceholder(canvas, options); + + const mimeType = getBrowserImageOutputMimeType(options.outputFormat); + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar la imagen placeholder.", + mimeType, + quality: options.quality, + }); + + return { + bytes: result.bytes, + fileName: buildPlaceholderFileName( + options.outputBaseName, + options.outputFormat, + getPlaceholderOutputBaseName(options.width, options.height), + ), + format: options.outputFormat, + height: result.height, + mimeType, + size: result.size, + width: result.width, + }; +} diff --git a/src/tools/image/image-placeholder/imagePlaceholder.types.ts b/src/tools/image/image-placeholder/imagePlaceholder.types.ts new file mode 100644 index 0000000..2e24cca --- /dev/null +++ b/src/tools/image/image-placeholder/imagePlaceholder.types.ts @@ -0,0 +1,26 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImagePlaceholderOutputFormat = BrowserImageOutputFormat; + +export type ImagePlaceholderOptions = { + backgroundColor: string; + height: number; + outputBaseName: string; + outputFormat: ImagePlaceholderOutputFormat; + quality?: number; + text: string; + textColor: string; + width: number; +}; + +export type ImagePlaceholderResult = { + bytes: ArrayBuffer; + fileName: string; + format: ImagePlaceholderOutputFormat; + height: number; + mimeType: string; + size: number; + width: number; +}; + +export type ImagePlaceholderStatus = "idle" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-placeholder/index.ts b/src/tools/image/image-placeholder/index.ts new file mode 100644 index 0000000..8bd4325 --- /dev/null +++ b/src/tools/image/image-placeholder/index.ts @@ -0,0 +1 @@ +export { ImagePlaceholderTool } from "./ImagePlaceholderTool"; From fb37b3b9d558bb66c7419f4a0955fc2311ad130d Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 04:22:53 -0300 Subject: [PATCH 08/10] feat(image): add image watermark tool --- src/features/tools/data/tools.ts | 106 +++ .../tools/renderers/toolRenderers.tsx | 3 + .../image-watermark/ImageWatermarkTool.tsx | 676 ++++++++++++++++++ src/tools/image/image-watermark/README.md | 16 + .../imageWatermark.service.test.ts | 65 ++ .../image-watermark/imageWatermark.service.ts | 305 ++++++++ .../image-watermark/imageWatermark.types.ts | 54 ++ src/tools/image/image-watermark/index.ts | 1 + 8 files changed, 1226 insertions(+) create mode 100644 src/tools/image/image-watermark/ImageWatermarkTool.tsx create mode 100644 src/tools/image/image-watermark/README.md create mode 100644 src/tools/image/image-watermark/imageWatermark.service.test.ts create mode 100644 src/tools/image/image-watermark/imageWatermark.service.ts create mode 100644 src/tools/image/image-watermark/imageWatermark.types.ts create mode 100644 src/tools/image/image-watermark/index.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index 8188a16..cb7f748 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -1296,6 +1296,112 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "image-watermark", + slug: "agregar-marca-agua-imagen", + slugEn: "add-image-watermark", + name: { es: "Agregar marca de agua", en: "Add image watermark" }, + description: { + es: "Agrega una marca de agua de texto sobre una imagen.", + en: "Add a text watermark over an image.", + }, + category: "image", + tags: { + es: ["imagen", "marca de agua", "texto", "png", "jpg", "webp"], + en: ["image", "watermark", "text", "png", "jpg", "webp"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Agregar marca de agua a imagen online gratis", + description: + "Agrega una marca de agua de texto a una imagen PNG, JPG o WebP. Configura texto, posicion, color y opacidad desde tu navegador.", + }, + en: { + title: "Add image watermark online free", + description: + "Add a text watermark to a PNG, JPG or WebP image. Configure text, position, color and opacity from your browser.", + }, + }, + doc: { + es: { + summary: + "Agregar marca de agua permite superponer texto sobre una imagen local y exportar el resultado desde el navegador.", + howTo: [ + "Selecciona una imagen PNG, JPG/JPEG o WebP.", + "Revisa nombre, tipo, peso y dimensiones originales.", + "Escribe el texto de la marca de agua.", + "Configura tamano de fuente, color, opacidad, posicion y margen.", + "Elige PNG, JPG o WebP si el navegador lo permite.", + "Agrega la marca y descarga la imagen.", + ], + useCases: [ + "Agregar una firma o nombre a una imagen.", + "Marcar capturas o recursos visuales de trabajo.", + "Preparar una imagen con texto sobrepuesto sin subirla a un servidor.", + ], + limits: [ + "JPG no conserva transparencia.", + "WebP aparece si tu navegador permite exportarlo correctamente.", + "No promete proteccion real contra copia o uso no autorizado.", + "No incluye marca de agua con otra imagen ni drag visual.", + ], + privacy: + "El procesamiento de esta imagen ocurre en tu navegador; no la subimos a Modulaq para agregar la marca.", + commonErrors: [ + "Archivo que no es una imagen PNG, JPG o WebP.", + "Texto vacio para la marca de agua.", + "Valores invalidos de opacidad, tamano de fuente o margen.", + ], + technicalNotes: [ + "La exportacion usa canvas en el navegador.", + "La marca se dibuja con texto, globalAlpha y posicion calculada.", + "La calidad aplica a JPG y WebP, no a PNG.", + ], + }, + en: { + summary: + "Add image watermark overlays text on a local image and exports the result from the browser.", + howTo: [ + "Select a PNG, JPG/JPEG or WebP image.", + "Review the original name, type, size and dimensions.", + "Enter the watermark text.", + "Configure font size, color, opacity, position and margin.", + "Choose PNG, JPG or WebP if the browser supports it.", + "Add the watermark and download the image.", + ], + useCases: [ + "Add a signature or name to an image.", + "Mark screenshots or work visuals.", + "Prepare an image with overlaid text without uploading it to a server.", + ], + limits: [ + "JPG does not preserve transparency.", + "WebP appears if your browser can export it correctly.", + "It does not promise real protection against copying or unauthorized use.", + "It does not include image watermarks or visual drag editing.", + ], + privacy: + "Processing happens in your browser; we don't upload this image to Modulaq to add the watermark.", + commonErrors: [ + "A file that is not PNG, JPG or WebP.", + "Empty watermark text.", + "Invalid opacity, font size or margin values.", + ], + technicalNotes: [ + "Export uses browser canvas.", + "The watermark is drawn with text, globalAlpha and calculated position.", + "Quality applies to JPG and WebP, not PNG.", + ], + }, + }, + }, { id: "image-compressor", slug: "comprimir-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index a14cbad..a5615c1 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -37,6 +37,9 @@ const toolRenderers: Partial> = { "image-splitter": lazy(() => import("../../../tools/image/image-splitter").then((module) => ({ default: module.ImageSplitterTool })), ), + "image-watermark": lazy(() => + import("../../../tools/image/image-watermark").then((module) => ({ default: module.ImageWatermarkTool })), + ), "image-rotator": lazy(() => import("../../../tools/image/image-rotator").then((module) => ({ default: module.ImageRotatorTool })), ), diff --git a/src/tools/image/image-watermark/ImageWatermarkTool.tsx b/src/tools/image/image-watermark/ImageWatermarkTool.tsx new file mode 100644 index 0000000..9244e4b --- /dev/null +++ b/src/tools/image/image-watermark/ImageWatermarkTool.tsx @@ -0,0 +1,676 @@ +import { Download, FileImage, Loader2, RotateCcw, Stamp, Upload } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../../shared/components/Button"; +import type { TranslationKey } from "../../../shared/i18n/dictionaries/es"; +import { useI18n } from "../../../shared/i18n/I18nProvider"; +import { cn } from "../../../shared/utils/cn"; +import { getImageFileSizeLimitError } from "../../../shared/utils/fileProcessingLimits"; +import { + canExportBrowserImageFormat, + jpegQualityDecimalToPercent, + jpegQualityPercentToDecimal, +} from "../../../shared/utils/imageFiles"; +import { + addImageWatermark, + buildWatermarkedImageFileName, + calculateWatermarkPosition, + defaultOutputBaseName, + defaultWatermarkColor, + defaultWatermarkFontSize, + defaultWatermarkMargin, + defaultWatermarkOpacity, + defaultWatermarkText, + formatFileSize, + getImageFormatLabel, + getImageMimeLabel, + getWatermarkedImageOutputBaseName, + normalizeHexColor, + readImageMetadata, + validateWatermarkFontSize, + validateWatermarkMargin, + validateWatermarkOpacity, +} from "./imageWatermark.service"; +import type { + ImageWatermarkMetadata, + ImageWatermarkOutputFormat, + ImageWatermarkPosition, + ImageWatermarkResult, + ImageWatermarkStatus, +} from "./imageWatermark.types"; + +const acceptedImageTypes = "image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"; +const inputClassName = + "min-h-11 rounded-lg border border-surface-200/90 bg-surface-50/95 px-3 text-sm font-normal text-ink-900 shadow-sm outline-none transition placeholder:text-ink-500/70 focus:border-accent-cyan focus:bg-surface-50 focus:ring-2 focus:ring-accent-cyan/25"; + +type DownloadableResult = ImageWatermarkResult & { + url: string; +}; + +const baseOutputFormats: ImageWatermarkOutputFormat[] = ["png", "jpeg"]; + +const formatDescKeys: Record = { + png: "imageUi.png.desc", + jpeg: "imageUi.jpg.desc", + webp: "imageUi.webp.desc", +}; + +const positionOptions: ImageWatermarkPosition[] = [ + "top-left", + "top-right", + "center", + "bottom-left", + "bottom-right", +]; + +const copy = { + es: { + bottomLeft: "Abajo izquierda", + bottomRight: "Abajo derecha", + center: "Centro", + color: "Color", + downloadReady: "Imagen con marca lista", + fontSize: "Tamano de fuente", + jpgTransparency: "JPG no conserva transparencia.", + margin: "Margen", + opacity: "Opacidad", + outputIntro: "Todo se procesa localmente en tu navegador.", + outputTitle: "Vista previa y salida", + position: "Posicion", + previewAlt: "Vista previa de marca de agua", + processing: "Agregando marca de agua...", + sourceIntro: "Agrega una marca de agua de texto sobre una imagen.", + sourceTitle: "Imagen y marca", + text: "Texto", + textHelp: "Configura texto, posicion, color y opacidad.", + topLeft: "Arriba izquierda", + topRight: "Arriba derecha", + }, + en: { + bottomLeft: "Bottom left", + bottomRight: "Bottom right", + center: "Center", + color: "Color", + downloadReady: "Watermarked image ready", + fontSize: "Font size", + jpgTransparency: "JPG does not preserve transparency.", + margin: "Margin", + opacity: "Opacity", + outputIntro: "Everything is processed locally in your browser.", + outputTitle: "Preview and output", + position: "Position", + previewAlt: "Watermark preview", + processing: "Adding watermark...", + sourceIntro: "Add a text watermark over an image.", + sourceTitle: "Image and watermark", + text: "Text", + textHelp: "Configure text, position, color and opacity.", + topLeft: "Top left", + topRight: "Top right", + }, +} as const; + +const positionLabelKeys: Record = { + "top-left": "topLeft", + "top-right": "topRight", + center: "center", + "bottom-left": "bottomLeft", + "bottom-right": "bottomRight", +}; + +function parseNumberInput(value: string) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : Number.NaN; +} + +function getPreviewTextDimensions(text: string, fontSize: number) { + return { + height: fontSize, + width: Math.max(fontSize, Math.round(text.length * fontSize * 0.58)), + }; +} + +function getPreviewWatermarkStyle( + metadata: ImageWatermarkMetadata, + text: string, + fontSize: number, + position: ImageWatermarkPosition, + margin: number, +) { + const textDimensions = getPreviewTextDimensions(text, fontSize); + const coordinates = calculateWatermarkPosition(metadata, textDimensions, position, margin); + + return { + fontSize: `${(fontSize / metadata.height) * 100}%`, + left: `${(coordinates.x / metadata.width) * 100}%`, + lineHeight: 1, + top: `${(coordinates.y / metadata.height) * 100}%`, + }; +} + +export function ImageWatermarkTool() { + const { language, t } = useI18n(); + const labels = copy[language]; + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const resultUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [metadata, setMetadata] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [text, setText] = useState(defaultWatermarkText); + const [fontSizeInput, setFontSizeInput] = useState(String(defaultWatermarkFontSize)); + const [color, setColor] = useState(defaultWatermarkColor); + const [opacityPercent, setOpacityPercent] = useState(Math.round(defaultWatermarkOpacity * 100)); + const [position, setPosition] = useState("bottom-right"); + const [marginInput, setMarginInput] = useState(String(defaultWatermarkMargin)); + const [outputFormat, setOutputFormat] = useState("png"); + const [qualityPercent, setQualityPercent] = useState(jpegQualityDecimalToPercent(0.92)); + const [outputFileName, setOutputFileName] = useState(defaultOutputBaseName); + const [hasCustomOutputFileName, setHasCustomOutputFileName] = useState(false); + const [webpSupported, setWebpSupported] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + setWebpSupported(canExportBrowserImageFormat("webp")); + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); + }; + }, []); + + const outputFormats = useMemo( + () => (webpSupported ? [...baseOutputFormats, "webp" as const] : baseOutputFormats), + [webpSupported], + ); + const fontSize = parseNumberInput(fontSizeInput); + const margin = parseNumberInput(marginInput); + const opacity = opacityPercent / 100; + const normalizedColor = normalizeHexColor(color); + const fontSizeError = validateWatermarkFontSize(fontSize); + const marginError = validateWatermarkMargin(margin); + const opacityError = validateWatermarkOpacity(opacity); + const textError = text.trim() ? null : language === "en" ? "Enter watermark text." : "Ingresa un texto para la marca de agua."; + const watermarkError = textError ?? fontSizeError ?? marginError ?? opacityError; + const shouldShowQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const fallbackBaseName = metadata ? getWatermarkedImageOutputBaseName(metadata.fileName) : defaultOutputBaseName; + const finalOutputFileName = buildWatermarkedImageFileName(outputFileName, outputFormat, fallbackBaseName); + const canApply = Boolean(file && metadata) && !watermarkError && status !== "reading" && status !== "processing"; + + const clearResult = () => { + if (resultUrlRef.current) { + URL.revokeObjectURL(resultUrlRef.current); + resultUrlRef.current = null; + } + setResult(null); + }; + + const resetFeedback = () => { + setError(null); + clearResult(); + if (status === "success" || status === "error") { + setStatus(metadata ? "ready" : "idle"); + } + }; + + const processFile = async (nextFile: File | undefined) => { + if (!nextFile) return; + setStatus("reading"); + setFile(null); + setMetadata(null); + setError(null); + clearResult(); + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + setPreviewUrl(null); + } + + const fileLimitError = getImageFileSizeLimitError(nextFile); + if (fileLimitError) { + setStatus("error"); + setError(fileLimitError); + return; + } + + try { + const nextMetadata = await readImageMetadata(nextFile); + const nextPreviewUrl = URL.createObjectURL(nextFile); + previewUrlRef.current = nextPreviewUrl; + setPreviewUrl(nextPreviewUrl); + setFile(nextFile); + setMetadata(nextMetadata); + if (!hasCustomOutputFileName) { + setOutputFileName(getWatermarkedImageOutputBaseName(nextFile.name)); + } + setStatus("ready"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : t("imageUi.couldNotRead")); + } + }; + + const clearSelection = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + clearResult(); + setFile(null); + setMetadata(null); + setPreviewUrl(null); + setText(defaultWatermarkText); + setFontSizeInput(String(defaultWatermarkFontSize)); + setColor(defaultWatermarkColor); + setOpacityPercent(Math.round(defaultWatermarkOpacity * 100)); + setPosition("bottom-right"); + setMarginInput(String(defaultWatermarkMargin)); + setOutputFormat("png"); + setQualityPercent(jpegQualityDecimalToPercent(0.92)); + setOutputFileName(defaultOutputBaseName); + setHasCustomOutputFileName(false); + setStatus("idle"); + setError(null); + setIsDragging(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const applyWatermark = async () => { + if (!file || !canApply) return; + setStatus("processing"); + setError(null); + clearResult(); + + try { + const nextResult = await addImageWatermark(file, { + color: normalizedColor, + fontSize, + margin, + opacity, + outputBaseName: outputFileName, + outputFormat, + position, + quality: shouldShowQuality ? jpegQualityPercentToDecimal(qualityPercent) : undefined, + text, + }); + const resultUrl = URL.createObjectURL(new Blob([nextResult.bytes], { type: nextResult.mimeType })); + resultUrlRef.current = resultUrl; + setResult({ ...nextResult, url: resultUrl }); + setStatus("success"); + } catch (nextError) { + setStatus("error"); + setError(nextError instanceof Error ? nextError.message : "No se pudo agregar la marca de agua."); + } + }; + + const downloadResult = () => { + if (!result) return; + const link = document.createElement("a"); + link.href = result.url; + link.download = result.fileName; + link.click(); + }; + + return ( +
+
+
+
+

{labels.sourceTitle}

+

{labels.sourceIntro}

+
+ + +

{t("imageUi.maxSize")}

+ + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {metadata ? ( +
+ {previewUrl ? ( + + ) : ( + + + + )} +
+
+
{t("imageUi.fileName")}
+
{metadata.fileName}
+
+
+
+
{t("imageUi.type")}
+
{getImageMimeLabel(metadata.mimeType)}
+
+
+
{t("imageUi.originalSize")}
+
{formatFileSize(metadata.fileSize)}
+
+
+
{t("imageUi.dimensions")}
+
+ {metadata.width} x {metadata.height}px +
+
+
+
+
+ ) : null} + +
+

{labels.textHelp}

+ +
+ + + +
+ + + +
+ {positionOptions.map((nextPosition) => ( + + ))} +
+ + {watermarkError ? ( +

+ {watermarkError} +

+ ) : null} +
+ + {status === "reading" || status === "processing" ? ( +

+ + {status === "processing" ? labels.processing : t("imageUi.reading")} +

+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+

{labels.outputTitle}

+

{labels.outputIntro}

+
+ +
+
+ {previewUrl && metadata ? ( +
+ {labels.previewAlt} + {text.trim() && !watermarkError ? ( + + {text} + + ) : null} +
+ ) : ( +
+ +

{t("imageUi.selectImage")}

+
+ )} +
+ +
+ {outputFormats.map((format) => ( + + ))} +
+ +

{language === "en" ? "WebP appears if your browser can export it correctly." : "WebP aparece si tu navegador permite exportarlo correctamente."}

+ + {shouldShowQuality ? ( + + ) : null} + + {outputFormat === "jpeg" ? ( +

+ {labels.jpgTransparency} +

+ ) : null} + + + + {result ? ( +
+

{labels.downloadReady}

+
+
+
{t("imageUi.finalDimensions")}
+
+ {result.width} x {result.height}px +
+
+
+
{t("imageUi.finalFormat")}
+
{getImageFormatLabel(result.format)}
+
+
+
{t("imageUi.finalSize")}
+
{formatFileSize(result.size)}
+
+
+ +
+ ) : null} + +
+ + + +
+
+
+
+ ); +} diff --git a/src/tools/image/image-watermark/README.md b/src/tools/image/image-watermark/README.md new file mode 100644 index 0000000..ca9544e --- /dev/null +++ b/src/tools/image/image-watermark/README.md @@ -0,0 +1,16 @@ +# Agregar marca de agua a imagen + +Herramienta local para agregar una marca de agua de texto sobre una imagen. + +## Comportamiento + +- Acepta imagenes PNG, JPG/JPEG o WebP que el navegador pueda decodificar. +- Permite configurar texto, tamano de fuente, color, opacidad, posicion y margen. +- Posiciones: arriba izquierda, arriba derecha, centro, abajo izquierda y abajo derecha. +- Exporta PNG, JPG o WebP si el navegador lo soporta. +- JPG no conserva transparencia. +- Todo el procesamiento ocurre en el navegador. + +## Limites + +Esta primera version no soporta marca de agua con imagen, drag visual ni edicion avanzada de fuentes. diff --git a/src/tools/image/image-watermark/imageWatermark.service.test.ts b/src/tools/image/image-watermark/imageWatermark.service.test.ts new file mode 100644 index 0000000..489eef1 --- /dev/null +++ b/src/tools/image/image-watermark/imageWatermark.service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + buildWatermarkedImageFileName, + calculateWatermarkPosition, + getWatermarkedImageOutputBaseName, + normalizeHexColor, + validateWatermarkFontSize, + validateWatermarkMargin, + validateWatermarkOpacity, +} from "./imageWatermark.service"; + +const imageDimensions = { width: 1000, height: 600 }; +const textDimensions = { width: 200, height: 50 }; + +describe("imageWatermark.service", () => { + it("calculates top-left watermark position", () => { + expect(calculateWatermarkPosition(imageDimensions, textDimensions, "top-left", 20)).toEqual({ x: 20, y: 20 }); + }); + + it("calculates top-right watermark position", () => { + expect(calculateWatermarkPosition(imageDimensions, textDimensions, "top-right", 20)).toEqual({ x: 780, y: 20 }); + }); + + it("calculates centered watermark position", () => { + expect(calculateWatermarkPosition(imageDimensions, textDimensions, "center", 20)).toEqual({ x: 400, y: 275 }); + }); + + it("calculates bottom positions with margin", () => { + expect(calculateWatermarkPosition(imageDimensions, textDimensions, "bottom-left", 20)).toEqual({ x: 20, y: 530 }); + expect(calculateWatermarkPosition(imageDimensions, textDimensions, "bottom-right", 20)).toEqual({ x: 780, y: 530 }); + }); + + it("validates opacity", () => { + expect(validateWatermarkOpacity(0.1)).toBeNull(); + expect(validateWatermarkOpacity(1)).toBeNull(); + expect(validateWatermarkOpacity(0)).toMatch(/10%/); + expect(validateWatermarkOpacity(1.1)).toMatch(/100%/); + }); + + it("validates font size", () => { + expect(validateWatermarkFontSize(48)).toBeNull(); + expect(validateWatermarkFontSize(7)).toMatch(/8/); + expect(validateWatermarkFontSize(513)).toMatch(/512/); + expect(validateWatermarkFontSize(12.5)).toMatch(/entero/); + }); + + it("validates margin", () => { + expect(validateWatermarkMargin(0)).toBeNull(); + expect(validateWatermarkMargin(24)).toBeNull(); + expect(validateWatermarkMargin(-1)).toMatch(/margen/); + expect(validateWatermarkMargin(1.5)).toMatch(/entero/); + }); + + it("builds output names", () => { + expect(getWatermarkedImageOutputBaseName("foto.png")).toBe("foto-marca-agua"); + expect(buildWatermarkedImageFileName("foto-marca-agua", "png")).toBe("foto-marca-agua.png"); + expect(buildWatermarkedImageFileName("foto-marca-agua", "jpeg")).toBe("foto-marca-agua.jpg"); + expect(buildWatermarkedImageFileName("foto-marca-agua", "webp")).toBe("foto-marca-agua.webp"); + }); + + it("normalizes hex colors", () => { + expect(normalizeHexColor("#abc")).toBe("#AABBCC"); + expect(normalizeHexColor("123456")).toBe("#123456"); + }); +}); diff --git a/src/tools/image/image-watermark/imageWatermark.service.ts b/src/tools/image/image-watermark/imageWatermark.service.ts new file mode 100644 index 0000000..1d439a3 --- /dev/null +++ b/src/tools/image/image-watermark/imageWatermark.service.ts @@ -0,0 +1,305 @@ +import { formatFileSize } from "../../../shared/utils/file"; +import { + buildBrowserImageDownloadFileName, + canExportBrowserImageFormat, + exportBrowserCanvas, + getBrowserImageMimeType, + getBrowserImageOutputMimeType, + getImageDownloadBaseName, + isBrowserImageFile, + loadBrowserImage, +} from "../../../shared/utils/imageFiles"; +import type { + ImageDimensions, + ImageWatermarkMetadata, + ImageWatermarkOptions, + ImageWatermarkOutputFormat, + ImageWatermarkPosition, + ImageWatermarkResult, + TextBoxDimensions, +} from "./imageWatermark.types"; + +export const defaultOutputBaseName = "imagen-marca-agua"; +export const defaultWatermarkText = "Modulaq"; +export const defaultWatermarkColor = "#FFFFFF"; +export const defaultWatermarkOpacity = 0.65; +export const defaultWatermarkFontSize = 48; +export const defaultWatermarkMargin = 32; +export const minWatermarkFontSize = 8; +export const maxWatermarkFontSize = 512; +export const maxWatermarkMargin = 10_000; + +export { formatFileSize }; + +export function isWatermarkableImageFile(file: File) { + return isBrowserImageFile(file); +} + +export function getImageFormatLabel(format: ImageWatermarkOutputFormat) { + if (format === "jpeg") { + return "JPG"; + } + + if (format === "webp") { + return "WebP"; + } + + return "PNG"; +} + +export function getImageMimeLabel(mimeType: string) { + if (mimeType === "image/jpeg") { + return "JPG"; + } + + if (mimeType === "image/webp") { + return "WebP"; + } + + if (mimeType === "image/png") { + return "PNG"; + } + + return mimeType || "Desconocido"; +} + +export function getWatermarkedImageOutputBaseName(fileName: string) { + const baseName = getImageDownloadBaseName(fileName, defaultOutputBaseName); + return `${baseName}-marca-agua`; +} + +export function buildWatermarkedImageFileName( + baseName: string, + outputFormat: ImageWatermarkOutputFormat, + fallbackBaseName = defaultOutputBaseName, +) { + return buildBrowserImageDownloadFileName(baseName, outputFormat, fallbackBaseName); +} + +export function normalizeHexColor(value: string, fallback = defaultWatermarkColor) { + const trimmedValue = value.trim(); + const shortMatch = trimmedValue.match(/^#?([0-9a-f]{3})$/i); + + if (shortMatch) { + return `#${shortMatch[1] + .split("") + .map((character) => `${character}${character}`) + .join("") + .toUpperCase()}`; + } + + const fullMatch = trimmedValue.match(/^#?([0-9a-f]{6})$/i); + if (fullMatch) { + return `#${fullMatch[1].toUpperCase()}`; + } + + return fallback; +} + +export function validateWatermarkOpacity(opacity: number) { + if (!Number.isFinite(opacity)) { + return "La opacidad debe ser un numero valido."; + } + + if (opacity < 0.1 || opacity > 1) { + return "La opacidad debe estar entre 10% y 100%."; + } + + return null; +} + +export function validateWatermarkFontSize(fontSize: number) { + if (!Number.isFinite(fontSize)) { + return "El tamano de fuente debe ser un numero valido."; + } + + if (!Number.isInteger(fontSize)) { + return "El tamano de fuente debe ser un numero entero."; + } + + if (fontSize < minWatermarkFontSize || fontSize > maxWatermarkFontSize) { + return `El tamano de fuente debe estar entre ${minWatermarkFontSize} y ${maxWatermarkFontSize}px.`; + } + + return null; +} + +export function validateWatermarkMargin(margin: number) { + if (!Number.isFinite(margin)) { + return "El margen debe ser un numero valido."; + } + + if (!Number.isInteger(margin)) { + return "El margen debe ser un numero entero."; + } + + if (margin < 0 || margin > maxWatermarkMargin) { + return "El margen debe ser cero o mayor y no superar el limite permitido."; + } + + return null; +} + +export function validateWatermarkOptions({ color, fontSize, margin, opacity, text }: ImageWatermarkOptions) { + if (!text.trim()) { + return "Ingresa un texto para la marca de agua."; + } + + const fontSizeError = validateWatermarkFontSize(fontSize); + if (fontSizeError) return fontSizeError; + + const opacityError = validateWatermarkOpacity(opacity); + if (opacityError) return opacityError; + + const marginError = validateWatermarkMargin(margin); + if (marginError) return marginError; + + if (normalizeHexColor(color, "") === "") { + return "El color debe ser hexadecimal."; + } + + return null; +} + +export function calculateWatermarkPosition( + imageDimensions: ImageDimensions, + textDimensions: TextBoxDimensions, + position: ImageWatermarkPosition, + margin: number, +) { + const centerX = Math.round((imageDimensions.width - textDimensions.width) / 2); + const centerY = Math.round((imageDimensions.height - textDimensions.height) / 2); + + if (position === "top-left") { + return { x: margin, y: margin }; + } + + if (position === "top-right") { + return { x: imageDimensions.width - textDimensions.width - margin, y: margin }; + } + + if (position === "bottom-left") { + return { x: margin, y: imageDimensions.height - textDimensions.height - margin }; + } + + if (position === "bottom-right") { + return { + x: imageDimensions.width - textDimensions.width - margin, + y: imageDimensions.height - textDimensions.height - margin, + }; + } + + return { x: centerX, y: centerY }; +} + +export async function readImageMetadata(file: File): Promise { + const mimeType = getBrowserImageMimeType(file); + + if (!mimeType) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + try { + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("La imagen no tiene dimensiones validas."); + } + + return { + fileName: file.name, + fileSize: file.size, + height: image.naturalHeight, + mimeType, + width: image.naturalWidth, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error("No se pudo leer la imagen."); + } +} + +function configureWatermarkFont(context: CanvasRenderingContext2D, fontSize: number) { + context.font = `700 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`; +} + +function measureWatermarkText(context: CanvasRenderingContext2D, text: string, fontSize: number): TextBoxDimensions { + configureWatermarkFont(context, fontSize); + const metrics = context.measureText(text); + return { + height: Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent) || fontSize, + width: Math.ceil(metrics.width), + }; +} + +export async function addImageWatermark(file: File, options: ImageWatermarkOptions): Promise { + if (!isWatermarkableImageFile(file)) { + throw new Error("Selecciona una imagen PNG, JPG o WebP valida."); + } + + const validationError = validateWatermarkOptions(options); + if (validationError) { + throw new Error(validationError); + } + + if (!canExportBrowserImageFormat(options.outputFormat)) { + throw new Error(`Este navegador no permite exportar imagenes como ${getImageFormatLabel(options.outputFormat)}.`); + } + + const image = await loadBrowserImage(file, "No se pudo decodificar la imagen en este navegador."); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("No se pudo preparar la imagen."); + } + + if (options.outputFormat === "jpeg") { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + context.drawImage(image, 0, 0); + const textDimensions = measureWatermarkText(context, options.text, options.fontSize); + const coordinates = calculateWatermarkPosition( + { height: canvas.height, width: canvas.width }, + textDimensions, + options.position, + options.margin, + ); + + context.save(); + context.globalAlpha = options.opacity; + configureWatermarkFont(context, options.fontSize); + context.fillStyle = normalizeHexColor(options.color); + context.textAlign = "left"; + context.textBaseline = "top"; + context.fillText(options.text, coordinates.x, coordinates.y); + context.restore(); + + const mimeType = getBrowserImageOutputMimeType(options.outputFormat); + const result = await exportBrowserCanvas(canvas, { + errorMessage: "No se pudo exportar la imagen con marca de agua.", + mimeType, + quality: options.quality, + }); + + return { + bytes: result.bytes, + fileName: buildWatermarkedImageFileName( + options.outputBaseName, + options.outputFormat, + getWatermarkedImageOutputBaseName(file.name), + ), + format: options.outputFormat, + height: result.height, + mimeType, + size: result.size, + width: result.width, + }; +} diff --git a/src/tools/image/image-watermark/imageWatermark.types.ts b/src/tools/image/image-watermark/imageWatermark.types.ts new file mode 100644 index 0000000..c6554c9 --- /dev/null +++ b/src/tools/image/image-watermark/imageWatermark.types.ts @@ -0,0 +1,54 @@ +import type { BrowserImageOutputFormat } from "../../../shared/utils/imageFiles"; + +export type ImageWatermarkPosition = + | "top-left" + | "top-right" + | "center" + | "bottom-left" + | "bottom-right"; + +export type ImageDimensions = { + height: number; + width: number; +}; + +export type TextBoxDimensions = ImageDimensions; + +export type ImageWatermarkOptions = { + color: string; + fontSize: number; + margin: number; + opacity: number; + outputBaseName: string; + outputFormat: ImageWatermarkOutputFormat; + position: ImageWatermarkPosition; + quality?: number; + text: string; +}; + +export type WatermarkCoordinates = { + x: number; + y: number; +}; + +export type ImageWatermarkMetadata = { + fileName: string; + fileSize: number; + height: number; + mimeType: string; + width: number; +}; + +export type ImageWatermarkOutputFormat = BrowserImageOutputFormat; + +export type ImageWatermarkResult = { + bytes: ArrayBuffer; + fileName: string; + format: ImageWatermarkOutputFormat; + height: number; + mimeType: string; + size: number; + width: number; +}; + +export type ImageWatermarkStatus = "idle" | "reading" | "ready" | "processing" | "success" | "error"; diff --git a/src/tools/image/image-watermark/index.ts b/src/tools/image/image-watermark/index.ts new file mode 100644 index 0000000..8fb79e8 --- /dev/null +++ b/src/tools/image/image-watermark/index.ts @@ -0,0 +1 @@ +export { ImageWatermarkTool } from "./ImageWatermarkTool"; From f4df4b157d7ac2f75fe8d801c15fa70780bcff15 Mon Sep 17 00:00:00 2001 From: "NELSON_PC\\nelso" Date: Sat, 6 Jun 2026 04:34:45 -0300 Subject: [PATCH 09/10] feat(image): add SVG to PNG tool --- src/features/tools/data/tools.ts | 104 ++++ .../tools/renderers/toolRenderers.tsx | 3 + src/tools/image/svg-to-png/README.md | 18 + src/tools/image/svg-to-png/SvgToPngTool.tsx | 569 ++++++++++++++++++ src/tools/image/svg-to-png/index.ts | 1 + .../image/svg-to-png/svgToPng.service.test.ts | 80 +++ .../image/svg-to-png/svgToPng.service.ts | 235 ++++++++ src/tools/image/svg-to-png/svgToPng.types.ts | 37 ++ 8 files changed, 1047 insertions(+) create mode 100644 src/tools/image/svg-to-png/README.md create mode 100644 src/tools/image/svg-to-png/SvgToPngTool.tsx create mode 100644 src/tools/image/svg-to-png/index.ts create mode 100644 src/tools/image/svg-to-png/svgToPng.service.test.ts create mode 100644 src/tools/image/svg-to-png/svgToPng.service.ts create mode 100644 src/tools/image/svg-to-png/svgToPng.types.ts diff --git a/src/features/tools/data/tools.ts b/src/features/tools/data/tools.ts index cb7f748..b1c8db1 100644 --- a/src/features/tools/data/tools.ts +++ b/src/features/tools/data/tools.ts @@ -1296,6 +1296,110 @@ export async function imagesToPdf(files: File[]): Promise { }, }, }, + { + id: "svg-to-png", + slug: "svg-a-png", + slugEn: "svg-to-png", + name: { es: "SVG a PNG", en: "SVG to PNG" }, + description: { + es: "Convierte un archivo SVG a PNG desde tu navegador.", + en: "Convert an SVG file to PNG from your browser.", + }, + category: "image", + tags: { + es: ["svg", "png", "imagen", "vector", "convertir"], + en: ["svg", "png", "image", "vector", "convert"], + }, + modes: v11AvailableModes, + plannedModes: v11PlannedModes, + status: "active", + pricing: "free", + requiresBackend: false, + requiresAI: false, + apiStatus: "planned", + seo: { + es: { + title: "Convertir SVG a PNG online gratis", + description: + "Convierte SVG simple a PNG desde tu navegador. Ajusta tamano y fondo antes de descargar, sin backend ni subir archivos.", + }, + en: { + title: "Convert SVG to PNG online free", + description: + "Convert simple SVG to PNG from your browser. Adjust size and background before downloading, with no backend or uploads.", + }, + }, + doc: { + es: { + summary: + "SVG a PNG convierte un archivo SVG o codigo SVG pegado en una imagen PNG generada localmente.", + howTo: [ + "Selecciona un archivo SVG o pega codigo SVG.", + "Revisa el nombre, peso y dimensiones detectadas desde width/height o viewBox.", + "Ajusta ancho, alto, fondo transparente o color de fondo.", + "Convierte y descarga el PNG.", + ], + useCases: [ + "Exportar un logo SVG como PNG.", + "Crear una version raster para compartir o usar en una pagina.", + "Probar rapidamente un SVG pegado como imagen PNG.", + ], + limits: [ + "Algunos SVG con recursos externos o scripts pueden no procesarse.", + "Los scripts se rechazan.", + "Los SVG con recursos externos pueden no renderizarse igual.", + "Maximo 8000 px por lado y 64 megapixeles de salida.", + ], + privacy: + "Todo se procesa localmente en tu navegador; no subimos el SVG a Modulaq.", + commonErrors: [ + "Contenido sin etiqueta SVG valida.", + "SVG con scripts.", + "Dimensiones de salida no validas o demasiado grandes.", + "SVG con recursos externos que el navegador no puede renderizar en canvas.", + ], + technicalNotes: [ + "La conversion usa Blob SVG, Image, canvas y exportacion PNG en el navegador.", + "Solo exporta PNG.", + "No promete compatibilidad perfecta con todos los SVG.", + ], + }, + en: { + summary: + "SVG to PNG converts an SVG file or pasted SVG code into a locally generated PNG image.", + howTo: [ + "Select an SVG file or paste SVG code.", + "Review the detected name, size and dimensions from width/height or viewBox.", + "Adjust width, height, transparent background or background color.", + "Convert and download the PNG.", + ], + useCases: [ + "Export an SVG logo as PNG.", + "Create a raster version to share or use on a page.", + "Quickly test pasted SVG as a PNG image.", + ], + limits: [ + "Some SVGs with external resources or scripts may not process.", + "Scripts are rejected.", + "SVGs with external resources may not render the same.", + "Maximum 8000 px per side and 64 megapixels output.", + ], + privacy: + "Everything is processed locally in your browser; we don't upload the SVG to Modulaq.", + commonErrors: [ + "Content without a valid SVG tag.", + "SVG with scripts.", + "Invalid or too large output dimensions.", + "SVG with external resources the browser cannot render into canvas.", + ], + technicalNotes: [ + "Conversion uses an SVG Blob, Image, canvas and PNG export in the browser.", + "It only exports PNG.", + "It does not promise perfect compatibility with every SVG.", + ], + }, + }, + }, { id: "image-watermark", slug: "agregar-marca-agua-imagen", diff --git a/src/features/tools/renderers/toolRenderers.tsx b/src/features/tools/renderers/toolRenderers.tsx index a5615c1..b619bec 100644 --- a/src/features/tools/renderers/toolRenderers.tsx +++ b/src/features/tools/renderers/toolRenderers.tsx @@ -37,6 +37,9 @@ const toolRenderers: Partial> = { "image-splitter": lazy(() => import("../../../tools/image/image-splitter").then((module) => ({ default: module.ImageSplitterTool })), ), + "svg-to-png": lazy(() => + import("../../../tools/image/svg-to-png").then((module) => ({ default: module.SvgToPngTool })), + ), "image-watermark": lazy(() => import("../../../tools/image/image-watermark").then((module) => ({ default: module.ImageWatermarkTool })), ), diff --git a/src/tools/image/svg-to-png/README.md b/src/tools/image/svg-to-png/README.md new file mode 100644 index 0000000..9dcc188 --- /dev/null +++ b/src/tools/image/svg-to-png/README.md @@ -0,0 +1,18 @@ +# SVG a PNG + +Herramienta local para convertir SVG simple a PNG desde el navegador. + +## Alcance + +- Permite subir un archivo `.svg` o pegar codigo SVG. +- Valida que exista una etiqueta ``. +- Rechaza SVG con `
+
+
+
+

{labels.sourceTitle}

+

{labels.inputHelp}

+
+ + + + { + void processFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> + +