@doccloak/core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,116 @@ 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
+ /**
67
+ * How far along the baseline the value's letters may still reach (an OCR show stretched over its
68
+ * word box): ink between `length` and here is covered even across a gap. Absent: `length`.
69
+ */
70
+ reach?: number;
71
+ /**
72
+ * The value starts or ends inside a word of the OCR layer ("2026" of "2026/03/017"): on that side
73
+ * the cover grows only by IN_WORD_GROWTH, so the rest of the word stays readable.
74
+ */
75
+ startsInWord?: boolean;
76
+ endsInWord?: boolean;
77
+ /** Font size of the value's text, user units. */
78
+ size: number;
79
+ /**
80
+ * Placeholder to show from the cover's start, with any punctuation glued to the value that the
81
+ * cover takes in ("[PHONE_1],"); on the rest of a value wrapped onto the next line only the
82
+ * punctuation glued before it, often '' (the cover alone).
83
+ */
84
+ label: string;
85
+ /** On the rest of a wrapped value: the punctuation glued after it, painted up to the cover's end, where it was printed. */
86
+ labelEnd?: string;
87
+ /**
88
+ * The text column along this mark's baseline (user units from its start): a label growing into
89
+ * blank paper beside a short value stays between the two. Absent: no room is taken.
90
+ */
91
+ columnStart?: number;
92
+ columnEnd?: number;
93
+ }
94
+ /** A rendered scan page: raw pixels, 1 (gray) or 3 (RGB) bytes each, row-major. */
95
+ export interface ScanRaster {
96
+ pixels: Uint8Array;
97
+ channels: 1 | 3;
98
+ widthPx: number;
99
+ heightPx: number;
100
+ widthPt: number;
101
+ heightPt: number;
102
+ /** pdf.js viewport transform of the render: source user space to these pixels. */
103
+ transform: number[];
104
+ }
105
+ /**
106
+ * Render page `index` at `options.dpi`, cover every mark in the paper colour
107
+ * around it and paint its label in the value's place: the baseline the OCR
108
+ * layer gives, the value's width (the label is squeezed, then shrunk, to fit;
109
+ * a shorter label leaves the rest of the width blank). Every cover is painted
110
+ * before any label, so a cover on the next line never clips a label above it.
111
+ * Returns the pixels, in gray when the page is gray (a scan usually is), so
112
+ * the writer can embed them losslessly at a size close to the original scan.
113
+ */
114
+ export declare function rasterizeScanPage(doc: OracleDocument, index: number, marks: PaintMark[], options: RasterizeOptions): Promise<ScanRaster>;
115
+ /**
116
+ * Vertical extent of the value's ink (user units from the baseline): the block of inked rows
117
+ * across [start, end] that holds the letters' bodies, grown up and down until a blank row (the
118
+ * leading between lines), within INK_REACH_BELOW / INK_REACH_ABOVE. Null when no ink is there.
119
+ */
120
+ 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): {
121
+ bottom: number;
122
+ top: number;
123
+ } | null;
124
+ /**
125
+ * Blank paper from `from` along a mark's baseline (user units; `dir` 1 after it, -1 before it), up
126
+ * to `want`: the run of columns with no ink from LABEL_ROOM_BELOW below the baseline to
127
+ * LABEL_ROOM_ABOVE above it (in `em`), ending LABEL_ROOM_GAP em before the next ink. The picture's
128
+ * edge ends it too. 0 without an ink threshold (no ink was measured, so blank cannot be told from print).
129
+ */
130
+ export declare function blankAfter(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], from: number, want: number, em: number, inkBelow: number, dir?: 1 | -1): number;
131
+ /**
132
+ * Marks of ink standing apart above a value's letters, whose `top` is the letters' measured top
133
+ * and `em` their size (see DIACRITIC_*): each as its extent in the mark's frame (user units).
134
+ * The window above the letters is sampled pixel by pixel and split into 8-connected shapes; a
135
+ * shape that reaches the window's top or sides belongs to something else (the line above, the
136
+ * next word) and is left alone.
137
+ */
138
+ export declare function diacriticsAbove(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], start: number, end: number, top: number, em: number, inkBelow: number): Array<{
139
+ start: number;
140
+ end: number;
141
+ top: number;
142
+ }>;
143
+ /**
144
+ * How far the ink of a value really reaches along its baseline (user units from the mark's
145
+ * start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
146
+ * outwards pixel by pixel from inside the value and stops at the first blank gap of a word
147
+ * space, or after MAX_GROWTH em / MAX_GROWTH_SHARE of the value's length.
148
+ */
149
+ export declare function inkExtent(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], m: PaintMark, inkBelow: number): {
150
+ start: number;
151
+ end: number;
152
+ };
153
+ /**
154
+ * The paper colour around a cover: per-channel median of the pixels on a ring
155
+ * three pixels outside the quad's bounding box (mostly background, whatever
156
+ * text the ring crosses is a minority). White when the ring is off canvas.
157
+ */
158
+ export declare function paperColour(rgba: Uint8ClampedArray, width: number, height: number, quad: Array<[number, number]>): [number, number, number];
159
+ /** RGBA canvas pixels to gray (every pixel has R = G = B) or RGB bytes; alpha is dropped (pdf.js paints an opaque page). */
160
+ export declare function packPixels(rgba: Uint8ClampedArray): {
161
+ pixels: Uint8Array;
162
+ channels: 1 | 3;
163
+ };
@@ -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,523 @@ 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, by a share of the value's length (a layer 15 % narrower than the print
112
+ * ends 2 em short of a 13 em e-mail address). A cover grows along the baseline to the end of the
113
+ * ink it touches: the search starts this far inside the value, stops at a blank gap of WORD_GAP
114
+ * (a word space is about 0.28 em, gaps between letters stay under about 0.12 em) and never grows
115
+ * by more than MAX_GROWTH em or MAX_GROWTH_SHARE of the value's length, whichever is more; ink is
116
+ * looked for in the band of the letters' bodies only, so the lines above and below do not count.
117
+ * In em of the value's font size unless said otherwise.
118
+ */
119
+ const INK_SEARCH_INSIDE = 0.25;
120
+ const WORD_GAP = 0.16;
121
+ /**
122
+ * On a side where the value continues as the same word (its letters placed by real proportions),
123
+ * the cover ends in the blank valley between two letters nearest the estimate on the side away
124
+ * from the value, searched up to IN_WORD_SEARCH outwards and IN_WORD_INSIDE inwards: OCR letter
125
+ * boxes can sit half a letter off ("d'ALTAMIR" read with the "A" starting in its middle), and a
126
+ * cover that takes one letter too many keeps the value hidden where one letter too few shows it.
127
+ * Without a valley (touching letters) it ends IN_WORD_GROWTH past the estimate.
128
+ */
129
+ const IN_WORD_SEARCH = 0.8;
130
+ const IN_WORD_INSIDE = 0.05;
131
+ const IN_WORD_GROWTH = 0.06;
132
+ const MAX_GROWTH = 1;
133
+ const MAX_GROWTH_SHARE = 0.35;
134
+ const INK_BAND_BELOW = 0.12;
135
+ const INK_BAND_ABOVE = 0.68;
136
+ /** A pixel is ink when its luminance is below this fraction of the paper's. */
137
+ const INK_LUMINANCE = 0.65;
138
+ /**
139
+ * Label size: the value's ink height (capitals and ascenders reach about 0.72 em) gives the
140
+ * label's em; without ink the OCR font size does, times LABEL_SIZE. The label is squeezed
141
+ * horizontally down to LABEL_MIN_SQUEEZE to fit the value's width, then shrunk.
142
+ */
143
+ const INK_TOP_EM = 0.76;
144
+ /**
145
+ * Marks standing apart above the letters (the dot of "Ż", the accent of "ó" over a word with no
146
+ * ascender, the dot of an "i"): a shape of ink within DIACRITIC_GAP of the letters' top, no taller
147
+ * than DIACRITIC_HEIGHT and no wider than DIACRITIC_WIDTH, that does not run on past
148
+ * DIACRITIC_WINDOW above that top (the line above's descenders do) or out of the cover's sides,
149
+ * gets a cover of its own. In em of the letters' measured size (their top is INK_TOP_EM of it).
150
+ */
151
+ const DIACRITIC_GAP = 0.2;
152
+ const DIACRITIC_HEIGHT = 0.3;
153
+ const DIACRITIC_WIDTH = 0.6;
154
+ const DIACRITIC_WINDOW = 0.5;
155
+ const LABEL_SIZE = 0.8;
156
+ const LABEL_MIN_SQUEEZE = 0.6;
157
+ /**
158
+ * A label longer than its value's width first takes the blank paper after the value (a line end,
159
+ * a wide gap), then before it (a right-aligned line), up to LABEL_ROOM_GAP em before the next ink
160
+ * and inside the text column (PaintMark.columnStart / columnEnd), so a short value's placeholder
161
+ * ("ul.", "557") stays readable; only then is it squeezed and shrunk. The paper must be blank from
162
+ * LABEL_ROOM_BELOW below the baseline to LABEL_ROOM_ABOVE above it (brackets reach that far).
163
+ */
164
+ const LABEL_ROOM_GAP = 0.35;
165
+ const LABEL_ROOM_BELOW = 0.25;
166
+ const LABEL_ROOM_ABOVE = 0.85;
167
+ const LABEL_FONT = '"Liberation Sans", Arial, Helvetica, sans-serif';
168
+ /**
169
+ * Render page `index` at `options.dpi`, cover every mark in the paper colour
170
+ * around it and paint its label in the value's place: the baseline the OCR
171
+ * layer gives, the value's width (the label is squeezed, then shrunk, to fit;
172
+ * a shorter label leaves the rest of the width blank). Every cover is painted
173
+ * before any label, so a cover on the next line never clips a label above it.
174
+ * Returns the pixels, in gray when the page is gray (a scan usually is), so
175
+ * the writer can embed them losslessly at a size close to the original scan.
176
+ */
177
+ export async function rasterizeScanPage(doc, index, marks, options) {
178
+ if (!(options.dpi > 0) || !Number.isFinite(options.dpi))
179
+ throw new RangeError(`invalid dpi ${options.dpi}`);
180
+ const page = await doc.render(index, options.dpi / 72, options.canvasFactory);
181
+ const ctx = page.canvas.context;
182
+ const before = page.canvas.getImageData();
183
+ const [a, b, c, d, e, f] = page.transform;
184
+ const toPx = (x, y) => [a * x + c * y + e, b * x + d * y + f];
185
+ // Measure every cover on the untouched pixels first.
186
+ const covers = marks.map((m) => {
187
+ const at = (along, up) => toPx(m.x + m.ux * along + m.vx * up, m.y + m.uy * along + m.vy * up);
188
+ const pad = COVER_PAD_ALONG * m.size;
189
+ const paper = paperColour(before, page.widthPx, page.heightPx, quadOf(at, -pad, m.length + pad, -COVER_BELOW * m.size, COVER_ABOVE * m.size));
190
+ const inkBelow = luminance(paper) * INK_LUMINANCE;
191
+ const along = inkExtent(before, page.widthPx, page.heightPx, at, m, inkBelow);
192
+ // Inside a word the cover stops in the valley between two letters: a pixel of margin, not the pad.
193
+ const onePxAlong = 1 / (Math.hypot(...sub(at(1, 0), at(0, 0))) || 1);
194
+ const start = (m.startsInWord ? along.start : Math.min(0, along.start)) - (m.startsInWord ? onePxAlong : pad);
195
+ const end = (m.endsInWord ? along.end : Math.max(m.length, along.end)) + (m.endsInWord ? onePxAlong : pad);
196
+ const rows = inkRows(before, page.widthPx, page.heightPx, at, m, start, end, inkBelow);
197
+ const onePx = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
198
+ const bottom = rows ? Math.min(-COVER_CORE_BELOW * m.size, rows.bottom - 1.5 * onePx) : -COVER_BELOW * m.size;
199
+ const top = rows ? Math.max(COVER_CORE_ABOVE * m.size, rows.top + 1.5 * onePx) : COVER_ABOVE * m.size;
200
+ const ink = rows ? inkColour(before, page.widthPx, page.heightPx, at, start, end, rows.bottom, rows.top, inkBelow) : null;
201
+ const marksAbove = rows && rows.top > 0 ? diacriticsAbove(before, page.widthPx, page.heightPx, at, start, end, rows.top, rows.top / INK_TOP_EM, inkBelow) : [];
202
+ const marks = marksAbove.map((d) => ({ start: d.start - 1.5 * onePxAlong, end: d.end + 1.5 * onePxAlong, bottom: top - onePx, top: d.top + 1.5 * onePx }));
203
+ return { mark: m, at, start, end, bottom, top, inkTop: rows ? rows.top : null, paper, ink, marks };
204
+ });
205
+ ctx.save();
206
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
207
+ ctx.globalAlpha = 1;
208
+ ctx.globalCompositeOperation = 'source-over';
209
+ for (const cv of covers) {
210
+ const fill = `rgb(${cv.paper[0]}, ${cv.paper[1]}, ${cv.paper[2]})`;
211
+ ctx.fillStyle = fill;
212
+ ctx.strokeStyle = fill;
213
+ ctx.lineWidth = 2; // the stroke grows the cover by a pixel on every side (anti-aliased glyph edges)
214
+ for (const box of [cv, ...cv.marks]) {
215
+ const quad = quadOf(cv.at, box.start, box.end, box.bottom, box.top);
216
+ ctx.beginPath();
217
+ ctx.moveTo(quad[0][0], quad[0][1]);
218
+ for (let i = 1; i < 4; i++)
219
+ ctx.lineTo(quad[i][0], quad[i][1]);
220
+ ctx.closePath();
221
+ ctx.fill();
222
+ ctx.stroke();
223
+ }
224
+ }
225
+ // How far each label may reach past its cover into blank paper: half the gap to the next (and
226
+ // the previous) cover on the same line, so two neighbours never claim the same room.
227
+ const reach = covers.map((a) => {
228
+ let right = Infinity;
229
+ let left = Infinity;
230
+ const am = a.mark;
231
+ for (const b of covers) {
232
+ if (b === a)
233
+ continue;
234
+ const bm = b.mark;
235
+ const across = (bm.x - am.x) * am.vx + (bm.y - am.y) * am.vy;
236
+ if (Math.abs(across) > 0.5 * Math.max(am.size, bm.size) || am.ux * bm.ux + am.uy * bm.uy < 0.99)
237
+ continue;
238
+ const along = (t) => (bm.x + bm.ux * t - am.x) * am.ux + (bm.y + bm.uy * t - am.y) * am.uy;
239
+ const bs = along(b.start);
240
+ const be = along(b.end);
241
+ if (bs >= a.end)
242
+ right = Math.min(right, (bs - a.end) / 2);
243
+ else if (be <= a.start)
244
+ left = Math.min(left, (a.start - be) / 2);
245
+ }
246
+ return { right, left };
247
+ });
248
+ covers.forEach((cv, k) => {
249
+ const m = cv.mark;
250
+ // The label from the cover's start; the punctuation of a wrapped value's rest up to its end.
251
+ for (const [text, alignEnd] of [[m.label, false], [m.labelEnd ?? '', true]]) {
252
+ if (!text)
253
+ continue;
254
+ const pad = COVER_PAD_ALONG * m.size;
255
+ let from = alignEnd ? cv.end - pad : cv.start + pad;
256
+ const [ox, oy] = cv.at(from, 0);
257
+ const [ax, ay] = cv.at(from + 1, 0);
258
+ const [ux, uy] = cv.at(from, 1);
259
+ const pxPerUnit = Math.hypot(ax - ox, ay - oy);
260
+ const pxPerUnitUp = Math.hypot(ux - ox, uy - oy);
261
+ let available = (cv.end - cv.start - 2 * pad) * pxPerUnit;
262
+ const em = cv.inkTop !== null && cv.inkTop > 0 ? cv.inkTop / INK_TOP_EM : LABEL_SIZE * m.size;
263
+ let fontPx = Math.min(em, 1.2 * m.size) * pxPerUnitUp;
264
+ if (!(fontPx > 0) || !(available > 0))
265
+ continue;
266
+ ctx.font = `${fontPx}px ${LABEL_FONT}`;
267
+ const natural = ctx.measureText(text).width;
268
+ if (!alignEnd && natural > available) {
269
+ // Blank paper after the value first, then before it (a right-aligned line, a line start).
270
+ const inkBelow = cv.ink !== null ? luminance(cv.paper) * INK_LUMINANCE : 0;
271
+ // Never past the text column: no room without it.
272
+ const columnRight = Math.max(0, (m.columnEnd ?? -Infinity) - (cv.end - pad));
273
+ const columnLeft = Math.max(0, (cv.start + pad) - (m.columnStart ?? Infinity));
274
+ const after = blankAfter(before, page.widthPx, page.heightPx, cv.at, cv.end - pad, Math.min((natural - available) / pxPerUnit, reach[k].right + pad, columnRight), em, inkBelow);
275
+ available += after * pxPerUnit;
276
+ if (natural > available) {
277
+ const beforeRoom = blankAfter(before, page.widthPx, page.heightPx, cv.at, cv.start + pad, Math.min((natural - available) / pxPerUnit, reach[k].left + pad, columnLeft), em, inkBelow, -1);
278
+ available += beforeRoom * pxPerUnit;
279
+ from -= beforeRoom;
280
+ }
281
+ }
282
+ const [x0, y0] = cv.at(from, 0);
283
+ const [x1, y1] = cv.at(from + 1, 0);
284
+ let squeeze = natural > available ? available / natural : 1;
285
+ if (squeeze < LABEL_MIN_SQUEEZE) {
286
+ fontPx *= squeeze / LABEL_MIN_SQUEEZE;
287
+ squeeze = LABEL_MIN_SQUEEZE;
288
+ ctx.font = `${fontPx}px ${LABEL_FONT}`;
289
+ }
290
+ ctx.save();
291
+ ctx.translate(x0, y0);
292
+ ctx.rotate(Math.atan2(y1 - y0, x1 - x0));
293
+ ctx.scale(squeeze, 1);
294
+ ctx.fillStyle = cv.ink !== null ? `rgb(${cv.ink}, ${cv.ink}, ${cv.ink})` : luminance(cv.paper) > 0.45 ? '#000000' : '#ffffff';
295
+ ctx.textAlign = alignEnd ? 'right' : 'left';
296
+ ctx.textBaseline = 'alphabetic';
297
+ ctx.fillText(text, 0, 0);
298
+ ctx.restore();
299
+ }
300
+ });
301
+ ctx.restore();
302
+ const rgba = page.canvas.getImageData();
303
+ const { pixels, channels } = packPixels(rgba);
304
+ return { pixels, channels, widthPx: page.widthPx, heightPx: page.heightPx, widthPt: page.widthPt, heightPt: page.heightPt, transform: page.transform };
305
+ }
306
+ function sub(p, q) {
307
+ return [p[0] - q[0], p[1] - q[1]];
308
+ }
309
+ /** Pixel corners of the box [lo, hi] along the baseline by [bottom, top] across it. */
310
+ function quadOf(at, lo, hi, bottom, top) {
311
+ return [at(lo, bottom), at(hi, bottom), at(hi, top), at(lo, top)];
312
+ }
313
+ /** Luminance (0..1) of the pixel under a baseline-frame point, or null off canvas. */
314
+ function lumAt(rgba, width, height, p) {
315
+ const px = Math.round(p[0]);
316
+ const py = Math.round(p[1]);
317
+ if (px < 0 || py < 0 || px >= width || py >= height)
318
+ return null;
319
+ const i = (py * width + px) * 4;
320
+ return (0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2]) / 255;
321
+ }
322
+ /**
323
+ * Vertical extent of the value's ink (user units from the baseline): the block of inked rows
324
+ * across [start, end] that holds the letters' bodies, grown up and down until a blank row (the
325
+ * leading between lines), within INK_REACH_BELOW / INK_REACH_ABOVE. Null when no ink is there.
326
+ */
327
+ export function inkRows(rgba, width, height, at, m, start, end, inkBelow) {
328
+ const pxAlong = Math.hypot(...sub(at(1, 0), at(0, 0)));
329
+ const pxUp = Math.hypot(...sub(at(0, 1), at(0, 0)));
330
+ if (!(pxAlong > 0) || !(pxUp > 0))
331
+ return null;
332
+ const stepAlong = 1 / pxAlong;
333
+ const stepUp = 1 / pxUp;
334
+ const rowHasInk = (up) => {
335
+ for (let along = start; along <= end; along += stepAlong) {
336
+ const lum = lumAt(rgba, width, height, at(along, up));
337
+ if (lum !== null && lum < inkBelow)
338
+ return true;
339
+ }
340
+ return false;
341
+ };
342
+ // Seed in the letters' bodies (every letter has ink between the baseline and the x-height).
343
+ let seed = null;
344
+ for (let up = 0.1 * m.size; up <= 0.45 * m.size; up += stepUp) {
345
+ if (rowHasInk(up)) {
346
+ seed = up;
347
+ break;
348
+ }
349
+ }
350
+ if (seed === null)
351
+ return null;
352
+ let top = seed;
353
+ for (let up = seed; up <= INK_REACH_ABOVE * m.size && rowHasInk(up); up += stepUp)
354
+ top = up;
355
+ let bottom = seed;
356
+ for (let up = seed; up >= -INK_REACH_BELOW * m.size && rowHasInk(up); up -= stepUp)
357
+ bottom = up;
358
+ return { bottom, top };
359
+ }
360
+ /**
361
+ * Blank paper from `from` along a mark's baseline (user units; `dir` 1 after it, -1 before it), up
362
+ * to `want`: the run of columns with no ink from LABEL_ROOM_BELOW below the baseline to
363
+ * LABEL_ROOM_ABOVE above it (in `em`), ending LABEL_ROOM_GAP em before the next ink. The picture's
364
+ * edge ends it too. 0 without an ink threshold (no ink was measured, so blank cannot be told from print).
365
+ */
366
+ export function blankAfter(rgba, width, height, at, from, want, em, inkBelow, dir = 1) {
367
+ if (!(want > 0) || !(em > 0) || !(inkBelow > 0))
368
+ return 0;
369
+ const pxAlong = Math.hypot(...sub(at(1, 0), at(0, 0)));
370
+ const pxUp = Math.hypot(...sub(at(0, 1), at(0, 0)));
371
+ if (!(pxAlong > 0) || !(pxUp > 0))
372
+ return 0;
373
+ const stepAlong = 1 / pxAlong;
374
+ const stepUp = 1 / pxUp;
375
+ const gap = LABEL_ROOM_GAP * em;
376
+ for (let d = 0; d <= want + gap; d += stepAlong) {
377
+ for (let up = -LABEL_ROOM_BELOW * em; up <= LABEL_ROOM_ABOVE * em; up += stepUp) {
378
+ const lum = lumAt(rgba, width, height, at(from + dir * d, up));
379
+ if (lum === null || lum < inkBelow)
380
+ return Math.max(0, Math.min(want, d - gap));
381
+ }
382
+ }
383
+ return want;
384
+ }
385
+ /**
386
+ * Marks of ink standing apart above a value's letters, whose `top` is the letters' measured top
387
+ * and `em` their size (see DIACRITIC_*): each as its extent in the mark's frame (user units).
388
+ * The window above the letters is sampled pixel by pixel and split into 8-connected shapes; a
389
+ * shape that reaches the window's top or sides belongs to something else (the line above, the
390
+ * next word) and is left alone.
391
+ */
392
+ export function diacriticsAbove(rgba, width, height, at, start, end, top, em, inkBelow) {
393
+ const pxAlong = Math.hypot(...sub(at(1, 0), at(0, 0)));
394
+ const pxUp = Math.hypot(...sub(at(0, 1), at(0, 0)));
395
+ if (!(pxAlong > 0) || !(pxUp > 0) || !(em > 0) || !(end > start))
396
+ return [];
397
+ const stepAlong = 1 / pxAlong;
398
+ const stepUp = 1 / pxUp;
399
+ const cols = Math.min(4000, Math.ceil((end - start) / stepAlong) + 1);
400
+ const rows = Math.min(400, Math.ceil((DIACRITIC_WINDOW * em) / stepUp));
401
+ if (rows < 2 || cols < 2)
402
+ return [];
403
+ const ink = new Uint8Array(cols * rows);
404
+ for (let j = 0; j < rows; j++) {
405
+ for (let i = 0; i < cols; i++) {
406
+ const lum = lumAt(rgba, width, height, at(start + i * stepAlong, top + (j + 1) * stepUp));
407
+ if (lum !== null && lum < inkBelow)
408
+ ink[j * cols + i] = 1;
409
+ }
410
+ }
411
+ const seen = new Uint8Array(cols * rows);
412
+ const out = [];
413
+ const stack = [];
414
+ for (let p0 = 0; p0 < ink.length; p0++) {
415
+ if (!ink[p0] || seen[p0])
416
+ continue;
417
+ seen[p0] = 1;
418
+ stack.push(p0);
419
+ let i0 = cols, i1 = 0, j0 = rows, j1 = 0;
420
+ while (stack.length > 0) {
421
+ const p = stack.pop();
422
+ const i = p % cols;
423
+ const j = (p - i) / cols;
424
+ if (i < i0)
425
+ i0 = i;
426
+ if (i > i1)
427
+ i1 = i;
428
+ if (j < j0)
429
+ j0 = j;
430
+ if (j > j1)
431
+ j1 = j;
432
+ for (let dj = -1; dj <= 1; dj++) {
433
+ for (let di = -1; di <= 1; di++) {
434
+ const ni = i + di;
435
+ const nj = j + dj;
436
+ if (ni < 0 || nj < 0 || ni >= cols || nj >= rows)
437
+ continue;
438
+ const q = nj * cols + ni;
439
+ if (ink[q] && !seen[q]) {
440
+ seen[q] = 1;
441
+ stack.push(q);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ if (j1 === rows - 1 || i0 === 0 || i1 === cols - 1)
447
+ continue;
448
+ if ((j0 + 1) * stepUp > DIACRITIC_GAP * em)
449
+ continue;
450
+ if ((j1 - j0 + 1) * stepUp > DIACRITIC_HEIGHT * em || (i1 - i0 + 1) * stepAlong > DIACRITIC_WIDTH * em)
451
+ continue;
452
+ out.push({ start: start + i0 * stepAlong, end: start + (i1 + 1) * stepAlong, top: top + (j1 + 1) * stepUp });
453
+ }
454
+ return out;
455
+ }
456
+ /**
457
+ * How far the ink of a value really reaches along its baseline (user units from the mark's
458
+ * start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
459
+ * outwards pixel by pixel from inside the value and stops at the first blank gap of a word
460
+ * space, or after MAX_GROWTH em / MAX_GROWTH_SHARE of the value's length.
461
+ */
462
+ export function inkExtent(rgba, width, height, at, m, inkBelow) {
463
+ const [ox, oy] = at(0, 0);
464
+ const [ax, ay] = at(1, 0);
465
+ const pxPerUnit = Math.hypot(ax - ox, ay - oy);
466
+ if (!(pxPerUnit > 0))
467
+ return { start: 0, end: m.length };
468
+ const step = 1 / pxPerUnit; // one pixel along the baseline, in user units
469
+ const [bx, by] = at(0, INK_BAND_BELOW * -m.size);
470
+ const [tx, ty] = at(0, INK_BAND_ABOVE * m.size);
471
+ const bandPx = Math.max(1, Math.ceil(Math.hypot(tx - bx, ty - by)));
472
+ const isInk = (along) => {
473
+ for (let k = 0; k <= bandPx; k++) {
474
+ const up = -INK_BAND_BELOW * m.size + (k / bandPx) * (INK_BAND_ABOVE + INK_BAND_BELOW) * m.size;
475
+ const lum = lumAt(rgba, width, height, at(along, up));
476
+ if (lum !== null && lum < inkBelow)
477
+ return true;
478
+ }
479
+ return false;
480
+ };
481
+ const inside = Math.min(INK_SEARCH_INSIDE * m.size, m.length / 2);
482
+ const gap = WORD_GAP * m.size;
483
+ const growth = Math.max(MAX_GROWTH * m.size, MAX_GROWTH_SHARE * m.length);
484
+ // Ink up to `reach` belongs to the value whatever the gaps: the search for the end starts there.
485
+ let end = m.length;
486
+ if (m.reach !== undefined && m.reach > m.length) {
487
+ for (let along = m.length; along <= m.reach; along += step)
488
+ if (isInk(along))
489
+ end = along + step;
490
+ }
491
+ const reach = (from, dir, limit) => {
492
+ let last = from;
493
+ for (let along = from; dir > 0 ? along <= limit : along >= limit; along += dir * step) {
494
+ if (isInk(along))
495
+ last = along;
496
+ else if (Math.abs(along - last) >= gap)
497
+ break;
498
+ }
499
+ return last + dir * step;
500
+ };
501
+ // The blank column run nearest `at` on the `outward` side (-1 before the value, 1 after it): the
502
+ // valley between two letters.
503
+ const valley = (at, outward) => {
504
+ const lo = outward < 0 ? at - IN_WORD_SEARCH * m.size : at - IN_WORD_INSIDE * m.size;
505
+ const hi = outward < 0 ? at + IN_WORD_INSIDE * m.size : at + IN_WORD_SEARCH * m.size;
506
+ let best = null;
507
+ let runStart = null;
508
+ for (let along = lo; along <= hi + step; along += step) {
509
+ if (!isInk(along) && along <= hi) {
510
+ if (runStart === null)
511
+ runStart = along;
512
+ continue;
513
+ }
514
+ if (runStart !== null) {
515
+ const centre = (runStart + along - step) / 2;
516
+ if (best === null || Math.abs(centre - at) < Math.abs(best - at))
517
+ best = centre;
518
+ runStart = null;
519
+ }
520
+ }
521
+ return best;
522
+ };
523
+ const startAt = m.startsInWord ? (valley(0, -1) ?? -IN_WORD_GROWTH * m.size) : Math.min(0, reach(inside, -1, -growth));
524
+ const endAt = m.endsInWord ? (valley(m.length, 1) ?? m.length + IN_WORD_GROWTH * m.size) : Math.max(end, reach(end - inside, 1, end + growth));
525
+ return { start: startAt, end: endAt };
526
+ }
527
+ /**
528
+ * Gray level (0..255) of the ink in the box [start, end] x [bottom, top]: the median luminance of
529
+ * its ink pixels, without their hue (a label never repeats the colour a value was printed in).
530
+ * Null without ink.
531
+ */
532
+ function inkColour(rgba, width, height, at, start, end, bottom, top, inkBelow) {
533
+ const stepAlong = 1 / (Math.hypot(...sub(at(1, 0), at(0, 0))) || 1);
534
+ const stepUp = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
535
+ const levels = [];
536
+ for (let up = bottom; up <= top; up += stepUp) {
537
+ for (let along = start; along <= end; along += stepAlong) {
538
+ const [x, y] = at(along, up);
539
+ const px = Math.round(x);
540
+ const py = Math.round(y);
541
+ if (px < 0 || py < 0 || px >= width || py >= height)
542
+ continue;
543
+ const i = (py * width + px) * 4;
544
+ const level = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
545
+ if (level / 255 < inkBelow)
546
+ levels.push(level);
547
+ }
548
+ }
549
+ if (levels.length === 0)
550
+ return null;
551
+ return Math.round(levels.sort((p, q) => p - q)[levels.length >> 1]);
552
+ }
553
+ /**
554
+ * The paper colour around a cover: per-channel median of the pixels on a ring
555
+ * three pixels outside the quad's bounding box (mostly background, whatever
556
+ * text the ring crosses is a minority). White when the ring is off canvas.
557
+ */
558
+ export function paperColour(rgba, width, height, quad) {
559
+ const xs = quad.map((p) => p[0]);
560
+ const ys = quad.map((p) => p[1]);
561
+ const left = Math.floor(Math.min(...xs)) - 3;
562
+ const right = Math.ceil(Math.max(...xs)) + 3;
563
+ const top = Math.floor(Math.min(...ys)) - 3;
564
+ const bottom = Math.ceil(Math.max(...ys)) + 3;
565
+ const channels = [[], [], []];
566
+ const take = (x, y) => {
567
+ if (x < 0 || y < 0 || x >= width || y >= height)
568
+ return;
569
+ const i = (y * width + x) * 4;
570
+ channels[0].push(rgba[i]);
571
+ channels[1].push(rgba[i + 1]);
572
+ channels[2].push(rgba[i + 2]);
573
+ };
574
+ for (let x = left; x <= right; x++) {
575
+ take(x, top);
576
+ take(x, bottom);
577
+ }
578
+ for (let y = top + 1; y < bottom; y++) {
579
+ take(left, y);
580
+ take(right, y);
581
+ }
582
+ if (channels[0].length === 0)
583
+ return [255, 255, 255];
584
+ const median = (v) => v.sort((p, q) => p - q)[v.length >> 1];
585
+ return [median(channels[0]), median(channels[1]), median(channels[2])];
586
+ }
587
+ function luminance(rgb) {
588
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
589
+ }
590
+ /** RGBA canvas pixels to gray (every pixel has R = G = B) or RGB bytes; alpha is dropped (pdf.js paints an opaque page). */
591
+ export function packPixels(rgba) {
592
+ const n = rgba.length >> 2;
593
+ let gray = true;
594
+ for (let i = 0; i < n; i++) {
595
+ const r = rgba[i * 4];
596
+ if (r !== rgba[i * 4 + 1] || r !== rgba[i * 4 + 2]) {
597
+ gray = false;
598
+ break;
599
+ }
600
+ }
601
+ if (gray) {
602
+ const pixels = new Uint8Array(n);
603
+ for (let i = 0; i < n; i++)
604
+ pixels[i] = rgba[i * 4];
605
+ return { pixels, channels: 1 };
606
+ }
607
+ const pixels = new Uint8Array(n * 3);
608
+ for (let i = 0; i < n; i++) {
609
+ pixels[i * 3] = rgba[i * 4];
610
+ pixels[i * 3 + 1] = rgba[i * 4 + 1];
611
+ pixels[i * 3 + 2] = rgba[i * 4 + 2];
612
+ }
613
+ return { pixels, channels: 3 };
614
+ }
@@ -6,7 +6,7 @@
6
6
  * The extraction keeps the input bytes; the writer re-extracts from them so
7
7
  * a stale extraction can never be applied to different bytes.
8
8
  */
9
- import type { GlyphRun, PdfAssetPaths, PdfPageInfo, PdfReadOptions, RemovedPart } from './types.js';
9
+ import type { ExtraOcrPage, GlyphRun, PdfAssetPaths, PdfPageInfo, PdfReadOptions, RemovedPart } from './types.js';
10
10
  import type { UnredactablePart } from '../dom/errors.js';
11
11
  export interface PdfExtraction {
12
12
  /** Flat text detection runs on. */
@@ -26,6 +26,8 @@ export interface PdfExtraction {
26
26
  rasterOnlyPages: number[];
27
27
  /** Input bytes (the writer's source of truth). */
28
28
  bytes: Uint8Array;
29
+ /** Text DocCloak read with its own OCR where a scan's OCR layer had none (the writer re-creates its runs). */
30
+ extraOcr?: ExtraOcrPage[];
29
31
  assets?: PdfAssetPaths;
30
32
  password?: string;
31
33
  }