diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da8b5d..c86d7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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 Image Compressor with Original/JPEG/PNG/WebP output, truthful format-specific quality behavior, unchanged dimensions, and local batch ZIP saving. +- Added per-file and aggregate compression metrics that distinguish byte savings from larger generated results. - 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 866bc77..85509ab 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The v1 baseline is recorded in the [changelog](./CHANGELOG.md) and historical [v - [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. +- [Image Compressor](./tools/image/compress/) — quality-compress image batches locally and compare original and result sizes. - [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. @@ -156,6 +157,18 @@ Image Resize is available at `/tools/image/resize/` on the v2 integration branch - 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. +## Image Compressor + +Image Compressor is available at `/tools/image/compress/` on the v2 integration branch. + +- Accepts signature-validated JPEG, PNG, and WebP images and preserves their oriented pixel dimensions while processing sequentially in browser memory. +- Defaults to Original format. JPEG and WebP use an explicit 50–100% quality setting; PNG is re-encoded without a fake lossy-quality control, so size reduction may be limited. +- Reports original size, result size, byte difference, and signed percentage change for every file, plus aggregate batch totals. Larger results are kept and labeled as increases rather than savings. +- Preserves alpha for PNG/WebP and flattens transparent pixels onto white for JPEG. Canvas re-encoding strips EXIF and other embedded metadata. +- Uses collision-safe Unicode `_compressed` names and saves multiple outputs as `compressed_images.zip` through the same-origin JSZip dependency. +- Reuses the established input, queue, dimension, 50-megapixel per-image, and 200-megapixel aggregate decoded-work limits. Queues remain available after recoverable failures or save cancellation. +- Performs no target-size search, resizing, cropping, metadata editing, upload, analytics, telemetry, remote codec, or runtime network request. + ## 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 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/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..2260d25 --- /dev/null +++ b/tests/image-compressor.test.mjs @@ -0,0 +1,60 @@ +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.match(app, /const metrics = \{ results: result\.results, aggregate: result\.aggregate \}/, "Result UI state must not retain generated output blobs"); +assert.match(app, /generated && !error\.code \? message\("imageConverter\.errors\.save"\)/, "Post-generation failures use the dedicated save error"); +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, /