OCR β PII detection β canvas editor for redacting Swedish screenshots before sharing. Two build targets from one codebase:
gr.Server backend, ZeroGPU on HF Space): PaddleOCR 3.5 β GLiNER2-PII β JSON.Stack: SvelteKit 2 Β· Svelte 5 (runes) Β· Tailwind 4 Β· shadcn-svelte Β· Bun Β· Tauri 2 Β· Biome.
.
βββ README.md
βββ Dockerfile # showcase: Python + static SPA, EXPOSE 7860
βββ Makefile # `make install`, `make showcase-backend`, `make tauri-dev`, β¦
βββ example-images/ # (you create) drop test screenshots here for the showcase landing page
βββ backend/ # Python β showcase target only
β βββ pyproject.toml # uv project (name = "ranymizer")
β βββ .python-version # 3.11
β βββ requirements.txt # fallback for hosts that need pip
β βββ app.py # PaddleOCR + GLiNER2 pipeline
β βββ server.py # gr.Server: API routes + SvelteKit static mount
β βββ README.md # uv setup + run instructions
βββ frontend/ # SvelteKit 2 SPA β both targets
βββ package.json # bun-managed
βββ biome.json # formatter + linter (.ts / .json only β Svelte handled by svelte-check)
βββ svelte.config.js # adapter-static (right for BOTH targets)
βββ vite.config.ts # engine selected by VITE_ENGINE at build time
βββ src-tauri/ # Tauri 2.0 shell (locked-down CSP, core:default capability only)
βββ src/
βββ app.html / app.css
βββ routes/ # +layout.ts has ssr=false + prerender=true (SPA)
βββ lib/
βββ state.svelte.ts # EditorState (depends on engine, not on Gradio)
βββ api.ts # @gradio/client wrapper (showcase only)
βββ utils.ts # cn() + shadcn-svelte type helpers
βββ types.ts
βββ engine/ # β the seam between UI and inference
β βββ types.ts # AnonymizerEngine interface
β βββ index.ts # picks engine from import.meta.env.VITE_ENGINE
β βββ gradio.ts # showcase: wraps @gradio/client
β βββ local.ts # desktop: drives the worker
β βββ worker.ts # transformers.js OCR + PII (ES module worker)
β βββ webgpu.ts # auto β webgpu β wasm backend detection
β βββ models.ts # model registry + offline category meta
βββ components/
βββ ui/ # shadcn-svelte primitives (button, card, toggle-group, badge, separator, toggle)
βββ Landing.svelte / Editor.svelte / Canvas.svelte / Sidebar.svelte / Loading.svelte
make install # installs bun + rust + uv if missing, then frontend deps + backend venv
Manual install:
# bun (frontend), rust (Tauri), uv (backend Python)
curl -fsSL https://bun.sh/install | bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
curl -LsSf https://astral.sh/uv/install.sh | sh
cd frontend && bun install
cd backend && uv sync
The Makefile has every target β make (with no args) lists them. The
common ones:
# ββ Showcase (Python backend + SPA over network) ββββββββββββββ
make showcase-backend # Python gr.Server on :7860
make showcase-frontend # SvelteKit dev on :5173 (Gradio engine)
# ββ Desktop (Tauri 2.0, fully local, no Python) ββββββββββββββ
make tauri-dev # opens a native window, hot-reload
make tauri-build # release bundle (.dmg/.app/.deb/.msi)
# ββ Local engine in a browser (no Python, no native shell) βββ
make local-dev # http://localhost:5173 with VITE_ENGINE=local
make local-build # static build with VITE_ENGINE=local
# ββ Showcase in one Docker image βββββββββββββββββββββββββββββ
make docker-build && make docker-run # http://localhost:7860
CORS is open on the Python side for :5173 so the dev frontend can hit the
backend on :7860. Edits to .svelte / .ts hot-reload. Edits to app.py
or server.py require a Python restart.
Same pattern as the BiRefNet demo: FileData in, JSON out. The source
image is never round-tripped through the server β the SvelteKit client
already has the File, so state.upload(file) calls
URL.createObjectURL(file) and feeds it straight to <img>. The Python
side returns only {filename, width, height, boxes, text, spans}.
/api/meta is a separate plain GET for the static category colors/labels;
it's cached in-memory on the client.
βββββ File (kept on client) βββββββββββββββΊ <img src=blob:β¦>
β (canvas + redaction)
β
β ββββ @gradio/client.predict("/anonymize_screenshot")
File βββββββββ€ FileData (multipart)
β βββββββββββββββββββββββββΊ PaddleOCR βΊ GLiNER2
β βββββββββββββββ
βββ JSON: {boxes, spans, text, width, height}
The carefully tuned colors from the original Ranymizer are
preserved as CSS variables in :root and mapped to shadcn's semantic
names via @theme inline in app.css:
| original token | shadcn name (Tailwind class) |
|---|---|
--bg | --background (bg-background) |
--surface | --card (bg-card) |
--surface2 | --muted, --secondary |
--text | --foreground |
--text2 | --muted-foreground |
--accent | --primary (bg-primary) |
--accent-dim | --accent (the shadcn one) |
--border-c | --border |
--danger | --destructive |
Fonts (Inter, Lora, ui-monospace) are bound to --font-sans,
--font-serif, --font-mono and exposed as font-sans, font-serif,
font-mono Tailwind utilities.
Both dark (default) and the original light-mode media query are wired
up; toggle via mode-watcher if you want a manual switch.
| var | default | meaning |
|---|---|---|
USE_GPU | 1 | flip to 0 to force CPU |
GLINER_MODEL | fastino/gliner2-privacy-filter-PII-multi | override to test other GLiNER2 checkpoints |
GLINER_COMPILE | 1 | torch.compile on the GLiNER2 encoder (GPU only) |
INFERENCE_DTYPE | float32 | passed to PaddleOCR's engine_config.dtype |
LOAD_MODELS_ON_STARTUP | 1 | preload both models so the first request is fast |
Personnummer / orgnr recall will be soft out of the box. The
GLiNER2-PII encoder was trained on English + Dutch / German /
French / Italian / Spanish / Portuguese, never on Swedish national-ID
formats. Description hints in PII_LABELS help but don't replace
fine-tuning. Run an eval on real docs and expect to LoRA-tune.
PaddleOCR returns line polygons, not per-word boxes. Each PII span is mapped to the OCR line(s) it overlaps; single-line spans are narrowed by character ratio within the line.
Dependency interaction (Python side). PaddleOCR 3.5's
transformers backend pins transformers >= 5.4.0. GLiNER2 has its
own transformers requirement via gliner2[local]. If pip can't
resolve, install gliner2[local] first then paddleocr==3.5.0
last in a fresh venv.
This is not two apps. The redaction editor (state.svelte.ts +
components) depends only on an AnonymizerEngine interface
(src/lib/engine/). Two implementations, selected at build time by
VITE_ENGINE:
| Target | VITE_ENGINE | Inference | Privacy | Ships as |
|---|---|---|---|---|
| Showcase | gradio (default) | Python gr.Server (ZeroGPU) | image uploaded | HF Space (static SPA) |
| Desktop | local | transformers.js + ONNX Runtime Web + WebGPU, in a Web Worker | nothing leaves the device | Tauri 2.0 app |
src/lib/engine/
βββ types.ts # AnonymizerEngine interface β the only seam
βββ index.ts # picks engine from import.meta.env.VITE_ENGINE
βββ gradio.ts # showcase: wraps @gradio/client (api.ts)
βββ local.ts # desktop: drives the worker
βββ worker.ts # transformers.js OCR + PII (ES module worker)
βββ webgpu.ts # auto β webgpu β wasm backend detection
βββ models.ts # model registry + offline category meta
src-tauri/ # Tauri 2.0 shell (per the sveltekit-tauri skill)
# Showcase (unchanged): Python + Gradio engine
python server.py
cd frontend && bun run dev # http://localhost:5173
# Secure local desktop (Tauri 2.0, local engine)
cd frontend
bun install
bunx tauri icon path/to/logo.png # one-time: generate app icons
bun run tauri dev # hot-reloading desktop window
bun run tauri build # release bundle
bun run dev:local / bun run build:local build the SPA with the local
engine without the Tauri shell (browser testing).
capabilities/default.json grants core:default only
(no fs/shell/http/dialog).tauri.conf.json: connect-src is the only outbound
allowance and only reaches the HF Hub β for the first-run model
download, cached in the WebView thereafter. The image/text are never in
an outbound request. Bundle weights as resources + drop connect-src to
go fully air-gapped.createObjectURL); the showcase only uploads to ZeroGPU for inference.src/lib/engine/models.ts ships placeholder model ids. The Python
side is PaddleOCR (full-page OCR with line polygons) + GLiNER2
(label-conditioned PII). Neither runs in transformers.js as-is: in-browser
OCR that returns box geometry, and a GLiNER2-equivalent ONNX (Swedish
personnummer/orgnr), are unresolved β worker.ts wires the pipeline
correctly but box placement depends on this. Tracked alongside "Known
limits" above.
Tooling note: the existing project's Vite/Tailwind/shadcn setup was kept
as-is; only the Tauri parts of the local sveltekit-tauri skill were
applied (it otherwise bootstraps a greenfield rolldown/Biome stack).
48 commits
Python
51.1%
Svelte
29.5%
TypeScript
15.4%
CSS
1.8%
Rust
1.0%
OCR β PII detection β canvas editor for redacting Swedish screenshots before sharing. Two build targets from one codebase:
gr.Server backend, ZeroGPU on HF Space): PaddleOCR 3.5 β GLiNER2-PII β JSON.Stack: SvelteKit 2 Β· Svelte 5 (runes) Β· Tailwind 4 Β· shadcn-svelte Β· Bun Β· Tauri 2 Β· Biome.
.
βββ README.md
βββ Dockerfile # showcase: Python + static SPA, EXPOSE 7860
βββ Makefile # `make install`, `make showcase-backend`, `make tauri-dev`, β¦
βββ example-images/ # (you create) drop test screenshots here for the showcase landing page
βββ backend/ # Python β showcase target only
β βββ pyproject.toml # uv project (name = "ranymizer")
β βββ .python-version # 3.11
β βββ requirements.txt # fallback for hosts that need pip
β βββ app.py # PaddleOCR + GLiNER2 pipeline
β βββ server.py # gr.Server: API routes + SvelteKit static mount
β βββ README.md # uv setup + run instructions
βββ frontend/ # SvelteKit 2 SPA β both targets
βββ package.json # bun-managed
βββ biome.json # formatter + linter (.ts / .json only β Svelte handled by svelte-check)
βββ svelte.config.js # adapter-static (right for BOTH targets)
βββ vite.config.ts # engine selected by VITE_ENGINE at build time
βββ src-tauri/ # Tauri 2.0 shell (locked-down CSP, core:default capability only)
βββ src/
βββ app.html / app.css
βββ routes/ # +layout.ts has ssr=false + prerender=true (SPA)
βββ lib/
βββ state.svelte.ts # EditorState (depends on engine, not on Gradio)
βββ api.ts # @gradio/client wrapper (showcase only)
βββ utils.ts # cn() + shadcn-svelte type helpers
βββ types.ts
βββ engine/ # β the seam between UI and inference
β βββ types.ts # AnonymizerEngine interface
β βββ index.ts # picks engine from import.meta.env.VITE_ENGINE
β βββ gradio.ts # showcase: wraps @gradio/client
β βββ local.ts # desktop: drives the worker
β βββ worker.ts # transformers.js OCR + PII (ES module worker)
β βββ webgpu.ts # auto β webgpu β wasm backend detection
β βββ models.ts # model registry + offline category meta
βββ components/
βββ ui/ # shadcn-svelte primitives (button, card, toggle-group, badge, separator, toggle)
βββ Landing.svelte / Editor.svelte / Canvas.svelte / Sidebar.svelte / Loading.svelte
make install # installs bun + rust + uv if missing, then frontend deps + backend venv
Manual install:
# bun (frontend), rust (Tauri), uv (backend Python)
curl -fsSL https://bun.sh/install | bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
curl -LsSf https://astral.sh/uv/install.sh | sh
cd frontend && bun install
cd backend && uv sync
The Makefile has every target β make (with no args) lists them. The
common ones:
# ββ Showcase (Python backend + SPA over network) ββββββββββββββ
make showcase-backend # Python gr.Server on :7860
make showcase-frontend # SvelteKit dev on :5173 (Gradio engine)
# ββ Desktop (Tauri 2.0, fully local, no Python) ββββββββββββββ
make tauri-dev # opens a native window, hot-reload
make tauri-build # release bundle (.dmg/.app/.deb/.msi)
# ββ Local engine in a browser (no Python, no native shell) βββ
make local-dev # http://localhost:5173 with VITE_ENGINE=local
make local-build # static build with VITE_ENGINE=local
# ββ Showcase in one Docker image βββββββββββββββββββββββββββββ
make docker-build && make docker-run # http://localhost:7860
CORS is open on the Python side for :5173 so the dev frontend can hit the
backend on :7860. Edits to .svelte / .ts hot-reload. Edits to app.py
or server.py require a Python restart.
Same pattern as the BiRefNet demo: FileData in, JSON out. The source
image is never round-tripped through the server β the SvelteKit client
already has the File, so state.upload(file) calls
URL.createObjectURL(file) and feeds it straight to <img>. The Python
side returns only {filename, width, height, boxes, text, spans}.
/api/meta is a separate plain GET for the static category colors/labels;
it's cached in-memory on the client.
βββββ File (kept on client) βββββββββββββββΊ <img src=blob:β¦>
β (canvas + redaction)
β
β ββββ @gradio/client.predict("/anonymize_screenshot")
File βββββββββ€ FileData (multipart)
β βββββββββββββββββββββββββΊ PaddleOCR βΊ GLiNER2
β βββββββββββββββ
βββ JSON: {boxes, spans, text, width, height}
The carefully tuned colors from the original Ranymizer are
preserved as CSS variables in :root and mapped to shadcn's semantic
names via @theme inline in app.css:
| original token | shadcn name (Tailwind class) |
|---|---|
--bg | --background (bg-background) |
--surface | --card (bg-card) |
--surface2 | --muted, --secondary |
--text | --foreground |
--text2 | --muted-foreground |
--accent | --primary (bg-primary) |
--accent-dim | --accent (the shadcn one) |
--border-c | --border |
--danger | --destructive |
Fonts (Inter, Lora, ui-monospace) are bound to --font-sans,
--font-serif, --font-mono and exposed as font-sans, font-serif,
font-mono Tailwind utilities.
Both dark (default) and the original light-mode media query are wired
up; toggle via mode-watcher if you want a manual switch.
| var | default | meaning |
|---|---|---|
USE_GPU | 1 | flip to 0 to force CPU |
GLINER_MODEL | fastino/gliner2-privacy-filter-PII-multi | override to test other GLiNER2 checkpoints |
GLINER_COMPILE | 1 | torch.compile on the GLiNER2 encoder (GPU only) |
INFERENCE_DTYPE | float32 | passed to PaddleOCR's engine_config.dtype |
LOAD_MODELS_ON_STARTUP | 1 | preload both models so the first request is fast |
Personnummer / orgnr recall will be soft out of the box. The
GLiNER2-PII encoder was trained on English + Dutch / German /
French / Italian / Spanish / Portuguese, never on Swedish national-ID
formats. Description hints in PII_LABELS help but don't replace
fine-tuning. Run an eval on real docs and expect to LoRA-tune.
PaddleOCR returns line polygons, not per-word boxes. Each PII span is mapped to the OCR line(s) it overlaps; single-line spans are narrowed by character ratio within the line.
Dependency interaction (Python side). PaddleOCR 3.5's
transformers backend pins transformers >= 5.4.0. GLiNER2 has its
own transformers requirement via gliner2[local]. If pip can't
resolve, install gliner2[local] first then paddleocr==3.5.0
last in a fresh venv.
This is not two apps. The redaction editor (state.svelte.ts +
components) depends only on an AnonymizerEngine interface
(src/lib/engine/). Two implementations, selected at build time by
VITE_ENGINE:
| Target | VITE_ENGINE | Inference | Privacy | Ships as |
|---|---|---|---|---|
| Showcase | gradio (default) | Python gr.Server (ZeroGPU) | image uploaded | HF Space (static SPA) |
| Desktop | local | transformers.js + ONNX Runtime Web + WebGPU, in a Web Worker | nothing leaves the device | Tauri 2.0 app |
src/lib/engine/
βββ types.ts # AnonymizerEngine interface β the only seam
βββ index.ts # picks engine from import.meta.env.VITE_ENGINE
βββ gradio.ts # showcase: wraps @gradio/client (api.ts)
βββ local.ts # desktop: drives the worker
βββ worker.ts # transformers.js OCR + PII (ES module worker)
βββ webgpu.ts # auto β webgpu β wasm backend detection
βββ models.ts # model registry + offline category meta
src-tauri/ # Tauri 2.0 shell (per the sveltekit-tauri skill)
# Showcase (unchanged): Python + Gradio engine
python server.py
cd frontend && bun run dev # http://localhost:5173
# Secure local desktop (Tauri 2.0, local engine)
cd frontend
bun install
bunx tauri icon path/to/logo.png # one-time: generate app icons
bun run tauri dev # hot-reloading desktop window
bun run tauri build # release bundle
bun run dev:local / bun run build:local build the SPA with the local
engine without the Tauri shell (browser testing).
capabilities/default.json grants core:default only
(no fs/shell/http/dialog).tauri.conf.json: connect-src is the only outbound
allowance and only reaches the HF Hub β for the first-run model
download, cached in the WebView thereafter. The image/text are never in
an outbound request. Bundle weights as resources + drop connect-src to
go fully air-gapped.createObjectURL); the showcase only uploads to ZeroGPU for inference.src/lib/engine/models.ts ships placeholder model ids. The Python
side is PaddleOCR (full-page OCR with line polygons) + GLiNER2
(label-conditioned PII). Neither runs in transformers.js as-is: in-browser
OCR that returns box geometry, and a GLiNER2-equivalent ONNX (Swedish
personnummer/orgnr), are unresolved β worker.ts wires the pipeline
correctly but box placement depends on this. Tracked alongside "Known
limits" above.
Tooling note: the existing project's Vite/Tailwind/shadcn setup was kept
as-is; only the Tauri parts of the local sveltekit-tauri skill were
applied (it otherwise bootstraps a greenfield rolldown/Biome stack).
48 commits
Python
51.1%
Svelte
29.5%
TypeScript
15.4%
CSS
1.8%
Rust
1.0%