feat(ui): refonte UI — logo aivanonym + palette magenta/pêche + onglets + v5.5

Intégration du logo "aivanonym" (gradient magenta → rose → pêche → noir)
fourni par le propriétaire. Refonte visuelle complète :

• APP_VERSION bump v5.4 → v5.5

• Assets (tous générés depuis assets/icons/logo.png) :
  - assets/icons/app.ico multi-résolution 16→256 (icône EXE Windows)
  - assets/icons/icon_{16,32,48,64,128,256,512}.png (fallback + taskbar)
  - assets/logo_header.png (260×61, intégré dans l'en-tête de la GUI)
  - assets/logo_splash.png (335×80, intégré dans le splash)
  - assets/splash.png redessiné avec logo + bandeau gradient primary→accent

• Palette dérivée du logo (remplace l'ancien bleu) :
  - CLR_PRIMARY       #E91E63  magenta logo (CTA, liens)
  - CLR_PRIMARY_DARK  #C2185B  hover / pressed
  - CLR_PRIMARY_LIGHT #FCE4EC  fond doux (tags, cartes)
  - CLR_ACCENT        #FFB74D  pêche logo (secondaire)
  - CLR_ACCENT_LIGHT  #FFF3E0
  - CLR_TEXT/SECONDARY proches du noir/gris du logo

• Pseudonymisation_Gui_V5.py :
  - Helper _asset(name) : résout sous sys._MEIPASS/assets en mode frozen
  - _apply_window_icon() : iconbitmap (.ico sur Windows) + iconphoto (PNG)
  - _load_image_safe() : charge PIL avec ref persistante (évite GC tkinter)
  - Header fixe hors onglets : logo image + baseline "100% local"
  - Ligne accent magenta sous le header (inspiration logo)
  - Onglets custom uniformes (remplace ttk.Notebook dont les tabs avaient
    des tailles variables selon l'état) : tous les boutons identiques,
    seule une bordure basse magenta signale l'onglet actif. _switch_tab()
    gère l'affichage du contenu et la mise à jour des styles.
  - Onglet 1 "Anonymisation" : workflow principal (choix, lancer, résultats)
  - Onglet 2 "Paramètres" : 3 listes (whitelist/blacklist/stopwords) +
    export/import + save. Plus de section repliable — respiration visuelle.
  - Boutons export/import repensés avec les couleurs de la palette

• anonymisation_onefile.spec :
  - datas : ajout du dossier assets/ entier
  - EXE(icon=assets/icons/app.ico) : le .exe a maintenant le logo dans
    l'Explorateur Windows, la barre des tâches, le gestionnaire des tâches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 22:04:41 +02:00
parent 6586b89b8f
commit ab5a24fa68
14 changed files with 247 additions and 72 deletions

View File

@@ -83,11 +83,18 @@ try:
except ImportError:
sv_ttk = None
# PIL pour charger le logo / icônes (optionnel — dégradation si absent).
try:
from PIL import Image, ImageTk
_PIL_AVAILABLE = True
except Exception:
_PIL_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------
APP_TITLE = "Pseudonymisation de vos documents"
APP_VERSION = "v5.4"
APP_VERSION = "v5.5"
# Métadonnées de build — chargées depuis build_info.py (régénéré par rebuild_anon.ps1)
try:
@@ -107,6 +114,15 @@ def _version_long() -> str:
parts.append(f"#{BUILD_COMMIT}")
return " · ".join(parts)
def _asset(name: str) -> Path:
"""Résout le chemin d'un asset dans assets/ (compatible frozen PyInstaller)."""
if getattr(sys, 'frozen', False):
base = Path(sys._MEIPASS)
else:
base = Path(__file__).resolve().parent
return base / 'assets' / name
def _app_dir() -> Path:
"""Répertoire racine de l'application (compatible PyInstaller/Nuitka)."""
if getattr(sys, 'frozen', False):
@@ -168,19 +184,27 @@ flags:
regex_engine: "python"
"""
# Couleurs
CLR_PRIMARY = "#2563eb"
CLR_PRIMARY_LIGHT = "#dbeafe"
CLR_GREEN = "#16a34a"
CLR_GREEN_LIGHT = "#dcfce7"
CLR_RED = "#dc2626"
CLR_RED_LIGHT = "#fee2e2"
CLR_BLUE_LIGHT = "#eff6ff"
CLR_CARD_BG = "#ffffff"
CLR_CARD_BORDER = "#d1d5db"
CLR_BG = "#f9fafb"
CLR_TEXT = "#111827"
CLR_TEXT_SECONDARY = "#6b7280"
# Palette dérivée du logo aivanonym (gradient magenta → rose → pêche → noir)
# Magenta du logo : primaire (boutons, accents)
# Pêche : secondaire (tags, highlights)
# Noir/gris : texte et neutres
# Blanc/gris clair : fonds
CLR_PRIMARY = "#E91E63" # magenta logo (CTA, liens)
CLR_PRIMARY_DARK = "#C2185B" # hover / pressed
CLR_PRIMARY_LIGHT = "#FCE4EC" # fond léger (cartes sélectionnées)
CLR_ACCENT = "#FFB74D" # pêche logo (tags secondaires)
CLR_ACCENT_LIGHT = "#FFF3E0" # fond accent léger
CLR_GREEN = "#2E7D32" # succès
CLR_GREEN_LIGHT = "#E8F5E9"
CLR_RED = "#C62828" # erreur / danger
CLR_RED_LIGHT = "#FFEBEE"
CLR_BLUE_LIGHT = "#FCE4EC" # conservé pour compat (remappé vers primary_light)
CLR_CARD_BG = "#FFFFFF"
CLR_CARD_BORDER = "#E0E0E0"
CLR_BG = "#FAFAFA" # fond principal (gris très clair)
CLR_TEXT = "#212121" # quasi-noir (du logo)
CLR_TEXT_SECONDARY = "#757575" # gris moyen
CLR_DIVIDER = "#EEEEEE"
# ---------------------------------------------------------------------------
# Messages worker → UI
@@ -307,6 +331,16 @@ class App:
self.root.geometry("780x820")
self.root.minsize(600, 650)
# Icône de la fenêtre (coin haut-gauche + taskbar Windows).
# En mode dev (Linux) tkinter lit iconphoto PNG ; sur Windows, iconbitmap
# accepte .ico. On tente les deux pour couvrir.
self._icon_refs: list = [] # refs pour éviter garbage collection
self._apply_window_icon()
# Préchargement logo pour l'en-tête (besoin de ref persistante sinon
# tkinter nettoie l'image → label blanc).
self._logo_img = self._load_image_safe(_asset('logo_header.png'))
# --- Thème ---
self._apply_theme()
@@ -360,6 +394,8 @@ class App:
# --- Construction UI ---
self._build_ui()
# Afficher l'onglet Anonymisation par défaut
self._switch_tab("anonym")
self._pump_logs()
self._ensure_cfg_exists()
self._load_cfg()
@@ -367,6 +403,63 @@ class App:
# --- Chargement automatique du modèle NER ---
self._auto_load_ner()
# ---------------------------------------------------------------
# Onglets custom
# ---------------------------------------------------------------
def _switch_tab(self, name: str):
"""Affiche l'onglet nommé, met à jour les styles des boutons."""
if name not in self._tab_frames:
return
# Cacher tous les contenus
for frame in self._tab_frames.values():
frame.pack_forget()
# Afficher l'onglet demandé
self._tab_frames[name].pack(fill=tk.BOTH, expand=True)
# Mettre à jour les styles des boutons d'onglets
for tab_name, widgets in self._tab_buttons.items():
if tab_name == name:
widgets["label"].configure(fg=CLR_PRIMARY, bg=CLR_BG)
widgets["underline"].configure(bg=CLR_PRIMARY)
else:
widgets["label"].configure(fg=CLR_TEXT_SECONDARY, bg=CLR_BG)
widgets["underline"].configure(bg=CLR_BG)
self._active_tab = name
# ---------------------------------------------------------------
# Icônes & assets
# ---------------------------------------------------------------
def _apply_window_icon(self):
"""Définit l'icône de la fenêtre. Windows : .ico préférable ; Linux : PNG."""
try:
ico = _asset('icons/app.ico')
if sys.platform == 'win32' and ico.exists():
try:
self.root.iconbitmap(str(ico))
return
except Exception:
pass
# Fallback : iconphoto PNG (toutes plateformes)
png = _asset('icons/icon_128.png')
if png.exists() and _PIL_AVAILABLE:
img = Image.open(png)
photo = ImageTk.PhotoImage(img)
self._icon_refs.append(photo)
self.root.iconphoto(True, photo)
except Exception:
pass # dégradation silencieuse — l'icône n'est pas bloquante
def _load_image_safe(self, path: Path):
"""Charge une image et garde la ref pour éviter le GC. None si PIL absent."""
if not _PIL_AVAILABLE or not path.exists():
return None
try:
img = Image.open(path).convert('RGBA')
photo = ImageTk.PhotoImage(img)
self._icon_refs.append(photo)
return photo
except Exception:
return None
# ---------------------------------------------------------------
# Thème
# ---------------------------------------------------------------
@@ -386,15 +479,89 @@ class App:
# ---------------------------------------------------------------
def _build_ui(self):
self.root.configure(bg=CLR_BG)
pad_x = 32
# Conteneur scrollable
outer = tk.Frame(self.root, bg=CLR_BG)
outer.pack(fill=tk.BOTH, expand=True)
# =============================================================
# HEADER fixe (logo + titre + baseline), hors onglets
# =============================================================
header = tk.Frame(self.root, bg=CLR_BG)
header.pack(fill=tk.X, padx=pad_x, pady=(16, 8))
canvas = tk.Canvas(outer, bg=CLR_BG, highlightthickness=0)
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
if self._logo_img is not None:
tk.Label(header, image=self._logo_img, bg=CLR_BG).pack(anchor="w")
else:
tk.Label(header, text="aivanonym", font=(self._font_family, 22, "bold"),
bg=CLR_BG, fg=CLR_PRIMARY).pack(anchor="w")
tk.Label(
header,
text="Pseudonymisation de documents médicaux — 100% local",
font=(self._font_family, 10),
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X, pady=(4, 0))
# Ligne colorée inspirée du gradient du logo
accent_bar = tk.Frame(self.root, bg=CLR_PRIMARY, height=3)
accent_bar.pack(fill=tk.X)
# =============================================================
# ONGLETS CUSTOM (boutons uniformes — rendu pro)
# Remplace ttk.Notebook dont les onglets ont des tailles/styles
# variables selon l'état actif. Ici : tous les onglets identiques,
# seule une bordure basse magenta signale l'onglet actif.
# =============================================================
tabs_bar = tk.Frame(self.root, bg=CLR_BG)
tabs_bar.pack(fill=tk.X, padx=0, pady=(4, 0))
self._tab_frames: dict = {} # nom → frame outer
self._tab_buttons: dict = {} # nom → dict(container, label, underline)
self._active_tab: Optional[str] = None
def _make_tab_button(parent, name: str, label: str):
"""Crée un onglet cliquable uniforme (fond, texte, underline)."""
container = tk.Frame(parent, bg=CLR_BG, cursor="hand2")
container.pack(side=tk.LEFT)
txt = tk.Label(
container, text=label,
font=(self._font_family, 11, "bold"),
bg=CLR_BG, fg=CLR_TEXT_SECONDARY,
padx=26, pady=10, cursor="hand2",
)
txt.pack(fill=tk.X)
# Bordure basse qui devient magenta quand actif
underline = tk.Frame(container, bg=CLR_BG, height=3)
underline.pack(fill=tk.X)
def _on_click(_e=None):
self._switch_tab(name)
for w in (container, txt, underline):
w.bind("<Button-1>", _on_click)
self._tab_buttons[name] = {
"container": container, "label": txt, "underline": underline,
}
_make_tab_button(tabs_bar, "anonym", "Anonymisation")
_make_tab_button(tabs_bar, "params", "Paramètres")
# Séparateur gris clair sous les onglets
tk.Frame(self.root, bg=CLR_DIVIDER, height=1).pack(fill=tk.X)
# Conteneur des contenus (un seul visible à la fois)
tabs_content = tk.Frame(self.root, bg=CLR_BG)
tabs_content.pack(fill=tk.BOTH, expand=True)
tab_anonym_outer = tk.Frame(tabs_content, bg=CLR_BG)
tab_params_outer = tk.Frame(tabs_content, bg=CLR_BG)
self._tab_frames["anonym"] = tab_anonym_outer
self._tab_frames["params"] = tab_params_outer
# --- Scroll pour l'onglet Anonymisation ---
canvas = tk.Canvas(tab_anonym_outer, bg=CLR_BG, highlightthickness=0)
scrollbar = ttk.Scrollbar(tab_anonym_outer, orient=tk.VERTICAL, command=canvas.yview)
self._scroll_frame = tk.Frame(canvas, bg=CLR_BG)
self._scroll_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
@@ -402,12 +569,10 @@ class App:
canvas_window = canvas.create_window((0, 0), window=self._scroll_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
# Ajuster la largeur du frame interne à celle du canvas
def _on_canvas_configure(event):
canvas.itemconfig(canvas_window, width=event.width)
canvas.bind("<Configure>", _on_canvas_configure)
# Scroll molette
def _on_mousewheel(event):
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def _on_mousewheel_linux(event):
@@ -415,30 +580,32 @@ class App:
canvas.yview_scroll(-3, "units")
elif event.num == 5:
canvas.yview_scroll(3, "units")
canvas.bind_all("<MouseWheel>", _on_mousewheel)
canvas.bind_all("<Button-4>", _on_mousewheel_linux)
canvas.bind_all("<Button-5>", _on_mousewheel_linux)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# --- Scroll pour l'onglet Paramètres ---
canvas2 = tk.Canvas(tab_params_outer, bg=CLR_BG, highlightthickness=0)
scrollbar2 = ttk.Scrollbar(tab_params_outer, orient=tk.VERTICAL, command=canvas2.yview)
self._params_scroll = tk.Frame(canvas2, bg=CLR_BG)
self._params_scroll.bind(
"<Configure>",
lambda e: canvas2.configure(scrollregion=canvas2.bbox("all")),
)
canvas2_window = canvas2.create_window((0, 0), window=self._params_scroll, anchor="nw")
canvas2.configure(yscrollcommand=scrollbar2.set)
def _on_canvas2_configure(event):
canvas2.itemconfig(canvas2_window, width=event.width)
canvas2.bind("<Configure>", _on_canvas2_configure)
canvas2.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar2.pack(side=tk.RIGHT, fill=tk.Y)
# "main" pointe désormais sur le scroll de l'onglet Anonymisation.
# Tout le contenu existant (étape 1, formats, boutons, progress, résultats)
# reste inchangé — seul le parent implicite a changé.
main = self._scroll_frame
pad_x = 32
# --- Titre ---
tk.Label(
main, text=APP_TITLE, font=self._f_title,
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(24, 2))
tk.Label(
main,
text="Masquez automatiquement les données personnelles de vos documents.",
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
# =============================================================
# ÉTAPE 1 — Choix du dossier
@@ -558,70 +725,71 @@ class App:
help_lbl.bind("<Button-1>", lambda e: self._show_help())
# =============================================================
# SECTION PARAMÈTRES (repliable)
# ONGLET "PARAMÈTRES" — contenu monté dans self._params_scroll
# =============================================================
self._params_visible = False
params_toggle = tk.Label(
main, text="\u2699 Paramètres avancés \u25B6", font=self._f_small,
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
)
params_toggle.pack(pady=(0, 4), padx=pad_x, anchor="w")
self._params_frame = self._params_scroll
self._params_frame = tk.Frame(main, bg=CLR_BG)
# NE PAS pack — déplié à la demande
tk.Label(
self._params_frame,
text="Personnaliser le masquage",
font=(self._font_family, 14, "bold"),
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(20, 4))
def _toggle_params(event=None):
if self._params_visible:
self._params_frame.pack_forget()
params_toggle.configure(text="\u2699 Paramètres avancés \u25B6")
else:
self._params_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
params_toggle.configure(text="\u2699 Paramètres avancés \u25BC")
self._params_visible = not self._params_visible
params_toggle.bind("<Button-1>", _toggle_params)
tk.Label(
self._params_frame,
text=("Ces listes complètent les détections automatiques du programme. "
"Utile pour gérer les spécificités de votre établissement."),
font=self._f_small,
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w", justify=tk.LEFT, wraplength=700,
).pack(fill=tk.X, padx=pad_x, pady=(0, 16))
# Conteneur interne avec padding latéral pour les listboxes
params_inner = tk.Frame(self._params_frame, bg=CLR_BG)
params_inner.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
# --- Whitelist (phrases à ne pas anonymiser) ---
self._wl_listbox, self._wl_entry = self._build_phrase_list(
self._params_frame,
params_inner,
title="\u2705 Phrases à ne PAS anonymiser :",
placeholder="Ajouter une phrase à protéger...",
color_tag="#e8f5e9",
color_tag=CLR_GREEN_LIGHT,
)
# --- Blacklist (phrases à toujours masquer) ---
self._bl_listbox, self._bl_entry = self._build_phrase_list(
self._params_frame,
params_inner,
title="\u26d4 Mots/phrases à TOUJOURS masquer :",
placeholder="Ajouter un mot ou phrase à masquer...",
color_tag="#fce4ec",
color_tag=CLR_PRIMARY_LIGHT,
)
# --- Stop-words additionnels (mots à ne jamais identifier comme noms) ---
# Différent de la whitelist : agit en amont, pour les sigles, acronymes,
# termes métier locaux qui ressemblent à des noms mais n'en sont pas.
self._sw_listbox, self._sw_entry = self._build_phrase_list(
self._params_frame,
params_inner,
title="\u26a0 Mots à ne jamais identifier comme noms (sigles, acronymes...) :",
placeholder="Ajouter un mot (ex: sigle local, acronyme métier)...",
color_tag="#fff8e1",
color_tag=CLR_ACCENT_LIGHT,
)
# Boutons sauvegarder + exporter
btn_row = tk.Frame(self._params_frame, bg=CLR_BG)
btn_row.pack(fill=tk.X, pady=(4, 4))
btn_row = tk.Frame(params_inner, bg=CLR_BG)
btn_row.pack(fill=tk.X, pady=(12, 12))
export_btn = tk.Button(
btn_row, text="\u2709 Exporter pour envoi",
font=self._f_small, bg="#e3f2fd", fg="#1565c0",
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
font=self._f_small, bg=CLR_ACCENT_LIGHT, fg=CLR_TEXT,
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
command=self._export_params,
)
export_btn.pack(side=tk.LEFT)
import_btn = tk.Button(
btn_row, text="\u2B07 Importer",
font=self._f_small, bg="#fff3e0", fg="#e65100",
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
font=self._f_small, bg=CLR_PRIMARY_LIGHT, fg=CLR_TEXT,
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
command=self._import_params,
)
import_btn.pack(side=tk.LEFT, padx=(4, 0))
@@ -629,8 +797,8 @@ class App:
save_btn = tk.Button(
btn_row, text="Sauvegarder",
font=self._f_small, bg=CLR_PRIMARY, fg="white",
activebackground="#1d4ed8", activeforeground="white",
relief=tk.FLAT, cursor="hand2", padx=12, pady=4,
activebackground=CLR_PRIMARY_DARK, activeforeground="white",
relief=tk.FLAT, cursor="hand2", padx=14, pady=6,
command=self._save_params,
)
save_btn.pack(side=tk.RIGHT)
@@ -638,6 +806,7 @@ class App:
# Charger les valeurs initiales depuis la config
self._load_params()
# Retour dans l'onglet Anonymisation
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 8))
# =============================================================

View File

@@ -10,6 +10,10 @@ datas = [
(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,
@@ -80,5 +84,7 @@ exe = EXE(
strip=False,
upx=False,
console=False,
icon=None,
# 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'),
)

BIN
assets/icons/app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

BIN
assets/icons/icon_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
assets/icons/icon_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

BIN
assets/icons/icon_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
assets/icons/icon_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
assets/icons/icon_48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
assets/icons/icon_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icons/icon_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/logo_header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
assets/logo_splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB