@doccloak/core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,12 +16,15 @@
16
16
  * attachments, JavaScript, named destinations, structure tree and the
17
17
  * incremental-update history of the input never reach the output;
18
18
  * 6. pages that cannot be edited safely are rendered by pdf.js and replaced
19
- * by an image with the redacted regions blacked out in the pixels;
19
+ * by an image with the redacted regions blacked out in the pixels; scan
20
+ * pages whose OCR text layer holds a redacted value are rendered at the
21
+ * scan's resolution with each value covered and its placeholder painted in;
20
22
  * 7. verify the output with both extractors and a byte scan; refuse on any trace.
21
23
  */
22
24
  import type { ValueReplacement } from '../docx.js';
23
25
  import type { PageRecord } from './extract.js';
24
26
  import type { CanvasFactory, OracleDocument } from './oracle.js';
27
+ import type { PaintMark } from './raster.js';
25
28
  import type { Segment } from './surgery.js';
26
29
  import type { GlyphRun, PdfWriteOptions, PdfWriteResult, Rect } from './types.js';
27
30
  import type { PdfExtraction } from './read.js';
@@ -41,6 +44,20 @@ interface Replacement {
41
44
  replacement: string;
42
45
  }
43
46
  export declare function writeAnonymizedPdfWithReport(extraction: PdfExtraction, replacements: Replacement[], valueReplacements?: ValueReplacement[], options?: PdfWriteOptionsInternal): Promise<PdfWriteResult>;
47
+ /**
48
+ * Where each redacted value sits on a scan page, for painting: one mark per line a value
49
+ * occupies (the pieces of a value on one line, split across operators, join into one), the
50
+ * placeholder on the value's first line and a blank cover on the lines it wraps onto.
51
+ * Whitespace glyphs at either end of a piece are left out.
52
+ */
53
+ export declare function paintMarksForPage(segments: Segment[], page: number, replacements: Replacement[]): PaintMark[];
54
+ /**
55
+ * The text column each mark's line sits in, along its baseline (`columnStart`, `columnEnd`, user
56
+ * units from the mark's start): where the page's words start and end, without the outlying
57
+ * COLUMN_START_OUTLIERS / COLUMN_END_OUTLIERS, over the words running the same way. A label growing into blank
58
+ * paper beside a short value stays inside it: never into the margin.
59
+ */
60
+ export declare function withTextColumn(marks: PaintMark[], runs: GlyphRun[], page: number): PaintMark[];
44
61
  /**
45
62
  * Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
46
63
  * whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
package/dist/pdf/write.js CHANGED
@@ -16,10 +16,12 @@
16
16
  * attachments, JavaScript, named destinations, structure tree and the
17
17
  * incremental-update history of the input never reach the output;
18
18
  * 6. pages that cannot be edited safely are rendered by pdf.js and replaced
19
- * by an image with the redacted regions blacked out in the pixels;
19
+ * by an image with the redacted regions blacked out in the pixels; scan
20
+ * pages whose OCR text layer holds a redacted value are rendered at the
21
+ * scan's resolution with each value covered and its placeholder painted in;
20
22
  * 7. verify the output with both extractors and a byte scan; refuse on any trace.
21
23
  */
22
- import { PDFDict, PDFDocument, PDFName, PDFRawStream, PDFString } from '@cantoo/pdf-lib';
24
+ import { PDFDict, PDFDocument, PDFName, PDFRawStream, PDFString, concatTransformationMatrix, drawObject, popGraphicsState, pushGraphicsState } from '@cantoo/pdf-lib';
23
25
  import { PDFRef } from '@cantoo/pdf-lib';
24
26
  import { UnsupportedDocumentError } from "../dom/errors.js";
25
27
  import { normalizeReplacements } from "../docx.js";
@@ -27,11 +29,17 @@ import { extractPdf } from "./extract.js";
27
29
  import { FallbackFontEmbedder, FallbackFontProvider, faceFor } from "./fallback-fonts.js";
28
30
  import { getDict, getStream, refOf, resolve } from "./objects.js";
29
31
  import { findOccurrences, openWithOracle, searchIndex } from "./oracle.js";
30
- import { rasterizePage } from "./raster.js";
32
+ import { rasterizePage, rasterizeScanPage } from "./raster.js";
33
+ import { helveticaWidth } from "./standard-metrics.js";
31
34
  import { serializeToUnicode } from "./cmap.js";
32
35
  import { planEdits, rewriteStream, segmentsFor } from "./surgery.js";
33
36
  import { PdfVerifyError, verifyPdf } from "./verify.js";
34
37
  import { lexContent } from "./lexer.js";
