18 Commits
v0.2.0 ... main

Author SHA1 Message Date
João Pedro Battistella Nadas
53796f3b03 feat: vendor all browser deps into the image (no runtime CDN)
esm.sh's degraded storage backend served an unpinned transitive
@codemirror/view@^6.27.0 range URL as text/plain, which browsers block for
a disallowed MIME type. A failed top-level module import aborts the whole
<script type=module>, so the language/model dropdowns came up empty despite
a healthy backend — and top-level version pinning can't fix a transitive
range.

Add a frontend/ esbuild stage (Node stage in the Dockerfile) that vendors
everything into static/vendor and serve only from there:
- CodeMirror (view/state/commands) bundled into one self-contained ESM file
- cropperjs (+ CSS) copied from npm dist
- Tesseract.js engine + wasm core + Dutch (nld) language pack; OCR assets
  stay lazy (fetched only when a scan runs) and fully local
- Drop vim mode (@replit/codemirror-vim); keep document-level Shift-H/L

Google Fonts remains the only external <link> (degrades gracefully, not in
the failing path). Bump to 0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:59:27 +02:00
fd8f0b74aa Merge pull request 'v0.3.0: OCR via camera, FAB UX, side drawer' (#4) from feature/ocr-camera into main 2026-06-09 18:38:33 +00:00
João Pedro Battistella Nadas
713e3f1993 release: v0.3.0 2026-06-09 20:38:13 +02:00
João Pedro Battistella Nadas
b4990e1729 ux: drop page header, move Finrod wordmark into the drawer
The h1 + tagline at the top of the main view were just taking
vertical space. Strip them. The "Finrod" wordmark now lives only
inside the side drawer, set in Cinzel (classical-inscription
serif, loaded from Google Fonts) alongside the Star of Fëanor
mark — matches the favicon and the loremaster vibe.

Body padding-top adjusted to 3.5 rem so the controls clear the
fixed hamburger button at the top-left.
2026-06-09 20:09:36 +02:00
João Pedro Battistella Nadas
1fffe8b7e1 ux: hamburger + slide-out side drawer (not a kebab popover)
Replace the <details>-based kebab popover with a proper Material-
style navigation drawer:

- Hamburger button (menu icon) at top-left.
- <aside> drawer slides in from the left via transform: translateX().
  width: min(85vw, 320px). Backdrop overlay behind it.
- Close paths: X button in the drawer header, backdrop click, Esc.
- Padding honours Android safe-area insets on all four sides.
- Model selector + label live inside the drawer; future per-app
  settings can move here too.
2026-06-09 20:02:11 +02:00
João Pedro Battistella Nadas
bde042a459 ux: 2-col controls, model in a kebab menu, Dutch default, drop iOS code
- Controls go 3 cols → 2 cols (From / To). Model selector moves
  into a kebab (⋮ more_vert) menu in the top-left corner, using
  a native <details> element so toggle and a11y come for free.
- Default source language: Dutch (was Auto-detect). Target stays
  English. Quickest path to a translation since the typical use
  case is reading Dutch text.
- Drop the VisualViewport listener and --kb-inset CSS var —
  Android Chrome already handles keyboard with the
  interactive-widget=resizes-content meta directive; iOS Safari
  isn't in scope. FAB bottom calc reverts to just safe-area-inset.
2026-06-09 19:58:43 +02:00
João Pedro Battistella Nadas
86620f9f5c ux(fab): float above the on-screen keyboard
Two-pronged fix so the Translate + Scan FABs stay reachable when
the mobile soft keyboard is open:

- Add `interactive-widget=resizes-content` to the viewport meta —
  Chrome on Android then shrinks the layout viewport when the
  keyboard opens, so `position: fixed; bottom: ...` is naturally
  above it. iOS Safari ignores this directive, hence:
- VisualViewport listener computes
    inset = innerHeight - vv.height - vv.offsetTop
  and writes it to a `--kb-inset` CSS custom property. The FAB
  bottom calc uses `max(var(--kb-inset), safe-area-inset-bottom)`
  so the keyboard inset wins when it's open and the home-indicator
  inset wins when it isn't.

Net: tap the editor on iOS → keyboard slides up → Translate /
Scan FABs rise with it, no manual minimise required.
2026-06-09 19:53:16 +02:00
João Pedro Battistella Nadas
f160c0510b feat(ux): translate becomes a primary FAB stacked under camera
Promotes Translate from an inline button under the input pane to
the primary 56x56 FAB at the bottom-right corner. Camera FAB
becomes the secondary in the stack, just above. Both share a
single .fab base class; positioning via .fab-primary /
.fab-secondary.

- Translate icon: Material Symbols `translate` (28 px).
- Pulsing-ring `busy` animation while a translation runs, since
  the icon-only button can't show "Translating…" text anymore.
- body padding-bottom bumped to ~9 rem so the output pane
  scrolls clear of the stacked FABs.
- ⌘/Ctrl-Enter still triggers translate via the existing
  CodeMirror keymap; tooltip text updated.
2026-06-09 19:49:05 +02:00
João Pedro Battistella Nadas
ffa133261d ux(ocr): one more shot at the mobile modal layout
Previous attempt was actually-broken — typo notwithstanding, the
mix of 100vh, no explicit flex-shrink, and inconsistent body
padding was easy for the body to grow and shove the footer
buttons offscreen.

This pass:
- 100dvh instead of 100vh (mobile chrome / address bar collapses
  don't cause overflow / under-shoot)
- flex-shrink: 0 on .modal-header and .modal-footer so the body
  can never squeeze them out
- explicit background on the footer so it's not transparent
  against whatever's behind
- camera <video> fills modal-body with object-fit: contain
- body padding kept just enough (~16 px) for crop handle safety
2026-06-09 19:42:36 +02:00
João Pedro Battistella Nadas
29226500e8 ux(ocr): full-bleed modal on mobile, buttons hug screen edges
@media (max-width: 640px):
- backdrop loses its 1 rem inset; modal goes 100vw × 100vh with
  no border-radius
- crop modal body keeps ~20 px horizontal padding so cropper
  corner handles stay safely outside Android's edge-swipe
  back-gesture zone (the corner DRAG is what triggers it; a tap
  on a button at the edge doesn't)
- camera viewfinder body has zero padding — video fills the
  whole space
- footer has minimal 0.5 rem padding so the rotate / invert /
  cancel / check buttons hug the screen edges
- iOS notch / home-indicator insets honoured via
  env(safe-area-inset-*)

Doesn't change the camera-confirm behaviour — that's the OS-camera
fallback path that fires when getUserMedia is denied for not being
a secure context (i.e. http://lan-ip:8000 from the phone, not
HTTPS or localhost).
2026-06-09 19:38:53 +02:00
João Pedro Battistella Nadas
a63b11e467 feat(ocr): live camera viewfinder + invert-colours toggle
Four wins:

1. Replace <input type=file capture> with a getUserMedia
   viewfinder modal on mobile. Tapping the shutter goes straight
   to the crop modal — skips the OS "retake/use" confirm step.
   Falls back to file picker if camera permission is denied or
   the API is unavailable. "Pick from file" icon
   (photo_library) inside the camera modal opens the gallery
   on demand.

2. Drop the Reset button — with no default crop box, "reset" is
   just "no box again," which is the starting state anyway.

3. The check (Use this) button is now larger and more prominent
   — wider padding, 24px icon, accent fill.

4. Invert-colours toggle (invert_colors icon) in the crop modal.
   White-on-dark text (signs, menus, screenshots) breaks
   Tesseract because the binariser was made for ink on paper.
   Toggling the button flips the grayscale buffer before
   adaptive thresholding so the binary output ends up
   black-on-white. State plumbed cleanly: openCropper now
   resolves with {blob, invert}; preprocessForOCR takes an
   {invert} option; binarizeAdaptive applies the flip.

Also: drop `capture=environment` from the file <input>. Without
it the file path correctly opens the gallery/file dialog instead
of re-opening the OS camera when the user picks "Pick from file".
2026-06-09 19:27:34 +02:00
João Pedro Battistella Nadas
cc249fb444 ux(ocr): Material Symbols icons, no default crop box, more padding
- Material Symbols Outlined webfont (Google Fonts) — used for the
  FAB camera (photo_camera), and the rotate_left / rotate_right /
  restart_alt / check buttons in the crop modal. Replaces the
  platform-rendered 📷 emoji that was off-centre, and replaces
  text labels on rotate/use buttons. Cancel stays text.
- Cropper: autoCrop=false — no default selection. User drags on
  the image to draw the crop region; if they "Use this" without
  drawing, falls back to the full (possibly rotated) image.
- Modal-body padding bumped from 1.25 rem to 2 rem so the
  bottom-right corner handle sits ~48 px from the screen edge,
  outside Android's edge-swipe back-gesture zone.
- Header copy updated to "Drag on the image to select the text
  region" since there's no longer a default box to convey intent.
2026-06-09 19:18:52 +02:00
João Pedro Battistella Nadas
a4064d0d72 ux(ocr): camera FAB + modal padding for Android edge gestures
- Scan button is now a 📷 floating action button at bottom-right
  of the viewport (camera emoji per explicit request). Replaces
  the small "Scan image" button in the input header.
- Crop modal: inner padding on .modal-body so cropperjs's
  bottom-right corner handle isn't on top of Android's edge-swipe
  back-gesture zone. Backdrop also respects iOS safe-area insets
  (env(safe-area-inset-{right,left,bottom})).
2026-06-09 19:10:22 +02:00
João Pedro Battistella Nadas
74b8877550 feat(ocr): crop + rotate modal before recognition
After picking an image, a modal opens with cropperjs's drag-handle
crop UI and Rotate left / Rotate right / Reset buttons. Cropping
to just the text region is the single biggest quality win for
OCR on phone photos — strips out logos, lighting transitions, and
background junk that bleed past the confidence filter.

- Lazy-loads cropperjs + its CSS from esm.sh on first use (no
  bundling; same pattern as Tesseract.js and CodeMirror).
- Honours EXIF orientation from phone shots (checkOrientation).
- Keyboard: Esc cancels, Enter accepts. Clicking the backdrop
  also cancels.
- Output goes through the existing preprocessForOCR pipeline
  (downscale to 1600px + Bradley adaptive threshold) before
  Tesseract sees it.
2026-06-09 19:02:51 +02:00
João Pedro Battistella Nadas
4d4e351860 feat(ocr): adaptive binarisation + per-word confidence filter
Two layers to stop tesseract hallucinating text on phone photos:

1. Bradley adaptive threshold (O(N) via integral images) turns the
   downscaled grayscale image into a binary B/W mask. Cuts out
   gradients, shadows, and surface texture that tesseract
   misreads as ink.
2. After recognise(), filter data.words by confidence >= 60 and
   re-assemble lines from kept words. Status line surfaces how
   many were dropped so the cost is visible.

Output of preprocess stage is PNG (not JPEG) so the sharp B/W
edges don't get re-blurred.
2026-06-09 18:54:27 +02:00
João Pedro Battistella Nadas
dcac01e51b perf(ocr): downscale photos to 1600px long edge before recognize
Phone cameras emit 12+ MP images (~4000 px long edge). Tesseract
on a full-resolution shot was painfully slow — minutes, not
seconds. Downscaling to 1600 px on the long edge before
worker.recognize() makes OCR 4-8× faster with no real accuracy
loss for printed text.

Implementation: createImageBitmap -> draw to a sized HTMLCanvas
-> canvas.toBlob() at JPEG 0.85. Smaller-than-1600 px inputs are
passed through unchanged. Status line briefly shows the
downscale ratio so the timing is interpretable.
2026-06-09 18:48:40 +02:00
João Pedro Battistella Nadas
6d6b52e10e fix: don't show "unloads in" when keep_alive=-1
With the radagast server flipped to OLLAMA_KEEP_ALIVE=-1, /api/ps
returns expires_at as year 4001 (effectively forever), which the
old code rendered as "Hot: gemma2:9b — unloads in 17500000h". Add
a 7-day threshold to formatRemaining(); anything beyond that just
shows the model name with no expiry suffix.
2026-06-09 18:47:52 +02:00
João Pedro Battistella Nadas
123ce59595 feat: OCR a camera shot / image into the input via Tesseract.js
New "Scan image" button in the input pane header:

- Opens the phone's rear camera (capture=environment) or a desktop
  file picker via a hidden <input type="file">.
- On image selected, lazy-imports tesseract.js from esm.sh — no
  bundling, no Docker image growth.
- Language follows the "From" dropdown (ISO 639-2 mapping for the
  30 currently-supported languages). "Auto-detect" loads the
  eng+por+deu+nld pack and lets tesseract pick.
- Progress status renders below the input (engine load → OCR
  percentage → "done in Xs").
- Result replaces the editor contents; existing vim bindings,
  Shift-H/L nav, Ctrl-Enter translate all keep working.

First scan triggers a ~5 MB Tesseract WASM download plus the chosen
language pack (~3-10 MB). Cached by the browser after.
2026-06-09 18:42:19 +02:00
10 changed files with 1658 additions and 53 deletions

View File

@@ -8,3 +8,7 @@ __pycache__
.mypy_cache
README.md
build.sh
# Rebuilt fresh in the frontend stage; never copy a local build into the image.
frontend/node_modules
static/vendor

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ __pycache__/
.mypy_cache/
.idea/
.vscode/
# Frontend vendor bundle is produced at Docker build time (frontend/build.mjs).
node_modules/
static/vendor/

View File

@@ -7,6 +7,48 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.5.0] - 2026-07-05
### Changed
- **All browser dependencies are now vendored into the image — nothing loads from a CDN at runtime.** A new `frontend/` esbuild step (run in a Node stage of the Dockerfile) bundles CodeMirror into a single self-contained `static/vendor/codemirror.js` and copies cropperjs + Tesseract.js (engine, wasm core, and the Dutch `nld` language pack) into `static/vendor/`. The page imports only from `/static/vendor/…`. Google Fonts (Material Symbols, Cinzel) is the one remaining external `<link>`; it degrades gracefully and isn't in the failing path.
### Fixed
- Empty language and model dropdowns whenever esm.sh was degraded. The editor's top-level `import`s pulled `@codemirror/*` from esm.sh, whose transitive graph contained an **unpinned** `@codemirror/view@^6.27.0` range URL. During esm.sh's storage outage that range returned an error body as `text/plain`, so the browser blocked the module for a disallowed MIME type — and a single failed top-level import aborts the whole `<script type="module">`, so `fillLangs()`/`loadModels()` never ran despite a healthy backend. Vendoring removes every runtime CDN import, eliminating the failure mode entirely.
### Removed
- Vim editing mode (`@replit/codemirror-vim`) and its `Vim.mapCommand` H/L bindings. The document-level Shift-H/L focus cycling across the dropdowns/buttons is retained. One fewer dependency to vendor.
## [0.3.0] - 2026-06-09
### Added
- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror.
- Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged.
- Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on.
- Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped.
- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling).
### Changed
- "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header).
- "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack.
- FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android).
- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model).
- Model selector moved into a slide-out side drawer (hamburger menu in the top-left). Drawer opens from the left with a backdrop, closes via the X button, backdrop click, or `Esc`. Honours Android safe-area insets.
- Page header (`h1 finrod` + tagline) removed from the main view to recover vertical space. The "Finrod" wordmark now lives only inside the side drawer, set in Cinzel (classical-inscription serif, loaded from Google Fonts) alongside the Star of Fëanor mark.
- Default source language is now Dutch (was Auto-detect). Target stays English.
- Crop modal:
- No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing.
- Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box).
- Inner padding bumped to 2 rem so cropperjs's corner handles never sit on the right/bottom edges where Android's edge-swipe back gesture fires. iOS safe-area insets honoured on the backdrop and FAB.
- Header text is now a hint ("Drag on the image to select the text region") instead of a title.
### Added
- Camera viewfinder modal on mobile (via `navigator.mediaDevices.getUserMedia` with `facingMode: environment`) — replaces the `<input type=file capture>` flow. Tapping the shutter goes directly to the crop modal, skipping the OS-level "retake / use" confirm step. Falls back to the file picker if camera access is denied or unavailable; a "Pick from file" icon (`photo_library`) inside the camera modal also routes to the picker on demand.
- Invert-colours toggle (`invert_colors`) in the crop modal. Tap to flip light-on-dark text into the dark-on-light polarity Tesseract expects. The grayscale buffer is inverted before adaptive thresholding so the binarised output is correct.
- Mobile-first modal layout (≤ 640 px): the crop and camera modals now fill the viewport (no backdrop padding, no border-radius), buttons hug the screen edges in the footer, and the camera video occupies the entire body. Body still has ~20 px horizontal padding so cropper's corner handles stay outside Android's edge-swipe back-gesture zone. iOS notch / home-bar insets honoured.
### Fixed
- "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name.
## [0.2.0] - 2026-06-09
### Added

