From fe458f5acaa4efaa5d0b7c3ac3339b7f8e8e1c64 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 23 Sep 2026 07:59:12 +0900 Subject: [PATCH 1/4] feat: add local image to text OCR UI --- js/i18n.js | 8 +- js/locales/image-to-text.js | 41 +++++++++ sitemap.xml | 1 + tools/image/index.html | 10 ++ tools/image/to-text/app.js | 124 +++++++++++++++++++++++++ tools/image/to-text/controller.js | 147 ++++++++++++++++++++++++++++++ tools/image/to-text/index.html | 16 ++++ tools/image/to-text/output.js | 38 ++++++++ tools/image/to-text/preview.js | 16 ++++ tools/image/to-text/tool.css | 16 ++++ 10 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 js/locales/image-to-text.js create mode 100644 tools/image/to-text/app.js create mode 100644 tools/image/to-text/controller.js create mode 100644 tools/image/to-text/index.html create mode 100644 tools/image/to-text/output.js create mode 100644 tools/image/to-text/preview.js create mode 100644 tools/image/to-text/tool.css diff --git a/js/i18n.js b/js/i18n.js index 071f446..09f9365 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -7,6 +7,7 @@ import { fr } from "./locales/fr.js"; import { imageResizeLocales } from "./locales/image-resize.js"; import { imageCompressorLocales } from "./locales/image-compressor.js"; import { imageMetadataLocales } from "./locales/image-metadata.js"; +import { imageToTextLocales } from "./locales/image-to-text.js"; import { privacyHubLocales } from "./locales/privacy-hub.js"; import { metadataUxLocales } from "./locales/metadata-ux.js"; @@ -14,12 +15,13 @@ const STORAGE_KEY = "secure-tools-language"; const baseTranslations = { en, ko, ja, es, de, fr }; export const translations = Object.fromEntries(Object.entries(baseTranslations).map(([language, catalog]) => [language, { ...catalog, - metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata, imageMetadata: imageMetadataLocales[language].metadata, privacyCategory: privacyHubLocales[language].metadata }, - tools: { ...catalog.tools, imageMetadata: imageMetadataLocales[language].toolName, categoryDescriptions: { ...catalog.tools.categoryDescriptions, privacy: privacyHubLocales[language].categoryDescription } }, - categories: { ...catalog.categories, image: { ...catalog.categories.image, metadata: imageMetadataLocales[language].categoryDescription } }, + metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata, imageMetadata: imageMetadataLocales[language].metadata, imageToText: imageToTextLocales[language].metadata, privacyCategory: privacyHubLocales[language].metadata }, + tools: { ...catalog.tools, imageMetadata: imageMetadataLocales[language].toolName, imageToText: imageToTextLocales[language].toolName, categoryDescriptions: { ...catalog.tools.categoryDescriptions, privacy: privacyHubLocales[language].categoryDescription } }, + categories: { ...catalog.categories, image: { ...catalog.categories.image, metadata: imageMetadataLocales[language].categoryDescription, toText: imageToTextLocales[language].categoryDescription } }, imageResize: imageResizeLocales[language].copy, imageCompressor: imageCompressorLocales[language].copy, imageMetadata: { ...imageMetadataLocales[language].copy, source: { ...imageMetadataLocales[language].copy.source, ...metadataUxLocales[language].image.source }, inspector: { ...imageMetadataLocales[language].copy.inspector, ...metadataUxLocales[language].image.inspector }, clean: { ...imageMetadataLocales[language].copy.clean, ...metadataUxLocales[language].image.clean }, policy: metadataUxLocales[language].image.policy }, + imageToText: imageToTextLocales[language].copy, pdfMetadata: { ...catalog.pdfMetadata, source: { ...catalog.pdfMetadata.source, ...metadataUxLocales[language].pdf.source }, inspector: { ...catalog.pdfMetadata.inspector, ...metadataUxLocales[language].pdf.inspector }, actions: { ...catalog.pdfMetadata.actions, ...metadataUxLocales[language].pdf.actions }, custom: metadataUxLocales[language].pdf.custom, errors: { ...catalog.pdfMetadata.errors, ...metadataUxLocales[language].pdf.errors } }, privacyHub: privacyHubLocales[language].copy, }])); diff --git a/js/locales/image-to-text.js b/js/locales/image-to-text.js new file mode 100644 index 0000000..5a69875 --- /dev/null +++ b/js/locales/image-to-text.js @@ -0,0 +1,41 @@ +const en = { + metadata: { title: "Image to Text OCR — Secure Tools", description: "Extract editable text from one PNG, JPEG, or WebP image locally in your browser." }, + toolName: "Image to Text OCR", categoryDescription: "Extract editable text from an image with local English and Korean OCR.", + copy: { + eyebrow: "Local OCR tool", title: "Image to Text", description: "Recognize text in one PNG, JPEG, or WebP image without uploading it.", + drop: { title: "Add one image", description: "Drop a PNG, JPEG, or WebP image here or use the picker.", choose: "Choose image", localTitle: "Processed locally.", localBody: "Your image and recognized text never leave this device.", privacyLink: "How privacy works" }, + source: { title: "Source image", empty: "No image selected yet.", selectedLabel: "Selected source image", meta: "{size} · {format}", replace: "Replace image", remove: "Remove image", previewAlt: "Preview of the selected image" }, + settings: { title: "Recognition", language: "Text language", english: "English", korean: "Korean", combined: "English + Korean", recognize: "Recognize text", cancel: "Cancel recognition", retry: "Try again", progress: "Recognition progress" }, + result: { title: "Recognized text", description: "Review and edit the text before copying or downloading it.", label: "Editable recognized text", copy: "Copy text", download: "Download TXT" }, + status: { ready: "Image ready. Choose a language and start recognition.", cancelled: "Recognition cancelled. The image remains ready.", success: "Recognition complete. You can edit the result.", copied: "Recognized text copied.", downloaded: "Downloaded {name}.", removed: "Source image removed.", selecting: "Preparing the image locally…" }, + progress: { "loading-engine": "Loading the local OCR engine…", "loading-language": "Loading language data…", initializing: "Initializing recognition…", recognizing: "Recognizing text… {percent}%", complete: "Finishing recognition…" }, + errors: { oneFile: "Choose exactly one image.", tooLarge: "The image exceeds the 50 MiB limit.", signature: "The file is not a supported PNG, JPEG, or WebP image.", decode: "The image could not be decoded.", initialization: "The local OCR engine could not be initialized.", recognition: "Text recognition failed. Try again or choose another image.", busy: "Recognition is still stopping. Try again in a moment.", copy: "The text could not be copied. Select it manually and copy it.", download: "The text file could not be downloaded.", generic: "The image could not be prepared for recognition." }, + }, +}; + +const ko = { + metadata: { title: "이미지 텍스트 OCR — Secure Tools", description: "PNG, JPEG, WebP 이미지 한 개에서 편집 가능한 텍스트를 브라우저에서 로컬로 추출합니다." }, toolName: "이미지 텍스트 OCR", categoryDescription: "로컬 영어·한국어 OCR로 이미지에서 편집 가능한 텍스트를 추출합니다.", + copy: { eyebrow: "로컬 OCR 도구", title: "이미지 → 텍스트", description: "업로드 없이 PNG, JPEG 또는 WebP 이미지 한 개의 텍스트를 인식합니다.", drop: { title: "이미지 한 개 추가", description: "PNG, JPEG 또는 WebP 이미지를 놓거나 선택하세요.", choose: "이미지 선택", localTitle: "로컬에서 처리됩니다.", localBody: "이미지와 인식된 텍스트는 이 기기를 떠나지 않습니다.", privacyLink: "개인정보 보호 방식" }, source: { title: "원본 이미지", empty: "선택한 이미지가 없습니다.", selectedLabel: "선택한 원본 이미지", meta: "{size} · {format}", replace: "이미지 교체", remove: "이미지 제거", previewAlt: "선택한 이미지 미리보기" }, settings: { title: "텍스트 인식", language: "텍스트 언어", english: "영어", korean: "한국어", combined: "영어 + 한국어", recognize: "텍스트 인식", cancel: "인식 취소", retry: "다시 시도", progress: "인식 진행률" }, result: { title: "인식된 텍스트", description: "복사하거나 다운로드하기 전에 텍스트를 확인하고 편집하세요.", label: "편집 가능한 인식 텍스트", copy: "텍스트 복사", download: "TXT 다운로드" }, status: { ready: "이미지가 준비되었습니다. 언어를 선택하고 인식을 시작하세요.", cancelled: "인식을 취소했습니다. 이미지는 준비 상태로 유지됩니다.", success: "인식이 완료되었습니다. 결과를 편집할 수 있습니다.", copied: "인식된 텍스트를 복사했습니다.", downloaded: "{name}을 다운로드했습니다.", removed: "원본 이미지를 제거했습니다.", selecting: "로컬에서 이미지를 준비하는 중…" }, progress: { "loading-engine": "로컬 OCR 엔진을 불러오는 중…", "loading-language": "언어 데이터를 불러오는 중…", initializing: "인식을 초기화하는 중…", recognizing: "텍스트 인식 중… {percent}%", complete: "인식을 마무리하는 중…" }, errors: { oneFile: "이미지 한 개만 선택하세요.", tooLarge: "이미지가 50 MiB 제한을 초과합니다.", signature: "지원되는 PNG, JPEG 또는 WebP 이미지가 아닙니다.", decode: "이미지를 해석할 수 없습니다.", initialization: "로컬 OCR 엔진을 초기화할 수 없습니다.", recognition: "텍스트 인식에 실패했습니다. 다시 시도하거나 다른 이미지를 선택하세요.", busy: "인식이 아직 중지되는 중입니다. 잠시 후 다시 시도하세요.", copy: "텍스트를 복사할 수 없습니다. 직접 선택해 복사하세요.", download: "텍스트 파일을 다운로드할 수 없습니다.", generic: "인식을 위해 이미지를 준비할 수 없습니다." } }, +}; + +const ja = { + metadata: { title: "画像からテキスト OCR — Secure Tools", description: "PNG、JPEG、WebP画像1件から編集可能なテキストをブラウザー内で抽出します。" }, toolName: "画像からテキスト OCR", categoryDescription: "ローカルの英語・韓国語OCRで画像から編集可能なテキストを抽出します。", + copy: { eyebrow: "ローカルOCRツール", title: "画像からテキスト", description: "アップロードせずにPNG、JPEG、WebP画像1件のテキストを認識します。", drop: { title: "画像を1件追加", description: "PNG、JPEG、WebP画像をドロップまたは選択します。", choose: "画像を選択", localTitle: "ローカル処理。", localBody: "画像と認識結果は端末外へ送信されません。", privacyLink: "プライバシーの仕組み" }, source: { title: "元画像", empty: "画像が未選択です。", selectedLabel: "選択した元画像", meta: "{size} · {format}", replace: "画像を置換", remove: "画像を削除", previewAlt: "選択した画像のプレビュー" }, settings: { title: "文字認識", language: "テキスト言語", english: "英語", korean: "韓国語", combined: "英語 + 韓国語", recognize: "テキストを認識", cancel: "認識をキャンセル", retry: "再試行", progress: "認識の進行状況" }, result: { title: "認識したテキスト", description: "コピーまたはダウンロードする前に確認・編集できます。", label: "編集可能な認識テキスト", copy: "テキストをコピー", download: "TXTをダウンロード" }, status: { ready: "画像の準備ができました。言語を選んで認識を開始してください。", cancelled: "認識をキャンセルしました。画像は準備済みです。", success: "認識が完了しました。結果を編集できます。", copied: "認識テキストをコピーしました。", downloaded: "{name}をダウンロードしました。", removed: "元画像を削除しました。", selecting: "画像をローカルで準備中…" }, progress: { "loading-engine": "ローカルOCRエンジンを読み込み中…", "loading-language": "言語データを読み込み中…", initializing: "認識を初期化中…", recognizing: "テキストを認識中… {percent}%", complete: "認識を完了中…" }, errors: { oneFile: "画像を1件だけ選択してください。", tooLarge: "画像が50 MiBの上限を超えています。", signature: "対応するPNG、JPEG、WebP画像ではありません。", decode: "画像をデコードできません。", initialization: "ローカルOCRエンジンを初期化できません。", recognition: "文字認識に失敗しました。再試行するか別の画像を選択してください。", busy: "認識の停止処理中です。少し待って再試行してください。", copy: "コピーできませんでした。手動で選択してコピーしてください。", download: "テキストファイルをダウンロードできません。", generic: "認識用に画像を準備できません。" } }, +}; + +const es = { + metadata: { title: "OCR de imagen a texto — Secure Tools", description: "Extrae texto editable de una imagen PNG, JPEG o WebP localmente en el navegador." }, toolName: "OCR de imagen a texto", categoryDescription: "Extrae texto editable con OCR local en inglés y coreano.", + copy: { eyebrow: "Herramienta OCR local", title: "Imagen a texto", description: "Reconoce texto en una imagen PNG, JPEG o WebP sin subirla.", drop: { title: "Añadir una imagen", description: "Suelta o elige una imagen PNG, JPEG o WebP.", choose: "Elegir imagen", localTitle: "Procesamiento local.", localBody: "La imagen y el texto reconocido no salen del dispositivo.", privacyLink: "Cómo funciona la privacidad" }, source: { title: "Imagen de origen", empty: "No hay ninguna imagen seleccionada.", selectedLabel: "Imagen de origen seleccionada", meta: "{size} · {format}", replace: "Reemplazar imagen", remove: "Quitar imagen", previewAlt: "Vista previa de la imagen seleccionada" }, settings: { title: "Reconocimiento", language: "Idioma del texto", english: "Inglés", korean: "Coreano", combined: "Inglés + coreano", recognize: "Reconocer texto", cancel: "Cancelar reconocimiento", retry: "Reintentar", progress: "Progreso del reconocimiento" }, result: { title: "Texto reconocido", description: "Revisa y edita el texto antes de copiarlo o descargarlo.", label: "Texto reconocido editable", copy: "Copiar texto", download: "Descargar TXT" }, status: { ready: "La imagen está lista. Elige el idioma e inicia el reconocimiento.", cancelled: "Reconocimiento cancelado. La imagen sigue lista.", success: "Reconocimiento terminado. Puedes editar el resultado.", copied: "Texto reconocido copiado.", downloaded: "Se descargó {name}.", removed: "Imagen de origen eliminada.", selecting: "Preparando la imagen localmente…" }, progress: { "loading-engine": "Cargando el motor OCR local…", "loading-language": "Cargando datos de idioma…", initializing: "Inicializando el reconocimiento…", recognizing: "Reconociendo texto… {percent}%", complete: "Finalizando el reconocimiento…" }, errors: { oneFile: "Elige exactamente una imagen.", tooLarge: "La imagen supera el límite de 50 MiB.", signature: "No es una imagen PNG, JPEG o WebP compatible.", decode: "No se pudo decodificar la imagen.", initialization: "No se pudo iniciar el motor OCR local.", recognition: "Falló el reconocimiento. Reintenta o elige otra imagen.", busy: "El reconocimiento aún se está deteniendo. Reintenta en un momento.", copy: "No se pudo copiar. Selecciona el texto y cópialo manualmente.", download: "No se pudo descargar el archivo de texto.", generic: "No se pudo preparar la imagen para el reconocimiento." } }, +}; + +const de = { + metadata: { title: "Bild-zu-Text-OCR — Secure Tools", description: "Extrahiert bearbeitbaren Text aus einem PNG-, JPEG- oder WebP-Bild lokal im Browser." }, toolName: "Bild-zu-Text-OCR", categoryDescription: "Extrahiert bearbeitbaren Text mit lokaler englischer und koreanischer OCR.", + copy: { eyebrow: "Lokales OCR-Werkzeug", title: "Bild zu Text", description: "Erkennt Text in einem PNG-, JPEG- oder WebP-Bild ohne Upload.", drop: { title: "Ein Bild hinzufügen", description: "PNG-, JPEG- oder WebP-Bild ablegen oder auswählen.", choose: "Bild auswählen", localTitle: "Lokale Verarbeitung.", localBody: "Bild und erkannter Text verlassen dieses Gerät nicht.", privacyLink: "So funktioniert der Datenschutz" }, source: { title: "Quellbild", empty: "Noch kein Bild ausgewählt.", selectedLabel: "Ausgewähltes Quellbild", meta: "{size} · {format}", replace: "Bild ersetzen", remove: "Bild entfernen", previewAlt: "Vorschau des ausgewählten Bildes" }, settings: { title: "Erkennung", language: "Textsprache", english: "Englisch", korean: "Koreanisch", combined: "Englisch + Koreanisch", recognize: "Text erkennen", cancel: "Erkennung abbrechen", retry: "Erneut versuchen", progress: "Erkennungsfortschritt" }, result: { title: "Erkannter Text", description: "Text vor dem Kopieren oder Herunterladen prüfen und bearbeiten.", label: "Bearbeitbarer erkannter Text", copy: "Text kopieren", download: "TXT herunterladen" }, status: { ready: "Bild bereit. Sprache wählen und Erkennung starten.", cancelled: "Erkennung abgebrochen. Das Bild bleibt bereit.", success: "Erkennung abgeschlossen. Das Ergebnis kann bearbeitet werden.", copied: "Erkannten Text kopiert.", downloaded: "{name} heruntergeladen.", removed: "Quellbild entfernt.", selecting: "Bild wird lokal vorbereitet…" }, progress: { "loading-engine": "Lokale OCR-Engine wird geladen…", "loading-language": "Sprachdaten werden geladen…", initializing: "Erkennung wird initialisiert…", recognizing: "Text wird erkannt… {percent}%", complete: "Erkennung wird abgeschlossen…" }, errors: { oneFile: "Genau ein Bild auswählen.", tooLarge: "Das Bild überschreitet 50 MiB.", signature: "Keine unterstützte PNG-, JPEG- oder WebP-Datei.", decode: "Das Bild konnte nicht dekodiert werden.", initialization: "Die lokale OCR-Engine konnte nicht initialisiert werden.", recognition: "Texterkennung fehlgeschlagen. Erneut versuchen oder anderes Bild wählen.", busy: "Die Erkennung wird noch beendet. Gleich erneut versuchen.", copy: "Text konnte nicht kopiert werden. Manuell auswählen und kopieren.", download: "Textdatei konnte nicht heruntergeladen werden.", generic: "Das Bild konnte nicht vorbereitet werden." } }, +}; + +const fr = { + metadata: { title: "OCR image vers texte — Secure Tools", description: "Extrayez localement du texte modifiable d’une image PNG, JPEG ou WebP dans le navigateur." }, toolName: "OCR image vers texte", categoryDescription: "Extrayez du texte modifiable avec l’OCR local anglais et coréen.", + copy: { eyebrow: "Outil OCR local", title: "Image vers texte", description: "Reconnaissez le texte d’une image PNG, JPEG ou WebP sans transfert.", drop: { title: "Ajouter une image", description: "Déposez ou choisissez une image PNG, JPEG ou WebP.", choose: "Choisir une image", localTitle: "Traitement local.", localBody: "L’image et le texte reconnu restent sur cet appareil.", privacyLink: "Fonctionnement de la confidentialité" }, source: { title: "Image source", empty: "Aucune image sélectionnée.", selectedLabel: "Image source sélectionnée", meta: "{size} · {format}", replace: "Remplacer l’image", remove: "Retirer l’image", previewAlt: "Aperçu de l’image sélectionnée" }, settings: { title: "Reconnaissance", language: "Langue du texte", english: "Anglais", korean: "Coréen", combined: "Anglais + coréen", recognize: "Reconnaître le texte", cancel: "Annuler la reconnaissance", retry: "Réessayer", progress: "Progression de la reconnaissance" }, result: { title: "Texte reconnu", description: "Vérifiez et modifiez le texte avant de le copier ou de le télécharger.", label: "Texte reconnu modifiable", copy: "Copier le texte", download: "Télécharger le TXT" }, status: { ready: "Image prête. Choisissez la langue et lancez la reconnaissance.", cancelled: "Reconnaissance annulée. L’image reste prête.", success: "Reconnaissance terminée. Vous pouvez modifier le résultat.", copied: "Texte reconnu copié.", downloaded: "{name} téléchargé.", removed: "Image source retirée.", selecting: "Préparation locale de l’image…" }, progress: { "loading-engine": "Chargement du moteur OCR local…", "loading-language": "Chargement des données de langue…", initializing: "Initialisation de la reconnaissance…", recognizing: "Reconnaissance du texte… {percent}%", complete: "Finalisation de la reconnaissance…" }, errors: { oneFile: "Choisissez une seule image.", tooLarge: "L’image dépasse la limite de 50 Mio.", signature: "Ce fichier n’est pas une image PNG, JPEG ou WebP prise en charge.", decode: "Impossible de décoder l’image.", initialization: "Impossible d’initialiser le moteur OCR local.", recognition: "Échec de la reconnaissance. Réessayez ou choisissez une autre image.", busy: "La reconnaissance est encore en cours d’arrêt. Réessayez dans un instant.", copy: "Impossible de copier. Sélectionnez le texte et copiez-le manuellement.", download: "Impossible de télécharger le fichier texte.", generic: "Impossible de préparer l’image pour la reconnaissance." } }, +}; + +export const imageToTextLocales = { en, ko, ja, es, de, fr }; diff --git a/sitemap.xml b/sitemap.xml index db18d1c..3171181 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -15,6 +15,7 @@ https://tools.securetools.app/tools/image/resize/ https://tools.securetools.app/tools/image/compress/ https://tools.securetools.app/tools/image/metadata/ + https://tools.securetools.app/tools/image/to-text/ https://tools.securetools.app/tools/privacy/ https://tools.securetools.app/tools/scan/ https://tools.securetools.app/tools/media/ diff --git a/tools/image/index.html b/tools/image/index.html index 28b63cc..9eb2af0 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -132,6 +132,16 @@

Image Metadata Inspector & Cleaner

Inspect privacy-relevant metadata and save a verified cleaned copy.

+
  • + +
    +Available + +
    +

    Image to Text OCR

    +

    Extract editable text from an image with local English and Korean OCR.

    +
    +
  • Available tools process file contents locally in browser memory.

    diff --git a/tools/image/to-text/app.js b/tools/image/to-text/app.js new file mode 100644 index 0000000..64e5a81 --- /dev/null +++ b/tools/image/to-text/app.js @@ -0,0 +1,124 @@ +import { t } from "../../../js/i18n.js"; +import { formatBytes } from "../../shared/file.js"; +import { createOcrService } from "../../shared/ocr.js"; +import { createImageToTextController, defaultOcrLanguage, OCR_UI_STATES } from "./controller.js"; +import { copyText, downloadText, textFilename } from "./output.js"; +import { preparePreviewSource, releasePreviewSource } from "./preview.js"; + +const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map((element) => [element.id.replaceAll("-", "_"), element])); +let state = null; +let transientStatus = null; +const service = createOcrService(); + +function message(key, values = {}) { + return Object.entries(values).reduce((value, [name, replacement]) => value.replaceAll(`{${name}}`, String(replacement)), t(key)); +} + +function errorKey(error) { + const code = error?.code; + if (code === "IMAGE_FILE_TOO_LARGE") return "imageToText.errors.tooLarge"; + if (code === "IMAGE_SIGNATURE_INVALID" || code === "UNSUPPORTED_IMAGE") return "imageToText.errors.signature"; + if (code === "IMAGE_DECODE_FAILED" || code === "IMAGE_DIMENSION_EXCEEDED" || code === "IMAGE_PIXELS_EXCEEDED") return "imageToText.errors.decode"; + if (code === "OCR_INITIALIZATION_FAILED") return "imageToText.errors.initialization"; + if (code === "OCR_BUSY") return "imageToText.errors.busy"; + if (code === "OCR_RECOGNITION_FAILED") return "imageToText.errors.recognition"; + return "imageToText.errors.generic"; +} + +function statusForState() { + if (transientStatus) return transientStatus; + if (state.phase === OCR_UI_STATES.RECOGNIZING) { + const stage = state.progress?.stage || "loading-engine"; + const percent = Math.round((state.progress?.progress || 0) * 100); + return { key: `imageToText.progress.${stage}`, values: { percent } }; + } + if (state.phase === OCR_UI_STATES.READY) return { key: "imageToText.status.ready" }; + if (state.phase === OCR_UI_STATES.SUCCESS) return { key: "imageToText.status.success", tone: "success" }; + if (state.phase === OCR_UI_STATES.CANCELLED) return { key: "imageToText.status.cancelled", tone: "warning" }; + if (state.phase === OCR_UI_STATES.ERROR) return { key: errorKey(state.error), tone: "error" }; + return null; +} + +function render() { + if (!state) return; + const hasSource = Boolean(state.source); + const recognizing = state.phase === OCR_UI_STATES.RECOGNIZING; + elements.source_empty.hidden = hasSource; + elements.source_card.hidden = !hasSource; + if (hasSource) { + elements.source_preview.src = state.source.previewUrl; + elements.source_preview.alt = t("imageToText.source.previewAlt"); + elements.source_name.textContent = state.source.file.name; + const format = (state.source.file.type.split("/")[1] || state.source.previewType.split("/")[1] || "image").toUpperCase(); + elements.source_meta.textContent = message("imageToText.source.meta", { size: formatBytes(state.source.file.size), format }); + } else { + elements.source_preview.removeAttribute("src"); + } + elements.file_input.disabled = recognizing; + elements.replace_source.disabled = recognizing; + elements.remove_source.disabled = recognizing; + elements.ocr_language.disabled = recognizing; + elements.ocr_language.value = state.language; + elements.recognize.disabled = !hasSource || recognizing; + elements.recognize.textContent = t(state.phase === OCR_UI_STATES.ERROR || state.phase === OCR_UI_STATES.CANCELLED ? "imageToText.settings.retry" : "imageToText.settings.recognize"); + elements.cancel.hidden = !recognizing; + elements.ocr_progress.hidden = !recognizing; + if (recognizing && Number.isFinite(state.progress?.progress)) elements.ocr_progress.value = state.progress.progress; + else elements.ocr_progress.removeAttribute("value"); + elements.result_panel.hidden = state.phase !== OCR_UI_STATES.SUCCESS; + if (state.phase === OCR_UI_STATES.SUCCESS && elements.result_text.value !== state.text) elements.result_text.value = state.text; + const canExport = state.phase === OCR_UI_STATES.SUCCESS && elements.result_text.value.length > 0; + elements.copy_result.disabled = !canExport; + elements.download_result.disabled = !canExport; + const status = statusForState(); + elements.tool_status.textContent = status ? message(status.key, status.values) : ""; + if (status?.tone) elements.tool_status.dataset.tone = status.tone; else delete elements.tool_status.dataset.tone; +} + +const controller = createImageToTextController({ + language: defaultOcrLanguage(document.documentElement.lang), + recognizeImage: service.recognizeImage, + prepareSource: preparePreviewSource, + releaseSource: releasePreviewSource, + dispose: service.dispose, + onChange(nextState) { state = nextState; transientStatus = null; render(); }, +}); + +async function selectFiles(files) { + if (files.length !== 1) { + transientStatus = { key: "imageToText.errors.oneFile", tone: "error" }; + render(); + return; + } + transientStatus = { key: "imageToText.status.selecting" }; + render(); + await controller.select(files[0]); + elements.file_input.value = ""; +} + +elements.file_input.addEventListener("change", (event) => selectFiles([...event.target.files])); +elements.replace_source.addEventListener("click", () => elements.file_input.click()); +elements.remove_source.addEventListener("click", async () => { await controller.remove(); transientStatus = { key: "imageToText.status.removed" }; render(); elements.file_input.focus(); }); +elements.ocr_language.addEventListener("change", (event) => controller.setLanguage(event.target.value)); +elements.recognize.addEventListener("click", () => controller.recognize()); +elements.cancel.addEventListener("click", () => controller.cancel()); +elements.result_text.addEventListener("input", (event) => controller.updateText(event.target.value)); +elements.copy_result.addEventListener("click", async () => { + try { await copyText(elements.result_text.value); transientStatus = { key: "imageToText.status.copied", tone: "success" }; } + catch { transientStatus = { key: "imageToText.errors.copy", tone: "error" }; } + render(); +}); +elements.download_result.addEventListener("click", () => { + try { + const name = textFilename(state.source.file.name); + downloadText(elements.result_text.value, state.source.file.name); + transientStatus = { key: "imageToText.status.downloaded", values: { name }, tone: "success" }; + } catch { transientStatus = { key: "imageToText.errors.download", tone: "error" }; } + render(); +}); +for (const type of ["dragenter", "dragover"]) elements.drop_zone.addEventListener(type, (event) => { event.preventDefault(); if (!elements.file_input.disabled) elements.drop_zone.dataset.dragging = "true"; }); +for (const type of ["dragleave", "drop"]) elements.drop_zone.addEventListener(type, (event) => { event.preventDefault(); delete elements.drop_zone.dataset.dragging; }); +elements.drop_zone.addEventListener("drop", (event) => { if (!elements.file_input.disabled) selectFiles([...event.dataTransfer.files]); }); +document.addEventListener("securetools:languagechange", render); +window.addEventListener("pagehide", () => controller.dispose(), { once: true }); +render(); diff --git a/tools/image/to-text/controller.js b/tools/image/to-text/controller.js new file mode 100644 index 0000000..f80b907 --- /dev/null +++ b/tools/image/to-text/controller.js @@ -0,0 +1,147 @@ +import { OCR_LANGUAGES, resolveOcrLanguage } from "../../shared/ocr.js"; + +export const OCR_UI_STATES = Object.freeze({ + EMPTY: "empty", + READY: "ready", + RECOGNIZING: "recognizing", + SUCCESS: "success", + ERROR: "error", + CANCELLED: "cancelled", +}); + +export function defaultOcrLanguage(uiLanguage) { + return String(uiLanguage || "").toLowerCase().startsWith("ko") + ? OCR_LANGUAGES.KOREAN_ENGLISH + : OCR_LANGUAGES.ENGLISH; +} + +export function createImageToTextController(configuration) { + const recognizeImage = configuration.recognizeImage; + const prepareSource = configuration.prepareSource; + const releaseSource = configuration.releaseSource || (() => {}); + const onChange = configuration.onChange || (() => {}); + let generation = 0; + let active = null; + let disposed = false; + let state = { + phase: OCR_UI_STATES.EMPTY, + source: null, + language: resolveOcrLanguage(configuration.language || OCR_LANGUAGES.ENGLISH), + text: "", + progress: null, + error: null, + }; + + function publish(patch) { + state = { ...state, ...patch }; + onChange({ ...state }); + } + + function snapshot() { + return { ...state }; + } + + async function cancel() { + if (!active) return false; + const current = active; + generation += 1; + active = null; + current.abortController.abort(); + publish({ phase: OCR_UI_STATES.CANCELLED, progress: null, error: null, text: "" }); + try { await current.promise; } catch { /* The active request reports cancellation itself. */ } + return true; + } + + async function select(file) { + if (disposed) return; + await cancel(); + const request = ++generation; + const previous = state.source; + if (previous) releaseSource(previous); + publish({ phase: OCR_UI_STATES.EMPTY, source: null, text: "", progress: null, error: null }); + try { + const source = await prepareSource(file); + if (disposed || request !== generation) { + releaseSource(source); + return; + } + publish({ phase: OCR_UI_STATES.READY, source, text: "", progress: null, error: null }); + } catch (error) { + if (request === generation && !disposed) { + publish({ phase: OCR_UI_STATES.ERROR, source: null, text: "", progress: null, error }); + } + } + } + + async function remove() { + if (disposed) return; + await cancel(); + generation += 1; + if (state.source) releaseSource(state.source); + publish({ phase: OCR_UI_STATES.EMPTY, source: null, text: "", progress: null, error: null }); + } + + async function setLanguage(language) { + const nextLanguage = resolveOcrLanguage(language); + if (nextLanguage === state.language || disposed) return; + await cancel(); + generation += 1; + publish({ + language: nextLanguage, + phase: state.source ? OCR_UI_STATES.READY : OCR_UI_STATES.EMPTY, + text: "", + progress: null, + error: null, + }); + } + + async function recognize() { + if (disposed || !state.source || active) return; + const request = ++generation; + const abortController = new AbortController(); + const source = state.source; + publish({ phase: OCR_UI_STATES.RECOGNIZING, text: "", progress: null, error: null }); + const promise = recognizeImage(source.file, { + language: state.language, + signal: abortController.signal, + onProgress(progress) { + if (!disposed && request === generation && active?.request === request) publish({ progress }); + }, + }); + active = { request, abortController, promise }; + try { + const result = await promise; + if (!disposed && request === generation && active?.request === request) { + publish({ phase: OCR_UI_STATES.SUCCESS, text: result.text, progress: null, error: null }); + } + } catch (error) { + if (!disposed && request === generation && active?.request === request) { + publish({ + phase: error?.code === "OCR_CANCELLED" ? OCR_UI_STATES.CANCELLED : OCR_UI_STATES.ERROR, + text: "", + progress: null, + error: error?.code === "OCR_CANCELLED" ? null : error, + }); + } + } finally { + if (active?.request === request) active = null; + } + } + + function updateText(text) { + if (state.phase === OCR_UI_STATES.SUCCESS) publish({ text: String(text) }); + } + + async function dispose() { + if (disposed) return; + await cancel(); + disposed = true; + generation += 1; + if (state.source) releaseSource(state.source); + state = { ...state, source: null, text: "", progress: null }; + await configuration.dispose?.(); + } + + onChange(snapshot()); + return Object.freeze({ snapshot, select, remove, setLanguage, recognize, cancel, updateText, dispose }); +} diff --git a/tools/image/to-text/index.html b/tools/image/to-text/index.html new file mode 100644 index 0000000..22fae48 --- /dev/null +++ b/tools/image/to-text/index.html @@ -0,0 +1,16 @@ + + + + +Image to Text OCR — Secure Tools + + + + +

    Local OCR tool

    Image to Text

    Recognize text in one PNG, JPEG, or WebP image without uploading it.

    +

    Add one image

    Drop a PNG, JPEG, or WebP image here or use the picker.

    Processed locally. Your image and recognized text never leave this device. How privacy works

    +

    Source image

    No image selected yet.

    +
    +
    + + diff --git a/tools/image/to-text/output.js b/tools/image/to-text/output.js new file mode 100644 index 0000000..2a54671 --- /dev/null +++ b/tools/image/to-text/output.js @@ -0,0 +1,38 @@ +import { downloadBlob } from "../../shared/save.js"; + +const ILLEGAL_FILENAME_CHARACTERS = /[\\/:*?"<>|\u0000-\u001f]+/g; + +export function textFilename(sourceName) { + const withoutExtension = String(sourceName || "").replace(/\.[^.]+$/, ""); + const clean = withoutExtension.trim().replace(ILLEGAL_FILENAME_CHARACTERS, "_").replace(/[. ]+$/g, ""); + return `${clean || "recognized-text"}.txt`; +} + +export function createTextBlob(text) { + return new Blob([String(text)], { type: "text/plain;charset=utf-8" }); +} + +export async function copyText(text, environment = {}) { + const navigatorObject = environment.navigatorObject || globalThis.navigator; + const documentObject = environment.documentObject || globalThis.document; + if (navigatorObject?.clipboard?.writeText) { + await navigatorObject.clipboard.writeText(String(text)); + return; + } + if (!documentObject?.createElement || typeof documentObject.execCommand !== "function") { + throw Object.assign(new Error("OCR_COPY_FAILED"), { code: "OCR_COPY_FAILED" }); + } + const field = documentObject.createElement("textarea"); + field.value = String(text); + field.setAttribute("readonly", ""); + field.className = "clipboard-fallback"; + documentObject.body.append(field); + field.select(); + const copied = documentObject.execCommand("copy"); + field.remove(); + if (!copied) throw Object.assign(new Error("OCR_COPY_FAILED"), { code: "OCR_COPY_FAILED" }); +} + +export function downloadText(text, sourceName, environment) { + downloadBlob(createTextBlob(text), textFilename(sourceName), environment); +} diff --git a/tools/image/to-text/preview.js b/tools/image/to-text/preview.js new file mode 100644 index 0000000..c6962fa --- /dev/null +++ b/tools/image/to-text/preview.js @@ -0,0 +1,16 @@ +import { prepareImageForOcr } from "../../shared/ocr.js"; + +export async function preparePreviewSource(file, environment = {}) { + const prepareImage = environment.prepareImage || prepareImageForOcr; + const urlObject = environment.urlObject || URL; + const previewBlob = await prepareImage(file); + return { + file, + previewUrl: urlObject.createObjectURL(previewBlob), + previewType: previewBlob.type, + }; +} + +export function releasePreviewSource(source, environment = {}) { + if (source?.previewUrl) (environment.urlObject || URL).revokeObjectURL(source.previewUrl); +} diff --git a/tools/image/to-text/tool.css b/tools/image/to-text/tool.css new file mode 100644 index 0000000..bdcc16c --- /dev/null +++ b/tools/image/to-text/tool.css @@ -0,0 +1,16 @@ +.ocr-source { display: grid; grid-template-columns: minmax(10rem, 42%) minmax(0, 1fr); gap: var(--space-5); align-items: center; } +.ocr-source[hidden], .ocr-result[hidden], .button[hidden], .generation-progress[hidden] { display: none; } +.ocr-preview-wrap { display: grid; min-height: 15rem; max-height: 28rem; place-items: center; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-subtle); } +.ocr-preview-wrap img { display: block; width: 100%; height: 100%; max-height: 28rem; object-fit: contain; } +.ocr-source-details { min-width: 0; } +.ocr-source-details strong { display: block; overflow-wrap: anywhere; } +.ocr-source-details p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); } +.source-actions, .result-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); } +.ocr-result { padding: var(--space-5); } +.ocr-result h2 { margin-bottom: var(--space-2); font-size: 1.35rem; } +.ocr-result > p { color: var(--text-secondary); } +.ocr-result label { display: block; margin-bottom: var(--space-2); font-weight: var(--font-weight-bold); } +.ocr-result textarea { width: 100%; min-height: 16rem; resize: vertical; margin-bottom: var(--space-4); padding: var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--bg-primary); color: var(--text-primary); font: inherit; line-height: 1.6; } +.ocr-result textarea:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.clipboard-fallback { position: fixed; left: -10000px; top: 0; } +@media (max-width: 48rem) { .ocr-source { grid-template-columns: 1fr; } .ocr-preview-wrap { min-height: 12rem; } .source-actions .button, .result-actions .button { flex: 1 1 10rem; } } From ac241efad6fcf3dd204bf39bb3fc15b5ec7466ec Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 23 Sep 2026 07:59:18 +0900 Subject: [PATCH 2/4] test: cover image to text workflows and routes --- tests/category-availability.test.mjs | 6 +- tests/cloudflare-bridge.test.mjs | 2 +- tests/deployment-smoke.mjs | 3 +- tests/i18n-quality.test.mjs | 5 +- tests/image-compressor.test.mjs | 2 +- tests/image-converter.test.mjs | 3 +- tests/image-metadata.test.mjs | 2 +- tests/image-resize.test.mjs | 2 +- tests/image-to-text.test.mjs | 144 ++++++++++++++++++++++++ tests/pdf-merge-and-categories.test.mjs | 2 +- tests/run-all.mjs | 1 + tests/security-hardening.test.mjs | 2 +- tests/seo-foundation.test.mjs | 3 +- tests/serve-ocr-smoke.mjs | 13 ++- tests/ux-consistency.test.mjs | 3 +- 15 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 tests/image-to-text.test.mjs diff --git a/tests/category-availability.test.mjs b/tests/category-availability.test.mjs index b516c40..f1ac444 100644 --- a/tests/category-availability.test.mjs +++ b/tests/category-availability.test.mjs @@ -28,10 +28,10 @@ function assertRoutesExist(categoryPage, routes) { const imageHtml = read("tools/image/index.html"); const imageList = categoryList(imageHtml); -const imageRoutes = ["./converter/", "./resize/", "./compress/", "./metadata/"]; -assert.equal((imageList.match(/
  • /g) || []).length, 4); +const imageRoutes = ["./converter/", "./resize/", "./compress/", "./metadata/", "./to-text/"]; +assert.equal((imageList.match(/
  • /g) || []).length, 5); assert.deepEqual(linkedRoutes(imageList), imageRoutes); -assert.equal((imageList.match(/status--available/g) || []).length, 4); +assert.equal((imageList.match(/status--available/g) || []).length, 5); assert.doesNotMatch(imageHtml, /<\/ul>\s*
  • /, "Image metadata card must remain inside the semantic list"); assertRoutesExist("tools/image/index.html", imageRoutes); diff --git a/tests/cloudflare-bridge.test.mjs b/tests/cloudflare-bridge.test.mjs index 465d0dc..e48349a 100644 --- a/tests/cloudflare-bridge.test.mjs +++ b/tests/cloudflare-bridge.test.mjs @@ -34,7 +34,7 @@ assert.doesNotMatch(workflow, /node tests\/deployment-smoke\.mjs https:\/\/secur const deploymentSmoke = fs.readFileSync("tests/deployment-smoke.mjs", "utf8"); const routeLines = deploymentSmoke.match(/^ "\/(?:"|[^"].*\/"),$/gm) || []; -assert.equal(routeLines.length, 19, "deployment smoke must validate all 19 H3.1 routes"); +assert.equal(routeLines.length, 20, "deployment smoke must validate all 20 public and migration routes"); assert.match(deploymentSmoke, /redirect: "manual"/); assert.match(deploymentSmoke, /\["noindex", "indexable"\]/); assert.match(deploymentSmoke, /"x-robots-tag"/); diff --git a/tests/deployment-smoke.mjs b/tests/deployment-smoke.mjs index 173cf71..55c2fcd 100644 --- a/tests/deployment-smoke.mjs +++ b/tests/deployment-smoke.mjs @@ -16,6 +16,7 @@ const routes = [ "/tools/image/resize/", "/tools/image/compress/", "/tools/image/metadata/", + "/tools/image/to-text/", "/tools/privacy/", "/tools/scan/", "/tools/media/", @@ -95,4 +96,4 @@ for (const asset of assets) { await response.arrayBuffer(); } -console.log(`Deployment smoke checks passed for ${base.origin}: indexing=${indexing}, 19 routes, 7 assets, no redirects, expected indexing header, 18 tools-host canonical and social metadata pages plus the intentional noindex legacy alias.`); +console.log(`Deployment smoke checks passed for ${base.origin}: indexing=${indexing}, 20 routes, 7 assets, no redirects, expected indexing header, 19 tools-host canonical and social metadata pages plus the intentional noindex legacy alias.`); diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 09f59ef..8225b0c 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, 761); + assert.equal(english.size, 817); 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, 19, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 20, "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]; @@ -117,6 +117,7 @@ function testDynamicToolsAndMetadata() { "tools/pdf/to-images/app.js", "tools/pdf/metadata/app.js", "tools/image/metadata/app.js", + "tools/image/to-text/app.js", ]; for (const relative of dynamicApps) { const source = fs.readFileSync(path.join(root, relative), "utf8"); diff --git a/tests/image-compressor.test.mjs b/tests/image-compressor.test.mjs index 80d52db..81691ab 100644 --- a/tests/image-compressor.test.mjs +++ b/tests/image-compressor.test.mjs @@ -50,7 +50,7 @@ await assert.rejects(compressImages({ files: Array.from({ length: 5 }, (_, i) => assert.equal(MAX_JOB_PIXELS, 200_000_000); assert.equal(limitClosed, 5); const html = read("tools/image/compress/index.html"); const app = read("tools/image/compress/app.js"); const logic = read("tools/image/compress/compressor.js"); const css = read("tools/image/compress/tool.css"); const category = read("tools/image/index.html"); -assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); assert.doesNotMatch(category, /categories\.plannedNote/); +assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.match(category, /href="\.\/metadata\/"/); assert.match(category, /href="\.\/to-text\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 5); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /id="output-format"[\s\S]*value="original"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/); assert.match(html, /id="quality"[^>]*min="0\.5"[^>]*max="1"[^>]*value="0\.8"/); assert.match(html, /id="compression-results"[^>]*hidden[^>]*aria-labelledby="results-title"/); assert.match(html, /role="status" aria-live="polite"/); assert.match(html, /connect-src 'none'/); assert.match(app, /showQuality = elements\.format\.value !== "png"/); assert.match(app, /invalidateResults\(true\)/); assert.match(app, /finally \{ state\.busy = false; elements\.progress\.hidden = true/); assert.match(app, /URL\.revokeObjectURL/); assert.match(logic, /decoded\?\.close\(\)/); assert.match(read("tools/shared/image.js"), /imageOrientation: "from-image"/); assert.match(app, /const metrics = \{ results: result\.results, aggregate: result\.aggregate \}/, "Result UI state must not retain generated output blobs"); diff --git a/tests/image-converter.test.mjs b/tests/image-converter.test.mjs index 9dbc977..97d3264 100644 --- a/tests/image-converter.test.mjs +++ b/tests/image-converter.test.mjs @@ -172,7 +172,8 @@ const organizer = read("tools/pdf/organize/app.js"); assert.match(category, /href="\.\/converter\/"[\s\S]*status--available/); assert.match(category, /href="\.\/resize\/"[\s\S]*status--available/); assert.match(category, /href="\.\/compress\/"[\s\S]*status--available/); -assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); +assert.match(category, /href="\.\/to-text\/"[\s\S]*status--available/); +assert.equal((category.match(/class="category-tool surface"/g) || []).length, 5); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /