@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/CHANGELOG.md CHANGED
@@ -4,6 +4,183 @@ All notable changes to `@doccloak/core` are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.15.0] - 2026-10-23
8
+
9
+ 0.14.1 was tagged but never published to npm, so this is the first release
10
+ on npm after 0.14.0 and it carries the 0.14.1 changes too (listed under
11
+ 0.14.1 below). New `readPdf` options (`ocrImagePages`, `onOcrProgress`),
12
+ hence the minor version; nothing a 0.14.0 host uses was removed.
13
+
14
+ Each PDF page is handled by what it holds (T239). The founder's mixed scan
15
+ had a picture page without any text layer between two OCR scans: its
16
+ personal data came out untouched.
17
+
18
+ - **Pages without a text layer are read with the host's OCR.** With a
19
+ `recognizeText`, `readPdf` reads a picture page that has no text at all on
20
+ the whole page (Tesseract's automatic segmentation only). A page with fewer
21
+ than three words read with 30 % confidence or more (a photo, a stamp,
22
+ noise) stays as it is, still listed as a scanned page. Otherwise every word
23
+ read is kept: Tesseract is least sure of elided words ("d'avril 2009",
24
+ "rachat d'actions"), and a word left out could be neither found nor
25
+ covered (seen on the issue file). A page read this way becomes a scan
26
+ with an OCR layer (`ocrText`, `PdfPageInfo.ocrWholePage` = the words
27
+ read): its values are detected, painted over in the picture and the page
28
+ keeps an invisible searchable layer. `ocrImagePages: false` turns this
29
+ off; without a host OCR nothing changes.
30
+ - **Text inside images on text pages.** The coverage check now also runs on
31
+ text pages with images, with every glyph of the page's own layer as
32
+ covered, so only text the images show is found and read. A text page with
33
+ a value inside an image is painted (rendered at 300 dpi) like a scan; a
34
+ text page whose images hold no value stays vector text, edited in place.
35
+ Its note says "an image on the page shows text its text layer does not
36
+ have", and the generic "contains images; text inside images is not
37
+ scanned" is no longer given for the pages a host OCR checked.
38
+ - **Values glued to an elision whose apostrophe OCR dropped.**
39
+ `propagateEntities` also finds a capitalised value right after a
40
+ lowercase "d", "l", "qu" and the other French and Italian elisions that
41
+ start a word ("dALTAMIR GERANCE", "lAltamir"); "McAltamir", "daltamir" and
42
+ "mALTAMIRE" are still left alone.
43
+ - **A value inside an OCR word is covered from its first letter.** Where a
44
+ value starts or ends inside a word ("ALTAMIR" of "d'ALTAMIR"), the cover
45
+ ended in the valley between two letters nearest the OCR's estimate,
46
+ either way. Tesseract placed that "A" from its middle, the nearest valley
47
+ was the one after it, and the "A" stayed visible ("d'A[PERSON_13]"). The
48
+ valley is now looked for on the side away from the value only (up to
49
+ 0.8 em out, 0.05 em in): at worst the cover takes one letter of the rest
50
+ of the word, never leaves one of the value.
51
+ - **OCR progress.** `onOcrProgress(done, total)` reports the pages read so
52
+ far; the web app shows "page X of Y" while a scanned file loads.
53
+ - **One Tesseract worker per file.** `recognizeCanvasLines` keeps its worker
54
+ between pages (`releaseOcrWorker()` ends it) and takes `wholePage`; word
55
+ confidences are passed on in `RecognizedLine`.
56
+ - **The verifier no longer reads character codes in a font's ToUnicode map
57
+ as text.** The fallback font's map holds `<2019>` (the code of a closing
58
+ quote), so a detected year "2019" refused the export of the issue file. In
59
+ a CMap stream the hex strings are skipped; any other text in it is still
60
+ searched.
61
+ - Tests: each page kind (a picture read whole and painted, its values
62
+ covered when the OCR is unsure of their words, a picture with only noise,
63
+ a mixed file, a text page with an image with and without a value, no host
64
+ OCR); a value glued to an elision; a value whose first letter the OCR
65
+ places half a letter late; a value in a CMap's hex codes and in its
66
+ comment.
67
+
68
+ ## [0.14.1] - 2026-10-22 (not published; shipped in 0.15.0)
69
+
70
+ Two fixes found by the acceptance run of web 0.15.0 on the preview (T237).
71
+
72
+ - **Long values on scans whose OCR letters are narrower than the print.** A
73
+ cover grew at most one em past the OCR layer's end. A layer 15 % narrower
74
+ than the print ends about 2 em short of a 13 em e-mail address, so its
75
+ last letters (".com") stayed visible. The cover may now grow by one em or
76
+ 35 % of the value's length, whichever is more, still stopping at the first
77
+ word space. Measured on synthetic scans with two long values: clean down to
78
+ OCR letters at 75 % of the print's width (at 70 % a fragment remains).
79
+ - **The verifier no longer searches picture pixels for values.** It scanned
80
+ the decoded bytes of every stream not compressed with an image codec,
81
+ pixels of Flate pictures included, so a short value (the street number
82
+ "36") could match two neighbouring gray levels (51, 54) by chance and
83
+ refuse the export. Painted scan pages are such pictures, and so are Flate
84
+ logos and scans in any PDF. Image samples are skipped; the image
85
+ dictionary's strings are still searched.
86
+ - Tests: a long e-mail on the narrow-OCR scan; the verifier ignores a value's
87
+ bytes in an image's samples and finds them in the image dictionary and in
88
+ another stream.
89
+
90
+ Found by the next preview runs on real Tesseract scans (T238):
91
+
92
+ - **Values ending at punctuation glued to them in an OCR word.** Tesseract
93
+ gives every glyph of its invisible font the same width, so a value that
94
+ stops before glued punctuation ("+48 527 870 616," "12/4,") ended at a
95
+ guessed point inside its last letter, and the cover's growth stopped at the
96
+ 0.23 em gap after a narrow "1": half of the last digit stayed visible.
97
+ Glued punctuation is now covered with the value, so the cover ends where
98
+ the OCR word does, and painted again around the placeholder
99
+ ("[PHONE_1]," "([PHONE_1])."), so the picture reads like the redacted
100
+ text; on the line a value wraps onto, at the end of that line's cover,
101
+ where it was printed.
102
+ - **Diacritics standing apart above a value.** The cover's height follows
103
+ the letters' ink up to the first blank row, so the dot of "Ż" in
104
+ "Krzysztof Żółć" (and an accent over a word without ascenders) stayed in
105
+ the picture above the label. Small marks just above the letters that do
106
+ not run on into the line above get a cover of their own.
107
+ - **Short values' labels use the blank paper next to them.** A placeholder
108
+ never took more room than its value, so the label for "ul." or "Kraków,"
109
+ was squeezed and shrunk until it was hard to read. It now first takes the
110
+ blank paper after the value, then before it (a right-aligned line), up to
111
+ a word space before the next ink, half the gap to the next value on the
112
+ line and never outside the text column (where the page's OCR words start
113
+ and end, a margin note or a stamp left out), so a label never grows into
114
+ the margin. Values with no room on either side keep the squeezed label;
115
+ the text layer holds every placeholder in full.
116
+ - **Table rules the OCR read as "|" no longer hide a cell's word from
117
+ detection.** "|Kraków" (a vertical rule glued to the city in a scanned
118
+ table) was missed by the model while the same city without the rule was
119
+ found. On OCR text those characters read as spaces in the flat text, one
120
+ for one, so every offset still maps to its glyph (the layer keeps them).
121
+ - **Polish street addresses take in the flat number** (`regex:pl:street`):
122
+ "ul. Słoneczna 7 m. 12" and "al. Jerozolimskie 44 lok. 3B" match whole. On
123
+ a scan of a letter the model found "ul. Słoneczna 7" without "m. 12",
124
+ which stayed in the picture; the text PDF of the same letter got all of it.
125
+ - **Tesseract stretches each word with its trailing space.** Its shows hold
126
+ the word and a space, stretched together over the word's box, so a word's
127
+ last letter reaches past its own advance (the "7" of "31-147" stuck out).
128
+ When only whitespace follows a value in an OCR show, the end of the show
129
+ bounds where the value's ink may be (`PaintMark.reach`): ink up to there is
130
+ covered across gaps; in layers that are not stretched that space is blank
131
+ and nothing more is covered.
132
+ - **Text a scan's OCR layer does not have is reported.** A table or a stamp
133
+ the OCR could not read is not in the layer, so it cannot be found and stays
134
+ in the picture. `readPdf` now renders each page with OCR text at 100 dpi
135
+ and looks for rows of letter-sized ink that no OCR glyph covers
136
+ (`src/pdf/coverage.ts`); it sets `PdfPageInfo.ocrMissingLines` and adds a
137
+ warning. Rules, frames, signatures and pictures are larger than letters and
138
+ never count, nor do single marks or rows shorter than 2 em. Calibrated on
139
+ the scans of the acceptance run: the two tables Tesseract could not read
140
+ are reported, the court judgment (signature, stamp) and complete scans are
141
+ not. Options: `checkOcrCoverage` (default true), `canvasFactory` for Node
142
+ hosts; without a canvas the check is skipped.
143
+ - **Hosts can read that text with their own OCR.** `readPdf` takes an
144
+ optional `recognizeText(canvas, region)`: the page rendered at 300 dpi and
145
+ the region around the missing lines. The words it returns (lines with
146
+ baselines and letter boxes) that do not lie under the layer's own glyphs
147
+ join the page as hidden OCR runs after its text (`PdfPageInfo.ocrReadWords`,
148
+ `PdfExtraction.extraOcr`); the writer re-creates the same runs, so their
149
+ values are detected and painted like the layer's. `@doccloak/core/dom`
150
+ adds `recognizeCanvasLines`, which tries Tesseract's automatic,
151
+ single-column and single-block segmentation on the crop and keeps the
152
+ reading with the most letters (the same crop reads in one mode and gives
153
+ nothing in another). The tables Tesseract garbled on full pages read
154
+ cleanly this way. The region reaches 2.5 em above and below the missing
155
+ lines: a row whose only OCR text is junk ("[miesiąc | opowiezany ema Je |")
156
+ looks covered and is never reported missing itself. A word read there is
157
+ dropped only when the layer's glyphs under it spell about the same word
158
+ (60 % of the letters in order); under junk it is added.
159
+ - **Values inside a longer OCR word.** In a Tesseract word ("2026/03/017")
160
+ the letters of a value that is only part of it are placed by Helvetica
161
+ proportions over the printed word, and the cover ends in the blank gap
162
+ between two letters nearest that point, with a pixel of margin: "2026" of
163
+ "2026/03/017" leaves "/03/017" whole (before, the cover grew through "/0").
164
+ - **Painted scan pages stay searchable.** Their text, every redacted value
165
+ replaced by its placeholder, is written as an invisible layer (render mode
166
+ 3, Liberation Sans) over the picture: each run on its own baseline,
167
+ stretched to its original width, so search and selection work as on the
168
+ input and sit over the printed words, on rotated pages too (the layer is
169
+ mapped through the same transform that placed the page in the picture).
170
+ A placeholder spans its whole value even when the OCR wrote the value as
171
+ several shows (Tesseract: one per word), and the punctuation after it
172
+ follows on the same line ("Telefon: [PHONE_1], e-mail").
173
+ The verifier reads the new layer like any text.
174
+ - The stretched-space reach applies only to shows whose glyphs all have the
175
+ same advance (Tesseract's invisible font): in a layer set in a real font
176
+ the trailing space is the gap before the next word, and the cover took in
177
+ the next word's first letter.
178
+ - Tests: glued punctuation and the Tesseract word reach (unit, fail without
179
+ the fixes), a Tesseract-style synthetic layer (one show per word with its
180
+ trailing space, equal letter widths, values red with black punctuation),
181
+ the missing-text finder (unit) and `readPdf` on a scan with a line the
182
+ layer lacks.
183
+
7
184
  ## [0.14.0] - 2026-10-22
8
185
 
9
186
  Scanned PDF pages with an OCR text layer are redacted where a reader sees
package/README.md CHANGED
@@ -255,15 +255,39 @@ least `rasterDpi`, at most 300 dpi) and paints the value over in the
255
255
  picture: a cover in the paper colour, grown along the line to where the
256
256
  value's ink really ends (OCR letters rarely have the scan's widths), and the
257
257
  placeholder label in its place, on the value's baseline, sized from the
258
- value's ink and fitted to the value's width (squeezed, then shrunk). The
259
- page is exported as a lossless image (gray for a gray scan) without a text
260
- layer and listed in `scanPages`. Pages of the same file without any text are
261
- not touched.
258
+ value's ink and fitted to the value's width, taking blank paper beside a
259
+ short value first (then squeezed, then shrunk). The
260
+ page is exported as a lossless image (gray for a gray scan) with an invisible
261
+ text layer holding its text, the placeholders in place of the values, so it
262
+ stays searchable, and listed in `scanPages`. Pages of the same file without any text are
263
+ not touched. Text the OCR layer does not have (a table or a stamp the OCR
264
+ could not read) cannot be found, so it stays in the picture: `readPdf`
265
+ renders each scan page with OCR text at 100 dpi, looks for rows of
266
+ letter-sized ink no OCR glyph covers and reports them in
267
+ `PdfPageInfo.ocrMissingLines` and `warnings` (`checkOcrCoverage: false` turns
268
+ the check off; Node hosts pass a `canvasFactory`, without one it is skipped).
269
+ A host with OCR passes `recognizeText(canvas, region)` (the web app hands in
270
+ Tesseract through `recognizeCanvasLines` from `@doccloak/core/dom`): the
271
+ words it reads there join the page as OCR text (`ocrReadWords`), and their
272
+ values are found and painted over like the rest.
273
+
274
+ **Each page by what it holds.** With a host OCR, a page that is only a
275
+ picture (no text layer at all) is read whole with Tesseract's automatic
276
+ segmentation. With fewer than three words read at 30 % confidence or more
277
+ the page stays as it is; otherwise every word read is kept, the unsure ones
278
+ too (`PdfPageInfo.ocrWholePage` gives the count).
279
+ A page read this way is handled as a scan with an OCR layer, so a file can
280
+ mix vector text pages, OCR scans and pictures, each redacted its own way. Text
281
+ pages with images get the same coverage check, with the page's own glyphs
282
+ counted as covered: text that only an image shows is read, and a text page
283
+ with a value in an image is exported painted, like a scan; without such a
284
+ value it stays vector text. `ocrImagePages: false` turns the whole-page
285
+ reading off, and `onOcrProgress(done, total)` reports the pages read.
262
286
 