View File

@@ -1,5 +1,17 @@
# syntax=docker/dockerfile:1.7
# Frontend stage: vendor all browser dependencies (CodeMirror, cropperjs,
# Tesseract.js + wasm core + the Dutch language pack) into static/vendor so the
# running container serves everything locally — no esm.sh / CDN at runtime.
# Pinned to the build platform: the output is architecture-neutral browser
# assets, so there's no need to re-run it under emulation for each target arch.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN node build.mjs
# Build stage: install deps into a venv with uv.
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
@@ -22,6 +34,7 @@ WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY app.py ./
COPY static ./static
COPY --from=frontend /app/static/vendor ./static/vendor
EXPOSE 8000

77
frontend/build.mjs Normal file
View File

@@ -0,0 +1,77 @@
// Vendors finrod's browser dependencies into ../static/vendor/ so the running
// container serves everything locally — no esm.sh / CDN dependency at runtime.
//
// node build.mjs
//
// Outputs:
// static/vendor/codemirror.js — esbuild bundle (view+state+commands)
// static/vendor/cropper.esm.js — cropperjs ESM (dependency-free)
// static/vendor/cropper.min.css — cropperjs stylesheet
// static/vendor/tesseract/tesseract.esm.min.js — Tesseract.js main thread
// static/vendor/tesseract/worker.min.js — Tesseract.js worker
// static/vendor/tesseract/tesseract-core*.{js,wasm} — wasm OCR core
// static/vendor/tesseract/lang/<lang>.traineddata.gz — default language packs
import * as esbuild from "esbuild";
import { createRequire } from "node:module";
import { cp, mkdir, rm, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, "..", "static", "vendor");
// Only Dutch is baked in — finrod's real use is OCR'ing Dutch source text.
// Any other OCR language falls back to on-demand download from Tesseract's
// default CDN. These packs are served locally but only fetched by the browser
// when OCR is actually used (the OCR code path dynamic-imports Tesseract).
const LANGS = ["nld"];
const TESSDATA_BASE = "https://tessdata.projectnaptha.com/4.0.0";
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
await rm(OUT, { recursive: true, force: true });
await mkdir(join(OUT, "tesseract", "lang"), { recursive: true });
// 1. CodeMirror — bundle the whole graph into one ESM file.
await esbuild.build({
entryPoints: [join(here, "src", "codemirror.js")],
bundle: true,
format: "esm",
minify: true,
legalComments: "none",
outfile: join(OUT, "codemirror.js"),
});
console.log("✓ codemirror.js");
// 2. cropperjs — ESM build + CSS, both dependency-free, just copy.
const cropper = pkgDir("cropperjs");
await cp(join(cropper, "dist", "cropper.esm.js"), join(OUT, "cropper.esm.js"));
await cp(join(cropper, "dist", "cropper.min.css"), join(OUT, "cropper.min.css"));
console.log("✓ cropperjs");
// 3. Tesseract.js — ship the prebuilt worker + wasm core (do NOT bundle; it
// spawns a Web Worker and streams wasm, which expects real dist files).
const tess = pkgDir("tesseract.js");
await cp(join(tess, "dist", "tesseract.esm.min.js"), join(OUT, "tesseract", "tesseract.esm.min.js"));
await cp(join(tess, "dist", "worker.min.js"), join(OUT, "tesseract", "worker.min.js"));
const core = pkgDir("tesseract.js-core");
for (const f of await readdir(core)) {
if (f.endsWith(".js") || f.endsWith(".wasm")) {
await cp(join(core, f), join(OUT, "tesseract", f));
}
}
console.log("✓ tesseract engine + core");
// Language packs — fetched at build time so the runtime image is self-contained.
for (const lang of LANGS) {
const url = `${TESSDATA_BASE}/${lang}.traineddata.gz`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(join(OUT, "tesseract", "lang", `${lang}.traineddata.gz`), buf);
console.log(`✓ tessdata ${lang} (${(buf.length / 1e6).toFixed(1)} MB)`);
}
console.log("vendor build complete →", OUT);

