@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/dist/pdf/write.js CHANGED
@@ -30,10 +30,12 @@ import { FallbackFontEmbedder, FallbackFontProvider, faceFor } from "./fallback-
30
30
  import { getDict, getStream, refOf, resolve } from "./objects.js";
31
31
  import { findOccurrences, openWithOracle, searchIndex } from "./oracle.js";
32
32
  import { rasterizePage, rasterizeScanPage } from "./raster.js";
33
+ import { helveticaWidth } from "./standard-metrics.js";
33
34
  import { serializeToUnicode } from "./cmap.js";
34
35
  import { planEdits, rewriteStream, segmentsFor } from "./surgery.js";
35
36
  import { PdfVerifyError, verifyPdf } from "./verify.js";
36
37
  import { lexContent } from "./lexer.js";
38
+ import { mul } from "./textstate.js";
37
39
  /** Scan pages render at the scan's own resolution, up to this. */
38
40
  const MAX_SCAN_DPI = 300;
39
41
  /** Pixel budget of one rendered scan page (a 300 dpi A3 page fits; larger pages render coarser). */
@@ -63,7 +65,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
63
65
  const oracle = await openWithOracle(extraction.bytes, assets, extraction.password);
64
66
  let fresh;
65
67
  try {
66
- 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 });
67
69
  }
68
70
  catch (err) {
69
71
  await oracle.close();
@@ -189,7 +191,7 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
189
191
  const sortedReplacements = [...all].sort((a, b) => a.start - b.start || a.end - b.end); // segmentsFor's order
190
192
  for (const page of model.pages) {
191
193
  if (scanPages.has(page.index)) {
192
- const marks = paintMarksForPage(segments, page.index, sortedReplacements);
194
+ const marks = withTextColumn(paintMarksForPage(segments, page.index, sortedReplacements), extraction.runs, page.index);
193
195
  let raster;
194
196
  try {
195
197
  raster = await rasterizeScanPage(oracle, page.index, marks, { dpi: scanDpi(page, options.rasterDpi), canvasFactory: options.canvasFactory });
@@ -209,8 +211,20 @@ export async function writeAnonymizedPdfWithReport(extraction, replacements, val
209
211
  const newPage = outDoc.addPage([raster.widthPt, raster.heightPt]);
210
212
  const name = newPage.node.newXObject('Scan', image);
211
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
+ }
212
226
  scanned.push(page.index);
213
- 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 (its text layer is gone)`);
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`);
214
228
  continue;
215
229
  }
216
230
  if (rasterPages.has(page.index)) {
@@ -591,9 +605,152 @@ function stampMetadata(outDoc) {
591
605
  outDoc.setCreationDate(NORMALISED_PDF_DATE);
592
606
  outDoc.setModificationDate(NORMALISED_PDF_DATE);
593
607
  }
594
- /** Resolution for a scan page: the scan's own (at least `base`, at most 300 dpi), within the pixel budget. */
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
+ */
595
752
  function scanDpi(page, base = 150) {
596
- const dpi = Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi)));
753
+ const dpi = page.info.imageOnly ? Math.max(base, Math.min(MAX_SCAN_DPI, Math.round(page.imageDpi))) : MAX_SCAN_DPI;
597
754
  const squareInches = Math.max(1e-6, (page.info.width / 72) * (page.info.height / 72));
598
755
  return Math.max(36, Math.min(dpi, Math.floor(Math.sqrt(MAX_SCAN_PIXELS / squareInches))));
599
756
  }
@@ -618,6 +775,35 @@ export function paintMarksForPage(segments, page, replacements) {
618
775
  to--;
619
776
  if (from >= to)
620
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;
621
807
  const exLen = Math.hypot(run.emX[0], run.emX[1]) || 1;
622
808
  const eyLen = Math.hypot(run.emY[0], run.emY[1]) || 1;
623
809
  const ux = run.emX[0] / exLen;
@@ -628,14 +814,30 @@ export function paintMarksForPage(segments, page, replacements) {
628
814
  const first = run.glyphs[from];
629
815
  let lo = 0;
630
816
  let hi = 0;
817
+ let hiReach = 0;
631
818
  let size = 0;
632
- for (let i = from; i < to; i++) {
819
+ for (let i = from; i < measureTo; i++) {
633
820
  const g = run.glyphs[i];
634
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;
635
825
  lo = Math.min(lo, along);
636
826
  hi = Math.max(hi, along + g.advance * scale);
637
827
  size = Math.max(size, g.fontSize);
638
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
+ }
639
841
  const x = first.x + ux * lo;
640
842
  const y = first.y + uy * lo;
641
843
  const prev = marks[marks.length - 1];
@@ -646,16 +848,80 @@ export function paintMarksForPage(segments, page, replacements) {
646
848
  const lineSize = Math.max(prev.size, size);
647
849
  if (Math.abs(across) < 0.4 * lineSize && along > -0.5 * lineSize && along < prev.length + 2 * lineSize) {
648
850
  prev.length = Math.max(prev.length, along + (hi - lo));
851
+ prev.reach = Math.max(prev.reach ?? prev.length, along + (hiReach - lo));
649
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;
650
861
  continue;
651
862
  }
652
863
  }
653
- const label = s.replacementIndex === lastIndex ? '' : replacements[s.replacementIndex]?.replacement ?? '';
654
- marks.push({ x, y, ux, uy, vx, vy, length: hi - lo, size, label });
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
+ });
655
873
  lastIndex = s.replacementIndex;
656
874
  }
657
875
  return marks;
658
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
+ }
659
925
  /**
660
926
  * Axis-aligned user-space boxes of every covered glyph on a page (for rasterization). On a page
661
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.14.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
  {