@ggalmazor/decoy 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1790 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { Command, InvalidArgumentError } from "commander";
4
+ import { GlobalFonts, ImageData as ImageData$1, createCanvas, loadImage } from "@napi-rs/canvas";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { existsSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ //#region src/core/rng.ts
13
+ function mulberry32(seed) {
14
+ let a = seed >>> 0;
15
+ return () => {
16
+ a = a + 1831565813 >>> 0;
17
+ let t = a;
18
+ t = Math.imul(t ^ t >>> 15, t | 1);
19
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
20
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
21
+ };
22
+ }
23
+ function lcg(seed) {
24
+ let s = seed >>> 0;
25
+ return () => {
26
+ s = s * 1664525 + 1013904223 >>> 0;
27
+ return s / 4294967296;
28
+ };
29
+ }
30
+ function hashString(text, seed = 7) {
31
+ let h = seed >>> 0;
32
+ for (let i = 0; i < text.length; i++) h = Math.imul(h, 31) + text.charCodeAt(i) >>> 0;
33
+ return h;
34
+ }
35
+ function randomSeed() {
36
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
37
+ const buf = /* @__PURE__ */ new Uint32Array(1);
38
+ crypto.getRandomValues(buf);
39
+ return buf[0];
40
+ }
41
+ return Math.floor(Math.random() * 4294967296) >>> 0;
42
+ }
43
+ function shuffle(rng, items) {
44
+ const out = items.slice();
45
+ for (let i = out.length - 1; i > 0; i--) {
46
+ const j = Math.floor(rng() * (i + 1));
47
+ [out[i], out[j]] = [out[j], out[i]];
48
+ }
49
+ return out;
50
+ }
51
+ //#endregion
52
+ //#region src/core/grawlix.ts
53
+ var PROFANE = [
54
+ "fucking",
55
+ "fuck",
56
+ "shithead",
57
+ "dipshit",
58
+ "shit",
59
+ "piss",
60
+ "bugger",
61
+ "bollocks",
62
+ "sod",
63
+ "fu"
64
+ ];
65
+ var SYMBOLS = [
66
+ "@",
67
+ "#",
68
+ "$",
69
+ "%",
70
+ "&",
71
+ "*",
72
+ "!"
73
+ ];
74
+ var VOWELS = /* @__PURE__ */ new Set([
75
+ "a",
76
+ "e",
77
+ "i",
78
+ "o",
79
+ "u"
80
+ ]);
81
+ var pattern = new RegExp(`\\b(${PROFANE.join("|")})\\b`, "gi");
82
+ function grawlixWord(word, rng) {
83
+ const letters = word.split("");
84
+ let masked = 0;
85
+ for (let i = 1; i < letters.length; i++) if (VOWELS.has(letters[i].toLowerCase())) {
86
+ letters[i] = SYMBOLS[Math.floor(rng() * SYMBOLS.length)];
87
+ masked++;
88
+ }
89
+ if (masked === 0 && letters.length > 1) letters[1] = SYMBOLS[Math.floor(rng() * SYMBOLS.length)];
90
+ return letters.join("");
91
+ }
92
+ function grawlix(text) {
93
+ const rng = mulberry32(hashString(text, 11));
94
+ return text.replace(pattern, (word) => grawlixWord(word, rng));
95
+ }
96
+ //#endregion
97
+ //#region src/core/color.ts
98
+ var INK_FALLBACK = [
99
+ 23,
100
+ 20,
101
+ 15
102
+ ];
103
+ var PAPER_FALLBACK = [
104
+ 246,
105
+ 241,
106
+ 230
107
+ ];
108
+ function parseHex(value, fallback) {
109
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(value ?? "").trim());
110
+ if (!m) return fallback;
111
+ const v = parseInt(m[1], 16);
112
+ return [
113
+ v >> 16 & 255,
114
+ v >> 8 & 255,
115
+ v & 255
116
+ ];
117
+ }
118
+ function toHex(rgb) {
119
+ return "#" + rgb.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("").toUpperCase();
120
+ }
121
+ function luma(r, g, b) {
122
+ return .2126 * r + .7152 * g + .0722 * b;
123
+ }
124
+ function lerpColor(from, to, t) {
125
+ return [
126
+ Math.round(from[0] + (to[0] - from[0]) * t),
127
+ Math.round(from[1] + (to[1] - from[1]) * t),
128
+ Math.round(from[2] + (to[2] - from[2]) * t)
129
+ ];
130
+ }
131
+ //#endregion
132
+ //#region src/core/mosaic.ts
133
+ var MAX_DARKNESS = .9;
134
+ var JITTER = .06;
135
+ function mosaicGrid(w, h, cell) {
136
+ return {
137
+ cols: Math.max(1, Math.round(w / cell)),
138
+ rows: Math.max(1, Math.round(h / cell))
139
+ };
140
+ }
141
+ function mosaicSeed(text) {
142
+ return hashString(text, 7);
143
+ }
144
+ function coverageFromRgba(rgba, w, h) {
145
+ const out = new Float32Array(w * h);
146
+ for (let i = 0; i < w * h; i++) out[i] = 1 - rgba[i * 4] / 255;
147
+ return out;
148
+ }
149
+ function blockDarkness(coverage, w, h, cols, rows) {
150
+ const dark = new Float64Array(cols * rows);
151
+ for (let y = 0; y < h; y++) {
152
+ const r = Math.min(rows - 1, Math.floor(y * rows / h));
153
+ for (let x = 0; x < w; x++) {
154
+ const c = Math.min(cols - 1, Math.floor(x * cols / w));
155
+ dark[r * cols + c] += coverage[y * w + x];
156
+ }
157
+ }
158
+ const area = w / cols * (h / rows);
159
+ for (let k = 0; k < dark.length; k++) dark[k] = dark[k] / area;
160
+ return dark;
161
+ }
162
+ function mosaicCells(darkness, ink, paper, seed) {
163
+ const rnd = lcg(seed);
164
+ let max = 0;
165
+ for (let k = 0; k < darkness.length; k++) if (darkness[k] > max) max = darkness[k];
166
+ const cells = new Uint8ClampedArray(darkness.length * 3);
167
+ for (let k = 0; k < darkness.length; k++) {
168
+ let t = max > 0 ? Math.min(1, darkness[k] / max * MAX_DARKNESS) : 0;
169
+ t = Math.max(0, Math.min(1, t + (rnd() - .5) * JITTER));
170
+ const [r, g, b] = lerpColor(paper, ink, t);
171
+ cells[k * 3] = r;
172
+ cells[k * 3 + 1] = g;
173
+ cells[k * 3 + 2] = b;
174
+ }
175
+ return cells;
176
+ }
177
+ function rasterizeCells(cells, cols, rows, w, h) {
178
+ const out = new Uint8ClampedArray(w * h * 4);
179
+ for (let r = 0; r < rows; r++) {
180
+ const y0 = Math.round(r * h / rows);
181
+ const y1 = r === rows - 1 ? h : Math.round((r + 1) * h / rows);
182
+ for (let c = 0; c < cols; c++) {
183
+ const x0 = Math.round(c * w / cols);
184
+ const x1 = c === cols - 1 ? w : Math.round((c + 1) * w / cols);
185
+ const k = (r * cols + c) * 3;
186
+ const red = cells[k];
187
+ const green = cells[k + 1];
188
+ const blue = cells[k + 2];
189
+ for (let y = y0; y < y1; y++) {
190
+ let i = (y * w + x0) * 4;
191
+ for (let x = x0; x < x1; x++) {
192
+ out[i] = red;
193
+ out[i + 1] = green;
194
+ out[i + 2] = blue;
195
+ out[i + 3] = 255;
196
+ i += 4;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ function mosaicFromCoverage(coverage, params) {
204
+ const { w, h, cell, ink, paper, seed } = params;
205
+ const { cols, rows } = mosaicGrid(w, h, cell);
206
+ return rasterizeCells(mosaicCells(blockDarkness(coverage, w, h, cols, rows), ink, paper, seed), cols, rows, w, h);
207
+ }
208
+ //#endregion
209
+ //#region src/core/phrases.ts
210
+ var MOODS = [
211
+ "rude",
212
+ "smug",
213
+ "corporate",
214
+ "wholesome"
215
+ ];
216
+ var DEFAULT_MOOD = "smug";
217
+ var p = (text) => ({
218
+ text,
219
+ profane: true
220
+ });
221
+ var c = (text) => ({
222
+ text,
223
+ profane: false
224
+ });
225
+ var s = (text, profane = false) => profane ? {
226
+ text,
227
+ profane
228
+ } : { text };
229
+ var DATA = {
230
+ rude: {
231
+ fixed: [
232
+ p("fuck off"),
233
+ p("fuck right off"),
234
+ p("kindly fuck off"),
235
+ p("piss off, nerd"),
236
+ p("sod off"),
237
+ p("bugger off"),
238
+ p("nice try, dipshit"),
239
+ p("fuck off and enhance that"),
240
+ p("absolutely fucking not"),
241
+ p("not a chance, shithead"),
242
+ p("eat shit, snoop"),
243
+ p("go fuck a cactus"),
244
+ p("bollocks to you"),
245
+ p("piss off and zoom out"),
246
+ c("none of your business"),
247
+ c("mind your own business"),
248
+ c("get your own password"),
249
+ c("go away"),
250
+ c("absolutely not, mate"),
251
+ c("who asked?"),
252
+ c("back off, creep"),
253
+ c("get a life"),
254
+ c("stop squinting"),
255
+ c("zoom out, loser"),
256
+ c("not today, buddy"),
257
+ c("keep dreaming"),
258
+ c("how about no"),
259
+ c("take a hike"),
260
+ c("get lost"),
261
+ c("bite me"),
262
+ c("nosy little gremlin"),
263
+ c("eat glass, snoop"),
264
+ c("nope, nosy"),
265
+ c("go bother someone else"),
266
+ c("cry about it"),
267
+ c("you wish, creep"),
268
+ c("nothing here for you, weirdo"),
269
+ c("stop it, get some help"),
270
+ c("not yours"),
271
+ c("buzz off"),
272
+ c("shove off"),
273
+ c("do one, pal"),
274
+ c("no. go away."),
275
+ p("sod it"),
276
+ p("piss off")
277
+ ],
278
+ templates: [{
279
+ pattern: "{off} off, {target}",
280
+ slots: {
281
+ off: [
282
+ s("fuck", true),
283
+ s("piss", true),
284
+ s("sod", true),
285
+ s("bugger", true),
286
+ s("back"),
287
+ s("buzz"),
288
+ s("shove")
289
+ ],
290
+ target: [
291
+ s("nerd"),
292
+ s("creep"),
293
+ s("buddy"),
294
+ s("pal"),
295
+ s("snoop"),
296
+ s("detective"),
297
+ s("hacker"),
298
+ s("weirdo")
299
+ ]
300
+ }
301
+ }]
302
+ },
303
+ smug: {
304
+ fixed: [
305
+ c("you thought this was a password?"),
306
+ c("nice try, enhance harder"),
307
+ c("enhance harder, detective"),
308
+ c("wow, you really zoomed in"),
309
+ c("nothing to see here, nerd"),
310
+ c("lol no"),
311
+ c("imagine trying this hard"),
312
+ c("you've been decoyed"),
313
+ c("this is not the password"),
314
+ c("still not the password"),
315
+ c("keep squinting"),
316
+ c("nope, try again"),
317
+ c("did you really think that'd work?"),
318
+ c("the real one is in my head"),
319
+ c("congratulations, you found nothing"),
320
+ c("10/10 effort, 0/10 result"),
321
+ c("zoom and enhance... nope"),
322
+ c("this took you how long?"),
323
+ c("plot twist: it's not here"),
324
+ c("your depixelizer is proud of you"),
325
+ c("adorable attempt"),
326
+ c("imagine trying this hard, lol"),
327
+ c("so much effort, so little reward"),
328
+ c("the pixels were never the point"),
329
+ c("decoded: a waste of your time"),
330
+ c("you un-blurred a joke"),
331
+ c("next time try asking nicely"),
332
+ c("that was the decoy, genius"),
333
+ c("close, but entirely wrong"),
334
+ c("you're reading a decoy right now"),
335
+ c("great tool, wrong target"),
336
+ c("a+ for effort, f for outcome"),
337
+ c("hope the enhance was worth it"),
338
+ c("all that compute, for this"),
339
+ c("nice try"),
340
+ c("lol nope"),
341
+ c("try again"),
342
+ c("not today"),
343
+ c("no chance"),
344
+ c("cute try"),
345
+ c("nope, nerd"),
346
+ c("so close"),
347
+ c("good luck"),
348
+ c("keep going"),
349
+ c("enhance this")
350
+ ],
351
+ templates: [{
352
+ pattern: "{opener}, {target}",
353
+ slots: {
354
+ opener: [
355
+ s("nice try"),
356
+ s("good effort"),
357
+ s("so close"),
358
+ s("bless your heart"),
359
+ s("adorable attempt"),
360
+ s("almost")
361
+ ],
362
+ target: [
363
+ s("detective"),
364
+ s("nerd"),
365
+ s("sherlock"),
366
+ s("genius"),
367
+ s("buddy"),
368
+ s("champ"),
369
+ s("inspector")
370
+ ]
371
+ }
372
+ }]
373
+ },
374
+ corporate: {
375
+ fixed: [
376
+ c("per my last email: no"),
377
+ c("let's take this offline"),
378
+ c("circling back: no"),
379
+ c("this is above your pay grade"),
380
+ c("please see attached nothing"),
381
+ c("as previously discussed, no"),
382
+ c("let's park this indefinitely"),
383
+ c("not a priority this quarter"),
384
+ c("request denied by legal"),
385
+ c("out of office, forever"),
386
+ c("synergy not found"),
387
+ c("we'll circle back never"),
388
+ c("this has been escalated to nobody"),
389
+ c("thanks for your interest. no."),
390
+ c("ticket closed: won’t fix"),
391
+ c("HR has been notified"),
392
+ c("noted, and ignored"),
393
+ c("per policy, no"),
394
+ c("not in scope"),
395
+ c("kindly revert with nothing"),
396
+ c("moving forward, no"),
397
+ c("pending approval until the heat death"),
398
+ c("access denied, see your manager"),
399
+ c("this meeting could have been a no"),
400
+ c("aligned on: nothing for you"),
401
+ c("redacted per compliance"),
402
+ c("let me loop in nobody"),
403
+ c("low priority, high nope"),
404
+ c("we value your curiosity. no."),
405
+ c("action item: stop"),
406
+ c("hard pass"),
407
+ c("declined"),
408
+ c("noted. no."),
409
+ c("no budget"),
410
+ c("see policy"),
411
+ c("per policy"),
412
+ c("out of scope"),
413
+ c("n/a"),
414
+ c("parked"),
415
+ c("escalated")
416
+ ],
417
+ templates: [{
418
+ pattern: "{opener}: {verdict}",
419
+ slots: {
420
+ opener: [
421
+ s("per my last email"),
422
+ s("as discussed"),
423
+ s("circling back"),
424
+ s("just flagging"),
425
+ s("quick sync"),
426
+ s("to close the loop"),
427
+ s("friendly reminder")
428
+ ],
429
+ verdict: [
430
+ s("no"),
431
+ s("still no"),
432
+ s("hard pass"),
433
+ s("won't fix"),
434
+ s("not happening"),
435
+ s("declined")
436
+ ]
437
+ }
438
+ }]
439
+ },
440
+ wholesome: {
441
+ fixed: [
442
+ c("go drink some water"),
443
+ c("you're doing great, sweetie"),
444
+ c("have you eaten today?"),
445
+ c("call your mother"),
446
+ c("take a deep breath"),
447
+ c("go touch some grass"),
448
+ c("you deserve a nap"),
449
+ c("stretch your back"),
450
+ c("be kind to yourself"),
451
+ c("go outside, it's sunny"),
452
+ c("pet a dog today"),
453
+ c("hydrate, then investigate"),
454
+ c("you are enough"),
455
+ c("posture check"),
456
+ c("go to bed, it's late"),
457
+ c("make yourself a tea"),
458
+ c("drink water, not secrets"),
459
+ c("text a friend back"),
460
+ c("unclench your jaw"),
461
+ c("blink. you forgot to blink."),
462
+ c("nothing here, but you look nice"),
463
+ c("proud of you for trying"),
464
+ c("rest your eyes for a bit"),
465
+ c("eat a vegetable today"),
466
+ c("you did your best, champ"),
467
+ c("go for a little walk"),
468
+ c("somebody is glad you exist"),
469
+ c("a snack would help right now"),
470
+ c("drink water"),
471
+ c("nap time"),
472
+ c("breathe"),
473
+ c("you got this"),
474
+ c("eat a snack"),
475
+ c("stretch"),
476
+ c("go outside"),
477
+ c("be gentle"),
478
+ c("rest up"),
479
+ c("you're loved")
480
+ ],
481
+ templates: [{
482
+ pattern: "{advice}, {endearment}",
483
+ slots: {
484
+ advice: [
485
+ s("drink some water"),
486
+ s("take a deep breath"),
487
+ s("go touch some grass"),
488
+ s("stretch your back"),
489
+ s("go to bed"),
490
+ s("have a snack"),
491
+ s("call your mother"),
492
+ s("rest your eyes"),
493
+ s("be gentle with yourself")
494
+ ],
495
+ endearment: [
496
+ s("friend"),
497
+ s("sweetie"),
498
+ s("pal"),
499
+ s("champ"),
500
+ s("buddy")
501
+ ]
502
+ }
503
+ }]
504
+ }
505
+ };
506
+ var FALLBACKS = [
507
+ c("no"),
508
+ c("nope"),
509
+ c("lol"),
510
+ c("nah"),
511
+ c("no.")
512
+ ];
513
+ var RUDE_FALLBACKS = [p("fu"), ...FALLBACKS];
514
+ function expandTemplate(template) {
515
+ const names = Object.keys(template.slots);
516
+ let partial = [{
517
+ text: template.pattern,
518
+ profane: template.profane ?? false
519
+ }];
520
+ for (const name of names) {
521
+ const values = template.slots[name];
522
+ const next = [];
523
+ for (const item of partial) for (const value of values) next.push({
524
+ text: item.text.split(`{${name}}`).join(value.text),
525
+ profane: item.profane || (value.profane ?? false)
526
+ });
527
+ partial = next;
528
+ }
529
+ return partial;
530
+ }
531
+ var cache = /* @__PURE__ */ new Map();
532
+ function allPhrases(mood) {
533
+ const hit = cache.get(mood);
534
+ if (hit) return hit;
535
+ const data = DATA[mood];
536
+ const seen = /* @__PURE__ */ new Set();
537
+ const out = [];
538
+ for (const phrase of [...data.fixed, ...data.templates.flatMap(expandTemplate)]) {
539
+ const key = phrase.text.toLowerCase();
540
+ if (seen.has(key)) continue;
541
+ seen.add(key);
542
+ out.push(phrase);
543
+ }
544
+ cache.set(mood, out);
545
+ return out;
546
+ }
547
+ function phrasesFor(mood, clean) {
548
+ const all = allPhrases(mood);
549
+ return clean ? all.filter((phrase) => !phrase.profane) : all;
550
+ }
551
+ function fallbacksFor(mood, clean) {
552
+ const all = mood === "rude" ? RUDE_FALLBACKS : FALLBACKS;
553
+ return clean ? all.filter((phrase) => !phrase.profane) : all;
554
+ }
555
+ //#endregion
556
+ //#region src/doc/types.ts
557
+ var LoadError = class extends Error {
558
+ code;
559
+ constructor(code, message) {
560
+ super(message);
561
+ this.code = code;
562
+ this.name = "LoadError";
563
+ }
564
+ };
565
+ var LOAD_MESSAGES = {
566
+ unsupported: "That's not a PDF, PNG, JPG or WebP. We only redact the classics.",
567
+ encrypted: "This PDF is password-protected. Ironic. Unlock it first.",
568
+ corrupt: "That file wouldn't open. It may be damaged, or not what its name says."
569
+ };
570
+ var MAX_PAGE_PX = 4500;
571
+ function sniffMime(bytes) {
572
+ const head = Buffer.from(bytes.subarray(0, 12));
573
+ if (head.subarray(0, 4).toString("latin1") === "%PDF") return "application/pdf";
574
+ if (head[0] === 137 && head.subarray(1, 4).toString("latin1") === "PNG") return "image/png";
575
+ if (head[0] === 255 && head[1] === 216) return "image/jpeg";
576
+ if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
577
+ return null;
578
+ }
579
+ async function loadImageDocument(path, bytes, mime) {
580
+ let image;
581
+ try {
582
+ image = await loadImage(Buffer.from(bytes));
583
+ } catch {
584
+ throw new LoadError("corrupt", "That image wouldn't decode.");
585
+ }
586
+ const canvas = createCanvas(image.width, image.height);
587
+ canvas.getContext("2d").drawImage(image, 0, 0);
588
+ return {
589
+ path,
590
+ name: basename(path),
591
+ kind: "image",
592
+ mime,
593
+ info: {},
594
+ pages: [{
595
+ index: 0,
596
+ width: canvas.width,
597
+ height: canvas.height,
598
+ canvas,
599
+ items: []
600
+ }]
601
+ };
602
+ }
603
+ function pdfjsRoot() {
604
+ const require = createRequire(import.meta.url);
605
+ return dirname(require.resolve("pdfjs-dist/package.json"));
606
+ }
607
+ function apply(m, x, y) {
608
+ return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
609
+ }
610
+ function itemRect(transform, width, height, viewport) {
611
+ const size = height || Math.hypot(transform[2], transform[3]) || 1;
612
+ const corners = [
613
+ apply(transform, 0, -.22),
614
+ apply(transform, 0, .8),
615
+ apply(transform, 0, .8),
616
+ apply(transform, 0, -.22)
617
+ ];
618
+ const advance = width / size;
619
+ corners[2] = apply(transform, advance, .8);
620
+ corners[3] = apply(transform, advance, -.22);
621
+ const points = corners.map(([x, y]) => apply(viewport, x, y));
622
+ const xs = points.map((p) => p[0]);
623
+ const ys = points.map((p) => p[1]);
624
+ const x = Math.min(...xs);
625
+ const y = Math.min(...ys);
626
+ return {
627
+ x,
628
+ y,
629
+ w: Math.max(...xs) - x,
630
+ h: Math.max(...ys) - y
631
+ };
632
+ }
633
+ async function nativeImageSize(page, OPS) {
634
+ try {
635
+ const ops = await page.getOperatorList();
636
+ const paints = ops.fnArray.map((fn, i) => fn === OPS.paintImageXObject ? i : -1).filter((i) => i >= 0);
637
+ if (paints.length !== 1) return null;
638
+ const name = ops.argsArray[paints[0]][0];
639
+ const image = await new Promise((done) => {
640
+ try {
641
+ page.objs.get(name, (value) => done(value));
642
+ } catch {
643
+ done(null);
644
+ }
645
+ });
646
+ if (!image?.width || !image.height) return null;
647
+ return {
648
+ w: image.width,
649
+ h: image.height
650
+ };
651
+ } catch {
652
+ return null;
653
+ }
654
+ }
655
+ async function loadPdfDocument(path, bytes, options) {
656
+ const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
657
+ const root = pdfjsRoot();
658
+ const task = pdfjs.getDocument({
659
+ data: bytes,
660
+ cMapUrl: join(root, "cmaps/"),
661
+ cMapPacked: true,
662
+ standardFontDataUrl: join(root, "standard_fonts/"),
663
+ iccUrl: join(root, "iccs/"),
664
+ wasmUrl: join(root, "wasm/"),
665
+ useSystemFonts: false,
666
+ stopAtErrors: false
667
+ });
668
+ task.onPassword = () => {
669
+ throw new LoadError("encrypted", "password");
670
+ };
671
+ let pdf;
672
+ try {
673
+ pdf = await task.promise;
674
+ } catch (error) {
675
+ if (error instanceof LoadError) throw error;
676
+ if (error.name === "PasswordException") throw new LoadError("encrypted", "password");
677
+ throw new LoadError("corrupt", "That PDF wouldn't open.");
678
+ }
679
+ const pages = [];
680
+ let info = {};
681
+ try {
682
+ const meta = await pdf.getMetadata().catch(() => null);
683
+ if (meta?.info) info = Object.fromEntries(Object.entries(meta.info).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v]));
684
+ for (let i = 1; i <= pdf.numPages; i++) {
685
+ const page = await pdf.getPage(i);
686
+ const base = page.getViewport({ scale: 1 });
687
+ let scale = 150 / 72;
688
+ const native = options.nativeImages ? await nativeImageSize(page, pdfjs.OPS) : null;
689
+ if (native && Math.abs(native.w / native.h - base.width / base.height) < .01) scale = native.w / base.width;
690
+ const longest = Math.max(base.width, base.height) * scale;
691
+ if (longest > MAX_PAGE_PX) scale *= MAX_PAGE_PX / longest;
692
+ const viewport = page.getViewport({ scale });
693
+ const canvas = createCanvas(Math.round(viewport.width), Math.round(viewport.height));
694
+ await page.render({
695
+ canvas,
696
+ viewport,
697
+ background: "#ffffff"
698
+ }).promise;
699
+ const content = await page.getTextContent();
700
+ const items = [];
701
+ for (const item of content.items) {
702
+ if (!("str" in item) || item.str.length === 0) continue;
703
+ items.push({
704
+ str: item.str,
705
+ rect: itemRect(item.transform, item.width, item.height, viewport.transform)
706
+ });
707
+ }
708
+ pages.push({
709
+ index: i - 1,
710
+ width: canvas.width,
711
+ height: canvas.height,
712
+ canvas,
713
+ points: {
714
+ w: base.width,
715
+ h: base.height
716
+ },
717
+ items
718
+ });
719
+ page.cleanup();
720
+ }
721
+ } finally {
722
+ await task.destroy();
723
+ }
724
+ return {
725
+ path,
726
+ name: basename(path),
727
+ kind: "pdf",
728
+ mime: "application/pdf",
729
+ pages,
730
+ info
731
+ };
732
+ }
733
+ async function loadDocument(path, options = {}) {
734
+ let bytes;
735
+ try {
736
+ bytes = new Uint8Array(await readFile(path));
737
+ } catch {
738
+ throw new LoadError("corrupt", `${path} could not be read.`);
739
+ }
740
+ const mime = sniffMime(bytes);
741
+ if (!mime) throw new LoadError("unsupported", "unsupported");
742
+ if (mime === "application/pdf") return loadPdfDocument(path, bytes, options);
743
+ return loadImageDocument(path, bytes, mime);
744
+ }
745
+ //#endregion
746
+ //#region src/core/fit.ts
747
+ var FONT_SCALE = .82;
748
+ var TARGET_RATIO = .95;
749
+ var JOINER = ". ";
750
+ var RANK_UNIT = .05;
751
+ var RANK_NOISE = 1.5;
752
+ function fontSizeFor(h) {
753
+ return Math.max(1, Math.round(h * FONT_SCALE));
754
+ }
755
+ function availableWidth(w) {
756
+ return Math.max(1, w - 4);
757
+ }
758
+ function capacityFor(w, h, measure) {
759
+ const avg = measure("the quick brown fox 0123", fontSizeFor(h)) / 24;
760
+ if (!(avg > 0)) return 0;
761
+ return Math.max(0, Math.floor(availableWidth(w) / avg));
762
+ }
763
+ function closeness(ratio) {
764
+ return Math.abs(ratio - TARGET_RATIO) / RANK_UNIT;
765
+ }
766
+ function inBand(ratio) {
767
+ return ratio >= .85 && ratio <= 1;
768
+ }
769
+ function rank(seed, scored) {
770
+ const rng = mulberry32(seed);
771
+ return shuffle(rng, scored).map((item) => ({
772
+ item,
773
+ key: closeness(item.ratio) + rng() * RANK_NOISE
774
+ })).sort((a, b) => a.key - b.key).map(({ item }) => item);
775
+ }
776
+ function chains(singles, avail, px, measure, seed) {
777
+ const out = [];
778
+ const seen = /* @__PURE__ */ new Set();
779
+ for (let attempt = 0; attempt < 16 && out.length < 12; attempt++) {
780
+ const order = shuffle(mulberry32(seed + attempt * 7919 >>> 0), singles);
781
+ let text = "";
782
+ for (const item of order) {
783
+ const next = text ? text + JOINER + item.text : item.text;
784
+ const ratio = measure(next, px) / avail;
785
+ if (ratio > 1) continue;
786
+ text = next;
787
+ if (ratio >= .85) break;
788
+ }
789
+ if (!text || seen.has(text)) continue;
790
+ const ratio = measure(text, px) / avail;
791
+ if (inBand(ratio)) {
792
+ seen.add(text);
793
+ out.push({
794
+ text,
795
+ ratio
796
+ });
797
+ }
798
+ }
799
+ return out;
800
+ }
801
+ function pairs(singles, avail, px, measure, seed) {
802
+ const joinerWidth = measure(JOINER, px);
803
+ const widths = singles.map((item) => item.ratio * avail);
804
+ const order = shuffle(mulberry32(seed ^ 2654435769), singles.map((_, i) => i));
805
+ const out = [];
806
+ const seen = /* @__PURE__ */ new Set();
807
+ for (const i of order) for (const j of order) {
808
+ if (i === j) continue;
809
+ if (!inBand((widths[i] + joinerWidth + widths[j]) / avail)) continue;
810
+ const text = singles[i].text + JOINER + singles[j].text;
811
+ if (seen.has(text)) continue;
812
+ seen.add(text);
813
+ const ratio = measure(text, px) / avail;
814
+ if (inBand(ratio)) out.push({
815
+ text,
816
+ ratio
817
+ });
818
+ if (out.length >= 24) return out;
819
+ }
820
+ return out;
821
+ }
822
+ function fitDecoy(options) {
823
+ const { w, h, measure, pool, fallbacks, seed, exclude } = options;
824
+ const px = fontSizeFor(h);
825
+ const avail = availableWidth(w);
826
+ const capacity = capacityFor(w, h, measure);
827
+ const singles = pool.map((phrase) => ({
828
+ text: phrase.text,
829
+ ratio: measure(phrase.text, px) / avail
830
+ }));
831
+ let candidates = singles.filter((item) => inBand(item.ratio));
832
+ if (candidates.length === 0 && singles.length > 0) {
833
+ if (singles.every((item) => item.ratio < .85)) {
834
+ candidates = pairs(singles, avail, px, measure, seed);
835
+ if (candidates.length === 0) candidates = chains(singles, avail, px, measure, seed);
836
+ if (candidates.length === 0) {
837
+ const widest = [...singles].sort((a, b) => b.ratio - a.ratio).slice(0, 6);
838
+ candidates = widest.flatMap((a) => widest.filter((b) => b.text !== a.text).map((b) => {
839
+ const text = a.text + JOINER + b.text;
840
+ return {
841
+ text,
842
+ ratio: measure(text, px) / avail
843
+ };
844
+ }));
845
+ }
846
+ }
847
+ }
848
+ if (candidates.length === 0) {
849
+ const roomy = singles.filter((item) => item.ratio <= 1 && item.ratio >= .5).sort((a, b) => b.ratio - a.ratio);
850
+ if (roomy.length > 0) {
851
+ const top = roomy[0].ratio;
852
+ candidates = roomy.filter((item) => item.ratio >= top - .1);
853
+ }
854
+ }
855
+ if (candidates.length === 0) {
856
+ const scoredFallbacks = fallbacks.map((phrase) => ({
857
+ text: phrase.text,
858
+ ratio: measure(phrase.text, px) / avail
859
+ }));
860
+ const fitting = scoredFallbacks.filter((item) => item.ratio <= 1);
861
+ candidates = fitting.length > 0 ? fitting : [...scoredFallbacks].sort((a, b) => a.ratio - b.ratio).slice(0, 1);
862
+ if (fitting.length > 0) {
863
+ candidates.sort((a, b) => b.ratio - a.ratio);
864
+ const best = candidates[0];
865
+ return {
866
+ best: best.text,
867
+ alternatives: candidates.slice(1, 4).map((item) => item.text),
868
+ ratio: best.ratio,
869
+ capacity,
870
+ fontSize: px
871
+ };
872
+ }
873
+ }
874
+ const ranked = rank(seed, candidates);
875
+ const withoutCurrent = exclude ? ranked.filter((item) => item.text !== exclude) : ranked;
876
+ const ordered = withoutCurrent.length > 0 ? withoutCurrent : ranked;
877
+ const best = ordered[0];
878
+ const alternatives = [];
879
+ for (const item of ordered.slice(1)) {
880
+ if (item.text === best.text) continue;
881
+ alternatives.push(item.text);
882
+ if (alternatives.length === 3) break;
883
+ }
884
+ return {
885
+ best: best.text,
886
+ alternatives,
887
+ ratio: best.ratio,
888
+ capacity,
889
+ fontSize: px
890
+ };
891
+ }
892
+ //#endregion
893
+ //#region src/core/geometry.ts
894
+ function clampRect(rect, bounds) {
895
+ const w = Math.max(1, Math.min(Math.round(rect.w), bounds.w));
896
+ const h = Math.max(1, Math.min(Math.round(rect.h), bounds.h));
897
+ return {
898
+ x: Math.max(0, Math.min(Math.round(rect.x), bounds.w - w)),
899
+ y: Math.max(0, Math.min(Math.round(rect.y), bounds.h - h)),
900
+ w,
901
+ h
902
+ };
903
+ }
904
+ function expandRect(rect, margin, bounds) {
905
+ const x0 = Math.max(0, rect.x - margin);
906
+ const y0 = Math.max(0, rect.y - margin);
907
+ const x1 = Math.min(bounds.w, rect.x + rect.w + margin);
908
+ const y1 = Math.min(bounds.h, rect.y + rect.h + margin);
909
+ return {
910
+ x: x0,
911
+ y: y0,
912
+ w: Math.max(0, x1 - x0),
913
+ h: Math.max(0, y1 - y0)
914
+ };
915
+ }
916
+ //#endregion
917
+ //#region src/core/sample.ts
918
+ var MIN_CONTRAST = 48;
919
+ function meanOf(data, indices) {
920
+ if (indices.length === 0) return [
921
+ 0,
922
+ 0,
923
+ 0
924
+ ];
925
+ let r = 0;
926
+ let g = 0;
927
+ let b = 0;
928
+ for (const i of indices) {
929
+ r += data[i * 4];
930
+ g += data[i * 4 + 1];
931
+ b += data[i * 4 + 2];
932
+ }
933
+ const n = indices.length;
934
+ return [
935
+ Math.round(r / n),
936
+ Math.round(g / n),
937
+ Math.round(b / n)
938
+ ];
939
+ }
940
+ function sampleInkPaper(region) {
941
+ const { data, w, h } = region;
942
+ const n = w * h;
943
+ if (n === 0) return {
944
+ ink: INK_FALLBACK,
945
+ paper: [
946
+ 255,
947
+ 255,
948
+ 255
949
+ ],
950
+ lowContrast: true
951
+ };
952
+ const lumas = new Float32Array(n);
953
+ for (let i = 0; i < n; i++) lumas[i] = luma(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
954
+ const sorted = Array.from(lumas).sort((a, b) => a - b);
955
+ const p60 = sorted[Math.min(n - 1, Math.floor(n * .6))];
956
+ const p5 = sorted[Math.min(n - 1, Math.floor(n * .05))];
957
+ const bright = [];
958
+ const dark = [];
959
+ for (let i = 0; i < n; i++) {
960
+ if (lumas[i] >= p60) bright.push(i);
961
+ if (lumas[i] <= p5) dark.push(i);
962
+ }
963
+ const paper = meanOf(data, bright);
964
+ let ink = meanOf(data, dark);
965
+ const lowContrast = Math.abs(luma(...paper) - luma(...ink)) < MIN_CONTRAST;
966
+ if (lowContrast) ink = INK_FALLBACK;
967
+ return {
968
+ ink,
969
+ paper,
970
+ lowContrast
971
+ };
972
+ }
973
+ //#endregion
974
+ //#region src/doc/pdf-assemble.ts
975
+ var PRODUCER = "DECOY";
976
+ async function assemblePdf(pages, options) {
977
+ const { PDFDocument } = await import("pdf-lib");
978
+ const out = await PDFDocument.create({ updateMetadata: false });
979
+ for (const page of pages) {
980
+ const image = page.kind === "jpeg" ? await out.embedJpg(page.bytes) : await out.embedPng(page.bytes);
981
+ const size = page.points ?? {
982
+ w: page.width * 72 / 150,
983
+ h: page.height * 72 / 150
984
+ };
985
+ out.addPage([size.w, size.h]).drawImage(image, {
986
+ x: 0,
987
+ y: 0,
988
+ width: size.w,
989
+ height: size.h
990
+ });
991
+ }
992
+ out.setTitle(options.title ?? "");
993
+ out.setAuthor("");
994
+ out.setSubject("");
995
+ out.setKeywords([]);
996
+ out.setCreator("");
997
+ out.setProducer(PRODUCER);
998
+ return out.save({ useObjectStreams: true });
999
+ }
1000
+ //#endregion
1001
+ //#region src/render/backend.ts
1002
+ var factory = null;
1003
+ function setCanvasFactory(next) {
1004
+ factory = next;
1005
+ }
1006
+ function canvasFactory() {
1007
+ return factory;
1008
+ }
1009
+ //#endregion
1010
+ //#region src/render/text.ts
1011
+ var DECOY_FONT_FAMILY = "\"Courier Prime\", \"Courier New\", Courier, monospace";
1012
+ function font(px) {
1013
+ return `700 ${px}px ${DECOY_FONT_FAMILY}`;
1014
+ }
1015
+ function makeCanvas(w, h) {
1016
+ const factory = canvasFactory();
1017
+ if (factory) return { ctx: factory(w, h) };
1018
+ if (typeof OffscreenCanvas !== "undefined") {
1019
+ const ctx = new OffscreenCanvas(w, h).getContext("2d", { willReadFrequently: true });
1020
+ if (ctx) return { ctx };
1021
+ }
1022
+ const canvas = document.createElement("canvas");
1023
+ canvas.width = w;
1024
+ canvas.height = h;
1025
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
1026
+ if (!ctx) throw new Error("2D canvas is not available");
1027
+ return { ctx };
1028
+ }
1029
+ var measureCtx = null;
1030
+ var widthCache = /* @__PURE__ */ new Map();
1031
+ function measureText(text, px) {
1032
+ const key = `${px}|${text}`;
1033
+ const hit = widthCache.get(key);
1034
+ if (hit !== void 0) return hit;
1035
+ measureCtx ??= makeCanvas(1, 1).ctx;
1036
+ measureCtx.font = font(px);
1037
+ const width = measureCtx.measureText(text).width;
1038
+ if (widthCache.size > 5e3) widthCache.clear();
1039
+ widthCache.set(key, width);
1040
+ return width;
1041
+ }
1042
+ function rasterizeText(text, w, h) {
1043
+ const { ctx } = makeCanvas(w, h);
1044
+ ctx.fillStyle = "#fff";
1045
+ ctx.fillRect(0, 0, w, h);
1046
+ let size = fontSizeFor(h);
1047
+ ctx.font = font(size);
1048
+ const width = ctx.measureText(text).width;
1049
+ const avail = availableWidth(w);
1050
+ if (width > avail) {
1051
+ size = Math.max(6, size * avail / width);
1052
+ ctx.font = font(size);
1053
+ }
1054
+ ctx.fillStyle = "#000";
1055
+ ctx.textBaseline = "middle";
1056
+ ctx.fillText(text, 2, h / 2 + 1);
1057
+ return ctx.getImageData(0, 0, w, h).data;
1058
+ }
1059
+ //#endregion
1060
+ //#region src/render/mosaic.ts
1061
+ function mosaicPixels(spec) {
1062
+ const w = Math.max(1, Math.round(spec.w));
1063
+ const h = Math.max(1, Math.round(spec.h));
1064
+ return {
1065
+ data: mosaicFromCoverage(coverageFromRgba(rasterizeText(spec.text, w, h), w, h), {
1066
+ w,
1067
+ h,
1068
+ cell: spec.cell,
1069
+ ink: parseHex(spec.ink, INK_FALLBACK),
1070
+ paper: parseHex(spec.paper, PAPER_FALLBACK),
1071
+ seed: mosaicSeed(spec.text)
1072
+ }),
1073
+ w,
1074
+ h
1075
+ };
1076
+ }
1077
+ //#endregion
1078
+ //#region src/cli/backend.ts
1079
+ var FONTS = [{
1080
+ file: "courier-prime-latin-700-normal.woff2",
1081
+ family: "Courier Prime"
1082
+ }, {
1083
+ file: "ibm-plex-mono-latin-600-normal.woff2",
1084
+ family: "IBM Plex Mono"
1085
+ }];
1086
+ function fontPath(file) {
1087
+ const here = dirname(fileURLToPath(import.meta.url));
1088
+ for (const rel of ["fonts", "../../design/fonts"]) {
1089
+ const candidate = join(here, rel, file);
1090
+ if (existsSync(candidate)) return candidate;
1091
+ }
1092
+ return null;
1093
+ }
1094
+ var ready = false;
1095
+ function setupNodeCanvas() {
1096
+ if (ready) return;
1097
+ for (const font of FONTS) {
1098
+ const path = fontPath(font.file);
1099
+ if (!path) throw new Error(`The font ${font.file} is missing next to the CLI; reinstall the package.`);
1100
+ GlobalFonts.registerFromPath(path, font.family);
1101
+ }
1102
+ setCanvasFactory((w, h) => createCanvas(w, h).getContext("2d"));
1103
+ ready = true;
1104
+ }
1105
+ function union(a, b) {
1106
+ const x = Math.min(a.x, b.x);
1107
+ const y = Math.min(a.y, b.y);
1108
+ return {
1109
+ x,
1110
+ y,
1111
+ w: Math.max(a.x + a.w, b.x + b.w) - x,
1112
+ h: Math.max(a.y + a.h, b.y + b.h) - y
1113
+ };
1114
+ }
1115
+ function linesFromItems(items) {
1116
+ const sorted = items.filter((item) => item.str.length > 0 && item.rect.w > 0 && item.rect.h > 0).map((item) => ({
1117
+ item,
1118
+ cy: item.rect.y + item.rect.h / 2
1119
+ })).sort((a, b) => a.cy - b.cy || a.item.rect.x - b.item.rect.x);
1120
+ const groups = [];
1121
+ for (const { item, cy } of sorted) {
1122
+ const last = groups[groups.length - 1];
1123
+ if (last && Math.abs(cy - last.cy) <= Math.max(last.h, item.rect.h) / 2) last.items.push(item);
1124
+ else groups.push({
1125
+ items: [item],
1126
+ cy,
1127
+ h: item.rect.h
1128
+ });
1129
+ }
1130
+ return groups.map(({ items: group }) => {
1131
+ group.sort((a, b) => a.rect.x - b.rect.x);
1132
+ let text = "";
1133
+ let rect = group[0].rect;
1134
+ const lineItems = [];
1135
+ group.forEach((item, i) => {
1136
+ if (i > 0) {
1137
+ const prev = group[i - 1];
1138
+ if (item.rect.x - (prev.rect.x + prev.rect.w) > item.rect.h * .2 && !text.endsWith(" ") && !item.str.startsWith(" ")) text += " ";
1139
+ }
1140
+ lineItems.push({
1141
+ item,
1142
+ start: text.length,
1143
+ end: text.length + item.str.length
1144
+ });
1145
+ text += item.str;
1146
+ rect = union(rect, item.rect);
1147
+ });
1148
+ return {
1149
+ text,
1150
+ items: lineItems,
1151
+ rect
1152
+ };
1153
+ });
1154
+ }
1155
+ function rectOfRange(line, start, end) {
1156
+ let rect = null;
1157
+ for (const { item, start: s, end: e } of line.items) {
1158
+ const from = Math.max(start, s);
1159
+ const to = Math.min(end, e);
1160
+ if (to <= from) continue;
1161
+ const len = Math.max(1, e - s);
1162
+ const x0 = item.rect.x + item.rect.w * (from - s) / len;
1163
+ const x1 = item.rect.x + item.rect.w * (to - s) / len;
1164
+ const part = {
1165
+ x: x0,
1166
+ y: item.rect.y,
1167
+ w: x1 - x0,
1168
+ h: item.rect.h
1169
+ };
1170
+ rect = rect ? union(rect, part) : part;
1171
+ }
1172
+ return rect;
1173
+ }
1174
+ function padRect(rect, pad, bounds) {
1175
+ const x = Math.max(0, Math.floor(rect.x - pad));
1176
+ const y = Math.max(0, Math.floor(rect.y - pad));
1177
+ let w = Math.ceil(rect.x + rect.w + pad) - x;
1178
+ let h = Math.ceil(rect.y + rect.h + pad) - y;
1179
+ if (bounds) {
1180
+ w = Math.min(w, bounds.w - x);
1181
+ h = Math.min(h, bounds.h - y);
1182
+ }
1183
+ return {
1184
+ x,
1185
+ y,
1186
+ w,
1187
+ h
1188
+ };
1189
+ }
1190
+ function compilePattern(source) {
1191
+ const flags = "gu";
1192
+ try {
1193
+ return new RegExp(source, flags);
1194
+ } catch (error) {
1195
+ throw new Error(`Bad pattern ${JSON.stringify(source)}: ${error.message}`, { cause: error });
1196
+ }
1197
+ }
1198
+ function findMatches(lines, pattern, bounds) {
1199
+ const matches = [];
1200
+ for (const line of lines) {
1201
+ pattern.lastIndex = 0;
1202
+ for (const m of line.text.matchAll(pattern)) {
1203
+ if (m[0].length === 0) continue;
1204
+ const rect = rectOfRange(line, m.index, m.index + m[0].length);
1205
+ if (!rect) continue;
1206
+ matches.push({
1207
+ text: m[0],
1208
+ line: line.text,
1209
+ rect: padRect(rect, 2, bounds)
1210
+ });
1211
+ }
1212
+ }
1213
+ return matches;
1214
+ }
1215
+ //#endregion
1216
+ //#region src/cli/redact.ts
1217
+ var DEFAULT_OPTIONS = {
1218
+ mood: DEFAULT_MOOD,
1219
+ clean: false,
1220
+ cell: 8,
1221
+ watermark: false,
1222
+ keepTitle: false
1223
+ };
1224
+ function findInDocument(doc, patterns) {
1225
+ const compiled = patterns.map(compilePattern);
1226
+ return doc.pages.map((page) => {
1227
+ const lines = linesFromItems(page.items);
1228
+ const bounds = {
1229
+ w: page.width,
1230
+ h: page.height
1231
+ };
1232
+ const matches = compiled.flatMap((pattern) => findMatches(lines, pattern, bounds));
1233
+ return {
1234
+ page: page.index,
1235
+ matches
1236
+ };
1237
+ });
1238
+ }
1239
+ function boxesFromMatches(found) {
1240
+ return found.flatMap(({ page, matches }) => matches.map((m) => ({
1241
+ page,
1242
+ rect: m.rect,
1243
+ source: m.text
1244
+ })));
1245
+ }
1246
+ function parseBoxArg(text) {
1247
+ const [coords, pageText] = text.split("@");
1248
+ const parts = coords.split(",").map((n) => Number(n.trim()));
1249
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0)) throw new Error(`A box is x,y,w,h in page pixels, optionally @page: got ${JSON.stringify(text)}`);
1250
+ const page = pageText === void 0 ? 1 : Number(pageText);
1251
+ if (!Number.isInteger(page) || page < 1) throw new Error(`Bad page in ${JSON.stringify(text)}`);
1252
+ const [x, y, w, h] = parts;
1253
+ return {
1254
+ page: page - 1,
1255
+ rect: {
1256
+ x,
1257
+ y,
1258
+ w,
1259
+ h
1260
+ },
1261
+ source: "box"
1262
+ };
1263
+ }
1264
+ function sampleColours(page, rect) {
1265
+ const region = expandRect(rect, 6, {
1266
+ w: page.width,
1267
+ h: page.height
1268
+ });
1269
+ const image = page.canvas.getContext("2d").getImageData(region.x, region.y, Math.max(1, region.w), Math.max(1, region.h));
1270
+ const sampled = sampleInkPaper({
1271
+ data: image.data,
1272
+ w: image.width,
1273
+ h: image.height
1274
+ });
1275
+ return {
1276
+ ink: toHex(sampled.ink),
1277
+ paper: toHex(sampled.paper)
1278
+ };
1279
+ }
1280
+ function normalise(rect, page) {
1281
+ return clampRect({
1282
+ x: Math.round(rect.x),
1283
+ y: Math.round(rect.y),
1284
+ w: Math.max(4, Math.round(rect.w)),
1285
+ h: Math.max(4, Math.round(rect.h))
1286
+ }, {
1287
+ w: page.width,
1288
+ h: page.height
1289
+ });
1290
+ }
1291
+ function planDecoys(doc, boxes, options) {
1292
+ setupNodeCanvas();
1293
+ return boxes.map((box, i) => {
1294
+ const page = doc.pages[box.page];
1295
+ if (!page) throw new Error(`Page ${box.page + 1} does not exist; the document has ${doc.pages.length}.`);
1296
+ const rect = normalise(box.rect, page);
1297
+ const seed = options.seed === void 0 ? randomSeed() : hashString(`${options.seed}:${i}`);
1298
+ const colours = sampleColours(page, rect);
1299
+ const text = options.text ?? fitDecoy({
1300
+ w: rect.w,
1301
+ h: rect.h,
1302
+ measure: measureText,
1303
+ pool: phrasesFor(options.mood, options.clean),
1304
+ fallbacks: fallbacksFor(options.mood, options.clean),
1305
+ seed
1306
+ }).best;
1307
+ return {
1308
+ page: box.page,
1309
+ rect,
1310
+ source: box.source,
1311
+ text,
1312
+ ink: colours.ink,
1313
+ paper: colours.paper,
1314
+ seed
1315
+ };
1316
+ });
1317
+ }
1318
+ function drawWatermark(canvas) {
1319
+ const ctx = canvas.getContext("2d");
1320
+ const px = Math.max(9, Math.round(Math.min(canvas.width, canvas.height) / 90));
1321
+ ctx.save();
1322
+ ctx.font = `600 ${px}px "IBM Plex Mono", "Courier New", monospace`;
1323
+ ctx.textBaseline = "alphabetic";
1324
+ ctx.textAlign = "right";
1325
+ ctx.fillStyle = "rgba(23, 20, 15, 0.55)";
1326
+ ctx.fillText("decoyed", canvas.width - px, canvas.height - px);
1327
+ ctx.restore();
1328
+ }
1329
+ function flattenPage(page, decoys, options) {
1330
+ setupNodeCanvas();
1331
+ const canvas = createCanvas(page.width, page.height);
1332
+ const ctx = canvas.getContext("2d");
1333
+ ctx.drawImage(page.canvas, 0, 0);
1334
+ for (const decoy of decoys) {
1335
+ const mosaic = mosaicPixels({
1336
+ text: decoy.text,
1337
+ w: decoy.rect.w,
1338
+ h: decoy.rect.h,
1339
+ cell: options.cell,
1340
+ ink: decoy.ink,
1341
+ paper: decoy.paper
1342
+ });
1343
+ ctx.putImageData(new ImageData$1(new Uint8ClampedArray(mosaic.data), mosaic.w, mosaic.h), decoy.rect.x, decoy.rect.y);
1344
+ }
1345
+ if (options.watermark) drawWatermark(canvas);
1346
+ return canvas;
1347
+ }
1348
+ function baseName(name) {
1349
+ return name.replace(/\.[^.]+$/, "") || "document";
1350
+ }
1351
+ function defaultFormat(doc) {
1352
+ return doc.kind === "pdf" ? "pdf" : "png";
1353
+ }
1354
+ function defaultOutput(doc, format) {
1355
+ return join(dirname(doc.path), `${baseName(doc.name)}-decoyed.${format}`);
1356
+ }
1357
+ async function writeRedacted(doc, decoys, options, format, out) {
1358
+ const flattened = doc.pages.map((page) => flattenPage(page, decoys.filter((d) => d.page === page.index), options));
1359
+ if (format === "png") {
1360
+ const many = flattened.length > 1;
1361
+ const files = [];
1362
+ for (const [i, canvas] of flattened.entries()) {
1363
+ const path = many ? out.replace(/\.png$/i, "") + `-p${i + 1}.png` : out;
1364
+ const bytes = await canvas.encode("png");
1365
+ await writeFile(path, bytes);
1366
+ files.push({
1367
+ path,
1368
+ bytes: bytes.byteLength
1369
+ });
1370
+ }
1371
+ return files;
1372
+ }
1373
+ const photographic = doc.kind === "image" && doc.mime === "image/jpeg";
1374
+ const pages = [];
1375
+ for (const [i, canvas] of flattened.entries()) {
1376
+ const bytes = photographic ? await canvas.encode("jpeg", 92) : await canvas.encode("png");
1377
+ pages.push({
1378
+ bytes: new Uint8Array(bytes),
1379
+ kind: photographic ? "jpeg" : "png",
1380
+ width: canvas.width,
1381
+ height: canvas.height,
1382
+ points: doc.pages[i].points
1383
+ });
1384
+ }
1385
+ const pdf = await assemblePdf(pages, { title: options.keepTitle ? baseName(doc.name) : null });
1386
+ await writeFile(out, pdf);
1387
+ return [{
1388
+ path: out,
1389
+ bytes: pdf.byteLength
1390
+ }];
1391
+ }
1392
+ //#endregion
1393
+ //#region src/cli/run.ts
1394
+ function runFind(doc, patterns) {
1395
+ const pages = doc.pages.map((p) => ({
1396
+ page: p.index + 1,
1397
+ width: p.width,
1398
+ height: p.height
1399
+ }));
1400
+ if (patterns.length === 0) {
1401
+ const lines = doc.pages.flatMap((p) => linesFromItems(p.items).map((line) => ({
1402
+ page: p.index + 1,
1403
+ text: line.text,
1404
+ rect: round(line.rect)
1405
+ })));
1406
+ return {
1407
+ file: doc.path,
1408
+ kind: doc.kind,
1409
+ pages,
1410
+ lines
1411
+ };
1412
+ }
1413
+ const matches = findInDocument(doc, patterns).flatMap(({ page, matches }) => matches.map((m) => ({
1414
+ page: page + 1,
1415
+ text: m.text,
1416
+ line: m.line,
1417
+ rect: round(m.rect)
1418
+ })));
1419
+ return {
1420
+ file: doc.path,
1421
+ kind: doc.kind,
1422
+ pages,
1423
+ matches
1424
+ };
1425
+ }
1426
+ async function runRedact(doc, request) {
1427
+ const options = {
1428
+ mood: request.mood ?? DEFAULT_OPTIONS.mood,
1429
+ clean: request.clean ?? DEFAULT_OPTIONS.clean,
1430
+ cell: request.cell ?? DEFAULT_OPTIONS.cell,
1431
+ seed: request.seed,
1432
+ text: request.text,
1433
+ watermark: request.watermark ?? DEFAULT_OPTIONS.watermark,
1434
+ keepTitle: request.keepTitle ?? DEFAULT_OPTIONS.keepTitle
1435
+ };
1436
+ const boxes = [...request.patterns.length > 0 ? boxesFromMatches(findInDocument(doc, request.patterns)) : [], ...request.boxes];
1437
+ if (boxes.length === 0) throw new Error(doc.kind === "pdf" ? "Nothing to redact: no pattern matched the text layer and no box was given." : "Nothing to redact: images have no text layer, so give boxes in page pixels.");
1438
+ const decoys = planDecoys(doc, boxes, options);
1439
+ const format = request.format ?? defaultFormat(doc);
1440
+ const outputs = await writeRedacted(doc, decoys, options, format, request.out ?? defaultOutput(doc, format));
1441
+ return {
1442
+ file: doc.path,
1443
+ format,
1444
+ outputs,
1445
+ decoys: decoys.map((d) => ({
1446
+ page: d.page + 1,
1447
+ covered: d.source,
1448
+ rect: d.rect,
1449
+ text: d.text,
1450
+ seed: d.seed
1451
+ }))
1452
+ };
1453
+ }
1454
+ function round(rect) {
1455
+ return {
1456
+ x: Math.round(rect.x),
1457
+ y: Math.round(rect.y),
1458
+ w: Math.round(rect.w),
1459
+ h: Math.round(rect.h)
1460
+ };
1461
+ }
1462
+ //#endregion
1463
+ //#region src/cli/verify.ts
1464
+ function inside(rects, x, y) {
1465
+ return rects.some((r) => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h);
1466
+ }
1467
+ function watermarkCorner(w, h) {
1468
+ const px = Math.max(9, Math.round(Math.min(w, h) / 90));
1469
+ return {
1470
+ x: w - px * 9,
1471
+ y: h - px * 3,
1472
+ w: px * 9,
1473
+ h: px * 3
1474
+ };
1475
+ }
1476
+ function pixelChecks(original, output, boxesByPage) {
1477
+ const checks = [];
1478
+ if (original.pages.length !== output.pages.length) return [{
1479
+ name: "pages",
1480
+ ok: false,
1481
+ detail: `the original has ${original.pages.length} page(s), the output ${output.pages.length}`
1482
+ }];
1483
+ for (const page of original.pages) {
1484
+ const other = output.pages[page.index];
1485
+ if (page.width !== other.width || page.height !== other.height) {
1486
+ checks.push({
1487
+ name: `page ${page.index + 1} size`,
1488
+ ok: false,
1489
+ detail: `${page.width}×${page.height} became ${other.width}×${other.height}; pixels cannot be compared`
1490
+ });
1491
+ continue;
1492
+ }
1493
+ const a = page.canvas.getContext("2d").getImageData(0, 0, page.width, page.height).data;
1494
+ const b = other.canvas.getContext("2d").getImageData(0, 0, page.width, page.height).data;
1495
+ const boxes = boxesByPage.get(page.index) ?? [];
1496
+ const corner = watermarkCorner(page.width, page.height);
1497
+ let insideChanged = 0;
1498
+ let insideTotal = 0;
1499
+ let outsideChanged = 0;
1500
+ for (let y = 0; y < page.height; y++) for (let x = 0; x < page.width; x++) {
1501
+ const i = (y * page.width + x) * 4;
1502
+ const same = a[i] === b[i] && a[i + 1] === b[i + 1] && a[i + 2] === b[i + 2];
1503
+ if (inside(boxes, x, y)) {
1504
+ insideTotal++;
1505
+ if (!same) insideChanged++;
1506
+ } else if (!same && !inside([corner], x, y)) outsideChanged++;
1507
+ }
1508
+ if (boxes.length > 0) checks.push({
1509
+ name: `page ${page.index + 1} matches covered`,
1510
+ ok: insideChanged > 0,
1511
+ detail: `${insideChanged} of ${insideTotal} pixels inside the ${boxes.length} matched box(es) differ from the original`
1512
+ });
1513
+ checks.push({
1514
+ name: `page ${page.index + 1} untouched elsewhere`,
1515
+ ok: outsideChanged === 0,
1516
+ detail: outsideChanged === 0 ? "every pixel outside the boxes (and the watermark corner) is identical to the original" : `${outsideChanged} pixel(s) outside the boxes changed`
1517
+ });
1518
+ }
1519
+ return checks;
1520
+ }
1521
+ async function verifyOutput(path, options) {
1522
+ const output = await loadDocument(path, { nativeImages: true });
1523
+ const checks = [];
1524
+ if (output.kind === "pdf") {
1525
+ const text = output.pages.reduce((n, p) => n + p.items.filter((i) => i.str.trim()).length, 0);
1526
+ checks.push({
1527
+ name: "no text layer",
1528
+ ok: text === 0,
1529
+ detail: text === 0 ? "every page is image-only" : `${text} text run(s) survive in the PDF`
1530
+ });
1531
+ const producer = output.info.Producer ?? "";
1532
+ checks.push({
1533
+ name: "written by DECOY",
1534
+ ok: producer === PRODUCER,
1535
+ detail: producer ? `producer is "${producer}"` : "no producer field"
1536
+ });
1537
+ const leftovers = [
1538
+ "Author",
1539
+ "Subject",
1540
+ "Keywords",
1541
+ "Creator"
1542
+ ].filter((k) => (output.info[k] ?? "") !== "");
1543
+ checks.push({
1544
+ name: "metadata cleared",
1545
+ ok: leftovers.length === 0,
1546
+ detail: leftovers.length === 0 ? "author, subject, keywords, and creator are empty" : `${leftovers.join(", ")} set`
1547
+ });
1548
+ }
1549
+ if (options.patterns.length > 0) {
1550
+ const hits = findInDocument(output, options.patterns).reduce((n, p) => n + p.matches.length, 0);
1551
+ checks.push({
1552
+ name: "patterns absent",
1553
+ ok: hits === 0,
1554
+ detail: hits === 0 ? "no pattern matches any text in the output" : `${hits} match(es) still readable as text`
1555
+ });
1556
+ }
1557
+ if (options.original) {
1558
+ const original = await loadDocument(options.original);
1559
+ const boxesByPage = /* @__PURE__ */ new Map();
1560
+ for (const box of options.boxes ?? []) boxesByPage.set(box.page, [...boxesByPage.get(box.page) ?? [], box.rect]);
1561
+ if (options.patterns.length > 0) {
1562
+ for (const box of boxesFromMatches(findInDocument(original, options.patterns))) boxesByPage.set(box.page, [...boxesByPage.get(box.page) ?? [], box.rect]);
1563
+ const total = [...boxesByPage.values()].reduce((n, r) => n + r.length, 0);
1564
+ checks.push({
1565
+ name: "patterns found in original",
1566
+ ok: total > 0,
1567
+ detail: total > 0 ? `${total} match(es) in the original` : "nothing matched in the original, so nothing was tested"
1568
+ });
1569
+ }
1570
+ checks.push(...pixelChecks(original, output, boxesByPage));
1571
+ }
1572
+ return {
1573
+ file: path,
1574
+ ok: checks.every((c) => c.ok),
1575
+ checks
1576
+ };
1577
+ }
1578
+ //#endregion
1579
+ //#region src/cli/mcp.ts
1580
+ var moodSchema = z.enum(MOODS);
1581
+ var boxSchema = z.object({
1582
+ page: z.number().int().min(1).describe("1-based page number"),
1583
+ x: z.number().min(0),
1584
+ y: z.number().min(0),
1585
+ w: z.number().positive(),
1586
+ h: z.number().positive()
1587
+ });
1588
+ function json(value) {
1589
+ return { content: [{
1590
+ type: "text",
1591
+ text: JSON.stringify(value, null, 2)
1592
+ }] };
1593
+ }
1594
+ function failure(error) {
1595
+ return {
1596
+ isError: true,
1597
+ content: [{
1598
+ type: "text",
1599
+ text: error.message
1600
+ }]
1601
+ };
1602
+ }
1603
+ async function serveMcp(version) {
1604
+ const server = new McpServer({
1605
+ name: "decoy",
1606
+ version
1607
+ });
1608
+ server.registerTool("find_text", {
1609
+ title: "Find text in a document",
1610
+ description: "Lists the text runs of a PDF with their boxes in page pixels (150 dpi), optionally only those matching regular expressions. Images have no text layer and return nothing; redact them with explicit boxes.",
1611
+ inputSchema: {
1612
+ file: z.string().describe("Path to a PDF, PNG, JPG, or WebP file"),
1613
+ patterns: z.array(z.string()).optional().describe("JavaScript regular expressions; omit to list every line")
1614
+ }
1615
+ }, async ({ file, patterns }) => {
1616
+ try {
1617
+ return json(runFind(await loadDocument(file), patterns ?? []));
1618
+ } catch (error) {
1619
+ return failure(error);
1620
+ }
1621
+ });
1622
+ server.registerTool("redact", {
1623
+ title: "Redact a document with decoys",
1624
+ description: "Covers every match of the patterns, and every explicit box, with a pixelated decoy phrase, then writes a flattened copy next to the input (or at \"out\"). The mosaic is computed from the decoy phrase only; the original pixels under a box never influence the output. Returns what was covered and where the file went.",
1625
+ inputSchema: {
1626
+ file: z.string().describe("Path to a PDF, PNG, JPG, or WebP file"),
1627
+ patterns: z.array(z.string()).optional().describe("JavaScript regular expressions to cover (PDFs only)"),
1628
+ boxes: z.array(boxSchema).optional().describe("Explicit boxes in page pixels at 150 dpi"),
1629
+ mood: moodSchema.optional().describe("Phrase mood: rude, smug, corporate, or wholesome (default smug)"),
1630
+ clean: z.boolean().optional().describe("Skip profane phrases"),
1631
+ cell: z.number().int().min(4).max(16).optional().describe("Mosaic block size in pixels"),
1632
+ seed: z.number().int().optional().describe("Seed for reproducible phrase choice"),
1633
+ text: z.string().optional().describe("Use this phrase for every box instead of choosing one"),
1634
+ watermark: z.boolean().optional().describe("Stamp \"decoyed\" in the corner"),
1635
+ format: z.enum(["pdf", "png"]).optional().describe("Output format; defaults to the input kind"),
1636
+ out: z.string().optional().describe("Output path; defaults to <name>-decoyed.<format> next to the input")
1637
+ }
1638
+ }, async (args) => {
1639
+ try {
1640
+ const request = {
1641
+ patterns: args.patterns ?? [],
1642
+ boxes: (args.boxes ?? []).map((b) => ({
1643
+ page: b.page - 1,
1644
+ rect: {
1645
+ x: b.x,
1646
+ y: b.y,
1647
+ w: b.w,
1648
+ h: b.h
1649
+ },
1650
+ source: "box"
1651
+ })),
1652
+ mood: args.mood,
1653
+ clean: args.clean,
1654
+ cell: args.cell,
1655
+ seed: args.seed,
1656
+ text: args.text,
1657
+ watermark: args.watermark,
1658
+ format: args.format,
1659
+ out: args.out
1660
+ };
1661
+ return json(await runRedact(await loadDocument(args.file), request));
1662
+ } catch (error) {
1663
+ return failure(error);
1664
+ }
1665
+ });
1666
+ server.registerTool("verify", {
1667
+ title: "Verify a redacted file",
1668
+ description: "Checks a DECOY output: a PDF has no text layer and no metadata, none of the patterns is readable as text, and, given the original, only the matched boxes changed. Returns the checks and an overall ok.",
1669
+ inputSchema: {
1670
+ file: z.string().describe("Path to the redacted file"),
1671
+ patterns: z.array(z.string()).optional().describe("Regular expressions that must not be readable"),
1672
+ boxes: z.array(boxSchema).optional().describe("Boxes that were redacted by hand"),
1673
+ original: z.string().optional().describe("Path to the original, to compare pixels outside the boxes")
1674
+ }
1675
+ }, async ({ file, patterns, boxes, original }) => {
1676
+ try {
1677
+ return json(await verifyOutput(file, {
1678
+ patterns: patterns ?? [],
1679
+ boxes: (boxes ?? []).map((b) => ({
1680
+ page: b.page - 1,
1681
+ rect: {
1682
+ x: b.x,
1683
+ y: b.y,
1684
+ w: b.w,
1685
+ h: b.h
1686
+ },
1687
+ source: "box"
1688
+ })),
1689
+ original
1690
+ }));
1691
+ } catch (error) {
1692
+ return failure(error);
1693
+ }
1694
+ });
1695
+ await server.connect(new StdioServerTransport());
1696
+ }
1697
+ //#endregion
1698
+ //#region src/cli/main.ts
1699
+ var VERSION = "1.1.0";
1700
+ function collect(value, previous) {
1701
+ return [...previous, value];
1702
+ }
1703
+ function integer(min, max) {
1704
+ return (value) => {
1705
+ const n = Number(value);
1706
+ if (!Number.isInteger(n) || n < min || n > max) throw new InvalidArgumentError(`expected an integer between ${min} and ${max}`);
1707
+ return n;
1708
+ };
1709
+ }
1710
+ function mood(value) {
1711
+ if (!MOODS.includes(value)) throw new InvalidArgumentError(`expected one of ${MOODS.join(", ")}`);
1712
+ return value;
1713
+ }
1714
+ function fail(error) {
1715
+ const message = error instanceof LoadError ? LOAD_MESSAGES[error.code] : error.message ?? String(error);
1716
+ console.error(`decoy: ${message}`);
1717
+ process.exit(error instanceof LoadError ? 3 : 2);
1718
+ }
1719
+ var program = new Command().name("decoy").description("Redact PDFs and images by painting a pixelated decoy phrase over the secret. Runs entirely on this machine.").version(VERSION).option("--json", "machine-readable output");
1720
+ program.command("find").description("list the text of a PDF with boxes in page pixels, or only the matches of patterns").argument("<file>", "PDF, PNG, JPG, or WebP").option("-m, --match <regex>", "pattern to look for (repeatable)", collect, []).action(async (file, opts) => {
1721
+ try {
1722
+ const result = runFind(await loadDocument(file), opts.match);
1723
+ if (program.opts().json) return console.log(JSON.stringify(result, null, 2));
1724
+ if (result.kind === "image") return console.log("An image has no text layer; redact it with --box.");
1725
+ const rows = result.matches ?? result.lines ?? [];
1726
+ if (rows.length === 0) return console.log(opts.match.length ? "No match." : "No text.");
1727
+ for (const row of rows) {
1728
+ const r = row.rect;
1729
+ console.log(`page ${row.page} ${r.x},${r.y} ${r.w}×${r.h} ${JSON.stringify(row.text)}`);
1730
+ }
1731
+ } catch (error) {
1732
+ fail(error);
1733
+ }
1734
+ });
1735
+ program.command("redact").description("cover every match and every box with a decoy and write a flattened copy").argument("<file>", "PDF, PNG, JPG, or WebP").option("-m, --match <regex>", "cover every match of this pattern (repeatable, PDFs only)", collect, []).option("-b, --box <x,y,w,h[@page]>", "cover this box in page pixels at 150 dpi (repeatable)", collect, []).option("--mood <mood>", `phrase mood: ${MOODS.join(", ")}`, mood).option("--clean", "no profanity").option("--cell <px>", `mosaic block size, 4-16`, integer(4, 16)).option("--seed <n>", "reproducible phrase choice", integer(0, Number.MAX_SAFE_INTEGER)).option("--text <phrase>", "use this phrase for every box").option("--watermark", "stamp \"decoyed\" in the corner").option("--keep-title", "keep the file name as the PDF title").option("-f, --format <pdf|png>", "output format; defaults to the input kind", (v) => {
1736
+ if (v !== "pdf" && v !== "png") throw new InvalidArgumentError("expected pdf or png");
1737
+ return v;
1738
+ }).option("-o, --out <path>", "output path; defaults to <name>-decoyed.<format> next to the input").action(async (file, opts) => {
1739
+ try {
1740
+ const result = await runRedact(await loadDocument(file), {
1741
+ patterns: opts.match,
1742
+ boxes: opts.box.map(parseBoxArg),
1743
+ mood: opts.mood,
1744
+ clean: opts.clean,
1745
+ cell: opts.cell,
1746
+ seed: opts.seed,
1747
+ text: opts.text,
1748
+ watermark: opts.watermark,
1749
+ keepTitle: opts.keepTitle,
1750
+ format: opts.format,
1751
+ out: opts.out
1752
+ });
1753
+ if (program.opts().json) return console.log(JSON.stringify(result, null, 2));
1754
+ for (const d of result.decoys) {
1755
+ const r = d.rect;
1756
+ const covered = d.covered === "box" ? "box" : JSON.stringify(d.covered);
1757
+ console.log(`page ${d.page} ${r.x},${r.y} ${r.w}×${r.h} ${covered} -> "${grawlix(d.text)}"`);
1758
+ }
1759
+ for (const out of result.outputs) console.log(`wrote ${out.path} (${(out.bytes / 1024).toFixed(1)} kB)`);
1760
+ } catch (error) {
1761
+ fail(error);
1762
+ }
1763
+ });
1764
+ program.command("verify").description("check a redacted file: no text layer, no metadata, patterns gone, nothing else touched").argument("<file>", "the redacted PDF or image").option("-m, --match <regex>", "pattern that must not be readable (repeatable)", collect, []).option("-b, --box <x,y,w,h[@page]>", "box that was redacted by hand (repeatable)", collect, []).option("--original <file>", "the original, to confirm only the matched boxes changed").action(async (file, opts) => {
1765
+ try {
1766
+ const report = await verifyOutput(file, {
1767
+ patterns: opts.match,
1768
+ boxes: opts.box.map(parseBoxArg),
1769
+ original: opts.original
1770
+ });
1771
+ if (program.opts().json) console.log(JSON.stringify(report, null, 2));
1772
+ else {
1773
+ for (const c of report.checks) console.log(`${c.ok ? "ok " : "FAIL"} ${c.name}: ${c.detail}`);
1774
+ console.log(report.ok ? "verified" : "NOT verified");
1775
+ }
1776
+ process.exit(report.ok ? 0 : 1);
1777
+ } catch (error) {
1778
+ fail(error);
1779
+ }
1780
+ });
1781
+ program.command("mcp").description("serve find_text, redact, and verify as MCP tools over stdio").action(async () => {
1782
+ try {
1783
+ await serveMcp(VERSION);
1784
+ } catch (error) {
1785
+ fail(error);
1786
+ }
1787
+ });
1788
+ await program.parseAsync(process.argv);
1789
+ //#endregion
1790
+ export {};