From b351e460b3d4995725daa486e8ee2c1cad255476 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:51:06 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20Image=20Compressor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/i18n.js | 4 +- js/locales/image-compressor.js | 26 +++++++++ tools/image/compress/app.js | 34 +++++++++++ tools/image/compress/compressor.js | 91 ++++++++++++++++++++++++++++++ tools/image/compress/index.html | 16 ++++++ tools/image/compress/tool.css | 22 ++++++++ tools/image/index.html | 10 ++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 js/locales/image-compressor.js create mode 100644 tools/image/compress/app.js create mode 100644 tools/image/compress/compressor.js create mode 100644 tools/image/compress/index.html create mode 100644 tools/image/compress/tool.css diff --git a/js/i18n.js b/js/i18n.js index 11b2a34..7a27a2c 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -5,13 +5,15 @@ 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"; +import { imageCompressorLocales } from "./locales/image-compressor.js"; const STORAGE_KEY = "secure-tools-language"; 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 }, + metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata }, imageResize: imageResizeLocales[language].copy, + imageCompressor: imageCompressorLocales[language].copy, }])); export function resolveLanguage(value, availableLanguages = Object.keys(translations)) { diff --git a/js/locales/image-compressor.js b/js/locales/image-compressor.js new file mode 100644 index 0000000..293b3c3 --- /dev/null +++ b/js/locales/image-compressor.js @@ -0,0 +1,26 @@ +export const imageCompressorLocales = { + en: { + metadata: { title: "Image Compressor — Secure Tools", description: "Compress JPEG, PNG, and WebP images locally in your browser." }, + copy: { eyebrow: "Image tool", title: "Image Compressor", description: "Re-encode JPEG, PNG, and WebP images locally and compare the resulting file sizes.", queue: { meta: "{size} · {format} · item {position}" }, settings: { title: "Compression settings", format: "Output format", original: "Original format", quality: "Quality", qualityHelp: "JPEG and WebP use this quality. PNG is re-encoded without a lossy quality setting.", outputHelp: "Pixel dimensions stay unchanged. PNG reduction may be limited. JPEG transparency becomes white, and re-encoding removes embedded metadata.", imageFile: "Compressed image", zipFile: "ZIP archive" }, actions: { compress: "Compress and save" }, results: { title: "Compression results", original: "Original", result: "Result", change: "Change", saved: "Saved {bytes} ({percent}%)", increased: "Increased {bytes} (+{percent}%)", aggregateSaved: "Total: {original} → {result}. Saved {bytes} ({percent}%).", aggregateIncreased: "Total: {original} → {result}. Increased {bytes} (+{percent}%)." }, status: { compressing: "Compressing images locally…", progress: "Compressing image {completed} of {total}…", progressLabel: "Image compression progress", saved: "Saved {name} with {count} compressed image(s).", downloaded: "Downloaded {name} with {count} compressed image(s).", saveCancelled: "Save cancelled. The generated results remain available.", settingsChanged: "Settings changed. Compress again to update the results." }, errors: { noFiles: "Add at least one image before compressing.", format: "Choose Original, JPEG, PNG, or WebP.", jobPixels: "This batch exceeds the 200 megapixel decoded-work limit.", size: "A valid non-empty source is required for size comparison.", failure: "{name} could not be compressed. The image may be damaged or unsupported." } }, + }, + ko: { + metadata: { title: "이미지 압축기 — Secure Tools", description: "JPEG, PNG, WebP 이미지를 브라우저에서 로컬로 압축하세요." }, + copy: { eyebrow: "이미지 도구", title: "이미지 압축기", description: "JPEG, PNG, WebP 이미지를 로컬에서 다시 인코딩하고 결과 용량을 비교합니다.", queue: { meta: "{size} · {format} · {position}번째" }, settings: { title: "압축 설정", format: "출력 형식", original: "원본 형식", quality: "품질", qualityHelp: "JPEG와 WebP에 적용됩니다. PNG는 손실 품질 설정 없이 다시 인코딩됩니다.", outputHelp: "픽셀 크기는 유지됩니다. PNG 용량 감소는 제한적일 수 있습니다. JPEG 투명 영역은 흰색이 되며 내장 메타데이터는 제거됩니다.", imageFile: "압축 이미지", zipFile: "ZIP 압축 파일" }, actions: { compress: "압축 후 저장" }, results: { title: "압축 결과", original: "원본", result: "결과", change: "변화", saved: "{bytes} 절약 ({percent}%)", increased: "{bytes} 증가 (+{percent}%)", aggregateSaved: "합계: {original} → {result}. {bytes} 절약 ({percent}%).", aggregateIncreased: "합계: {original} → {result}. {bytes} 증가 (+{percent}%)." }, status: { compressing: "이미지를 로컬에서 압축하는 중…", progress: "{total}개 중 {completed}번째 이미지 압축 중…", progressLabel: "이미지 압축 진행률", saved: "압축 이미지 {count}개를 {name}에 저장했습니다.", downloaded: "압축 이미지 {count}개가 담긴 {name}을 다운로드했습니다.", saveCancelled: "저장을 취소했습니다. 생성된 결과는 그대로 확인할 수 있습니다.", settingsChanged: "설정이 변경되었습니다. 결과를 갱신하려면 다시 압축하세요." }, errors: { noFiles: "압축할 이미지를 하나 이상 추가하세요.", format: "원본, JPEG, PNG 또는 WebP를 선택하세요.", jobPixels: "이 작업은 전체 디코딩 2억 픽셀 제한을 초과합니다.", size: "용량 비교에는 비어 있지 않은 올바른 원본이 필요합니다.", failure: "{name}을 압축하지 못했습니다. 이미지가 손상되었거나 지원되지 않을 수 있습니다." } }, + }, + ja: { + metadata: { title: "画像圧縮 — Secure Tools", description: "JPEG、PNG、WebP画像をブラウザー内でローカルに圧縮します。" }, + copy: { eyebrow: "画像ツール", title: "画像圧縮", description: "JPEG、PNG、WebP画像をローカルで再エンコードし、結果のファイルサイズを比較します。", queue: { meta: "{size} · {format} · {position}番目" }, settings: { title: "圧縮設定", format: "出力形式", original: "元の形式", quality: "品質", qualityHelp: "JPEGとWebPに適用します。PNGは非可逆品質設定なしで再エンコードされます。", outputHelp: "ピクセル寸法は変わりません。PNGの削減は限定的な場合があります。JPEGの透明部分は白になり、埋め込みメタデータは削除されます。", imageFile: "圧縮画像", zipFile: "ZIPアーカイブ" }, actions: { compress: "圧縮して保存" }, results: { title: "圧縮結果", original: "元のサイズ", result: "結果", change: "変化", saved: "{bytes}削減({percent}%)", increased: "{bytes}増加(+{percent}%)", aggregateSaved: "合計: {original} → {result}。{bytes}削減({percent}%)。", aggregateIncreased: "合計: {original} → {result}。{bytes}増加(+{percent}%)。" }, status: { compressing: "ローカルで画像を圧縮中…", progress: "{total}件中{completed}件目を圧縮中…", progressLabel: "画像圧縮の進行状況", saved: "{count}件の画像を{name}に保存しました。", downloaded: "{count}件の画像を含む{name}をダウンロードしました。", saveCancelled: "保存をキャンセルしました。生成結果は引き続き確認できます。", settingsChanged: "設定が変更されました。結果を更新するには再圧縮してください。" }, errors: { noFiles: "圧縮する画像を追加してください。", format: "元の形式、JPEG、PNG、WebPから選択してください。", jobPixels: "合計2億デコードピクセルの上限を超えます。", size: "サイズ比較には空でない有効な元ファイルが必要です。", failure: "{name}を圧縮できませんでした。画像が破損している可能性があります。" } }, + }, + es: { + metadata: { title: "Compresor de imágenes — Secure Tools", description: "Comprime imágenes JPEG, PNG y WebP localmente en tu navegador." }, + copy: { eyebrow: "Herramienta de imagen", title: "Compresor de imágenes", description: "Recodifica imágenes JPEG, PNG y WebP localmente y compara los tamaños resultantes.", queue: { meta: "{size} · {format} · elemento {position}" }, settings: { title: "Ajustes de compresión", format: "Formato de salida", original: "Formato original", quality: "Calidad", qualityHelp: "Se aplica a JPEG y WebP. PNG se recodifica sin calidad con pérdida.", outputHelp: "Las dimensiones no cambian. La reducción de PNG puede ser limitada. La transparencia JPEG se vuelve blanca y se eliminan los metadatos.", imageFile: "Imagen comprimida", zipFile: "Archivo ZIP" }, actions: { compress: "Comprimir y guardar" }, results: { title: "Resultados", original: "Original", result: "Resultado", change: "Cambio", saved: "Ahorrado {bytes} ({percent}%)", increased: "Aumentó {bytes} (+{percent}%)", aggregateSaved: "Total: {original} → {result}. Ahorrado {bytes} ({percent}%).", aggregateIncreased: "Total: {original} → {result}. Aumentó {bytes} (+{percent}%)." }, status: { compressing: "Comprimiendo localmente…", progress: "Comprimiendo imagen {completed} de {total}…", progressLabel: "Progreso de compresión", saved: "Se guardó {name} con {count} imagen(es).", downloaded: "Se descargó {name} con {count} imagen(es).", saveCancelled: "Guardado cancelado. Los resultados siguen disponibles.", settingsChanged: "Los ajustes cambiaron. Comprime de nuevo para actualizar los resultados." }, errors: { noFiles: "Añade al menos una imagen.", format: "Elige Original, JPEG, PNG o WebP.", jobPixels: "El lote supera 200 megapíxeles de trabajo decodificado.", size: "Se requiere un origen válido y no vacío.", failure: "No se pudo comprimir {name}. La imagen puede estar dañada." } }, + }, + de: { + metadata: { title: "Bildkompressor — Secure Tools", description: "JPEG-, PNG- und WebP-Bilder lokal im Browser komprimieren." }, + copy: { eyebrow: "Bildwerkzeug", title: "Bildkompressor", description: "JPEG-, PNG- und WebP-Bilder lokal neu kodieren und Ergebnisgrößen vergleichen.", queue: { meta: "{size} · {format} · Element {position}" }, settings: { title: "Komprimierungseinstellungen", format: "Ausgabeformat", original: "Originalformat", quality: "Qualität", qualityHelp: "Gilt für JPEG und WebP. PNG wird ohne verlustbehaftete Qualität neu kodiert.", outputHelp: "Pixelmaße bleiben gleich. PNG kann nur begrenzt kleiner werden. JPEG-Transparenz wird weiß; Metadaten werden entfernt.", imageFile: "Komprimiertes Bild", zipFile: "ZIP-Archiv" }, actions: { compress: "Komprimieren und speichern" }, results: { title: "Komprimierungsergebnisse", original: "Original", result: "Ergebnis", change: "Änderung", saved: "{bytes} gespart ({percent}%)", increased: "{bytes} größer (+{percent}%)", aggregateSaved: "Gesamt: {original} → {result}. {bytes} gespart ({percent}%).", aggregateIncreased: "Gesamt: {original} → {result}. {bytes} größer (+{percent}%)." }, status: { compressing: "Bilder werden lokal komprimiert…", progress: "Bild {completed} von {total} wird komprimiert…", progressLabel: "Fortschritt der Bildkomprimierung", saved: "{name} mit {count} Bild(ern) gespeichert.", downloaded: "{name} mit {count} Bild(ern) heruntergeladen.", saveCancelled: "Speichern abgebrochen. Ergebnisse bleiben verfügbar.", settingsChanged: "Einstellungen geändert. Für neue Ergebnisse erneut komprimieren." }, errors: { noFiles: "Mindestens ein Bild hinzufügen.", format: "Original, JPEG, PNG oder WebP auswählen.", jobPixels: "Der Stapel überschreitet 200 Megapixel Dekodierarbeit.", size: "Für den Vergleich ist eine gültige, nicht leere Quelle erforderlich.", failure: "{name} konnte nicht komprimiert werden." } }, + }, + fr: { + metadata: { title: "Compresseur d’images — Secure Tools", description: "Compressez des images JPEG, PNG et WebP localement dans votre navigateur." }, + copy: { eyebrow: "Outil d’image", title: "Compresseur d’images", description: "Réencodez des images JPEG, PNG et WebP localement et comparez les tailles obtenues.", queue: { meta: "{size} · {format} · élément {position}" }, settings: { title: "Paramètres de compression", format: "Format de sortie", original: "Format d’origine", quality: "Qualité", qualityHelp: "S’applique à JPEG et WebP. PNG est réencodé sans qualité avec perte.", outputHelp: "Les dimensions restent identiques. La réduction PNG peut être limitée. La transparence JPEG devient blanche et les métadonnées sont supprimées.", imageFile: "Image compressée", zipFile: "Archive ZIP" }, actions: { compress: "Compresser et enregistrer" }, results: { title: "Résultats", original: "Original", result: "Résultat", change: "Variation", saved: "{bytes} économisés ({percent} %)", increased: "Augmentation de {bytes} (+{percent} %)", aggregateSaved: "Total : {original} → {result}. {bytes} économisés ({percent} %).", aggregateIncreased: "Total : {original} → {result}. Augmentation de {bytes} (+{percent} %)." }, status: { compressing: "Compression locale…", progress: "Compression de l’image {completed} sur {total}…", progressLabel: "Progression de la compression", saved: "{name} enregistré avec {count} image(s).", downloaded: "{name} téléchargé avec {count} image(s).", saveCancelled: "Enregistrement annulé. Les résultats restent disponibles.", settingsChanged: "Les paramètres ont changé. Relancez la compression pour actualiser les résultats." }, errors: { noFiles: "Ajoutez au moins une image.", format: "Choisissez Original, JPEG, PNG ou WebP.", jobPixels: "Le lot dépasse 200 mégapixels de travail décodé.", size: "Une source valide et non vide est requise.", failure: "Impossible de compresser {name}. L’image peut être endommagée." } }, + }, +}; diff --git a/tools/image/compress/app.js b/tools/image/compress/app.js new file mode 100644 index 0000000..02241e0 --- /dev/null +++ b/tools/image/compress/app.js @@ -0,0 +1,34 @@ +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 { compressImages, createCompressionPlan } from "./compressor.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"), compress: document.querySelector("#compress-images"), form: document.querySelector("#compression-settings"), format: document.querySelector("#output-format"), quality: document.querySelector("#quality"), qualityField: document.querySelector("#quality-field"), qualityValue: document.querySelector("#quality-value"), progress: document.querySelector("#compression-progress"), status: document.querySelector("#tool-status"), results: document.querySelector("#compression-results"), resultList: document.querySelector("#result-list"), summary: document.querySelector("#result-summary"), +}; +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 invalidateResults(changed = false) { if (changed && state.result) setStatus("imageCompressor.status.settingsChanged"); state.result = null; elements.results.hidden = true; elements.resultList.replaceChildren(); elements.summary.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 updateFormatSettings() { 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 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.compress.disabled = state.busy || !state.items.length; elements.add.disabled = state.busy; elements.input.disabled = state.busy; elements.format.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("imageCompressor.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); }); updateFormatSettings(); } + +async function addFiles(files) { const { accepted, rejected } = await selectImageQueueFiles(state.items.map((item) => item.file), files); for (const file of accepted) state.items.push({ id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, file, format: await validateImageSignature(file), url: URL.createObjectURL(file) }); invalidateResults(); 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); invalidateResults(); setStatus("imageToPdf.status.removed"); renderQueue(); } +function moveItem(index, offset) { const next = moveArrayItem(state.items, index, offset); if (next < 0) return; invalidateResults(); 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 = ""; invalidateResults(); setStatus("imageToPdf.status.cleared"); renderQueue(); } +function changeText(metrics) { return metrics.larger ? message("imageCompressor.results.increased", { percent: Math.abs(metrics.reductionPercent).toFixed(1), bytes: formatBytes(Math.abs(metrics.difference)) }) : message("imageCompressor.results.saved", { percent: metrics.reductionPercent.toFixed(1), bytes: formatBytes(metrics.difference) }); } +function metric(labelKey, value) { const wrapper = document.createElement("span"); wrapper.className = "compression-result__metric"; const label = document.createElement("span"); label.textContent = t(labelKey); const strong = document.createElement("strong"); strong.textContent = value; wrapper.append(label, strong); return wrapper; } +function renderResults(result) { state.result = result; elements.resultList.replaceChildren(); result.results.forEach((entry) => { const item = document.createElement("li"); item.className = "compression-result"; const name = document.createElement("strong"); name.className = "compression-result__name"; name.textContent = entry.name; name.title = entry.name; const change = metric("imageCompressor.results.change", changeText(entry)); change.classList.add("compression-result__change"); item.append(name, metric("imageCompressor.results.original", formatBytes(entry.originalSize)), metric("imageCompressor.results.result", formatBytes(entry.resultSize)), change); elements.resultList.append(item); }); elements.summary.textContent = message(result.aggregate.larger ? "imageCompressor.results.aggregateIncreased" : "imageCompressor.results.aggregateSaved", { original: formatBytes(result.aggregate.originalSize), result: formatBytes(result.aggregate.resultSize), bytes: formatBytes(Math.abs(result.aggregate.difference)), percent: Math.abs(result.aggregate.reductionPercent).toFixed(1) }); elements.results.hidden = false; } +function errorMessage(error) { return message(({ NO_FILES: "imageCompressor.errors.noFiles", IMAGE_FORMAT_INVALID: "imageCompressor.errors.format", IMAGE_DECODE_FAILED: "imageToPdf.errors.decode", IMAGE_DIMENSION_EXCEEDED: "imageToPdf.errors.dimension", IMAGE_PIXELS_EXCEEDED: "imageToPdf.errors.pixels", IMAGE_JOB_PIXELS_EXCEEDED: "imageCompressor.errors.jobPixels", COMPRESSION_SIZE_INVALID: "imageCompressor.errors.size", CANVAS_UNAVAILABLE: "imageConverter.errors.canvas", IMAGE_ENCODER_FAILED: "imageConverter.errors.encoder", ARCHIVE_LIBRARY_UNAVAILABLE: "imageConverter.errors.archiveLibrary", ARCHIVE_GENERATION_FAILED: "imageConverter.errors.archive" })[error.code] || "imageCompressor.errors.failure", { name: error.fileName || "" }); } + +async function compressQueue() { if (!state.items.length || state.busy) { setStatus("imageCompressor.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) }; try { createCompressionPlan(options); } catch (error) { elements.status.textContent = errorMessage(error); elements.status.dataset.tone = "error"; return; } state.busy = true; elements.progress.hidden = false; elements.progress.max = files.length; elements.progress.value = 0; invalidateResults(); setStatus("imageCompressor.status.compressing"); renderQueue(); try { const result = await compressImages({ files, outputFormat: options.outputFormat, quality: options.quality, JSZip: window.JSZip, onProgress(completed, total) { elements.progress.value = completed; setStatus("imageCompressor.status.progress", { completed, total }); } }); renderResults(result); const multiple = files.length > 1; const outputType = multiple ? "application/zip" : IMAGE_FORMATS[result.plan.formats[0]].mimeType; const extension = multiple ? ".zip" : `.${IMAGE_FORMATS[result.plan.formats[0]].extension}`; let handle = null; if ("showSaveFilePicker" in window) { try { handle = await requestSaveHandle(window, { suggestedName: result.filename, description: t(multiple ? "imageCompressor.settings.zipFile" : "imageCompressor.settings.imageFile"), mimeType: outputType, extension }); } catch (error) { if (error.name === "AbortError") { setStatus("imageCompressor.status.saveCancelled"); return; } throw error; } } if (handle) await writeBlobToHandle(handle, result.blob); else downloadBlob(result.blob, result.filename); setStatus(handle ? "imageCompressor.status.saved" : "imageCompressor.status.downloaded", { name: result.filename, count: result.entries.length }, "success"); } catch (error) { console.error("Image compression failed", error); elements.status.textContent = 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.compress.addEventListener("click", compressQueue); elements.format.addEventListener("change", () => { invalidateResults(true); updateFormatSettings(); }); elements.quality.addEventListener("input", () => { elements.qualityValue.textContent = `${Math.round(Number(elements.quality.value) * 100)}%`; invalidateResults(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.result) renderResults(state.result); 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/compress/compressor.js b/tools/image/compress/compressor.js new file mode 100644 index 0000000..9e547ef --- /dev/null +++ b/tools/image/compress/compressor.js @@ -0,0 +1,91 @@ +import { decodeImage, validateImageSignature } from "../../shared/image.js"; +import { + createImageOutputNames, + IMAGE_FORMATS, + normalizeFormat, + normalizeQuality, + packageImageOutputs, + renderDecodedImage, +} from "../../shared/image-output.js"; + +export const OUTPUT_FORMATS = Object.freeze(["original", "jpeg", "png", "webp"]); +export const MAX_JOB_PIXELS = 200_000_000; +export const ARCHIVE_FILENAME = "compressed_images.zip"; + +function compressionError(code, cause) { + const error = new Error(code, cause ? { cause } : undefined); + error.code = code; + return error; +} + +export function normalizeOutputFormat(value) { + const format = String(value || "").toLowerCase(); + if (!OUTPUT_FORMATS.includes(format)) throw compressionError("IMAGE_FORMAT_INVALID"); + return format; +} + +export function resolveOutputFormats(outputFormat, sourceFormats) { + const normalized = normalizeOutputFormat(outputFormat); + return sourceFormats.map((sourceFormat) => normalized === "original" ? normalizeFormat(sourceFormat) : normalizeFormat(normalized)); +} + +export function calculateCompressionMetrics(originalSize, resultSize) { + const original = Number(originalSize); + const result = Number(resultSize); + if (!Number.isFinite(original) || original <= 0 || !Number.isFinite(result) || result < 0) throw compressionError("COMPRESSION_SIZE_INVALID"); + const difference = original - result; + const reductionPercent = Math.round((difference / original) * 1000) / 10; + return { originalSize: original, resultSize: result, difference, reductionPercent, larger: difference < 0 }; +} + +export function calculateAggregateMetrics(results) { + if (!Array.isArray(results) || results.length < 1) throw compressionError("OUTPUT_INVALID"); + return calculateCompressionMetrics( + results.reduce((total, result) => total + result.originalSize, 0), + results.reduce((total, result) => total + result.resultSize, 0), + ); +} + +export function createCompressionPlan({ files, sourceFormats, outputFormat, quality }) { + if (!Array.isArray(files) || files.length < 1) throw compressionError("NO_FILES"); + if (!Array.isArray(sourceFormats) || sourceFormats.length !== files.length) throw compressionError("OUTPUT_INVALID"); + const formats = resolveOutputFormats(outputFormat, sourceFormats); + return { + formats, + qualities: formats.map((format) => normalizeQuality(format, quality)), + names: createImageOutputNames(files, formats, { suffix: "_compressed", archive: ARCHIVE_FILENAME }), + }; +} + +export async function compressImages({ + files, outputFormat, quality, JSZip, decode = decodeImage, identify = validateImageSignature, + canvasFactory = () => document.createElement("canvas"), onProgress = () => {}, +}) { + const sourceFormats = []; + for (const file of files) sourceFormats.push(await identify(file)); + const plan = createCompressionPlan({ files, sourceFormats, outputFormat, quality }); + const blobs = []; + const results = []; + let decodedPixels = 0; + + for (let index = 0; index < files.length; index += 1) { + let decoded; + try { + decoded = await decode(files[index]); + decodedPixels += decoded.width * decoded.height; + if (decodedPixels > MAX_JOB_PIXELS) throw compressionError("IMAGE_JOB_PIXELS_EXCEEDED"); + const blob = await renderDecodedImage({ decoded, format: plan.formats[index], quality: plan.qualities[index], canvasFactory }); + blobs.push(blob); + const metrics = calculateCompressionMetrics(files[index].size, blob.size); + results.push({ name: plan.names.entries[index], width: decoded.width, height: decoded.height, ...metrics }); + 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, aggregate: calculateAggregateMetrics(results) }; +} diff --git a/tools/image/compress/index.html b/tools/image/compress/index.html new file mode 100644 index 0000000..ed314a0 --- /dev/null +++ b/tools/image/compress/index.html @@ -0,0 +1,16 @@ + + + +Image Compressor — Secure Tools + + + + +

