@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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - pdf.js oracle (T212).
|
|
3
|
+
*
|
|
4
|
+
* pdf.js is the INDEPENDENT second extractor. It never edits anything; it is
|
|
5
|
+
* used (1) before a page is touched, to confirm that our own decoder read the
|
|
6
|
+
* same text pdf.js reads, (2) after writing, to verify that no redacted value
|
|
7
|
+
* is extractable from the output, and (3) to render pages for the rasterize
|
|
8
|
+
* fallback (see raster.ts).
|
|
9
|
+
*
|
|
10
|
+
* Runtime configuration:
|
|
11
|
+
* - The legacy build (`pdfjs-dist/legacy/build/pdf.mjs`) is imported lazily in
|
|
12
|
+
* every environment: it carries the Node factories (fs-backed asset fetch,
|
|
13
|
+
* `@napi-rs/canvas` canvas factory) as well as the browser ones and is the
|
|
14
|
+
* build the package's typings describe. It is loaded on first use so hosts
|
|
15
|
+
* that never open a PDF never pay for it.
|
|
16
|
+
* - Node: no worker; pdf.js runs its "fake worker" on the calling thread.
|
|
17
|
+
* `cMapUrl` / `standardFontDataUrl` are file-system paths (or file: URLs).
|
|
18
|
+
* - Browsers: the host serves the worker file and passes its URL through
|
|
19
|
+
* `PdfAssetPaths.pdfjsWorkerUrl`; it is applied to
|
|
20
|
+
* `GlobalWorkerOptions.workerSrc` once. Without it pdf.js throws
|
|
21
|
+
* 'No "GlobalWorkerOptions.workerSrc" specified.' in a browser.
|
|
22
|
+
* - Fonts are never taken from the system (`useSystemFonts: false`). pdf.js 6
|
|
23
|
+
* no longer has an eval-based PostScript compiler (`isEvalSupported` is gone).
|
|
24
|
+
* - `disableFontFace` is true wherever there is no `document` (Node, Web
|
|
25
|
+
* Workers): glyphs are then drawn as paths, which also makes rendering
|
|
26
|
+
* deterministic across hosts.
|
|
27
|
+
*/
|
|
28
|
+
import { UnsupportedDocumentError } from "../dom/errors.js";
|
|
29
|
+
/** Deadline for one pdf.js page operation (text, render). */
|
|
30
|
+
export const ORACLE_DEADLINE_MS = 60000;
|
|
31
|
+
let pdfjsPromise;
|
|
32
|
+
function loadPdfJs() {
|
|
33
|
+
pdfjsPromise ?? (pdfjsPromise = import('pdfjs-dist/legacy/build/pdf.mjs'));
|
|
34
|
+
return pdfjsPromise;
|
|
35
|
+
}
|
|
36
|
+
function errorName(err) {
|
|
37
|
+
return typeof err === 'object' && err !== null ? String(err.name ?? '') : '';
|
|
38
|
+
}
|
|
39
|
+
function errorMessage(err) {
|
|
40
|
+
return err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* pdf.js takes the canvas factory as a class at getDocument() time, while we
|
|
44
|
+
* receive it per render() call. This bridge is handed to pdf.js and forwards
|
|
45
|
+
* to whichever factory the current render uses.
|
|
46
|
+
*/
|
|
47
|
+
class FactorySlot {
|
|
48
|
+
bridgeClass() {
|
|
49
|
+
const slot = this;
|
|
50
|
+
return class OracleCanvasFactory {
|
|
51
|
+
constructor(_options) { }
|
|
52
|
+
create(width, height) {
|
|
53
|
+
if (width <= 0 || height <= 0)
|
|
54
|
+
throw new Error('Invalid canvas size');
|
|
55
|
+
const factory = slot.current ?? defaultCanvasFactory();
|
|
56
|
+
const handle = factory.create(Math.ceil(width), Math.ceil(height));
|
|
57
|
+
return { canvas: handle.canvas, context: handle.context };
|
|
58
|
+
}
|
|
59
|
+
reset(pair, width, height) {
|
|
60
|
+
if (!pair.canvas)
|
|
61
|
+
throw new Error('Canvas is not specified');
|
|
62
|
+
if (width <= 0 || height <= 0)
|
|
63
|
+
throw new Error('Invalid canvas size');
|
|
64
|
+
pair.canvas.width = Math.ceil(width);
|
|
65
|
+
pair.canvas.height = Math.ceil(height);
|
|
66
|
+
}
|
|
67
|
+
destroy(pair) {
|
|
68
|
+
if (!pair.canvas)
|
|
69
|
+
throw new Error('Canvas is not specified');
|
|
70
|
+
pair.canvas.width = 0;
|
|
71
|
+
pair.canvas.height = 0;
|
|
72
|
+
pair.canvas = null;
|
|
73
|
+
pair.context = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Browser default: OffscreenCanvas when available (works in Web Workers and
|
|
80
|
+
* on the main thread), else an HTMLCanvasElement. Node has neither: inject a
|
|
81
|
+
* factory (tests use a recording double; hosts can wrap `@napi-rs/canvas`).
|
|
82
|
+
*/
|
|
83
|
+
export function defaultCanvasFactory() {
|
|
84
|
+
const g = globalThis;
|
|
85
|
+
if (typeof g.OffscreenCanvas === 'function') {
|
|
86
|
+
const Offscreen = g.OffscreenCanvas;
|
|
87
|
+
return {
|
|
88
|
+
create(width, height) {
|
|
89
|
+
const canvas = new Offscreen(width, height);
|
|
90
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
91
|
+
if (!context)
|
|
92
|
+
throw new Error('OffscreenCanvas 2D context unavailable');
|
|
93
|
+
return {
|
|
94
|
+
canvas,
|
|
95
|
+
context,
|
|
96
|
+
async toPng() {
|
|
97
|
+
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
|
98
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
99
|
+
},
|
|
100
|
+
getImageData() {
|
|
101
|
+
return context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (g.document && typeof g.document.createElement === 'function') {
|
|
108
|
+
const document = g.document;
|
|
109
|
+
return {
|
|
110
|
+
create(width, height) {
|
|
111
|
+
const canvas = document.createElement('canvas');
|
|
112
|
+
canvas.width = width;
|
|
113
|
+
canvas.height = height;
|
|
114
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
115
|
+
if (!context)
|
|
116
|
+
throw new Error('Canvas 2D context unavailable');
|
|
117
|
+
return {
|
|
118
|
+
canvas,
|
|
119
|
+
context,
|
|
120
|
+
toPng() {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
canvas.toBlob((blob) => {
|
|
123
|
+
if (!blob) {
|
|
124
|
+
reject(new Error('canvas.toBlob returned null'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
blob.arrayBuffer().then((buf) => resolve(new Uint8Array(buf)), reject);
|
|
128
|
+
}, 'image/png');
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
getImageData() {
|
|
132
|
+
return context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
throw new Error('No canvas implementation available in this runtime: pass a canvasFactory');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Open `bytes` with pdf.js. The buffer is copied because pdf.js transfers
|
|
142
|
+
* (and thereby neuters) the ArrayBuffer it is given. Password-protected files
|
|
143
|
+
* without the right password raise UnsupportedDocumentError('encrypted');
|
|
144
|
+
* files pdf.js cannot parse at all raise 'invalid-package'.
|
|
145
|
+
*/
|
|
146
|
+
export async function openWithOracle(bytes, assets, password) {
|
|
147
|
+
const pdfjs = await loadPdfJs();
|
|
148
|
+
if (assets?.pdfjsWorkerUrl && pdfjs.GlobalWorkerOptions.workerSrc !== assets.pdfjsWorkerUrl) {
|
|
149
|
+
pdfjs.GlobalWorkerOptions.workerSrc = assets.pdfjsWorkerUrl;
|
|
150
|
+
}
|
|
151
|
+
const slot = new FactorySlot();
|
|
152
|
+
const data = new Uint8Array(bytes.byteLength);
|
|
153
|
+
data.set(bytes);
|
|
154
|
+
const task = pdfjs.getDocument({
|
|
155
|
+
data,
|
|
156
|
+
password,
|
|
157
|
+
cMapUrl: assets?.cMapUrl,
|
|
158
|
+
cMapPacked: true,
|
|
159
|
+
standardFontDataUrl: assets?.standardFontDataUrl,
|
|
160
|
+
wasmUrl: assets?.wasmUrl,
|
|
161
|
+
useSystemFonts: false,
|
|
162
|
+
// Never register the document's fonts with the browser's FontFace API: text extraction does
|
|
163
|
+
// not need it and every registration is a worker round trip per font (a 100-font file took
|
|
164
|
+
// ten times longer in a browser than in Node); the raster renders glyph outlines itself.
|
|
165
|
+
disableFontFace: true,
|
|
166
|
+
stopAtErrors: false,
|
|
167
|
+
verbosity: 0,
|
|
168
|
+
useWorkerFetch: false,
|
|
169
|
+
CanvasFactory: slot.bridgeClass(),
|
|
170
|
+
});
|
|
171
|
+
let doc;
|
|
172
|
+
try {
|
|
173
|
+
doc = await task.promise;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
await task.destroy().catch(() => undefined);
|
|
177
|
+
const name = errorName(err);
|
|
178
|
+
if (name === 'PasswordException') {
|
|
179
|
+
throw new UnsupportedDocumentError('encrypted', 'PDF is encrypted; a valid user password is required', [errorMessage(err)]);
|
|
180
|
+
}
|
|
181
|
+
if (name === 'InvalidPDFException') {
|
|
182
|
+
throw new UnsupportedDocumentError('invalid-package', 'pdf.js cannot parse this PDF', [errorMessage(err)]);
|
|
183
|
+
}
|
|
184
|
+
// Anything else pdf.js raises while opening (unsupported encryption, broken xref, a worker
|
|
185
|
+
// error) is a document we cannot handle, never an internal failure.
|
|
186
|
+
const message = errorMessage(err);
|
|
187
|
+
if (/encrypt|password/i.test(message))
|
|
188
|
+
throw new UnsupportedDocumentError('encrypted', 'PDF uses an encryption pdf.js cannot open', [message]);
|
|
189
|
+
throw new UnsupportedDocumentError('invalid-package', `pdf.js cannot open this PDF: ${message}`, [message]);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* pdf.js can be made to work forever by a hostile content stream (a hundred thousand nested
|
|
193
|
+
* `q`, an exponential chain of form XObjects): every page operation gets a deadline, after which
|
|
194
|
+
* the worker is torn down and the caller sees an error (a page issue, or a refusal).
|
|
195
|
+
*/
|
|
196
|
+
const deadline = (what, work) => {
|
|
197
|
+
let timer;
|
|
198
|
+
const timeout = new Promise((_, reject) => {
|
|
199
|
+
timer = setTimeout(() => {
|
|
200
|
+
void task.destroy().catch(() => undefined);
|
|
201
|
+
reject(new Error(`pdf.js did not finish ${what} within ${ORACLE_DEADLINE_MS / 1000} s`));
|
|
202
|
+
}, ORACLE_DEADLINE_MS);
|
|
203
|
+
});
|
|
204
|
+
return Promise.race([work, timeout]).finally(() => { if (timer !== undefined)
|
|
205
|
+
clearTimeout(timer); });
|
|
206
|
+
};
|
|
207
|
+
const getPage = async (index) => {
|
|
208
|
+
if (!Number.isInteger(index) || index < 0 || index >= doc.numPages) {
|
|
209
|
+
throw new RangeError(`page index ${index} out of range (0..${doc.numPages - 1})`);
|
|
210
|
+
}
|
|
211
|
+
return doc.getPage(index + 1);
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
pageCount: doc.numPages,
|
|
215
|
+
async pageText(index) {
|
|
216
|
+
const page = await deadline(`loading page ${index + 1}`, getPage(index));
|
|
217
|
+
const content = await deadline(`reading page ${index + 1}`, page.getTextContent({ includeMarkedContent: false, disableNormalization: true }));
|
|
218
|
+
let out = '';
|
|
219
|
+
for (const item of content.items) {
|
|
220
|
+
if (!('str' in item))
|
|
221
|
+
continue;
|
|
222
|
+
out += item.str;
|
|
223
|
+
if (item.hasEOL)
|
|
224
|
+
out += '\n';
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
},
|
|
228
|
+
async pageItems(index) {
|
|
229
|
+
const page = await deadline(`loading page ${index + 1}`, getPage(index));
|
|
230
|
+
const content = await deadline(`reading page ${index + 1}`, page.getTextContent({ includeMarkedContent: false, disableNormalization: true }));
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const item of content.items) {
|
|
233
|
+
if (!('str' in item))
|
|
234
|
+
continue;
|
|
235
|
+
out.push({ str: item.str, transform: item.transform, width: item.width, height: item.height, hasEOL: item.hasEOL, fontName: item.fontName, dir: item.dir });
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
},
|
|
239
|
+
async pageSize(index) {
|
|
240
|
+
const page = await getPage(index);
|
|
241
|
+
const [x0, y0, x1, y1] = page.view;
|
|
242
|
+
return { width: Math.abs(x1 - x0), height: Math.abs(y1 - y0), rotate: page.rotate };
|
|
243
|
+
},
|
|
244
|
+
async render(index, scale, canvasFactory) {
|
|
245
|
+
if (!(scale > 0) || !Number.isFinite(scale))
|
|
246
|
+
throw new RangeError(`invalid render scale ${scale}`);
|
|
247
|
+
const page = await deadline(`loading page ${index + 1}`, getPage(index));
|
|
248
|
+
const viewport = page.getViewport({ scale });
|
|
249
|
+
const widthPx = Math.ceil(viewport.width);
|
|
250
|
+
const heightPx = Math.ceil(viewport.height);
|
|
251
|
+
const factory = canvasFactory ?? defaultCanvasFactory();
|
|
252
|
+
slot.current = factory;
|
|
253
|
+
try {
|
|
254
|
+
const handle = factory.create(widthPx, heightPx);
|
|
255
|
+
const renderTask = page.render({
|
|
256
|
+
canvas: (handle.canvas ?? null),
|
|
257
|
+
canvasContext: handle.context,
|
|
258
|
+
viewport,
|
|
259
|
+
intent: 'print',
|
|
260
|
+
annotationMode: pdfjs.AnnotationMode.DISABLE,
|
|
261
|
+
background: 'rgb(255,255,255)',
|
|
262
|
+
});
|
|
263
|
+
await deadline(`rendering page ${index + 1}`, renderTask.promise);
|
|
264
|
+
return {
|
|
265
|
+
widthPx,
|
|
266
|
+
heightPx,
|
|
267
|
+
widthPt: viewport.width / scale,
|
|
268
|
+
heightPt: viewport.height / scale,
|
|
269
|
+
transform: Array.from(viewport.transform),
|
|
270
|
+
canvas: handle,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
slot.current = undefined;
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
async close() {
|
|
278
|
+
await task.destroy();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** Text of every page, in page order (see {@link OracleDocument.pageText}). */
|
|
283
|
+
export async function oracleText(doc) {
|
|
284
|
+
const pages = [];
|
|
285
|
+
for (let i = 0; i < doc.pageCount; i++)
|
|
286
|
+
pages.push(await doc.pageText(i));
|
|
287
|
+
return pages;
|
|
288
|
+
}
|
|
289
|
+
const LIGATURES = {
|
|
290
|
+
'ff': 'ff',
|
|
291
|
+
'fi': 'fi',
|
|
292
|
+
'fl': 'fl',
|
|
293
|
+
'ffi': 'ffi',
|
|
294
|
+
'ffl': 'ffl',
|
|
295
|
+
'ſt': 'st',
|
|
296
|
+
'st': 'st',
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Whitespace (JS `\s` is Unicode White_Space minus U+0085), the soft hyphen,
|
|
300
|
+
* zero-width characters and bidi controls, all of which one extractor may emit
|
|
301
|
+
* and another drop.
|
|
302
|
+
*/
|
|
303
|
+
const DROP_RE = /[\s\u0085----]/gu;
|
|
304
|
+
/**
|
|
305
|
+
* Bring two extractors' output onto common ground: NFC, typographic
|
|
306
|
+
* ligatures spelled out, and every whitespace / soft-hyphen / zero-width
|
|
307
|
+
* character removed. Case and punctuation are kept.
|
|
308
|
+
*/
|
|
309
|
+
export function normalizeForCompare(text) {
|
|
310
|
+
return foldGlyphText(text.normalize('NFC')).replace(DROP_RE, '');
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Kangxi radicals, CJK radicals supplement and CJK compatibility ideographs: some ToUnicode
|
|
314
|
+
* maps (Chrome with Hiragino) spell ordinary ideographs with these look-alikes; NFKC turns them
|
|
315
|
+
* into the unified ideograph a detector and a user type.
|
|
316
|
+
*/
|
|
317
|
+
const COMPAT_CJK_RE = /[\u2E80-\u2EF3\u2F00-\u2FD5\uF900-\uFAFF]/u;
|
|
318
|
+
/**
|
|
319
|
+
* The text of a glyph as a detector should see it: typographic ligatures spelled out
|
|
320
|
+
* (fi -> fi) and CJK compatibility forms unified. Applied when glyphs are decoded, so plainText,
|
|
321
|
+
* the oracle comparison and the layer-zero search all agree.
|
|
322
|
+
*/
|
|
323
|
+
export function foldGlyphText(text) {
|
|
324
|
+
let out = text.replace(/[ff-st]/g, (m) => LIGATURES[m] ?? m);
|
|
325
|
+
if (COMPAT_CJK_RE.test(out))
|
|
326
|
+
out = [...out].map((ch) => (COMPAT_CJK_RE.test(ch) ? ch.normalize('NFKC') : ch)).join('');
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The same folding as {@link normalizeForCompare} plus a length-preserving
|
|
331
|
+
* lower-casing, with an offset map back into the input, so a search in the
|
|
332
|
+
* folded text can be turned into a replacement range of the original. Used
|
|
333
|
+
* by the writer's layer zero so that it covers exactly what the verifier
|
|
334
|
+
* would flag (a value wrapped over a line end, spaced differently, or
|
|
335
|
+
* written with a typographic ligature).
|
|
336
|
+
*/
|
|
337
|
+
export function foldForSearch(text) {
|
|
338
|
+
let folded = '';
|
|
339
|
+
const offsets = [];
|
|
340
|
+
const dropRe = new RegExp(DROP_RE.source, 'u');
|
|
341
|
+
for (let i = 0; i < text.length; i++) {
|
|
342
|
+
const c = text[i];
|
|
343
|
+
if (dropRe.test(c))
|
|
344
|
+
continue;
|
|
345
|
+
// Hyphens are dropped on both sides, so a value broken at a line end ("Kowal-" / "ski") and a
|
|
346
|
+
// value written with or without hyphens ("600-100-200") are found either way.
|
|
347
|
+
if (c === '-' || c === '\u2010' || c === '\u2011')
|
|
348
|
+
continue;
|
|
349
|
+
const expanded = foldGlyphText(c);
|
|
350
|
+
for (const ch of expanded) {
|
|
351
|
+
const lower = ch.toLowerCase();
|
|
352
|
+
folded += lower.length === 1 ? lower : ch;
|
|
353
|
+
offsets.push(i);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { folded, offsets };
|
|
357
|
+
}
|
|
358
|
+
const WORD_CHAR_RE = /[\p{L}\p{N}]/u;
|
|
359
|
+
/** The search fold of a text, reusable across many values (folding a long text once). */
|
|
360
|
+
export function searchIndex(text) {
|
|
361
|
+
const { folded, offsets } = foldForSearch(text);
|
|
362
|
+
return { text, folded, offsets };
|
|
363
|
+
}
|
|
364
|
+
export function findOccurrences(source, value) {
|
|
365
|
+
const out = [];
|
|
366
|
+
const needle = foldForSearch(value).folded;
|
|
367
|
+
if (needle.length < 2)
|
|
368
|
+
return out;
|
|
369
|
+
const index = typeof source === 'string' ? searchIndex(source) : source;
|
|
370
|
+
const { text, folded, offsets } = index;
|
|
371
|
+
const boundStart = WORD_CHAR_RE.test(value.trim().charAt(0));
|
|
372
|
+
const boundEnd = WORD_CHAR_RE.test(value.trim().slice(-1));
|
|
373
|
+
for (let i = folded.indexOf(needle); i >= 0; i = folded.indexOf(needle, i + 1)) {
|
|
374
|
+
const start = offsets[i];
|
|
375
|
+
const end = offsets[i + needle.length - 1] + 1;
|
|
376
|
+
if (boundStart && start > 0 && WORD_CHAR_RE.test(text.charAt(start - 1)))
|
|
377
|
+
continue;
|
|
378
|
+
if (boundEnd && end < text.length && WORD_CHAR_RE.test(text.charAt(end)))
|
|
379
|
+
continue;
|
|
380
|
+
out.push([start, end]);
|
|
381
|
+
i += needle.length - 1;
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - page rasterization fallback (T212).
|
|
3
|
+
*
|
|
4
|
+
* A page whose text cannot be edited safely is rendered to pixels by pdf.js
|
|
5
|
+
* and the redacted rectangles are painted black IN THE PIXELS, on the same
|
|
6
|
+
* canvas, after the render: the PNG never contains what was under a box.
|
|
7
|
+
* The caller (writer) replaces the page contents with this image and
|
|
8
|
+
* reports the page in `rasterizedPages`.
|
|
9
|
+
*/
|
|
10
|
+
import type { CanvasFactory, OracleDocument } from './oracle.js';
|
|
11
|
+
import type { Rect } from './types.js';
|
|
12
|
+
export interface RasterizedPage {
|
|
13
|
+
png: Uint8Array;
|
|
14
|
+
widthPx: number;
|
|
15
|
+
heightPx: number;
|
|
16
|
+
/** Displayed page size in points (CropBox with /Rotate applied), the size of a replacement page that shows the PNG unrotated. */
|
|
17
|
+
widthPt: number;
|
|
18
|
+
heightPt: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RasterizeOptions {
|
|
21
|
+
dpi: number;
|
|
22
|
+
canvasFactory?: CanvasFactory;
|
|
23
|
+
}
|
|
24
|
+
/** Integer pixel box, top-left origin. */
|
|
25
|
+
export interface PixelBox {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
w: number;
|
|
29
|
+
h: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Map a user-space rectangle to canvas pixels through the pdf.js viewport
|
|
33
|
+
* transform [a b c d e f] (x' = a*x + c*y + e, y' = b*x + d*y + f). The four
|
|
34
|
+
* corners are transformed (rotation may swap axes) and their bounding box is
|
|
35
|
+
* returned, expanded outwards to whole pixels. No padding is applied here.
|
|
36
|
+
*/
|
|
37
|
+
export declare function userRectToPixels(viewportTransform: number[], rect: Rect): PixelBox;
|
|
38
|
+
/** Pad a pixel box by `pad` on every side and clamp it to the canvas. Empty when the box lies outside. */
|
|
39
|
+
export declare function padAndClamp(box: PixelBox, pad: number, widthPx: number, heightPx: number): PixelBox;
|
|
40
|
+
/**
|
|
41
|
+
* Render page `index` at `options.dpi` (pdf.js applies /Rotate as a viewer
|
|
42
|
+
* would), paint every `boxes` rectangle black on the rendered pixels, and
|
|
43
|
+
* encode the canvas as PNG.
|
|
44
|
+
*/
|
|
45
|
+
export declare function rasterizePage(doc: OracleDocument, index: number, boxes: Rect[], options: RasterizeOptions): Promise<RasterizedPage>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - page rasterization fallback (T212).
|
|
3
|
+
*
|
|
4
|
+
* A page whose text cannot be edited safely is rendered to pixels by pdf.js
|
|
5
|
+
* and the redacted rectangles are painted black IN THE PIXELS, on the same
|
|
6
|
+
* canvas, after the render: the PNG never contains what was under a box.
|
|
7
|
+
* The caller (writer) replaces the page contents with this image and
|
|
8
|
+
* reports the page in `rasterizedPages`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Map a user-space rectangle to canvas pixels through the pdf.js viewport
|
|
12
|
+
* transform [a b c d e f] (x' = a*x + c*y + e, y' = b*x + d*y + f). The four
|
|
13
|
+
* corners are transformed (rotation may swap axes) and their bounding box is
|
|
14
|
+
* returned, expanded outwards to whole pixels. No padding is applied here.
|
|
15
|
+
*/
|
|
16
|
+
export function userRectToPixels(viewportTransform, rect) {
|
|
17
|
+
const [a, b, c, d, e, f] = viewportTransform;
|
|
18
|
+
const xs = [rect.x0, rect.x1, rect.x0, rect.x1];
|
|
19
|
+
const ys = [rect.y0, rect.y0, rect.y1, rect.y1];
|
|
20
|
+
let minX = Infinity;
|
|
21
|
+
let minY = Infinity;
|
|
22
|
+
let maxX = -Infinity;
|
|
23
|
+
let maxY = -Infinity;
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
const px = a * xs[i] + c * ys[i] + e;
|
|
26
|
+
const py = b * xs[i] + d * ys[i] + f;
|
|
27
|
+
if (px < minX)
|
|
28
|
+
minX = px;
|
|
29
|
+
if (px > maxX)
|
|
30
|
+
maxX = px;
|
|
31
|
+
if (py < minY)
|
|
32
|
+
minY = py;
|
|
33
|
+
if (py > maxY)
|
|
34
|
+
maxY = py;
|
|
35
|
+
}
|
|
36
|
+
// Round to whole pixels outwards; tolerate float noise (1224.0000001 stays 1224).
|
|
37
|
+
const EPS = 1e-6;
|
|
38
|
+
const x = Math.floor(minX + EPS);
|
|
39
|
+
const y = Math.floor(minY + EPS);
|
|
40
|
+
const x1 = Math.ceil(maxX - EPS);
|
|
41
|
+
const y1 = Math.ceil(maxY - EPS);
|
|
42
|
+
return { x, y, w: Math.max(0, x1 - x), h: Math.max(0, y1 - y) };
|
|
43
|
+
}
|
|
44
|
+
/** Pad a pixel box by `pad` on every side and clamp it to the canvas. Empty when the box lies outside. */
|
|
45
|
+
export function padAndClamp(box, pad, widthPx, heightPx) {
|
|
46
|
+
const x0 = Math.max(0, box.x - pad);
|
|
47
|
+
const y0 = Math.max(0, box.y - pad);
|
|
48
|
+
const x1 = Math.min(widthPx, box.x + box.w + pad);
|
|
49
|
+
const y1 = Math.min(heightPx, box.y + box.h + pad);
|
|
50
|
+
if (x1 <= x0 || y1 <= y0)
|
|
51
|
+
return { x: 0, y: 0, w: 0, h: 0 };
|
|
52
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
53
|
+
}
|
|
54
|
+
/** Pixels of padding around every box (covers anti-aliased glyph edges). */
|
|
55
|
+
const BOX_PAD_PX = 1;
|
|
56
|
+
/**
|
|
57
|
+
* Render page `index` at `options.dpi` (pdf.js applies /Rotate as a viewer
|
|
58
|
+
* would), paint every `boxes` rectangle black on the rendered pixels, and
|
|
59
|
+
* encode the canvas as PNG.
|
|
60
|
+
*/
|
|
61
|
+
export async function rasterizePage(doc, index, boxes, options) {
|
|
62
|
+
if (!(options.dpi > 0) || !Number.isFinite(options.dpi))
|
|
63
|
+
throw new RangeError(`invalid dpi ${options.dpi}`);
|
|
64
|
+
const scale = options.dpi / 72;
|
|
65
|
+
const page = await doc.render(index, scale, options.canvasFactory);
|
|
66
|
+
const ctx = page.canvas.context;
|
|
67
|
+
// pdf.js leaves its own transform / composite state on the context: draw
|
|
68
|
+
// in plain device pixels with an opaque black brush.
|
|
69
|
+
ctx.save();
|
|
70
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
71
|
+
ctx.globalAlpha = 1;
|
|
72
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
73
|
+
ctx.fillStyle = '#000000';
|
|
74
|
+
for (const box of boxes) {
|
|
75
|
+
const px = padAndClamp(userRectToPixels(page.transform, box), BOX_PAD_PX, page.widthPx, page.heightPx);
|
|
76
|
+
if (px.w === 0 || px.h === 0)
|
|
77
|
+
continue;
|
|
78
|
+
ctx.fillRect(px.x, px.y, px.w, px.h);
|
|
79
|
+
}
|
|
80
|
+
ctx.restore();
|
|
81
|
+
const png = await page.canvas.toPng();
|
|
82
|
+
return {
|
|
83
|
+
png,
|
|
84
|
+
widthPx: page.widthPx,
|
|
85
|
+
heightPx: page.heightPx,
|
|
86
|
+
widthPt: page.widthPt,
|
|
87
|
+
heightPt: page.heightPt,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - readPdf (T213/T215).
|
|
3
|
+
*
|
|
4
|
+
* Parses a PDF, extracts its text with glyph geometry, cross-checks every
|
|
5
|
+
* page against pdf.js and returns the extraction hosts run detection on.
|
|
6
|
+
* The extraction keeps the input bytes; the writer re-extracts from them so
|
|
7
|
+
* a stale extraction can never be applied to different bytes.
|
|
8
|
+
*/
|
|
9
|
+
import type { GlyphRun, PdfAssetPaths, PdfPageInfo, PdfReadOptions, RemovedPart } from './types.js';
|
|
10
|
+
import type { UnredactablePart } from '../dom/errors.js';
|
|
11
|
+
export interface PdfExtraction {
|
|
12
|
+
/** Flat text detection runs on. */
|
|
13
|
+
plainText: string;
|
|
14
|
+
pageCount: number;
|
|
15
|
+
pages: PdfPageInfo[];
|
|
16
|
+
/** Glyph runs in flat-text order (internal geometry, exposed for hosts that draw previews). */
|
|
17
|
+
runs: GlyphRun[];
|
|
18
|
+
/** Parts that cannot be redacted (scanned pages, undecodable text); gate the export like docx. */
|
|
19
|
+
unredactable: UnredactablePart[];
|
|
20
|
+
/** Parts the writer will drop from the output. */
|
|
21
|
+
removed: RemovedPart[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
/** True when no readable text was found. */
|
|
24
|
+
empty: boolean;
|
|
25
|
+
/** 0-based pages whose text came from pdf.js because our decoder disagreed (edits rasterize them). */
|
|
26
|
+
rasterOnlyPages: number[];
|
|
27
|
+
/** Input bytes (the writer's source of truth). */
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
assets?: PdfAssetPaths;
|
|
30
|
+
password?: string;
|
|
31
|
+
}
|
|
32
|
+
export type PdfInput = Blob | ArrayBuffer | Uint8Array;
|
|
33
|
+
export declare function toPdfBytes(input: PdfInput): Promise<Uint8Array>;
|
|
34
|
+
export declare function readPdf(input: PdfInput, options?: PdfReadOptions): Promise<PdfExtraction>;
|
|
35
|
+
/** True for names ending in .pdf (case-insensitive). */
|
|
36
|
+
export declare function isPdfFile(filename: string): boolean;
|
package/dist/pdf/read.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - readPdf (T213/T215).
|
|
3
|
+
*
|
|
4
|
+
* Parses a PDF, extracts its text with glyph geometry, cross-checks every
|
|
5
|
+
* page against pdf.js and returns the extraction hosts run detection on.
|
|
6
|
+
* The extraction keeps the input bytes; the writer re-extracts from them so
|
|
7
|
+
* a stale extraction can never be applied to different bytes.
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_MAX_BYTES, extractPdf } from "./extract.js";
|
|
10
|
+
import { UnsupportedDocumentError } from "../dom/errors.js";
|
|
11
|
+
import { openWithOracle } from "./oracle.js";
|
|
12
|
+
export async function toPdfBytes(input) {
|
|
13
|
+
if (input instanceof Uint8Array)
|
|
14
|
+
return input;
|
|
15
|
+
if (input instanceof ArrayBuffer)
|
|
16
|
+
return new Uint8Array(input);
|
|
17
|
+
if (typeof input.arrayBuffer === 'function')
|
|
18
|
+
return new Uint8Array(await input.arrayBuffer());
|
|
19
|
+
// Older Blob implementations (jsdom): go through FileReader.
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const reader = new FileReader();
|
|
22
|
+
reader.onerror = () => reject(reader.error ?? new Error('could not read the file'));
|
|
23
|
+
reader.onload = () => resolve(new Uint8Array(reader.result));
|
|
24
|
+
reader.readAsArrayBuffer(input);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export async function readPdf(input, options = {}) {
|
|
28
|
+
const bytes = await toPdfBytes(input);
|
|
29
|
+
// Size cap before anything parses the file (pdf.js would otherwise read all of it first).
|
|
30
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
31
|
+
if (bytes.length > maxBytes) {
|
|
32
|
+
throw new UnsupportedDocumentError('too-large', `PDF is ${bytes.length} bytes; the limit is ${maxBytes}`, [String(bytes.length)]);
|
|
33
|
+
}
|
|
34
|
+
const oracle = await openWithOracle(bytes, options.assets, options.password);
|
|
35
|
+
try {
|
|
36
|
+
const res = await extractPdf(bytes, {
|
|
37
|
+
maxBytes: options.maxBytes,
|
|
38
|
+
maxPages: options.maxPages,
|
|
39
|
+
maxGlyphs: options.maxGlyphs,
|
|
40
|
+
password: options.password,
|
|
41
|
+
oracle,
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
plainText: res.plainText,
|
|
45
|
+
pageCount: res.pages.length,
|
|
46
|
+
pages: res.pages,
|
|
47
|
+
runs: res.runs,
|
|
48
|
+
unredactable: res.unredactable,
|
|
49
|
+
removed: res.removed,
|
|
50
|
+
warnings: res.warnings,
|
|
51
|
+
empty: res.empty,
|
|
52
|
+
rasterOnlyPages: res.model.pages.filter((p) => p.oracleText).map((p) => p.index),
|
|
53
|
+
bytes,
|
|
54
|
+
assets: options.assets,
|
|
55
|
+
password: options.password,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await oracle.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** True for names ending in .pdf (case-insensitive). */
|
|
63
|
+
export function isPdfFile(filename) {
|
|
64
|
+
return /\.pdf$/i.test(filename.trim());
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - content stream serializer (T206).
|
|
3
|
+
*
|
|
4
|
+
* Writes operators back to bytes. Operators that still carry their source
|
|
5
|
+
* range are copied verbatim from the original stream (so nothing we did not
|
|
6
|
+
* touch changes by even a byte); synthesized or edited operators are emitted
|
|
7
|
+
* from their operands. New strings are always written as hex strings, which
|
|
8
|
+
* is binary-safe for every font encoding.
|
|
9
|
+
*/
|
|
10
|
+
import type { ContentOp, Operand } from './lexer.js';
|
|
11
|
+
export declare function formatNumber(value: number): string;
|
|
12
|
+
export declare function hexOf(bytes: Uint8Array): string;
|
|
13
|
+
export declare function serializeOperand(operand: Operand): string;
|
|
14
|
+
/** Serialize one operator from its operands (ignores the source range). */
|
|
15
|
+
export declare function serializeOp(op: ContentOp): string;
|
|
16
|
+
/**
|
|
17
|
+
* Serialize a whole operator list. `source` is the original stream: an op
|
|
18
|
+
* whose [start, end) range is present is copied from it byte-for-byte.
|
|
19
|
+
*/
|
|
20
|
+
export declare function serializeOps(ops: readonly ContentOp[], source?: Uint8Array): Uint8Array;
|
|
21
|
+
export declare function latin1Bytes(text: string): Uint8Array;
|