Spaces:
Running on Zero
Running on Zero
| """ | |
| ========================================== | |
| Screenshot Anonymizer β v2 (tool revision) | |
| ========================================== | |
| """ | |
| import base64 | |
| import functools | |
| import io | |
| import traceback | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response | |
| from gradio.data_classes import FileData | |
| from PIL import Image | |
| from app import ( | |
| CATEGORIES_META, | |
| map_spans_to_boxes, | |
| ocr_image, | |
| run_pii_analysis, | |
| ) | |
| EXAMPLES_DIR = Path(__file__).parent / "example-images" | |
| EXAMPLE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} | |
| def _list_examples() -> list[str]: | |
| if not EXAMPLES_DIR.is_dir(): | |
| return [] | |
| return sorted(p.name for p in EXAMPLES_DIR.iterdir() | |
| if p.is_file() and p.suffix.lower() in EXAMPLE_EXTS) | |
| def _example_thumbnail(name: str) -> bytes: | |
| """Downscaled preview for the landing grid. Cached after first hit.""" | |
| path = EXAMPLES_DIR / name | |
| img = Image.open(path).convert("RGB") | |
| img.thumbnail((480, 480)) | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=82, optimize=True) | |
| return buf.getvalue() | |
| # ===================================================================== | |
| # SERVER | |
| # ===================================================================== | |
| server = gr.Server() | |
| async def homepage(): | |
| return FRONTEND_HTML | |
| # The /api/examples and /examples/{name} routes below serve static | |
| # example thumbnails and originals from disk. They are plain FastAPI | |
| # routes because they do no GPU / queued compute β they just read | |
| # files, which is exactly the pattern the gradio.Server blog | |
| # recommends plain @server.get for. | |
| async def api_examples(): | |
| return JSONResponse({"examples": _list_examples()}) | |
| async def get_example(name: str, thumb: int = 0): | |
| safe = Path(name).name | |
| if Path(safe).suffix.lower() not in EXAMPLE_EXTS: | |
| return JSONResponse({"error": "invalid file type"}, 400) | |
| path = EXAMPLES_DIR / safe | |
| if not path.is_file(): | |
| return JSONResponse({"error": "not found"}, 404) | |
| if thumb: | |
| return Response( | |
| content=_example_thumbnail(safe), | |
| media_type="image/jpeg", | |
| headers={"Cache-Control": "public, max-age=86400"}, | |
| ) | |
| return FileResponse(path, headers={"Cache-Control": "public, max-age=3600"}) | |
| # The compute endpoint: goes through Gradio's queue, plays nicely with | |
| # @spaces.GPU on ZeroGPU, and is callable by both the browser via the | |
| # @gradio/client JS client AND by Python users via gradio_client. | |
| def anonymize_screenshot_api(image: FileData) -> dict: | |
| """OCR + PII-filter an uploaded image. | |
| Input: FileData from `handle_file(file)` (JS client) or | |
| `gradio_client.handle_file(path)` (Python client). | |
| Output: dict with the base-64 image, OCR text, detected spans, | |
| per-span pixel boxes, and the category color/label table. | |
| """ | |
| try: | |
| path = image.get("path") or image.get("url") or "" | |
| if not path: | |
| return {"error": "expected an image file"} | |
| img = Image.open(path).convert("RGB") | |
| ocr = ocr_image(img) | |
| if not ocr["text"].strip(): | |
| return {"error": "No text detected in the image."} | |
| source_text, spans = run_pii_analysis(ocr["text"]) | |
| if source_text != ocr["text"]: | |
| spans = [s for s in spans if s["end"] <= len(ocr["text"])] | |
| boxes = map_spans_to_boxes(ocr["words"], spans) | |
| buf = io.BytesIO(); img.save(buf, format="PNG") | |
| data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() | |
| return { | |
| "filename": Path(path).name, | |
| "image": data_url, | |
| "width": img.width, "height": img.height, | |
| "boxes": boxes, | |
| "text": ocr["text"], | |
| "spans": spans, | |
| "categories_meta": { | |
| k: {"color": v["color"], "label": v["label"]} | |
| for k, v in CATEGORIES_META.items() | |
| }, | |
| } | |
| except Exception as e: | |
| traceback.print_exc() | |
| return {"error": f"{type(e).__name__}: {e}"} | |
| # ===================================================================== | |
| # FRONTEND | |
| # ===================================================================== | |
| FRONTEND_HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Screenshot Anonymizer</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Lora:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet"> | |
| <style> | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| :root{ | |
| color-scheme:dark light; | |
| --bg:#0d0d0f; | |
| --surface:#131316; | |
| --surface2:#1a1a1e; | |
| --surface3:#222227; | |
| --border:rgba(255,255,255,.06); | |
| --border-strong:rgba(255,255,255,.14); | |
| --text:#e9e9ea; | |
| --text2:#9a9a9f; | |
| --text3:#65656c; | |
| --accent:#818cf8; | |
| --accent-dim:rgba(129,140,248,.12); | |
| --danger:#f87171; | |
| --mono:ui-monospace,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace; | |
| --serif:'Lora',Georgia,'Times New Roman',serif; | |
| --sans:'Inter',system-ui,-apple-system,sans-serif; | |
| } | |
| @media (prefers-color-scheme: light){ | |
| :root{ | |
| --bg:#fafaf8; | |
| --surface:#ffffff; | |
| --surface2:#f4f4f2; | |
| --surface3:#eeeeec; | |
| --border:rgba(0,0,0,.07); | |
| --border-strong:rgba(0,0,0,.16); | |
| --text:#1c1c1e; | |
| --text2:#515156; | |
| --text3:#86868b; | |
| --accent:#4f46e5; | |
| --accent-dim:rgba(79,70,229,.08); | |
| } | |
| } | |
| html,body{height:100%} | |
| body{ | |
| font-family:var(--sans);font-size:13.5px;line-height:1.5; | |
| color:var(--text);background:var(--bg); | |
| font-feature-settings:'ss01','cv11'; | |
| -webkit-font-smoothing:antialiased; | |
| text-rendering:optimizeLegibility; | |
| overflow:hidden; | |
| } | |
| button{font:inherit;color:inherit;background:none;border:none;padding:0;cursor:pointer} | |
| svg{display:block;flex-shrink:0} | |
| /* ββ shared chrome βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .app-bar{ | |
| display:flex;align-items:center;gap:.9rem; | |
| padding:.55rem 1rem; | |
| border-bottom:.5px solid var(--border); | |
| background:var(--bg); | |
| flex-shrink:0; | |
| height:40px; | |
| } | |
| .brand{display:flex;align-items:center;gap:.55rem} | |
| .brand-icon{color:var(--text);width:18px;height:18px} | |
| .wordmark{ | |
| font-family:var(--sans);font-weight:500;font-size:13.5px; | |
| color:var(--text);letter-spacing:-.005em; | |
| } | |
| .version{ | |
| font-family:var(--mono);font-size:11px;color:var(--text3); | |
| padding:1px 6px;border:.5px solid var(--border);border-radius:3px; | |
| margin-left:.25rem; | |
| } | |
| .app-bar .spacer{flex:1} | |
| .kbd-hints{font-family:var(--mono);font-size:11px;color:var(--text3)} | |
| .kbd-hints kbd{ | |
| font-family:var(--mono);color:var(--text2); | |
| padding:1px 4px;border:.5px solid var(--border);border-radius:3px; | |
| background:var(--surface);font-size:10.5px; | |
| } | |
| .btn-link{ | |
| font-family:var(--sans);font-size:12.5px;font-weight:500; | |
| color:var(--text2);padding:.3rem .55rem; | |
| border:.5px solid var(--border);border-radius:5px; | |
| transition:color .12s, border-color .12s, background .12s; | |
| } | |
| .btn-link:hover{color:var(--text);border-color:var(--border-strong);background:var(--surface2)} | |
| /* ββ landing view ββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #landing{display:flex;flex-direction:column;height:100vh;overflow-y:auto} | |
| .landing-content{ | |
| max-width:720px;width:100%; | |
| margin:0 auto;padding:3.5rem 1.5rem 2rem; | |
| display:flex;flex-direction:column;gap:1.5rem; | |
| } | |
| .headline{ | |
| font-family:var(--serif);font-weight:400; | |
| font-size:30px;line-height:1.15;letter-spacing:-.01em; | |
| color:var(--text); | |
| } | |
| .subtitle{ | |
| font-family:var(--sans);font-size:14px;color:var(--text2); | |
| max-width:52ch; | |
| } | |
| .dropzone{ | |
| position:relative; | |
| border:.5px dashed var(--border-strong); | |
| border-radius:8px; | |
| background:var(--surface); | |
| aspect-ratio:3/1;min-height:140px; | |
| display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem; | |
| cursor:pointer;transition:border-color .15s, background .15s; | |
| } | |
| .dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-dim)} | |
| .dropzone input{position:absolute;inset:0;opacity:0;cursor:pointer} | |
| .dz-icon{color:var(--text2);margin-bottom:.1rem} | |
| .dz-text{font-size:13.5px;color:var(--text);font-weight:500} | |
| .dz-hint{font-family:var(--mono);font-size:11px;color:var(--text3)} | |
| /* example strip */ | |
| .example-wrap{ | |
| border:.5px solid var(--border);border-radius:8px; | |
| background:var(--surface);overflow:hidden; | |
| } | |
| .example-head{ | |
| display:flex;align-items:center;justify-content:space-between;gap:.75rem; | |
| padding:.5rem .8rem;border-bottom:.5px solid var(--border); | |
| font-family:var(--mono);font-size:10.5px;color:var(--text3); | |
| } | |
| .example-head .title{color:var(--text2)} | |
| .example-head .note{color:var(--text3)} | |
| .example-scroll{ | |
| display:flex;gap:8px;padding:10px;overflow-x:auto; | |
| scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent; | |
| } | |
| .example-scroll::-webkit-scrollbar{height:6px} | |
| .example-scroll::-webkit-scrollbar-track{background:transparent} | |
| .example-scroll::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:3px} | |
| .ex-thumb{ | |
| flex-shrink:0;width:108px;height:108px; | |
| border:.5px solid var(--border);border-radius:5px;overflow:hidden; | |
| background:var(--surface2);cursor:pointer;position:relative; | |
| transition:border-color .12s, transform .12s; | |
| } | |
| .ex-thumb:hover{border-color:var(--accent);transform:translateY(-1px)} | |
| .ex-thumb img{width:100%;height:100%;object-fit:cover;display:block} | |
| .ex-thumb .ex-idx{ | |
| position:absolute;top:4px;left:5px; | |
| font-family:var(--mono);font-size:9.5px;color:#fff; | |
| background:rgba(0,0,0,.55);padding:1px 4px;border-radius:2px; | |
| letter-spacing:.02em; | |
| } | |
| .ex-empty{ | |
| padding:1rem;font-family:var(--mono);font-size:11px;color:var(--text3); | |
| } | |
| .landing-foot{ | |
| max-width:720px;margin:.5rem auto 2rem;padding:0 1.5rem; | |
| font-family:var(--mono);font-size:10.5px;color:var(--text3);line-height:1.7; | |
| } | |
| .landing-foot a{color:var(--text2);text-decoration:none;border-bottom:.5px dotted var(--text3)} | |
| .landing-foot a:hover{color:var(--text)} | |
| /* ββ editor view βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #editor{display:none;flex-direction:column;height:100vh} | |
| .editor-body{flex:1;display:flex;min-height:0} | |
| .canvas-area{ | |
| flex:1;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0; | |
| background:var(--bg); | |
| } | |
| .canvas-scroll{ | |
| flex:1;overflow:auto;position:relative; | |
| background: | |
| linear-gradient(45deg,var(--surface2) 25%,transparent 25%), | |
| linear-gradient(-45deg,var(--surface2) 25%,transparent 25%), | |
| linear-gradient(45deg,transparent 75%,var(--surface2) 75%), | |
| linear-gradient(-45deg,transparent 75%,var(--surface2) 75%); | |
| background-color:var(--bg); | |
| background-size:16px 16px; | |
| background-position:0 0,0 8px,8px -8px,8px 0; | |
| } | |
| .canvas-inner{ | |
| position:relative;padding:28px 24px 24px 36px; | |
| display:flex;align-items:flex-start;justify-content:flex-start; | |
| min-width:100%;min-height:100%; | |
| } | |
| .canvas-wrap{position:relative;cursor:crosshair;flex-shrink:0} | |
| .canvas-wrap.mode-select{cursor:default} | |
| .canvas-wrap.mode-select.over-box{cursor:move} | |
| .canvas-wrap.mode-select.dragging{cursor:grabbing} | |
| .canvas-wrap canvas{display:block} | |
| /* rulers */ | |
| .ruler-top,.ruler-left{position:absolute;background:var(--surface);pointer-events:none} | |
| .ruler-top{top:4px;left:36px;height:20px} | |
| .ruler-left{left:4px;top:28px;width:24px} | |
| .ruler-corner{ | |
| position:absolute;top:4px;left:4px; | |
| width:24px;height:20px;background:var(--surface); | |
| border-right:.5px solid var(--border);border-bottom:.5px solid var(--border); | |
| } | |
| /* status bar */ | |
| .status-bar{ | |
| display:flex;align-items:center;gap:.75rem; | |
| padding:.35rem .75rem;flex-shrink:0; | |
| border-top:.5px solid var(--border); | |
| font-family:var(--mono);font-size:11px;color:var(--text3); | |
| background:var(--surface); | |
| height:26px; | |
| } | |
| .status-bar .sep{color:var(--text3);opacity:.4} | |
| .status-bar .k{color:var(--text3)} | |
| .status-bar .v{color:var(--text2)} | |
| .status-bar .spacer{flex:1} | |
| /* zoom toolbar */ | |
| .zoom-tools{ | |
| position:absolute;bottom:38px;right:14px;z-index:5; | |
| display:flex;align-items:center;gap:0; | |
| background:var(--surface);border:.5px solid var(--border);border-radius:6px; | |
| padding:2px; | |
| } | |
| .zoom-tools button, .zoom-tools .zoom-pct{ | |
| font-family:var(--mono);font-size:11px;color:var(--text2); | |
| padding:4px 8px;border-radius:4px;min-width:22px;text-align:center; | |
| } | |
| .zoom-tools button:hover{background:var(--surface2);color:var(--text)} | |
| .zoom-tools .zoom-pct{color:var(--text)} | |
| .zoom-tools .sep{width:.5px;background:var(--border);align-self:stretch;margin:2px 0} | |
| /* ββ right panel βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .panel{ | |
| width:272px;flex-shrink:0; | |
| background:var(--surface);border-left:.5px solid var(--border); | |
| overflow-y:auto;display:flex;flex-direction:column; | |
| } | |
| .p-section{padding:.85rem 1rem;border-bottom:.5px solid var(--border)} | |
| .p-section:last-child{border-bottom:none} | |
| .p-label{ | |
| font-family:var(--sans);font-size:10.5px;font-weight:500; | |
| color:var(--text3);text-transform:uppercase;letter-spacing:.08em; | |
| margin-bottom:.55rem; | |
| } | |
| /* tool row */ | |
| .tools{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin-bottom:.7rem} | |
| .tool{ | |
| display:flex;align-items:center;gap:.55rem; | |
| padding:10px 11px; | |
| border:.5px solid var(--border);border-radius:6px; | |
| color:var(--text2); | |
| transition:color .12s, background .12s, border-color .12s; | |
| } | |
| .tool:hover{color:var(--text);border-color:var(--border-strong)} | |
| .tool.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)} | |
| .tool svg{width:18px;height:18px} | |
| .tool .name{ | |
| flex:1;text-align:left; | |
| font-family:var(--sans);font-size:13px;font-weight:500;letter-spacing:-.005em; | |
| } | |
| .tool .sc{ | |
| font-family:var(--mono);font-size:10.5px;color:var(--text3); | |
| padding:1px 5px;border:.5px solid var(--border);border-radius:3px; | |
| background:var(--bg); | |
| } | |
| .tool.active .sc{color:var(--accent);border-color:var(--accent);background:transparent} | |
| /* contextual tool options */ | |
| .tool-opts{ | |
| font-size:12px;color:var(--text2);line-height:1.5; | |
| padding:.5rem .65rem;background:var(--surface2);border-radius:5px; | |
| border:.5px solid var(--border); | |
| } | |
| .tool-opts .hint{display:flex;align-items:flex-start;gap:.4rem;font-family:var(--sans)} | |
| .tool-opts .hint svg{color:var(--text3);margin-top:1px} | |
| .tool-opts .kb{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.45rem} | |
| .tool-opts kbd{ | |
| font-family:var(--mono);font-size:10px;color:var(--text2); | |
| padding:1px 4px;border:.5px solid var(--border);border-radius:3px; | |
| background:var(--bg); | |
| } | |
| .tool-opts .sep{color:var(--text3);margin:0 .25rem} | |
| /* detected summary */ | |
| .summary{ | |
| font-family:var(--mono);font-size:11.5px;color:var(--text2); | |
| margin-bottom:.5rem; | |
| } | |
| .summary .num{color:var(--text)} | |
| .dist-bar{ | |
| height:4px;background:var(--surface2);border-radius:2px;overflow:hidden; | |
| display:flex; | |
| } | |
| .dist-seg{height:100%;transition:width .4s ease} | |
| /* category pills */ | |
| .pills{display:flex;flex-direction:column;gap:2px} | |
| .pill{ | |
| display:flex;align-items:center;gap:.55rem; | |
| padding:.4rem .5rem; | |
| border:.5px solid transparent;border-radius:5px; | |
| cursor:pointer;user-select:none; | |
| transition:background .1s, border-color .1s; | |
| } | |
| .pill:hover{background:var(--surface2)} | |
| .pill.active{background:var(--surface2);border-color:var(--border)} | |
| .pill.inactive{opacity:.42} | |
| .pill .swatch{ | |
| width:3px;height:14px;border-radius:1.5px;flex-shrink:0; | |
| } | |
| .pill .name{flex:1;font-size:12.5px;color:var(--text);font-weight:400} | |
| .pill.inactive .name{color:var(--text2)} | |
| .pill .count{font-family:var(--mono);font-size:11px;color:var(--text3)} | |
| .pills-empty{ | |
| font-size:12px;color:var(--text3);font-style:italic;padding:.25rem 0; | |
| } | |
| /* export row */ | |
| .export-row{display:flex;flex-direction:column;gap:4px} | |
| .export-btn{ | |
| display:flex;align-items:center;justify-content:space-between; | |
| padding:.5rem .65rem; | |
| border:.5px solid var(--border);border-radius:5px; | |
| color:var(--text);font-size:12.5px;font-weight:500; | |
| transition:background .1s, border-color .1s; | |
| } | |
| .export-btn:hover{background:var(--surface2);border-color:var(--border-strong)} | |
| .export-btn.primary{border-color:var(--border-strong)} | |
| .export-btn.primary:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)} | |
| .export-btn .sc{font-family:var(--mono);font-size:10.5px;color:var(--text3)} | |
| .export-btn.primary:hover .sc{color:var(--accent)} | |
| .export-text-link{ | |
| font-size:11.5px;color:var(--text3);font-family:var(--sans); | |
| padding:.3rem .1rem 0;text-align:left; | |
| transition:color .1s; | |
| } | |
| .export-text-link:hover{color:var(--text2)} | |
| /* ββ loading + toast βββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #loading{ | |
| position:fixed;inset:0;background:rgba(13,13,15,.85);backdrop-filter:blur(6px); | |
| display:none;flex-direction:column;align-items:center;justify-content:center;z-index:9999; | |
| gap:.9rem; | |
| } | |
| @media(prefers-color-scheme:light){ #loading{background:rgba(250,250,248,.85)} } | |
| .spinner{ | |
| width:18px;height:18px;border:1.5px solid var(--border);border-top-color:var(--accent); | |
| border-radius:50%;animation:spin .75s linear infinite; | |
| } | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| #loading p{font-family:var(--mono);font-size:11px;color:var(--text2);letter-spacing:.02em} | |
| .error-banner{ | |
| margin:.75rem 1rem 0;padding:.55rem .8rem; | |
| border:.5px solid rgba(248,113,113,.3);background:rgba(248,113,113,.08); | |
| color:var(--danger);font-size:12px;border-radius:5px;display:none;font-family:var(--mono); | |
| } | |
| .toast{ | |
| position:fixed;bottom:1rem;left:50%;transform:translateX(-50%) translateY(60px); | |
| background:var(--surface);border:.5px solid var(--border-strong);color:var(--text); | |
| padding:.45rem .8rem;border-radius:5px;font-size:12px;font-family:var(--mono); | |
| transition:transform .2s ease;z-index:9998; | |
| } | |
| .toast.show{transform:translateX(-50%) translateY(0)} | |
| @media (max-width: 820px){ | |
| .editor-body{flex-direction:column} | |
| .panel{width:100%;max-height:44vh;border-left:none;border-top:.5px solid var(--border)} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- βββββββββββββββββββββββ LANDING βββββββββββββββββββββββ --> | |
| <div id="landing"> | |
| <header class="app-bar"> | |
| <div class="brand"> | |
| <svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true"> | |
| <rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/> | |
| <rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/> | |
| <rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/> | |
| </svg> | |
| <span class="wordmark">Screenshot Anonymizer</span> | |
| <span class="version">v0.3 Β· beta</span> | |
| </div> | |
| <div class="spacer"></div> | |
| <div class="kbd-hints"><kbd>βV</kbd> paste <span style="margin:0 .35rem;opacity:.4">Β·</span> <kbd>βO</kbd> open</div> | |
| </header> | |
| <div class="landing-content"> | |
| <h1 class="headline">Redact screenshots before you post them.</h1> | |
| <p class="subtitle">OCR finds the text. The privacy filter marks names, emails, phones, addresses, dates, URLs, accounts, and secrets. You approve what gets hidden before it ever leaves the page.</p> | |
| <label class="dropzone" id="dropzone"> | |
| <input type="file" id="file-input" accept="image/png,image/jpeg,image/webp,image/bmp,image/tiff"> | |
| <svg class="dz-icon" width="42" height="30" viewBox="0 0 42 30" aria-hidden="true"> | |
| <rect x="2" y="3" width="26" height="5" rx="1" fill="currentColor"/> | |
| <rect x="2" y="12" width="38" height="5" rx="1" fill="currentColor"/> | |
| <rect x="2" y="21" width="18" height="5" rx="1" fill="currentColor"/> | |
| </svg> | |
| <div class="dz-text">Drop a screenshot, paste from clipboard, or click to browse</div> | |
| <div class="dz-hint">png Β· jpg Β· webp Β· bmp Β· tiff</div> | |
| </label> | |
| <div class="example-wrap"> | |
| <div class="example-head"> | |
| <span class="title">try an example Β· click to load</span> | |
| <span class="note">all content is fictitious Β· mock data for testing only</span> | |
| </div> | |
| <div class="example-scroll" id="example-scroll"> | |
| <div class="ex-empty">loading examplesβ¦</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="landing-foot"> | |
| pii: openai/privacy-filter Β· 1.5b params, 50m active Β· apache 2.0<br> | |
| ocr: rednote-hilab/dots.ocr Β· 3b vlm, top-3 on olmocr-bench Β· edits stay in your browser | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββββββ EDITOR βββββββββββββββββββββββ --> | |
| <div id="editor"> | |
| <header class="app-bar"> | |
| <div class="brand"> | |
| <svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true"> | |
| <rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/> | |
| <rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/> | |
| <rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/> | |
| </svg> | |
| <span class="wordmark">Screenshot Anonymizer</span> | |
| </div> | |
| <span class="version" id="meta-info">β</span> | |
| <div class="spacer"></div> | |
| <button class="btn-link" onclick="resetView()">new screenshot</button> | |
| </header> | |
| <div class="error-banner" id="error-banner"></div> | |
| <div class="editor-body"> | |
| <div class="canvas-area"> | |
| <div class="canvas-scroll" id="canvas-scroll"> | |
| <div class="canvas-inner" id="canvas-inner"> | |
| <div class="ruler-corner"></div> | |
| <canvas class="ruler-top" id="ruler-top"></canvas> | |
| <canvas class="ruler-left" id="ruler-left"></canvas> | |
| <div class="canvas-wrap mode-select" id="canvas-wrap"> | |
| <canvas id="canvas"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="zoom-tools"> | |
| <button title="Zoom out (β)" onclick="zoomStep(-1)">β</button> | |
| <div class="sep"></div> | |
| <span class="zoom-pct" id="zoom-pct">100%</span> | |
| <div class="sep"></div> | |
| <button title="Zoom in (+)" onclick="zoomStep(1)">+</button> | |
| <div class="sep"></div> | |
| <button title="Fit (F)" onclick="zoomFit()">fit</button> | |
| <button title="100% (0)" onclick="zoomReset()">1:1</button> | |
| </div> | |
| <div class="status-bar"> | |
| <span class="k">zoom</span> <span class="v" id="status-zoom">100%</span> | |
| <span class="sep">Β·</span> | |
| <span class="k">cursor</span> <span class="v" id="status-cursor">β</span> | |
| <span class="sep">Β·</span> | |
| <span class="k">selection</span> <span class="v" id="status-sel">β</span> | |
| <span class="spacer"></span> | |
| <span class="k" id="status-mode">select</span> | |
| </div> | |
| </div> | |
| <aside class="panel"> | |
| <section class="p-section"> | |
| <div class="p-label">Tool</div> | |
| <div class="tools"> | |
| <button class="tool active" data-mode="select" title="Select (V)"> | |
| <svg viewBox="0 0 18 18"><path fill="currentColor" d="M3 2l11 6-5 1.6L7.5 15z"/></svg> | |
| <span class="name">Select</span> | |
| <span class="sc">V</span> | |
| </button> | |
| <button class="tool" data-mode="draw" title="Draw bar (B)"> | |
| <svg viewBox="0 0 18 18"><rect x="2" y="8" width="14" height="2.5" rx=".5" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1.5" d="M2 5h14M2 13h14"/></svg> | |
| <span class="name">Draw</span> | |
| <span class="sc">B</span> | |
| </button> | |
| </div> | |
| <div class="tool-opts" id="tool-opts"></div> | |
| </section> | |
| <section class="p-section"> | |
| <div class="p-label">Detected</div> | |
| <div class="summary" id="summary"><span class="num">0</span> bars across <span class="num">0</span> categories</div> | |
| <div class="dist-bar" id="dist-bar"></div> | |
| </section> | |
| <section class="p-section"> | |
| <div class="p-label">Categories</div> | |
| <div class="pills" id="pills"> | |
| <div class="pills-empty">no pii detected</div> | |
| </div> | |
| </section> | |
| <section class="p-section"> | |
| <div class="p-label">Export</div> | |
| <div class="export-row"> | |
| <button class="export-btn primary" onclick="downloadImage()"> | |
| <span>Download PNG</span> | |
| <span class="sc"><kbd>βS</kbd></span> | |
| </button> | |
| <button class="export-btn" onclick="copyToClipboard()"> | |
| <span>Copy to clipboard</span> | |
| <span class="sc"><kbd>ββ§C</kbd></span> | |
| </button> | |
| <button class="export-text-link" onclick="exportText()">Export sanitized text only β</button> | |
| </div> | |
| </section> | |
| </aside> | |
| </div> | |
| </div> | |
| <div id="loading"><div class="spinner"></div><p>ocr β privacy filter β map to pixels</p></div> | |
| <div class="toast" id="toast"></div> | |
| <script type="module"> | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Gradio JS client β talks to the queued @server.api routes so that | |
| // requests are serialized, progress is tracked, and ZeroGPU's | |
| // @spaces.GPU allocator gets invoked correctly. A plain fetch() to a | |
| // FastAPI route would bypass all of that. | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const clientPromise = Client.connect(window.location.origin); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // State | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const S = { | |
| img: null, width: 0, height: 0, | |
| boxes: [], // {id, x, y, w, h, label, text, enabled, custom} | |
| nextId: 1, | |
| activeCats: new Set(), | |
| catMeta: {}, | |
| catCounts: {}, | |
| sourceText: '', | |
| spans: [], | |
| mode: 'select', // 'select' | 'draw' | |
| selected: null, | |
| drag: null, // {type:'draw'|'move', startX, startY, origBox?, newBox?, boxId?} | |
| scale: 1, | |
| filename: '', | |
| }; | |
| const LABELS = {private_person:'Person',private_address:'Address',private_email:'Email',private_phone:'Phone',private_url:'URL',private_date:'Date',account_number:'Account',secret:'Secret',custom:'Custom'}; | |
| const CUSTOM_COLOR = '#9ca3af'; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Upload | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const dz = document.getElementById('dropzone'); | |
| const fi = document.getElementById('file-input'); | |
| ['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); })); | |
| ['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); })); | |
| dz.addEventListener('drop', ev => { const f = ev.dataTransfer.files[0]; if (f) uploadFile(f); }); | |
| fi.addEventListener('change', ev => { const f = ev.target.files[0]; if (f) uploadFile(f); }); | |
| document.addEventListener('paste', ev => { | |
| if (document.getElementById('landing').style.display === 'none') return; | |
| const items = ev.clipboardData && ev.clipboardData.items; | |
| if (!items) return; | |
| for (const it of items) { | |
| if (it.type && it.type.startsWith('image/')) { | |
| const f = it.getAsFile(); | |
| if (f) { uploadFile(f); ev.preventDefault(); return; } | |
| } | |
| } | |
| }); | |
| async function loadExamples() { | |
| const scroll = document.getElementById('example-scroll'); | |
| try { | |
| const r = await fetch('/api/examples'); | |
| const d = await r.json(); | |
| const list = d.examples || []; | |
| if (!list.length) { | |
| scroll.innerHTML = '<div class="ex-empty">no examples available</div>'; | |
| return; | |
| } | |
| scroll.innerHTML = ''; | |
| list.forEach((name, i) => { | |
| const el = document.createElement('div'); | |
| el.className = 'ex-thumb'; | |
| el.title = 'load ' + name; | |
| el.innerHTML = `<span class="ex-idx">${String(i + 1).padStart(2, '0')}</span> | |
| <img loading="lazy" src="/examples/${encodeURIComponent(name)}?thumb=1" alt="">`; | |
| el.addEventListener('click', () => loadExample(name)); | |
| scroll.appendChild(el); | |
| }); | |
| } catch (e) { | |
| scroll.innerHTML = '<div class="ex-empty">could not load examples</div>'; | |
| } | |
| } | |
| async function loadExample(name) { | |
| try { | |
| const r = await fetch('/examples/' + encodeURIComponent(name)); | |
| if (!r.ok) throw new Error('fetch failed'); | |
| const blob = await r.blob(); | |
| const type = blob.type || 'image/png'; | |
| const file = new File([blob], name, { type }); | |
| uploadFile(file); | |
| } catch (e) { | |
| showError('could not load example: ' + e.message); | |
| } | |
| } | |
| loadExamples(); | |
| async function uploadFile(file) { | |
| if (!file.type || !file.type.startsWith('image/')) { showError('please drop an image file.'); return; } | |
| document.getElementById('loading').style.display = 'flex'; | |
| document.getElementById('landing').style.display = 'none'; | |
| try { | |
| const client = await clientPromise; | |
| const result = await client.predict("/anonymize_screenshot", { | |
| image: handle_file(file), | |
| }); | |
| // @server.api returns a dict; the client wraps outputs in result.data[] | |
| const d = result.data[0] || {}; | |
| if (d.error) { showError(d.error); return; } | |
| // The server returns Path(path).name as `filename`; fall back to the | |
| // original File.name for display continuity. | |
| if (!d.filename) d.filename = file.name; | |
| await initEditor(d); | |
| } catch (e) { | |
| showError('analysis failed: ' + (e && e.message ? e.message : e)); | |
| } finally { | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| } | |
| function showError(msg) { | |
| document.getElementById('loading').style.display = 'none'; | |
| document.getElementById('editor').style.display = 'flex'; | |
| const b = document.getElementById('error-banner'); | |
| b.textContent = msg; b.style.display = 'block'; | |
| } | |
| function resetView() { | |
| document.getElementById('editor').style.display = 'none'; | |
| document.getElementById('landing').style.display = 'flex'; | |
| document.getElementById('error-banner').style.display = 'none'; | |
| fi.value = ''; | |
| S.boxes = []; S.selected = null; S.img = null; S.drag = null; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Editor init | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function initEditor(d) { | |
| document.getElementById('editor').style.display = 'flex'; | |
| document.getElementById('error-banner').style.display = 'none'; | |
| S.filename = d.filename || 'screenshot.png'; | |
| S.width = d.width; S.height = d.height; | |
| S.catMeta = d.categories_meta || {}; | |
| S.sourceText = d.text || ''; | |
| S.spans = d.spans || []; | |
| S.catCounts = {}; | |
| S.boxes = (d.boxes || []).map(b => { | |
| S.catCounts[b.label] = (S.catCounts[b.label] || 0) + 1; | |
| return { id: S.nextId++, x: b.x, y: b.y, w: b.w, h: b.h, | |
| label: b.label, text: b.text, enabled: true, custom: false }; | |
| }); | |
| S.activeCats = new Set(Object.keys(S.catCounts)); | |
| const img = new Image(); | |
| await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = d.image; }); | |
| S.img = img; | |
| setMode('select', true); | |
| zoomFit(); | |
| renderPills(); | |
| renderToolOpts(); | |
| renderSummary(); | |
| updateMeta(); | |
| draw(); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Zoom + layout | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function applyScale() { | |
| const cv = document.getElementById('canvas'); | |
| cv.width = S.width; cv.height = S.height; | |
| cv.style.width = (S.width * S.scale) + 'px'; | |
| cv.style.height = (S.height * S.scale) + 'px'; | |
| drawRulers(); | |
| document.getElementById('zoom-pct').textContent = Math.round(S.scale * 100) + '%'; | |
| document.getElementById('status-zoom').textContent = Math.round(S.scale * 100) + '%'; | |
| } | |
| function zoomFit() { | |
| const sc = document.getElementById('canvas-scroll'); | |
| const pad = 72; | |
| const maxW = sc.clientWidth - pad, maxH = sc.clientHeight - pad; | |
| const s = Math.min(1, maxW / S.width, maxH / S.height); | |
| S.scale = Math.max(0.1, s); | |
| applyScale(); draw(); | |
| } | |
| function zoomReset() { S.scale = 1; applyScale(); draw(); } | |
| function zoomStep(dir) { | |
| const steps = [0.1,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,3,4]; | |
| let i = steps.findIndex(s => s >= S.scale - 0.001); | |
| if (i < 0) i = 0; | |
| i = Math.max(0, Math.min(steps.length - 1, i + dir)); | |
| S.scale = steps[i]; applyScale(); draw(); | |
| } | |
| window.addEventListener('resize', () => { if (S.img) { applyScale(); draw(); } }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Rulers | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function cssVar(name) { | |
| return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); | |
| } | |
| function drawRulers() { | |
| const tc = document.getElementById('ruler-top'); | |
| const lc = document.getElementById('ruler-left'); | |
| const w = Math.ceil(S.width * S.scale); | |
| const h = Math.ceil(S.height * S.scale); | |
| const dpr = window.devicePixelRatio || 1; | |
| const color = cssVar('--text3') || '#65656c'; | |
| const colorStrong = cssVar('--text2') || '#9a9a9f'; | |
| const border = cssVar('--border') || 'rgba(255,255,255,.06)'; | |
| // Top | |
| tc.style.width = w + 'px'; tc.style.height = '20px'; | |
| tc.width = w * dpr; tc.height = 20 * dpr; | |
| const tx = tc.getContext('2d'); tx.scale(dpr, dpr); | |
| tx.clearRect(0, 0, w, 20); | |
| tx.fillStyle = 'transparent'; | |
| tx.strokeStyle = border; tx.lineWidth = 1; | |
| tx.beginPath(); tx.moveTo(0, 19.5); tx.lineTo(w, 19.5); tx.stroke(); | |
| tx.fillStyle = color; | |
| tx.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace'); | |
| tx.textBaseline = 'middle'; | |
| const step = pickRulerStep(S.scale); | |
| for (let x = 0; x <= S.width; x += step.minor) { | |
| const sx = Math.round(x * S.scale) + 0.5; | |
| const th = x % step.major === 0 ? 8 : x % (step.minor * 5) === 0 ? 5 : 3; | |
| tx.fillStyle = x % step.major === 0 ? colorStrong : color; | |
| tx.fillRect(sx, 20 - th, 1, th); | |
| if (x % step.major === 0 && x > 0 && sx + 30 < w) { | |
| tx.fillStyle = colorStrong; | |
| tx.fillText(String(x), sx + 2, 7); | |
| } | |
| } | |
| // Left | |
| lc.style.width = '24px'; lc.style.height = h + 'px'; | |
| lc.width = 24 * dpr; lc.height = h * dpr; | |
| const ly = lc.getContext('2d'); ly.scale(dpr, dpr); | |
| ly.clearRect(0, 0, 24, h); | |
| ly.strokeStyle = border; ly.lineWidth = 1; | |
| ly.beginPath(); ly.moveTo(23.5, 0); ly.lineTo(23.5, h); ly.stroke(); | |
| ly.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace'); | |
| ly.textBaseline = 'middle'; | |
| for (let y = 0; y <= S.height; y += step.minor) { | |
| const sy = Math.round(y * S.scale) + 0.5; | |
| const tw = y % step.major === 0 ? 8 : y % (step.minor * 5) === 0 ? 5 : 3; | |
| ly.fillStyle = y % step.major === 0 ? colorStrong : color; | |
| ly.fillRect(24 - tw, sy, tw, 1); | |
| if (y % step.major === 0 && y > 0 && sy + 20 < h) { | |
| ly.save(); | |
| ly.fillStyle = colorStrong; | |
| ly.translate(10, sy + 2); | |
| ly.rotate(-Math.PI / 2); | |
| ly.fillText(String(y), 0, 0); | |
| ly.restore(); | |
| } | |
| } | |
| } | |
| function pickRulerStep(scale) { | |
| // pick minor tick spacing so adjacent ticks are roughly 8-15 screen px apart | |
| const candidates = [5, 10, 20, 50, 100, 200, 500]; | |
| let minor = 10; | |
| for (const c of candidates) { | |
| if (c * scale >= 8) { minor = c; break; } | |
| } | |
| const major = minor * 10; | |
| return { minor, major }; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Draw | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function isVisible(b) { | |
| if (!b.enabled) return false; | |
| if (!b.custom && !S.activeCats.has(b.label)) return false; | |
| return true; | |
| } | |
| function draw() { | |
| if (!S.img) return; | |
| const cv = document.getElementById('canvas'); | |
| const ctx = cv.getContext('2d'); | |
| ctx.clearRect(0, 0, cv.width, cv.height); | |
| ctx.drawImage(S.img, 0, 0); | |
| for (const b of S.boxes) { | |
| if (!isVisible(b)) continue; | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(b.x, b.y, b.w, b.h); | |
| } | |
| const sel = selectedBox(); | |
| if (sel) { | |
| ctx.save(); | |
| ctx.strokeStyle = cssVar('--accent') || '#818cf8'; | |
| ctx.lineWidth = Math.max(1, 1.5 / S.scale); | |
| ctx.setLineDash([5 / S.scale, 3 / S.scale]); | |
| ctx.strokeRect(sel.x - 1, sel.y - 1, sel.w + 2, sel.h + 2); | |
| ctx.restore(); | |
| } | |
| if (S.drag && S.drag.type === 'draw' && S.drag.newBox) { | |
| const b = S.drag.newBox; | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(0,0,0,.7)'; | |
| ctx.fillRect(b.x, b.y, b.w, b.h); | |
| ctx.strokeStyle = cssVar('--accent') || '#818cf8'; | |
| ctx.lineWidth = Math.max(1, 1 / S.scale); | |
| ctx.strokeRect(b.x, b.y, b.w, b.h); | |
| ctx.restore(); | |
| } | |
| } | |
| function selectedBox() { | |
| return S.selected == null ? null : (S.boxes.find(b => b.id === S.selected) || null); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Interaction | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const wrap = document.getElementById('canvas-wrap'); | |
| function canvasXY(ev) { | |
| const rect = wrap.getBoundingClientRect(); | |
| return { | |
| x: (ev.clientX - rect.left) / S.scale, | |
| y: (ev.clientY - rect.top) / S.scale, | |
| }; | |
| } | |
| function hitTest(x, y) { | |
| for (let i = S.boxes.length - 1; i >= 0; i--) { | |
| const b = S.boxes[i]; | |
| if (!isVisible(b)) continue; | |
| if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return b; | |
| } | |
| return null; | |
| } | |
| wrap.addEventListener('mousedown', ev => { | |
| if (ev.button !== 0) return; | |
| ev.preventDefault(); | |
| const { x, y } = canvasXY(ev); | |
| const hit = hitTest(x, y); | |
| if (S.mode === 'draw' && !hit) { | |
| S.selected = null; | |
| S.drag = { type: 'draw', startX: x, startY: y, newBox: { x, y, w: 0, h: 0 } }; | |
| } else if (hit) { | |
| S.selected = hit.id; | |
| S.drag = { type: 'move', startX: x, startY: y, | |
| origBox: { x: hit.x, y: hit.y, w: hit.w, h: hit.h }, boxId: hit.id }; | |
| wrap.classList.add('dragging'); | |
| } else { | |
| S.selected = null; S.drag = null; | |
| } | |
| updateStatus(x, y); | |
| draw(); | |
| }); | |
| window.addEventListener('mousemove', ev => { | |
| if (S.img && document.getElementById('editor').style.display === 'flex') { | |
| const rect = wrap.getBoundingClientRect(); | |
| if (ev.clientX >= rect.left && ev.clientX <= rect.right && | |
| ev.clientY >= rect.top && ev.clientY <= rect.bottom) { | |
| const x = Math.round((ev.clientX - rect.left) / S.scale); | |
| const y = Math.round((ev.clientY - rect.top) / S.scale); | |
| updateStatus(x, y); | |
| if (!S.drag) { | |
| const hit = hitTest(x, y); | |
| wrap.classList.toggle('over-box', !!hit); | |
| } | |
| } | |
| } | |
| if (!S.drag) return; | |
| const { x, y } = canvasXY(ev); | |
| if (S.drag.type === 'draw') { | |
| const sx = S.drag.startX, sy = S.drag.startY; | |
| S.drag.newBox = { | |
| x: Math.max(0, Math.min(S.width, Math.min(sx, x))), | |
| y: Math.max(0, Math.min(S.height, Math.min(sy, y))), | |
| w: Math.min(Math.abs(x - sx), S.width), | |
| h: Math.min(Math.abs(y - sy), S.height), | |
| }; | |
| } else if (S.drag.type === 'move') { | |
| const dx = x - S.drag.startX, dy = y - S.drag.startY; | |
| const b = S.boxes.find(b => b.id === S.drag.boxId); | |
| if (b) { | |
| const o = S.drag.origBox; | |
| b.x = Math.max(0, Math.min(S.width - o.w, Math.round(o.x + dx))); | |
| b.y = Math.max(0, Math.min(S.height - o.h, Math.round(o.y + dy))); | |
| } | |
| } | |
| draw(); | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| if (!S.drag) return; | |
| wrap.classList.remove('dragging'); | |
| if (S.drag.type === 'draw') { | |
| const b = S.drag.newBox; | |
| if (b.w > 3 && b.h > 3) { | |
| const nb = { id: S.nextId++, x: Math.round(b.x), y: Math.round(b.y), | |
| w: Math.round(b.w), h: Math.round(b.h), | |
| label: 'custom', text: '', enabled: true, custom: true }; | |
| S.boxes.push(nb); | |
| S.selected = nb.id; | |
| renderSummary(); | |
| } | |
| } | |
| S.drag = null; | |
| draw(); | |
| }); | |
| // Keyboard | |
| window.addEventListener('keydown', ev => { | |
| if (document.getElementById('editor').style.display !== 'flex') return; | |
| const tgt = ev.target; | |
| if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return; | |
| const cmd = ev.metaKey || ev.ctrlKey; | |
| if (cmd && ev.key.toLowerCase() === 's') { ev.preventDefault(); downloadImage(); return; } | |
| if (cmd && ev.shiftKey && ev.key.toLowerCase() === 'c') { ev.preventDefault(); copyToClipboard(); return; } | |
| if (ev.key === 'Delete' || ev.key === 'Backspace') { | |
| if (S.selected != null) { | |
| S.boxes = S.boxes.filter(b => b.id !== S.selected); | |
| S.selected = null; | |
| renderSummary(); draw(); | |
| ev.preventDefault(); | |
| } | |
| } else if (ev.key === 'Escape') { | |
| S.selected = null; S.drag = null; draw(); | |
| } else if (ev.key === 'v' || ev.key === 'V') setMode('select'); | |
| else if (ev.key === 'b' || ev.key === 'B') setMode('draw'); | |
| else if (ev.key === 'f' || ev.key === 'F') zoomFit(); | |
| else if (ev.key === '0') zoomReset(); | |
| else if (ev.key === '+' || ev.key === '=') zoomStep(1); | |
| else if (ev.key === '-' || ev.key === '_') zoomStep(-1); | |
| }); | |
| function updateStatus(x, y) { | |
| document.getElementById('status-cursor').textContent = `${x}, ${y}`; | |
| const sel = selectedBox(); | |
| document.getElementById('status-sel').textContent = sel ? `${sel.w}Γ${sel.h}` : 'β'; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Tool mode | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.querySelectorAll('.tool').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| if (btn.disabled) return; | |
| setMode(btn.dataset.mode); | |
| }); | |
| }); | |
| function setMode(m, silent) { | |
| S.mode = m; | |
| document.querySelectorAll('.tool').forEach(b => b.classList.toggle('active', b.dataset.mode === m)); | |
| wrap.classList.toggle('mode-select', m === 'select'); | |
| wrap.classList.toggle('mode-draw', m === 'draw'); | |
| document.getElementById('status-mode').textContent = m; | |
| renderToolOpts(); | |
| if (!silent) updateStatus(0, 0); | |
| } | |
| function renderToolOpts() { | |
| const el = document.getElementById('tool-opts'); | |
| if (S.mode === 'select') { | |
| el.innerHTML = ` | |
| <div class="hint">Click a bar to select. Drag to move. Press <kbd style="font-family:var(--mono);padding:1px 4px;border:.5px solid var(--border);border-radius:3px;background:var(--bg)">Del</kbd> to remove.</div> | |
| <div class="kb"><kbd>V</kbd> select <span class="sep">Β·</span> <kbd>B</kbd> draw <span class="sep">Β·</span> <kbd>Esc</kbd> clear</div>`; | |
| } else if (S.mode === 'draw') { | |
| el.innerHTML = ` | |
| <div class="hint">Drag on empty canvas to add a black bar. Release to confirm.</div> | |
| <div class="kb"><kbd>B</kbd> draw <span class="sep">Β·</span> <kbd>V</kbd> select <span class="sep">Β·</span> <kbd>F</kbd> fit <span class="sep">Β·</span> <kbd>0</kbd> 1:1</div>`; | |
| } else { | |
| el.innerHTML = `<div class="hint">coming soon.</div>`; | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Side panel rendering | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateMeta() { | |
| const parts = [S.filename, `${S.width}Γ${S.height}`]; | |
| document.getElementById('meta-info').textContent = parts.join(' Β· '); | |
| } | |
| function renderSummary() { | |
| const visible = S.boxes.filter(isVisible); | |
| const catsActive = new Set(); | |
| for (const b of visible) catsActive.add(b.custom ? 'custom' : b.label); | |
| document.getElementById('summary').innerHTML = | |
| `<span class="num">${visible.length}</span> bar${visible.length === 1 ? '' : 's'} across <span class="num">${catsActive.size}</span> categor${catsActive.size === 1 ? 'y' : 'ies'}`; | |
| const dist = document.getElementById('dist-bar'); | |
| dist.innerHTML = ''; | |
| if (!visible.length) return; | |
| const counts = {}; | |
| for (const b of visible) { | |
| const k = b.custom ? 'custom' : b.label; | |
| counts[k] = (counts[k] || 0) + 1; | |
| } | |
| const total = visible.length; | |
| for (const [k, n] of Object.entries(counts)) { | |
| const seg = document.createElement('div'); | |
| seg.className = 'dist-seg'; | |
| seg.style.width = (n / total * 100) + '%'; | |
| seg.style.background = (k === 'custom' ? CUSTOM_COLOR : (S.catMeta[k] && S.catMeta[k].color)) || '#888'; | |
| dist.appendChild(seg); | |
| } | |
| } | |
| function renderPills() { | |
| const container = document.getElementById('pills'); | |
| const cats = Object.keys(S.catCounts); | |
| if (!cats.length) { | |
| container.innerHTML = '<div class="pills-empty">no pii detected</div>'; | |
| return; | |
| } | |
| container.innerHTML = ''; | |
| for (const cat of cats) { | |
| const meta = S.catMeta[cat] || { color: '#888', label: cat }; | |
| const active = S.activeCats.has(cat); | |
| const pill = document.createElement('div'); | |
| pill.className = 'pill ' + (active ? 'active' : 'inactive'); | |
| pill.innerHTML = ` | |
| <span class="swatch" style="background:${meta.color}"></span> | |
| <span class="name">${meta.label}</span> | |
| <span class="count">${S.catCounts[cat]}</span>`; | |
| pill.addEventListener('click', () => { | |
| if (S.activeCats.has(cat)) S.activeCats.delete(cat); | |
| else S.activeCats.add(cat); | |
| renderPills(); renderSummary(); draw(); | |
| }); | |
| container.appendChild(pill); | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Export | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderExportCanvas() { | |
| const ec = document.createElement('canvas'); | |
| ec.width = S.width; ec.height = S.height; | |
| const ctx = ec.getContext('2d'); | |
| ctx.drawImage(S.img, 0, 0); | |
| ctx.fillStyle = '#000'; | |
| for (const b of S.boxes) if (isVisible(b)) ctx.fillRect(b.x, b.y, b.w, b.h); | |
| return ec; | |
| } | |
| function downloadImage() { | |
| const ec = renderExportCanvas(); | |
| ec.toBlob(blob => { | |
| const a = document.createElement('a'); | |
| const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, ''); | |
| a.download = base + '-redacted.png'; | |
| a.href = URL.createObjectURL(blob); | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 1000); | |
| toast('saved ' + a.download); | |
| }, 'image/png'); | |
| } | |
| async function copyToClipboard() { | |
| const ec = renderExportCanvas(); | |
| try { | |
| await new Promise((res, rej) => { | |
| ec.toBlob(async blob => { | |
| try { | |
| if (!navigator.clipboard || !window.ClipboardItem) { rej(new Error('clipboard api not supported')); return; } | |
| await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); | |
| res(); | |
| } catch (e) { rej(e); } | |
| }, 'image/png'); | |
| }); | |
| toast('copied to clipboard'); | |
| } catch (e) { | |
| toast('copy failed Β· ' + e.message); | |
| } | |
| } | |
| function exportText() { | |
| // Build redacted plaintext by replacing detected spans with [REDACTED label] | |
| let out = '', pos = 0; | |
| const spans = [...S.spans].filter(s => S.activeCats.has(s.label)).sort((a, b) => a.start - b.start); | |
| for (const sp of spans) { | |
| if (sp.start < pos) continue; | |
| out += S.sourceText.slice(pos, sp.start) + `[${(LABELS[sp.label] || sp.label).toLowerCase()}]`; | |
| pos = sp.end; | |
| } | |
| out += S.sourceText.slice(pos); | |
| const blob = new Blob([out], { type: 'text/plain' }); | |
| const a = document.createElement('a'); | |
| const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, ''); | |
| a.download = base + '-redacted.txt'; | |
| a.href = URL.createObjectURL(blob); | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 1000); | |
| toast('saved ' + a.download); | |
| } | |
| let toastTimer = null; | |
| function toast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => t.classList.remove('show'), 2000); | |
| } | |
| // Module scripts don't expose top-level names to the global scope, but | |
| // several buttons in the HTML use inline onclick="foo()" handlers β | |
| // bridge them explicitly. | |
| Object.assign(window, { | |
| resetView, zoomStep, zoomFit, zoomReset, | |
| downloadImage, copyToClipboard, exportText, | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| if __name__ == "__main__": | |
| server.launch(server_name="0.0.0.0", server_port=7860, show_error=True) | |