diff --git a/CHANGELOG.md b/CHANGELOG.md index c8825b1..1da8b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Started the v2 cycle with a production Image category and local Image Converter. +- Added Image Resize with pixel and percentage modes, aspect-ratio preservation, optional enlargement, Original/JPEG/PNG/WebP output, and local batch ZIP saving. +- Added per-image output dimension/pixel checks and a 200-megapixel aggregate resize-output workload limit. - Added JPEG, PNG, and WebP input/output, lossy quality controls for JPEG/WebP, deterministic white JPEG transparency, metadata-stripping canvas re-encoding, collision-safe Unicode names, and ZIP batch output. - Added per-file, queue, dimension, decoded-pixel, and 200-megapixel aggregate-work protections with recoverable errors. diff --git a/README.md b/README.md index db24ab6..866bc77 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The v1 baseline is recorded in the [changelog](./CHANGELOG.md) and historical [v ## Available tools - [Image Converter](./tools/image/converter/) — convert batches of JPEG, PNG, and WebP images locally with predictable names and ZIP output. +- [Image Resize](./tools/image/resize/) — resize image batches by pixels or percentage while preserving aspect ratio by default. - [Images to PDF](./tools/pdf/images-to-pdf/) — arrange JPEG, PNG, and WebP images and save them as one PDF. - [Merge PDF](./tools/pdf/merge/) — validate, order, and combine PDF pages without rasterizing them. - [Split PDF](./tools/pdf/split/) — extract ordered page ranges or create predictable per-page and fixed-interval archives. @@ -143,6 +144,18 @@ Image Converter is available at `/tools/image/converter/`. - Reuses the existing 50 MiB file, 100-file queue, 500 MiB queue, 16,384-pixel dimension, and 50-megapixel per-image limits, plus a 200-megapixel aggregate decoded-work limit per conversion. - Keeps queued sources available after cancellation or recoverable decode, encode, archive, and save failures so the job can be retried without reloading. +## Image Resize + +Image Resize is available at `/tools/image/resize/` on the v2 integration branch. + +- Accepts signature-validated JPEG, PNG, and WebP images and processes batches sequentially without uploading file contents. +- Supports pixel bounding dimensions and per-source percentage scaling. Aspect ratio is preserved by default, either dimension may remain automatic, and enlargement is disabled by default. +- Supports Original format for mixed batches plus explicit JPEG, PNG, and WebP output. JPEG and WebP use one batch quality setting; PNG does not expose a meaningless quality control. +- Preserves transparency for PNG/WebP and flattens it onto white for JPEG, matching Image Converter. Canvas re-encoding strips EXIF and other embedded metadata. +- Uses `_resized` names with deterministic collision suffixes and the established Unicode character/byte limits. Multiple outputs are packaged in `resized_images.zip`. +- Enforces the shared 50 MiB file, 100-file queue, 500 MiB queue, 16,384-pixel dimension, and 50-megapixel per-image limits, plus a 200-megapixel aggregate output-work limit. +- Keeps the queue available after validation, decode, canvas, encoding, archive, save, or cancellation errors so settings can be corrected and retried. + ## Images to PDF Images to PDF is available at `/tools/pdf/images-to-pdf/`. 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/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, /