diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f1419..c8825b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,21 @@ # Changelog +## Unreleased + +### Added + +- Started the v2 cycle with a production Image category and local Image Converter. +- 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. + +### Changed + +- Removed the standalone planned Rotate PDF card; page rotation remains available as part of PDF Organizer. +- Moved shared browser image validation and decoding into `tools/shared/image.js` for PDF and Image tools. + ## 1.0.0 -This section describes the planned v1.0.0 release candidate. The Git tag and GitHub release are intentionally separate release-manager actions and are not created by Sprint 11. +Secure Tools v1.0.0 was published on 2026-08-24 after the Sprint 11 release-candidate hardening and manual release process. ### PDF tools diff --git a/README.md b/README.md index 5e0a158..db24ab6 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ Secure Tools is a privacy-first web hub for everyday file utilities. Production The project uses a category-first architecture, a Pull Request/main CI gate, and static GitHub Pages deployment. -Release-candidate context is recorded in the [v1.0.0 changelog](./CHANGELOG.md) and [release QA checklist](./docs/release-qa.md). The v1.0.0 Git tag and GitHub release are deliberate release-manager actions after manual sign-off; Sprint 11 does not create them. +The v1 baseline is recorded in the [changelog](./CHANGELOG.md) and historical [v1.0.0 release QA checklist](./docs/release-qa.md). Sprint 12 begins the v2 cycle by expanding production tools into the Image category. ## Available tools +- [Image Converter](./tools/image/converter/) — convert batches of JPEG, PNG, and WebP images locally with predictable names and ZIP output. - [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. @@ -75,6 +76,7 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E ├── tools/ │ ├── shared/ │ │ ├── file.js +│ │ ├── image.js │ │ ├── pdf.js │ │ ├── tool.css │ │ └── save.js @@ -87,6 +89,8 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E │ │ ├── to-images/ │ │ └── metadata/ │ ├── image/ +│ │ ├── index.html +│ │ └── converter/ │ ├── privacy/ │ ├── scan/ │ ├── media/ @@ -101,6 +105,7 @@ The hub is a static site built with semantic HTML, CSS, and Vanilla JavaScript E ├── ci-foundation.test.mjs ├── home-structure.test.mjs ├── image-to-pdf.test.mjs + ├── image-converter.test.mjs ├── pdf-merge-and-categories.test.mjs ├── pdf-split.test.mjs ├── pdf-metadata.test.mjs @@ -124,12 +129,26 @@ Categories are the stable navigation layer. Individual utilities can be added be `/tools/image-to-pdf/` is a lightweight static migration page with a meta refresh and visible fallback link to `/tools/pdf/images-to-pdf/`. It does not use JavaScript to redirect. +## Image Converter + +Image Converter is available at `/tools/image/converter/`. + +- Accepts signature-validated JPEG, PNG, and WebP files and converts one or many sources sequentially with browser-native decode, canvas, and encode APIs. +- Supports JPEG, PNG, and WebP output. JPEG and WebP expose a 50–100% lossy quality control; PNG hides that irrelevant setting. +- Uses explicit browser orientation handling so normally displayed camera images are rendered in their intended orientation. +- Flattens transparent pixels onto white for JPEG while PNG and WebP keep the canvas alpha channel. +- Canvas re-encoding does not preserve EXIF or other embedded image metadata. The tool intentionally makes no configurable metadata-cleaning or preservation claim. +- Replaces supported source extensions, preserves Unicode, limits output bases to 120 characters and 180 UTF-8 bytes, and adds deterministic suffixes for collisions. +- Saves one converted image directly or packages multiple outputs into `converted_images.zip` with the existing same-origin JSZip build. +- 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. + ## Images to PDF Images to PDF is available at `/tools/pdf/images-to-pdf/`. - `app.js` manages picker/drop input, duplicate-preserving queue state, ordering, removal, settings, progress, and save behavior. -- `image.js` validates and decodes JPEG, PNG, and WebP using browser-local APIs. +- `tools/shared/image.js` validates and decodes JPEG, PNG, and WebP using browser-local APIs shared with Image Converter. - `pdf.js` owns page geometry, image placement, sequential preparation, and jsPDF generation. - Preview object URLs and temporary download URLs are revoked when no longer needed. - File admission validates JPEG, PNG, and WebP signatures before browser decoding; a supported signature may override a misleading MIME type or extension. @@ -234,7 +253,7 @@ All processing libraries are pinned and served as same-origin static files. Prod ### JSZip - Version: `3.10.1` -- Purpose: local multi-file PDF Split and PDF to Images archives +- Purpose: local multi-file Image Converter, PDF Split, and PDF to Images archives - Package: `jszip@3.10.1` from npm - License choice: MIT - Details and hashes: [assets/vendor/jszip/README.md](./assets/vendor/jszip/README.md) @@ -266,7 +285,7 @@ Run the complete local and CI validation entry point with: node tests/run-all.mjs ``` -It checks JavaScript syntax and runs Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, dependency-integrity, save-path, ZIP, and CI workflow regression coverage. PDF fixtures are generated deterministically during tests; CI never processes real user files. +It checks JavaScript syntax and runs Image Converter, Images to PDF, PDF Merge, PDF Split, PDF Organizer, PDF to Images, PDF Metadata, category-first homepage, system typography, CJK wrapping, long-copy layout, six-language catalog parity and placeholders, locale detection and persistence, static resource, privacy/network, security-hardening, dependency-integrity, save-path, ZIP, and CI workflow regression coverage. Test fixtures are generated deterministically; CI never processes real user files. ## Production security controls @@ -325,8 +344,8 @@ Production routes load only same-origin HTML, CSS, JavaScript, and vendored libr The historical prototype is absent from the deployed tree. External URLs in documentation, licenses, and source links are informational or user-initiated; no production processing dependency is fetched remotely. ## Deferred work -- PDF rotate, compression, and encryption tools -- Image conversion, compression, and resizing +- Broader PDF modification, compression, and encryption workflows +- Image resizing, compression, and dedicated metadata inspection/cleaning - Broader XMP and structural PDF metadata sanitization - Scan/OCR and media tools - Offline/PWA support diff --git a/js/locales/de.js b/js/locales/de.js index 06a1379..351e134 100644 --- a/js/locales/de.js +++ b/js/locales/de.js @@ -40,6 +40,10 @@ export const de = { "title": "PDF Metadata Inspector & Cleaner – Secure Tools", "description": "Überprüfen und entfernen Sie unterstützte PDF-Metadaten lokal in Ihrem Browser." }, + "imageConverter": { + "title": "Bildkonverter – Secure Tools", + "description": "Konvertieren Sie JPEG-, PNG- und WebP-Bilder lokal in Ihrem Browser." + }, "pdfCategory": { "title": "PDF-Tools – Secure Tools", "description": "Erstellen und organisieren Sie PDF-Dokumente lokal in Ihrem Browser." @@ -140,8 +144,6 @@ export const de = { "imagesToPdf": "Ordnen Sie JPEG-, PNG- und WebP-Bilder in einem PDF an.", "merge": "Kombinieren Sie PDF-Dateien in der von Ihnen gewählten Reihenfolge.", "split": "Extrahieren Sie ausgewählte Seiten oder teilen Sie ein Dokument in lokale Dateien auf.", - "rotateTitle": "PDF drehen", - "rotate": "Korrigieren Sie die Seitenausrichtung, ohne die Dokumentqualität zu verändern.", "organize": "Sehen Sie sich eine Vorschau an, ordnen Sie sie neu an, drehen Sie sie und entfernen Sie Seiten aus einer PDF-Datei.", "toImages": "Konvertieren Sie geordnete PDF-Seiten in PNG-, JPEG- oder WebP-Bilder.", "metadata": "Überprüfen Sie allgemeine Dokumentmetadaten und erstellen Sie eine verifizierte, bereinigte Kopie." @@ -383,6 +385,45 @@ export const de = { "save": "Der Speicherort konnte nicht geöffnet werden. Bitte versuchen Sie es erneut." } }, + "imageConverter": { + "eyebrow": "Bildtool", + "title": "Bildkonverter", + "description": "Konvertieren Sie JPEG-, PNG- und WebP-Bilder lokal. Mehrere Dateien werden gemeinsam als ZIP gespeichert.", + "queue": { "meta": "{size} · Eintrag {position}" }, + "settings": { + "title": "Konvertierungseinstellungen", + "format": "Ausgabeformat", + "jpeg": "JPEG", + "png": "PNG", + "webp": "WebP", + "quality": "Qualität", + "qualityHelp": "JPEG und WebP verwenden verlustbehaftete Qualität. PNG verwendet diese Einstellung nicht.", + "outputHelp": "Transparente Pixel werden in JPEG weiß. Die erneute Canvas-Kodierung entfernt eingebettete Bildmetadaten aus jeder Ausgabe.", + "imageFile": "Konvertiertes Bild", + "zipFile": "ZIP-Archiv" + }, + "actions": { "convert": "Konvertieren und speichern" }, + "status": { + "converting": "Bilder werden lokal konvertiert…", + "progress": "Bild {completed} von {total} wird konvertiert…", + "progressLabel": "Fortschritt der Bildkonvertierung", + "archive": "ZIP-Archiv wird vorbereitet…", + "saved": "{name} mit {count} konvertierten Bildern wurde gespeichert.", + "downloaded": "{name} mit {count} konvertierten Bildern wurde heruntergeladen.", + "saveCancelled": "Speichern abgebrochen. Es wurde keine Datei geschrieben." + }, + "errors": { + "noFiles": "Fügen Sie vor der Konvertierung mindestens ein Bild hinzu.", + "format": "Wählen Sie JPEG, PNG oder WebP.", + "jobPixels": "Dieser Stapel überschreitet das kombinierte Limit von 200 Megapixeln für dekodierte Bilder. Konvertieren Sie weniger Bilder gleichzeitig.", + "canvas": "Dieser Browser kann die Bildzeichenfläche nicht erstellen.", + "encoder": "{name} konnte nicht in das ausgewählte Format kodiert werden.", + "archiveLibrary": "Die lokale Archivbibliothek konnte nicht geladen werden.", + "archive": "Das ZIP-Archiv konnte nicht erstellt werden.", + "failure": "{name} konnte nicht konvertiert werden. Das Bild ist möglicherweise beschädigt oder wird von diesem Browser nicht unterstützt.", + "save": "Der Speicherort konnte nicht geöffnet werden. Bitte versuchen Sie es erneut." + } + }, "imageToPdf": { "eyebrow": "Bildtool", "title": "Bilder in PDF", diff --git a/js/locales/en.js b/js/locales/en.js index fb6b13d..5c39a47 100644 --- a/js/locales/en.js +++ b/js/locales/en.js @@ -10,6 +10,7 @@ export const en = { organizePdf: { title: "Organize PDF — Secure Tools", description: "Reorder, rotate, and remove PDF pages locally in your browser." }, pdfToImages: { title: "PDF to Images — Secure Tools", description: "Convert PDF pages to PNG, JPEG, or WebP images locally in your browser." }, pdfMetadata: { title: "PDF Metadata Inspector & Cleaner — Secure Tools", description: "Inspect and remove supported PDF metadata locally in your browser." }, + imageConverter: { title: "Image Converter — Secure Tools", description: "Convert JPEG, PNG, and WebP images locally in your browser." }, pdfCategory: { title: "PDF tools — Secure Tools", description: "Create and organize PDF documents locally in your browser." }, imageCategory: { title: "Image tools — Secure Tools", description: "Private image tools designed to run locally in your browser." }, privacyCategory: { title: "Privacy tools — Secure Tools", description: "Inspect and remove hidden file details without uploading the source file." }, @@ -46,7 +47,7 @@ export const en = { pdf: { title: "PDF tools", description: "Create and organize PDF documents locally, without uploading private files.", imagesToPdf: "Arrange JPEG, PNG, and WebP images into one PDF.", merge: "Combine PDF files in the order you choose.", - split: "Extract selected pages or divide a document into local files.", rotateTitle: "Rotate PDF", rotate: "Correct page orientation without changing document quality.", + split: "Extract selected pages or divide a document into local files.", organize: "Preview, reorder, rotate, and remove pages from one PDF.", toImages: "Convert ordered PDF pages to PNG, JPEG, or WebP images.", metadata: "Inspect common document metadata and create a verified cleaned copy.", @@ -154,6 +155,29 @@ export const en = { library: "The local PDF library could not be loaded.", generation: "The PDF could not be merged. Try fewer or smaller files.", save: "The save location could not be opened. Please try again.", }, }, + imageConverter: { + eyebrow: "Image tool", title: "Image Converter", description: "Convert JPEG, PNG, and WebP images locally. Multiple files are saved together as one ZIP archive.", + queue: { meta: "{size} · item {position}" }, + settings: { + title: "Conversion settings", format: "Output format", jpeg: "JPEG", png: "PNG", webp: "WebP", quality: "Quality", + qualityHelp: "JPEG and WebP use lossy quality. PNG does not use this setting.", + outputHelp: "Transparent pixels become white in JPEG. Canvas re-encoding removes embedded image metadata from every output.", + imageFile: "Converted image", zipFile: "ZIP archive", + }, + actions: { convert: "Convert and save" }, + status: { + converting: "Converting images locally…", progress: "Converting image {completed} of {total}…", progressLabel: "Image conversion progress", + archive: "Preparing the ZIP archive…", saved: "Saved {name} with {count} converted image(s).", downloaded: "Downloaded {name} with {count} converted image(s).", + saveCancelled: "Save cancelled. No file was written.", + }, + errors: { + noFiles: "Add at least one image before converting.", format: "Choose JPEG, PNG, or WebP.", + jobPixels: "This batch exceeds the 200 megapixel combined decoded-work limit. Convert fewer images at once.", + canvas: "This browser cannot create the image canvas.", encoder: "{name} could not be encoded in the selected format.", + archiveLibrary: "The local archive library could not be loaded.", archive: "The ZIP archive could not be created.", + failure: "{name} could not be converted. The image may be damaged or unsupported by this browser.", save: "The save location could not be opened. Please try again.", + }, + }, imageToPdf: { eyebrow: "Image tool", title: "Images to PDF", description: "Arrange JPEG, PNG, and WebP images and save them as one PDF. Everything happens on this device.", diff --git a/js/locales/es.js b/js/locales/es.js index 18d0a77..3d50a9e 100644 --- a/js/locales/es.js +++ b/js/locales/es.js @@ -40,6 +40,10 @@ export const es = { "title": "Metadatos del PDF — Secure Tools", "description": "Inspeccione y elimine metadatos PDF compatibles localmente en su navegador." }, + "imageConverter": { + "title": "Convertidor de imágenes — Secure Tools", + "description": "Convierta imágenes JPEG, PNG y WebP localmente en su navegador." + }, "pdfCategory": { "title": "Herramientas PDF — Secure Tools", "description": "Cree y organice documentos PDF localmente en su navegador." @@ -140,8 +144,6 @@ export const es = { "imagesToPdf": "Organice imágenes JPEG, PNG y WebP en un PDF.", "merge": "Combina archivos PDF en el orden que elijas.", "split": "Extrae páginas seleccionadas o divide un documento en archivos locales.", - "rotateTitle": "Girar PDF", - "rotate": "Orientación correcta de la página sin cambiar la calidad del documento.", "organize": "Obtenga una vista previa, reordene, rote y elimine páginas de un PDF.", "toImages": "Convierte páginas PDF ordenadas a imágenes PNG, JPEG o WebP.", "metadata": "Inspecciona los metadatos de documentos comunes y crea una copia limpia verificada." @@ -383,6 +385,45 @@ export const es = { "save": "No se pudo abrir la ubicación para guardar. Inténtelo de nuevo." } }, + "imageConverter": { + "eyebrow": "Herramienta de imagen", + "title": "Convertidor de imágenes", + "description": "Convierta imágenes JPEG, PNG y WebP localmente. Varios archivos se guardan juntos en un único ZIP.", + "queue": { "meta": "{size} · elemento {position}" }, + "settings": { + "title": "Ajustes de conversión", + "format": "Formato de salida", + "jpeg": "JPEG", + "png": "PNG", + "webp": "WebP", + "quality": "Calidad", + "qualityHelp": "JPEG y WebP usan calidad con pérdida. PNG no utiliza este ajuste.", + "outputHelp": "Los píxeles transparentes se vuelven blancos en JPEG. La recodificación con canvas elimina los metadatos de imagen de cada salida.", + "imageFile": "Imagen convertida", + "zipFile": "Archivo ZIP" + }, + "actions": { "convert": "Convertir y guardar" }, + "status": { + "converting": "Convirtiendo imágenes localmente…", + "progress": "Convirtiendo imagen {completed} de {total}…", + "progressLabel": "Progreso de conversión de imágenes", + "archive": "Preparando el archivo ZIP…", + "saved": "Se guardó {name} con {count} imagen(es) convertida(s).", + "downloaded": "Se descargó {name} con {count} imagen(es) convertida(s).", + "saveCancelled": "Guardado cancelado. No se escribió ningún archivo." + }, + "errors": { + "noFiles": "Añada al menos una imagen antes de convertir.", + "format": "Elija JPEG, PNG o WebP.", + "jobPixels": "Este lote supera el límite combinado de 200 megapíxeles decodificados. Convierta menos imágenes a la vez.", + "canvas": "Este navegador no puede crear el lienzo de imagen.", + "encoder": "No se pudo codificar {name} en el formato seleccionado.", + "archiveLibrary": "No se pudo cargar la biblioteca de archivos local.", + "archive": "No se pudo crear el archivo ZIP.", + "failure": "No se pudo convertir {name}. La imagen puede estar dañada o no ser compatible con este navegador.", + "save": "No se pudo abrir la ubicación para guardar. Inténtelo de nuevo." + } + }, "imageToPdf": { "eyebrow": "Herramienta de imagen", "title": "Imágenes a PDF", diff --git a/js/locales/fr.js b/js/locales/fr.js index 5153b44..3e9334b 100644 --- a/js/locales/fr.js +++ b/js/locales/fr.js @@ -40,6 +40,10 @@ export const fr = { "title": "Inspecteur et nettoyeur de métadonnées PDF – Secure Tools", "description": "Inspectez et supprimez les métadonnées PDF prises en charge localement dans votre navigateur." }, + "imageConverter": { + "title": "Convertisseur d'images — Secure Tools", + "description": "Convertissez des images JPEG, PNG et WebP localement dans votre navigateur." + }, "pdfCategory": { "title": "Outils PDF — Secure Tools", "description": "Créez et organisez des documents PDF localement dans votre navigateur." @@ -140,8 +144,6 @@ export const fr = { "imagesToPdf": "Organisez les images JPEG, PNG et WebP en un seul PDF.", "merge": "Combinez les fichiers PDF dans l'ordre de votre choix.", "split": "Extrayez les pages sélectionnées ou divisez un document en fichiers locaux.", - "rotateTitle": "Rotation du PDF", - "rotate": "Corriger l'orientation de la page sans modifier la qualité du document.", "organize": "Prévisualisez, réorganisez, faites pivoter et supprimez des pages d'un PDF.", "toImages": "Convertissez les pages PDF commandées en images PNG, JPEG ou WebP.", "metadata": "Inspectez les métadonnées communes du document et créez une copie nettoyée et vérifiée." @@ -383,6 +385,45 @@ export const fr = { "save": "L'emplacement de sauvegarde n'a pas pu être ouvert. Veuillez réessayer." } }, + "imageConverter": { + "eyebrow": "Outil Image", + "title": "Convertisseur d'images", + "description": "Convertissez localement des images JPEG, PNG et WebP. Plusieurs fichiers sont enregistrés ensemble dans une seule archive ZIP.", + "queue": { "meta": "{size} · élément {position}" }, + "settings": { + "title": "Paramètres de conversion", + "format": "Format de sortie", + "jpeg": "JPEG", + "png": "PNG", + "webp": "WebP", + "quality": "Qualité", + "qualityHelp": "JPEG et WebP utilisent une compression avec perte. Ce réglage ne s'applique pas au PNG.", + "outputHelp": "Les pixels transparents deviennent blancs en JPEG. Le réencodage par canvas supprime les métadonnées d'image intégrées de chaque sortie.", + "imageFile": "Image convertie", + "zipFile": "Archive ZIP" + }, + "actions": { "convert": "Convertir et enregistrer" }, + "status": { + "converting": "Conversion locale des images…", + "progress": "Conversion de l'image {completed} sur {total}…", + "progressLabel": "Progression de la conversion des images", + "archive": "Préparation de l'archive ZIP…", + "saved": "{name} contenant {count} image(s) convertie(s) a été enregistré.", + "downloaded": "{name} contenant {count} image(s) convertie(s) a été téléchargé.", + "saveCancelled": "Enregistrement annulé. Aucun fichier n'a été écrit." + }, + "errors": { + "noFiles": "Ajoutez au moins une image avant de lancer la conversion.", + "format": "Choisissez JPEG, PNG ou WebP.", + "jobPixels": "Ce lot dépasse la limite combinée de 200 mégapixels décodés. Convertissez moins d'images à la fois.", + "canvas": "Ce navigateur ne peut pas créer le canevas d'image.", + "encoder": "Impossible d'encoder {name} dans le format sélectionné.", + "archiveLibrary": "La bibliothèque d'archivage locale n'a pas pu être chargée.", + "archive": "Impossible de créer l'archive ZIP.", + "failure": "Impossible de convertir {name}. L'image est peut-être endommagée ou non prise en charge par ce navigateur.", + "save": "L'emplacement de sauvegarde n'a pas pu être ouvert. Veuillez réessayer." + } + }, "imageToPdf": { "eyebrow": "Outil Image", "title": "Images au format PDF", diff --git a/js/locales/ja.js b/js/locales/ja.js index c1be239..04ecda6 100644 --- a/js/locales/ja.js +++ b/js/locales/ja.js @@ -40,6 +40,10 @@ export const ja = { "title": "PDFメタデータの確認・削除 — Secure Tools", "description": "対応しているPDFメタデータを端末内で確認し、選んで削除できます。" }, + "imageConverter": { + "title": "画像変換 — Secure Tools", + "description": "JPEG、PNG、WebP画像をブラウザ上で端末内変換します。" + }, "pdfCategory": { "title": "PDFツール — Secure Tools", "description": "PDFの作成・結合・分割・整理を端末内で行えます。" @@ -140,8 +144,6 @@ export const ja = { "imagesToPdf": "JPEG、PNG、WebP画像を好きな順番で1つのPDFにまとめます", "merge": "PDFを選んだ順番で1つに結合します", "split": "必要なページを抽出したり、PDFを複数のファイルに分割したりできます", - "rotateTitle": "PDFを回転", - "rotate": "画質を変えずにページの向きを調整します", "organize": "PDFのページをプレビューし、並べ替え・回転・削除できます", "toImages": "PDFページをPNG、JPEG、WebP画像に変換します", "metadata": "一般的な文書メタデータを確認し、削除後の内容を検証したコピーを作成します" @@ -383,6 +385,45 @@ export const ja = { "save": "保存先を開けませんでした。もう一度試してください" } }, + "imageConverter": { + "eyebrow": "画像ツール", + "title": "画像変換", + "description": "JPEG、PNG、WebP画像を端末内で変換します。複数のファイルは1つのZIPにまとめて保存されます。", + "queue": { "meta": "{size} · {position}番目" }, + "settings": { + "title": "変換設定", + "format": "出力形式", + "jpeg": "JPEG", + "png": "PNG", + "webp": "WebP", + "quality": "品質", + "qualityHelp": "JPEGとWebPは非可逆圧縮の品質を使用します。PNGにはこの設定は適用されません。", + "outputHelp": "JPEGでは透明部分が白になります。Canvasで再エンコードするため、すべての出力から画像メタデータが削除されます。", + "imageFile": "変換済み画像", + "zipFile": "ZIPアーカイブ" + }, + "actions": { "convert": "変換して保存" }, + "status": { + "converting": "画像を端末内で変換しています…", + "progress": "{total}件中{completed}件目の画像を変換しています…", + "progressLabel": "画像変換の進行状況", + "archive": "ZIPアーカイブを作成しています…", + "saved": "変換済み画像{count}件を含む{name}を保存しました。", + "downloaded": "変換済み画像{count}件を含む{name}をダウンロードしました。", + "saveCancelled": "保存をキャンセルしました。ファイルは書き込まれていません。" + }, + "errors": { + "noFiles": "変換する画像を1つ以上追加してください。", + "format": "JPEG、PNG、WebPのいずれかを選んでください。", + "jobPixels": "この処理はデコード後の合計2億ピクセル制限を超えます。一度に変換する画像を減らしてください。", + "canvas": "このブラウザでは画像用Canvasを作成できません。", + "encoder": "{name}を選択した形式でエンコードできませんでした。", + "archiveLibrary": "ローカルのアーカイブライブラリを読み込めませんでした。", + "archive": "ZIPアーカイブを作成できませんでした。", + "failure": "{name}を変換できませんでした。画像が破損しているか、このブラウザで対応していない可能性があります。", + "save": "保存先を開けませんでした。もう一度試してください。" + } + }, "imageToPdf": { "eyebrow": "画像ツール", "title": "画像をPDFに変換", diff --git a/js/locales/ko.js b/js/locales/ko.js index 941a05f..5ff80b3 100644 --- a/js/locales/ko.js +++ b/js/locales/ko.js @@ -10,6 +10,7 @@ export const ko = { organizePdf: { title: "PDF 정리 — Secure Tools", description: "PDF 페이지를 브라우저에서 로컬로 재정렬하고 회전하고 제거하세요." }, pdfToImages: { title: "PDF 이미지 변환 — Secure Tools", description: "PDF 페이지를 브라우저에서 로컬로 PNG, JPEG 또는 WebP 이미지로 변환하세요." }, pdfMetadata: { title: "PDF 메타데이터 확인 및 제거 — Secure Tools", description: "지원되는 PDF 메타데이터를 브라우저에서 로컬로 확인하고 제거하세요." }, + imageConverter: { title: "이미지 변환기 — Secure Tools", description: "JPEG, PNG, WebP 이미지를 브라우저에서 로컬로 변환하세요." }, pdfCategory: { title: "PDF 도구 — Secure Tools", description: "브라우저에서 PDF 문서를 로컬로 만들고 정리하세요." }, imageCategory: { title: "이미지 도구 — Secure Tools", description: "브라우저에서 로컬로 작동하도록 설계된 프라이빗 이미지 도구입니다." }, privacyCategory: { title: "개인정보 보호 도구 — Secure Tools", description: "원본 파일을 업로드하지 않고 숨겨진 파일 정보를 확인하고 제거하세요." }, @@ -41,7 +42,7 @@ export const ko = { pdf: { title: "PDF 도구", description: "개인 파일을 업로드하지 않고 PDF 문서를 로컬에서 만들고 정리하세요.", imagesToPdf: "JPEG, PNG, WebP 이미지를 원하는 순서로 하나의 PDF로 만듭니다.", merge: "선택한 순서대로 PDF 파일을 합칩니다.", - split: "선택한 페이지를 추출하거나 문서를 로컬 파일로 나눕니다.", rotateTitle: "PDF 회전", rotate: "문서 품질을 바꾸지 않고 페이지 방향을 바로잡습니다.", + split: "선택한 페이지를 추출하거나 문서를 로컬 파일로 나눕니다.", organize: "PDF 페이지를 미리 보고 재정렬하거나 회전·제거합니다", toImages: "PDF 페이지를 지정한 순서대로 PNG, JPEG 또는 WebP 이미지로 변환합니다", metadata: "일반 문서 메타데이터를 확인하고 정리한 사본을 만듭니다", @@ -138,6 +139,29 @@ export const ko = { library: "로컬 PDF 라이브러리를 불러오지 못했습니다.", generation: "PDF를 합치지 못했습니다. 파일 수나 크기를 줄여 다시 시도하세요.", save: "저장 위치를 열 수 없습니다. 다시 시도하세요.", }, }, + imageConverter: { + eyebrow: "이미지 도구", title: "이미지 변환기", description: "JPEG, PNG, WebP 이미지를 기기에서 변환하세요. 여러 파일은 하나의 ZIP으로 저장됩니다.", + queue: { meta: "{size} · {position}번째" }, + settings: { + title: "변환 설정", format: "출력 형식", jpeg: "JPEG", png: "PNG", webp: "WebP", quality: "품질", + qualityHelp: "JPEG와 WebP는 손실 품질을 사용합니다. PNG에는 이 설정이 적용되지 않습니다.", + outputHelp: "JPEG에서는 투명한 영역이 흰색으로 바뀝니다. 캔버스로 다시 인코딩하므로 모든 출력에서 이미지 메타데이터가 제거됩니다.", + imageFile: "변환한 이미지", zipFile: "ZIP 압축 파일", + }, + actions: { convert: "변환하고 저장" }, + status: { + converting: "이미지를 기기에서 변환하는 중…", progress: "{total}개 중 {completed}번째 이미지 변환 중…", progressLabel: "이미지 변환 진행률", + archive: "ZIP 압축 파일 만드는 중…", saved: "변환한 이미지 {count}개가 담긴 {name} 파일을 저장했습니다.", downloaded: "변환한 이미지 {count}개가 담긴 {name} 파일을 다운로드했습니다.", + saveCancelled: "저장을 취소했습니다. 파일은 기록되지 않았습니다.", + }, + errors: { + noFiles: "변환하기 전에 이미지를 하나 이상 추가하세요.", format: "JPEG, PNG 또는 WebP를 선택하세요.", + jobPixels: "이 작업은 디코딩된 이미지 합계 2억 픽셀 제한을 초과합니다. 한 번에 변환할 이미지 수를 줄이세요.", + canvas: "이 브라우저에서 이미지 캔버스를 만들 수 없습니다.", encoder: "{name} 파일을 선택한 형식으로 인코딩하지 못했습니다.", + archiveLibrary: "로컬 압축 라이브러리를 불러오지 못했습니다.", archive: "ZIP 압축 파일을 만들지 못했습니다.", + failure: "{name} 파일을 변환하지 못했습니다. 이미지가 손상되었거나 이 브라우저에서 지원하지 않을 수 있습니다.", save: "저장 위치를 열 수 없습니다. 다시 시도하세요.", + }, + }, imageToPdf: { eyebrow: "이미지 도구", title: "이미지를 PDF로", description: "JPEG, PNG, WebP 이미지를 원하는 순서로 정리해 하나의 PDF로 저장하세요. 모든 처리는 이 기기에서 이루어집니다.", drop: { title: "이미지 추가", description: "파일을 여기에 놓거나 파일 선택 버튼을 누르세요. JPEG, PNG, WebP를 함께 추가할 수 있습니다.", choose: "이미지 선택", localTitle: "이 기기에서 바로 처리합니다", localBody: "이미지는 외부로 전송되지 않습니다", privacyLink: "개인정보 보호 방식" }, diff --git a/tests/file-input-queue-state.test.mjs b/tests/file-input-queue-state.test.mjs index fb5a273..b2643a7 100644 --- a/tests/file-input-queue-state.test.mjs +++ b/tests/file-input-queue-state.test.mjs @@ -7,6 +7,15 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); const tools = [ + { + name: "Image Converter", + html: read("tools/image/converter/index.html"), + app: read("tools/image/converter/app.js"), + chooseKey: "imageToPdf.drop.choose", + renderCall: "renderQueue()", + stateRule: /elements\.empty\.hidden\s*=\s*state\.items\.length\s*>\s*0/, + programmaticPickerCount: 1, + }, { name: "Images to PDF", html: read("tools/pdf/images-to-pdf/index.html"), diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 26055b2..7081baf 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, 522); + assert.equal(english.size, 553); 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, 15, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 16, "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 new file mode 100644 index 0000000..5f96d2f --- /dev/null +++ b/tests/image-converter.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { File } from "node:buffer"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + ARCHIVE_FILENAME, + createConversionPlan, + createOutputNames, + convertImages, + DEFAULT_QUALITY, + IMAGE_FORMATS, + MAX_BASE_BYTES, + MAX_JOB_PIXELS, + normalizeFormat, + normalizeQuality, + packageConvertedImages, + renderDecodedImage, + sourceBaseName, +} from "../tools/image/converter/converter.js"; +import { selectImageQueueFiles } from "../tools/shared/image.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); +const imageFile = (bytes, name, type) => new File([bytes], name, { type }); +const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]); +const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const webp = new TextEncoder().encode("RIFF0000WEBP"); + +assert.deepEqual(IMAGE_FORMATS, { + jpeg: { extension: "jpg", mimeType: "image/jpeg", label: "JPEG" }, + png: { extension: "png", mimeType: "image/png", label: "PNG" }, + webp: { extension: "webp", mimeType: "image/webp", label: "WebP" }, +}); +assert.equal(normalizeFormat("JPEG"), "jpeg"); +assert.throws(() => normalizeFormat("gif"), (error) => error.code === "IMAGE_FORMAT_INVALID"); +assert.equal(normalizeQuality("png", 0.5), null); +assert.equal(normalizeQuality("jpeg", "invalid"), DEFAULT_QUALITY); +assert.equal(normalizeQuality("webp", 0.1), 0.5); +assert.equal(normalizeQuality("jpeg", 2), 1); + +assert.equal(sourceBaseName(" photo.png "), "photo"); +assert.equal(sourceBaseName("한글 파일.webp"), "한글 파일"); +assert.equal(sourceBaseName("bad:name.jpg"), "bad_name"); +const veryLong = `${"가".repeat(200)}.png`; +const shortened = sourceBaseName(veryLong); +assert.ok(Array.from(shortened).length <= 120); +assert.ok(new TextEncoder().encode(shortened).length <= MAX_BASE_BYTES); +const named = createOutputNames([ + { name: "photo.png" }, { name: "photo.jpg" }, { name: "photo_2.webp" }, { name: "한글 파일.png" }, +], "jpeg"); +assert.deepEqual(named, { + archive: ARCHIVE_FILENAME, + entries: ["photo.jpg", "photo_2.jpg", "photo_2_2.jpg", "한글 파일.jpg"], +}); +assert.deepEqual(createConversionPlan({ files: [{ name: "one.png" }], format: "png", quality: 0.5 }), { + format: "png", mimeType: "image/png", quality: null, + names: { archive: ARCHIVE_FILENAME, entries: ["one.png"] }, +}); +assert.throws(() => createConversionPlan({ files: [], format: "png" }), (error) => error.code === "NO_FILES"); + +const selected = await selectImageQueueFiles([], [ + imageFile(png, "valid.jpg", "image/jpeg"), + imageFile([], "empty.png", "image/png"), + imageFile(new TextEncoder().encode("not an image"), "fake.webp", "image/webp"), + imageFile(jpeg, "camera.unknown", "image/jpeg"), + imageFile(webp, "graphic.webp", "image/webp"), +]); +assert.deepEqual(selected.accepted.map((file) => file.name), ["valid.jpg", "camera.unknown", "graphic.webp"]); +assert.deepEqual(selected.rejected.map(({ code }) => code), ["IMAGE_SIGNATURE_INVALID", "IMAGE_SIGNATURE_INVALID"]); + +function canvasHarness({ encodedType, fail = false } = {}) { + const events = []; + const context = { + set fillStyle(value) { events.push(["fillStyle", value]); }, + fillRect(...values) { events.push(["fillRect", ...values]); }, + drawImage(...values) { events.push(["drawImage", ...values]); }, + }; + const canvas = { + width: 0, + height: 0, + getContext(type, options) { events.push(["context", type, options]); return context; }, + toBlob(callback, mimeType, quality) { + events.push(["encode", mimeType, quality]); + callback(fail ? null : new Blob([mimeType], { type: encodedType || mimeType })); + }, + }; + return { canvas, events }; +} + +const decoded = { source: { id: "pixels" }, width: 12, height: 8 }; +const jpegCanvas = canvasHarness(); +const jpegBlob = await renderDecodedImage({ decoded, format: "jpeg", quality: 0.8, canvasFactory: () => jpegCanvas.canvas }); +assert.equal(jpegBlob.type, "image/jpeg"); +assert.deepEqual(jpegCanvas.events.slice(0, 4), [ + ["context", "2d", { alpha: false }], + ["fillStyle", "#ffffff"], + ["fillRect", 0, 0, 12, 8], + ["drawImage", decoded.source, 0, 0], +]); +assert.deepEqual(jpegCanvas.events.at(-1), ["encode", "image/jpeg", 0.8]); +assert.equal(jpegCanvas.canvas.width, 1); +assert.equal(jpegCanvas.canvas.height, 1); + +const pngCanvas = canvasHarness(); +await renderDecodedImage({ decoded, format: "png", quality: 0.5, canvasFactory: () => pngCanvas.canvas }); +assert.deepEqual(pngCanvas.events[0], ["context", "2d", { alpha: true }]); +assert.equal(pngCanvas.events.some(([event]) => event === "fillRect"), false, "PNG must preserve alpha pixels"); +assert.deepEqual(pngCanvas.events.at(-1), ["encode", "image/png", undefined]); +await assert.rejects(renderDecodedImage({ decoded, format: "webp", canvasFactory: () => canvasHarness({ fail: true }).canvas }), (error) => error.code === "IMAGE_ENCODER_FAILED"); +await assert.rejects(renderDecodedImage({ decoded, format: "png", canvasFactory: () => ({ width: 0, height: 0, getContext: () => null }) }), (error) => error.code === "CANVAS_UNAVAILABLE"); + +const singleBlob = new Blob(["single"], { type: "image/png" }); +assert.deepEqual(await packageConvertedImages({ + blobs: [singleBlob], names: { archive: ARCHIVE_FILENAME, entries: ["one.png"] }, JSZip: null, +}), { kind: "image", filename: "one.png", entries: ["one.png"], blob: singleBlob }); + +class TestZip { + constructor() { this.entries = []; TestZip.instance = this; } + file(name, bytes) { this.entries.push([name, [...bytes]]); } + async generateAsync(options) { this.options = options; return new Blob(["zip"], { type: "application/zip" }); } +} +const batch = await packageConvertedImages({ + blobs: [new Blob(["a"], { type: "image/png" }), new Blob(["b"], { type: "image/png" })], + names: { archive: ARCHIVE_FILENAME, entries: ["a.png", "b.png"] }, + JSZip: TestZip, +}); +assert.equal(batch.kind, "zip"); +assert.equal(batch.filename, ARCHIVE_FILENAME); +assert.deepEqual(TestZip.instance.entries.map(([name]) => name), ["a.png", "b.png"]); +assert.deepEqual(TestZip.instance.options, { type: "blob", compression: "STORE", streamFiles: true }); +await assert.rejects(packageConvertedImages({ blobs: [singleBlob, singleBlob], names: { archive: "x.zip", entries: ["a.png", "b.png"] } }), (error) => error.code === "ARCHIVE_LIBRARY_UNAVAILABLE"); + +let closed = 0; +const progress = []; +const conversionCanvas = () => canvasHarness().canvas; +const converted = await convertImages({ + files: [{ name: "a.png" }, { name: "a.jpg" }], format: "webp", quality: 0.75, JSZip: TestZip, + decode: async (file) => ({ source: file, width: 20, height: 10, close() { closed += 1; } }), + canvasFactory: conversionCanvas, + onProgress: (completed, total, file) => progress.push([completed, total, file.name]), +}); +assert.equal(converted.kind, "zip"); +assert.deepEqual(converted.entries, ["a.webp", "a_2.webp"]); +assert.equal(closed, 2); +assert.deepEqual(progress, [[1, 2, "a.png"], [2, 2, "a.jpg"]]); + +let limitClosed = 0; +await assert.rejects(convertImages({ + files: Array.from({ length: 5 }, (_, index) => ({ name: `${index}.png` })), format: "png", JSZip: TestZip, + decode: async () => ({ source: {}, width: 10_000, height: 5_000, close() { limitClosed += 1; } }), + canvasFactory: conversionCanvas, +}), (error) => error.code === "IMAGE_JOB_PIXELS_EXCEEDED" && error.fileName === "4.png"); +assert.equal(MAX_JOB_PIXELS, 200_000_000); +assert.equal(limitClosed, 5, "Every decoded source must be released when the aggregate limit is reached"); + +let failureClosed = false; +await assert.rejects(convertImages({ + files: [{ name: "broken.png" }], format: "png", + decode: async () => ({ source: {}, width: 2, height: 2, close() { failureClosed = true; } }), + canvasFactory: () => canvasHarness({ fail: true }).canvas, +}), (error) => error.code === "IMAGE_ENCODER_FAILED" && error.fileName === "broken.png"); +assert.equal(failureClosed, true, "Decoded sources must be released after encoder failure"); + +const html = read("tools/image/converter/index.html"); +const app = read("tools/image/converter/app.js"); +const css = read("tools/image/converter/tool.css"); +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(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); +assert.match(html, /