backup: WIP Windows avant repart propre (GUI core installer splash spec)

This commit is contained in:
2026-06-05 12:11:21 +02:00
parent 012445755a
commit b8c9c411dc
27 changed files with 4628 additions and 775 deletions

5
.gitignore vendored
View File

@@ -6,10 +6,12 @@ __pycache__/
*.egg
dist/
build/
release/
*.whl
# === Virtual environments ===
.venv/
.venv_build_win/
venv/
venv_*/
env/
@@ -66,6 +68,9 @@ Thumbs.db
# === Secrets ===
.env
*.env
*.pfx
*.p12
build_signing.local.ps1
credentials.json
token.pickle

File diff suppressed because it is too large Load Diff

406
admin_rules.py Normal file
View File

@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""
Helpers partagés pour les règles d'administration.
"""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any
import re
try:
import yaml
except Exception:
yaml = None
from config_defaults import CONFIG_DIR, deep_merge_dict
DEFAULT_ADMIN_RULES_CONFIG_PATH = CONFIG_DIR / "admin_rules.default.yml"
RUNTIME_ADMIN_RULES_CONFIG_PATH = CONFIG_DIR / "admin_rules.yml"
_RUNTIME_ADMIN_RULES_OVERLAY_TEXT = """# Surcharge locale des règles d'administration.
# Ce fichier est optionnel. Les règles actives de config/admin_rules.default.yml
# restent valides tant qu'aucune surcharge locale n'est définie ici.
#
# Exemple :
# version: 1
# rules:
# - id: rule_identifier_1234567
# status: active
# governance:
# approved_by: responsable_qualite
version: 1
rules: []
"""
_FALLBACK_DEFAULT_ADMIN_RULES_DICT: dict[str, Any] = {
"version": 1,
"rules": [],
}
def _is_non_empty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def read_default_admin_rules_text() -> str:
try:
return DEFAULT_ADMIN_RULES_CONFIG_PATH.read_text(encoding="utf-8")
except Exception:
return "version: 1\nrules: []\n"
def read_runtime_admin_rules_overlay_text() -> str:
return _RUNTIME_ADMIN_RULES_OVERLAY_TEXT
def load_default_admin_rules_dict() -> dict[str, Any]:
if yaml is None:
return deepcopy(_FALLBACK_DEFAULT_ADMIN_RULES_DICT)
try:
loaded = yaml.safe_load(read_default_admin_rules_text()) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return deepcopy(_FALLBACK_DEFAULT_ADMIN_RULES_DICT)
def load_runtime_admin_rules_overlay_dict(path: Path | None = None) -> dict[str, Any]:
target = Path(path) if path is not None else RUNTIME_ADMIN_RULES_CONFIG_PATH
if not target.exists() or yaml is None:
return {}
try:
loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return {}
def _merge_rules_by_id(base_rules: list[dict[str, Any]], overlay_rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
merged: list[dict[str, Any]] = [deepcopy(rule) for rule in base_rules]
index_by_id = {
rule.get("id"): idx
for idx, rule in enumerate(merged)
if isinstance(rule, dict) and _is_non_empty_string(rule.get("id"))
}
for overlay_rule in overlay_rules:
if not isinstance(overlay_rule, dict):
continue
rule_id = overlay_rule.get("id")
if _is_non_empty_string(rule_id) and rule_id in index_by_id:
idx = index_by_id[rule_id]
merged[idx] = deep_merge_dict(merged[idx], overlay_rule)
else:
merged.append(deepcopy(overlay_rule))
if _is_non_empty_string(rule_id):
index_by_id[rule_id] = len(merged) - 1
return merged
def merge_admin_rules_dict(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
merged = deep_merge_dict(base, {k: v for k, v in overlay.items() if k != "rules"})
merged["rules"] = _merge_rules_by_id(base.get("rules", []) or [], overlay.get("rules", []) or [])
return merged
def load_effective_admin_rules_dict(path: Path | None = None) -> dict[str, Any]:
return merge_admin_rules_dict(
load_default_admin_rules_dict(),
load_runtime_admin_rules_overlay_dict(path),
)
def ensure_runtime_admin_rules_config(path: Path | None = None) -> Path:
target = Path(path) if path is not None else RUNTIME_ADMIN_RULES_CONFIG_PATH
if not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(read_runtime_admin_rules_overlay_text(), encoding="utf-8")
return target
def _dedupe_keep_order(values: list[str]) -> list[str]:
seen: set[str] = set()
output: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
output.append(value)
return output
def generate_rule_variants(rule: dict[str, Any], limit: int = 12) -> list[str]:
rule_type = rule.get("type")
match = rule.get("match") or {}
normalization = rule.get("normalization") or {}
variants: list[str] = []
if rule_type in {"exact_term", "preserve_phrase"}:
exact_value = str(match.get("exact_value", "")).strip()
return [exact_value] if exact_value else []
if rule_type == "normalized_identifier":
canonical = str(match.get("canonical_value", "")).strip()
prefixes = normalization.get("accepted_prefixes") or []
separators = normalization.get("prefix_value_separators") or [" "]
if normalization.get("allow_bare_value", False) and canonical:
variants.append(canonical)
for prefix in prefixes:
for separator in separators:
variants.append(f"{prefix}{separator}{canonical}")
if normalization.get("multiline", False):
variants.append(f"{prefix}\n{canonical}")
return _dedupe_keep_order(variants)[:limit]
if rule_type == "contextual_identifier":
canonical = str(match.get("canonical_value", "")).strip()
prefixes = match.get("context_prefixes") or []
separators = match.get("context_separators") or [": ", ":"]
for prefix in prefixes:
for separator in separators:
variants.append(f"{prefix}{separator}{canonical}")
if (rule.get("normalization") or {}).get("multiline", False):
variants.append(f"{prefix}\n{canonical}")
variants.append(f"{prefix} :\n{canonical}")
return _dedupe_keep_order(variants)[:limit]
return []
VALID_TYPES = {
"exact_term",
"normalized_identifier",
"contextual_identifier",
"preserve_phrase",
}
VALID_ACTIONS = {"mask", "preserve"}
VALID_STATUSES = {"draft", "candidate", "approved", "active", "disabled", "retired"}
VALID_ENVIRONMENTS = {"test", "staging", "prod"}
VALID_SECTIONS = {"narrative", "structured", "table", "header", "footer"}
def validate_rules_config(data: dict[str, Any]) -> list[str]:
errors: list[str] = []
version = data.get("version")
if not isinstance(version, int) or version < 1:
errors.append("`version` doit etre un entier >= 1.")
rules = data.get("rules")
if not isinstance(rules, list):
errors.append("`rules` doit etre une liste.")
return errors
seen_ids: set[str] = set()
for index, rule in enumerate(rules):
prefix = f"rules[{index}]"
if not isinstance(rule, dict):
errors.append(f"{prefix}: chaque regle doit etre un mapping.")
continue
rule_id = rule.get("id")
if not _is_non_empty_string(rule_id):
errors.append(f"{prefix}: `id` est obligatoire.")
elif rule_id in seen_ids:
errors.append(f"{prefix}: `id` duplique `{rule_id}`.")
else:
seen_ids.add(rule_id)
if not _is_non_empty_string(rule.get("label")):
errors.append(f"{prefix}: `label` est obligatoire.")
rule_type = rule.get("type")
if rule_type not in VALID_TYPES:
errors.append(f"{prefix}: `type` invalide.")
action = rule.get("action")
if action not in VALID_ACTIONS:
errors.append(f"{prefix}: `action` invalide.")
status = rule.get("status")
if status not in VALID_STATUSES:
errors.append(f"{prefix}: `status` invalide.")
if action == "mask" and not _is_non_empty_string(rule.get("placeholder")):
errors.append(f"{prefix}: `placeholder` est obligatoire pour une regle de masquage.")
match = rule.get("match")
if not isinstance(match, dict):
errors.append(f"{prefix}: `match` doit etre un mapping.")
match = {}
normalization = rule.get("normalization") or {}
if normalization and not isinstance(normalization, dict):
errors.append(f"{prefix}: `normalization` doit etre un mapping.")
normalization = {}
scope = rule.get("scope")
if not isinstance(scope, dict):
errors.append(f"{prefix}: `scope` doit etre un mapping.")
scope = {}
governance = rule.get("governance")
if not isinstance(governance, dict):
errors.append(f"{prefix}: `governance` doit etre un mapping.")
governance = {}
document_families = scope.get("document_families")
if not isinstance(document_families, list) or not document_families:
errors.append(f"{prefix}: `scope.document_families` doit etre une liste non vide.")
environments = scope.get("environments")
if not isinstance(environments, list) or not environments:
errors.append(f"{prefix}: `scope.environments` doit etre une liste non vide.")
else:
invalid_envs = [value for value in environments if value not in VALID_ENVIRONMENTS]
if invalid_envs:
errors.append(f"{prefix}: environnements invalides: {', '.join(invalid_envs)}.")
sections = scope.get("sections")
if not isinstance(sections, list) or not sections:
errors.append(f"{prefix}: `scope.sections` doit etre une liste non vide.")
else:
invalid_sections = [value for value in sections if value not in VALID_SECTIONS]
if invalid_sections:
errors.append(f"{prefix}: sections invalides: {', '.join(invalid_sections)}.")
if not _is_non_empty_string(governance.get("owner")):
errors.append(f"{prefix}: `governance.owner` est obligatoire.")
if not _is_non_empty_string(governance.get("justification")):
errors.append(f"{prefix}: `governance.justification` est obligatoire.")
if not _is_non_empty_string(governance.get("created_at")):
errors.append(f"{prefix}: `governance.created_at` est obligatoire.")
tests = governance.get("tests")
if not isinstance(tests, dict):
errors.append(f"{prefix}: `governance.tests` doit etre un mapping.")
tests = {}
required_case_ids = tests.get("required_case_ids")
if not isinstance(required_case_ids, list) or not required_case_ids:
errors.append(f"{prefix}: `governance.tests.required_case_ids` doit etre une liste non vide.")
if rule_type == "exact_term":
if not _is_non_empty_string(match.get("exact_value")):
errors.append(f"{prefix}: `match.exact_value` est obligatoire pour `exact_term`.")
if rule_type == "preserve_phrase":
if action != "preserve":
errors.append(f"{prefix}: `preserve_phrase` doit utiliser `action: preserve`.")
if not _is_non_empty_string(match.get("exact_value")):
errors.append(f"{prefix}: `match.exact_value` est obligatoire pour `preserve_phrase`.")
if rule_type == "normalized_identifier":
if not _is_non_empty_string(match.get("canonical_value")):
errors.append(f"{prefix}: `match.canonical_value` est obligatoire pour `normalized_identifier`.")
if rule_type == "contextual_identifier":
if not _is_non_empty_string(match.get("canonical_value")):
errors.append(f"{prefix}: `match.canonical_value` est obligatoire pour `contextual_identifier`.")
context_prefixes = match.get("context_prefixes")
if not isinstance(context_prefixes, list) or not context_prefixes:
errors.append(f"{prefix}: `match.context_prefixes` doit etre une liste non vide.")
if status == "active" and governance.get("review_required_for_activation", False):
if not _is_non_empty_string(governance.get("approved_by")):
errors.append(f"{prefix}: `governance.approved_by` est obligatoire pour une regle active.")
return errors
def _placeholder_to_kind(placeholder: str) -> str:
if isinstance(placeholder, str) and placeholder.startswith("[") and placeholder.endswith("]"):
return placeholder[1:-1]
return "MASK"
def _literal_to_pattern(text: str, multiline: bool) -> str:
parts: list[str] = []
for char in text:
if char == " ":
parts.append(r"\s*" if multiline else r"[ \t]*")
elif char == "\n":
parts.append(r"\s*" if multiline else r"\n")
else:
parts.append(re.escape(char))
return "".join(parts)
def _compile_identifier_rule(rule: dict[str, Any]) -> dict[str, Any]:
rule_type = rule.get("type")
normalization = rule.get("normalization") or {}
multiline = bool(normalization.get("multiline", False))
flags = re.IGNORECASE if normalization.get("case_insensitive", False) else 0
value = str((rule.get("match") or {}).get("canonical_value", "")).strip()
value_rx = re.escape(value)
boundary_before = r"(?<![A-Za-z0-9])"
boundary_after = r"(?![A-Za-z0-9])"
patterns = []
if rule_type == "normalized_identifier":
if normalization.get("allow_bare_value", False):
patterns.append(re.compile(rf"{boundary_before}({value_rx}){boundary_after}", flags | re.MULTILINE))
prefixes = normalization.get("accepted_prefixes") or []
separators = normalization.get("prefix_value_separators") or [" "]
else:
prefixes = (rule.get("match") or {}).get("context_prefixes") or []
separators = (rule.get("match") or {}).get("context_separators") or [": ", ":"]
gap = r"\s*" if multiline else r"[ \t]*"
for prefix in prefixes:
prefix_rx = _literal_to_pattern(str(prefix), multiline)
for separator in separators:
separator_rx = _literal_to_pattern(str(separator), multiline)
patterns.append(
re.compile(
rf"{boundary_before}{prefix_rx}{separator_rx}{gap}({value_rx}){boundary_after}",
flags | re.MULTILINE,
)
)
return {
"id": rule.get("id"),
"type": rule_type,
"kind": _placeholder_to_kind(rule.get("placeholder", "[MASK]")),
"placeholder": rule.get("placeholder", "[MASK]"),
"patterns": patterns,
}
def compile_active_admin_rules(data: dict[str, Any]) -> dict[str, Any]:
compiled = {
"force_mask_terms": [],
"whitelist_phrases": [],
"detection_rules": [],
"active_rule_ids": [],
}
for rule in data.get("rules", []) or []:
if not isinstance(rule, dict):
continue
if rule.get("status") != "active":
continue
compiled["active_rule_ids"].append(rule.get("id"))
rule_type = rule.get("type")
action = rule.get("action")
match = rule.get("match") or {}
if rule_type == "exact_term" and action == "mask":
value = str(match.get("exact_value", "")).strip()
if value:
compiled["force_mask_terms"].append(value)
elif rule_type == "preserve_phrase" and action == "preserve":
value = str(match.get("exact_value", "")).strip()
if value:
compiled["whitelist_phrases"].append(value)
elif rule_type in {"normalized_identifier", "contextual_identifier"} and action == "mask":
if _is_non_empty_string(match.get("canonical_value")):
compiled["detection_rules"].append(_compile_identifier_rule(rule))
compiled["force_mask_terms"] = _dedupe_keep_order(compiled["force_mask_terms"])
compiled["whitelist_phrases"] = _dedupe_keep_order(compiled["whitelist_phrases"])
return compiled

91
anonymisation.spec Normal file
View File

@@ -0,0 +1,91 @@
# -*- mode: python ; coding: utf-8 -*-
import os
import sys
block_cipher = None
app_dir = 'C:\\Users\\dom\\ai\\anonymisation'
# Fichiers de données à inclure
datas = [
(os.path.join(app_dir, 'config'), 'config'),
(os.path.join(app_dir, 'data', 'bdpm'), os.path.join('data', 'bdpm')),
(os.path.join(app_dir, 'data', 'finess'), os.path.join('data', 'finess')),
(os.path.join(app_dir, 'data', 'insee'), os.path.join('data', 'insee')),
(os.path.join(app_dir, 'models', 'camembert-bio-deid', 'onnx'), os.path.join('models', 'camembert-bio-deid', 'onnx')),
(os.path.join(app_dir, 'detectors'), 'detectors'),
(os.path.join(app_dir, 'scripts'), 'scripts'),
]
# Modules Python à inclure comme data (importés dynamiquement)
for pyfile in ['anonymizer_core_refactored_onnx.py', 'eds_pseudo_manager.py',
'gliner_manager.py', 'camembert_ner_manager.py',
'Pseudonymisation_Gui_V5.py']:
datas.append((os.path.join(app_dir, pyfile), '.'))
a = Analysis(
[os.path.join(app_dir, 'launcher.py')],
pathex=[app_dir],
binaries=[],
datas=datas,
hiddenimports=[
'anonymizer_core_refactored_onnx',
'eds_pseudo_manager',
'gliner_manager',
'camembert_ner_manager',
'Pseudonymisation_Gui_V5',
'edsnlp',
'edsnlp.pipes',
'edsnlp.pipes.ner',
'edsnlp.pipes.ner.pseudo',
'spacy',
'spacy.lang.fr',
'gliner',
'onnxruntime',
'transformers',
'tokenizers',
'torch',
'pdfplumber',
'ahocorasick',
'sklearn',
'scipy',
'pydantic',
'yaml',
'PIL',
'loguru',
'regex',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='Anonymisation',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False, # Pas de console Windows
icon=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
name='Anonymisation',
)

View File

@@ -1,90 +1,128 @@
import os
block_cipher = None
app_dir = 'C:\\Users\\dom\\ai\\anonymisation'
from pathlib import Path
datas = [
(os.path.join(app_dir, 'config'), 'config'),
(os.path.join(app_dir, 'data', 'bdpm'), os.path.join('data', 'bdpm')),
(os.path.join(app_dir, 'data', 'finess'), os.path.join('data', 'finess')),
(os.path.join(app_dir, 'data', 'insee'), os.path.join('data', 'insee')),
(os.path.join(app_dir, 'models', 'camembert-bio-deid', 'onnx'), os.path.join('models', 'camembert-bio-deid', 'onnx')),
(os.path.join(app_dir, 'detectors'), 'detectors'),
(os.path.join(app_dir, 'scripts'), 'scripts'),
# Assets UI : logo (header + splash), icônes fenêtre, splash image.
# Le launcher et la GUI y accèdent via _asset(name) qui résout sous
# sys._MEIPASS/assets en mode frozen.
(os.path.join(app_dir, 'assets'), 'assets'),
]
# Fichiers directs dans data/ — IMPÉRATIF pour fonctionnement correct du core.
# Sans eux : stop-words/villes/DPI labels/companion blacklist sont des sets vides,
# ce qui dégrade la qualité d'anonymisation et peut masquer/laisser passer des faux-positifs.
for data_file in [
'stopwords_manuels.txt',
'villes_blacklist.txt',
'dpi_labels_blacklist.txt',
'companion_blacklist.txt',
block_cipher = None
project_dir = Path(globals().get("SPECPATH", os.getcwd())).resolve()
def _data_entry(relative_path: str, target_dir: str | None = None):
src = project_dir / relative_path
if not src.exists():
return None
return (str(src), target_dir or relative_path)
datas = []
for relative_path, target_dir in [
("config", "config"),
("data/bdpm", "data/bdpm"),
("data/finess", "data/finess"),
("data/insee", "data/insee"),
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
("detectors", "detectors"),
("scripts", "scripts"),
("assets", "assets"),
]:
src = os.path.join(app_dir, 'data', data_file)
if os.path.exists(src):
datas.append((src, 'data'))
for pyfile in ['anonymizer_core_refactored_onnx.py', 'eds_pseudo_manager.py',
'gliner_manager.py', 'camembert_ner_manager.py',
'Pseudonymisation_Gui_V5.py', 'build_info.py']:
datas.append((os.path.join(app_dir, pyfile), '.'))
entry = _data_entry(relative_path, target_dir)
if entry is not None:
datas.append(entry)
# Fichiers directs sous data/ requis par le core.
for relative_path in [
"data/stopwords_manuels.txt",
"data/villes_blacklist.txt",
"data/dpi_labels_blacklist.txt",
"data/companion_blacklist.txt",
]:
entry = _data_entry(relative_path, "data")
if entry is not None:
datas.append(entry)
hiddenimports = [
"Pseudonymisation_Gui_V5",
"anonymizer_core_refactored_onnx",
"admin_rules",
"config_defaults",
"profile_defaults",
"gui_batch_paths",
"manual_masking",
"pdf_mask_designer",
"format_converter",
"ner_manager_onnx",
"camembert_ner_manager",
"eds_pseudo_manager",
"gliner_manager",
"vlm_manager",
"build_info",
"doctr",
"doctr.io",
"doctr.models",
"doctr.models.detection",
"doctr.models.recognition",
"cv2",
"torchvision",
"edsnlp",
"edsnlp.pipes",
"edsnlp.pipes.ner",
"edsnlp.pipes.ner.pseudo",
"spacy",
"spacy.lang.fr",
"gliner",
"onnxruntime",
"transformers",
"tokenizers",
"torch",
"pdfplumber",
"fitz",
"PIL",
"yaml",
"loguru",
"regex",
"optimum",
"optimum.onnxruntime",
"optimum.pipelines",
"optimum.modeling_base",
"optimum.exporters.onnx",
]
a = Analysis(
[os.path.join(app_dir, 'launcher.py')],
pathex=[app_dir],
[str(project_dir / "launcher.py")],
pathex=[str(project_dir)],
datas=datas,
hiddenimports=[
'anonymizer_core_refactored_onnx', 'eds_pseudo_manager',
'gliner_manager', 'camembert_ner_manager', 'Pseudonymisation_Gui_V5',
'edsnlp', 'edsnlp.pipes', 'edsnlp.pipes.ner', 'edsnlp.pipes.ner.pseudo',
'spacy', 'spacy.lang.fr', 'gliner', 'onnxruntime',
'transformers', 'tokenizers', 'torch', 'pdfplumber',
'ahocorasick', 'sklearn', 'scipy', 'pydantic', 'yaml', 'PIL',
'loguru', 'regex',
# optimum : utilisé par ner_manager_onnx.py (fallback NER legacy).
# Sans ça, la GUI affiche "NER indisponible : optimum.onnxruntime introuvable"
# si EDS-Pseudo échoue. Le pipeline principal (CamemBERT-bio ONNX +
# EDS-Pseudo + GLiNER) n'en dépend pas — mais l'absence du hiddenimport
# crée un message d'erreur cosmétique gênant.
'optimum', 'optimum.onnxruntime', 'optimum.pipelines',
'optimum.modeling_base', 'optimum.exporters.onnx',
],
hiddenimports=hiddenimports,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# Splash natif PyInstaller : image affichée AU LANCEMENT DE L'EXE,
# avant même que Python démarre. Couvre les ~15-30 s de décompression
# du bundle --onefile dans %TEMP% qui laissaient l'écran vide auparavant.
# Le launcher ferme le splash via pyi_splash.close() une fois la GUI prête.
splash = Splash(
os.path.join(app_dir, 'assets', 'splash.png'),
str(project_dir / "assets" / "splash.png"),
binaries=a.binaries,
datas=a.datas,
# Texte dynamique PyInstaller positionné dans la zone libre du PNG
# (y=170-235). text_pos correspond au coin haut-gauche du texte.
text_pos=(60, 195),
text_size=10,
text_color='white',
text_color="white",
minify_script=True,
always_on_top=False,
)
exe = EXE(
pyz, a.scripts,
splash, # image affichée immédiatement
splash.binaries, # bootloader splash
a.binaries, a.zipfiles, a.datas, [],
name='Anonymisation',
pyz,
a.scripts,
splash,
splash.binaries,
a.binaries,
a.zipfiles,
a.datas,
[],
name="Anonymisation",
debug=False,
strip=False,
upx=False,
console=False,
# Icône du fichier .exe visible dans l'Explorateur Windows et la taskbar
# (dérivée du logo aivanonym, multi-résolution 16→256 dans le .ico).
icon=os.path.join(app_dir, 'assets', 'icons', 'app.ico'),
icon=str(project_dir / "assets" / "icons" / "app.ico"),
)

File diff suppressed because it is too large Load Diff

17
build_signing.example.ps1 Normal file
View File

@@ -0,0 +1,17 @@
# Copier ce fichier en build_signing.local.ps1 sur la machine Windows de build.
# Ne pas versionner build_signing.local.ps1 : il peut contenir des secrets.
# Active la signature Authenticode pendant build_windows_oneclick.bat.
$BuildSigningEnabled = $true
# Option recommandée si le certificat est installé dans le magasin Windows.
# Récupérer l'empreinte avec :
# Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert
$BuildSigningCertThumbprint = "REMPLACER_PAR_L_EMPREINTE_DU_CERTIFICAT"
# Alternative si vous disposez d'un fichier PFX.
# $BuildSigningPfxPath = "C:\chemin\certificat-code-signing.pfx"
# $BuildSigningPfxPassword = "MOT_DE_PASSE_PFX"
# Serveur d'horodatage RFC 3161.
$BuildSigningTimestampServer = "http://timestamp.digicert.com"

View File

@@ -0,0 +1,28 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
set "PS_SCRIPT=%SCRIPT_DIR%scripts\build_windows_oneclick.ps1"
if not exist "%PS_SCRIPT%" (
echo Script PowerShell introuvable : %PS_SCRIPT%
pause
exit /b 1
)
echo Lancement du build Windows avec installateur...
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%"
set "EXITCODE=%ERRORLEVEL%"
if not "%EXITCODE%"=="0" (
echo.
echo Le build installateur a echoue. Code retour : %EXITCODE%
pause
exit /b %EXITCODE%
)
echo.
echo Build installateur termine avec succes.
echo Sortie attendue : release\Anonymisation-Setup.exe
pause
exit /b 0

View File

@@ -0,0 +1,27 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
set "PS_SCRIPT=%SCRIPT_DIR%scripts\build_windows_oneclick.ps1"
if not exist "%PS_SCRIPT%" (
echo Script PowerShell introuvable : %PS_SCRIPT%
pause
exit /b 1
)
echo Lancement du build Windows one-click...
powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%"
set "EXITCODE=%ERRORLEVEL%"
if not "%EXITCODE%"=="0" (
echo.
echo Le build a echoue. Code retour : %EXITCODE%
pause
exit /b %EXITCODE%
)
echo.
echo Build termine avec succes.
pause
exit /b 0

309
build_windows_oneclick.ps1 Normal file
View File

@@ -0,0 +1,309 @@
param(
[switch]$SkipZip,
[switch]$SkipRequirements,
[switch]$Sign,
[string]$CertThumbprint,
[string]$PfxPath,
[string]$PfxPassword,
[string]$TimestampServer = "http://timestamp.digicert.com"
)
$ErrorActionPreference = "Stop"
$script:SignatureSummary = "Non signé"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "=== $Message ===" -ForegroundColor Cyan
}
function Require-Path {
param(
[string]$PathValue,
[string]$Label
)
if (-not (Test-Path $PathValue)) {
throw "$Label introuvable: $PathValue"
}
}
function Invoke-BootstrapPython {
param([string[]]$Arguments)
if ($script:PythonBootstrap[0] -eq "py") {
& py $script:PythonBootstrap[1] @Arguments
} else {
& $script:PythonBootstrap[0] @Arguments
}
}
function Resolve-BootstrapPython {
if (Get-Command py -ErrorAction SilentlyContinue) {
try {
& py -3.11 --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("py", "-3.11")
}
} catch {}
try {
& py -3 --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("py", "-3")
}
} catch {}
}
if (Get-Command python -ErrorAction SilentlyContinue) {
& python --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("python")
}
}
throw "Python introuvable sur la machine de build Windows."
}
function Resolve-SignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$programFilesX86 = ${env:ProgramFiles(x86)}
if ($programFilesX86) {
$kitsRoot = Join-Path $programFilesX86 "Windows Kits\10\bin"
if (Test-Path $kitsRoot) {
$candidates = @(
Get-ChildItem -Path $kitsRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } |
Sort-Object FullName -Descending
)
if ($candidates.Count -gt 0) {
return $candidates[0].FullName
}
}
}
throw "signtool.exe introuvable. Installer Windows SDK ou ajouter signtool.exe au PATH."
}
function Invoke-CodeSigning {
param([string]$FilePath)
if (-not $Sign) {
Write-Host "Signature Authenticode ignorée. Utiliser -Sign pour signer l'exécutable."
return
}
Require-Path -PathValue $FilePath -Label "Fichier à signer"
if ($PfxPath) {
Require-Path -PathValue $PfxPath -Label "Certificat PFX"
}
$signTool = Resolve-SignTool
Write-Host "SignTool : $signTool"
if ($CertThumbprint -eq "REMPLACER_PAR_L_EMPREINTE_DU_CERTIFICAT") {
throw "Empreinte de certificat non renseignée dans build_signing.local.ps1."
}
$args = @("sign", "/fd", "SHA256", "/tr", $TimestampServer, "/td", "SHA256", "/d", "Anonymisation")
if ($PfxPath) {
$args += @("/f", $PfxPath)
if ($PfxPassword) {
$args += @("/p", $PfxPassword)
}
} elseif ($CertThumbprint) {
$args += @("/sha1", ($CertThumbprint -replace "\s", ""))
} else {
$args += @("/a")
}
$args += $FilePath
& $signTool @args
if ($LASTEXITCODE -ne 0) {
throw "La signature Authenticode a échoué."
}
& $signTool verify /pa /v $FilePath
if ($LASTEXITCODE -ne 0) {
throw "La vérification Authenticode a échoué."
}
$signature = Get-AuthenticodeSignature $FilePath
$subject = ""
if ($signature.SignerCertificate) {
$subject = $signature.SignerCertificate.Subject
}
$script:SignatureSummary = "$($signature.Status) - $subject"
Write-Host "Signature : $script:SignatureSummary"
if ($signature.Status -ne "Valid") {
throw "Signature Authenticode non valide : $($signature.Status)"
}
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path
$SigningConfigPath = Join-Path $ProjectRoot "build_signing.local.ps1"
$SpecPath = Join-Path $ProjectRoot "anonymisation_onefile.spec"
$BuildInfoPath = Join-Path $ProjectRoot "build_info.py"
$ModelPath = Join-Path $ProjectRoot "models\camembert-bio-deid\onnx\model.onnx"
$VenvDir = Join-Path $ProjectRoot ".venv_build_win"
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
$DistDir = Join-Path $ProjectRoot "dist"
$BuildDir = Join-Path $ProjectRoot "build"
$ReleaseDir = Join-Path $ProjectRoot "release"
$ExePath = Join-Path $DistDir "Anonymisation.exe"
$PackageDir = Join-Path $ReleaseDir "Anonymisation-Windows"
$ZipPath = Join-Path $ReleaseDir "Anonymisation-Windows.zip"
$HashPath = Join-Path $ReleaseDir "Anonymisation.exe.sha256.txt"
$ReadmePath = Join-Path $PackageDir "README.txt"
$RequiredSourceFiles = @(
"launcher.py",
"Pseudonymisation_Gui_V5.py",
"anonymizer_core_refactored_onnx.py",
"admin_rules.py",
"config_defaults.py",
"profile_defaults.py",
"gui_batch_paths.py",
"manual_masking.py",
"pdf_mask_designer.py",
"format_converter.py",
"camembert_ner_manager.py"
)
Write-Step "Préparation du build Windows"
Write-Host "Projet : $ProjectRoot"
Require-Path -PathValue $SpecPath -Label "Spec PyInstaller"
Require-Path -PathValue $ModelPath -Label "Modèle ONNX embarqué"
foreach ($RelativeSourceFile in $RequiredSourceFiles) {
Require-Path -PathValue (Join-Path $ProjectRoot $RelativeSourceFile) -Label "Module source requis"
}
if (Test-Path $SigningConfigPath) {
Write-Step "Configuration locale de signature"
. $SigningConfigPath
if ($BuildSigningEnabled) { $Sign = $true }
if ($BuildSigningCertThumbprint -and -not $CertThumbprint) { $CertThumbprint = $BuildSigningCertThumbprint }
if ($BuildSigningPfxPath -and -not $PfxPath) { $PfxPath = $BuildSigningPfxPath }
if ($BuildSigningPfxPassword -and -not $PfxPassword) { $PfxPassword = $BuildSigningPfxPassword }
if ($BuildSigningTimestampServer -and $TimestampServer -eq "http://timestamp.digicert.com") {
$TimestampServer = $BuildSigningTimestampServer
}
if ($Sign) {
Write-Host "Signature activée depuis build_signing.local.ps1"
}
}
Write-Step "Détection de Python"
$script:PythonBootstrap = Resolve-BootstrapPython
Write-Host "Bootstrap Python : $($script:PythonBootstrap -join ' ')"
Write-Step "Environnement virtuel de build"
if (-not (Test-Path $VenvPython)) {
Write-Host "Création du venv : $VenvDir"
Invoke-BootstrapPython -Arguments @("-m", "venv", $VenvDir)
}
Require-Path -PathValue $VenvPython -Label "Python du venv"
Push-Location $ProjectRoot
try {
Write-Step "Installation des dépendances de build"
& $VenvPython -m pip install --upgrade pip setuptools wheel
if (-not $SkipRequirements) {
& $VenvPython -m pip install -r requirements.txt
}
& $VenvPython -m pip install pyinstaller
Write-Step "Génération de build_info.py"
$commit = "local"
$branch = "local"
if (Get-Command git -ErrorAction SilentlyContinue) {
try {
$gitCommit = (git rev-parse --short HEAD 2>$null | Out-String).Trim()
if ($gitCommit) { $commit = $gitCommit }
$gitBranch = (git rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim()
if ($gitBranch) { $branch = $gitBranch }
} catch {}
}
$buildDate = Get-Date -Format "yyyy-MM-dd HH:mm"
$buildInfo = @"
"""Métadonnées de build - généré automatiquement par build_windows_oneclick.ps1."""
BUILD_DATE = "$buildDate"
BUILD_COMMIT = "$commit"
BUILD_BRANCH = "$branch"
"@
Set-Content -Path $BuildInfoPath -Value $buildInfo -Encoding UTF8
Write-Host "Build info : $buildDate / $branch / $commit"
Write-Step "Nettoyage des anciens artefacts"
foreach ($PathValue in @($BuildDir, $DistDir, $PackageDir)) {
if (Test-Path $PathValue) {
Remove-Item -Recurse -Force $PathValue -ErrorAction SilentlyContinue
}
}
if (Test-Path $ZipPath) {
Remove-Item -Force $ZipPath -ErrorAction SilentlyContinue
}
if (Test-Path $HashPath) {
Remove-Item -Force $HashPath -ErrorAction SilentlyContinue
}
Write-Step "Compilation PyInstaller"
& $VenvPython -m PyInstaller --clean --noconfirm $SpecPath
if ($LASTEXITCODE -ne 0) {
throw "PyInstaller a échoué avec le code $LASTEXITCODE."
}
Write-Step "Vérification de l'exécutable"
Require-Path -PathValue $ExePath -Label "Exécutable Windows"
$exeSizeMb = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Write-Host "EXE créé : $ExePath ($exeSizeMb MB)"
Write-Step "Signature Authenticode"
Invoke-CodeSigning -FilePath $ExePath
Write-Step "Préparation du dossier de livraison"
New-Item -ItemType Directory -Force -Path $PackageDir | Out-Null
Copy-Item $ExePath (Join-Path $PackageDir "Anonymisation.exe")
$readme = @"
Anonymisation - paquet Windows
================================
Fichier principal :
- Anonymisation.exe
Conseils de diffusion :
- Aucune installation de Python n'est nécessaire pour l'utilisateur final.
- Conservez le fichier dans un dossier en écriture (par exemple Bureau ou Documents).
- Privilégiez une diffusion par partage réseau interne, Intune, GPO ou portail établissement.
- Évitez l'envoi direct par e-mail ou téléchargement public non signé.
- Le journal applicatif s'écrit à côté de l'exécutable : anonymisation.log
Build :
- Date : $buildDate
- Branche : $branch
- Commit : $commit
- Signature : $script:SignatureSummary
"@
Set-Content -Path $ReadmePath -Value $readme -Encoding UTF8
$hash = (Get-FileHash -Algorithm SHA256 $ExePath).Hash
Set-Content -Path $HashPath -Value "SHA256 Anonymisation.exe $hash" -Encoding UTF8
Write-Host "SHA256 : $hash"
if (-not $SkipZip) {
Write-Step "Création de l'archive de livraison"
Compress-Archive -Path (Join-Path $PackageDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal
Write-Host "Archive créée : $ZipPath"
}
Write-Step "Build terminé"
Write-Host "EXE final : $ExePath" -ForegroundColor Green
if (-not $SkipZip) {
Write-Host "Archive prête : $ZipPath" -ForegroundColor Green
}
Write-Host "Hash SHA256 : $HashPath" -ForegroundColor Green
} finally {
Pop-Location
}

View File

@@ -0,0 +1,18 @@
version: 1
name: FC19_template
page_size:
width: 595.0
height: 842.0
masks:
- page: 0
x0: 123.2
y0: 25.6
x1: 485.6
y1: 66.4
label: MASK
- page: 0
x0: 205.6
y0: 351.2
x1: 341.6
y1: 367.2
label: MASK

View File

@@ -0,0 +1,48 @@
version: 1
default_profile: standard_local
profiles:
standard_local:
label: Standard local
description: Profil par défaut pour les traitements internes sur poste bureautique.
require_manual_mask: false
force_disable_vlm: false
dictionaries_overlay: {}
chcb_strict:
label: CHCB strict
description: Profil conservateur pour les échanges prudents du CHCB.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay:
blacklist:
force_mask_terms:
- CHCB
- Centre Hospitalier de la Côte Basque
- CENTRE HOSPITALIER DE LA COTE BASQUE
partage_recherche:
label: Partage recherche
description: Profil externe strict. Le masque manuel est recommandé pour les documents formatés.
require_manual_mask: true
force_disable_vlm: true
dictionaries_overlay:
blacklist:
force_mask_terms:
- CHCB
- Centre Hospitalier de la Côte Basque
- CENTRE HOSPITALIER DE LA COTE BASQUE
dossier_audit:
label: Dossier audit
description: Profil orienté traçabilité et reproductibilité des traitements.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay: {}
demo:
label: Démo
description: Profil léger pour démonstration interne sur machine de bureau.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay: {}

53
config/profiles.yml Normal file
View File

@@ -0,0 +1,53 @@
# Surcharge locale des profils métier.
# Source de vérité : config/profiles.default.yml
# Les profils créés depuis la GUI sont enregistrés ici.
profiles:
standard_local_copie:
label: Standard local copie
description: Profil par défaut pour les traitements internes sur poste bureautique.
require_manual_mask: false
force_disable_vlm: false
dictionaries_overlay: {}
param_lists:
whitelist_phrases:
- classification internationale
- prise en charge
- bas de contention
- date de naissance
- lieu de naissance
- ville de résidence
- date de sortie
- date d'admission
- code postal
blacklist_force_mask_terms:
- CHCB
- 'Dates du séjour :'
- CONCERTATION
- LABORATOIRE de BIOLOGIE MEDICALE
additional_stopwords: []
preferred_manual_mask_template: ''
standard_local_copie_copie:
label: Standard local copie copie
description: Profil par défaut pour les traitements internes sur poste bureautique.
require_manual_mask: false
force_disable_vlm: false
dictionaries_overlay: {}
param_lists:
whitelist_phrases:
- classification internationale
- prise en charge
- bas de contention
- date de naissance
- lieu de naissance
- ville de résidence
- date de sortie
- date d'admission
- code postal
blacklist_force_mask_terms:
- CHCB
- 'Dates du séjour :'
- CONCERTATION
- LABORATOIRE de BIOLOGIE MEDICALE
additional_stopwords: []
preferred_manual_mask_template: ''

200
config_defaults.py Normal file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Helpers partagés pour la config dictionnaires.
"""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict
try:
import yaml
except Exception:
yaml = None
PROJECT_DIR = Path(__file__).resolve().parent
CONFIG_DIR = PROJECT_DIR / "config"
DEFAULT_DICTIONARIES_CONFIG_PATH = CONFIG_DIR / "dictionnaires.default.yml"
RUNTIME_DICTIONARIES_CONFIG_PATH = CONFIG_DIR / "dictionnaires.yml"
_RUNTIME_DICTIONARIES_OVERLAY_TEXT = """# Surcharge locale chargée par défaut par l'application.
# Seuls les écarts par rapport à config/dictionnaires.default.yml sont nécessaires ici.
# Si ce fichier est vide, les valeurs du template par défaut s'appliquent.
#
# Exemples :
# blacklist:
# force_mask_terms:
# - VOTRE_SIGLE
# additional_stopwords:
# - votre_terme
{}
"""
_FALLBACK_DEFAULT_DICTIONARIES_TEXT = """version: 1
encoding: utf-8
normalization: NFKC
whitelist:
sections_titres:
- DIM
- GHM
- GHS
- RUM
- COMPTE
- RENDU
- DIAGNOSTIC
noms_maj_excepts:
- Médecin DIM
- Praticien conseil
org_gpe_keep: false
blacklist:
force_mask_terms: []
force_mask_regex: []
kv_labels_preserve:
- FINESS
- IPP
- N° OGC
- Etablissement
regex_overrides:
- name: OGC_court
pattern: \\b(?:N°\\s*)?OGC\\s*[:\\-]?\\s*([A-Za-z0-9\\-]{1,3})\\b
placeholder: '[OGC]'
flags:
- IGNORECASE
whitelist_phrases: []
additional_stopwords: []
additional_villes_blacklist: []
additional_dpi_labels: []
additional_companion_blacklist: []
flags:
case_insensitive: true
unicode_word_boundaries: true
regex_engine: python
"""
_FALLBACK_DEFAULT_DICTIONARIES_DICT: Dict[str, Any] = {
"version": 1,
"encoding": "utf-8",
"normalization": "NFKC",
"whitelist": {
"sections_titres": ["DIM", "GHM", "GHS", "RUM", "COMPTE", "RENDU", "DIAGNOSTIC"],
"noms_maj_excepts": ["Médecin DIM", "Praticien conseil"],
"org_gpe_keep": False,
},
"blacklist": {
"force_mask_terms": [],
"force_mask_regex": [],
},
"kv_labels_preserve": ["FINESS", "IPP", "N° OGC", "Etablissement"],
"regex_overrides": [
{
"name": "OGC_court",
"pattern": r"\b(?:N°\s*)?OGC\s*[:\-]?\s*([A-Za-z0-9\-]{1,3})\b",
"placeholder": "[OGC]",
"flags": ["IGNORECASE"],
}
],
"whitelist_phrases": [],
"additional_stopwords": [],
"additional_villes_blacklist": [],
"additional_dpi_labels": [],
"additional_companion_blacklist": [],
"flags": {
"case_insensitive": True,
"unicode_word_boundaries": True,
"regex_engine": "python",
},
}
def read_default_dictionaries_text() -> str:
try:
return DEFAULT_DICTIONARIES_CONFIG_PATH.read_text(encoding="utf-8")
except Exception:
return _FALLBACK_DEFAULT_DICTIONARIES_TEXT
def read_runtime_dictionaries_overlay_text() -> str:
return _RUNTIME_DICTIONARIES_OVERLAY_TEXT
def load_default_dictionaries_dict() -> Dict[str, Any]:
text = read_default_dictionaries_text()
if yaml is not None:
try:
loaded = yaml.safe_load(text) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return deepcopy(_FALLBACK_DEFAULT_DICTIONARIES_DICT)
def load_runtime_dictionaries_overlay_dict(path: Path | None = None) -> Dict[str, Any]:
target = Path(path) if path is not None else RUNTIME_DICTIONARIES_CONFIG_PATH
if not target.exists():
return {}
if yaml is None:
return {}
try:
loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return {}
def load_effective_dictionaries_dict(path: Path | None = None) -> Dict[str, Any]:
return deep_merge_dict(
load_default_dictionaries_dict(),
load_runtime_dictionaries_overlay_dict(path),
)
def _normalize_string_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
normalized: list[str] = []
for value in values:
text = str(value).strip()
if text:
normalized.append(text)
return normalized
def load_effective_param_lists(path: Path | None = None) -> Dict[str, list[str]]:
"""Return the effective parameter lists shown in the GUI."""
data = load_effective_dictionaries_dict(path)
return {
"whitelist_phrases": _normalize_string_list(data.get("whitelist_phrases", [])),
"blacklist_force_mask_terms": _normalize_string_list(
data.get("blacklist", {}).get("force_mask_terms", [])
),
"additional_stopwords": _normalize_string_list(data.get("additional_stopwords", [])),
}
def deep_merge_dict(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
merged = deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = deep_merge_dict(merged[key], value)
elif isinstance(value, list) and isinstance(merged.get(key), list):
combined = list(merged[key])
for item in value:
if item not in combined:
combined.append(deepcopy(item))
merged[key] = combined
else:
merged[key] = deepcopy(value)
return merged
def ensure_runtime_dictionaries_config(path: Path | None = None) -> Path:
target = Path(path) if path is not None else RUNTIME_DICTIONARIES_CONFIG_PATH
if not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(read_runtime_dictionaries_overlay_text(), encoding="utf-8")
return target

66
dictionnaires.yml Normal file
View File

@@ -0,0 +1,66 @@
version: 1
encoding: utf-8
normalization: NFKC
whitelist:
sections_titres:
- DIM
- GHM
- GHS
- RUM
- COMPTE
- RENDU
- DIAGNOSTIC
noms_maj_excepts:
- Médecin DIM
- Praticien conseil
org_gpe_keep: false
blacklist:
force_mask_terms:
- CENTRE HOSPITALIER COTE BASQUE
- CENTRE HOSPITALIER DE LA COTE BASQUE
- POLYCLINIQUE COTE BASQUE SUD
- POLYCLINIQUE CÔTE BASQUE SUD
- CHCB
- '640780417'
- 'Dates du séjour :'
- CONCERTATION
- BAYONNE CEDEX
- BAYONNE
- '64109'
- LABORATOIRE de BIOLOGIE MEDICALE
- REED LES EMBRUNS
- LES EMBRUNS
- EMBRUNS BIDART
force_mask_regex:
- '[Ee]mbruns'
- 'Centre\s+Hospitalier\s+(?:de\s+(?:la\s+)?)?C[oôÔ]te\s+Basque'
- 'Polyclinique\s+C[oôÔ]te\s+Basque\s+Sud'
- '13\s*,?\s*Avenue\s+de\s+l.Interne\s+J\.?\s*LOEB\s+BP\s*\d+'
kv_labels_preserve:
- FINESS
- IPP
- N° OGC
- Etablissement
regex_overrides:
- name: OGC_court
pattern: \b(?:N°\s*)?OGC\s*[:\-]?\s*([A-Za-z0-9\-]{1,3})\b
placeholder: '[OGC]'
flags:
- IGNORECASE
# Phrases à ne JAMAIS anonymiser (faux positifs récurrents)
# Ajouter ici les expressions qui sont masquées à tort.
# La correspondance est insensible à la casse.
whitelist_phrases:
- "classification internationale"
- "prise en charge"
- "bas de contention"
- "date de naissance"
- "lieu de naissance"
- "ville de résidence"
- "date de sortie"
- "date d'admission"
- "code postal"
flags:
case_insensitive: true
unicode_word_boundaries: true
regex_engine: python

View File

@@ -0,0 +1,119 @@
# Build Windows One-Click
Le packaging Windows standard du projet repose sur :
- `build_windows_oneclick.bat`
- `build_windows_installer_oneclick.bat`
- `scripts/build_windows_oneclick.ps1`
- `anonymisation_onefile.spec`
- `installer/Anonymisation.iss`
## Usage
Sur la machine Windows de build :
1. ouvrir le dossier du projet
2. double-cliquer sur `build_windows_oneclick.bat`
Le script :
- crée un venv de build local `.venv_build_win`
- installe les dépendances nécessaires au packaging
- génère `build_info.py`
- lance `PyInstaller` avec `anonymisation_onefile.spec`
- vérifie la présence de l'exécutable final
- prépare un dossier de livraison et une archive ZIP
- crée `release\Anonymisation-Setup.exe` si Inno Setup 6 est installé
## Sorties attendues
- exécutable : `dist\Anonymisation.exe`
- dossier de livraison : `release\Anonymisation-Windows\`
- archive : `release\Anonymisation-Windows.zip`
- installateur : `release\Anonymisation-Setup.exe`
- hash : `release\Anonymisation.exe.sha256.txt`
## Installateur Windows
L'installateur est généré avec Inno Setup 6. Il fournit :
- choix du dossier d'installation
- installation utilisateur par défaut sans droits administrateur
- raccourci menu Démarrer
- option d'icône sur le bureau
- désinstallation Windows standard
Si Inno Setup n'est pas présent sur la machine de build, le script conserve le
build EXE/ZIP et affiche un avertissement. Installer Inno Setup 6 depuis le site
officiel puis relancer le build.
Installation automatisée de la dépendance de build Inno Setup :
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install_inno_setup_build_dep.ps1
```
Recompiler uniquement l'installateur à partir de `release\Anonymisation-Windows\Anonymisation.exe` :
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_installer_only.ps1
```
Pour ne générer que l'exécutable et le ZIP :
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -SkipInstaller
```
## Important
- les utilisateurs finaux n'ont pas besoin d'installer Python
- le build doit être lancé depuis Windows
- le modèle ONNX embarqué requis doit exister localement dans :
`models\camembert-bio-deid\onnx\model.onnx`
## Blocage Windows / SmartScreen
Un exécutable PyInstaller non signé peut déclencher Microsoft Defender SmartScreen, surtout s'il est téléchargé depuis Internet ou envoyé par e-mail. La signature réduit fortement le risque et évite l'éditeur inconnu, mais elle ne garantit pas toujours l'absence totale d'avertissement SmartScreen pour une toute nouvelle version : Windows tient aussi compte de la réputation du fichier et de son hash.
Pour une diffusion à des utilisateurs novices, la voie recommandée est :
- signer `Anonymisation.exe` avec un certificat Authenticode
- horodater la signature
- diffuser par partage réseau interne, Intune, GPO ou portail établissement
- conserver le hash `release\Anonymisation.exe.sha256.txt`
- éviter de demander aux utilisateurs de cliquer sur `Exécuter quand même`
Le script prend en charge la signature si un certificat est disponible.
### Signature automatique avec configuration locale
Sur la machine Windows de build :
1. copier `build_signing.example.ps1` en `build_signing.local.ps1`
2. renseigner l'empreinte du certificat ou le chemin du PFX
3. double-cliquer comme d'habitude sur `build_windows_oneclick.bat`
`build_signing.local.ps1` est ignoré par Git pour éviter de versionner des secrets.
### Signature manuelle via PowerShell
Avec un certificat installé dans le magasin Windows :
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -Sign -CertThumbprint "EMPREINTE_CERTIFICAT"
```
Avec un fichier PFX :
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -Sign -PfxPath "C:\chemin\certificat.pfx" -PfxPassword "mot-de-passe"
```
Si aucun certificat n'est disponible, le build reste possible, mais Windows peut afficher un avertissement de réputation au premier lancement.
Références Microsoft :
- SmartScreen reputation : https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation
- SignTool : https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
- Authenticode timestamping : https://learn.microsoft.com/en-us/windows/win32/seccrypto/time-stamping-authenticode-signatures

298
docs/memoire-projet.md Normal file
View File

@@ -0,0 +1,298 @@
# Memoire projet
Derniere mise a jour : 2026-04-22
## Objet
But du projet : anonymiser/pseudonymiser des documents medicaux de facon fiable, diffable, validable par des humains, avec une contrainte forte de conformite et de non-fuite.
Ce fichier sert de point de reprise rapide pour ne pas perdre le fil entre deux sessions.
## Etat courant
- La source de verite des dictionnaires par defaut est `config/dictionnaires.default.yml`.
- La surcharge runtime/site est `config/dictionnaires.yml`.
- Les dictionnaires hardcodes ont ete externalises vers `data/`.
- Les regles d'administration ont un contrat dedie :
- `config/admin_rules.default.yml`
- `config/admin_rules.yml`
- `schemas/admin_rules.schema.json`
- `admin_rules.py`
- Les regles admin sont branchees dans le moteur ONNX.
- Le core legacy n'est pas encore aligne sur ce branchement admin.
- La GUI conserve maintenant le chemin relatif des cas sous `anonymise/` au lieu d'ecraser les sorties homonymes.
- La GUI ignore maintenant le sous-dossier `anonymise/` lors du scan recursif des entrees.
- L'onglet Parametres de la GUI charge maintenant les listes effectives `default + overlay`, donc les phrases/termes par defaut sont visibles meme si `config/dictionnaires.yml` est vide.
- L'onglet Parametres affiche aussi un resume chiffré des listes visibles et precise que le moteur applique d'autres regles automatiques non affichees dans ces champs.
- La GUI expose maintenant un mode `masques PDF reutilisables` pour les documents formates :
- ouverture d'un editeur de caviardage manuel depuis l'onglet Parametres
- stockage persistant des templates dans `config/mask_templates/`
- ouverture automatique du PDF courant quand l'utilisateur a selectionne un fichier PDF
- selection d'un template dans la GUI pour l'appliquer a tous les PDF du lot avant anonymisation
- La GUI expose maintenant aussi des `profils metier` :
- definitions chargees depuis `config/profiles.default.yml` + `config/profiles.yml`
- selection d'un profil dans l'onglet Parametres
- surcharge de configuration appliquee au moteur pour le lot courant
- options de poste utilisateur prises en compte comme `masque manuel requis` et `VLM desactive`
- Le moteur anonymise maintenant correctement deux layouts reels supplementaires :
- numero de venue BACTERIO rejete juste avant `IPP`
- artefacts de noms de fichiers scannes `EXT2-...-1234567890.TIF`
## Validation deja en place
- Suite rapide : `tests/synthetic_regression/`
- Corpus complet de revue : `tests/synthetic_review/`
- Runner de revue : `tools/run_synthetic_review_corpus.py`
- Protocole humain : `docs/protocole-validation-humaine.md`
- Fiche de revue : `docs/fiche-validation-humaine-modele.md`
Tests ajoutes/maintenus :
- `tests/unit/test_config_externalization.py`
- `tests/unit/test_header_pii_detection.py`
- `tests/unit/test_synthetic_regression.py`
- `tests/unit/test_admin_rules_validator.py`
- `tests/unit/test_admin_rules_integration.py`
- `tests/unit/test_gui_batch_paths.py`
## Commits repere
- `500ebc2` Externalize dictionaries and add anonymization review corpus
- `b58d79f` Add project framing for anonymization
- `0fc8665` Add human review protocol and admin rules contract
- `df5dabf` Wire admin rules into ONNX anonymizer
## Dernier constat important
La campagne lancee depuis la GUI sur le dossier global `tests/synthetic_regression/cases` n'est pas exploitable comme validation complete.
Cause racine :
- la GUI parcourt recursivement tous les fichiers supportes du dossier choisi
- la GUI ecrit toutes les sorties dans un seul dossier `anonymise/`
- les sorties sont nommees avec le seul `stem` du fichier source
- comme chaque cas contient `input.txt`, `test.txt` et `expected.txt`, les sorties s'ecrasent entre elles
Rapport detaille :
- `docs/rapport-analyse-campagne-gui-2026-04-21.md`
Conclusion :
- seul le cas `010_spaced_establishment_header` restait encore verifiable
- ce cas etait conforme
- la campagne globale est non concluante pour les autres cas
## Correctif applique ensuite
Le probleme de nommage GUI identifie ci-dessus a ete corrige dans `Pseudonymisation_Gui_V5.py`.
Effets du correctif :
- les sorties de campagne conservent desormais le sous-dossier relatif de chaque cas
- le dossier `anonymise/` est exclu des entrees candidates, pour eviter les retraitements accidentels
- le controle de fuite GUI relit desormais les `.pseudonymise.txt` de facon recursive
Exemple attendu :
- `anonymise/001_patient_header_and_birth/test.pseudonymise.txt`
- `anonymise/002_contact_bundle/test.pseudonymise.txt`
## Echantillon reel CHCB du 2026-04-22
Lot teste :
- dossier source : `/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs`
- echantillon aleatoire reproductible de 30 documents
- manifeste : `anonymise/_sample_manifest_2026-04-22_seed20260422.json`
Resultat de traitement :
- 27 documents anonymises avec succes
- 3 echecs dus a des PDF proteges par mot de passe :
- `149_23089771/ANAPATH 23089771.pdf`
- `26_23127395/ANAPATH 23127395.pdf`
- `29_23137897/ANAPATH 23137897.pdf`
Validation apres correctifs moteur :
- 2 fuites probables observees au premier passage ont ete corrigees :
- `228_23176885/BACTERIO 23176885.pdf`
- `84_23215994/trackare-16014215-23215994_16014215_23215994.pdf`
- controle automatique final : 22 documents sans fuite detectee sur 27
- les 5 alertes restantes sont des faux positifs connus du `LeakScanner`
- initiales d'une lettre dans l'audit (`A`, `F`, `S`)
- code produit `16371071` dans une ligne CLARISCAN
- ratio medical `1/10000`
Rapports produits :
- `anonymise/_sample_run_report_2026-04-22_seed20260422.json`
- `anonymise/_sample_validation_report_2026-04-22_seed20260422.json`
- `anonymise/_sample_validation_triage_2026-04-22_seed20260422.json`
## Prochaine action recommandee
Relancer soit :
- une nouvelle vague aleatoire de 30 documents reels CHCB
- soit la campagne de validation sur `tests/synthetic_regression/cases`
Objectif :
- separer les vrais ecarts moteur des faux positifs du validateur
- prioriser ensuite une amelioration du `LeakScanner` pour ignorer les hits NOM mono-lettre et certains numeriques medicaux non patients
Option recommandee :
- verifier d'abord que la GUI ne traite plus `anonymise/` comme entree
- lancer une passe complete sur le corpus
- confirmer visuellement que chaque cas produit sa sortie dans son propre sous-dossier
Amelioration utile ensuite :
- ajouter un mode GUI "campagne de tests" qui ne traite que `test.txt`
- generer automatiquement un rapport de comparaison contre les `expected.txt`
## Fichiers a relire en premier pour reprendre
- `docs/cadrage-projet-anonymisation.md`
- `docs/spec-regles-administration.md`
- `docs/protocole-validation-humaine.md`
- `docs/rapport-analyse-campagne-gui-2026-04-21.md`
- `gui_batch_paths.py`
- `anonymizer_core_refactored_onnx.py`
- `Pseudonymisation_Gui_V5.py`
## Etat du worktree a ne pas confondre avec le chantier courant
Il existe des changements hors perimetre qu'il ne faut pas ecraser par erreur :
- suppressions sous `ano/pdf_natif/pseudonymise/`
- gros volume non tracke sous `data/silver_annotations/`
- sorties generees sous `tests/synthetic_review/actual/`
- sorties GUI sous `tests/synthetic_regression/cases/anonymise/`
## Regle de reprise
Avant toute nouvelle passe de validation humaine sur corpus :
1. verifier le mode de sortie de la GUI
2. eviter de traiter le dossier global tant que le nommage de sortie n'est pas corrige
3. preferer un cas a la fois si la GUI n'a pas encore ete corrigee
## Derniere avancee
Les profils metier ne sont plus seulement lus depuis YAML :
- la GUI permet maintenant de creer un nouveau profil
- la GUI permet d'enregistrer les reglages courants dans le profil selectionne
- les profils utilisateur sont ecrits dans `config/profiles.yml`
- un profil peut memoriser :
- les listes visibles de preservation / masquage / stop-words
- le caractere obligatoire du masque manuel
- la desactivation du VLM
- le modele de masque PDF prefere
Effet important :
- la selection d'un profil recharge maintenant ses reglages visibles dans l'onglet Parametres
- le lancement de traitement utilise les reglages courants de l'ecran via une config temporaire de lot, sans exiger un `Sauvegarder` prealable dans `dictionnaires.yml`
Ergonomie GUI :
- l'onglet `Parametres` a ete simplifie pour un usage bureautique
- la navigation est maintenant organisee en trois onglets stables :
- `Anonymisation`
- `Parametres`
- `Profils`
- les listes manuelles sont revenues directement dans `Parametres`
- la creation / edition / suppression / profil par defaut sont gerees directement dans l'onglet `Profils`
- on evite ainsi les enchainements de popups pour le flux normal
- l'onglet `Profils` expose maintenant explicitement le `masque PDF memorise par ce profil`
- le sens de `masque manuel obligatoire` est documente dans l'UI :
- cela n'impose pas un masque precis
- cela bloque seulement le lancement si aucun masque PDF n'est selectionne
Packaging Windows :
- le build Windows a maintenant un point d'entree "un clic" : `build_windows_oneclick.bat`
- ce lanceur appelle `scripts/build_windows_oneclick.ps1`
- le packaging utilise `PyInstaller` via `anonymisation_onefile.spec`
- le `.spec` n'est plus fige sur `C:\Users\dom\ai\anonymisation` ; il resolve maintenant le projet de facon portable
- les repertoires de configuration, donnees, detecteurs, assets et modele ONNX sont embarques dans l'executable
- sur la machine Windows de build, la sortie attendue est :
- `dist\Anonymisation.exe`
- `release\Anonymisation-Windows\`
- `release\Anonymisation-Windows.zip`
- `release\Anonymisation.exe.sha256.txt`
- objectif produit :
- les utilisateurs finaux n'ont pas besoin d'installer Python
- le build doit en revanche etre realise depuis un poste Windows
- risque Windows identifie :
- un executable PyInstaller non signe peut declencher SmartScreen / Defender
- meme signe, un nouveau hash peut encore afficher un avertissement de reputation selon les politiques Windows
- `scripts/build_windows_oneclick.ps1` accepte maintenant une signature Authenticode via `-Sign`
- un fichier local non versionne `build_signing.local.ps1` peut activer la signature automatiquement pour conserver le build en un clic
- le modele de configuration est `build_signing.example.ps1`
Build Windows realise le 2026-04-23 via SSH sur `dom@192.168.1.11` :
- poste : `DESKTOP-58D5CAC`
- chemin projet Windows : `C:\Users\dom\ai\anonymisation`
- executable cree : `C:\Users\dom\ai\anonymisation\dist\Anonymisation.exe`
- archive creee : `C:\Users\dom\ai\anonymisation\release\Anonymisation-Windows.zip`
- hash : `C:\Users\dom\ai\anonymisation\release\Anonymisation.exe.sha256.txt`
- SHA256 final : `8F3E3786D669F44824D24BF14AC06EF22CE19A8E900056DAB031891791871841`
- taille exe : environ 697 MB
- contenu OCR : `python-doctr`, `torchvision`, `opencv-python`, `scipy` embarques dans l'environnement de build
- signature : non signee, car aucun certificat n'est configure
- smoke test : lancement de l'exe OK ; processus encore vivant apres 45 secondes, puis arret volontaire
Correctif build Windows du 2026-04-23 :
- probleme constate au lancement utilisateur : `No module named admin_rules`
- cause : `admin_rules.py` n'avait pas ete synchronise sur le poste Windows avant le build precedent
- correction : transfert de `admin_rules.py` sur `C:\Users\dom\ai\anonymisation`
- durcissement : `scripts/build_windows_oneclick.ps1` verifie maintenant la presence des modules source critiques avant PyInstaller
- nouveau build cree : `C:\Users\dom\ai\anonymisation\dist\Anonymisation.exe`
- nouveau SHA256 : `0EB97B1E2859D0BCD6E45DC420CFDC929C3B79B6B0AF123CF59F2230187F5712`
- smoke test : lancement de l'exe OK ; processus encore vivant apres 60 secondes, puis arret volontaire
Demarrage produit / installateur Windows du 2026-04-23 :
- le lanceur conserve le splash visuel `aivanonym` existant
- apres le splash natif PyInstaller, une fenetre de demarrage applicative reprend le meme visuel et affiche :
- etapes numerotees de chargement
- barre de progression
- journal court des modules/dictionnaires charges
- la fenetre de configuration initiale affiche aussi le visuel produit et un journal des chargements de modeles
- les sorties `stdout/stderr` de type `tqdm` pendant le chargement EDS-Pseudo / GLiNER sont redirigees vers ce journal pour montrer les poids/modules en cours
- un script Inno Setup a ete ajoute : `installer/Anonymisation.iss`
- le build Windows peut maintenant produire un vrai installateur : `release\Anonymisation-Setup.exe`
- l'installateur propose :
- choix du dossier d'installation
- installation utilisateur sans droit administrateur par defaut
- raccourci menu Demarrer
- option icone bureau
- desinstallation Windows standard
- `scripts/build_windows_oneclick.ps1` genere l'installateur si Inno Setup 6 est present ; sinon il conserve EXE/ZIP et affiche un avertissement
- verification locale Linux : `python3 -m py_compile launcher.py Pseudonymisation_Gui_V5.py camembert_ner_manager.py eds_pseudo_manager.py gliner_manager.py`
- smoke test local du nouveau splash : OK
- build Windows non relance a ce stade : authentification SSH refusee lors de la tentative de reconnexion au poste Windows
Build Windows installateur realise le 2026-04-23 via SSH sur `dom@192.168.1.11` :
- Inno Setup 6.7.1 installe en mode utilisateur sur le poste Windows via `scripts/install_inno_setup_build_dep.ps1`
- chemin Inno : `C:\Users\dom\AppData\Local\Programs\Inno Setup 6\ISCC.exe`
- build relance avec `scripts\build_windows_oneclick.ps1 -SkipRequirements`
- executable cree : `C:\Users\dom\ai\anonymisation\dist\Anonymisation.exe`
- archive creee : `C:\Users\dom\ai\anonymisation\release\Anonymisation-Windows.zip`
- installateur cree : `C:\Users\dom\ai\anonymisation\release\Anonymisation-Setup.exe`
- taille executable : `730 483 452` octets, environ 696.6 MB
- taille ZIP : `728 300 929` octets
- taille installateur : `729 517 505` octets, environ 695.7 MB
- SHA256 executable : `520EE614CD9B56EB7C748AB5BCCDF0DD4DAAD0726EF0EAB0EFE89177A84E5882`
- SHA256 installateur : `A22B5D1A3AE10203DEEA7FB053C0184695A88084294603CF1EA643F123597FC1`
- signature : non signee, car aucun certificat Authenticode n'est configure
- smoke test Windows : lancement de `dist\Anonymisation.exe` OK ; deux processus `Anonymisation` repondants apres 60 secondes, puis arret volontaire

43
gui_batch_paths.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable
def _is_relative_to(path: Path, other: Path) -> bool:
try:
path.relative_to(other)
return True
except ValueError:
return False
def list_supported_documents(root_dir: Path, supported_extensions: Iterable[str]) -> list[Path]:
"""List supported input documents while ignoring the GUI output subtree."""
normalized_exts = {ext.lower() for ext in supported_extensions}
output_dir = root_dir / "anonymise"
documents: list[Path] = []
for path in root_dir.rglob("*"):
if not path.is_file():
continue
if _is_relative_to(path, output_dir):
continue
if path.suffix.lower() not in normalized_exts:
continue
documents.append(path)
return sorted(documents)
def build_batch_output_dir(root_dir: Path, output_root: Path, source_path: Path) -> Path:
"""Preserve the source parent path under the batch output directory."""
relative_parent = source_path.relative_to(root_dir).parent
if relative_parent == Path("."):
return output_root
return output_root / relative_parent
def iter_pseudonymized_texts(output_dir: Path):
"""Yield anonymized text outputs recursively for post-run checks."""
return output_dir.rglob("*.pseudonymise.txt")

View File

@@ -0,0 +1,43 @@
#define MyAppName "Anonymisation"
#define MyAppPublisher "CHCB"
#define MyAppExeName "Anonymisation.exe"
#ifndef AppVersion
#define AppVersion "1.0.0"
#endif
[Setup]
AppId={{6D11E4F8-26D8-4CFB-9F19-5A81E0637F56}
AppName={#MyAppName}
AppVersion={#AppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={localappdata}\Programs\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableDirPage=no
DisableProgramGroupPage=no
PrivilegesRequired=lowest
OutputDir=..\release
OutputBaseFilename=Anonymisation-Setup
SetupIconFile=..\assets\icons\app.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
[Files]
Source: "..\release\Anonymisation-Windows\Anonymisation.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\release\Anonymisation-Windows\README.txt"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@@ -8,6 +8,8 @@ from tkinter import ttk, messagebox
from pathlib import Path
import threading
import logging
import contextlib
import time
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
@@ -38,6 +40,216 @@ def _splash_close() -> None:
except Exception:
pass
class BrandedSplash:
"""Splash applicatif avec le visuel existant + progression détaillée.
PyInstaller affiche d'abord le splash natif pendant l'extraction du onefile.
Dès que Python est démarré, cette fenêtre prend le relais pour montrer des
étapes lisibles et un petit journal de chargement.
"""
def __init__(self, total_steps: int = 6):
self.total_steps = max(total_steps, 1)
self.current_step = 0
self.enabled = False
self.root = None
self.status_var = None
self.progress = None
self.log_box = None
self._image = None
self._lines = []
try:
self.root = tk.Tk()
self.root.withdraw()
self.root.title("aivanonym")
self.root.resizable(False, False)
self.root.overrideredirect(True)
self.root.configure(bg="white")
container = tk.Frame(
self.root,
bg="white",
highlightthickness=1,
highlightbackground="#d8d8d8",
)
container.pack(fill="both", expand=True)
splash_path = APP_DIR / "assets" / "splash.png"
if splash_path.exists():
self._image = tk.PhotoImage(file=str(splash_path))
tk.Label(container, image=self._image, bg="white", bd=0).pack()
else:
fallback = tk.Frame(container, bg="white", width=500, height=170)
fallback.pack_propagate(False)
fallback.pack()
tk.Frame(fallback, bg="#cc0000", height=4).pack(fill="x")
tk.Label(
fallback,
text="aivanonym",
bg="white",
fg="#222222",
font=("Segoe UI", 28),
).pack(expand=True)
body = tk.Frame(container, bg="white", padx=24, pady=14)
body.pack(fill="x")
self.status_var = tk.StringVar(value="Initialisation...")
tk.Label(
body,
textvariable=self.status_var,
bg="white",
fg="#222222",
font=("Segoe UI", 10, "bold"),
anchor="w",
).pack(fill="x")
self.progress = ttk.Progressbar(
body,
mode="determinate",
maximum=self.total_steps,
length=452,
)
self.progress.pack(fill="x", pady=(8, 10))
tk.Label(
body,
text="Chargements en cours",
bg="white",
fg="#666666",
font=("Segoe UI", 8),
anchor="w",
).pack(fill="x")
self.log_box = tk.Listbox(
body,
height=5,
activestyle="none",
bg="#f7f7f7",
fg="#333333",
bd=0,
highlightthickness=1,
highlightbackground="#e7e7e7",
font=("Consolas", 8),
)
self.log_box.pack(fill="x", pady=(4, 0))
self._center()
self.root.deiconify()
self.root.lift()
self.root.update_idletasks()
self.root.update()
self.enabled = True
# Le splash natif PyInstaller n'a qu'une ligne de texte. Une fois
# cette fenêtre prête, elle prend le relais sans changer le visuel.
_splash_close()
except Exception as exc:
try:
if self.root is not None:
self.root.destroy()
except Exception:
pass
self.root = None
log.warning(f"Branded splash unavailable: {exc}")
def _center(self) -> None:
if self.root is None:
return
self.root.update_idletasks()
width = self.root.winfo_reqwidth()
height = self.root.winfo_reqheight()
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
x = max(0, int((screen_width - width) / 2))
y = max(0, int((screen_height - height) / 2))
self.root.geometry(f"{width}x{height}+{x}+{y}")
def step(self, message: str) -> None:
self.current_step = min(self.current_step + 1, self.total_steps)
status = f"[{self.current_step}/{self.total_steps}] {message}"
self.message(status)
if self.progress is not None:
self.progress["value"] = self.current_step
self._pump()
def message(self, message: str) -> None:
_splash_update(message)
if self.enabled and self.status_var is not None:
self.status_var.set(message)
self._pump()
def detail(self, message: str) -> None:
_splash_update(message)
clean = " ".join(str(message).split())
if not clean:
return
if len(clean) > 150:
clean = clean[:147] + "..."
if self.enabled and self.log_box is not None:
self._lines.append(clean)
self._lines = self._lines[-7:]
self.log_box.delete(0, tk.END)
for line in self._lines:
self.log_box.insert(tk.END, line)
self.log_box.see(tk.END)
self._pump()
def close(self) -> None:
_splash_close()
if self.root is not None:
try:
self.root.destroy()
except Exception:
pass
self.root = None
self.enabled = False
def _pump(self) -> None:
if self.root is None:
return
try:
self.root.update_idletasks()
self.root.update()
except Exception:
self.enabled = False
class ModelProgressStream:
"""Redirige les sorties type tqdm vers une callback UI."""
def __init__(self, callback, prefix: str):
self.callback = callback
self.prefix = prefix
self.buffer = ""
self.last_line = ""
self.last_emit = 0.0
def write(self, data) -> int:
text = str(data)
self.buffer += text.replace("\r", "\n")
while "\n" in self.buffer:
line, self.buffer = self.buffer.split("\n", 1)
self._emit(line)
return len(text)
def flush(self) -> None:
if self.buffer:
self._emit(self.buffer)
self.buffer = ""
def _emit(self, line: str) -> None:
clean = " ".join(line.split())
if len(clean) < 3:
return
now = time.monotonic()
if clean == self.last_line and now - self.last_emit < 1.0:
return
self.last_line = clean
self.last_emit = now
self.callback(f"{self.prefix} : {clean}")
# ---------------------------------------------------------------------------
# Single-instance guard (lock file in user's temp directory)
# ---------------------------------------------------------------------------
@@ -105,23 +317,10 @@ def check_models_ready():
def launch_gui():
"""Launch the main GUI — étapes de chargement affichées DANS le splash natif.
Le splash natif PyInstaller (image avec logo + texte dynamique) reste
visible pendant TOUTE la phase de chargement. On intercepte les log.info()
du core via un logging.Handler et on pousse chaque étape traduite dans
le splash natif via pyi_splash.update_text(). L'utilisateur voit défiler
sous le logo :
"Chargement des prénoms français (INSEE)…"
"Chargement des noms de famille (INSEE)…"
"Chargement des numéros FINESS…"
Puis le splash se ferme et la GUI s'ouvre — pas de fenêtre intermédiaire.
En mode dev (pas frozen), pyi_splash n'existe pas ; on ajoute un
mini-splash tkinter temporaire pour voir le même rendu pendant le test.
"""
"""Launch the main GUI with visible startup progress."""
log.info("Launching GUI...")
progress = BrandedSplash(total_steps=5)
progress.step("Préparation de l'environnement")
# Traductions log.info() → libellés "prod" lisibles pour l'utilisateur.
_LOG_TRANSLATIONS = [
@@ -158,7 +357,7 @@ def launch_gui():
class _SplashHandler(logging.Handler):
def emit(self, record):
try:
_splash_update(_translate(record.getMessage()))
progress.detail(_translate(record.getMessage()))
except Exception:
pass
@@ -167,17 +366,24 @@ def launch_gui():
logging.getLogger().addHandler(_handler)
# Afficher tout de suite un message initial sous le logo
_splash_update("Démarrage…")
progress.detail("Démarrage du moteur applicatif")
# Import du core et de la GUI (synchrone : pas besoin de thread puisque
# le splash natif tourne dans son propre processus bootloader).
result = {"error": None}
try:
_splash_update("Chargement des dictionnaires médicaux")
progress.step("Chargement des dictionnaires médicaux")
import anonymizer_core_refactored_onnx # noqa
log.info("Core imported OK")
progress.step("Chargement du moteur d'anonymisation")
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI module imported OK")
progress.step("Vérification des modèles locaux")
if check_models_ready():
progress.detail("CamemBERT-bio ONNX local disponible")
else:
progress.detail("CamemBERT-bio ONNX non trouvé dans le bundle")
progress.step("Ouverture de l'interface")
except Exception as e:
result["error"] = f"{e}\n{traceback.format_exc()}"
log.error(f"Import error: {result['error']}")
@@ -188,8 +394,8 @@ def launch_gui():
except Exception:
pass
# Fermer le splash natif maintenant que tout est prêt
_splash_close()
# Fermer le splash maintenant que tout est prêt
progress.close()
if result["error"]:
try:
@@ -239,12 +445,19 @@ class SetupWindow:
def __init__(self):
self.root = tk.Tk()
self.root.title("Anonymisation — Configuration initiale")
self.root.geometry("620x450")
self.root.geometry("660x700")
self.root.resizable(False, False)
self._logo_image = None
self._log_lines = []
frame = ttk.Frame(self.root, padding=20)
frame = ttk.Frame(self.root, padding=18)
frame.pack(fill="both", expand=True)
splash_path = APP_DIR / "assets" / "splash.png"
if splash_path.exists():
self._logo_image = tk.PhotoImage(file=str(splash_path))
ttk.Label(frame, image=self._logo_image).pack(pady=(0, 8))
ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
font=("", 13, "bold")).pack(pady=(0, 4))
ttk.Label(
@@ -278,6 +491,22 @@ class SetupWindow:
font=("", 8)).pack(side="left")
self.step_labels[key] = icon
log_frame = ttk.LabelFrame(frame, text=" Détail du chargement ", padding=8)
log_frame.pack(fill="x", pady=(0, 12))
self.log_text = tk.Text(
log_frame,
height=7,
wrap="word",
state="disabled",
bg="#f7f7f7",
fg="#333333",
bd=0,
padx=8,
pady=6,
font=("Consolas", 8),
)
self.log_text.pack(fill="x")
# Bouton relance (caché au début)
self.btn = ttk.Button(frame, text="Relancer", command=self.start_download)
self.btn.pack(pady=6)
@@ -321,43 +550,54 @@ class SetupWindow:
try:
# 1. EDS-Pseudo
self._update("Téléchargement d'EDS-Pseudo… (modèle CamemBERT clinique)")
self._append_log("EDS-Pseudo : téléchargement/chargement du modèle AP-HP")
self._set_step("eds_pseudo", "running")
log.info("Downloading EDS-Pseudo...")
try:
from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager()
with self._capture_model_output("EDS-Pseudo"):
mgr.load()
self._set_step("eds_pseudo", "ok")
self._append_log("EDS-Pseudo : modèle prêt")
log.info("EDS-Pseudo OK")
except Exception as e:
self._set_step("eds_pseudo", "fail")
self._append_log(f"EDS-Pseudo : échec - {e}")
failures.append(("EDS-Pseudo", str(e)))
log.warning(f"EDS-Pseudo failed: {e}")
self._advance()
# 2. GLiNER
self._update("Téléchargement de GLiNER… (détection zero-shot)")
self._append_log("GLiNER : téléchargement/chargement du modèle PII")
self._set_step("gliner", "running")
log.info("Downloading GLiNER...")
try:
from gliner_manager import GlinerManager
mgr = GlinerManager()
with self._capture_model_output("GLiNER"):
mgr.load()
self._set_step("gliner", "ok")
self._append_log("GLiNER : modèle prêt")
log.info("GLiNER OK")
except Exception as e:
self._set_step("gliner", "fail")
self._append_log(f"GLiNER : échec - {e}")
failures.append(("GLiNER", str(e)))
log.warning(f"GLiNER failed: {e}")
self._advance()
# 3. CamemBERT-bio ONNX
self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
self._append_log("CamemBERT-bio ONNX : vérification du modèle embarqué")
self._set_step("camembert_onnx", "running")
if check_models_ready():
self._set_step("camembert_onnx", "ok")
self._append_log("CamemBERT-bio ONNX : modèle local présent")
else:
self._set_step("camembert_onnx", "fail")
self._append_log("CamemBERT-bio ONNX : fichier ONNX introuvable")
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
log.error("CamemBERT-bio ONNX not found")
self._advance()
@@ -384,6 +624,31 @@ class SetupWindow:
def _update(self, msg):
self.root.after(0, lambda: self.status_var.set(msg))
def _append_log(self, msg):
clean = " ".join(str(msg).split())
if not clean:
return
if len(clean) > 180:
clean = clean[:177] + "..."
def _apply():
self._log_lines.append(clean)
self._log_lines = self._log_lines[-80:]
self.log_text.configure(state="normal")
self.log_text.delete("1.0", tk.END)
self.log_text.insert("end", "\n".join(self._log_lines))
self.log_text.configure(state="disabled")
self.log_text.see("end")
self.root.after(0, _apply)
@contextlib.contextmanager
def _capture_model_output(self, label):
stream = ModelProgressStream(self._append_log, label)
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
yield
stream.flush()
def _finish(self):
try:
self.root.destroy()

56
manual_masking.py Normal file
View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
MASK_TEMPLATES_SUBDIR = Path("config") / "mask_templates"
MASK_TEMPLATE_EXTENSIONS = {".yml", ".yaml", ".json"}
DEFAULT_MASK_OUTPUT_DIRNAME = "anonymise"
DEFAULT_MASK_PREVIEW_DIRNAME = "anonymise_preview"
def mask_templates_dir(base_dir: Path) -> Path:
return base_dir / MASK_TEMPLATES_SUBDIR
def ensure_mask_templates_dir(base_dir: Path) -> Path:
path = mask_templates_dir(base_dir)
path.mkdir(parents=True, exist_ok=True)
return path
def resolve_manual_mask_pdf(single_file: Optional[Path]) -> Optional[Path]:
if single_file is None:
return None
if single_file.suffix.lower() != ".pdf":
return None
return single_file
def list_mask_templates(base_dir: Path) -> list[Path]:
templates_root = ensure_mask_templates_dir(base_dir)
return sorted(
path
for path in templates_root.rglob("*")
if path.is_file() and path.suffix.lower() in MASK_TEMPLATE_EXTENSIONS
)
def mask_template_label(path: Path, base_dir: Optional[Path] = None) -> str:
if base_dir is None:
return path.name
try:
return str(path.relative_to(mask_templates_dir(base_dir)))
except ValueError:
return path.name
def append_jsonl_file(target_path: Path, extra_path: Path) -> None:
if not target_path.exists() or not extra_path.exists():
return
extra_text = extra_path.read_text(encoding="utf-8").strip()
if not extra_text:
return
with target_path.open("a", encoding="utf-8") as target:
target.write(extra_text + "\n")

View File

@@ -17,6 +17,7 @@ Dépendances : PyMuPDF (pymupdf), Pillow, PyYAML
"""
from __future__ import annotations
import argparse
import io
import json
import math
@@ -31,7 +32,12 @@ from PIL import Image, ImageTk
import fitz # PyMuPDF
import yaml
APP_TITLE = "PDF Mask Designer (Standalone)"
from manual_masking import (
DEFAULT_MASK_OUTPUT_DIRNAME,
DEFAULT_MASK_PREVIEW_DIRNAME,
)
APP_TITLE = "Éditeur de masques PDF"
TEMPLATE_VERSION = 1
# ----------------------------- Data structures -----------------------------
@@ -167,7 +173,16 @@ def apply_template_raster(pdf_in: Path, pdf_out: Path, tpl: Template, dpi: int,
# ----------------------------- GUI ------------------------------
class MaskDesignerApp:
def __init__(self, root: tk.Tk):
def __init__(
self,
root: tk.Tk,
*,
initial_pdf: Optional[Path] = None,
initial_template: Optional[Path] = None,
templates_dir: Optional[Path] = None,
output_dir_name: str = DEFAULT_MASK_OUTPUT_DIRNAME,
preview_dir_name: str = DEFAULT_MASK_PREVIEW_DIRNAME,
):
self.root = root
self.root.title(APP_TITLE)
self.root.geometry("1280x900")
@@ -181,11 +196,18 @@ class MaskDesignerApp:
self.template_name = tk.StringVar(value="template_masks")
self.status = tk.StringVar(value="Prêt.")
self.raster_dpi = tk.IntVar(value=200)
self.templates_dir = templates_dir
self.output_dir_name = output_dir_name
self.preview_dir_name = preview_dir_name
self.is_drawing = False
self.start_xy: Optional[Tuple[int,int]] = None
self._build_ui()
if initial_pdf:
self.open_pdf_path(initial_pdf)
if initial_template:
self.load_template_path(initial_template)
# UI layout
def _build_ui(self):
@@ -228,14 +250,17 @@ class MaskDesignerApp:
def open_pdf(self):
path = filedialog.askopenfilename(filetypes=[("PDF", "*.pdf")])
if not path: return
self.open_pdf_path(Path(path))
def open_pdf_path(self, path: Path):
try:
self.doc = fitz.open(path)
self.doc = fitz.open(str(path))
self.doc_path = Path(path)
self.curr_page = 0
self.masks.clear()
self.template_name.set(self.doc_path.stem + "_template")
self.refresh()
self.status.set(f"PDF ouvert : {Path(path).name}{len(self.doc)} page(s)")
self.status.set(f"PDF ouvert : {self.doc_path.name}{len(self.doc)} page(s)")
except Exception as e:
messagebox.showerror("Erreur", f"Impossible d'ouvrir le PDF : {e}")
@@ -244,7 +269,7 @@ class MaskDesignerApp:
img = page_pix(self.doc, self.curr_page, self.zoom)
# overlay current page masks
rects = self.masks.get(self.curr_page, [])
img_o = draw_overlay(img, rects, 1.0, self.curr_page)
img_o = draw_overlay(img, rects, self.zoom, self.curr_page)
self.curr_image = img_o
self.tk_image = ImageTk.PhotoImage(img_o)
self.canvas.delete("all")
@@ -269,19 +294,25 @@ class MaskDesignerApp:
def on_down(self, ev):
if not self.doc: return
self.is_drawing = True
self.start_xy = (ev.x, ev.y)
self._preview_rect = self.canvas.create_rectangle(ev.x, ev.y, ev.x, ev.y, outline="#000", width=2)
x = self.canvas.canvasx(ev.x)
y = self.canvas.canvasy(ev.y)
self.start_xy = (x, y)
self._preview_rect = self.canvas.create_rectangle(x, y, x, y, outline="#000", width=2)
def on_drag(self, ev):
if not self.doc or not self.is_drawing: return
sx, sy = self.start_xy
self.canvas.coords(self._preview_rect, sx, sy, ev.x, ev.y)
x = self.canvas.canvasx(ev.x)
y = self.canvas.canvasy(ev.y)
self.canvas.coords(self._preview_rect, sx, sy, x, y)
def on_up(self, ev):
if not self.doc or not self.is_drawing: return
self.is_drawing = False
sx, sy = self.start_xy
x0, y0, x1, y1 = rect_norm(sx, sy, ev.x, ev.y)
x = self.canvas.canvasx(ev.x)
y = self.canvas.canvasy(ev.y)
x0, y0, x1, y1 = rect_norm(sx, sy, x, y)
# convert screen px to PDF points
page = self.doc[self.curr_page]
# we rendered with zoom, but here current image is at display resolution (zoom applied in page_pix)
@@ -311,9 +342,12 @@ class MaskDesignerApp:
tpl = self._current_template()
except Exception as e:
messagebox.showwarning("Info", str(e)); return
path = filedialog.asksaveasfilename(defaultextension=".yml",
path = filedialog.asksaveasfilename(
defaultextension=".yml",
filetypes=[("YAML", "*.yml *.yaml"), ("JSON", "*.json")],
initialfile=f"{tpl.name}.yml")
initialdir=str(self._template_initialdir()),
initialfile=f"{tpl.name}.yml",
)
if not path: return
p = Path(path)
try:
@@ -326,8 +360,14 @@ class MaskDesignerApp:
messagebox.showerror("Erreur", f"Impossible d'écrire le template : {e}")
def load_template(self):
path = filedialog.askopenfilename(filetypes=[("YAML/JSON", "*.yml *.yaml *.json")])
path = filedialog.askopenfilename(
filetypes=[("YAML/JSON", "*.yml *.yaml *.json")],
initialdir=str(self._template_initialdir()),
)
if not path: return
self.load_template_path(Path(path))
def load_template_path(self, path: Path):
p = Path(path)
try:
if p.suffix.lower() in (".yml", ".yaml"):
@@ -351,6 +391,14 @@ class MaskDesignerApp:
self.refresh()
self.status.set(f"Masques de la page {self.curr_page+1} supprimés.")
def _template_initialdir(self) -> Path:
if self.templates_dir is not None:
self.templates_dir.mkdir(parents=True, exist_ok=True)
return self.templates_dir
if self.doc_path is not None:
return self.doc_path.parent
return Path.cwd()
# Preview / Apply
def _build_template_from_state(self) -> Optional[Template]:
if not self.doc:
@@ -365,7 +413,7 @@ class MaskDesignerApp:
if not samp: return
for i, s in enumerate(samp[:2], start=1):
pdf_in = Path(s)
out_dir = pdf_in.parent / "masked_preview"
out_dir = pdf_in.parent / self.preview_dir_name
out_dir.mkdir(exist_ok=True)
pdf_out = out_dir / f"{pdf_in.stem}.preview_vector.pdf"
audit = out_dir / f"{pdf_in.stem}.audit.jsonl"
@@ -373,7 +421,10 @@ class MaskDesignerApp:
apply_template_vector(pdf_in, pdf_out, tpl, audit)
except Exception as e:
messagebox.showerror("Erreur", f"Prévisualisation vectorielle échouée sur {pdf_in.name} : {e}")
messagebox.showinfo("Prévisualisation", "Terminé (vectoriel). Ouvrez le dossier 'masked_preview'.")
messagebox.showinfo(
"Prévisualisation",
f"Terminé (vectoriel). Ouvrez le dossier '{self.preview_dir_name}'.",
)
def preview_raster(self):
tpl = self._build_template_from_state()
@@ -383,7 +434,7 @@ class MaskDesignerApp:
dpi = int(self.raster_dpi.get())
for i, s in enumerate(samp[:2], start=1):
pdf_in = Path(s)
out_dir = pdf_in.parent / "masked_preview"
out_dir = pdf_in.parent / self.preview_dir_name
out_dir.mkdir(exist_ok=True)
pdf_out = out_dir / f"{pdf_in.stem}.preview_raster.pdf"
audit = out_dir / f"{pdf_in.stem}.audit.jsonl"
@@ -391,7 +442,10 @@ class MaskDesignerApp:
apply_template_raster(pdf_in, pdf_out, tpl, dpi, audit)
except Exception as e:
messagebox.showerror("Erreur", f"Prévisualisation raster échouée sur {pdf_in.name} : {e}")
messagebox.showinfo("Prévisualisation", "Terminé (raster). Ouvrez le dossier 'masked_preview'.")
messagebox.showinfo(
"Prévisualisation",
f"Terminé (raster). Ouvrez le dossier '{self.preview_dir_name}'.",
)
def apply_vector_batch(self):
tpl = self._build_template_from_state()
@@ -400,7 +454,7 @@ class MaskDesignerApp:
if not files: return
for s in files:
pdf_in = Path(s)
out_dir = pdf_in.parent / "masked"
out_dir = pdf_in.parent / self.output_dir_name
out_dir.mkdir(exist_ok=True)
pdf_out = out_dir / f"{pdf_in.stem}.masked_vector.pdf"
audit = out_dir / f"{pdf_in.stem}.audit.jsonl"
@@ -418,7 +472,7 @@ class MaskDesignerApp:
dpi = int(self.raster_dpi.get())
for s in files:
pdf_in = Path(s)
out_dir = pdf_in.parent / "masked"
out_dir = pdf_in.parent / self.output_dir_name
out_dir.mkdir(exist_ok=True)
pdf_out = out_dir / f"{pdf_in.stem}.masked_raster.pdf"
audit = out_dir / f"{pdf_in.stem}.audit.jsonl"
@@ -430,9 +484,27 @@ class MaskDesignerApp:
# ----------------------------- Main ------------------------------
def main():
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Editeur de masques PDF reutilisables")
parser.add_argument("--pdf", type=Path, help="PDF de reference a ouvrir au demarrage")
parser.add_argument("--template", type=Path, help="Template YAML/JSON a charger au demarrage")
parser.add_argument("--templates-dir", type=Path, help="Dossier par defaut pour sauver/charger les templates")
parser.add_argument("--output-dir-name", default=DEFAULT_MASK_OUTPUT_DIRNAME, help="Nom du dossier de sortie pour l'application des masques")
parser.add_argument("--preview-dir-name", default=DEFAULT_MASK_PREVIEW_DIRNAME, help="Nom du dossier de sortie pour les previsualisations")
return parser
def main(argv: Optional[List[str]] = None):
args = build_arg_parser().parse_args(argv)
root = tk.Tk()
app = MaskDesignerApp(root)
app = MaskDesignerApp(
root,
initial_pdf=args.pdf,
initial_template=args.template,
templates_dir=args.templates_dir,
output_dir_name=args.output_dir_name,
preview_dir_name=args.preview_dir_name,
)
root.mainloop()
if __name__ == "__main__":

356
profile_defaults.py Normal file
View File

@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Helpers partagés pour les profils métier.
"""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict
try:
import yaml
except Exception:
yaml = None
from config_defaults import CONFIG_DIR, deep_merge_dict
DEFAULT_PROFILES_CONFIG_PATH = CONFIG_DIR / "profiles.default.yml"
RUNTIME_PROFILES_CONFIG_PATH = CONFIG_DIR / "profiles.yml"
_RUNTIME_PROFILES_OVERLAY_TEXT = """# Surcharge locale des profils métier.
# Source de vérité : config/profiles.default.yml
# Ne mettez ici que les écarts spécifiques à votre environnement.
#
# Exemples :
# default_profile: chcb_strict
# profiles:
# mon_profil:
# label: Mon profil
# description: Surcharge locale
# require_manual_mask: true
# force_disable_vlm: true
# preferred_manual_mask_template: chcb/formulaire.yml
# param_lists:
# whitelist_phrases:
# - Document validé DIM
# dictionaries_overlay:
# blacklist:
# force_mask_terms:
# - MON_ETAB
{}
"""
_FALLBACK_DEFAULT_PROFILES_TEXT = """version: 1
default_profile: standard_local
profiles:
standard_local:
label: Standard local
description: Profil par défaut pour les traitements internes.
require_manual_mask: false
force_disable_vlm: false
dictionaries_overlay: {}
chcb_strict:
label: CHCB strict
description: Profil conservateur pour le CHCB, orienté diffusion prudente.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay:
blacklist:
force_mask_terms:
- CHCB
- Centre Hospitalier de la Côte Basque
- CENTRE HOSPITALIER DE LA COTE BASQUE
partage_recherche:
label: Partage recherche
description: Profil externe strict. Le masque manuel est recommandé pour les formulaires répétitifs.
require_manual_mask: true
force_disable_vlm: true
dictionaries_overlay:
blacklist:
force_mask_terms:
- CHCB
- Centre Hospitalier de la Côte Basque
- CENTRE HOSPITALIER DE LA COTE BASQUE
dossier_audit:
label: Dossier audit
description: Profil orienté traçabilité et reproductibilité.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay: {}
demo:
label: Démo
description: Profil léger pour démonstration interne sur poste bureautique.
require_manual_mask: false
force_disable_vlm: true
dictionaries_overlay: {}
"""
_FALLBACK_DEFAULT_PROFILES_DICT: Dict[str, Any] = {
"version": 1,
"default_profile": "standard_local",
"profiles": {
"standard_local": {
"label": "Standard local",
"description": "Profil par défaut pour les traitements internes.",
"require_manual_mask": False,
"force_disable_vlm": False,
"dictionaries_overlay": {},
},
"chcb_strict": {
"label": "CHCB strict",
"description": "Profil conservateur pour le CHCB, orienté diffusion prudente.",
"require_manual_mask": False,
"force_disable_vlm": True,
"dictionaries_overlay": {
"blacklist": {
"force_mask_terms": [
"CHCB",
"Centre Hospitalier de la Côte Basque",
"CENTRE HOSPITALIER DE LA COTE BASQUE",
],
},
},
},
"partage_recherche": {
"label": "Partage recherche",
"description": (
"Profil externe strict. Le masque manuel est recommandé "
"pour les formulaires répétitifs."
),
"require_manual_mask": True,
"force_disable_vlm": True,
"dictionaries_overlay": {
"blacklist": {
"force_mask_terms": [
"CHCB",
"Centre Hospitalier de la Côte Basque",
"CENTRE HOSPITALIER DE LA COTE BASQUE",
],
},
},
},
"dossier_audit": {
"label": "Dossier audit",
"description": "Profil orienté traçabilité et reproductibilité.",
"require_manual_mask": False,
"force_disable_vlm": True,
"dictionaries_overlay": {},
},
"demo": {
"label": "Démo",
"description": "Profil léger pour démonstration interne sur poste bureautique.",
"require_manual_mask": False,
"force_disable_vlm": True,
"dictionaries_overlay": {},
},
},
}
def read_default_profiles_text() -> str:
try:
return DEFAULT_PROFILES_CONFIG_PATH.read_text(encoding="utf-8")
except Exception:
return _FALLBACK_DEFAULT_PROFILES_TEXT
def read_runtime_profiles_overlay_text() -> str:
return _RUNTIME_PROFILES_OVERLAY_TEXT
def load_default_profiles_dict() -> Dict[str, Any]:
text = read_default_profiles_text()
if yaml is not None:
try:
loaded = yaml.safe_load(text) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return deepcopy(_FALLBACK_DEFAULT_PROFILES_DICT)
def list_default_profile_keys() -> set[str]:
data = load_default_profiles_dict()
profiles = data.get("profiles", {}) or {}
if not isinstance(profiles, dict):
return set()
return {str(key) for key in profiles}
def load_runtime_profiles_overlay_dict(path: Path | None = None) -> Dict[str, Any]:
target = Path(path) if path is not None else RUNTIME_PROFILES_CONFIG_PATH
if not target.exists() or yaml is None:
return {}
try:
loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return {}
def load_effective_profiles_dict(path: Path | None = None) -> Dict[str, Any]:
return deep_merge_dict(
load_default_profiles_dict(),
load_runtime_profiles_overlay_dict(path),
)
def _normalize_string_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
normalized: list[str] = []
for value in values:
text = str(value).strip()
if text:
normalized.append(text)
return normalized
def _normalize_param_lists(value: Any) -> Dict[str, list[str]]:
if not isinstance(value, dict):
return {}
return {
"whitelist_phrases": _normalize_string_list(value.get("whitelist_phrases", [])),
"blacklist_force_mask_terms": _normalize_string_list(
value.get("blacklist_force_mask_terms", [])
),
"additional_stopwords": _normalize_string_list(value.get("additional_stopwords", [])),
}
def _write_runtime_profiles_overlay_dict(path: Path, data: Dict[str, Any]) -> Path:
if yaml is None:
raise RuntimeError("PyYAML indisponible")
body = yaml.safe_dump(
data or {},
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
header = (
"# Surcharge locale des profils métier.\n"
"# Source de vérité : config/profiles.default.yml\n"
"# Les profils créés depuis la GUI sont enregistrés ici.\n"
)
path.write_text(header + "\n" + body, encoding="utf-8")
return path
def ensure_runtime_profiles_config(path: Path | None = None) -> Path:
target = Path(path) if path is not None else RUNTIME_PROFILES_CONFIG_PATH
if not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(read_runtime_profiles_overlay_text(), encoding="utf-8")
return target
def list_effective_profiles(path: Path | None = None) -> Dict[str, Dict[str, Any]]:
data = load_effective_profiles_dict(path)
profiles = data.get("profiles", {}) or {}
if not isinstance(profiles, dict):
return {}
normalized: Dict[str, Dict[str, Any]] = {}
for key, value in profiles.items():
if not isinstance(value, dict):
continue
raw_param_lists = value.get("param_lists")
has_param_lists = isinstance(raw_param_lists, dict)
preferred_manual_mask_template = str(value.get("preferred_manual_mask_template") or "").strip()
normalized[str(key)] = {
"label": str(value.get("label") or key),
"description": str(value.get("description") or ""),
"require_manual_mask": bool(value.get("require_manual_mask", False)),
"force_disable_vlm": bool(value.get("force_disable_vlm", False)),
"dictionaries_overlay": deepcopy(value.get("dictionaries_overlay") or {}),
"param_lists": _normalize_param_lists(raw_param_lists),
"has_param_lists": has_param_lists,
"preferred_manual_mask_template": preferred_manual_mask_template,
"has_preferred_manual_mask_template": "preferred_manual_mask_template" in value,
}
return normalized
def get_default_profile_key(path: Path | None = None) -> str:
data = load_effective_profiles_dict(path)
key = str(data.get("default_profile") or "").strip()
profiles = list_effective_profiles(path)
if key and key in profiles:
return key
if profiles:
return next(iter(profiles))
return "standard_local"
def save_runtime_profile(
profile_key: str,
profile_spec: Dict[str, Any],
path: Path | None = None,
*,
set_default: bool = False,
) -> Path:
target = ensure_runtime_profiles_config(path)
data = load_runtime_profiles_overlay_dict(target)
if not isinstance(data, dict):
data = {}
profiles = data.get("profiles")
if not isinstance(profiles, dict):
profiles = {}
data["profiles"] = profiles
normalized_spec: Dict[str, Any] = {
"label": str(profile_spec.get("label") or profile_key),
"description": str(profile_spec.get("description") or ""),
"require_manual_mask": bool(profile_spec.get("require_manual_mask", False)),
"force_disable_vlm": bool(profile_spec.get("force_disable_vlm", False)),
"dictionaries_overlay": deepcopy(profile_spec.get("dictionaries_overlay") or {}),
}
if profile_spec.get("has_param_lists") or "param_lists" in profile_spec:
normalized_spec["param_lists"] = _normalize_param_lists(profile_spec.get("param_lists"))
if (
profile_spec.get("has_preferred_manual_mask_template")
or "preferred_manual_mask_template" in profile_spec
):
normalized_spec["preferred_manual_mask_template"] = str(
profile_spec.get("preferred_manual_mask_template") or ""
).strip()
profiles[str(profile_key)] = normalized_spec
if set_default:
data["default_profile"] = str(profile_key)
return _write_runtime_profiles_overlay_dict(target, data)
def set_runtime_default_profile(profile_key: str, path: Path | None = None) -> Path:
target = ensure_runtime_profiles_config(path)
data = load_runtime_profiles_overlay_dict(target)
if not isinstance(data, dict):
data = {}
data["default_profile"] = str(profile_key)
return _write_runtime_profiles_overlay_dict(target, data)
def delete_runtime_profile(profile_key: str, path: Path | None = None) -> Path:
target = ensure_runtime_profiles_config(path)
data = load_runtime_profiles_overlay_dict(target)
if not isinstance(data, dict):
data = {}
profiles = data.get("profiles")
if isinstance(profiles, dict):
profiles.pop(str(profile_key), None)
if not profiles:
data.pop("profiles", None)
if str(data.get("default_profile") or "").strip() == str(profile_key):
data["default_profile"] = "standard_local"
return _write_runtime_profiles_overlay_dict(target, data)

View File

@@ -17,8 +17,8 @@ PyYAML==6.0.2
# torch==2.3.1
# huggingface_hub==0.23.4
# (optionnel OCR pour PDF scannés, nécessite torch)
# python-doctr[torch]>=0.9.0
# --- OCR pour PDF scannés ---
python-doctr[torch]>=0.9.0
# (optionnel NER clinique EDS-Pseudo AP-HP, activer manuellement)
# edsnlp[ml]>=0.12.0

View File

@@ -0,0 +1,61 @@
param(
[string]$AppVersion = (Get-Date -Format "yyyy.MM.dd.HHmm")
)
$ErrorActionPreference = "Stop"
function Resolve-InnoCompiler {
$command = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$candidates = @()
if (${env:ProgramFiles(x86)}) {
$candidates += (Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe")
}
if ($env:ProgramFiles) {
$candidates += (Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
}
if ($env:LOCALAPPDATA) {
$candidates += (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
$candidates += (Join-Path $env:LOCALAPPDATA "Inno Setup 6\ISCC.exe")
}
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
return $candidate
}
}
throw "ISCC.exe introuvable. Installer Inno Setup 6 puis relancer."
}
function Require-Path {
param(
[string]$PathValue,
[string]$Label
)
if (-not (Test-Path $PathValue)) {
throw "$Label introuvable: $PathValue"
}
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path
$InstallerScriptPath = Join-Path $ProjectRoot "installer\Anonymisation.iss"
$PackageExePath = Join-Path $ProjectRoot "release\Anonymisation-Windows\Anonymisation.exe"
$InstallerPath = Join-Path $ProjectRoot "release\Anonymisation-Setup.exe"
Require-Path -PathValue $InstallerScriptPath -Label "Script Inno Setup"
Require-Path -PathValue $PackageExePath -Label "Executable package"
$innoCompiler = Resolve-InnoCompiler
Write-Host "Inno Setup Compiler : $innoCompiler"
& $innoCompiler "/DAppVersion=$AppVersion" $InstallerScriptPath
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup a echoue avec le code $LASTEXITCODE."
}
Require-Path -PathValue $InstallerPath -Label "Installateur Windows"
$installerSizeMb = [math]::Round((Get-Item $InstallerPath).Length / 1MB, 1)
Write-Host "Installateur pret : $InstallerPath ($installerSizeMb MB)"

View File

@@ -0,0 +1,369 @@
param(
[switch]$SkipZip,
[switch]$SkipInstaller,
[switch]$SkipRequirements,
[switch]$Sign,
[string]$CertThumbprint,
[string]$PfxPath,
[string]$PfxPassword,
[string]$TimestampServer = "http://timestamp.digicert.com"
)
$ErrorActionPreference = "Stop"
$script:SignatureSummary = "Non signé"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "=== $Message ===" -ForegroundColor Cyan
}
function Require-Path {
param(
[string]$PathValue,
[string]$Label
)
if (-not (Test-Path $PathValue)) {
throw "$Label introuvable: $PathValue"
}
}
function Invoke-BootstrapPython {
param([string[]]$Arguments)
if ($script:PythonBootstrap[0] -eq "py") {
& py $script:PythonBootstrap[1] @Arguments
} else {
& $script:PythonBootstrap[0] @Arguments
}
}
function Resolve-BootstrapPython {
if (Get-Command py -ErrorAction SilentlyContinue) {
try {
& py -3.11 --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("py", "-3.11")
}
} catch {}
try {
& py -3 --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("py", "-3")
}
} catch {}
}
if (Get-Command python -ErrorAction SilentlyContinue) {
& python --version | Out-Host
if ($LASTEXITCODE -eq 0) {
return @("python")
}
}
throw "Python introuvable sur la machine de build Windows."
}
function Resolve-SignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$programFilesX86 = ${env:ProgramFiles(x86)}
if ($programFilesX86) {
$kitsRoot = Join-Path $programFilesX86 "Windows Kits\10\bin"
if (Test-Path $kitsRoot) {
$candidates = @(
Get-ChildItem -Path $kitsRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "\\x64\\signtool\.exe$" } |
Sort-Object FullName -Descending
)
if ($candidates.Count -gt 0) {
return $candidates[0].FullName
}
}
}
throw "signtool.exe introuvable. Installer Windows SDK ou ajouter signtool.exe au PATH."
}
function Resolve-InnoCompiler {
$command = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$candidates = @()
if (${env:ProgramFiles(x86)}) {
$candidates += (Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe")
}
if ($env:ProgramFiles) {
$candidates += (Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
}
if ($env:LOCALAPPDATA) {
$candidates += (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
$candidates += (Join-Path $env:LOCALAPPDATA "Inno Setup 6\ISCC.exe")
}
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
return $candidate
}
}
return $null
}
function Invoke-CodeSigning {
param([string]$FilePath)
if (-not $Sign) {
Write-Host "Signature Authenticode ignorée. Utiliser -Sign pour signer l'exécutable."
return
}
Require-Path -PathValue $FilePath -Label "Fichier à signer"
if ($PfxPath) {
Require-Path -PathValue $PfxPath -Label "Certificat PFX"
}
$signTool = Resolve-SignTool
Write-Host "SignTool : $signTool"
if ($CertThumbprint -eq "REMPLACER_PAR_L_EMPREINTE_DU_CERTIFICAT") {
throw "Empreinte de certificat non renseignée dans build_signing.local.ps1."
}
$args = @("sign", "/fd", "SHA256", "/tr", $TimestampServer, "/td", "SHA256", "/d", "Anonymisation")
if ($PfxPath) {
$args += @("/f", $PfxPath)
if ($PfxPassword) {
$args += @("/p", $PfxPassword)
}
} elseif ($CertThumbprint) {
$args += @("/sha1", ($CertThumbprint -replace "\s", ""))
} else {
$args += @("/a")
}
$args += $FilePath
& $signTool @args
if ($LASTEXITCODE -ne 0) {
throw "La signature Authenticode a échoué."
}
& $signTool verify /pa /v $FilePath
if ($LASTEXITCODE -ne 0) {
throw "La vérification Authenticode a échoué."
}
$signature = Get-AuthenticodeSignature $FilePath
$subject = ""
if ($signature.SignerCertificate) {
$subject = $signature.SignerCertificate.Subject
}
$script:SignatureSummary = "$($signature.Status) - $subject"
Write-Host "Signature : $script:SignatureSummary"
if ($signature.Status -ne "Valid") {
throw "Signature Authenticode non valide : $($signature.Status)"
}
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = (Resolve-Path (Join-Path $ScriptDir "..")).Path
$SigningConfigPath = Join-Path $ProjectRoot "build_signing.local.ps1"
$SpecPath = Join-Path $ProjectRoot "anonymisation_onefile.spec"
$InstallerScriptPath = Join-Path $ProjectRoot "installer\Anonymisation.iss"
$BuildInfoPath = Join-Path $ProjectRoot "build_info.py"
$ModelPath = Join-Path $ProjectRoot "models\camembert-bio-deid\onnx\model.onnx"
$VenvDir = Join-Path $ProjectRoot ".venv_build_win"
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
$DistDir = Join-Path $ProjectRoot "dist"
$BuildDir = Join-Path $ProjectRoot "build"
$ReleaseDir = Join-Path $ProjectRoot "release"
$ExePath = Join-Path $DistDir "Anonymisation.exe"
$PackageDir = Join-Path $ReleaseDir "Anonymisation-Windows"
$ZipPath = Join-Path $ReleaseDir "Anonymisation-Windows.zip"
$HashPath = Join-Path $ReleaseDir "Anonymisation.exe.sha256.txt"
$InstallerPath = Join-Path $ReleaseDir "Anonymisation-Setup.exe"
$ReadmePath = Join-Path $PackageDir "README.txt"
$RequiredSourceFiles = @(
"launcher.py",
"Pseudonymisation_Gui_V5.py",
"anonymizer_core_refactored_onnx.py",
"admin_rules.py",
"config_defaults.py",
"profile_defaults.py",
"gui_batch_paths.py",
"manual_masking.py",
"pdf_mask_designer.py",
"format_converter.py",
"camembert_ner_manager.py"
)
Write-Step "Préparation du build Windows"
Write-Host "Projet : $ProjectRoot"
Require-Path -PathValue $SpecPath -Label "Spec PyInstaller"
Require-Path -PathValue $InstallerScriptPath -Label "Script installateur Inno Setup"
Require-Path -PathValue $ModelPath -Label "Modèle ONNX embarqué"
foreach ($RelativeSourceFile in $RequiredSourceFiles) {
Require-Path -PathValue (Join-Path $ProjectRoot $RelativeSourceFile) -Label "Module source requis"
}
if (Test-Path $SigningConfigPath) {
Write-Step "Configuration locale de signature"
. $SigningConfigPath
if ($BuildSigningEnabled) { $Sign = $true }
if ($BuildSigningCertThumbprint -and -not $CertThumbprint) { $CertThumbprint = $BuildSigningCertThumbprint }
if ($BuildSigningPfxPath -and -not $PfxPath) { $PfxPath = $BuildSigningPfxPath }
if ($BuildSigningPfxPassword -and -not $PfxPassword) { $PfxPassword = $BuildSigningPfxPassword }
if ($BuildSigningTimestampServer -and $TimestampServer -eq "http://timestamp.digicert.com") {
$TimestampServer = $BuildSigningTimestampServer
}
if ($Sign) {
Write-Host "Signature activée depuis build_signing.local.ps1"
}
}
Write-Step "Détection de Python"
$script:PythonBootstrap = Resolve-BootstrapPython
Write-Host "Bootstrap Python : $($script:PythonBootstrap -join ' ')"
Write-Step "Environnement virtuel de build"
if (-not (Test-Path $VenvPython)) {
Write-Host "Création du venv : $VenvDir"
Invoke-BootstrapPython -Arguments @("-m", "venv", $VenvDir)
}
Require-Path -PathValue $VenvPython -Label "Python du venv"
Push-Location $ProjectRoot
try {
Write-Step "Installation des dépendances de build"
& $VenvPython -m pip install --upgrade pip setuptools wheel
if (-not $SkipRequirements) {
& $VenvPython -m pip install -r requirements.txt
}
& $VenvPython -m pip install pyinstaller
Write-Step "Génération de build_info.py"
$commit = "local"
$branch = "local"
if (Get-Command git -ErrorAction SilentlyContinue) {
try {
$gitCommit = (git rev-parse --short HEAD 2>$null | Out-String).Trim()
if ($gitCommit) { $commit = $gitCommit }
$gitBranch = (git rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim()
if ($gitBranch) { $branch = $gitBranch }
} catch {}
}
$buildDate = Get-Date -Format "yyyy-MM-dd HH:mm"
$buildInfo = @"
"""Métadonnées de build - généré automatiquement par build_windows_oneclick.ps1."""
BUILD_DATE = "$buildDate"
BUILD_COMMIT = "$commit"
BUILD_BRANCH = "$branch"
"@
Set-Content -Path $BuildInfoPath -Value $buildInfo -Encoding UTF8
Write-Host "Build info : $buildDate / $branch / $commit"
Write-Step "Nettoyage des anciens artefacts"
foreach ($PathValue in @($BuildDir, $DistDir, $PackageDir)) {
if (Test-Path $PathValue) {
Remove-Item -Recurse -Force $PathValue -ErrorAction SilentlyContinue
}
}
if (Test-Path $ZipPath) {
Remove-Item -Force $ZipPath -ErrorAction SilentlyContinue
}
if (Test-Path $HashPath) {
Remove-Item -Force $HashPath -ErrorAction SilentlyContinue
}
if (Test-Path $InstallerPath) {
Remove-Item -Force $InstallerPath -ErrorAction SilentlyContinue
}
Write-Step "Compilation PyInstaller"
& $VenvPython -m PyInstaller --clean --noconfirm $SpecPath
if ($LASTEXITCODE -ne 0) {
throw "PyInstaller a échoué avec le code $LASTEXITCODE."
}
Write-Step "Vérification de l'exécutable"
Require-Path -PathValue $ExePath -Label "Exécutable Windows"
$exeSizeMb = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Write-Host "EXE créé : $ExePath ($exeSizeMb MB)"
Write-Step "Signature Authenticode"
Invoke-CodeSigning -FilePath $ExePath
Write-Step "Préparation du dossier de livraison"
New-Item -ItemType Directory -Force -Path $PackageDir | Out-Null
Copy-Item $ExePath (Join-Path $PackageDir "Anonymisation.exe")
$readme = @"
Anonymisation - paquet Windows
================================
Fichier principal :
- Anonymisation.exe
Conseils de diffusion :
- Aucune installation de Python n'est nécessaire pour l'utilisateur final.
- Conservez le fichier dans un dossier en écriture (par exemple Bureau ou Documents).
- Privilégiez une diffusion par partage réseau interne, Intune, GPO ou portail établissement.
- Évitez l'envoi direct par e-mail ou téléchargement public non signé.
- Le journal applicatif s'écrit à côté de l'exécutable : anonymisation.log
Build :
- Date : $buildDate
- Branche : $branch
- Commit : $commit
- Signature : $script:SignatureSummary
"@
Set-Content -Path $ReadmePath -Value $readme -Encoding UTF8
$hash = (Get-FileHash -Algorithm SHA256 $ExePath).Hash
Set-Content -Path $HashPath -Value "SHA256 Anonymisation.exe $hash" -Encoding UTF8
Write-Host "SHA256 : $hash"
if (-not $SkipZip) {
Write-Step "Création de l'archive de livraison"
Compress-Archive -Path (Join-Path $PackageDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal
Write-Host "Archive créée : $ZipPath"
}
if (-not $SkipInstaller) {
Write-Step "Création de l'installateur Windows"
$innoCompiler = Resolve-InnoCompiler
if ($innoCompiler) {
Write-Host "Inno Setup Compiler : $innoCompiler"
$installerVersion = (Get-Date -Format "yyyy.MM.dd.HHmm")
& $innoCompiler "/DAppVersion=$installerVersion" $InstallerScriptPath
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup a échoué avec le code $LASTEXITCODE."
}
Require-Path -PathValue $InstallerPath -Label "Installateur Windows"
$installerSizeMb = [math]::Round((Get-Item $InstallerPath).Length / 1MB, 1)
Write-Host "Installateur créé : $InstallerPath ($installerSizeMb MB)"
if ($Sign) {
Write-Step "Signature Authenticode de l'installateur"
Invoke-CodeSigning -FilePath $InstallerPath
}
} else {
Write-Warning "Inno Setup 6 introuvable. Installateur ignoré. Installer Inno Setup puis relancer le build."
Write-Warning "Téléchargement officiel : https://jrsoftware.org/isdl.php"
}
}
Write-Step "Build terminé"
Write-Host "EXE final : $ExePath" -ForegroundColor Green
if (-not $SkipZip) {
Write-Host "Archive prête : $ZipPath" -ForegroundColor Green
}
if ((-not $SkipInstaller) -and (Test-Path $InstallerPath)) {
Write-Host "Installateur prêt : $InstallerPath" -ForegroundColor Green
}
Write-Host "Hash SHA256 : $HashPath" -ForegroundColor Green
} finally {
Pop-Location
}

View File

@@ -0,0 +1,57 @@
param(
[string]$DownloadUrl = "https://jrsoftware.org/download.php/is.exe"
)
$ErrorActionPreference = "Stop"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "=== $Message ===" -ForegroundColor Cyan
}
function Find-InnoCompiler {
$candidates = @()
if (${env:ProgramFiles(x86)}) {
$candidates += (Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe")
}
if ($env:ProgramFiles) {
$candidates += (Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
}
if ($env:LOCALAPPDATA) {
$candidates += (Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
$candidates += (Join-Path $env:LOCALAPPDATA "Inno Setup 6\ISCC.exe")
}
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
return $candidate
}
}
return $null
}
$existing = Find-InnoCompiler
if ($existing) {
Write-Host "Inno Setup deja disponible : $existing"
exit 0
}
Write-Step "Telechargement Inno Setup"
$installerPath = Join-Path $env:TEMP "innosetup-build-dep.exe"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $installerPath
Write-Host "Installeur telecharge : $installerPath"
Write-Step "Installation Inno Setup utilisateur"
$args = @("/SP-", "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/CURRENTUSER")
$process = Start-Process -FilePath $installerPath -ArgumentList $args -Wait -PassThru
Write-Host "Code retour : $($process.ExitCode)"
if ($process.ExitCode -ne 0) {
throw "Installation Inno Setup echouee avec le code $($process.ExitCode)."
}
$compiler = Find-InnoCompiler
if (-not $compiler) {
throw "ISCC.exe introuvable apres installation Inno Setup."
}
Write-Host "Inno Setup pret : $compiler"