@doccloak/core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +230 -0
- package/README.md +49 -7
- package/dist/dom/ocr.d.ts +41 -0
- package/dist/dom/ocr.js +78 -0
- package/dist/dom/office.d.ts +2 -0
- package/dist/dom/office.js +1 -0
- package/dist/env.d.ts +2 -2
- package/dist/pdf/coverage.d.ts +41 -0
- package/dist/pdf/coverage.js +193 -0
- package/dist/pdf/extract.d.ts +9 -1
- package/dist/pdf/extract.js +193 -12
- package/dist/pdf/raster.d.ts +118 -0
- package/dist/pdf/raster.js +525 -0
- package/dist/pdf/read.d.ts +3 -1
- package/dist/pdf/read.js +209 -7
- package/dist/pdf/standard-metrics.d.ts +6 -0
- package/dist/pdf/standard-metrics.js +24 -1
- package/dist/pdf/textstate.d.ts +15 -4
- package/dist/pdf/textstate.js +18 -5
- package/dist/pdf/types.d.ts +118 -4
- package/dist/pdf/verify.js +38 -5
- package/dist/pdf/write.d.ts +18 -1
- package/dist/pdf/write.js +382 -10
- package/dist/pipeline.d.ts +2 -1
- package/dist/pipeline.js +16 -2
- package/dist/regex/rules.data.js +5 -3
- package/package.json +1 -1
- package/rules/pl.json +5 -3
package/dist/pdf/write.d.ts
CHANGED
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
* attachments, JavaScript, named destinations, structure tree and the
|
|
17
17
|
* incremental-update history of the input never reach the output;
|
|
18
18
|
* 6. pages that cannot be edited safely are rendered by pdf.js and replaced
|
|
19
|
-
* by an image with the redacted regions blacked out in the pixels;
|
|
19
|
+
* by an image with the redacted regions blacked out in the pixels; scan
|
|
20
|
+
* pages whose OCR text layer holds a redacted value are rendered at the
|
|
21
|
+
* scan's resolution with each value covered and its placeholder painted in;
|
|
20
22
|
* 7. verify the output with both extractors and a byte scan; refuse on any trace.
|
|
21
23
|
*/
|
|
22
24
|
import type { ValueReplacement } from '../docx.js';
|
|
23
25
|
import type { PageRecord } from './extract.js';
|
|
24
26
|
import type { CanvasFactory, OracleDocument } from './oracle.js';
|
|
27
|
+
import type { PaintMark } from './raster.js';
|
|
25
28
|
import type { Segment } from './surgery.js';
|
|
26
29
|
import type { GlyphRun, PdfWriteOptions, PdfWriteResult, Rect } from './types.js';
|
|
27
30
|
import type { PdfExtraction } from './read.js';
|
|
@@ -41,6 +44,20 @@ interface Replacement {
|
|
|
41
44
|
replacement: string;
|
|
42
45
|
}
|
|
43
46
|
export declare function writeAnonymizedPdfWithReport(extraction: PdfExtraction, replacements: Replacement[], valueReplacements?: ValueReplacement[], options?: PdfWriteOptionsInternal): Promise<PdfWriteResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Where each redacted value sits on a scan page, for painting: one mark per line a value
|
|
49
|
+
* occupies (the pieces of a value on one line, split across operators, join into one), the
|
|
50
|
+
* placeholder on the value's first line and a blank cover on the lines it wraps onto.
|
|
51
|
+
* Whitespace glyphs at either end of a piece are left out.
|
|
52
|
+
*/
|
|
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[];
|
|
44
61
|
/**
|
|
45
62
|
* Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
|
|
46
63
|
* whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
|
package/dist/pdf/write.js
CHANGED
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
* attachments, JavaScript, named destinations, structure tree and the
|
|
17
17
|
* incremental-update history of the input never reach the output;
|
|
18
18
|
* 6. pages that cannot be edited safely are rendered by pdf.js and replaced
|
|
19
|
-
* by an image with the redacted regions blacked out in the pixels;
|
|
19
|
+
* by an image with the redacted regions blacked out in the pixels; scan
|
|
20
|
+
* pages whose OCR text layer holds a redacted value are rendered at the
|
|
21
|
+
* scan's resolution with each value covered and its placeholder painted in;
|
|
20
22
|
* 7. verify the output with both extractors and a byte scan; refuse on any trace.
|
|
21
23
|
*/
|
|
22
|
-
import { PDFDict, PDFDocument, PDFName, PDFRawStream, PDFString } from '@cantoo/pdf-lib';
|
|
24
|
+
import { PDFDict, PDFDocument, PDFName, PDFRawStream, PDFString, concatTransformationMatrix, drawObject, popGraphicsState, pushGraphicsState } from '@cantoo/pdf-lib';
|
|
23
25
|
import { PDFRef } from '@cantoo/pdf-lib';
|
|
24
26
|
import { UnsupportedDocumentError } from "../dom/errors.js";
|
|
25
27
|
import { normalizeReplacements } from "../docx.js";
|
|
@@ -27,11 +29,17 @@ import { extractPdf } from "./extract.js";
|
|
|
27
29
|
import { FallbackFontEmbedder, FallbackFontProvider, faceFor } from "./fallback-fonts.js";
|
|
28
30
|
import { getDict, getStream, refOf, resolve } from "./objects.js";
|
|
29
31
|
import { findOccurrences, openWithOracle, searchIndex } from "./oracle.js";
|
|
30
|
-
import { rasterizePage } from "./raster.js";
|
|
32
|
+
import { rasterizePage, rasterizeScanPage } from "./raster.js";
|
|
33
|
+
import { helveticaWidth } from "./standard-metrics.js";
|
|
31
34
|
import { serializeToUnicode } from "./cmap.js";
|
|
32
35
|
import { planEdits, rewriteStream, segmentsFor } from "./surgery.js";
|
|
33
36
|
import { PdfVerifyError, verifyPdf } from "./verify.js";
|
|
34
37
|
import { lexContent } from "./lexer.js";
|
|
38
|
+
import { mul } from "./textstate.js";
|
|
39
|
+
/** Scan pages render at the scan's own resolution, up to this. */
|
|
40
|
+
const MAX_SCAN_DPI = 300;
|
|
41
|
+
/** Pixel budget of one rendered scan page (a 300 dpi A3 page fits; larger pages render coarser). */
|
|
42
|
+
const MAX_SCAN_PIXELS = 40000000;
|
|
35
43
|
/** Same normalised date the docx writer stamps. */
|
|
36
44
|
export const NORMALISED_PDF_DATE = new Date('2000-01-01T00:00:00Z');
|
|
37
45
|
export class StalePdfExtractionError extends Error {
|
|
@@ -57,7 +65,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
57
65
|
const oracle = await openWithOracle(extraction.bytes, assets, extraction.password);
|
|
58
66
|
let fresh;
|
|
59
67
|
try {
|
|
60
|
-
fresh = await extractPdf(extraction.bytes, { oracle, password: extraction.password, maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER });
|
|
68
|
+
fresh = await extractPdf(extraction.bytes, { oracle, password: extraction.password, maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER, extraOcr: extraction.extraOcr });
|
|
61
69
|
}
|
|
62
70
|
catch (err) {
|
|
63
71
|
await oracle.close();
|
|
@@ -112,13 +120,20 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
112
120
|
if (page.issues.length > 0 || page.oracleText)
|
|
113
121
|
rasterPages.add(page.index);
|
|
114
122
|
}
|
|
123
|
+
// Scan pages: an edit in an OCR text layer would leave the value in the picture under it, so the
|
|
124
|
+
// page is rendered and the value painted over where the layer says it is.
|
|
125
|
+
const scanPages = new Set();
|
|
126
|
+
for (const s of segments)
|
|
127
|
+
if (s.run.ocr && !rasterPages.has(s.run.page))
|
|
128
|
+
scanPages.add(s.run.page);
|
|
129
|
+
const paintedPages = new Set([...rasterPages, ...scanPages]);
|
|
115
130
|
// Text in a pattern cell or soft-mask group cannot be blacked out in a raster (the cell is tiled
|
|
116
131
|
// over its fill area, the group sits behind a mask): such a page must be edited in place or not at all.
|
|
117
|
-
const hostile = [...
|
|
132
|
+
const hostile = [...paintedPages].filter((p) => model.pages[p]?.hostile);
|
|
118
133
|
if (hostile.length > 0) {
|
|
119
134
|
throw new UnsupportedDocumentError('unredactable-parts', `pages ${hostile.map((p) => p + 1).join(', ')} hold content a viewer cannot render safely (rasterization refused)`, hostile.map((p) => `page ${p + 1}`));
|
|
120
135
|
}
|
|
121
|
-
const rasterWithAux = [...
|
|
136
|
+
const rasterWithAux = [...paintedPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
|
|
122
137
|
if (rasterWithAux.length > 0) {
|
|
123
138
|
throw new UnsupportedDocumentError('unredactable-parts', `pages ${rasterWithAux.map((p) => p + 1).join(', ')} hold redacted text inside a pattern or soft mask and cannot be rasterized safely`, rasterWithAux.map((p) => `page ${p + 1}`));
|
|
124
139
|
}
|
|
@@ -142,9 +157,9 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
142
157
|
const stream = model.streams.get(key);
|
|
143
158
|
if (!stream)
|
|
144
159
|
continue;
|
|
145
|
-
if ([...stream.pages].every((p) =>
|
|
160
|
+
if ([...stream.pages].every((p) => paintedPages.has(p)))
|
|
146
161
|
continue;
|
|
147
|
-
const segs = (plan.byStream.get(key) ?? []).filter((s) => !
|
|
162
|
+
const segs = (plan.byStream.get(key) ?? []).filter((s) => !paintedPages.has(s.run.page));
|
|
148
163
|
const result = rewriteStream(model, stream, runsByStreamOp.get(key) ?? new Map(), segs, plan.shiftedRuns);
|
|
149
164
|
for (const [k, codes] of result.usedCodes)
|
|
150
165
|
mergeCodes(usedCodes, k, codes);
|
|
@@ -167,12 +182,51 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
167
182
|
scrubToUnicode(model, editedFonts, usedCodes, warnings);
|
|
168
183
|
scrubSourceSideChannels(model);
|
|
169
184
|
// 5. Rebuild into the fresh document.
|
|
170
|
-
const keptIndexes = model.pages.map((p) => p.index).filter((i) => !
|
|
185
|
+
const keptIndexes = model.pages.map((p) => p.index).filter((i) => !paintedPages.has(i));
|
|
171
186
|
const copied = keptIndexes.length > 0 ? await outDoc.copyPages(model.doc, keptIndexes) : [];
|
|
172
187
|
const copiedByIndex = new Map();
|
|
173
188
|
keptIndexes.forEach((idx, k) => copiedByIndex.set(idx, copied[k]));
|
|
174
189
|
const rasterized = [];
|
|
190
|
+
const scanned = [];
|
|
191
|
+
const sortedReplacements = [...all].sort((a, b) => a.start - b.start || a.end - b.end); // segmentsFor's order
|
|
175
192
|
for (const page of model.pages) {
|
|
193
|
+
if (scanPages.has(page.index)) {
|
|
194
|
+
const marks = withTextColumn(paintMarksForPage(segments, page.index, sortedReplacements), extraction.runs, page.index);
|
|
195
|
+
let raster;
|
|
196
|
+
try {
|
|
197
|
+
raster = await rasterizeScanPage(oracle, page.index, marks, { dpi: scanDpi(page, options.rasterDpi), canvasFactory: options.canvasFactory });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
throw new UnsupportedDocumentError('unredactable-parts', `page ${page.index + 1} cannot be rendered: ${err.message ?? String(err)}`, [`page ${page.index + 1}`]);
|
|
201
|
+
}
|
|
202
|
+
// Raw pixels in a Flate image (gray for a gray scan): lossless, and far smaller than an RGBA PNG.
|
|
203
|
+
const image = outDoc.context.register(outDoc.context.flateStream(raster.pixels, {
|
|
204
|
+
Type: 'XObject',
|
|
205
|
+
Subtype: 'Image',
|
|
206
|
+
Width: raster.widthPx,
|
|
207
|
+
Height: raster.heightPx,
|
|
208
|
+
ColorSpace: raster.channels === 1 ? 'DeviceGray' : 'DeviceRGB',
|
|
209
|
+
BitsPerComponent: 8,
|
|
210
|
+
}));
|
|
211
|
+
const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
|
|
212
|
+
const name = newPage.node.newXObject('Scan', image);
|
|
213
|
+
newPage.pushOperators(pushGraphicsState(), concatTransformationMatrix(raster.widthPt, 0, 0, raster.heightPt, 0, 0), drawObject(name), popGraphicsState());
|
|
214
|
+
// The page stays searchable: its text, values replaced by their placeholders, as an
|
|
215
|
+
// invisible layer over the picture (as the scan's own OCR layer was).
|
|
216
|
+
// Source user space -> picture pixels (the render) -> the picture page's user space.
|
|
217
|
+
const [ta, tb, tc, td, te, tf] = raster.transform;
|
|
218
|
+
const sx = raster.widthPt / raster.widthPx;
|
|
219
|
+
const sy = raster.heightPt / raster.heightPx;
|
|
220
|
+
const toPage = [sx * ta, -sy * tb, sx * tc, -sy * td, sx * te, raster.heightPt - sy * tf];
|
|
221
|
+
const layer = await searchableTextLayer(fresh.runs, segments, page.index, sortedReplacements, embedder, toPage);
|
|
222
|
+
if (layer) {
|
|
223
|
+
newPage.node.setFontDictionary(PDFName.of(layer.fontName), layer.fontRef);
|
|
224
|
+
newPage.node.addContentStream(outDoc.context.register(outDoc.context.flateStream(layer.content)));
|
|
225
|
+
}
|
|
226
|
+
scanned.push(page.index);
|
|
227
|
+
warnings.push(`page ${page.index + 1}: scan with an OCR text layer; the values are painted over in the picture with their placeholders and the page is exported as an image, with its text (placeholders in place of the values) as an invisible layer`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
176
230
|
if (rasterPages.has(page.index)) {
|
|
177
231
|
const boxes = boxesForPage(segments, page.index, fresh.runs, page, [...needles]);
|
|
178
232
|
const dpi = options.rasterDpi ?? 150;
|
|
@@ -217,7 +271,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
217
271
|
scrubOutputObjects(outDoc);
|
|
218
272
|
stampMetadata(outDoc);
|
|
219
273
|
const bytes = await outDoc.save({ useObjectStreams: false, updateFieldAppearances: false, addDefaultPage: false });
|
|
220
|
-
return { bytes, needles: [...needles], removedSequences, rasterized, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
|
|
274
|
+
return { bytes, needles: [...needles], removedSequences, rasterized, scanned, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
|
|
221
275
|
}
|
|
222
276
|
finally {
|
|
223
277
|
await oracle.close();
|
|
@@ -241,6 +295,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
241
295
|
warnings: [...new Set(warnings)],
|
|
242
296
|
removed: produced.removed,
|
|
243
297
|
rasterizedPages: produced.rasterized,
|
|
298
|
+
scanPages: produced.scanned,
|
|
244
299
|
shiftedLines: produced.shiftedLines,
|
|
245
300
|
};
|
|
246
301
|
}
|
|
@@ -550,6 +605,323 @@ function stampMetadata(outDoc) {
|
|
|
550
605
|
outDoc.setCreationDate(NORMALISED_PDF_DATE);
|
|
551
606
|
outDoc.setModificationDate(NORMALISED_PDF_DATE);
|
|
552
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Along-baseline [start, end] of every letter of a one-word OCR show whose letters all have the same
|
|
610
|
+
* width (Tesseract's invisible font), from Helvetica proportions spread over the whole show (Tesseract
|
|
611
|
+
* stretches the word and its trailing space over the printed word). Offsets are from the show's
|
|
612
|
+
* first glyph. Null for any other run.
|
|
613
|
+
*/
|
|
614
|
+
function proportionalLetters(run, ux, uy, scale) {
|
|
615
|
+
const g = run.glyphs;
|
|
616
|
+
if (!run.ocr || g.length < 2)
|
|
617
|
+
return null;
|
|
618
|
+
let k = g.length;
|
|
619
|
+
while (k > 0 && g[k - 1].unicode.trim() === '')
|
|
620
|
+
k--;
|
|
621
|
+
if (k < 2 || g.slice(0, k).some((x) => x.unicode.trim() === ''))
|
|
622
|
+
return null;
|
|
623
|
+
const a0 = g[0].advance;
|
|
624
|
+
if (!(a0 > 0) || g.slice(0, k).some((x) => Math.abs(x.advance - a0) > 0.01 * a0))
|
|
625
|
+
return null;
|
|
626
|
+
const last = g[g.length - 1];
|
|
627
|
+
const showEnd = (last.x - g[0].x) * ux + (last.y - g[0].y) * uy + last.advance * scale;
|
|
628
|
+
const widths = g.slice(0, k).map((x) => [...x.unicode].reduce((sum, ch) => sum + helveticaWidth(ch), 0) || 556);
|
|
629
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
630
|
+
const spans = [];
|
|
631
|
+
let at = 0;
|
|
632
|
+
for (const w of widths) {
|
|
633
|
+
const next = at + (w / total) * showEnd;
|
|
634
|
+
spans.push([at, next]);
|
|
635
|
+
at = next;
|
|
636
|
+
}
|
|
637
|
+
for (let i = k; i < g.length; i++)
|
|
638
|
+
spans.push([showEnd, showEnd]);
|
|
639
|
+
return spans;
|
|
640
|
+
}
|
|
641
|
+
/** True when every glyph of the run has the same advance: an invisible OCR font (Tesseract's GlyphLessFont). */
|
|
642
|
+
function equalAdvances(run) {
|
|
643
|
+
const a0 = run.glyphs[0]?.advance ?? 0;
|
|
644
|
+
return a0 > 0 && run.glyphs.length >= 2 && run.glyphs.every((g) => Math.abs(g.advance - a0) <= 0.01 * a0);
|
|
645
|
+
}
|
|
646
|
+
/** Punctuation and symbols that can stick to a value inside one OCR word ("616," "(+48" "12/4."). */
|
|
647
|
+
const GLUED_PUNCTUATION = /^[\p{P}\p{S}]+$/u;
|
|
648
|
+
/** Text shows have no business holding more than this horizontal stretch (Tz %); beyond it a run is left out. */
|
|
649
|
+
const MAX_LAYER_STRETCH = 1000;
|
|
650
|
+
/**
|
|
651
|
+
* Invisible text layer for a painted scan page: every text run of the page, with each redacted
|
|
652
|
+
* value replaced by its placeholder (on the value's first piece, nothing on the pieces it wraps
|
|
653
|
+
* onto), shown in render mode 3 in the Liberation Sans fallback face on the run's own baseline and
|
|
654
|
+
* stretched (Tz) over the run's width, so a reader can search and select the page as before and
|
|
655
|
+
* the selection sits over the picture. `toPage` maps the source page's user space to the picture
|
|
656
|
+
* page's (rotation and crop box included).
|
|
657
|
+
*/
|
|
658
|
+
async function searchableTextLayer(runs, segments, page, replacements, embedder, toPage) {
|
|
659
|
+
const pageRuns = runs.filter((r) => r.page === page && !r.space && r.glyphs.length > 0);
|
|
660
|
+
if (pageRuns.length === 0)
|
|
661
|
+
return null;
|
|
662
|
+
const font = await embedder.ensure(faceFor({ family: 'sans', bold: false, italic: false }));
|
|
663
|
+
const segsByRun = new Map();
|
|
664
|
+
const labelled = new Set();
|
|
665
|
+
for (const s of segments) {
|
|
666
|
+
if (s.run.page !== page)
|
|
667
|
+
continue;
|
|
668
|
+
const list = segsByRun.get(s.run) ?? [];
|
|
669
|
+
list.push(s);
|
|
670
|
+
segsByRun.set(s.run, list);
|
|
671
|
+
}
|
|
672
|
+
// Where each glyph ends along its run's baseline, in user space.
|
|
673
|
+
const glyphEnd = (run, i) => {
|
|
674
|
+
const len = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
675
|
+
const k = len / (run.local.fontSize * run.local.hscale || 1);
|
|
676
|
+
const g = run.glyphs[i];
|
|
677
|
+
return { x: g.x + (run.emX[0] / len) * g.advance * k, y: g.y + (run.emX[1] / len) * g.advance * k };
|
|
678
|
+
};
|
|
679
|
+
const ops = ['q BT 3 Tr'];
|
|
680
|
+
for (const run of pageRuns) {
|
|
681
|
+
const segs = segsByRun.get(run) ?? [];
|
|
682
|
+
let text = '';
|
|
683
|
+
// The show starts at the first glyph that puts text in it: after the end of a value that began
|
|
684
|
+
// in an earlier show (Tesseract writes one show per word), the rest sits where it is printed.
|
|
685
|
+
let first = -1;
|
|
686
|
+
let labelAtEnd = null;
|
|
687
|
+
for (let i = 0; i < run.glyphs.length; i++) {
|
|
688
|
+
const seg = segs.find((s) => i >= s.from && i < s.to);
|
|
689
|
+
if (!seg) {
|
|
690
|
+
if (first < 0)
|
|
691
|
+
first = i;
|
|
692
|
+
text += run.glyphs[i].unicode;
|
|
693
|
+
if (run.glyphs[i].unicode.trim() !== '')
|
|
694
|
+
labelAtEnd = null;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (i === seg.from && !labelled.has(seg.replacementIndex)) {
|
|
698
|
+
labelled.add(seg.replacementIndex);
|
|
699
|
+
if (first < 0)
|
|
700
|
+
first = i;
|
|
701
|
+
text += replacements[seg.replacementIndex]?.replacement ?? '';
|
|
702
|
+
labelAtEnd = seg;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
text = [...text.replace(/\s+/g, ' ')].map((ch) => (font.canShow(ch) ? ch : '?')).join('');
|
|
706
|
+
if (first < 0 || text.trim() === '')
|
|
707
|
+
continue;
|
|
708
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
709
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
710
|
+
const ux = run.emX[0] / exLen;
|
|
711
|
+
const uy = run.emX[1] / exLen;
|
|
712
|
+
const vx = run.emY[0] / eyLen;
|
|
713
|
+
const vy = run.emY[1] / eyLen;
|
|
714
|
+
const g0 = run.glyphs[first];
|
|
715
|
+
const along = (p) => (p.x - g0.x) * ux + (p.y - g0.y) * uy;
|
|
716
|
+
const size = Math.max(...run.glyphs.map((g) => g.fontSize));
|
|
717
|
+
let width = along(glyphEnd(run, run.glyphs.length - 1));
|
|
718
|
+
// A placeholder that ends the show while its value runs on into the next shows of the line
|
|
719
|
+
// spans the whole value, so it selects as one word with whatever follows it.
|
|
720
|
+
if (labelAtEnd) {
|
|
721
|
+
for (const s of segments) {
|
|
722
|
+
if (s.replacementIndex !== labelAtEnd.replacementIndex || s.run === run || s.run.page !== page || s.to <= s.from)
|
|
723
|
+
continue;
|
|
724
|
+
const end = glyphEnd(s.run, s.to - 1);
|
|
725
|
+
const across = (end.x - g0.x) * vx + (end.y - g0.y) * vy;
|
|
726
|
+
if (Math.abs(across) < 0.4 * size)
|
|
727
|
+
width = Math.max(width, along(end));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const natural = font.measure(text, size);
|
|
731
|
+
if (!(width > 0) || !(natural > 0) || !(size > 0))
|
|
732
|
+
continue;
|
|
733
|
+
const tz = (100 * width) / natural;
|
|
734
|
+
if (tz > MAX_LAYER_STRETCH)
|
|
735
|
+
continue;
|
|
736
|
+
const hex = [...font.encode(text)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
737
|
+
const n = (v) => (Math.abs(v) < 1e-6 ? '0' : v.toFixed(4).replace(/\.?0+$/, ''));
|
|
738
|
+
// The run's text matrix, carried into the picture page's user space.
|
|
739
|
+
const tm = mul([ux * size, uy * size, vx * size, vy * size, g0.x, g0.y], toPage);
|
|
740
|
+
ops.push(`/${font.resourceName} 1 Tf ${n(tz)} Tz ${tm.map(n).join(' ')} Tm <${hex}> Tj`);
|
|
741
|
+
}
|
|
742
|
+
if (ops.length === 1)
|
|
743
|
+
return null;
|
|
744
|
+
ops.push('ET Q');
|
|
745
|
+
return { content: new TextEncoder().encode(ops.join('\n')), fontName: font.resourceName, fontRef: font.pdfFont.ref };
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Resolution for a scan page: the scan's own (at least `base`, at most 300 dpi), within the pixel
|
|
749
|
+
* budget. A text page painted for a value inside one of its images is rendered at 300 dpi: its
|
|
750
|
+
* text is sharp at any size, the image's resolution says nothing about it.
|
|
751
|
+
*/
|
|
752
|
+
function scanDpi(page, base = 150) {
|
|
753
|
+
const dpi = page.info.imageOnly ? Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi))) : MAX_SCAN_DPI;
|
|
754
|
+
const squareInches = Math.max(1e-6, (page.info.width / 72) * (page.info.height / 72));
|
|
755
|
+
return Math.max(36, Math.min(dpi, Math.floor(Math.sqrt(MAX_SCAN_PIXELS / squareInches))));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Where each redacted value sits on a scan page, for painting: one mark per line a value
|
|
759
|
+
* occupies (the pieces of a value on one line, split across operators, join into one), the
|
|
760
|
+
* placeholder on the value's first line and a blank cover on the lines it wraps onto.
|
|
761
|
+
* Whitespace glyphs at either end of a piece are left out.
|
|
762
|
+
*/
|
|
763
|
+
export function paintMarksForPage(segments, page, replacements) {
|
|
764
|
+
const marks = [];
|
|
765
|
+
let lastIndex = -1;
|
|
766
|
+
for (const s of segments) {
|
|
767
|
+
const run = s.run;
|
|
768
|
+
if (run.page !== page || run.space)
|
|
769
|
+
continue;
|
|
770
|
+
let from = s.from;
|
|
771
|
+
let to = s.to;
|
|
772
|
+
while (from < to && run.glyphs[from].unicode.trim() === '')
|
|
773
|
+
from++;
|
|
774
|
+
while (to > from && run.glyphs[to - 1].unicode.trim() === '')
|
|
775
|
+
to--;
|
|
776
|
+
if (from >= to)
|
|
777
|
+
continue;
|
|
778
|
+
// An OCR layer knows where its words end, not where their letters do (Tesseract gives every
|
|
779
|
+
// glyph of its invisible font the same width): a value that stops at punctuation glued to it
|
|
780
|
+
// ("616," "12/4,") would end at a guessed point inside its last letter. Such punctuation is
|
|
781
|
+
// covered with the value, so the cover ends where the word does, and painted again as part of
|
|
782
|
+
// the label ("[PHONE_1],"), so the picture reads like the redacted text.
|
|
783
|
+
let after = to;
|
|
784
|
+
let trail = '';
|
|
785
|
+
while (after < run.glyphs.length && run.glyphs[after].unicode.trim() !== '')
|
|
786
|
+
after++;
|
|
787
|
+
if (after > to && run.glyphs.slice(to, after).every((g) => GLUED_PUNCTUATION.test(g.unicode))) {
|
|
788
|
+
trail = run.glyphs.slice(to, after).map((g) => g.unicode).join('');
|
|
789
|
+
to = after;
|
|
790
|
+
}
|
|
791
|
+
let before = from;
|
|
792
|
+
let lead = '';
|
|
793
|
+
while (before > 0 && run.glyphs[before - 1].unicode.trim() !== '')
|
|
794
|
+
before--;
|
|
795
|
+
if (before < from && run.glyphs.slice(before, from).every((g) => GLUED_PUNCTUATION.test(g.unicode))) {
|
|
796
|
+
lead = run.glyphs.slice(before, from).map((g) => g.unicode).join('');
|
|
797
|
+
from = before;
|
|
798
|
+
}
|
|
799
|
+
// Tesseract writes each word with its trailing space in an invisible font whose letters all have
|
|
800
|
+
// the same width, and stretches both over the word's box: the letters may reach the end of the
|
|
801
|
+
// show, past the last letter's own advance. When only whitespace follows the value in such a show,
|
|
802
|
+
// the end of the show is where its ink may still be (`reach`). A layer set in a real font is not
|
|
803
|
+
// stretched: its trailing space is the gap before the next word and never belongs to the value.
|
|
804
|
+
let measureTo = to;
|
|
805
|
+
if (run.ocr && to < run.glyphs.length && run.glyphs.slice(to).every((g) => g.unicode.trim() === '') && equalAdvances(run))
|
|
806
|
+
measureTo = run.glyphs.length;
|
|
807
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
808
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
809
|
+
const ux = run.emX[0] / exLen;
|
|
810
|
+
const uy = run.emX[1] / exLen;
|
|
811
|
+
const vx = run.emY[0] / eyLen;
|
|
812
|
+
const vy = run.emY[1] / eyLen;
|
|
813
|
+
const scale = exLen / (run.local.fontSize * run.local.hscale || 1);
|
|
814
|
+
const first = run.glyphs[from];
|
|
815
|
+
let lo = 0;
|
|
816
|
+
let hi = 0;
|
|
817
|
+
let hiReach = 0;
|
|
818
|
+
let size = 0;
|
|
819
|
+
for (let i = from; i < measureTo; i++) {
|
|
820
|
+
const g = run.glyphs[i];
|
|
821
|
+
const along = (g.x - first.x) * ux + (g.y - first.y) * uy;
|
|
822
|
+
hiReach = Math.max(hiReach, along + g.advance * scale);
|
|
823
|
+
if (i >= to)
|
|
824
|
+
continue;
|
|
825
|
+
lo = Math.min(lo, along);
|
|
826
|
+
hi = Math.max(hi, along + g.advance * scale);
|
|
827
|
+
size = Math.max(size, g.fontSize);
|
|
828
|
+
}
|
|
829
|
+
const startsInWord = from > 0 && run.glyphs[from - 1].unicode.trim() !== '';
|
|
830
|
+
const endsInWord = to < run.glyphs.length && run.glyphs[to].unicode.trim() !== '';
|
|
831
|
+
// Inside a Tesseract word every letter has the same width: a value that is only part of the word
|
|
832
|
+
// ("2026" of "2026/03/017") is placed by real letter proportions over the printed word instead.
|
|
833
|
+
const spans = (startsInWord || endsInWord) ? proportionalLetters(run, ux, uy, scale) : null;
|
|
834
|
+
if (spans) {
|
|
835
|
+
// Offsets stay relative to the value's first glyph origin, as above.
|
|
836
|
+
const firstAlong = (first.x - run.glyphs[0].x) * ux + (first.y - run.glyphs[0].y) * uy;
|
|
837
|
+
lo = spans[from][0] - firstAlong;
|
|
838
|
+
hi = spans[to - 1][1] - firstAlong;
|
|
839
|
+
hiReach = hi;
|
|
840
|
+
}
|
|
841
|
+
const x = first.x + ux * lo;
|
|
842
|
+
const y = first.y + uy * lo;
|
|
843
|
+
const prev = marks[marks.length - 1];
|
|
844
|
+
if (prev && s.replacementIndex === lastIndex) {
|
|
845
|
+
// Same value: join when this piece continues the previous one's line.
|
|
846
|
+
const across = (x - prev.x) * prev.vx + (y - prev.y) * prev.vy;
|
|
847
|
+
const along = (x - prev.x) * prev.ux + (y - prev.y) * prev.uy;
|
|
848
|
+
const lineSize = Math.max(prev.size, size);
|
|
849
|
+
if (Math.abs(across) < 0.4 * lineSize && along > -0.5 * lineSize && along < prev.length + 2 * lineSize) {
|
|
850
|
+
prev.length = Math.max(prev.length, along + (hi - lo));
|
|
851
|
+
prev.reach = Math.max(prev.reach ?? prev.length, along + (hiReach - lo));
|
|
852
|
+
prev.size = lineSize;
|
|
853
|
+
if (endsInWord)
|
|
854
|
+
prev.endsInWord = true;
|
|
855
|
+
else
|
|
856
|
+
delete prev.endsInWord;
|
|
857
|
+
if (prev.labelEnd !== undefined)
|
|
858
|
+
prev.labelEnd += trail;
|
|
859
|
+
else
|
|
860
|
+
prev.label += trail;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// The rest of a value wrapped onto the next line shows only the punctuation glued to it, each
|
|
865
|
+
// side where it was printed.
|
|
866
|
+
const rest = s.replacementIndex === lastIndex;
|
|
867
|
+
const label = rest ? lead : lead + (replacements[s.replacementIndex]?.replacement ?? '') + trail;
|
|
868
|
+
marks.push({
|
|
869
|
+
x, y, ux, uy, vx, vy, length: hi - lo, ...(hiReach > hi ? { reach: hiReach - lo } : {}), size, label,
|
|
870
|
+
...(rest ? { labelEnd: trail } : {}),
|
|
871
|
+
...(startsInWord ? { startsInWord } : {}), ...(endsInWord ? { endsInWord } : {}),
|
|
872
|
+
});
|
|
873
|
+
lastIndex = s.replacementIndex;
|
|
874
|
+
}
|
|
875
|
+
return marks;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Share of a page's words that may start before / end after the text column: a margin note runs to
|
|
879
|
+
* a few percent of a page's words; on the right, past ragged line ends, only a page number or a
|
|
880
|
+
* stamp, so the end takes in nearly every line.
|
|
881
|
+
*/
|
|
882
|
+
const COLUMN_START_OUTLIERS = 0.05;
|
|
883
|
+
const COLUMN_END_OUTLIERS = 0.02;
|
|
884
|
+
/**
|
|
885
|
+
* The text column each mark's line sits in, along its baseline (`columnStart`, `columnEnd`, user
|
|
886
|
+
* units from the mark's start): where the page's words start and end, without the outlying
|
|
887
|
+
* COLUMN_START_OUTLIERS / COLUMN_END_OUTLIERS, over the words running the same way. A label growing into blank
|
|
888
|
+
* paper beside a short value stays inside it: never into the margin.
|
|
889
|
+
*/
|
|
890
|
+
export function withTextColumn(marks, runs, page) {
|
|
891
|
+
const words = [];
|
|
892
|
+
for (const run of runs) {
|
|
893
|
+
if (run.page !== page || run.space || run.glyphs.length === 0)
|
|
894
|
+
continue;
|
|
895
|
+
const len = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
896
|
+
const k = len / (run.local.fontSize * run.local.hscale || 1);
|
|
897
|
+
const ux = run.emX[0] / len;
|
|
898
|
+
const uy = run.emX[1] / len;
|
|
899
|
+
let first = -1;
|
|
900
|
+
for (let i = 0; i <= run.glyphs.length; i++) {
|
|
901
|
+
const blank = i === run.glyphs.length || run.glyphs[i].unicode.trim() === '';
|
|
902
|
+
if (!blank && first < 0)
|
|
903
|
+
first = i;
|
|
904
|
+
if (blank && first >= 0) {
|
|
905
|
+
const a = run.glyphs[first];
|
|
906
|
+
const b = run.glyphs[i - 1];
|
|
907
|
+
words.push({ x0: a.x, y0: a.y, x1: b.x + ux * b.advance * k, y1: b.y + uy * b.advance * k, ux, uy });
|
|
908
|
+
first = -1;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const quantile = (values, q) => {
|
|
913
|
+
const sorted = [...values].sort((p, r) => p - r);
|
|
914
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(q * (sorted.length - 1))))];
|
|
915
|
+
};
|
|
916
|
+
return marks.map((m) => {
|
|
917
|
+
const same = words.filter((w) => w.ux * m.ux + w.uy * m.uy > 0.99);
|
|
918
|
+
if (same.length === 0)
|
|
919
|
+
return m;
|
|
920
|
+
const starts = same.map((w) => (w.x0 - m.x) * m.ux + (w.y0 - m.y) * m.uy);
|
|
921
|
+
const ends = same.map((w) => (w.x1 - m.x) * m.ux + (w.y1 - m.y) * m.uy);
|
|
922
|
+
return { ...m, columnStart: quantile(starts, COLUMN_START_OUTLIERS), columnEnd: quantile(ends, 1 - COLUMN_END_OUTLIERS) };
|
|
923
|
+
});
|
|
924
|
+
}
|
|
553
925
|
/**
|
|
554
926
|
* Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
|
|
555
927
|
* whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
|
package/dist/pipeline.d.ts
CHANGED
|
@@ -91,7 +91,8 @@ export declare const COMMON_WORD_NAMES: Readonly<Record<'en' | 'pl' | 'de', read
|
|
|
91
91
|
* are exact even after characters whose lowercase form has a different
|
|
92
92
|
* length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
|
|
93
93
|
* on both sides ([\p{L}\p{N}] complement), except on a side where the term
|
|
94
|
-
* itself ends in an unspaced script (CJK, Thai)
|
|
94
|
+
* itself ends in an unspaced script (CJK, Thai), and before a capitalised hit
|
|
95
|
+
* glued to an elision whose apostrophe OCR dropped ("dALTAMIR").
|
|
95
96
|
*/
|
|
96
97
|
export declare function propagateEntities(text: string, entities: DetectedEntity[]): DetectedEntity[];
|
|
97
98
|
/**
|
package/dist/pipeline.js
CHANGED
|
@@ -289,6 +289,19 @@ function startsUppercase(value) {
|
|
|
289
289
|
const first = codePointAt(value, 0);
|
|
290
290
|
return first !== '' && first !== first.toLowerCase();
|
|
291
291
|
}
|
|
292
|
+
/** French and Italian elisions ("d'", "l'", "qu'") written before a word. */
|
|
293
|
+
const ELISION_PREFIXES = ['qu', 'c', 'd', 'j', 'l', 'm', 'n', 's', 't'];
|
|
294
|
+
/**
|
|
295
|
+
* An elision glued to a capitalised hit ("dALTAMIR", "lEtat"): OCR often drops the apostrophe of
|
|
296
|
+
* "d'ALTAMIR". The lowercase prefix then counts as the word boundary when it starts a word itself.
|
|
297
|
+
*/
|
|
298
|
+
function elidedBefore(text, idx, found) {
|
|
299
|
+
if (!startsUppercase(found))
|
|
300
|
+
return false;
|
|
301
|
+
return ELISION_PREFIXES.some((p) => idx >= p.length
|
|
302
|
+
&& text.slice(idx - p.length, idx) === p
|
|
303
|
+
&& !WORD_CHAR.test(codePointBefore(text, idx - p.length)));
|
|
304
|
+
}
|
|
292
305
|
/**
|
|
293
306
|
* Find every further occurrence of each detected value (and of the words of
|
|
294
307
|
* multi-word values) in the text and emit it as a 'propagated' entity.
|
|
@@ -300,7 +313,8 @@ function startsUppercase(value) {
|
|
|
300
313
|
* are exact even after characters whose lowercase form has a different
|
|
301
314
|
* length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
|
|
302
315
|
* on both sides ([\p{L}\p{N}] complement), except on a side where the term
|
|
303
|
-
* itself ends in an unspaced script (CJK, Thai)
|
|
316
|
+
* itself ends in an unspaced script (CJK, Thai), and before a capitalised hit
|
|
317
|
+
* glued to an elision whose apostrophe OCR dropped ("dALTAMIR").
|
|
304
318
|
*/
|
|
305
319
|
export function propagateEntities(text, entities) {
|
|
306
320
|
const propagated = [];
|
|
@@ -341,7 +355,7 @@ export function propagateEntities(text, entities) {
|
|
|
341
355
|
const key = `${idx}:${end}`;
|
|
342
356
|
if (seen.has(key))
|
|
343
357
|
continue;
|
|
344
|
-
if (needsBoundaryBefore && WORD_CHAR.test(codePointBefore(text, idx)))
|
|
358
|
+
if (needsBoundaryBefore && WORD_CHAR.test(codePointBefore(text, idx)) && !elidedBefore(text, idx, found))
|
|
345
359
|
continue;
|
|
346
360
|
if (needsBoundaryAfter && WORD_CHAR.test(codePointAt(text, end)))
|
|
347
361
|
continue;
|
package/dist/regex/rules.data.js
CHANGED
|
@@ -812,18 +812,20 @@ export const RULES_DATA = [
|
|
|
812
812
|
{
|
|
813
813
|
"id": "regex:pl:street",
|
|
814
814
|
"entityType": "ADDRESS",
|
|
815
|
-
"pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?",
|
|
815
|
+
"pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?(?:\\s+(?:m\\.?|lok\\.)\\s*\\d+[\\p{L}]?)?",
|
|
816
816
|
"flags": "gu",
|
|
817
817
|
"confidence": 0.9,
|
|
818
818
|
"domains": [
|
|
819
819
|
"contact"
|
|
820
820
|
],
|
|
821
|
-
"description": "Polish street address (ul./al./pl./os. + name + number)",
|
|
821
|
+
"description": "Polish street address (ul./al./pl./os. + name + number, with the flat: /3, m. 3, lok. 3)",
|
|
822
822
|
"examples": [
|
|
823
823
|
"ul. Floriańska 27/3",
|
|
824
824
|
"ul. Juliusza Słowackiego 15/8",
|
|
825
825
|
"al. Krakowska 42",
|
|
826
|
-
"os. Złotego Wieku 12"
|
|
826
|
+
"os. Złotego Wieku 12",
|
|
827
|
+
"ul. Słoneczna 7 m. 12",
|
|
828
|
+
"ul. Długa 5 lok. 3"
|
|
827
829
|
]
|
|
828
830
|
},
|
|
829
831
|
{
|
package/package.json
CHANGED
package/rules/pl.json
CHANGED
|
@@ -150,18 +150,20 @@
|
|
|
150
150
|
{
|
|
151
151
|
"id": "regex:pl:street",
|
|
152
152
|
"entityType": "ADDRESS",
|
|
153
|
-
"pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?",
|
|
153
|
+
"pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?(?:\\s+(?:m\\.?|lok\\.)\\s*\\d+[\\p{L}]?)?",
|
|
154
154
|
"flags": "gu",
|
|
155
155
|
"confidence": 0.9,
|
|
156
156
|
"domains": [
|
|
157
157
|
"contact"
|
|
158
158
|
],
|
|
159
|
-
"description": "Polish street address (ul./al./pl./os. + name + number)",
|
|
159
|
+
"description": "Polish street address (ul./al./pl./os. + name + number, with the flat: /3, m. 3, lok. 3)",
|
|
160
160
|
"examples": [
|
|
161
161
|
"ul. Floriańska 27/3",
|
|
162
162
|
"ul. Juliusza Słowackiego 15/8",
|
|
163
163
|
"al. Krakowska 42",
|
|
164
|
-
"os. Złotego Wieku 12"
|
|
164
|
+
"os. Złotego Wieku 12",
|
|
165
|
+
"ul. Słoneczna 7 m. 12",
|
|
166
|
+
"ul. Długa 5 lok. 3"
|
|
165
167
|
]
|
|
166
168
|
},
|
|
167
169
|
{
|