@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/dist/pdf/read.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { DEFAULT_MAX_BYTES, extractPdf } from "./extract.js";
10
10
  import { UnsupportedDocumentError } from "../dom/errors.js";
11
11
  import { openWithOracle } from "./oracle.js";
12
+ import { findTextWithoutOcr, ocrGlyphBoxes } from "./coverage.js";
12
13
  export async function toPdfBytes(input) {
13
14
  if (input instanceof Uint8Array)
14
15
  return input;
@@ -24,6 +25,8 @@ export async function toPdfBytes(input) {
24
25
  reader.readAsArrayBuffer(input);
25
26
  });
26
27
  }
28
+ /** Resolution of the missing-OCR check: letters stay several pixels tall, a page stays cheap. */
29
+ const COVERAGE_DPI = 100;
27
30
  export async function readPdf(input, options = {}) {
28
31
  const bytes = await toPdfBytes(input);
29
32
  // Size cap before anything parses the file (pdf.js would otherwise read all of it first).
@@ -33,13 +36,88 @@ export async function readPdf(input, options = {}) {
33
36
  }
34
37
  const oracle = await openWithOracle(bytes, options.assets, options.password);
35
38
  try {
36
- const res = await extractPdf(bytes, {
37
- maxBytes: options.maxBytes,
38
- maxPages: options.maxPages,
39
- maxGlyphs: options.maxGlyphs,
40
- password: options.password,
41
- oracle,
42
- });
39
+ const extractOptions = { maxBytes: options.maxBytes, maxPages: options.maxPages, maxGlyphs: options.maxGlyphs, password: options.password, oracle };
40
+ let res = await extractPdf(bytes, extractOptions);
41
+ const missing = new Map();
42
+ const extraOcr = [];
43
+ const ocrImagePages = options.ocrImagePages !== false && !!options.recognizeText;
44
+ const checkCoverage = options.checkOcrCoverage !== false;
45
+ // Each page by what it holds: a picture without any text layer is read whole; a scan with an OCR
46
+ // layer and a text page with images are checked for text their layer does not have, and that is
47
+ // read. Words a layer already has are never added twice.
48
+ const wholePages = res.pages.filter((p) => p.imageOnly && !p.ocrText && ocrImagePages).map((p) => p.index);
49
+ const checked = res.pages.filter((p) => checkCoverage && (p.ocrText || (p.hasImages && !p.imageOnly))).map((p) => p.index);
50
+ const total = wholePages.length + (options.recognizeText ? checked.length : 0);
51
+ let done = 0;
52
+ const progress = () => options.onOcrProgress?.(done, total);
53
+ if (total > 0)
54
+ progress();
55
+ const wholeRead = new Map();
56
+ for (const index of wholePages) {
57
+ try {
58
+ const read = await readText(oracle, res.runs, index, null, 0, options);
59
+ const words = read ? read.lines.reduce((n, l) => n + l.length, 0) : 0;
60
+ wholeRead.set(index, words);
61
+ if (read && words > 0)
62
+ extraOcr.push({ ...read, whole: true });
63
+ }
64
+ catch {
65
+ // No canvas in this host, or the host OCR failed: the page stays a picture the card lists.
66
+ }
67
+ done++;
68
+ progress();
69
+ }
70
+ for (const index of checked) {
71
+ const page = res.pages[index];
72
+ try {
73
+ const rendered = await oracle.render(index, COVERAGE_DPI / 72, options.canvasFactory);
74
+ // On a scan the OCR layer is the text; on a text page every glyph of its layer is.
75
+ const allText = !page.ocrText;
76
+ const { boxes, emPx } = ocrGlyphBoxes(res.runs, index, rendered.transform, allText);
77
+ const lines = findTextWithoutOcr(rendered.canvas.getImageData(), rendered.widthPx, rendered.heightPx, boxes, emPx);
78
+ if (lines.length > 0) {
79
+ missing.set(index, lines.length);
80
+ if (options.recognizeText) {
81
+ const read = await readText(oracle, res.runs, index, lines, emPx, options, allText);
82
+ if (read && read.lines.length > 0)
83
+ extraOcr.push(read);
84
+ }
85
+ }
86
+ }
87
+ catch {
88
+ // No canvas in this host (Node without a factory), or the host OCR failed: the page keeps
89
+ // what its own layer has.
90
+ }
91
+ if (options.recognizeText) {
92
+ done++;
93
+ progress();
94
+ }
95
+ }
96
+ // Read text joins the pages' text: extract again with it, as the writer will.
97
+ if (extraOcr.length > 0)
98
+ res = await extractPdf(bytes, { ...extractOptions, extraOcr });
99
+ for (const [index, words] of wholeRead)
100
+ res.pages[index].ocrWholePage = words;
101
+ for (const [index, count] of missing) {
102
+ const page = res.pages[index];
103
+ page.ocrMissingLines = count;
104
+ const where = page.ocrText ? 'the picture shows text its OCR layer does not have' : 'an image on the page shows text its text layer does not have';
105
+ res.warnings.push(page.ocrReadWords
106
+ ? `page ${index + 1}: ${where} (${count} lines, for example a table); DocCloak read it with its own OCR (${page.ocrReadWords} words), check it before sharing`
107
+ : `page ${index + 1}: ${where} (${count} lines, for example a table or a stamp); it cannot be found or redacted, check it before sharing`);
108
+ }
109
+ for (const [index, words] of wholeRead) {
110
+ if (words > 0)
111
+ res.warnings.push(`page ${index + 1}: no text layer; DocCloak read the page with its own OCR (${words} words), check it before sharing`);
112
+ }
113
+ // With the host's OCR, the images of the text pages checked above were scanned for text.
114
+ if (options.recognizeText) {
115
+ const scanned = new Set(checked);
116
+ res.warnings = res.warnings.filter((w) => {
117
+ const m = w.match(/^page (\d+): contains images; text inside images is not scanned$/);
118
+ return !(m && scanned.has(Number(m[1]) - 1));
119
+ });
120
+ }
43
121
  return {
44
122
  plainText: res.plainText,
45
123
  pageCount: res.pages.length,
@@ -53,12 +131,136 @@ export async function readPdf(input, options = {}) {
53
131
  bytes,
54
132
  assets: options.assets,
55
133
  password: options.password,
134
+ ...(extraOcr.length > 0 ? { extraOcr } : {}),
56
135
  };
57
136
  }
58
137
  finally {
59
138
  await oracle.close();
60
139
  }
61
140
  }
141
+ /** Resolution DocCloak's own OCR reads missing text at. */
142
+ const READ_DPI = 300;
143
+ /** Two readings of a word are the same word at this share of common letters (in order). */
144
+ const SAME_WORD = 0.6;
145
+ /** Letters and digits of `s`, lower-cased, without diacritics. */
146
+ function letters(s) {
147
+ return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
148
+ }
149
+ /** Longest common subsequence of `a` and `b` over the longer length (0..1). */
150
+ function similarity(a, b) {
151
+ if (!a || !b)
152
+ return a === b ? 1 : 0;
153
+ const row = new Array(b.length + 1).fill(0);
154
+ for (let i = 1; i <= a.length; i++) {
155
+ let prev = 0;
156
+ for (let j = 1; j <= b.length; j++) {
157
+ const keep = row[j];
158
+ row[j] = a[i - 1] === b[j - 1] ? prev + 1 : Math.max(row[j], row[j - 1]);
159
+ prev = keep;
160
+ }
161
+ }
162
+ return row[b.length] / Math.max(a.length, b.length);
163
+ }
164
+ /** Words the OCR is less sure of than this (0..100) do not count towards a page read whole being text: on a picture without text they are noise. */
165
+ const MIN_WHOLE_PAGE_CONFIDENCE = 30;
166
+ /** A page read whole counts as text when it gives at least this many such words of two or more letters or digits. */
167
+ const MIN_WHOLE_PAGE_WORDS = 3;
168
+ /**
169
+ * Read text a page's layer does not have with the host's OCR: render the page at READ_DPI, hand
170
+ * over the region around the missing `lines` (grown by an em) or, with `lines` null, the whole page
171
+ * (a picture without any text layer); keep the words that do not lie under the layer's own glyphs
172
+ * (its OCR glyphs, or with `allText` every glyph of a text page), and turn them into glyphs in user
173
+ * space (letters at the boxes the OCR gives, origins on the line's baseline).
174
+ */
175
+ async function readText(oracle, runs, pageIndex, lines, emPx, options, allText = false) {
176
+ const k = READ_DPI / COVERAGE_DPI;
177
+ const page = await oracle.render(pageIndex, READ_DPI / 72, options.canvasFactory);
178
+ // The region holds the missing lines and, above and below, the rows next to them: a row whose
179
+ // only OCR text is junk looks covered, so it is never reported missing itself.
180
+ const growX = emPx * k;
181
+ const growY = 2.5 * emPx * k;
182
+ const region = lines === null ? { x0: 0, y0: 0, x1: page.widthPx, y1: page.heightPx } : {
183
+ x0: Math.max(0, Math.floor(Math.min(...lines.map((l) => l.x0)) * k - growX)),
184
+ y0: Math.max(0, Math.floor(Math.min(...lines.map((l) => l.y0)) * k - growY)),
185
+ x1: Math.min(page.widthPx, Math.ceil(Math.max(...lines.map((l) => l.x1)) * k + growX)),
186
+ y1: Math.min(page.heightPx, Math.ceil(Math.max(...lines.map((l) => l.y1)) * k + growY)),
187
+ };
188
+ const read = await options.recognizeText(page.canvas.canvas, region, READ_DPI);
189
+ if (lines === null) {
190
+ // Confidence decides whether the picture holds text; once it does, every word read is kept:
191
+ // Tesseract is least sure of elided words ("d'avril", "d'actions"), and a word left out can be
192
+ // neither found nor covered.
193
+ const sure = read.reduce((n, l) => n + l.words.filter((w) => (w.confidence ?? 100) >= MIN_WHOLE_PAGE_CONFIDENCE && letters(w.text).length >= 2).length, 0);
194
+ if (sure < MIN_WHOLE_PAGE_WORDS)
195
+ return null;
196
+ }
197
+ const { boxes, chars } = ocrGlyphBoxes(runs, pageIndex, page.transform, allText);
198
+ // A word the layer already has (its glyphs lie under the word and spell about the same) is not
199
+ // added twice; where the layer holds junk over the word ("[miesiąc | opowiezany ema"), the word
200
+ // DocCloak read is added.
201
+ const alreadyThere = (b, text) => {
202
+ const area = Math.max(1, (b.x1 - b.x0) * (b.y1 - b.y0));
203
+ let under = 0;
204
+ const spelled = [];
205
+ boxes.forEach((o, i) => {
206
+ const w = Math.min(b.x1, o.x1) - Math.max(b.x0, o.x0);
207
+ const h = Math.min(b.y1, o.y1) - Math.max(b.y0, o.y0);
208
+ if (w > 0 && h > 0) {
209
+ under += w * h;
210
+ const cx = (o.x0 + o.x1) / 2;
211
+ if (cx >= b.x0 && cx <= b.x1)
212
+ spelled.push([o.x0, chars[i]]);
213
+ }
214
+ });
215
+ if (under / area <= 0.5)
216
+ return false;
217
+ const layer = spelled.sort((p, q) => p[0] - q[0]).map((x) => x[1]).join('');
218
+ return similarity(letters(text), letters(layer)) >= SAME_WORD;
219
+ };
220
+ // Pixels to user space: the inverse of the render transform [a b c d e f].
221
+ const [a, b, c, d, e, f] = page.transform;
222
+ const det = a * d - b * c;
223
+ const toUser = (px, py) => [(d * (px - e) - c * (py - f)) / det, (a * (py - f) - b * (px - e)) / det];
224
+ const unit = (v) => { const l = Math.hypot(v[0], v[1]) || 1; return [v[0] / l, v[1] / l]; };
225
+ const [ox, oy] = toUser(0, 0);
226
+ const [rx, ry] = toUser(1, 0);
227
+ const [ux, uy] = toUser(0, -1);
228
+ const dir = unit([rx - ox, ry - oy]);
229
+ const up = unit([ux - ox, uy - oy]);
230
+ const userPerPx = Math.hypot(rx - ox, ry - oy);
231
+ const out = [];
232
+ for (const line of read) {
233
+ const words = [];
234
+ const baselineAt = (px) => {
235
+ const { x0, y0, x1, y1 } = line.baseline;
236
+ return x1 === x0 ? y0 : y0 + ((px - x0) / (x1 - x0)) * (y1 - y0);
237
+ };
238
+ // The line's text size: its tallest letters (capitals and ascenders reach about 0.72 em) over the baseline.
239
+ const tops = line.words.map((w) => baselineAt(w.bbox.x0) - w.bbox.y0).filter((h) => h > 0).sort((p, q) => p - q);
240
+ const sizePx = tops.length > 0 ? tops[tops.length - 1] / 0.72 : 0;
241
+ if (!(sizePx > 0))
242
+ continue;
243
+ for (const w of line.words) {
244
+ const box = { x0: w.bbox.x0 + region.x0, y0: w.bbox.y0 + region.y0, x1: w.bbox.x1 + region.x0, y1: w.bbox.y1 + region.y0 };
245
+ if (alreadyThere(box, w.text))
246
+ continue;
247
+ const letters = (w.symbols && w.symbols.length > 0 ? w.symbols : [...w.text].map((ch, i, all) => ({
248
+ text: ch,
249
+ bbox: { x0: w.bbox.x0 + ((w.bbox.x1 - w.bbox.x0) * i) / all.length, y0: w.bbox.y0, x1: w.bbox.x0 + ((w.bbox.x1 - w.bbox.x0) * (i + 1)) / all.length, y1: w.bbox.y1 },
250
+ })));
251
+ const glyphs = letters.map((s, i) => {
252
+ const x0 = s.bbox.x0 + region.x0;
253
+ const nextX = i + 1 < letters.length ? letters[i + 1].bbox.x0 + region.x0 : s.bbox.x1 + region.x0;
254
+ const [x, y] = toUser(x0, baselineAt(s.bbox.x0) + region.y0);
255
+ return { unicode: s.text, x, y, advance: Math.max(0, nextX - x0) * userPerPx };
256
+ });
257
+ words.push({ glyphs, size: sizePx * userPerPx, dir, up });
258
+ }
259
+ if (words.length > 0)
260
+ out.push(words);
261
+ }
262
+ return { page: pageIndex, lines: out };
263
+ }
62
264
  /** True for names ending in .pdf (case-insensitive). */
63
265
  export function isPdfFile(filename) {
64
266
  return /\.pdf$/i.test(filename.trim());
@@ -25,3 +25,9 @@ export interface StandardMetrics {
25
25
  capHeight: number;
26
26
  }
27
27
  export declare function loadStandardMetrics(name: StandardFontName): StandardMetrics;
28
+ /**
29
+ * Helvetica advance of one character in 1/1000 em: a stand-in for the proportions of a sans-serif
30
+ * print (Arial and Liberation Sans share them). Letters WinAnsi cannot encode take their base
31
+ * letter's width ("ł" as "l"), anything else the width of a digit.
32
+ */
33
+ export declare function helveticaWidth(ch: string): number;
@@ -5,7 +5,7 @@
5
5
  * common aliases: Arial, Times New Roman, Courier New) get their advance
6
6
  * widths from the AFM metrics vendored by @cantoo/pdf-lib.
7
7
  */
8
- import { Font as AfmFont } from '@cantoo/pdf-lib/standard-fonts';
8
+ import { Encodings, Font as AfmFont } from '@cantoo/pdf-lib/standard-fonts';
9
9
  const CACHE = new Map();
10
10
  function normalizeBaseFont(baseFont) {
11
11
  return baseFont.replace(/^[A-Z]{6}\+/, '').replace(/[\s_]/g, '').toLowerCase();
@@ -70,3 +70,26 @@ export function loadStandardMetrics(name) {
70
70
  capHeight: font.CapHeight ?? 718,
71
71
  };
72
72
  }
73
+ /**
74
+ * Helvetica advance of one character in 1/1000 em: a stand-in for the proportions of a sans-serif
75
+ * print (Arial and Liberation Sans share them). Letters WinAnsi cannot encode take their base
76
+ * letter's width ("ł" as "l"), anything else the width of a digit.
77
+ */
78
+ export function helveticaWidth(ch) {
79
+ const metrics = loadStandardMetrics('Helvetica');
80
+ for (const candidate of [ch, ch.normalize('NFD').charAt(0)]) {
81
+ const cp = candidate.codePointAt(0);
82
+ if (cp === undefined)
83
+ continue;
84
+ try {
85
+ const glyph = Encodings.WinAnsi.encodeUnicodeCodePoint(cp);
86
+ const w = metrics.widthOfGlyph(glyph.name);
87
+ if (w !== undefined)
88
+ return w;
89
+ }
90
+ catch {
91
+ // not in WinAnsi: try the base letter
92
+ }
93
+ }
94
+ return 556;
95
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
5
5
  * 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
6
- * fill colour (to spot white-on-white text), and the q/Q stack. The same
6
+ * fill colour (to spot white-on-white text), constant alpha from ExtGState
7
+ * (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
7
8
  * class is used by the extractor (to compute glyph origins) and by the
8
9
  * surgery (to know the state at any operator).
9
10
  */
@@ -36,6 +37,15 @@ export interface GraphicsState {
36
37
  fillLuminance: number | null;
37
38
  /** Stroke luminance, for render modes that only stroke. */
38
39
  strokeLuminance: number | null;
40
+ /** Constant alpha (ExtGState /ca) for filling, 0..1. */
41
+ fillAlpha: number;
42
+ /** Constant alpha (ExtGState /CA) for stroking, 0..1. */
43
+ strokeAlpha: number;
44
+ }
45
+ /** The alpha entries of a named ExtGState (absent when the dictionary does not set them). */
46
+ export interface ExtGStateAlpha {
47
+ fillAlpha?: number;
48
+ strokeAlpha?: number;
39
49
  }
40
50
  export declare function initialState(ctm?: Matrix): GraphicsState;
41
51
  /**
@@ -58,10 +68,11 @@ export declare class StateMachine {
58
68
  constructor(initial: GraphicsState);
59
69
  /**
60
70
  * Apply one operator's effect on the state. `resolveFont` maps a resource
61
- * name to a LoadedFont (undefined when unknown). Returns false when the
62
- * operator is a text-showing operator (caller handles it).
71
+ * name to a LoadedFont (undefined when unknown); `resolveGState` maps an
72
+ * ExtGState resource name to its alpha entries (without it `gs` is ignored).
73
+ * Text-showing operators are not handled here (caller handles them).
63
74
  */
64
- update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined): void;
75
+ update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined, resolveGState?: (name: string) => ExtGStateAlpha | undefined): void;
65
76
  nextLine(): void;
66
77
  /** Move the text matrix by an unscaled text-space advance (already includes Tfs, Tc, Tw, Th). */
67
78
  advanceText(tx: number): void;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
5
5
  * 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
6
- * fill colour (to spot white-on-white text), and the q/Q stack. The same
6
+ * fill colour (to spot white-on-white text), constant alpha from ExtGState
7
+ * (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
7
8
  * class is used by the extractor (to compute glyph origins) and by the
8
9
  * surgery (to know the state at any operator).
9
10
  */
@@ -50,7 +51,7 @@ export function matrixFromOperands(ops) {
50
51
  return [n[0], n[1], n[2], n[3], n[4], n[5]];
51
52
  }
52
53
  function cloneState(s) {
53
- return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance };
54
+ return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance, fillAlpha: s.fillAlpha, strokeAlpha: s.strokeAlpha };
54
55
  }
