@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 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 (image without a text layer) and text no decoder can read are
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
 
@@ -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;
@@ -198,5 +198,6 @@ export async function redactOfficeFile(file, options) {
198
198
  unredactable: extraction.unredactable,
199
199
  removed: written.removed,
200
200
  rasterizedPages: written.rasterizedPages,
201
+ scanPages: written.scanPages,
201
202
  };
202
203
  }
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 0.14.0).
45
+ * loadTokenizer is removed (planned for a later release).
46
46
  */
47
47
  buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
48
48
  /**
@@ -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;
@@ -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 OCR text layer: the text layer is redacted, the picture keeps the original`
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
- /* Content walker */
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.
@@ -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
+ };
@@ -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
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
5
5
  * 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
6
- * fill colour (to spot white-on-white text), and the q/Q stack. The same
6
+ * fill colour (to spot white-on-white text), constant alpha from ExtGState
7
+ * (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
7
8
  * class is used by the extractor (to compute glyph origins) and by the
8
9
  * surgery (to know the state at any operator).
9
10
  */
@@ -36,6 +37,15 @@ export interface GraphicsState {
36
37
  fillLuminance: number | null;
37
38
  /** Stroke luminance, for render modes that only stroke. */
38
39
  strokeLuminance: number | null;
40
+ /** Constant alpha (ExtGState /ca) for filling, 0..1. */
41
+ fillAlpha: number;
42
+ /** Constant alpha (ExtGState /CA) for stroking, 0..1. */
43
+ strokeAlpha: number;
44
+ }
45
+ /** The alpha entries of a named ExtGState (absent when the dictionary does not set them). */
46
+ export interface ExtGStateAlpha {
47
+ fillAlpha?: number;
48
+ strokeAlpha?: number;
39
49
  }
40
50
  export declare function initialState(ctm?: Matrix): GraphicsState;
41
51
  /**
@@ -58,10 +68,11 @@ export declare class StateMachine {
58
68
  constructor(initial: GraphicsState);
59
69
  /**
60
70
  * Apply one operator's effect on the state. `resolveFont` maps a resource
61
- * name to a LoadedFont (undefined when unknown). Returns false when the
62
- * operator is a text-showing operator (caller handles it).
71
+ * name to a LoadedFont (undefined when unknown); `resolveGState` maps an
72
+ * ExtGState resource name to its alpha entries (without it `gs` is ignored).
73
+ * Text-showing operators are not handled here (caller handles them).
63
74
  */
64
- update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined): void;
75
+ update(op: ContentOp, resolveFont: (name: string) => LoadedFont | undefined, resolveGState?: (name: string) => ExtGStateAlpha | undefined): void;
65
76
  nextLine(): void;
66
77
  /** Move the text matrix by an unscaled text-space advance (already includes Tfs, Tc, Tw, Th). */
67
78
  advanceText(tx: number): void;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Tracks the subset of the graphics state that positions glyphs (ISO 32000-1
5
5
  * 8.4, 9.3, 9.4): CTM, text state parameters, text matrix and line matrix,
6
- * fill colour (to spot white-on-white text), and the q/Q stack. The same
6
+ * fill colour (to spot white-on-white text), constant alpha from ExtGState
7
+ * (to spot fully transparent text, a common OCR layer), and the q/Q stack. The same
7
8
  * class is used by the extractor (to compute glyph origins) and by the
8
9
  * surgery (to know the state at any operator).
9
10
  */
@@ -50,7 +51,7 @@ export function matrixFromOperands(ops) {
50
51
  return [n[0], n[1], n[2], n[3], n[4], n[5]];
51
52
  }
52
53
  function cloneState(s) {
53
- return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance };
54
+ return { ctm: s.ctm, text: { ...s.text }, fillLuminance: s.fillLuminance, strokeLuminance: s.strokeLuminance, fillAlpha: s.fillAlpha, strokeAlpha: s.strokeAlpha };
54
55
  }
55
56
  export function initialState(ctm = IDENTITY) {
56
57
  return {
@@ -58,6 +59,8 @@ export function initialState(ctm = IDENTITY) {
58
59
  text: { font: null, fontName: '', fontSize: 0, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, renderMode: 0 },
59
60
  fillLuminance: 0,
60
61
  strokeLuminance: 0,
62
+ fillAlpha: 1,
63
+ strokeAlpha: 1,
61
64
  };
62
65
  }
63
66
  function num(o, fallback = 0) {
@@ -103,10 +106,11 @@ export class StateMachine {
103
106
  }
104
107
  /**
105
108
  * Apply one operator's effect on the state. `resolveFont` maps a resource
106
- * name to a LoadedFont (undefined when unknown). Returns false when the
107
- * operator is a text-showing operator (caller handles it).
109
+ * name to a LoadedFont (undefined when unknown); `resolveGState` maps an
110
+ * ExtGState resource name to its alpha entries (without it `gs` is ignored).
111
+ * Text-showing operators are not handled here (caller handles them).
108
112
  */
109
- update(op, resolveFont) {
113
+ update(op, resolveFont, resolveGState) {
110
114
  const s = this.state;
111
115
  const o = op.operands;
112
116
  switch (op.op) {
@@ -234,6 +238,15 @@ export class StateMachine {
234
238
  s.strokeLuminance = this.strokeSpace && nums.length === o.length ? luminanceOf(nums, this.strokeSpace) : null;
235
239
  break;
236
240
  }
241
+ case 'gs': {
242
+ const nameOp = o[0];
243
+ const alpha = nameOp && nameOp.kind === 'name' ? resolveGState?.(nameOp.value) : undefined;
244
+ if (alpha?.fillAlpha !== undefined)
245
+ s.fillAlpha = clamp01(alpha.fillAlpha);
246
+ if (alpha?.strokeAlpha !== undefined)
247
+ s.strokeAlpha = clamp01(alpha.strokeAlpha);
248
+ break;
249
+ }
237
250
  default:
238
251
  break;
239
252
  }
@@ -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, or fully outside the page). */
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
  }
@@ -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 = [...rasterPages].filter((p) => model.pages[p]?.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 = [...rasterPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
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) => rasterPages.has(p)))
158
+ if ([...stream.pages].every((p) => paintedPages.has(p)))
146
159
  continue;
147
- const segs = (plan.byStream.get(key) ?? []).filter((s) => !rasterPages.has(s.run.page));
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) => !rasterPages.has(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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccloak/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Environment-agnostic document anonymization engine for the DocCloak ecosystem.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Witold Lojek",