702
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,702 @@
{
"name": "finrod-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "finrod-frontend",
"dependencies": {
"@codemirror/commands": "6.7.1",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.36.1",
"cropperjs": "1.6.2",
"tesseract.js": "5.1.1",
"tesseract.js-core": "5.1.1"
},
"devDependencies": {
"esbuild": "0.24.2"
}
},
"node_modules/@codemirror/commands": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz",
"integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.36.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.1.tgz",
"integrity": "sha512-miD1nyT4m4uopZaDdO2uXU/LLHliKNYL9kB1C1wJHrunHLm/rpkb5QVSokqgw9hFqEZakrdlb/VGWX8aYZTslQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/idb-keyval": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==",
"license": "Apache-2.0"
},
"node_modules/is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
"license": "MIT"
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tesseract.js": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",
"integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-electron": "^2.2.2",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^5.1.1",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz",
"integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
"license": "Apache-2.0"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "finrod-frontend",
"private": true,
"type": "module",
"description": "Vendors finrod's browser dependencies into static/vendor so nothing loads from a CDN at runtime.",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@codemirror/commands": "6.7.1",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.36.1",
"cropperjs": "1.6.2",
"tesseract.js": "5.1.1",
"tesseract.js-core": "5.1.1"
},
"devDependencies": {
"esbuild": "0.24.2"
}
}

