Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/`.
Expand Down
12 changes: 11 additions & 1 deletion js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
});
Expand Down
56 changes: 56 additions & 0 deletions js/locales/image-resize.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions tests/i18n-quality.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(/<select[^>]*data-language-select[^>]*>([\s\S]*?)<\/select>/)?.[1];
Expand Down
5 changes: 3 additions & 2 deletions tests/image-converter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<label class="drop-zone__picker" for="file-input">/);
assert.match(html, /id="output-format"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/);
Expand Down
113 changes: 113 additions & 0 deletions tests/image-resize.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import {
ARCHIVE_FILENAME,
calculateResizeDimensions,
createResizePlan,
MAX_OUTPUT_JOB_PIXELS,
normalizeOutputFormat,
normalizeResizeMode,
resizeImages,
resolveOutputFormats,
} from "../tools/image/resize/resize.js";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");

assert.equal(normalizeResizeMode("pixels"), "pixels");
assert.equal(normalizeResizeMode("percentage"), "percentage");
assert.throws(() => normalizeResizeMode("crop"), (error) => error.code === "RESIZE_MODE_INVALID");
assert.equal(normalizeOutputFormat("ORIGINAL"), "original");
assert.throws(() => normalizeOutputFormat("gif"), (error) => error.code === "IMAGE_FORMAT_INVALID");

assert.deepEqual(calculateResizeDimensions({ sourceWidth: 1920, sourceHeight: 1080, mode: "percentage", percentage: 50 }), { width: 960, height: 540 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 1, sourceHeight: 1, mode: "percentage", percentage: 1 }), { width: 1, height: 1 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 1920, sourceHeight: 1080, mode: "pixels", width: 1280, height: "", lockAspectRatio: true }), { width: 1280, height: 720 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 1200, mode: "pixels", width: "", height: 600, lockAspectRatio: true }), { width: 400, height: 600 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 2000, sourceHeight: 1000, mode: "pixels", width: 1000, height: 1000, lockAspectRatio: true }), { width: 1000, height: 500 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 1200, mode: "pixels", width: 1000, height: 1000, lockAspectRatio: true }), { width: 667, height: 1000 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 600, mode: "pixels", width: 400, height: 200, lockAspectRatio: false }), { width: 400, height: 200 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 600, mode: "pixels", width: 1600, height: 1200, lockAspectRatio: true, allowEnlargement: false }), { width: 800, height: 600 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 600, mode: "pixels", width: 1600, height: 1200, lockAspectRatio: true, allowEnlargement: true }), { width: 1600, height: 1200 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 600, mode: "percentage", percentage: 150, allowEnlargement: false }), { width: 800, height: 600 });
assert.deepEqual(calculateResizeDimensions({ sourceWidth: 800, sourceHeight: 600, mode: "percentage", percentage: 150, allowEnlargement: true }), { width: 1200, height: 900 });
for (const settings of [
{ mode: "pixels", width: "", height: "", lockAspectRatio: true },
{ mode: "pixels", width: 0, height: 20, lockAspectRatio: false },
]) assert.throws(() => calculateResizeDimensions({ sourceWidth: 100, sourceHeight: 100, ...settings }), (error) => /^RESIZE_DIMENSION_/.test(error.code));
assert.throws(() => calculateResizeDimensions({ sourceWidth: 100, sourceHeight: 100, mode: "percentage", percentage: 0 }), (error) => error.code === "RESIZE_PERCENTAGE_INVALID");
assert.throws(() => calculateResizeDimensions({ sourceWidth: 100, sourceHeight: 100, mode: "percentage", percentage: 1001, allowEnlargement: true }), (error) => error.code === "RESIZE_PERCENTAGE_INVALID");
assert.throws(() => calculateResizeDimensions({ sourceWidth: 100, sourceHeight: 100, mode: "pixels", width: 16385, lockAspectRatio: true, allowEnlargement: true }), (error) => error.code === "IMAGE_DIMENSION_EXCEEDED");

assert.deepEqual(resolveOutputFormats("original", ["jpeg", "png", "webp"]), ["jpeg", "png", "webp"]);
assert.deepEqual(resolveOutputFormats("png", ["jpeg", "webp"]), ["png", "png"]);
const plan = createResizePlan({ files: [{ name: "photo.jpg" }, { name: "photo.png" }, { name: "한글 사진.webp" }], sourceFormats: ["jpeg", "png", "webp"], outputFormat: "original", quality: 0.8, settings: { mode: "percentage", percentage: 50 } });
assert.deepEqual(plan.names, { archive: ARCHIVE_FILENAME, entries: ["photo_resized.jpg", "photo_resized.png", "한글 사진_resized.webp"] });
const collisions = createResizePlan({ files: [{ name: "photo.png" }, { name: "photo.jpg" }], sourceFormats: ["png", "png"], outputFormat: "png", quality: 1, settings: { mode: "percentage", percentage: 50 } });
assert.deepEqual(collisions.names.entries, ["photo_resized.png", "photo_resized_2.png"]);
assert.ok(new TextEncoder().encode(plan.names.entries[2]).length <= 196, "Unicode output remains bounded after suffix and extension");

function canvasHarness() {
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(new Blob([type], { 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 batch = await resizeImages({
files: [{ name: "wide.png" }, { name: "tall.jpg" }], outputFormat: "jpeg", quality: 0.75,
settings: { mode: "percentage", percentage: 50, allowEnlargement: false }, JSZip: TestZip,
identify: async (file) => file.name.endsWith(".jpg") ? "jpeg" : "png",
decode: async (file) => ({ source: file, width: file.name.startsWith("wide") ? 2000 : 800, height: file.name.startsWith("wide") ? 1000 : 1200, close() { closed += 1; } }),
canvasFactory: () => { const harness = canvasHarness(); canvases.push(harness); return harness.canvas; },
});
assert.equal(batch.kind, "zip");
assert.deepEqual(batch.results, [{ original: { width: 2000, height: 1000 }, output: { width: 1000, height: 500 } }, { original: { width: 800, height: 1200 }, output: { width: 400, height: 600 } }]);
assert.equal(closed, 2);
assert.ok(canvases.every(({ events }) => events.some((event) => event[0] === "fillRect" && event[1] === 0)), "JPEG must flatten alpha onto white");
assert.deepEqual(canvases[0].events.find((event) => event[0] === "drawImage").slice(-2), [1000, 500]);
assert.deepEqual(TestZip.instance.entries.map(([name]) => name), ["wide_resized.jpg", "tall_resized.jpg"]);
assert.deepEqual(TestZip.instance.options, { type: "blob", compression: "STORE", streamFiles: true });

let limitClosed = 0;
await assert.rejects(resizeImages({
files: Array.from({ length: 5 }, (_, index) => ({ name: `${index}.png` })), outputFormat: "png", settings: { mode: "percentage", percentage: 100 }, JSZip: TestZip,
identify: async () => "png", decode: async () => ({ source: {}, width: 10_000, height: 5_000, close() { limitClosed += 1; } }), canvasFactory: () => canvasHarness().canvas,
}), (error) => error.code === "RESIZE_JOB_PIXELS_EXCEEDED" && error.fileName === "4.png");
assert.equal(MAX_OUTPUT_JOB_PIXELS, 200_000_000);
assert.equal(limitClosed, 5, "Decoded sources close after aggregate output rejection");

let recoveryClosed = false;
await assert.rejects(resizeImages({ files: [{ name: "bad.png" }], outputFormat: "png", settings: { mode: "pixels", width: 0, height: 10, lockAspectRatio: false }, identify: async () => "png", decode: async () => ({ source: {}, width: 20, height: 20, close() { recoveryClosed = true; } }) }), (error) => error.code === "RESIZE_DIMENSION_INVALID");
assert.equal(recoveryClosed, true, "Decoded source closes after recoverable settings failure");

const html = read("tools/image/resize/index.html");
const app = read("tools/image/resize/app.js");
const logic = read("tools/image/resize/resize.js");
const css = read("tools/image/resize/tool.css");
const category = read("tools/image/index.html");
assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/);
assert.equal((category.match(/class="category-tool surface"/g) || []).length, 2);
assert.doesNotMatch(category, /imageCompressor|metadataInspector|metadataCleaner|categories\.plannedNote/);
assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/);
assert.match(html, /name="resize-mode"[^>]*value="pixels"/); assert.match(html, /name="resize-mode"[^>]*value="percentage"/);
assert.match(html, /id="aspect-ratio"[^>]*checked/); assert.match(html, /id="allow-enlargement"/);
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, /role="status" aria-live="polite"/); assert.match(html, /assets\/vendor\/jszip\/jszip\.min\.js/);
assert.match(html, /connect-src 'none'/); assert.doesNotMatch(html, /unsafe-inline|unsafe-eval|https?:\/\/(?!github\.com)/);
assert.match(app, /finally\s*\{[\s\S]*state\.busy = false[\s\S]*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: 38rem\)/); assert.doesNotMatch(css, /width:\s*100vw|word-break:\s*break-all|transform:\s*scale\(/);
assert.match(read("tools/image/converter/converter.js"), /from "\.\.\/\.\.\/shared\/image-output\.js"/);
assert.ok(read("tests/run-all.mjs").includes("image-converter.test.mjs"));
assert.ok(read("tests/run-all.mjs").includes("pdf-merge-and-categories.test.mjs"));

console.log("Image Resize dimensions, formats, alpha, batch, limits, recovery, UI, privacy, and regression contracts passed.");
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ for (const file of sourceFiles) runNode(["--check", file], `Syntax check for ${p
for (const test of [
"tests/image-to-pdf.test.mjs",
"tests/image-converter.test.mjs",
"tests/image-resize.test.mjs",
"tests/pdf-merge-and-categories.test.mjs",
"tests/file-input-queue-state.test.mjs",
"tests/pdf-split.test.mjs",
Expand Down
Loading
Loading