@doccloak/core 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +386 -0
  2. package/NOTICE +5 -0
  3. package/README.md +148 -9
  4. package/dist/dom/errors.d.ts +1 -1
  5. package/dist/dom/ocr.d.ts +33 -7
  6. package/dist/dom/ocr.js +98 -4
  7. package/dist/dom/office.d.ts +22 -6
  8. package/dist/dom/office.js +45 -11
  9. package/dist/engine.js +4 -5
  10. package/dist/env.d.ts +3 -3
  11. package/dist/pdf/cmap.d.ts +83 -0
  12. package/dist/pdf/cmap.js +659 -0
  13. package/dist/pdf/encodings.d.ts +49 -0
  14. package/dist/pdf/encodings.js +181 -0
  15. package/dist/pdf/extract.d.ts +108 -0
  16. package/dist/pdf/extract.js +1164 -0
  17. package/dist/pdf/fallback-fonts.d.ts +146 -0
  18. package/dist/pdf/fallback-fonts.js +312 -0
  19. package/dist/pdf/fit.d.ts +35 -0
  20. package/dist/pdf/fit.js +60 -0
  21. package/dist/pdf/fonts.d.ts +82 -0
  22. package/dist/pdf/fonts.js +465 -0
  23. package/dist/pdf/glyphlist.d.ts +37 -0
  24. package/dist/pdf/glyphlist.js +485 -0
  25. package/dist/pdf/index.d.ts +21 -0
  26. package/dist/pdf/index.js +7 -0
  27. package/dist/pdf/lexer.d.ts +61 -0
  28. package/dist/pdf/lexer.js +554 -0
  29. package/dist/pdf/objects.d.ts +57 -0
  30. package/dist/pdf/objects.js +217 -0
  31. package/dist/pdf/oracle.d.ts +149 -0
  32. package/dist/pdf/oracle.js +384 -0
  33. package/dist/pdf/raster.d.ts +119 -0
  34. package/dist/pdf/raster.js +405 -0
  35. package/dist/pdf/read.d.ts +36 -0
  36. package/dist/pdf/read.js +65 -0
  37. package/dist/pdf/serializer.d.ts +21 -0
  38. package/dist/pdf/serializer.js +106 -0
  39. package/dist/pdf/standard-metrics.d.ts +27 -0
  40. package/dist/pdf/standard-metrics.js +72 -0
  41. package/dist/pdf/surgery.d.ts +131 -0
  42. package/dist/pdf/surgery.js +847 -0
  43. package/dist/pdf/textstate.d.ts +88 -0
  44. package/dist/pdf/textstate.js +293 -0
  45. package/dist/pdf/types.d.ts +206 -0
  46. package/dist/pdf/types.js +11 -0
  47. package/dist/pdf/verify.d.ts +33 -0
  48. package/dist/pdf/verify.js +446 -0
  49. package/dist/pdf/write.d.ts +62 -0
  50. package/dist/pdf/write.js +750 -0
  51. package/dist/pipeline.d.ts +15 -0
  52. package/dist/pipeline.js +123 -8
  53. package/dist/providers/bardsai.d.ts +1 -1
  54. package/dist/providers/bardsai.js +5 -1
  55. package/dist/providers/gliner-base.d.ts +1 -1
  56. package/dist/providers/gliner-base.js +5 -1
  57. package/dist/providers/gliner.d.ts +1 -1
  58. package/dist/providers/gliner.js +5 -1
  59. package/dist/regex/rules.data.js +13 -8
  60. package/dist/types.d.ts +17 -2
  61. package/dist/types.js +17 -0
  62. package/dist/worker-protocol.d.ts +11 -0
  63. package/dist/worker-protocol.js +41 -8
  64. package/fonts/liberation/LICENSE +102 -0
  65. package/fonts/liberation/LiberationMono-Bold.ttf +0 -0
  66. package/fonts/liberation/LiberationMono-BoldItalic.ttf +0 -0
  67. package/fonts/liberation/LiberationMono-Italic.ttf +0 -0
  68. package/fonts/liberation/LiberationMono-Regular.ttf +0 -0
  69. package/fonts/liberation/LiberationSans-Bold.ttf +0 -0
  70. package/fonts/liberation/LiberationSans-BoldItalic.ttf +0 -0
  71. package/fonts/liberation/LiberationSans-Italic.ttf +0 -0
  72. package/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  73. package/fonts/liberation/LiberationSerif-Bold.ttf +0 -0
  74. package/fonts/liberation/LiberationSerif-BoldItalic.ttf +0 -0
  75. package/fonts/liberation/LiberationSerif-Italic.ttf +0 -0
  76. package/fonts/liberation/LiberationSerif-Regular.ttf +0 -0
  77. package/package.json +11 -2
  78. package/rules/pl.json +7 -3
  79. package/rules/universal.json +6 -5
@@ -0,0 +1,405 @@
1
+ /**
2
+ * @doccloak/core/pdf - page rasterization fallback (T212).
3
+ *
4
+ * A page whose text cannot be edited safely is rendered to pixels by pdf.js
5
+ * and the redacted rectangles are painted black IN THE PIXELS, on the same
6
+ * canvas, after the render: the PNG never contains what was under a box.
7
+ * The caller (writer) replaces the page contents with this image and
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`).
14
+ */
15
+ /**
16
+ * Map a user-space rectangle to canvas pixels through the pdf.js viewport
17
+ * transform [a b c d e f] (x' = a*x + c*y + e, y' = b*x + d*y + f). The four
18
+ * corners are transformed (rotation may swap axes) and their bounding box is
19
+ * returned, expanded outwards to whole pixels. No padding is applied here.
20
+ */
21
+ export function userRectToPixels(viewportTransform, rect) {
22
+ const [a, b, c, d, e, f] = viewportTransform;
23
+ const xs = [rect.x0, rect.x1, rect.x0, rect.x1];
24
+ const ys = [rect.y0, rect.y0, rect.y1, rect.y1];
25
+ let minX = Infinity;
26
+ let minY = Infinity;
27
+ let maxX = -Infinity;
28
+ let maxY = -Infinity;
29
+ for (let i = 0; i < 4; i++) {
30
+ const px = a * xs[i] + c * ys[i] + e;
31
+ const py = b * xs[i] + d * ys[i] + f;
32
+ if (px < minX)
33
+ minX = px;
34
+ if (px > maxX)
35
+ maxX = px;
36
+ if (py < minY)
37
+ minY = py;
38
+ if (py > maxY)
39
+ maxY = py;
40
+ }
41
+ // Round to whole pixels outwards; tolerate float noise (1224.0000001 stays 1224).
42
+ const EPS = 1e-6;
43
+ const x = Math.floor(minX + EPS);
44
+ const y = Math.floor(minY + EPS);
45
+ const x1 = Math.ceil(maxX - EPS);
46
+ const y1 = Math.ceil(maxY - EPS);
47
+ return { x, y, w: Math.max(0, x1 - x), h: Math.max(0, y1 - y) };
48
+ }
49
+ /** Pad a pixel box by `pad` on every side and clamp it to the canvas. Empty when the box lies outside. */
50
+ export function padAndClamp(box, pad, widthPx, heightPx) {
51
+ const x0 = Math.max(0, box.x - pad);
52
+ const y0 = Math.max(0, box.y - pad);
53
+ const x1 = Math.min(widthPx, box.x + box.w + pad);
54
+ const y1 = Math.min(heightPx, box.y + box.h + pad);
55
+ if (x1 <= x0 || y1 <= y0)
56
+ return { x: 0, y: 0, w: 0, h: 0 };
57
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
58
+ }
59
+ /** Pixels of padding around every box (covers anti-aliased glyph edges). */
60
+ const BOX_PAD_PX = 1;
61
+ /**
62
+ * Render page `index` at `options.dpi` (pdf.js applies /Rotate as a viewer
63
+ * would), paint every `boxes` rectangle black on the rendered pixels, and
64
+ * encode the canvas as PNG.
65
+ */
66
+ export async function rasterizePage(doc, index, boxes, options) {
67
+ if (!(options.dpi > 0) || !Number.isFinite(options.dpi))
68
+ throw new RangeError(`invalid dpi ${options.dpi}`);
69
+ const scale = options.dpi / 72;
70
+ const page = await doc.render(index, scale, options.canvasFactory);
71
+ const ctx = page.canvas.context;
72
+ // pdf.js leaves its own transform / composite state on the context: draw
73
+ // in plain device pixels with an opaque black brush.
74
+ ctx.save();
75
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
76
+ ctx.globalAlpha = 1;
77
+ ctx.globalCompositeOperation = 'source-over';
78
+ ctx.fillStyle = '#000000';
79
+ for (const box of boxes) {
80
+ const px = padAndClamp(userRectToPixels(page.transform, box), BOX_PAD_PX, page.widthPx, page.heightPx);
81
+ if (px.w === 0 || px.h === 0)
82
+ continue;
83
+ ctx.fillRect(px.x, px.y, px.w, px.h);
84
+ }
85
+ ctx.restore();
86
+ const png = await page.canvas.toPng();
87
+ return {
88
+ png,
89
+ widthPx: page.widthPx,
90
+ heightPx: page.heightPx,
91
+ widthPt: page.widthPt,
92
+ heightPt: page.heightPt,
93
+ };
94
+ }
95
+ /**
96
+ * Cover extent around a value, in em of its font size: along the baseline, and below and above
97
+ * it when the picture shows no ink to measure. The OCR layer's font size is only a hint (layers
98
+ * often stretch it to the line height), so the cover's height and the label's size come from
99
+ * the ink itself (see inkRows), within INK_REACH_BELOW / INK_REACH_ABOVE of the baseline; the
100
+ * cover always holds the core band COVER_CORE_BELOW..COVER_CORE_ABOVE.
101
+ */
102
+ const COVER_PAD_ALONG = 0.12;
103
+ const COVER_BELOW = 0.32;
104
+ const COVER_ABOVE = 0.92;
105
+ const COVER_CORE_BELOW = 0.15;
106
+ const COVER_CORE_ABOVE = 0.6;
107
+ const INK_REACH_BELOW = 0.45;
108
+ const INK_REACH_ABOVE = 1.1;
109
+ /**
110
+ * OCR layers place words well but size letters from their own font, so a value's ink can run
111
+ * past the layer's ends. A cover grows along the baseline to the end of the ink it touches: the
112
+ * search starts this far inside the value, stops at a blank gap of WORD_GAP (a word space is
113
+ * about 0.28 em, gaps between letters stay under about 0.12 em) and never grows by more than
114
+ * MAX_GROWTH; ink is looked for in the band of the letters' bodies only, so the lines above and
115
+ * below do not count. All in em of the value's font size.
116
+ */
117
+ const INK_SEARCH_INSIDE = 0.25;
118
+ const WORD_GAP = 0.16;
119
+ const MAX_GROWTH = 1;
120
+ const INK_BAND_BELOW = 0.12;
121
+ const INK_BAND_ABOVE = 0.68;
122
+ /** A pixel is ink when its luminance is below this fraction of the paper's. */
123
+ const INK_LUMINANCE = 0.65;
124
+ /**
125
+ * Label size: the value's ink height (capitals and ascenders reach about 0.72 em) gives the
126
+ * label's em; without ink the OCR font size does, times LABEL_SIZE. The label is squeezed
127
+ * horizontally down to LABEL_MIN_SQUEEZE to fit the value's width, then shrunk.
128
+ */
129
+ const INK_TOP_EM = 0.76;
130
+ const LABEL_SIZE = 0.8;
131
+ const LABEL_MIN_SQUEEZE = 0.6;
132
+ const LABEL_FONT = '"Liberation Sans", Arial, Helvetica, sans-serif';
133
+ /**
134
+ * Render page `index` at `options.dpi`, cover every mark in the paper colour
135
+ * around it and paint its label in the value's place: the baseline the OCR
136
+ * layer gives, the value's width (the label is squeezed, then shrunk, to fit;
137
+ * a shorter label leaves the rest of the width blank). Every cover is painted
138
+ * before any label, so a cover on the next line never clips a label above it.
139
+ * Returns the pixels, in gray when the page is gray (a scan usually is), so
140
+ * the writer can embed them losslessly at a size close to the original scan.
141
+ */
142
+ export async function rasterizeScanPage(doc, index, marks, options) {
143
+ if (!(options.dpi > 0) || !Number.isFinite(options.dpi))
144
+ throw new RangeError(`invalid dpi ${options.dpi}`);
145
+ const page = await doc.render(index, options.dpi / 72, options.canvasFactory);
146
+ const ctx = page.canvas.context;
147
+ const before = page.canvas.getImageData();
148
+ const [a, b, c, d, e, f] = page.transform;
149
+ const toPx = (x, y) => [a * x + c * y + e, b * x + d * y + f];
150
+ // Measure every cover on the untouched pixels first.
151
+ const covers = marks.map((m) => {
152
+ const at = (along, up) => toPx(m.x + m.ux * along + m.vx * up, m.y + m.uy * along + m.vy * up);
153
+ const pad = COVER_PAD_ALONG * m.size;
154
+ const paper = paperColour(before, page.widthPx, page.heightPx, quadOf(at, -pad, m.length + pad, -COVER_BELOW * m.size, COVER_ABOVE * m.size));
155
+ const inkBelow = luminance(paper) * INK_LUMINANCE;
156
+ const along = inkExtent(before, page.widthPx, page.heightPx, at, m, inkBelow);
157
+ const start = Math.min(0, along.start) - pad;
158
+ const end = Math.max(m.length, along.end) + pad;
159
+ const rows = inkRows(before, page.widthPx, page.heightPx, at, m, start, end, inkBelow);
160
+ const onePx = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
161
+ const bottom = rows ? Math.min(-COVER_CORE_BELOW * m.size, rows.bottom - 1.5 * onePx) : -COVER_BELOW * m.size;
162
+ const top = rows ? Math.max(COVER_CORE_ABOVE * m.size, rows.top + 1.5 * onePx) : COVER_ABOVE * m.size;
163
+ const ink = rows ? inkColour(before, page.widthPx, page.heightPx, at, start, end, rows.bottom, rows.top, inkBelow) : null;
164
+ return { mark: m, at, start, end, bottom, top, inkTop: rows ? rows.top : null, paper, ink };
165
+ });
166
+ ctx.save();
167
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
168
+ ctx.globalAlpha = 1;
169
+ ctx.globalCompositeOperation = 'source-over';
170
+ for (const cv of covers) {
171
+ const quad = quadOf(cv.at, cv.start, cv.end, cv.bottom, cv.top);
172
+ const fill = `rgb(${cv.paper[0]}, ${cv.paper[1]}, ${cv.paper[2]})`;
173
+ ctx.fillStyle = fill;
174
+ ctx.strokeStyle = fill;
175
+ ctx.lineWidth = 2; // the stroke grows the cover by a pixel on every side (anti-aliased glyph edges)
176
+ ctx.beginPath();
177
+ ctx.moveTo(quad[0][0], quad[0][1]);
178
+ for (let i = 1; i < 4; i++)
179
+ ctx.lineTo(quad[i][0], quad[i][1]);
180
+ ctx.closePath();
181
+ ctx.fill();
182
+ ctx.stroke();
183
+ }
184
+ for (const cv of covers) {
185
+ const m = cv.mark;
186
+ if (!m.label)
187
+ continue;
188
+ const pad = COVER_PAD_ALONG * m.size;
189
+ const [x0, y0] = cv.at(cv.start + pad, 0);
190
+ const [x1, y1] = cv.at(cv.start + pad + 1, 0);
191
+ const [xu, yu] = cv.at(cv.start + pad, 1);
192
+ const pxPerUnit = Math.hypot(x1 - x0, y1 - y0);
193
+ const pxPerUnitUp = Math.hypot(xu - x0, yu - y0);
194
+ const available = (cv.end - cv.start - 2 * pad) * pxPerUnit;
195
+ const em = cv.inkTop !== null && cv.inkTop > 0 ? cv.inkTop / INK_TOP_EM : LABEL_SIZE * m.size;
196
+ let fontPx = Math.min(em, 1.2 * m.size) * pxPerUnitUp;
197
+ if (!(fontPx > 0) || !(available > 0))
198
+ continue;
199
+ ctx.font = `${fontPx}px ${LABEL_FONT}`;
200
+ const natural = ctx.measureText(m.label).width;
201
+ let squeeze = natural > available ? available / natural : 1;
202
+ if (squeeze < LABEL_MIN_SQUEEZE) {
203
+ fontPx *= squeeze / LABEL_MIN_SQUEEZE;
204
+ squeeze = LABEL_MIN_SQUEEZE;
205
+ ctx.font = `${fontPx}px ${LABEL_FONT}`;
206
+ }
207
+ ctx.save();
208
+ ctx.translate(x0, y0);
209
+ ctx.rotate(Math.atan2(y1 - y0, x1 - x0));
210
+ ctx.scale(squeeze, 1);
211
+ ctx.fillStyle = cv.ink !== null ? `rgb(${cv.ink}, ${cv.ink}, ${cv.ink})` : luminance(cv.paper) > 0.45 ? '#000000' : '#ffffff';
212
+ ctx.textAlign = 'left';
213
+ ctx.textBaseline = 'alphabetic';
214
+ ctx.fillText(m.label, 0, 0);
215
+ ctx.restore();
216
+ }
217
+ ctx.restore();
218
+ const rgba = page.canvas.getImageData();
219
+ const { pixels, channels } = packPixels(rgba);
220
+ return { pixels, channels, widthPx: page.widthPx, heightPx: page.heightPx, widthPt: page.widthPt, heightPt: page.heightPt };
221
+ }
222
+ function sub(p, q) {
223
+ return [p[0] - q[0], p[1] - q[1]];
224
+ }
225
+ /** Pixel corners of the box [lo, hi] along the baseline by [bottom, top] across it. */
226
+ function quadOf(at, lo, hi, bottom, top) {
227
+ return [at(lo, bottom), at(hi, bottom), at(hi, top), at(lo, top)];
228
+ }
229
+ /** Luminance (0..1) of the pixel under a baseline-frame point, or null off canvas. */
230
+ function lumAt(rgba, width, height, p) {
231
+ const px = Math.round(p[0]);
232
+ const py = Math.round(p[1]);
233
+ if (px < 0 || py < 0 || px >= width || py >= height)
234
+ return null;
235
+ const i = (py * width + px) * 4;
236
+ return (0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2]) / 255;
237
+ }
238
+ /**
239
+ * Vertical extent of the value's ink (user units from the baseline): the block of inked rows
240
+ * across [start, end] that holds the letters' bodies, grown up and down until a blank row (the
241
+ * leading between lines), within INK_REACH_BELOW / INK_REACH_ABOVE. Null when no ink is there.
242
+ */
243
+ export function inkRows(rgba, width, height, at, m, start, end, inkBelow) {
244
+ const pxAlong = Math.hypot(...sub(at(1, 0), at(0, 0)));
245
+ const pxUp = Math.hypot(...sub(at(0, 1), at(0, 0)));
246
+ if (!(pxAlong > 0) || !(pxUp > 0))
247
+ return null;
248
+ const stepAlong = 1 / pxAlong;
249
+ const stepUp = 1 / pxUp;
250
+ const rowHasInk = (up) => {
251
+ for (let along = start; along <= end; along += stepAlong) {
252
+ const lum = lumAt(rgba, width, height, at(along, up));
253
+ if (lum !== null && lum < inkBelow)
254
+ return true;
255
+ }
256
+ return false;
257
+ };
258
+ // Seed in the letters' bodies (every letter has ink between the baseline and the x-height).
259
+ let seed = null;
260
+ for (let up = 0.1 * m.size; up <= 0.45 * m.size; up += stepUp) {
261
+ if (rowHasInk(up)) {
262
+ seed = up;
263
+ break;
264
+ }
265
+ }
266
+ if (seed === null)
267
+ return null;
268
+ let top = seed;
269
+ for (let up = seed; up <= INK_REACH_ABOVE * m.size && rowHasInk(up); up += stepUp)
270
+ top = up;
271
+ let bottom = seed;
272
+ for (let up = seed; up >= -INK_REACH_BELOW * m.size && rowHasInk(up); up -= stepUp)
273
+ bottom = up;
274
+ return { bottom, top };
275
+ }
276
+ /**
277
+ * How far the ink of a value really reaches along its baseline (user units from the mark's
278
+ * start; `start` <= 0 and `end` >= length when the ink runs past the OCR layer's ends). Walks
279
+ * outwards pixel by pixel from inside the value and stops at the first blank gap of a word
280
+ * space, or after MAX_GROWTH.
281
+ */
282
+ export function inkExtent(rgba, width, height, at, m, inkBelow) {
283
+ const [ox, oy] = at(0, 0);
284
+ const [ax, ay] = at(1, 0);
285
+ const pxPerUnit = Math.hypot(ax - ox, ay - oy);
286
+ if (!(pxPerUnit > 0))
287
+ return { start: 0, end: m.length };
288
+ const step = 1 / pxPerUnit; // one pixel along the baseline, in user units
289
+ const [bx, by] = at(0, INK_BAND_BELOW * -m.size);
290
+ const [tx, ty] = at(0, INK_BAND_ABOVE * m.size);
291
+ const bandPx = Math.max(1, Math.ceil(Math.hypot(tx - bx, ty - by)));
292
+ const isInk = (along) => {
293
+ for (let k = 0; k <= bandPx; k++) {
294
+ const up = -INK_BAND_BELOW * m.size + (k / bandPx) * (INK_BAND_ABOVE + INK_BAND_BELOW) * m.size;
295
+ const lum = lumAt(rgba, width, height, at(along, up));
296
+ if (lum !== null && lum < inkBelow)
297
+ return true;
298
+ }
299
+ return false;
300
+ };
301
+ const inside = Math.min(INK_SEARCH_INSIDE * m.size, m.length / 2);
302
+ const gap = WORD_GAP * m.size;
303
+ const reach = (from, dir, limit) => {
304
+ let last = from;
305
+ for (let along = from; dir > 0 ? along <= limit : along >= limit; along += dir * step) {
306
+ if (isInk(along))
307
+ last = along;
308
+ else if (Math.abs(along - last) >= gap)
309
+ break;
310
+ }
311
+ return last + dir * step;
312
+ };
313
+ return {
314
+ start: Math.min(0, reach(inside, -1, -MAX_GROWTH * m.size)),
315
+ end: Math.max(m.length, reach(m.length - inside, 1, m.length + MAX_GROWTH * m.size)),
316
+ };
317
+ }
318
+ /**
319
+ * Gray level (0..255) of the ink in the box [start, end] x [bottom, top]: the median luminance of
320
+ * its ink pixels, without their hue (a label never repeats the colour a value was printed in).
321
+ * Null without ink.
322
+ */
323
+ function inkColour(rgba, width, height, at, start, end, bottom, top, inkBelow) {
324
+ const stepAlong = 1 / (Math.hypot(...sub(at(1, 0), at(0, 0))) || 1);
325
+ const stepUp = 1 / (Math.hypot(...sub(at(0, 1), at(0, 0))) || 1);
326
+ const levels = [];
327
+ for (let up = bottom; up <= top; up += stepUp) {
328
+ for (let along = start; along <= end; along += stepAlong) {
329
+ const [x, y] = at(along, up);
330
+ const px = Math.round(x);
331
+ const py = Math.round(y);
332
+ if (px < 0 || py < 0 || px >= width || py >= height)
333
+ continue;
334
+ const i = (py * width + px) * 4;
335
+ const level = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
336
+ if (level / 255 < inkBelow)
337
+ levels.push(level);
338
+ }
339
+ }
340
+ if (levels.length === 0)
341
+ return null;
342
+ return Math.round(levels.sort((p, q) => p - q)[levels.length >> 1]);
343
+ }
344
+ /**
345
+ * The paper colour around a cover: per-channel median of the pixels on a ring
346
+ * three pixels outside the quad's bounding box (mostly background, whatever
347
+ * text the ring crosses is a minority). White when the ring is off canvas.
348
+ */
349
+ export function paperColour(rgba, width, height, quad) {
350
+ const xs = quad.map((p) => p[0]);
351
+ const ys = quad.map((p) => p[1]);
352
+ const left = Math.floor(Math.min(...xs)) - 3;
353
+ const right = Math.ceil(Math.max(...xs)) + 3;
354
+ const top = Math.floor(Math.min(...ys)) - 3;
355
+ const bottom = Math.ceil(Math.max(...ys)) + 3;
356
+ const channels = [[], [], []];
357
+ const take = (x, y) => {
358
+ if (x < 0 || y < 0 || x >= width || y >= height)
359
+ return;
360
+ const i = (y * width + x) * 4;
361
+ channels[0].push(rgba[i]);
362
+ channels[1].push(rgba[i + 1]);
363
+ channels[2].push(rgba[i + 2]);
364
+ };
365
+ for (let x = left; x <= right; x++) {
366
+ take(x, top);
367
+ take(x, bottom);
368
+ }
369
+ for (let y = top + 1; y < bottom; y++) {
370
+ take(left, y);
371
+ take(right, y);
372
+ }
373
+ if (channels[0].length === 0)
374
+ return [255, 255, 255];
375
+ const median = (v) => v.sort((p, q) => p - q)[v.length >> 1];
376
+ return [median(channels[0]), median(channels[1]), median(channels[2])];
377
+ }
378
+ function luminance(rgb) {
379
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
380
+ }
381
+ /** RGBA canvas pixels to gray (every pixel has R = G = B) or RGB bytes; alpha is dropped (pdf.js paints an opaque page). */
382
+ export function packPixels(rgba) {
383
+ const n = rgba.length >> 2;
384
+ let gray = true;
385
+ for (let i = 0; i < n; i++) {
386
+ const r = rgba[i * 4];
387
+ if (r !== rgba[i * 4 + 1] || r !== rgba[i * 4 + 2]) {
388
+ gray = false;
389
+ break;
390
+ }
391
+ }
392
+ if (gray) {
393
+ const pixels = new Uint8Array(n);
394
+ for (let i = 0; i < n; i++)
395
+ pixels[i] = rgba[i * 4];
396
+ return { pixels, channels: 1 };
397
+ }
398
+ const pixels = new Uint8Array(n * 3);
399
+ for (let i = 0; i < n; i++) {
400
+ pixels[i * 3] = rgba[i * 4];
401
+ pixels[i * 3 + 1] = rgba[i * 4 + 1];
402
+ pixels[i * 3 + 2] = rgba[i * 4 + 2];
403
+ }
404
+ return { pixels, channels: 3 };
405
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @doccloak/core/pdf - readPdf (T213/T215).
3
+ *
4
+ * Parses a PDF, extracts its text with glyph geometry, cross-checks every
5
+ * page against pdf.js and returns the extraction hosts run detection on.
6
+ * The extraction keeps the input bytes; the writer re-extracts from them so
7
+ * a stale extraction can never be applied to different bytes.
8
+ */
9
+ import type { GlyphRun, PdfAssetPaths, PdfPageInfo, PdfReadOptions, RemovedPart } from './types.js';
10
+ import type { UnredactablePart } from '../dom/errors.js';
11
+ export interface PdfExtraction {
12
+ /** Flat text detection runs on. */
13
+ plainText: string;
14
+ pageCount: number;
15
+ pages: PdfPageInfo[];
16
+ /** Glyph runs in flat-text order (internal geometry, exposed for hosts that draw previews). */
17
+ runs: GlyphRun[];
18
+ /** Parts that cannot be redacted (scanned pages, undecodable text); gate the export like docx. */
19
+ unredactable: UnredactablePart[];
20
+ /** Parts the writer will drop from the output. */
21
+ removed: RemovedPart[];
22
+ warnings: string[];
23
+ /** True when no readable text was found. */
24
+ empty: boolean;
25
+ /** 0-based pages whose text came from pdf.js because our decoder disagreed (edits rasterize them). */
26
+ rasterOnlyPages: number[];
27
+ /** Input bytes (the writer's source of truth). */
28
+ bytes: Uint8Array;
29
+ assets?: PdfAssetPaths;
30
+ password?: string;
31
+ }
32
+ export type PdfInput = Blob | ArrayBuffer | Uint8Array;
33
+ export declare function toPdfBytes(input: PdfInput): Promise<Uint8Array>;
34
+ export declare function readPdf(input: PdfInput, options?: PdfReadOptions): Promise<PdfExtraction>;
35
+ /** True for names ending in .pdf (case-insensitive). */
36
+ export declare function isPdfFile(filename: string): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @doccloak/core/pdf - readPdf (T213/T215).
3
+ *
4
+ * Parses a PDF, extracts its text with glyph geometry, cross-checks every
5
+ * page against pdf.js and returns the extraction hosts run detection on.
6
+ * The extraction keeps the input bytes; the writer re-extracts from them so
7
+ * a stale extraction can never be applied to different bytes.
8
+ */
9
+ import { DEFAULT_MAX_BYTES, extractPdf } from "./extract.js";
10
+ import { UnsupportedDocumentError } from "../dom/errors.js";
11
+ import { openWithOracle } from "./oracle.js";
12
+ export async function toPdfBytes(input) {
13
+ if (input instanceof Uint8Array)
14
+ return input;
15
+ if (input instanceof ArrayBuffer)
16
+ return new Uint8Array(input);
17
+ if (typeof input.arrayBuffer === 'function')
18
+ return new Uint8Array(await input.arrayBuffer());
19
+ // Older Blob implementations (jsdom): go through FileReader.
20
+ return new Promise((resolve, reject) => {
21
+ const reader = new FileReader();
22
+ reader.onerror = () => reject(reader.error ?? new Error('could not read the file'));
23
+ reader.onload = () => resolve(new Uint8Array(reader.result));
24
+ reader.readAsArrayBuffer(input);
25
+ });
26
+ }
27
+ export async function readPdf(input, options = {}) {
28
+ const bytes = await toPdfBytes(input);
29
+ // Size cap before anything parses the file (pdf.js would otherwise read all of it first).
30
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
31
+ if (bytes.length > maxBytes) {
32
+ throw new UnsupportedDocumentError('too-large', `PDF is ${bytes.length} bytes; the limit is ${maxBytes}`, [String(bytes.length)]);
33
+ }
34
+ const oracle = await openWithOracle(bytes, options.assets, options.password);
35
+ try {
36
+ const res = await extractPdf(bytes, {
37
+ maxBytes: options.maxBytes,
38
+ maxPages: options.maxPages,
39
+ maxGlyphs: options.maxGlyphs,
40
+ password: options.password,
41
+ oracle,
42
+ });
43
+ return {
44
+ plainText: res.plainText,
45
+ pageCount: res.pages.length,
46
+ pages: res.pages,
47
+ runs: res.runs,
48
+ unredactable: res.unredactable,
49
+ removed: res.removed,
50
+ warnings: res.warnings,
51
+ empty: res.empty,
52
+ rasterOnlyPages: res.model.pages.filter((p) => p.oracleText).map((p) => p.index),
53
+ bytes,
54
+ assets: options.assets,
55
+ password: options.password,
56
+ };
57
+ }
58
+ finally {
59
+ await oracle.close();
60
+ }
61
+ }
62
+ /** True for names ending in .pdf (case-insensitive). */
63
+ export function isPdfFile(filename) {
64
+ return /\.pdf$/i.test(filename.trim());
65
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @doccloak/core/pdf - content stream serializer (T206).
3
+ *
4
+ * Writes operators back to bytes. Operators that still carry their source
5
+ * range are copied verbatim from the original stream (so nothing we did not
6
+ * touch changes by even a byte); synthesized or edited operators are emitted
7
+ * from their operands. New strings are always written as hex strings, which
8
+ * is binary-safe for every font encoding.
9
+ */
10
+ import type { ContentOp, Operand } from './lexer.js';
11
+ export declare function formatNumber(value: number): string;
12
+ export declare function hexOf(bytes: Uint8Array): string;
13
+ export declare function serializeOperand(operand: Operand): string;
14
+ /** Serialize one operator from its operands (ignores the source range). */
15
+ export declare function serializeOp(op: ContentOp): string;
16
+ /**
17
+ * Serialize a whole operator list. `source` is the original stream: an op
18
+ * whose [start, end) range is present is copied from it byte-for-byte.
19
+ */
20
+ export declare function serializeOps(ops: readonly ContentOp[], source?: Uint8Array): Uint8Array;
21
+ export declare function latin1Bytes(text: string): Uint8Array;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @doccloak/core/pdf - content stream serializer (T206).
3
+ *
4
+ * Writes operators back to bytes. Operators that still carry their source
5
+ * range are copied verbatim from the original stream (so nothing we did not
6
+ * touch changes by even a byte); synthesized or edited operators are emitted
7
+ * from their operands. New strings are always written as hex strings, which
8
+ * is binary-safe for every font encoding.
9
+ */
10
+ export function formatNumber(value) {
11
+ if (!Number.isFinite(value))
12
+ return '0';
13
+ if (Number.isInteger(value))
14
+ return Math.abs(value) < 1e21 ? String(value) : BigInt(value).toString();
15
+ // Shortest round-trip form keeps the source precision; PDF has no exponent syntax.
16
+ let s = String(value);
17
+ if (/e/i.test(s) || (s.split('.')[1]?.length ?? 0) > 5)
18
+ s = value.toFixed(5);
19
+ s = s.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
20
+ if (s === '-0' || s === '')
21
+ s = '0';
22
+ return s;
23
+ }
24
+ function escapeName(value) {
25
+ let out = '/';
26
+ for (let i = 0; i < value.length; i++) {
27
+ const c = value.charCodeAt(i);
28
+ const ch = value[i];
29
+ if (c < 0x21 || c > 0x7e || '()<>[]{}/%#'.includes(ch)) {
30
+ out += '#' + c.toString(16).padStart(2, '0').toUpperCase();
31
+ }
32
+ else {
33
+ out += ch;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ export function hexOf(bytes) {
39
+ let s = '<';
40
+ for (let i = 0; i < bytes.length; i++)
41
+ s += bytes[i].toString(16).padStart(2, '0').toUpperCase();
42
+ return s + '>';
43
+ }
44
+ export function serializeOperand(operand) {
45
+ switch (operand.kind) {
46
+ case 'number': return formatNumber(operand.value);
47
+ case 'name': return escapeName(operand.value);
48
+ case 'string': return hexOf(operand.bytes);
49
+ case 'bool': return operand.value ? 'true' : 'false';
50
+ case 'null': return 'null';
51
+ case 'array': return '[' + operand.items.map(serializeOperand).join(' ') + ']';
52
+ case 'dict': {
53
+ const parts = [];
54
+ for (const [k, v] of operand.entries)
55
+ parts.push(escapeName(k) + ' ' + serializeOperand(v));
56
+ return '<<' + parts.join(' ') + '>>';
57
+ }
58
+ }
59
+ }
60
+ /** Serialize one operator from its operands (ignores the source range). */
61
+ export function serializeOp(op) {
62
+ if (op.op === 'BI' || op.op === '') {
63
+ return op.raw ? latin1Of(op.raw) : '';
64
+ }
65
+ const parts = op.operands.map(serializeOperand);
66
+ parts.push(op.op);
67
+ return parts.join(' ');
68
+ }
69
+ function latin1Of(bytes) {
70
+ let s = '';
71
+ for (let i = 0; i < bytes.length; i++)
72
+ s += String.fromCharCode(bytes[i]);
73
+ return s;
74
+ }
75
+ /**
76
+ * Serialize a whole operator list. `source` is the original stream: an op
77
+ * whose [start, end) range is present is copied from it byte-for-byte.
78
+ */
79
+ export function serializeOps(ops, source) {
80
+ const chunks = [];
81
+ let total = 0;
82
+ const push = (u) => { chunks.push(u); total += u.length; };
83
+ const NL = Uint8Array.of(0x0a);
84
+ for (const op of ops) {
85
+ if (source && op.start !== undefined && op.end !== undefined) {
86
+ push(source.subarray(op.start, op.end));
87
+ }
88
+ else {
89
+ push(latin1Bytes(serializeOp(op)));
90
+ }
91
+ push(NL);
92
+ }
93
+ const out = new Uint8Array(total);
94
+ let p = 0;
95
+ for (const c of chunks) {
96
+ out.set(c, p);
97
+ p += c.length;
98
+ }
99
+ return out;
100
+ }
101
+ export function latin1Bytes(text) {
102
+ const out = new Uint8Array(text.length);
103
+ for (let i = 0; i < text.length; i++)
104
+ out[i] = text.charCodeAt(i) & 0xff;
105
+ return out;
106
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @doccloak/core/pdf - metrics of the standard 14 fonts (T209).
3
+ *
4
+ * Fonts that are not embedded and carry no /Widths (the standard 14 and their
5
+ * common aliases: Arial, Times New Roman, Courier New) get their advance
6
+ * widths from the AFM metrics vendored by @cantoo/pdf-lib.
7
+ */
8
+ export type StandardFontName = 'Courier' | 'Courier-Bold' | 'Courier-Oblique' | 'Courier-BoldOblique' | 'Helvetica' | 'Helvetica-Bold' | 'Helvetica-Oblique' | 'Helvetica-BoldOblique' | 'Times-Roman' | 'Times-Bold' | 'Times-Italic' | 'Times-BoldItalic' | 'Symbol' | 'ZapfDingbats';
9
+ /**
10
+ * Map a /BaseFont to a standard 14 font, or undefined. Handles the aliases
11
+ * from the PDF 1.7 implementation notes (Arial, TimesNewRoman, CourierNew)
12
+ * and the ",Bold" / "-BoldItalic" / "PS-BoldMT" style suffixes.
13
+ */
14
+ export declare function standardFontFor(baseFont: string): StandardFontName | undefined;
15
+ export interface StandardMetrics {
16
+ name: StandardFontName;
17
+ /** Advance width of a glyph by name, in 1/1000 text space units; undefined when the font has no such glyph. */
18
+ widthOfGlyph(glyphName: string): number | undefined;
19
+ /** Glyph names present in the AFM (for encodability of placeholders in a non-embedded font). */
20
+ hasGlyph(glyphName: string): boolean;
21
+ /** Built-in encoding of the AFM (code -> glyph name), for Symbol and ZapfDingbats. */
22
+ builtinEncoding(): ReadonlyArray<string | undefined>;
23
+ ascender: number;
24
+ descender: number;
25
+ capHeight: number;
26
+ }
27
+ export declare function loadStandardMetrics(name: StandardFontName): StandardMetrics;