@doccloak/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/session.js CHANGED
@@ -34,6 +34,59 @@ function isPersonVariant(a, b) {
34
34
  const [small, big] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
35
35
  return small.every((t) => big.includes(t)) && small.some((t) => t.length >= 3);
36
36
  }
37
+ /**
38
+ * Variant suffix (T171): where a person variant sits inside the reference
39
+ * name. A single token at the reference's first/last position is a first
40
+ * name / surname; a single token elsewhere is a middle name; several tokens
41
+ * are a shortened form; a variant with MORE tokens than the reference is the
42
+ * fuller form. Suffixes are stable uppercase ASCII so the resulting token
43
+ * ([PERSON_2_LAST]) stays inside the tolerant-restore candidate shape.
44
+ */
45
+ function variantSuffix(variant, reference) {
46
+ const vt = personTokens(variant);
47
+ const rt = personTokens(reference);
48
+ if (vt.length > rt.length)
49
+ return 'FULL';
50
+ if (vt.length === rt.length)
51
+ return 'ALT';
52
+ if (vt.length === 1) {
53
+ const idx = rt.indexOf(vt[0]);
54
+ if (idx === 0)
55
+ return 'FIRST';
56
+ if (idx === rt.length - 1)
57
+ return 'LAST';
58
+ return 'MIDDLE';
59
+ }
60
+ return 'SHORT';
61
+ }
62
+ /** Whether every person token of `a` occurs in `b`. */
63
+ function isTokenSubset(a, b) {
64
+ const tb = personTokens(b);
65
+ const ta = personTokens(a);
66
+ return ta.length > 0 && ta.every((t) => tb.includes(t));
67
+ }
68
+ /**
69
+ * A variant token derived from its base token: the suffix goes inside the
70
+ * closing bracket ("[PERSON_2]" -> "[PERSON_2_LAST]"); a renamed label
71
+ * without brackets gets a plain underscore suffix.
72
+ */
73
+ function withVariantSuffix(base, suffix) {
74
+ return base.endsWith(']') ? `${base.slice(0, -1)}_${suffix}]` : `${base}_${suffix}`;
75
+ }
76
+ /** Variant token shape, for re-linking deserialized maps (T171). */
77
+ const VARIANT_TOKEN_RE = /^(\[.+?)_(FIRST|LAST|MIDDLE|SHORT|FULL|ALT)(?:_(\d+))?\]$/;
78
+ /**
79
+ * Apply the case pattern of `sample` to `word` (T171 surrogate variants):
80
+ * an all-lowercase "smith" maps to "nowak", an all-uppercase "SMITH" to
81
+ * "NOWAK"; anything else keeps the surrogate's own casing.
82
+ */
83
+ function matchCase(word, sample) {
84
+ if (sample === sample.toLowerCase() && sample !== sample.toUpperCase())
85
+ return word.toLowerCase();
86
+ if (sample === sample.toUpperCase() && sample !== sample.toLowerCase())
87
+ return word.toUpperCase();
88
+ return word;
89
+ }
37
90
  /**
38
91
  * Typed placeholder token, e.g. "[PERSON_1]" or "[CREDIT_CARD_2]".
39
92
  * EntityType ids are stable uppercase ASCII ([A-Z_]), so generated tokens
@@ -52,6 +105,11 @@ export class AnonymizationSession {
52
105
  this.reverseMap = new Map();
53
106
  this.entityTypeMap = new Map();
54
107
  this.typeCounters = new Map();
108
+ /**
109
+ * Variant token -> its group's base token and suffix (T171), so renaming
110
+ * the base re-derives its variants and deserialized maps re-link.
111
+ */
112
+ this.variantOf = new Map();
55
113
  this.mode = options?.mode ?? 'labeled';
56
114
  this.salt = options?.salt ?? generateSessionSalt();
57
115
  }
@@ -120,23 +178,31 @@ export class AnonymizationSession {
120
178
  return this.forwardMap.get(original);
121
179
  }
122
180
  // T057 person-variant unification: "John Smith", "John" and "Smith"
123
- // in one session are the same person and share one replacement (in
124
- // every mode - the variant simply reuses the matched value's
125
- // placeholder/surrogate). The reverse map keeps the LONGEST variant,
126
- // so restore always yields the fullest known form.
181
+ // in one session are the same person and share one identity. T171
182
+ // makes restore exact: every distinct variant gets its OWN token that
183
+ // keeps the group's number ([PERSON_2] / [PERSON_2_LAST]) or, in
184
+ // surrogate mode, the matching part of the group's surrogate ("Nowak"
185
+ // for "Smith" when "John Smith" became "Adam Nowak"), so the reverse map
186
+ // stays one-to-one and restore writes back exactly what was there.
127
187
  if (entityType === 'PERSON') {
128
188
  const match = this.findPersonVariant(original);
129
189
  if (match) {
130
- const placeholder = this.forwardMap.get(match);
131
- this.forwardMap.set(original, placeholder);
190
+ const token = this.mode === 'blanked'
191
+ ? '________'
192
+ : this.mode === 'surrogate'
193
+ ? this.variantSurrogate(original, match)
194
+ : this.variantPlaceholder(original, match);
195
+ this.forwardMap.set(original, token);
132
196
  this.entityTypeMap.set(original, entityType);
133
197
  if (this.mode !== 'blanked') {
134
- const canonical = this.reverseMap.get(placeholder);
198
+ // Token-mapping can legitimately fall back to reusing the group's
199
+ // surrogate; then the longest original stays canonical (T057).
200
+ const canonical = this.reverseMap.get(token);
135
201
  if (canonical === undefined || original.length > canonical.length) {
136
- this.reverseMap.set(placeholder, original);
202
+ this.reverseMap.set(token, original);
137
203
  }
138
204
  }
139
- return placeholder;
205
+ return token;
140
206
  }
141
207
  }
142
208
  const placeholder = this.mode === 'blanked'
@@ -153,6 +219,65 @@ export class AnonymizationSession {
153
219
  this.entityTypeMap.set(original, entityType);
154
220
  return placeholder;
155
221
  }