263
287
  **Refusals.** Encrypted files (`'encrypted'`, pass `password`), files over
264
288
  50 MB or 500 pages (`'too-large'`), non-PDF bytes (`'invalid-package'`).
265
- Scanned pages without a text layer (no OCR) and text no decoder can read are
266
- reported in `unredactable` (`'scanned-page'`, `'undecodable-text'`) and gate
289
+ Scanned pages without a text layer (no OCR, and none the host's OCR could
290
+ read) and text no decoder can read are reported in `unredactable` (`'scanned-page'`, `'undecodable-text'`) and gate
267
291
  the export behind `allowUnredactable`, like Office packages; with it they
268
292
  are exported as they are. A file whose pages hold no text at all reads as
269
293
  `empty`.
@@ -318,7 +342,8 @@ Independently of what was detected, every exported .docx / .xlsx has:
318
342
  encoding names); a placeholder is never drawn with a Type3 font unless the
319
343
  font itself showed every character of it. Digital signatures are
320
344
  invalidated by any change.
321
- - Images are copied unchanged; text inside pictures is not OCR-scanned.
345
+ - Images are copied unchanged. Without a host OCR, text inside pictures is
346
+ not OCR-scanned; with one, see "Each page by what it holds" above.
322
347
  - Forms, annotations, bookmarks and attachments are dropped, not redacted.
