From 09b91a3566a08d744f297883cd6cdcba536ef847 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:16:56 +0900 Subject: [PATCH 01/34] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20Share=20im?= =?UTF-8?q?age=20output=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image/converter/converter.js | 119 +++++------------------------ tools/shared/image-output.js | 118 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 101 deletions(-) create mode 100644 tools/shared/image-output.js diff --git a/tools/image/converter/converter.js b/tools/image/converter/converter.js index 59a5210..c170a5a 100644 --- a/tools/image/converter/converter.js +++ b/tools/image/converter/converter.js @@ -1,74 +1,33 @@ import { decodeImage } from "../../shared/image.js"; +import { + createImageOutputNames, + DEFAULT_QUALITY, + IMAGE_FORMATS, + MAX_BASE_BYTES, + MAX_BASE_CHARACTERS, + normalizeFormat, + normalizeQuality, + packageImageOutputs, + renderDecodedImage, + sourceBaseName, +} from "../../shared/image-output.js"; -export const IMAGE_FORMATS = Object.freeze({ - jpeg: { extension: "jpg", mimeType: "image/jpeg", label: "JPEG" }, - png: { extension: "png", mimeType: "image/png", label: "PNG" }, - webp: { extension: "webp", mimeType: "image/webp", label: "WebP" }, -}); -export const DEFAULT_QUALITY = 0.92; -export const MIN_QUALITY = 0.5; -export const MAX_QUALITY = 1; +export { + DEFAULT_QUALITY, IMAGE_FORMATS, MAX_BASE_BYTES, MAX_BASE_CHARACTERS, + normalizeFormat, normalizeQuality, renderDecodedImage, sourceBaseName, +}; export const MAX_JOB_PIXELS = 200_000_000; export const ARCHIVE_FILENAME = "converted_images.zip"; -const ILLEGAL_FILENAME_CHARACTERS = /[\\/:*?"<>|\u0000-\u001f]+/g; -const INPUT_EXTENSION = /\.(?:jpe?g|png|webp)$/i; -export const MAX_BASE_CHARACTERS = 120; -export const MAX_BASE_BYTES = 180; - function conversionError(code, cause) { const error = new Error(code, cause ? { cause } : undefined); error.code = code; return error; } -export function normalizeFormat(value) { - const format = String(value || "").toLowerCase(); - if (!IMAGE_FORMATS[format]) throw conversionError("IMAGE_FORMAT_INVALID"); - return format; -} - -export function normalizeQuality(format, value = DEFAULT_QUALITY) { - if (normalizeFormat(format) === "png") return null; - const quality = Number(value); - if (!Number.isFinite(quality)) return DEFAULT_QUALITY; - return Math.min(MAX_QUALITY, Math.max(MIN_QUALITY, quality)); -} - -export function sourceBaseName(value, fallback = "image") { - const clean = String(value || "") - .trim() - .replace(INPUT_EXTENSION, "") - .replace(ILLEGAL_FILENAME_CHARACTERS, "_") - .replace(/[. ]+$/g, ""); - const encoder = new TextEncoder(); - const output = []; - let bytes = 0; - for (const character of Array.from(clean || fallback).slice(0, MAX_BASE_CHARACTERS)) { - const characterBytes = encoder.encode(character).length; - if (bytes + characterBytes > MAX_BASE_BYTES) break; - output.push(character); - bytes += characterBytes; - } - return output.join("") || fallback; -} - export function createOutputNames(files, format) { const normalizedFormat = normalizeFormat(format); - const extension = IMAGE_FORMATS[normalizedFormat].extension; - const used = new Set(); - const entries = files.map((file) => { - const base = sourceBaseName(file?.name); - let suffix = 1; - let candidate = `${base}.${extension}`; - while (used.has(candidate.toLocaleLowerCase("en-US"))) { - suffix += 1; - candidate = `${base}_${suffix}.${extension}`; - } - used.add(candidate.toLocaleLowerCase("en-US")); - return candidate; - }); - return { archive: ARCHIVE_FILENAME, entries }; + return createImageOutputNames(files, normalizedFormat, { archive: ARCHIVE_FILENAME }); } export function createConversionPlan({ files, format, quality }) { @@ -82,50 +41,8 @@ export function createConversionPlan({ files, format, quality }) { }; } -export function canvasToBlob(canvas, mimeType, quality) { - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (!blob || blob.type !== mimeType) reject(conversionError("IMAGE_ENCODER_FAILED")); - else resolve(blob); - }, mimeType, quality ?? undefined); - }); -} - -export async function renderDecodedImage({ decoded, format, quality, canvasFactory = () => document.createElement("canvas") }) { - const normalizedFormat = normalizeFormat(format); - const canvas = canvasFactory(); - canvas.width = decoded.width; - canvas.height = decoded.height; - const jpeg = normalizedFormat === "jpeg"; - const context = canvas.getContext("2d", { alpha: !jpeg }); - if (!context) throw conversionError("CANVAS_UNAVAILABLE"); - try { - if (jpeg) { - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, canvas.width, canvas.height); - } - context.drawImage(decoded.source, 0, 0); - return await canvasToBlob(canvas, IMAGE_FORMATS[normalizedFormat].mimeType, normalizeQuality(normalizedFormat, quality)); - } finally { - canvas.width = 1; - canvas.height = 1; - } -} - export async function packageConvertedImages({ blobs, names, JSZip }) { - if (blobs.length !== names.entries.length || blobs.length < 1) throw conversionError("OUTPUT_INVALID"); - if (blobs.length === 1) return { kind: "image", filename: names.entries[0], entries: names.entries, blob: blobs[0] }; - if (typeof JSZip !== "function") throw conversionError("ARCHIVE_LIBRARY_UNAVAILABLE"); - const archive = new JSZip(); - for (let index = 0; index < blobs.length; index += 1) { - archive.file(names.entries[index], new Uint8Array(await blobs[index].arrayBuffer())); - } - try { - const blob = await archive.generateAsync({ type: "blob", compression: "STORE", streamFiles: true }); - return { kind: "zip", filename: names.archive, entries: names.entries, blob }; - } catch (error) { - throw conversionError("ARCHIVE_GENERATION_FAILED", error); - } + return packageImageOutputs({ blobs, names, JSZip }); } export async function convertImages({ diff --git a/tools/shared/image-output.js b/tools/shared/image-output.js new file mode 100644 index 0000000..666f53c --- /dev/null +++ b/tools/shared/image-output.js @@ -0,0 +1,118 @@ +export const IMAGE_FORMATS = Object.freeze({ + jpeg: { extension: "jpg", mimeType: "image/jpeg", label: "JPEG" }, + png: { extension: "png", mimeType: "image/png", label: "PNG" }, + webp: { extension: "webp", mimeType: "image/webp", label: "WebP" }, +}); +export const DEFAULT_QUALITY = 0.92; +export const MIN_QUALITY = 0.5; +export const MAX_QUALITY = 1; +export const MAX_BASE_CHARACTERS = 120; +export const MAX_BASE_BYTES = 180; + +const ILLEGAL_FILENAME_CHARACTERS = /[\\/:*?"<>|\u0000-\u001f]+/g; +const INPUT_EXTENSION = /\.(?:jpe?g|png|webp)$/i; + +export function imageOutputError(code, cause) { + const error = new Error(code, cause ? { cause } : undefined); + error.code = code; + return error; +} + +export function normalizeFormat(value) { + const format = String(value || "").toLowerCase(); + if (!IMAGE_FORMATS[format]) throw imageOutputError("IMAGE_FORMAT_INVALID"); + return format; +} + +export function normalizeQuality(format, value = DEFAULT_QUALITY) { + if (normalizeFormat(format) === "png") return null; + const quality = Number(value); + if (!Number.isFinite(quality)) return DEFAULT_QUALITY; + return Math.min(MAX_QUALITY, Math.max(MIN_QUALITY, quality)); +} + +export function sourceBaseName(value, fallback = "image") { + const clean = String(value || "") + .trim() + .replace(INPUT_EXTENSION, "") + .replace(ILLEGAL_FILENAME_CHARACTERS, "_") + .replace(/[. ]+$/g, ""); + const encoder = new TextEncoder(); + const output = []; + let bytes = 0; + for (const character of Array.from(clean || fallback).slice(0, MAX_BASE_CHARACTERS)) { + const characterBytes = encoder.encode(character).length; + if (bytes + characterBytes > MAX_BASE_BYTES) break; + output.push(character); + bytes += characterBytes; + } + return output.join("") || fallback; +} + +export function createImageOutputNames(files, formats, { suffix = "", archive = "images.zip" } = {}) { + const formatList = Array.isArray(formats) ? formats : files.map(() => formats); + if (formatList.length !== files.length) throw imageOutputError("OUTPUT_INVALID"); + const used = new Set(); + const entries = files.map((file, index) => { + const extension = IMAGE_FORMATS[normalizeFormat(formatList[index])].extension; + const base = `${sourceBaseName(file?.name)}${suffix}`; + let collision = 1; + let candidate = `${base}.${extension}`; + while (used.has(candidate.toLocaleLowerCase("en-US"))) { + collision += 1; + candidate = `${base}_${collision}.${extension}`; + } + used.add(candidate.toLocaleLowerCase("en-US")); + return candidate; + }); + return { archive, entries }; +} + +export function canvasToBlob(canvas, mimeType, quality) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob || blob.type !== mimeType) reject(imageOutputError("IMAGE_ENCODER_FAILED")); + else resolve(blob); + }, mimeType, quality ?? undefined); + }); +} + +export async function renderDecodedImage({ decoded, format, quality, width = null, height = null, canvasFactory = () => document.createElement("canvas") }) { + const normalizedFormat = normalizeFormat(format); + const canvas = canvasFactory(); + const outputWidth = width ?? decoded.width; + const outputHeight = height ?? decoded.height; + canvas.width = outputWidth; + canvas.height = outputHeight; + const jpeg = normalizedFormat === "jpeg"; + const context = canvas.getContext("2d", { alpha: !jpeg }); + if (!context) throw imageOutputError("CANVAS_UNAVAILABLE"); + try { + if (jpeg) { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + if (width === null && height === null) context.drawImage(decoded.source, 0, 0); + else context.drawImage(decoded.source, 0, 0, outputWidth, outputHeight); + return await canvasToBlob(canvas, IMAGE_FORMATS[normalizedFormat].mimeType, normalizeQuality(normalizedFormat, quality)); + } finally { + canvas.width = 1; + canvas.height = 1; + } +} + +export async function packageImageOutputs({ blobs, names, JSZip }) { + if (blobs.length !== names.entries.length || blobs.length < 1) throw imageOutputError("OUTPUT_INVALID"); + if (blobs.length === 1) return { kind: "image", filename: names.entries[0], entries: names.entries, blob: blobs[0] }; + if (typeof JSZip !== "function") throw imageOutputError("ARCHIVE_LIBRARY_UNAVAILABLE"); + const archive = new JSZip(); + for (let index = 0; index < blobs.length; index += 1) { + archive.file(names.entries[index], new Uint8Array(await blobs[index].arrayBuffer())); + } + try { + const blob = await archive.generateAsync({ type: "blob", compression: "STORE", streamFiles: true }); + return { kind: "zip", filename: names.archive, entries: names.entries, blob }; + } catch (error) { + throw imageOutputError("ARCHIVE_GENERATION_FAILED", error); + } +} From 6a0f51c426c95bec7910b268bce1ae7451f59f04 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:26:10 +0900 Subject: [PATCH 02/34] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20Image=20Resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/i18n.js | 12 ++- js/locales/image-resize.js | 56 ++++++++++++++ tools/image/index.html | 126 ++++++++++++++++++++++++++++-- tools/image/resize/app.js | 56 ++++++++++++++ tools/image/resize/index.html | 30 +++++++ tools/image/resize/resize.js | 142 ++++++++++++++++++++++++++++++++++ tools/image/resize/tool.css | 17 ++++ 7 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 js/locales/image-resize.js create mode 100644 tools/image/resize/app.js create mode 100644 tools/image/resize/index.html create mode 100644 tools/image/resize/resize.js create mode 100644 tools/image/resize/tool.css diff --git a/js/i18n.js b/js/i18n.js index e05547d..11b2a34 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -4,9 +4,15 @@ import { ja } from "./locales/ja.js"; import { es } from "./locales/es.js"; import { de } from "./locales/de.js"; import { fr } from "./locales/fr.js"; +import { imageResizeLocales } from "./locales/image-resize.js"; const STORAGE_KEY = "secure-tools-language"; -export const translations = { en, ko, ja, es, de, fr }; +const baseTranslations = { en, ko, ja, es, de, fr }; +export const translations = Object.fromEntries(Object.entries(baseTranslations).map(([language, catalog]) => [language, { + ...catalog, + metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata }, + imageResize: imageResizeLocales[language].copy, +}])); export function resolveLanguage(value, availableLanguages = Object.keys(translations)) { const normalized = String(value || "").trim().toLowerCase(); @@ -63,6 +69,10 @@ const translateDocument = () => { element.setAttribute("title", t(element.dataset.i18nTitle)); }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => { + element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder)); + }); + document.querySelectorAll("[data-language-select]").forEach((select) => { select.value = currentLanguage; }); diff --git a/js/locales/image-resize.js b/js/locales/image-resize.js new file mode 100644 index 0000000..cea5611 --- /dev/null +++ b/js/locales/image-resize.js @@ -0,0 +1,56 @@ +export const imageResizeLocales = { + en: { + metadata: { title: "Image Resize — Secure Tools", description: "Resize JPEG, PNG, and WebP images locally in your browser." }, + copy: { + eyebrow: "Image tool", title: "Image Resize", description: "Resize JPEG, PNG, and WebP images locally. Multiple results are saved as one ZIP archive.", queue: { meta: "{size} · {format} · item {position}" }, + settings: { title: "Resize settings", mode: "Resize mode", pixels: "Pixels", percentage: "Percentage", width: "Width", height: "Height", auto: "auto", dimensionHelp: "With aspect ratio locked, these dimensions form a bounding box for each image. Leave either dimension blank for automatic sizing.", percentageLabel: "Scale", percentageHelp: "Each image is resized relative to its own original dimensions.", aspectRatio: "Preserve aspect ratio", enlargement: "Allow enlargement", enlargementHelp: "When off, images never become larger than their original dimensions.", format: "Output format", original: "Original format", qualityHelp: "Used for JPEG and WebP outputs. PNG ignores this setting.", outputHelp: "Transparent pixels become white in JPEG. Resizing through canvas removes embedded metadata.", imageFile: "Resized image", zipFile: "ZIP archive" }, + actions: { resize: "Resize and save" }, status: { resizing: "Resizing images locally…", progress: "Resizing image {completed} of {total}…", progressLabel: "Image resize progress", saved: "Saved {name} with {count} resized image(s).", downloaded: "Downloaded {name} with {count} resized image(s).", saveCancelled: "Save cancelled. No file was written.", dimensions: "Original: {originalWidth} × {originalHeight} · Result: {width} × {height}", settingsChanged: "Settings changed. Resize again to create an updated result." }, + errors: { noFiles: "Add at least one image before resizing.", mode: "Choose pixels or percentage mode.", dimensionRequired: "Enter a width, a height, or both.", dimensionInvalid: "Width and height must be whole numbers greater than zero.", percentage: "Enter a percentage from 1 to 1000.", dimensionLimit: "The requested output exceeds the 16,384 pixel dimension limit.", pixelLimit: "The requested output exceeds the 50 megapixel per-image limit.", jobPixels: "This batch exceeds the 200 megapixel output-work limit. Resize fewer images or use smaller dimensions.", format: "Choose Original, JPEG, PNG, or WebP.", failure: "{name} could not be resized. Try smaller dimensions or a different supported image." }, + }, + }, + ko: { + metadata: { title: "이미지 크기 조절 — Secure Tools", description: "JPEG, PNG, WebP 이미지 크기를 브라우저에서 로컬로 조절하세요." }, + copy: { + eyebrow: "이미지 도구", title: "이미지 크기 조절", description: "JPEG, PNG, WebP 이미지 크기를 로컬에서 조절합니다. 여러 결과는 ZIP 파일 하나로 저장됩니다.", queue: { meta: "{size} · {format} · {position}번째" }, + settings: { title: "크기 조절 설정", mode: "크기 기준", pixels: "픽셀", percentage: "백분율", width: "너비", height: "높이", auto: "자동", dimensionHelp: "비율을 유지하면 입력값은 이미지별 최대 영역이 됩니다. 한쪽을 비워 자동으로 계산할 수 있습니다.", percentageLabel: "배율", percentageHelp: "각 이미지는 원본 크기를 기준으로 조절됩니다.", aspectRatio: "가로세로 비율 유지", enlargement: "확대 허용", enlargementHelp: "끄면 원본보다 크게 만들지 않습니다.", format: "출력 형식", original: "원본 형식", qualityHelp: "JPEG와 WebP 출력에 적용됩니다. PNG에는 적용되지 않습니다.", outputHelp: "JPEG의 투명 픽셀은 흰색이 됩니다. 캔버스 재인코딩으로 내장 메타데이터가 제거됩니다.", imageFile: "크기 조절 이미지", zipFile: "ZIP 압축 파일" }, + actions: { resize: "크기 조절 후 저장" }, status: { resizing: "이미지 크기를 로컬에서 조절하는 중…", progress: "{total}개 중 {completed}번째 이미지 처리 중…", progressLabel: "이미지 크기 조절 진행률", saved: "크기 조절 이미지 {count}개를 {name}에 저장했습니다.", downloaded: "크기 조절 이미지 {count}개가 담긴 {name}을 다운로드했습니다.", saveCancelled: "저장을 취소했습니다. 파일은 기록되지 않았습니다.", dimensions: "원본: {originalWidth} × {originalHeight} · 결과: {width} × {height}", settingsChanged: "설정이 변경되었습니다. 새 결과를 만들려면 다시 실행하세요." }, + errors: { noFiles: "크기를 조절할 이미지를 하나 이상 추가하세요.", mode: "픽셀 또는 백분율 모드를 선택하세요.", dimensionRequired: "너비나 높이 중 하나 이상 입력하세요.", dimensionInvalid: "너비와 높이는 0보다 큰 정수여야 합니다.", percentage: "1에서 1000 사이의 백분율을 입력하세요.", dimensionLimit: "요청한 출력이 한 변 16,384픽셀 제한을 초과합니다.", pixelLimit: "요청한 출력이 이미지당 5천만 픽셀 제한을 초과합니다.", jobPixels: "이 작업은 전체 출력 2억 픽셀 제한을 초과합니다. 이미지 수나 크기를 줄이세요.", format: "원본, JPEG, PNG 또는 WebP를 선택하세요.", failure: "{name}의 크기를 조절하지 못했습니다. 더 작은 크기나 다른 지원 이미지를 사용하세요." }, + }, + }, + ja: { + metadata: { title: "画像サイズ変更 — Secure Tools", description: "JPEG、PNG、WebP画像をブラウザー内でローカルにサイズ変更します。" }, + copy: { + eyebrow: "画像ツール", title: "画像サイズ変更", description: "JPEG、PNG、WebP画像をローカルでサイズ変更します。複数の結果は1つのZIPに保存されます。", queue: { meta: "{size} · {format} · {position}番目" }, + settings: { title: "サイズ変更設定", mode: "サイズ指定", pixels: "ピクセル", percentage: "パーセント", width: "幅", height: "高さ", auto: "自動", dimensionHelp: "縦横比を維持すると、指定値は画像ごとの境界になります。片方を空欄にすると自動計算します。", percentageLabel: "倍率", percentageHelp: "各画像を元の寸法に対する割合で変更します。", aspectRatio: "縦横比を維持", enlargement: "拡大を許可", enlargementHelp: "オフの場合、元の寸法より大きくしません。", format: "出力形式", original: "元の形式", qualityHelp: "JPEGとWebP出力に使用します。PNGでは使用しません。", outputHelp: "JPEGでは透明部分が白になります。Canvasでの再エンコードにより埋め込みメタデータは削除されます。", imageFile: "サイズ変更画像", zipFile: "ZIPアーカイブ" }, + actions: { resize: "サイズ変更して保存" }, status: { resizing: "ローカルで画像をサイズ変更中…", progress: "{total}件中{completed}件目を処理中…", progressLabel: "画像サイズ変更の進行状況", saved: "{count}件の画像を{name}に保存しました。", downloaded: "{count}件の画像を含む{name}をダウンロードしました。", saveCancelled: "保存をキャンセルしました。ファイルは書き込まれていません。", dimensions: "元: {originalWidth} × {originalHeight} · 結果: {width} × {height}", settingsChanged: "設定が変更されました。更新した結果を作成するには再実行してください。" }, + errors: { noFiles: "サイズ変更する画像を追加してください。", mode: "ピクセルまたはパーセントを選択してください。", dimensionRequired: "幅または高さを入力してください。", dimensionInvalid: "幅と高さには0より大きい整数を入力してください。", percentage: "1〜1000の割合を入力してください。", dimensionLimit: "出力が16,384ピクセルの寸法上限を超えます。", pixelLimit: "出力が画像あたり5,000万ピクセルの上限を超えます。", jobPixels: "バッチが合計2億出力ピクセルの上限を超えます。", format: "元の形式、JPEG、PNG、WebPから選択してください。", failure: "{name}をサイズ変更できませんでした。より小さい寸法を試してください。" }, + }, + }, + es: { + metadata: { title: "Redimensionar imágenes — Secure Tools", description: "Redimensiona imágenes JPEG, PNG y WebP localmente en tu navegador." }, + copy: { + eyebrow: "Herramienta de imagen", title: "Redimensionar imágenes", description: "Redimensiona imágenes JPEG, PNG y WebP localmente. Los resultados múltiples se guardan en un ZIP.", queue: { meta: "{size} · {format} · elemento {position}" }, + settings: { title: "Ajustes de tamaño", mode: "Modo", pixels: "Píxeles", percentage: "Porcentaje", width: "Ancho", height: "Alto", auto: "automático", dimensionHelp: "Con la proporción bloqueada, las dimensiones forman un límite para cada imagen. Deja una vacía para calcularla.", percentageLabel: "Escala", percentageHelp: "Cada imagen cambia respecto a sus propias dimensiones originales.", aspectRatio: "Conservar proporción", enlargement: "Permitir ampliación", enlargementHelp: "Si está desactivado, ninguna imagen supera su tamaño original.", format: "Formato de salida", original: "Formato original", qualityHelp: "Se usa para JPEG y WebP. PNG ignora este ajuste.", outputHelp: "La transparencia se vuelve blanca en JPEG. La recodificación elimina los metadatos incrustados.", imageFile: "Imagen redimensionada", zipFile: "Archivo ZIP" }, + actions: { resize: "Redimensionar y guardar" }, status: { resizing: "Redimensionando localmente…", progress: "Procesando imagen {completed} de {total}…", progressLabel: "Progreso de redimensionado", saved: "Se guardó {name} con {count} imagen(es).", downloaded: "Se descargó {name} con {count} imagen(es).", saveCancelled: "Guardado cancelado. No se escribió ningún archivo.", dimensions: "Original: {originalWidth} × {originalHeight} · Resultado: {width} × {height}", settingsChanged: "Los ajustes cambiaron. Vuelve a ejecutar para actualizar el resultado." }, + errors: { noFiles: "Añade al menos una imagen.", mode: "Elige píxeles o porcentaje.", dimensionRequired: "Introduce ancho, alto o ambos.", dimensionInvalid: "El ancho y el alto deben ser enteros mayores que cero.", percentage: "Introduce un porcentaje entre 1 y 1000.", dimensionLimit: "La salida supera el límite de 16.384 píxeles por dimensión.", pixelLimit: "La salida supera 50 megapíxeles por imagen.", jobPixels: "El lote supera el límite de 200 megapíxeles de salida.", format: "Elige Original, JPEG, PNG o WebP.", failure: "No se pudo redimensionar {name}. Prueba dimensiones menores." }, + }, + }, + de: { + metadata: { title: "Bilder skalieren — Secure Tools", description: "JPEG-, PNG- und WebP-Bilder lokal im Browser skalieren." }, + copy: { + eyebrow: "Bildwerkzeug", title: "Bilder skalieren", description: "JPEG-, PNG- und WebP-Bilder lokal skalieren. Mehrere Ergebnisse werden in einer ZIP-Datei gespeichert.", queue: { meta: "{size} · {format} · Element {position}" }, + settings: { title: "Skalierungseinstellungen", mode: "Größenmodus", pixels: "Pixel", percentage: "Prozent", width: "Breite", height: "Höhe", auto: "automatisch", dimensionHelp: "Bei gesperrtem Seitenverhältnis bilden die Maße einen Begrenzungsrahmen. Ein Feld kann leer bleiben.", percentageLabel: "Skalierung", percentageHelp: "Jedes Bild wird relativ zu seinen eigenen Originalmaßen skaliert.", aspectRatio: "Seitenverhältnis beibehalten", enlargement: "Vergrößerung zulassen", enlargementHelp: "Wenn deaktiviert, werden Bilder nicht größer als das Original.", format: "Ausgabeformat", original: "Originalformat", qualityHelp: "Gilt für JPEG und WebP. PNG ignoriert diese Einstellung.", outputHelp: "Transparenz wird bei JPEG weiß. Die Canvas-Neukodierung entfernt eingebettete Metadaten.", imageFile: "Skaliertes Bild", zipFile: "ZIP-Archiv" }, + actions: { resize: "Skalieren und speichern" }, status: { resizing: "Bilder werden lokal skaliert…", progress: "Bild {completed} von {total} wird verarbeitet…", progressLabel: "Fortschritt der Bildskalierung", saved: "{name} mit {count} Bild(ern) gespeichert.", downloaded: "{name} mit {count} Bild(ern) heruntergeladen.", saveCancelled: "Speichern abgebrochen. Es wurde keine Datei geschrieben.", dimensions: "Original: {originalWidth} × {originalHeight} · Ergebnis: {width} × {height}", settingsChanged: "Einstellungen geändert. Für ein neues Ergebnis erneut ausführen." }, + errors: { noFiles: "Mindestens ein Bild hinzufügen.", mode: "Pixel oder Prozent auswählen.", dimensionRequired: "Breite, Höhe oder beides eingeben.", dimensionInvalid: "Breite und Höhe müssen positive ganze Zahlen sein.", percentage: "Einen Wert von 1 bis 1000 Prozent eingeben.", dimensionLimit: "Die Ausgabe überschreitet 16.384 Pixel pro Abmessung.", pixelLimit: "Die Ausgabe überschreitet 50 Megapixel pro Bild.", jobPixels: "Der Stapel überschreitet das Ausgabelimit von 200 Megapixeln.", format: "Original, JPEG, PNG oder WebP auswählen.", failure: "{name} konnte nicht skaliert werden. Kleinere Maße versuchen." }, + }, + }, + fr: { + metadata: { title: "Redimensionner des images — Secure Tools", description: "Redimensionnez des images JPEG, PNG et WebP localement dans votre navigateur." }, + copy: { + eyebrow: "Outil d’image", title: "Redimensionner des images", description: "Redimensionnez des images JPEG, PNG et WebP localement. Plusieurs résultats sont enregistrés dans une archive ZIP.", queue: { meta: "{size} · {format} · élément {position}" }, + settings: { title: "Paramètres de taille", mode: "Mode", pixels: "Pixels", percentage: "Pourcentage", width: "Largeur", height: "Hauteur", auto: "auto", dimensionHelp: "Avec les proportions verrouillées, ces dimensions délimitent chaque image. Laissez une dimension vide pour la calculer.", percentageLabel: "Échelle", percentageHelp: "Chaque image est redimensionnée par rapport à ses propres dimensions.", aspectRatio: "Conserver les proportions", enlargement: "Autoriser l’agrandissement", enlargementHelp: "Si désactivé, les images ne dépassent jamais leur taille d’origine.", format: "Format de sortie", original: "Format d’origine", qualityHelp: "Utilisé pour JPEG et WebP. PNG ignore ce réglage.", outputHelp: "La transparence devient blanche en JPEG. Le réencodage supprime les métadonnées intégrées.", imageFile: "Image redimensionnée", zipFile: "Archive ZIP" }, + actions: { resize: "Redimensionner et enregistrer" }, status: { resizing: "Redimensionnement local…", progress: "Traitement de l’image {completed} sur {total}…", progressLabel: "Progression du redimensionnement", saved: "{name} enregistré avec {count} image(s).", downloaded: "{name} téléchargé avec {count} image(s).", saveCancelled: "Enregistrement annulé. Aucun fichier n’a été écrit.", dimensions: "Original : {originalWidth} × {originalHeight} · Résultat : {width} × {height}", settingsChanged: "Les paramètres ont changé. Relancez pour mettre à jour le résultat." }, + errors: { noFiles: "Ajoutez au moins une image.", mode: "Choisissez pixels ou pourcentage.", dimensionRequired: "Saisissez une largeur, une hauteur ou les deux.", dimensionInvalid: "La largeur et la hauteur doivent être des entiers positifs.", percentage: "Saisissez un pourcentage de 1 à 1000.", dimensionLimit: "La sortie dépasse 16 384 pixels par dimension.", pixelLimit: "La sortie dépasse 50 mégapixels par image.", jobPixels: "Le lot dépasse la limite de sortie de 200 mégapixels.", format: "Choisissez Original, JPEG, PNG ou WebP.", failure: "Impossible de redimensionner {name}. Essayez des dimensions plus petites." }, + }, + }, +}; diff --git a/tools/image/index.html b/tools/image/index.html index 7994b62..df36af7 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -1,6 +1,122 @@ - - Image tools — Secure Tools - - -