Image tool

Image Compressor

Re-encode JPEG, PNG, and WebP images locally and compare the resulting file sizes.

+

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/compress/tool.css b/tools/image/compress/tool.css new file mode 100644 index 0000000..bb3ca89 --- /dev/null +++ b/tools/image/compress/tool.css @@ -0,0 +1,22 @@ +.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; } +.compress-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; } +.compression-results { margin-top: var(--space-5); } +.compression-results__list { display: grid; gap: var(--space-3); margin: var(--space-3) 0 0; padding: 0; list-style: none; } +.compression-result { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, minmax(5rem, auto)); gap: var(--space-3); align-items: center; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.compression-result__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compression-result__metric { display: grid; gap: 0.15rem; } +.compression-result__metric span { color: var(--text-secondary); font-size: 0.72rem; } +.compression-result__change { font-weight: var(--font-weight-bold); } +.compression-summary { margin: var(--space-3) 0 0; font-weight: var(--font-weight-bold); overflow-wrap: anywhere; } + +@media (max-width: 44rem) { + .compression-result { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .compression-result__name { grid-column: 1 / -1; white-space: normal; overflow-wrap: anywhere; } +} +@media (max-width: 24rem) { + .settings { padding: var(--space-4); } + .compression-result { grid-template-columns: 1fr; } + .compression-result__name { grid-column: auto; } +} diff --git a/tools/image/index.html b/tools/image/index.html index df36af7..0bd6a3f 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -94,6 +94,16 @@

    Image Resize

    Resize images to exact pixel dimensions.

    +
  1. + +
    +Available + +
    +

    Image Compressor

    +

    Reduce file size with clear quality controls.

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

    From 1ff7637ca2f7bad53dbc2b47f4b515fa65b732b4 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:54:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B[Fix]=20Allow=20compression=20r?= =?UTF-8?q?esult=20names=20to=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image/compress/tool.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/image/compress/tool.css b/tools/image/compress/tool.css index bb3ca89..d1766f9 100644 --- a/tools/image/compress/tool.css +++ b/tools/image/compress/tool.css @@ -5,7 +5,7 @@ .compression-results { margin-top: var(--space-5); } .compression-results__list { display: grid; gap: var(--space-3); margin: var(--space-3) 0 0; padding: 0; list-style: none; } .compression-result { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, minmax(5rem, auto)); gap: var(--space-3); align-items: center; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); } -.compression-result__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compression-result__name { min-width: 0; overflow-wrap: anywhere; } .compression-result__metric { display: grid; gap: 0.15rem; } .compression-result__metric span { color: var(--text-secondary); font-size: 0.72rem; } .compression-result__change { font-weight: var(--font-weight-bold); } From 53755b66c5eb4bee1a0003b8fe8e8528fed9eb09 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 11:55:08 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=85[Test]=20Add=20Image=20Compressor?= =?UTF-8?q?=20regression=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/i18n-quality.test.mjs | 4 +-- tests/image-compressor.test.mjs | 58 +++++++++++++++++++++++++++++++++ tests/image-converter.test.mjs | 5 +-- tests/image-resize.test.mjs | 4 +-- tests/run-all.mjs | 1 + 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 tests/image-compressor.test.mjs diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 8c4cb69..9b06ae6 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, 597); + assert.equal(english.size, 632); 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, 17, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 18, "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-compressor.test.mjs b/tests/image-compressor.test.mjs new file mode 100644 index 0000000..7ea515a --- /dev/null +++ b/tests/image-compressor.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ARCHIVE_FILENAME, calculateAggregateMetrics, calculateCompressionMetrics, compressImages, + createCompressionPlan, MAX_JOB_PIXELS, normalizeOutputFormat, resolveOutputFormats, +} from "../tools/image/compress/compressor.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); +assert.equal(normalizeOutputFormat("ORIGINAL"), "original"); +assert.throws(() => normalizeOutputFormat("gif"), (error) => error.code === "IMAGE_FORMAT_INVALID"); +assert.deepEqual(resolveOutputFormats("original", ["jpeg", "png", "webp"]), ["jpeg", "png", "webp"]); +assert.deepEqual(resolveOutputFormats("webp", ["jpeg", "png"]), ["webp", "webp"]); + +assert.deepEqual(calculateCompressionMetrics(1000, 600), { originalSize: 1000, resultSize: 600, difference: 400, reductionPercent: 40, larger: false }); +assert.deepEqual(calculateCompressionMetrics(1000, 1134), { originalSize: 1000, resultSize: 1134, difference: -134, reductionPercent: -13.4, larger: true }); +assert.deepEqual(calculateAggregateMetrics([{ originalSize: 1000, resultSize: 600 }, { originalSize: 500, resultSize: 600 }]), { originalSize: 1500, resultSize: 1200, difference: 300, reductionPercent: 20, larger: false }); +assert.throws(() => calculateCompressionMetrics(0, 10), (error) => error.code === "COMPRESSION_SIZE_INVALID"); +assert.throws(() => calculateCompressionMetrics(10, -1), (error) => error.code === "COMPRESSION_SIZE_INVALID"); + +const plan = createCompressionPlan({ files: [{ name: "photo.jpg" }, { name: "photo.png" }, { name: "한글 사진.webp" }], sourceFormats: ["jpeg", "png", "webp"], outputFormat: "original", quality: 0.8 }); +assert.deepEqual(plan.formats, ["jpeg", "png", "webp"]); +assert.deepEqual(plan.qualities, [0.8, null, 0.8]); +assert.deepEqual(plan.names, { archive: ARCHIVE_FILENAME, entries: ["photo_compressed.jpg", "photo_compressed.png", "한글 사진_compressed.webp"] }); +assert.deepEqual(createCompressionPlan({ files: [{ name: "same.jpg" }, { name: "same.png" }], sourceFormats: ["png", "png"], outputFormat: "png", quality: 0.5 }).names.entries, ["same_compressed.png", "same_compressed_2.png"]); +assert.ok(new TextEncoder().encode(plan.names.entries[2]).length <= 196); + +function canvasHarness({ resultSize = 40, fail = false } = {}) { const events = []; const context = { set fillStyle(value) { events.push(["fillStyle", value]); }, fillRect(...args) { events.push(["fillRect", ...args]); }, drawImage(...args) { events.push(["drawImage", ...args]); } }; const canvas = { width: 0, height: 0, getContext(type, options) { events.push(["context", type, options]); return context; }, toBlob(callback, type, quality) { events.push(["encode", type, quality]); callback(fail ? null : new Blob([new Uint8Array(resultSize)], { type })); } }; return { canvas, events }; } +class TestZip { constructor() { this.entries = []; TestZip.instance = this; } file(name, bytes) { this.entries.push([name, bytes.length]); } async generateAsync(options) { this.options = options; return new Blob(["zip"], { type: "application/zip" }); } } + +let closed = 0; const canvases = []; +const result = await compressImages({ files: [{ name: "alpha.png", size: 100 }, { name: "photo.jpg", size: 80 }], outputFormat: "original", quality: 0.75, JSZip: TestZip, identify: async (file) => file.name.endsWith(".png") ? "png" : "jpeg", decode: async (file) => ({ source: file, width: 20, height: 10, close() { closed += 1; } }), canvasFactory: () => { const harness = canvasHarness({ resultSize: 40 }); canvases.push(harness); return harness.canvas; } }); +assert.equal(result.kind, "zip"); assert.equal(closed, 2); assert.deepEqual(result.results.map(({ width, height, originalSize, resultSize }) => ({ width, height, originalSize, resultSize })), [{ width: 20, height: 10, originalSize: 100, resultSize: 40 }, { width: 20, height: 10, originalSize: 80, resultSize: 40 }]); +assert.deepEqual(result.aggregate, { originalSize: 180, resultSize: 80, difference: 100, reductionPercent: 55.6, larger: false }); +assert.deepEqual(canvases[0].events[0], ["context", "2d", { alpha: true }]); assert.equal(canvases[0].events.some(([event]) => event === "fillRect"), false); assert.deepEqual(canvases[0].events.find(([event]) => event === "drawImage").length, 4, "Compression must preserve source dimensions without resize arguments"); +assert.deepEqual(canvases[1].events.slice(0, 3), [["context", "2d", { alpha: false }], ["fillStyle", "#ffffff"], ["fillRect", 0, 0, 20, 10]]); +assert.deepEqual(canvases.map(({ events }) => events.find(([event]) => event === "encode").slice(1)), [["image/png", undefined], ["image/jpeg", 0.75]]); +assert.deepEqual(TestZip.instance.entries.map(([name]) => name), ["alpha_compressed.png", "photo_compressed.jpg"]); + +let repeatClosed = 0; +for (const quality of [0.9, 0.7]) await compressImages({ files: [{ name: "again.webp", size: 100 }], outputFormat: "webp", quality, identify: async () => "webp", decode: async () => ({ source: {}, width: 10, height: 5, close() { repeatClosed += 1; } }), canvasFactory: () => canvasHarness({ resultSize: 60 }).canvas }); +assert.equal(repeatClosed, 2, "Repeated compression releases every decoded source"); +let failureClosed = false; +await assert.rejects(compressImages({ files: [{ name: "broken.png", size: 100 }], outputFormat: "png", identify: async () => "png", decode: async () => ({ source: {}, width: 10, height: 10, close() { failureClosed = true; } }), canvasFactory: () => canvasHarness({ fail: true }).canvas }), (error) => error.code === "IMAGE_ENCODER_FAILED" && error.fileName === "broken.png"); +assert.equal(failureClosed, true); +let limitClosed = 0; +await assert.rejects(compressImages({ files: Array.from({ length: 5 }, (_, i) => ({ name: `${i}.png`, size: 100 })), outputFormat: "png", identify: async () => "png", decode: async () => ({ source: {}, width: 10_000, height: 5_000, close() { limitClosed += 1; } }), canvasFactory: () => canvasHarness().canvas, JSZip: TestZip }), (error) => error.code === "IMAGE_JOB_PIXELS_EXCEEDED" && error.fileName === "4.png"); +assert.equal(MAX_JOB_PIXELS, 200_000_000); assert.equal(limitClosed, 5); + +const html = read("tools/image/compress/index.html"); const app = read("tools/image/compress/app.js"); const logic = read("tools/image/compress/compressor.js"); const css = read("tools/image/compress/tool.css"); const category = read("tools/image/index.html"); +assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); assert.doesNotMatch(category, /metadataInspector|metadataCleaner|categories\.plannedNote/); +assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /id="output-format"[\s\S]*value="original"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/); assert.match(html, /id="quality"[^>]*min="0\.5"[^>]*max="1"[^>]*value="0\.8"/); assert.match(html, /id="compression-results"[^>]*hidden[^>]*aria-labelledby="results-title"/); assert.match(html, /role="status" aria-live="polite"/); assert.match(html, /connect-src 'none'/); +assert.match(app, /showQuality = elements\.format\.value !== "png"/); assert.match(app, /invalidateResults\(true\)/); assert.match(app, /finally \{ state\.busy = false; elements\.progress\.hidden = true/); assert.match(app, /URL\.revokeObjectURL/); assert.match(logic, /decoded\?\.close\(\)/); assert.match(read("tools/shared/image.js"), /imageOrientation: "from-image"/); +assert.doesNotMatch(app + logic, /\bfetch\s*\(|XMLHttpRequest|WebSocket|sendBeacon|https?:\/\//); assert.match(css, /@media \(max-width: 44rem\)/); assert.match(css, /@media \(max-width: 24rem\)/); assert.doesNotMatch(css, /width:\s*100vw|word-break:\s*break-all|transform:\s*scale\(/); +assert.ok(read("tests/run-all.mjs").includes("image-converter.test.mjs")); assert.ok(read("tests/run-all.mjs").includes("image-resize.test.mjs")); assert.ok(read("tests/run-all.mjs").includes("pdf-merge-and-categories.test.mjs")); +console.log("Image Compressor metrics, formats, quality, alpha, dimensions, batch, cleanup, UI, privacy, and regression contracts passed."); diff --git a/tests/image-converter.test.mjs b/tests/image-converter.test.mjs index caf2f9b..e25b19f 100644 --- a/tests/image-converter.test.mjs +++ b/tests/image-converter.test.mjs @@ -171,8 +171,9 @@ 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.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(category, /href="\.\/compress\/"[\s\S]*status--available/); +assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); +assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /