@doccloak/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +177 -0
- package/README.md +34 -9
- package/dist/dom/ocr.d.ts +41 -0
- package/dist/dom/ocr.js +78 -0
- package/dist/env.d.ts +1 -1
- package/dist/pdf/coverage.d.ts +41 -0
- package/dist/pdf/coverage.js +193 -0
- package/dist/pdf/extract.d.ts +7 -1
- package/dist/pdf/extract.js +82 -1
- package/dist/pdf/raster.d.ts +46 -2
- package/dist/pdf/raster.js +260 -51
- package/dist/pdf/read.d.ts +3 -1
- package/dist/pdf/read.js +209 -7
- package/dist/pdf/standard-metrics.d.ts +6 -0
- package/dist/pdf/standard-metrics.js +24 -1
- package/dist/pdf/types.d.ts +99 -0
- package/dist/pdf/verify.js +38 -5
- package/dist/pdf/write.d.ts +7 -0
- package/dist/pdf/write.js +274 -8
- package/dist/pipeline.d.ts +2 -1
- package/dist/pipeline.js +16 -2
- package/dist/regex/rules.data.js +5 -3
- package/package.json +1 -1
- package/rules/pl.json +5 -3
package/dist/pdf/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
|
|
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
|
-
/**
|
|
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 <
|
|
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
|
-
|
|
654
|
-
|
|
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
|
package/dist/pipeline.d.ts
CHANGED
|
@@ -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;
|
package/dist/regex/rules.data.js
CHANGED
|
@@ -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
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
|
{
|