diff --git a/CHANGELOG.md b/CHANGELOG.md
index 554b115..b73f27e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Added the Sprint 16B Image → Text OCR interface for the v2.1.0 development cycle with single-image PNG/JPEG/WebP input, English/Korean/combined recognition, editable results, copy, UTF-8 TXT download, cancellation, retry, orientation-correct previews, request-identity safeguards, and complete six-locale UI coverage.
- Added the internal Sprint 16A local OCR foundation with pinned, same-origin Tesseract.js 7.0.0 worker/core assets, English and Korean language data, orientation-aware image preparation, normalized progress, worker reuse, cancellation, cleanup, and real OCR smoke coverage. No public OCR tool was added.
- Started the v2 cycle with a production Image category and local Image Converter.
- Added Image Resize with pixel and percentage modes, aspect-ratio preservation, optional enlargement, Original/JPEG/PNG/WebP output, and local batch ZIP saving.
diff --git a/README.md b/README.md
index bcbf550..b4e3fca 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ npm test
node tests/ocr-smoke.test.mjs
```
-The public Image → Text interface is not part of the current production surface. The reusable local OCR runtime is documented in [Local OCR foundation](./docs/ocr-foundation.md).
+The Image category includes a public, single-image [Image → Text OCR](./tools/image/to-text/) workflow for PNG, JPEG, and WebP input. English, Korean, and combined English + Korean recognition run through the same-origin OCR runtime documented in [Local OCR foundation](./docs/ocr-foundation.md).
## Documentation
diff --git a/docs/README.md b/docs/README.md
index 8ac2650..ee12f55 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -11,6 +11,7 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det
| [Privacy model](./privacy-model.md) | Local-processing and network boundaries, storage, security controls, and bounded privacy claims |
| [Dependencies](./dependencies.md) | Production runtime inventory, versions, vendoring, licenses, and integrity ownership |
| [Local OCR foundation](./ocr-foundation.md) | Self-hosted Tesseract assets, languages, lifecycle, cancellation, caching, and privacy guarantees |
+| [Sprint 16B Image → Text QA](./sprint-16b-qa.md) | Automated and Chromium browser evidence for the v2.1.0 Image → Text workflow |
| [Tool status](./tool-status.md) | Production and planned surfaces, supported formats, behavior, and resource boundaries |
| [Search discovery and metadata](./seo.md) | Canonical routes, crawler files, metadata policy, maintenance, and submission steps |
| [Cloudflare Pages migration bridge](./cloudflare-pages-bridge.md) | H3.2/H3.3 provenance plus the prepared H3.5 hostname-specific indexing, activation, validation, and rollback contracts |
diff --git a/docs/ocr-foundation.md b/docs/ocr-foundation.md
index 4dc0851..41930f3 100644
--- a/docs/ocr-foundation.md
+++ b/docs/ocr-foundation.md
@@ -2,7 +2,7 @@
## Scope and privacy
-Sprint 16A provides reusable image OCR infrastructure without publishing an Image → Text tool. It accepts the same signature-validated PNG, JPEG, and WebP formats as the existing image pipeline and rasterizes them through the shared orientation-aware decoder before recognition. PDF rendering, searchable PDF output, camera capture, batch UI, OCR history, cloud OCR, and text post-processing are outside this foundation.
+Sprint 16A provides the reusable OCR infrastructure. Sprint 16B publishes it as a single-image Image → Text tool in the v2.1.0 development cycle. It accepts the same signature-validated PNG, JPEG, and WebP formats as the existing image pipeline and rasterizes them through the shared orientation-aware decoder before recognition. PDF rendering, searchable PDF output, camera capture, batch UI, OCR history, cloud OCR, and automatic text post-processing remain outside this scope.
**User images and OCR output remain in the browser and are not sent to an OCR server.**
@@ -35,13 +35,15 @@ The service always passes explicit `workerPath`, `corePath`, and `langPath` valu
One service instance reuses a ready worker while the selected language stays the same. Changing the language terminates that worker and creates a replacement. Recognition failure discards the worker so the next request starts cleanly. `dispose()` is idempotent and terminates the owned worker.
-Progress callbacks receive only project-owned stages: `loading-engine`, `loading-language`, `initializing`, `recognizing`, and `complete`. Numeric upstream progress is clamped to `0..1`; missing progress remains `null`. Raw logger objects are never exposed, and callbacks are cleared after each operation.
+Progress callbacks receive only project-owned stages: `loading-engine`, `loading-language`, `initializing`, `recognizing`, and `complete`. Numeric upstream progress is clamped to `0..1`; missing progress remains `null`. The Image → Text page renders missing numeric progress as indeterminate instead of inventing a percentage. Raw logger objects are never exposed, and callbacks are cleared after each operation.
Tesseract.js does not expose safe per-job cancellation. During recognition, an abort terminates and discards the worker before the promise rejects with `OCR_CANCELLED`; later work creates a new worker. An abort during initialization is observed as soon as the library yields the worker handle, which is then terminated before cancellation returns. This avoids reporting cancellation while an owned worker continues running.
+The public controller adds a monotonically increasing request identity around the service. Only the current source, language, and recognition request may update progress or results. Replacing or removing a source, changing language, cancelling, or leaving the page invalidates earlier callbacks. The six visible phases are `empty`, `ready`, `recognizing`, `success`, `error`, and `cancelled`.
+
## CSP and verification
-The unlinked browser smoke page at `tests/browser/ocr-smoke.html` runs the real browser bundle, direct worker, WASM core, and English model under the same strict meta CSP used by production pages. It passed in Chromium with the existing `script-src 'self'`, inherited `worker-src 'self'`, and `connect-src 'none'` policy, without CSP or console errors. No production CSP was changed.
+The browser smoke page at `tests/browser/ocr-smoke.html` runs the real browser bundle, direct worker, WASM core, and English model under the same strict meta CSP used by production pages. The public route uses the same runtime paths and unchanged production CSP. Browser QA also exercises the actual Image → Text selection, recognition, edit, copy/download, replace, remove, and cancellation paths.
Run the repeatable browser check with `npm run smoke:ocr:browser`, then open the printed localhost URL and require a visible `PASS` result. The page rejects any third-party resource entry it observes. The automated Node smoke test performs real English recognition using local core and trained data; unit tests cover path configuration, language mapping, progress, orientation cleanup, reuse, language replacement, initialization/recognition failures, cancellation, disposal, and stale callbacks.
diff --git a/docs/sprint-16b-qa.md b/docs/sprint-16b-qa.md
new file mode 100644
index 0000000..95b9498
--- /dev/null
+++ b/docs/sprint-16b-qa.md
@@ -0,0 +1,32 @@
+# Sprint 16B Image → Text QA
+
+Date: 2026-09-23
+
+Development cycle: v2.1.0
+
+Route: `/tools/image/to-text/`
+
+## Automated evidence
+
+- `npm run build`: pinned OCR assets match the installed packages and lockfile.
+- `npm test`: syntax, state-machine, stale-callback, localization, route, CSP, privacy, responsive-contract, and regression suites pass.
+- `node tests/ocr-smoke.test.mjs`: real local English OCR passes with the pinned core and language data.
+- The browser smoke page completes real English OCR through the same direct worker, WASM core, and language-model paths and reports `PASS: HELLO`.
+
+## Chromium browser QA
+
+Completed in the Chromium-based Codex in-app browser against the repository’s local static server.
+
+- Selected a production PNG through the actual public Image → Text file input and confirmed an orientation-normalized preview, source name, type, and size.
+- Ran real English OCR through the production page and received editable recognized text.
+- Edited the result to mixed English/Korean text, copied it successfully, and triggered the UTF-8 `.txt` download path with the source-derived filename.
+- Cancelled combined English + Korean recognition while language data was loading, confirmed the explicit cancelled state, then retried successfully.
+- Confirmed replacing language or starting a new request clears the earlier result; automated delayed-callback tests cover completion, cancellation, source replacement, and language-change races.
+- Confirmed the empty state hides the source card, result panel, progress element, and cancel action. This caught and fixed a CSS `hidden`-attribute regression during QA.
+- Switched through English, Korean, Japanese, Spanish, German, and French and confirmed the localized heading, controls, status text, document language, and page metadata update without reload.
+- Reloaded with Korean selected and confirmed the preferred OCR default is English + Korean. Reloaded with Japanese selected and confirmed the preferred default returns to English.
+- Switched through System, Light, and Dark themes. Confirmed resolved theme values and readable styled surfaces.
+- Checked a 375 × 812 viewport: the page remained styled, the tool stacked responsively, and document width matched viewport width without horizontal page overflow.
+- Confirmed no browser console warnings or errors in the final styled pass. The real OCR smoke recorded no third-party resource request.
+
+PDF OCR and searchable PDF output remain deferred. This QA does not create a release or tag.
diff --git a/docs/tool-status.md b/docs/tool-status.md
index c3e6ca7..1a78857 100644
--- a/docs/tool-status.md
+++ b/docs/tool-status.md
@@ -14,13 +14,14 @@
| Image | [Image Resize](../tools/image/resize/) | Production | Pixel or percentage batch resize |
| Image | [Image Compressor](../tools/image/compress/) | Production | JPEG/WebP quality and PNG re-encoding |
| Image | [Image Metadata Inspector & Cleaner](../tools/image/metadata/) | Production | Supported JPEG, PNG, WebP metadata |
+| Image | [Image → Text OCR](../tools/image/to-text/) | Production | One PNG, JPEG, or WebP → editable English/Korean text |
| Privacy | [Privacy hub](../tools/privacy/) | Production hub | Navigation to Image and PDF metadata tools |
| Scan/OCR | Category surface | Planned | No production processing tool |
| Media | Category surface | Planned | No production processing tool |
“Production” means linked and covered by the current repository validation. “Planned” cards are non-interactive. Secure Tools may later link separately deployed companion applications, but no Companion status or integration exists on the live site today.
-The Scan/OCR category now has an internal, tested OCR runtime foundation for JPEG, PNG, and WebP input with English, Korean, and combined recognition. It remains planned because Sprint 16A does not expose an Image → Text page or change navigation, sitemap, structured data, or the product catalog.
+The Image category exposes the tested OCR runtime through a single-image Image → Text workflow with English, Korean, and combined recognition. The broader Scan/OCR category remains planned; PDF OCR and searchable PDF output are deferred.
## Shared Image boundaries
@@ -46,6 +47,10 @@ Preserves oriented pixel dimensions while re-encoding in Original/JPEG/PNG/WebP
Accepts one JPEG, PNG, or WebP file and uses the pinned `secure-metadata v0.1.1` browser artifact without decoding or re-encoding pixels. It separates decoded fields from opaque detected containers and presents partial inspection as non-exhaustive. Privacy Clean and Customize operate only on supported metadata classes; verification must pass before output is saved. Valid unambiguous JPEG EXIF Orientation and ICC color information are preserved according to policy. Full guarantees and exclusions are in [Image Metadata privacy and verification](./image-metadata-privacy.md).
+### Image → Text OCR
+
+Accepts one signature-validated PNG, JPEG, or WebP image and recognizes English, Korean, or combined text with the pinned same-origin Tesseract.js runtime. The preview and OCR input use the shared orientation-aware decoder. Recognition starts only after an explicit action and reports real engine progress; it can be cancelled and retried. Results remain editable and can be copied or downloaded as UTF-8 text. Source replacement, removal, language changes, cancellation, and navigation invalidate older requests and release owned preview URLs and workers.
+
## PDF tools
### Images to PDF
@@ -74,7 +79,7 @@ Inspects Title, Author, Subject, Keywords, Creator, Producer, Creation Date, and
## Deferred directions
-Broader PDF modification, compression, encryption, XMP/structural sanitization, Scan/OCR, Media, and offline/PWA work remain deferred. Separately deployed companion applications are an architectural possibility, not a current product status or integration.
+Broader PDF modification, compression, encryption, XMP/structural sanitization, PDF OCR, searchable PDF output, broader Scan/OCR workflows, Media, and offline/PWA work remain deferred. Separately deployed companion applications are an architectural possibility, not a current product status or integration.
## Related guarantees
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/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(/