222
+ /**
223
+ * The group a mapped value belongs to (T171): its base token and the
224
+ * canonical original that token restores to.
225
+ */
226
+ variantGroup(match) {
227
+ const matchedToken = this.forwardMap.get(match);
228
+ const base = this.variantOf.get(matchedToken)?.base ?? matchedToken;
229
+ return { base, canonical: this.reverseMap.get(base) ?? match };
230
+ }
231
+ /**
232
+ * Labeled-mode variant token (T171): the group's base token plus a
233
+ * positional suffix, computed against the group's canonical name when
234
+ * the variant is part of it, else against the value it matched. Distinct
235
+ * originals never share a token: a suffix already in use gets a counter
236
+ * ("Smith" -> [PERSON_2_LAST], "smith" -> [PERSON_2_LAST_2]).
237
+ */
238
+ variantPlaceholder(original, match) {
239
+ const { base, canonical } = this.variantGroup(match);
240
+ const reference = isTokenSubset(original, canonical) ? canonical : match;
241
+ const suffix = variantSuffix(original, reference);
242
+ let token = withVariantSuffix(base, suffix);
243
+ for (let n = 2; this.reverseMap.has(token); n++) {
244
+ token = withVariantSuffix(base, `${suffix}_${n}`);
245
+ }
246
+ this.variantOf.set(token, { base, suffix });
247
+ return token;
248
+ }
249
+ /**
250
+ * Surrogate-mode variant (T171): when the variant is a strict part of the
251
+ * group's canonical name and the surrogate has the same word count, take
252
+ * the surrogate words at the same positions with the variant's casing.
253
+ * Anything else (fuller forms, reshaped surrogates, a collision with an
254
+ * existing value) falls back to the group's surrogate as before.
255
+ */
256
+ variantSurrogate(original, match) {
257
+ const { base, canonical } = this.variantGroup(match);
258
+ const canonicalWords = canonical.split(/\s+/).filter(Boolean);
259
+ const surrogateWords = base.split(/\s+/).filter(Boolean);
260
+ const canonicalTokens = personTokens(canonical);
261
+ const variantWords = original.split(/\s+/).filter(Boolean);
262
+ const variantTokens = personTokens(original);
263
+ const shapesAlign = canonicalWords.length === surrogateWords.length
264
+ && canonicalTokens.length === canonicalWords.length
265
+ && variantTokens.length === variantWords.length
266
+ && variantTokens.length < canonicalTokens.length
267
+ && variantTokens.every((t) => canonicalTokens.includes(t));
268
+ if (!shapesAlign)
269
+ return base;
270
+ const mapped = variantWords
271
+ .map((word, i) => matchCase(surrogateWords[canonicalTokens.indexOf(variantTokens[i])], word))
272
+ .join(' ');
273
+ const taken = mapped === original
274
+ || this.forwardMap.has(mapped)
275
+ || (this.reverseMap.has(mapped) && this.reverseMap.get(mapped) !== original);
276
+ if (taken)
277
+ return base;
278
+ this.variantOf.set(mapped, { base, suffix: '' });
279
+ return mapped;
280
+ }
156
281
  deanonymize(text) {
157
282
  let result = text;
158
283
  // Sort by placeholder length (longest first) to avoid partial replacements
@@ -201,17 +326,21 @@ export class AnonymizationSession {
201
326
  return out + text.slice(last);
202
327
  }
203
328
  anonymizeText(text, entities) {
204
- // Surrogate mode (T043): map PERSON entities first, in reading order,
205
- // so an email appearing before/after its owner in the text can derive
206
- // its local part from the person's surrogate (jan.kowalski maps to
207
- // adam.nowak style). anonymize() is idempotent per value, so the
208
- // replacement pass below reuses these mappings. Labeled mode keeps its
209
- // historical end-to-start issuing order (placeholder numbering).
210
- if (this.mode === 'surrogate') {
211
- for (const entity of [...entities].sort((a, b) => a.start - b.start)) {
212
- if (entity.type === 'PERSON')
213
- this.anonymize(entity.value, entity.type);
214
- }
329
+ // PERSON pre-pass: map people before anything else so that (T171) each
330
+ // variant group forms around its FULLEST name - "John Smith" is the
331
+ // base and "Smith" / "smith" hang off it, whatever order they appear in
332
+ // - and so that (T043) an email appearing before/after its owner can
333
+ // derive its local part from the person's surrogate (jan.kowalski maps
334
+ // to adam.nowak style). Fullest name first, then reading order.
335
+ // anonymize() is idempotent per value, so the replacement pass below
336
+ // reuses these mappings; other types keep the historical end-to-start
337
+ // issuing order (placeholder numbering).
338
+ if (this.mode !== 'blanked') {
339
+ const people = entities
340
+ .filter((e) => e.type === 'PERSON')
341
+ .sort((a, b) => personTokens(b.value).length - personTokens(a.value).length || a.start - b.start);
342
+ for (const entity of people)
343
+ this.anonymize(entity.value, entity.type);
215
344
  }
216
345
  // Sort entities by position (end to start) to preserve indices during replacement
217
346
  const sorted = [...entities].sort((a, b) => b.start - a.start);
@@ -238,19 +367,51 @@ export class AnonymizationSession {
238
367
  getForward(original) {
239
368
  return this.forwardMap.get(original);
240
369
  }
370
+ /**
371
+ * Rename the label of a mapped value. Renaming a group's base token
372
+ * (T171) re-derives every variant token from the new label
373
+ * ([PERSON_1] -> [CLIENT] takes [PERSON_1_LAST] to [CLIENT_LAST]);
374
+ * renaming a variant token renames only that token and detaches it from
375
+ * the group, so a later base rename does not overwrite the user's choice.
376
+ * Returns every [oldLabel, newLabel] pair applied, so hosts can update
377
+ * already-rendered text without re-anonymizing.
378
+ */
241
379
  renameLabel(original, newLabel) {
242
380
  const oldLabel = this.forwardMap.get(original);
243
- if (!oldLabel)
244
- return;
245
- // T057: variants share one label; renaming any of them renames the
246
- // whole group, and the canonical restore value carries over.
247
- const canonical = this.reverseMap.get(oldLabel) ?? original;
248
- for (const [orig, label] of this.forwardMap) {
249
- if (label === oldLabel)
250
- this.forwardMap.set(orig, newLabel);
381
+ if (!oldLabel || oldLabel === newLabel)
382
+ return [];
383
+ const pairs = [];
384
+ const rename = (from, to) => {
385
+ // Every original sharing the label (legacy many-to-one maps) moves
386
+ // with it, and the canonical restore value carries over.
387
+ const canonical = this.reverseMap.get(from);
388
+ for (const [orig, label] of this.forwardMap) {
389
+ if (label === from)
390
+ this.forwardMap.set(orig, to);
391
+ }
392
+ this.reverseMap.delete(from);
393
+ if (canonical !== undefined)
394
+ this.reverseMap.set(to, canonical);
395
+ pairs.push([from, to]);
396
+ };
397
+ if (this.variantOf.has(oldLabel)) {
398
+ this.variantOf.delete(oldLabel);
399
+ rename(oldLabel, newLabel);
400
+ return pairs;
251
401
  }
252
- this.reverseMap.delete(oldLabel);
253
- this.reverseMap.set(newLabel, canonical);
402
+ rename(oldLabel, newLabel);
403
+ for (const [token, info] of [...this.variantOf]) {
404
+ if (info.base !== oldLabel)
405
+ continue;
406
+ let next = withVariantSuffix(newLabel, info.suffix);
407
+ for (let n = 2; this.reverseMap.has(next) || next === newLabel; n++) {
408
+ next = withVariantSuffix(newLabel, `${info.suffix}_${n}`);
409
+ }
410
+ this.variantOf.delete(token);
411
+ this.variantOf.set(next, { base: newLabel, suffix: info.suffix });
412
+ rename(token, next);
413
+ }
414
+ return pairs;
254
415
  }
255
416
  /**
256
417
  * Serialize the session map to JSON.
@@ -332,6 +493,10 @@ export class AnonymizationSession {
332
493
  }
333
494
  }
334
495
  this.entityTypeMap.set(entry.original, entry.entityType);
496
+ // T171 variant tokens ([PERSON_1_LAST], [PERSON_1_LAST_2]) carry no
497
+ // counter of their own; they re-link to their base below.
498
+ if (VARIANT_TOKEN_RE.test(entry.replacement))
499
+ continue;
335
500
  const match = TYPED_PLACEHOLDER_RE.exec(entry.replacement);
336
501
  if (match) {
337
502
  const [, type, num] = match;
@@ -339,6 +504,15 @@ export class AnonymizationSession {
339
504
  this.typeCounters.set(type, Math.max(current, Number(num)));
340
505
  }
341
506
  }
507
+ for (const entry of entries) {
508
+ const variant = VARIANT_TOKEN_RE.exec(entry.replacement);
509
+ if (!variant)
510
+ continue;
511
+ const base = `${variant[1]}]`;
512
+ if (this.reverseMap.has(base)) {
513
+ this.variantOf.set(entry.replacement, { base, suffix: variant[2] });
514
+ }
515
+ }
342
516
  }
343
517
  /**
344
518
  * Rebuild a session from JSON produced by serialize() or by the Python
@@ -382,5 +556,6 @@ export class AnonymizationSession {
382
556
  this.reverseMap.clear();
383
557
  this.entityTypeMap.clear();
384
558
  this.typeCounters.clear();
559
+ this.variantOf.clear();
385
560
  }
386
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccloak/core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Environment-agnostic document anonymization engine for the DocCloak ecosystem.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Witold Lojek",
@@ -62,6 +62,7 @@
62
62
  "rules:gen": "node scripts/generate-rules-module.mjs",
63
63
  "rules:check": "node scripts/generate-rules-module.mjs --check",
64
64
  "bench": "node --experimental-strip-types eval/run.mts",
65
+ "bench:multilang": "node --experimental-strip-types eval/multilang.mts",
65
66
  "bench:data": "node eval/download.mjs",
66
67
  "test": "vitest run",
67
68
  "test:watch": "vitest",
@@ -75,7 +76,7 @@
75
76
  "dependencies": {
76
77
  "cfb": "1.2.2",
77
78
  "jszip": "3.10.1",
78
- "onnxruntime-web": "1.19.2",
79
+ "onnxruntime-web": "1.29.0",
79
80
  "tesseract.js": "7.0.0"
80
81
  }
81
82
  }