Compare commits
1 Commits
675e328d8c
...
backup/win
| Author | SHA1 | Date | |
|---|---|---|---|
| 1abee3e089 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
406
admin_rules.py
Normal 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
91
anonymisation.spec
Normal 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',
|
||||
)
|
||||
@@ -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
17
build_signing.example.ps1
Normal 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"
|
||||
28
build_windows_installer_oneclick.bat
Normal file
28
build_windows_installer_oneclick.bat
Normal 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
|
||||
27
build_windows_oneclick.bat
Normal file
27
build_windows_oneclick.bat
Normal 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
309
build_windows_oneclick.ps1
Normal 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
|
||||
}
|
||||
18
config/mask_templates/FC19_template.yml
Normal file
18
config/mask_templates/FC19_template.yml
Normal 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
|
||||
48
config/profiles.default.yml
Normal file
48
config/profiles.default.yml
Normal 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
53
config/profiles.yml
Normal 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
200
config_defaults.py
Normal 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
66
dictionnaires.yml
Normal 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
|
||||
119
docs/build-windows-oneclick.md
Normal file
119
docs/build-windows-oneclick.md
Normal 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
298
docs/memoire-projet.md
Normal 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
43
gui_batch_paths.py
Normal 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")
|
||||
43
installer/Anonymisation.iss
Normal file
43
installer/Anonymisation.iss
Normal 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
|
||||
315
launcher.py
315
launcher.py
@@ -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()
|
||||
mgr.load()
|
||||
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()
|
||||
mgr.load()
|
||||
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
56
manual_masking.py
Normal 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")
|
||||
@@ -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",
|
||||
filetypes=[("YAML", "*.yml *.yaml"), ("JSON", "*.json")],
|
||||
initialfile=f"{tpl.name}.yml")
|
||||
path = filedialog.asksaveasfilename(
|
||||
defaultextension=".yml",
|
||||
filetypes=[("YAML", "*.yml *.yaml"), ("JSON", "*.json")],
|
||||
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
356
profile_defaults.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
61
scripts/build_windows_installer_only.ps1
Normal file
61
scripts/build_windows_installer_only.ps1
Normal 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)"
|
||||
369
scripts/build_windows_oneclick.ps1
Normal file
369
scripts/build_windows_oneclick.ps1
Normal 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
|
||||
}
|
||||
57
scripts/install_inno_setup_build_dep.ps1
Normal file
57
scripts/install_inno_setup_build_dep.ps1
Normal 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"
|
||||
Reference in New Issue
Block a user