View File

@@ -0,0 +1,6 @@
// Single entry point re-exporting every CodeMirror symbol index.html needs.
// esbuild bundles this (and the whole transitive @codemirror/@lezer graph)
// into one self-contained ESM file — no runtime CDN, no semver-range URLs.
export { EditorView, keymap, placeholder } from "@codemirror/view";
export { EditorState } from "@codemirror/state";
export { history, defaultKeymap, historyKeymap } from "@codemirror/commands";

View File

@@ -1,6 +1,6 @@
[project]
name = "finrod"
version = "0.2.0"
version = "0.5.0"
description = "Translator UI backed by Ollama"
requires-python = ">=3.11"
dependencies = [

View File

@@ -2,9 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>finrod — translator</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg fill='%23d4a566'%3E%3Cpath d='M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z'/%3E%3Cpath d='M16 4 L22 16 L16 28 L10 16 Z' opacity='.55'/%3E%3C/g%3E%3C/svg%3E" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=block" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&display=swap" />
<style>
:root {
color-scheme: light dark;
@@ -33,23 +35,119 @@
background: var(--bg);
color: var(--fg);
margin: 0;
padding: 2rem 1rem;
/* Top padding (~3.5 rem) clears the fixed hamburger button.
Bottom padding leaves room for the stacked FABs. */
padding: 3.5rem 1rem calc(9rem + env(safe-area-inset-bottom, 0px)) 1rem;
}
.wrap { max-width: 960px; margin: 0 auto; }
h1 {
font-weight: 300;
letter-spacing: 0.05em;
margin: 0 0 0.25rem 0;
/* Cinzel wordmark — only used inside the side drawer */
.drawer-wordmark {
font-family: 'Cinzel', Georgia, serif;
font-size: 1.9rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0;
text-transform: uppercase;
line-height: 1;
}
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.drawer-wordmark svg { width: 26px; height: 26px; }
.controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.controls { grid-template-columns: 1fr; }
/* Hamburger menu (top-left) → slide-out side drawer that houses the
Model selector (and future per-app settings). */
.drawer-toggle {
position: fixed;
top: calc(1rem + env(safe-area-inset-top, 0px));
left: calc(1rem + env(safe-area-inset-left, 0px));
width: 40px;
height: 40px;
padding: 0;
margin: 0;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--panel);
color: var(--fg);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.drawer-toggle .material-symbols-outlined { font-size: 22px; }
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 200;
}
.drawer-backdrop.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 320px);
background: var(--bg);
border-right: 1px solid var(--border);
padding:
max(1rem, env(safe-area-inset-top, 0px))
1.25rem
max(1rem, env(safe-area-inset-bottom, 0px))
max(1.25rem, env(safe-area-inset-left, 0px));
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 201;
box-shadow: 4px 0 14px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.drawer.open { transform: translateX(0); }
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.drawer-close {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
margin: 0;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.drawer-close:hover { color: var(--fg); background: var(--panel); }
.drawer-close .material-symbols-outlined { font-size: 20px; }
.drawer label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.25rem;
}
label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.08em; }
select, textarea, button {
@@ -156,6 +254,211 @@
box-shadow: none;
}
/* Material Symbols Outlined — used for FAB camera, rotate, reset, check */
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
line-height: 1;
vertical-align: middle;
user-select: none;
}
/* Floating action buttons — stacked at bottom-right.
Translate is the primary action (closest to the thumb), Scan sits above. */
.fab {
position: fixed;
right: calc(1.5rem + env(safe-area-inset-right, 0px));
width: 56px;
height: 56px;
padding: 0;
margin: 0;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, box-shadow 0.15s;
}
.fab .material-symbols-outlined { font-size: 28px; }
.fab:hover:not(:disabled) { transform: scale(1.06); }
.fab:disabled { opacity: 0.55; cursor: progress; }
/* Android Chrome shrinks the layout viewport when the soft keyboard
opens (via `interactive-widget=resizes-content` on the viewport meta),
so plain `bottom: 1.5rem` is already above the keyboard. */
.fab-primary {
bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px));
}
.fab-secondary {
/* sits above the primary: 56 (primary height) + 0.75rem gap + base offset */
bottom: calc(1.5rem + 56px + 0.75rem + env(safe-area-inset-bottom, 0px));
}
.fab.busy {
animation: fab-pulse 1.4s ease-in-out infinite;
}
@keyframes fab-pulse {
0%, 100% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }
50% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(224, 122, 62, 0.35); }
}
.scan-status {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.4rem;
min-height: 1em;
}
.scan-status.error { color: var(--error); }
/* Crop / rotate modal (cropperjs lives inside .modal-body) */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
/* Generous side padding pushes the modal in from Android's edge-swipe
gesture zone (~24dp from screen edges triggers back/home). */
padding: 1rem max(1rem, env(safe-area-inset-right, 0px)) 1rem max(1rem, env(safe-area-inset-left, 0px));
z-index: 1000;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
width: min(90vw, 900px);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
color: var(--muted);
letter-spacing: 0.04em;
}
.modal-body {
flex: 1;
min-height: 0;
max-height: 70vh;
background: #000;
/* Inset so cropper's corner handles never sit on a screen-edge gesture zone.
2 rem + backdrop 1 rem = ~48 px clear from screen edges on a phone. */
padding: 2rem;
}
.modal-body img { display: block; max-width: 100%; }
.modal-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
justify-content: space-between;
flex-wrap: wrap;
}
.modal-footer .group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.modal-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 0.4rem 0.9rem;
border-radius: 4px;
cursor: pointer;
margin: 0;
width: auto;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.modal-btn .material-symbols-outlined { font-size: 20px; }
.modal-btn.icon { padding: 0.35rem 0.55rem; min-width: 2.4rem; }
.modal-btn:hover { border-color: var(--accent); color: var(--fg); }
.modal-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.modal-btn.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.modal-btn.primary.icon {
padding: 0.55rem 1.1rem;
min-width: 3.2rem;
}
.modal-btn.primary.icon .material-symbols-outlined { font-size: 24px; }
.modal-btn.primary:hover { color: #fff; }
/* Camera viewfinder modal */
.cam-body {
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
}
.cam-body video {
max-width: 100%;
max-height: 70vh;
display: block;
}
/* On mobile: full-bleed modal, buttons hug the screen edges. Body padding
still ~16 px so cropper corner handles stay outside Android's edge-swipe
gesture zone — tapping a button doesn't trigger it, but dragging into
the very edge does. */
@media (max-width: 640px) {
.modal-backdrop { padding: 0; }
.modal {
width: 100vw;
max-width: 100vw;
height: 100dvh; /* dynamic viewport — accounts for mobile browser chrome */
max-height: 100dvh;
border: 0;
border-radius: 0;
}
.modal-header {
flex-shrink: 0; /* never get squeezed out by the body */
padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
}
.modal-body {
flex: 1 1 auto;
min-height: 0;
padding:
0.5rem
max(1rem, env(safe-area-inset-right, 0px))
0.5rem
max(1rem, env(safe-area-inset-left, 0px));
max-height: none;
}
.cam-body { padding: 0 !important; }
.cam-body video {
width: 100%;
height: 100%;
max-height: none;
object-fit: contain;
}
.modal-footer {
flex-shrink: 0;
background: var(--bg); /* explicit so it always reads clearly */
padding:
0.5rem
max(0.5rem, env(safe-area-inset-right, 0px))
max(0.5rem, env(safe-area-inset-bottom, 0px))
max(0.5rem, env(safe-area-inset-left, 0px));
}
}
/* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor {
min-height: 180px;
@@ -171,21 +474,35 @@
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
</head>
<body>
<button type="button" id="drawer-toggle" class="drawer-toggle" aria-label="Open menu" title="Menu">
<span class="material-symbols-outlined">menu</span>
</button>
<div id="drawer-backdrop" class="drawer-backdrop"></div>
<aside id="drawer" class="drawer" aria-hidden="true">
<div class="drawer-header">
<div class="drawer-wordmark">
<svg viewBox="0 0 32 32" aria-hidden="true">
<g fill="currentColor">
<path d="M16 1 L18 14 L31 16 L18 18 L16 31 L14 18 L1 16 L14 14 Z"/>
<path d="M16 4 L22 16 L16 28 L10 16 Z" opacity=".55"/>
</g>
</svg>
<span>Finrod</span>
</div>
<button type="button" id="drawer-close" class="drawer-close" aria-label="Close menu" title="Close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div>
<label for="model">Model</label>
<select id="model"></select>
</div>
</aside>
<div class="wrap">
<h1>finrod</h1>
<div class="sub">Translation via Ollama · loremaster of tongues</div>
<div class="controls">
<div>
<label for="source">From</label>
@@ -195,10 +512,6 @@
<label for="target">To</label>
<select id="target"></select>
</div>
<div>
<label for="model">Model</label>
<select id="model"></select>
</div>
</div>
<div id="hot-status" class="hot-status idle">
@@ -210,6 +523,8 @@
<div>
<label for="input">Input</label>
<div id="input"></div>
<input type="file" id="scan-input" accept="image/*" hidden>
<div id="scan-status" class="scan-status"></div>
</div>
<div>
<label for="output">Translation</label>
@@ -217,15 +532,22 @@
</div>
</div>
<button id="go">Translate</button>
<div id="meta" class="meta"></div>
</div>
<button type="button" id="scan-btn" class="fab fab-secondary" aria-label="Scan image (OCR)" title="Scan image (OCR)">
<span class="material-symbols-outlined">photo_camera</span>
</button>
<button type="button" id="go" class="fab fab-primary" aria-label="Translate" title="Translate (⌘/Ctrl + Enter)">
<span class="material-symbols-outlined">translate</span>
</button>
<script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
import { vim, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
import {
EditorView, keymap, placeholder,
EditorState,
history, defaultKeymap, historyKeymap,
} from "/static/vendor/codemirror.js";
const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
@@ -244,19 +566,421 @@
const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
const scanBtn = document.getElementById("scan-btn");
const scanInput = document.getElementById("scan-input");
const scanStatus = document.getElementById("scan-status");
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
// --- OCR via Tesseract.js (vendored; lazy-loaded on first use) ----------
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
const TESS_LANGS = {
"English": "eng",
"Portuguese (Brazil)": "por",
"Portuguese (Portugal)": "por",
"Spanish": "spa",
"French": "fra",
"German": "deu",
"Italian": "ita",
"Dutch": "nld",
"Polish": "pol",
"Russian": "rus",
"Japanese": "jpn",
"Korean": "kor",
"Chinese (Simplified)": "chi_sim",
"Chinese (Traditional)": "chi_tra",
"Arabic": "ara",
"Hindi": "hin",
"Turkish": "tur",
"Swedish": "swe",
"Norwegian": "nor",
"Danish": "dan",
"Finnish": "fin",
"Greek": "ell",
"Hebrew": "heb",
"Czech": "ces",
"Hungarian": "hun",
"Romanian": "ron",
"Ukrainian": "ukr",
"Vietnamese": "vie",
"Thai": "tha",
"Indonesian": "ind",
};
// "Auto-detect" — load common European set together; tesseract picks best.
const TESS_AUTO = "eng+por+deu+nld";
function setScanStatus(text, { error = false } = {}) {
scanStatus.textContent = text;
scanStatus.classList.toggle("error", error);
}
// Crop + rotate modal. Lazy-loads the vendored cropperjs + its CSS on
// first use. Resolves with a Blob (cropped, rotation baked in) or null
// if the user cancelled.
let cropperCssLoaded = false;
async function openCropper(file) {
if (!cropperCssLoaded) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/static/vendor/cropper.min.css";
document.head.appendChild(link);
cropperCssLoaded = true;
}
const { default: Cropper } = await import("/static/vendor/cropper.esm.js");
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
const modal = document.createElement("div");
modal.className = "modal";
const url = URL.createObjectURL(file);
modal.innerHTML = `
<div class="modal-header">Drag on the image to select the text region</div>
<div class="modal-body"><img alt="" /></div>
<div class="modal-footer">
<div class="group">
<button class="modal-btn icon" data-act="rotL" title="Rotate left"><span class="material-symbols-outlined">rotate_left</span></button>
<button class="modal-btn icon" data-act="rotR" title="Rotate right"><span class="material-symbols-outlined">rotate_right</span></button>
<button class="modal-btn icon" data-act="invert" title="Invert colours (for light text on dark background)"><span class="material-symbols-outlined">invert_colors</span></button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary icon" data-act="use" title="Use this"><span class="material-symbols-outlined">check</span></button>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const img = modal.querySelector("img");
img.src = url;
const cropper = new Cropper(img, {
viewMode: 1,
// No default crop box — user draws their own by dragging on the image.
autoCrop: false,
background: false,
// Honour EXIF orientation from the phone shot.
checkOrientation: true,
});
let invert = false;
const cleanup = () => {
cropper.destroy();
URL.revokeObjectURL(url);
backdrop.remove();
document.removeEventListener("keydown", onKey);
};
const finish = (result) => { cleanup(); resolve(result); };
const onKey = (e) => {
if (e.key === "Escape") finish(null);
else if (e.key === "Enter") emit("use");
};
document.addEventListener("keydown", onKey);
function emit(act, btn) {
if (act === "rotL") cropper.rotate(-90);
else if (act === "rotR") cropper.rotate(90);
else if (act === "invert") {
invert = !invert;
btn?.classList.toggle("active", invert);
}
else if (act === "cancel") finish(null);
else if (act === "use") {
// With autoCrop:false, if the user never drew a region, force a
// default crop covering the full image so getCroppedCanvas() works.
if (!cropper.cropped) cropper.crop();
const canvas = cropper.getCroppedCanvas({
maxWidth: OCR_MAX_DIM * 2,
maxHeight: OCR_MAX_DIM * 2,
});
if (canvas) canvas.toBlob(blob => finish({ blob, invert }), "image/png");
else finish(null);
}
}
modal.addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
if (btn) emit(btn.dataset.act, btn);
});
// Click on backdrop (outside the modal) = cancel
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) finish(null);
});
});
}
// Downscale to at most OCR_MAX_DIM on the long edge AND binarise with
// Bradley's adaptive threshold. Phone photos have gradients, shadows
// and texture that Tesseract trained on scanned pages doesn't expect,
// so it hallucinates text. Adaptive thresholding turns the input into
// a binary "ink vs paper" mask which is what the recogniser wants.
const OCR_MAX_DIM = 1600;
const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100)
async function preprocessForOCR(file, { invert = false } = {}) {
const bitmap = await createImageBitmap(file);
const long = Math.max(bitmap.width, bitmap.height);
const scale = Math.min(1, OCR_MAX_DIM / long);
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close?.();
binarizeAdaptive(ctx, w, h, { invert });
// PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges.
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
return { blob, w, h, scaled: scale < 1, origLong: long };
}
// Bradley/Roth adaptive threshold: compare each pixel against the
// mean of a local window. Computed in O(N) total via an integral
// image. Output is a 3-channel B/W image (R=G=B=0 or 255).
// invert=true flips the grayscale before thresholding so white-on-dark
// text comes out as the expected black-on-white for Tesseract.
function binarizeAdaptive(ctx, w, h, { invert = false } = {}) {
const imgData = ctx.getImageData(0, 0, w, h);
const data = imgData.data;
// 1. Grayscale via BT.601 luminance
const gray = new Uint8Array(w * h);
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0;
}
if (invert) {
for (let i = 0; i < gray.length; i++) gray[i] = 255 - gray[i];
}
// 2. Integral image (summed-area table)
const integral = new Float64Array(w * h);
for (let y = 0; y < h; y++) {
let rowSum = 0;
const yw = y * w;
for (let x = 0; x < w; x++) {
rowSum += gray[yw + x];
integral[yw + x] = rowSum + (y > 0 ? integral[yw - w + x] : 0);
}
}
// 3. Threshold: pixel becomes black if it's > T% below the local mean
const winSize = Math.max(15, Math.floor(w / 32)) | 1; // odd window
const halfWin = (winSize - 1) >> 1;
const T = 0.15;
for (let y = 0; y < h; y++) {
const yw = y * w;
const y1 = Math.max(0, y - halfWin);
const y2 = Math.min(h - 1, y + halfWin);
for (let x = 0; x < w; x++) {
const x1 = Math.max(0, x - halfWin);
const x2 = Math.min(w - 1, x + halfWin);
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
const A = (x1 > 0 && y1 > 0) ? integral[(y1 - 1) * w + (x1 - 1)] : 0;
const B = (y1 > 0) ? integral[(y1 - 1) * w + x2] : 0;
const C = (x1 > 0) ? integral[y2 * w + (x1 - 1)] : 0;
const D = integral[y2 * w + x2];
const mean = (D - B - C + A) / count;
const black = gray[yw + x] < mean * (1 - T) ? 0 : 255;
const idx = (yw + x) * 4;
data[idx] = data[idx + 1] = data[idx + 2] = black;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Drop words below confidence threshold; re-assemble preserving line breaks.
function filterByConfidence(result, threshold = OCR_CONFIDENCE_MIN) {
const lines = result.lines || [];
if (!lines.length) return result.text || "";
const keptLines = lines.map(line => {
const words = (line.words || [])
.filter(w => (w.confidence ?? 100) >= threshold)
.map(w => w.text);
return words.length ? words.join(" ") : "";
});
// Collapse consecutive blank lines so dropped runs don't leave gaps.
return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
async function runOCR(file) {
const src = sourceSel.value;
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
scanBtn.disabled = true;
setScanStatus("Loading crop editor…");
let cropResult;
try {
cropResult = await openCropper(file);
} catch (e) {
console.error(e);
setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true });
scanBtn.disabled = false;
scanInput.value = "";
return;
}
if (!cropResult) {
// User cancelled
setScanStatus("");
scanBtn.disabled = false;
scanInput.value = "";
return;
}
const { blob: cropped, invert } = cropResult;
setScanStatus("Preparing image…");
const t0 = performance.now();
try {
const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped, { invert });
if (scaled) {
setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`);
} else {
setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`);
}
// Lazy import — the vendored engine, wasm core, and language pack are
// only fetched from the container on first scan, then browser-cached.
// Everything is served locally (workerPath/corePath/langPath) so OCR
// never depends on a CDN. Only the Dutch pack (nld) is bundled.
const { createWorker } = await import("/static/vendor/tesseract/tesseract.esm.min.js");
const worker = await createWorker(lang, 1, {
workerPath: "/static/vendor/tesseract/worker.min.js",
corePath: "/static/vendor/tesseract",
langPath: "/static/vendor/tesseract/lang",
logger: (m) => {
if (m.status === "recognizing text") {
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
} else if (m.status) {
setScanStatus(`OCR: ${m.status}`);
}
},
});
const { data } = await worker.recognize(blob);
await worker.terminate();
const text = filterByConfidence(data);
const droppedWords = (data.words || []).filter(w => (w.confidence ?? 100) < OCR_CONFIDENCE_MIN).length;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: text },
selection: { anchor: 0 },
});
const dt = ((performance.now() - t0) / 1000).toFixed(1);
const dropped = droppedWords ? `, dropped ${droppedWords} low-confidence` : "";
setScanStatus(`OCR done in ${dt}s (${lang}${dropped})`);
} catch (e) {
console.error(e);
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });
} finally {
scanBtn.disabled = false;
scanInput.value = ""; // allow re-selecting the same file
}
}
// Live camera capture via getUserMedia — bypasses the OS camera's
// "retake / use" confirm step that <input type=file capture> shows.
// Tap the shutter → frame is captured and crop modal opens immediately.
// Returns the captured Blob, "file" if the user wants the file picker,
// or null on cancel.
async function captureFromCamera() {
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false,
});
} catch (e) {
console.warn("Camera unavailable:", e);
return "file"; // fall through to file picker
}
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
const modal = document.createElement("div");
modal.className = "modal";
modal.innerHTML = `
<div class="modal-header">Aim at the text and tap the shutter</div>
<div class="modal-body cam-body"><video autoplay playsinline muted></video></div>
<div class="modal-footer">
<div class="group">
<button class="modal-btn icon" data-act="file" title="Pick from file instead"><span class="material-symbols-outlined">photo_library</span></button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary icon" data-act="shoot" title="Capture"><span class="material-symbols-outlined">photo_camera</span></button>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const video = modal.querySelector("video");
video.srcObject = stream;
const cleanup = () => {
stream.getTracks().forEach(t => t.stop());
video.srcObject = null;
backdrop.remove();
document.removeEventListener("keydown", onKey);
};
const finish = (r) => { cleanup(); resolve(r); };
const onKey = (e) => {
if (e.key === "Escape") finish(null);
};
document.addEventListener("keydown", onKey);
modal.addEventListener("click", (e) => {
const act = e.target.closest("[data-act]")?.dataset.act;
if (act === "cancel") finish(null);
else if (act === "file") finish("file");
else if (act === "shoot") {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
canvas.toBlob(blob => finish(blob), "image/jpeg", 0.92);
}
});
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) finish(null);
});
});
}
async function startScan() {
// On a touch device with getUserMedia, go straight to the live
// viewfinder. On desktop / no-camera, use the file picker.
if (isCoarsePointer && navigator.mediaDevices?.getUserMedia) {
const result = await captureFromCamera();
if (result === "file") {
scanInput.click();
} else if (result) {
runOCR(result);
}
return;
}
scanInput.click();
}
scanBtn.addEventListener("click", startScan);
scanInput.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (file) runOCR(file);
});
// --- CodeMirror editor --------------------------------------------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
@@ -289,7 +1013,7 @@
o2.value = lang; o2.textContent = lang;
targetSel.appendChild(o2);
}
sourceSel.value = "auto";
sourceSel.value = "Dutch";
targetSel.value = "English";
}
@@ -338,7 +1062,7 @@
const text = inputText().trim();
if (!text) return;
goBtn.disabled = true;
goBtn.textContent = "Translating…";
goBtn.classList.add("busy");
output.textContent = "";
output.classList.add("streaming");
@@ -410,7 +1134,7 @@
} finally {
output.classList.remove("streaming");
goBtn.disabled = false;
goBtn.textContent = "Translate";
goBtn.classList.remove("busy");
}
}
@@ -418,6 +1142,10 @@
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (!isFinite(ms) || ms <= 0) return null;
// Ollama's keep_alive=-1 returns expires_at far in the future (year 4001).
// Any realistic finite keep_alive is hours at most, so treat >7 days
// as "never unloads" and drop the misleading suffix.
if (ms > 7 * 24 * 60 * 60 * 1000) return null;
const totalSec = Math.floor(ms / 1000);
const mins = Math.floor(totalSec / 60);
const secs = totalSec % 60;
@@ -480,10 +1208,8 @@
goBtn.addEventListener("click", translate);
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
// Tab). Two paths kept in sync:
// 1. Document keydown listener — fires when focus is on a select / button
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
// Together they make Shift-H/L work everywhere, including escaping the editor.
// Tab), for focus on a select / button. Inside the editor they type
// normally.
const focusables = [
{ el: sourceSel, focus: () => sourceSel.focus() },
{ el: targetSel, focus: () => targetSel.focus() },
@@ -505,22 +1231,33 @@
if (!e.shiftKey) return;
const k = e.key.toLowerCase();
if (k !== "h" && k !== "l") return;
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
// the event bubble here would double-jump.
// Inside the editor, let H/L type normally.
if (editor.dom.contains(document.activeElement)) return;
e.preventDefault();
moveFocus(k === "l" ? 1 : -1);
});
// Override vim's H/L (normally "viewport top/bottom") so they exit the
// editor to the previous/next control, matching the Shift-H/L contract
// the document handler uses elsewhere.
if (!isCoarsePointer) {
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
Vim.defineAction("finrodFocusNext", () => moveFocus(1));
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
// Side drawer (Settings) wiring — opens from the top-left hamburger.
const drawerEl = document.getElementById("drawer");
const drawerBackdrop = document.getElementById("drawer-backdrop");
const drawerToggle = document.getElementById("drawer-toggle");
const drawerCloseBtn = document.getElementById("drawer-close");
function openDrawer() {
drawerEl.classList.add("open");
drawerBackdrop.classList.add("open");
drawerEl.setAttribute("aria-hidden", "false");
}
function closeDrawer() {
drawerEl.classList.remove("open");
drawerBackdrop.classList.remove("open");
drawerEl.setAttribute("aria-hidden", "true");
}
drawerToggle.addEventListener("click", openDrawer);
drawerCloseBtn.addEventListener("click", closeDrawer);
drawerBackdrop.addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && drawerEl.classList.contains("open")) closeDrawer();
});
fillLangs();
loadModels().then(() => refreshLoaded(true));