@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 {};
|