From 49025bd67025a343ae91c6976e1395589d8f37cc Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 09:55:59 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20Share=20brow?= =?UTF-8?q?ser=20image=20processing=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/image-to-pdf.test.mjs | 2 +- tests/security-hardening.test.mjs | 2 +- tools/pdf/images-to-pdf/app.js | 2 +- tools/pdf/images-to-pdf/pdf.js | 2 +- tools/{pdf/images-to-pdf => shared}/image.js | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename tools/{pdf/images-to-pdf => shared}/image.js (100%) diff --git a/tests/image-to-pdf.test.mjs b/tests/image-to-pdf.test.mjs index 781c051..0dec558 100644 --- a/tests/image-to-pdf.test.mjs +++ b/tests/image-to-pdf.test.mjs @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import { File } from "node:buffer"; import fs from "node:fs"; -import { isSupportedImage } from "../tools/pdf/images-to-pdf/image.js"; +import { isSupportedImage } from "../tools/shared/image.js"; import { createPdfBlob, getImagePlacement, diff --git a/tests/security-hardening.test.mjs b/tests/security-hardening.test.mjs index dabc37b..2d441d7 100644 --- a/tests/security-hardening.test.mjs +++ b/tests/security-hardening.test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { MAX_DIMENSION, MAX_FILE_SIZE, MAX_PIXELS, MAX_QUEUE_BYTES, MAX_QUEUE_FILES, detectImageFormat, selectImageQueueFiles, validateImageDimensions, validateImageSignature, -} from "../tools/pdf/images-to-pdf/image.js"; +} 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"); diff --git a/tools/pdf/images-to-pdf/app.js b/tools/pdf/images-to-pdf/app.js index 79e30d9..831bb8a 100644 --- a/tools/pdf/images-to-pdf/app.js +++ b/tools/pdf/images-to-pdf/app.js @@ -1,7 +1,7 @@ import { t } from "../../../js/i18n.js"; import { formatBytes, moveArrayItem } from "../../shared/file.js"; import { downloadBlob, requestPdfSaveHandle, writeBlobToHandle } from "../../shared/save.js"; -import { ACCEPTED_IMAGE_TYPES, selectImageQueueFiles } from "./image.js"; +import { ACCEPTED_IMAGE_TYPES, selectImageQueueFiles } from "../../shared/image.js"; import { createPdfBlob, sanitizeFilename } from "./pdf.js"; const elements = { diff --git a/tools/pdf/images-to-pdf/pdf.js b/tools/pdf/images-to-pdf/pdf.js index aa93792..edb594a 100644 --- a/tools/pdf/images-to-pdf/pdf.js +++ b/tools/pdf/images-to-pdf/pdf.js @@ -1,4 +1,4 @@ -import { decodeImage, imageToJpegData } from "./image.js"; +import { decodeImage, imageToJpegData } from "../../shared/image.js"; import { sanitizePdfFilename } from "../../shared/file.js"; const PAGE_SIZES = { diff --git a/tools/pdf/images-to-pdf/image.js b/tools/shared/image.js similarity index 100% rename from tools/pdf/images-to-pdf/image.js rename to tools/shared/image.js From de9bef7fbaf5982e836241303e29dece0d8ea13a Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 09:58:26 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B[Fix]=20Remove=20standalone=20P?= =?UTF-8?q?DF=20Rotation=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/locales/de.js | 2 -- js/locales/en.js | 2 +- js/locales/es.js | 2 -- js/locales/fr.js | 2 -- js/locales/ja.js | 2 -- js/locales/ko.js | 2 +- tests/i18n-quality.test.mjs | 2 +- tools/pdf/index.html | 1 - 8 files changed, 3 insertions(+), 12 deletions(-) diff --git a/js/locales/de.js b/js/locales/de.js index 06a1379..63406c7 100644 --- a/js/locales/de.js +++ b/js/locales/de.js @@ -140,8 +140,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." diff --git a/js/locales/en.js b/js/locales/en.js index fb6b13d..0e56ad6 100644 --- a/js/locales/en.js +++ b/js/locales/en.js @@ -46,7 +46,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.", diff --git a/js/locales/es.js b/js/locales/es.js index 18d0a77..1db4099 100644 --- a/js/locales/es.js +++ b/js/locales/es.js @@ -140,8 +140,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." diff --git a/js/locales/fr.js b/js/locales/fr.js index 5153b44..b133358 100644 --- a/js/locales/fr.js +++ b/js/locales/fr.js @@ -140,8 +140,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." diff --git a/js/locales/ja.js b/js/locales/ja.js index c1be239..d5b41f4 100644 --- a/js/locales/ja.js +++ b/js/locales/ja.js @@ -140,8 +140,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": "一般的な文書メタデータを確認し、削除後の内容を検証したコピーを作成します" diff --git a/js/locales/ko.js b/js/locales/ko.js index 941a05f..dbe4c1d 100644 --- a/js/locales/ko.js +++ b/js/locales/ko.js @@ -41,7 +41,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: "일반 문서 메타데이터를 확인하고 정리한 사본을 만듭니다", diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 26055b2..e873094 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, 520); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); diff --git a/tools/pdf/index.html b/tools/pdf/index.html index d717cce..6f4e38e 100644 --- a/tools/pdf/index.html +++ b/tools/pdf/index.html @@ -10,7 +10,6 @@
  • Available

    Images to PDF

    Arrange JPEG, PNG, and WebP images into one PDF.

  • Available

    Merge PDF

    Combine PDF files in the order you choose.

  • Available

    Split PDF

    Extract selected pages or divide a document into local files.

  • -
  • Coming soon

    Rotate PDF

    Correct page orientation without changing document quality.

  • Available

    Organize PDF

    Preview, reorder, rotate, and remove pages from one PDF.

  • Available

    PDF to Images

    Convert ordered PDF pages to PNG, JPEG, or WebP images.

  • Available

    PDF Metadata

    Inspect common document metadata and create a verified cleaned copy.

  • From 7937a05ce40360fc5e9bc6f1b2a074d33d52c3d7 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 10:09:59 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20Image=20Converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/locales/de.js | 43 +++++ js/locales/en.js | 24 +++ js/locales/es.js | 43 +++++ js/locales/fr.js | 43 +++++ js/locales/ja.js | 43 +++++ js/locales/ko.js | 24 +++ tests/i18n-quality.test.mjs | 4 +- tools/image/converter/app.js | 296 +++++++++++++++++++++++++++++ tools/image/converter/converter.js | 151 +++++++++++++++ tools/image/converter/index.html | 92 +++++++++ tools/image/converter/tool.css | 10 + tools/image/index.html | 4 +- tools/shared/image.js | 2 +- 13 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 tools/image/converter/app.js create mode 100644 tools/image/converter/converter.js create mode 100644 tools/image/converter/index.html create mode 100644 tools/image/converter/tool.css diff --git a/js/locales/de.js b/js/locales/de.js index 63406c7..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." @@ -381,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 0e56ad6..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." }, @@ -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 1db4099..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." @@ -381,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 b133358..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." @@ -381,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 d5b41f4..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の作成・結合・分割・整理を端末内で行えます。" @@ -381,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 dbe4c1d..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: "원본 파일을 업로드하지 않고 숨겨진 파일 정보를 확인하고 제거하세요." }, @@ -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/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index e873094..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, 520); + 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/tools/image/converter/app.js b/tools/image/converter/app.js new file mode 100644 index 0000000..658db6e --- /dev/null +++ b/tools/image/converter/app.js @@ -0,0 +1,296 @@ +import { t } from "../../../js/i18n.js"; +import { formatBytes, moveArrayItem } from "../../shared/file.js"; +import { ACCEPTED_IMAGE_TYPES, selectImageQueueFiles } from "../../shared/image.js"; +import { downloadBlob, requestSaveHandle, writeBlobToHandle } from "../../shared/save.js"; +import { createConversionPlan, convertImages, IMAGE_FORMATS } from "./converter.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"), + convert: document.querySelector("#convert-images"), + form: document.querySelector("#conversion-settings"), + format: document.querySelector("#output-format"), + quality: document.querySelector("#quality"), + qualityField: document.querySelector("#quality-field"), + qualityValue: document.querySelector("#quality-value"), + progress: document.querySelector("#conversion-progress"), + status: document.querySelector("#tool-status"), +}; + +const state = { items: [], busy: false, status: 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 rejectionReason(code) { + const keyByCode = { + 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", + }; + return message(keyByCode[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 createActionButton(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 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 === 0; + elements.convert.disabled = state.busy || state.items.length === 0; + elements.add.disabled = state.busy; + elements.input.disabled = state.busy; + elements.format.disabled = state.busy; + elements.quality.disabled = state.busy || elements.format.value === "png"; + + 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 metadata = document.createElement("span"); + metadata.className = "queue-meta"; + metadata.textContent = message("imageConverter.queue.meta", { size: formatBytes(item.file.size), position: index + 1 }); + details.append(name, metadata); + + const actions = document.createElement("div"); + actions.className = "queue-actions"; + actions.append( + createActionButton("up", "imageToPdf.queue.moveUp", "↑", index === 0), + createActionButton("down", "imageToPdf.queue.moveDown", "↓", index === state.items.length - 1), + createActionButton("remove", "imageToPdf.queue.remove", "×"), + ); + row.append(thumbnail, details, actions); + elements.list.append(row); + }); +} + +async function addFiles(files) { + const { accepted, rejected } = await selectImageQueueFiles(state.items.map((item) => item.file), files); + accepted.forEach((file) => state.items.push({ + id: crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`, + file, + url: URL.createObjectURL(file), + })); + 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); + setStatus("imageToPdf.status.removed"); + renderQueue(); +} + +function moveItem(index, offset) { + const nextIndex = moveArrayItem(state.items, index, offset); + if (nextIndex < 0) return; + setStatus("imageToPdf.status.reordered"); + renderQueue(); + elements.list.querySelector(`[data-id="${state.items[nextIndex].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 = ""; + setStatus("imageToPdf.status.cleared"); + renderQueue(); +} + +function updateFormatSettings() { + const isPng = elements.format.value === "png"; + elements.qualityField.hidden = isPng; + elements.quality.disabled = state.busy || isPng; +} + +function updateQualityValue() { + elements.qualityValue.textContent = `${Math.round(Number(elements.quality.value) * 100)}%`; +} + +function errorMessage(error) { + const keyByCode = { + NO_FILES: "imageConverter.errors.noFiles", + IMAGE_FORMAT_INVALID: "imageConverter.errors.format", + UNSUPPORTED_IMAGE: "imageToPdf.errors.unsupported", + IMAGE_FILE_TOO_LARGE: "imageToPdf.errors.fileTooLarge", + IMAGE_DECODE_FAILED: "imageToPdf.errors.decode", + IMAGE_DIMENSION_EXCEEDED: "imageToPdf.errors.dimension", + IMAGE_SIGNATURE_INVALID: "imageToPdf.errors.signature", + IMAGE_PIXELS_EXCEEDED: "imageToPdf.errors.pixels", + IMAGE_JOB_PIXELS_EXCEEDED: "imageConverter.errors.jobPixels", + CANVAS_UNAVAILABLE: "imageConverter.errors.canvas", + IMAGE_ENCODER_FAILED: "imageConverter.errors.encoder", + ARCHIVE_LIBRARY_UNAVAILABLE: "imageConverter.errors.archiveLibrary", + ARCHIVE_GENERATION_FAILED: "imageConverter.errors.archive", + }; + return message(keyByCode[error.code] || "imageConverter.errors.failure", { name: error.fileName || "" }); +} + +async function convertQueue() { + if (!state.items.length || state.busy) { + setStatus("imageConverter.errors.noFiles", {}, "error"); + return; + } + + const files = state.items.map((item) => item.file); + const options = { files, format: elements.format.value, quality: Number(elements.quality.value) }; + let plan; + try { + plan = createConversionPlan(options); + } catch (error) { + elements.status.textContent = errorMessage(error); + elements.status.dataset.tone = "error"; + return; + } + + const multiple = files.length > 1; + const filename = multiple ? plan.names.archive : plan.names.entries[0]; + const outputType = multiple ? "application/zip" : plan.mimeType; + const extension = multiple ? ".zip" : `.${IMAGE_FORMATS[plan.format].extension}`; + let fileHandle = null; + if ("showSaveFilePicker" in window) { + try { + fileHandle = await requestSaveHandle(window, { + suggestedName: filename, + description: t(multiple ? "imageConverter.settings.zipFile" : "imageConverter.settings.imageFile"), + mimeType: outputType, + extension, + }); + } catch (error) { + if (error.name === "AbortError") setStatus("imageConverter.status.saveCancelled"); + else setStatus("imageConverter.errors.save", {}, "error"); + return; + } + } + + state.busy = true; + elements.progress.hidden = false; + elements.progress.max = files.length; + elements.progress.value = 0; + setStatus("imageConverter.status.converting"); + renderQueue(); + let converted = false; + + try { + const result = await convertImages({ + ...options, + JSZip: window.JSZip, + onProgress(completed, total) { + elements.progress.value = completed; + setStatus("imageConverter.status.progress", { completed, total }); + }, + }); + converted = true; + if (result.kind === "zip") setStatus("imageConverter.status.archive"); + if (fileHandle) await writeBlobToHandle(fileHandle, result.blob); + else downloadBlob(result.blob, result.filename); + setStatus(fileHandle ? "imageConverter.status.saved" : "imageConverter.status.downloaded", { + name: result.filename, + count: result.entries.length, + }, "success"); + } catch (error) { + console.error("Image conversion failed", error); + elements.status.textContent = converted && !error.code ? message("imageConverter.errors.save") : errorMessage(error); + elements.status.dataset.tone = "error"; + state.status = null; + } finally { + state.busy = false; + elements.progress.hidden = true; + renderQueue(); + updateFormatSettings(); + } +} + +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.convert.addEventListener("click", convertQueue); +elements.format.addEventListener("change", updateFormatSettings); +elements.quality.addEventListener("input", updateQualityValue); + +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.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))); + +updateQualityValue(); +updateFormatSettings(); +renderQueue(); diff --git a/tools/image/converter/converter.js b/tools/image/converter/converter.js new file mode 100644 index 0000000..a995f19 --- /dev/null +++ b/tools/image/converter/converter.js @@ -0,0 +1,151 @@ +import { decodeImage } from "../../shared/image.js"; + +export const IMAGE_FORMATS = Object.freeze({ + jpeg: { extension: "jpg", mimeType: "image/jpeg", label: "JPEG" }, + png: { extension: "png", mimeType: "image/png", label: "PNG" }, + webp: { extension: "webp", mimeType: "image/webp", label: "WebP" }, +}); +export const DEFAULT_QUALITY = 0.92; +export const MIN_QUALITY = 0.5; +export const MAX_QUALITY = 1; +export const MAX_JOB_PIXELS = 200_000_000; +export const ARCHIVE_FILENAME = "converted_images.zip"; + +const ILLEGAL_FILENAME_CHARACTERS = /[\\/:*?"<>|\u0000-\u001f]+/g; +const INPUT_EXTENSION = /\.(?:jpe?g|png|webp)$/i; +const MAX_BASE_CHARACTERS = 120; + +function conversionError(code, cause) { + const error = new Error(code, cause ? { cause } : undefined); + error.code = code; + return error; +} + +export function normalizeFormat(value) { + const format = String(value || "").toLowerCase(); + if (!IMAGE_FORMATS[format]) throw conversionError("IMAGE_FORMAT_INVALID"); + return format; +} + +export function normalizeQuality(format, value = DEFAULT_QUALITY) { + if (normalizeFormat(format) === "png") return null; + const quality = Number(value); + if (!Number.isFinite(quality)) return DEFAULT_QUALITY; + return Math.min(MAX_QUALITY, Math.max(MIN_QUALITY, quality)); +} + +export function sourceBaseName(value, fallback = "image") { + const clean = String(value || "") + .trim() + .replace(INPUT_EXTENSION, "") + .replace(ILLEGAL_FILENAME_CHARACTERS, "_") + .replace(/[. ]+$/g, ""); + return Array.from(clean || fallback).slice(0, MAX_BASE_CHARACTERS).join(""); +} + +export function createOutputNames(files, format) { + const normalizedFormat = normalizeFormat(format); + const extension = IMAGE_FORMATS[normalizedFormat].extension; + const used = new Set(); + const entries = files.map((file) => { + const base = sourceBaseName(file?.name); + let suffix = 1; + let candidate = `${base}.${extension}`; + while (used.has(candidate.toLocaleLowerCase("en-US"))) { + suffix += 1; + candidate = `${base}_${suffix}.${extension}`; + } + used.add(candidate.toLocaleLowerCase("en-US")); + return candidate; + }); + return { archive: ARCHIVE_FILENAME, entries }; +} + +export function createConversionPlan({ files, format, quality }) { + if (!Array.isArray(files) || files.length < 1) throw conversionError("NO_FILES"); + const normalizedFormat = normalizeFormat(format); + return { + format: normalizedFormat, + mimeType: IMAGE_FORMATS[normalizedFormat].mimeType, + quality: normalizeQuality(normalizedFormat, quality), + names: createOutputNames(files, normalizedFormat), + }; +} + +export function canvasToBlob(canvas, mimeType, quality) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob || blob.type !== mimeType) reject(conversionError("IMAGE_ENCODER_FAILED")); + else resolve(blob); + }, mimeType, quality ?? undefined); + }); +} + +export async function renderDecodedImage({ decoded, format, quality, canvasFactory = () => document.createElement("canvas") }) { + const normalizedFormat = normalizeFormat(format); + const canvas = canvasFactory(); + canvas.width = decoded.width; + canvas.height = decoded.height; + const jpeg = normalizedFormat === "jpeg"; + const context = canvas.getContext("2d", { alpha: !jpeg }); + if (!context) throw conversionError("CANVAS_UNAVAILABLE"); + try { + if (jpeg) { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + context.drawImage(decoded.source, 0, 0); + return await canvasToBlob(canvas, IMAGE_FORMATS[normalizedFormat].mimeType, normalizeQuality(normalizedFormat, quality)); + } finally { + canvas.width = 1; + canvas.height = 1; + } +} + +export async function packageConvertedImages({ blobs, names, JSZip }) { + if (blobs.length !== names.entries.length || blobs.length < 1) throw conversionError("OUTPUT_INVALID"); + if (blobs.length === 1) return { kind: "image", filename: names.entries[0], entries: names.entries, blob: blobs[0] }; + if (typeof JSZip !== "function") throw conversionError("ARCHIVE_LIBRARY_UNAVAILABLE"); + const archive = new JSZip(); + for (let index = 0; index < blobs.length; index += 1) { + archive.file(names.entries[index], new Uint8Array(await blobs[index].arrayBuffer())); + } + try { + const blob = await archive.generateAsync({ type: "blob", compression: "STORE", streamFiles: true }); + return { kind: "zip", filename: names.archive, entries: names.entries, blob }; + } catch (error) { + throw conversionError("ARCHIVE_GENERATION_FAILED", error); + } +} + +export async function convertImages({ + files, format, quality, JSZip, decode = decodeImage, + canvasFactory = () => document.createElement("canvas"), onProgress = () => {}, +}) { + const plan = createConversionPlan({ files, format, quality }); + const blobs = []; + let totalPixels = 0; + + for (let index = 0; index < files.length; index += 1) { + let decoded; + try { + decoded = await decode(files[index]); + totalPixels += decoded.width * decoded.height; + if (totalPixels > MAX_JOB_PIXELS) throw conversionError("IMAGE_JOB_PIXELS_EXCEEDED"); + blobs.push(await renderDecodedImage({ + decoded, + format: plan.format, + quality: plan.quality, + canvasFactory, + })); + onProgress(index + 1, files.length, files[index]); + } catch (error) { + if (!error.fileName) error.fileName = files[index]?.name || ""; + throw error; + } finally { + decoded?.close(); + } + } + + return { ...(await packageConvertedImages({ blobs, names: plan.names, JSZip })), plan }; +} diff --git a/tools/image/converter/index.html b/tools/image/converter/index.html new file mode 100644 index 0000000..0ca33c1 --- /dev/null +++ b/tools/image/converter/index.html @@ -0,0 +1,92 @@ + + + + + + + + + + + + Image Converter — Secure Tools + + + + + + + + + + + + + +
    +
    +
    +

    Image tool

    +

    Image Converter

    +

    Convert JPEG, PNG, and WebP images locally. Multiple files are saved together as one ZIP archive.

    +
    + +
    +
    +
    +

    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/converter/tool.css b/tools/image/converter/tool.css new file mode 100644 index 0000000..88a3c4b --- /dev/null +++ b/tools/image/converter/tool.css @@ -0,0 +1,10 @@ +.quality-heading { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: var(--space-3); } +.quality-heading label { min-width: 0; } +.quality-heading output { color: var(--text-primary); font-size: 0.82rem; font-weight: var(--font-weight-bold); } +.field[hidden] { display: none; } +.converter-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; } + +@media (max-width: 38rem) { + .settings { padding: var(--space-4); } + .quality-heading { align-items: flex-start; } +} diff --git a/tools/image/index.html b/tools/image/index.html index 838a793..7994b62 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -1,6 +1,6 @@ - Image tools — Secure Tools + Image tools — Secure Tools -

      Tool category

      Image tools

      Prepare everyday images privately in your browser.

      • Coming soon

        Image Converter

        Convert common image formats locally.

      • Coming soon

        Image Compressor

        Reduce file size with clear quality controls.

      • Coming soon

        Image Resizer

        Resize images to exact pixel dimensions.

      This category is planned. No unavailable item is presented as working.

      +

      Tool category

      Image tools

      Prepare everyday images privately in your browser.

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

      diff --git a/tools/shared/image.js b/tools/shared/image.js index 18c8944..1ef243c 100644 --- a/tools/shared/image.js +++ b/tools/shared/image.js @@ -109,7 +109,7 @@ export async function decodeImage(file) { if ("createImageBitmap" in window) { try { - const bitmap = await createImageBitmap(file); + const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" }); try { validateImageDimensions(bitmap.width, bitmap.height); } catch (error) { bitmap.close(); throw error; } return { source: bitmap, width: bitmap.width, height: bitmap.height, close: () => bitmap.close() }; From fcc1ee2b42fc7e91667b81242b4c867c5de76a03 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Mon, 24 Aug 2026 10:18:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=85[Test]=20Add=20Image=20Converter?= =?UTF-8?q?=20regression=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/file-input-queue-state.test.mjs | 9 ++ tests/image-converter.test.mjs | 188 ++++++++++++++++++++++++ tests/pdf-merge-and-categories.test.mjs | 2 +- tests/run-all.mjs | 1 + tests/security-hardening.test.mjs | 2 +- tests/ux-consistency.test.mjs | 5 +- 6 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 tests/image-converter.test.mjs 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/image-converter.test.mjs b/tests/image-converter.test.mjs new file mode 100644 index 0000000..1866679 --- /dev/null +++ b/tests/image-converter.test.mjs @@ -0,0 +1,188 @@ +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_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`; +assert.equal(Array.from(sourceBaseName(veryLong)).length, 120); +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, /