@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.
@@ -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
+ }
@@ -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;
@@ -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 OCR text layer: the text layer is redacted, the picture keeps the original`
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
- /* Content walker */
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.