@doccloak/core 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -3
- package/dist/model-loader.d.ts +45 -2
- package/dist/model-loader.js +99 -5
- package/dist/providers/bardsai.d.ts +12 -0
- package/dist/providers/bardsai.js +35 -3
- package/dist/providers/gliner.d.ts +12 -0
- package/dist/providers/gliner.js +36 -4
- package/dist/regex/rules.data.js +186 -0
- package/dist/regex/validators.d.ts +7 -0
- package/dist/regex/validators.js +95 -4
- package/dist/restore-tokens.d.ts +67 -0
- package/dist/restore-tokens.js +120 -0
- package/dist/session.d.ts +89 -14
- package/dist/session.js +270 -37
- package/dist/surrogates.d.ts +82 -0
- package/dist/surrogates.js +1510 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +4 -0
- package/package.json +7 -1
- package/rules/gb.json +17 -0
- package/rules/schema.json +2 -0
- package/rules/universal.json +169 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core - realistic, shape-preserving surrogate generator (T043).
|
|
3
|
+
*
|
|
4
|
+
* Generates fake-but-plausible replacements for detected entities instead
|
|
5
|
+
* of bracket placeholders: locale-aware names, dates shifted while keeping
|
|
6
|
+
* their format, IBANs/phones/IDs that keep their shape (checksum-valid
|
|
7
|
+
* where the checksum is cheap to compute).
|
|
8
|
+
*
|
|
9
|
+
* Determinism contract: every surrogate is a pure function of
|
|
10
|
+
* (session salt, entity type, original value, attempt). No Math.random or
|
|
11
|
+
* Date.now is ever consulted at module or generation scope, so the same
|
|
12
|
+
* original always yields the same surrogate within a session and tests are
|
|
13
|
+
* reproducible. The salt itself is created once per session (see
|
|
14
|
+
* generateSessionSalt, called from the AnonymizationSession constructor)
|
|
15
|
+
* and persisted with the map so a deserialized session keeps producing
|
|
16
|
+
* consistent surrogates.
|
|
17
|
+
*
|
|
18
|
+
* Fake-identifier policy: we never deliberately generate a real person's
|
|
19
|
+
* identifier. Generated PESELs encode a 19th-century birth date (a range
|
|
20
|
+
* the registry never issued numbers for), generated SSN shapes use the
|
|
21
|
+
* 900-999 area (never allocated by the SSA) with a group outside every
|
|
22
|
+
* valid ITIN range (T101: a 9xx area alone could still form a real ITIN),
|
|
23
|
+
* generated EIN shapes use campus prefixes the IRS has never assigned,
|
|
24
|
+
* generated ABA routing shapes are checksum-valid but carry the 99 leading
|
|
25
|
+
* pair (the ABA assigns only 00-12, 21-32, 61-72, and 80), generated NHS
|
|
26
|
+
* numbers sit in the mod-11-valid 999 test range (never issued to
|
|
27
|
+
* patients), and generated NINOs use the QQ prefix HMRC reserves for
|
|
28
|
+
* documentation examples. Random collisions with real identifiers of other
|
|
29
|
+
* kinds are statistically possible but not targetable: nothing about the
|
|
30
|
+
* original value survives into the surrogate beyond its shape.
|
|
31
|
+
*/
|
|
32
|
+
// ── Deterministic hashing / PRNG ───────────────────────────
|
|
33
|
+
/** 32-bit FNV-1a over a UTF-16 string. */
|
|
34
|
+
function fnv1a(str) {
|
|
35
|
+
let h = 0x811c9dc5;
|
|
36
|
+
for (let i = 0; i < str.length; i++) {
|
|
37
|
+
h ^= str.charCodeAt(i);
|
|
38
|
+
h = Math.imul(h, 0x01000193);
|
|
39
|
+
}
|
|
40
|
+
return h >>> 0;
|
|
41
|
+
}
|
|
42
|
+
/** Small deterministic PRNG (mulberry32). */
|
|
43
|
+
function mulberry32(seed) {
|
|
44
|
+
let a = seed >>> 0;
|
|
45
|
+
return () => {
|
|
46
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
47
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
48
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
49
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function rngFor(salt, ...parts) {
|
|
53
|
+
return mulberry32(fnv1a(`${salt}\u0000${parts.join('\u0000')}`));
|
|
54
|
+
}
|
|
55
|
+
function pick(rand, arr) {
|
|
56
|
+
return arr[Math.floor(rand() * arr.length) % arr.length];
|
|
57
|
+
}
|
|
58
|
+
/** Integer in [min, max] inclusive. */
|
|
59
|
+
function randInt(rand, min, max) {
|
|
60
|
+
return min + Math.floor(rand() * (max - min + 1));
|
|
61
|
+
}
|
|
62
|
+
function randDigit(rand) {
|
|
63
|
+
return String(randInt(rand, 0, 9));
|
|
64
|
+
}
|
|
65
|
+
// ── Session salt ───────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* One salt per session, created at session construction time (never at
|
|
68
|
+
* module scope). WebCrypto when available; a constructor-time Math.random
|
|
69
|
+
* fallback otherwise (the salt only needs to be unique-ish per session,
|
|
70
|
+
* not cryptographically strong - surrogates are not secrets).
|
|
71
|
+
*/
|
|
72
|
+
export function generateSessionSalt() {
|
|
73
|
+
const c = globalThis.crypto;
|
|
74
|
+
if (c?.getRandomValues) {
|
|
75
|
+
const bytes = new Uint8Array(16);
|
|
76
|
+
c.getRandomValues(bytes);
|
|
77
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
78
|
+
}
|
|
79
|
+
let out = '';
|
|
80
|
+
for (let i = 0; i < 32; i++)
|
|
81
|
+
out += Math.floor(Math.random() * 16).toString(16);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
// ── Name pools (EN + PL) ───────────────────────────────────
|
|
85
|
+
const EN_FIRST_MALE = [
|
|
86
|
+
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph',
|
|
87
|
+
'Thomas', 'Charles', 'Daniel', 'Matthew', 'Anthony', 'Mark', 'Steven', 'Paul',
|
|
88
|
+
'Andrew', 'Joshua', 'Kevin', 'Brian', 'George', 'Timothy', 'Ronald', 'Edward',
|
|
89
|
+
'Jason', 'Jeffrey', 'Ryan', 'Jacob', 'Gary', 'Nicholas', 'Eric', 'Jonathan',
|
|
90
|
+
'Stephen', 'Larry', 'Justin', 'Scott', 'Brandon', 'Benjamin', 'Samuel', 'Gregory',
|
|
91
|
+
];
|
|
92
|
+
const EN_FIRST_FEMALE = [
|
|
93
|
+
'Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth', 'Barbara', 'Susan', 'Jessica',
|
|
94
|
+
'Sarah', 'Karen', 'Lisa', 'Nancy', 'Betty', 'Margaret', 'Sandra', 'Ashley',
|
|
95
|
+
'Kimberly', 'Emily', 'Donna', 'Michelle', 'Carol', 'Amanda', 'Dorothy', 'Melissa',
|
|
96
|
+
'Deborah', 'Stephanie', 'Rebecca', 'Sharon', 'Laura', 'Cynthia', 'Kathleen', 'Amy',
|
|
97
|
+
'Angela', 'Shirley', 'Anna', 'Ruth', 'Brenda', 'Pamela', 'Emma', 'Nicole',
|
|
98
|
+
];
|
|
99
|
+
const EN_SURNAMES = [
|
|
100
|
+
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis',
|
|
101
|
+
'Wilson', 'Anderson', 'Taylor', 'Thomas', 'Moore', 'Jackson', 'Martin', 'Lee',
|
|
102
|
+
'Thompson', 'White', 'Harris', 'Clark', 'Lewis', 'Robinson', 'Walker', 'Young',
|
|
103
|
+
'Allen', 'King', 'Wright', 'Scott', 'Green', 'Baker', 'Adams', 'Nelson',
|
|
104
|
+
'Hill', 'Campbell', 'Mitchell', 'Roberts', 'Carter', 'Phillips', 'Evans', 'Turner',
|
|
105
|
+
];
|
|
106
|
+
const PL_FIRST_MALE = [
|
|
107
|
+
'Jan', 'Piotr', 'Krzysztof', 'Andrzej', 'Tomasz', 'Paweł', 'Michał', 'Marcin',
|
|
108
|
+
'Marek', 'Grzegorz', 'Jerzy', 'Tadeusz', 'Adam', 'Łukasz', 'Zbigniew', 'Ryszard',
|
|
109
|
+
'Dariusz', 'Henryk', 'Mariusz', 'Kazimierz', 'Wojciech', 'Robert', 'Mateusz', 'Marian',
|
|
110
|
+
'Rafał', 'Jacek', 'Janusz', 'Mirosław', 'Maciej', 'Sławomir', 'Jarosław', 'Kamil',
|
|
111
|
+
'Wiesław', 'Roman', 'Władysław', 'Jakub', 'Artur', 'Zdzisław', 'Edward', 'Dawid',
|
|
112
|
+
];
|
|
113
|
+
const PL_FIRST_FEMALE = [
|
|
114
|
+
'Anna', 'Maria', 'Katarzyna', 'Małgorzata', 'Agnieszka', 'Barbara', 'Krystyna', 'Ewa',
|
|
115
|
+
'Elżbieta', 'Zofia', 'Janina', 'Teresa', 'Joanna', 'Magdalena', 'Monika', 'Jadwiga',
|
|
116
|
+
'Danuta', 'Irena', 'Halina', 'Helena', 'Beata', 'Aleksandra', 'Marta', 'Dorota',
|
|
117
|
+
'Marianna', 'Grażyna', 'Jolanta', 'Stanisława', 'Iwona', 'Karolina', 'Bożena', 'Urszula',
|
|
118
|
+
'Justyna', 'Renata', 'Alicja', 'Paulina', 'Sylwia', 'Natalia', 'Wanda', 'Agata',
|
|
119
|
+
];
|
|
120
|
+
/** Masculine base forms; feminizePlSurname derives -ska/-cka/-dzka endings. */
|
|
121
|
+
const PL_SURNAMES = [
|
|
122
|
+
'Nowak', 'Kowalski', 'Wiśniewski', 'Wójcik', 'Kowalczyk', 'Kamiński', 'Lewandowski', 'Zieliński',
|
|
123
|
+
'Szymański', 'Woźniak', 'Dąbrowski', 'Kozłowski', 'Jankowski', 'Mazur', 'Kwiatkowski', 'Krawczyk',
|
|
124
|
+
'Piotrowski', 'Grabowski', 'Nowakowski', 'Pawłowski', 'Michalski', 'Nowicki', 'Adamczyk', 'Dudek',
|
|
125
|
+
'Zając', 'Wieczorek', 'Jabłoński', 'Król', 'Majewski', 'Olszewski', 'Jaworski', 'Wróbel',
|
|
126
|
+
'Malinowski', 'Pawlak', 'Witkowski', 'Walczak', 'Stępień', 'Górski', 'Rutkowski', 'Michalak',
|
|
127
|
+
];
|
|
128
|
+
function feminizePlSurname(surname) {
|
|
129
|
+
if (surname.endsWith('dzki'))
|
|
130
|
+
return surname.slice(0, -1) + 'a';
|
|
131
|
+
if (surname.endsWith('cki'))
|
|
132
|
+
return surname.slice(0, -1) + 'a';
|
|
133
|
+
if (surname.endsWith('ski'))
|
|
134
|
+
return surname.slice(0, -1) + 'a';
|
|
135
|
+
return surname; // invariant surnames (Nowak, Mazur, ...)
|
|
136
|
+
}
|
|
137
|
+
// ── Locale packs beyond EN/PL (T073) ───────────────────────
|
|
138
|
+
//
|
|
139
|
+
// Detection covers 24 EU languages (BardS.ai); generation must not hand
|
|
140
|
+
// a German document an English fake name. Each pack carries the data one
|
|
141
|
+
// language needs; adding a locale is adding data, not code. Pools are
|
|
142
|
+
// deliberately modest (16-24 entries): variety within one session is
|
|
143
|
+
// guaranteed by collision handling, not pool size.
|
|
144
|
+
const DE_FIRST_MALE = [
|
|
145
|
+
'Hans', 'Peter', 'Michael', 'Thomas', 'Andreas', 'Wolfgang', 'Klaus', 'Jürgen',
|
|
146
|
+
'Stefan', 'Christian', 'Markus', 'Alexander', 'Frank', 'Uwe', 'Martin', 'Werner',
|
|
147
|
+
'Matthias', 'Bernd', 'Florian', 'Tobias', 'Sebastian', 'Lukas', 'Felix', 'Jonas',
|
|
148
|
+
];
|
|
149
|
+
const DE_FIRST_FEMALE = [
|
|
150
|
+
'Ursula', 'Monika', 'Petra', 'Sabine', 'Renate', 'Helga', 'Karin', 'Brigitte',
|
|
151
|
+
'Andrea', 'Claudia', 'Susanne', 'Julia', 'Katrin', 'Anja', 'Nicole', 'Stefanie',
|
|
152
|
+
'Christina', 'Birgit', 'Heike', 'Lena', 'Hannah', 'Laura', 'Lisa', 'Sophie',
|
|
153
|
+
];
|
|
154
|
+
const DE_SURNAMES = [
|
|
155
|
+
'Müller', 'Schmidt', 'Schneider', 'Fischer', 'Weber', 'Meyer', 'Wagner', 'Becker',
|
|
156
|
+
'Schulz', 'Hoffmann', 'Schäfer', 'Koch', 'Bauer', 'Richter', 'Klein', 'Wolf',
|
|
157
|
+
'Schröder', 'Neumann', 'Schwarz', 'Zimmermann', 'Braun', 'Krüger', 'Hofmann', 'Lange',
|
|
158
|
+
];
|
|
159
|
+
const FR_FIRST_MALE = [
|
|
160
|
+
'Jean', 'Pierre', 'Michel', 'Philippe', 'Alain', 'Bernard', 'Christophe', 'Nicolas',
|
|
161
|
+
'François', 'Laurent', 'Éric', 'Julien', 'Olivier', 'Thierry', 'Antoine', 'Mathieu',
|
|
162
|
+
'Sébastien', 'Vincent', 'Guillaume', 'Hugo', 'Louis', 'Lucas', 'Thomas', 'Paul',
|
|
163
|
+
];
|
|
164
|
+
const FR_FIRST_FEMALE = [
|
|
165
|
+
'Marie', 'Nathalie', 'Isabelle', 'Sylvie', 'Catherine', 'Françoise', 'Christine', 'Monique',
|
|
166
|
+
'Sophie', 'Céline', 'Julie', 'Aurélie', 'Camille', 'Léa', 'Chloé', 'Manon',
|
|
167
|
+
'Élise', 'Charlotte', 'Emma', 'Louise', 'Alice', 'Juliette', 'Margaux', 'Inès',
|
|
168
|
+
];
|
|
169
|
+
const FR_SURNAMES = [
|
|
170
|
+
'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand',
|
|
171
|
+
'Leroy', 'Moreau', 'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David',
|
|
172
|
+
'Bertrand', 'Roux', 'Vincent', 'Fournier', 'Morel', 'Girard', 'Lambert', 'Fontaine',
|
|
173
|
+
];
|
|
174
|
+
const ES_FIRST_MALE = [
|
|
175
|
+
'José', 'Antonio', 'Manuel', 'Francisco', 'Juan', 'David', 'Javier', 'Carlos',
|
|
176
|
+
'Miguel', 'Rafael', 'Pedro', 'Ángel', 'Alejandro', 'Fernando', 'Sergio', 'Pablo',
|
|
177
|
+
'Jorge', 'Alberto', 'Diego', 'Adrián', 'Raúl', 'Iván', 'Rubén', 'Óscar',
|
|
178
|
+
];
|
|
179
|
+
const ES_FIRST_FEMALE = [
|
|
180
|
+
'María', 'Carmen', 'Josefa', 'Isabel', 'Ana', 'Dolores', 'Pilar', 'Teresa',
|
|
181
|
+
'Rosa', 'Cristina', 'Laura', 'Marta', 'Elena', 'Lucía', 'Sara', 'Paula',
|
|
182
|
+
'Raquel', 'Beatriz', 'Silvia', 'Patricia', 'Nuria', 'Alba', 'Andrea', 'Irene',
|
|
183
|
+
];
|
|
184
|
+
const ES_SURNAMES = [
|
|
185
|
+
'García', 'Rodríguez', 'González', 'Fernández', 'López', 'Martínez', 'Sánchez', 'Pérez',
|
|
186
|
+
'Gómez', 'Martín', 'Jiménez', 'Ruiz', 'Hernández', 'Díaz', 'Moreno', 'Muñoz',
|
|
187
|
+
'Álvarez', 'Romero', 'Alonso', 'Gutiérrez', 'Navarro', 'Torres', 'Domínguez', 'Vázquez',
|
|
188
|
+
];
|
|
189
|
+
const IT_FIRST_MALE = [
|
|
190
|
+
'Giuseppe', 'Giovanni', 'Antonio', 'Mario', 'Luigi', 'Francesco', 'Angelo', 'Vincenzo',
|
|
191
|
+
'Pietro', 'Salvatore', 'Carlo', 'Franco', 'Domenico', 'Bruno', 'Paolo', 'Michele',
|
|
192
|
+
'Giorgio', 'Aldo', 'Sergio', 'Luciano', 'Marco', 'Alessandro', 'Andrea', 'Stefano',
|
|
193
|
+
];
|
|
194
|
+
const IT_FIRST_FEMALE = [
|
|
195
|
+
'Maria', 'Anna', 'Giuseppina', 'Rosa', 'Angela', 'Giovanna', 'Teresa', 'Lucia',
|
|
196
|
+
'Carmela', 'Caterina', 'Francesca', 'Paola', 'Laura', 'Elena', 'Giulia', 'Chiara',
|
|
197
|
+
'Sofia', 'Alessia', 'Martina', 'Valentina', 'Federica', 'Silvia', 'Elisa', 'Sara',
|
|
198
|
+
];
|
|
199
|
+
const IT_SURNAMES = [
|
|
200
|
+
'Rossi', 'Russo', 'Ferrari', 'Esposito', 'Bianchi', 'Romano', 'Colombo', 'Ricci',
|
|
201
|
+
'Marino', 'Greco', 'Bruno', 'Gallo', 'Conti', 'Vitale', 'Mancini', 'Costa',
|
|
202
|
+
'Giordano', 'Rizzo', 'Lombardi', 'Moretti', 'Barbieri', 'Fontana', 'Santoro', 'Mariani',
|
|
203
|
+
];
|
|
204
|
+
/** Masculine base forms; feminizeCsSurname derives -ová / -á endings. */
|
|
205
|
+
const CS_FIRST_MALE = [
|
|
206
|
+
'Jiří', 'Jan', 'Petr', 'Josef', 'Pavel', 'Martin', 'Tomáš', 'Jaroslav',
|
|
207
|
+
'Miroslav', 'Zdeněk', 'František', 'Václav', 'Michal', 'Milan', 'Karel', 'Lukáš',
|
|
208
|
+
'David', 'Ladislav', 'Stanislav', 'Roman', 'Ondřej', 'Jakub', 'Vladimír', 'Radek',
|
|
209
|
+
];
|
|
210
|
+
const CS_FIRST_FEMALE = [
|
|
211
|
+
'Marie', 'Jiřina', 'Anna', 'Věra', 'Alena', 'Lenka', 'Hana', 'Jaroslava',
|
|
212
|
+
'Kateřina', 'Lucie', 'Eva', 'Jana', 'Petra', 'Martina', 'Zuzana', 'Michaela',
|
|
213
|
+
'Tereza', 'Barbora', 'Veronika', 'Kristýna', 'Markéta', 'Ivana', 'Monika', 'Klára',
|
|
214
|
+
];
|
|
215
|
+
const CS_SURNAMES = [
|
|
216
|
+
'Novák', 'Svoboda', 'Novotný', 'Dvořák', 'Černý', 'Procházka', 'Kučera', 'Veselý',
|
|
217
|
+
'Horák', 'Němec', 'Marek', 'Pokorný', 'Pospíšil', 'Hájek', 'Král', 'Jelínek',
|
|
218
|
+
'Růžička', 'Beneš', 'Fiala', 'Sedláček', 'Doležal', 'Zeman', 'Kolář', 'Urban',
|
|
219
|
+
];
|
|
220
|
+
function feminizeCsSurname(surname) {
|
|
221
|
+
if (surname.endsWith('ý'))
|
|
222
|
+
return surname.slice(0, -1) + 'á';
|
|
223
|
+
if (surname.endsWith('ová') || surname.endsWith('á'))
|
|
224
|
+
return surname;
|
|
225
|
+
return surname + 'ová';
|
|
226
|
+
}
|
|
227
|
+
/** Masculine base forms; feminizeUkSurname derives -ська/-цька endings. */
|
|
228
|
+
const UK_FIRST_MALE = [
|
|
229
|
+
'Олександр', 'Сергій', 'Андрій', 'Володимир', 'Іван', 'Михайло', 'Віктор', 'Юрій',
|
|
230
|
+
'Микола', 'Дмитро', 'Олег', 'Василь', 'Петро', 'Тарас', 'Богдан', 'Максим',
|
|
231
|
+
'Павло', 'Роман', 'Ігор', 'Антон', 'Назар', 'Остап', 'Денис', 'Артем',
|
|
232
|
+
];
|
|
233
|
+
const UK_FIRST_FEMALE = [
|
|
234
|
+
'Олена', 'Тетяна', 'Наталія', 'Ірина', 'Оксана', 'Людмила', 'Світлана', 'Марія',
|
|
235
|
+
'Ганна', 'Юлія', 'Катерина', 'Вікторія', 'Анастасія', 'Ольга', 'Соломія', 'Дарина',
|
|
236
|
+
'Христина', 'Софія', 'Леся', 'Надія', 'Галина', 'Зоряна', 'Мирослава', 'Лілія',
|
|
237
|
+
];
|
|
238
|
+
const UK_SURNAMES = [
|
|
239
|
+
'Шевченко', 'Бондаренко', 'Коваленко', 'Ткаченко', 'Кравченко', 'Олійник', 'Мельник', 'Поліщук',
|
|
240
|
+
'Бойко', 'Ковальчук', 'Лисенко', 'Савченко', 'Руденко', 'Марченко', 'Петренко', 'Козак',
|
|
241
|
+
'Мороз', 'Гончаренко', 'Левченко', 'Василенко', 'Кушнір', 'Романюк', 'Гаврилюк', 'Заєць',
|
|
242
|
+
];
|
|
243
|
+
const NL_FIRST_MALE = [
|
|
244
|
+
'Daan', 'Sem', 'Bram', 'Lars', 'Thijs', 'Ruben', 'Kees', 'Joost',
|
|
245
|
+
'Maarten', 'Wouter', 'Jeroen', 'Sander', 'Bas', 'Niels', 'Floris', 'Gerrit',
|
|
246
|
+
'Pieter', 'Willem', 'Hendrik', 'Cornelis', 'Dirk', 'Jaap', 'Sjoerd', 'Teun',
|
|
247
|
+
];
|
|
248
|
+
const NL_FIRST_FEMALE = [
|
|
249
|
+
'Sanne', 'Lotte', 'Femke', 'Anouk', 'Fleur', 'Iris', 'Nienke', 'Marloes',
|
|
250
|
+
'Ilse', 'Esmee', 'Roos', 'Lieke', 'Johanna', 'Cornelia', 'Willemien', 'Truus',
|
|
251
|
+
'Marijke', 'Annemiek', 'Wilma', 'Jantine', 'Gerda', 'Hanneke', 'Mieke', 'Els',
|
|
252
|
+
];
|
|
253
|
+
/** Single-token forms only: multi-token surnames (van der ...) would
|
|
254
|
+
* break the token-count preservation guarantee of generatePerson. */
|
|
255
|
+
const NL_SURNAMES = [
|
|
256
|
+
'Jansen', 'Bakker', 'Visser', 'Smit', 'Meijer', 'Mulder', 'Bos', 'Vos',
|
|
257
|
+
'Peters', 'Hendriks', 'Dekker', 'Brouwer', 'Dijkstra', 'Smits', 'Kuipers', 'Post',
|
|
258
|
+
'Kok', 'Verhoeven', 'Willems', 'Maas', 'Hermans', 'Timmermans', 'Schouten', 'Jacobs',
|
|
259
|
+
];
|
|
260
|
+
const PT_FIRST_MALE = [
|
|
261
|
+
'Jo\u00e3o', 'Lu\u00eds', 'Paulo', 'Rui', 'Nuno', 'Tiago', 'Ricardo', 'Andr\u00e9',
|
|
262
|
+
'Diogo', 'Gon\u00e7alo', 'Vasco', 'Duarte', 'Afonso', 'Bernardo', 'Rodrigo', 'Ant\u00f3nio',
|
|
263
|
+
'Manuel', 'Francisco', 'Carlos', 'Pedro', 'Miguel', 'Jos\u00e9', 'Henrique', 'Sim\u00e3o',
|
|
264
|
+
];
|
|
265
|
+
const PT_FIRST_FEMALE = [
|
|
266
|
+
'Catarina', 'Margarida', 'Mariana', 'Joana', 'Rita', 'Carolina', 'Leonor', 'Matilde',
|
|
267
|
+
'Francisca', 'Madalena', 'Const\u00e2ncia', 'Louren\u00e7a', 'Gra\u00e7a', 'Concei\u00e7\u00e3o', 'Lurdes', 'In\u00eas',
|
|
268
|
+
'Beatriz', 'Isabel', 'Teresa', 'Sofia', 'Ana', 'Maria', 'Manuela', 'Fernanda',
|
|
269
|
+
];
|
|
270
|
+
const PT_SURNAMES = [
|
|
271
|
+
'Silva', 'Santos', 'Ferreira', 'Pereira', 'Oliveira', 'Rodrigues', 'Martins', 'Sousa',
|
|
272
|
+
'Fernandes', 'Gon\u00e7alves', 'Gomes', 'Lopes', 'Marques', 'Alves', 'Almeida', 'Ribeiro',
|
|
273
|
+
'Pinto', 'Carvalho', 'Teixeira', 'Moreira', 'Correia', 'Mendes', 'Nunes', 'Coelho',
|
|
274
|
+
];
|
|
275
|
+
function feminizeUkSurname(surname) {
|
|
276
|
+
if (surname.endsWith('ський') || surname.endsWith('цький')) {
|
|
277
|
+
return surname.slice(0, -2) + 'а'; // -ський -> -ська, -цький -> -цька
|
|
278
|
+
}
|
|
279
|
+
return surname; // -енко/-ук/-як and similar are invariant
|
|
280
|
+
}
|
|
281
|
+
const SV_FIRST_MALE = [
|
|
282
|
+
'Erik', 'Lars', 'Karl', 'Anders', 'Johan', 'Per', 'Nils', 'Sven',
|
|
283
|
+
'Gunnar', 'Bo', '\u00c5ke', 'G\u00f6ran', 'Henrik', 'Magnus', 'Fredrik', 'Oskar',
|
|
284
|
+
];
|
|
285
|
+
const SV_FIRST_FEMALE = [
|
|
286
|
+
'Anna', 'Eva', 'Maria', 'Karin', 'Ingrid', 'Kerstin', 'Lena', 'Helena',
|
|
287
|
+
'Marianne', 'Birgitta', 'Elin', 'Sara', 'Emma', 'Linnea', 'Astrid', 'Ebba',
|
|
288
|
+
];
|
|
289
|
+
const SV_SURNAMES = [
|
|
290
|
+
'Andersson', 'Johansson', 'Karlsson', 'Nilsson', 'Eriksson', 'Larsson', 'Olsson', 'Persson',
|
|
291
|
+
'Svensson', 'Gustafsson', 'Pettersson', 'Jonsson', 'Lindberg', 'Lindqvist', 'Bergstr\u00f6m', 'Sandberg',
|
|
292
|
+
];
|
|
293
|
+
const NO_FIRST_MALE = [
|
|
294
|
+
'Ole', 'Lars', 'Knut', 'Bj\u00f8rn', 'Arne', 'Odd', 'Geir', 'Tor',
|
|
295
|
+
'Terje', 'Kjell', 'Espen', 'H\u00e5kon', 'Sindre', 'Eirik', 'Trygve', 'Leif',
|
|
296
|
+
];
|
|
297
|
+
const NO_FIRST_FEMALE = [
|
|
298
|
+
'Anne', 'Inger', 'Kari', 'Marit', 'Ingrid', 'Liv', 'Astrid', 'Solveig',
|
|
299
|
+
'Randi', 'Bj\u00f8rg', 'Silje', 'Mari', 'Ingeborg', 'Tone', 'Gunn', 'Sigrid',
|
|
300
|
+
];
|
|
301
|
+
const NO_SURNAMES = [
|
|
302
|
+
'Hansen', 'Johansen', 'Olsen', 'Larsen', 'Andersen', 'Pedersen', 'Nilsen', 'Kristiansen',
|
|
303
|
+
'Jensen', 'Karlsen', 'Berg', 'Haugen', 'Hagen', 'Solberg', 'Moen', 'Lien',
|
|
304
|
+
];
|
|
305
|
+
const DA_FIRST_MALE = [
|
|
306
|
+
'Jens', 'Peter', 'Lars', 'Henrik', 'S\u00f8ren', 'Niels', 'Ole', 'Erik',
|
|
307
|
+
'Mads', 'Rasmus', 'Bent', 'Kaj', 'Frederik', 'Mikkel', 'Emil', 'Bjarne',
|
|
308
|
+
];
|
|
309
|
+
const DA_FIRST_FEMALE = [
|
|
310
|
+
'Kirsten', 'Mette', 'Hanne', 'Lone', 'Bente', 'Karen', 'Dorthe', 'Pia',
|
|
311
|
+
'Gitte', 'Ditte', 'Freja', 'Signe', 'Maja', 'Cecilie', 'Louise', 'Astrid',
|
|
312
|
+
];
|
|
313
|
+
const DA_SURNAMES = [
|
|
314
|
+
'Nielsen', 'Jensen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'S\u00f8rensen',
|
|
315
|
+
'Rasmussen', 'J\u00f8rgensen', 'Petersen', 'Madsen', 'Kristensen', 'Olsen', 'Thomsen', 'Poulsen',
|
|
316
|
+
];
|
|
317
|
+
const FI_FIRST_MALE = [
|
|
318
|
+
'Juha', 'Matti', 'Pekka', 'Timo', 'Jari', 'Antti', 'Mikko', 'Kari',
|
|
319
|
+
'Heikki', 'Ville', 'Janne', 'Sami', 'Jussi', 'Eero', 'Olli', 'Tapio',
|
|
320
|
+
];
|
|
321
|
+
const FI_FIRST_FEMALE = [
|
|
322
|
+
'Tiina', 'Sanna', 'Anne', 'P\u00e4ivi', 'Ritva', 'Leena', 'Maarit', 'Hanna',
|
|
323
|
+
'Laura', 'Elina', 'Kaisa', 'Aino', 'Helmi', 'Sofia', 'Emmi', 'Noora',
|
|
324
|
+
];
|
|
325
|
+
const FI_SURNAMES = [
|
|
326
|
+
'Korhonen', 'Virtanen', 'M\u00e4kinen', 'Nieminen', 'M\u00e4kel\u00e4', 'H\u00e4m\u00e4l\u00e4inen', 'Laine', 'Heikkinen',
|
|
327
|
+
'Koskinen', 'J\u00e4rvinen', 'Lehtonen', 'Lehtinen', 'Saarinen', 'Salminen', 'Heinonen', 'Niemi',
|
|
328
|
+
];
|
|
329
|
+
/** CJK pools: full-name composition differs (surname first, no space). */
|
|
330
|
+
const JA_SURNAMES = [
|
|
331
|
+
'\u7530\u4e2d', '\u4f50\u85e4', '\u9234\u6728', '\u9ad8\u6a4b', '\u4f0a\u85e4', '\u6e21\u8fba', '\u5c71\u672c', '\u4e2d\u6751',
|
|
332
|
+
'\u5c0f\u6797', '\u52a0\u85e4', '\u5409\u7530', '\u5c71\u7530', '\u677e\u672c', '\u4e95\u4e0a', '\u6728\u6751', '\u6e05\u6c34',
|
|
333
|
+
];
|
|
334
|
+
const JA_GIVEN_MALE = [
|
|
335
|
+
'\u592a\u90ce', '\u5065\u4e00', '\u8aa0', '\u6d69', '\u9686', '\u5b66', '\u4fee', '\u5927\u8f14',
|
|
336
|
+
'\u76f4\u6a39', '\u5065\u592a', '\u7fd4\u592a', '\u62d3\u54c9', '\u96c4\u4ecb', '\u5eb7\u5e73', '\u6b63\u6a39', '\u548c\u5f66',
|
|
337
|
+
];
|
|
338
|
+
const JA_GIVEN_FEMALE = [
|
|
339
|
+
'\u82b1\u5b50', '\u7f8e\u54b2', '\u967d\u5b50', '\u6075\u5b50', '\u7531\u7f8e', '\u611b', '\u821e', '\u5343\u5c0b',
|
|
340
|
+
'\u7d50\u8863', '\u3055\u304f\u3089', '\u7f8e\u7a42', '\u771f\u7531', '\u5f69\u82b1', '\u679c\u6b69', '\u512a\u5b50', '\u660e\u65e5\u9999',
|
|
341
|
+
];
|
|
342
|
+
const ZH_SURNAMES = [
|
|
343
|
+
'\u738b', '\u674e', '\u5f20', '\u5218', '\u9648', '\u6768', '\u9ec4', '\u8d75',
|
|
344
|
+
'\u5468', '\u5434', '\u5f90', '\u5b59', '\u9a6c', '\u6731', '\u80e1', '\u90ed',
|
|
345
|
+
];
|
|
346
|
+
const ZH_GIVEN_MALE = [
|
|
347
|
+
'\u4f1f', '\u5f3a', '\u78ca', '\u519b', '\u52c7', '\u6770', '\u6d9b', '\u660e',
|
|
348
|
+
'\u8d85', '\u9e4f', '\u5efa\u534e', '\u6587\u8f89', '\u5fd7\u5f3a', '\u6d77\u6d0b', '\u5b87\u8ed2', '\u6653\u4e1c',
|
|
349
|
+
];
|
|
350
|
+
const ZH_GIVEN_FEMALE = [
|
|
351
|
+
'\u82b3', '\u5a1c', '\u654f', '\u9759', '\u4e3d', '\u8273', '\u971e', '\u71d5',
|
|
352
|
+
'\u79c0\u82f1', '\u6842\u82f1', '\u96e8\u6674', '\u6021\u7136', '\u6b23\u6021', '\u4f73\u7434', '\u6653\u6885', '\u5a77\u5a77',
|
|
353
|
+
];
|
|
354
|
+
// ── Other pools ────────────────────────────────────────────
|
|
355
|
+
/** Reserved/example domains only (RFC 2606 / RFC 6761): never routable. */
|
|
356
|
+
export const FAKE_EMAIL_DOMAINS = [
|
|
357
|
+
'example.com', 'example.org', 'example.net', 'mail.example',
|
|
358
|
+
'post.example', 'inbox.test', 'mail.test', 'poczta.test',
|
|
359
|
+
];
|
|
360
|
+
const EN_STREETS = [
|
|
361
|
+
'Maple', 'Oak', 'Cedar', 'Elm', 'Willow', 'Chestnut', 'Birch', 'Highland',
|
|
362
|
+
'Lakeview', 'Hillcrest', 'Riverside', 'Sunset', 'Meadow', 'Orchard', 'Garden', 'Prospect',
|
|
363
|
+
];
|
|
364
|
+
const PL_STREETS = [
|
|
365
|
+
'Polna', 'Leśna', 'Słoneczna', 'Krótka', 'Szkolna', 'Ogrodowa', 'Lipowa', 'Brzozowa',
|
|
366
|
+
'Kwiatowa', 'Sosnowa', 'Łąkowa', 'Akacjowa', 'Spacerowa', 'Parkowa', 'Zielona', 'Wiosenna',
|
|
367
|
+
];
|
|
368
|
+
const EN_COMPANIES = [
|
|
369
|
+
'Northbridge Solutions', 'Bluepine Systems', 'Graystone Consulting', 'Silverleaf Media',
|
|
370
|
+
'Ironwood Logistics', 'Brightharbor Labs', 'Stonegate Partners', 'Clearwater Dynamics',
|
|
371
|
+
'Redfern Ventures', 'Copperfield Trading', 'Summitline Software', 'Harborview Analytics',
|
|
372
|
+
'Oakfield Services', 'Westgate Supplies', 'Pinecrest Manufacturing', 'Lakeshore Digital',
|
|
373
|
+
];
|
|
374
|
+
const PL_COMPANIES = [
|
|
375
|
+
'Polbud', 'Stalmex', 'Drewpol', 'Budomax', 'Agropol', 'Techmet', 'Instalex', 'Metalpol',
|
|
376
|
+
'Transwex', 'Elektrobud', 'Chemipol', 'Dombex', 'Hydromax', 'Termopol', 'Mebloplast', 'Kablomex',
|
|
377
|
+
];
|
|
378
|
+
const COMPANY_SUFFIXES = [
|
|
379
|
+
'Sp. z o.o.', 'sp. z o.o.', 'S.A.', 'sp.j.', 'sp. j.', 'GmbH & Co. KG', 'GmbH', 'AG',
|
|
380
|
+
'Ltd.', 'Ltd', 'LLC', 'Inc.', 'Inc', 'B.V.', 'S.à r.l.', 'SARL', 'SAS', 'AB', 'Oy',
|
|
381
|
+
'S.r.l.', 'S.p.A.', 'S.L.', 'S.L.U.', 's.r.o.', 'a.s.', 'ТОВ', 'ПрАТ',
|
|
382
|
+
'N.V.', 'Unipessoal Lda.', 'Lda.', 'Lda',
|
|
383
|
+
'ApS', 'A/S', 'AS', 'ASA', 'Oyj', '\u682a\u5f0f\u4f1a\u793e', '\u6709\u9650\u516c\u53f8',
|
|
384
|
+
];
|
|
385
|
+
// T073 street pools; each locale's formatAddress composes them below.
|
|
386
|
+
const DE_STREETS = [
|
|
387
|
+
'Linden', 'Garten', 'Berg', 'Wald', 'Schul', 'Haupt', 'Birken', 'Rosen',
|
|
388
|
+
'Feld', 'Wiesen', 'Mühlen', 'Kirch', 'Bahnhof', 'Ahorn', 'Buchen', 'Tannen',
|
|
389
|
+
];
|
|
390
|
+
const FR_STREETS = [
|
|
391
|
+
'des Lilas', 'des Érables', 'de la Gare', 'du Moulin', 'des Peupliers', 'de la Fontaine',
|
|
392
|
+
'des Tilleuls', 'du Château', 'des Acacias', 'de la Mairie', 'des Cerisiers', 'du Stade',
|
|
393
|
+
];
|
|
394
|
+
const ES_STREETS = [
|
|
395
|
+
'del Sol', 'de la Luna', 'del Prado', 'de los Olivos', 'del Pinar', 'de la Fuente',
|
|
396
|
+
'de las Flores', 'del Rosal', 'de la Sierra', 'de los Almendros', 'del Parque', 'de la Vega',
|
|
397
|
+
];
|
|
398
|
+
const IT_STREETS = [
|
|
399
|
+
'dei Tigli', 'delle Rose', 'dei Pini', 'del Sole', 'delle Querce', 'dei Gelsomini',
|
|
400
|
+
'della Fontana', 'dei Ciliegi', 'del Bosco', 'delle Viole', 'degli Ulivi', 'del Parco',
|
|
401
|
+
];
|
|
402
|
+
const CS_STREETS = [
|
|
403
|
+
'Polní', 'Zahradní', 'Krátká', 'Školní', 'Lipová', 'Květinová', 'Luční', 'Lesní',
|
|
404
|
+
'Slunečná', 'Březová', 'Jasmínová', 'Růžová', 'Nádražní', 'Sportovní', 'Okružní', 'Havlíčkova',
|
|
405
|
+
];
|
|
406
|
+
const UK_STREETS = [
|
|
407
|
+
'Зелена', 'Садова', 'Шкільна', 'Польова', 'Лісова', 'Квіткова', 'Сонячна', 'Вишнева',
|
|
408
|
+
'Калинова', 'Озерна', 'Джерельна', 'Ярова', 'Липова', 'Вербова', 'Степова', 'Затишна',
|
|
409
|
+
];
|
|
410
|
+
const NL_STREETS = [
|
|
411
|
+
'Kerk', 'School', 'Molen', 'Dorps', 'Beuken', 'Wilgen', 'Tulpen', 'Eiken',
|
|
412
|
+
'Berken', 'Meidoorn', 'Zonnebloem', 'Sparren', 'Populieren', 'Kastanje', 'Rozen', 'Iepen',
|
|
413
|
+
];
|
|
414
|
+
const PT_STREETS = [
|
|
415
|
+
'das Flores', 'do Sol', 'da Paz', 'dos Pinheiros', 'das Oliveiras', 'da Fonte',
|
|
416
|
+
'do Campo', 'das Amendoeiras', 'do Moinho', 'da Liberdade', 'das Ac\u00e1cias', 'dos Salgueiros',
|
|
417
|
+
];
|
|
418
|
+
const DE_COMPANIES = [
|
|
419
|
+
'Nordbau Technik', 'Rheinwerk Systeme', 'Waldhof Logistik', 'Steinbach Metall',
|
|
420
|
+
'Bergland Elektro', 'Hansewerk Handel', 'Talblick Software', 'Eichenhof Bau',
|
|
421
|
+
];
|
|
422
|
+
const FR_COMPANIES = [
|
|
423
|
+
'Techni Sud', 'Batim Ouest', 'Loginor', 'Métalfrance', 'Sologis', 'Verdalis',
|
|
424
|
+
'Clermontech', 'Rivelec',
|
|
425
|
+
];
|
|
426
|
+
const ES_COMPANIES = [
|
|
427
|
+
'Construcciones Levante', 'Ibertec Soluciones', 'Logística Meridional', 'Metalúrgica Norte',
|
|
428
|
+
'Electrosur', 'Grupo Almenara', 'Tecnovega', 'Riberalia',
|
|
429
|
+
];
|
|
430
|
+
const IT_COMPANIES = [
|
|
431
|
+
'Tecnitalia', 'Costruzioni Adriatica', 'Logistica Padana', 'Metallurgica Verde',
|
|
432
|
+
'Elettrosud', 'Gruppo Collina', 'Softogna', 'Riviera Impianti',
|
|
433
|
+
];
|
|
434
|
+
const CS_COMPANIES = [
|
|
435
|
+
'Stavomont', 'Kovodílo', 'Elektroservis Morava', 'Dřevostav', 'Logistika Vltava',
|
|
436
|
+
'Technoplast', 'Montáže Sever', 'Agroslužby Haná',
|
|
437
|
+
];
|
|
438
|
+
const UK_COMPANIES = [
|
|
439
|
+
'Будсервіс', 'Агротехніка', 'Металінвест', 'Електропостач', 'Логістик Захід',
|
|
440
|
+
'Технопривід', 'Деревобуд', 'Енергоресурс',
|
|
441
|
+
];
|
|
442
|
+
const SV_STREETS = [
|
|
443
|
+
'Bj\u00f6rk', 'Ek', 'Lind', 'Ros', 'Kyrko', 'Skol', 'Strand', '\u00c4ngs',
|
|
444
|
+
'Berg', 'Sj\u00f6', 'Furu', 'Aspen',
|
|
445
|
+
];
|
|
446
|
+
const NO_STREETS = [
|
|
447
|
+
'Bj\u00f8rke', 'Eike', 'Kirke', 'Skole', 'Strand', 'Fjell', 'Elve', 'Gran',
|
|
448
|
+
'Furubakk', 'Solbakk', 'Ljabru', 'Bekke',
|
|
449
|
+
];
|
|
450
|
+
const DA_STREETS = [
|
|
451
|
+
'Birke', 'Ege', 'Kirke', 'Skole', 'Strand', 'M\u00f8lle', 'Ro', 'Sol',
|
|
452
|
+
'S\u00f8nder', 'N\u00f8rre', '\u00d8ster', 'Vester',
|
|
453
|
+
];
|
|
454
|
+
const FI_STREETS = [
|
|
455
|
+
'Koivu', 'Tammi', 'Kirkko', 'Koulu', 'Ranta', 'M\u00e4ki', 'J\u00e4rvi', 'Kuusi',
|
|
456
|
+
'Honka', 'Niitty', 'Pelto', 'Vaahtera',
|
|
457
|
+
];
|
|
458
|
+
const JA_STREETS = [
|
|
459
|
+
'\u685c', '\u7dd1', '\u672c', '\u65ed', '\u82e5\u8449', '\u6804', '\u5e73\u548c', '\u661f',
|
|
460
|
+
];
|
|
461
|
+
const ZH_STREETS = [
|
|
462
|
+
'\u548c\u5e73\u8def', '\u89e3\u653e\u8def', '\u4eba\u6c11\u8def', '\u4e2d\u5c71\u8def', '\u5efa\u8bbe\u8def', '\u5149\u660e\u8def', '\u6587\u5316\u8def', '\u80dc\u5229\u8def',
|
|
463
|
+
];
|
|
464
|
+
const SV_COMPANIES = [
|
|
465
|
+
'Nordstr\u00f6m Bygg', 'Sj\u00f6berg Teknik', 'Dalaverken', 'Lindqvist Handel',
|
|
466
|
+
'Berglund Logistik', 'Svea Konsult', 'Kustdata', 'Fj\u00e4llverk',
|
|
467
|
+
];
|
|
468
|
+
const NO_COMPANIES = [
|
|
469
|
+
'Fjordbygg', 'Nordkraft Teknikk', 'Vestlandslogistikk', 'Bergheim Handel',
|
|
470
|
+
'Polardata', 'Kystverk Consult', 'Solli Entrepren\u00f8r', 'Elvestad Maskin',
|
|
471
|
+
];
|
|
472
|
+
const DA_COMPANIES = [
|
|
473
|
+
'Nordjysk Byg', 'Baltika Handel', '\u00d8resund Teknik', 'Danlogistik',
|
|
474
|
+
'K\u00f8benhavns Maskinv\u00e6rk', 'Vestkyst Data', 'Gr\u00f8nvang Consult', 'Midtjysk Montage',
|
|
475
|
+
];
|
|
476
|
+
const FI_COMPANIES = [
|
|
477
|
+
'Pohjolan Rakennus', 'J\u00e4rvitekniikka', 'Suomen Logistiikka', 'Kalustemesta',
|
|
478
|
+
'Datapaja', 'Konepaja Kaiku', 'Mets\u00e4palvelu Honka', 'Lakeuden S\u00e4hk\u00f6',
|
|
479
|
+
];
|
|
480
|
+
const JA_COMPANIES = [
|
|
481
|
+
'\u7530\u4e2d\u5546\u4e8b', '\u5c71\u672c\u5de5\u696d', '\u4f50\u85e4\u7269\u7523', '\u9234\u6728\u96fb\u6a5f',
|
|
482
|
+
'\u4e2d\u6751\u5efa\u8a2d', '\u5927\u548c\u6280\u7814', '\u65ed\u901a\u5546', '\u685c\u88fd\u4f5c\u6240',
|
|
483
|
+
];
|
|
484
|
+
const ZH_COMPANIES = [
|
|
485
|
+
'\u534e\u4fe1\u79d1\u6280', '\u5b8f\u8fbe\u8d38\u6613', '\u91d1\u6865\u5efa\u8bbe', '\u84dd\u5929\u7269\u6d41',
|
|
486
|
+
'\u4e1c\u65b9\u7535\u5b50', '\u65b0\u661f\u5b9e\u4e1a', '\u957f\u6cb0\u673a\u68b0', '\u6cf0\u5b89\u5b9e\u4e1a',
|
|
487
|
+
];
|
|
488
|
+
const NL_COMPANIES = [
|
|
489
|
+
'Noordbouw', 'Rijnstaal', 'Deltatech', 'Havenlogistiek', 'Polderland Handel',
|
|
490
|
+
'Wielingen Installaties', 'Maasstad Software', 'Duinzicht Advies',
|
|
491
|
+
];
|
|
492
|
+
const PT_COMPANIES = [
|
|
493
|
+
'Tecnolusa', 'Construtora Atl\u00e2ntico', 'Metalomec\u00e2nica Douro', 'Log\u00edstica Tejo',
|
|
494
|
+
'Ib\u00e9rica Montagens', 'Transportes Minho', 'Enerlis', 'Vidral\u00e2ndia',
|
|
495
|
+
];
|
|
496
|
+
// ── Locale / shape helpers ─────────────────────────────────
|
|
497
|
+
const PL_CHARS_RE = /[\u0105\u0107\u0119\u0142\u0144\u00f3\u015b\u017a\u017c\u0104\u0106\u0118\u0141\u0143\u00d3\u015a\u0179\u017b]/;
|
|
498
|
+
function foldDiacritics(s) {
|
|
499
|
+
return s
|
|
500
|
+
.normalize('NFD')
|
|
501
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
502
|
+
.replace(/\u0142/g, 'l')
|
|
503
|
+
.replace(/\u0141/g, 'L')
|
|
504
|
+
.replace(/\u00df/g, 'ss')
|
|
505
|
+
.replace(/\u0153/g, 'oe')
|
|
506
|
+
.replace(/\u0152/g, 'Oe');
|
|
507
|
+
}
|
|
508
|
+
const LOCALE_PACKS = [
|
|
509
|
+
{
|
|
510
|
+
id: 'pl',
|
|
511
|
+
firstMale: PL_FIRST_MALE,
|
|
512
|
+
firstFemale: PL_FIRST_FEMALE,
|
|
513
|
+
surnames: PL_SURNAMES,
|
|
514
|
+
feminizeSurname: feminizePlSurname,
|
|
515
|
+
chars: PL_CHARS_RE,
|
|
516
|
+
surnameEndings: /(?:ski|ska|cki|cka|dzki|dzka|wicz|czyk|szek|owski|ewski)$/,
|
|
517
|
+
femaleSurnameEndings: /(?:ska|cka|dzka)$/,
|
|
518
|
+
maleSurnameEndings: /(?:ski|cki|dzki)$/,
|
|
519
|
+
aEndsFemale: true,
|
|
520
|
+
addressHint: /\b(?:ul|al|os|pl)\.\s/i,
|
|
521
|
+
streets: PL_STREETS,
|
|
522
|
+
formatAddress: (s, n) => `ul. ${s} ${n}`,
|
|
523
|
+
companies: PL_COMPANIES,
|
|
524
|
+
companySuffixHint: /z o\.o\.|S\.A\.|sp\. ?j\./i,
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
id: 'uk',
|
|
528
|
+
firstMale: UK_FIRST_MALE,
|
|
529
|
+
firstFemale: UK_FIRST_FEMALE,
|
|
530
|
+
surnames: UK_SURNAMES,
|
|
531
|
+
feminizeSurname: feminizeUkSurname,
|
|
532
|
+
// Cyrillic script is decisive on its own (uk is our Cyrillic pack).
|
|
533
|
+
chars: /[\u0400-\u04ff]/,
|
|
534
|
+
femaleSurnameEndings: /(?:\u0441\u044c\u043a\u0430|\u0446\u044c\u043a\u0430)$/,
|
|
535
|
+
maleSurnameEndings: /(?:\u0441\u044c\u043a\u0438\u0439|\u0446\u044c\u043a\u0438\u0439)$/,
|
|
536
|
+
aEndsFemale: true,
|
|
537
|
+
addressHint: /\u0432\u0443\u043b\.?\s/i,
|
|
538
|
+
streets: UK_STREETS,
|
|
539
|
+
formatAddress: (s, n) => `\u0432\u0443\u043b. ${s} ${n}`,
|
|
540
|
+
companies: UK_COMPANIES,
|
|
541
|
+
companySuffixHint: /\u0422\u041e\u0412|\u041f\u0440\u0410\u0422/,
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
id: 'cs',
|
|
545
|
+
firstMale: CS_FIRST_MALE,
|
|
546
|
+
firstFemale: CS_FIRST_FEMALE,
|
|
547
|
+
surnames: CS_SURNAMES,
|
|
548
|
+
feminizeSurname: feminizeCsSurname,
|
|
549
|
+
chars: /[\u0159\u011b\u016f\u0165\u010f\u0148\u0158\u011a\u016e\u0164\u010e\u0147]/,
|
|
550
|
+
surnameEndings: /(?:ova|acek|icek|ansky|ensky)$/,
|
|
551
|
+
femaleSurnameEndings: /ov\u00e1$/,
|
|
552
|
+
aEndsFemale: true,
|
|
553
|
+
streets: CS_STREETS,
|
|
554
|
+
formatAddress: (s, n) => `${s} ${n}`,
|
|
555
|
+
companies: CS_COMPANIES,
|
|
556
|
+
companySuffixHint: /s\.r\.o\.|a\.s\./i,
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
id: 'de',
|
|
560
|
+
firstMale: DE_FIRST_MALE,
|
|
561
|
+
firstFemale: DE_FIRST_FEMALE,
|
|
562
|
+
surnames: DE_SURNAMES,
|
|
563
|
+
chars: /\u00df/,
|
|
564
|
+
addressHint: /stra(?:\u00df|ss)e|\bweg\b|\bgasse\b/i,
|
|
565
|
+
streets: DE_STREETS,
|
|
566
|
+
formatAddress: (s, n) => `${s}stra\u00dfe ${n}`,
|
|
567
|
+
companies: DE_COMPANIES,
|
|
568
|
+
companySuffixHint: /GmbH|\bAG$/,
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
id: 'es',
|
|
572
|
+
firstMale: ES_FIRST_MALE,
|
|
573
|
+
firstFemale: ES_FIRST_FEMALE,
|
|
574
|
+
surnames: ES_SURNAMES,
|
|
575
|
+
chars: /[\u00f1\u00d1\u00a1\u00bf]/,
|
|
576
|
+
aEndsFemale: true,
|
|
577
|
+
addressHint: /\b(?:calle|avenida|avda|plaza)\b/i,
|
|
578
|
+
streets: ES_STREETS,
|
|
579
|
+
formatAddress: (s, n) => `Calle ${s} ${n}`,
|
|
580
|
+
companies: ES_COMPANIES,
|
|
581
|
+
companySuffixHint: /S\.L\.U?\.?$/,
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
id: 'fr',
|
|
585
|
+
firstMale: FR_FIRST_MALE,
|
|
586
|
+
firstFemale: FR_FIRST_FEMALE,
|
|
587
|
+
surnames: FR_SURNAMES,
|
|
588
|
+
chars: /[\u0153\u0152]/,
|
|
589
|
+
addressHint: /\b(?:rue|avenue|boulevard|impasse)\b/i,
|
|
590
|
+
streets: FR_STREETS,
|
|
591
|
+
formatAddress: (s, n) => `${n} rue ${s}`,
|
|
592
|
+
companies: FR_COMPANIES,
|
|
593
|
+
companySuffixHint: /SARL|S\.\u00e0 r\.l\.|SAS$/,
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
id: 'it',
|
|
597
|
+
firstMale: IT_FIRST_MALE,
|
|
598
|
+
firstFemale: IT_FIRST_FEMALE,
|
|
599
|
+
surnames: IT_SURNAMES,
|
|
600
|
+
aEndsFemale: true,
|
|
601
|
+
addressHint: /\b(?:via|viale|piazza|corso)\b/i,
|
|
602
|
+
streets: IT_STREETS,
|
|
603
|
+
formatAddress: (s, n) => `Via ${s} ${n}`,
|
|
604
|
+
companies: IT_COMPANIES,
|
|
605
|
+
companySuffixHint: /S\.r\.l\.|S\.p\.A\./,
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
id: 'pt',
|
|
609
|
+
firstMale: PT_FIRST_MALE,
|
|
610
|
+
firstFemale: PT_FIRST_FEMALE,
|
|
611
|
+
surnames: PT_SURNAMES,
|
|
612
|
+
chars: /[\u00e3\u00f5\u00c3\u00d5]/,
|
|
613
|
+
aEndsFemale: true,
|
|
614
|
+
addressHint: /\b(?:rua|travessa|pra\u00e7a|largo)\b/i,
|
|
615
|
+
streets: PT_STREETS,
|
|
616
|
+
formatAddress: (s, n) => `Rua ${s} ${n}`,
|
|
617
|
+
companies: PT_COMPANIES,
|
|
618
|
+
companySuffixHint: /Lda\.?$/,
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: 'nl',
|
|
622
|
+
firstMale: NL_FIRST_MALE,
|
|
623
|
+
firstFemale: NL_FIRST_FEMALE,
|
|
624
|
+
surnames: NL_SURNAMES,
|
|
625
|
+
addressHint: /straat\b|\blaan\b|\bgracht\b|\bplein\b/i,
|
|
626
|
+
streets: NL_STREETS,
|
|
627
|
+
formatAddress: (s, n) => `${s}straat ${n}`,
|
|
628
|
+
companies: NL_COMPANIES,
|
|
629
|
+
companySuffixHint: /B\.V\.|N\.V\./,
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: 'sv',
|
|
633
|
+
firstMale: SV_FIRST_MALE,
|
|
634
|
+
firstFemale: SV_FIRST_FEMALE,
|
|
635
|
+
surnames: SV_SURNAMES,
|
|
636
|
+
aEndsFemale: true,
|
|
637
|
+
addressHint: /(?:v\u00e4gen|gatan)\b/i,
|
|
638
|
+
streets: SV_STREETS,
|
|
639
|
+
formatAddress: (s, n) => `${s}v\u00e4gen ${n}`,
|
|
640
|
+
companies: SV_COMPANIES,
|
|
641
|
+
companySuffixHint: /\bAB$/,
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
id: 'no',
|
|
645
|
+
firstMale: NO_FIRST_MALE,
|
|
646
|
+
firstFemale: NO_FIRST_FEMALE,
|
|
647
|
+
surnames: NO_SURNAMES,
|
|
648
|
+
addressHint: /(?:veien|gata)\b/i,
|
|
649
|
+
streets: NO_STREETS,
|
|
650
|
+
formatAddress: (s, n) => `${s}veien ${n}`,
|
|
651
|
+
companies: NO_COMPANIES,
|
|
652
|
+
companySuffixHint: /\bASA$|\bAS$/,
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: 'da',
|
|
656
|
+
firstMale: DA_FIRST_MALE,
|
|
657
|
+
firstFemale: DA_FIRST_FEMALE,
|
|
658
|
+
surnames: DA_SURNAMES,
|
|
659
|
+
addressHint: /(?:vej|gade)\b/i,
|
|
660
|
+
streets: DA_STREETS,
|
|
661
|
+
formatAddress: (s, n) => `${s}vej ${n}`,
|
|
662
|
+
companies: DA_COMPANIES,
|
|
663
|
+
companySuffixHint: /A\/S$|ApS$/,
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
id: 'fi',
|
|
667
|
+
firstMale: FI_FIRST_MALE,
|
|
668
|
+
firstFemale: FI_FIRST_FEMALE,
|
|
669
|
+
surnames: FI_SURNAMES,
|
|
670
|
+
addressHint: /(?:katu|kuja)\b/i,
|
|
671
|
+
streets: FI_STREETS,
|
|
672
|
+
formatAddress: (s, n) => `${s}katu ${n}`,
|
|
673
|
+
companies: FI_COMPANIES,
|
|
674
|
+
companySuffixHint: /\bOyj?$/,
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
id: 'ja',
|
|
678
|
+
firstMale: JA_GIVEN_MALE,
|
|
679
|
+
firstFemale: JA_GIVEN_FEMALE,
|
|
680
|
+
surnames: JA_SURNAMES,
|
|
681
|
+
// Kana is decisively Japanese; kanji-only names resolve via the
|
|
682
|
+
// surname-prefix scoring below (2-char JA prefixes outscore 1-char
|
|
683
|
+
// ZH ones) or the script fallback.
|
|
684
|
+
chars: /[\u3040-\u30ff]/,
|
|
685
|
+
surnamePrefixes: JA_SURNAMES,
|
|
686
|
+
scriptFallback: /[\u3040-\u30ff]/,
|
|
687
|
+
addressHint: /\u4e01\u76ee|\u756a\u5730/,
|
|
688
|
+
streets: JA_STREETS,
|
|
689
|
+
formatAddress: (s, n) => `${s}\u753a${n}\u4e01\u76ee`,
|
|
690
|
+
companies: JA_COMPANIES,
|
|
691
|
+
companySuffixHint: /\u682a\u5f0f\u4f1a\u793e/,
|
|
692
|
+
formatPerson: (original, rand) => {
|
|
693
|
+
const sep = original.includes('\u3000') ? '\u3000' : original.includes(' ') ? ' ' : '';
|
|
694
|
+
const female = rand() < 0.5;
|
|
695
|
+
const given = pick(rand, female ? JA_GIVEN_FEMALE : JA_GIVEN_MALE);
|
|
696
|
+
return `${pick(rand, JA_SURNAMES)}${sep}${given}`;
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
id: 'zh',
|
|
701
|
+
firstMale: ZH_GIVEN_MALE,
|
|
702
|
+
firstFemale: ZH_GIVEN_FEMALE,
|
|
703
|
+
surnames: ZH_SURNAMES,
|
|
704
|
+
surnamePrefixes: ZH_SURNAMES,
|
|
705
|
+
scriptFallback: /[\u4e00-\u9fff]/,
|
|
706
|
+
addressHint: /[\u4e00-\u9fff]+\u8def|\u53f7$/,
|
|
707
|
+
streets: ZH_STREETS,
|
|
708
|
+
formatAddress: (s, n) => `${s}${n}\u53f7`,
|
|
709
|
+
companies: ZH_COMPANIES,
|
|
710
|
+
companySuffixHint: /\u6709\u9650\u516c\u53f8/,
|
|
711
|
+
formatPerson: (original, rand) => {
|
|
712
|
+
const female = rand() < 0.5;
|
|
713
|
+
const rest = original.replace(/\s/g, '').length - 1;
|
|
714
|
+
const pool = female ? ZH_GIVEN_FEMALE : ZH_GIVEN_MALE;
|
|
715
|
+
const sized = pool.filter((g) => g.length === Math.max(1, Math.min(2, rest)));
|
|
716
|
+
return `${pick(rand, ZH_SURNAMES)}${pick(rand, sized.length > 0 ? sized : pool)}`;
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
id: 'en',
|
|
721
|
+
firstMale: EN_FIRST_MALE,
|
|
722
|
+
firstFemale: EN_FIRST_FEMALE,
|
|
723
|
+
surnames: EN_SURNAMES,
|
|
724
|
+
streets: EN_STREETS,
|
|
725
|
+
formatAddress: (s, n) => `${n} ${s} Street`,
|
|
726
|
+
companies: EN_COMPANIES,
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
const EN_PACK = LOCALE_PACKS[LOCALE_PACKS.length - 1];
|
|
730
|
+
/** Folded lowercase name sets per pack, built once. */
|
|
731
|
+
const packNameSets = new Map();
|
|
732
|
+
for (const pack of LOCALE_PACKS) {
|
|
733
|
+
const female = new Set(pack.firstFemale.map((n) => foldDiacritics(n).toLowerCase()));
|
|
734
|
+
const male = new Set(pack.firstMale.map((n) => foldDiacritics(n).toLowerCase()));
|
|
735
|
+
const surnames = new Set(pack.surnames.flatMap((n) => foldDiacritics(n).toLowerCase().split(/\s+/)));
|
|
736
|
+
packNameSets.set(pack.id, { female, male, all: new Set([...female, ...male]), surnames });
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Locale detection precedence: distinctive characters (script, or
|
|
740
|
+
* diacritics unique enough to decide), then given-name pool membership,
|
|
741
|
+
* then surname endings; EN is the fallback. Pack order in LOCALE_PACKS
|
|
742
|
+
* is the tie-breaker for names shared between locales (Anna stays
|
|
743
|
+
* Polish here, matching the pre-T073 behavior).
|
|
744
|
+
*/
|
|
745
|
+
function detectPersonPack(value) {
|
|
746
|
+
for (const pack of LOCALE_PACKS) {
|
|
747
|
+
if (pack.chars?.test(value))
|
|
748
|
+
return pack;
|
|
749
|
+
}
|
|
750
|
+
// Names are shared between languages (Marie is French AND Czech), so
|
|
751
|
+
// membership is SCORED per token across given-name and surname pools
|
|
752
|
+
// plus characteristic surname endings; the best total wins and ties
|
|
753
|
+
// keep the earlier pack (Anna stays Polish, pre-T073 behavior).
|
|
754
|
+
const folded = value
|
|
755
|
+
.toLowerCase()
|
|
756
|
+
.split(/[\s-]+/)
|
|
757
|
+
.map((t) => foldDiacritics(t));
|
|
758
|
+
let best = null;
|
|
759
|
+
let bestScore = 0;
|
|
760
|
+
for (const pack of LOCALE_PACKS) {
|
|
761
|
+
const names = packNameSets.get(pack.id);
|
|
762
|
+
if (!names)
|
|
763
|
+
continue;
|
|
764
|
+
let score = 0;
|
|
765
|
+
for (const t of folded) {
|
|
766
|
+
if (names.all.has(t) || names.surnames.has(t))
|
|
767
|
+
score += 1;
|
|
768
|
+
else if (pack.surnameEndings?.test(t))
|
|
769
|
+
score += 1;
|
|
770
|
+
}
|
|
771
|
+
// CJK surname-first prefixes; longer prefixes score higher so a
|
|
772
|
+
// 2-char Japanese surname outranks a 1-char Chinese one.
|
|
773
|
+
if (pack.surnamePrefixes) {
|
|
774
|
+
for (const prefix of pack.surnamePrefixes) {
|
|
775
|
+
if (value.startsWith(prefix)) {
|
|
776
|
+
score += prefix.length * 2;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (score > bestScore) {
|
|
782
|
+
bestScore = score;
|
|
783
|
+
best = pack;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (best)
|
|
787
|
+
return best;
|
|
788
|
+
for (const pack of LOCALE_PACKS) {
|
|
789
|
+
if (pack.scriptFallback?.test(value))
|
|
790
|
+
return pack;
|
|
791
|
+
}
|
|
792
|
+
return EN_PACK;
|
|
793
|
+
}
|
|
794
|
+
function capsPatternOf(token) {
|
|
795
|
+
if (/\p{L}/u.test(token)) {
|
|
796
|
+
if (token === token.toUpperCase() && token !== token.toLowerCase())
|
|
797
|
+
return 'upper';
|
|
798
|
+
if (token === token.toLowerCase())
|
|
799
|
+
return 'lower';
|
|
800
|
+
}
|
|
801
|
+
return 'title';
|
|
802
|
+
}
|
|
803
|
+
function applyCaps(word, pattern) {
|
|
804
|
+
if (pattern === 'upper')
|
|
805
|
+
return word.toUpperCase();
|
|
806
|
+
if (pattern === 'lower')
|
|
807
|
+
return word.toLowerCase();
|
|
808
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Same-shape substitution: digits become other digits, ASCII letters
|
|
812
|
+
* become other letters of the same case; everything else (separators,
|
|
813
|
+
* diacritics, symbols) is kept verbatim. The generic fallback.
|
|
814
|
+
*/
|
|
815
|
+
function sameShape(value, rand) {
|
|
816
|
+
let out = '';
|
|
817
|
+
for (const ch of value) {
|
|
818
|
+
if (ch >= '0' && ch <= '9')
|
|
819
|
+
out += randDigit(rand);
|
|
820
|
+
else if (ch >= 'a' && ch <= 'z')
|
|
821
|
+
out += String.fromCharCode(97 + randInt(rand, 0, 25));
|
|
822
|
+
else if (ch >= 'A' && ch <= 'Z')
|
|
823
|
+
out += String.fromCharCode(65 + randInt(rand, 0, 25));
|
|
824
|
+
else
|
|
825
|
+
out += ch;
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
/** Digits-only substitution: keeps every non-digit (incl. letters) as-is. */
|
|
830
|
+
function sameShapeDigits(value, rand) {
|
|
831
|
+
return value.replace(/\d/g, () => randDigit(rand));
|
|
832
|
+
}
|
|
833
|
+
// ── PERSON ─────────────────────────────────────────────────
|
|
834
|
+
/**
|
|
835
|
+
* Gender from the given name (pack pools first, then this pack's female
|
|
836
|
+
* surname endings, then the Slavic/Romance -a heuristic where it holds).
|
|
837
|
+
*/
|
|
838
|
+
function detectGender(value, pack, rand) {
|
|
839
|
+
const tokens = value.split(/\s+/);
|
|
840
|
+
const rawFirst = tokens[0] ?? '';
|
|
841
|
+
const first = foldDiacritics(rawFirst).toLowerCase();
|
|
842
|
+
const names = packNameSets.get(pack.id);
|
|
843
|
+
if (names) {
|
|
844
|
+
if (names.female.has(first))
|
|
845
|
+
return 'f';
|
|
846
|
+
if (names.male.has(first))
|
|
847
|
+
return 'm';
|
|
848
|
+
}
|
|
849
|
+
const rawLast = tokens[tokens.length - 1] ?? '';
|
|
850
|
+
if (pack.femaleSurnameEndings?.test(rawLast.toLowerCase()))
|
|
851
|
+
return 'f';
|
|
852
|
+
if (pack.maleSurnameEndings?.test(rawLast.toLowerCase()))
|
|
853
|
+
return 'm';
|
|
854
|
+
if (pack.aEndsFemale) {
|
|
855
|
+
if (first.endsWith('a'))
|
|
856
|
+
return 'f';
|
|
857
|
+
if (first.length > 1)
|
|
858
|
+
return 'm';
|
|
859
|
+
}
|
|
860
|
+
return rand() < 0.5 ? 'f' : 'm';
|
|
861
|
+
}
|
|
862
|
+
function surnameFor(pack, gender, rand) {
|
|
863
|
+
const base = pick(rand, pack.surnames);
|
|
864
|
+
return gender === 'f' && pack.feminizeSurname ? pack.feminizeSurname(base) : base;
|
|
865
|
+
}
|
|
866
|
+
function firstNameFor(pack, gender, rand) {
|
|
867
|
+
return pick(rand, gender === 'f' ? pack.firstFemale : pack.firstMale);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Token count and per-token capitalization are preserved: a two-token
|
|
871
|
+
* name maps to first + surname, extra middle tokens become extra given
|
|
872
|
+
* names, a hyphenated final token becomes a hyphenated double surname.
|
|
873
|
+
*/
|
|
874
|
+
function generatePerson(original, rand) {
|
|
875
|
+
const tokens = original.trim().split(/\s+/);
|
|
876
|
+
if (tokens.length === 0 || original.trim() === '')
|
|
877
|
+
return sameShape(original, rand);
|
|
878
|
+
const pack = detectPersonPack(original);
|
|
879
|
+
if (pack.formatPerson)
|
|
880
|
+
return pack.formatPerson(original, rand);
|
|
881
|
+
const gender = detectGender(original, pack, rand);
|
|
882
|
+
const out = [];
|
|
883
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
884
|
+
const caps = capsPatternOf(tokens[i]);
|
|
885
|
+
const isLast = i === tokens.length - 1;
|
|
886
|
+
let word;
|
|
887
|
+
if (tokens.length === 1 || !isLast) {
|
|
888
|
+
word = firstNameFor(pack, gender, rand);
|
|
889
|
+
}
|
|
890
|
+
else if (tokens[i].includes('-')) {
|
|
891
|
+
const parts = tokens[i].split('-');
|
|
892
|
+
word = parts.map(() => surnameFor(pack, gender, rand)).join('-');
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
word = surnameFor(pack, gender, rand);
|
|
896
|
+
}
|
|
897
|
+
out.push(caps === 'title' ? word : applyCaps(word, caps));
|
|
898
|
+
}
|
|
899
|
+
return out.join(' ');
|
|
900
|
+
}
|
|
901
|
+
/** A replacement usable as a name source: not a bracket/legacy token. */
|
|
902
|
+
function isNameLike(replacement) {
|
|
903
|
+
return !/[[\]<>]/.test(replacement) && /\p{L}/u.test(replacement);
|
|
904
|
+
}
|
|
905
|
+
function findMatchingPerson(local, entries) {
|
|
906
|
+
if (!entries)
|
|
907
|
+
return null;
|
|
908
|
+
const haystack = foldDiacritics(local).toLowerCase();
|
|
909
|
+
for (const e of entries) {
|
|
910
|
+
if (e.entityType !== 'PERSON' || !isNameLike(e.replacement))
|
|
911
|
+
continue;
|
|
912
|
+
const tokens = foldDiacritics(e.original).toLowerCase().split(/[\s-]+/);
|
|
913
|
+
if (tokens.some((t) => t.length >= 3 && haystack.includes(t))) {
|
|
914
|
+
return { original: e.original, replacement: e.replacement };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
function generateEmail(original, ctx, rand) {
|
|
920
|
+
const m = /^([^@\s]+)@([^@\s]+)$/.exec(original);
|
|
921
|
+
if (!m)
|
|
922
|
+
return sameShape(original, rand);
|
|
923
|
+
const [, local] = m;
|
|
924
|
+
const digitsMatch = /^(.*?)(\d+)$/.exec(local);
|
|
925
|
+
const localBase = digitsMatch ? digitsMatch[1] : local;
|
|
926
|
+
const digitCount = digitsMatch ? digitsMatch[2].length : 0;
|
|
927
|
+
const sep = ['.', '_', '-'].find((s) => localBase.includes(s)) ?? '';
|
|
928
|
+
const person = findMatchingPerson(localBase, ctx.entries);
|
|
929
|
+
let nameTokens = null;
|
|
930
|
+
if (person) {
|
|
931
|
+
const folded = foldDiacritics(person.replacement).toLowerCase().split(/[\s-]+/);
|
|
932
|
+
// T073: a non-Latin surrogate name (Cyrillic) cannot form a sane
|
|
933
|
+
// ASCII local part; fall through to the unrelated EN identity then.
|
|
934
|
+
if (folded.every((t) => /^[a-z0-9]+$/.test(t)))
|
|
935
|
+
nameTokens = folded;
|
|
936
|
+
}
|
|
937
|
+
if (!nameTokens) {
|
|
938
|
+
// No in-session person to mirror: deterministic unrelated identity.
|
|
939
|
+
nameTokens = [
|
|
940
|
+
foldDiacritics(firstNameFor(EN_PACK, rand() < 0.5 ? 'f' : 'm', rand)).toLowerCase(),
|
|
941
|
+
foldDiacritics(pick(rand, EN_SURNAMES)).toLowerCase(),
|
|
942
|
+
];
|
|
943
|
+
}
|
|
944
|
+
let newLocal;
|
|
945
|
+
const origTokens = sep ? localBase.split(sep) : [localBase];
|
|
946
|
+
if (sep && origTokens.length === nameTokens.length) {
|
|
947
|
+
// Mirror the original pattern token by token; single-letter tokens
|
|
948
|
+
// stay initials (j.kowalski maps to a.nowak).
|
|
949
|
+
newLocal = origTokens
|
|
950
|
+
.map((t, i) => (t.length === 1 ? nameTokens[i].charAt(0) : nameTokens[i]))
|
|
951
|
+
.join(sep);
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
newLocal = nameTokens.join(sep || '.');
|
|
955
|
+
}
|
|
956
|
+
if (digitCount > 0) {
|
|
957
|
+
let digits = '';
|
|
958
|
+
for (let i = 0; i < digitCount; i++)
|
|
959
|
+
digits += randDigit(rand);
|
|
960
|
+
newLocal += digits;
|
|
961
|
+
}
|
|
962
|
+
const domain = pick(rand, FAKE_EMAIL_DOMAINS);
|
|
963
|
+
return `${newLocal}@${domain}`;
|
|
964
|
+
}
|
|
965
|
+
// ── DATE ───────────────────────────────────────────────────
|
|
966
|
+
const EN_MONTHS = [
|
|
967
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
968
|
+
'july', 'august', 'september', 'october', 'november', 'december',
|
|
969
|
+
];
|
|
970
|
+
const EN_MONTHS_ABBR = [
|
|
971
|
+
'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
|
972
|
+
];
|
|
973
|
+
const PL_MONTHS_GEN = [
|
|
974
|
+
'stycznia', 'lutego', 'marca', 'kwietnia', 'maja', 'czerwca',
|
|
975
|
+
'lipca', 'sierpnia', 'września', 'października', 'listopada', 'grudnia',
|
|
976
|
+
];
|
|
977
|
+
const PL_MONTHS_NOM = [
|
|
978
|
+
'styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec',
|
|
979
|
+
'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień',
|
|
980
|
+
];
|
|
981
|
+
// T073 month names; genitive forms where dates inflect (cs, uk).
|
|
982
|
+
const DE_MONTHS = [
|
|
983
|
+
'januar', 'februar', 'm\u00e4rz', 'april', 'mai', 'juni',
|
|
984
|
+
'juli', 'august', 'september', 'oktober', 'november', 'dezember',
|
|
985
|
+
];
|
|
986
|
+
const FR_MONTHS = [
|
|
987
|
+
'janvier', 'f\u00e9vrier', 'mars', 'avril', 'mai', 'juin',
|
|
988
|
+
'juillet', 'ao\u00fbt', 'septembre', 'octobre', 'novembre', 'd\u00e9cembre',
|
|
989
|
+
];
|
|
990
|
+
const ES_MONTHS = [
|
|
991
|
+
'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
|
|
992
|
+
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre',
|
|
993
|
+
];
|
|
994
|
+
const IT_MONTHS = [
|
|
995
|
+
'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno',
|
|
996
|
+
'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre',
|
|
997
|
+
];
|
|
998
|
+
const CS_MONTHS_GEN = [
|
|
999
|
+
'ledna', '\u00fanora', 'b\u0159ezna', 'dubna', 'kv\u011btna', '\u010dervna',
|
|
1000
|
+
'\u010dervence', 'srpna', 'z\u00e1\u0159\u00ed', '\u0159\u00edjna', 'listopadu', 'prosince',
|
|
1001
|
+
];
|
|
1002
|
+
const UK_MONTHS_GEN = [
|
|
1003
|
+
'\u0441\u0456\u0447\u043d\u044f', '\u043b\u044e\u0442\u043e\u0433\u043e', '\u0431\u0435\u0440\u0435\u0437\u043d\u044f', '\u043a\u0432\u0456\u0442\u043d\u044f', '\u0442\u0440\u0430\u0432\u043d\u044f', '\u0447\u0435\u0440\u0432\u043d\u044f',
|
|
1004
|
+
'\u043b\u0438\u043f\u043d\u044f', '\u0441\u0435\u0440\u043f\u043d\u044f', '\u0432\u0435\u0440\u0435\u0441\u043d\u044f', '\u0436\u043e\u0432\u0442\u043d\u044f', '\u043b\u0438\u0441\u0442\u043e\u043f\u0430\u0434\u0430', '\u0433\u0440\u0443\u0434\u043d\u044f',
|
|
1005
|
+
];
|
|
1006
|
+
const NL_MONTHS = [
|
|
1007
|
+
'januari', 'februari', 'maart', 'april', 'mei', 'juni',
|
|
1008
|
+
'juli', 'augustus', 'september', 'oktober', 'november', 'december',
|
|
1009
|
+
];
|
|
1010
|
+
const PT_MONTHS = [
|
|
1011
|
+
'janeiro', 'fevereiro', 'mar\u00e7o', 'abril', 'maio', 'junho',
|
|
1012
|
+
'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro',
|
|
1013
|
+
];
|
|
1014
|
+
const SV_MONTHS = [
|
|
1015
|
+
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
1016
|
+
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
1017
|
+
];
|
|
1018
|
+
const NO_MONTHS = [
|
|
1019
|
+
'januar', 'februar', 'mars', 'april', 'mai', 'juni',
|
|
1020
|
+
'juli', 'august', 'september', 'oktober', 'november', 'desember',
|
|
1021
|
+
];
|
|
1022
|
+
const DA_MONTHS = [
|
|
1023
|
+
'januar', 'februar', 'marts', 'april', 'maj', 'juni',
|
|
1024
|
+
'juli', 'august', 'september', 'oktober', 'november', 'december',
|
|
1025
|
+
];
|
|
1026
|
+
/** Finnish dates use the partitive (15. maaliskuuta 2024). */
|
|
1027
|
+
const FI_MONTHS_PART = [
|
|
1028
|
+
'tammikuuta', 'helmikuuta', 'maaliskuuta', 'huhtikuuta', 'toukokuuta', 'kes\u00e4kuuta',
|
|
1029
|
+
'hein\u00e4kuuta', 'elokuuta', 'syyskuuta', 'lokakuuta', 'marraskuuta', 'joulukuuta',
|
|
1030
|
+
];
|
|
1031
|
+
const MONTH_LISTS = [
|
|
1032
|
+
EN_MONTHS, EN_MONTHS_ABBR, PL_MONTHS_GEN, PL_MONTHS_NOM,
|
|
1033
|
+
DE_MONTHS, FR_MONTHS, ES_MONTHS, IT_MONTHS, CS_MONTHS_GEN, UK_MONTHS_GEN,
|
|
1034
|
+
NL_MONTHS, PT_MONTHS, SV_MONTHS, NO_MONTHS, DA_MONTHS, FI_MONTHS_PART,
|
|
1035
|
+
];
|
|
1036
|
+
function lookupMonth(token) {
|
|
1037
|
+
const lower = token.toLowerCase();
|
|
1038
|
+
for (const list of MONTH_LISTS) {
|
|
1039
|
+
const i = list.indexOf(lower);
|
|
1040
|
+
if (i !== -1)
|
|
1041
|
+
return { month: i + 1, list };
|
|
1042
|
+
}
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
function pad(n, width) {
|
|
1046
|
+
return String(n).padStart(width, '0');
|
|
1047
|
+
}
|
|
1048
|
+
function parseDate(value) {
|
|
1049
|
+
// CJK date (2024\u5e743\u670815\u65e5), shared by ja and zh.
|
|
1050
|
+
const cjk = /^(\d{4})\u5e74(\d{1,2})\u6708(\d{1,2})\u65e5$/.exec(value);
|
|
1051
|
+
if (cjk) {
|
|
1052
|
+
return {
|
|
1053
|
+
y: +cjk[1], m: +cjk[2], d: +cjk[3],
|
|
1054
|
+
rebuild: (y, mo, d) => `${y}\u5e74${mo}\u6708${d}\u65e5`,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
let m = /^(\d{4})([./-])(\d{1,2})\2(\d{1,2})$/.exec(value);
|
|
1058
|
+
if (m) {
|
|
1059
|
+
const [, ys, sep, ms, ds] = m;
|
|
1060
|
+
return {
|
|
1061
|
+
y: +ys, m: +ms, d: +ds,
|
|
1062
|
+
rebuild: (y, mo, d) => `${y}${sep}${pad(mo, ms.length)}${sep}${pad(d, ds.length)}`,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
// Day-first (European) reading for D.M.YYYY / D/M/YYYY / D-M-YYYY.
|
|
1066
|
+
m = /^(\d{1,2})([./-])(\d{1,2})\2(\d{4})$/.exec(value);
|
|
1067
|
+
if (m) {
|
|
1068
|
+
const [, ds, sep, ms, ys] = m;
|
|
1069
|
+
if (+ms >= 1 && +ms <= 12) {
|
|
1070
|
+
return {
|
|
1071
|
+
y: +ys, m: +ms, d: +ds,
|
|
1072
|
+
rebuild: (y, mo, d) => `${pad(d, ds.length)}${sep}${pad(mo, ms.length)}${sep}${y}`,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
// "15 March 2024" / "15 marca 2024"
|
|
1078
|
+
m = /^(\d{1,2})(\s+)(\p{L}+)(\s+)(\d{4})$/u.exec(value);
|
|
1079
|
+
if (m) {
|
|
1080
|
+
const [, ds, ws1, monthToken, ws2, ys] = m;
|
|
1081
|
+
const month = lookupMonth(monthToken);
|
|
1082
|
+
if (!month)
|
|
1083
|
+
return null;
|
|
1084
|
+
const caps = capsPatternOf(monthToken);
|
|
1085
|
+
return {
|
|
1086
|
+
y: +ys, m: month.month, d: +ds,
|
|
1087
|
+
rebuild: (y, mo, d) => `${pad(d, ds.length)}${ws1}${applyCaps(month.list[mo - 1], caps)}${ws2}${y}`,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
// "March 15, 2024" / "March 15 2024"
|
|
1091
|
+
m = /^(\p{L}+)(\s+)(\d{1,2})(,?)(\s+)(\d{4})$/u.exec(value);
|
|
1092
|
+
if (m) {
|
|
1093
|
+
const [, monthToken, ws1, ds, comma, ws2, ys] = m;
|
|
1094
|
+
const month = lookupMonth(monthToken);
|
|
1095
|
+
if (!month)
|
|
1096
|
+
return null;
|
|
1097
|
+
const caps = capsPatternOf(monthToken);
|
|
1098
|
+
return {
|
|
1099
|
+
y: +ys, m: month.month, d: +ds,
|
|
1100
|
+
rebuild: (y, mo, d) => `${applyCaps(month.list[mo - 1], caps)}${ws1}${pad(d, ds.length)}${comma}${ws2}${y}`,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Per-session day offset in [-30, -1] or [1, 30]: derived from the salt
|
|
1107
|
+
* only, so every date in a session shifts by the same amount and relative
|
|
1108
|
+
* spacing between dates is preserved. Never 0 (a zero shift would leak the
|
|
1109
|
+
* original date unchanged).
|
|
1110
|
+
*/
|
|
1111
|
+
export function sessionDayOffset(salt) {
|
|
1112
|
+
const n = randInt(rngFor(salt, 'DATE_OFFSET'), 1, 60);
|
|
1113
|
+
return n <= 30 ? n : 30 - n;
|
|
1114
|
+
}
|
|
1115
|
+
function generateDate(original, ctx, rand, attempt) {
|
|
1116
|
+
const parsed = parseDate(original.trim());
|
|
1117
|
+
if (parsed) {
|
|
1118
|
+
const { y, m, d } = parsed;
|
|
1119
|
+
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) {
|
|
1120
|
+
const ts = Date.UTC(y, m - 1, d);
|
|
1121
|
+
const check = new Date(ts);
|
|
1122
|
+
if (check.getUTCFullYear() === y &&
|
|
1123
|
+
check.getUTCMonth() === m - 1 &&
|
|
1124
|
+
check.getUTCDate() === d) {
|
|
1125
|
+
const offset = sessionDayOffset(ctx.salt) + attempt;
|
|
1126
|
+
const shifted = new Date(ts + offset * 86400000);
|
|
1127
|
+
return parsed.rebuild(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, shifted.getUTCDate());
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Unparseable shapes keep their exact layout with substituted digits.
|
|
1132
|
+
return sameShapeDigits(original, rand);
|
|
1133
|
+
}
|
|
1134
|
+
// ── PHONE ──────────────────────────────────────────────────
|
|
1135
|
+
/**
|
|
1136
|
+
* Formatting (spaces, dashes, parentheses) is kept verbatim; the
|
|
1137
|
+
* international prefix (+CC, 00CC, or a leading trunk 0) keeps its
|
|
1138
|
+
* digits and every subscriber digit is substituted.
|
|
1139
|
+
*/
|
|
1140
|
+
function generatePhone(original, rand) {
|
|
1141
|
+
const trimmed = original.trim();
|
|
1142
|
+
let preserved;
|
|
1143
|
+
const digitsOnly = trimmed.replace(/\D/g, '');
|
|
1144
|
+
if (trimmed.startsWith('+'))
|
|
1145
|
+
preserved = Math.min(2, Math.max(0, digitsOnly.length - 4));
|
|
1146
|
+
else if (digitsOnly.startsWith('00'))
|
|
1147
|
+
preserved = Math.min(4, Math.max(0, digitsOnly.length - 4));
|
|
1148
|
+
else if (digitsOnly.startsWith('0'))
|
|
1149
|
+
preserved = 1;
|
|
1150
|
+
else
|
|
1151
|
+
preserved = 0;
|
|
1152
|
+
let seen = 0;
|
|
1153
|
+
return original.replace(/\d/g, (d) => {
|
|
1154
|
+
seen++;
|
|
1155
|
+
return seen <= preserved ? d : randDigit(rand);
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
// ── IBAN ───────────────────────────────────────────────────
|
|
1159
|
+
/** BBAN+check+country lengths for common IBAN countries. */
|
|
1160
|
+
const IBAN_LENGTHS = {
|
|
1161
|
+
AT: 20, BE: 16, BG: 22, CH: 21, CZ: 24, DE: 22, DK: 18, EE: 20, ES: 24,
|
|
1162
|
+
FI: 18, FR: 27, GB: 22, GR: 27, HR: 21, HU: 28, IE: 22, IT: 27, LT: 20,
|
|
1163
|
+
LU: 20, LV: 21, NL: 18, NO: 15, PL: 28, PT: 25, RO: 24, SE: 24, SI: 19,
|
|
1164
|
+
SK: 24,
|
|
1165
|
+
};
|
|
1166
|
+
/** mod 97 over the alphanumeric IBAN expansion (A=10 .. Z=35). */
|
|
1167
|
+
function ibanMod97(s) {
|
|
1168
|
+
let rem = 0;
|
|
1169
|
+
for (const ch of s) {
|
|
1170
|
+
const v = ch >= '0' && ch <= '9' ? ch.charCodeAt(0) - 48 : ch.charCodeAt(0) - 55;
|
|
1171
|
+
rem = (rem * (v > 9 ? 100 : 10) + v) % 97;
|
|
1172
|
+
}
|
|
1173
|
+
return rem;
|
|
1174
|
+
}
|
|
1175
|
+
function generateIban(original, rand) {
|
|
1176
|
+
const compact = original.replace(/\s/g, '').toUpperCase();
|
|
1177
|
+
const m = /^([A-Z]{2})\d{2}[A-Z0-9]+$/.exec(compact);
|
|
1178
|
+
if (!m)
|
|
1179
|
+
return sameShape(original, rand);
|
|
1180
|
+
const country = m[1];
|
|
1181
|
+
const length = IBAN_LENGTHS[country] ?? compact.length;
|
|
1182
|
+
const bbanLength = length - 4;
|
|
1183
|
+
// Mirror the original's letter/digit pattern positionally when lengths
|
|
1184
|
+
// match (keeps e.g. GB bank-code letters); otherwise all digits.
|
|
1185
|
+
let bban = '';
|
|
1186
|
+
for (let i = 0; i < bbanLength; i++) {
|
|
1187
|
+
const origCh = compact.length === length ? compact[4 + i] : '0';
|
|
1188
|
+
bban += origCh >= 'A' && origCh <= 'Z'
|
|
1189
|
+
? String.fromCharCode(65 + randInt(rand, 0, 25))
|
|
1190
|
+
: randDigit(rand);
|
|
1191
|
+
}
|
|
1192
|
+
const check = String(98 - ibanMod97(`${bban}${country}00`)).padStart(2, '0');
|
|
1193
|
+
const generated = `${country}${check}${bban}`;
|
|
1194
|
+
// Reapply the original's grouping when the alnum counts line up.
|
|
1195
|
+
const origAlnum = original.replace(/\s/g, '').length;
|
|
1196
|
+
if (origAlnum === generated.length) {
|
|
1197
|
+
let i = 0;
|
|
1198
|
+
return original.replace(/[^\s]/g, () => generated[i++]);
|
|
1199
|
+
}
|
|
1200
|
+
return /\s/.test(original)
|
|
1201
|
+
? generated.replace(/(.{4})(?=.)/g, '$1 ')
|
|
1202
|
+
: generated;
|
|
1203
|
+
}
|
|
1204
|
+
// ── National IDs (SSN entity type: PESEL, US SSN, ...) ─────
|
|
1205
|
+
const PESEL_WEIGHTS = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
|
|
1206
|
+
function peselChecksum(digits10) {
|
|
1207
|
+
let sum = 0;
|
|
1208
|
+
for (let i = 0; i < 10; i++)
|
|
1209
|
+
sum += Number(digits10[i]) * PESEL_WEIGHTS[i];
|
|
1210
|
+
return (10 - (sum % 10)) % 10;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Checksum-valid PESEL with a deliberate implausibility marker: the
|
|
1214
|
+
* encoded birth date lies in 1800-1899 (PESEL month field 81-92). The
|
|
1215
|
+
* registry has never issued numbers for people born in the 19th century,
|
|
1216
|
+
* so a generated value can never be a real living person's PESEL. We never
|
|
1217
|
+
* deliberately generate a real identifier; random collisions with other
|
|
1218
|
+
* identifier kinds are statistically possible but not targetable.
|
|
1219
|
+
*/
|
|
1220
|
+
function generatePesel(rand) {
|
|
1221
|
+
const yy = pad(randInt(rand, 0, 99), 2);
|
|
1222
|
+
const mm = pad(80 + randInt(rand, 1, 12), 2); // 1800s century encoding
|
|
1223
|
+
const dd = pad(randInt(rand, 1, 28), 2);
|
|
1224
|
+
let serial = '';
|
|
1225
|
+
for (let i = 0; i < 4; i++)
|
|
1226
|
+
serial += randDigit(rand);
|
|
1227
|
+
const body = `${yy}${mm}${dd}${serial}`;
|
|
1228
|
+
return `${body}${peselChecksum(body)}`;
|
|
1229
|
+
}
|
|
1230
|
+
/** UK NINO display shape: 2 prefix letters, 6 digits, suffix A-D. */
|
|
1231
|
+
const NINO_SHAPE = /^[A-Za-z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Da-d]$/;
|
|
1232
|
+
/** UK NHS display shape: 10 digits, optionally grouped 3-3-4. */
|
|
1233
|
+
const NHS_SHAPE = /^\d{3}\s?\d{3}\s?\d{4}$/;
|
|
1234
|
+
const NHS_WEIGHTS = [10, 9, 8, 7, 6, 5, 4, 3, 2];
|
|
1235
|
+
/**
|
|
1236
|
+
* Mod-11-valid 10-digit NHS number in the 999 range, which NHS Digital
|
|
1237
|
+
* reserves for test data and never issues to patients (T100) - the NHS
|
|
1238
|
+
* counterpart of the 1800s PESEL marker. Bodies whose check digit
|
|
1239
|
+
* computes to 10 have no valid final digit; redraw the serial then.
|
|
1240
|
+
*/
|
|
1241
|
+
function generateNhsNumber(rand) {
|
|
1242
|
+
for (;;) {
|
|
1243
|
+
let body = '999';
|
|
1244
|
+
for (let i = 0; i < 6; i++)
|
|
1245
|
+
body += randDigit(rand);
|
|
1246
|
+
let sum = 0;
|
|
1247
|
+
for (let i = 0; i < 9; i++)
|
|
1248
|
+
sum += Number(body[i]) * NHS_WEIGHTS[i];
|
|
1249
|
+
const check = 11 - (sum % 11);
|
|
1250
|
+
if (check === 10)
|
|
1251
|
+
continue;
|
|
1252
|
+
return body + String(check === 11 ? 0 : check);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* UK NINO stand-in (T100): NINOs carry no checksum, so the safety marker
|
|
1257
|
+
* is the QQ prefix - Q is never used in real prefixes, and HMRC's own
|
|
1258
|
+
* forms use QQ 12 34 56 C as the documentation example (the NINO
|
|
1259
|
+
* equivalent of an RFC 2606 reserved domain). Digits are substituted,
|
|
1260
|
+
* the suffix stays in the real A-D range, and the original's spacing
|
|
1261
|
+
* and letter case are preserved.
|
|
1262
|
+
*/
|
|
1263
|
+
function generateNino(original, rand) {
|
|
1264
|
+
const suffix = 'ABCD'[randInt(rand, 0, 3)];
|
|
1265
|
+
let letterIdx = 0;
|
|
1266
|
+
return original.replace(/[A-Za-z0-9]/g, (ch) => {
|
|
1267
|
+
if (ch >= '0' && ch <= '9')
|
|
1268
|
+
return randDigit(rand);
|
|
1269
|
+
letterIdx++;
|
|
1270
|
+
const repl = letterIdx <= 2 ? 'Q' : suffix;
|
|
1271
|
+
return ch === ch.toLowerCase() ? repl.toLowerCase() : repl;
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
/** US EIN display shape: 2-7 hyphenated. */
|
|
1275
|
+
const US_EIN_SHAPE = /^\d{2}-\d{7}$/;
|
|
1276
|
+
/**
|
|
1277
|
+
* EIN campus prefixes the IRS has never assigned - the exact complement
|
|
1278
|
+
* of the valid-prefix classes in rules/us.json (regex:us:ein), so a
|
|
1279
|
+
* stand-in built on one can never be a real employer's EIN.
|
|
1280
|
+
*/
|
|
1281
|
+
const EIN_UNASSIGNED_PREFIXES = [
|
|
1282
|
+
'07', '08', '09', '17', '18', '19', '28', '29',
|
|
1283
|
+
'49', '69', '70', '78', '79', '89', '96', '97',
|
|
1284
|
+
];
|
|
1285
|
+
/**
|
|
1286
|
+
* US SSN-shaped stand-in (T101): 9 digits mapped positionally onto the
|
|
1287
|
+
* original's layout. The area is forced into 900-999, which the SSA has
|
|
1288
|
+
* never allocated (and the post-2011 randomization scheme explicitly
|
|
1289
|
+
* excludes), so the value can never be a real SSN. That alone is not
|
|
1290
|
+
* enough: ITINs live exactly in the 9xx area, so the group is drawn from
|
|
1291
|
+
* 01-49 - outside every valid ITIN group range (50-65, 70-88, 90-92,
|
|
1292
|
+
* 94-99) - making the stand-in provably neither an SSN nor an ITIN.
|
|
1293
|
+
*/
|
|
1294
|
+
function generateSsnStandIn(original, rand) {
|
|
1295
|
+
const area = String(randInt(rand, 900, 999));
|
|
1296
|
+
const group = pad(randInt(rand, 1, 49), 2);
|
|
1297
|
+
const serial = pad(randInt(rand, 1, 9999), 4);
|
|
1298
|
+
const digits = `${area}${group}${serial}`;
|
|
1299
|
+
let i = 0;
|
|
1300
|
+
return original.replace(/\d/g, () => digits[i++]);
|
|
1301
|
+
}
|
|
1302
|
+
/** US EIN stand-in (T101): a never-assigned campus prefix + 7 digits. */
|
|
1303
|
+
function generateEinStandIn(original, rand) {
|
|
1304
|
+
let digits = pick(rand, EIN_UNASSIGNED_PREFIXES);
|
|
1305
|
+
for (let i = 0; i < 7; i++)
|
|
1306
|
+
digits += randDigit(rand);
|
|
1307
|
+
let i = 0;
|
|
1308
|
+
return original.replace(/\d/g, () => digits[i++]);
|
|
1309
|
+
}
|
|
1310
|
+
function generateNationalId(original, rand) {
|
|
1311
|
+
const digits = original.replace(/\D/g, '');
|
|
1312
|
+
if (digits.length === 11 && /^\d{11}$/.test(original.trim())) {
|
|
1313
|
+
return generatePesel(rand);
|
|
1314
|
+
}
|
|
1315
|
+
const trimmed = original.trim();
|
|
1316
|
+
if (NINO_SHAPE.test(trimmed)) {
|
|
1317
|
+
return generateNino(original, rand);
|
|
1318
|
+
}
|
|
1319
|
+
if (digits.length === 10 && NHS_SHAPE.test(trimmed)) {
|
|
1320
|
+
const nhs = generateNhsNumber(rand);
|
|
1321
|
+
let i = 0;
|
|
1322
|
+
return original.replace(/\d/g, () => nhs[i++]);
|
|
1323
|
+
}
|
|
1324
|
+
if (US_EIN_SHAPE.test(trimmed)) {
|
|
1325
|
+
return generateEinStandIn(original, rand);
|
|
1326
|
+
}
|
|
1327
|
+
if (digits.length === 9) {
|
|
1328
|
+
// US SSN shape (hyphenated or bare 9 digits): never-issued area AND
|
|
1329
|
+
// never-valid ITIN group.
|
|
1330
|
+
return generateSsnStandIn(original, rand);
|
|
1331
|
+
}
|
|
1332
|
+
return sameShapeDigits(original, rand);
|
|
1333
|
+
}
|
|
1334
|
+
// ── US ABA routing numbers (OTHER entity type) ─────────────
|
|
1335
|
+
/** 3-7-1 weighted mod-10 sum of a 9-digit ABA routing number. */
|
|
1336
|
+
function abaChecksumOk(digits) {
|
|
1337
|
+
const n = digits.split('').map(Number);
|
|
1338
|
+
const sum = 3 * (n[0] + n[3] + n[6]) + 7 * (n[1] + n[4] + n[7]) + (n[2] + n[5] + n[8]);
|
|
1339
|
+
return sum % 10 === 0;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* US ABA routing stand-in (T101): checksum-valid so shape-checking
|
|
1343
|
+
* consumers keep accepting it, but with the 99 leading pair - the ABA
|
|
1344
|
+
* assigns only 00-12, 21-32, 61-72, and 80, so a 99xxxxxxx number can
|
|
1345
|
+
* never be a real institution's routing number (the checksummed
|
|
1346
|
+
* counterpart of the NHS 999 test range and the NINO QQ prefix).
|
|
1347
|
+
*/
|
|
1348
|
+
function generateRoutingStandIn(original, rand) {
|
|
1349
|
+
let body = '99';
|
|
1350
|
+
for (let i = 0; i < 6; i++)
|
|
1351
|
+
body += randDigit(rand);
|
|
1352
|
+
const n = body.split('').map(Number);
|
|
1353
|
+
// The 9th digit has weight 1 in the 3-7-1 scheme: choose it to zero
|
|
1354
|
+
// the sum mod 10, so a valid check digit always exists.
|
|
1355
|
+
const partial = 3 * (n[0] + n[3] + n[6]) + 7 * (n[1] + n[4] + n[7]) + (n[2] + n[5]);
|
|
1356
|
+
const digits = body + String((10 - (partial % 10)) % 10);
|
|
1357
|
+
let i = 0;
|
|
1358
|
+
return original.replace(/\d/g, () => digits[i++]);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* OTHER is a grab-bag type; the only shape given special treatment is a
|
|
1362
|
+
* checksum-valid 9-digit ABA routing number (regex:us:routing emits
|
|
1363
|
+
* OTHER). Everything else keeps the generic same-shape substitution,
|
|
1364
|
+
* which would otherwise break the routing checksum - or worse, randomly
|
|
1365
|
+
* land on a real institution's number.
|
|
1366
|
+
*/
|
|
1367
|
+
function generateOther(original, rand) {
|
|
1368
|
+
const trimmed = original.trim();
|
|
1369
|
+
if (/^\d{9}$/.test(trimmed) && abaChecksumOk(trimmed)) {
|
|
1370
|
+
return generateRoutingStandIn(original, rand);
|
|
1371
|
+
}
|
|
1372
|
+
return sameShape(original, rand);
|
|
1373
|
+
}
|
|
1374
|
+
// ── CREDIT_CARD ────────────────────────────────────────────
|
|
1375
|
+
function luhnCheckDigit(payload) {
|
|
1376
|
+
let sum = 0;
|
|
1377
|
+
let double = true; // start doubling from the rightmost payload digit
|
|
1378
|
+
for (let i = payload.length - 1; i >= 0; i--) {
|
|
1379
|
+
let d = Number(payload[i]);
|
|
1380
|
+
if (double) {
|
|
1381
|
+
d *= 2;
|
|
1382
|
+
if (d > 9)
|
|
1383
|
+
d -= 9;
|
|
1384
|
+
}
|
|
1385
|
+
sum += d;
|
|
1386
|
+
double = !double;
|
|
1387
|
+
}
|
|
1388
|
+
return (10 - (sum % 10)) % 10;
|
|
1389
|
+
}
|
|
1390
|
+
function generateCreditCard(original, rand) {
|
|
1391
|
+
const digitCount = (original.match(/\d/g) ?? []).length;
|
|
1392
|
+
if (digitCount < 2)
|
|
1393
|
+
return sameShapeDigits(original, rand);
|
|
1394
|
+
let payload = '';
|
|
1395
|
+
for (let i = 0; i < digitCount - 1; i++)
|
|
1396
|
+
payload += randDigit(rand);
|
|
1397
|
+
const full = payload + String(luhnCheckDigit(payload));
|
|
1398
|
+
let i = 0;
|
|
1399
|
+
return original.replace(/\d/g, () => full[i++]);
|
|
1400
|
+
}
|
|
1401
|
+
// ── IP_ADDRESS ─────────────────────────────────────────────
|
|
1402
|
+
function generateIp(original, rand) {
|
|
1403
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(original.trim());
|
|
1404
|
+
if (m) {
|
|
1405
|
+
return [0, 0, 0, 0].map(() => randInt(rand, 1, 254)).join('.');
|
|
1406
|
+
}
|
|
1407
|
+
// IPv6 and anything else: substitute hex digits, keep structure.
|
|
1408
|
+
return original.replace(/[0-9a-fA-F]/g, (ch) => {
|
|
1409
|
+
const v = randInt(rand, 0, 15).toString(16);
|
|
1410
|
+
return ch === ch.toUpperCase() && /[a-f]/i.test(ch) ? v.toUpperCase() : v;
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
// ── ADDRESS / COMPANY (pool-based, kept simple) ────────────
|
|
1414
|
+
/**
|
|
1415
|
+
* Locale from the address keyword first (rue/calle/via/stra\u00dfe/\u0432\u0443\u043b...),
|
|
1416
|
+
* then distinctive characters; EN format is the fallback (T073).
|
|
1417
|
+
*/
|
|
1418
|
+
function detectAddressPack(original) {
|
|
1419
|
+
for (const pack of LOCALE_PACKS) {
|
|
1420
|
+
if (pack.addressHint?.test(original))
|
|
1421
|
+
return pack;
|
|
1422
|
+
}
|
|
1423
|
+
for (const pack of LOCALE_PACKS) {
|
|
1424
|
+
if (pack.chars?.test(original))
|
|
1425
|
+
return pack;
|
|
1426
|
+
}
|
|
1427
|
+
for (const pack of LOCALE_PACKS) {
|
|
1428
|
+
if (pack.scriptFallback?.test(original))
|
|
1429
|
+
return pack;
|
|
1430
|
+
}
|
|
1431
|
+
return EN_PACK;
|
|
1432
|
+
}
|
|
1433
|
+
function generateAddress(original, rand) {
|
|
1434
|
+
const pack = detectAddressPack(original);
|
|
1435
|
+
const n = randInt(rand, 1, 120);
|
|
1436
|
+
const streets = pack.streets ?? EN_PACK.streets;
|
|
1437
|
+
const format = pack.formatAddress ?? EN_PACK.formatAddress;
|
|
1438
|
+
return format(pick(rand, streets), n);
|
|
1439
|
+
}
|
|
1440
|
+
function generateCompany(original, rand) {
|
|
1441
|
+
const trimmed = original.trim();
|
|
1442
|
+
// Latin suffixes must follow a space ('SAAB' must not shed an 'AB');
|
|
1443
|
+
// CJK suffixes attach directly (\u4f8b: \u2026\u6709\u9650\u516c\u53f8).
|
|
1444
|
+
const suffix = COMPANY_SUFFIXES.find((s) => /^[\x00-\x7f]+$/.test(s) ? trimmed.endsWith(` ${s}`) : trimmed.endsWith(s));
|
|
1445
|
+
// Legal form decides the locale first (GmbH is German wherever it
|
|
1446
|
+
// appears), then distinctive characters; EN pool is the fallback.
|
|
1447
|
+
let pack = null;
|
|
1448
|
+
if (suffix !== undefined) {
|
|
1449
|
+
pack = LOCALE_PACKS.find((p) => p.companySuffixHint?.test(suffix)) ?? null;
|
|
1450
|
+
}
|
|
1451
|
+
if (!pack)
|
|
1452
|
+
pack = LOCALE_PACKS.find((p) => p.chars?.test(original)) ?? null;
|
|
1453
|
+
const base = pick(rand, pack?.companies ?? EN_PACK.companies);
|
|
1454
|
+
if (!suffix)
|
|
1455
|
+
return base;
|
|
1456
|
+
const spaced = trimmed.endsWith(` ${suffix}`);
|
|
1457
|
+
return spaced ? `${base} ${suffix}` : `${base}${suffix}`;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Test/diagnostic probe (T073): which locale pack a PERSON value would
|
|
1461
|
+
* use. Not part of the anonymization data path.
|
|
1462
|
+
*/
|
|
1463
|
+
export function detectSurrogateLocale(value) {
|
|
1464
|
+
return detectPersonPack(value).id;
|
|
1465
|
+
}
|
|
1466
|
+
// ── Public API ─────────────────────────────────────────────
|
|
1467
|
+
/**
|
|
1468
|
+
* Generate a surrogate for one entity. Pure and deterministic in
|
|
1469
|
+
* (ctx.salt, type, original, attempt); bump `attempt` to re-derive after
|
|
1470
|
+
* a collision.
|
|
1471
|
+
*/
|
|
1472
|
+
export function generateSurrogate(original, type, ctx, attempt = 0) {
|
|
1473
|
+
const rand = rngFor(ctx.salt, type, original, attempt);
|
|
1474
|
+
switch (type) {
|
|
1475
|
+
case 'PERSON': return generatePerson(original, rand);
|
|
1476
|
+
case 'EMAIL': return generateEmail(original, ctx, rand);
|
|
1477
|
+
case 'DATE': return generateDate(original, ctx, rand, attempt);
|
|
1478
|
+
case 'PHONE': return generatePhone(original, rand);
|
|
1479
|
+
case 'IBAN': return generateIban(original, rand);
|
|
1480
|
+
case 'SSN': return generateNationalId(original, rand);
|
|
1481
|
+
case 'CREDIT_CARD': return generateCreditCard(original, rand);
|
|
1482
|
+
case 'IP_ADDRESS': return generateIp(original, rand);
|
|
1483
|
+
case 'ADDRESS': return generateAddress(original, rand);
|
|
1484
|
+
case 'COMPANY': return generateCompany(original, rand);
|
|
1485
|
+
case 'CURRENCY': return sameShapeDigits(original, rand);
|
|
1486
|
+
case 'OTHER': return generateOther(original, rand);
|
|
1487
|
+
default: return sameShape(original, rand);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Collision-safe wrapper: re-derives with a bumped attempt while the
|
|
1492
|
+
* candidate equals the original or `isTaken` reports a clash (another
|
|
1493
|
+
* original value or an already-issued replacement); after bounded retries
|
|
1494
|
+
* it appends digits until unique. Termination is guaranteed because the
|
|
1495
|
+
* taken set is finite.
|
|
1496
|
+
*/
|
|
1497
|
+
export function generateUniqueSurrogate(original, type, ctx, isTaken) {
|
|
1498
|
+
const MAX_ATTEMPTS = 8;
|
|
1499
|
+
let candidate = '';
|
|
1500
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
1501
|
+
candidate = generateSurrogate(original, type, ctx, attempt);
|
|
1502
|
+
if (candidate !== original && !isTaken(candidate))
|
|
1503
|
+
return candidate;
|
|
1504
|
+
}
|
|
1505
|
+
for (let n = 2;; n++) {
|
|
1506
|
+
const suffixed = `${candidate}${n}`;
|
|
1507
|
+
if (suffixed !== original && !isTaken(suffixed))
|
|
1508
|
+
return suffixed;
|
|
1509
|
+
}
|
|
1510
|
+
}
|