ysharma HF Staff commited on
Commit
36f92e5
Β·
verified Β·
1 Parent(s): 1d8fad0

Upload app_v2.py

Browse files
Files changed (1) hide show
  1. app_v2.py +1211 -0
app_v2.py ADDED
@@ -0,0 +1,1211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Screenshot Anonymizer β€” v2 (tool revision)
3
+ ==========================================
4
+ Addresses critique-ui-1.txt: the v1 landing page had the classic
5
+ "AI-generated SaaS" tells (gradient wordmark, centered hero, three feature
6
+ boxes, soft glow, chunky purple primary button). This version is designed
7
+ as a tool-in-rest-state: dense, pixel-editor chrome; mono typography for
8
+ technical values; serif for the one headline; pill-style category rows;
9
+ icon tool row with keyboard shortcuts; rulers, zoom mini-toolbar, status
10
+ bar, and contextual tool options in the editor.
11
+
12
+ Backend is imported from app.py verbatim β€” the only thing changing here is
13
+ the frontend and the FastAPI routes that serve it.
14
+ """
15
+
16
+ import base64
17
+ import io
18
+ import json
19
+ from pathlib import Path
20
+
21
+ import gradio as gr
22
+ from fastapi import File, UploadFile
23
+ from fastapi.responses import HTMLResponse, JSONResponse
24
+ from PIL import Image
25
+
26
+ from app import (
27
+ CATEGORIES_META,
28
+ map_spans_to_boxes,
29
+ ocr_image,
30
+ run_pii_analysis,
31
+ )
32
+
33
+ # =====================================================================
34
+ # SERVER
35
+ # =====================================================================
36
+
37
+ server = gr.Server()
38
+
39
+
40
+ @server.get("/", response_class=HTMLResponse)
41
+ async def homepage():
42
+ return FRONTEND_HTML
43
+
44
+
45
+ @server.post("/api/detect")
46
+ async def detect(file: UploadFile = File(...)):
47
+ suffix = Path(file.filename or "").suffix.lower()
48
+ if suffix not in (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"):
49
+ return JSONResponse({"error": f"Unsupported image type: {suffix or '(none)'}"}, 400)
50
+ try:
51
+ img_bytes = await file.read()
52
+ img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
53
+ except Exception as e:
54
+ return JSONResponse({"error": f"Could not read image: {e}"}, 400)
55
+
56
+ ocr = ocr_image(img)
57
+ if not ocr["text"].strip():
58
+ return JSONResponse({"error": "No text detected in the image."}, 400)
59
+
60
+ try:
61
+ source_text, spans = run_pii_analysis(ocr["text"])
62
+ except Exception as e:
63
+ return JSONResponse({"error": f"PII analysis failed: {e}"}, 500)
64
+
65
+ if source_text != ocr["text"]:
66
+ spans = [s for s in spans if s["end"] <= len(ocr["text"])]
67
+
68
+ boxes = map_spans_to_boxes(ocr["words"], spans)
69
+
70
+ buf = io.BytesIO(); img.save(buf, format="PNG")
71
+ data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
72
+
73
+ return JSONResponse({
74
+ "filename": file.filename,
75
+ "image": data_url,
76
+ "width": img.width, "height": img.height,
77
+ "boxes": boxes,
78
+ "text": ocr["text"],
79
+ "spans": spans,
80
+ "categories_meta": {k: {"color": v["color"], "label": v["label"]}
81
+ for k, v in CATEGORIES_META.items()},
82
+ })
83
+
84
+
85
+ @server.api(name="anonymize_screenshot")
86
+ def anonymize_screenshot_api(image_path: str) -> str:
87
+ img = Image.open(image_path).convert("RGB")
88
+ ocr = ocr_image(img)
89
+ if not ocr["text"].strip():
90
+ return json.dumps({"boxes": [], "text": "", "spans": []})
91
+ _, spans = run_pii_analysis(ocr["text"])
92
+ boxes = map_spans_to_boxes(ocr["words"], spans)
93
+ return json.dumps({
94
+ "width": img.width, "height": img.height,
95
+ "boxes": boxes, "text": ocr["text"], "spans": spans,
96
+ }, ensure_ascii=False)
97
+
98
+
99
+ # =====================================================================
100
+ # FRONTEND
101
+ # =====================================================================
102
+
103
+ FRONTEND_HTML = r"""<!DOCTYPE html>
104
+ <html lang="en">
105
+ <head>
106
+ <meta charset="UTF-8">
107
+ <meta name="viewport" content="width=device-width,initial-scale=1">
108
+ <title>Screenshot Anonymizer</title>
109
+ <link rel="preconnect" href="https://fonts.googleapis.com">
110
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
111
+ <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">
112
+ <style>
113
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
114
+ :root{
115
+ color-scheme:dark light;
116
+ --bg:#0d0d0f;
117
+ --surface:#131316;
118
+ --surface2:#1a1a1e;
119
+ --surface3:#222227;
120
+ --border:rgba(255,255,255,.06);
121
+ --border-strong:rgba(255,255,255,.14);
122
+ --text:#e9e9ea;
123
+ --text2:#9a9a9f;
124
+ --text3:#65656c;
125
+ --accent:#818cf8;
126
+ --accent-dim:rgba(129,140,248,.12);
127
+ --danger:#f87171;
128
+ --mono:ui-monospace,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace;
129
+ --serif:'Lora',Georgia,'Times New Roman',serif;
130
+ --sans:'Inter',system-ui,-apple-system,sans-serif;
131
+ }
132
+ @media (prefers-color-scheme: light){
133
+ :root{
134
+ --bg:#fafaf8;
135
+ --surface:#ffffff;
136
+ --surface2:#f4f4f2;
137
+ --surface3:#eeeeec;
138
+ --border:rgba(0,0,0,.07);
139
+ --border-strong:rgba(0,0,0,.16);
140
+ --text:#1c1c1e;
141
+ --text2:#515156;
142
+ --text3:#86868b;
143
+ --accent:#4f46e5;
144
+ --accent-dim:rgba(79,70,229,.08);
145
+ }
146
+ }
147
+ html,body{height:100%}
148
+ body{
149
+ font-family:var(--sans);font-size:13.5px;line-height:1.5;
150
+ color:var(--text);background:var(--bg);
151
+ font-feature-settings:'ss01','cv11';
152
+ -webkit-font-smoothing:antialiased;
153
+ text-rendering:optimizeLegibility;
154
+ overflow:hidden;
155
+ }
156
+ button{font:inherit;color:inherit;background:none;border:none;padding:0;cursor:pointer}
157
+ svg{display:block;flex-shrink:0}
158
+
159
+ /* ── shared chrome ─────────────────────────────────────────────── */
160
+ .app-bar{
161
+ display:flex;align-items:center;gap:.9rem;
162
+ padding:.55rem 1rem;
163
+ border-bottom:.5px solid var(--border);
164
+ background:var(--bg);
165
+ flex-shrink:0;
166
+ height:40px;
167
+ }
168
+ .brand{display:flex;align-items:center;gap:.55rem}
169
+ .brand-icon{color:var(--text);width:18px;height:18px}
170
+ .wordmark{
171
+ font-family:var(--sans);font-weight:500;font-size:13.5px;
172
+ color:var(--text);letter-spacing:-.005em;
173
+ }
174
+ .version{
175
+ font-family:var(--mono);font-size:11px;color:var(--text3);
176
+ padding:1px 6px;border:.5px solid var(--border);border-radius:3px;
177
+ margin-left:.25rem;
178
+ }
179
+ .app-bar .spacer{flex:1}
180
+ .kbd-hints{font-family:var(--mono);font-size:11px;color:var(--text3)}
181
+ .kbd-hints kbd{
182
+ font-family:var(--mono);color:var(--text2);
183
+ padding:1px 4px;border:.5px solid var(--border);border-radius:3px;
184
+ background:var(--surface);font-size:10.5px;
185
+ }
186
+ .btn-link{
187
+ font-family:var(--sans);font-size:12.5px;font-weight:500;
188
+ color:var(--text2);padding:.3rem .55rem;
189
+ border:.5px solid var(--border);border-radius:5px;
190
+ transition:color .12s, border-color .12s, background .12s;
191
+ }
192
+ .btn-link:hover{color:var(--text);border-color:var(--border-strong);background:var(--surface2)}
193
+
194
+ /* ── landing view ──────────────────────────────────────────────── */
195
+ #landing{display:flex;flex-direction:column;height:100vh;overflow-y:auto}
196
+ .landing-content{
197
+ max-width:720px;width:100%;
198
+ margin:0 auto;padding:3.5rem 1.5rem 2rem;
199
+ display:flex;flex-direction:column;gap:1.5rem;
200
+ }
201
+ .headline{
202
+ font-family:var(--serif);font-weight:400;
203
+ font-size:30px;line-height:1.15;letter-spacing:-.01em;
204
+ color:var(--text);
205
+ }
206
+ .subtitle{
207
+ font-family:var(--sans);font-size:14px;color:var(--text2);
208
+ max-width:52ch;
209
+ }
210
+ .dropzone{
211
+ position:relative;
212
+ border:.5px dashed var(--border-strong);
213
+ border-radius:8px;
214
+ background:var(--surface);
215
+ aspect-ratio:3/1;min-height:140px;
216
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;
217
+ cursor:pointer;transition:border-color .15s, background .15s;
218
+ }
219
+ .dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-dim)}
220
+ .dropzone input{position:absolute;inset:0;opacity:0;cursor:pointer}
221
+ .dz-icon{color:var(--text2);margin-bottom:.1rem}
222
+ .dz-text{font-size:13.5px;color:var(--text);font-weight:500}
223
+ .dz-hint{font-family:var(--mono);font-size:11px;color:var(--text3)}
224
+
225
+ /* example strip */
226
+ .example{
227
+ border:.5px solid var(--border);border-radius:8px;
228
+ background:var(--surface);overflow:hidden;
229
+ }
230
+ .example-head{
231
+ display:grid;grid-template-columns:1fr 1fr;
232
+ font-family:var(--mono);font-size:10.5px;
233
+ color:var(--text3);text-transform:lowercase;
234
+ }
235
+ .example-head > div{padding:.45rem .75rem;border-bottom:.5px solid var(--border)}
236
+ .example-head > div + div{border-left:.5px solid var(--border)}
237
+ .example-body{display:grid;grid-template-columns:1fr 1fr;min-height:120px}
238
+ .example-body > div{padding:.85rem 1rem}
239
+ .example-body > div + div{border-left:.5px solid var(--border)}
240
+ .ex-msg{font-size:12.5px;line-height:1.55;color:var(--text)}
241
+ .ex-msg .who{font-weight:500;margin-bottom:.2rem}
242
+ .ex-msg .line{color:var(--text2)}
243
+ .ex-msg .line + .line{margin-top:.15rem}
244
+ .ex-bar{
245
+ display:inline-block;background:var(--text);vertical-align:baseline;
246
+ border-radius:1px;height:.95em;transform:translateY(2px);
247
+ }
248
+ .example-foot{
249
+ padding:.4rem .75rem;border-top:.5px solid var(--border);
250
+ font-family:var(--mono);font-size:10.5px;color:var(--text3);
251
+ background:var(--surface2);
252
+ }
253
+
254
+ .landing-foot{
255
+ max-width:720px;margin:.5rem auto 2rem;padding:0 1.5rem;
256
+ font-family:var(--mono);font-size:10.5px;color:var(--text3);line-height:1.7;
257
+ }
258
+ .landing-foot a{color:var(--text2);text-decoration:none;border-bottom:.5px dotted var(--text3)}
259
+ .landing-foot a:hover{color:var(--text)}
260
+
261
+ /* ── editor view ───────────────────────────────────────────────── */
262
+ #editor{display:none;flex-direction:column;height:100vh}
263
+ .editor-body{flex:1;display:flex;min-height:0}
264
+
265
+ .canvas-area{
266
+ flex:1;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;
267
+ background:var(--bg);
268
+ }
269
+ .canvas-scroll{
270
+ flex:1;overflow:auto;position:relative;
271
+ background:
272
+ linear-gradient(45deg,var(--surface2) 25%,transparent 25%),
273
+ linear-gradient(-45deg,var(--surface2) 25%,transparent 25%),
274
+ linear-gradient(45deg,transparent 75%,var(--surface2) 75%),
275
+ linear-gradient(-45deg,transparent 75%,var(--surface2) 75%);
276
+ background-color:var(--bg);
277
+ background-size:16px 16px;
278
+ background-position:0 0,0 8px,8px -8px,8px 0;
279
+ }
280
+ .canvas-inner{
281
+ position:relative;padding:28px 24px 24px 36px;
282
+ display:flex;align-items:flex-start;justify-content:flex-start;
283
+ min-width:100%;min-height:100%;
284
+ }
285
+ .canvas-wrap{position:relative;cursor:crosshair;flex-shrink:0}
286
+ .canvas-wrap.mode-select{cursor:default}
287
+ .canvas-wrap.mode-select.over-box{cursor:move}
288
+ .canvas-wrap.mode-select.dragging{cursor:grabbing}
289
+ .canvas-wrap canvas{display:block}
290
+
291
+ /* rulers */
292
+ .ruler-top,.ruler-left{position:absolute;background:var(--surface);pointer-events:none}
293
+ .ruler-top{top:4px;left:36px;height:20px}
294
+ .ruler-left{left:4px;top:28px;width:24px}
295
+ .ruler-corner{
296
+ position:absolute;top:4px;left:4px;
297
+ width:24px;height:20px;background:var(--surface);
298
+ border-right:.5px solid var(--border);border-bottom:.5px solid var(--border);
299
+ }
300
+
301
+ /* status bar */
302
+ .status-bar{
303
+ display:flex;align-items:center;gap:.75rem;
304
+ padding:.35rem .75rem;flex-shrink:0;
305
+ border-top:.5px solid var(--border);
306
+ font-family:var(--mono);font-size:11px;color:var(--text3);
307
+ background:var(--surface);
308
+ height:26px;
309
+ }
310
+ .status-bar .sep{color:var(--text3);opacity:.4}
311
+ .status-bar .k{color:var(--text3)}
312
+ .status-bar .v{color:var(--text2)}
313
+ .status-bar .spacer{flex:1}
314
+
315
+ /* zoom toolbar */
316
+ .zoom-tools{
317
+ position:absolute;bottom:38px;right:14px;z-index:5;
318
+ display:flex;align-items:center;gap:0;
319
+ background:var(--surface);border:.5px solid var(--border);border-radius:6px;
320
+ padding:2px;
321
+ }
322
+ .zoom-tools button, .zoom-tools .zoom-pct{
323
+ font-family:var(--mono);font-size:11px;color:var(--text2);
324
+ padding:4px 8px;border-radius:4px;min-width:22px;text-align:center;
325
+ }
326
+ .zoom-tools button:hover{background:var(--surface2);color:var(--text)}
327
+ .zoom-tools .zoom-pct{color:var(--text)}
328
+ .zoom-tools .sep{width:.5px;background:var(--border);align-self:stretch;margin:2px 0}
329
+
330
+ /* ── right panel ───────────────────────────────────────────────── */
331
+ .panel{
332
+ width:272px;flex-shrink:0;
333
+ background:var(--surface);border-left:.5px solid var(--border);
334
+ overflow-y:auto;display:flex;flex-direction:column;
335
+ }
336
+ .p-section{padding:.85rem 1rem;border-bottom:.5px solid var(--border)}
337
+ .p-section:last-child{border-bottom:none}
338
+ .p-label{
339
+ font-family:var(--sans);font-size:10.5px;font-weight:500;
340
+ color:var(--text3);text-transform:uppercase;letter-spacing:.08em;
341
+ margin-bottom:.55rem;
342
+ }
343
+
344
+ /* tool row */
345
+ .tools{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-bottom:.6rem}
346
+ .tool{
347
+ position:relative;
348
+ display:flex;flex-direction:column;align-items:center;gap:3px;
349
+ padding:8px 0 6px;
350
+ border:.5px solid var(--border);border-radius:5px;
351
+ color:var(--text2);
352
+ transition:color .12s, background .12s, border-color .12s;
353
+ }
354
+ .tool:hover:not(:disabled){color:var(--text);border-color:var(--border-strong)}
355
+ .tool.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
356
+ .tool:disabled{opacity:.35;cursor:not-allowed}
357
+ .tool svg{width:15px;height:15px}
358
+ .tool .sc{
359
+ font-family:var(--mono);font-size:9.5px;color:var(--text3);
360
+ letter-spacing:.02em;
361
+ }
362
+ .tool.active .sc{color:var(--accent)}
363
+
364
+ /* contextual tool options */
365
+ .tool-opts{
366
+ font-size:12px;color:var(--text2);line-height:1.5;
367
+ padding:.5rem .65rem;background:var(--surface2);border-radius:5px;
368
+ border:.5px solid var(--border);
369
+ }
370
+ .tool-opts .hint{display:flex;align-items:flex-start;gap:.4rem;font-family:var(--sans)}
371
+ .tool-opts .hint svg{color:var(--text3);margin-top:1px}
372
+ .tool-opts .kb{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.45rem}
373
+ .tool-opts kbd{
374
+ font-family:var(--mono);font-size:10px;color:var(--text2);
375
+ padding:1px 4px;border:.5px solid var(--border);border-radius:3px;
376
+ background:var(--bg);
377
+ }
378
+ .tool-opts .sep{color:var(--text3);margin:0 .25rem}
379
+
380
+ /* detected summary */
381
+ .summary{
382
+ font-family:var(--mono);font-size:11.5px;color:var(--text2);
383
+ margin-bottom:.5rem;
384
+ }
385
+ .summary .num{color:var(--text)}
386
+ .dist-bar{
387
+ height:4px;background:var(--surface2);border-radius:2px;overflow:hidden;
388
+ display:flex;
389
+ }
390
+ .dist-seg{height:100%;transition:width .4s ease}
391
+
392
+ /* category pills */
393
+ .pills{display:flex;flex-direction:column;gap:2px}
394
+ .pill{
395
+ display:flex;align-items:center;gap:.55rem;
396
+ padding:.4rem .5rem;
397
+ border:.5px solid transparent;border-radius:5px;
398
+ cursor:pointer;user-select:none;
399
+ transition:background .1s, border-color .1s;
400
+ }
401
+ .pill:hover{background:var(--surface2)}
402
+ .pill.active{background:var(--surface2);border-color:var(--border)}
403
+ .pill.inactive{opacity:.42}
404
+ .pill .swatch{
405
+ width:3px;height:14px;border-radius:1.5px;flex-shrink:0;
406
+ }
407
+ .pill .name{flex:1;font-size:12.5px;color:var(--text);font-weight:400}
408
+ .pill.inactive .name{color:var(--text2)}
409
+ .pill .count{font-family:var(--mono);font-size:11px;color:var(--text3)}
410
+ .pills-empty{
411
+ font-size:12px;color:var(--text3);font-style:italic;padding:.25rem 0;
412
+ }
413
+
414
+ /* export row */
415
+ .export-row{display:flex;flex-direction:column;gap:4px}
416
+ .export-btn{
417
+ display:flex;align-items:center;justify-content:space-between;
418
+ padding:.5rem .65rem;
419
+ border:.5px solid var(--border);border-radius:5px;
420
+ color:var(--text);font-size:12.5px;font-weight:500;
421
+ transition:background .1s, border-color .1s;
422
+ }
423
+ .export-btn:hover{background:var(--surface2);border-color:var(--border-strong)}
424
+ .export-btn.primary{border-color:var(--border-strong)}
425
+ .export-btn.primary:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
426
+ .export-btn .sc{font-family:var(--mono);font-size:10.5px;color:var(--text3)}
427
+ .export-btn.primary:hover .sc{color:var(--accent)}
428
+ .export-text-link{
429
+ font-size:11.5px;color:var(--text3);font-family:var(--sans);
430
+ padding:.3rem .1rem 0;text-align:left;
431
+ transition:color .1s;
432
+ }
433
+ .export-text-link:hover{color:var(--text2)}
434
+
435
+ /* ── loading + toast ───────────────────────────────────────────── */
436
+ #loading{
437
+ position:fixed;inset:0;background:rgba(13,13,15,.85);backdrop-filter:blur(6px);
438
+ display:none;flex-direction:column;align-items:center;justify-content:center;z-index:9999;
439
+ gap:.9rem;
440
+ }
441
+ @media(prefers-color-scheme:light){ #loading{background:rgba(250,250,248,.85)} }
442
+ .spinner{
443
+ width:18px;height:18px;border:1.5px solid var(--border);border-top-color:var(--accent);
444
+ border-radius:50%;animation:spin .75s linear infinite;
445
+ }
446
+ @keyframes spin{to{transform:rotate(360deg)}}
447
+ #loading p{font-family:var(--mono);font-size:11px;color:var(--text2);letter-spacing:.02em}
448
+
449
+ .error-banner{
450
+ margin:.75rem 1rem 0;padding:.55rem .8rem;
451
+ border:.5px solid rgba(248,113,113,.3);background:rgba(248,113,113,.08);
452
+ color:var(--danger);font-size:12px;border-radius:5px;display:none;font-family:var(--mono);
453
+ }
454
+
455
+ .toast{
456
+ position:fixed;bottom:1rem;left:50%;transform:translateX(-50%) translateY(60px);
457
+ background:var(--surface);border:.5px solid var(--border-strong);color:var(--text);
458
+ padding:.45rem .8rem;border-radius:5px;font-size:12px;font-family:var(--mono);
459
+ transition:transform .2s ease;z-index:9998;
460
+ }
461
+ .toast.show{transform:translateX(-50%) translateY(0)}
462
+
463
+ @media (max-width: 820px){
464
+ .editor-body{flex-direction:column}
465
+ .panel{width:100%;max-height:44vh;border-left:none;border-top:.5px solid var(--border)}
466
+ }
467
+ </style>
468
+ </head>
469
+ <body>
470
+
471
+ <!-- ═══════════════════════ LANDING ═══════════════════════ -->
472
+ <div id="landing">
473
+ <header class="app-bar">
474
+ <div class="brand">
475
+ <svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true">
476
+ <rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/>
477
+ <rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
478
+ <rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
479
+ </svg>
480
+ <span class="wordmark">Screenshot Anonymizer</span>
481
+ <span class="version">v0.3 Β· beta</span>
482
+ </div>
483
+ <div class="spacer"></div>
484
+ <div class="kbd-hints"><kbd>⌘V</kbd> paste <span style="margin:0 .35rem;opacity:.4">·</span> <kbd>⌘O</kbd> open</div>
485
+ </header>
486
+
487
+ <div class="landing-content">
488
+ <h1 class="headline">Redact screenshots before you post them.</h1>
489
+ <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>
490
+
491
+ <label class="dropzone" id="dropzone">
492
+ <input type="file" id="file-input" accept="image/png,image/jpeg,image/webp,image/bmp,image/tiff">
493
+ <svg class="dz-icon" width="42" height="30" viewBox="0 0 42 30" aria-hidden="true">
494
+ <rect x="2" y="3" width="26" height="5" rx="1" fill="currentColor"/>
495
+ <rect x="2" y="12" width="38" height="5" rx="1" fill="currentColor"/>
496
+ <rect x="2" y="21" width="18" height="5" rx="1" fill="currentColor"/>
497
+ </svg>
498
+ <div class="dz-text">Drop a screenshot, paste from clipboard, or click to browse</div>
499
+ <div class="dz-hint">png Β· jpg Β· webp Β· bmp Β· tiff</div>
500
+ </label>
501
+
502
+ <div class="example">
503
+ <div class="example-head"><div>before</div><div>after</div></div>
504
+ <div class="example-body">
505
+ <div>
506
+ <div class="ex-msg">
507
+ <div class="who">Alice Chen</div>
508
+ <div class="line">send the invoice to alice@acme.com</div>
509
+ <div class="line">or text me at (415) 555-0182</div>
510
+ </div>
511
+ </div>
512
+ <div>
513
+ <div class="ex-msg">
514
+ <div class="who"><span class="ex-bar" style="width:84px"></span></div>
515
+ <div class="line">send the invoice to <span class="ex-bar" style="width:128px"></span></div>
516
+ <div class="line">or text me at <span class="ex-bar" style="width:96px"></span></div>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ <div class="example-foot">example Β· mock chat message</div>
521
+ </div>
522
+ </div>
523
+
524
+ <div class="landing-foot">
525
+ model: openai privacy filter Β· 1.5b params, 50m active Β· apache 2.0<br>
526
+ ocr: tesseract 5 Β· processing: server-side, l4 gpu Β· edits stay in your browser
527
+ </div>
528
+ </div>
529
+
530
+ <!-- ═══════════════════════ EDITOR ═══════════════════════ -->
531
+ <div id="editor">
532
+ <header class="app-bar">
533
+ <div class="brand">
534
+ <svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true">
535
+ <rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/>
536
+ <rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
537
+ <rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
538
+ </svg>
539
+ <span class="wordmark">Screenshot Anonymizer</span>
540
+ </div>
541
+ <span class="version" id="meta-info">β€”</span>
542
+ <div class="spacer"></div>
543
+ <button class="btn-link" onclick="resetView()">new screenshot</button>
544
+ </header>
545
+
546
+ <div class="error-banner" id="error-banner"></div>
547
+
548
+ <div class="editor-body">
549
+ <div class="canvas-area">
550
+ <div class="canvas-scroll" id="canvas-scroll">
551
+ <div class="canvas-inner" id="canvas-inner">
552
+ <div class="ruler-corner"></div>
553
+ <canvas class="ruler-top" id="ruler-top"></canvas>
554
+ <canvas class="ruler-left" id="ruler-left"></canvas>
555
+ <div class="canvas-wrap mode-select" id="canvas-wrap">
556
+ <canvas id="canvas"></canvas>
557
+ </div>
558
+ </div>
559
+ </div>
560
+
561
+ <div class="zoom-tools">
562
+ <button title="Zoom out (βˆ’)" onclick="zoomStep(-1)">βˆ’</button>
563
+ <div class="sep"></div>
564
+ <span class="zoom-pct" id="zoom-pct">100%</span>
565
+ <div class="sep"></div>
566
+ <button title="Zoom in (+)" onclick="zoomStep(1)">+</button>
567
+ <div class="sep"></div>
568
+ <button title="Fit (F)" onclick="zoomFit()">fit</button>
569
+ <button title="100% (0)" onclick="zoomReset()">1:1</button>
570
+ </div>
571
+
572
+ <div class="status-bar">
573
+ <span class="k">zoom</span> <span class="v" id="status-zoom">100%</span>
574
+ <span class="sep">Β·</span>
575
+ <span class="k">cursor</span> <span class="v" id="status-cursor">β€”</span>
576
+ <span class="sep">Β·</span>
577
+ <span class="k">selection</span> <span class="v" id="status-sel">β€”</span>
578
+ <span class="spacer"></span>
579
+ <span class="k" id="status-mode">select</span>
580
+ </div>
581
+ </div>
582
+
583
+ <aside class="panel">
584
+
585
+ <section class="p-section">
586
+ <div class="p-label">Tool</div>
587
+ <div class="tools">
588
+ <button class="tool active" data-mode="select" title="Select (V)">
589
+ <svg viewBox="0 0 16 16"><path fill="currentColor" d="M3 2l10 5.5-4.5 1.5L7 13.5z"/></svg>
590
+ <span class="sc">V</span>
591
+ </button>
592
+ <button class="tool" data-mode="draw" title="Draw bar (B)">
593
+ <svg viewBox="0 0 16 16"><rect x="2" y="7" width="12" height="2" rx=".5" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-width="1" d="M2 4.5h12M2 11.5h12"/></svg>
594
+ <span class="sc">B</span>
595
+ </button>
596
+ <button class="tool" data-mode="eyedropper" title="Eyedropper β€” coming soon" disabled>
597
+ <svg viewBox="0 0 16 16"><path fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" d="M10 3l3 3M11.5 4.5L6 10l-2 .5L3 13l.5-1L9 6.5"/></svg>
598
+ <span class="sc">I</span>
599
+ </button>
600
+ <button class="tool" data-mode="crop" title="Crop β€” coming soon" disabled>
601
+ <svg viewBox="0 0 16 16"><path fill="none" stroke="currentColor" stroke-width="1.2" d="M4 1v11h11M1 4h11v11"/></svg>
602
+ <span class="sc">C</span>
603
+ </button>
604
+ </div>
605
+ <div class="tool-opts" id="tool-opts"></div>
606
+ </section>
607
+
608
+ <section class="p-section">
609
+ <div class="p-label">Detected</div>
610
+ <div class="summary" id="summary"><span class="num">0</span> bars across <span class="num">0</span> categories</div>
611
+ <div class="dist-bar" id="dist-bar"></div>
612
+ </section>
613
+
614
+ <section class="p-section">
615
+ <div class="p-label">Categories</div>
616
+ <div class="pills" id="pills">
617
+ <div class="pills-empty">no pii detected</div>
618
+ </div>
619
+ </section>
620
+
621
+ <section class="p-section">
622
+ <div class="p-label">Export</div>
623
+ <div class="export-row">
624
+ <button class="export-btn primary" onclick="downloadImage()">
625
+ <span>Download PNG</span>
626
+ <span class="sc"><kbd>⌘S</kbd></span>
627
+ </button>
628
+ <button class="export-btn" onclick="copyToClipboard()">
629
+ <span>Copy to clipboard</span>
630
+ <span class="sc"><kbd>βŒ˜β‡§C</kbd></span>
631
+ </button>
632
+ <button class="export-text-link" onclick="exportText()">Export sanitized text only β†’</button>
633
+ </div>
634
+ </section>
635
+
636
+ </aside>
637
+ </div>
638
+ </div>
639
+
640
+ <div id="loading"><div class="spinner"></div><p>ocr β†’ privacy filter β†’ map to pixels</p></div>
641
+ <div class="toast" id="toast"></div>
642
+
643
+ <script>
644
+ // ══════════════════════════════════════════════════════════════════
645
+ // State
646
+ // ══════════════════════════════════════════════════════════════════
647
+ const S = {
648
+ img: null, width: 0, height: 0,
649
+ boxes: [], // {id, x, y, w, h, label, text, enabled, custom}
650
+ nextId: 1,
651
+ activeCats: new Set(),
652
+ catMeta: {},
653
+ catCounts: {},
654
+ sourceText: '',
655
+ spans: [],
656
+ mode: 'select', // 'select' | 'draw'
657
+ selected: null,
658
+ drag: null, // {type:'draw'|'move', startX, startY, origBox?, newBox?, boxId?}
659
+ scale: 1,
660
+ filename: '',
661
+ };
662
+
663
+ 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'};
664
+ const CUSTOM_COLOR = '#9ca3af';
665
+
666
+ // ══════════════════════════════════════════════════════════════════
667
+ // Upload
668
+ // ══════════════════════════════════════════════════════════════════
669
+ const dz = document.getElementById('dropzone');
670
+ const fi = document.getElementById('file-input');
671
+ ['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); }));
672
+ ['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); }));
673
+ dz.addEventListener('drop', ev => { const f = ev.dataTransfer.files[0]; if (f) uploadFile(f); });
674
+ fi.addEventListener('change', ev => { const f = ev.target.files[0]; if (f) uploadFile(f); });
675
+
676
+ document.addEventListener('paste', ev => {
677
+ if (document.getElementById('landing').style.display === 'none') return;
678
+ const items = ev.clipboardData && ev.clipboardData.items;
679
+ if (!items) return;
680
+ for (const it of items) {
681
+ if (it.type && it.type.startsWith('image/')) {
682
+ const f = it.getAsFile();
683
+ if (f) { uploadFile(f); ev.preventDefault(); return; }
684
+ }
685
+ }
686
+ });
687
+
688
+ async function uploadFile(file) {
689
+ if (!file.type || !file.type.startsWith('image/')) { showError('please drop an image file.'); return; }
690
+ document.getElementById('loading').style.display = 'flex';
691
+ document.getElementById('landing').style.display = 'none';
692
+ const form = new FormData(); form.append('file', file);
693
+ try {
694
+ const r = await fetch('/api/detect', { method:'POST', body: form });
695
+ const d = await r.json();
696
+ if (d.error) { showError(d.error); return; }
697
+ await initEditor(d);
698
+ } catch (e) {
699
+ showError('analysis failed: ' + e.message);
700
+ } finally {
701
+ document.getElementById('loading').style.display = 'none';
702
+ }
703
+ }
704
+
705
+ function showError(msg) {
706
+ document.getElementById('loading').style.display = 'none';
707
+ document.getElementById('editor').style.display = 'flex';
708
+ const b = document.getElementById('error-banner');
709
+ b.textContent = msg; b.style.display = 'block';
710
+ }
711
+
712
+ function resetView() {
713
+ document.getElementById('editor').style.display = 'none';
714
+ document.getElementById('landing').style.display = 'flex';
715
+ document.getElementById('error-banner').style.display = 'none';
716
+ fi.value = '';
717
+ S.boxes = []; S.selected = null; S.img = null; S.drag = null;
718
+ }
719
+
720
+ // ══════════════════════════════════════════════════════════════════
721
+ // Editor init
722
+ // ══════════════════════════════════════════════════════════════════
723
+ async function initEditor(d) {
724
+ document.getElementById('editor').style.display = 'flex';
725
+ document.getElementById('error-banner').style.display = 'none';
726
+ S.filename = d.filename || 'screenshot.png';
727
+ S.width = d.width; S.height = d.height;
728
+ S.catMeta = d.categories_meta || {};
729
+ S.sourceText = d.text || '';
730
+ S.spans = d.spans || [];
731
+ S.catCounts = {};
732
+ S.boxes = (d.boxes || []).map(b => {
733
+ S.catCounts[b.label] = (S.catCounts[b.label] || 0) + 1;
734
+ return { id: S.nextId++, x: b.x, y: b.y, w: b.w, h: b.h,
735
+ label: b.label, text: b.text, enabled: true, custom: false };
736
+ });
737
+ S.activeCats = new Set(Object.keys(S.catCounts));
738
+
739
+ const img = new Image();
740
+ await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = d.image; });
741
+ S.img = img;
742
+
743
+ setMode('select', true);
744
+ zoomFit();
745
+ renderPills();
746
+ renderToolOpts();
747
+ renderSummary();
748
+ updateMeta();
749
+ draw();
750
+ }
751
+
752
+ // ══════════════════════════════════════════════════════════════════
753
+ // Zoom + layout
754
+ // ══════════════════════════════════════════════════════════════════
755
+ function applyScale() {
756
+ const cv = document.getElementById('canvas');
757
+ cv.width = S.width; cv.height = S.height;
758
+ cv.style.width = (S.width * S.scale) + 'px';
759
+ cv.style.height = (S.height * S.scale) + 'px';
760
+ drawRulers();
761
+ document.getElementById('zoom-pct').textContent = Math.round(S.scale * 100) + '%';
762
+ document.getElementById('status-zoom').textContent = Math.round(S.scale * 100) + '%';
763
+ }
764
+
765
+ function zoomFit() {
766
+ const sc = document.getElementById('canvas-scroll');
767
+ const pad = 72;
768
+ const maxW = sc.clientWidth - pad, maxH = sc.clientHeight - pad;
769
+ const s = Math.min(1, maxW / S.width, maxH / S.height);
770
+ S.scale = Math.max(0.1, s);
771
+ applyScale(); draw();
772
+ }
773
+ function zoomReset() { S.scale = 1; applyScale(); draw(); }
774
+ function zoomStep(dir) {
775
+ const steps = [0.1,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,3,4];
776
+ let i = steps.findIndex(s => s >= S.scale - 0.001);
777
+ if (i < 0) i = 0;
778
+ i = Math.max(0, Math.min(steps.length - 1, i + dir));
779
+ S.scale = steps[i]; applyScale(); draw();
780
+ }
781
+
782
+ window.addEventListener('resize', () => { if (S.img) { applyScale(); draw(); } });
783
+
784
+ // ══════════════════════════════════════════════════════════════════
785
+ // Rulers
786
+ // ══════════════════════════════════════════════════════════════════
787
+ function cssVar(name) {
788
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
789
+ }
790
+
791
+ function drawRulers() {
792
+ const tc = document.getElementById('ruler-top');
793
+ const lc = document.getElementById('ruler-left');
794
+ const w = Math.ceil(S.width * S.scale);
795
+ const h = Math.ceil(S.height * S.scale);
796
+ const dpr = window.devicePixelRatio || 1;
797
+ const color = cssVar('--text3') || '#65656c';
798
+ const colorStrong = cssVar('--text2') || '#9a9a9f';
799
+ const border = cssVar('--border') || 'rgba(255,255,255,.06)';
800
+
801
+ // Top
802
+ tc.style.width = w + 'px'; tc.style.height = '20px';
803
+ tc.width = w * dpr; tc.height = 20 * dpr;
804
+ const tx = tc.getContext('2d'); tx.scale(dpr, dpr);
805
+ tx.clearRect(0, 0, w, 20);
806
+ tx.fillStyle = 'transparent';
807
+ tx.strokeStyle = border; tx.lineWidth = 1;
808
+ tx.beginPath(); tx.moveTo(0, 19.5); tx.lineTo(w, 19.5); tx.stroke();
809
+ tx.fillStyle = color;
810
+ tx.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace');
811
+ tx.textBaseline = 'middle';
812
+ const step = pickRulerStep(S.scale);
813
+ for (let x = 0; x <= S.width; x += step.minor) {
814
+ const sx = Math.round(x * S.scale) + 0.5;
815
+ const th = x % step.major === 0 ? 8 : x % (step.minor * 5) === 0 ? 5 : 3;
816
+ tx.fillStyle = x % step.major === 0 ? colorStrong : color;
817
+ tx.fillRect(sx, 20 - th, 1, th);
818
+ if (x % step.major === 0 && x > 0 && sx + 30 < w) {
819
+ tx.fillStyle = colorStrong;
820
+ tx.fillText(String(x), sx + 2, 7);
821
+ }
822
+ }
823
+
824
+ // Left
825
+ lc.style.width = '24px'; lc.style.height = h + 'px';
826
+ lc.width = 24 * dpr; lc.height = h * dpr;
827
+ const ly = lc.getContext('2d'); ly.scale(dpr, dpr);
828
+ ly.clearRect(0, 0, 24, h);
829
+ ly.strokeStyle = border; ly.lineWidth = 1;
830
+ ly.beginPath(); ly.moveTo(23.5, 0); ly.lineTo(23.5, h); ly.stroke();
831
+ ly.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace');
832
+ ly.textBaseline = 'middle';
833
+ for (let y = 0; y <= S.height; y += step.minor) {
834
+ const sy = Math.round(y * S.scale) + 0.5;
835
+ const tw = y % step.major === 0 ? 8 : y % (step.minor * 5) === 0 ? 5 : 3;
836
+ ly.fillStyle = y % step.major === 0 ? colorStrong : color;
837
+ ly.fillRect(24 - tw, sy, tw, 1);
838
+ if (y % step.major === 0 && y > 0 && sy + 20 < h) {
839
+ ly.save();
840
+ ly.fillStyle = colorStrong;
841
+ ly.translate(10, sy + 2);
842
+ ly.rotate(-Math.PI / 2);
843
+ ly.fillText(String(y), 0, 0);
844
+ ly.restore();
845
+ }
846
+ }
847
+ }
848
+
849
+ function pickRulerStep(scale) {
850
+ // pick minor tick spacing so adjacent ticks are roughly 8-15 screen px apart
851
+ const candidates = [5, 10, 20, 50, 100, 200, 500];
852
+ let minor = 10;
853
+ for (const c of candidates) {
854
+ if (c * scale >= 8) { minor = c; break; }
855
+ }
856
+ const major = minor * 10;
857
+ return { minor, major };
858
+ }
859
+
860
+ // ══════════════════════════════════════════════════════════════════
861
+ // Draw
862
+ // ══════════════════════════════════════════════════════════════════
863
+ function isVisible(b) {
864
+ if (!b.enabled) return false;
865
+ if (!b.custom && !S.activeCats.has(b.label)) return false;
866
+ return true;
867
+ }
868
+
869
+ function draw() {
870
+ if (!S.img) return;
871
+ const cv = document.getElementById('canvas');
872
+ const ctx = cv.getContext('2d');
873
+ ctx.clearRect(0, 0, cv.width, cv.height);
874
+ ctx.drawImage(S.img, 0, 0);
875
+
876
+ for (const b of S.boxes) {
877
+ if (!isVisible(b)) continue;
878
+ ctx.fillStyle = '#000';
879
+ ctx.fillRect(b.x, b.y, b.w, b.h);
880
+ }
881
+
882
+ const sel = selectedBox();
883
+ if (sel) {
884
+ ctx.save();
885
+ ctx.strokeStyle = cssVar('--accent') || '#818cf8';
886
+ ctx.lineWidth = Math.max(1, 1.5 / S.scale);
887
+ ctx.setLineDash([5 / S.scale, 3 / S.scale]);
888
+ ctx.strokeRect(sel.x - 1, sel.y - 1, sel.w + 2, sel.h + 2);
889
+ ctx.restore();
890
+ }
891
+
892
+ if (S.drag && S.drag.type === 'draw' && S.drag.newBox) {
893
+ const b = S.drag.newBox;
894
+ ctx.save();
895
+ ctx.fillStyle = 'rgba(0,0,0,.7)';
896
+ ctx.fillRect(b.x, b.y, b.w, b.h);
897
+ ctx.strokeStyle = cssVar('--accent') || '#818cf8';
898
+ ctx.lineWidth = Math.max(1, 1 / S.scale);
899
+ ctx.strokeRect(b.x, b.y, b.w, b.h);
900
+ ctx.restore();
901
+ }
902
+ }
903
+
904
+ function selectedBox() {
905
+ return S.selected == null ? null : (S.boxes.find(b => b.id === S.selected) || null);
906
+ }
907
+
908
+ // ══════════════════════════════════════════════════════════════════
909
+ // Interaction
910
+ // ══════════════════════════════════════════════════════════════════
911
+ const wrap = document.getElementById('canvas-wrap');
912
+
913
+ function canvasXY(ev) {
914
+ const rect = wrap.getBoundingClientRect();
915
+ return {
916
+ x: (ev.clientX - rect.left) / S.scale,
917
+ y: (ev.clientY - rect.top) / S.scale,
918
+ };
919
+ }
920
+
921
+ function hitTest(x, y) {
922
+ for (let i = S.boxes.length - 1; i >= 0; i--) {
923
+ const b = S.boxes[i];
924
+ if (!isVisible(b)) continue;
925
+ if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return b;
926
+ }
927
+ return null;
928
+ }
929
+
930
+ wrap.addEventListener('mousedown', ev => {
931
+ if (ev.button !== 0) return;
932
+ ev.preventDefault();
933
+ const { x, y } = canvasXY(ev);
934
+ const hit = hitTest(x, y);
935
+
936
+ if (S.mode === 'draw' && !hit) {
937
+ S.selected = null;
938
+ S.drag = { type: 'draw', startX: x, startY: y, newBox: { x, y, w: 0, h: 0 } };
939
+ } else if (hit) {
940
+ S.selected = hit.id;
941
+ S.drag = { type: 'move', startX: x, startY: y,
942
+ origBox: { x: hit.x, y: hit.y, w: hit.w, h: hit.h }, boxId: hit.id };
943
+ wrap.classList.add('dragging');
944
+ } else {
945
+ S.selected = null; S.drag = null;
946
+ }
947
+ updateStatus(x, y);
948
+ draw();
949
+ });
950
+
951
+ window.addEventListener('mousemove', ev => {
952
+ if (S.img && document.getElementById('editor').style.display === 'flex') {
953
+ const rect = wrap.getBoundingClientRect();
954
+ if (ev.clientX >= rect.left && ev.clientX <= rect.right &&
955
+ ev.clientY >= rect.top && ev.clientY <= rect.bottom) {
956
+ const x = Math.round((ev.clientX - rect.left) / S.scale);
957
+ const y = Math.round((ev.clientY - rect.top) / S.scale);
958
+ updateStatus(x, y);
959
+ if (!S.drag) {
960
+ const hit = hitTest(x, y);
961
+ wrap.classList.toggle('over-box', !!hit);
962
+ }
963
+ }
964
+ }
965
+
966
+ if (!S.drag) return;
967
+ const { x, y } = canvasXY(ev);
968
+ if (S.drag.type === 'draw') {
969
+ const sx = S.drag.startX, sy = S.drag.startY;
970
+ S.drag.newBox = {
971
+ x: Math.max(0, Math.min(S.width, Math.min(sx, x))),
972
+ y: Math.max(0, Math.min(S.height, Math.min(sy, y))),
973
+ w: Math.min(Math.abs(x - sx), S.width),
974
+ h: Math.min(Math.abs(y - sy), S.height),
975
+ };
976
+ } else if (S.drag.type === 'move') {
977
+ const dx = x - S.drag.startX, dy = y - S.drag.startY;
978
+ const b = S.boxes.find(b => b.id === S.drag.boxId);
979
+ if (b) {
980
+ const o = S.drag.origBox;
981
+ b.x = Math.max(0, Math.min(S.width - o.w, Math.round(o.x + dx)));
982
+ b.y = Math.max(0, Math.min(S.height - o.h, Math.round(o.y + dy)));
983
+ }
984
+ }
985
+ draw();
986
+ });
987
+
988
+ window.addEventListener('mouseup', () => {
989
+ if (!S.drag) return;
990
+ wrap.classList.remove('dragging');
991
+ if (S.drag.type === 'draw') {
992
+ const b = S.drag.newBox;
993
+ if (b.w > 3 && b.h > 3) {
994
+ const nb = { id: S.nextId++, x: Math.round(b.x), y: Math.round(b.y),
995
+ w: Math.round(b.w), h: Math.round(b.h),
996
+ label: 'custom', text: '', enabled: true, custom: true };
997
+ S.boxes.push(nb);
998
+ S.selected = nb.id;
999
+ renderSummary();
1000
+ }
1001
+ }
1002
+ S.drag = null;
1003
+ draw();
1004
+ });
1005
+
1006
+ // Keyboard
1007
+ window.addEventListener('keydown', ev => {
1008
+ if (document.getElementById('editor').style.display !== 'flex') return;
1009
+ const tgt = ev.target;
1010
+ if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return;
1011
+
1012
+ const cmd = ev.metaKey || ev.ctrlKey;
1013
+ if (cmd && ev.key.toLowerCase() === 's') { ev.preventDefault(); downloadImage(); return; }
1014
+ if (cmd && ev.shiftKey && ev.key.toLowerCase() === 'c') { ev.preventDefault(); copyToClipboard(); return; }
1015
+
1016
+ if (ev.key === 'Delete' || ev.key === 'Backspace') {
1017
+ if (S.selected != null) {
1018
+ S.boxes = S.boxes.filter(b => b.id !== S.selected);
1019
+ S.selected = null;
1020
+ renderSummary(); draw();
1021
+ ev.preventDefault();
1022
+ }
1023
+ } else if (ev.key === 'Escape') {
1024
+ S.selected = null; S.drag = null; draw();
1025
+ } else if (ev.key === 'v' || ev.key === 'V') setMode('select');
1026
+ else if (ev.key === 'b' || ev.key === 'B') setMode('draw');
1027
+ else if (ev.key === 'f' || ev.key === 'F') zoomFit();
1028
+ else if (ev.key === '0') zoomReset();
1029
+ else if (ev.key === '+' || ev.key === '=') zoomStep(1);
1030
+ else if (ev.key === '-' || ev.key === '_') zoomStep(-1);
1031
+ });
1032
+
1033
+ function updateStatus(x, y) {
1034
+ document.getElementById('status-cursor').textContent = `${x}, ${y}`;
1035
+ const sel = selectedBox();
1036
+ document.getElementById('status-sel').textContent = sel ? `${sel.w}Γ—${sel.h}` : 'β€”';
1037
+ }
1038
+
1039
+ // ══════════════════════════════════════════════════════════════════
1040
+ // Tool mode
1041
+ // ══════════════════════════════════════════════════════════════════
1042
+ document.querySelectorAll('.tool').forEach(btn => {
1043
+ btn.addEventListener('click', () => {
1044
+ if (btn.disabled) return;
1045
+ setMode(btn.dataset.mode);
1046
+ });
1047
+ });
1048
+
1049
+ function setMode(m, silent) {
1050
+ S.mode = m;
1051
+ document.querySelectorAll('.tool').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
1052
+ wrap.classList.toggle('mode-select', m === 'select');
1053
+ wrap.classList.toggle('mode-draw', m === 'draw');
1054
+ document.getElementById('status-mode').textContent = m;
1055
+ renderToolOpts();
1056
+ if (!silent) updateStatus(0, 0);
1057
+ }
1058
+
1059
+ function renderToolOpts() {
1060
+ const el = document.getElementById('tool-opts');
1061
+ if (S.mode === 'select') {
1062
+ el.innerHTML = `
1063
+ <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>
1064
+ <div class="kb"><kbd>V</kbd> select <span class="sep">Β·</span> <kbd>B</kbd> draw <span class="sep">Β·</span> <kbd>Esc</kbd> clear</div>`;
1065
+ } else if (S.mode === 'draw') {
1066
+ el.innerHTML = `
1067
+ <div class="hint">Drag on empty canvas to add a black bar. Release to confirm.</div>
1068
+ <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>`;
1069
+ } else {
1070
+ el.innerHTML = `<div class="hint">coming soon.</div>`;
1071
+ }
1072
+ }
1073
+
1074
+ // ══════════════════════════════════════════════════════════════════
1075
+ // Side panel rendering
1076
+ // ══════════════════════════════════════════════════════════════════
1077
+ function updateMeta() {
1078
+ const parts = [S.filename, `${S.width}Γ—${S.height}`];
1079
+ document.getElementById('meta-info').textContent = parts.join(' Β· ');
1080
+ }
1081
+
1082
+ function renderSummary() {
1083
+ const visible = S.boxes.filter(isVisible);
1084
+ const catsActive = new Set();
1085
+ for (const b of visible) catsActive.add(b.custom ? 'custom' : b.label);
1086
+ document.getElementById('summary').innerHTML =
1087
+ `<span class="num">${visible.length}</span> bar${visible.length === 1 ? '' : 's'} across <span class="num">${catsActive.size}</span> categor${catsActive.size === 1 ? 'y' : 'ies'}`;
1088
+
1089
+ const dist = document.getElementById('dist-bar');
1090
+ dist.innerHTML = '';
1091
+ if (!visible.length) return;
1092
+ const counts = {};
1093
+ for (const b of visible) {
1094
+ const k = b.custom ? 'custom' : b.label;
1095
+ counts[k] = (counts[k] || 0) + 1;
1096
+ }
1097
+ const total = visible.length;
1098
+ for (const [k, n] of Object.entries(counts)) {
1099
+ const seg = document.createElement('div');
1100
+ seg.className = 'dist-seg';
1101
+ seg.style.width = (n / total * 100) + '%';
1102
+ seg.style.background = (k === 'custom' ? CUSTOM_COLOR : (S.catMeta[k] && S.catMeta[k].color)) || '#888';
1103
+ dist.appendChild(seg);
1104
+ }
1105
+ }
1106
+
1107
+ function renderPills() {
1108
+ const container = document.getElementById('pills');
1109
+ const cats = Object.keys(S.catCounts);
1110
+ if (!cats.length) {
1111
+ container.innerHTML = '<div class="pills-empty">no pii detected</div>';
1112
+ return;
1113
+ }
1114
+ container.innerHTML = '';
1115
+ for (const cat of cats) {
1116
+ const meta = S.catMeta[cat] || { color: '#888', label: cat };
1117
+ const active = S.activeCats.has(cat);
1118
+ const pill = document.createElement('div');
1119
+ pill.className = 'pill ' + (active ? 'active' : 'inactive');
1120
+ pill.innerHTML = `
1121
+ <span class="swatch" style="background:${meta.color}"></span>
1122
+ <span class="name">${meta.label}</span>
1123
+ <span class="count">${S.catCounts[cat]}</span>`;
1124
+ pill.addEventListener('click', () => {
1125
+ if (S.activeCats.has(cat)) S.activeCats.delete(cat);
1126
+ else S.activeCats.add(cat);
1127
+ renderPills(); renderSummary(); draw();
1128
+ });
1129
+ container.appendChild(pill);
1130
+ }
1131
+ }
1132
+
1133
+ // ══════════════════════════════════════════════════════════════════
1134
+ // Export
1135
+ // ══════════════════════════════════════════════════════════════════
1136
+ function renderExportCanvas() {
1137
+ const ec = document.createElement('canvas');
1138
+ ec.width = S.width; ec.height = S.height;
1139
+ const ctx = ec.getContext('2d');
1140
+ ctx.drawImage(S.img, 0, 0);
1141
+ ctx.fillStyle = '#000';
1142
+ for (const b of S.boxes) if (isVisible(b)) ctx.fillRect(b.x, b.y, b.w, b.h);
1143
+ return ec;
1144
+ }
1145
+
1146
+ function downloadImage() {
1147
+ const ec = renderExportCanvas();
1148
+ ec.toBlob(blob => {
1149
+ const a = document.createElement('a');
1150
+ const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, '');
1151
+ a.download = base + '-redacted.png';
1152
+ a.href = URL.createObjectURL(blob);
1153
+ a.click();
1154
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1155
+ toast('saved ' + a.download);
1156
+ }, 'image/png');
1157
+ }
1158
+
1159
+ async function copyToClipboard() {
1160
+ const ec = renderExportCanvas();
1161
+ try {
1162
+ await new Promise((res, rej) => {
1163
+ ec.toBlob(async blob => {
1164
+ try {
1165
+ if (!navigator.clipboard || !window.ClipboardItem) { rej(new Error('clipboard api not supported')); return; }
1166
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
1167
+ res();
1168
+ } catch (e) { rej(e); }
1169
+ }, 'image/png');
1170
+ });
1171
+ toast('copied to clipboard');
1172
+ } catch (e) {
1173
+ toast('copy failed Β· ' + e.message);
1174
+ }
1175
+ }
1176
+
1177
+ function exportText() {
1178
+ // Build redacted plaintext by replacing detected spans with [REDACTED label]
1179
+ let out = '', pos = 0;
1180
+ const spans = [...S.spans].filter(s => S.activeCats.has(s.label)).sort((a, b) => a.start - b.start);
1181
+ for (const sp of spans) {
1182
+ if (sp.start < pos) continue;
1183
+ out += S.sourceText.slice(pos, sp.start) + `[${(LABELS[sp.label] || sp.label).toLowerCase()}]`;
1184
+ pos = sp.end;
1185
+ }
1186
+ out += S.sourceText.slice(pos);
1187
+ const blob = new Blob([out], { type: 'text/plain' });
1188
+ const a = document.createElement('a');
1189
+ const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, '');
1190
+ a.download = base + '-redacted.txt';
1191
+ a.href = URL.createObjectURL(blob);
1192
+ a.click();
1193
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1194
+ toast('saved ' + a.download);
1195
+ }
1196
+
1197
+ let toastTimer = null;
1198
+ function toast(msg) {
1199
+ const t = document.getElementById('toast');
1200
+ t.textContent = msg;
1201
+ t.classList.add('show');
1202
+ clearTimeout(toastTimer);
1203
+ toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
1204
+ }
1205
+ </script>
1206
+ </body>
1207
+ </html>"""
1208
+
1209
+
1210
+ if __name__ == "__main__":
1211
+ server.launch(server_name="0.0.0.0", server_port=7860)