38
+ import { mul } from "./textstate.js";
39
+ /** Scan pages render at the scan's own resolution, up to this. */
40
+ const MAX_SCAN_DPI = 300;
41
+ /** Pixel budget of one rendered scan page (a 300 dpi A3 page fits; larger pages render coarser). */
42
+ const MAX_SCAN_PIXELS = 40000000;
35
43
  /** Same normalised date the docx writer stamps. */
36
44
  export const NORMALISED_PDF_DATE = new Date('2000-01-01T00:00:00Z');
37
45
  export class StalePdfExtractionError extends Error {
@@ -57,7 +65,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
57
65
  const oracle = await openWithOracle(extraction.bytes, assets, extraction.password);
58
66
  let fresh;
59
67
  try {
60
- fresh = await extractPdf(extraction.bytes, { oracle, password: extraction.password, maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER });
68
+ fresh = await extractPdf(extraction.bytes, { oracle, password: extraction.password, maxBytes: Number.MAX_SAFE_INTEGER, maxPages: Number.MAX_SAFE_INTEGER, extraOcr: extraction.extraOcr });
61
69
  }
62
70
  catch (err) {
63
71
  await oracle.close();
@@ -112,13 +120,20 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
112
120
  if (page.issues.length > 0 || page.oracleText)
113
121
  rasterPages.add(page.index);
114
122
  }
123
+ // Scan pages: an edit in an OCR text layer would leave the value in the picture under it, so the
124
+ // page is rendered and the value painted over where the layer says it is.
125
+ const scanPages = new Set();
126
+ for (const s of segments)
127
+ if (s.run.ocr && !rasterPages.has(s.run.page))
128
+ scanPages.add(s.run.page);
129
+ const paintedPages = new Set([...rasterPages, ...scanPages]);
115
130
  // Text in a pattern cell or soft-mask group cannot be blacked out in a raster (the cell is tiled
116
131
  // over its fill area, the group sits behind a mask): such a page must be edited in place or not at all.
117
- const hostile = [...rasterPages].filter((p) => model.pages[p]?.hostile);
132
+ const hostile = [...paintedPages].filter((p) => model.pages[p]?.hostile);
118
133
  if (hostile.length > 0) {
119
134
  throw new UnsupportedDocumentError('unredactable-parts', `pages ${hostile.map((p) => p + 1).join(', ')} hold content a viewer cannot render safely (rasterization refused)`, hostile.map((p) => `page ${p + 1}`));
120
135
  }
121
- const rasterWithAux = [...rasterPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
136
+ const rasterWithAux = [...paintedPages].filter((p) => segments.some((sg) => sg.run.page === p && sg.run.space));
122
137
  if (rasterWithAux.length > 0) {
123
138
  throw new UnsupportedDocumentError('unredactable-parts', `pages ${rasterWithAux.map((p) => p + 1).join(', ')} hold redacted text inside a pattern or soft mask and cannot be rasterized safely`, rasterWithAux.map((p) => `page ${p + 1}`));
124
139
  }
@@ -142,9 +157,9 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
142
157
  const stream = model.streams.get(key);
143
158
  if (!stream)
144
159
  continue;
145
- if ([...stream.pages].every((p) => rasterPages.has(p)))
160
+ if ([...stream.pages].every((p) => paintedPages.has(p)))
146
161
  continue;
147
- const segs = (plan.byStream.get(key) ?? []).filter((s) => !rasterPages.has(s.run.page));
162
+ const segs = (plan.byStream.get(key) ?? []).filter((s) => !paintedPages.has(s.run.page));
148
163
  const result = rewriteStream(model, stream, runsByStreamOp.get(key) ?? new Map(), segs, plan.shiftedRuns);
149
164
  for (const [k, codes] of result.usedCodes)
150
165
  mergeCodes(usedCodes, k, codes);
@@ -167,12 +182,51 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
167
182
  scrubToUnicode(model, editedFonts, usedCodes, warnings);
168
183
  scrubSourceSideChannels(model);
169
184
  // 5. Rebuild into the fresh document.
170
- const keptIndexes = model.pages.map((p) => p.index).filter((i) => !rasterPages.has(i));
185
+ const keptIndexes = model.pages.map((p) => p.index).filter((i) => !paintedPages.has(i));
171
186
  const copied = keptIndexes.length > 0 ? await outDoc.copyPages(model.doc, keptIndexes) : [];
172
187
  const copiedByIndex = new Map();
173
188
  keptIndexes.forEach((idx, k) => copiedByIndex.set(idx, copied[k]));
174
189
  const rasterized = [];
190
+ const scanned = [];
191
+ const sortedReplacements = [...all].sort((a, b) => a.start - b.start || a.end - b.end); // segmentsFor's order
175
192
  for (const page of model.pages) {
193
+ if (scanPages.has(page.index)) {
194
+ const marks = withTextColumn(paintMarksForPage(segments, page.index, sortedReplacements), extraction.runs, page.index);
195
+ let raster;
196
+ try {
197
+ raster = await rasterizeScanPage(oracle, page.index, marks, { dpi: scanDpi(page, options.rasterDpi), canvasFactory: options.canvasFactory });
198
+ }
199
+ catch (err) {
200
+ throw new UnsupportedDocumentError('unredactable-parts', `page ${page.index + 1} cannot be rendered: ${err.message ?? String(err)}`, [`page ${page.index + 1}`]);
201
+ }
202
+ // Raw pixels in a Flate image (gray for a gray scan): lossless, and far smaller than an RGBA PNG.
203
+ const image = outDoc.context.register(outDoc.context.flateStream(raster.pixels, {
204
+ Type: 'XObject',
205
+ Subtype: 'Image',
206
+ Width: raster.widthPx,
207
+ Height: raster.heightPx,
208
+ ColorSpace: raster.channels === 1 ? 'DeviceGray' : 'DeviceRGB',
209
+ BitsPerComponent: 8,
210
+ }));
211
+ const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
212
+ const name = newPage.node.newXObject('Scan', image);
213
+ newPage.pushOperators(pushGraphicsState(), concatTransformationMatrix(raster.widthPt, 0, 0, raster.heightPt, 0, 0), drawObject(name), popGraphicsState());
214
+ // The page stays searchable: its text, values replaced by their placeholders, as an
215
+ // invisible layer over the picture (as the scan's own OCR layer was).
216
+ // Source user space -> picture pixels (the render) -> the picture page's user space.
217
+ const [ta, tb, tc, td, te, tf] = raster.transform;
218
+ const sx = raster.widthPt / raster.widthPx;
219
+ const sy = raster.heightPt / raster.heightPx;
220
+ const toPage = [sx * ta, -sy * tb, sx * tc, -sy * td, sx * te, raster.heightPt - sy * tf];
221
+ const layer = await searchableTextLayer(fresh.runs, segments, page.index, sortedReplacements, embedder, toPage);
222
+ if (layer) {
223
+ newPage.node.setFontDictionary(PDFName.of(layer.fontName), layer.fontRef);
224
+ newPage.node.addContentStream(outDoc.context.register(outDoc.context.flateStream(layer.content)));
225
+ }
226
+ scanned.push(page.index);
227
+ warnings.push(`page ${page.index + 1}: scan with an OCR text layer; the values are painted over in the picture with their placeholders and the page is exported as an image, with its text (placeholders in place of the values) as an invisible layer`);
228
+ continue;
229
+ }
176
230
  if (rasterPages.has(page.index)) {
177
231
  const boxes = boxesForPage(segments, page.index, fresh.runs, page, [...needles]);
178
232
  const dpi = options.rasterDpi ?? 150;
@@ -217,7 +271,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
217
271
  scrubOutputObjects(outDoc);
218
272
  stampMetadata(outDoc);
219
273
  const bytes = await outDoc.save({ useObjectStreams: false, updateFieldAppearances: false, addDefaultPage: false });
220
- return { bytes, needles: [...needles], removedSequences, rasterized, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
274
+ return { bytes, needles: [...needles], removedSequences, rasterized, scanned, shiftedLines: plan.shiftedLines, removed: fresh.removed, placeholders: [...new Set(all.map((r) => r.replacement))] };
221
275
  }
222
276
  finally {
223
277
  await oracle.close();
@@ -241,6 +295,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
241
295
  warnings: [...new Set(warnings)],
242
296
  removed: produced.removed,
243
297
  rasterizedPages: produced.rasterized,
298
+ scanPages: produced.scanned,
244
299
  shiftedLines: produced.shiftedLines,
245
300
  };
246
301
  }
@@ -550,6 +605,323 @@ function stampMetadata(outDoc) {
550
605
  outDoc.setCreationDate(NORMALISED_PDF_DATE);
551
606
  outDoc.setModificationDate(NORMALISED_PDF_DATE);
552
607
  }
608
+ /**
609
+ * Along-baseline [start, end] of every letter of a one-word OCR show whose letters all have the same
610
+ * width (Tesseract's invisible font), from Helvetica proportions spread over the whole show (Tesseract
611
+ * stretches the word and its trailing space over the printed word). Offsets are from the show's
612
+ * first glyph. Null for any other run.
613
+ */
614
+ function proportionalLetters(run, ux, uy, scale) {
615
+ const g = run.glyphs;
616
+ if (!run.ocr || g.length < 2)
617
+ return null;
618
+ let k = g.length;
619
+ while (k > 0 && g[k - 1].unicode.trim() === '')
620
+ k--;
621
+ if (k < 2 || g.slice(0, k).some((x) => x.unicode.trim() === ''))
622
+ return null;
623
+ const a0 = g[0].advance;
624
+ if (!(a0 > 0) || g.slice(0, k).some((x) => Math.abs(x.advance - a0) > 0.01 * a0))
625
+ return null;
626
+ const last = g[g.length - 1];
627
+ const showEnd = (last.x - g[0].x) * ux + (last.y - g[0].y) * uy + last.advance * scale;
628
+ const widths = g.slice(0, k).map((x) => [...x.unicode].reduce((sum, ch) => sum + helveticaWidth(ch), 0) || 556);
629
+ const total = widths.reduce((a, b) => a + b, 0);
630
+ const spans = [];
631
+ let at = 0;
632
+ for (const w of widths) {
633
+ const next = at + (w / total) * showEnd;
634
+ spans.push([at, next]);
635
+ at = next;
636
+ }
637
+ for (let i = k; i < g.length; i++)
638
+ spans.push([showEnd, showEnd]);
639
+ return spans;
640
+ }
641
+ /** True when every glyph of the run has the same advance: an invisible OCR font (Tesseract's GlyphLessFont). */
642
+ function equalAdvances(run) {
643
+ const a0 = run.glyphs[0]?.advance ?? 0;
644
+ return a0 > 0 && run.glyphs.length >= 2 && run.glyphs.every((g) => Math.abs(g.advance - a0) <= 0.01 * a0);
645
+ }
646
+ /** Punctuation and symbols that can stick to a value inside one OCR word ("616," "(+48" "12/4."). */
647
+ const GLUED_PUNCTUATION = /^[\p{P}\p{S}]+$/u;
648
+ /** Text shows have no business holding more than this horizontal stretch (Tz %); beyond it a run is left out. */
649
+ const MAX_LAYER_STRETCH = 1000;
650
+ /**
651
+ * Invisible text layer for a painted scan page: every text run of the page, with each redacted
652
+ * value replaced by its placeholder (on the value's first piece, nothing on the pieces it wraps
653
+ * onto), shown in render mode 3 in the Liberation Sans fallback face on the run's own baseline and
654
+ * stretched (Tz) over the run's width, so a reader can search and select the page as before and
655
+ * the selection sits over the picture. `toPage` maps the source page's user space to the picture
656
+ * page's (rotation and crop box included).
657
+ */
658
+ async function searchableTextLayer(runs, segments, page, replacements, embedder, toPage) {
659
+ const pageRuns = runs.filter((r) => r.page === page && !r.space && r.glyphs.length > 0);
660
+ if (pageRuns.length === 0)
661
+ return null;
662
+ const font = await embedder.ensure(faceFor({ family: 'sans', bold: false, italic: false }));
663
+ const segsByRun = new Map();
664
+ const labelled = new Set();
665
+ for (const s of segments) {
666
+ if (s.run.page !== page)
667
+ continue;
668
+ const list = segsByRun.get(s.run) ?? [];
669
+ list.push(s);
670
+ segsByRun.set(s.run, list);
671
+ }
672
+ // Where each glyph ends along its run's baseline, in user space.
673
+ const glyphEnd = (run, i) => {
674
+ const len = Math.hypot(run.emX[0], run.emX[1]) || 1;
675
+ const k = len / (run.local.fontSize * run.local.hscale || 1);
676
+ const g = run.glyphs[i];
677
+ return { x: g.x + (run.emX[0] / len) * g.advance * k, y: g.y + (run.emX[1] / len) * g.advance * k };
678
+ };
679
+ const ops = ['q BT 3 Tr'];
680
+ for (const run of pageRuns) {
681
+ const segs = segsByRun.get(run) ?? [];
682
+ let text = '';
683
+ // The show starts at the first glyph that puts text in it: after the end of a value that began
684
+ // in an earlier show (Tesseract writes one show per word), the rest sits where it is printed.
685
+ let first = -1;
686
+ let labelAtEnd = null;
687
+ for (let i = 0; i < run.glyphs.length; i++) {
688
+ const seg = segs.find((s) => i >= s.from && i < s.to);
689
+ if (!seg) {
690
+ if (first < 0)
691
+ first = i;
692
+ text += run.glyphs[i].unicode;
693
+ if (run.glyphs[i].unicode.trim() !== '')
694
+ labelAtEnd = null;
695
+ continue;
696
+ }
697
+ if (i === seg.from && !labelled.has(seg.replacementIndex)) {
698
+ labelled.add(seg.replacementIndex);
699
+ if (first < 0)
700
+ first = i;
701
+ text += replacements[seg.replacementIndex]?.replacement ?? '';
702
+ labelAtEnd = seg;
703
+ }
704
+ }
705
+ text = [...text.replace(/\s+/g, ' ')].map((ch) => (font.canShow(ch) ? ch : '?')).join('');
706
+ if (first < 0 || text.trim() === '')
707
+ continue;
708
+ const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
709
+ const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
710
+ const ux = run.emX[0] / exLen;
711
+ const uy = run.emX[1] / exLen;
712
+ const vx = run.emY[0] / eyLen;
713
+ const vy = run.emY[1] / eyLen;
714
+ const g0 = run.glyphs[first];
715
+ const along = (p) => (p.x - g0.x) * ux + (p.y - g0.y) * uy;
716
+ const size = Math.max(...run.glyphs.map((g) => g.fontSize));
717
+ let width = along(glyphEnd(run, run.glyphs.length - 1));
718
+ // A placeholder that ends the show while its value runs on into the next shows of the line
719
+ // spans the whole value, so it selects as one word with whatever follows it.
720
+ if (labelAtEnd) {
721
+ for (const s of segments) {
722
+ if (s.replacementIndex !== labelAtEnd.replacementIndex || s.run === run || s.run.page !== page || s.to <= s.from)
723
+ continue;
724
+ const end = glyphEnd(s.run, s.to - 1);
725
+ const across = (end.x - g0.x) * vx + (end.y - g0.y) * vy;
726
+ if (Math.abs(across) < 0.4 * size)
727
+ width = Math.max(width, along(end));
728
+ }
729
+ }
730
+ const natural = font.measure(text, size);
731
+ if (!(width > 0) || !(natural > 0) || !(size > 0))
732
+ continue;
733
+ const tz = (100 * width) / natural;
734
+ if (tz > MAX_LAYER_STRETCH)
735
+ continue;
736
+ const hex = [...font.encode(text)].map((b) => b.toString(16).padStart(2, '0')).join('');
737
+ const n = (v) => (Math.abs(v) < 1e-6 ? '0' : v.toFixed(4).replace(/\.?0+$/, ''));
738
+ // The run's text matrix, carried into the picture page's user space.
739
+ const tm = mul([ux * size, uy * size, vx * size, vy * size, g0.x, g0.y], toPage);
740
+ ops.push(`/${font.resourceName} 1 Tf ${n(tz)} Tz ${tm.map(n).join(' ')} Tm <${hex}> Tj`);
741
+ }
742
+ if (ops.length === 1)
743
+ return null;
744
+ ops.push('ET Q');
745
+ return { content: new TextEncoder().encode(ops.join('\n')), fontName: font.resourceName, fontRef: font.pdfFont.ref };
746
+ }
747
+ /**
748
+ * Resolution for a scan page: the scan's own (at least `base`, at most 300 dpi), within the pixel
749
+ * budget. A text page painted for a value inside one of its images is rendered at 300 dpi: its
750
+ * text is sharp at any size, the image's resolution says nothing about it.
751
+ */
752
+ function scanDpi(page, base = 150) {
753
+ const dpi = page.info.imageOnly ? Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi))) : MAX_SCAN_DPI;
754
+ const squareInches = Math.max(1e-6, (page.info.width / 72) * (page.info.height / 72));
755
+ return Math.max(36, Math.min(dpi, Math.floor(Math.sqrt(MAX_SCAN_PIXELS / squareInches))));
756
+ }
757
+ /**
758
+ * Where each redacted value sits on a scan page, for painting: one mark per line a value
759
+ * occupies (the pieces of a value on one line, split across operators, join into one), the
760
+ * placeholder on the value's first line and a blank cover on the lines it wraps onto.
761
+ * Whitespace glyphs at either end of a piece are left out.
762
+ */
763
+ export function paintMarksForPage(segments, page, replacements) {
764
+ const marks = [];
765
+ let lastIndex = -1;
766
+ for (const s of segments) {
767
+ const run = s.run;
768
+ if (run.page !== page || run.space)
769
+ continue;
770
+ let from = s.from;
771
+ let to = s.to;
772
+ while (from < to && run.glyphs[from].unicode.trim() === '')
773
+ from++;
774
+ while (to > from && run.glyphs[to - 1].unicode.trim() === '')
775
+ to--;
776
+ if (from >= to)
777
+ continue;
778
+ // An OCR layer knows where its words end, not where their letters do (Tesseract gives every
779
+ // glyph of its invisible font the same width): a value that stops at punctuation glued to it
780
+ // ("616," "12/4,") would end at a guessed point inside its last letter. Such punctuation is
781
+ // covered with the value, so the cover ends where the word does, and painted again as part of
782
+ // the label ("[PHONE_1],"), so the picture reads like the redacted text.
783
+ let after = to;
784
+ let trail = '';
785
+ while (after < run.glyphs.length && run.glyphs[after].unicode.trim() !== '')
786
+ after++;
787
+ if (after > to && run.glyphs.slice(to, after).every((g) => GLUED_PUNCTUATION.test(g.unicode))) {
788
+ trail = run.glyphs.slice(to, after).map((g) => g.unicode).join('');
789
+ to = after;
790
+ }
791
+ let before = from;
792
+ let lead = '';
793
+ while (before > 0 && run.glyphs[before - 1].unicode.trim() !== '')
794
+ before--;
795
+ if (before < from && run.glyphs.slice(before, from).every((g) => GLUED_PUNCTUATION.test(g.unicode))) {
796
+ lead = run.glyphs.slice(before, from).map((g) => g.unicode).join('');
797
+ from = before;
798
+ }
799
+ // Tesseract writes each word with its trailing space in an invisible font whose letters all have
800
+ // the same width, and stretches both over the word's box: the letters may reach the end of the
801
+ // show, past the last letter's own advance. When only whitespace follows the value in such a show,
802
+ // the end of the show is where its ink may still be (`reach`). A layer set in a real font is not
803
+ // stretched: its trailing space is the gap before the next word and never belongs to the value.
804
+ let measureTo = to;
805
+ if (run.ocr && to < run.glyphs.length && run.glyphs.slice(to).every((g) => g.unicode.trim() === '') && equalAdvances(run))
806
+ measureTo = run.glyphs.length;
807
+ const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
808
+ const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
809
+ const ux = run.emX[0] / exLen;
810
+ const uy = run.emX[1] / exLen;
811
+ const vx = run.emY[0] / eyLen;
812
+ const vy = run.emY[1] / eyLen;
813
+ const scale = exLen / (run.local.fontSize * run.local.hscale || 1);
814
+ const first = run.glyphs[from];
815
+ let lo = 0;
816
+ let hi = 0;
817
+ let hiReach = 0;
818
+ let size = 0;
819
+ for (let i = from; i < measureTo; i++) {
820
+ const g = run.glyphs[i];
821
+ const along = (g.x - first.x) * ux + (g.y - first.y) * uy;
822
+ hiReach = Math.max(hiReach, along + g.advance * scale);
823
+ if (i >= to)
824
+ continue;
825
+ lo = Math.min(lo, along);
826
+ hi = Math.max(hi, along + g.advance * scale);
827
+ size = Math.max(size, g.fontSize);
828
+ }
829
+ const startsInWord = from > 0 && run.glyphs[from - 1].unicode.trim() !== '';
830
+ const endsInWord = to < run.glyphs.length && run.glyphs[to].unicode.trim() !== '';
831
+ // Inside a Tesseract word every letter has the same width: a value that is only part of the word
832
+ // ("2026" of "2026/03/017") is placed by real letter proportions over the printed word instead.
833
+ const spans = (startsInWord || endsInWord) ? proportionalLetters(run, ux, uy, scale) : null;
834
+ if (spans) {
835
+ // Offsets stay relative to the value's first glyph origin, as above.
836
+ const firstAlong = (first.x - run.glyphs[0].x) * ux + (first.y - run.glyphs[0].y) * uy;
837
+ lo = spans[from][0] - firstAlong;
838
+ hi = spans[to - 1][1] - firstAlong;
839
+ hiReach = hi;
840
+ }
841
+ const x = first.x + ux * lo;
842
+ const y = first.y + uy * lo;
843
+ const prev = marks[marks.length - 1];
844
+ if (prev && s.replacementIndex === lastIndex) {
845
+ // Same value: join when this piece continues the previous one's line.
846
+ const across = (x - prev.x) * prev.vx + (y - prev.y) * prev.vy;
847
+ const along = (x - prev.x) * prev.ux + (y - prev.y) * prev.uy;
848
+ const lineSize = Math.max(prev.size, size);
849
+ if (Math.abs(across) < 0.4 * lineSize && along > -0.5 * lineSize && along < prev.length + 2 * lineSize) {
850
+ prev.length = Math.max(prev.length, along + (hi - lo));
851
+ prev.reach = Math.max(prev.reach ?? prev.length, along + (hiReach - lo));
852
+ prev.size = lineSize;
853
+ if (endsInWord)
854
+ prev.endsInWord = true;
855
+ else
856
+ delete prev.endsInWord;
857
+ if (prev.labelEnd !== undefined)
858
+ prev.labelEnd += trail;
859
+ else
860
+ prev.label += trail;
861
+ continue;
862
+ }
863
+ }
864
+ // The rest of a value wrapped onto the next line shows only the punctuation glued to it, each
865
+ // side where it was printed.
866
+ const rest = s.replacementIndex === lastIndex;
867
+ const label = rest ? lead : lead + (replacements[s.replacementIndex]?.replacement ?? '') + trail;
868
+ marks.push({
869
+ x, y, ux, uy, vx, vy, length: hi - lo, ...(hiReach > hi ? { reach: hiReach - lo } : {}), size, label,
870
+ ...(rest ? { labelEnd: trail } : {}),
871
+ ...(startsInWord ? { startsInWord } : {}), ...(endsInWord ? { endsInWord } : {}),
872
+ });
873
+ lastIndex = s.replacementIndex;
874
+ }
875
+ return marks;
876
+ }
877
+ /**
878
+ * Share of a page's words that may start before / end after the text column: a margin note runs to
879
+ * a few percent of a page's words; on the right, past ragged line ends, only a page number or a
880
+ * stamp, so the end takes in nearly every line.
881
+ */
882
+ const COLUMN_START_OUTLIERS = 0.05;
883
+ const COLUMN_END_OUTLIERS = 0.02;
884
+ /**
885
+ * The text column each mark's line sits in, along its baseline (`columnStart`, `columnEnd`, user
886
+ * units from the mark's start): where the page's words start and end, without the outlying
887
+ * COLUMN_START_OUTLIERS / COLUMN_END_OUTLIERS, over the words running the same way. A label growing into blank
888
+ * paper beside a short value stays inside it: never into the margin.
889
+ */
890
+ export function withTextColumn(marks, runs, page) {
891
+ const words = [];
892
+ for (const run of runs) {
893
+ if (run.page !== page || run.space || run.glyphs.length === 0)
894
+ continue;
895
+ const len = Math.hypot(run.emX[0], run.emX[1]) || 1;
896
+ const k = len / (run.local.fontSize * run.local.hscale || 1);
897
+ const ux = run.emX[0] / len;
898
+ const uy = run.emX[1] / len;
899
+ let first = -1;
900
+ for (let i = 0; i <= run.glyphs.length; i++) {
901
+ const blank = i === run.glyphs.length || run.glyphs[i].unicode.trim() === '';
902
+ if (!blank && first < 0)
903
+ first = i;
904
+ if (blank && first >= 0) {
905
+ const a = run.glyphs[first];
906
+ const b = run.glyphs[i - 1];
907
+ words.push({ x0: a.x, y0: a.y, x1: b.x + ux * b.advance * k, y1: b.y + uy * b.advance * k, ux, uy });
908
+ first = -1;
909
+ }
910
+ }
911
+ }
912
+ const quantile = (values, q) => {
913
+ const sorted = [...values].sort((p, r) => p - r);
914
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(q * (sorted.length - 1))))];
915
+ };
916
+ return marks.map((m) => {
917
+ const same = words.filter((w) => w.ux * m.ux + w.uy * m.uy > 0.99);
918
+ if (same.length === 0)
919
+ return m;
920
+ const starts = same.map((w) => (w.x0 - m.x) * m.ux + (w.y0 - m.y) * m.uy);
921
+ const ends = same.map((w) => (w.x1 - m.x) * m.ux + (w.y1 - m.y) * m.uy);
922
+ return { ...m, columnStart: quantile(starts, COLUMN_START_OUTLIERS), columnEnd: quantile(ends, 1 - COLUMN_END_OUTLIERS) };
923
+ });
924
+ }
553
925
  /**
554
926
  * Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
555
927
  * whose text came from pdf.js, our own decoder's glyphs that spell a value are blacked out as
@@ -91,7 +91,8 @@ export declare const COMMON_WORD_NAMES: Readonly<Record<'en' | 'pl' | 'de', read
91
91
  * are exact even after characters whose lowercase form has a different
92
92
  * length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
93
93
  * on both sides ([\p{L}\p{N}] complement), except on a side where the term
94
- * itself ends in an unspaced script (CJK, Thai).
94
+ * itself ends in an unspaced script (CJK, Thai), and before a capitalised hit
95
+ * glued to an elision whose apostrophe OCR dropped ("dALTAMIR").
95
96
  */
96
97
  export declare function propagateEntities(text: string, entities: DetectedEntity[]): DetectedEntity[];
97
98
  /**
package/dist/pipeline.js CHANGED
@@ -289,6 +289,19 @@ function startsUppercase(value) {
289
289
  const first = codePointAt(value, 0);
290
290
  return first !== '' && first !== first.toLowerCase();
291
291
  }
292
+ /** French and Italian elisions ("d'", "l'", "qu'") written before a word. */
293
+ const ELISION_PREFIXES = ['qu', 'c', 'd', 'j', 'l', 'm', 'n', 's', 't'];
294
+ /**
295
+ * An elision glued to a capitalised hit ("dALTAMIR", "lEtat"): OCR often drops the apostrophe of
296
+ * "d'ALTAMIR". The lowercase prefix then counts as the word boundary when it starts a word itself.
297
+ */
298
+ function elidedBefore(text, idx, found) {
299
+ if (!startsUppercase(found))
300
+ return false;
301
+ return ELISION_PREFIXES.some((p) => idx >= p.length
302
+ && text.slice(idx - p.length, idx) === p
303
+ && !WORD_CHAR.test(codePointBefore(text, idx - p.length)));
304
+ }
292
305
  /**
293
306
  * Find every further occurrence of each detected value (and of the words of
294
307
  * multi-word values) in the text and emit it as a 'propagated' entity.
@@ -300,7 +313,8 @@ function startsUppercase(value) {
300
313
  * are exact even after characters whose lowercase form has a different
301
314
  * length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
302
315
  * on both sides ([\p{L}\p{N}] complement), except on a side where the term
303
- * itself ends in an unspaced script (CJK, Thai).
316
+ * itself ends in an unspaced script (CJK, Thai), and before a capitalised hit
317
+ * glued to an elision whose apostrophe OCR dropped ("dALTAMIR").
304
318
  */