Tool category

Image tools

Prepare everyday images privately in your browser.

Available tools process file contents in browser memory. Planned items are clearly marked.

+ + + + + + + + + + +Image tools — Secure Tools + + + + + + + + + + +
+
+
+
+

Tool category

+

Image tools

+
+

Prepare everyday images privately in your browser.

+
+ +

Available tools process file contents in browser memory. Planned items are clearly marked.

+
+
+ + + + diff --git a/tools/image/resize/app.js b/tools/image/resize/app.js new file mode 100644 index 0000000..809f8f3 --- /dev/null +++ b/tools/image/resize/app.js @@ -0,0 +1,56 @@ +import { t } from "../../../js/i18n.js"; +import { formatBytes, moveArrayItem } from "../../shared/file.js"; +import { ACCEPTED_IMAGE_TYPES, selectImageQueueFiles, validateImageSignature } from "../../shared/image.js"; +import { IMAGE_FORMATS } from "../../shared/image-output.js"; +import { downloadBlob, requestSaveHandle, writeBlobToHandle } from "../../shared/save.js"; +import { createResizePlan, resizeImages } from "./resize.js"; + +const elements = { + input: document.querySelector("#file-input"), dropZone: document.querySelector("#drop-zone"), list: document.querySelector("#image-list"), empty: document.querySelector("#queue-empty"), count: document.querySelector("#image-count"), + add: document.querySelector("#add-images"), clear: document.querySelector("#clear-images"), resize: document.querySelector("#resize-images"), + form: document.querySelector("#resize-settings"), pixelFields: document.querySelector("#pixel-fields"), percentageField: document.querySelector("#percentage-field"), width: document.querySelector("#resize-width"), height: document.querySelector("#resize-height"), percentage: document.querySelector("#resize-percentage"), lock: document.querySelector("#aspect-ratio"), enlargement: document.querySelector("#allow-enlargement"), + format: document.querySelector("#output-format"), quality: document.querySelector("#quality"), qualityField: document.querySelector("#quality-field"), qualityValue: document.querySelector("#quality-value"), progress: document.querySelector("#resize-progress"), result: document.querySelector("#result-summary"), status: document.querySelector("#tool-status"), +}; +const state = { items: [], busy: false, status: null, result: null }; +elements.input.accept = ACCEPTED_IMAGE_TYPES; +const message = (key, values = {}) => Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key)); + +function setStatus(key, values = {}, tone = "neutral") { state.status = key ? { key, values, tone } : null; elements.status.textContent = key ? message(key, values) : ""; elements.status.dataset.tone = tone; } +function clearResult(changed = false) { if (changed && state.result) setStatus("imageResize.status.settingsChanged"); state.result = null; elements.result.hidden = true; elements.result.textContent = ""; } +function rejectionReason(code) { return message(({ UNSUPPORTED_IMAGE: "imageToPdf.errors.unsupported", IMAGE_FILE_TOO_LARGE: "imageToPdf.errors.fileTooLarge", IMAGE_QUEUE_FILES_EXCEEDED: "imageToPdf.errors.queueFiles", IMAGE_SIGNATURE_INVALID: "imageToPdf.errors.signature", IMAGE_QUEUE_BYTES_EXCEEDED: "imageToPdf.errors.queueBytes" })[code] || "imageToPdf.errors.unsupported"); } +function setRejectedStatus(added, rejected) { state.status = { added, rejected, tone: "warning" }; const reasons = rejected.map(({ file, code }) => message("imageToPdf.status.rejectedItem", { name: file.name || "file", reason: rejectionReason(code) })).join("; "); elements.status.textContent = message("imageToPdf.status.addedWithReasons", { added, reasons }); elements.status.dataset.tone = "warning"; } +function actionButton(action, labelKey, symbol, disabled = false) { const button = document.createElement("button"); button.type = "button"; button.className = `queue-action queue-action--${action}`; button.dataset.action = action; button.setAttribute("aria-label", t(labelKey)); button.title = t(labelKey); button.textContent = symbol; button.disabled = disabled || state.busy; return button; } + +function renderQueue() { + elements.list.replaceChildren(); elements.empty.hidden = state.items.length > 0; elements.count.textContent = message("imageToPdf.queue.count", { count: state.items.length }); + elements.clear.disabled = state.busy || !state.items.length; elements.resize.disabled = state.busy || !state.items.length; elements.add.disabled = state.busy; elements.input.disabled = state.busy; + elements.form.querySelectorAll("input, select").forEach((control) => { if (control !== elements.input) control.disabled = state.busy; }); + state.items.forEach((item, index) => { const row = document.createElement("li"); row.className = "queue-item"; row.dataset.id = item.id; const thumbnail = document.createElement("img"); thumbnail.className = "queue-thumbnail"; thumbnail.src = item.url; thumbnail.alt = message("imageToPdf.queue.previewAlt", { name: item.file.name }); const details = document.createElement("div"); details.className = "queue-details"; const name = document.createElement("strong"); name.className = "queue-name"; name.textContent = item.file.name; name.title = item.file.name; const meta = document.createElement("span"); meta.className = "queue-meta"; meta.textContent = message("imageResize.queue.meta", { size: formatBytes(item.file.size), format: item.format.toUpperCase(), position: index + 1 }); details.append(name, meta); const actions = document.createElement("div"); actions.className = "queue-actions"; actions.append(actionButton("up", "imageToPdf.queue.moveUp", "↑", index === 0), actionButton("down", "imageToPdf.queue.moveDown", "↓", index === state.items.length - 1), actionButton("remove", "imageToPdf.queue.remove", "×")); row.append(thumbnail, details, actions); elements.list.append(row); }); + updateControls(); +} + +async function addFiles(files) { const { accepted, rejected } = await selectImageQueueFiles(state.items.map((item) => item.file), files); for (const file of accepted) { const format = await validateImageSignature(file); state.items.push({ id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, file, format, url: URL.createObjectURL(file) }); } clearResult(); if (rejected.length) setRejectedStatus(accepted.length, rejected); else if (accepted.length) setStatus("imageToPdf.status.added", { count: accepted.length }, "success"); else setStatus("imageToPdf.errors.unsupported", {}, "error"); renderQueue(); } +function removeItem(index) { const [item] = state.items.splice(index, 1); if (item) URL.revokeObjectURL(item.url); clearResult(); setStatus("imageToPdf.status.removed"); renderQueue(); } +function moveItem(index, offset) { const next = moveArrayItem(state.items, index, offset); if (next < 0) return; clearResult(); setStatus("imageToPdf.status.reordered"); renderQueue(); elements.list.querySelector(`[data-id="${state.items[next].id}"] [data-action="${offset < 0 ? "up" : "down"}"]`)?.focus(); } +function clearItems() { state.items.forEach((item) => URL.revokeObjectURL(item.url)); state.items.length = 0; elements.input.value = ""; clearResult(); setStatus("imageToPdf.status.cleared"); renderQueue(); } +function selectedMode() { return elements.form.elements["resize-mode"].value; } +function updateControls() { const percentage = selectedMode() === "percentage"; elements.pixelFields.hidden = percentage; elements.percentageField.hidden = !percentage; elements.lock.closest("label").hidden = percentage; const originalHasLossy = !state.items.length || state.items.some((item) => item.format !== "png"); const showQuality = elements.format.value !== "png" && (elements.format.value !== "original" || originalHasLossy); elements.qualityField.hidden = !showQuality; elements.quality.disabled = state.busy || !showQuality; } +function settings() { return { mode: selectedMode(), width: elements.width.value, height: elements.height.value, percentage: elements.percentage.value, lockAspectRatio: elements.lock.checked, allowEnlargement: elements.enlargement.checked }; } +function errorMessage(error) { const key = ({ NO_FILES: "imageResize.errors.noFiles", RESIZE_MODE_INVALID: "imageResize.errors.mode", RESIZE_DIMENSION_REQUIRED: "imageResize.errors.dimensionRequired", RESIZE_DIMENSION_INVALID: "imageResize.errors.dimensionInvalid", RESIZE_PERCENTAGE_INVALID: "imageResize.errors.percentage", IMAGE_DIMENSION_EXCEEDED: "imageResize.errors.dimensionLimit", IMAGE_PIXELS_EXCEEDED: "imageResize.errors.pixelLimit", RESIZE_JOB_PIXELS_EXCEEDED: "imageResize.errors.jobPixels", IMAGE_FORMAT_INVALID: "imageResize.errors.format", IMAGE_DECODE_FAILED: "imageToPdf.errors.decode", IMAGE_ENCODER_FAILED: "imageConverter.errors.encoder", CANVAS_UNAVAILABLE: "imageConverter.errors.canvas", ARCHIVE_LIBRARY_UNAVAILABLE: "imageConverter.errors.archiveLibrary", ARCHIVE_GENERATION_FAILED: "imageConverter.errors.archive" })[error.code] || "imageResize.errors.failure"; return message(key, { name: error.fileName || "" }); } + +async function resizeQueue() { + if (!state.items.length || state.busy) { setStatus("imageResize.errors.noFiles", {}, "error"); return; } + const files = state.items.map((item) => item.file); const options = { files, sourceFormats: state.items.map((item) => item.format), outputFormat: elements.format.value, quality: Number(elements.quality.value), settings: settings() }; let plan; + try { plan = createResizePlan(options); } catch (error) { elements.status.textContent = errorMessage(error); elements.status.dataset.tone = "error"; return; } + const multiple = files.length > 1; const filename = multiple ? plan.names.archive : plan.names.entries[0]; const outputType = multiple ? "application/zip" : IMAGE_FORMATS[plan.formats[0]].mimeType; const extension = multiple ? ".zip" : `.${IMAGE_FORMATS[plan.formats[0]].extension}`; let fileHandle = null; + if ("showSaveFilePicker" in window) { try { fileHandle = await requestSaveHandle(window, { suggestedName: filename, description: t(multiple ? "imageResize.settings.zipFile" : "imageResize.settings.imageFile"), mimeType: outputType, extension }); } catch (error) { setStatus(error.name === "AbortError" ? "imageResize.status.saveCancelled" : "imageConverter.errors.save", {}, error.name === "AbortError" ? "neutral" : "error"); return; } } + state.busy = true; elements.progress.hidden = false; elements.progress.max = files.length; elements.progress.value = 0; setStatus("imageResize.status.resizing"); renderQueue(); let resized = false; + try { const result = await resizeImages({ files, outputFormat: options.outputFormat, quality: options.quality, settings: options.settings, JSZip: window.JSZip, onProgress(completed, total) { elements.progress.value = completed; setStatus("imageResize.status.progress", { completed, total }); } }); resized = true; if (fileHandle) await writeBlobToHandle(fileHandle, result.blob); else downloadBlob(result.blob, result.filename); state.result = result.results; if (result.results.length === 1) { const dimensions = result.results[0]; elements.result.textContent = message("imageResize.status.dimensions", { originalWidth: dimensions.original.width, originalHeight: dimensions.original.height, width: dimensions.output.width, height: dimensions.output.height }); elements.result.hidden = false; } setStatus(fileHandle ? "imageResize.status.saved" : "imageResize.status.downloaded", { name: result.filename, count: result.entries.length }, "success"); } catch (error) { console.error("Image resize failed", error); elements.status.textContent = resized && !error.code ? message("imageConverter.errors.save") : errorMessage(error); elements.status.dataset.tone = "error"; state.status = null; } finally { state.busy = false; elements.progress.hidden = true; renderQueue(); } +} + +elements.add.addEventListener("click", () => elements.input.click()); elements.input.addEventListener("change", (event) => { void addFiles([...event.target.files]); elements.input.value = ""; }); elements.clear.addEventListener("click", clearItems); elements.resize.addEventListener("click", resizeQueue); elements.quality.addEventListener("input", () => { elements.qualityValue.textContent = `${Math.round(Number(elements.quality.value) * 100)}%`; clearResult(true); }); +elements.form.addEventListener("change", (event) => { if (event.target !== elements.quality) clearResult(true); updateControls(); }); elements.form.addEventListener("input", (event) => { if ([elements.width, elements.height, elements.percentage].includes(event.target)) clearResult(true); }); +elements.list.addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); if (!button) return; const index = state.items.findIndex((item) => item.id === button.closest("[data-id]").dataset.id); if (button.dataset.action === "remove") removeItem(index); if (button.dataset.action === "up") moveItem(index, -1); if (button.dataset.action === "down") moveItem(index, 1); }); +["dragenter", "dragover"].forEach((type) => elements.dropZone.addEventListener(type, (event) => { event.preventDefault(); if (!state.busy) elements.dropZone.dataset.dragging = "true"; })); ["dragleave", "drop"].forEach((type) => elements.dropZone.addEventListener(type, (event) => { event.preventDefault(); delete elements.dropZone.dataset.dragging; })); elements.dropZone.addEventListener("drop", (event) => { if (!state.busy) void addFiles([...event.dataTransfer.files]); }); +document.addEventListener("securetools:languagechange", () => { renderQueue(); if (state.status?.rejected) setRejectedStatus(state.status.added, state.status.rejected); else if (state.status) setStatus(state.status.key, state.status.values, state.status.tone); }); window.addEventListener("beforeunload", () => state.items.forEach((item) => URL.revokeObjectURL(item.url))); +elements.qualityValue.textContent = `${Math.round(Number(elements.quality.value) * 100)}%`; renderQueue(); diff --git a/tools/image/resize/index.html b/tools/image/resize/index.html new file mode 100644 index 0000000..223adfe --- /dev/null +++ b/tools/image/resize/index.html @@ -0,0 +1,30 @@ + + + + + + Image Resize — Secure Tools + + + + + + +
+