323
348
  - Right-to-left text: pdf.js reports it in visual order, so the cross-check
324
349
  compares the characters of such pages as a set; detection runs on our
@@ -407,7 +432,7 @@ interface CoreEnv {
407
432
  fetch: typeof fetch; // injectable for tests/proxies
408
433
  wasm: { paths: string; numThreads?: number }; // onnxruntime-web asset location
409
434
  buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
410
- /** @deprecated since 0.12.0, removal planned for 0.14.0; used only when buildTokenizer is absent */
435
+ /** @deprecated since 0.12.0, removal planned for a later release; used only when buildTokenizer is absent */
411
436
  loadTokenizer?(hfModelId: string): Promise<unknown>;
412
437
  hardware?: HardwareHints; // deviceMemoryGB / isMobile, feeds provider default
413
438
  persistStorage?: () => Promise<boolean>; // e.g. navigator.storage.persist()
@@ -444,7 +469,7 @@ are the three pinned `resolve/<commit>/...` files of the active provider.
444
469
  `loadTokenizer(hfModelId)` (0.11 and earlier) is still honoured in 0.13.0
445
470
  when `buildTokenizer` is absent, with a one-time deprecation warning. It
446
471
  lets the host library probe the mutable `resolve/main` branch and keep an
447
- unverified second cache, so migrate now: the removal is planned for 0.14.0,
472
+ unverified second cache, so migrate now: the removal is planned for a later release,
448
473
  where `buildTokenizer` becomes required.
449
474
 
450
475
  ### autoLoad and ModelNotLoadedError
package/dist/dom/ocr.d.ts CHANGED
@@ -61,6 +61,47 @@ export declare function loadImageToCanvas(file: Blob): Promise<OcrCanvas>;
61
61
  * TesseractAssetPaths). onProgress receives 0..1 during the recognition phase.
