diff --git a/data/paranames/EXTRACTION.md b/data/paranames/EXTRACTION.md
new file mode 100644
index 0000000..0de7820
--- /dev/null
+++ b/data/paranames/EXTRACTION.md
@@ -0,0 +1,88 @@
+# Procédure d'extraction — gazetteer paranames
+
+## Vue d'ensemble
+
+Le script `scripts/build_paranames_gazetteer.py` télécharge le dataset
+paranames depuis HuggingFace, filtre les entités de type PER, normalise
+les noms (NFKD UPPERCASE A-Z) et produit deux gazetteers compressés.
+
+## Pré-requis
+
+- Python ≥ 3.10
+- Venv du projet activé : `source .venv/bin/activate`
+- Paquets : `datasets`, `huggingface_hub`, `pyarrow`, `pandas`
+ (déjà présents dans `requirements.txt`).
+- Connexion réseau pour le premier téléchargement (~1.33 GB).
+- ~3 GB de cache HuggingFace disponibles.
+- ~1 GB de RAM (le script lit le parquet par batches de 64 K lignes).
+
+## Lancement
+
+```bash
+cd /home/dom/ai/anonymisation
+source .venv/bin/activate
+python scripts/build_paranames_gazetteer.py
+```
+
+Options :
+- `--hf-cache /chemin` : forcer un cache custom (défaut : `~/.cache/huggingface`).
+- `--limit N` : ne traiter que N lignes (debug uniquement).
+
+## Étapes internes du script
+
+1. **Téléchargement** via `huggingface_hub.hf_hub_download` du parquet
+ `data/train.parquet` du repo `imvladikon/paranames`. Le cache HF est
+ réutilisé (idempotent).
+2. **Chargement** du BDPM stop-words (`data/bdpm/medicaments_stopwords.txt`,
+ 7 312 tokens normalisés en UPPER A-Z) pour filtrer les noms qui sont en
+ fait des médicaments.
+3. **Itération par batches** (`pyarrow.parquet.ParquetFile.iter_batches`)
+ sur les colonnes `name` et `type` uniquement. Filtre `type == "PER"`.
+4. **Split** de chaque `name` sur espaces et séparateurs courants
+ (`SPLIT_CHARS`).
+5. **Heuristique nom/prénom** :
+ - dernier token → **nom de famille candidat**
+ - tokens précédents → **prénoms candidats**
+ - cas mononyme (1 seul token) : considéré comme nom de famille.
+6. **Normalisation** : NFKD → strip diacritiques → UPPER → conserver
+ uniquement A-Z (chars latins de base).
+7. **Filtres anti-bruit** :
+ - longueur ≥ 3 caractères
+ - longueur ≤ 25 caractères
+ - non présent dans la BDPM stop-words.
+8. **Écriture** triée alphabétique en `.txt.gz` compresslevel=9.
+
+## Volumes attendus (ordre de grandeur)
+
+- Lignes parquet totales : ~124 M
+- Lignes PER après filtre : ~82 M
+- Noms famille uniques (après dédup + normalisation) : quelques centaines
+ de milliers à quelques millions.
+- Prénoms uniques : idem.
+
+## Régénération (mise à jour)
+
+Si une nouvelle version de paranames est publiée, supprimer le cache HF
+correspondant :
+
+```bash
+rm -rf ~/.cache/huggingface/datasets--imvladikon--paranames/
+python scripts/build_paranames_gazetteer.py
+```
+
+ou supprimer simplement les `.txt.gz` cibles et relancer (le download
+réutilise le cache si la version est inchangée).
+
+## Vérification rapide
+
+```bash
+zcat data/paranames/noms_famille_world.txt.gz | wc -l
+zcat data/paranames/prenoms_world.txt.gz | wc -l
+zcat data/paranames/noms_famille_world.txt.gz | grep -E "^(OYARCABAL|EJNAINI|NGUYEN|SCHMIDT|OBAMA)$"
+```
+
+## Licence
+
+paranames est sous **CC BY 4.0**. Les fichiers dérivés (`*.txt.gz`)
+héritent de cette licence et doivent être redistribués avec attribution
+(voir README.md).
diff --git a/data/paranames/README.md b/data/paranames/README.md
new file mode 100644
index 0000000..a2b3719
--- /dev/null
+++ b/data/paranames/README.md
@@ -0,0 +1,64 @@
+# data/paranames — Gazetteers de noms mondiaux
+
+Issu de [paranames](https://github.com/bltlab/paranames) v2024.05.07.0,
+sous licence **CC BY 4.0**.
+
+## Citation
+
+> Sälevä, J., & Lignos, C. (2024). *ParaNames 1.0: Creating an Entity Name
+> Corpus for 400+ Languages using Wikidata.* In Proceedings of LREC-COLING
+> 2024.
+
+Lien :
+
+## Contenu
+
+| Fichier | Description |
+|----------------------------------|--------------------------------------------------------------------|
+| `noms_famille_world.txt.gz` | Noms de famille mondiaux (UPPERCASE, NFKD sans diacritiques, A-Z). |
+| `prenoms_world.txt.gz` | Prénoms mondiaux (UPPERCASE, NFKD sans diacritiques, A-Z). |
+| `EXTRACTION.md` | Procédure reproductible d'extraction. |
+
+Les deux fichiers sont triés alphabétiquement, encodés UTF-8, compressés gzip
+niveau 9. Une entrée par ligne.
+
+## Régénération
+
+```bash
+python scripts/build_paranames_gazetteer.py
+```
+
+Le script est **idempotent** : relance = même résultat. Le cache HuggingFace
+(~/.cache/huggingface/) évite tout re-téléchargement.
+
+Voir [EXTRACTION.md](EXTRACTION.md) pour le détail de la procédure.
+
+## Source amont
+
+- **Repo** :
+- **Mirror HuggingFace** :
+- **Données** : `data/train.parquet` (~1.33 GB, 124 M lignes — noms parallèles
+ de plus de 12 M d'entités nommées dans 400+ langues, extraits de Wikidata).
+- **Filtrage appliqué** : seuls les `type == "PER"` (personnes) sont retenus.
+
+## Utilisation dans l'anonymiseur
+
+Ces gazetteers complètent les listes INSEE (françaises) pour couvrir les noms
+**internationaux** (basques, vietnamiens, arabes, asiatiques, africains…)
+fréquents dans les documents médicaux français des CHU et hôpitaux de
+territoires multi-ethniques (La Réunion, Antilles, métropole).
+
+Charger en lecture :
+
+```python
+import gzip
+with gzip.open("data/paranames/noms_famille_world.txt.gz", "rt", encoding="utf-8") as f:
+ NOMS_WORLD = {line.strip() for line in f if line.strip()}
+```
+
+## Attribution dans l'application
+
+L'écran « À propos » de l'application Pseudonymisation mentionne :
+
+> Gazetteers de noms mondiaux issus de paranames (Sälevä & Lignos, 2024)
+> sous licence CC BY 4.0.
diff --git a/data/paranames/noms_famille_world.txt.gz b/data/paranames/noms_famille_world.txt.gz
new file mode 100644
index 0000000..df0b8a1
Binary files /dev/null and b/data/paranames/noms_famille_world.txt.gz differ
diff --git a/data/paranames/prenoms_world.txt.gz b/data/paranames/prenoms_world.txt.gz
new file mode 100644
index 0000000..bbb92f1
Binary files /dev/null and b/data/paranames/prenoms_world.txt.gz differ
diff --git a/scripts/build_paranames_gazetteer.py b/scripts/build_paranames_gazetteer.py
new file mode 100644
index 0000000..20ea2c2
--- /dev/null
+++ b/scripts/build_paranames_gazetteer.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+"""Build gazetteer paranames pour anonymisation médicale FR.
+
+Source : https://github.com/bltlab/paranames (CC BY 4.0)
+Citation : Sälevä & Lignos, ParaNames 1.0, LREC-COLING 2024.
+
+Workflow :
+1. Télécharge ``data/train.parquet`` (~1.33 GB) du repo HF
+ ``imvladikon/paranames`` via ``huggingface_hub.hf_hub_download`` (cache
+ persistant, pas de re-téléchargement si déjà présent).
+2. Itère sur le fichier parquet **par batches** avec pyarrow (RAM constante,
+ < 500 Mo de pointe).
+3. Filtre ``type == "PER"`` (personnes uniquement).
+4. Pour chaque ``name`` :
+ - split par espace et séparateurs courants
+ - dernier token UPPER NFKD → candidat **nom de famille**
+ - tokens précédents UPPER NFKD → candidats **prénoms**
+5. Normalisation NFKD + uppercase + suppression diacritiques + ASCII A-Z.
+6. Filtrage anti-bruit :
+ - longueur ≥ 3
+ - exclusion des stop-words médicaments BDPM
+7. Sortie : 2 fichiers ``.txt.gz`` triés alphabétiquement, encodés UTF-8.
+
+Idempotent : relance = même résultat. Cache HF réutilisé si présent.
+
+Usage :
+ python scripts/build_paranames_gazetteer.py
+ python scripts/build_paranames_gazetteer.py --hf-cache /tmp/hf_paranames
+ python scripts/build_paranames_gazetteer.py --limit 200000 # debug
+"""
+from __future__ import annotations
+
+import argparse
+import gzip
+import os
+import sys
+import time
+import unicodedata
+from pathlib import Path
+from typing import Iterable, Iterator
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DATA_DIR = REPO_ROOT / "data" / "paranames"
+BDPM_STOPWORDS = REPO_ROOT / "data" / "bdpm" / "medicaments_stopwords.txt"
+INSEE_NOMS = REPO_ROOT / "data" / "insee" / "noms_famille_france.txt"
+
+OUT_NOMS = DATA_DIR / "noms_famille_world.txt.gz"
+OUT_PRENOMS = DATA_DIR / "prenoms_world.txt.gz"
+
+HF_REPO_ID = "imvladikon/paranames"
+HF_PARQUET_PATH = "data/train.parquet"
+
+MIN_TOKEN_LEN = 3
+MAX_TOKEN_LEN = 25
+
+# Caractères à découper en plus de l'espace (séparateurs internes).
+SPLIT_CHARS = " \t /,;:|()[]{}\"'`«»–—−.·"
+SPLIT_TABLE = str.maketrans({c: " " for c in SPLIT_CHARS})
+
+
+def normalize(token: str) -> str:
+ """NFKD → uppercase → drop diacritics → A-Z only."""
+ if not token:
+ return ""
+ nfkd = unicodedata.normalize("NFKD", token)
+ no_acc = "".join(c for c in nfkd if not unicodedata.combining(c))
+ up = no_acc.upper()
+ return "".join(c for c in up if "A" <= c <= "Z")
+
+
+def load_stopwords() -> set[str]:
+ stop: set[str] = set()
+ if not BDPM_STOPWORDS.exists():
+ print(f"[WARN] {BDPM_STOPWORDS} introuvable — pas de filtrage BDPM.")
+ return stop
+ with BDPM_STOPWORDS.open("r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ n = normalize(line)
+ if n:
+ stop.add(n)
+ print(f"[INFO] BDPM stop-words : {len(stop):,} entrées.")
+ return stop
+
+
+def download_parquet(cache_dir: str) -> Path:
+ """Télécharge (ou récupère du cache) le parquet paranames."""
+ try:
+ from huggingface_hub import hf_hub_download # type: ignore
+ except ImportError as e:
+ sys.exit(
+ "[FATAL] `huggingface_hub` requis. Install : pip install huggingface_hub\n"
+ f"Erreur : {e}"
+ )
+ try:
+ path = hf_hub_download(
+ repo_id=HF_REPO_ID,
+ filename=HF_PARQUET_PATH,
+ repo_type="dataset",
+ cache_dir=cache_dir,
+ )
+ except Exception as e:
+ sys.exit(
+ f"[FATAL] Impossible de télécharger {HF_REPO_ID}:{HF_PARQUET_PATH}\n"
+ f" Vérifier réseau / cache HF / accès huggingface.co\n"
+ f" Erreur : {e}"
+ )
+ p = Path(path)
+ print(f"[INFO] Parquet local : {p} ({p.stat().st_size/1e9:.2f} GB)")
+ return p
+
+
+def iter_per_names(parquet_path: Path, limit: int | None) -> Iterator[str]:
+ """Stream les noms PER du parquet par row-groups (RAM constante)."""
+ try:
+ import pyarrow.parquet as pq # type: ignore
+ except ImportError as e:
+ sys.exit(f"[FATAL] pyarrow requis. Install : pip install pyarrow\n{e}")
+
+ pf = pq.ParquetFile(parquet_path)
+ print(
+ f"[INFO] Parquet : {pf.num_row_groups} row groups, "
+ f"{pf.metadata.num_rows:,} lignes totales."
+ )
+ count_in = 0
+ count_per = 0
+ # On ne lit que les colonnes utiles
+ for batch in pf.iter_batches(batch_size=65536, columns=["name", "type"]):
+ names = batch.column("name").to_pylist()
+ types = batch.column("type").to_pylist()
+ for nm, tp in zip(names, types):
+ count_in += 1
+ if tp != "PER":
+ continue
+ if nm:
+ count_per += 1
+ yield nm
+ if limit is not None and count_in >= limit:
+ print(f"[INFO] Limite atteinte ({limit}).")
+ print(f"[INFO] Total lignes lues : {count_in:,}")
+ print(f"[INFO] Total PER conservés : {count_per:,}")
+ return
+ if count_in % 1_000_000 < 65536:
+ print(
+ f"[PROGRESS] {count_in:>11,} lignes lues, "
+ f"{count_per:>11,} PER."
+ )
+ print(f"[INFO] Total lignes lues : {count_in:,}")
+ print(f"[INFO] Total PER conservés : {count_per:,}")
+
+
+def split_name(name: str) -> tuple[list[str], str | None]:
+ clean = name.translate(SPLIT_TABLE)
+ tokens = [t for t in clean.split() if t]
+ if not tokens:
+ return [], None
+ if len(tokens) == 1:
+ return [], tokens[0]
+ return tokens[:-1], tokens[-1]
+
+
+def good_token(tok: str, stop: set[str]) -> bool:
+ if not tok:
+ return False
+ if len(tok) < MIN_TOKEN_LEN or len(tok) > MAX_TOKEN_LEN:
+ return False
+ if tok in stop:
+ return False
+ return True
+
+
+def write_sorted_gz(path: Path, items: Iterable[str]) -> int:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ data = sorted(items)
+ with gzip.open(path, "wt", encoding="utf-8", compresslevel=9) as f:
+ for s in data:
+ f.write(s)
+ f.write("\n")
+ return len(data)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+ parser.add_argument(
+ "--hf-cache",
+ default=os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")),
+ help="Répertoire cache HuggingFace (par défaut : ~/.cache/huggingface).",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ help="Limiter le nombre de lignes lues (debug).",
+ )
+ args = parser.parse_args()
+
+ t0 = time.time()
+ print(f"[INFO] Cache HF : {args.hf_cache}")
+
+ stopwords = load_stopwords()
+ parquet_path = download_parquet(args.hf_cache)
+
+ noms: set[str] = set()
+ prenoms: set[str] = set()
+ bad_kept = 0
+
+ for raw_name in iter_per_names(parquet_path, limit=args.limit):
+ prens, fam = split_name(raw_name)
+ if fam is not None:
+ n = normalize(fam)
+ if good_token(n, stopwords):
+ noms.add(n)
+ else:
+ bad_kept += 1
+ for p in prens:
+ n = normalize(p)
+ if good_token(n, stopwords):
+ prenoms.add(n)
+
+ print(f"[INFO] Noms de famille uniques (post-filtre) : {len(noms):,}")
+ print(f"[INFO] Prénoms uniques (post-filtre) : {len(prenoms):,}")
+ print(f"[INFO] Tokens rejetés (longueur/stop/vide) : {bad_kept:,}")
+
+ n_noms = write_sorted_gz(OUT_NOMS, noms)
+ n_pren = write_sorted_gz(OUT_PRENOMS, prenoms)
+ print(
+ f"[OK] {OUT_NOMS} — {n_noms:,} entrées "
+ f"({OUT_NOMS.stat().st_size/1e6:.1f} Mo)"
+ )
+ print(
+ f"[OK] {OUT_PRENOMS} — {n_pren:,} entrées "
+ f"({OUT_PRENOMS.stat().st_size/1e6:.1f} Mo)"
+ )
+
+ if INSEE_NOMS.exists():
+ insee_noms = {
+ line.strip().upper()
+ for line in INSEE_NOMS.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ }
+ inter = noms & insee_noms
+ cov = 100 * len(inter) / max(1, len(insee_noms))
+ print(
+ f"[INFO] Intersection noms_famille_world ∩ INSEE_FR : "
+ f"{len(inter):,} ({cov:.1f}% de couverture INSEE)"
+ )
+
+ print(f"[DONE] Temps total : {time.time()-t0:.1f}s")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())