@doccloak/core 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -195,6 +195,14 @@ export async function extractPdf(bytes, options = {}) {
195
195
  }
196
196
  }
197
197
  }
198
+ const extra = options.extraOcr?.find((x) => x.page === p);
199
+ if (extra && !record.oracleText && extra.lines.length > 0) {
200
+ record.info.ocrReadWords = appendExtraOcrRuns(p, extra.lines, runs, text);
201
+ // A picture without any text layer, read whole by DocCloak's OCR: from here on a scan with an
202
+ // OCR layer (its values are painted over in the picture), not a page left as it is.
203
+ if (extra.whole && record.info.imageOnly && record.info.ocrReadWords > 0)
204
+ record.info.ocrText = true;
205
+ }
198
206
  record.info.textEnd = text.length;
199
207
  if (record.info.imageOnly && record.info.ocrText) {
200
208
  // A scan with an OCR text layer: detection reads the layer, and the writer paints the
@@ -227,7 +235,7 @@ export async function extractPdf(bytes, options = {}) {
227
235
  text.pageBreak();
228
236
  }
229
237
  const removed = scanDocumentLevelParts(ctx, doc, model, warnings);
230
- const plainText = text.toString();
238
+ const plainText = ocrRulesAsSpaces(text.toString(), runs);
231
239
  aggregatePageParts(unredactable);
232
240
  // Reading-order insertion can put a later operator's text before an earlier one's: keep the
233
241
  // runs in flat-text order (the writer's segment mapping relies on it).
@@ -243,6 +251,29 @@ export async function extractPdf(bytes, options = {}) {
243
251
  model,
244
252
  };
245
253
  }
254
+ /** Characters an OCR reads off a table's vertical rules. */
255
+ const OCR_RULE = /^[|¦]$/;
256
+ /**
257
+ * In OCR text a table's vertical rules read as "|", often glued to a cell's word ("|Kraków"), and
258
+ * the detector then misses the word. Those characters read as spaces in the flat text, one for one,
259
+ * so every offset still maps to its glyph (the glyphs, and the layer written for a painted page,
260
+ * keep what the OCR read).
261
+ */
262
+ function ocrRulesAsSpaces(plain, runs) {
263
+ let units = null;
264
+ for (const run of runs) {
265
+ if (!run.ocr)
266
+ continue;
267
+ run.glyphs.forEach((g, i) => {
268
+ const at = run.textOffsets[i];
269
+ if (!OCR_RULE.test(g.unicode) || at === undefined || at < 0 || plain[at] !== g.unicode)
270
+ return;
271
+ units ?? (units = plain.split(''));
272
+ units[at] = ' ';
273
+ });
274
+ }
275
+ return units ? units.join('') : plain;
276
+ }
246
277
  /** pdf.js `view`: the CropBox clipped to the MediaBox (defaults to US Letter when absent). */
247
278
  function pageBox(mediaBox, cropBox) {
248
279
  const m = mediaBox.length === 4 ? mediaBox : [0, 0, 612, 792];
@@ -554,6 +585,56 @@ function appendOracleRuns(page, items, runs, text) {
554
585
  });
555
586
  });
556
587
  }
588
+ /**
589
+ * Runs for text DocCloak read with its own OCR (one run per word, one glyph per letter, in user
590
+ * space), after the page's own text: a blank line, then each line's words. They are hidden OCR text
591
+ * with no stream behind them (`oracle`), so the writer paints them and never edits a stream for
592
+ * them. Returns the number of words.
593
+ */
594
+ function appendExtraOcrRuns(page, lines, runs, text) {
595
+ let count = 0;
596
+ text.append('\n\n');
597
+ lines.forEach((line, li) => {
598
+ line.forEach((word, wi) => {
599
+ if (wi > 0)
600
+ text.append(' ');
601
+ const glyphs = [];
602
+ const textOffsets = [];
603
+ const advances = [];
604
+ const start = text.length;
605
+ let along = 0;
606
+ for (const g of word.glyphs) {
607
+ textOffsets.push(text.length);
608
+ text.append(g.unicode);
609
+ glyphs.push({ code: -1, codeLength: 0, unicode: g.unicode, width: word.size > 0 ? (g.advance / word.size) * 1000 : 0, advance: g.advance, x: g.x, y: g.y, fontSize: word.size, item: 0, byteOffset: 0 });
610
+ advances.push(along);
611
+ along += g.advance;
612
+ }
613
+ runs.push({
614
+ page,
615
+ streamKey: `ocr:p${page}`,
616
+ opIndex: count,
617
+ fontName: '',
618
+ fontKey: '',
619
+ glyphs,
620
+ renderMode: 3,
621
+ hidden: true,
622
+ ocr: true,
623
+ textOffsets,
624
+ textStart: start,
625
+ textEnd: text.length,
626
+ emX: [word.dir[0] * word.size, word.dir[1] * word.size],
627
+ emY: [word.up[0] * word.size, word.up[1] * word.size],
628
+ local: { ctm: [1, 0, 0, 1, 0, 0], tm: [1, 0, 0, 1, 0, 0], tlm: [1, 0, 0, 1, 0, 0], fontSize: word.size, charSpacing: 0, wordSpacing: 0, hscale: 1, leading: 0, rise: 0, advances },
629
+ oracle: true,
630
+ });
631
+ count++;
632
+ });
633
+ if (li < lines.length - 1)
634
+ text.append('\n');
635
+ });
636
+ return count;
637
+ }
557
638
  /** Constant alpha below this paints nothing a reader can see. */
558
639
  const MIN_VISIBLE_ALPHA = 0.02;
559
640
  class Walker {
@@ -63,10 +63,33 @@ export interface PaintMark {
63
63
  vy: number;
64
64
  /** Length of the value along the baseline, user units. */
65
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;
66
77
  /** Font size of the value's text, user units. */
67
78
  size: number;
68
- /** Placeholder to show; '' only covers (the rest of a value wrapped onto the next line). */
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
+ */
69
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;
70
93
  }
71
94
  /** A rendered scan page: raw pixels, 1 (gray) or 3 (RGB) bytes each, row-major. */
72
95
  export interface ScanRaster {
@@ -76,6 +99,8 @@ export interface ScanRaster {
76
99
  heightPx: number;
77
100
  widthPt: number;
78
101
  heightPt: number;
102
+ /** pdf.js viewport transform of the render: source user space to these pixels. */
103
+ transform: number[];
79
104
  }
80
105
  /**
81
106
  * Render page `index` at `options.dpi`, cover every mark in the paper colour
@@ -96,11 +121,30 @@ export declare function inkRows(rgba: Uint8ClampedArray, width: number, height:
96
121
  bottom: number;
97
122
  top: number;
98
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
+ }>;
99
143
  /**
100
144
  * How far the ink of a value really reaches along its baseline (user units from the mark's
101
145
  * start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
102
146
  * outwards pixel by pixel from inside the value and stops at the first blank gap of a word
103
- * space, or after MAX_GROWTH.
147
+ * space, or after MAX_GROWTH em / MAX_GROWTH_SHARE of the value's length.
104
148
  */
105
149
  export declare function inkExtent(rgba: Uint8ClampedArray, width: number, height: number, at: (along: number, up: number) => [number, number], m: PaintMark, inkBelow: number): {
106
150
  start: number;
@@ -108,15 +108,29 @@ const INK_REACH_BELOW = 0.45;
108
108
  const INK_REACH_ABOVE = 1.1;
109
109
  /**
110
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.
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.
116
118
  */
117
119
  const INK_SEARCH_INSIDE = 0.25;
118
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;
119
132
  const MAX_GROWTH = 1;
133
+ const MAX_GROWTH_SHARE = 0.35;
120
134
  const INK_BAND_BELOW = 0.12;
121
135
  const INK_BAND_ABOVE = 0.68;
122
136
  /** A pixel is ink when its luminance is below this fraction of the paper's. */
@@ -127,8 +141,29 @@ const INK_LUMINANCE = 0.65;
127
141
  * horizontally down to LABEL_MIN_SQUEEZE to fit the value's width, then shrunk.
128
142
  */
129
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;
130
155
  const LABEL_SIZE = 0.8;
131
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;
132
167
  const LABEL_FONT = '"Liberation Sans", Arial, Helvetica, sans-serif';
133
168
  /**
134
169
  * Render page `index` at `options.dpi`, cover every mark in the paper colour
@@ -154,70 +189,119 @@ export async function rasterizeScanPage(doc, index, marks, options) {
154
189
  const paper = paperColour(before, page.widthPx, page.heightPx, quadOf(at, -pad, m.length + pad, -COVER_BELOW * m.size, COVER_ABOVE * m.size));
155
190
  const inkBelow = luminance(paper) * INK_LUMINANCE;
156
191
  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;
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);
159
196
  const rows = inkRows(before, page.widthPx, page.heightPx, at, m, start, end, inkBelow);
160
197
  const onePx = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
161
198
  const bottom = rows ? Math.min(-COVER_CORE_BELOW * m.size, rows.bottom - 1.5 * onePx) : -COVER_BELOW * m.size;
162
199
  const top = rows ? Math.max(COVER_CORE_ABOVE * m.size, rows.top + 1.5 * onePx) : COVER_ABOVE * m.size;
163
200
  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 };
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 };
165
204
  });
166
205
  ctx.save();
167
206
  ctx.setTransform(1, 0, 0, 1, 0, 0);
168
207
  ctx.globalAlpha = 1;
169
208
  ctx.globalCompositeOperation = 'source-over';
170
209
  for (const cv of covers) {
171
- const quad = quadOf(cv.at, cv.start, cv.end, cv.bottom, cv.top);
172
210
  const fill = `rgb(${cv.paper[0]}, ${cv.paper[1]}, ${cv.paper[2]})`;
173
211
  ctx.fillStyle = fill;
174
212
  ctx.strokeStyle = fill;
175
213
  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();
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
+ }
183
224
  }
184
- for (const cv of covers) {
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) => {
185
249
  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;
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;
205
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();
206
299
  }
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
- }
300
+ });
217
301
  ctx.restore();
218
302
  const rgba = page.canvas.getImageData();
219
303
  const { pixels, channels } = packPixels(rgba);
220
- return { pixels, channels, widthPx: page.widthPx, heightPx: page.heightPx, widthPt: page.widthPt, heightPt: page.heightPt };
304
+ return { pixels, channels, widthPx: page.widthPx, heightPx: page.heightPx, widthPt: page.widthPt, heightPt: page.heightPt, transform: page.transform };
221
305
  }
222
306
  function sub(p, q) {
223
307
  return [p[0] - q[0], p[1] - q[1]];
@@ -273,11 +357,107 @@ export function inkRows(rgba, width, height, at, m, start, end, inkBelow) {
273
357
  bottom = up;
274
358
  return { bottom, top };
275
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
+ }
276
456
  /**
277
457
  * How far the ink of a value really reaches along its baseline (user units from the mark's
278
458
  * start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
279
459
  * outwards pixel by pixel from inside the value and stops at the first blank gap of a word
280
- * space, or after MAX_GROWTH.
460
+ * space, or after MAX_GROWTH em / MAX_GROWTH_SHARE of the value's length.
281
461
  */
282
462
  export function inkExtent(rgba, width, height, at, m, inkBelow) {
283
463
  const [ox, oy] = at(0, 0);
@@ -300,6 +480,14 @@ export function inkExtent(rgba, width, height, at, m, inkBelow) {
300
480
  };
301
481
  const inside = Math.min(INK_SEARCH_INSIDE * m.size, m.length / 2);
302
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
+ }
303
491
  const reach = (from, dir, limit) => {
304
492
  let last = from;
305
493
  for (let along = from; dir > 0 ? along <= limit : along >= limit; along += dir * step) {
@@ -310,10 +498,31 @@ export function inkExtent(rgba, width, height, at, m, inkBelow) {
310
498
  }
311
499
  return last + dir * step;
312
500
  };
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)),
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;
316
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 };
317
526
  }
318
527
  /**
319
528
  * Gray level (0..255) of the ink in the box [start, end] x [bottom, top]: the median luminance of
@@ -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
  }