55
56
  export function initialState(ctm = IDENTITY) {
56
57
  return {
@@ -58,6 +59,8 @@ export function initialState(ctm = IDENTITY) {
58
59
  text: { font: null, fontName: '', fontSize: 0, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, renderMode: 0 },
59
60
  fillLuminance: 0,
60
61
  strokeLuminance: 0,
62
+ fillAlpha: 1,
63
+ strokeAlpha: 1,
61
64
  };
62
65
  }
63
66
  function num(o, fallback = 0) {
@@ -103,10 +106,11 @@ export class StateMachine {
103
106
  }
104
107
  /**
105
108
  * Apply one operator's effect on the state. `resolveFont` maps a resource
106
- * name to a LoadedFont (undefined when unknown). Returns false when the
107
- * operator is a text-showing operator (caller handles it).
109
+ * name to a LoadedFont (undefined when unknown); `resolveGState` maps an
110
+ * ExtGState resource name to its alpha entries (without it `gs` is ignored).
111
+ * Text-showing operators are not handled here (caller handles them).
108
112
  */
109
- update(op, resolveFont) {
113
+ update(op, resolveFont, resolveGState) {
110
114
  const s = this.state;
111
115
  const o = op.operands;
112
116
  switch (op.op) {
@@ -234,6 +238,15 @@ export class StateMachine {
234
238
  s.strokeLuminance = this.strokeSpace && nums.length === o.length ? luminanceOf(nums, this.strokeSpace) : null;
235
239
  break;
236
240
  }
241
+ case 'gs': {
242
+ const nameOp = o[0];
243
+ const alpha = nameOp && nameOp.kind === 'name' ? resolveGState?.(nameOp.value) : undefined;
244
+ if (alpha?.fillAlpha !== undefined)
245
+ s.fillAlpha = clamp01(alpha.fillAlpha);
246
+ if (alpha?.strokeAlpha !== undefined)
247
+ s.strokeAlpha = clamp01(alpha.strokeAlpha);
248
+ break;
249
+ }
237
250
  default:
238
251
  break;
239
252
  }
@@ -8,6 +8,7 @@
8
8
  * cannot be edited safely are rasterized and reported, never shipped with a
9
9
  * box drawn over live text.
10
10
  */
11
+ import type { CanvasFactory } from './oracle.js';
11
12
  import type { UnredactablePart } from '../dom/errors.js';
12
13
  /**
13
14
  * Runtime assets the host serves. Everything is optional: in Node tests the
@@ -81,8 +82,13 @@ export interface GlyphRun {
81
82
  glyphs: Glyph[];
82
83
  /** Text render mode at the time of showing (3 and 7 paint nothing: invisible OCR layers). */
83
84
  renderMode: number;
84
- /** True when the run is not visible (render mode 3/7, or fully outside the page). */
85
+ /** True when the run is not visible (render mode 3/7, white, fully transparent, tiny, or painted under an opaque image). */
85
86
  hidden: boolean;
87
+ /**
88
+ * Hidden text over an image: an OCR layer, the reading of what a scanned picture shows. A
89
+ * redaction that touches it is painted into the picture (the page is exported as an image).
90
+ */
91
+ ocr?: boolean;
86
92
  /** Flat-text offset of each glyph's first character (parallel to glyphs). */
87
93
  textOffsets: number[];
88
94
  /** User-space vector of one em (1000 glyph units) along the baseline, at this run's transform. */
@@ -134,6 +140,30 @@ export interface PdfPageInfo {
134
140
  hasImages: boolean;
135
141
  /** True when the page shows images but no glyph at all (scanned page). */
136
142
  imageOnly: boolean;
143
+ /**
144
+ * True when hidden text lies over an image on the page (a scan with an OCR text layer).
145
+ * Redacted values in it are painted over in the picture, and the page is exported as an image.
146
+ */
147
+ ocrText: boolean;
148
+ /**
149
+ * Lines of text the page shows that its text layer does not have: on a scan, text its OCR layer
150
+ * missed (a table, a stamp); on a text page, text inside an image. Without the host's OCR they
151
+ * cannot be found or redacted. Absent when the page was not checked (no canvas in the host) or
152
+ * nothing is missing.
153
+ */
154
+ ocrMissingLines?: number;
155
+ /**
156
+ * Words DocCloak read itself (the host's `recognizeText`) where the page's text layer had none
157
+ * (`ocrMissingLines`). They are detected like the layer's own words, and their values are painted
158
+ * over in the picture (a text page with such a value is exported as an image).
159
+ */
160
+ ocrReadWords?: number;
161
+ /**
162
+ * On a page without any text layer (a picture only): the words DocCloak read on the whole page
163
+ * with the host's OCR. Above 0 the page is handled as a scan with an OCR layer (`ocrText`); 0: read,
164
+ * no text found (the page stays as it is). Absent when the page was not read.
165
+ */
166
+ ocrWholePage?: number;
137
167
  /** Flat-text range of the page. */
138
168
  textStart: number;
139
169
  textEnd: number;
@@ -156,6 +186,85 @@ export interface PdfReadOptions {
156
186
  maxGlyphs?: number;
157
187
  /** User password for encrypted files. Without it an encrypted file is refused. */
158
188
  password?: string;
189
+ /**
190
+ * Look for text a page shows that its text layer does not have: on a scan with an OCR layer, text
191
+ * the OCR missed; on a text page with images, text inside the images (renders those pages at
192
+ * 100 dpi). Default true.
193
+ */
194
+ checkOcrCoverage?: boolean;
195
+ /**
196
+ * Read the pages without any text layer (pictures only) with `recognizeText`, so their text joins
197
+ * the document and their values are painted over like a scan's. Default true.
198
+ */
199
+ ocrImagePages?: boolean;
200
+ /** Progress of the host OCR: pages read so far of the pages to read (called before the first and after each). */
201
+ onOcrProgress?: (done: number, total: number) => void;
202
+ /** Node hosts and tests: canvas factory for that check. Browsers use OffscreenCanvas. */
203
+ canvasFactory?: CanvasFactory;
204
+ /**
205
+ * OCR for text a scan page's OCR layer does not have: read `region` (pixels) of `canvas` (the page
206
+ * rendered at `dpi`, 300) and return its lines, relative to the region. Without it such text is only
207
+ * reported (`ocrMissingLines`).
208
+ */
209
+ recognizeText?: RecognizeRegion;
210
+ }
211
+ /** A line a host OCR read: baseline and words with their letter boxes, in the pixels of the canvas given. */
212
+ export interface RecognizedLine {
213
+ baseline: {
214
+ x0: number;
215
+ y0: number;
216
+ x1: number;
217
+ y1: number;
218
+ };
219
+ words: Array<{
220
+ text: string;
221
+ bbox: {
222
+ x0: number;
223
+ y0: number;
224
+ x1: number;
225
+ y1: number;
226
+ };
227
+ /** The OCR's confidence in the word, 0..100, when it gives one. */
228
+ confidence?: number;
229
+ symbols?: Array<{
230
+ text: string;
231
+ bbox: {
232
+ x0: number;
233
+ y0: number;
234
+ x1: number;
235
+ y1: number;
236
+ };
237
+ }>;
238
+ }>;
239
+ }
240
+ export type RecognizeRegion = (canvas: unknown, region: {
241
+ x0: number;
242
+ y0: number;
243
+ x1: number;
244
+ y1: number;
245
+ }, dpi: number) => Promise<RecognizedLine[]>;
246
+ /** One glyph DocCloak read with its own OCR, in user space (origin on the baseline). */
247
+ export interface ExtraOcrGlyph {
248
+ unicode: string;
249
+ x: number;
250
+ y: number;
251
+ advance: number;
252
+ }
253
+ /** A word DocCloak read with its own OCR: its glyphs, the text size and the baseline / up directions (unit vectors). */
254
+ export interface ExtraOcrWord {
255
+ glyphs: ExtraOcrGlyph[];
256
+ size: number;
257
+ dir: [number, number];
258
+ up: [number, number];
259
+ }
260
+ /**
261
+ * Text DocCloak read on a page where the text layer had none, by line. The writer re-creates the
262
+ * same runs from it. `whole`: the page had no text layer at all and was read entirely.
263
+ */
264
+ export interface ExtraOcrPage {
265
+ page: number;
266
+ lines: ExtraOcrWord[][];
267
+ whole?: boolean;
159
268
  }
160
269
  export interface PlaceholderFit {
161
270
  /** Lowest font-size ratio (placeholder size / original size) before condensing kicks in. Default 0.65. */
@@ -171,9 +280,9 @@ export interface PdfWriteOptions {
171
280
  allowUnredactable?: boolean;
172
281
  /** Placeholder fitting policy. */
173
282
  fit?: PlaceholderFit;
174
- /** Rasterize pages whose text cannot be edited safely instead of refusing the file. Default true. */
283
+ /** Rasterize pages whose text cannot be edited safely instead of refusing the file. Default true. Scan pages are always rendered. */
175
284
  rasterizeUnsafePages?: boolean;
176
- /** DPI for rasterized pages. Default 150. */
285
+ /** DPI for rasterized pages. Default 150; scan pages use the scan's own resolution (at least this, at most 300). */
177
286
  rasterDpi?: number;
178
287
  /** Skip the post-write verification (tests only; never in hosts). */
179
288
  skipVerify?: boolean;
@@ -184,8 +293,13 @@ export interface PdfWriteResult {
184
293
  warnings: string[];
185
294
  /** Parts dropped from the output (annotations, forms, ...). */
186
295
  removed: RemovedPart[];
187
- /** 0-based indexes of pages that were rasterized. */
296
+ /** 0-based indexes of pages that were rasterized because their text could not be edited safely (black boxes). */
188
297
  rasterizedPages: number[];
298
+ /**
299
+ * 0-based indexes of scan pages with an OCR text layer whose values were painted over in the
300
+ * picture (placeholder labels at the values' places); each is exported as an image.
301
+ */
302
+ scanPages: number[];
189
303
  /** Placeholders whose fit needed a line shift (informational). */
190
304
  shiftedLines: number;
191
305
  }
@@ -107,6 +107,13 @@ export async function verifyPdf(input) {
107
107
  if (obj instanceof PDFRawStream) {
108
108
  if (isImageEncoded(ctx, obj))
109
109
  continue;
110
+ // Image samples (a Flate or uncompressed picture, a scan page we painted) are pixels, not
111
+ // text: a short value turns up in them by chance ("36" in a gray ramp 51, 54). Only the
112
+ // image dictionary's strings are searched.
113
+ if (namesOf(ctx, obj.dict.get(PDFName.of('Subtype')))[0] === 'Image') {
114
+ scanStrings(obj.dict, ref.objectNumber, encodedNeedles, findings, new Set());
115
+ continue;
116
+ }
110
117
  let decoded;
111
118
  try {
112
119
  decoded = decodeStream(ctx, obj);
@@ -130,13 +137,16 @@ export async function verifyPdf(input) {
130
137
  }
131
138
  const type = namesOf(ctx, obj.dict.get(PDFName.of('Type')))[0];
132
139
  const subtype = namesOf(ctx, obj.dict.get(PDFName.of('Subtype')))[0];
133
- const isContent = type === undefined || type === 'Pattern' || (type === 'XObject' && subtype === 'Form');
140
+ const cmap = looksLikeCMap(decoded);
141
+ const isContent = !cmap && (type === undefined || type === 'Pattern' || (type === 'XObject' && subtype === 'Form'));
134
142
  if (isContent)
135
143
  contentStreams.push(decoded);
136
144
  // A content stream shows text only through its string operands: numbers, names and
137
- // operators ("/F1 12 Tf", coordinates) are not text a viewer displays. Other streams
138
- // (XMP, CMaps, anything unknown) are scanned whole.
139
- const haystack = haystackOf(isContent ? stringOperandsOf(decoded) : decoded);
145
+ // operators ("/F1 12 Tf", coordinates) are not text a viewer displays. A CMap's hex codes
146
+ // are character codes and code points ("<004A> <2019>" maps a code to the apostrophe), not
147
+ // text: a four-digit value turns up in them by chance; the rest of the CMap is searched.
148
+ // Other streams (XMP, anything unknown) are scanned whole.
149
+ const haystack = haystackOf(isContent ? stringOperandsOf(decoded) : cmap ? withoutHexStrings(decoded) : decoded);
140
150
  if (isContent)
141
151
  contentStrings.push(haystack);
142
152
  for (const n of encodedNeedles) {
@@ -175,7 +185,7 @@ export async function verifyPdf(input) {
175
185
  }
176
186
  }
177
187
  // No whole-file byte pass: every object pdf-lib writes is either a stream (scanned decoded above,
178
- // font programs and image codecs excepted) or a dict/array/string (scanned above). A raw scan would
188
+ // font programs, image codecs and image samples excepted) or a dict/array/string (scanned above). A raw scan would
179
189
  // only add false positives from font names ("Roman", "Georgia") inside embedded font programs.
180
190
  return dedupe(findings);
181
191
  }
@@ -444,3 +454,26 @@ function hex(bytes) {
444
454
  s += b.toString(16).padStart(2, '0');
445
455
  return s;
446
456
  }
457
+ /** A CMap (a ToUnicode map, an embedded CID CMap): its program begins the CMap resource. */
458
+ function looksLikeCMap(decoded) {
459
+ const head = new TextDecoder('latin1').decode(decoded.subarray(0, Math.min(decoded.length, 600)));
460
+ return /\bbegincmap\b/.test(head) || /\/CIDInit\b/.test(head);
461
+ }
462
+ /** The bytes of a CMap without its hex strings ("<004A>"), each replaced by one space. */
463
+ function withoutHexStrings(decoded) {
464
+ const out = [];
465
+ for (let i = 0; i < decoded.length; i++) {
466
+ if (decoded[i] === 0x3c /* < */ && decoded[i + 1] !== 0x3c) {
467
+ let j = i + 1;
468
+ while (j < decoded.length && decoded[j] !== 0x3e /* > */ && /[0-9A-Fa-f\s]/.test(String.fromCharCode(decoded[j])))
469
+ j++;
470
+ if (j < decoded.length && decoded[j] === 0x3e) {
471
+ out.push(0x20);
472
+ i = j;
473
+ continue;
474
+ }
475
+ }
476
+ out.push(decoded[i]);
477
+ }
478
+ return new Uint8Array(out);
479
+ }