305
319
  export function propagateEntities(text, entities) {
306
320
  const propagated = [];
@@ -341,7 +355,7 @@ export function propagateEntities(text, entities) {
341
355
  const key = `${idx}:${end}`;
342
356
  if (seen.has(key))
343
357
  continue;
344
- if (needsBoundaryBefore && WORD_CHAR.test(codePointBefore(text, idx)))
358
+ if (needsBoundaryBefore && WORD_CHAR.test(codePointBefore(text, idx)) && !elidedBefore(text, idx, found))
345
359
  continue;
346
360
  if (needsBoundaryAfter && WORD_CHAR.test(codePointAt(text, end)))
347
361
  continue;
@@ -812,18 +812,20 @@ export const RULES_DATA = [
812
812
  {
813
813
  "id": "regex:pl:street",
814
814
  "entityType": "ADDRESS",
815
- "pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?",
815
+ "pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?(?:\\s+(?:m\\.?|lok\\.)\\s*\\d+[\\p{L}]?)?",
816
816
  "flags": "gu",
817
817
  "confidence": 0.9,
818
818
  "domains": [
819
819
  "contact"
820
820
  ],
821
- "description": "Polish street address (ul./al./pl./os. + name + number)",
821
+ "description": "Polish street address (ul./al./pl./os. + name + number, with the flat: /3, m. 3, lok. 3)",
822
822
  "examples": [
823
823
  "ul. Floriańska 27/3",
824
824
  "ul. Juliusza Słowackiego 15/8",
825
825
  "al. Krakowska 42",
826
- "os. Złotego Wieku 12"
826
+ "os. Złotego Wieku 12",
827
+ "ul. Słoneczna 7 m. 12",
828
+ "ul. Długa 5 lok. 3"
827
829
  ]
828
830
  },
829
831
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccloak/core",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Environment-agnostic document anonymization engine for the DocCloak ecosystem.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Witold Lojek",
package/rules/pl.json CHANGED
@@ -150,18 +150,20 @@
150
150
  {
151
151
  "id": "regex:pl:street",
152
152
  "entityType": "ADDRESS",
153
- "pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?",
153
+ "pattern": "(?:ul\\.|al\\.|pl\\.|os\\.)\\s+[\\p{L}][\\p{L}\\s]+\\s+\\d+[\\p{L}]?(?:\\s*[/\\\\]\\s*\\d+[\\p{L}]?)?(?:\\s+(?:m\\.?|lok\\.)\\s*\\d+[\\p{L}]?)?",
154
154
  "flags": "gu",
155
155
  "confidence": 0.9,
156
156
  "domains": [
157
157
  "contact"
158
158
  ],
159
- "description": "Polish street address (ul./al./pl./os. + name + number)",
159
+ "description": "Polish street address (ul./al./pl./os. + name + number, with the flat: /3, m. 3, lok. 3)",
160
160
  "examples": [
161
161
  "ul. Floriańska 27/3",
162
162
  "ul. Juliusza Słowackiego 15/8",
163
163
  "al. Krakowska 42",
164
- "os. Złotego Wieku 12"
164
+ "os. Złotego Wieku 12",
165
+ "ul. Słoneczna 7 m. 12",
166
+ "ul. Długa 5 lok. 3"
165
167
  ]
166
168
  },
167
169
  {