@doccloak/core 0.12.1 → 0.13.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 +333 -0
  2. package/NOTICE +5 -0
  3. package/README.md +131 -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 +20 -6
  8. package/dist/dom/office.js +44 -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 +106 -0
  16. package/dist/pdf/extract.js +1064 -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 +45 -0
  34. package/dist/pdf/raster.js +89 -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 +77 -0
  44. package/dist/pdf/textstate.js +280 -0
  45. package/dist/pdf/types.d.ts +191 -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 +52 -0
  50. package/dist/pdf/write.js +644 -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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,339 @@ All notable changes to `@doccloak/core` are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.13.0] - 2026-09-25
8
+
9
+ PDF-to-PDF redaction that keeps the text layer (new `@doccloak/core/pdf`
10
+ entry point, see "Added" below), cancelable detection, and detection fixes
11
+ found while testing real PDFs: Polish landline phones, catch-all numbers
12
+ that swallowed a PESEL and a phone, regex spans without surrounding
13
+ whitespace, placeholder fitting, OCR tokens split by a lost character,
14
+ kerned punctuation after a redacted value, and overlapping detections that
15
+ dropped part of a value.
16
+ Host-facing additions are optional (`signal` on detect, `cancelDetect` in
17
+ the worker protocol, the PDF entry point); nothing a 0.12.x host uses was
18
+ removed. The new PDF dependencies are pinned exactly like the others:
19
+ `@cantoo/pdf-lib` 2.11.1, `@cantoo/fontkit` 2.0.12, `pdfjs-dist` 6.3.289.
20
+
21
+ ### Found by the first CI corpus run on real LibreOffice PDFs (T233)
22
+
23
+ - **PDF writer: kerned punctuation after a redacted value.** LibreOffice
24
+ writes `[(... Müller) 40 (, wohnhaft)] TJ`: the comma is pulled 0.48 pt
25
+ into the value's span. When a narrower placeholder closed the slack, the
26
+ rest of the line moved left but the comma, starting just before the end
27
+ of the removed span, counted as "left of the gap" and kept its old x,
28
+ landing inside "wohnhaft" ("wohn,haft", "[PHO,NE_1]"). A kept glyph now
29
+ moves with the text after a gap when it starts past the gap's middle; a
30
+ TJ that really jumps back still draws its text before the gap.
31
+ - **Overlap resolution keeps what a losing entity covers past the winner.**
32
+ `resolveOverlaps` dropped the loser whole. In a PDF exported from a
33
+ spreadsheet a clipped cell is glued to the next one ("31-147
34
+ Krakók.wisniewska@firma.pl"): the model took "31-147 Krakók", the e-mail
35
+ rule the whole token, and ".wisniewska@firma.pl" stayed in clear. Now the
36
+ rest of a regex match is re-run through its rule and kept where it still
37
+ matches (the e-mail does; "before 2025" left over from a bogus
38
+ "00 before 2025" date does not), and the rest of a model span is kept
39
+ without its edge punctuation. Detection never covers less than before.
40
+ - Tests: the corpus matches needles in PDFs across the line breaks a PDF
41
+ export puts inside a value (a space turned newline, a long e-mail broken
42
+ in a narrow cell, "Pawlak-" / "Dudek"), so wrapped values are replaced
43
+ and asserted absent instead of being counted as present and left alone;
44
+ values that LibreOffice Calc clips at a cell border are listed under
45
+ `optionalFor.pdf` in two seeds. PDF outputs of tests that redact only
46
+ some values on purpose (the desktop fixtures, the office invoice) are
47
+ written as `pdf-*`, so the CI poppler step opens them without grepping
48
+ the corpus needles.
49
+
50
+ ### OCR image redaction covers both halves of a token OCR split (T232)
51
+
52
+ - Tesseract can drop a character inside a token and return two words: in
53
+ the kit's scanned letter "t.zielinski@zielinski-kancelaria.pl" came back
54
+ as "t" + "zielinski@zielinski-kancelaria.pl." with the dot's pixels in the
55
+ second box. The e-mail rule matched the second word only and boxes are
56
+ drawn per OCR word, so the "t" stayed visible (with "jan.kowalski@..." it
57
+ would be a first name). `selectRedactionBoxes` now spreads a selection to
58
+ same-line neighbours that `findSplitTokens` (exported) marks as one token:
59
+ the gap between their boxes is at most 0.3 of the line's median word gap
60
+ (height-based fallback on short lines; touching boxes included), and
61
+ boxes taller than 1.6x the line's median height (OCR noise such as a
62
+ 26 px "w" on an 11 px line) never join. On seven OCR runs (the scan plus
63
+ kit pages 01/02 at 1000/1700/2500 px, about 1 150 words) exactly one pair
64
+ joins: the split e-mail. An ink test for a lost glyph left in a
65
+ normal-width gap was tried and dropped: Tesseract often cuts a letter's
66
+ edge out of its own box, which reads the same ("w terminie", "nr wpisu").
67
+ `renderRedactedImage`'s signature is unchanged.
68
+
69
+ ### A catch-all regex span yields to the specific detections it overlaps (T230)
70
+
71
+ - `universal:long_number` (confidence 0.5) admits whitespace, so a PESEL cell
72
+ followed by a phone cell without "+48" ("90050598761 512 987 654") was one
73
+ 23-character span that overlap resolution preferred (same start, longer)
74
+ over PESEL 0.9 + PHONE 0.8: the row came out as a single [SSN] and the
75
+ phone was "not detected". `detectEntities` now runs `splitCatchAlls`
76
+ first: a regex entity at or below confidence 0.5 that overlaps specific
77
+ detections (confidence >= 0.8, ML or regex) is replaced by its leftovers,
78
+ which are re-run through the same rule (an unknown long number next to a
79
+ phone is still caught); the specific spans then win. Catch-alls nothing
80
+ specific overlaps are untouched, and so is every other overlap (the
81
+ leak-averse "earlier start, longer span" order is unchanged).
82
+ Tests: tests/regex/catch-all-split.test.ts. No corpus expectation changed.
83
+
84
+ ### Polish landlines without +48; stricter postal-code-plus-city (T227)
85
+
86
+ - `regex:pl:phone` only knew the 3-3-3 mobile grouping, so Polish landlines
87
+ written without the country code ("22 555 01 02", "(22) 555-01-02",
88
+ "71 777 88 99") fell through to `universal:long_number` and came out as
89
+ [SSN_n] under region pl. The rule now also accepts the 2-3-2-2 landline
90
+ grouping with an optional bracketed area code, with or without +48, and
91
+ the "+" of a "+48" prefix is part of the match (the mobile branch used to
92
+ start at "48"). A PESEL, a NIP, a bank account and a date still do not
93
+ match; an 11-digit number starting with 48 is no longer taken for a phone.
94
+ - `regex:universal:postal_city` matched "05/2026 Maria" (date-like prefix)
95
+ and "111 222 przegląd" (lowercase word) and stole the start of a phone or
96
+ a person name. The code must now stand at the start of the line or after
97
+ whitespace (so nothing glued to a digit, "/", "." or "-" matches), a
98
+ 4-2 / 4-4 hyphenated shape ("2025-03", "2024-2025") is skipped and the
99
+ city must start with an uppercase letter. "00-950 Warszawa", "75008 Paris"
100
+ and "1010 Wien" still match; a code glued to an opening bracket
101
+ ("(75008 Paris)") no longer does through this rule (country packs still
102
+ cover it). The rule stays lookbehind-free (pattern dialect lint) by
103
+ consuming the leading whitespace, which `runRule` trims (T226).
104
+ - Web regression corpus `universal.json` case `u-03-generic-identifiers`:
105
+ "987654321 remains" was a baked-in postal_city false positive; the number
106
+ is now PHONE "987654321" via `regex:pl:phone` (0.8 beats
107
+ `universal:long_number` 0.5 and the other 9-digit rules on the same span).
108
+
109
+ ### Regex matches never carry whitespace (T226)
110
+
111
+ - The IBAN rule ended its match on a word boundary after a class that admits
112
+ whitespace, so "PL61 1090 1014 0000 0712 1981 2874 w terminie" matched
113
+ with the trailing space and the redaction produced "[IBAN_1]w terminie"
114
+ (text, DOCX and PDF alike). The pattern now ends on a letter or digit, and
115
+ `runRule` trims whitespace at both ends of every regex match as a general
116
+ safety net (start/end adjusted; a validator sees the trimmed value).
117
+
118
+ ### Placeholder fit: move before shrink (T225)
119
+
120
+ - The planner now moves the rest of the line before it shrinks a placeholder:
121
+ room up to the next column and up to the page's text edge is used at full
122
+ size first; only the remainder is shrunk (floor raised from 65 % to 75 %)
123
+ and condensed (80 %). Short words on left-aligned lines ("NIP", "Kraków")
124
+ no longer get half-size placeholders; lines that already reach the margin
125
+ behave as before. `fit.minSizeRatio` default is 0.75. The page's text edge
126
+ is a hard limit (content boxes are clipped by Chrome and Word), and of a
127
+ value wrapped over several lines the widest part now carries the
128
+ placeholder (before: always the first part).
129
+
130
+ ### Cancelable detection (T222)
131
+
132
+ - `DetectionProvider.detect(text, onProgress?, signal?)`: the three built-in
133
+ providers check the signal between inference chunks and reject with the new
134
+ `DetectionAbortedError` (also thrown by `engine.detect` for a signal aborted
135
+ before or after the ML pass; it used to be a plain `Error('Detection aborted')`).
136
+ - Worker protocol: new `cancelDetect { requestId }` request; the cancelled
137
+ call answers `detectError { aborted: true }`. `connectEngine().detect` with
138
+ an aborted signal now sends `cancelDetect` and rejects with
139
+ `DetectionAbortedError` once the host has acknowledged, so a rejection
140
+ means the worker is idle again (before, it rejected at once and the host
141
+ kept computing).
142
+
143
+ PDF-to-PDF redaction that keeps the text layer (T206-T218, founder decision
144
+ 2026-09-24 reversing the 2026-08-30 desktop-only split).
145
+
146
+ ### Added
147
+
148
+ - New entry point **`@doccloak/core/pdf`**: `readPdf(file, { assets })` and
149
+ `writeAnonymizedPdfWithReport(extraction, replacements, values, options)`.
150
+ The redacted glyphs are cut out of the content streams, the placeholder is
151
+ written back as real text in the document's own font when that font can
152
+ show it (codes proven present in the subset), else in a metrics-compatible
153
+ Liberation face (SIL OFL, shipped under `fonts/liberation`) embedded as a
154
+ subset. Placeholders are fitted to the redacted span: same size when it
155
+ fits, then shrunk to 65 %, then condensed to 80 % width, then the rest of
156
+ the line moves right by the overflow; a narrow placeholder closes the slack
157
+ so no hole is left. Every later show in the positioning scope is
158
+ re-anchored absolutely, so residual glyph positions cannot encode the
159
+ removed text (USENIX 2022 "Story Beyond the Eye").
160
+ - Fonts: simple Type1/TrueType/Type3 (Standard/WinAnsi/MacRoman/Differences,
161
+ ToUnicode), composite Type0 with Identity-H/V or embedded CMaps (Chrome,
162
+ Word, macOS output), standard-14 metrics for non-embedded fonts.
163
+ - Two independent extractors: our decoder and pdf.js. A page whose text
164
+ differs between them, or whose fonts cannot be read safely, is rasterized
165
+ (pixels blacked out) when it carries a redaction, and listed in
166
+ `rasterizedPages`. Text of such pages comes from pdf.js so detection still
167
+ sees it.
168
+ - Output is rebuilt as a fresh single-revision document: Info, XMP,
169
+ outlines, forms, annotations, attachments, JavaScript, named destinations,
170
+ structure tree, page labels and the incremental-update history of the
171
+ input never reach the output (reported in `removed`); ToUnicode maps of
172
+ edited fonts are trimmed to the codes still used; ActualText/Alt marked
173
+ content is stripped; dates normalised to 2000-01-01, producer `DocCloak`.
174
+ - Verification after every write: re-extraction with both extractors plus a
175
+ byte scan of every decoded stream and string (UTF-8, Latin-1, UTF-16 BE/LE)
176
+ and of the removed code sequences; any trace throws `PdfVerifyError` and
177
+ nothing is exported.
178
+ - `office.ts`: `officeFileKind` returns `'pdf'`; `analyzeOfficeFile` and
179
+ `redactOfficeFile` handle PDFs through a lazy import of the PDF module
180
+ (`options.pdf` carries assets, password, fit policy); results gain
181
+ `removed` and `rasterizedPages`.
182
+ - `UnredactablePart.kind` gains `'scanned-page'` and `'undecodable-text'`.
183
+ - Dependencies: `@cantoo/pdf-lib`, `@cantoo/fontkit`, `pdfjs-dist`. Hosts
184
+ serve the pdf.js worker, its CMaps and standard fonts, and the Liberation
185
+ TTFs same-origin (see README, "PDF").
186
+
187
+ ### Second review (T219, same release)
188
+
189
+ - Layer zero searches the flat text in the verifier's normal form (whitespace,
190
+ soft hyphens and zero-width characters dropped, ligatures spelled out, case
191
+ folded), so a value wrapped over a line end ("Jan" / "Kowalski" on the next
192
+ line), spaced differently by the layout or set with a ligature is redacted
193
+ instead of blocking the export with `PdfVerifyError`. Segments of a value
194
+ on other lines close up over the removed glyphs.
195
+ - A fallback font needed inside a form XObject that has no `/Resources` of
196
+ its own goes into the page resources (where viewers resolve it); duplicated
197
+ forms with identical bytes all receive it.
198
+ - Lexer: inline image data ends the way pdf.js finds it (`EI` need not follow
199
+ whitespace, must be followed by ASCII; DCT data ends at its EOI marker,
200
+ ASCIIHex at `>`, ASCII85 at `~>`; `/L` honoured); an image without an end
201
+ makes the rest of the stream unsafe instead of a silent opaque operator;
202
+ arrays/dicts nested deeper than 64 are unsafe instead of a stack overflow;
203
+ `--5` reads as -5 (pdf.js, Acrobat); raw CR inside literal strings is kept.
204
+ - CMap parser tolerates what pdf.js tolerates: a missing `endcmap`, a bfrange
205
+ destination array shorter than the range, bounds of different byte lengths.
206
+ Rewritten ToUnicode maps keep the source codespace and skip empty
207
+ destinations. Glyph list gains the AGL combining-mark names.
208
+ - `readPdf` enforces the byte cap before pdf.js parses the file. Files that
209
+ carry only an owner password (print/copy restrictions) open without one.
210
+ - Fonts: a placeholder reuses a simple font only through its encoding (glyph
211
+ names), never through ToUnicode, and only for codes whose ToUnicode entry
212
+ agrees with the glyph, so the page shows what the text layer says; composite
213
+ placeholders take the code byte length from the encoding CMap (Identity-H:
214
+ 2), not from the ToUnicode codespace; the lowest code wins ties in the
215
+ reverse map (space is 32, not 160); an empty ToUnicode destination falls
216
+ back to the encoding; TrueType fonts without `/Encoding` default the way
217
+ pdf.js does (Standard when Nonsymbolic, WinAnsi without the flag, MacRoman
218
+ for symbolic non-embedded); a symbolic embedded font our decoder cannot
219
+ read is rasterized on edit (pdf.js reads it), no longer refused; direct
220
+ font dictionaries no longer share a cache entry; right-to-left pages are
221
+ compared with pdf.js as a bag of characters (pdf.js hands them back in
222
+ visual order).
223
+ - Style matching: weight and slant words count only as whole words
224
+ ("Kobold", "Digital", "Hospital" are not bold/italic), `/StemV` is no
225
+ longer read as a weight (Chrome writes 130+ for regular Georgia), Computer
226
+ Modern / Nimbus / Cascadia names map to their families.
227
+ - Verifier: the removed-code-sequence pass only checks the complete code
228
+ sequence of a whole value (a fragment of a value split over two shows
229
+ legitimately occurs elsewhere) and deduplicates sequences; layer zero and
230
+ the placement planner are linear in the number of replacements (a 200-page
231
+ file with 32 000 replacements writes in under 2 s instead of 90 s).
232
+ - Layout: text pushed right by an overflowing placeholder stops before the
233
+ next column (the placeholder gives up its size floors rather than overlap);
234
+ a continuation line that starts with the tail of a wrapped value closes up
235
+ as a whole, justified word gaps included.
236
+ - `fitPlaceholder` rejects non-finite input and clamps a policy that would
237
+ enlarge the placeholder.
238
+
239
+ ### Third review (T220, same release)
240
+
241
+ - Text that lives in tiling pattern cells and ExtGState soft-mask groups is
242
+ walked like form XObjects: it reaches detection, is edited in place, and
243
+ never shares a line with page text (it was invisible to both extractors).
244
+ - Glyphs are kept in reading order along the baseline: a value shown back to
245
+ front, or words drawn from the right, reads as "Jan Kowalski" in the text a
246
+ detector sees; overlapping glyphs keep content order.
247
+ - The pdf.js cross-check no longer replaces our text when pdf.js read less
248
+ (a font it rejects, pattern text) or misread a font we decoded completely;
249
+ such pages keep our text and are rasterized on edit with our glyph
250
+ geometry for the black boxes (pdf.js item widths can be wrong). A page
251
+ whose only reading comes from pdf.js as glyph ids, controls, U+FFFD or
252
+ private-use characters is unredactable instead of "fine".
253
+ - Fonts: a placeholder never reuses a code the font advances by zero;
254
+ /Differences names without metrics (/.notdef, /gNN) advance like the base
255
+ encoding's glyph and blank glyphs are not "unreadable"; symbolic embedded
256
+ TrueType fonts without /Encoding decode through MacRoman (pdf.js); glyph
257
+ text is folded at decode time (fi -> fi, Kangxi radicals -> ideographs).
258
+ - Matching: layer zero, the verifier's text passes and its byte passes share
259
+ one search (whitespace, hyphens and ligatures ignored, case folded) that
260
+ requires token boundaries: "Nowak" is not "Nowakowski", "kid" is not /Kids;
261
+ a value hyphenated at a line end is still found. The verifier also scans
262
+ name objects and, for multi-word or numeric values, font programs.
263
+ - Layout: two placeholders on one line, or a value touching another in one
264
+ show, each get their placeholder and their shift; consecutive TJ
265
+ adjustments add up; the room before the next column accounts for shifts
266
+ already planned; a placeholder never goes below 4 pt (a warning reports an
267
+ unavoidable overlap); text on a rotated baseline crossing a line no longer
268
+ drags that line's cells.
269
+ - Output hygiene: XMP on any copied object, /PieceInfo, font descriptor
270
+ family/charset strings and layer names are removed from the output (and
271
+ the orphaned objects from the file); ToUnicode maps are rewritten for every
272
+ edited font, including fonts whose edited shows were deleted whole.
273
+ - Files with only an owner password open; Type3 fonts never take standard-14
274
+ metrics.
275
+
276
+ ### Fourth review (T221, same release)
277
+
278
+ - Randomized invariants (`tests/pdf-fuzz.test.ts`, seeded generator of
279
+ layouts with out-of-order text, kerning, wrapped and touching values,
280
+ ligatures, diacritics, fallback faces; 2000-document campaigns) found and
281
+ fixed: narrow glyphs drawn back to front filed after their neighbour;
282
+ shifts inside a TJ applied by array order instead of position (a jump-back
283
+ adjustment moved the wrong text, even off the page); overlapping runs
284
+ losing their word separator; two values touching in one show
285
+ over-estimating the room before the next column; layer zero handling a
286
+ shorter value before a longer one that contains it (the surname stayed);
287
+ verifier byte passes counting a Latin-1 letter (ó, ü) or a kerning number
288
+ as a token boundary ("Nowak" refused because of "Nowaków"); the
289
+ code-sequence pass without boundaries (now single-byte codes at token
290
+ boundaries only).
291
+ - Second real-world corpus (44 files: Acrobat, iText, Aspose, Scribus,
292
+ TCPDF, wkhtmltopdf, pdfTeX/xdvipdfmx, LibreOffice/OpenOffice, ArcGIS,
293
+ PDF 2.0 samples): pdfTeX Type1 fonts without /Encoding are decoded through
294
+ the font program's cleartext encoding (LaTeX documents are edited in place
295
+ instead of rasterized); a rasterized vertical-writing page is covered whole
296
+ (the value stayed legible before); the verifier scans only string operands
297
+ of content streams (a house number equal to a font size no longer refuses
298
+ the export), recognises font programs by their magic bytes (TrueType table
299
+ tags in the fallback subset are not values), and ignores a value that only
300
+ survives inside its own placeholder ("We" in "[WE]"); raster boxes on
301
+ pdf.js-read pages cover the value plus one glyph each side instead of the
302
+ whole item; scanned pages with a hidden OCR layer are described as such;
303
+ consecutive unredactable pages collapse into one entry ("pages 2-22").
304
+ - Robustness (byte mutations, hostile structures): page-tree errors and
305
+ every pdf.js failure surface as typed errors; a page pdf.js cannot read
306
+ fails verification closed; rasterization failures are typed; replacement
307
+ ranges outside the text are refused instead of clamped to the whole
308
+ document; caps on decoded content per page (32 MB), form XObject draws per
309
+ page (20 000) and glyphs per document (1.5 million, runs weighted) turn
310
+ Flate bombs and exponential XObject graphs into `too-large` or a refused
311
+ page, and pdf.js is never asked about such a page; every pdf.js page
312
+ operation has a 60 s deadline (effective where pdf.js runs in a worker);
313
+ layer zero, the placement planner (baseline index) and the byte scan
314
+ (native string search) are linear again for thousands of replacements.
315
+ - Verifier: pages both extractors read identically are settled by our own
316
+ pass; pdf.js is consulted only for pages with issues (its spacing
317
+ heuristics reported "Jan" inside a kerned "Janusz"), and its items are read
318
+ in baseline order (words drawn from the right no longer spell a value); the
319
+ removed-code-sequence pass runs only when our extractor cannot re-read the
320
+ output.
321
+ - pdf.js never registers document fonts with the browser's FontFace API:
322
+ reading a 100-font file in Chromium dropped from 4.2 s to 0.8 s and the
323
+ export is no longer dominated by font round trips.
324
+ - Web: a damaged PDF gets its own message (the docx one told users to open
325
+ the file in Word); a failed upload while another file is loaded shows its
326
+ message.
327
+
328
+ ### Tests
329
+
330
+ - 15 synthetic PDF fixtures (standard-14, Type0 Liberation, TJ kerning,
331
+ split shows, short names, form XObject, hidden text, ActualText,
332
+ annotations, AcroForm, outlines/Info/XMP, embedded file, incremental
333
+ update, object streams, rotated page with rise/Tz/Tw), the desktop's 10
334
+ standard-14 fixtures and a Chrome/Skia Type0 fixture; `assertNoTrace`
335
+ understands PDF (inflated streams, object streams, strings, UTF-16 BE).
336
+ - Fixture zips no longer carry implicit folder entries: JSZip stamped them
337
+ with the current time, so the "is deterministic" check failed when two
338
+ builds straddled a 2-second boundary (seen once under full-suite load).
339
+
7
340
  ## [0.12.1] - 2026-09-24
8
341
 
9
342
  First run of the real-file corpus job (LibreOffice-generated docx, doc and
package/NOTICE CHANGED
@@ -3,3 +3,8 @@ Copyright 2026 Witold Lojek
3
3
 
4
4
  This product includes software developed as part of the DocCloak project
5
5
  (https://github.com/WLojek).
6
+
7
+ This product bundles the Liberation Fonts (fonts/liberation/), Copyright 2012
8
+ Red Hat, Inc., licensed under the SIL Open Font License, Version 1.1
9
+ (see fonts/liberation/LICENSE). The fonts are embedded as subsets into
10
+ redacted PDF output; the Reserved Font Name "Liberation" is kept.
package/README.md CHANGED
@@ -59,7 +59,10 @@ ONNX Runtime also needs its WASM binaries at runtime; point
59
59
  ## API overview
60
60
 
61
61
  - **Engine** - `createEngine(env, initialSettings?, options?)` returns a
62
- `DocCloakEngine`: `detect(text, signal?, onProgress?)`, `preload()`,
62
+ `DocCloakEngine`: `detect(text, signal?, onProgress?)` (an aborted
63
+ `signal` stops the model at the next inference chunk and rejects with
64
+ `DetectionAbortedError`; over the worker protocol the client sends
65
+ `cancelDetect` and rejects once the host has stopped), `preload()`,
63
66
  `switchProvider(id)`, `getSettings()` / `updateSettings(patch)`,
64
67
  `onDownloadProgress` / `onDetectionProgress`, `release()`. Settings persist
65
68
  through `env.kv`. `options.autoLoad` (default `true`) lets `detect()`
@@ -165,6 +168,89 @@ streams: { data, objectPool, macros } }` without throwing, so a host can show
165
168
  the same consent card for a .doc whose `ObjectPool` (embedded objects) or
166
169
  `Macros` (VBA) storage will be copied unchanged.
167
170
 
171
+ ## PDF: redaction that keeps the text layer
172
+
173
+ `@doccloak/core/pdf` redacts PDFs without rasterizing them. The glyphs of a
174
+ detected value are cut out of the content stream; the placeholder is written
175
+ back as real text, in the document's own font whenever that font can show it
176
+ (every character must map to a code whose glyph is proven present), else in
177
+ a metrics-compatible Liberation face (Sans/Serif/Mono, matched by the font's
178
+ style flags and name) embedded as a subset. Everything else on the page
179
+ stays live text.
180
+
181
+ ```ts
182
+ import { readPdf, writeAnonymizedPdfWithReport } from '@doccloak/core/pdf';
183
+
184
+ const assets = {
185
+ pdfjsWorkerUrl: '/pdf/pdf.worker.mjs', // node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs
186
+ cMapUrl: '/pdf/cmaps/', // node_modules/pdfjs-dist/cmaps/
187
+ standardFontDataUrl: '/pdf/standard_fonts/', // node_modules/pdfjs-dist/standard_fonts/
188
+ fontsUrl: '/pdf/fonts/', // node_modules/@doccloak/core/fonts/liberation/
189
+ };
190
+ const extraction = await readPdf(file, { assets }); // extraction.plainText -> detect
191
+ const entities = await engine.detect(extraction.plainText);
192
+ const replacements = entities.map((e) => ({ start: e.start, end: e.end, replacement: session.anonymize(e.value, e.type) }));
193
+ const { blob, warnings, removed, rasterizedPages } = await writeAnonymizedPdfWithReport(
194
+ extraction, replacements, session.getEntries(), { assets },
195
+ );
196
+ ```
197
+
198
+ Or go through `redactOfficeFile(file, { session, detect, pdf: { assets } })`,
199
+ which now accepts `.pdf` next to `.docx` and `.xlsx`.
200
+
201
+ **Placeholder fitting.** A placeholder rarely has the width of what it
202
+ replaces. The writer keeps the original size when the placeholder fits the
203
+ gap. Otherwise it first moves the rest of the line right, as far as the next
204
+ column and the page's text edge (the farthest line end on the page, a proxy
205
+ for the right margin) allow; only what still does not fit is shrunk down to
206
+ 75 % of the size, then condensed down to 80 % of its width (`Tz`), so a
207
+ short word on a left-aligned line gets a full-size placeholder and a line
208
+ that already reaches the margin (right-aligned, justified, a table cell)
209
+ gets a smaller one (`fit` option: `minSizeRatio`, `minHorizontalScale`,
210
+ `allowOverflow`). The page's text edge is a hard limit (Chrome and Word clip
211
+ their content boxes, so text pushed past the margin would be cut); against
212
+ it, as against a column, the placeholder gives up its floors (never below
213
+ 4 pt). Of a value wrapped over several lines, the widest part carries the
214
+ placeholder and the other parts are excised, so "ul." at a line end never
215
+ has to hold "[ADDRESS_1]". A placeholder narrower than the gap closes the slack, so
216
+ "[PERSON_1], PESEL [ID_1], ..." reads without holes. Text on other lines and
217
+ text separated by a tab-sized gap on the same line never moves. Every other
218
+ occurrence of a session value is redacted too (layer zero, as for docx),
219
+ matched with whitespace, soft hyphens and ligatures ignored: a name wrapped
220
+ over a line end gets its placeholder on the first line, and the next line
221
+ closes up over the removed part.
222
+
223
+ **Safety.** Every show after an edited one in the same positioning scope is
224
+ re-anchored with an absolute text matrix computed from the original
225
+ geometry, so the surviving glyphs cannot encode what was removed. Text in
226
+ form XObjects, tiling pattern cells and soft-mask groups is walked too, and
227
+ glyphs are read in baseline order whatever the content order. Two
228
+ independent extractors read every page (our decoder and pdf.js): a page
229
+ whose text differs, or whose fonts cannot be read safely (CJK predefined
230
+ CMaps, unreadable ToUnicode, vertical writing, unparsable content), is rasterized
231
+ when it carries a redaction and blacked out in the pixels; such pages are
232
+ listed in `rasterizedPages`. After writing, the output is re-read by both
233
+ extractors and byte-scanned (every decoded stream and string, UTF-8, Latin-1,
234
+ UTF-16 BE/LE, plus the removed code sequences); any trace throws
235
+ `PdfVerifyError` and nothing is exported.
236
+
237
+ **What is removed.** The output is rebuilt as a fresh single-revision file:
238
+ document properties (`/Info`), XMP, bookmarks, interactive forms,
239
+ annotations (comments, links, widgets), attachments, JavaScript, named
240
+ destinations, page labels and the tagged-PDF structure tree are dropped and
241
+ listed in `removed`; the input's incremental-update history cannot survive.
242
+ ToUnicode maps of edited fonts are trimmed to the codes still used,
243
+ `ActualText`/`Alt` marked-content properties are stripped, dates are set to
244
+ 2000-01-01 and the producer to `DocCloak`. Optional-content (layer)
245
+ configuration is dropped as well, so content of hidden layers becomes
246
+ visible (it is redacted like everything else).
247
+
248
+ **Refusals.** Encrypted files (`'encrypted'`, pass `password`), files over
249
+ 50 MB or 500 pages (`'too-large'`), non-PDF bytes (`'invalid-package'`).
250
+ Scanned pages (image without a text layer) and text no decoder can read are
251
+ reported in `unredactable` (`'scanned-page'`, `'undecodable-text'`) and gate
252
+ the export behind `allowUnredactable`, like Office packages.
253
+
168
254
  ## What is removed or normalised in Office packages
169
255
 
170
256
  Independently of what was detected, every exported .docx / .xlsx has:
@@ -200,6 +286,41 @@ Independently of what was detected, every exported .docx / .xlsx has:
200
286
 
201
287
  ## Known limits
202
288
 
289
+ **PDF**
290
+
291
+ - The width of a redacted region stays observable: the placeholder (or the
292
+ closed slack) tells how wide the original text was. This is inherent to
293
+ every layout-preserving redaction; only rasterizing or re-typesetting the
294
+ paragraph would hide it.
295
+ - Subset fonts keep their glyph repertoire (which characters occurred
296
+ somewhere in the document), not the removed text.
297
+ - Pages with predefined CJK CMaps, unreadable ToUnicode maps, vertical
298
+ writing or text our decoder cannot confirm against pdf.js are rasterized
299
+ when they carry a redaction (their text layer is gone on that page).
300
+ Type3 fonts are edited like other simple fonts (their text comes from the
301
+ encoding names); a placeholder is never drawn with a Type3 font unless the
302
+ font itself showed every character of it. Digital signatures are
303
+ invalidated by any change.
304
+ - Images are copied unchanged; text inside pictures is not OCR-scanned.
305
+ - Forms, annotations, bookmarks and attachments are dropped, not redacted.
306
+ - Right-to-left text: pdf.js reports it in visual order, so the cross-check
307
+ compares the characters of such pages as a set; detection runs on our
308
+ logical-order text.
309
+ - Placeholders are set in the document's font or a Liberation face: a
310
+ placeholder character neither can show (CJK, emoji) is written as `?`
311
+ and reported in `warnings`.
312
+ - A page whose text only pdf.js can read, and only as glyph ids, controls or
313
+ replacement characters (fonts without a usable encoding or ToUnicode), is
314
+ reported as unredactable: no detector could find a value in it.
315
+ - Limits: 50 MB, 500 pages, 1.5 million glyphs (`maxBytes`, `maxPages`,
316
+ `maxGlyphs`), 32 MB of decoded content and 20 000 form draws per page.
317
+ Memory: the glyph model of the whole file is held during write and verify
318
+ (a 200-page text-heavy file peaks around 0.8 GB of heap in Node).
319
+ - A page whose content would make a viewer work forever (an exponential
320
+ chain of form XObjects, a decompression bomb) is never handed to pdf.js and
321
+ cannot be rasterized; pdf.js page operations time out after 60 s where it
322
+ runs in a worker (in Node's fake worker a hostile file can still block).
323
+
203
324
  **Office packages (.docx, .xlsx)**
204
325
 
205
326
  - Images inside documents (`media/*`) are copied unchanged; text in pictures
@@ -207,8 +328,9 @@ Independently of what was detected, every exported .docx / .xlsx has:
207
328
  - Embedded objects (`embeddings/*`), VBA projects, `altChunk` HTML fragments,
208
329
  external data connections, external links and printer settings cannot be
209
330
  redacted. They are reported in `extraction.unredactable` and exported
210
- verbatim only with `allowUnredactable: true` (see above). Removing them is
211
- planned for 0.13.0 (T199), gated on the real-file corpus (T196).
331
+ verbatim only with `allowUnredactable: true` (see above). Removing them
332
+ (instead of copying them) is planned for a later release (T199), gated on
333
+ the real-file corpus (T196); 0.13.0 keeps the report-and-ask behaviour.
212
334
  - Spreadsheets: sheet names with an entity are reported in `warnings`, not
213
335
  renamed; numeric and date cells, formula literals, defined names and data
214
336
  validation texts are not extracted yet (T176).
@@ -268,7 +390,7 @@ interface CoreEnv {
268
390
  fetch: typeof fetch; // injectable for tests/proxies
269
391
  wasm: { paths: string; numThreads?: number }; // onnxruntime-web asset location
270
392
  buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
271
- /** @deprecated since 0.12.0, removed in 0.13.0; used only when buildTokenizer is absent */
393
+ /** @deprecated since 0.12.0, removal planned for 0.14.0; used only when buildTokenizer is absent */
272
394
  loadTokenizer?(hfModelId: string): Promise<unknown>;
273
395
  hardware?: HardwareHints; // deviceMemoryGB / isMobile, feeds provider default
274
396
  persistStorage?: () => Promise<boolean>; // e.g. navigator.storage.persist()
@@ -302,11 +424,11 @@ With `buildTokenizer` the host never contacts the network for tokenizers, a
302
424
  warm `modelCache` works offline, and the only URLs fetched on a cold start
303
425
  are the three pinned `resolve/<commit>/...` files of the active provider.
304
426
 
305
- `loadTokenizer(hfModelId)` (0.11 and earlier) is still honoured when
306
- `buildTokenizer` is absent, with a one-time deprecation warning. It lets the
307
- host library probe the mutable `resolve/main` branch and keep an unverified
308
- second cache, so migrate before 0.13.0, where `buildTokenizer` becomes
309
- required and `loadTokenizer` is removed.
427
+ `loadTokenizer(hfModelId)` (0.11 and earlier) is still honoured in 0.13.0
428
+ when `buildTokenizer` is absent, with a one-time deprecation warning. It
429
+ lets the host library probe the mutable `resolve/main` branch and keep an
430
+ unverified second cache, so migrate now: the removal is planned for 0.14.0,
431
+ where `buildTokenizer` becomes required.
310
432
 
311
433
  ### autoLoad and ModelNotLoadedError
312
434
 
@@ -29,7 +29,7 @@ export declare function isUnsupportedDocumentError(err: unknown): err is Unsuppo
29
29
  export interface UnredactablePart {
30
30
  /** Zip path of the part, e.g. 'word/embeddings/oleObject1.bin' */
31
31
  part: string;
32
- kind: 'embedded-object' | 'macros' | 'html-chunk' | 'external-data' | 'printer-settings' | 'unknown';
32
+ kind: 'embedded-object' | 'macros' | 'html-chunk' | 'external-data' | 'printer-settings' | 'unknown' | 'scanned-page' | 'undecodable-text';
33
33
  /** Human-readable label for host UIs, e.g. 'embedded Excel sheet' */
34
34
  label: string;
35
35
  }
package/dist/dom/ocr.d.ts CHANGED
@@ -85,22 +85,48 @@ interface BlockLike {
85
85
  * Exported for tests.
86
86
  */
87
87
  export declare function buildTextFromBlocks(blocks: BlockLike[]): OcrExtraction;
88
+ type PixelBox = {
89
+ x0: number;
90
+ y0: number;
91
+ x1: number;
92
+ y1: number;
93
+ };
94
+ /**
95
+ * For each pair of consecutive OCR words, whether they are really one token
96
+ * that OCR split by dropping a character. Tesseract reads the dot in
97
+ * "t.zielinski@firma.pl" as a space and returns "t" + "zielinski@firma.pl",
98
+ * with the dot's pixels inside the second box; the e-mail rule then matches
99
+ * only the second word, and without this the "t" would stay visible. Such a
100
+ * split leaves a sliver between the boxes, far narrower than a real space: a
101
+ * pair on the same line is one token when its gap is at most
102
+ * TIGHT_GAP_SHARE of the line's typical word gap (boxes that touch or
103
+ * overlap included). Boxes much taller than the line (MAX_JOIN_HEIGHT_RATIO)
104
+ * are OCR noise and never join, so the fix cannot paint a bar across
105
+ * neighbouring lines.
106
+ *
107
+ * Not covered: a lost glyph that stays in a normal-width gap (seen only on
108
+ * low-resolution images, around 120 dpi). Telling it from a glyph edge that
109
+ * Tesseract cut off its own box needs pixels and misfired on real scans
110
+ * ("w terminie", "nr wpisu"), so gap width alone decides.
111
+ *
112
+ * Returns an array of length words.length - 1: entry i joins words i and i+1.
113
+ * Exported for tests.
114
+ */
115
+ export declare function findSplitTokens(words: OcrWord[]): boolean[];
88
116
  /**
89
117
  * Pick the bounding boxes of every OCR word that overlaps one of the given
90
118
  * character ranges. A word partially covered by a range is fully redacted
91
- * (over-redaction is safer than leaking half an identifier).
119
+ * (over-redaction is safer than leaking half an identifier). A selected
120
+ * word also pulls in the same-line neighbours OCR split off by dropping a
121
+ * character (findSplitTokens), so "t" + "zielinski@firma.pl" is redacted
122
+ * as one token.
92
123
  *
93
124
  * Exported for tests.
94
125
  */
95
126
  export declare function selectRedactionBoxes(words: OcrWord[], ranges: {
96
127
  start: number;
97
128
  end: number;
98
- }[]): {
99
- x0: number;
100
- y0: number;
101
- x1: number;
102
- y1: number;
103
- }[];
129
+ }[]): PixelBox[];
104
130
  /**
105
131
  * Draw black boxes over the given character ranges and return the redacted
106
132
  * image as a PNG blob.