@doccloak/core 0.12.1 → 0.13.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.
- package/CHANGELOG.md +333 -0
- package/NOTICE +5 -0
- package/README.md +131 -9
- package/dist/dom/errors.d.ts +1 -1
- package/dist/dom/ocr.d.ts +33 -7
- package/dist/dom/ocr.js +98 -4
- package/dist/dom/office.d.ts +20 -6
- package/dist/dom/office.js +44 -11
- package/dist/engine.js +4 -5
- package/dist/env.d.ts +3 -3
- package/dist/pdf/cmap.d.ts +83 -0
- package/dist/pdf/cmap.js +659 -0
- package/dist/pdf/encodings.d.ts +49 -0
- package/dist/pdf/encodings.js +181 -0
- package/dist/pdf/extract.d.ts +106 -0
- package/dist/pdf/extract.js +1064 -0
- package/dist/pdf/fallback-fonts.d.ts +146 -0
- package/dist/pdf/fallback-fonts.js +312 -0
- package/dist/pdf/fit.d.ts +35 -0
- package/dist/pdf/fit.js +60 -0
- package/dist/pdf/fonts.d.ts +82 -0
- package/dist/pdf/fonts.js +465 -0
- package/dist/pdf/glyphlist.d.ts +37 -0
- package/dist/pdf/glyphlist.js +485 -0
- package/dist/pdf/index.d.ts +21 -0
- package/dist/pdf/index.js +7 -0
- package/dist/pdf/lexer.d.ts +61 -0
- package/dist/pdf/lexer.js +554 -0
- package/dist/pdf/objects.d.ts +57 -0
- package/dist/pdf/objects.js +217 -0
- package/dist/pdf/oracle.d.ts +149 -0
- package/dist/pdf/oracle.js +384 -0
- package/dist/pdf/raster.d.ts +45 -0
- package/dist/pdf/raster.js +89 -0
- package/dist/pdf/read.d.ts +36 -0
- package/dist/pdf/read.js +65 -0
- package/dist/pdf/serializer.d.ts +21 -0
- package/dist/pdf/serializer.js +106 -0
- package/dist/pdf/standard-metrics.d.ts +27 -0
- package/dist/pdf/standard-metrics.js +72 -0
- package/dist/pdf/surgery.d.ts +131 -0
- package/dist/pdf/surgery.js +847 -0
- package/dist/pdf/textstate.d.ts +77 -0
- package/dist/pdf/textstate.js +280 -0
- package/dist/pdf/types.d.ts +191 -0
- package/dist/pdf/types.js +11 -0
- package/dist/pdf/verify.d.ts +33 -0
- package/dist/pdf/verify.js +446 -0
- package/dist/pdf/write.d.ts +52 -0
- package/dist/pdf/write.js +644 -0
- package/dist/pipeline.d.ts +15 -0
- package/dist/pipeline.js +123 -8
- package/dist/providers/bardsai.d.ts +1 -1
- package/dist/providers/bardsai.js +5 -1
- package/dist/providers/gliner-base.d.ts +1 -1
- package/dist/providers/gliner-base.js +5 -1
- package/dist/providers/gliner.d.ts +1 -1
- package/dist/providers/gliner.js +5 -1
- package/dist/regex/rules.data.js +13 -8
- package/dist/types.d.ts +17 -2
- package/dist/types.js +17 -0
- package/dist/worker-protocol.d.ts +11 -0
- package/dist/worker-protocol.js +41 -8
- package/fonts/liberation/LICENSE +102 -0
- package/fonts/liberation/LiberationMono-Bold.ttf +0 -0
- package/fonts/liberation/LiberationMono-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationMono-Italic.ttf +0 -0
- package/fonts/liberation/LiberationMono-Regular.ttf +0 -0
- package/fonts/liberation/LiberationSans-Bold.ttf +0 -0
- package/fonts/liberation/LiberationSans-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationSans-Italic.ttf +0 -0
- package/fonts/liberation/LiberationSans-Regular.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Bold.ttf +0 -0
- package/fonts/liberation/LiberationSerif-BoldItalic.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Italic.ttf +0 -0
- package/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
- package/package.json +11 -2
- package/rules/pl.json +7 -3
- package/rules/universal.json +6 -5
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - post-write verification (T213).
|
|
3
|
+
*
|
|
4
|
+
* The output is re-read by two independent extractors (ours and pdf.js) and
|
|
5
|
+
* scanned byte-wise; any surviving needle refuses the export. Nothing this
|
|
6
|
+
* module reports is advisory: the writer throws on a non-empty finding list.
|
|
7
|
+
*/
|
|
8
|
+
import { PDFDocument, PDFRawStream, PDFHexString, PDFString, PDFDict, PDFArray, PDFStream, PDFName } from '@cantoo/pdf-lib';
|
|
9
|
+
import { decodeStream, isImageEncoded, namesOf } from "./objects.js";
|
|
10
|
+
import { extractPdf } from "./extract.js";
|
|
11
|
+
import { findOccurrences, searchIndex } from "./oracle.js";
|
|
12
|
+
import { lexContent } from "./lexer.js";
|
|
13
|
+
export class PdfVerifyError extends Error {
|
|
14
|
+
constructor(findings) {
|
|
15
|
+
super(`redacted PDF still holds ${findings.length} trace(s): ${findings.slice(0, 5).map((f) => `${f.pass}@${f.where}`).join(', ')}`);
|
|
16
|
+
this.name = 'PdfVerifyError';
|
|
17
|
+
this.findings = findings;
|
|
18
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Run every pass; returns the findings (empty when clean). */
|
|
22
|
+
export async function verifyPdf(input) {
|
|
23
|
+
const findings = [];
|
|
24
|
+
const needles = uniqueNeedles(input.needles);
|
|
25
|
+
if (needles.length === 0)
|
|
26
|
+
return findings;
|
|
27
|
+
const placeholders = uniqueNeedles(input.placeholders ?? []);
|
|
28
|
+
// Occurrences of a needle that lie inside a placeholder holding that needle ("Name" in "[NAME]") are the placeholder, not the value.
|
|
29
|
+
const outsidePlaceholders = (index, needle) => {
|
|
30
|
+
const hits = findOccurrences(index, needle);
|
|
31
|
+
if (hits.length === 0)
|
|
32
|
+
return false;
|
|
33
|
+
const shelters = [];
|
|
34
|
+
for (const ph of placeholders) {
|
|
35
|
+
if (findOccurrences(ph, needle).length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
for (let i = index.text.indexOf(ph); i >= 0; i = index.text.indexOf(ph, i + ph.length))
|
|
38
|
+
shelters.push([i, i + ph.length]);
|
|
39
|
+
}
|
|
40
|
+
return hits.some(([a, b]) => !shelters.some(([s, e]) => a >= s && b <= e));
|
|
41
|
+
};
|
|
42
|
+
const sheltered = (needle) => placeholders.some((ph) => findOccurrences(ph, needle).length > 0);
|
|
43
|
+
// Pass 1: our extractor (the same search as layer zero: folded, at token boundaries), cross-checked
|
|
44
|
+
// against pdf.js page by page. Pages both extractors read identically are settled by this pass.
|
|
45
|
+
let ownExtractorFailed = false;
|
|
46
|
+
let unsettled = null; // pages pass 2 must still search (null: all of them)
|
|
47
|
+
try {
|
|
48
|
+
const ours = await extractPdf(input.bytes, { maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER, oracle: input.oracle });
|
|
49
|
+
const index = searchIndex(ours.plainText);
|
|
50
|
+
for (const n of needles)
|
|
51
|
+
if (outsidePlaceholders(index, n))
|
|
52
|
+
findings.push({ pass: 'own-extractor', needle: n, where: 'text' });
|
|
53
|
+
if (input.oracle) {
|
|
54
|
+
unsettled = new Set();
|
|
55
|
+
ours.model.pages.forEach((page, p) => { if (page.issues.length > 0 || page.oracleText)
|
|
56
|
+
unsettled.add(p); });
|
|
57
|
+
for (const u of ours.unredactable) {
|
|
58
|
+
const m = /^pages? (\d+)(?:-(\d+))?$/.exec(u.part);
|
|
59
|
+
if (m)
|
|
60
|
+
for (let p = Number(m[1]) - 1; p <= Number(m[2] ?? m[1]) - 1; p++)
|
|
61
|
+
unsettled.add(p);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
ownExtractorFailed = true;
|
|
67
|
+
findings.push({ pass: 'own-extractor', needle: '', where: `re-extraction failed: ${err.message}` });
|
|
68
|
+
}
|
|
69
|
+
// Pass 2: pdf.js, for pages our extractor could not settle (its own spacing heuristics would
|
|
70
|
+
// otherwise report "Jan" inside a kerned "Janusz").
|
|
71
|
+
if (input.oracle) {
|
|
72
|
+
for (let p = 0; p < input.oracle.pageCount; p++) {
|
|
73
|
+
if (unsettled && !unsettled.has(p))
|
|
74
|
+
continue;
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
// pdf.js hands items back in content order; words drawn from the right would spell a value
|
|
78
|
+
// that is not on the page. Read the items in baseline order instead (top to bottom, left to right).
|
|
79
|
+
text = readingOrderText(await input.oracle.pageItems(p));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// A page the reference extractor cannot read cannot be verified: fail closed.
|
|
83
|
+
findings.push({ pass: 'oracle', needle: '', where: `page ${p + 1} unreadable by pdf.js: ${err.message ?? String(err)}` });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const index = searchIndex(text);
|
|
87
|
+
for (const n of needles)
|
|
88
|
+
if (outsidePlaceholders(index, n))
|
|
89
|
+
findings.push({ pass: 'oracle', needle: n, where: `page ${p + 1}` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Pass 3-5: bytes of every decoded stream, every string object, and the raw file. A needle that a
|
|
93
|
+
// placeholder contains is left to the text passes (its bytes are in every placeholder).
|
|
94
|
+
const encodedNeedles = needles.filter((n) => !sheltered(n)).flatMap((n) => encodeNeedle(n));
|
|
95
|
+
let doc;
|
|
96
|
+
try {
|
|
97
|
+
doc = await PDFDocument.load(input.bytes, { ignoreEncryption: true, updateMetadata: false, throwOnInvalidObject: false });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
findings.push({ pass: 'stream-bytes', needle: '', where: `output could not be parsed: ${err.message}` });
|
|
101
|
+
}
|
|
102
|
+
if (doc) {
|
|
103
|
+
const ctx = doc.context;
|
|
104
|
+
const contentStreams = [];
|
|
105
|
+
const contentStrings = []; // their string operands, for the code-sequence pass
|
|
106
|
+
for (const [ref, obj] of ctx.enumerateIndirectObjects()) {
|
|
107
|
+
if (obj instanceof PDFRawStream) {
|
|
108
|
+
if (isImageEncoded(ctx, obj))
|
|
109
|
+
continue;
|
|
110
|
+
let decoded;
|
|
111
|
+
try {
|
|
112
|
+
decoded = decodeStream(ctx, obj);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (isFontProgram(ctx, obj) || looksLikeFontProgram(decoded)) {
|
|
118
|
+
// Glyph programs carry font names ("Times New Roman"), never document text: only a needle
|
|
119
|
+
// no font name can contain (several words, or digits) is searched here.
|
|
120
|
+
const strong = encodedNeedles.filter((n) => strongNeedle(n.raw));
|
|
121
|
+
if (strong.length > 0) {
|
|
122
|
+
const fontHay = haystackOf(decoded);
|
|
123
|
+
for (const n of strong) {
|
|
124
|
+
if (containsBytes(fontHay, n))
|
|
125
|
+
findings.push({ pass: 'stream-bytes', needle: n.raw, where: `obj ${ref.objectNumber} font program (${n.encoding})` });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
scanStrings(obj.dict, ref.objectNumber, encodedNeedles, findings, new Set());
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const type = namesOf(ctx, obj.dict.get(PDFName.of('Type')))[0];
|
|
132
|
+
const subtype = namesOf(ctx, obj.dict.get(PDFName.of('Subtype')))[0];
|
|
133
|
+
const isContent = type === undefined || type === 'Pattern' || (type === 'XObject' && subtype === 'Form');
|
|
134
|
+
if (isContent)
|
|
135
|
+
contentStreams.push(decoded);
|
|
136
|
+
// A content stream shows text only through its string operands: numbers, names and
|
|
137
|
+
// operators ("/F1 12 Tf", coordinates) are not text a viewer displays. Other streams
|
|
138
|
+
// (XMP, CMaps, anything unknown) are scanned whole.
|
|
139
|
+
const haystack = haystackOf(isContent ? stringOperandsOf(decoded) : decoded);
|
|
140
|
+
if (isContent)
|
|
141
|
+
contentStrings.push(haystack);
|
|
142
|
+
for (const n of encodedNeedles) {
|
|
143
|
+
if (containsBytes(haystack, n))
|
|
144
|
+
findings.push({ pass: 'stream-bytes', needle: n.raw, where: `obj ${ref.objectNumber} (${n.encoding})` });
|
|
145
|
+
}
|
|
146
|
+
scanStrings(obj.dict, ref.objectNumber, encodedNeedles, findings, new Set());
|
|
147
|
+
}
|
|
148
|
+
else if (obj instanceof PDFDict || obj instanceof PDFArray || obj instanceof PDFString || obj instanceof PDFHexString) {
|
|
149
|
+
scanStrings(obj, ref.objectNumber, encodedNeedles, findings, new Set());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Removed code sequences are searched only when our extractor could not re-read the output
|
|
153
|
+
// (pass 1 covers every font we edit; a value split over two shows next to another word
|
|
154
|
+
// would otherwise be reported although no reader sees it).
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
for (const seq of ownExtractorFailed ? input.removedSequences ?? [] : []) {
|
|
157
|
+
if (seq.bytes.length < 4)
|
|
158
|
+
continue;
|
|
159
|
+
// Multi-byte codes (Identity-H glyph ids) have no token boundaries in a byte scan and are
|
|
160
|
+
// read by both text passes anyway; a prefix of a longer word would refuse every export.
|
|
161
|
+
if ((seq.codeLength ?? 1) !== 1)
|
|
162
|
+
continue;
|
|
163
|
+
const key = hex(seq.bytes);
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
// Single-byte codes: the sequence must fill a whole token of the string it sits in (a
|
|
168
|
+
// value "w100" is not the "w100" inside "w1000"); multi-byte codes have no such notion.
|
|
169
|
+
const probe = { raw: hex(seq.bytes), encoding: 'utf8', bytes: seq.bytes, boundStart: (seq.codeLength ?? 1) === 1, boundEnd: (seq.codeLength ?? 1) === 1 };
|
|
170
|
+
for (let i = 0; i < contentStreams.length; i++) {
|
|
171
|
+
if (containsBytes(contentStrings[i], probe)) {
|
|
172
|
+
findings.push({ pass: 'code-sequence', needle: hex(seq.bytes), where: `content stream #${i}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// No whole-file byte pass: every object pdf-lib writes is either a stream (scanned decoded above,
|
|
178
|
+
// font programs and image codecs excepted) or a dict/array/string (scanned above). A raw scan would
|
|
179
|
+
// only add false positives from font names ("Roman", "Georgia") inside embedded font programs.
|
|
180
|
+
return dedupe(findings);
|
|
181
|
+
}
|
|
182
|
+
function scanStrings(obj, objNum, needles, findings, seen) {
|
|
183
|
+
if (seen.has(obj))
|
|
184
|
+
return;
|
|
185
|
+
seen.add(obj);
|
|
186
|
+
if (obj instanceof PDFHexString || obj instanceof PDFString) {
|
|
187
|
+
const bytes = obj.asBytes();
|
|
188
|
+
for (const n of needles) {
|
|
189
|
+
if (containsBytes(bytes, n))
|
|
190
|
+
findings.push({ pass: 'string-bytes', needle: n.raw, where: `obj ${objNum} string (${n.encoding})` });
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (obj instanceof PDFName) {
|
|
195
|
+
// Names (resource keys, /BaseFont, /Title of a layer...) can spell a value too; font names are
|
|
196
|
+
// skipped since a person may share a name with a typeface ("Georgia", "Roman"), and the value
|
|
197
|
+
// must fill a whole token of the name ("kid" is not /Kids, "Source" is not /SourceSans).
|
|
198
|
+
const text = obj.decodeText();
|
|
199
|
+
for (const n of needles) {
|
|
200
|
+
if (n.encoding !== 'utf8')
|
|
201
|
+
continue;
|
|
202
|
+
if (findOccurrences(text, n.raw).length > 0)
|
|
203
|
+
findings.push({ pass: 'string-bytes', needle: n.raw, where: `obj ${objNum} name` });
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (obj instanceof PDFStream) {
|
|
208
|
+
scanStrings(obj.dict, objNum, needles, findings, seen);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (obj instanceof PDFDict) {
|
|
212
|
+
const type = obj.get(PDFName.of('Type'));
|
|
213
|
+
const isFontDict = type instanceof PDFName && (type.decodeText() === 'FontDescriptor' || type.decodeText() === 'Font');
|
|
214
|
+
for (const [k, v] of obj.entries()) {
|
|
215
|
+
const key = k.decodeText();
|
|
216
|
+
// Font names are metadata, never document text; a surname equal to a font name must not block the export.
|
|
217
|
+
if (key === 'BaseFont' || key === 'FontName' || key === 'FontFamily' || key === 'CharSet')
|
|
218
|
+
continue;
|
|
219
|
+
if (isFontDict && v instanceof PDFName)
|
|
220
|
+
continue;
|
|
221
|
+
scanStrings(k, objNum, needles, findings, seen);
|
|
222
|
+
scanStrings(v, objNum, needles, findings, seen);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (obj instanceof PDFArray) {
|
|
227
|
+
for (let i = 0; i < obj.size(); i++)
|
|
228
|
+
scanStrings(obj.get(i), objNum, needles, findings, seen);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** A needle no font program or font name could legitimately contain: several words, or a digit. */
|
|
232
|
+
function strongNeedle(needle) {
|
|
233
|
+
return /\s/.test(needle.trim()) || /\d/.test(needle);
|
|
234
|
+
}
|
|
235
|
+
/** pdf.js items joined in reading order: lines by baseline (top first), items left to right within a line. */
|
|
236
|
+
function readingOrderText(items) {
|
|
237
|
+
const rows = items.map((it, i) => ({ it, i, x: it.transform[4], y: it.transform[5], w: it.width, h: Math.hypot(it.transform[2], it.transform[3]) || it.height || 1 }));
|
|
238
|
+
rows.sort((a, b) => {
|
|
239
|
+
const tol = 0.5 * Math.max(a.h, b.h, 1);
|
|
240
|
+
if (Math.abs(a.y - b.y) > tol)
|
|
241
|
+
return b.y - a.y;
|
|
242
|
+
if (Math.abs(a.x - b.x) > 1e-6)
|
|
243
|
+
return a.x - b.x;
|
|
244
|
+
return a.i - b.i;
|
|
245
|
+
});
|
|
246
|
+
let out = '';
|
|
247
|
+
let prev;
|
|
248
|
+
for (const r of rows) {
|
|
249
|
+
if (prev && Math.abs(prev.y - r.y) > 0.5 * Math.max(prev.h, r.h, 1))
|
|
250
|
+
out += '\n';
|
|
251
|
+
// Only a real gap separates words: two items butting against each other are one word ("Jan" + "usz").
|
|
252
|
+
else if (prev && r.x - (prev.x + prev.w) > 0.12 * Math.max(prev.h, r.h, 1) && !out.endsWith(' ') && !r.it.str.startsWith(' '))
|
|
253
|
+
out += ' ';
|
|
254
|
+
out += r.it.str;
|
|
255
|
+
prev = r;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/** The bytes of every string operand of a content stream, separated by newlines. */
|
|
260
|
+
function stringOperandsOf(decoded) {
|
|
261
|
+
const parts = [];
|
|
262
|
+
let total = 0;
|
|
263
|
+
const collect = (op) => {
|
|
264
|
+
if (op.kind === 'string') {
|
|
265
|
+
parts.push(op.bytes);
|
|
266
|
+
total += op.bytes.length + 1;
|
|
267
|
+
}
|
|
268
|
+
else if (op.kind === 'array')
|
|
269
|
+
for (const it of op.items)
|
|
270
|
+
collect(it);
|
|
271
|
+
else if (op.kind === 'dict')
|
|
272
|
+
for (const [, v] of op.entries)
|
|
273
|
+
collect(v);
|
|
274
|
+
};
|
|
275
|
+
for (const op of lexContent(decoded)) {
|
|
276
|
+
if (op.op === '' || op.op === 'BI') { // unparsable bytes and inline images are scanned as they are
|
|
277
|
+
if (op.raw) {
|
|
278
|
+
parts.push(op.raw);
|
|
279
|
+
total += op.raw.length + 1;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (op.op === 'TJ' && op.operands[0]?.kind === 'array') {
|
|
284
|
+
// The strings of one TJ are one show: a word split by kerning numbers stays one token.
|
|
285
|
+
const joined = [];
|
|
286
|
+
for (const it of op.operands[0].items)
|
|
287
|
+
if (it.kind === 'string')
|
|
288
|
+
for (const b of it.bytes)
|
|
289
|
+
joined.push(b);
|
|
290
|
+
const u = Uint8Array.from(joined);
|
|
291
|
+
parts.push(u);
|
|
292
|
+
total += u.length + 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
for (const operand of op.operands)
|
|
296
|
+
collect(operand);
|
|
297
|
+
}
|
|
298
|
+
const out = new Uint8Array(total);
|
|
299
|
+
let at = 0;
|
|
300
|
+
for (const part of parts) {
|
|
301
|
+
out.set(part, at);
|
|
302
|
+
at += part.length;
|
|
303
|
+
out[at++] = 0x0a;
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** Font program magic: TrueType (00010000 / 'true'), OpenType ('OTTO'), Type1 ('%!PS' or PFB 80 01), CFF (01 00 04). */
|
|
308
|
+
function looksLikeFontProgram(bytes) {
|
|
309
|
+
if (bytes.length < 4)
|
|
310
|
+
return false;
|
|
311
|
+
const b0 = bytes[0], b1 = bytes[1], b2 = bytes[2], b3 = bytes[3];
|
|
312
|
+
if (b0 === 0 && b1 === 1 && b2 === 0 && b3 === 0)
|
|
313
|
+
return true;
|
|
314
|
+
const tag = String.fromCharCode(b0, b1, b2, b3);
|
|
315
|
+
if (tag === 'true' || tag === 'OTTO' || tag === 'ttcf' || tag === '%!PS')
|
|
316
|
+
return true;
|
|
317
|
+
if (b0 === 0x80 && b1 === 0x01)
|
|
318
|
+
return true;
|
|
319
|
+
if (b0 === 1 && b1 === 0 && b2 === 4 && b3 <= 4)
|
|
320
|
+
return true;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
/** Embedded font programs (FontFile, FontFile2, FontFile3): glyph outlines and font names, never document text. */
|
|
324
|
+
function isFontProgram(ctx, stream) {
|
|
325
|
+
const d = stream.dict;
|
|
326
|
+
if (d.get(PDFName.of('Length1')) || d.get(PDFName.of('Length2')) || d.get(PDFName.of('Length3')))
|
|
327
|
+
return true;
|
|
328
|
+
const subtype = namesOf(ctx, d.get(PDFName.of('Subtype')))[0];
|
|
329
|
+
return subtype === 'Type1C' || subtype === 'CIDFontType0C' || subtype === 'OpenType';
|
|
330
|
+
}
|
|
331
|
+
function latin1String(bytes) {
|
|
332
|
+
let s = '';
|
|
333
|
+
const CHUNK = 8192;
|
|
334
|
+
for (let i = 0; i < bytes.length; i += CHUNK)
|
|
335
|
+
s += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)));
|
|
336
|
+
return s;
|
|
337
|
+
}
|
|
338
|
+
function haystackOf(bytes) {
|
|
339
|
+
return { bytes, text: latin1String(bytes) };
|
|
340
|
+
}
|
|
341
|
+
function encodeNeedle(needle) {
|
|
342
|
+
const out = [];
|
|
343
|
+
const bounds = { boundStart: /[\p{L}\p{N}]/u.test(needle.charAt(0)), boundEnd: /[\p{L}\p{N}]/u.test(needle.slice(-1)) };
|
|
344
|
+
const utf8 = new TextEncoder().encode(needle);
|
|
345
|
+
out.push({ raw: needle, encoding: 'utf8', bytes: utf8, ...bounds });
|
|
346
|
+
let latin1Ok = true;
|
|
347
|
+
const latin1 = new Uint8Array(needle.length);
|
|
348
|
+
for (let i = 0; i < needle.length; i++) {
|
|
349
|
+
const c = needle.charCodeAt(i);
|
|
350
|
+
if (c > 0xff) {
|
|
351
|
+
latin1Ok = false;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
latin1[i] = c;
|
|
355
|
+
}
|
|
356
|
+
if (latin1Ok && !sameBytes(latin1, utf8))
|
|
357
|
+
out.push({ raw: needle, encoding: 'latin1', bytes: latin1, ...bounds });
|
|
358
|
+
const be = new Uint8Array(needle.length * 2);
|
|
359
|
+
const le = new Uint8Array(needle.length * 2);
|
|
360
|
+
for (let i = 0; i < needle.length; i++) {
|
|
361
|
+
const c = needle.charCodeAt(i);
|
|
362
|
+
be[2 * i] = c >> 8;
|
|
363
|
+
be[2 * i + 1] = c & 0xff;
|
|
364
|
+
le[2 * i] = c & 0xff;
|
|
365
|
+
le[2 * i + 1] = c >> 8;
|
|
366
|
+
}
|
|
367
|
+
out.push({ raw: needle, encoding: 'utf16be', bytes: be, ...bounds }, { raw: needle, encoding: 'utf16le', bytes: le, ...bounds });
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
function sameBytes(a, b) {
|
|
371
|
+
if (a.length !== b.length)
|
|
372
|
+
return false;
|
|
373
|
+
for (let i = 0; i < a.length; i++)
|
|
374
|
+
if (a[i] !== b[i])
|
|
375
|
+
return false;
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
export function indexOfBytes(hay, needle, from = 0) {
|
|
379
|
+
if (needle.length === 0 || hay.length < needle.length)
|
|
380
|
+
return -1;
|
|
381
|
+
const first = needle[0];
|
|
382
|
+
const last = hay.length - needle.length;
|
|
383
|
+
outer: for (let i = from; i <= last; i++) {
|
|
384
|
+
if (hay[i] !== first)
|
|
385
|
+
continue;
|
|
386
|
+
for (let j = 1; j < needle.length; j++)
|
|
387
|
+
if (hay[i + j] !== needle[j])
|
|
388
|
+
continue outer;
|
|
389
|
+
return i;
|
|
390
|
+
}
|
|
391
|
+
return -1;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Byte search honouring token boundaries for values that start/end with a letter or digit: the
|
|
395
|
+
* byte before/after the hit must not be an ASCII letter or digit (so "Nowak" is not found in
|
|
396
|
+
* "Nowakowski" but is found in "Nowak,"). Non-ASCII neighbours count as boundaries.
|
|
397
|
+
*/
|
|
398
|
+
function containsBytes(hay, n) {
|
|
399
|
+
const h = hay instanceof Uint8Array ? haystackOf(hay) : hay;
|
|
400
|
+
// ASCII letters and digits, and every byte above 0x7F (a Latin-1 / WinAnsi letter such as ó or ü
|
|
401
|
+
// is part of the word, so "Nowak" is not found in "Nowaków").
|
|
402
|
+
const alnum = (b) => b !== undefined && ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) || b >= 0x80);
|
|
403
|
+
const step = n.encoding === 'utf16be' || n.encoding === 'utf16le' ? 2 : 1;
|
|
404
|
+
const bytes = h.bytes;
|
|
405
|
+
const unit = (at) => {
|
|
406
|
+
if (at < 0 || at + step > bytes.length)
|
|
407
|
+
return undefined;
|
|
408
|
+
return n.encoding === 'utf16be' ? (bytes[at] === 0 ? bytes[at + 1] : 0x100) : n.encoding === 'utf16le' ? (bytes[at + 1] === 0 ? bytes[at] : 0x100) : bytes[at];
|
|
409
|
+
};
|
|
410
|
+
// The search runs on Latin-1 strings: the engine's native indexOf is many times faster than a
|
|
411
|
+
// byte loop, which matters when hundreds of values are checked against megabytes of streams.
|
|
412
|
+
const needle = n.text ?? (n.text = latin1String(n.bytes));
|
|
413
|
+
for (let i = h.text.indexOf(needle); i >= 0; i = h.text.indexOf(needle, i + 1)) {
|
|
414
|
+
if (n.boundStart && alnum(unit(i - step)))
|
|
415
|
+
continue;
|
|
416
|
+
if (n.boundEnd && alnum(unit(i + n.bytes.length)))
|
|
417
|
+
continue;
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
function uniqueNeedles(list) {
|
|
423
|
+
const out = new Set();
|
|
424
|
+
for (const n of list) {
|
|
425
|
+
const t = n.trim();
|
|
426
|
+
if (t.length >= 2)
|
|
427
|
+
out.add(t);
|
|
428
|
+
}
|
|
429
|
+
return [...out];
|
|
430
|
+
}
|
|
431
|
+
function dedupe(findings) {
|
|
432
|
+
const seen = new Set();
|
|
433
|
+
return findings.filter((f) => {
|
|
434
|
+
const k = `${f.pass}|${f.needle}|${f.where}`;
|
|
435
|
+
if (seen.has(k))
|
|
436
|
+
return false;
|
|
437
|
+
seen.add(k);
|
|
438
|
+
return true;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function hex(bytes) {
|
|
442
|
+
let s = '';
|
|
443
|
+
for (const b of bytes)
|
|
444
|
+
s += b.toString(16).padStart(2, '0');
|
|
445
|
+
return s;
|
|
446
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - writer (T213).
|
|
3
|
+
*
|
|
4
|
+
* writeAnonymizedPdfWithReport takes an extraction plus flat-text
|
|
5
|
+
* replacements and produces a redacted PDF:
|
|
6
|
+
*
|
|
7
|
+
* 1. re-extract the input bytes (deterministic) and confirm the flat text is
|
|
8
|
+
* unchanged, so offsets are trustworthy (StaleAnalysisError otherwise);
|
|
9
|
+
* 2. layer zero: every other occurrence of a session value in the text
|
|
10
|
+
* becomes a replacement too (as the docx writer scrubs all XML parts);
|
|
11
|
+
* 3. plan placeholders (font choice, fit) and rewrite the content streams;
|
|
12
|
+
* 4. scrub ToUnicode CMaps of edited fonts down to the codes still used,
|
|
13
|
+
* strip ActualText/Alt properties, drop annotations and page actions;
|
|
14
|
+
* 5. rebuild the output as a FRESH document (pdf-lib copyPages): only objects
|
|
15
|
+
* reachable from the kept pages survive; Info, XMP, outlines, forms,
|
|
16
|
+
* attachments, JavaScript, named destinations, structure tree and the
|
|
17
|
+
* incremental-update history of the input never reach the output;
|
|
18
|
+
* 6. pages that cannot be edited safely are rendered by pdf.js and replaced
|
|
19
|
+
* by an image with the redacted regions blacked out in the pixels;
|
|
20
|
+
* 7. verify the output with both extractors and a byte scan; refuse on any trace.
|
|
21
|
+
*/
|
|
22
|
+
import type { ValueReplacement } from '../docx.js';
|
|
23
|
+
import type { PageRecord } from './extract.js';
|
|
24
|
+
import type { CanvasFactory, OracleDocument } from './oracle.js';
|
|
25
|
+
import type { Segment } from './surgery.js';
|
|
26
|
+
import type { GlyphRun, PdfWriteOptions, PdfWriteResult, Rect } from './types.js';
|
|
27
|
+
import type { PdfExtraction } from './read.js';
|
|
28
|
+
import { lexContent } from './lexer.js';
|
|
29
|
+
/** Same normalised date the docx writer stamps. */
|
|
30
|
+
export declare const NORMALISED_PDF_DATE: Date;
|
|
31
|
+
export declare class StalePdfExtractionError extends Error {
|
|
32
|
+
constructor();
|
|
33
|
+
}
|
|
34
|
+
export interface PdfWriteOptionsInternal extends PdfWriteOptions {
|
|
35
|
+
/** Node/tests: canvas factory for rasterization. Browsers use OffscreenCanvas. */
|
|
36
|
+
canvasFactory?: CanvasFactory;
|
|
37
|
+
}
|
|
38
|
+
interface Replacement {
|
|
39
|
+
start: number;
|
|
40
|
+
end: number;
|
|
41
|
+
replacement: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function writeAnonymizedPdfWithReport(extraction: PdfExtraction, replacements: Replacement[], valueReplacements?: ValueReplacement[], options?: PdfWriteOptionsInternal): Promise<PdfWriteResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
|
|
46
|
+
* whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
|
|
47
|
+
* well (pdf.js item widths can be wrong for fonts it misreads), and each pdf.js item box is
|
|
48
|
+
* padded by half an em along the baseline.
|
|
49
|
+
*/
|
|
50
|
+
export declare function boxesForPage(segments: Segment[], page: number, runs: GlyphRun[], record?: PageRecord, needles?: string[]): Rect[];
|
|
51
|
+
export { lexContent };
|
|
52
|
+
export type { OracleDocument };
|