62
62
  */
63
63
  export declare function recognizeCanvas(canvas: OcrCanvas, uiLanguage: string, assets: TesseractAssetPaths, onProgress?: (progress: number) => void): Promise<OcrExtraction>;
64
+ /** One line Tesseract read: its baseline and words, each word with its letters' boxes (pixels) and its confidence (0..100). */
65
+ export interface OcrLine {
66
+ baseline: {
67
+ x0: number;
68
+ y0: number;
69
+ x1: number;
70
+ y1: number;
71
+ };
72
+ words: Array<{
73
+ text: string;
74
+ bbox: {
75
+ x0: number;
76
+ y0: number;
77
+ x1: number;
78
+ y1: number;
79
+ };
80
+ confidence?: number;
81
+ symbols: Array<{
82
+ text: string;
83
+ bbox: {
84
+ x0: number;
85
+ y0: number;
86
+ x1: number;
87
+ y1: number;
88
+ };
89
+ }>;
90
+ }>;
91
+ }
92
+ /** Terminate the worker recognizeCanvasLines keeps between calls (after the last page of a document). */
93
+ export declare function releaseOcrWorker(): Promise<void>;
94
+ /**
95
+ * OCR a canvas holding part of a page and return its lines with baselines, words and letter
96
+ * boxes. Used on the parts of a scanned PDF page whose OCR layer has no text: cropped to such a
97
+ * part, a table that full-page segmentation turned into junk reads cleanly. Tesseract's page
98
+ * segmentation is picky about a crop (the same table reads in one mode and gives nothing in
99
+ * another), so the automatic, single-column and single-block modes are tried in turn and the
100
+ * reading with the most letters wins.
101
+ */
102
+ export declare function recognizeCanvasLines(canvas: OcrCanvas, uiLanguage: string, assets: TesseractAssetPaths, dpi?: number, options?: {
103
+ wholePage?: boolean;
104
+ }): Promise<OcrLine[]>;
64
105
  interface BlockLike {
65
106
  paragraphs: {
66
107
  lines: {
package/dist/dom/ocr.js CHANGED
@@ -139,6 +139,84 @@ export async function recognizeCanvas(canvas, uiLanguage, assets, onProgress) {
139
139
  await worker.terminate();
140
140
  }
141
141
  }
142
+ /**
143
+ * One Tesseract worker for the pages of a document: starting one loads the engine and the language
144
+ * data, which costs about a second, so the pages of a PDF are read by the same worker. The host
145
+ * releases it when the document is read (releaseOcrWorker).
146
+ */
147
+ let shared = null;
148
+ function sharedWorker(langs, assets) {
149
+ const key = `${langs.join('+')}|${assets.workerPath}|${assets.corePath}|${assets.langPath}`;
150
+ if (shared && shared.key === key)
151
+ return shared.worker;
152
+ void releaseOcrWorker();
153
+ const worker = Tesseract.createWorker(langs, 1, { workerPath: assets.workerPath, corePath: assets.corePath, langPath: assets.langPath });
154
+ shared = { key, worker };
155
+ // A worker that failed to start is not kept: the next call tries again.
156
+ worker.catch(() => { if (shared?.worker === worker)
157
+ shared = null; });
158
+ return worker;
159
+ }
160
+ /** Terminate the worker recognizeCanvasLines keeps between calls (after the last page of a document). */
161
+ export async function releaseOcrWorker() {
162
+ const current = shared;
163
+ shared = null;
164
+ if (!current)
165
+ return;
166
+ try {
167
+ await (await current.worker).terminate();
168
+ }
169
+ catch {
170
+ /* never started, or already gone */
171
+ }
172
+ }
173
+ /**
174
+ * OCR a canvas holding part of a page and return its lines with baselines, words and letter
175
+ * boxes. Used on the parts of a scanned PDF page whose OCR layer has no text: cropped to such a
176
+ * part, a table that full-page segmentation turned into junk reads cleanly. Tesseract's page
177
+ * segmentation is picky about a crop (the same table reads in one mode and gives nothing in
178
+ * another), so the automatic, single-column and single-block modes are tried in turn and the
179
+ * reading with the most letters wins.
180
+ */
181
+ export async function recognizeCanvasLines(canvas, uiLanguage, assets, dpi, options = {}) {
182
+ const worker = await sharedWorker(buildLangList(uiLanguage), assets);
183
+ let best = [];
184
+ let bestLetters = 0;
185
+ // A whole page is what automatic segmentation is made for; the other modes are for crops.
186
+ const modes = options.wholePage ? [Tesseract.PSM.AUTO] : [Tesseract.PSM.AUTO, Tesseract.PSM.SINGLE_COLUMN, Tesseract.PSM.SINGLE_BLOCK];
187
+ for (const mode of modes) {
188
+ // The resolution is known (a rendered page): Tesseract does not have to guess it (and log the guess).
189
+ await worker.setParameters({ tessedit_pageseg_mode: mode, ...(dpi ? { user_defined_dpi: String(Math.round(dpi)) } : {}) });
190
+ const { data } = await worker.recognize(canvas, {}, { blocks: true });
191
+ const lines = linesOf(data.blocks ?? []);
192
+ const letters = lines.reduce((n, l) => n + l.words.reduce((m, w) => m + w.text.length, 0), 0);
193
+ if (letters > bestLetters) {
194
+ best = lines;
195
+ bestLetters = letters;
196
+ }
197
+ }
198
+ return best;
199
+ }
200
+ function linesOf(blocks) {
201
+ const lines = [];
202
+ for (const block of blocks) {
203
+ for (const paragraph of block.paragraphs ?? []) {
204
+ for (const line of paragraph.lines ?? []) {
205
+ const words = (line.words ?? [])
206
+ .filter((w) => (w.text ?? '').trim() !== '')
207
+ .map((w) => ({
208
+ text: (w.text ?? '').trim(),
209
+ bbox: w.bbox,
210
+ ...(typeof w.confidence === 'number' ? { confidence: w.confidence } : {}),
211
+ symbols: (w.symbols ?? []).filter((x) => (x.text ?? '').trim() !== '').map((x) => ({ text: x.text ?? '', bbox: x.bbox })),
212
+ }));
213
+ if (words.length > 0)
214
+ lines.push({ baseline: line.baseline, words });
215
+ }
216
+ }
217
+ }
218
+ return lines;
219
+ }
142
220
  /**
143
221
  * Reconstruct plain text from Tesseract's block hierarchy while recording
144
222
  * each word's character range. The reconstruction (not Tesseract's own
package/dist/env.d.ts CHANGED
@@ -46,7 +46,7 @@ export interface CoreEnv {
46
46
  */
47
47
  buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
48
48
  /**
49
- * @deprecated since 0.12.0, removal planned for 0.14.0. Used only when
49
+ * @deprecated since 0.12.0, removal planned for a later release. Used only when
50
50
  * buildTokenizer is absent. Loading by Hugging Face model id lets the
51
51
  * host library probe mutable `resolve/main` and keep an unverified
52
52
  * second cache (security report S1/S3); implement buildTokenizer.
@@ -0,0 +1,41 @@
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
+ import type { GlyphRun } from './types.js';
14
+ /** Pixel box, top-left origin, inclusive start and exclusive end. */
15
+ export interface PxBox {
16
+ x0: number;
17
+ y0: number;
18
+ x1: number;
19
+ y1: number;
20
+ }
21
+ /** A line of text-like ink no OCR glyph covers. */
22
+ export interface MissingLine extends PxBox {
23
+ /** Letter-sized shapes on the line. */
24
+ shapes: number;
25
+ }
26
+ /**
27
+ * Lines of text-like ink in `rgba` (width x height, RGBA) that none of
28
+ * `ocrBoxes` covers. `emPx` is the page's typical OCR text size in pixels.
29
+ */
30
+ export declare function findTextWithoutOcr(rgba: Uint8ClampedArray, width: number, height: number, ocrBoxes: PxBox[], emPx: number): MissingLine[];
31
+ /**
32
+ * Pixel boxes of every OCR glyph on `page` (runs marked `ocr`; with `allText`, every glyph of the
33
+ * page's text layer), from 0.3 em below the baseline to 0.9 em above it, through the render
34
+ * transform [a b c d e f]; and the median glyph size in pixels (the page's text size, the scale
35
+ * every rule above is measured in).
36
+ */
37
+ export declare function ocrGlyphBoxes(runs: GlyphRun[], page: number, transform: number[], allText?: boolean): {
38
+ boxes: PxBox[];
39
+ chars: string[];
40
+ emPx: number;
41
+ };
@@ -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. */
@@ -89,6 +89,12 @@ export interface ExtractOptions {
89
89
  * taken from the oracle (so detection still sees what a viewer shows).
90
90
  */
91
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[];
92
98
  }
93
99
  export declare const DEFAULT_MAX_BYTES: number;
94
100
  export declare const DEFAULT_MAX_PAGES = 500;