diff --git a/Pseudonymisation_Gui_V5.py b/Pseudonymisation_Gui_V5.py index a2be728..472b7ca 100644 --- a/Pseudonymisation_Gui_V5.py +++ b/Pseudonymisation_Gui_V5.py @@ -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("", _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( "", 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("", _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("", _on_mousewheel) canvas.bind_all("", _on_mousewheel_linux) canvas.bind_all("", _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( + "", + 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("", _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("", 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("", _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)) # ============================================================= diff --git a/anonymisation_onefile.spec b/anonymisation_onefile.spec index a8ba508..edfa6a5 100644 --- a/anonymisation_onefile.spec +++ b/anonymisation_onefile.spec @@ -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'), ) diff --git a/assets/icons/app.ico b/assets/icons/app.ico new file mode 100644 index 0000000..0415fd0 Binary files /dev/null and b/assets/icons/app.ico differ diff --git a/assets/icons/icon_128.png b/assets/icons/icon_128.png new file mode 100644 index 0000000..f818e3d Binary files /dev/null and b/assets/icons/icon_128.png differ diff --git a/assets/icons/icon_16.png b/assets/icons/icon_16.png new file mode 100644 index 0000000..4173000 Binary files /dev/null and b/assets/icons/icon_16.png differ diff --git a/assets/icons/icon_256.png b/assets/icons/icon_256.png new file mode 100644 index 0000000..6c4a7a2 Binary files /dev/null and b/assets/icons/icon_256.png differ diff --git a/assets/icons/icon_32.png b/assets/icons/icon_32.png new file mode 100644 index 0000000..4ec575d Binary files /dev/null and b/assets/icons/icon_32.png differ diff --git a/assets/icons/icon_48.png b/assets/icons/icon_48.png new file mode 100644 index 0000000..24973f1 Binary files /dev/null and b/assets/icons/icon_48.png differ diff --git a/assets/icons/icon_512.png b/assets/icons/icon_512.png new file mode 100644 index 0000000..3181aad Binary files /dev/null and b/assets/icons/icon_512.png differ diff --git a/assets/icons/icon_64.png b/assets/icons/icon_64.png new file mode 100644 index 0000000..57903ad Binary files /dev/null and b/assets/icons/icon_64.png differ diff --git a/assets/icons/logo.png b/assets/icons/logo.png new file mode 100644 index 0000000..d029764 Binary files /dev/null and b/assets/icons/logo.png differ diff --git a/assets/logo_header.png b/assets/logo_header.png new file mode 100644 index 0000000..4918405 Binary files /dev/null and b/assets/logo_header.png differ diff --git a/assets/logo_splash.png b/assets/logo_splash.png new file mode 100644 index 0000000..7a3c073 Binary files /dev/null and b/assets/logo_splash.png differ diff --git a/assets/splash.png b/assets/splash.png index 8e39b24..8620acf 100644 Binary files a/assets/splash.png and b/assets/splash.png differ