@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.
- package/CHANGELOG.md +177 -0
- package/README.md +34 -9
- package/dist/dom/ocr.d.ts +41 -0
- package/dist/dom/ocr.js +78 -0
- package/dist/env.d.ts +1 -1
- package/dist/pdf/coverage.d.ts +41 -0
- package/dist/pdf/coverage.js +193 -0
- package/dist/pdf/extract.d.ts +7 -1
- package/dist/pdf/extract.js +82 -1
- package/dist/pdf/raster.d.ts +46 -2
- package/dist/pdf/raster.js +260 -51
- package/dist/pdf/read.d.ts +3 -1
- package/dist/pdf/read.js +209 -7
- package/dist/pdf/standard-metrics.d.ts +6 -0
- package/dist/pdf/standard-metrics.js +24 -1
- package/dist/pdf/types.d.ts +99 -0
- package/dist/pdf/verify.js +38 -5
- package/dist/pdf/write.d.ts +7 -0
- package/dist/pdf/write.js +274 -8
- package/dist/pipeline.d.ts +2 -1
- package/dist/pipeline.js +16 -2
- package/dist/regex/rules.data.js +5 -3
- package/package.json +1 -1
- package/rules/pl.json +5 -3
package/dist/pdf/extract.js
CHANGED
|
@@ -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 {
|
package/dist/pdf/raster.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/pdf/raster.js
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
158
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
ctx.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
package/dist/pdf/read.d.ts
CHANGED
|
@@ -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
|
}
|