Image tool

Image Resize

Resize JPEG, PNG, and WebP images locally. Multiple results are saved as one ZIP archive.

+
+

Add your images

Drop files here or use the picker. JPEG, PNG, and WebP can be mixed.

Processed locally. Your images never leave this device. How privacy works

+

Image order

0 images

No images added yet.

    +
    +
    + + + diff --git a/tools/image/resize/resize.js b/tools/image/resize/resize.js new file mode 100644 index 0000000..a1fc4ca --- /dev/null +++ b/tools/image/resize/resize.js @@ -0,0 +1,142 @@ +import { decodeImage, validateImageDimensions, validateImageSignature } from "../../shared/image.js"; +import { + createImageOutputNames, + IMAGE_FORMATS, + normalizeFormat, + normalizeQuality, + packageImageOutputs, + renderDecodedImage, +} from "../../shared/image-output.js"; + +export const RESIZE_MODES = Object.freeze(["pixels", "percentage"]); +export const OUTPUT_FORMATS = Object.freeze(["original", "jpeg", "png", "webp"]); +export const DEFAULT_PERCENTAGE = 50; +export const MAX_PERCENTAGE = 1000; +export const MAX_OUTPUT_JOB_PIXELS = 200_000_000; +export const ARCHIVE_FILENAME = "resized_images.zip"; + +function resizeError(code, cause) { + const error = new Error(code, cause ? { cause } : undefined); + error.code = code; + return error; +} + +function positiveInteger(value, required = true) { + if (value === "" || value === null || value === undefined) { + if (required) throw resizeError("RESIZE_DIMENSION_REQUIRED"); + return null; + } + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) throw resizeError("RESIZE_DIMENSION_INVALID"); + return number; +} + +export function normalizeResizeMode(value) { + if (!RESIZE_MODES.includes(value)) throw resizeError("RESIZE_MODE_INVALID"); + return value; +} + +export function normalizeOutputFormat(value) { + const format = String(value || "").toLowerCase(); + if (!OUTPUT_FORMATS.includes(format)) throw resizeError("IMAGE_FORMAT_INVALID"); + return format; +} + +export function calculateResizeDimensions({ + sourceWidth, sourceHeight, mode = "pixels", width = null, height = null, + percentage = DEFAULT_PERCENTAGE, lockAspectRatio = true, allowEnlargement = false, +}) { + validateImageDimensions(sourceWidth, sourceHeight); + const normalizedMode = normalizeResizeMode(mode); + let outputWidth; + let outputHeight; + + if (normalizedMode === "percentage") { + const percent = Number(percentage); + if (!Number.isFinite(percent) || percent <= 0 || percent > MAX_PERCENTAGE) throw resizeError("RESIZE_PERCENTAGE_INVALID"); + const scale = allowEnlargement ? percent / 100 : Math.min(1, percent / 100); + outputWidth = Math.max(1, Math.round(sourceWidth * scale)); + outputHeight = Math.max(1, Math.round(sourceHeight * scale)); + } else if (lockAspectRatio) { + const requestedWidth = positiveInteger(width, false); + const requestedHeight = positiveInteger(height, false); + if (requestedWidth === null && requestedHeight === null) throw resizeError("RESIZE_DIMENSION_REQUIRED"); + const scales = []; + if (requestedWidth !== null) scales.push(requestedWidth / sourceWidth); + if (requestedHeight !== null) scales.push(requestedHeight / sourceHeight); + let scale = Math.min(...scales); + if (!allowEnlargement) scale = Math.min(1, scale); + outputWidth = Math.max(1, Math.round(sourceWidth * scale)); + outputHeight = Math.max(1, Math.round(sourceHeight * scale)); + } else { + outputWidth = positiveInteger(width); + outputHeight = positiveInteger(height); + if (!allowEnlargement) { + outputWidth = Math.min(sourceWidth, outputWidth); + outputHeight = Math.min(sourceHeight, outputHeight); + } + } + + validateImageDimensions(outputWidth, outputHeight); + return { width: outputWidth, height: outputHeight }; +} + +export function resolveOutputFormats(outputFormat, sourceFormats) { + const normalized = normalizeOutputFormat(outputFormat); + return sourceFormats.map((sourceFormat) => normalized === "original" ? normalizeFormat(sourceFormat) : normalizeFormat(normalized)); +} + +export function createResizePlan({ files, sourceFormats, outputFormat, quality, settings }) { + if (!Array.isArray(files) || files.length < 1) throw resizeError("NO_FILES"); + if (!Array.isArray(sourceFormats) || sourceFormats.length !== files.length) throw resizeError("OUTPUT_INVALID"); + const formats = resolveOutputFormats(outputFormat, sourceFormats); + return { + formats, + qualities: formats.map((format) => normalizeQuality(format, quality)), + names: createImageOutputNames(files, formats, { suffix: "_resized", archive: ARCHIVE_FILENAME }), + settings: { ...settings, mode: normalizeResizeMode(settings.mode) }, + }; +} + +export async function resizeImages({ + files, outputFormat, quality, settings, JSZip, decode = decodeImage, + identify = validateImageSignature, canvasFactory = () => document.createElement("canvas"), onProgress = () => {}, +}) { + const sourceFormats = []; + for (const file of files) sourceFormats.push(await identify(file)); + const plan = createResizePlan({ files, sourceFormats, outputFormat, quality, settings }); + const blobs = []; + const results = []; + let outputPixels = 0; + + for (let index = 0; index < files.length; index += 1) { + let decoded; + try { + decoded = await decode(files[index]); + const dimensions = calculateResizeDimensions({ + sourceWidth: decoded.width, + sourceHeight: decoded.height, + ...plan.settings, + }); + outputPixels += dimensions.width * dimensions.height; + if (outputPixels > MAX_OUTPUT_JOB_PIXELS) throw resizeError("RESIZE_JOB_PIXELS_EXCEEDED"); + blobs.push(await renderDecodedImage({ + decoded, + format: plan.formats[index], + quality: plan.qualities[index], + width: dimensions.width, + height: dimensions.height, + canvasFactory, + })); + results.push({ original: { width: decoded.width, height: decoded.height }, output: dimensions }); + onProgress(index + 1, files.length, files[index], results.at(-1)); + } catch (error) { + if (!error.fileName) error.fileName = files[index]?.name || ""; + throw error; + } finally { + decoded?.close(); + } + } + + return { ...(await packageImageOutputs({ blobs, names: plan.names, JSZip })), plan, results }; +} diff --git a/tools/image/resize/tool.css b/tools/image/resize/tool.css new file mode 100644 index 0000000..5a26642 --- /dev/null +++ b/tools/image/resize/tool.css @@ -0,0 +1,17 @@ +.resize-mode { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-2) 0 var(--space-4); } +.resize-mode label, .resize-check { display: flex; align-items: flex-start; gap: var(--space-2); min-width: 0; } +.dimension-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.dimension-grid .field { margin: 0; } +.dimension-input { display: flex; align-items: center; gap: var(--space-2); } +.dimension-input input { min-width: 0; width: 100%; } +.dimension-unit { color: var(--text-secondary); font-size: 0.78rem; } +.quality-heading { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: var(--space-3); } +.quality-heading output { color: var(--text-primary); font-size: 0.82rem; font-weight: var(--font-weight-bold); } +.field[hidden], [hidden] { display: none !important; } +.resize-note { margin: var(--space-4) 0 0; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-subtle); color: var(--text-secondary); font-size: 0.78rem; overflow-wrap: anywhere; } + +@media (max-width: 38rem) { + .settings { padding: var(--space-4); } + .dimension-grid { grid-template-columns: 1fr; } + .quality-heading { align-items: flex-start; } +} From ab8a3aa33400238d7f8ad352746b716f3b71c564 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:29:09 +0900 Subject: [PATCH 03/34] =?UTF-8?q?=E2=9C=85[Test]=20Add=20Image=20Resize=20?= =?UTF-8?q?regression=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/i18n-quality.test.mjs | 4 +- tests/image-converter.test.mjs | 5 +- tests/image-resize.test.mjs | 113 +++++++++++++++++++++++++++++++++ tests/run-all.mjs | 1 + 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 tests/image-resize.test.mjs diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 7081baf..8c4cb69 100644 --- a/tests/i18n-quality.test.mjs +++ b/tests/i18n-quality.test.mjs @@ -45,7 +45,7 @@ function placeholders(value) { function testCatalogParityAndQuality() { assert.deepEqual([...Object.keys(translations)], [...languageNames.keys()]); const english = flatten(translations.en); - assert.equal(english.size, 553); + assert.equal(english.size, 597); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); @@ -77,7 +77,7 @@ function testResolutionDetectionAndPersistence() { function testSelectorsAndDocumentTranslation() { const pages = listFiles(root, (file) => file.endsWith(".html") && fs.readFileSync(file, "utf8").includes("data-language-select")); - assert.equal(pages.length, 16, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 17, "Every production page with the shared header must expose the language selector"); for (const file of pages) { const html = fs.readFileSync(file, "utf8"); const select = html.match(/]*data-language-select[^>]*>([\s\S]*?)<\/select>/)?.[1]; diff --git a/tests/image-converter.test.mjs b/tests/image-converter.test.mjs index 5f96d2f..caf2f9b 100644 --- a/tests/image-converter.test.mjs +++ b/tests/image-converter.test.mjs @@ -170,8 +170,9 @@ const category = read("tools/image/index.html"); const pdfCategory = read("tools/pdf/index.html"); const organizer = read("tools/pdf/organize/app.js"); assert.match(category, /href="\.\/converter\/"[\s\S]*status--available/); -assert.equal((category.match(/class="category-tool surface"/g) || []).length, 1); -assert.doesNotMatch(category, /imageCompressor|imageResizer|categories\.plannedNote/); +assert.match(category, /href="\.\/resize\/"[\s\S]*status--available/); +assert.equal((category.match(/class="category-tool surface"/g) || []).length, 2); +assert.doesNotMatch(category, /imageCompressor|categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /