diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b237c..d5ef3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - main - v2 + - v2.1 permissions: contents: read @@ -22,11 +23,45 @@ jobs: with: fetch-depth: 0 + - name: Enforce pull request branch policy + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + run: | + if [[ "$BASE_REF" == "v2.1" ]]; then + if [[ "$HEAD_REF" =~ ^(feat|fix|test|chore)/.+$ ]]; then + exit 0 + fi + echo "::error::Pull requests into v2.1 must come from feat/*, fix/*, test/*, or chore/* branches." + exit 1 + fi + + if [[ "$BASE_REF" == "main" ]]; then + if [[ "$HEAD_REF" == "v2.1" || "$HEAD_REF" =~ ^hotfix/.+$ ]]; then + exit 0 + fi + echo "::error::Only v2.1 release promotion or an explicit hotfix/* branch may target main." + exit 1 + fi + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 24 + - name: Enforce commit and pull request title conventions + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT_CONVENTION_GRANDFATHER: edb0ade331c54f380ae33abe7816c5a92f3a590a + run: | + node scripts/validate-commit-message.mjs --range "$BASE_SHA" "$HEAD_SHA" --grandfather-through "$COMMIT_CONVENTION_GRANDFATHER" + node scripts/validate-commit-message.mjs --title "$PR_TITLE" + - name: Check changed files for whitespace errors shell: bash env: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38c1065 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Repository workflow policy + +## Protected branches + +- `main` is production-only. `v2.1` is the active integration branch for the v2.1.0 development cycle. +- Never commit feature, fix, test, or chore work directly to `main` or `v2.1`. + +## Development work + +- Create short-lived `feat/*`, `fix/*`, `test/*`, or `chore/*` branches from `v2.1`. +- Sprint and routine development pull requests target `v2.1`, not `main`. +- After a successful merge, delete only the merged short-lived branch when cleanup is authorized. + +## Commit messages + +- Use exactly `[] ` for every human-authored commit. There is no space before `[Action]`, square brackets are mandatory, and exactly one space follows `]`. +- Use one fixed pair: `✨[Feat]`, `➕[Add]`, `🚀[Deploy]`, `✅[Test]`, `📈[Data]`, `🐛[Fix]`, `♻️[Refactor]`, `🔧[Config]`, `🚨[Hotfix]`, `⚙️[Chore]`, `🎉[Init]`, `📄[Docs]`, `🎀[Style]`, or `🚚[Rename]`. +- Write a concise imperative subject for one logical change. Split unrelated changes into separate commits. +- Plain Conventional Commit prefixes such as `feat:`, mismatched pairs such as `🐛[Feat]`, and spaced forms such as `✨ [Feat]` are prohibited. +- Good: `✨[Feat] Add Image to Text OCR`, `✅[Test] Cover OCR cancellation`, `📄[Docs] Document release workflow`. +- Bad: `feat: add OCR`, `✨ [Feat] Add OCR`, `✨[Fix] Add OCR`. +- Inspect recent conforming history if uncertain. Do not create a commit until its message satisfies this convention. +- Commit creation does not authorize merging; the merge-authority policy below still applies. + +## Merge authority + +- Creating a pull request and merging it are separate operations. +- An agent must not merge its own pull request automatically. Passing CI does not authorize a merge. +- Merge only when the user explicitly requests that specific merge after review. Never enable auto-merge without an explicit request. + +## Production promotion + +- Normal development reaches `main` only through a dedicated release or hardening pull request from `v2.1`. +- Creating the v2.1.0 tag or release requires separate explicit authorization after final verification. + +## Hotfixes + +- Production hotfixes use a dedicated `hotfix/*` branch and pull request into `main`. +- Never push a hotfix directly to `main`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 554b115..ad6b466 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. @@ -19,6 +20,7 @@ ### Changed +- Hardened the v2.1.0 release candidate with real English, Korean, and combined local OCR smoke coverage plus reproducible browser, privacy, CSP, asset, locale, responsive, accessibility, and regression evidence. - Hardened v2 promotion gates for all ten production tools, vendored-resource integrity, local-only network invariants, save failure paths, and resource boundaries. - Upgraded secure-metadata to v0.1.1 so JPEG Privacy Clean preserves one valid rendering Orientation while removing other targeted EXIF/GPS data without decoding or re-encoding pixels. - Aligned Image and PDF Metadata action panels and bounded their primary decoded summaries while retaining complete details. 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 251725f..78464a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,9 +7,13 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det | Document | Responsibility | | --- | --- | | [Architecture](./architecture.md) | Static application structure, shared browser foundations, delivery, and future ecosystem direction | +| [Development workflow](./development-workflow.md) | Production, integration, Sprint, release, hotfix, merge-authority, and branch-cleanup rules | | [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 | +| [Sprint 16C v2.1.0 release hardening](./sprint-16c-v2.1-release-hardening.md) | Release-candidate regression, OCR, privacy, browser, performance, and readiness evidence | +| [v2.1.0 release notes draft](./v2.1.0-release-notes-draft.md) | Unpublished release-note copy for the later promotion and release task | | [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/architecture.md b/docs/architecture.md index 24e0f74..9fac252 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,7 @@ Production routes load application code and pinned libraries from the same origi The homepage points to stable category hubs instead of maintaining a flat list of every utility: - PDF: six production tools; -- Image: four production tools; +- Image: five production tools, including Image → Text OCR; - Privacy: a cross-category hub for the two metadata tools; - Scan/OCR and Media: planned, non-interactive surfaces. @@ -44,7 +44,7 @@ The current production inventory and tool-specific behavior live in [tool status - `js/i18n.js` resolves six supported languages, applies translations without reload, updates document metadata and ``, and preserves tool state when language changes. - `js/config.js` centralizes repository links. - `tools/shared/` owns common file admission, signature validation, image/PDF helpers, queue conventions, local save behavior, and shared tool presentation. -- `tools/shared/ocr.js` owns language selection, same-origin OCR paths, normalized progress, orientation-aware image preparation, worker reuse, cancellation, and disposal. It is infrastructure only and is not linked from the public tool surface. +- `tools/shared/ocr.js` owns language selection, same-origin OCR paths, normalized progress, orientation-aware image preparation, worker reuse, cancellation, and disposal for the public Image → Text workflow. - The File System Access API is used when available; a revoking Blob-download fallback serves other browsers. Tool implementations retain specialized models when their workflows differ. Organizer uses a page grid and PDF rendering lifecycle; Metadata tools use bounded inspection models and fail-closed output verification. Shared UI does not erase these tool-specific guarantees. @@ -57,7 +57,7 @@ Image conversion, resizing, and compression use browser decode, Canvas, and enco ## Development and delivery -Serving the committed production tree requires only an HTTP server. Reproducing OCR assets and running the full CI checks requires Node.js 24 and the exact lockfile. `npm run build` verifies prepared OCR assets, `npm test` runs the static and unit suite, and `node tests/ocr-smoke.test.mjs` performs real English recognition. +Serving the committed production tree requires only an HTTP server. Reproducing OCR assets and running the full CI checks requires Node.js 24 and the exact lockfile. `npm run build` verifies prepared OCR assets, `npm test` runs the static and unit suite, and `node tests/ocr-smoke.test.mjs` performs real English, Korean, and combined recognition. `.github/workflows/ci.yml` validates pull requests and pushes to `main` using Node.js 24. It installs the lockfile only to reproduce and verify OCR assets, then checks commit-range whitespace, JavaScript syntax, unit coverage, and real local OCR without adding deployment behavior. diff --git a/docs/development-workflow.md b/docs/development-workflow.md new file mode 100644 index 0000000..5ded476 --- /dev/null +++ b/docs/development-workflow.md @@ -0,0 +1,51 @@ +# Development workflow + +Secure Tools separates product versions from Sprint numbers. A Sprint is a bounded unit of work within a product development cycle; it does not create a version, tag, or release by itself. Sprint 16B, for example, belongs to the v2.1.0 development cycle. + +## Branch roles + +- `main` is the production branch. Routine development does not target it. +- `v2.1` is the integration branch for the v2.1.0 cycle. +- Short-lived `feat/*`, `fix/*`, `test/*`, and `chore/*` branches start from `v2.1` and return through pull requests into `v2.1`. +- Direct feature or fix commits to `main` or `v2.1` are prohibited. + +```text +main (production) + ↑ +release PR after hardening + ↑ +v2.1 (integration) + ↑ +Sprint PRs + ↑ +feat/* fix/* test/* chore/* +``` + +## Sprint delivery + +1. Update local `v2.1` from `origin/v2.1`. +2. Create a short-lived branch from that exact integration state. +3. Commit and validate only the Sprint’s intended changes. +4. Open a pull request into `v2.1` and wait for required CI. +5. Treat review and merge as a separate step. An agent does not merge its own pull request or enable auto-merge unless the user explicitly authorizes that specific action. +6. After a successful merge and verification, remove the merged short-lived branch when branch cleanup is authorized. + +## Commit convention + +Human-authored commits use `[] `. The prefix must be one fixed canonical pair: `✨[Feat]`, `➕[Add]`, `🚀[Deploy]`, `✅[Test]`, `📈[Data]`, `🐛[Fix]`, `♻️[Refactor]`, `🔧[Config]`, `🚨[Hotfix]`, `⚙️[Chore]`, `🎉[Init]`, `📄[Docs]`, `🎀[Style]`, or `🚚[Rename]`. + +There is no space between the Gitmoji and `[Action]`; exactly one space separates the closing bracket from a non-empty, concise imperative subject. Each commit represents one logical change. For example, `✨[Feat] Add Image to Text OCR` and `✅[Test] Cover OCR cancellation` are valid; `feat: add OCR`, `✨ [Feat] Add OCR`, and `✨[Fix] Add OCR` are invalid. + +CI validates non-merge commits introduced by the pull request’s actual base-to-head range and validates the pull-request title with the same structural rule. Technical merge commits are excluded by their multiple-parent topology so normal merge commits remain supported. Published non-conforming history through `edb0ade331c54f380ae33abe7816c5a92f3a590a` is an explicit grandfather boundary: ancestors of that commit are excluded from later ranges, while every human-authored commit after it remains subject to validation. That history is retained and never rewritten solely for message compliance. + +## Production release + +After the v2.1.0 scope is integrated, complete release hardening and final verification on `v2.1`. Promote it through a dedicated `v2.1` → `main` pull request. Only after that pull request is explicitly reviewed and merged may a separately authorized task create the v2.1.0 tag and release. + +## Hotfixes + +Urgent production fixes use a dedicated `hotfix/*` branch and pull request into `main`. They are never pushed directly. After production verification, carry the correction back into the active integration line as needed through an appropriate pull request. + +## Enforced pull request policy + +CI permits routine `feat/*`, `fix/*`, `test/*`, and `chore/*` pull requests into `v2.1`. Pull requests into `main` pass the branch-policy gate only when the head is exactly `v2.1` or a dedicated `hotfix/*` branch. The repository protects both long-lived branches with required pull requests, the existing `Validate static tools` check, resolved review conversations, blocked force pushes, and blocked deletion. Because the repository currently has one maintainer, an approving-review count is not required; explicit merge authorization remains mandatory. diff --git a/docs/ocr-foundation.md b/docs/ocr-foundation.md index 4dc0851..0b2bccd 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.** @@ -29,21 +29,25 @@ Production URLs are: The service always passes explicit `workerPath`, `corePath`, and `langPath` values. `corePath` is the directory, so Tesseract.js can choose the scalar, SIMD, or relaxed-SIMD LSTM runtime. `workerBlobURL: false` creates a direct same-origin worker. Missing local assets produce a controlled initialization failure; the application has no CDN or external-service retry. +Browser HTTP cache and Tesseract.js model caching may allow a previously loaded workflow to run while disconnected. Secure Tools does not install a service worker and does not guarantee that application, worker, core, or model assets are available offline. A fresh or partially cached browser can therefore fail with the localized initialization error. There is no cloud fallback. + ## Service behavior `tools/shared/ocr.js` supports `eng`, `kor`, and `eng+kor` internally. UI code should present localized language names rather than these engine identifiers. 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. +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, Korean, and combined 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. ## Adding a language diff --git a/docs/sprint-16b-qa.md b/docs/sprint-16b-qa.md new file mode 100644 index 0000000..c9cda0b --- /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, Korean, and combined OCR pass 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/sprint-16c-v2.1-release-hardening.md b/docs/sprint-16c-v2.1-release-hardening.md new file mode 100644 index 0000000..726c190 --- /dev/null +++ b/docs/sprint-16c-v2.1-release-hardening.md @@ -0,0 +1,63 @@ +# Sprint 16C v2.1.0 release hardening + +Date: 2026-09-23 + +Candidate: `v2.1` at `74d152a5b79a82abb38ca86d60b3b1b313041dce` + +Production baseline: `main` at `4bc9fb5835d72b1512662d7ca80f2505ba08be00` + +## Baseline and automated checks + +- The baseline working tree was clean, `v2.1` matched `origin/v2.1`, and no open issue or pull request affected the candidate. +- Sprint 16A, Sprint 16B, the development-safety workflow, and branch-policy CI guard were present. +- A clean `npm ci --ignore-scripts` installed the exact lockfile with zero reported vulnerabilities. +- `npm run build`, `npm test`, `node tests/ocr-smoke.test.mjs`, and `git diff --check` passed before hardening. +- The real runtime smoke now recognizes English, Korean, and combined Korean/English fixtures through the pinned local runtime. +- The full suite covers every PDF and Image workflow, both metadata tools, direct routes, localization parity, CSP, SEO/canonical metadata, sitemap, deployment resources, object-URL cleanup, save behavior, and branch policy. + +## OCR runtime, assets, and privacy + +- Pinned packages remain `tesseract.js@7.0.0`, `tesseract.js-core@7.0.0`, and English/Korean data packages at `1.0.0`. +- Explicit worker, core, and language paths resolve under `/assets/vendor/tesseract/`; `workerBlobURL: false` keeps the worker same-origin. No CDN or OCR API fallback is present. +- The prepared inventory contains the engine, worker, both trained-data files, and all 18 scalar/SIMD/relaxed-SIMD core files. Manifest byte lengths and SHA-256 values match every deployed asset, with no duplicate or extra runtime copy. +- The complete committed OCR vendor tree is 49,961,571 bytes. Core variants total 45,244,609 bytes; English data is 2,952,873 bytes; Korean data is 1,572,336 bytes; worker files total 111,773 bytes; engine files total 63,110 bytes. +- Image → Text loads its OCR engine and modules only on its own route. Representative non-OCR pages do not reference the Tesseract runtime. +- Source images, filenames, decoded pixels, recognized text, and history are held only for the active in-memory workflow. Secure Tools does not persist them. Tesseract.js may cache static language-model bytes in IndexedDB; those dependency bytes contain no user content. + +## Chromium browser QA + +QA used the repository static server and the production Image → Text page under its unchanged production CSP. + +- A clean-origin first run selected a real PNG, began combined recognition, cancelled during initialization, reported the localized cancelled state, and retried successfully. +- Combined OCR returned `한글 HELLO`. The result remained editable; clipboard copy preserved mixed Korean/English text; TXT download reported the expected source-derived filename. +- Replacing the source with a 3000 × 2000 PNG and recognizing eight text lines completed correctly. The page did not permanently freeze or lose its source/result state. This constrained browser run took about 14 minutes, so large-image latency remains device-dependent and cancellation is the practical escape path. +- The browser smoke page returned `PASS: HELLO` and would fail on any third-party resource entry. The production workflow produced no console or CSP warning. +- With the local server stopped after prior loading, Chromium reloaded the cached page and completed combined OCR from cached static runtime/model assets. This is observed cache behavior, not a full offline guarantee; a fresh browser without required cached assets cannot rely on offline OCR and receives the existing localized initialization failure. +- No hidden cloud fallback, user upload, analytics, telemetry, or third-party OCR request exists. + +## Locale, theme, responsive, and accessibility QA + +- English, Korean, Japanese, Spanish, German, and French each rendered the localized Image → Text heading with zero raw translation keys. Automated parity covers all 817 keys, placeholders, metadata, controls, errors, status text, and accessibility labels. +- System, Light, and Dark selected distinct resolved theme values without changing workflow state. +- At 375 × 812 the document width stayed within the viewport and the picker, recognition action, and result textarea remained visible and usable. The tablet check at 768 pixels also had no horizontal overflow. +- The browser accessibility tree exposed the file picker, language selector, result editor, cancel/retry, copy, and download controls with names in the active locale. Unit/static checks cover live status, busy/disabled state, logical native controls, visible focus rules, and reduced-motion rules. + +## Existing-tool and discovery regression + +- Direct Chromium navigation loaded Images → PDF, PDF Merge, PDF Split, PDF Organizer, PDF → Images, PDF Metadata, Image Converter, Resize, Compress, and Image Metadata with their file controls and localized headings. +- Automated workflow tests exercise actual processing, cancellation/error paths, signature validation, output generation, and metadata verification for those tools. +- Image → Text has one canonical route, localized title/description metadata, Open Graph metadata, sitemap membership, and Image-category discovery. No duplicate canonical route was found. + +## Findings + +| Severity | Finding | Status | +| --- | --- | --- | +| MEDIUM | Architecture documentation still described four Image tools and called OCR non-public after Sprint 16B. | Fixed: inventory and public OCR ownership now match the implementation. | +| LOW | The real runtime smoke covered English only, leaving Korean and combined model execution to unit/browser evidence. | Fixed: deterministic Korean/English fixture now exercises `kor` and `eng+kor`. | +| LOW | A 3000 × 2000 OCR run took about 14 minutes in the constrained Chromium QA profile. | Open limitation: operation completed correctly; progress/cancel remain available and latency depends on image and device. No arbitrary file limit was introduced. | + +No BLOCKER or HIGH issue was found. No CSP or dependency change was required. + +## Release readiness + +**GO.** The v2.1 candidate has no remaining release blocker and is ready for a separate release-promotion pull request into `main`. This hardening Sprint does not create that PR, a tag, or a GitHub Release. 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/docs/v2.1.0-release-notes-draft.md b/docs/v2.1.0-release-notes-draft.md new file mode 100644 index 0000000..26eeac4 --- /dev/null +++ b/docs/v2.1.0-release-notes-draft.md @@ -0,0 +1,11 @@ +# Secure Tools v2.1.0 release notes — draft + +Status: unpublished. Use this draft during the separate v2.1.0 promotion and release task. + +Secure Tools v2.1.0 adds Image → Text, a local OCR workflow for one PNG, JPEG, or WebP image. Recognition supports English, Korean, and combined English + Korean, with editable results, clipboard copy, and UTF-8 text download. + +The OCR engine, worker, WebAssembly core, and trained language data are pinned and served from the Secure Tools origin. Images and recognized text stay in the browser, no OCR server or analytics endpoint is used, and Secure Tools stores no OCR history. The browser may cache static language-model data for performance; that cache contains no user image or recognized text. + +The six interface locales remain English, Korean, Japanese, Spanish, German, and French. This release also hardens cancellation and retry, stale-result protection, worker and preview cleanup, production asset integrity, Content Security Policy compatibility, responsive behavior, accessibility, and existing PDF and image-tool regression coverage. + +PDF OCR, searchable PDF output, batch OCR, camera capture, and additional recognition languages are outside v2.1.0. 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/package.json b/package.json index 684367a..2500808 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "node scripts/prepare-ocr-assets.mjs --check", "prepare:ocr": "node scripts/prepare-ocr-assets.mjs", "smoke:ocr:browser": "node tests/serve-ocr-smoke.mjs", + "test:commit-messages": "node tests/commit-message.test.mjs", "test": "node tests/run-all.mjs" }, "dependencies": { diff --git a/scripts/validate-commit-message.mjs b/scripts/validate-commit-message.mjs new file mode 100644 index 0000000..84e181a --- /dev/null +++ b/scripts/validate-commit-message.mjs @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export const CANONICAL_PAIRS = Object.freeze([ + "✨[Feat]", + "➕[Add]", + "🚀[Deploy]", + "✅[Test]", + "📈[Data]", + "🐛[Fix]", + "♻️[Refactor]", + "🔧[Config]", + "🚨[Hotfix]", + "⚙️[Chore]", + "🎉[Init]", + "📄[Docs]", + "🎀[Style]", + "🚚[Rename]", +]); + +const conventionalSubject = /^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^\r\n)]+\))?!?:\s/i; +const escapedPairs = CANONICAL_PAIRS.map((pair) => pair.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); +const commitPattern = new RegExp(`^(?:${escapedPairs.join("|")}) ([^\\s].*)$`, "u"); + +export function validateCommitMessage(message) { + if (typeof message !== "string" || message !== message.trim() || /[\r\n]/.test(message) || !commitPattern.test(message)) { + return { valid: false, reason: "message must use one exact canonical Gitmoji/action pair followed by one space and a non-empty subject" }; + } + + const subject = message.slice(message.indexOf("]") + 2); + if (conventionalSubject.test(subject)) { + return { valid: false, reason: "subject must not repeat Conventional Commit syntax after the action tag" }; + } + return { valid: true, reason: null }; +} + +function git(argumentsList, cwd) { + return execFileSync("git", argumentsList, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +export function nonMergeCommits(base, head, cwd = process.cwd(), grandfatherThrough = null) { + const argumentsList = ["rev-list", "--reverse", "--no-merges", `${base}..${head}`]; + if (grandfatherThrough) argumentsList.push(`^${grandfatherThrough}`); + const output = git(argumentsList, cwd); + return output ? output.split(/\r?\n/) : []; +} + +export function validateCommitRange(base, head, cwd = process.cwd(), grandfatherThrough = null) { + return nonMergeCommits(base, head, cwd, grandfatherThrough).map((sha) => { + const subject = git(["show", "-s", "--format=%s", sha], cwd); + return { sha, subject, ...validateCommitMessage(subject) }; + }); +} + +function failureText(label, subject, reason) { + return [ + `Invalid ${label}: ${subject || ""}`, + `Reason: ${reason}`, + "Expected: [] ", + "Examples: ✨[Feat] Add Image to Text OCR | ✅[Test] Cover OCR cancellation | 📄[Docs] Document release workflow", + `Canonical pairs: ${CANONICAL_PAIRS.join(", ")}`, + ].join("\n"); +} + +export function run(argumentsList = process.argv.slice(2), cwd = process.cwd()) { + const [mode, ...values] = argumentsList; + if ((mode === "--message" || mode === "--title") && values.length === 1) { + const result = validateCommitMessage(values[0]); + if (!result.valid) throw new Error(failureText(mode === "--title" ? "pull request title" : "commit message", values[0], result.reason)); + console.log(`${mode === "--title" ? "Pull request title" : "Commit message"} follows the Secure Tools convention.`); + return; + } + + if (mode === "--range" && (values.length === 2 || (values.length === 4 && values[2] === "--grandfather-through"))) { + const grandfatherThrough = values[3] || null; + const results = validateCommitRange(values[0], values[1], cwd, grandfatherThrough); + const failures = results.filter((result) => !result.valid); + if (failures.length) { + throw new Error(failures.map((failure) => failureText(`commit ${failure.sha}`, failure.subject, failure.reason)).join("\n\n")); + } + const boundary = grandfatherThrough ? ` after grandfather boundary ${grandfatherThrough}` : ""; + console.log(`Validated ${results.length} non-merge commit${results.length === 1 ? "" : "s"} in ${values[0]}..${values[1]}${boundary}.`); + return; + } + + throw new Error("Usage: node scripts/validate-commit-message.mjs --message | --title | --range <base-sha> <head-sha> [--grandfather-through <sha>]"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { run(); } + catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/sitemap.xml b/sitemap.xml index db18d1c..3171181 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -15,6 +15,7 @@ <url><loc>https://tools.securetools.app/tools/image/resize/</loc></url> <url><loc>https://tools.securetools.app/tools/image/compress/</loc></url> <url><loc>https://tools.securetools.app/tools/image/metadata/</loc></url> + <url><loc>https://tools.securetools.app/tools/image/to-text/</loc></url> <url><loc>https://tools.securetools.app/tools/privacy/</loc></url> <url><loc>https://tools.securetools.app/tools/scan/</loc></url> <url><loc>https://tools.securetools.app/tools/media/</loc></url> 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(/<li>/g) || []).length, 4); +const imageRoutes = ["./converter/", "./resize/", "./compress/", "./metadata/", "./to-text/"]; +assert.equal((imageList.match(/<li>/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*<li>/, "Image metadata card must remain inside the semantic list"); assertRoutesExist("tools/image/index.html", imageRoutes); diff --git a/tests/ci-foundation.test.mjs b/tests/ci-foundation.test.mjs index 9d3b9cf..478d19f 100644 --- a/tests/ci-foundation.test.mjs +++ b/tests/ci-foundation.test.mjs @@ -4,11 +4,26 @@ import fs from "node:fs"; const workflow = fs.readFileSync(".github/workflows/ci.yml", "utf8"); assert.match(workflow, /^name: CI$/m); assert.match(workflow, /^\s{2}pull_request:$/m); -assert.match(workflow, /^\s{2}push:\s*$[\s\S]*?^\s{6}- main$[\s\S]*?^\s{6}- v2$/m); +assert.match(workflow, /^\s{2}push:\s*$[\s\S]*?^\s{6}- main$[\s\S]*?^\s{6}- v2$[\s\S]*?^\s{6}- v2\.1$/m); assert.match(workflow, /uses: actions\/checkout@v4/); assert.match(workflow, /uses: actions\/setup-node@v4/); assert.match(workflow, /node-version: 24/); assert.match(workflow, /permissions:\s*\n\s+contents: read/); +assert.match(workflow, /name: Enforce pull request branch policy/); +assert.match(workflow, /if: github\.event_name == 'pull_request'/); +assert.match(workflow, /BASE_REF: \$\{\{ github\.base_ref \}\}/); +assert.match(workflow, /HEAD_REF: \$\{\{ github\.head_ref \}\}/); +assert.match(workflow, /\^\(feat\|fix\|test\|chore\)\/\.\+\$/); +assert.match(workflow, /HEAD_REF" == "v2\.1"/); +assert.match(workflow, /\^hotfix\/\.\+\$/); +assert.match(workflow, /Only v2\.1 release promotion or an explicit hotfix/); +assert.match(workflow, /name: Enforce commit and pull request title conventions/); +assert.match(workflow, /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/); +assert.match(workflow, /HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/); +assert.match(workflow, /PR_TITLE: \$\{\{ github\.event\.pull_request\.title \}\}/); +assert.match(workflow, /COMMIT_CONVENTION_GRANDFATHER: edb0ade331c54f380ae33abe7816c5a92f3a590a/); +assert.match(workflow, /node scripts\/validate-commit-message\.mjs --range "\$BASE_SHA" "\$HEAD_SHA" --grandfather-through "\$COMMIT_CONVENTION_GRANDFATHER"/); +assert.match(workflow, /node scripts\/validate-commit-message\.mjs --title "\$PR_TITLE"/); assert.match(workflow, /git diff --check/); assert.match(workflow, /run: npm ci --ignore-scripts/); assert.match(workflow, /run: npm run build/); 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/commit-message.test.mjs b/tests/commit-message.test.mjs new file mode 100644 index 0000000..1e4982f --- /dev/null +++ b/tests/commit-message.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { CANONICAL_PAIRS, run, validateCommitMessage, validateCommitRange } from "../scripts/validate-commit-message.mjs"; + +const accepted = [ + "✨[Feat] Add Image to Text OCR", + "➕[Add] Add local OCR assets", + "🚀[Deploy] Publish v2.1.0", + "✅[Test] Cover OCR cancellation", + "📈[Data] Update language model inventory", + "🐛[Fix] Prevent stale OCR callbacks", + "♻️[Refactor] Simplify OCR worker lifecycle", + "🔧[Config] Enforce protected branch workflow", + "🚨[Hotfix] Restore production OCR loading", + "⚙️[Chore] Reinstate commit convention", + "🎉[Init] Initialize Secure Tools", + "📄[Docs] Document release workflow", + "🎀[Style] Align OCR action layout", + "🚚[Rename] Rename metadata helper", +]; + +const rejected = [ + "feat: add OCR", + "✨ feat: add OCR", + "✨ [Feat] Add OCR", + "[Feat] ✨ Add OCR", + "✨[Fix] Add OCR", + "🐛[Feat] Fix OCR", + "✨[Feat]", + "✨[Unknown] Add OCR", + "Add OCR", + "✨[Feat] Add OCR", + "✨[Feat] feat: add OCR", +]; + +assert.equal(CANONICAL_PAIRS.length, 14); +for (const message of accepted) assert.deepEqual(validateCommitMessage(message), { valid: true, reason: null }, message); +for (const message of rejected) assert.equal(validateCommitMessage(message).valid, false, message); +assert.doesNotThrow(() => run(["--title", "⚙️[Chore] Reinstate commit convention"])); +assert.throws( + () => run(["--message", "docs: explain policy"]), + (error) => /Invalid commit message: docs: explain policy[\s\S]*Expected:[\s\S]*Canonical pairs:/.test(error.message), +); + +const temporaryRepository = fs.mkdtempSync(path.join(os.tmpdir(), "secure-tools-commit-validator-")); +const git = (...argumentsList) => execFileSync("git", argumentsList, { cwd: temporaryRepository, encoding: "utf8" }).trim(); +try { + git("init", "-q"); + git("config", "user.name", "Secure Tools Test"); + git("config", "user.email", "test@securetools.invalid"); + fs.writeFileSync(path.join(temporaryRepository, "base.txt"), "base\n"); + git("add", "base.txt"); + git("commit", "-q", "-m", "🎉[Init] Initialize validator fixture"); + const base = git("rev-parse", "HEAD"); + + git("switch", "-q", "-c", "side"); + fs.writeFileSync(path.join(temporaryRepository, "side.txt"), "side\n"); + git("add", "side.txt"); + git("commit", "-q", "-m", "✅[Test] Add side fixture"); + + git("switch", "-q", "-c", "feature", base); + fs.writeFileSync(path.join(temporaryRepository, "feature.txt"), "feature\n"); + git("add", "feature.txt"); + git("commit", "-q", "-m", "✨[Feat] Add feature fixture"); + git("merge", "-q", "--no-ff", "side", "-m", "Merge side into feature"); + + const mergeHead = git("rev-parse", "HEAD"); + const mergeResults = validateCommitRange(base, mergeHead, temporaryRepository); + assert.equal(mergeResults.length, 2, "the topology-based range excludes the technical merge commit"); + assert.ok(mergeResults.every((result) => result.valid)); + + fs.writeFileSync(path.join(temporaryRepository, "invalid.txt"), "invalid\n"); + git("add", "invalid.txt"); + git("commit", "-q", "-m", "test: add invalid fixture"); + const invalidHead = git("rev-parse", "HEAD"); + const failures = validateCommitRange(mergeHead, invalidHead, temporaryRepository).filter((result) => !result.valid); + assert.equal(failures.length, 1); + assert.equal(failures[0].subject, "test: add invalid fixture"); + assert.match(failures[0].sha, /^[0-9a-f]{40}$/); + + fs.writeFileSync(path.join(temporaryRepository, "current.txt"), "current\n"); + git("add", "current.txt"); + git("commit", "-q", "-m", "🐛[Fix] Validate commits after enforcement"); + const currentHead = git("rev-parse", "HEAD"); + const grandfathered = validateCommitRange(base, currentHead, temporaryRepository, invalidHead); + assert.equal(grandfathered.length, 1, "published ancestors through the enforcement boundary are excluded"); + assert.equal(grandfathered[0].subject, "🐛[Fix] Validate commits after enforcement"); + assert.equal(grandfathered[0].valid, true); + assert.doesNotThrow(() => run(["--range", base, currentHead, "--grandfather-through", invalidHead], temporaryRepository)); +} finally { + fs.rmSync(temporaryRepository, { recursive: true, force: true }); +} + +console.log("Commit message accepted, rejected, range, and merge-topology checks passed."); 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/fixtures/ocr-korean-english.png.b64 b/tests/fixtures/ocr-korean-english.png.b64 new file mode 100644 index 0000000..14d08b2 --- /dev/null +++ b/tests/fixtures/ocr-korean-english.png.b64 @@ -0,0 +1 @@ +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 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(/<select[^>]*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, /<label class="drop-zone__picker" for="file-input">/); diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs index d9814ff..88d2bcc 100644 --- a/tests/image-metadata.test.mjs +++ b/tests/image-metadata.test.mjs @@ -249,7 +249,7 @@ assert.match(app, /FORMAT_POLICY_KEYS[\s\S]*jpeg:[^\n]*removeIptc[^\n]*removeCom assert.match(app, /renderGroups\(elements\.metadata_groups, state\.inspection\.summaryGroups, false\)/); assert.match(app, /additionalDecodedCount/); assert.ok(html.indexOf("metadata-action-panel") < html.indexOf('id="inspection"'), "Primary actions precede arbitrary metadata content in DOM order"); assert.match(html, /connect-src 'none'/); assert.match(html, /role="status" aria-live="polite"/); -assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); +assert.match(category, /href="\.\/metadata\/"/); assert.match(category, /href="\.\/to-text\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 5); const requestIndex = app.indexOf("await requestSaveHandle"); const cleanIndex = app.indexOf("await cleanAndVerifyImageMetadata"); const writeIndex = app.indexOf("await writeBlobToHandle"); assert.ok(requestIndex > -1 && requestIndex < cleanIndex, "Save capability is acquired from the click path before asynchronous cleaning"); assert.ok(cleanIndex < writeIndex, "No bytes are written before cleaning and fail-closed verification succeed"); diff --git a/tests/image-resize.test.mjs b/tests/image-resize.test.mjs index c11cead..6bb7c73 100644 --- a/tests/image-resize.test.mjs +++ b/tests/image-resize.test.mjs @@ -93,7 +93,7 @@ const logic = read("tools/image/resize/resize.js"); const css = read("tools/image/resize/tool.css"); const category = read("tools/image/index.html"); assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); -assert.match(category, /href="\.\/compress\/"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); +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, /name="resize-mode"[^>]*value="pixels"/); assert.match(html, /name="resize-mode"[^>]*value="percentage"/); diff --git a/tests/image-to-text.test.mjs b/tests/image-to-text.test.mjs new file mode 100644 index 0000000..4659314 --- /dev/null +++ b/tests/image-to-text.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createImageToTextController, defaultOcrLanguage, OCR_UI_STATES } from "../tools/image/to-text/controller.js"; +import { copyText, createTextBlob, downloadText, textFilename } from "../tools/image/to-text/output.js"; +import { preparePreviewSource, releasePreviewSource } from "../tools/image/to-text/preview.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); +const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const cancellationError = () => Object.assign(new Error("OCR_CANCELLED"), { code: "OCR_CANCELLED" }); + +assert.equal(defaultOcrLanguage("ko"), "eng+kor"); +assert.equal(defaultOcrLanguage("ko-KR"), "eng+kor"); +for (const language of ["en", "ja", "es", "de", "fr", ""]) assert.equal(defaultOcrLanguage(language), "eng"); +assert.equal(textFilename("영수증.사진.png"), "영수증.사진.txt"); +assert.equal(textFilename("bad:name.webp"), "bad_name.txt"); +assert.equal(textFilename(".png"), "recognized-text.txt"); +const unicodeBlob = createTextBlob("English 한국어 日本語"); +assert.equal(unicodeBlob.type, "text/plain;charset=utf-8"); +assert.equal(await unicodeBlob.text(), "English 한국어 日本語"); + +let copied = ""; +await copyText("corrected text", { navigatorObject: { clipboard: { async writeText(value) { copied = value; } } } }); +assert.equal(copied, "corrected text"); +const downloadCalls = []; +downloadText("한글", "scan.jpeg", { + documentObject: { body: { append() {} }, createElement() { return { click() { downloadCalls.push([this.download, this.href]); }, remove() {} }; } }, + urlObject: { createObjectURL(blob) { assert.equal(blob.type, "text/plain;charset=utf-8"); return "blob:txt"; }, revokeObjectURL(value) { downloadCalls.push(["revoke", value]); } }, + schedule(callback) { callback(); }, +}); +assert.deepEqual(downloadCalls, [["scan.txt", "blob:txt"], ["revoke", "blob:txt"]]); + +const previewUrls = []; +const previewFile = new Blob(["image"], { type: "image/png" }); +Object.defineProperty(previewFile, "name", { value: "oriented.png" }); +const preview = await preparePreviewSource(previewFile, { prepareImage: async () => new Blob(["oriented"], { type: "image/png" }), urlObject: { createObjectURL() { previewUrls.push("blob:preview"); return "blob:preview"; } } }); +assert.equal(preview.file, previewFile); +assert.equal(preview.previewUrl, "blob:preview"); +releasePreviewSource(preview, { urlObject: { revokeObjectURL(value) { previewUrls.push(`revoked:${value}`); } } }); +assert.deepEqual(previewUrls, ["blob:preview", "revoked:blob:preview"]); + +const file = (name) => ({ name, size: 10, type: "image/png" }); +const releases = []; +const calls = []; +const requests = []; +const controller = createImageToTextController({ + language: "eng", + prepareSource: async (candidate) => ({ file: candidate, previewUrl: `blob:${candidate.name}`, previewType: candidate.type }), + releaseSource(source) { releases.push(source.file.name); }, + recognizeImage(candidate, options) { + const job = deferred(); + const request = { candidate, options, job }; + requests.push(request); + options.signal.addEventListener("abort", () => job.reject(cancellationError()), { once: true }); + return job.promise; + }, + onChange(state) { calls.push(state); }, +}); + +await controller.select(file("first.png")); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.READY); +const firstRecognition = controller.recognize(); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.RECOGNIZING); +requests[0].options.onProgress({ stage: "recognizing", progress: 0.42 }); +assert.deepEqual(controller.snapshot().progress, { stage: "recognizing", progress: 0.42 }); +requests[0].job.resolve({ text: "first result" }); +await firstRecognition; +assert.equal(controller.snapshot().phase, OCR_UI_STATES.SUCCESS); +assert.equal(controller.snapshot().text, "first result"); +controller.updateText("edited result"); +assert.equal(controller.snapshot().text, "edited result"); +requests[0].options.onProgress({ stage: "recognizing", progress: 0.99 }); +assert.equal(controller.snapshot().progress, null, "late progress after completion is ignored"); + +await controller.setLanguage("kor"); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.READY); +assert.equal(controller.snapshot().text, "", "language changes clear stale output"); +const cancelledRecognition = controller.recognize(); +const cancelling = controller.cancel(); +await cancelling; +await cancelledRecognition; +assert.equal(controller.snapshot().phase, OCR_UI_STATES.CANCELLED); +requests[1].options.onProgress({ stage: "recognizing", progress: 1 }); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.CANCELLED, "callbacks after cancellation are ignored"); + +const restarted = controller.recognize(); +requests[2].job.resolve({ text: "restarted" }); +await restarted; +assert.equal(controller.snapshot().text, "restarted", "start-cancel-start produces only the newest result"); + +const replacing = controller.recognize(); +const replacement = controller.select(file("second.png")); +await replacing; +await replacement; +requests[3].options.onProgress({ stage: "recognizing", progress: 0.8 }); +assert.equal(controller.snapshot().source.file.name, "second.png"); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.READY, "callbacks after source replacement are ignored"); +assert.deepEqual(releases, ["first.png"]); + +const languageChangeRecognition = controller.recognize(); +const languageChange = controller.setLanguage("eng+kor"); +await languageChangeRecognition; +await languageChange; +requests[4].options.onProgress({ stage: "recognizing", progress: 0.7 }); +assert.equal(controller.snapshot().language, "eng+kor"); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.READY, "callbacks after language change are ignored"); + +const failed = controller.recognize(); +requests[5].job.reject(Object.assign(new Error("failed"), { code: "OCR_RECOGNITION_FAILED" })); +await failed; +assert.equal(controller.snapshot().phase, OCR_UI_STATES.ERROR); +const retried = controller.recognize(); +requests[6].job.resolve({ text: "recovered" }); +await retried; +assert.equal(controller.snapshot().text, "recovered"); +await controller.remove(); +assert.equal(controller.snapshot().phase, OCR_UI_STATES.EMPTY); +assert.deepEqual(releases, ["first.png", "second.png"]); +await controller.dispose(); + +const html = read("tools/image/to-text/index.html"); +const app = read("tools/image/to-text/app.js"); +const css = read("tools/image/to-text/tool.css"); +const category = read("tools/image/index.html"); +assert.match(html, /type="file"[^>]*accept="image\/jpeg,image\/png,image\/webp,[^"]+"[^>]*aria-describedby="drop-description"/); +assert.doesNotMatch(html, /type="file"[^>]*multiple/); +assert.match(html, /assets\/vendor\/tesseract\/engine\/tesseract\.min\.js/); +assert.match(html, /id="result-text"[^>]*spellcheck="true"/); +assert.match(html, /id="ocr-progress"[^>]*max="1"[^>]*hidden/); +assert.match(html, /connect-src 'none'/); +assert.doesNotMatch(html, /unsafe-inline|unsafe-eval/); +assert.match(app, /securetools:languagechange/); +assert.match(app, /pagehide/); +assert.match(app, /removeAttribute\("value"\)/, "unknown progress remains indeterminate"); +assert.match(css, /\.ocr-source\[hidden\][^{]*[\s\S]*\.button\[hidden\][^{]*\{\s*display:\s*none/, "component display rules must preserve hidden states"); +assert.doesNotMatch(app, /fetch\s*\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource/); +assert.match(category, /href="\.\/to-text\/"/); +for (const required of ["empty", "ready", "recognizing", "success", "error", "cancelled"]) assert.ok(Object.values(OCR_UI_STATES).includes(required)); +assert.ok(calls.length > 10, "state changes remain observable for the UI"); + +console.log("Image to Text workflow, stale-callback safety, localization hooks, preview cleanup, copy, and UTF-8 download checks passed."); diff --git a/tests/ocr-foundation.test.mjs b/tests/ocr-foundation.test.mjs index c9fb97a..b11f2bd 100644 --- a/tests/ocr-foundation.test.mjs +++ b/tests/ocr-foundation.test.mjs @@ -200,6 +200,17 @@ assert.deepEqual( [...Object.keys(manifest.assets), "README.md", "manifest.json"].sort(), "OCR vendor inventory contains only documented prepared assets", ); +function listAbsoluteFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? listAbsoluteFiles(target) : [target]; + }); +} +const publicOcrReferences = listAbsoluteFiles(path.join(root, "tools")) + .filter((file) => file.endsWith(".html")) + .map((file) => path.relative(root, file).replaceAll("\\", "/")) + .filter((relative) => read(relative).includes("assets/vendor/tesseract")); +assert.deepEqual(publicOcrReferences, ["tools/image/to-text/index.html"], "OCR runtime must stay lazy to its public route"); for (const required of ["engine/tesseract.min.js", "worker/worker.min.js", "lang/eng.traineddata.gz", "lang/kor.traineddata.gz"]) { assert.ok(manifest.assets[required], `missing ${required}`); } diff --git a/tests/ocr-smoke.test.mjs b/tests/ocr-smoke.test.mjs index 61b2f3c..73895a7 100644 --- a/tests/ocr-smoke.test.mjs +++ b/tests/ocr-smoke.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -58,21 +59,36 @@ function createTextBmp(text) { return Buffer.concat([header, pixels]); } -const progress = []; -const worker = await createWorker("eng", 1, { - langPath: path.join(root, "assets", "vendor", "tesseract", "lang"), - cacheMethod: "none", - logger(message) { progress.push(message.status); }, -}); +async function recognize(language, image) { + const progress = []; + const worker = await createWorker(language, 1, { + langPath: path.join(root, "assets", "vendor", "tesseract", "lang"), + cacheMethod: "none", + logger(message) { progress.push(message.status); }, + }); -try { - const result = await worker.recognize(createTextBmp("HELLO")); - assert.match(result.data.text.replace(/\s+/g, " ").trim(), /HELLO/i); - assert.ok(progress.includes("loading tesseract core")); - assert.ok(progress.includes("loading language traineddata")); - assert.ok(progress.includes("recognizing text")); -} finally { - await worker.terminate(); + try { + const result = await worker.recognize(image); + return { text: result.data.text.replace(/\s+/g, " ").trim(), progress }; + } finally { + await worker.terminate(); + } } -console.log("Real local English OCR smoke test passed."); +const english = await recognize("eng", createTextBmp("HELLO")); +assert.match(english.text, /HELLO/i); +assert.ok(english.progress.includes("loading tesseract core")); +assert.ok(english.progress.includes("loading language traineddata")); +assert.ok(english.progress.includes("recognizing text")); + +const multilingualFixture = Buffer.from( + fs.readFileSync(path.join(root, "tests", "fixtures", "ocr-korean-english.png.b64"), "utf8").trim(), + "base64", +); +const korean = await recognize("kor", multilingualFixture); +assert.match(korean.text, /한글/); +const combined = await recognize("eng+kor", multilingualFixture); +assert.match(combined.text, /한글/); +assert.match(combined.text, /HELLO/i); + +console.log("Real local English, Korean, and combined OCR smoke tests passed."); diff --git a/tests/pdf-merge-and-categories.test.mjs b/tests/pdf-merge-and-categories.test.mjs index 576a8ae..c0f37b0 100644 --- a/tests/pdf-merge-and-categories.test.mjs +++ b/tests/pdf-merge-and-categories.test.mjs @@ -129,7 +129,7 @@ function testRoutesTranslationsAndPrivacy() { "index.html", "privacy/index.html", "about/index.html", "404.html", "tools/image-to-pdf/index.html", "tools/pdf/index.html", "tools/pdf/images-to-pdf/index.html", "tools/pdf/merge/index.html", "tools/pdf/split/index.html", "tools/pdf/organize/index.html", "tools/pdf/to-images/index.html", "tools/pdf/metadata/index.html", "tools/image/index.html", "tools/image/converter/index.html", "tools/image/resize/index.html", "tools/image/compress/index.html", - "tools/image/metadata/index.html", "tools/privacy/index.html", "tools/scan/index.html", "tools/media/index.html", + "tools/image/metadata/index.html", "tools/image/to-text/index.html", "tools/privacy/index.html", "tools/scan/index.html", "tools/media/index.html", ]; const toImagesHtml = fs.readFileSync(path.join(root, "tools/pdf/to-images/index.html"), "utf8"); assert.match(toImagesHtml, /assets\/vendor\/pdf-lib\/pdf-lib\.min\.js/); diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 8d9a093..f0916d6 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -30,6 +30,7 @@ for (const test of [ "tests/image-compressor.test.mjs", "tests/image-metadata.test.mjs", "tests/ocr-foundation.test.mjs", + "tests/image-to-text.test.mjs", "tests/category-availability.test.mjs", "tests/pdf-merge-and-categories.test.mjs", "tests/file-input-queue-state.test.mjs", @@ -45,6 +46,7 @@ for (const test of [ "tests/i18n-quality.test.mjs", "tests/ux-consistency.test.mjs", "tests/ci-foundation.test.mjs", + "tests/commit-message.test.mjs", "tests/cloudflare-bridge.test.mjs", ]) { runNode([test], test); diff --git a/tests/security-hardening.test.mjs b/tests/security-hardening.test.mjs index 82438dd..07a49bf 100644 --- a/tests/security-hardening.test.mjs +++ b/tests/security-hardening.test.mjs @@ -56,7 +56,7 @@ assert.doesNotMatch(imageHtml, /(?:unpkg|jsdelivr|cdnjs|https?:\/\/[^"']*jspdf)/ const productionHtml = [ "index.html", "404.html", "about/index.html", "privacy/index.html", "tools/image-to-pdf/index.html", - "tools/image/index.html", "tools/image/converter/index.html", "tools/image/resize/index.html", "tools/image/compress/index.html", "tools/image/metadata/index.html", + "tools/image/index.html", "tools/image/converter/index.html", "tools/image/resize/index.html", "tools/image/compress/index.html", "tools/image/metadata/index.html", "tools/image/to-text/index.html", "tools/media/index.html", "tools/pdf/index.html", "tools/privacy/index.html", "tools/scan/index.html", "tools/pdf/images-to-pdf/index.html", "tools/pdf/merge/index.html", "tools/pdf/split/index.html", "tools/pdf/organize/index.html", "tools/pdf/to-images/index.html", "tools/pdf/metadata/index.html", ]; diff --git a/tests/seo-foundation.test.mjs b/tests/seo-foundation.test.mjs index d78490e..d7859c2 100644 --- a/tests/seo-foundation.test.mjs +++ b/tests/seo-foundation.test.mjs @@ -25,6 +25,7 @@ const indexableRoutes = new Map([ ["tools/image/resize/index.html", "/tools/image/resize/"], ["tools/image/compress/index.html", "/tools/image/compress/"], ["tools/image/metadata/index.html", "/tools/image/metadata/"], + ["tools/image/to-text/index.html", "/tools/image/to-text/"], ["tools/privacy/index.html", "/tools/privacy/"], ["tools/scan/index.html", "/tools/scan/"], ["tools/media/index.html", "/tools/media/"], @@ -32,7 +33,7 @@ const indexableRoutes = new Map([ const excludedRoutes = ["404.html", "tools/image-to-pdf/index.html"]; const allHtmlRoutes = [...indexableRoutes.keys(), ...excludedRoutes]; -assert.equal(indexableRoutes.size + 1, 19, "all 19 H3 migration routes remain represented"); +assert.equal(indexableRoutes.size + 1, 20, "all 20 public and migration routes remain represented"); const shareImagePath = "assets/images/og-image.png"; const shareImageUrl = `${origin}/${shareImagePath}`; const iconLinks = new Map([ diff --git a/tests/serve-ocr-smoke.mjs b/tests/serve-ocr-smoke.mjs index 6a9179a..72a4e9a 100644 --- a/tests/serve-ocr-smoke.mjs +++ b/tests/serve-ocr-smoke.mjs @@ -4,17 +4,23 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const port = Number.parseInt(process.argv[2] || "4173", 10); const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], [".gz", "application/gzip"], [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], + [".png", "image/png"], + [".ico", "image/x-icon"], [".wasm", "application/wasm"], ]); const server = http.createServer((request, response) => { const pathname = new URL(request.url, "http://127.0.0.1").pathname; - const requested = pathname === "/" ? "/tests/browser/ocr-smoke.html" : pathname; + const requested = pathname === "/" + ? "/tests/browser/ocr-smoke.html" + : pathname.endsWith("/") ? `${pathname}index.html` : pathname; const target = path.resolve(root, `.${decodeURIComponent(requested)}`); if (!target.startsWith(`${root}${path.sep}`)) { response.writeHead(403).end("Forbidden"); @@ -33,6 +39,7 @@ const server = http.createServer((request, response) => { }); }); -server.listen(4173, "127.0.0.1", () => { - console.log("OCR browser smoke: http://127.0.0.1:4173/tests/browser/ocr-smoke.html"); +server.listen(port, "127.0.0.1", () => { + console.log(`OCR browser smoke: http://127.0.0.1:${port}/tests/browser/ocr-smoke.html`); + console.log(`Image to Text UI QA: http://127.0.0.1:${port}/tools/image/to-text/`); }); diff --git a/tests/ux-consistency.test.mjs b/tests/ux-consistency.test.mjs index 377a991..808ea65 100644 --- a/tests/ux-consistency.test.mjs +++ b/tests/ux-consistency.test.mjs @@ -11,6 +11,7 @@ const fileTools = [ ["Image Resize", "tools/image/resize/index.html", "tools/image/resize/app.js", "image/"], ["Image Compressor", "tools/image/compress/index.html", "tools/image/compress/app.js", "image/"], ["Image Metadata", "tools/image/metadata/index.html", "tools/image/metadata/app.js", "image/"], + ["Image to Text", "tools/image/to-text/index.html", "tools/image/to-text/app.js", "image/"], ["Images to PDF", "tools/pdf/images-to-pdf/index.html", "tools/pdf/images-to-pdf/app.js", "image/"], ["Merge PDF", "tools/pdf/merge/index.html", "tools/pdf/merge/app.js", "pdf"], ["Split PDF", "tools/pdf/split/index.html", "tools/pdf/split/app.js", "pdf"], @@ -24,7 +25,7 @@ const productionPages = [ "tools/pdf/index.html", "tools/pdf/images-to-pdf/index.html", "tools/pdf/merge/index.html", "tools/pdf/split/index.html", "tools/pdf/organize/index.html", "tools/pdf/to-images/index.html", "tools/pdf/metadata/index.html", "tools/image/index.html", "tools/image/converter/index.html", "tools/image/resize/index.html", - "tools/image/compress/index.html", "tools/image/metadata/index.html", "tools/privacy/index.html", + "tools/image/compress/index.html", "tools/image/metadata/index.html", "tools/image/to-text/index.html", "tools/privacy/index.html", "tools/scan/index.html", "tools/media/index.html", ]; 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 @@ <h2 data-i18n="tools.imageMetadata">Image Metadata Inspector & Cleaner</h2> <p data-i18n="categories.image.metadata">Inspect privacy-relevant metadata and save a verified cleaned copy.</p> </a> </li> +<li> +<a class="category-tool surface" href="./to-text/"> +<div class="category-tool__top"> +<span class="status status--available" data-i18n="tools.available">Available</span> +<span class="category-tool__arrow" aria-hidden="true">→</span> +</div> +<h2 data-i18n="tools.imageToText">Image to Text OCR</h2> +<p data-i18n="categories.image.toText">Extract editable text from an image with local English and Korean OCR.</p> +</a> +</li> </ul> <p class="category-note" data-i18n="categories.localNote">Available tools process file contents locally in browser memory.</p> </div> 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 @@ +<!doctype html> +<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> +<link rel="icon" href="/assets/icons/favicon.ico" sizes="any"><link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png"> +<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'"> +<meta name="description" content="Extract editable text from one PNG, JPEG, or WebP image locally in your browser."><link rel="canonical" href="https://tools.securetools.app/tools/image/to-text/"><meta property="og:site_name" content="Secure Tools"><meta property="og:url" content="https://tools.securetools.app/tools/image/to-text/"><meta name="theme-color" content="#f7f7f5"><meta property="og:type" content="website"><meta property="og:title" content="Image to Text OCR — Secure Tools"><meta property="og:description" content="Extract editable text from one PNG, JPEG, or WebP image locally in your browser."><meta property="og:image" content="https://tools.securetools.app/assets/images/og-image.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta property="og:image:alt" content="Secure Tools — Local-first browser utilities for PDF, Image, and Privacy"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="Image to Text OCR — Secure Tools"><meta name="twitter:description" content="Extract editable text from one PNG, JPEG, or WebP image locally in your browser."><meta name="twitter:image" content="https://tools.securetools.app/assets/images/og-image.png"><meta name="twitter:image:alt" content="Secure Tools — Local-first browser utilities for PDF, Image, and Privacy"><title>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; } }