@doccloak/core 0.13.0 → 0.15.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 +230 -0
- package/README.md +49 -7
- package/dist/dom/ocr.d.ts +41 -0
- package/dist/dom/ocr.js +78 -0
- package/dist/dom/office.d.ts +2 -0
- package/dist/dom/office.js +1 -0
- package/dist/env.d.ts +2 -2
- package/dist/pdf/coverage.d.ts +41 -0
- package/dist/pdf/coverage.js +193 -0
- package/dist/pdf/extract.d.ts +9 -1
- package/dist/pdf/extract.js +193 -12
- package/dist/pdf/raster.d.ts +118 -0
- package/dist/pdf/raster.js +525 -0
- package/dist/pdf/read.d.ts +3 -1
- package/dist/pdf/read.js +209 -7
- package/dist/pdf/standard-metrics.d.ts +6 -0
- package/dist/pdf/standard-metrics.js +24 -1
- package/dist/pdf/textstate.d.ts +15 -4
- package/dist/pdf/textstate.js +18 -5
- package/dist/pdf/types.d.ts +118 -4
- package/dist/pdf/verify.js +38 -5
- package/dist/pdf/write.d.ts +18 -1
- package/dist/pdf/write.js +382 -10
- package/dist/pipeline.d.ts +2 -1
- package/dist/pipeline.js +16 -2
- package/dist/regex/rules.data.js +5 -3
- package/package.json +1 -1
- package/rules/pl.json +5 -3
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/pdf - text in a scan that its OCR layer does not have (T238).
|
|
3
|
+
*
|
|
4
|
+
* A scan page is redacted through its OCR text layer: what the layer does not
|
|
5
|
+
* hold (a table the OCR could not read, a stamp, a margin note) cannot be
|
|
6
|
+
* found, so it stays in the picture. This module finds such places so the
|
|
7
|
+
* reader can say so. It works on the rendered page: ink is grouped into
|
|
8
|
+
* letter-sized shapes, shapes that no OCR glyph box covers are grouped into
|
|
9
|
+
* lines, and a line of several such shapes is text the layer does not have.
|
|
10
|
+
* Rules, frames, signatures and pictures are larger than letters and never
|
|
11
|
+
* count; single marks (a page number, a tick) are too short to form a line.
|
|
12
|
+
*/
|
|
13
|
+
/** A pixel is ink when its luminance is below this fraction of the paper's. */
|
|
14
|
+
const INK_LUMINANCE = 0.6;
|
|
15
|
+
/** Letter-sized: height within these multiples of the page's OCR text size (em, in pixels). */
|
|
16
|
+
const SHAPE_MIN_HEIGHT = 0.25;
|
|
17
|
+
const SHAPE_MAX_HEIGHT = 1.6;
|
|
18
|
+
const SHAPE_MAX_WIDTH = 2.5;
|
|
19
|
+
/** A shape counts as covered when this share of its box lies under an OCR glyph box. */
|
|
20
|
+
const COVERED_SHARE = 0.3;
|
|
21
|
+
/** Shapes join a line when their centres are this close vertically and the gap between them this small (em). */
|
|
22
|
+
const LINE_SAME_ROW = 0.45;
|
|
23
|
+
const LINE_MAX_GAP = 1.2;
|
|
24
|
+
/** A line needs this many shapes and this width (em): a short word, never a lone mark or the tip of a covered word. */
|
|
25
|
+
const LINE_MIN_SHAPES = 5;
|
|
26
|
+
const LINE_MIN_WIDTH = 2;
|
|
27
|
+
/**
|
|
28
|
+
* Lines of text-like ink in `rgba` (width x height, RGBA) that none of
|
|
29
|
+
* `ocrBoxes` covers. `emPx` is the page's typical OCR text size in pixels.
|
|
30
|
+
*/
|
|
31
|
+
export function findTextWithoutOcr(rgba, width, height, ocrBoxes, emPx) {
|
|
32
|
+
if (!(emPx >= 4) || width <= 0 || height <= 0)
|
|
33
|
+
return [];
|
|
34
|
+
const n = width * height;
|
|
35
|
+
const paper = paperLuminance(rgba, n);
|
|
36
|
+
const ink = new Uint8Array(n);
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
const lum = 0.2126 * rgba[i * 4] + 0.7152 * rgba[i * 4 + 1] + 0.0722 * rgba[i * 4 + 2];
|
|
39
|
+
if (lum < paper * INK_LUMINANCE)
|
|
40
|
+
ink[i] = 1;
|
|
41
|
+
}
|
|
42
|
+
// Coverage: every OCR glyph box, grown by a third of an em (layers sit a little off the print).
|
|
43
|
+
const covered = new Uint8Array(n);
|
|
44
|
+
const grow = Math.round(emPx / 3);
|
|
45
|
+
for (const b of ocrBoxes) {
|
|
46
|
+
const x0 = Math.max(0, Math.floor(b.x0) - grow);
|
|
47
|
+
const x1 = Math.min(width, Math.ceil(b.x1) + grow);
|
|
48
|
+
const y0 = Math.max(0, Math.floor(b.y0) - grow);
|
|
49
|
+
const y1 = Math.min(height, Math.ceil(b.y1) + grow);
|
|
50
|
+
for (let y = y0; y < y1; y++)
|
|
51
|
+
covered.fill(1, y * width + x0, y * width + x1);
|
|
52
|
+
}
|
|
53
|
+
const shapes = letterShapes(ink, covered, width, height, emPx);
|
|
54
|
+
return groupLines(shapes, emPx);
|
|
55
|
+
}
|
|
56
|
+
/** Median luminance of a sample of pixels: the paper, on any page that is mostly paper. */
|
|
57
|
+
function paperLuminance(rgba, n) {
|
|
58
|
+
const sample = [];
|
|
59
|
+
const step = Math.max(1, Math.floor(n / 20000));
|
|
60
|
+
for (let i = 0; i < n; i += step)
|
|
61
|
+
sample.push(0.2126 * rgba[i * 4] + 0.7152 * rgba[i * 4 + 1] + 0.0722 * rgba[i * 4 + 2]);
|
|
62
|
+
sample.sort((a, b) => a - b);
|
|
63
|
+
return sample[sample.length >> 1] || 255;
|
|
64
|
+
}
|
|
65
|
+
/** Connected ink components (4-neighbour) that are letter-sized and not covered by the OCR layer. */
|
|
66
|
+
function letterShapes(ink, covered, width, height, emPx) {
|
|
67
|
+
const label = new Int32Array(width * height);
|
|
68
|
+
const out = [];
|
|
69
|
+
const stack = [];
|
|
70
|
+
let next = 0;
|
|
71
|
+
for (let start = 0; start < ink.length; start++) {
|
|
72
|
+
if (!ink[start] || label[start])
|
|
73
|
+
continue;
|
|
74
|
+
next++;
|
|
75
|
+
label[start] = next;
|
|
76
|
+
stack.push(start);
|
|
77
|
+
let x0 = width, y0 = height, x1 = 0, y1 = 0, count = 0, under = 0;
|
|
78
|
+
while (stack.length > 0) {
|
|
79
|
+
const p = stack.pop();
|
|
80
|
+
const x = p % width;
|
|
81
|
+
const y = (p - x) / width;
|
|
82
|
+
count++;
|
|
83
|
+
if (covered[p])
|
|
84
|
+
under++;
|
|
85
|
+
if (x < x0)
|
|
86
|
+
x0 = x;
|
|
87
|
+
if (x > x1)
|
|
88
|
+
x1 = x;
|
|
89
|
+
if (y < y0)
|
|
90
|
+
y0 = y;
|
|
91
|
+
if (y > y1)
|
|
92
|
+
y1 = y;
|
|
93
|
+
if (x > 0 && ink[p - 1] && !label[p - 1]) {
|
|
94
|
+
label[p - 1] = next;
|
|
95
|
+
stack.push(p - 1);
|
|
96
|
+
}
|
|
97
|
+
if (x < width - 1 && ink[p + 1] && !label[p + 1]) {
|
|
98
|
+
label[p + 1] = next;
|
|
99
|
+
stack.push(p + 1);
|
|
100
|
+
}
|
|
101
|
+
if (y > 0 && ink[p - width] && !label[p - width]) {
|
|
102
|
+
label[p - width] = next;
|
|
103
|
+
stack.push(p - width);
|
|
104
|
+
}
|
|
105
|
+
if (y < height - 1 && ink[p + width] && !label[p + width]) {
|
|
106
|
+
label[p + width] = next;
|
|
107
|
+
stack.push(p + width);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const h = y1 - y0 + 1;
|
|
111
|
+
const w = x1 - x0 + 1;
|
|
112
|
+
if (h < SHAPE_MIN_HEIGHT * emPx || h > SHAPE_MAX_HEIGHT * emPx || w > SHAPE_MAX_WIDTH * emPx)
|
|
113
|
+
continue;
|
|
114
|
+
if (count < 3 || under / count >= COVERED_SHARE)
|
|
115
|
+
continue;
|
|
116
|
+
out.push({ x0, y0, x1: x1 + 1, y1: y1 + 1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** Shapes on one row, each close to the next, as lines; only lines of LINE_MIN_SHAPES or more. */
|
|
121
|
+
function groupLines(shapes, emPx) {
|
|
122
|
+
const sorted = [...shapes].sort((a, b) => a.cy - b.cy || a.x0 - b.x0);
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const s of sorted) {
|
|
125
|
+
const row = rows.find((r) => Math.abs(r[0].cy - s.cy) <= LINE_SAME_ROW * emPx);
|
|
126
|
+
if (row)
|
|
127
|
+
row.push(s);
|
|
128
|
+
else
|
|
129
|
+
rows.push([s]);
|
|
130
|
+
}
|
|
131
|
+
const lines = [];
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
row.sort((a, b) => a.x0 - b.x0);
|
|
134
|
+
let run = [];
|
|
135
|
+
const flush = () => {
|
|
136
|
+
const x0 = Math.min(...run.map((s) => s.x0));
|
|
137
|
+
const x1 = Math.max(...run.map((s) => s.x1));
|
|
138
|
+
if (run.length >= LINE_MIN_SHAPES && x1 - x0 >= LINE_MIN_WIDTH * emPx) {
|
|
139
|
+
lines.push({ x0, y0: Math.min(...run.map((s) => s.y0)), x1, y1: Math.max(...run.map((s) => s.y1)), shapes: run.length });
|
|
140
|
+
}
|
|
141
|
+
run = [];
|
|
142
|
+
};
|
|
143
|
+
for (const s of row) {
|
|
144
|
+
if (run.length > 0 && s.x0 - run[run.length - 1].x1 > LINE_MAX_GAP * emPx)
|
|
145
|
+
flush();
|
|
146
|
+
run.push(s);
|
|
147
|
+
}
|
|
148
|
+
flush();
|
|
149
|
+
}
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Pixel boxes of every OCR glyph on `page` (runs marked `ocr`; with `allText`, every glyph of the
|
|
154
|
+
* page's text layer), from 0.3 em below the baseline to 0.9 em above it, through the render
|
|
155
|
+
* transform [a b c d e f]; and the median glyph size in pixels (the page's text size, the scale
|
|
156
|
+
* every rule above is measured in).
|
|
157
|
+
*/
|
|
158
|
+
export function ocrGlyphBoxes(runs, page, transform, allText = false) {
|
|
159
|
+
const [a, b, c, d, e, f] = transform;
|
|
160
|
+
const scale = Math.hypot(a, b) || 1;
|
|
161
|
+
const boxes = [];
|
|
162
|
+
const chars = [];
|
|
163
|
+
const sizes = [];
|
|
164
|
+
for (const run of runs) {
|
|
165
|
+
if (run.page !== page || run.space || (!allText && !run.ocr))
|
|
166
|
+
continue;
|
|
167
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
168
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
169
|
+
const ux = run.emX[0] / exLen;
|
|
170
|
+
const uy = run.emX[1] / exLen;
|
|
171
|
+
const vx = run.emY[0] / eyLen;
|
|
172
|
+
const vy = run.emY[1] / eyLen;
|
|
173
|
+
const k = exLen / (run.local.fontSize * run.local.hscale || 1);
|
|
174
|
+
for (const g of run.glyphs) {
|
|
175
|
+
if (g.unicode.trim() === '')
|
|
176
|
+
continue;
|
|
177
|
+
const w = g.advance * k;
|
|
178
|
+
const xs = [];
|
|
179
|
+
const ys = [];
|
|
180
|
+
for (const [along, up] of [[0, -0.3], [w, -0.3], [w, 0.9], [0, 0.9]]) {
|
|
181
|
+
const x = g.x + ux * along + vx * up * g.fontSize;
|
|
182
|
+
const y = g.y + uy * along + vy * up * g.fontSize;
|
|
183
|
+
xs.push(a * x + c * y + e);
|
|
184
|
+
ys.push(b * x + d * y + f);
|
|
185
|
+
}
|
|
186
|
+
boxes.push({ x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) });
|
|
187
|
+
chars.push(g.unicode);
|
|
188
|
+
sizes.push(g.fontSize * scale);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
sizes.sort((p, q) => p - q);
|
|
192
|
+
return { boxes, chars, emPx: sizes[sizes.length >> 1] ?? 0 };
|
|
193
|
+
}
|
package/dist/pdf/extract.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { PDFContext } from './objects.js';
|
|
|
17
17
|
import { PDFDict, PDFRef, PDFStream } from './objects.js';
|
|
18
18
|
import type { ContentOp } from './lexer.js';
|
|
19
19
|
import type { LoadedFont } from './fonts.js';
|
|
20
|
-
import type { GlyphRun, PdfPageInfo, RemovedPart } from './types.js';
|
|
20
|
+
import type { ExtraOcrPage, GlyphRun, PdfPageInfo, RemovedPart } from './types.js';
|
|
21
21
|
import type { UnredactablePart } from '../dom/errors.js';
|
|
22
22
|
import type { OracleDocument } from './oracle.js';
|
|
23
23
|
/** One content stream we may rewrite: page contents (joined) or a form XObject. */
|
|
@@ -56,6 +56,8 @@ export interface PageRecord {
|
|
|
56
56
|
/** Our decoder's runs and text of a raster-only page (kept for blacking out with our geometry too). */
|
|
57
57
|
ownRuns?: GlyphRun[];
|
|
58
58
|
ownText?: string;
|
|
59
|
+
/** Highest effective resolution of the images drawn on the page (pixels per inch; 0 without images). */
|
|
60
|
+
imageDpi: number;
|
|
59
61
|
}
|
|
60
62
|
export interface PdfModel {
|
|
61
63
|
bytes: Uint8Array;
|
|
@@ -87,6 +89,12 @@ export interface ExtractOptions {
|
|
|
87
89
|
* taken from the oracle (so detection still sees what a viewer shows).
|
|
88
90
|
*/
|
|
89
91
|
oracle?: OracleDocument;
|
|
92
|
+
/**
|
|
93
|
+
* Text DocCloak read itself where a scan page's OCR layer had none (readPdf's recognizeText): its
|
|
94
|
+
* words join the page as hidden OCR runs after the page's own text, so the reader and the writer
|
|
95
|
+
* build the same flat text from the same bytes.
|
|
96
|
+
*/
|
|
97
|
+
extraOcr?: ExtraOcrPage[];
|
|
90
98
|
}
|
|
91
99
|
export declare const DEFAULT_MAX_BYTES: number;
|
|
92
100
|
export declare const DEFAULT_MAX_PAGES = 500;
|
package/dist/pdf/extract.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* never modified.
|
|
14
14
|
*/
|
|
15
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";
|
|
16
|
+
import { PDFArray, PDFDict, PDFName, PDFNumber, PDFRef, PDFStream, StreamDecodeError, decodeStream, getArray, getBool, getDict, getName, getNumber, getStream, inheritedPageValue, itemsOf, numbersOf, pageContentStreams, pageResources, refKey, resolve, stringText, } from "./objects.js";
|
|
17
17
|
import { lexContent, TEXT_SHOW_OPS } from "./lexer.js";
|
|
18
18
|
import { StateMachine, adjustmentAdvance, apply, glyphAdvance, initialState, mul, matrixFromOperands } from "./textstate.js";
|
|
19
19
|
import { loadFont } from "./fonts.js";
|
|
@@ -92,11 +92,12 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
92
92
|
index: p,
|
|
93
93
|
dict: pageDict,
|
|
94
94
|
ref: pageRef,
|
|
95
|
-
info: { index: p, width, height, rotate, hasImages: false, imageOnly: false, textStart: text.length, textEnd: text.length },
|
|
95
|
+
info: { index: p, width, height, rotate, hasImages: false, imageOnly: false, ocrText: false, textStart: text.length, textEnd: text.length },
|
|
96
96
|
streams: [],
|
|
97
97
|
issues: [],
|
|
98
98
|
hiddenGlyphs: 0,
|
|
99
99
|
oracleText: false,
|
|
100
|
+
imageDpi: 0,
|
|
100
101
|
};
|
|
101
102
|
model.pages.push(record);
|
|
102
103
|
const pageTextStart = text.length;
|
|
@@ -143,8 +144,11 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
143
144
|
if (glyphCount > maxGlyphs) {
|
|
144
145
|
throw new UnsupportedDocumentError('too-large', `PDF holds more than ${maxGlyphs} glyphs; the limit protects the host's memory`, [String(glyphCount)]);
|
|
145
146
|
}
|
|
147
|
+
walker.markOcrText(runs, pageRunStart);
|
|
146
148
|
record.info.hasImages = walker.sawImage;
|
|
147
149
|
record.info.imageOnly = walker.sawImage && walker.visibleGlyphs === 0;
|
|
150
|
+
record.info.ocrText = walker.ocrGlyphs > 0;
|
|
151
|
+
record.imageDpi = walker.imageDpi();
|
|
148
152
|
let oracleGarbage = false;
|
|
149
153
|
if (options.oracle && !record.hostile) {
|
|
150
154
|
let items = null;
|
|
@@ -186,16 +190,30 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
186
190
|
appendOracleRuns(p, items, runs, text);
|
|
187
191
|
if (items.some((it) => it.str.trim() !== ''))
|
|
188
192
|
record.info.imageOnly = false;
|
|
193
|
+
// The oracle's runs replace ours and carry no OCR marks: the page is blacked out as a raster-only page.
|
|
194
|
+
record.info.ocrText = false;
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
}
|
|
198
|
+
const extra = options.extraOcr?.find((x) => x.page === p);
|
|
199
|
+
if (extra && !record.oracleText && extra.lines.length > 0) {
|
|
200
|
+
record.info.ocrReadWords = appendExtraOcrRuns(p, extra.lines, runs, text);
|
|
201
|
+
// A picture without any text layer, read whole by DocCloak's OCR: from here on a scan with an
|
|
202
|
+
// OCR layer (its values are painted over in the picture), not a page left as it is.
|
|
203
|
+
if (extra.whole && record.info.imageOnly && record.info.ocrReadWords > 0)
|
|
204
|
+
record.info.ocrText = true;
|
|
205
|
+
}
|
|
192
206
|
record.info.textEnd = text.length;
|
|
193
|
-
if (record.info.imageOnly) {
|
|
207
|
+
if (record.info.imageOnly && record.info.ocrText) {
|
|
208
|
+
// A scan with an OCR text layer: detection reads the layer, and the writer paints the
|
|
209
|
+
// placeholders into the picture where the layer says the values are.
|
|
210
|
+
}
|
|
211
|
+
else if (record.info.imageOnly) {
|
|
194
212
|
unredactable.push({
|
|
195
213
|
part: `page ${p + 1}`,
|
|
196
214
|
kind: 'scanned-page',
|
|
197
215
|
label: record.hiddenGlyphs > 0
|
|
198
|
-
? `page ${p + 1} is a scanned image with a hidden
|
|
216
|
+
? `page ${p + 1} is a scanned image with a hidden text layer that is not over the picture: the text layer is redacted, the picture keeps the original`
|
|
199
217
|
: `page ${p + 1} is an image without a text layer (scanned page); its content cannot be redacted`,
|
|
200
218
|
});
|
|
201
219
|
}
|
|
@@ -217,7 +235,7 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
217
235
|
text.pageBreak();
|
|
218
236
|
}
|
|
219
237
|
const removed = scanDocumentLevelParts(ctx, doc, model, warnings);
|
|
220
|
-
const plainText = text.toString();
|
|
238
|
+
const plainText = ocrRulesAsSpaces(text.toString(), runs);
|
|
221
239
|
aggregatePageParts(unredactable);
|
|
222
240
|
// Reading-order insertion can put a later operator's text before an earlier one's: keep the
|
|
223
241
|
// runs in flat-text order (the writer's segment mapping relies on it).
|
|
@@ -233,6 +251,29 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
233
251
|
model,
|
|
234
252
|
};
|
|
235
253
|
}
|
|
254
|
+
/** Characters an OCR reads off a table's vertical rules. */
|
|
255
|
+
const OCR_RULE = /^[|¦]$/;
|
|
256
|
+
/**
|
|
257
|
+
* In OCR text a table's vertical rules read as "|", often glued to a cell's word ("|Kraków"), and
|
|
258
|
+
* the detector then misses the word. Those characters read as spaces in the flat text, one for one,
|
|
259
|
+
* so every offset still maps to its glyph (the glyphs, and the layer written for a painted page,
|
|
260
|
+
* keep what the OCR read).
|
|
261
|
+
*/
|
|
262
|
+
function ocrRulesAsSpaces(plain, runs) {
|
|
263
|
+
let units = null;
|
|
264
|
+
for (const run of runs) {
|
|
265
|
+
if (!run.ocr)
|
|
266
|
+
continue;
|
|
267
|
+
run.glyphs.forEach((g, i) => {
|
|
268
|
+
const at = run.textOffsets[i];
|
|
269
|
+
if (!OCR_RULE.test(g.unicode) || at === undefined || at < 0 || plain[at] !== g.unicode)
|
|
270
|
+
return;
|
|
271
|
+
units ?? (units = plain.split(''));
|
|
272
|
+
units[at] = ' ';
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return units ? units.join('') : plain;
|
|
276
|
+
}
|
|
236
277
|
/** pdf.js `view`: the CropBox clipped to the MediaBox (defaults to US Letter when absent). */
|
|
237
278
|
function pageBox(mediaBox, cropBox) {
|
|
238
279
|
const m = mediaBox.length === 4 ? mediaBox : [0, 0, 612, 792];
|
|
@@ -544,13 +585,68 @@ function appendOracleRuns(page, items, runs, text) {
|
|
|
544
585
|
});
|
|
545
586
|
});
|
|
546
587
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
588
|
+
/**
|
|
589
|
+
* Runs for text DocCloak read with its own OCR (one run per word, one glyph per letter, in user
|
|
590
|
+
* space), after the page's own text: a blank line, then each line's words. They are hidden OCR text
|
|
591
|
+
* with no stream behind them (`oracle`), so the writer paints them and never edits a stream for
|
|
592
|
+
* them. Returns the number of words.
|
|
593
|
+
*/
|
|
594
|
+
function appendExtraOcrRuns(page, lines, runs, text) {
|
|
595
|
+
let count = 0;
|
|
596
|
+
text.append('\n\n');
|
|
597
|
+
lines.forEach((line, li) => {
|
|
598
|
+
line.forEach((word, wi) => {
|
|
599
|
+
if (wi > 0)
|
|
600
|
+
text.append(' ');
|
|
601
|
+
const glyphs = [];
|
|
602
|
+
const textOffsets = [];
|
|
603
|
+
const advances = [];
|
|
604
|
+
const start = text.length;
|
|
605
|
+
let along = 0;
|
|
606
|
+
for (const g of word.glyphs) {
|
|
607
|
+
textOffsets.push(text.length);
|
|
608
|
+
text.append(g.unicode);
|
|
609
|
+
glyphs.push({ code: -1, codeLength: 0, unicode: g.unicode, width: word.size > 0 ? (g.advance / word.size) * 1000 : 0, advance: g.advance, x: g.x, y: g.y, fontSize: word.size, item: 0, byteOffset: 0 });
|
|
610
|
+
advances.push(along);
|
|
611
|
+
along += g.advance;
|
|
612
|
+
}
|
|
613
|
+
runs.push({
|
|
614
|
+
page,
|
|
615
|
+
streamKey: `ocr:p${page}`,
|
|
616
|
+
opIndex: count,
|
|
617
|
+
fontName: '',
|
|
618
|
+
fontKey: '',
|
|
619
|
+
glyphs,
|
|
620
|
+
renderMode: 3,
|
|
621
|
+
hidden: true,
|
|
622
|
+
ocr: true,
|
|
623
|
+
textOffsets,
|
|
624
|
+
textStart: start,
|
|
625
|
+
textEnd: text.length,
|
|
626
|
+
emX: [word.dir[0] * word.size, word.dir[1] * word.size],
|
|
627
|
+
emY: [word.up[0] * word.size, word.up[1] * word.size],
|
|
628
|
+
local: { ctm: [1, 0, 0, 1, 0, 0], tm: [1, 0, 0, 1, 0, 0], tlm: [1, 0, 0, 1, 0, 0], fontSize: word.size, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, advances },
|
|
629
|
+
oracle: true,
|
|
630
|
+
});
|
|
631
|
+
count++;
|
|
632
|
+
});
|
|
633
|
+
if (li < lines.length - 1)
|
|
634
|
+
text.append('\n');
|
|
635
|
+
});
|
|
636
|
+
return count;
|
|
637
|
+
}
|
|
638
|
+
/** Constant alpha below this paints nothing a reader can see. */
|
|
639
|
+
const MIN_VISIBLE_ALPHA = 0.02;
|
|
550
640
|
class Walker {
|
|
551
641
|
constructor(model, page, runs, text, box) {
|
|
552
642
|
this.sawImage = false;
|
|
553
643
|
this.visibleGlyphs = 0;
|
|
644
|
+
/** Glyphs of text hidden over an image (an OCR layer), counted by markOcrText. */
|
|
645
|
+
this.ocrGlyphs = 0;
|
|
646
|
+
this.images = [];
|
|
647
|
+
/** Paint order of text runs and images on this page (text drawn before an opaque image is covered by it). */
|
|
648
|
+
this.order = 0;
|
|
649
|
+
this.runOrder = new Map();
|
|
554
650
|
this.undecodableGlyphs = 0;
|
|
555
651
|
/** A composite font without a usable CMap/ToUnicode showed text: pdf.js's reading of it is a guess (glyph ids), never trusted. */
|
|
556
652
|
this.unreadableCompositeFont = false;
|
|
@@ -569,6 +665,13 @@ class Walker {
|
|
|
569
665
|
const { ctx } = this.model;
|
|
570
666
|
const fontsDict = getDict(ctx, stream.resources, 'Font');
|
|
571
667
|
const resolveFont = (name) => loadFont(ctx, fontsDict, name, this.model.fonts);
|
|
668
|
+
const statesDict = getDict(ctx, stream.resources, 'ExtGState');
|
|
669
|
+
const resolveGState = (name) => {
|
|
670
|
+
const d = resolve(ctx, statesDict?.get(PDFName.of(name)));
|
|
671
|
+
if (!(d instanceof PDFDict))
|
|
672
|
+
return undefined;
|
|
673
|
+
return { fillAlpha: getNumber(ctx, d, 'ca'), strokeAlpha: getNumber(ctx, d, 'CA') };
|
|
674
|
+
};
|
|
572
675
|
const sm = new StateMachine(base);
|
|
573
676
|
// Text can also live in tiling pattern cells and soft-mask groups of this stream's resources.
|
|
574
677
|
this.walkAuxiliary(stream, base, depth, active);
|
|
@@ -582,17 +685,19 @@ class Walker {
|
|
|
582
685
|
if (TEXT_SHOW_OPS.has(op.op)) {
|
|
583
686
|
// ' and " move to the next line (and set spacing) before showing.
|
|
584
687
|
if (op.op === "'" || op.op === '"')
|
|
585
|
-
sm.update(op, resolveFont);
|
|
688
|
+
sm.update(op, resolveFont, resolveGState);
|
|
586
689
|
this.show(stream, i, op, sm, resolveFont);
|
|
587
690
|
continue;
|
|
588
691
|
}
|
|
589
692
|
if (op.op === 'Do') {
|
|
590
|
-
sm.update(op, resolveFont);
|
|
693
|
+
sm.update(op, resolveFont, resolveGState);
|
|
591
694
|
this.doXObject(stream, op, sm, depth, active);
|
|
592
695
|
continue;
|
|
593
696
|
}
|
|
594
697
|
if (op.op === 'BI') {
|
|
595
698
|
this.sawImage = true;
|
|
699
|
+
// Inline images are small (the format caps them at 4 KB in practice): placed, never trusted as opaque.
|
|
700
|
+
this.placeImage(sm, 0, 0, false);
|
|
596
701
|
continue;
|
|
597
702
|
}
|
|
598
703
|
if (op.op === 'd0' || op.op === 'd1') {
|
|
@@ -601,7 +706,62 @@ class Walker {
|
|
|
601
706
|
if (op.op === 'sh') {
|
|
602
707
|
// Shading is drawing, not text; nothing to do.
|
|
603
708
|
}
|
|
604
|
-
sm.update(op, resolveFont);
|
|
709
|
+
sm.update(op, resolveFont, resolveGState);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** Record an image painted at the current CTM (it fills the unit square of image space). */
|
|
713
|
+
placeImage(sm, widthPx, heightPx, opaque) {
|
|
714
|
+
const m = sm.state.ctm;
|
|
715
|
+
const corners = [apply(m, 0, 0), apply(m, 1, 0), apply(m, 0, 1), apply(m, 1, 1)];
|
|
716
|
+
const xs = corners.map((c) => c[0]);
|
|
717
|
+
const ys = corners.map((c) => c[1]);
|
|
718
|
+
// Pixels per inch along each image axis: the axis spans |row| user units (1/72 in).
|
|
719
|
+
const alongX = Math.hypot(m[0], m[1]) / 72;
|
|
720
|
+
const alongY = Math.hypot(m[2], m[3]) / 72;
|
|
721
|
+
const dpi = Math.max(alongX > 0 ? widthPx / alongX : 0, alongY > 0 ? heightPx / alongY : 0);
|
|
722
|
+
this.images.push({
|
|
723
|
+
x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys),
|
|
724
|
+
dpi: Number.isFinite(dpi) ? dpi : 0,
|
|
725
|
+
opaque: opaque && sm.state.fillAlpha >= 1,
|
|
726
|
+
order: this.order++,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** Highest effective resolution of the images on the page. */
|
|
730
|
+
imageDpi() {
|
|
731
|
+
let dpi = 0;
|
|
732
|
+
for (const im of this.images)
|
|
733
|
+
dpi = Math.max(dpi, im.dpi);
|
|
734
|
+
return dpi;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* After the walk: text hidden over an image is an OCR layer (`ocr`), the reading of what the
|
|
738
|
+
* picture shows. Text painted BEFORE an opaque image that covers it is hidden too ("text under
|
|
739
|
+
* the page image", another common OCR layout), so it counts as hidden and as OCR text.
|
|
740
|
+
*/
|
|
741
|
+
markOcrText(runs, from) {
|
|
742
|
+
if (this.images.length === 0)
|
|
743
|
+
return;
|
|
744
|
+
for (let r = from; r < runs.length; r++) {
|
|
745
|
+
const run = runs[r];
|
|
746
|
+
if (run.space || run.oracle)
|
|
747
|
+
continue;
|
|
748
|
+
const order = this.runOrder.get(run) ?? -1;
|
|
749
|
+
const shown = run.glyphs.filter((g) => g.unicode.trim() !== '');
|
|
750
|
+
if (shown.length === 0)
|
|
751
|
+
continue;
|
|
752
|
+
const within = (im) => shown.filter((g) => glyphInside(im, g, run)).length * 2 >= shown.length;
|
|
753
|
+
if (!run.hidden) {
|
|
754
|
+
if (!this.images.some((im) => im.opaque && im.order > order && within(im)))
|
|
755
|
+
continue;
|
|
756
|
+
run.hidden = true;
|
|
757
|
+
this.visibleGlyphs -= shown.length;
|
|
758
|
+
this.page.hiddenGlyphs += run.glyphs.length;
|
|
759
|
+
}
|
|
760
|
+
else if (!this.images.some(within)) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
run.ocr = true;
|
|
764
|
+
this.ocrGlyphs += shown.length;
|
|
605
765
|
}
|
|
606
766
|
}
|
|
607
767
|
/**
|
|
@@ -707,6 +867,10 @@ class Walker {
|
|
|
707
867
|
const subtype = getName(ctx, xobj.dict, 'Subtype');
|
|
708
868
|
if (subtype === 'Image') {
|
|
709
869
|
this.sawImage = true;
|
|
870
|
+
const d = xobj.dict;
|
|
871
|
+
const masked = getBool(ctx, d, 'ImageMask') === true || d.get(PDFName.of('SMask')) !== undefined
|
|
872
|
+
|| d.get(PDFName.of('Mask')) !== undefined || (getNumber(ctx, d, 'SMaskInData') ?? 0) !== 0;
|
|
873
|
+
this.placeImage(sm, getNumber(ctx, d, 'Width') ?? 0, getNumber(ctx, d, 'Height') ?? 0, !masked);
|
|
710
874
|
return;
|
|
711
875
|
}
|
|
712
876
|
if (subtype !== 'Form')
|
|
@@ -755,6 +919,8 @@ class Walker {
|
|
|
755
919
|
inner.text = { ...sm.state.text };
|
|
756
920
|
inner.fillLuminance = sm.state.fillLuminance;
|
|
757
921
|
inner.strokeLuminance = sm.state.strokeLuminance;
|
|
922
|
+
inner.fillAlpha = sm.state.fillAlpha;
|
|
923
|
+
inner.strokeAlpha = sm.state.strokeAlpha;
|
|
758
924
|
active.add(key);
|
|
759
925
|
this.walk(record, inner, depth + 1, active);
|
|
760
926
|
active.delete(key);
|
|
@@ -817,9 +983,14 @@ class Walker {
|
|
|
817
983
|
const emY = [trm0[2], trm0[3]];
|
|
818
984
|
const hiddenByMode = t.renderMode === 3 || t.renderMode === 7;
|
|
819
985
|
const hiddenByColor = t.renderMode === 0 && sm.state.fillLuminance !== null && sm.state.fillLuminance > 0.97;
|
|
986
|
+
// Fully transparent paint (ExtGState /ca, /CA): the OCR layer of many scanners.
|
|
987
|
+
const fills = [0, 2, 4, 6].includes(t.renderMode);
|
|
988
|
+
const strokes = [1, 2, 5, 6].includes(t.renderMode);
|
|
989
|
+
const hiddenByAlpha = (fills || strokes)
|
|
990
|
+
&& (!fills || sm.state.fillAlpha < MIN_VISIBLE_ALPHA) && (!strokes || sm.state.strokeAlpha < MIN_VISIBLE_ALPHA);
|
|
820
991
|
const sizeUser = Math.hypot(trm0[2], trm0[3]);
|
|
821
992
|
const tiny = sizeUser < 0.5;
|
|
822
|
-
const hidden = hiddenByMode || hiddenByColor || tiny;
|
|
993
|
+
const hidden = hiddenByMode || hiddenByColor || hiddenByAlpha || tiny;
|
|
823
994
|
const glyphs = [];
|
|
824
995
|
const textOffsets = [];
|
|
825
996
|
let advanceSoFar = 0; // unscaled text-space x from the run start
|
|
@@ -898,8 +1069,18 @@ class Walker {
|
|
|
898
1069
|
...(this.space ? { space: this.space } : {}),
|
|
899
1070
|
};
|
|
900
1071
|
this.runs.push(run);
|
|
1072
|
+
this.runOrder.set(run, this.order++);
|
|
901
1073
|
}
|
|
902
1074
|
}
|
|
1075
|
+
/** True when the middle of glyph `g` (half an advance along, a third of an em up) lies inside `b`. */
|
|
1076
|
+
function glyphInside(b, g, run) {
|
|
1077
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
1078
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
1079
|
+
const w = g.advance * (exLen / (run.local.fontSize * run.local.hscale || 1));
|
|
1080
|
+
const x = g.x + (run.emX[0] / exLen) * (w / 2) + (run.emY[0] / eyLen) * (g.fontSize / 3);
|
|
1081
|
+
const y = g.y + (run.emX[1] / exLen) * (w / 2) + (run.emY[1] / eyLen) * (g.fontSize / 3);
|
|
1082
|
+
return insideBox(b, x, y);
|
|
1083
|
+
}
|
|
903
1084
|
/**
|
|
904
1085
|
* Consecutive pages with the same kind of problem collapse into one part ("pages 2-22"), so a
|
|
905
1086
|
* document whose body font is unreadable shows one line to consent to, not one per page.
|