@doccloak/core 0.14.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
+ }
@@ -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
@@ -144,6 +145,25 @@ export interface PdfPageInfo {
144
145
  * Redacted values in it are painted over in the picture, and the page is exported as an image.
145
146
  */
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;
147
167
  /** Flat-text range of the page. */
148
168
  textStart: number;
149
169
  textEnd: number;
@@ -166,6 +186,85 @@ export interface PdfReadOptions {
166
186
  maxGlyphs?: number;
167
187
  /** User password for encrypted files. Without it an encrypted file is refused. */
168
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;
169
268
  }
170
269
  export interface PlaceholderFit {
171
270
  /** Lowest font-size ratio (placeholder size / original size) before condensing kicks in. Default 0.65. */
@@ -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
+ }
@@ -51,6 +51,13 @@ export declare function writeAnonymizedPdfWithReport(extraction: PdfExtraction,
51
51
  * Whitespace glyphs at either end of a piece are left out.
52
52
  */
53
53
  export declare function paintMarksForPage(segments: Segment[], page: number, replacements: Replacement[]): PaintMark[];
54
+ /**
55
+ * The text column each mark's line sits in, along its baseline (`columnStart`, `columnEnd`, user
56
+ * units from the mark's start): where the page's words start and end, without the outlying
57
+ * COLUMN_START_OUTLIERS / COLUMN_END_OUTLIERS, over the words running the same way. A label growing into blank
58
+ * paper beside a short value stays inside it: never into the margin.
59
+ */
60
+ export declare function withTextColumn(marks: PaintMark[], runs: GlyphRun[], page: number): PaintMark[];
54
61
  /**
55
62
  * Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
56
63
  * whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as