@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,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - extraction with glyph geometry (T210).
|
|
3
|
+
*
|
|
4
|
+
* Walks every page's content (the /Contents streams joined into one operator
|
|
5
|
+
* list, plus form XObjects recursively) with the state machine, decodes every
|
|
6
|
+
* text-showing operator through the font model and records one GlyphRun per
|
|
7
|
+
* operator: codes, Unicode, advances and user-space origins. The flat text
|
|
8
|
+
* is built from the runs in content order with deterministic separators, so
|
|
9
|
+
* detection offsets map back to (stream, operator, glyph).
|
|
10
|
+
*
|
|
11
|
+
* Everything the writer needs later (parsed document, operator lists, fonts,
|
|
12
|
+
* per-page safety issues) is kept in the returned model; the input bytes are
|
|
13
|
+
* never modified.
|
|
14
|
+
*/
|
|
15
|
+
import { PDFDocument } from '@cantoo/pdf-lib';
|
|
16
|
+
import { PDFArray, PDFDict, PDFName, PDFNumber, PDFRef, PDFStream, StreamDecodeError, decodeStream, getArray, getDict, getName, getNumber, getStream, inheritedPageValue, itemsOf, numbersOf, pageContentStreams, pageResources, refKey, resolve, stringText, } from "./objects.js";
|
|
17
|
+
import { lexContent, TEXT_SHOW_OPS } from "./lexer.js";
|
|
18
|
+
import { StateMachine, adjustmentAdvance, apply, glyphAdvance, initialState, mul, matrixFromOperands } from "./textstate.js";
|
|
19
|
+
import { loadFont } from "./fonts.js";
|
|
20
|
+
import { UnsupportedDocumentError } from "../dom/errors.js";
|
|
21
|
+
import { normalizeForCompare } from "./oracle.js";
|
|
22
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
23
|
+
export const DEFAULT_MAX_PAGES = 500;
|
|
24
|
+
const MAX_XOBJECT_DEPTH = 8;
|
|
25
|
+
const MAX_OPS_PER_PAGE = 2000000;
|
|
26
|
+
/** Decoded content bytes walked per page (page streams, form XObjects, patterns): a Flate bomb stops here. */
|
|
27
|
+
const MAX_CONTENT_BYTES_PER_PAGE = 32 * 1024 * 1024;
|
|
28
|
+
/** Form XObject draws per page: an exponential chain of forms stops here. */
|
|
29
|
+
const MAX_XOBJECT_DRAWS_PER_PAGE = 20000;
|
|
30
|
+
/** Glyphs per document (each glyph is a JavaScript object with its geometry): beyond this the file is too large for a browser tab. */
|
|
31
|
+
export const DEFAULT_MAX_GLYPHS = 1500000;
|
|
32
|
+
/** Parse the file and extract text with geometry. Throws UnsupportedDocumentError for refusals. */
|
|
33
|
+
export async function extractPdf(bytes, options = {}) {
|
|
34
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
35
|
+
if (bytes.length > maxBytes) {
|
|
36
|
+
throw new UnsupportedDocumentError('too-large', `PDF is ${bytes.length} bytes; the limit is ${maxBytes}`, [String(bytes.length)]);
|
|
37
|
+
}
|
|
38
|
+
if (!looksLikePdf(bytes)) {
|
|
39
|
+
throw new UnsupportedDocumentError('invalid-package', 'not a PDF file (missing %PDF header)');
|
|
40
|
+
}
|
|
41
|
+
let doc;
|
|
42
|
+
try {
|
|
43
|
+
doc = await PDFDocument.load(bytes, {
|
|
44
|
+
ignoreEncryption: true,
|
|
45
|
+
updateMetadata: false,
|
|
46
|
+
throwOnInvalidObject: false,
|
|
47
|
+
capNumbers: true,
|
|
48
|
+
// An empty user password opens files that only carry an owner password (print/copy
|
|
49
|
+
// restrictions), the common case; pdf.js opens those without asking too.
|
|
50
|
+
password: options.password ?? '',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const message = err.message ?? String(err);
|
|
55
|
+
if (/password|encrypt|decrypt/i.test(message)) {
|
|
56
|
+
throw new UnsupportedDocumentError('encrypted', 'PDF is encrypted; supply the password to redact it', [message]);
|
|
57
|
+
}
|
|
58
|
+
throw new UnsupportedDocumentError('invalid-package', `PDF could not be parsed: ${message}`);
|
|
59
|
+
}
|
|
60
|
+
if (doc.isEncrypted) {
|
|
61
|
+
throw new UnsupportedDocumentError('encrypted', 'PDF is encrypted; supply the password to redact it');
|
|
62
|
+
}
|
|
63
|
+
const ctx = doc.context;
|
|
64
|
+
let pdfPages;
|
|
65
|
+
try {
|
|
66
|
+
pdfPages = doc.getPages();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// A page tree that is not one (cycles, a leaf as root, an invalid object) is a broken file.
|
|
70
|
+
throw new UnsupportedDocumentError('invalid-package', `PDF page tree cannot be read: ${err.message ?? String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
73
|
+
if (pdfPages.length > maxPages) {
|
|
74
|
+
throw new UnsupportedDocumentError('too-large', `PDF has ${pdfPages.length} pages; the limit is ${maxPages}`, [String(pdfPages.length)]);
|
|
75
|
+
}
|
|
76
|
+
const model = { bytes, doc, ctx, pages: [], streams: new Map(), fonts: new Map() };
|
|
77
|
+
const warnings = [];
|
|
78
|
+
const unredactable = [];
|
|
79
|
+
const runs = [];
|
|
80
|
+
const pageInfos = [];
|
|
81
|
+
const text = new TextBuilder();
|
|
82
|
+
for (let p = 0; p < pdfPages.length; p++) {
|
|
83
|
+
const page = pdfPages[p];
|
|
84
|
+
const pageDict = page.node;
|
|
85
|
+
const pageRef = page.ref;
|
|
86
|
+
const mediaBox = numbersOf(ctx, asArray(inheritedPageValue(ctx, pageDict, 'MediaBox')));
|
|
87
|
+
const width = mediaBox.length === 4 ? Math.abs(mediaBox[2] - mediaBox[0]) : 612;
|
|
88
|
+
const height = mediaBox.length === 4 ? Math.abs(mediaBox[3] - mediaBox[1]) : 792;
|
|
89
|
+
const rotateObj = inheritedPageValue(ctx, pageDict, 'Rotate');
|
|
90
|
+
const rotate = rotateObj instanceof PDFNumber ? ((rotateObj.asNumber() % 360) + 360) % 360 : 0;
|
|
91
|
+
const record = {
|
|
92
|
+
index: p,
|
|
93
|
+
dict: pageDict,
|
|
94
|
+
ref: pageRef,
|
|
95
|
+
info: { index: p, width, height, rotate, hasImages: false, imageOnly: false, textStart: text.length, textEnd: text.length },
|
|
96
|
+
streams: [],
|
|
97
|
+
issues: [],
|
|
98
|
+
hiddenGlyphs: 0,
|
|
99
|
+
oracleText: false,
|
|
100
|
+
};
|
|
101
|
+
model.pages.push(record);
|
|
102
|
+
const pageTextStart = text.length;
|
|
103
|
+
const pageRunStart = runs.length;
|
|
104
|
+
text.startPage(runs, runs.length);
|
|
105
|
+
const cropBox = numbersOf(ctx, asArray(inheritedPageValue(ctx, pageDict, 'CropBox')));
|
|
106
|
+
const box = pageBox(mediaBox, cropBox);
|
|
107
|
+
// Join the page's content streams into one operator list (an operator may span streams).
|
|
108
|
+
const parts = pageContentStreams(ctx, pageDict);
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for (const part of parts) {
|
|
111
|
+
try {
|
|
112
|
+
chunks.push(decodeStream(ctx, part.stream));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
record.issues.push(`content stream unreadable: ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const source = joinWithNewlines(chunks);
|
|
119
|
+
if (source.length > MAX_CONTENT_BYTES_PER_PAGE) {
|
|
120
|
+
throw new UnsupportedDocumentError('too-large', `page ${p + 1} holds ${source.length} bytes of content; the limit is ${MAX_CONTENT_BYTES_PER_PAGE}`, [String(source.length)]);
|
|
121
|
+
}
|
|
122
|
+
const ops = lexContent(source);
|
|
123
|
+
if (ops.length > MAX_OPS_PER_PAGE)
|
|
124
|
+
record.issues.push('content stream too large');
|
|
125
|
+
const key = `p${p}`;
|
|
126
|
+
const stream = {
|
|
127
|
+
key, kind: 'page', page: p, ref: null, stream: null, source, ops,
|
|
128
|
+
resources: pageResources(ctx, pageDict), ownResources: true, fontsUsed: new Set(), pages: new Set([p]),
|
|
129
|
+
};
|
|
130
|
+
model.streams.set(key, stream);
|
|
131
|
+
record.streams.push(key);
|
|
132
|
+
const walker = new Walker(model, record, runs, text, box);
|
|
133
|
+
walker.walk(stream, initialState(), 0, new Set());
|
|
134
|
+
if (walker.hostile) {
|
|
135
|
+
record.hostile = true;
|
|
136
|
+
record.issues.push('page content is hostile to a viewer (rasterization refused)');
|
|
137
|
+
}
|
|
138
|
+
const maxGlyphs = options.maxGlyphs ?? DEFAULT_MAX_GLYPHS;
|
|
139
|
+
// Every run costs about as much as a few glyphs (its own objects and arrays).
|
|
140
|
+
let glyphCount = 0;
|
|
141
|
+
for (const r of runs)
|
|
142
|
+
glyphCount += r.glyphs.length + 3;
|
|
143
|
+
if (glyphCount > maxGlyphs) {
|
|
144
|
+
throw new UnsupportedDocumentError('too-large', `PDF holds more than ${maxGlyphs} glyphs; the limit protects the host's memory`, [String(glyphCount)]);
|
|
145
|
+
}
|
|
146
|
+
record.info.hasImages = walker.sawImage;
|
|
147
|
+
record.info.imageOnly = walker.sawImage && walker.visibleGlyphs === 0;
|
|
148
|
+
let oracleGarbage = false;
|
|
149
|
+
if (options.oracle && !record.hostile) {
|
|
150
|
+
let items = null;
|
|
151
|
+
let theirs = '';
|
|
152
|
+
try {
|
|
153
|
+
items = await options.oracle.pageItems(p);
|
|
154
|
+
theirs = normalizeForCompare(items.map((it) => it.str + (it.hasEOL ? '\n' : '')).join(''));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// pdf.js could not read the page: keep our text (so detection still runs) and mark the page
|
|
158
|
+
// unsafe; an edit on it will rasterize, and if pdf.js cannot render it either the write refuses.
|
|
159
|
+
record.issues.push(`reference extractor failed: ${err.message}`);
|
|
160
|
+
warnings.push(`page ${p + 1}: the reference extractor could not read it; edits on this page are rasterized`);
|
|
161
|
+
}
|
|
162
|
+
let ours = normalizeForCompare(text.compareText());
|
|
163
|
+
if (items?.some((it) => it.dir === 'rtl')) {
|
|
164
|
+
// pdf.js hands right-to-left items back in visual order; compare the characters as a
|
|
165
|
+
// bag so a decoding difference still shows while the ordering does not.
|
|
166
|
+
ours = [...ours].sort().join('');
|
|
167
|
+
theirs = [...theirs].sort().join('');
|
|
168
|
+
}
|
|
169
|
+
if (items !== null && ours !== theirs) {
|
|
170
|
+
record.issues.push('text decoding differs from the reference extractor');
|
|
171
|
+
oracleGarbage = hasGarbage(theirs);
|
|
172
|
+
const { oursOnly, theirsOnly } = bagDifference(ours, theirs);
|
|
173
|
+
if ((oracleGarbage && walker.undecodableGlyphs === 0) || (oursOnly.length > 0 && theirsOnly.length === 0)) {
|
|
174
|
+
// pdf.js read less than we did (a font it rejects, glyphs it drops) or misread a font we
|
|
175
|
+
// decoded completely: our text stays for detection; the page is still unsafe to edit in
|
|
176
|
+
// place and is rasterized when redacted (with our geometry for the black boxes).
|
|
177
|
+
warnings.push(`page ${p + 1}: the reference extractor read less than our decoder; the page is rasterized when redacted`);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
record.oracleText = true;
|
|
181
|
+
// Replace our page text and runs with the oracle's items; ours are kept for the raster.
|
|
182
|
+
record.ownRuns = runs.slice(pageRunStart);
|
|
183
|
+
record.ownText = text.slice(pageTextStart);
|
|
184
|
+
text.truncate(pageTextStart);
|
|
185
|
+
runs.length = pageRunStart;
|
|
186
|
+
appendOracleRuns(p, items, runs, text);
|
|
187
|
+
if (items.some((it) => it.str.trim() !== ''))
|
|
188
|
+
record.info.imageOnly = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
record.info.textEnd = text.length;
|
|
193
|
+
if (record.info.imageOnly) {
|
|
194
|
+
unredactable.push({
|
|
195
|
+
part: `page ${p + 1}`,
|
|
196
|
+
kind: 'scanned-page',
|
|
197
|
+
label: record.hiddenGlyphs > 0
|
|
198
|
+
? `page ${p + 1} is a scanned image with a hidden OCR text layer: the text layer is redacted, the picture keeps the original`
|
|
199
|
+
: `page ${p + 1} is an image without a text layer (scanned page); its content cannot be redacted`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else if (walker.sawImage) {
|
|
203
|
+
warnings.push(`page ${p + 1}: contains images; text inside images is not scanned`);
|
|
204
|
+
}
|
|
205
|
+
// Glyphs our decoder cannot read make the page unredactable, unless the reference extractor
|
|
206
|
+
// read the page (its text drives detection and the page is rasterized when edited). pdf.js's
|
|
207
|
+
// reading does not count when it holds garbage characters or comes from a composite font
|
|
208
|
+
// without a usable CMap (glyph ids shown as characters): no detector could find a value there.
|
|
209
|
+
if (walker.undecodableGlyphs > 0 && (!record.oracleText || oracleGarbage || walker.unreadableCompositeFont)) {
|
|
210
|
+
unredactable.push({
|
|
211
|
+
part: `page ${p + 1}`,
|
|
212
|
+
kind: 'undecodable-text',
|
|
213
|
+
label: `page ${p + 1}: ${walker.undecodableGlyphs} glyphs use a font whose characters cannot be read`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
pageInfos.push(record.info);
|
|
217
|
+
text.pageBreak();
|
|
218
|
+
}
|
|
219
|
+
const removed = scanDocumentLevelParts(ctx, doc, model, warnings);
|
|
220
|
+
const plainText = text.toString();
|
|
221
|
+
aggregatePageParts(unredactable);
|
|
222
|
+
// Reading-order insertion can put a later operator's text before an earlier one's: keep the
|
|
223
|
+
// runs in flat-text order (the writer's segment mapping relies on it).
|
|
224
|
+
runs.sort((a, b) => a.textStart - b.textStart || a.textEnd - b.textEnd);
|
|
225
|
+
return {
|
|
226
|
+
plainText,
|
|
227
|
+
runs,
|
|
228
|
+
pages: pageInfos,
|
|
229
|
+
unredactable,
|
|
230
|
+
removed,
|
|
231
|
+
warnings,
|
|
232
|
+
empty: runs.every((r) => r.glyphs.every((g) => g.unicode.trim() === '')),
|
|
233
|
+
model,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** pdf.js `view`: the CropBox clipped to the MediaBox (defaults to US Letter when absent). */
|
|
237
|
+
function pageBox(mediaBox, cropBox) {
|
|
238
|
+
const m = mediaBox.length === 4 ? mediaBox : [0, 0, 612, 792];
|
|
239
|
+
let box = { x0: Math.min(m[0], m[2]), y0: Math.min(m[1], m[3]), x1: Math.max(m[0], m[2]), y1: Math.max(m[1], m[3]) };
|
|
240
|
+
if (cropBox.length === 4) {
|
|
241
|
+
const c = { x0: Math.min(cropBox[0], cropBox[2]), y0: Math.min(cropBox[1], cropBox[3]), x1: Math.max(cropBox[0], cropBox[2]), y1: Math.max(cropBox[1], cropBox[3]) };
|
|
242
|
+
const inter = { x0: Math.max(box.x0, c.x0), y0: Math.max(box.y0, c.y0), x1: Math.min(box.x1, c.x1), y1: Math.min(box.y1, c.y1) };
|
|
243
|
+
if (inter.x1 > inter.x0 && inter.y1 > inter.y0)
|
|
244
|
+
box = inter;
|
|
245
|
+
}
|
|
246
|
+
return box;
|
|
247
|
+
}
|
|
248
|
+
function insideBox(b, x, y) {
|
|
249
|
+
const eps = 0.01;
|
|
250
|
+
return x >= b.x0 - eps && x <= b.x1 + eps && y >= b.y0 - eps && y <= b.y1 + eps;
|
|
251
|
+
}
|
|
252
|
+
function looksLikePdf(bytes) {
|
|
253
|
+
const head = Math.min(bytes.length, 1024);
|
|
254
|
+
for (let i = 0; i + 4 <= head; i++) {
|
|
255
|
+
if (bytes[i] === 0x25 && bytes[i + 1] === 0x50 && bytes[i + 2] === 0x44 && bytes[i + 3] === 0x46)
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
function asArray(obj) {
|
|
261
|
+
return obj instanceof PDFArray ? obj : undefined;
|
|
262
|
+
}
|
|
263
|
+
function joinWithNewlines(chunks) {
|
|
264
|
+
let total = 0;
|
|
265
|
+
for (const c of chunks)
|
|
266
|
+
total += c.length + 1;
|
|
267
|
+
const out = new Uint8Array(Math.max(0, total));
|
|
268
|
+
let p = 0;
|
|
269
|
+
for (const c of chunks) {
|
|
270
|
+
out.set(c, p);
|
|
271
|
+
p += c.length;
|
|
272
|
+
out[p++] = 0x0a;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Builds the plain text from glyphs. Separators are decided from geometry: a
|
|
278
|
+
* baseline jump starts a new line, a gap along the baseline inserts a space.
|
|
279
|
+
* Within a line the glyphs are kept in READING order along the baseline: a
|
|
280
|
+
* glyph placed left of earlier ones (right-aligned cells drawn from the
|
|
281
|
+
* right, words or glyphs deliberately shown back to front) is inserted where
|
|
282
|
+
* it belongs, so detection sees "Jan Kowalski" whatever the content order was.
|
|
283
|
+
* Every inserted character is recorded so the writer knows which offsets
|
|
284
|
+
* belong to glyphs and which are synthetic.
|
|
285
|
+
*/
|
|
286
|
+
class TextBuilder {
|
|
287
|
+
constructor() {
|
|
288
|
+
this.parts = [];
|
|
289
|
+
this.len = 0;
|
|
290
|
+
/** Text of the glyphs pdf.js would report (origin inside the page box), for the oracle comparison. */
|
|
291
|
+
this.compare = [];
|
|
292
|
+
this.lastGlyph = null;
|
|
293
|
+
/** Glyphs of the current line in text order, with the line frame (origin and baseline direction of its first glyph). */
|
|
294
|
+
this.line = null;
|
|
295
|
+
/** Runs of the current page (their textStart/textEnd move when text is inserted before them). */
|
|
296
|
+
this.runsRef = null;
|
|
297
|
+
this.pageRunStart = 0;
|
|
298
|
+
}
|
|
299
|
+
get length() {
|
|
300
|
+
return this.len;
|
|
301
|
+
}
|
|
302
|
+
/** Start a page: separators reset, and the runs the page produces (from index `from` of `runs`) are tracked. */
|
|
303
|
+
startPage(runs, from) {
|
|
304
|
+
this.runsRef = runs;
|
|
305
|
+
this.pageRunStart = from;
|
|
306
|
+
this.line = null;
|
|
307
|
+
this.lastGlyph = null;
|
|
308
|
+
this.compare = [];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Place a glyph: decide the separator and where its text goes. Returns the offset where the
|
|
312
|
+
* glyph text starts; the text itself is appended or inserted here.
|
|
313
|
+
*/
|
|
314
|
+
placeGlyph(page, x, y, endX, endY, emX, size, unicode, offsets, index) {
|
|
315
|
+
const last = this.lastGlyph;
|
|
316
|
+
const sizeRef = Math.max(last?.size ?? 0, size, 1);
|
|
317
|
+
let newLine = !last || last.page !== page || !this.line;
|
|
318
|
+
let across = 0;
|
|
319
|
+
if (last && last.page === page) {
|
|
320
|
+
const dx = x - last.endX;
|
|
321
|
+
const dy = y - last.endY;
|
|
322
|
+
const exLen = Math.hypot(last.ex, last.ey) || 1;
|
|
323
|
+
across = (dx * -last.ey + dy * last.ex) / exLen;
|
|
324
|
+
if (Math.abs(across) > 0.45 * sizeRef)
|
|
325
|
+
newLine = true;
|
|
326
|
+
}
|
|
327
|
+
if (unicode === '') {
|
|
328
|
+
// Nothing to show: no separator, no line change; a slot keeps the offset patched.
|
|
329
|
+
const offset = this.len;
|
|
330
|
+
if (!newLine && this.line) {
|
|
331
|
+
const along = this.alongOnLine(this.line, x, y);
|
|
332
|
+
this.line.slots.push({ offset, len: 0, lead: 0, along, endAlong: this.alongOnLine(this.line, endX, endY), isSpace: false, offsets, index });
|
|
333
|
+
}
|
|
334
|
+
return offset;
|
|
335
|
+
}
|
|
336
|
+
const isSpace = /^\s+$/.test(unicode);
|
|
337
|
+
if (newLine) {
|
|
338
|
+
if (last && last.page === page) {
|
|
339
|
+
const gapLines = Math.abs(across) / (sizeRef * 1.15);
|
|
340
|
+
this.append(gapLines > 1.8 ? '\n\n' : '\n');
|
|
341
|
+
}
|
|
342
|
+
this.line = { slots: [], x, y, ex: emX[0], ey: emX[1], page };
|
|
343
|
+
const offset = this.len;
|
|
344
|
+
this.append(unicode);
|
|
345
|
+
this.line.slots.push({ offset, len: unicode.length, lead: 0, along: 0, endAlong: this.alongOnLine(this.line, endX, endY), isSpace, offsets, index });
|
|
346
|
+
this.lastGlyph = { x, y, ex: emX[0], ey: emX[1], size, endX, endY, page };
|
|
347
|
+
return offset;
|
|
348
|
+
}
|
|
349
|
+
const line = this.line;
|
|
350
|
+
const along = this.alongOnLine(line, x, y);
|
|
351
|
+
const endAlong = this.alongOnLine(line, endX, endY);
|
|
352
|
+
// Position in reading order: before the first slot that sits clearly further right. Glyphs
|
|
353
|
+
// that physically overlap their neighbours (overprinting, an overflowing placeholder) keep
|
|
354
|
+
// content order instead of being interleaved.
|
|
355
|
+
const shown = (from, step) => {
|
|
356
|
+
for (let k = from; k >= 0 && k < line.slots.length; k += step)
|
|
357
|
+
if (line.slots[k].len > 0)
|
|
358
|
+
return line.slots[k];
|
|
359
|
+
return undefined;
|
|
360
|
+
};
|
|
361
|
+
const overlaps = (sl) => {
|
|
362
|
+
if (!sl)
|
|
363
|
+
return false;
|
|
364
|
+
const common = Math.min(endAlong, sl.endAlong) - Math.max(along, sl.along);
|
|
365
|
+
return common > 0.3 * Math.max(0.01, Math.min(endAlong - along, sl.endAlong - sl.along));
|
|
366
|
+
};
|
|
367
|
+
// Fast path: text almost always continues to the right of everything placed so far.
|
|
368
|
+
const lastSlot = line.slots[line.slots.length - 1];
|
|
369
|
+
// Order by glyph centre, so a narrow glyph (i, l, a space) placed just left of a wide one is
|
|
370
|
+
// not filed after it.
|
|
371
|
+
const centre = (along + endAlong) / 2;
|
|
372
|
+
const slotCentre = (sl) => (sl.along + sl.endAlong) / 2;
|
|
373
|
+
let at = !lastSlot || centre >= slotCentre(lastSlot) ? line.slots.length : line.slots.findIndex((sl) => slotCentre(sl) > centre);
|
|
374
|
+
if (at < 0)
|
|
375
|
+
at = line.slots.length;
|
|
376
|
+
let left = shown(at - 1, -1);
|
|
377
|
+
let right = at < line.slots.length ? shown(at, 1) : undefined;
|
|
378
|
+
if (at < line.slots.length && (overlaps(left) || overlaps(right))) {
|
|
379
|
+
at = line.slots.length;
|
|
380
|
+
left = shown(at - 1, -1);
|
|
381
|
+
right = undefined;
|
|
382
|
+
}
|
|
383
|
+
const gap = (from, toAlong) => !!from && toAlong - from.endAlong > 0.12 * sizeRef;
|
|
384
|
+
// A glyph that starts clearly left of its predecessor's end but stays in content order (overprint,
|
|
385
|
+
// a run drawn over another) is a separate word too.
|
|
386
|
+
const backwards = !!left && along < left.endAlong - 0.5 * sizeRef;
|
|
387
|
+
const lead = (gap(left, along) || backwards) && !left.isSpace && !isSpace ? 1 : 0;
|
|
388
|
+
const piece = (lead ? ' ' : '') + unicode;
|
|
389
|
+
const insertAt = right ? right.offset - right.lead : at < line.slots.length ? line.slots[at].offset : this.len;
|
|
390
|
+
const inserted = insertAt < this.len; // else appended: nothing after it to move
|
|
391
|
+
this.splice(insertAt, 0, piece);
|
|
392
|
+
if (inserted)
|
|
393
|
+
for (const sl of line.slots)
|
|
394
|
+
if (sl.offset >= insertAt) {
|
|
395
|
+
sl.offset += piece.length;
|
|
396
|
+
sl.offsets[sl.index] += piece.length;
|
|
397
|
+
}
|
|
398
|
+
const slot = { offset: insertAt + lead, len: unicode.length, lead, along, endAlong, isSpace, offsets, index };
|
|
399
|
+
line.slots.splice(at, 0, slot);
|
|
400
|
+
if (right) {
|
|
401
|
+
// The right neighbour's separator is decided again against its new left neighbour.
|
|
402
|
+
const wantLead = endAlong < right.along && right.along - endAlong > 0.12 * sizeRef && !isSpace && !right.isSpace ? 1 : 0;
|
|
403
|
+
if (wantLead !== right.lead) {
|
|
404
|
+
const sepAt = right.offset - right.lead;
|
|
405
|
+
if (wantLead)
|
|
406
|
+
this.splice(sepAt, 0, ' ');
|
|
407
|
+
else
|
|
408
|
+
this.splice(sepAt, 1, '');
|
|
409
|
+
const delta = wantLead - right.lead;
|
|
410
|
+
for (const sl of line.slots)
|
|
411
|
+
if (sl.offset > sepAt || (sl === right)) {
|
|
412
|
+
sl.offset += delta;
|
|
413
|
+
sl.offsets[sl.index] += delta;
|
|
414
|
+
}
|
|
415
|
+
right.lead = wantLead;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
this.lastGlyph = { x, y, ex: emX[0], ey: emX[1], size, endX, endY, page };
|
|
419
|
+
return slot.offset;
|
|
420
|
+
}
|
|
421
|
+
alongOnLine(line, x, y) {
|
|
422
|
+
const len = Math.hypot(line.ex, line.ey) || 1;
|
|
423
|
+
return ((x - line.x) * line.ex + (y - line.y) * line.ey) / len;
|
|
424
|
+
}
|
|
425
|
+
append(s) {
|
|
426
|
+
if (!s)
|
|
427
|
+
return;
|
|
428
|
+
this.parts.push(s);
|
|
429
|
+
this.len += s.length;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Replace `del` characters at `at` by `ins` (always inside the current line, the tail of the
|
|
433
|
+
* text) and move the text ranges of the page's finished runs after it. Glyph offsets of the
|
|
434
|
+
* current line are patched by the caller through the slots.
|
|
435
|
+
*/
|
|
436
|
+
splice(at, del, ins) {
|
|
437
|
+
if (!del && !ins)
|
|
438
|
+
return;
|
|
439
|
+
if (at === this.len && !del) {
|
|
440
|
+
this.append(ins);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const all = this.toString();
|
|
444
|
+
this.parts = [all.slice(0, at) + ins + all.slice(at + del)];
|
|
445
|
+
this.len += ins.length - del;
|
|
446
|
+
const delta = ins.length - del;
|
|
447
|
+
if (!this.runsRef)
|
|
448
|
+
return;
|
|
449
|
+
for (let i = this.pageRunStart; i < this.runsRef.length; i++) {
|
|
450
|
+
const run = this.runsRef[i];
|
|
451
|
+
if (run.textStart >= at + del)
|
|
452
|
+
run.textStart += delta;
|
|
453
|
+
else if (run.textStart > at)
|
|
454
|
+
run.textStart = at;
|
|
455
|
+
if (run.textEnd > at)
|
|
456
|
+
run.textEnd = Math.max(at, run.textEnd + delta);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Record a glyph's text for the oracle comparison (off-page glyphs are skipped, as pdf.js drops them). */
|
|
460
|
+
appendCompare(s) {
|
|
461
|
+
if (s)
|
|
462
|
+
this.compare.push(s);
|
|
463
|
+
}
|
|
464
|
+
compareText() {
|
|
465
|
+
return this.compare.join('');
|
|
466
|
+
}
|
|
467
|
+
pageBreak() {
|
|
468
|
+
if (this.len > 0 && !this.endsWith('\n\n'))
|
|
469
|
+
this.append(this.endsWith('\n') ? '\n' : '\n\n');
|
|
470
|
+
this.lastGlyph = null;
|
|
471
|
+
this.line = null;
|
|
472
|
+
}
|
|
473
|
+
slice(from) {
|
|
474
|
+
return this.toString().slice(from);
|
|
475
|
+
}
|
|
476
|
+
truncate(len) {
|
|
477
|
+
const s = this.toString().slice(0, len);
|
|
478
|
+
this.parts = [s];
|
|
479
|
+
this.len = s.length;
|
|
480
|
+
this.lastGlyph = null;
|
|
481
|
+
this.line = null;
|
|
482
|
+
}
|
|
483
|
+
endsWith(suffix) {
|
|
484
|
+
let tail = '';
|
|
485
|
+
for (let i = this.parts.length - 1; i >= 0 && tail.length < suffix.length; i--)
|
|
486
|
+
tail = this.parts[i] + tail;
|
|
487
|
+
return tail.endsWith(suffix);
|
|
488
|
+
}
|
|
489
|
+
toString() {
|
|
490
|
+
if (this.parts.length > 1)
|
|
491
|
+
this.parts = [this.parts.join('')];
|
|
492
|
+
return this.parts[0] ?? '';
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Synthesize runs from pdf.js text items for a raster-only page: one glyph
|
|
497
|
+
* per character with proportional widths, geometry from the item transform.
|
|
498
|
+
* These runs carry `oracle: true` and are never edited, only rasterized.
|
|
499
|
+
*/
|
|
500
|
+
function appendOracleRuns(page, items, runs, text) {
|
|
501
|
+
items.forEach((item, idx) => {
|
|
502
|
+
const chars = [...item.str];
|
|
503
|
+
const [a, b, c, d, e, f] = item.transform;
|
|
504
|
+
const vertical = item.dir === 'ttb';
|
|
505
|
+
const fontSize = Math.hypot(c, d) || Math.hypot(a, b) || 1;
|
|
506
|
+
// Horizontal items advance along (a, b) over item.width; vertical ones downwards along -(c, d)
|
|
507
|
+
// over item.height (the raster covers a vertical item whole, both directions).
|
|
508
|
+
const len = vertical ? Math.hypot(c, d) || 1 : Math.hypot(a, b) || 1;
|
|
509
|
+
const ux = vertical ? -c / len : a / len;
|
|
510
|
+
const uy = vertical ? -d / len : b / len;
|
|
511
|
+
const per = chars.length > 0 ? (vertical ? item.height : item.width) / chars.length : 0;
|
|
512
|
+
const glyphs = [];
|
|
513
|
+
const textOffsets = [];
|
|
514
|
+
const advances = [];
|
|
515
|
+
const start = text.length;
|
|
516
|
+
chars.forEach((ch, i) => {
|
|
517
|
+
textOffsets.push(text.length);
|
|
518
|
+
text.append(ch);
|
|
519
|
+
glyphs.push({
|
|
520
|
+
code: -1, codeLength: 0, unicode: ch, width: fontSize > 0 ? (per / fontSize) * 1000 : 0, advance: per,
|
|
521
|
+
x: e + ux * per * i, y: f + uy * per * i, fontSize, item: 0, byteOffset: 0,
|
|
522
|
+
});
|
|
523
|
+
advances.push(per * i);
|
|
524
|
+
});
|
|
525
|
+
if (item.hasEOL)
|
|
526
|
+
text.append('\n');
|
|
527
|
+
runs.push({
|
|
528
|
+
page,
|
|
529
|
+
streamKey: `oracle:p${page}`,
|
|
530
|
+
opIndex: idx,
|
|
531
|
+
fontName: item.fontName,
|
|
532
|
+
fontKey: '',
|
|
533
|
+
glyphs,
|
|
534
|
+
renderMode: 0,
|
|
535
|
+
hidden: false,
|
|
536
|
+
textOffsets,
|
|
537
|
+
textStart: start,
|
|
538
|
+
textEnd: text.length,
|
|
539
|
+
emX: vertical ? [-c, -d] : [a, b],
|
|
540
|
+
emY: vertical ? [a, b] : [c, d],
|
|
541
|
+
local: { ctm: [1, 0, 0, 1, 0, 0], tm: [1, 0, 0, 1, 0, 0], tlm: [1, 0, 0, 1, 0, 0], fontSize: len, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, advances },
|
|
542
|
+
oracle: true,
|
|
543
|
+
...(vertical ? { vertical: true } : {}),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/* ------------------------------------------------------------------ */
|
|
548
|
+
/* Content walker */
|
|
549
|
+
/* ------------------------------------------------------------------ */
|
|
550
|
+
class Walker {
|
|
551
|
+
constructor(model, page, runs, text, box) {
|
|
552
|
+
this.sawImage = false;
|
|
553
|
+
this.visibleGlyphs = 0;
|
|
554
|
+
this.undecodableGlyphs = 0;
|
|
555
|
+
/** A composite font without a usable CMap/ToUnicode showed text: pdf.js's reading of it is a guess (glyph ids), never trusted. */
|
|
556
|
+
this.unreadableCompositeFont = false;
|
|
557
|
+
this.walkedAux = new Set();
|
|
558
|
+
this.draws = 0;
|
|
559
|
+
this.contentBytes = 0;
|
|
560
|
+
/** Set when a cap fired: pdf.js would walk the same content without our caps. */
|
|
561
|
+
this.hostile = false;
|
|
562
|
+
this.model = model;
|
|
563
|
+
this.page = page;
|
|
564
|
+
this.runs = runs;
|
|
565
|
+
this.text = text;
|
|
566
|
+
this.box = box;
|
|
567
|
+
}
|
|
568
|
+
walk(stream, base, depth, active) {
|
|
569
|
+
const { ctx } = this.model;
|
|
570
|
+
const fontsDict = getDict(ctx, stream.resources, 'Font');
|
|
571
|
+
const resolveFont = (name) => loadFont(ctx, fontsDict, name, this.model.fonts);
|
|
572
|
+
const sm = new StateMachine(base);
|
|
573
|
+
// Text can also live in tiling pattern cells and soft-mask groups of this stream's resources.
|
|
574
|
+
this.walkAuxiliary(stream, base, depth, active);
|
|
575
|
+
const ops = stream.ops;
|
|
576
|
+
for (let i = 0; i < ops.length; i++) {
|
|
577
|
+
const op = ops[i];
|
|
578
|
+
if (op.op === '') {
|
|
579
|
+
this.page.issues.push(`${stream.key}: unparsable content at operator ${i}`);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (TEXT_SHOW_OPS.has(op.op)) {
|
|
583
|
+
// ' and " move to the next line (and set spacing) before showing.
|
|
584
|
+
if (op.op === "'" || op.op === '"')
|
|
585
|
+
sm.update(op, resolveFont);
|
|
586
|
+
this.show(stream, i, op, sm, resolveFont);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (op.op === 'Do') {
|
|
590
|
+
sm.update(op, resolveFont);
|
|
591
|
+
this.doXObject(stream, op, sm, depth, active);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (op.op === 'BI') {
|
|
595
|
+
this.sawImage = true;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (op.op === 'd0' || op.op === 'd1') {
|
|
599
|
+
this.page.issues.push(`${stream.key}: Type3 glyph procedure operators in page content`);
|
|
600
|
+
}
|
|
601
|
+
if (op.op === 'sh') {
|
|
602
|
+
// Shading is drawing, not text; nothing to do.
|
|
603
|
+
}
|
|
604
|
+
sm.update(op, resolveFont);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Tiling patterns (PatternType 1) and ExtGState soft masks (/SMask /G) are content streams a
|
|
609
|
+
* viewer paints, with their own resources: walk them once per page so their text reaches
|
|
610
|
+
* detection and can be edited in place. Their glyphs are tagged with a `space` (pattern space
|
|
611
|
+
* is tiled over the fill area, a mask group sits at the `gs` transform), so they never share a
|
|
612
|
+
* line with page text and are never blacked out by a raster.
|
|
613
|
+
*/
|
|
614
|
+
walkAuxiliary(stream, base, depth, active) {
|
|
615
|
+
const { ctx } = this.model;
|
|
616
|
+
const patterns = getDict(ctx, stream.resources, 'Pattern');
|
|
617
|
+
if (patterns) {
|
|
618
|
+
for (const [k, v] of patterns.entries()) {
|
|
619
|
+
const obj = resolve(ctx, v);
|
|
620
|
+
if (!(obj instanceof PDFStream))
|
|
621
|
+
continue; // shading patterns are dictionaries without content
|
|
622
|
+
if (getNumber(ctx, obj.dict, 'PatternType') !== 1)
|
|
623
|
+
continue;
|
|
624
|
+
this.walkAuxStream(stream, obj, v, `pattern ${k.decodeText()}`, base, depth, active);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const states = getDict(ctx, stream.resources, 'ExtGState');
|
|
628
|
+
if (states) {
|
|
629
|
+
for (const [k, v] of states.entries()) {
|
|
630
|
+
const d = resolve(ctx, v);
|
|
631
|
+
if (!(d instanceof PDFDict))
|
|
632
|
+
continue;
|
|
633
|
+
const mask = getDict(ctx, d, 'SMask');
|
|
634
|
+
if (!mask)
|
|
635
|
+
continue;
|
|
636
|
+
const raw = mask.get(PDFName.of('G'));
|
|
637
|
+
const group = resolve(ctx, raw);
|
|
638
|
+
if (!(group instanceof PDFStream))
|
|
639
|
+
continue;
|
|
640
|
+
this.walkAuxStream(stream, group, raw, `soft mask ${k.decodeText()}`, base, depth, active);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
walkAuxStream(parent, xobj, raw, label, base, depth, active) {
|
|
645
|
+
const { ctx } = this.model;
|
|
646
|
+
const ref = raw instanceof PDFRef ? raw : null;
|
|
647
|
+
const key = ref ? `x${refKey(ref)}` : `${parent.key}/${label}`;
|
|
648
|
+
if (this.walkedAux.has(key) || active.has(key))
|
|
649
|
+
return;
|
|
650
|
+
if (depth >= MAX_XOBJECT_DEPTH) {
|
|
651
|
+
this.page.issues.push(`${parent.key}: ${label} nested deeper than ${MAX_XOBJECT_DEPTH}`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
this.walkedAux.add(key);
|
|
655
|
+
let record = this.model.streams.get(key);
|
|
656
|
+
if (!record) {
|
|
657
|
+
let source;
|
|
658
|
+
try {
|
|
659
|
+
source = decodeStream(ctx, xobj);
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
this.page.issues.push(`${label} unreadable: ${err instanceof StreamDecodeError ? err.message : String(err)}`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (!this.accountBytes(source.length, key))
|
|
666
|
+
return;
|
|
667
|
+
const ownResources = getDict(ctx, xobj.dict, 'Resources');
|
|
668
|
+
record = {
|
|
669
|
+
key, kind: 'xobject', page: this.page.index, ref, stream: xobj, source, ops: lexContent(source),
|
|
670
|
+
resources: ownResources ?? parent.resources, ownResources: !!ownResources, fontsUsed: new Set(), pages: new Set(),
|
|
671
|
+
};
|
|
672
|
+
this.model.streams.set(key, record);
|
|
673
|
+
}
|
|
674
|
+
record.pages.add(this.page.index);
|
|
675
|
+
if (!this.page.streams.includes(key))
|
|
676
|
+
this.page.streams.push(key);
|
|
677
|
+
const matrix = matrixFromOperands(toOperands(numbersOf(ctx, getArray(ctx, xobj.dict, 'Matrix')))) ?? [1, 0, 0, 1, 0, 0];
|
|
678
|
+
const inner = initialState(mul(matrix, base.ctm));
|
|
679
|
+
const prevSpace = this.space;
|
|
680
|
+
this.space = key;
|
|
681
|
+
active.add(key);
|
|
682
|
+
this.walk(record, inner, depth + 1, active);
|
|
683
|
+
active.delete(key);
|
|
684
|
+
this.space = prevSpace;
|
|
685
|
+
}
|
|
686
|
+
/** Decoded content walked for this page; a page past the cap is unsafe (issue) and its remaining streams are skipped. */
|
|
687
|
+
accountBytes(n, key) {
|
|
688
|
+
this.contentBytes += n;
|
|
689
|
+
if (this.contentBytes <= MAX_CONTENT_BYTES_PER_PAGE)
|
|
690
|
+
return true;
|
|
691
|
+
this.page.issues.push(`${key}: page content exceeds ${MAX_CONTENT_BYTES_PER_PAGE} decoded bytes`);
|
|
692
|
+
this.hostile = true;
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
doXObject(stream, op, sm, depth, active) {
|
|
696
|
+
const { ctx } = this.model;
|
|
697
|
+
const nameOp = op.operands[0];
|
|
698
|
+
if (!nameOp || nameOp.kind !== 'name')
|
|
699
|
+
return;
|
|
700
|
+
const xobjects = getDict(ctx, stream.resources, 'XObject');
|
|
701
|
+
if (!xobjects)
|
|
702
|
+
return;
|
|
703
|
+
const raw = xobjects.get(PDFName.of(nameOp.value));
|
|
704
|
+
const xobj = resolve(ctx, raw);
|
|
705
|
+
if (!(xobj instanceof PDFStream))
|
|
706
|
+
return;
|
|
707
|
+
const subtype = getName(ctx, xobj.dict, 'Subtype');
|
|
708
|
+
if (subtype === 'Image') {
|
|
709
|
+
this.sawImage = true;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (subtype !== 'Form')
|
|
713
|
+
return;
|
|
714
|
+
if (depth >= MAX_XOBJECT_DEPTH) {
|
|
715
|
+
this.page.issues.push(`${stream.key}: form XObject nesting deeper than ${MAX_XOBJECT_DEPTH}`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (++this.draws > MAX_XOBJECT_DRAWS_PER_PAGE) {
|
|
719
|
+
if (this.draws === MAX_XOBJECT_DRAWS_PER_PAGE + 1)
|
|
720
|
+
this.page.issues.push(`more than ${MAX_XOBJECT_DRAWS_PER_PAGE} form XObject draws on one page`);
|
|
721
|
+
this.hostile = true;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const ref = raw instanceof PDFRef ? raw : null;
|
|
725
|
+
const key = ref ? `x${refKey(ref)}` : `${stream.key}/${nameOp.value}`;
|
|
726
|
+
if (active.has(key)) {
|
|
727
|
+
this.page.issues.push(`${stream.key}: recursive form XObject ${key}`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
let record = this.model.streams.get(key);
|
|
731
|
+
if (!record) {
|
|
732
|
+
let source;
|
|
733
|
+
try {
|
|
734
|
+
source = decodeStream(ctx, xobj);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
this.page.issues.push(`form XObject ${key} unreadable: ${err instanceof StreamDecodeError ? err.message : String(err)}`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!this.accountBytes(source.length, key))
|
|
741
|
+
return;
|
|
742
|
+
const ownResources = getDict(ctx, xobj.dict, 'Resources');
|
|
743
|
+
record = {
|
|
744
|
+
key, kind: 'xobject', page: this.page.index, ref, stream: xobj, source, ops: lexContent(source),
|
|
745
|
+
resources: ownResources ?? stream.resources, ownResources: !!ownResources, fontsUsed: new Set(), pages: new Set(),
|
|
746
|
+
};
|
|
747
|
+
this.model.streams.set(key, record);
|
|
748
|
+
}
|
|
749
|
+
record.pages.add(this.page.index);
|
|
750
|
+
if (!this.page.streams.includes(key))
|
|
751
|
+
this.page.streams.push(key);
|
|
752
|
+
// Form space: CTM' = Matrix × CTM; the walk runs in a saved state.
|
|
753
|
+
const matrix = matrixFromOperands(toOperands(numbersOf(ctx, getArray(ctx, xobj.dict, 'Matrix')))) ?? [1, 0, 0, 1, 0, 0];
|
|
754
|
+
const inner = initialState(mul(matrix, sm.state.ctm));
|
|
755
|
+
inner.text = { ...sm.state.text };
|
|
756
|
+
inner.fillLuminance = sm.state.fillLuminance;
|
|
757
|
+
inner.strokeLuminance = sm.state.strokeLuminance;
|
|
758
|
+
active.add(key);
|
|
759
|
+
this.walk(record, inner, depth + 1, active);
|
|
760
|
+
active.delete(key);
|
|
761
|
+
}
|
|
762
|
+
show(stream, opIndex, op, sm, resolveFont) {
|
|
763
|
+
const t = sm.state.text;
|
|
764
|
+
const font = t.font ?? (t.fontName ? resolveFont(t.fontName) : undefined) ?? null;
|
|
765
|
+
if (!sm.inText)
|
|
766
|
+
this.page.issues.push(`${stream.key}: text shown outside BT/ET at operator ${opIndex}`);
|
|
767
|
+
if (!font) {
|
|
768
|
+
this.page.issues.push(`${stream.key}: text shown with unknown font /${t.fontName || '?'} at operator ${opIndex}`);
|
|
769
|
+
// Still advance nothing; we cannot decode. Mark as undecodable text.
|
|
770
|
+
this.undecodableGlyphs += 1;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (font.problems.length > 0) {
|
|
774
|
+
for (const pr of font.problems)
|
|
775
|
+
this.page.issues.push(`${stream.key}: font /${t.fontName} (${font.baseFont}): ${pr.message}`);
|
|
776
|
+
if (font.composite && font.problems.some((pr) => pr.code !== 'vertical'))
|
|
777
|
+
this.unreadableCompositeFont = true;
|
|
778
|
+
}
|
|
779
|
+
stream.fontsUsed.add(t.fontName);
|
|
780
|
+
// Collect items: strings and adjustments in order.
|
|
781
|
+
const items = [];
|
|
782
|
+
if (op.op === 'TJ') {
|
|
783
|
+
const arr = op.operands[0];
|
|
784
|
+
if (!arr || arr.kind !== 'array') {
|
|
785
|
+
this.page.issues.push(`${stream.key}: TJ without an array at operator ${opIndex}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
arr.items.forEach((it, idx) => {
|
|
789
|
+
if (it.kind === 'string')
|
|
790
|
+
items.push({ bytes: it.bytes, item: idx });
|
|
791
|
+
else if (it.kind === 'number')
|
|
792
|
+
items.push({ adj: it.value });
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
const strOp = op.op === '"' ? op.operands[2] : op.operands[0];
|
|
797
|
+
if (!strOp || strOp.kind !== 'string') {
|
|
798
|
+
this.page.issues.push(`${stream.key}: ${op.op} without a string at operator ${opIndex}`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
items.push({ bytes: strOp.bytes, item: 0 });
|
|
802
|
+
}
|
|
803
|
+
const local = {
|
|
804
|
+
ctm: sm.state.ctm,
|
|
805
|
+
tm: sm.tm,
|
|
806
|
+
tlm: sm.tlm,
|
|
807
|
+
fontSize: t.fontSize,
|
|
808
|
+
charSpacing: t.charSpacing,
|
|
809
|
+
wordSpacing: t.wordSpacing,
|
|
810
|
+
hscale: t.hscale,
|
|
811
|
+
leading: t.leading,
|
|
812
|
+
rise: t.rise,
|
|
813
|
+
advances: [],
|
|
814
|
+
};
|
|
815
|
+
const trm0 = sm.trm();
|
|
816
|
+
const emX = [trm0[0], trm0[1]];
|
|
817
|
+
const emY = [trm0[2], trm0[3]];
|
|
818
|
+
const hiddenByMode = t.renderMode === 3 || t.renderMode === 7;
|
|
819
|
+
const hiddenByColor = t.renderMode === 0 && sm.state.fillLuminance !== null && sm.state.fillLuminance > 0.97;
|
|
820
|
+
const sizeUser = Math.hypot(trm0[2], trm0[3]);
|
|
821
|
+
const tiny = sizeUser < 0.5;
|
|
822
|
+
const hidden = hiddenByMode || hiddenByColor || tiny;
|
|
823
|
+
const glyphs = [];
|
|
824
|
+
const textOffsets = [];
|
|
825
|
+
let advanceSoFar = 0; // unscaled text-space x from the run start
|
|
826
|
+
const positionIfEmpty = this.text.length;
|
|
827
|
+
for (const item of items) {
|
|
828
|
+
if ('adj' in item) {
|
|
829
|
+
const d = adjustmentAdvance(t, item.adj);
|
|
830
|
+
advanceSoFar += d;
|
|
831
|
+
sm.advanceText(d);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const decoded = font.decode(item.bytes);
|
|
835
|
+
for (const g of decoded) {
|
|
836
|
+
font.usedCodes.add(g.code);
|
|
837
|
+
const trm = sm.trm();
|
|
838
|
+
const [x, y] = apply(trm, 0, 0);
|
|
839
|
+
const adv = glyphAdvance(t, g.width, g.isSpaceCode);
|
|
840
|
+
const fontSize = Math.hypot(trm[2], trm[3]);
|
|
841
|
+
const unicode = g.unicode;
|
|
842
|
+
// A glyph whose Unicode is nothing, a control, U+FFFD or a private-use character cannot be
|
|
843
|
+
// read by a detector: the page is unredactable unless the reference extractor reads it.
|
|
844
|
+
if (unreadable(unicode) && g.width !== 0 && !g.notdef)
|
|
845
|
+
this.undecodableGlyphs++;
|
|
846
|
+
if (!hidden && unicode.trim() !== '')
|
|
847
|
+
this.visibleGlyphs++;
|
|
848
|
+
if (hidden)
|
|
849
|
+
this.page.hiddenGlyphs++;
|
|
850
|
+
// Origin after the advance: trm's x basis is one unit of (Tfs * Th)-scaled text space.
|
|
851
|
+
const basis = (t.fontSize || 1) * (t.hscale || 1);
|
|
852
|
+
const [endX, endY] = apply(sm.trm(), adv / basis, 0);
|
|
853
|
+
const offset = this.text.placeGlyph(this.page.index, x, y, endX, endY, emX, fontSize, unicode, textOffsets, glyphs.length);
|
|
854
|
+
if (unicode && !this.space && insideBox(this.box, x, y))
|
|
855
|
+
this.text.appendCompare(unicode);
|
|
856
|
+
glyphs.push({
|
|
857
|
+
code: g.code,
|
|
858
|
+
codeLength: g.length,
|
|
859
|
+
unicode,
|
|
860
|
+
width: g.width,
|
|
861
|
+
advance: adv,
|
|
862
|
+
x, y, fontSize,
|
|
863
|
+
item: item.item,
|
|
864
|
+
byteOffset: g.offset,
|
|
865
|
+
});
|
|
866
|
+
textOffsets.push(offset);
|
|
867
|
+
local.advances.push(advanceSoFar);
|
|
868
|
+
advanceSoFar += adv;
|
|
869
|
+
sm.advanceText(adv);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// Text range of the run: reading-order insertion may have placed its glyphs before earlier text.
|
|
873
|
+
let textStart = Number.POSITIVE_INFINITY;
|
|
874
|
+
let textEnd = 0;
|
|
875
|
+
glyphs.forEach((g, i) => {
|
|
876
|
+
textStart = Math.min(textStart, textOffsets[i]);
|
|
877
|
+
textEnd = Math.max(textEnd, textOffsets[i] + g.unicode.length);
|
|
878
|
+
});
|
|
879
|
+
if (!Number.isFinite(textStart)) {
|
|
880
|
+
textStart = positionIfEmpty;
|
|
881
|
+
textEnd = positionIfEmpty;
|
|
882
|
+
}
|
|
883
|
+
const run = {
|
|
884
|
+
page: this.page.index,
|
|
885
|
+
streamKey: stream.key,
|
|
886
|
+
opIndex,
|
|
887
|
+
fontName: t.fontName,
|
|
888
|
+
fontKey: font.key,
|
|
889
|
+
glyphs,
|
|
890
|
+
renderMode: t.renderMode,
|
|
891
|
+
hidden,
|
|
892
|
+
textOffsets,
|
|
893
|
+
textStart,
|
|
894
|
+
textEnd,
|
|
895
|
+
emX,
|
|
896
|
+
emY,
|
|
897
|
+
local,
|
|
898
|
+
...(this.space ? { space: this.space } : {}),
|
|
899
|
+
};
|
|
900
|
+
this.runs.push(run);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Consecutive pages with the same kind of problem collapse into one part ("pages 2-22"), so a
|
|
905
|
+
* document whose body font is unreadable shows one line to consent to, not one per page.
|
|
906
|
+
*/
|
|
907
|
+
function aggregatePageParts(parts) {
|
|
908
|
+
const pageOf = (p) => {
|
|
909
|
+
const m = /^page (\d+)$/.exec(p.part);
|
|
910
|
+
return m ? Number(m[1]) : null;
|
|
911
|
+
};
|
|
912
|
+
const out = [];
|
|
913
|
+
let i = 0;
|
|
914
|
+
while (i < parts.length) {
|
|
915
|
+
const first = parts[i];
|
|
916
|
+
const start = pageOf(first);
|
|
917
|
+
if (start === null) {
|
|
918
|
+
out.push(first);
|
|
919
|
+
i++;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
let j = i;
|
|
923
|
+
let end = start;
|
|
924
|
+
while (j + 1 < parts.length && parts[j + 1].kind === first.kind && pageOf(parts[j + 1]) === end + 1) {
|
|
925
|
+
j++;
|
|
926
|
+
end++;
|
|
927
|
+
}
|
|
928
|
+
if (j === i) {
|
|
929
|
+
out.push(first);
|
|
930
|
+
i++;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
const count = j - i + 1;
|
|
934
|
+
const label = first.kind === 'undecodable-text'
|
|
935
|
+
? `pages ${start}-${end}: text uses fonts whose characters cannot be read (${count} pages)`
|
|
936
|
+
: first.kind === 'scanned-page'
|
|
937
|
+
? `pages ${start}-${end}: ${first.label.replace(/^page \d+ /, '')} (${count} pages)`
|
|
938
|
+
: `pages ${start}-${end} (${count} pages): ${first.label}`;
|
|
939
|
+
out.push({ part: `pages ${start}-${end}`, kind: first.kind, label });
|
|
940
|
+
i = j + 1;
|
|
941
|
+
}
|
|
942
|
+
parts.length = 0;
|
|
943
|
+
for (const p of out)
|
|
944
|
+
parts.push(p);
|
|
945
|
+
}
|
|
946
|
+
/** Characters no detector can read: controls (except tab/newline), U+FFFD, private use, non-characters. */
|
|
947
|
+
const GARBAGE_CHAR_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\uFFFD\uE000-\uF8FF\uFFFE\uFFFF]/u;
|
|
948
|
+
const ALL_GARBAGE_RE = /^[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\uFFFD\uE000-\uF8FF\uFFFE\uFFFF]+$/u;
|
|
949
|
+
/** Characters of `a` not in `b` and of `b` not in `a`, as multisets. */
|
|
950
|
+
function bagDifference(a, b) {
|
|
951
|
+
const count = new Map();
|
|
952
|
+
for (const ch of a)
|
|
953
|
+
count.set(ch, (count.get(ch) ?? 0) + 1);
|
|
954
|
+
let theirsOnly = '';
|
|
955
|
+
for (const ch of b) {
|
|
956
|
+
const c = count.get(ch) ?? 0;
|
|
957
|
+
if (c > 0)
|
|
958
|
+
count.set(ch, c - 1);
|
|
959
|
+
else
|
|
960
|
+
theirsOnly += ch;
|
|
961
|
+
}
|
|
962
|
+
let oursOnly = '';
|
|
963
|
+
for (const [ch, c] of count)
|
|
964
|
+
if (c > 0)
|
|
965
|
+
oursOnly += ch.repeat(c);
|
|
966
|
+
return { oursOnly, theirsOnly };
|
|
967
|
+
}
|
|
968
|
+
function unreadable(unicode) {
|
|
969
|
+
return unicode === '' || ALL_GARBAGE_RE.test(unicode);
|
|
970
|
+
}
|
|
971
|
+
export function hasGarbage(text) {
|
|
972
|
+
return GARBAGE_CHAR_RE.test(text);
|
|
973
|
+
}
|
|
974
|
+
function toOperands(nums) {
|
|
975
|
+
return nums.map((n) => ({ kind: 'number', value: n }));
|
|
976
|
+
}
|
|
977
|
+
/* ------------------------------------------------------------------ */
|
|
978
|
+
/* Document-level side channels */
|
|
979
|
+
/* ------------------------------------------------------------------ */
|
|
980
|
+
/**
|
|
981
|
+
* Inventory of everything outside page content that the writer drops
|
|
982
|
+
* (never copied to the fresh output document). Reported so the user knows.
|
|
983
|
+
*/
|
|
984
|
+
export function scanDocumentLevelParts(ctx, doc, model, warnings) {
|
|
985
|
+
const removed = [];
|
|
986
|
+
const catalog = doc.catalog;
|
|
987
|
+
const acro = getDict(ctx, catalog, 'AcroForm');
|
|
988
|
+
if (acro) {
|
|
989
|
+
const fields = getArray(ctx, acro, 'Fields');
|
|
990
|
+
const count = fields ? fields.size() : 0;
|
|
991
|
+
const xfa = acro.get(PDFName.of('XFA')) !== undefined;
|
|
992
|
+
removed.push({ kind: 'form-fields', label: xfa ? `interactive form (${count} fields, XFA)` : `interactive form (${count} fields)`, count });
|
|
993
|
+
const sigFlags = getNumber(ctx, acro, 'SigFlags') ?? 0;
|
|
994
|
+
if (sigFlags & 1)
|
|
995
|
+
removed.push({ kind: 'signatures', label: 'digital signatures (invalidated by redaction)' });
|
|
996
|
+
}
|
|
997
|
+
if (getDict(ctx, catalog, 'Outlines'))
|
|
998
|
+
removed.push({ kind: 'outlines', label: 'bookmarks (outline)' });
|
|
999
|
+
const names = getDict(ctx, catalog, 'Names');
|
|
1000
|
+
if (names) {
|
|
1001
|
+
if (getDict(ctx, names, 'EmbeddedFiles'))
|
|
1002
|
+
removed.push({ kind: 'attachments', label: 'embedded files (attachments)' });
|
|
1003
|
+
if (getDict(ctx, names, 'JavaScript'))
|
|
1004
|
+
removed.push({ kind: 'javascript', label: 'document JavaScript' });
|
|
1005
|
+
if (getDict(ctx, names, 'Dests'))
|
|
1006
|
+
removed.push({ kind: 'named-destinations', label: 'named destinations' });
|
|
1007
|
+
}
|
|
1008
|
+
if (getDict(ctx, catalog, 'Dests'))
|
|
1009
|
+
removed.push({ kind: 'named-destinations', label: 'named destinations' });
|
|
1010
|
+
if (catalog.get(PDFName.of('OpenAction')) || catalog.get(PDFName.of('AA'))) {
|
|
1011
|
+
removed.push({ kind: 'javascript', label: 'open action / additional actions' });
|
|
1012
|
+
}
|
|
1013
|
+
if (getStream(ctx, catalog, 'Metadata') || infoDict(ctx)) {
|
|
1014
|
+
removed.push({ kind: 'metadata', label: 'document properties and XMP metadata (replaced by blanks)' });
|
|
1015
|
+
}
|
|
1016
|
+
if (getDict(ctx, catalog, 'StructTreeRoot'))
|
|
1017
|
+
removed.push({ kind: 'structure-tree', label: 'tagged-PDF structure (accessibility tree)' });
|
|
1018
|
+
if (getDict(ctx, catalog, 'PageLabels'))
|
|
1019
|
+
removed.push({ kind: 'page-labels', label: 'page labels' });
|
|
1020
|
+
if (getDict(ctx, catalog, 'OCProperties'))
|
|
1021
|
+
removed.push({ kind: 'optional-content', label: 'optional content (layers) configuration' });
|
|
1022
|
+
let annots = 0;
|
|
1023
|
+
for (const page of model.pages) {
|
|
1024
|
+
const arr = getArray(ctx, page.dict, 'Annots');
|
|
1025
|
+
if (arr)
|
|
1026
|
+
annots += arr.size();
|
|
1027
|
+
if (getStream(ctx, page.dict, 'Metadata'))
|
|
1028
|
+
warnings.push(`page ${page.index + 1}: page-level XMP metadata removed`);
|
|
1029
|
+
// Page-level actions and thumbnails are dropped as well (no separate report).
|
|
1030
|
+
}
|
|
1031
|
+
if (annots > 0)
|
|
1032
|
+
removed.push({ kind: 'annotations', label: `${annots} annotations (comments, links, form widgets)`, count: annots });
|
|
1033
|
+
// Marked content ActualText / Alt inside content streams is scrubbed by the writer.
|
|
1034
|
+
return removed;
|
|
1035
|
+
}
|
|
1036
|
+
/** The trailer /Info dictionary, if any. */
|
|
1037
|
+
export function infoDict(ctx) {
|
|
1038
|
+
const ref = ctx.trailerInfo.Info;
|
|
1039
|
+
const v = ref ? resolve(ctx, ref) : undefined;
|
|
1040
|
+
return v instanceof PDFDict ? v : undefined;
|
|
1041
|
+
}
|
|
1042
|
+
/** Text stored in document-level side channels (Info, XMP, annotations, outlines, fields), for the verifier. */
|
|
1043
|
+
export function collectSideChannelText(ctx, doc) {
|
|
1044
|
+
const out = [];
|
|
1045
|
+
const info = infoDict(ctx);
|
|
1046
|
+
if (info)
|
|
1047
|
+
for (const [, v] of info.entries()) {
|
|
1048
|
+
const s = stringText(resolve(ctx, v));
|
|
1049
|
+
if (s)
|
|
1050
|
+
out.push(s);
|
|
1051
|
+
}
|
|
1052
|
+
for (const page of doc.getPages()) {
|
|
1053
|
+
for (const a of itemsOf(ctx, getArray(ctx, page.node, 'Annots'))) {
|
|
1054
|
+
if (a instanceof PDFDict) {
|
|
1055
|
+
for (const key of ['Contents', 'T', 'Subj', 'RC', 'V', 'DV', 'TU']) {
|
|
1056
|
+
const s = stringText(resolve(ctx, a.get(PDFName.of(key))));
|
|
1057
|
+
if (s)
|
|
1058
|
+
out.push(s);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|