@doccloak/core 0.13.0 → 0.14.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 +53 -0
- package/README.md +20 -3
- package/dist/dom/office.d.ts +2 -0
- package/dist/dom/office.js +1 -0
- package/dist/env.d.ts +1 -1
- package/dist/pdf/extract.d.ts +2 -0
- package/dist/pdf/extract.js +111 -11
- package/dist/pdf/raster.d.ts +74 -0
- package/dist/pdf/raster.js +316 -0
- package/dist/pdf/textstate.d.ts +15 -4
- package/dist/pdf/textstate.js +18 -5
- package/dist/pdf/types.d.ts +19 -4
- package/dist/pdf/write.d.ts +11 -1
- package/dist/pdf/write.js +115 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ 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.14.0] - 2026-10-22
|
|
8
|
+
|
|
9
|
+
Scanned PDF pages with an OCR text layer are redacted where a reader sees
|
|
10
|
+
them: in the picture. Additive for hosts (`PdfPageInfo.ocrText`,
|
|
11
|
+
`GlyphRun.ocr`, `PdfWriteResult.scanPages`, `scanPages` from
|
|
12
|
+
`redactOfficeFile`); one behaviour change: such pages are no longer reported
|
|
13
|
+
as unredactable `'scanned-page'` parts, because they are now redacted.
|
|
14
|
+
`CoreEnv.loadTokenizer` still works with its deprecation warning; its
|
|
15
|
+
removal, announced for this release, moves to a later one.
|
|
16
|
+
|
|
17
|
+
### Scanned pages with an OCR text layer are redacted in the picture (T235)
|
|
18
|
+
|
|
19
|
+
Reported in DocCloak.Core#1: a court judgment scanned with an OCR text layer
|
|
20
|
+
(Lutecia / 3-Heights: the text sits in a form XObject under an ExtGState
|
|
21
|
+
with `/ca 0`) was "redacted" in its invisible text layer only; the picture,
|
|
22
|
+
which is what a reader sees, still showed every name.
|
|
23
|
+
|
|
24
|
+
- **Transparent text is hidden text.** The state machine follows `gs` and
|
|
25
|
+
keeps the fill and stroke alpha (`ca` / `CA`); text painted at alpha below
|
|
26
|
+
0.02 counts as hidden, like render mode 3 and white text. Text painted
|
|
27
|
+
before an opaque image that covers it ("text under the page image") is
|
|
28
|
+
hidden too.
|
|
29
|
+
- **OCR text.** Hidden text over an image is marked `GlyphRun.ocr`, and its
|
|
30
|
+
page `PdfPageInfo.ocrText`. A scan page with OCR text is no longer an
|
|
31
|
+
unredactable part; a scan page without any text still is
|
|
32
|
+
(`'scanned-page'`, exported as is with `allowUnredactable`), and a file
|
|
33
|
+
without any text still reads as `empty`.
|
|
34
|
+
- **Painted placeholders.** A page whose OCR text holds a redacted value is
|
|
35
|
+
rendered at the scan's resolution (from the images' size and placement, at
|
|
36
|
+
least `rasterDpi`, at most 300 dpi, within a 40 Mpx budget). Each value is
|
|
37
|
+
covered in the paper colour (median of the pixels around it) and its
|
|
38
|
+
placeholder is painted on the value's baseline. The cover grows along the
|
|
39
|
+
line to where the value's ink ends (stopping at a word space, at most one
|
|
40
|
+
em), because OCR layers set letters in their own font and a value's last
|
|
41
|
+
letters can run past the layer's end; its height and the label's size come
|
|
42
|
+
from the value's ink, because OCR layers often stretch their font size to
|
|
43
|
+
the line height. The label fits the value's width: squeezed down to 60 %,
|
|
44
|
+
then shrunk. Pieces of a value on one line join under one label; a value
|
|
45
|
+
wrapped to the next line is covered there without a label. All covers are
|
|
46
|
+
painted before any label, so a cover never clips the line above.
|
|
47
|
+
- The page is embedded as a lossless Flate image (gray when the page is
|
|
48
|
+
gray), not a PNG: page 1 of the reported file stays the size of the scan.
|
|
49
|
+
It has no text layer. The writer lists it in the new
|
|
50
|
+
`PdfWriteResult.scanPages` (and `redactOfficeFile` in `scanPages`), with
|
|
51
|
+
a warning; `rasterizedPages` keeps meaning pages blacked out because
|
|
52
|
+
their text could not be edited safely.
|
|
53
|
+
- Text-layer PDFs are unchanged: the founder's kit files 01-08 read
|
|
54
|
+
identically (text, pages, parts, warnings) to 0.13.0.
|
|
55
|
+
- Tests: `pdf-scan.test.ts` (synthetic scans with values painted red: alpha,
|
|
56
|
+
render mode 3 and text-under-image layers, OCR letters narrower than the
|
|
57
|
+
scan, a mixed scan / picture-only / text file; transparent text without an
|
|
58
|
+
image; alpha in the state machine; cover growth; mark joining).
|
|
59
|
+
|
|
7
60
|
## [0.13.0] - 2026-09-25
|
|
8
61
|
|
|
9
62
|
PDF-to-PDF redaction that keeps the text layer (new `@doccloak/core/pdf`
|
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ const assets = {
|
|
|
190
190
|
const extraction = await readPdf(file, { assets }); // extraction.plainText -> detect
|
|
191
191
|
const entities = await engine.detect(extraction.plainText);
|
|
192
192
|
const replacements = entities.map((e) => ({ start: e.start, end: e.end, replacement: session.anonymize(e.value, e.type) }));
|
|
193
|
-
const { blob, warnings, removed, rasterizedPages } = await writeAnonymizedPdfWithReport(
|
|
193
|
+
const { blob, warnings, removed, rasterizedPages, scanPages } = await writeAnonymizedPdfWithReport(
|
|
194
194
|
extraction, replacements, session.getEntries(), { assets },
|
|
195
195
|
);
|
|
196
196
|
```
|
|
@@ -245,11 +245,28 @@ ToUnicode maps of edited fonts are trimmed to the codes still used,
|
|
|
245
245
|
configuration is dropped as well, so content of hidden layers becomes
|
|
246
246
|
visible (it is redacted like everything else).
|
|
247
247
|
|
|
248
|
+
**Scanned pages with an OCR text layer.** A scan with OCR shows a picture;
|
|
249
|
+
its text layer is invisible (render mode 3, fully transparent paint through
|
|
250
|
+
an ExtGState `/ca 0`, or text painted under the picture). Editing that layer
|
|
251
|
+
alone would leave every value visible in the picture, so the reader marks
|
|
252
|
+
such text (`GlyphRun.ocr`, `PdfPageInfo.ocrText`) and the writer renders a
|
|
253
|
+
page whose OCR text holds a redacted value at the scan's own resolution (at
|
|
254
|
+
least `rasterDpi`, at most 300 dpi) and paints the value over in the
|
|
255
|
+
picture: a cover in the paper colour, grown along the line to where the
|
|
256
|
+
value's ink really ends (OCR letters rarely have the scan's widths), and the
|
|
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.
|
|
262
|
+
|
|
248
263
|
**Refusals.** Encrypted files (`'encrypted'`, pass `password`), files over
|
|
249
264
|
50 MB or 500 pages (`'too-large'`), non-PDF bytes (`'invalid-package'`).
|
|
250
|
-
Scanned pages
|
|
265
|
+
Scanned pages without a text layer (no OCR) and text no decoder can read are
|
|
251
266
|
reported in `unredactable` (`'scanned-page'`, `'undecodable-text'`) and gate
|
|
252
|
-
the export behind `allowUnredactable`, like Office packages
|
|
267
|
+
the export behind `allowUnredactable`, like Office packages; with it they
|
|
268
|
+
are exported as they are. A file whose pages hold no text at all reads as
|
|
269
|
+
`empty`.
|
|
253
270
|
|
|
254
271
|
## What is removed or normalised in Office packages
|
|
255
272
|
|
package/dist/dom/office.d.ts
CHANGED
|
@@ -127,6 +127,8 @@ export interface OfficeRedactionResult {
|
|
|
127
127
|
removed?: RemovedPart[];
|
|
128
128
|
/** PDF only: 0-based pages that were rasterized because they could not be edited safely. */
|
|
129
129
|
rasterizedPages?: number[];
|
|
130
|
+
/** PDF only: 0-based scan pages whose OCR-found values were painted over in the picture. */
|
|
131
|
+
scanPages?: number[];
|
|
130
132
|
}
|
|
131
133
|
/** "report.docx" -> "report.redacted.docx"; extensionless names get a suffix. */
|
|
132
134
|
export declare function redactedFileName(filename: string): string;
|
package/dist/dom/office.js
CHANGED
package/dist/env.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface CoreEnv {
|
|
|
42
42
|
* named by `tokenizerConfig.tokenizer_class`). May return a promise.
|
|
43
43
|
*
|
|
44
44
|
* Optional so hosts still on loadTokenizer keep compiling; required once
|
|
45
|
-
* loadTokenizer is removed (planned for
|
|
45
|
+
* loadTokenizer is removed (planned for a later release).
|
|
46
46
|
*/
|
|
47
47
|
buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
|
|
48
48
|
/**
|
package/dist/pdf/extract.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface PageRecord {
|
|
|
56
56
|
/** Our decoder's runs and text of a raster-only page (kept for blacking out with our geometry too). */
|
|
57
57
|
ownRuns?: GlyphRun[];
|
|
58
58
|
ownText?: string;
|
|
59
|
+
/** Highest effective resolution of the images drawn on the page (pixels per inch; 0 without images). */
|
|
60
|
+
imageDpi: number;
|
|
59
61
|
}
|
|
60
62
|
export interface PdfModel {
|
|
61
63
|
bytes: Uint8Array;
|
package/dist/pdf/extract.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* never modified.
|
|
14
14
|
*/
|
|
15
15
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
|
16
|
-
import { PDFArray, PDFDict, PDFName, PDFNumber, PDFRef, PDFStream, StreamDecodeError, decodeStream, getArray, getDict, getName, getNumber, getStream, inheritedPageValue, itemsOf, numbersOf, pageContentStreams, pageResources, refKey, resolve, stringText, } from "./objects.js";
|
|
16
|
+
import { PDFArray, PDFDict, PDFName, PDFNumber, PDFRef, PDFStream, StreamDecodeError, decodeStream, getArray, getBool, getDict, getName, getNumber, getStream, inheritedPageValue, itemsOf, numbersOf, pageContentStreams, pageResources, refKey, resolve, stringText, } from "./objects.js";
|
|
17
17
|
import { lexContent, TEXT_SHOW_OPS } from "./lexer.js";
|
|
18
18
|
import { StateMachine, adjustmentAdvance, apply, glyphAdvance, initialState, mul, matrixFromOperands } from "./textstate.js";
|
|
19
19
|
import { loadFont } from "./fonts.js";
|
|
@@ -92,11 +92,12 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
92
92
|
index: p,
|
|
93
93
|
dict: pageDict,
|
|
94
94
|
ref: pageRef,
|
|
95
|
-
info: { index: p, width, height, rotate, hasImages: false, imageOnly: false, textStart: text.length, textEnd: text.length },
|
|
95
|
+
info: { index: p, width, height, rotate, hasImages: false, imageOnly: false, ocrText: false, textStart: text.length, textEnd: text.length },
|
|
96
96
|
streams: [],
|
|
97
97
|
issues: [],
|
|
98
98
|
hiddenGlyphs: 0,
|
|
99
99
|
oracleText: false,
|
|
100
|
+
imageDpi: 0,
|
|
100
101
|
};
|
|
101
102
|
model.pages.push(record);
|
|
102
103
|
const pageTextStart = text.length;
|
|
@@ -143,8 +144,11 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
143
144
|
if (glyphCount > maxGlyphs) {
|
|
144
145
|
throw new UnsupportedDocumentError('too-large', `PDF holds more than ${maxGlyphs} glyphs; the limit protects the host's memory`, [String(glyphCount)]);
|
|
145
146
|
}
|
|
147
|
+
walker.markOcrText(runs, pageRunStart);
|
|
146
148
|
record.info.hasImages = walker.sawImage;
|
|
147
149
|
record.info.imageOnly = walker.sawImage && walker.visibleGlyphs === 0;
|
|
150
|
+
record.info.ocrText = walker.ocrGlyphs > 0;
|
|
151
|
+
record.imageDpi = walker.imageDpi();
|
|
148
152
|
let oracleGarbage = false;
|
|
149
153
|
if (options.oracle && !record.hostile) {
|
|
150
154
|
let items = null;
|
|
@@ -186,16 +190,22 @@ export async function extractPdf(bytes, options = {}) {
|
|
|
186
190
|
appendOracleRuns(p, items, runs, text);
|
|
187
191
|
if (items.some((it) => it.str.trim() !== ''))
|
|
188
192
|
record.info.imageOnly = false;
|
|
193
|
+
// The oracle's runs replace ours and carry no OCR marks: the page is blacked out as a raster-only page.
|
|
194
|
+
record.info.ocrText = false;
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
198
|
record.info.textEnd = text.length;
|
|
193
|
-
if (record.info.imageOnly) {
|
|
199
|
+
if (record.info.imageOnly && record.info.ocrText) {
|
|
200
|
+
// A scan with an OCR text layer: detection reads the layer, and the writer paints the
|
|
201
|
+
// placeholders into the picture where the layer says the values are.
|
|
202
|
+
}
|
|
203
|
+
else if (record.info.imageOnly) {
|
|
194
204
|
unredactable.push({
|
|
195
205
|
part: `page ${p + 1}`,
|
|
196
206
|
kind: 'scanned-page',
|
|
197
207
|
label: record.hiddenGlyphs > 0
|
|
198
|
-
? `page ${p + 1} is a scanned image with a hidden
|
|
208
|
+
? `page ${p + 1} is a scanned image with a hidden text layer that is not over the picture: the text layer is redacted, the picture keeps the original`
|
|
199
209
|
: `page ${p + 1} is an image without a text layer (scanned page); its content cannot be redacted`,
|
|
200
210
|
});
|
|
201
211
|
}
|
|
@@ -544,13 +554,18 @@ function appendOracleRuns(page, items, runs, text) {
|
|
|
544
554
|
});
|
|
545
555
|
});
|
|
546
556
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
/* ------------------------------------------------------------------ */
|
|
557
|
+
/** Constant alpha below this paints nothing a reader can see. */
|
|
558
|
+
const MIN_VISIBLE_ALPHA = 0.02;
|
|
550
559
|
class Walker {
|
|
551
560
|
constructor(model, page, runs, text, box) {
|
|
552
561
|
this.sawImage = false;
|
|
553
562
|
this.visibleGlyphs = 0;
|
|
563
|
+
/** Glyphs of text hidden over an image (an OCR layer), counted by markOcrText. */
|
|
564
|
+
this.ocrGlyphs = 0;
|
|
565
|
+
this.images = [];
|
|
566
|
+
/** Paint order of text runs and images on this page (text drawn before an opaque image is covered by it). */
|
|
567
|
+
this.order = 0;
|
|
568
|
+
this.runOrder = new Map();
|
|
554
569
|
this.undecodableGlyphs = 0;
|
|
555
570
|
/** A composite font without a usable CMap/ToUnicode showed text: pdf.js's reading of it is a guess (glyph ids), never trusted. */
|
|
556
571
|
this.unreadableCompositeFont = false;
|
|
@@ -569,6 +584,13 @@ class Walker {
|
|
|
569
584
|
const { ctx } = this.model;
|
|
570
585
|
const fontsDict = getDict(ctx, stream.resources, 'Font');
|
|
571
586
|
const resolveFont = (name) => loadFont(ctx, fontsDict, name, this.model.fonts);
|
|
587
|
+
const statesDict = getDict(ctx, stream.resources, 'ExtGState');
|
|
588
|
+
const resolveGState = (name) => {
|
|
589
|
+
const d = resolve(ctx, statesDict?.get(PDFName.of(name)));
|
|
590
|
+
if (!(d instanceof PDFDict))
|
|
591
|
+
return undefined;
|
|
592
|
+
return { fillAlpha: getNumber(ctx, d, 'ca'), strokeAlpha: getNumber(ctx, d, 'CA') };
|
|
593
|
+
};
|
|
572
594
|
const sm = new StateMachine(base);
|
|
573
595
|
// Text can also live in tiling pattern cells and soft-mask groups of this stream's resources.
|
|
574
596
|
this.walkAuxiliary(stream, base, depth, active);
|
|
@@ -582,17 +604,19 @@ class Walker {
|
|
|
582
604
|
if (TEXT_SHOW_OPS.has(op.op)) {
|
|
583
605
|
// ' and " move to the next line (and set spacing) before showing.
|
|
584
606
|
if (op.op === "'" || op.op === '"')
|
|
585
|
-
sm.update(op, resolveFont);
|
|
607
|
+
sm.update(op, resolveFont, resolveGState);
|
|
586
608
|
this.show(stream, i, op, sm, resolveFont);
|
|
587
609
|
continue;
|
|
588
610
|
}
|
|
589
611
|
if (op.op === 'Do') {
|
|
590
|
-
sm.update(op, resolveFont);
|
|
612
|
+
sm.update(op, resolveFont, resolveGState);
|
|
591
613
|
this.doXObject(stream, op, sm, depth, active);
|
|
592
614
|
continue;
|
|
593
615
|
}
|
|
594
616
|
if (op.op === 'BI') {
|
|
595
617
|
this.sawImage = true;
|
|
618
|
+
// Inline images are small (the format caps them at 4 KB in practice): placed, never trusted as opaque.
|
|
619
|
+
this.placeImage(sm, 0, 0, false);
|
|
596
620
|
continue;
|
|
597
621
|
}
|
|
598
622
|
if (op.op === 'd0' || op.op === 'd1') {
|
|
@@ -601,7 +625,62 @@ class Walker {
|
|
|
601
625
|
if (op.op === 'sh') {
|
|
602
626
|
// Shading is drawing, not text; nothing to do.
|
|
603
627
|
}
|
|
604
|
-
sm.update(op, resolveFont);
|
|
628
|
+
sm.update(op, resolveFont, resolveGState);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** Record an image painted at the current CTM (it fills the unit square of image space). */
|
|
632
|
+
placeImage(sm, widthPx, heightPx, opaque) {
|
|
633
|
+
const m = sm.state.ctm;
|
|
634
|
+
const corners = [apply(m, 0, 0), apply(m, 1, 0), apply(m, 0, 1), apply(m, 1, 1)];
|
|
635
|
+
const xs = corners.map((c) => c[0]);
|
|
636
|
+
const ys = corners.map((c) => c[1]);
|
|
637
|
+
// Pixels per inch along each image axis: the axis spans |row| user units (1/72 in).
|
|
638
|
+
const alongX = Math.hypot(m[0], m[1]) / 72;
|
|
639
|
+
const alongY = Math.hypot(m[2], m[3]) / 72;
|
|
640
|
+
const dpi = Math.max(alongX > 0 ? widthPx / alongX : 0, alongY > 0 ? heightPx / alongY : 0);
|
|
641
|
+
this.images.push({
|
|
642
|
+
x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys),
|
|
643
|
+
dpi: Number.isFinite(dpi) ? dpi : 0,
|
|
644
|
+
opaque: opaque && sm.state.fillAlpha >= 1,
|
|
645
|
+
order: this.order++,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** Highest effective resolution of the images on the page. */
|
|
649
|
+
imageDpi() {
|
|
650
|
+
let dpi = 0;
|
|
651
|
+
for (const im of this.images)
|
|
652
|
+
dpi = Math.max(dpi, im.dpi);
|
|
653
|
+
return dpi;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* After the walk: text hidden over an image is an OCR layer (`ocr`), the reading of what the
|
|
657
|
+
* picture shows. Text painted BEFORE an opaque image that covers it is hidden too ("text under
|
|
658
|
+
* the page image", another common OCR layout), so it counts as hidden and as OCR text.
|
|
659
|
+
*/
|
|
660
|
+
markOcrText(runs, from) {
|
|
661
|
+
if (this.images.length === 0)
|
|
662
|
+
return;
|
|
663
|
+
for (let r = from; r < runs.length; r++) {
|
|
664
|
+
const run = runs[r];
|
|
665
|
+
if (run.space || run.oracle)
|
|
666
|
+
continue;
|
|
667
|
+
const order = this.runOrder.get(run) ?? -1;
|
|
668
|
+
const shown = run.glyphs.filter((g) => g.unicode.trim() !== '');
|
|
669
|
+
if (shown.length === 0)
|
|
670
|
+
continue;
|
|
671
|
+
const within = (im) => shown.filter((g) => glyphInside(im, g, run)).length * 2 >= shown.length;
|
|
672
|
+
if (!run.hidden) {
|
|
673
|
+
if (!this.images.some((im) => im.opaque && im.order > order && within(im)))
|
|
674
|
+
continue;
|
|
675
|
+
run.hidden = true;
|
|
676
|
+
this.visibleGlyphs -= shown.length;
|
|
677
|
+
this.page.hiddenGlyphs += run.glyphs.length;
|
|
678
|
+
}
|
|
679
|
+
else if (!this.images.some(within)) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
run.ocr = true;
|
|
683
|
+
this.ocrGlyphs += shown.length;
|
|
605
684
|
}
|
|
606
685
|
}
|
|
607
686
|
/**
|
|
@@ -707,6 +786,10 @@ class Walker {
|
|
|
707
786
|
const subtype = getName(ctx, xobj.dict, 'Subtype');
|
|
708
787
|
if (subtype === 'Image') {
|
|
709
788
|
this.sawImage = true;
|
|
789
|
+
const d = xobj.dict;
|
|
790
|
+
const masked = getBool(ctx, d, 'ImageMask') === true || d.get(PDFName.of('SMask')) !== undefined
|
|
791
|
+
|| d.get(PDFName.of('Mask')) !== undefined || (getNumber(ctx, d, 'SMaskInData') ?? 0) !== 0;
|
|
792
|
+
this.placeImage(sm, getNumber(ctx, d, 'Width') ?? 0, getNumber(ctx, d, 'Height') ?? 0, !masked);
|
|
710
793
|
return;
|
|
711
794
|
}
|
|
712
795
|
if (subtype !== 'Form')
|
|
@@ -755,6 +838,8 @@ class Walker {
|
|
|
755
838
|
inner.text = { ...sm.state.text };
|
|
756
839
|
inner.fillLuminance = sm.state.fillLuminance;
|
|
757
840
|
inner.strokeLuminance = sm.state.strokeLuminance;
|
|
841
|
+
inner.fillAlpha = sm.state.fillAlpha;
|
|
842
|
+
inner.strokeAlpha = sm.state.strokeAlpha;
|
|
758
843
|
active.add(key);
|
|
759
844
|
this.walk(record, inner, depth + 1, active);
|
|
760
845
|
active.delete(key);
|
|
@@ -817,9 +902,14 @@ class Walker {
|
|
|
817
902
|
const emY = [trm0[2], trm0[3]];
|
|
818
903
|
const hiddenByMode = t.renderMode === 3 || t.renderMode === 7;
|
|
819
904
|
const hiddenByColor = t.renderMode === 0 && sm.state.fillLuminance !== null && sm.state.fillLuminance > 0.97;
|
|
905
|
+
// Fully transparent paint (ExtGState /ca, /CA): the OCR layer of many scanners.
|
|
906
|
+
const fills = [0, 2, 4, 6].includes(t.renderMode);
|
|
907
|
+
const strokes = [1, 2, 5, 6].includes(t.renderMode);
|
|
908
|
+
const hiddenByAlpha = (fills || strokes)
|
|
909
|
+
&& (!fills || sm.state.fillAlpha < MIN_VISIBLE_ALPHA) && (!strokes || sm.state.strokeAlpha < MIN_VISIBLE_ALPHA);
|
|
820
910
|
const sizeUser = Math.hypot(trm0[2], trm0[3]);
|
|
821
911
|
const tiny = sizeUser < 0.5;
|
|
822
|
-
const hidden = hiddenByMode || hiddenByColor || tiny;
|
|
912
|
+
const hidden = hiddenByMode || hiddenByColor || hiddenByAlpha || tiny;
|
|
823
913
|
const glyphs = [];
|
|
824
914
|
const textOffsets = [];
|
|
825
915
|
let advanceSoFar = 0; // unscaled text-space x from the run start
|
|
@@ -898,8 +988,18 @@ class Walker {
|
|
|
898
988
|
...(this.space ? { space: this.space } : {}),
|
|
899
989
|
};
|
|
900
990
|
this.runs.push(run);
|
|
991
|
+
this.runOrder.set(run, this.order++);
|
|
901
992
|
}
|
|
902
993
|
}
|
|
994
|
+
/** True when the middle of glyph `g` (half an advance along, a third of an em up) lies inside `b`. */
|
|
995
|
+
function glyphInside(b, g, run) {
|
|
996
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
997
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
998
|
+
const w = g.advance * (exLen / (run.local.fontSize * run.local.hscale || 1));
|
|
999
|
+
const x = g.x + (run.emX[0] / exLen) * (w / 2) + (run.emY[0] / eyLen) * (g.fontSize / 3);
|
|
1000
|
+
const y = g.y + (run.emX[1] / exLen) * (w / 2) + (run.emY[1] / eyLen) * (g.fontSize / 3);
|
|
1001
|
+
return insideBox(b, x, y);
|
|
1002
|
+
}
|
|
903
1003
|
/**
|
|
904
1004
|
* Consecutive pages with the same kind of problem collapse into one part ("pages 2-22"), so a
|
|
905
1005
|
* document whose body font is unreadable shows one line to consent to, not one per page.
|
package/dist/pdf/raster.d.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* canvas, after the render: the PNG never contains what was under a box.
|
|
7
7
|
* The caller (writer) replaces the page contents with this image and
|
|
8
8
|
* reports the page in `rasterizedPages`.
|
|
9
|
+
*
|
|
10
|
+
* Scan pages with an OCR text layer (T235) are rendered the same way at the
|
|
11
|
+
* scan's resolution, but each redacted value is covered in paper colour and
|
|
12
|
+
* its placeholder label is painted in its place, along the baseline and
|
|
13
|
+
* within the value's width (`rasterizeScanPage`).
|
|
9
14
|
*/
|
|
10
15
|
import type { CanvasFactory, OracleDocument } from './oracle.js';
|
|
11
16
|
import type { Rect } from './types.js';
|
|
@@ -43,3 +48,72 @@ export declare function padAndClamp(box: PixelBox, pad: number, widthPx: number,
|
|
|
43
48
|
* encode the canvas as PNG.
|
|
44
49
|
*/
|
|
45
50
|
export declare function rasterizePage(doc: OracleDocument, index: number, boxes: Rect[], options: RasterizeOptions): Promise<RasterizedPage>;
|
|
51
|
+
/**
|
|
52
|
+
* A value to paint over on a scan page, in user space: the baseline segment
|
|
53
|
+
* the value occupies and the label to show there.
|
|
54
|
+
*/
|
|
55
|
+
export interface PaintMark {
|
|
56
|
+
/** Baseline point where the value starts. */
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
/** Unit vector along the baseline and unit vector "up" (the text's em direction). */
|
|
60
|
+
ux: number;
|
|
61
|
+
uy: number;
|
|
62
|
+
vx: number;
|
|
63
|
+
vy: number;
|
|
64
|
+
/** Length of the value along the baseline, user units. */
|
|
65
|
+
length: number;
|
|
66
|
+
/** Font size of the value's text, user units. */
|
|
67
|
+
size: number;
|
|
68
|
+
/** Placeholder to show; '' only covers (the rest of a value wrapped onto the next line). */
|
|
69
|
+
label: string;
|
|
70
|
+
}
|
|
71
|
+
/** A rendered scan page: raw pixels, 1 (gray) or 3 (RGB) bytes each, row-major. */
|
|
72
|
+
export interface ScanRaster {
|
|
73
|
+
pixels: Uint8Array;
|
|
74
|
+
channels: 1 | 3;
|
|
75
|
+
widthPx: number;
|
|
76
|
+
heightPx: number;
|
|
77
|
+
widthPt: number;
|
|
78
|
+
heightPt: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render page `index` at `options.dpi`, cover every mark in the paper colour
|
|
82
|
+
* around it and paint its label in the value's place: the baseline the OCR
|
|
83
|
+
* layer gives, the value's width (the label is squeezed, then shrunk, to fit;
|
|
84
|
+
* a shorter label leaves the rest of the width blank). Every cover is painted
|
|
85
|
+
* before any label, so a cover on the next line never clips a label above it.
|
|
86
|
+
* Returns the pixels, in gray when the page is gray (a scan usually is), so
|
|
87
|
+
* the writer can embed them losslessly at a size close to the original scan.
|
|
88
|
+
*/
|
|
89
|
+
export declare function rasterizeScanPage(doc: OracleDocument, index: number, marks: PaintMark[], options: RasterizeOptions): Promise<ScanRaster>;
|
|
90
|
+
/**
|
|
91
|
+
* Vertical extent of the value's ink (user units from the baseline): the block of inked rows
|
|
92
|
+
* across [start, end] that holds the letters' bodies, grown up and down until a blank row (the
|
|
93
|
+
* leading between lines), within INK_REACH_BELOW / INK_REACH_ABOVE. Null when no ink is there.
|
|
94
|
+
*/
|
|
95
|
+
export declare function inkRows(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], m: PaintMark, start: number, end: number, inkBelow: number): {
|
|
96
|
+
bottom: number;
|
|
97
|
+
top: number;
|
|
98
|
+
} | null;
|
|
99
|
+
/**
|
|
100
|
+
* How far the ink of a value really reaches along its baseline (user units from the mark's
|
|
101
|
+
* start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
|
|
102
|
+
* outwards pixel by pixel from inside the value and stops at the first blank gap of a word
|
|
103
|
+
* space, or after MAX_GROWTH.
|
|
104
|
+
*/
|
|
105
|
+
export declare function inkExtent(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], m: PaintMark, inkBelow: number): {
|
|
106
|
+
start: number;
|
|
107
|
+
end: number;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The paper colour around a cover: per-channel median of the pixels on a ring
|
|
111
|
+
* three pixels outside the quad's bounding box (mostly background, whatever
|
|
112
|
+
* text the ring crosses is a minority). White when the ring is off canvas.
|
|
113
|
+
*/
|
|
114
|
+
export declare function paperColour(rgba: Uint8ClampedArray, width: number, height: number, quad: Array<[number, number]>): [number, number, number];
|
|
115
|
+
/** RGBA canvas pixels to gray (every pixel has R = G = B) or RGB bytes; alpha is dropped (pdf.js paints an opaque page). */
|
|
116
|
+
export declare function packPixels(rgba: Uint8ClampedArray): {
|
|
117
|
+
pixels: Uint8Array;
|
|
118
|
+
channels: 1 | 3;
|
|
119
|
+
};
|
package/dist/pdf/raster.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* canvas, after the render: the PNG never contains what was under a box.
|
|
7
7
|
* The caller (writer) replaces the page contents with this image and
|
|
8
8
|
* reports the page in `rasterizedPages`.
|
|
9
|
+
*
|
|
10
|
+
* Scan pages with an OCR text layer (T235) are rendered the same way at the
|
|
11
|
+
* scan's resolution, but each redacted value is covered in paper colour and
|
|
12
|
+
* its placeholder label is painted in its place, along the baseline and
|
|
13
|
+
* within the value's width (`rasterizeScanPage`).
|
|
9
14
|
*/
|
|
10
15
|
/**
|
|
11
16
|
* Map a user-space rectangle to canvas pixels through the pdf.js viewport
|
|
@@ -87,3 +92,314 @@ export async function rasterizePage(doc, index, boxes, options) {
|
|
|
87
92
|
heightPt: page.heightPt,
|
|
88
93
|
};
|
|
89
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Cover extent around a value, in em of its font size: along the baseline, and below and above
|
|
97
|
+
* it when the picture shows no ink to measure. The OCR layer's font size is only a hint (layers
|
|
98
|
+
* often stretch it to the line height), so the cover's height and the label's size come from
|
|
99
|
+
* the ink itself (see inkRows), within INK_REACH_BELOW / INK_REACH_ABOVE of the baseline; the
|
|
100
|
+
* cover always holds the core band COVER_CORE_BELOW..COVER_CORE_ABOVE.
|
|
101
|
+
*/
|
|
102
|
+
const COVER_PAD_ALONG = 0.12;
|
|
103
|
+
const COVER_BELOW = 0.32;
|
|
104
|
+
const COVER_ABOVE = 0.92;
|
|
105
|
+
const COVER_CORE_BELOW = 0.15;
|
|
106
|
+
const COVER_CORE_ABOVE = 0.6;
|
|
107
|
+
const INK_REACH_BELOW = 0.45;
|
|
108
|
+
const INK_REACH_ABOVE = 1.1;
|
|
109
|
+
/**
|
|
110
|
+
* OCR layers place words well but size letters from their own font, so a value's ink can run
|
|
111
|
+
* past the layer's ends. A cover grows along the baseline to the end of the ink it touches: the
|
|
112
|
+
* search starts this far inside the value, stops at a blank gap of WORD_GAP (a word space is
|
|
113
|
+
* about 0.28 em, gaps between letters stay under about 0.12 em) and never grows by more than
|
|
114
|
+
* MAX_GROWTH; ink is looked for in the band of the letters' bodies only, so the lines above and
|
|
115
|
+
* below do not count. All in em of the value's font size.
|
|
116
|
+
*/
|
|
117
|
+
const INK_SEARCH_INSIDE = 0.25;
|
|
118
|
+
const WORD_GAP = 0.16;
|
|
119
|
+
const MAX_GROWTH = 1;
|
|
120
|
+
const INK_BAND_BELOW = 0.12;
|
|
121
|
+
const INK_BAND_ABOVE = 0.68;
|
|
122
|
+
/** A pixel is ink when its luminance is below this fraction of the paper's. */
|
|
123
|
+
const INK_LUMINANCE = 0.65;
|
|
124
|
+
/**
|
|
125
|
+
* Label size: the value's ink height (capitals and ascenders reach about 0.72 em) gives the
|
|
126
|
+
* label's em; without ink the OCR font size does, times LABEL_SIZE. The label is squeezed
|
|
127
|
+
* horizontally down to LABEL_MIN_SQUEEZE to fit the value's width, then shrunk.
|
|
128
|
+
*/
|
|
129
|
+
const INK_TOP_EM = 0.76;
|
|
130
|
+
const LABEL_SIZE = 0.8;
|
|
131
|
+
const LABEL_MIN_SQUEEZE = 0.6;
|
|
132
|
+
const LABEL_FONT = '"Liberation Sans", Arial, Helvetica, sans-serif';
|
|
133
|
+
/**
|
|
134
|
+
* Render page `index` at `options.dpi`, cover every mark in the paper colour
|
|
135
|
+
* around it and paint its label in the value's place: the baseline the OCR
|
|
136
|
+
* layer gives, the value's width (the label is squeezed, then shrunk, to fit;
|
|
137
|
+
* a shorter label leaves the rest of the width blank). Every cover is painted
|
|
138
|
+
* before any label, so a cover on the next line never clips a label above it.
|
|
139
|
+
* Returns the pixels, in gray when the page is gray (a scan usually is), so
|
|
140
|
+
* the writer can embed them losslessly at a size close to the original scan.
|
|
141
|
+
*/
|
|
142
|
+
export async function rasterizeScanPage(doc, index, marks, options) {
|
|
143
|
+
if (!(options.dpi > 0) || !Number.isFinite(options.dpi))
|
|
144
|
+
throw new RangeError(`invalid dpi ${options.dpi}`);
|
|
145
|
+
const page = await doc.render(index, options.dpi / 72, options.canvasFactory);
|
|
146
|
+
const ctx = page.canvas.context;
|
|
147
|
+
const before = page.canvas.getImageData();
|
|
148
|
+
const [a, b, c, d, e, f] = page.transform;
|
|
149
|
+
const toPx = (x, y) => [a * x + c * y + e, b * x + d * y + f];
|
|
150
|
+
// Measure every cover on the untouched pixels first.
|
|
151
|
+
const covers = marks.map((m) => {
|
|
152
|
+
const at = (along, up) => toPx(m.x + m.ux * along + m.vx * up, m.y + m.uy * along + m.vy * up);
|
|
153
|
+
const pad = COVER_PAD_ALONG * m.size;
|
|
154
|
+
const paper = paperColour(before, page.widthPx, page.heightPx, quadOf(at, -pad, m.length + pad, -COVER_BELOW * m.size, COVER_ABOVE * m.size));
|
|
155
|
+
const inkBelow = luminance(paper) * INK_LUMINANCE;
|
|
156
|
+
const along = inkExtent(before, page.widthPx, page.heightPx, at, m, inkBelow);
|
|
157
|
+
const start = Math.min(0, along.start) - pad;
|
|
158
|
+
const end = Math.max(m.length, along.end) + pad;
|
|
159
|
+
const rows = inkRows(before, page.widthPx, page.heightPx, at, m, start, end, inkBelow);
|
|
160
|
+
const onePx = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
|
|
161
|
+
const bottom = rows ? Math.min(-COVER_CORE_BELOW * m.size, rows.bottom - 1.5 * onePx) : -COVER_BELOW * m.size;
|
|
162
|
+
const top = rows ? Math.max(COVER_CORE_ABOVE * m.size, rows.top + 1.5 * onePx) : COVER_ABOVE * m.size;
|
|
163
|
+
const ink = rows ? inkColour(before, page.widthPx, page.heightPx, at, start, end, rows.bottom, rows.top, inkBelow) : null;
|
|
164
|
+
return { mark: m, at, start, end, bottom, top, inkTop: rows ? rows.top : null, paper, ink };
|
|
165
|
+
});
|
|
166
|
+
ctx.save();
|
|
167
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
168
|
+
ctx.globalAlpha = 1;
|
|
169
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
170
|
+
for (const cv of covers) {
|
|
171
|
+
const quad = quadOf(cv.at, cv.start, cv.end, cv.bottom, cv.top);
|
|
172
|
+
const fill = `rgb(${cv.paper[0]}, ${cv.paper[1]}, ${cv.paper[2]})`;
|
|
173
|
+
ctx.fillStyle = fill;
|
|
174
|
+
ctx.strokeStyle = fill;
|
|
175
|
+
ctx.lineWidth = 2; // the stroke grows the cover by a pixel on every side (anti-aliased glyph edges)
|
|
176
|
+
ctx.beginPath();
|
|
177
|
+
ctx.moveTo(quad[0][0], quad[0][1]);
|
|
178
|
+
for (let i = 1; i < 4; i++)
|
|
179
|
+
ctx.lineTo(quad[i][0], quad[i][1]);
|
|
180
|
+
ctx.closePath();
|
|
181
|
+
ctx.fill();
|
|
182
|
+
ctx.stroke();
|
|
183
|
+
}
|
|
184
|
+
for (const cv of covers) {
|
|
185
|
+
const m = cv.mark;
|
|
186
|
+
if (!m.label)
|
|
187
|
+
continue;
|
|
188
|
+
const pad = COVER_PAD_ALONG * m.size;
|
|
189
|
+
const [x0, y0] = cv.at(cv.start + pad, 0);
|
|
190
|
+
const [x1, y1] = cv.at(cv.start + pad + 1, 0);
|
|
191
|
+
const [xu, yu] = cv.at(cv.start + pad, 1);
|
|
192
|
+
const pxPerUnit = Math.hypot(x1 - x0, y1 - y0);
|
|
193
|
+
const pxPerUnitUp = Math.hypot(xu - x0, yu - y0);
|
|
194
|
+
const available = (cv.end - cv.start - 2 * pad) * pxPerUnit;
|
|
195
|
+
const em = cv.inkTop !== null && cv.inkTop > 0 ? cv.inkTop / INK_TOP_EM : LABEL_SIZE * m.size;
|
|
196
|
+
let fontPx = Math.min(em, 1.2 * m.size) * pxPerUnitUp;
|
|
197
|
+
if (!(fontPx > 0) || !(available > 0))
|
|
198
|
+
continue;
|
|
199
|
+
ctx.font = `${fontPx}px ${LABEL_FONT}`;
|
|
200
|
+
const natural = ctx.measureText(m.label).width;
|
|
201
|
+
let squeeze = natural > available ? available / natural : 1;
|
|
202
|
+
if (squeeze < LABEL_MIN_SQUEEZE) {
|
|
203
|
+
fontPx *= squeeze / LABEL_MIN_SQUEEZE;
|
|
204
|
+
squeeze = LABEL_MIN_SQUEEZE;
|
|
205
|
+
ctx.font = `${fontPx}px ${LABEL_FONT}`;
|
|
206
|
+
}
|
|
207
|
+
ctx.save();
|
|
208
|
+
ctx.translate(x0, y0);
|
|
209
|
+
ctx.rotate(Math.atan2(y1 - y0, x1 - x0));
|
|
210
|
+
ctx.scale(squeeze, 1);
|
|
211
|
+
ctx.fillStyle = cv.ink !== null ? `rgb(${cv.ink}, ${cv.ink}, ${cv.ink})` : luminance(cv.paper) > 0.45 ? '#000000' : '#ffffff';
|
|
212
|
+
ctx.textAlign = 'left';
|
|
213
|
+
ctx.textBaseline = 'alphabetic';
|
|
214
|
+
ctx.fillText(m.label, 0, 0);
|
|
215
|
+
ctx.restore();
|
|
216
|
+
}
|
|
217
|
+
ctx.restore();
|
|
218
|
+
const rgba = page.canvas.getImageData();
|
|
219
|
+
const { pixels, channels } = packPixels(rgba);
|
|
220
|
+
return { pixels, channels, widthPx: page.widthPx, heightPx: page.heightPx, widthPt: page.widthPt, heightPt: page.heightPt };
|
|
221
|
+
}
|
|
222
|
+
function sub(p, q) {
|
|
223
|
+
return [p[0] - q[0], p[1] - q[1]];
|
|
224
|
+
}
|
|
225
|
+
/** Pixel corners of the box [lo, hi] along the baseline by [bottom, top] across it. */
|
|
226
|
+
function quadOf(at, lo, hi, bottom, top) {
|
|
227
|
+
return [at(lo, bottom), at(hi, bottom), at(hi, top), at(lo, top)];
|
|
228
|
+
}
|
|
229
|
+
/** Luminance (0..1) of the pixel under a baseline-frame point, or null off canvas. */
|
|
230
|
+
function lumAt(rgba, width, height, p) {
|
|
231
|
+
const px = Math.round(p[0]);
|
|
232
|
+
const py = Math.round(p[1]);
|
|
233
|
+
if (px < 0 || py < 0 || px >= width || py >= height)
|
|
234
|
+
return null;
|
|
235
|
+
const i = (py * width + px) * 4;
|
|
236
|
+
return (0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2]) / 255;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Vertical extent of the value's ink (user units from the baseline): the block of inked rows
|
|
240
|
+
* across [start, end] that holds the letters' bodies, grown up and down until a blank row (the
|
|
241
|
+
* leading between lines), within INK_REACH_BELOW / INK_REACH_ABOVE. Null when no ink is there.
|
|
242
|
+
*/
|
|
243
|
+
export function inkRows(rgba, width, height, at, m, start, end, inkBelow) {
|
|
244
|
+
const pxAlong = Math.hypot(...sub(at(1, 0), at(0, 0)));
|
|
245
|
+
const pxUp = Math.hypot(...sub(at(0, 1), at(0, 0)));
|
|
246
|
+
if (!(pxAlong > 0) || !(pxUp > 0))
|
|
247
|
+
return null;
|
|
248
|
+
const stepAlong = 1 / pxAlong;
|
|
249
|
+
const stepUp = 1 / pxUp;
|
|
250
|
+
const rowHasInk = (up) => {
|
|
251
|
+
for (let along = start; along <= end; along += stepAlong) {
|
|
252
|
+
const lum = lumAt(rgba, width, height, at(along, up));
|
|
253
|
+
if (lum !== null && lum < inkBelow)
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
};
|
|
258
|
+
// Seed in the letters' bodies (every letter has ink between the baseline and the x-height).
|
|
259
|
+
let seed = null;
|
|
260
|
+
for (let up = 0.1 * m.size; up <= 0.45 * m.size; up += stepUp) {
|
|
261
|
+
if (rowHasInk(up)) {
|
|
262
|
+
seed = up;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (seed === null)
|
|
267
|
+
return null;
|
|
268
|
+
let top = seed;
|
|
269
|
+
for (let up = seed; up <= INK_REACH_ABOVE * m.size && rowHasInk(up); up += stepUp)
|
|
270
|
+
top = up;
|
|
271
|
+
let bottom = seed;
|
|
272
|
+
for (let up = seed; up >= -INK_REACH_BELOW * m.size && rowHasInk(up); up -= stepUp)
|
|
273
|
+
bottom = up;
|
|
274
|
+
return { bottom, top };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* How far the ink of a value really reaches along its baseline (user units from the mark's
|
|
278
|
+
* start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
|
|
279
|
+
* outwards pixel by pixel from inside the value and stops at the first blank gap of a word
|
|
280
|
+
* space, or after MAX_GROWTH.
|
|
281
|
+
*/
|
|
282
|
+
export function inkExtent(rgba, width, height, at, m, inkBelow) {
|
|
283
|
+
const [ox, oy] = at(0, 0);
|
|
284
|
+
const [ax, ay] = at(1, 0);
|
|
285
|
+
const pxPerUnit = Math.hypot(ax - ox, ay - oy);
|
|
286
|
+
if (!(pxPerUnit > 0))
|
|
287
|
+
return { start: 0, end: m.length };
|
|
288
|
+
const step = 1 / pxPerUnit; // one pixel along the baseline, in user units
|
|
289
|
+
const [bx, by] = at(0, INK_BAND_BELOW * -m.size);
|
|
290
|
+
const [tx, ty] = at(0, INK_BAND_ABOVE * m.size);
|
|
291
|
+
const bandPx = Math.max(1, Math.ceil(Math.hypot(tx - bx, ty - by)));
|
|
292
|
+
const isInk = (along) => {
|
|
293
|
+
for (let k = 0; k <= bandPx; k++) {
|
|
294
|
+
const up = -INK_BAND_BELOW * m.size + (k / bandPx) * (INK_BAND_ABOVE + INK_BAND_BELOW) * m.size;
|
|
295
|
+
const lum = lumAt(rgba, width, height, at(along, up));
|
|
296
|
+
if (lum !== null && lum < inkBelow)
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
};
|
|
301
|
+
const inside = Math.min(INK_SEARCH_INSIDE * m.size, m.length / 2);
|
|
302
|
+
const gap = WORD_GAP * m.size;
|
|
303
|
+
const reach = (from, dir, limit) => {
|
|
304
|
+
let last = from;
|
|
305
|
+
for (let along = from; dir > 0 ? along <= limit : along >= limit; along += dir * step) {
|
|
306
|
+
if (isInk(along))
|
|
307
|
+
last = along;
|
|
308
|
+
else if (Math.abs(along - last) >= gap)
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
return last + dir * step;
|
|
312
|
+
};
|
|
313
|
+
return {
|
|
314
|
+
start: Math.min(0, reach(inside, -1, -MAX_GROWTH * m.size)),
|
|
315
|
+
end: Math.max(m.length, reach(m.length - inside, 1, m.length + MAX_GROWTH * m.size)),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Gray level (0..255) of the ink in the box [start, end] x [bottom, top]: the median luminance of
|
|
320
|
+
* its ink pixels, without their hue (a label never repeats the colour a value was printed in).
|
|
321
|
+
* Null without ink.
|
|
322
|
+
*/
|
|
323
|
+
function inkColour(rgba, width, height, at, start, end, bottom, top, inkBelow) {
|
|
324
|
+
const stepAlong = 1 / (Math.hypot(...sub(at(1, 0), at(0, 0))) || 1);
|
|
325
|
+
const stepUp = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
|
|
326
|
+
const levels = [];
|
|
327
|
+
for (let up = bottom; up <= top; up += stepUp) {
|
|
328
|
+
for (let along = start; along <= end; along += stepAlong) {
|
|
329
|
+
const [x, y] = at(along, up);
|
|
330
|
+
const px = Math.round(x);
|
|
331
|
+
const py = Math.round(y);
|
|
332
|
+
if (px < 0 || py < 0 || px >= width || py >= height)
|
|
333
|
+
continue;
|
|
334
|
+
const i = (py * width + px) * 4;
|
|
335
|
+
const level = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
336
|
+
if (level / 255 < inkBelow)
|
|
337
|
+
levels.push(level);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (levels.length === 0)
|
|
341
|
+
return null;
|
|
342
|
+
return Math.round(levels.sort((p, q) => p - q)[levels.length >> 1]);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The paper colour around a cover: per-channel median of the pixels on a ring
|
|
346
|
+
* three pixels outside the quad's bounding box (mostly background, whatever
|
|
347
|
+
* text the ring crosses is a minority). White when the ring is off canvas.
|
|
348
|
+
*/
|
|
349
|
+
export function paperColour(rgba, width, height, quad) {
|
|
350
|
+
const xs = quad.map((p) => p[0]);
|
|
351
|
+
const ys = quad.map((p) => p[1]);
|
|
352
|
+
const left = Math.floor(Math.min(...xs)) - 3;
|
|
353
|
+
const right = Math.ceil(Math.max(...xs)) + 3;
|
|
354
|
+
const top = Math.floor(Math.min(...ys)) - 3;
|
|
355
|
+
const bottom = Math.ceil(Math.max(...ys)) + 3;
|
|
356
|
+
const channels = [[], [], []];
|
|
357
|
+
const take = (x, y) => {
|
|
358
|
+
if (x < 0 || y < 0 || x >= width || y >= height)
|
|
359
|
+
return;
|
|
360
|
+
const i = (y * width + x) * 4;
|
|
361
|
+
channels[0].push(rgba[i]);
|
|
362
|
+
channels[1].push(rgba[i + 1]);
|
|
363
|
+
channels[2].push(rgba[i + 2]);
|
|
364
|
+
};
|
|
365
|
+
for (let x = left; x <= right; x++) {
|
|
366
|
+
take(x, top);
|
|
367
|
+
take(x, bottom);
|
|
368
|
+
}
|
|
369
|
+
for (let y = top + 1; y < bottom; y++) {
|
|
370
|
+
take(left, y);
|
|
371
|
+
take(right, y);
|
|
372
|
+
}
|
|
373
|
+
if (channels[0].length === 0)
|
|
374
|
+
return [255, 255, 255];
|
|
375
|
+
const median = (v) => v.sort((p, q) => p - q)[v.length >> 1];
|
|
376
|
+
return [median(channels[0]), median(channels[1]), median(channels[2])];
|
|
377
|
+
}
|
|
378
|
+
function luminance(rgb) {
|
|
379
|
+
return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
|
|
380
|
+
}
|
|
381
|
+
/** RGBA canvas pixels to gray (every pixel has R = G = B) or RGB bytes; alpha is dropped (pdf.js paints an opaque page). */
|
|
382
|
+
export function packPixels(rgba) {
|
|
383
|
+
const n = rgba.length >> 2;
|
|
384
|
+
let gray = true;
|
|
385
|
+
for (let i = 0; i < n; i++) {
|
|
386
|
+
const r = rgba[i * 4];
|
|
387
|
+
if (r !== rgba[i * 4 + 1] || r !== rgba[i * 4 + 2]) {
|
|
388
|
+
gray = false;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (gray) {
|
|
393
|
+
const pixels = new Uint8Array(n);
|
|
394
|
+
for (let i = 0; i < n; i++)
|
|
395
|
+
pixels[i] = rgba[i * 4];
|
|
396
|
+
return { pixels, channels: 1 };
|
|
397
|
+
}
|
|
398
|
+
const pixels = new Uint8Array(n * 3);
|
|
399
|
+
for (let i = 0; i < n; i++) {
|
|
400
|
+
pixels[i * 3] = rgba[i * 4];
|
|
401
|
+
pixels[i * 3 + 1] = rgba[i * 4 + 1];
|
|
402
|
+
pixels[i * 3 + 2] = rgba[i * 4 + 2];
|
|
403
|
+
}
|
|
404
|
+
return { pixels, channels: 3 };
|
|
405
|
+
}
|
package/dist/pdf/textstate.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
|
|
5
5
|
* 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
|
|
6
|
-
* fill colour (to spot white-on-white text),
|
|
6
|
+
* fill colour (to spot white-on-white text), constant alpha from ExtGState
|
|
7
|
+
* (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
|
|
7
8
|
* class is used by the extractor (to compute glyph origins) and by the
|
|
8
9
|
* surgery (to know the state at any operator).
|
|
9
10
|
*/
|
|
@@ -36,6 +37,15 @@ export interface GraphicsState {
|
|
|
36
37
|
fillLuminance: number | null;
|
|
37
38
|
/** Stroke luminance, for render modes that only stroke. */
|
|
38
39
|
strokeLuminance: number | null;
|
|
40
|
+
/** Constant alpha (ExtGState /ca) for filling, 0..1. */
|
|
41
|
+
fillAlpha: number;
|
|
42
|
+
/** Constant alpha (ExtGState /CA) for stroking, 0..1. */
|
|
43
|
+
strokeAlpha: number;
|
|
44
|
+
}
|
|
45
|
+
/** The alpha entries of a named ExtGState (absent when the dictionary does not set them). */
|
|
46
|
+
export interface ExtGStateAlpha {
|
|
47
|
+
fillAlpha?: number;
|
|
48
|
+
strokeAlpha?: number;
|
|
39
49
|
}
|
|
40
50
|
export declare function initialState(ctm?: Matrix): GraphicsState;
|
|
41
51
|
/**
|
|
@@ -58,10 +68,11 @@ export declare class StateMachine {
|
|
|
58
68
|
constructor(initial: GraphicsState);
|
|
59
69
|
/**
|
|
60
70
|
* Apply one operator's effect on the state. `resolveFont` maps a resource
|
|
61
|
-
* name to a LoadedFont (undefined when unknown)
|
|
62
|
-
*
|
|
71
|
+
* name to a LoadedFont (undefined when unknown); `resolveGState` maps an
|
|
72
|
+
* ExtGState resource name to its alpha entries (without it `gs` is ignored).
|
|
73
|
+
* Text-showing operators are not handled here (caller handles them).
|
|
63
74
|
*/
|
|
64
|
-
update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined): void;
|
|
75
|
+
update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined, resolveGState?: (name: string) => ExtGStateAlpha | undefined): void;
|
|
65
76
|
nextLine(): void;
|
|
66
77
|
/** Move the text matrix by an unscaled text-space advance (already includes Tfs, Tc, Tw, Th). */
|
|
67
78
|
advanceText(tx: number): void;
|
package/dist/pdf/textstate.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
|
|
5
5
|
* 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
|
|
6
|
-
* fill colour (to spot white-on-white text),
|
|
6
|
+
* fill colour (to spot white-on-white text), constant alpha from ExtGState
|
|
7
|
+
* (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
|
|
7
8
|
* class is used by the extractor (to compute glyph origins) and by the
|
|
8
9
|
* surgery (to know the state at any operator).
|
|
9
10
|
*/
|
|
@@ -50,7 +51,7 @@ export function matrixFromOperands(ops) {
|
|
|
50
51
|
return [n[0], n[1], n[2], n[3], n[4], n[5]];
|
|
51
52
|
}
|
|
52
53
|
function cloneState(s) {
|
|
53
|
-
return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance };
|
|
54
|
+
return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance, fillAlpha: s.fillAlpha, strokeAlpha: s.strokeAlpha };
|
|
54
55
|
}
|
|
55
56
|
export function initialState(ctm = IDENTITY) {
|
|
56
57
|
return {
|
|
@@ -58,6 +59,8 @@ export function initialState(ctm = IDENTITY) {
|
|
|
58
59
|
text: { font: null, fontName: '', fontSize: 0, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, renderMode: 0 },
|
|
59
60
|
fillLuminance: 0,
|
|
60
61
|
strokeLuminance: 0,
|
|
62
|
+
fillAlpha: 1,
|
|
63
|
+
strokeAlpha: 1,
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
66
|
function num(o, fallback = 0) {
|
|
@@ -103,10 +106,11 @@ export class StateMachine {
|
|
|
103
106
|
}
|
|
104
107
|
/**
|
|
105
108
|
* Apply one operator's effect on the state. `resolveFont` maps a resource
|
|
106
|
-
* name to a LoadedFont (undefined when unknown)
|
|
107
|
-
*
|
|
109
|
+
* name to a LoadedFont (undefined when unknown); `resolveGState` maps an
|
|
110
|
+
* ExtGState resource name to its alpha entries (without it `gs` is ignored).
|
|
111
|
+
* Text-showing operators are not handled here (caller handles them).
|
|
108
112
|
*/
|
|
109
|
-
update(op, resolveFont) {
|
|
113
|
+
update(op, resolveFont, resolveGState) {
|
|
110
114
|
const s = this.state;
|
|
111
115
|
const o = op.operands;
|
|
112
116
|
switch (op.op) {
|
|
@@ -234,6 +238,15 @@ export class StateMachine {
|
|
|
234
238
|
s.strokeLuminance = this.strokeSpace && nums.length === o.length ? luminanceOf(nums, this.strokeSpace) : null;
|
|
235
239
|
break;
|
|
236
240
|
}
|
|
241
|
+
case 'gs': {
|
|
242
|
+
const nameOp = o[0];
|
|
243
|
+
const alpha = nameOp && nameOp.kind === 'name' ? resolveGState?.(nameOp.value) : undefined;
|
|
244
|
+
if (alpha?.fillAlpha !== undefined)
|
|
245
|
+
s.fillAlpha = clamp01(alpha.fillAlpha);
|
|
246
|
+
if (alpha?.strokeAlpha !== undefined)
|
|
247
|
+
s.strokeAlpha = clamp01(alpha.strokeAlpha);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
237
250
|
default:
|
|
238
251
|
break;
|
|
239
252
|
}
|
package/dist/pdf/types.d.ts
CHANGED
|
@@ -81,8 +81,13 @@ export interface GlyphRun {
|
|
|
81
81
|
glyphs: Glyph[];
|
|
82
82
|
/** Text render mode at the time of showing (3 and 7 paint nothing: invisible OCR layers). */
|
|
83
83
|
renderMode: number;
|
|
84
|
-
/** True when the run is not visible (render mode 3/7,
|
|
84
|
+
/** True when the run is not visible (render mode 3/7, white, fully transparent, tiny, or painted under an opaque image). */
|
|
85
85
|
hidden: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Hidden text over an image: an OCR layer, the reading of what a scanned picture shows. A
|
|
88
|
+
* redaction that touches it is painted into the picture (the page is exported as an image).
|
|
89
|
+
*/
|
|
90
|
+
ocr?: boolean;
|
|
86
91
|
/** Flat-text offset of each glyph's first character (parallel to glyphs). */
|
|
87
92
|
textOffsets: number[];
|
|
88
93
|
/** User-space vector of one em (1000 glyph units) along the baseline, at this run's transform. */
|
|
@@ -134,6 +139,11 @@ export interface PdfPageInfo {
|
|
|
134
139
|
hasImages: boolean;
|
|
135
140
|
/** True when the page shows images but no glyph at all (scanned page). */
|
|
136
141
|
imageOnly: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* True when hidden text lies over an image on the page (a scan with an OCR text layer).
|
|
144
|
+
* Redacted values in it are painted over in the picture, and the page is exported as an image.
|
|
145
|
+
*/
|
|
146
|
+
ocrText: boolean;
|
|
137
147
|
/** Flat-text range of the page. */
|
|
138
148
|
textStart: number;
|
|
139
149
|
textEnd: number;
|
|
@@ -171,9 +181,9 @@ export interface PdfWriteOptions {
|
|
|
171
181
|
allowUnredactable?: boolean;
|
|
172
182
|
/** Placeholder fitting policy. */
|
|
173
183
|
fit?: PlaceholderFit;
|
|
174
|
-
/** Rasterize pages whose text cannot be edited safely instead of refusing the file. Default true. */
|
|
184
|
+
/** Rasterize pages whose text cannot be edited safely instead of refusing the file. Default true. Scan pages are always rendered. */
|
|
175
185
|
rasterizeUnsafePages?: boolean;
|
|
176
|
-
/** DPI for rasterized pages. Default 150. */
|
|
186
|
+
/** DPI for rasterized pages. Default 150; scan pages use the scan's own resolution (at least this, at most 300). */
|
|
177
187
|
rasterDpi?: number;
|
|
178
188
|
/** Skip the post-write verification (tests only; never in hosts). */
|
|
179
189
|
skipVerify?: boolean;
|
|
@@ -184,8 +194,13 @@ export interface PdfWriteResult {
|
|
|
184
194
|
warnings: string[];
|
|
185
195
|
/** Parts dropped from the output (annotations, forms, ...). */
|
|
186
196
|
removed: RemovedPart[];
|
|
187
|
-
/** 0-based indexes of pages that were rasterized. */
|
|
197
|
+
/** 0-based indexes of pages that were rasterized because their text could not be edited safely (black boxes). */
|
|
188
198
|
rasterizedPages: number[];
|
|
199
|
+
/**
|
|
200
|
+
* 0-based indexes of scan pages with an OCR text layer whose values were painted over in the
|
|
201
|
+
* picture (placeholder labels at the values' places); each is exported as an image.
|
|
202
|
+
*/
|
|
203
|
+
scanPages: number[];
|
|
189
204
|
/** Placeholders whose fit needed a line shift (informational). */
|
|
190
205
|
shiftedLines: number;
|
|
191
206
|
}
|
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,13 @@ 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[];
|
|
44
54
|
/**
|
|
45
55
|
* Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
|
|
46
56
|
* 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,15 @@ 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";
|
|
31
33
|
import { serializeToUnicode } from "./cmap.js";
|
|
32
34
|
import { planEdits, rewriteStream, segmentsFor } from "./surgery.js";
|
|
33
35
|
import { PdfVerifyError, verifyPdf } from "./verify.js";
|
|
34
36
|
import { lexContent } from "./lexer.js";
|
|
37
|
+
/** Scan pages render at the scan's own resolution, up to this. */
|
|
38
|
+
const MAX_SCAN_DPI = 300;
|
|
39
|
+
/** Pixel budget of one rendered scan page (a 300 dpi A3 page fits; larger pages render coarser). */
|
|
40
|
+
const MAX_SCAN_PIXELS = 40000000;
|
|
35
41
|
/** Same normalised date the docx writer stamps. */
|
|
36
42
|
export const NORMALISED_PDF_DATE = new Date('2000-01-01T00:00:00Z');
|
|
37
43
|
export class StalePdfExtractionError extends Error {
|
|
@@ -112,13 +118,20 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
112
118
|
if (page.issues.length > 0 || page.oracleText)
|
|
113
119
|
rasterPages.add(page.index);
|
|
114
120
|
}
|
|
121
|
+
// Scan pages: an edit in an OCR text layer would leave the value in the picture under it, so the
|
|
122
|
+
// page is rendered and the value painted over where the layer says it is.
|
|
123
|
+
const scanPages = new Set();
|
|
124
|
+
for (const s of segments)
|
|
125
|
+
if (s.run.ocr && !rasterPages.has(s.run.page))
|
|
126
|
+
scanPages.add(s.run.page);
|
|
127
|
+
const paintedPages = new Set([...rasterPages, ...scanPages]);
|
|
115
128
|
// Text in a pattern cell or soft-mask group cannot be blacked out in a raster (the cell is tiled
|
|
116
129
|
// 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 = [...
|
|
130
|
+
const hostile = [...paintedPages].filter((p) => model.pages[p]?.hostile);
|
|
118
131
|
if (hostile.length > 0) {
|
|
119
132
|
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
133
|
}
|
|
121
|
-
const rasterWithAux = [...
|
|
134
|
+
const rasterWithAux = [...paintedPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
|
|
122
135
|
if (rasterWithAux.length > 0) {
|
|
123
136
|
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
137
|
}
|
|
@@ -142,9 +155,9 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
142
155
|
const stream = model.streams.get(key);
|
|
143
156
|
if (!stream)
|
|
144
157
|
continue;
|
|
145
|
-
if ([...stream.pages].every((p) =>
|
|
158
|
+
if ([...stream.pages].every((p) => paintedPages.has(p)))
|
|
146
159
|
continue;
|
|
147
|
-
const segs = (plan.byStream.get(key) ?? []).filter((s) => !
|
|
160
|
+
const segs = (plan.byStream.get(key) ?? []).filter((s) => !paintedPages.has(s.run.page));
|
|
148
161
|
const result = rewriteStream(model, stream, runsByStreamOp.get(key) ?? new Map(), segs, plan.shiftedRuns);
|
|
149
162
|
for (const [k, codes] of result.usedCodes)
|
|
150
163
|
mergeCodes(usedCodes, k, codes);
|
|
@@ -167,12 +180,39 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
167
180
|
scrubToUnicode(model, editedFonts, usedCodes, warnings);
|
|
168
181
|
scrubSourceSideChannels(model);
|
|
169
182
|
// 5. Rebuild into the fresh document.
|
|
170
|
-
const keptIndexes = model.pages.map((p) => p.index).filter((i) => !
|
|
183
|
+
const keptIndexes = model.pages.map((p) => p.index).filter((i) => !paintedPages.has(i));
|
|
171
184
|
const copied = keptIndexes.length > 0 ? await outDoc.copyPages(model.doc, keptIndexes) : [];
|
|
172
185
|
const copiedByIndex = new Map();
|
|
173
186
|
keptIndexes.forEach((idx, k) => copiedByIndex.set(idx, copied[k]));
|
|
174
187
|
const rasterized = [];
|
|
188
|
+
const scanned = [];
|
|
189
|
+
const sortedReplacements = [...all].sort((a, b) => a.start - b.start || a.end - b.end); // segmentsFor's order
|
|
175
190
|
for (const page of model.pages) {
|
|
191
|
+
if (scanPages.has(page.index)) {
|
|
192
|
+
const marks = paintMarksForPage(segments, page.index, sortedReplacements);
|
|
193
|
+
let raster;
|
|
194
|
+
try {
|
|
195
|
+
raster = await rasterizeScanPage(oracle, page.index, marks, { dpi: scanDpi(page, options.rasterDpi), canvasFactory: options.canvasFactory });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw new UnsupportedDocumentError('unredactable-parts', `page ${page.index + 1} cannot be rendered: ${err.message ?? String(err)}`, [`page ${page.index + 1}`]);
|
|
199
|
+
}
|
|
200
|
+
// Raw pixels in a Flate image (gray for a gray scan): lossless, and far smaller than an RGBA PNG.
|
|
201
|
+
const image = outDoc.context.register(outDoc.context.flateStream(raster.pixels, {
|
|
202
|
+
Type: 'XObject',
|
|
203
|
+
Subtype: 'Image',
|
|
204
|
+
Width: raster.widthPx,
|
|
205
|
+
Height: raster.heightPx,
|
|
206
|
+
ColorSpace: raster.channels === 1 ? 'DeviceGray' : 'DeviceRGB',
|
|
207
|
+
BitsPerComponent: 8,
|
|
208
|
+
}));
|
|
209
|
+
const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
|
|
210
|
+
const name = newPage.node.newXObject('Scan', image);
|
|
211
|
+
newPage.pushOperators(pushGraphicsState(), concatTransformationMatrix(raster.widthPt, 0, 0, raster.heightPt, 0, 0), drawObject(name), popGraphicsState());
|
|
212
|
+
scanned.push(page.index);
|
|
213
|
+
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 (its text layer is gone)`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
176
216
|
if (rasterPages.has(page.index)) {
|
|
177
217
|
const boxes = boxesForPage(segments, page.index, fresh.runs, page, [...needles]);
|
|
178
218
|
const dpi = options.rasterDpi ?? 150;
|
|
@@ -217,7 +257,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
217
257
|
scrubOutputObjects(outDoc);
|
|
218
258
|
stampMetadata(outDoc);
|
|
219
259
|
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))] };
|
|
260
|
+
return { bytes, needles: [...needles], removedSequences, rasterized, scanned, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
|
|
221
261
|
}
|
|
222
262
|
finally {
|
|
223
263
|
await oracle.close();
|
|
@@ -241,6 +281,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
|
|
|
241
281
|
warnings: [...new Set(warnings)],
|
|
242
282
|
removed: produced.removed,
|
|
243
283
|
rasterizedPages: produced.rasterized,
|
|
284
|
+
scanPages: produced.scanned,
|
|
244
285
|
shiftedLines: produced.shiftedLines,
|
|
245
286
|
};
|
|
246
287
|
}
|
|
@@ -550,6 +591,71 @@ function stampMetadata(outDoc) {
|
|
|
550
591
|
outDoc.setCreationDate(NORMALISED_PDF_DATE);
|
|
551
592
|
outDoc.setModificationDate(NORMALISED_PDF_DATE);
|
|
552
593
|
}
|
|
594
|
+
/** Resolution for a scan page: the scan's own (at least `base`, at most 300 dpi), within the pixel budget. */
|
|
595
|
+
function scanDpi(page, base = 150) {
|
|
596
|
+
const dpi = Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi)));
|
|
597
|
+
const squareInches = Math.max(1e-6, (page.info.width / 72) * (page.info.height / 72));
|
|
598
|
+
return Math.max(36, Math.min(dpi, Math.floor(Math.sqrt(MAX_SCAN_PIXELS / squareInches))));
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Where each redacted value sits on a scan page, for painting: one mark per line a value
|
|
602
|
+
* occupies (the pieces of a value on one line, split across operators, join into one), the
|
|
603
|
+
* placeholder on the value's first line and a blank cover on the lines it wraps onto.
|
|
604
|
+
* Whitespace glyphs at either end of a piece are left out.
|
|
605
|
+
*/
|
|
606
|
+
export function paintMarksForPage(segments, page, replacements) {
|
|
607
|
+
const marks = [];
|
|
608
|
+
let lastIndex = -1;
|
|
609
|
+
for (const s of segments) {
|
|
610
|
+
const run = s.run;
|
|
611
|
+
if (run.page !== page || run.space)
|
|
612
|
+
continue;
|
|
613
|
+
let from = s.from;
|
|
614
|
+
let to = s.to;
|
|
615
|
+
while (from < to && run.glyphs[from].unicode.trim() === '')
|
|
616
|
+
from++;
|
|
617
|
+
while (to > from && run.glyphs[to - 1].unicode.trim() === '')
|
|
618
|
+
to--;
|
|
619
|
+
if (from >= to)
|
|
620
|
+
continue;
|
|
621
|
+
const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
|
|
622
|
+
const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
|
|
623
|
+
const ux = run.emX[0] / exLen;
|
|
624
|
+
const uy = run.emX[1] / exLen;
|
|
625
|
+
const vx = run.emY[0] / eyLen;
|
|
626
|
+
const vy = run.emY[1] / eyLen;
|
|
627
|
+
const scale = exLen / (run.local.fontSize * run.local.hscale || 1);
|
|
628
|
+
const first = run.glyphs[from];
|
|
629
|
+
let lo = 0;
|
|
630
|
+
let hi = 0;
|
|
631
|
+
let size = 0;
|
|
632
|
+
for (let i = from; i < to; i++) {
|
|
633
|
+
const g = run.glyphs[i];
|
|
634
|
+
const along = (g.x - first.x) * ux + (g.y - first.y) * uy;
|
|
635
|
+
lo = Math.min(lo, along);
|
|
636
|
+
hi = Math.max(hi, along + g.advance * scale);
|
|
637
|
+
size = Math.max(size, g.fontSize);
|
|
638
|
+
}
|
|
639
|
+
const x = first.x + ux * lo;
|
|
640
|
+
const y = first.y + uy * lo;
|
|
641
|
+
const prev = marks[marks.length - 1];
|
|
642
|
+
if (prev && s.replacementIndex === lastIndex) {
|
|
643
|
+
// Same value: join when this piece continues the previous one's line.
|
|
644
|
+
const across = (x - prev.x) * prev.vx + (y - prev.y) * prev.vy;
|
|
645
|
+
const along = (x - prev.x) * prev.ux + (y - prev.y) * prev.uy;
|
|
646
|
+
const lineSize = Math.max(prev.size, size);
|
|
647
|
+
if (Math.abs(across) < 0.4 * lineSize && along > -0.5 * lineSize && along < prev.length + 2 * lineSize) {
|
|
648
|
+
prev.length = Math.max(prev.length, along + (hi - lo));
|
|
649
|
+
prev.size = lineSize;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const label = s.replacementIndex === lastIndex ? '' : replacements[s.replacementIndex]?.replacement ?? '';
|
|
654
|
+
marks.push({ x, y, ux, uy, vx, vy, length: hi - lo, size, label });
|
|
655
|
+
lastIndex = s.replacementIndex;
|
|
656
|
+
}
|
|
657
|
+
return marks;
|
|
658
|
+
}
|
|
553
659
|
/**
|
|
554
660
|
* Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
|
|
555
661
|
* whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
|