@doccloak/core 0.12.1 → 0.14.0

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