"""Onglet Configuration de la GUI V6. L'onglet est construit une seule fois : les sous-sections sont des panneaux préchargés puis simplement relevés avec ``tkraise()``. Cela évite le rendu progressif et les micro-latences visibles lors du passage Réglages/Masquage/ Partage/Règles. """ from __future__ import annotations import json import sys import webbrowser from pathlib import Path from tkinter import filedialog, messagebox import tkinter as tk from typing import Any import customtkinter as ctk from gui_v6 import theme as theme_mod from gui_v6 import ui_kit from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label _SUBTABS = [ ("reg", "⚙️ Réglages"), ("msk", "🎭 Masquage"), ("shr", "🔄 Partage"), ("rul", "🛡️ Règles 2"), ] _DETECTION_OPTIONS = [ ("Noms et prénoms", "Gazetteers + IA"), ("Dates de naissance", "Contexte naissance"), ("Établissements", "FINESS + contexte"), ("Adresses / CP", "Voie, ville, code"), ("N° sécurité sociale", "NIR"), ("Téléphones / e-mails", "Contact"), ("N° adhérent mutuelle", "Identifiant local"), ] _REPLACEMENT_CODES = [ ("Nom/Prénom", "[NOM]"), ("Date naissance", "[DATE_NAISSANCE]"), ("Établissement", "[ETABLISSEMENT]"), ("Adresse", "[ADRESSE]"), ("Téléphone", "[TEL]"), ("N° sécu", "[NIR]"), ("IPP", "[IPP]"), ("Email", "[EMAIL]"), ] _MASK_COLORS = [ ("Noir", "#000000"), ("Bleu nuit", "#1a1a2e"), ("Gris", "#374151"), ("Marron", "#92400e"), ("Bleu marine", "#1e3a5f"), ] _PRESERVE_TERMS = ["FUROSEMIDE", "rééducation fonctionnelle", "classification internationale"] _MASK_TERMS = ["CHUXX"] _STOPWORDS = ["hospitalisation", "contrôle", "prescription"] MANUAL_MASK_NONE_LABEL = "Aucun masque manuel" CONFIG_MOCKUP_SECTIONS = { "reglages": [ "Profil métier", "Moteurs NER", "Données à détecter", "Termes à toujours conserver", "Termes à toujours masquer", "Masque manuel obligatoire", "Template de masque manuel", ], "masquage": [ "Couleur de masquage (PDF)", "Style des marqueurs (texte)", "Épaisseur du masque", "Codes de remplacement", "Masques de zones fixes", "Éditeur interactif de masques", ], "partage": ["Exporter la configuration", "Importer une configuration"], "regles": ["Règles actives", "Testeur de règle"], } CONFIG_INTERACTION_CONTRACT = { "subtabs": "prebuilt_panels", "reglages_columns": 3, "mask_editor": [ "open_pdf", "draw_rectangle", "delete_rectangle_on_click", "zoom", "save_template_json", "load_template_json_or_yaml", "clear_page", "apply_template_selection", ], } def _app_base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path.cwd() def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) class ConfigTab(ctk.CTkFrame): def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs): self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME) super().__init__(master, fg_color=self._p["bg"], **kwargs) self._state = state if state is not None else ConfigState() self._sub = "reg" self._sub_buttons: dict[str, ctk.CTkButton] = {} self._panels: dict[str, ctk.CTkFrame] = {} self._manual_mask_templates: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None} self._manual_mask_var = ctk.StringVar(value=MANUAL_MASK_NONE_LABEL) self._manual_mask_required_var = ctk.BooleanVar(value=self._state.manual_mask_required) self._mask_style_var = ctk.StringVar(value=self._state.mask_marker_style) self._mask_color = self._state.mask_color self._mask_margin_x_var = ctk.IntVar(value=self._state.mask_margin_x) self._mask_margin_y_var = ctk.IntVar(value=self._state.mask_margin_y) self._mask_rounded_var = ctk.BooleanVar(value=self._state.mask_rounded_corners) self._mask_template_name = ctk.StringVar(value="template_masques") self._mask_dpi = ctk.IntVar(value=200) self._mask_zoom = 1.0 self._mask_zoom_text = ctk.StringVar(value="100%") self._mask_status_text = ctk.StringVar(value="Aucun PDF chargé") self._mask_count_text = ctk.StringVar(value="0 masque") self._mask_doc = None self._mask_pdf_path: Path | None = None self._mask_page_index = 0 self._mask_page_size = (595.0, 842.0) self._mask_rects: list[dict[str, Any]] = [] self._mask_photo = None self._mask_canvas: tk.Canvas | None = None self._mask_page_origin = (0.0, 0.0) self._mask_current_scale = 1.0 self._mask_drag_start: tuple[float, float] | None = None self._mask_preview_item: int | None = None self._build() @property def state(self) -> ConfigState: return self._state # -- construction ----------------------------------------------------- def _build(self) -> None: p = self._p bar = ctk.CTkFrame(self, fg_color="transparent") bar.pack(fill="x", padx=14, pady=(10, 2)) for key, label in _SUBTABS: btn = ctk.CTkButton( bar, text=label, command=lambda k=key: self._show_sub(k), fg_color=p["btn_sec_bg"] if key == self._sub else "transparent", hover_color=p["card_border"], text_color=p["primary"] if key == self._sub else p["text_dim"], border_color=p["card_border"], border_width=1 if key == self._sub else 0, font=ui_kit.font(13, "bold" if key == self._sub else "normal"), corner_radius=6, width=10, height=30, ) btn.pack(side="left", padx=(0, 6)) self._sub_buttons[key] = btn self._body = ctk.CTkFrame(self, fg_color="transparent") self._body.pack(fill="both", expand=True, padx=14, pady=(4, 0)) self._body.grid_columnconfigure(0, weight=1) self._body.grid_rowconfigure(0, weight=1) builders = { "reg": self._build_reglages, "msk": self._build_masquage, "shr": self._build_partage, "rul": self._build_regles, } for key, builder in builders.items(): panel = ctk.CTkFrame(self._body, fg_color="transparent") panel.grid(row=0, column=0, sticky="nsew") self._panels[key] = panel builder(panel) self._refresh_manual_mask_templates() self._show_sub("reg") def _show_sub(self, key: str) -> None: self._sub = key p = self._p for k, btn in self._sub_buttons.items(): active = k == key btn.configure( fg_color=p["btn_sec_bg"] if active else "transparent", border_width=1 if active else 0, text_color=p["primary"] if active else p["text_dim"], font=ui_kit.font(13, "bold" if active else "normal"), ) self._panels[key].tkraise() if key == "msk": self.after_idle(self._refresh_mask_canvas) # -- Réglages --------------------------------------------------------- def _build_reglages(self, parent) -> None: p = self._p top = ctk.CTkFrame( parent, fg_color=p["card"], border_color=p["card_border"], border_width=1, corner_radius=8, ) top.pack(fill="x", pady=(0, 8)) profiles = list_profile_keys() current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "") self._state.profile = current or None ctk.CTkLabel(top, text="Profil métier", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack( side="left", padx=(12, 8), pady=10 ) self._profile_menu = ctk.CTkOptionMenu( top, values=profiles or ["(aucun profil)"], command=self._on_profile, fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], text_color=p["text"], width=180, height=30, ) if current: self._profile_menu.set(current) self._profile_menu.pack(side="left", pady=10) ui_kit.secondary_button(top, p, "📁 Sortie…", command=self._pick_output).pack( side="left", padx=(12, 6), pady=10 ) self._out_label = ctk.CTkLabel( top, text=str(self._state.output_dir or "anonymise/"), text_color=p["text_muted"], font=ui_kit.font(12), ) self._out_label.pack(side="left", fill="x", expand=True, padx=(0, 12), pady=10) cols = self._columns(parent, 3, gap=8, height=455) det = ui_kit.Card(cols[0], p, title="🔍 Données à détecter") det.pack(fill="both", expand=True) for label, hint in _DETECTION_OPTIONS: self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1) ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques") ner.pack(fill="both", expand=True) self._tog_ner = self._mini_toggle( ner, "CamemBERT-bio", "rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner ) self._tog_ner.pack(fill="x", padx=12, pady=1) self._tog_eds = self._mini_toggle( ner, "EDS-Pseudo", "médical français", value=self._state.enable_eds, command=self._on_eds ) self._tog_eds.pack(fill="x", padx=12, pady=1) self._tog_gli = self._mini_toggle( ner, "GLiNER", "vote croisé", value=self._state.enable_gliner, command=self._on_gliner ) self._tog_gli.pack(fill="x", padx=12, pady=1) self._mini_toggle( ner, "Masque manuel obligatoire", "bloque le traitement si absent", value=self._state.manual_mask_required, variable=self._manual_mask_required_var, command=self._on_manual_mask_required, ).pack(fill="x", padx=12, pady=(8, 1)) ctk.CTkLabel( ner, text="Template de masque manuel", text_color=p["text_muted"], font=ui_kit.font(11, "bold"), anchor="w", ).pack(fill="x", padx=12, pady=(10, 2)) self._manual_mask_menu = ctk.CTkOptionMenu( ner, values=[MANUAL_MASK_NONE_LABEL], variable=self._manual_mask_var, command=self._on_manual_mask_template, fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"], text_color=p["text"], height=30, ) self._manual_mask_menu.pack(fill="x", padx=12, pady=(0, 6)) mask_actions = ctk.CTkFrame(ner, fg_color="transparent") mask_actions.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.secondary_button(mask_actions, p, "🔄 Actualiser", command=self._refresh_manual_mask_templates).pack( side="left", fill="x", expand=True, padx=(0, 4) ) ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack( side="left", fill="x", expand=True, padx=(4, 0) ) terms = ui_kit.Card(cols[2], p, title="✅ Listes locales") terms.pack(fill="both", expand=True) self._compact_tag_editor(terms, "Termes à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep") self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask") self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop") # -- Masquage --------------------------------------------------------- def _build_masquage(self, parent) -> None: p = self._p top_cols = self._columns(parent, 3, gap=8, height=300) pdf_opts = ui_kit.Card(top_cols[0], p, title="⬛ PDF") pdf_opts.pack(fill="both", expand=True) ctk.CTkLabel( pdf_opts, text="Couleur de masquage", text_color=p["text"], font=ui_kit.font(12, "bold") ).pack(anchor="w", padx=12, pady=(0, 4)) swatches = ctk.CTkFrame(pdf_opts, fg_color="transparent") swatches.pack(fill="x", padx=12, pady=(0, 8)) self._swatch_buttons: dict[str, ctk.CTkButton] = {} for label, color_value in _MASK_COLORS: btn = ctk.CTkButton( swatches, text="", width=30, height=26, fg_color=color_value, hover_color=color_value, border_color=p["primary"] if color_value == self._mask_color else p["card_border"], border_width=3 if color_value == self._mask_color else 1, corner_radius=6, command=lambda c=color_value: self._set_mask_color(c), ) btn.pack(side="left", padx=(0, 6)) self._swatch_buttons[color_value] = btn self._slider_row(pdf_opts, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x) self._slider_row(pdf_opts, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y) self._mini_toggle( pdf_opts, "Coins arrondis", "", value=self._state.mask_rounded_corners, variable=self._mask_rounded_var, command=self._on_rounded_corners, ).pack(fill="x", padx=12, pady=(4, 10)) text_opts = ui_kit.Card(top_cols[1], p, title="🏷️ Texte") text_opts.pack(fill="both", expand=True) for label, value, preview in [ ("Crochets", "brackets", "[NOM]"), ("Étoiles", "stars", "***"), ("Noirci", "blackout", "████"), ]: ctk.CTkRadioButton( text_opts, text=f"{label} {preview}", variable=self._mask_style_var, value=value, command=self._update_mask_preview, text_color=p["text"], fg_color=p["primary"], hover_color=p["primary_dim"], font=ui_kit.font(12), ).pack(anchor="w", padx=12, pady=2) self._mask_preview = ctk.CTkLabel( text_opts, text="Patient [NOM], né le [DATE_NAISSANCE]", text_color=p["text_dim"], fg_color=p["divider"], corner_radius=6, font=ui_kit.font(12), anchor="w", ) self._mask_preview.pack(fill="x", padx=12, pady=(8, 10), ipady=7) codes = ui_kit.Card(top_cols[2], p, title="🔒 Codes") codes.pack(fill="both", expand=True) grid = ctk.CTkFrame(codes, fg_color="transparent") grid.pack(fill="both", expand=True, padx=12, pady=(0, 10)) for idx, (label, code) in enumerate(_REPLACEMENT_CODES): ctk.CTkLabel(grid, text=label, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid( row=idx, column=0, sticky="w", pady=1 ) ctk.CTkLabel(grid, text=code, text_color=p["primary"], font=ui_kit.font(11, "bold"), anchor="w").grid( row=idx, column=1, sticky="w", padx=(8, 0), pady=1 ) grid.grid_columnconfigure(0, weight=1) editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes") editor.pack(fill="x", pady=(8, 0)) toolbar = ctk.CTkFrame(editor, fg_color="transparent") toolbar.pack(fill="x", padx=12, pady=(0, 8)) ui_kit.primary_button(toolbar, p, "📄 Ouvrir PDF…", command=self._open_mask_pdf).pack( side="left", padx=(0, 6) ) ui_kit.secondary_button(toolbar, p, "↙ Fenêtre complète", command=self._open_full_mask_editor).pack( side="left", padx=(0, 6) ) self._toolbar_button(toolbar, "←", lambda: self._move_mask_page(-1), width=38).pack(side="left", padx=(0, 4)) self._toolbar_button(toolbar, "→", lambda: self._move_mask_page(1), width=38).pack(side="left", padx=(0, 8)) self._toolbar_button(toolbar, "−", lambda: self._zoom_mask(-0.15), width=38).pack(side="left", padx=(0, 4)) ctk.CTkLabel(toolbar, textvariable=self._mask_zoom_text, text_color=p["text_muted"], font=ui_kit.font(12), width=46).pack( side="left" ) self._toolbar_button(toolbar, "+", lambda: self._zoom_mask(0.15), width=38).pack(side="left", padx=(4, 8)) work = ctk.CTkFrame(editor, fg_color="transparent") work.pack(fill="x", padx=12, pady=(0, 12)) canvas_wrap = ctk.CTkFrame( work, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8, height=292, ) canvas_wrap.pack(side="left", fill="both", expand=True, padx=(0, 8)) canvas_wrap.pack_propagate(False) self._mask_canvas = tk.Canvas( canvas_wrap, bg=p["divider"], highlightthickness=0, bd=0, height=288, cursor="crosshair", ) self._mask_canvas.pack(fill="both", expand=True, padx=6, pady=6) self._mask_canvas.bind("", self._on_mask_canvas_down) self._mask_canvas.bind("", self._on_mask_canvas_drag) self._mask_canvas.bind("", self._on_mask_canvas_up) self._mask_canvas.bind("", lambda _event: self._refresh_mask_canvas()) side = ctk.CTkFrame( work, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8, width=230, ) side.pack(side="left", fill="y") ctk.CTkLabel(side, text="Template", text_color=p["text_dim"], font=ui_kit.font(11, "bold"), anchor="w").pack( fill="x", padx=12, pady=(12, 2) ) ctk.CTkEntry( side, textvariable=self._mask_template_name, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30, ).pack(fill="x", padx=12, pady=(0, 8)) dpi_row = ctk.CTkFrame(side, fg_color="transparent") dpi_row.pack(fill="x", padx=12, pady=(0, 8)) ctk.CTkLabel(dpi_row, text="DPI raster", text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left") ctk.CTkEntry( dpi_row, textvariable=self._mask_dpi, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], width=62, height=28, justify="center", ).pack(side="right") ui_kit.secondary_button(side, p, "💾 Sauver JSON", command=self._save_mask_template).pack( fill="x", padx=12, pady=(0, 6) ) ui_kit.secondary_button(side, p, "📁 Charger", command=self._load_mask_template).pack( fill="x", padx=12, pady=(0, 6) ) ui_kit.secondary_button(side, p, "👁 Prévisualiser", command=self._preview_mask_template).pack( fill="x", padx=12, pady=(0, 6) ) ui_kit.primary_button(side, p, "▶ Appliquer", command=self._apply_mask_template_selection).pack( fill="x", padx=12, pady=(0, 10) ) clear_row = ctk.CTkFrame(side, fg_color="transparent") clear_row.pack(fill="x", padx=12, pady=(0, 8)) ui_kit.secondary_button(clear_row, p, "Page", command=self._clear_mask_page).pack(side="left", fill="x", expand=True, padx=(0, 4)) ui_kit.secondary_button(clear_row, p, "Tout", command=self._clear_all_masks).pack(side="left", fill="x", expand=True, padx=(4, 0)) ctk.CTkLabel(side, textvariable=self._mask_status_text, text_color=p["text_muted"], font=ui_kit.font(11), wraplength=190, justify="left").pack( fill="x", padx=12, pady=(4, 4) ) ctk.CTkLabel(side, textvariable=self._mask_count_text, text_color=p["primary"], font=ui_kit.font(13, "bold")).pack( fill="x", padx=12, pady=(0, 12) ) # -- Partage / Règles ------------------------------------------------- def _build_partage(self, parent) -> None: p = self._p cols = self._columns(parent, 2, gap=8, height=180) export = ui_kit.Card(cols[0], p, title="📤 Exporter la configuration") export.pack(fill="both", expand=True) self._note(export, "Listes locales, règles admin, style de masquage et template actif.") ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) import_card = ui_kit.Card(cols[1], p, title="📥 Importer une configuration") import_card.pack(fill="both", expand=True) self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.") ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=12, pady=(0, 12)) def _build_regles(self, parent) -> None: p = self._p card = ui_kit.Card(parent, p, title="🛡️ Règles actives") card.pack(fill="x", pady=(0, 8)) self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.") headers = ctk.CTkFrame(card, fg_color="transparent") headers.pack(fill="x", padx=12, pady=(0, 4)) for text, width in [("Label", 190), ("Type", 80), ("Cible → Résultat", 210), ("Statut", 70), ("", 70)]: ctk.CTkLabel(headers, text=text.upper(), width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left") for row in [ ("Masquer le sigle CHUXX", "exact", "CHUXX → [MASK]", "Actif"), ("Préserver “classification internationale”", "preserve", "conservé tel quel", "Actif"), ("Identifier N° 1234567", "norm-id", "N° 1234567 → [NDA]", "Candidat"), ]: self._rule_row(card, row) actions = ctk.CTkFrame(card, fg_color="transparent") actions.pack(fill="x", padx=12, pady=(8, 12)) ui_kit.primary_button(actions, p, "+ Nouvelle règle", command=lambda: None).pack(side="left", padx=(0, 8)) ui_kit.secondary_button(actions, p, "🔄 Recharger", command=lambda: None).pack(side="left") sim = ui_kit.Card(parent, p, title="🧪 Testeur de règle") sim.pack(fill="x") ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=12) txt = ctk.CTkTextbox(sim, height=74, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1) txt.pack(fill="x", padx=12, pady=(5, 8)) txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.") btns = ctk.CTkFrame(sim, fg_color="transparent") btns.pack(fill="x", padx=12, pady=(0, 12)) ui_kit.primary_button(btns, p, "▶ Tester", command=lambda: None).pack(side="left", padx=(0, 8)) ui_kit.secondary_button(btns, p, "✖ Fermer", command=lambda: None).pack(side="left") # -- callbacks réglages ---------------------------------------------- def _on_profile(self, value: str) -> None: self._state.profile = value def _on_ner(self) -> None: self._state.use_local_ner = self._tog_ner.get() def _on_eds(self) -> None: self._state.enable_eds = self._tog_eds.get() def _on_gliner(self) -> None: self._state.enable_gliner = self._tog_gli.get() def _on_manual_mask_required(self) -> None: self._state.manual_mask_required = bool(self._manual_mask_required_var.get()) def _on_manual_mask_template(self, label: str) -> None: self._state.manual_mask_template = self._manual_mask_templates.get(label) def _pick_output(self) -> None: path = filedialog.askdirectory(title="Dossier de sortie") if path: self._state.output_dir = Path(path) self._out_label.configure(text=str(self._state.output_dir)) def _refresh_manual_mask_templates(self) -> None: selected = self._state.manual_mask_template options: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None} try: for path in list_mask_templates(_app_base_dir()): options[mask_template_label(path, _app_base_dir())] = path except Exception: pass self._manual_mask_templates = options labels = list(options) if hasattr(self, "_manual_mask_menu"): self._manual_mask_menu.configure(values=labels) selected_label = MANUAL_MASK_NONE_LABEL if selected is not None: for label, path in options.items(): if path == selected: selected_label = label break self._manual_mask_var.set(selected_label) self._state.manual_mask_template = options.get(selected_label) def _open_templates_dir(self) -> None: path = ensure_mask_templates_dir(_app_base_dir()) try: webbrowser.open(path.as_uri()) except Exception: messagebox.showinfo("Dossier modèles", str(path)) # -- callbacks masquage ---------------------------------------------- def _set_mask_color(self, color: str) -> None: self._mask_color = color self._state.mask_color = color p = self._p for value, btn in self._swatch_buttons.items(): btn.configure( border_color=p["primary"] if value == color else p["card_border"], border_width=3 if value == color else 1, ) self._refresh_mask_canvas() def _on_mask_margin_x(self, value: float) -> None: self._state.mask_margin_x = int(round(value)) self._mask_margin_x_var.set(self._state.mask_margin_x) def _on_mask_margin_y(self, value: float) -> None: self._state.mask_margin_y = int(round(value)) self._mask_margin_y_var.set(self._state.mask_margin_y) def _on_rounded_corners(self) -> None: self._state.mask_rounded_corners = bool(self._mask_rounded_var.get()) def _update_mask_preview(self) -> None: value = self._mask_style_var.get() self._state.mask_marker_style = value if value == "stars": text = "Patient ***, né le ***" elif value == "blackout": text = "Patient ████, né le ████" else: text = "Patient [NOM], né le [DATE_NAISSANCE]" self._mask_preview.configure(text=text) # -- éditeur masques -------------------------------------------------- def _open_mask_pdf(self) -> None: path = filedialog.askopenfilename(title="PDF modèle", filetypes=[("PDF", "*.pdf")]) if not path: return try: import fitz doc = fitz.open(path) if len(doc) == 0: raise ValueError("PDF vide") if self._mask_doc is not None: try: self._mask_doc.close() except Exception: pass self._mask_doc = doc self._mask_pdf_path = Path(path) self._mask_page_index = 0 self._mask_page_size = (float(doc[0].rect.width), float(doc[0].rect.height)) self._mask_rects.clear() self._mask_template_name.set(f"{self._mask_pdf_path.stem}_template") self._mask_status_text.set(f"{self._mask_pdf_path.name} — page 1/{len(doc)}") self._update_mask_count() self._refresh_mask_canvas() except Exception as exc: messagebox.showerror("Masques PDF", f"Impossible d'ouvrir le PDF : {exc}") def _open_full_mask_editor(self) -> None: try: from pdf_mask_designer import MaskDesignerApp win = tk.Toplevel(self) MaskDesignerApp(win, templates_dir=ensure_mask_templates_dir(_app_base_dir())) except Exception as exc: messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur complet : {exc}") def _move_mask_page(self, delta: int) -> None: if self._mask_doc is None: return self._mask_page_index = int(_clamp(self._mask_page_index + delta, 0, len(self._mask_doc) - 1)) page = self._mask_doc[self._mask_page_index] self._mask_page_size = (float(page.rect.width), float(page.rect.height)) self._mask_status_text.set(f"{self._mask_pdf_path.name if self._mask_pdf_path else 'PDF'} — page {self._mask_page_index + 1}/{len(self._mask_doc)}") self._update_mask_count() self._refresh_mask_canvas() def _zoom_mask(self, delta: float) -> None: self._mask_zoom = _clamp(self._mask_zoom + delta, 0.55, 2.5) self._mask_zoom_text.set(f"{round(self._mask_zoom * 100)}%") self._refresh_mask_canvas() def _clear_mask_page(self) -> None: self._mask_rects = [m for m in self._mask_rects if int(m.get("page", 0)) != self._mask_page_index] self._mask_status_text.set(f"Masques page {self._mask_page_index + 1} supprimés.") self._update_mask_count() self._refresh_mask_canvas() def _clear_all_masks(self) -> None: self._mask_rects.clear() self._mask_status_text.set("Tous les masques ont été supprimés.") self._update_mask_count() self._refresh_mask_canvas() def _preview_mask_template(self) -> None: count = self._current_page_mask_count() self._mask_status_text.set(f"Prévisualisation : {count} masque(s) sur la page courante.") self._refresh_mask_canvas() def _apply_mask_template_selection(self) -> None: path = self._save_mask_template(silent=True) if path is None: return self._state.manual_mask_template = path self._manual_mask_required_var.set(True) self._on_manual_mask_required() self._refresh_manual_mask_templates() self._manual_mask_var.set(mask_template_label(path, _app_base_dir())) self._mask_status_text.set(f"Template actif : {path.name}") def _save_mask_template(self, silent: bool = False) -> Path | None: tpl = self._current_mask_template_payload() if not tpl["masks"]: self._mask_status_text.set("Dessinez au moins un masque avant de sauvegarder.") if not silent: messagebox.showwarning("Masques PDF", "Aucun masque défini.") return None initial_dir = ensure_mask_templates_dir(_app_base_dir()) default_name = f"{tpl['name'] or 'template_masques'}.json" if silent: path = initial_dir / default_name else: selected = filedialog.asksaveasfilename( title="Sauver le template", defaultextension=".json", filetypes=[("JSON", "*.json")], initialdir=str(initial_dir), initialfile=default_name, ) if not selected: return None path = Path(selected) try: path.write_text(json.dumps(tpl, ensure_ascii=False, indent=2), encoding="utf-8") self._mask_status_text.set(f"Template sauvegardé : {path.name}") self._refresh_manual_mask_templates() return path except Exception as exc: if not silent: messagebox.showerror("Masques PDF", f"Impossible d'écrire le template : {exc}") self._mask_status_text.set("Échec sauvegarde template.") return None def _load_mask_template(self) -> None: initial_dir = ensure_mask_templates_dir(_app_base_dir()) selected = filedialog.askopenfilename( title="Charger un template", filetypes=[("Templates", "*.json *.yml *.yaml")], initialdir=str(initial_dir), ) if not selected: return try: payload = self._read_mask_template(Path(selected)) self._mask_template_name.set(str(payload.get("name") or Path(selected).stem)) ps = payload.get("page_size") or {} self._mask_page_size = (float(ps.get("width", 595)), float(ps.get("height", 842))) self._mask_rects = [ { "page": int(m.get("page", 0)), "x0": float(m.get("x0", 0)), "y0": float(m.get("y0", 0)), "x1": float(m.get("x1", 0)), "y1": float(m.get("y1", 0)), "label": str(m.get("label", "MASK")), } for m in payload.get("masks", []) ] self._state.manual_mask_template = Path(selected) self._refresh_manual_mask_templates() self._manual_mask_var.set(mask_template_label(Path(selected), _app_base_dir())) self._mask_status_text.set(f"Template chargé : {Path(selected).name}") self._update_mask_count() self._refresh_mask_canvas() except Exception as exc: messagebox.showerror("Masques PDF", f"Template invalide : {exc}") def _read_mask_template(self, path: Path) -> dict[str, Any]: if path.suffix.lower() in {".yml", ".yaml"}: import yaml return yaml.safe_load(path.read_text(encoding="utf-8")) or {} return json.loads(path.read_text(encoding="utf-8")) def _current_mask_template_payload(self) -> dict[str, Any]: width, height = self._mask_page_size return { "version": 1, "name": self._mask_template_name.get().strip() or "template_masques", "page_size": {"width": width, "height": height}, "masks": [ { "page": int(m["page"]), "x0": round(float(m["x0"]), 2), "y0": round(float(m["y0"]), 2), "x1": round(float(m["x1"]), 2), "y1": round(float(m["y1"]), 2), "label": str(m.get("label", "MASK")), } for m in self._mask_rects ], } def _refresh_mask_canvas(self) -> None: canvas = self._mask_canvas if canvas is None: return canvas.delete("all") p = self._p width = max(360, canvas.winfo_width() or 420) height = max(260, canvas.winfo_height() or 318) page_w, page_h = self._mask_page_size fit_scale = min((width - 26) / page_w, (height - 26) / page_h) scale = max(0.1, fit_scale * self._mask_zoom) page_px_w = page_w * scale page_px_h = page_h * scale origin_x = max(12, (width - page_px_w) / 2) origin_y = 12 self._mask_page_origin = (origin_x, origin_y) self._mask_current_scale = scale if self._mask_doc is not None: try: from PIL import Image, ImageTk import fitz page = self._mask_doc[self._mask_page_index] pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), annots=False) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) self._mask_photo = ImageTk.PhotoImage(img) canvas.create_image(origin_x, origin_y, image=self._mask_photo, anchor="nw") except Exception: self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) else: self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) self._draw_mask_rectangles(canvas) if self._mask_doc is None: canvas.create_text( width / 2, height - 18, text="Ouvrez un PDF modèle, ou dessinez directement sur l'aperçu d'exemple.", fill=p["text_muted"], font=("TkDefaultFont", 9), ) def _draw_sample_page(self, canvas: tk.Canvas, x: float, y: float, w: float, h: float) -> None: p = self._p canvas.create_rectangle(x, y, x + w, y + h, fill="#f8fafc", outline=p["card_border"], width=1) header_h = min(54, h * 0.16) canvas.create_rectangle(x, y, x + w, y + header_h, fill="#e5e7eb", outline="") scale = self._mask_current_scale canvas.create_text(x + 18 * scale, y + 35 * scale, text="EN-TÊTE ÉTABLISSEMENT [LOGO]", fill="#6b7280", anchor="w", font=("TkDefaultFont", 10, "bold")) canvas.create_text(x + 18 * scale, y + 72 * scale, text="Service de cardiologie | Tel : 05.59.XX.XX.XX", fill="#6b7280", anchor="w", font=("TkDefaultFont", 8)) for idx, line in enumerate( [ "Patient : Dupont Jean Né le : 12/03/1955", "IPP : 1234567 NDA : 8901234", "Motif : Insuffisance cardiaque décompensée.", "Traitement : FUROSEMIDE 40mg, BISOPROLOL 5mg.", "Signé : Dr Martin RPPS 12345678", ] ): canvas.create_text(x + 28 * scale, y + (128 + idx * 32) * scale, text=line, fill="#111827", anchor="w", font=("TkDefaultFont", 8)) def _draw_mask_rectangles(self, canvas: tk.Canvas) -> None: x0, y0 = self._mask_page_origin scale = self._mask_current_scale for idx, mask in enumerate(self._mask_rects): if int(mask.get("page", 0)) != self._mask_page_index: continue rx0 = x0 + float(mask["x0"]) * scale ry0 = y0 + float(mask["y0"]) * scale rx1 = x0 + float(mask["x1"]) * scale ry1 = y0 + float(mask["y1"]) * scale canvas.create_rectangle(rx0, ry0, rx1, ry1, fill=self._mask_color, outline=self._p["primary"], width=2, tags=(f"mask-{idx}",)) canvas.create_text((rx0 + rx1) / 2, (ry0 + ry1) / 2, text="×", fill="#ffffff", font=("TkDefaultFont", 10, "bold")) def _on_mask_canvas_down(self, event) -> None: canvas = self._mask_canvas if canvas is None: return point = self._canvas_to_pdf(event.x, event.y) if point is None: return hit = self._mask_at(point) if hit is not None: del self._mask_rects[hit] self._mask_status_text.set("Masque supprimé.") self._update_mask_count() self._refresh_mask_canvas() return self._mask_drag_start = point x, y = event.x, event.y self._mask_preview_item = canvas.create_rectangle(x, y, x, y, outline=self._p["primary"], width=2, dash=(4, 2)) def _on_mask_canvas_drag(self, event) -> None: canvas = self._mask_canvas if canvas is None or self._mask_drag_start is None or self._mask_preview_item is None: return start = self._pdf_to_canvas(self._mask_drag_start) if start is None: return canvas.coords(self._mask_preview_item, start[0], start[1], event.x, event.y) def _on_mask_canvas_up(self, event) -> None: canvas = self._mask_canvas if canvas is None or self._mask_drag_start is None: return end = self._canvas_to_pdf(event.x, event.y) if self._mask_preview_item is not None: canvas.delete(self._mask_preview_item) self._mask_preview_item = None start = self._mask_drag_start self._mask_drag_start = None if end is None: return x0, y0 = start x1, y1 = end rx0, rx1 = sorted([x0, x1]) ry0, ry1 = sorted([y0, y1]) if (rx1 - rx0) < 5 or (ry1 - ry0) < 5: return self._mask_rects.append( {"page": self._mask_page_index, "x0": rx0, "y0": ry0, "x1": rx1, "y1": ry1, "label": "MASK"} ) self._mask_status_text.set(f"Masque ajouté page {self._mask_page_index + 1}.") self._update_mask_count() self._refresh_mask_canvas() def _canvas_to_pdf(self, x: float, y: float) -> tuple[float, float] | None: ox, oy = self._mask_page_origin scale = self._mask_current_scale page_w, page_h = self._mask_page_size px = (x - ox) / scale py = (y - oy) / scale if px < 0 or py < 0 or px > page_w or py > page_h: return None return px, py def _pdf_to_canvas(self, point: tuple[float, float]) -> tuple[float, float] | None: ox, oy = self._mask_page_origin scale = self._mask_current_scale return ox + point[0] * scale, oy + point[1] * scale def _mask_at(self, point: tuple[float, float]) -> int | None: px, py = point for idx in range(len(self._mask_rects) - 1, -1, -1): mask = self._mask_rects[idx] if int(mask.get("page", 0)) != self._mask_page_index: continue if float(mask["x0"]) <= px <= float(mask["x1"]) and float(mask["y0"]) <= py <= float(mask["y1"]): return idx return None def _current_page_mask_count(self) -> int: return sum(1 for m in self._mask_rects if int(m.get("page", 0)) == self._mask_page_index) def _update_mask_count(self) -> None: page_count = self._current_page_mask_count() total = len(self._mask_rects) self._mask_count_text.set(f"{page_count} masque(s) page · {total} total") # -- helpers UI ------------------------------------------------------- def _columns(self, parent, count: int, gap: int = 8, height: int | None = None) -> list[ctk.CTkFrame]: row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x") if height is not None: row.configure(height=height) row.pack_propagate(False) row.grid_rowconfigure(0, weight=1) frames: list[ctk.CTkFrame] = [] for idx in range(count): row.grid_columnconfigure(idx, weight=1, uniform="config-cols") frame = ctk.CTkFrame(row, fg_color="transparent") frame.grid(row=0, column=idx, sticky="nsew", padx=(0 if idx == 0 else gap // 2, 0 if idx == count - 1 else gap // 2)) frames.append(frame) return frames def _note(self, parent, text: str) -> None: p = self._p ctk.CTkLabel( parent, text=text, text_color=p["text_muted"], fg_color=p["divider"], corner_radius=4, font=ui_kit.font(11), anchor="w", justify="left", wraplength=330, ).pack(fill="x", padx=12, pady=(0, 10), ipady=5) def _mini_toggle(self, parent, label: str, hint: str, value: bool = True, variable=None, command=None): p = self._p row = ctk.CTkFrame(parent, fg_color="transparent", height=34) row.pack_propagate(False) left = ctk.CTkFrame(row, fg_color="transparent") left.pack(side="left", fill="x", expand=True) ctk.CTkLabel(left, text=label, text_color=p["text"], font=ui_kit.font(12), anchor="w").pack(anchor="w") if hint: ctk.CTkLabel(left, text=hint, text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w") var = variable if variable is not None else ctk.BooleanVar(value=value) switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38) switch.pack(side="right", padx=(6, 0)) row.var = var # type: ignore[attr-defined] row.get = lambda: bool(var.get()) # type: ignore[attr-defined] return row def _compact_tag_editor(self, parent, title: str, placeholder: str, terms: list[str], kind: str) -> None: p = self._p color = {"keep": p["success"], "mask": p["primary"], "stop": p["warning"]}.get(kind, p["primary"]) ctk.CTkLabel(parent, text=title, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack( fill="x", padx=12, pady=(0, 2) ) row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=12, pady=(0, 5)) ctk.CTkEntry( row, placeholder_text=placeholder, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=28, ).pack(side="left", fill="x", expand=True, padx=(0, 6)) ui_kit.secondary_button(row, p, "+").pack(side="right") cloud = ctk.CTkFrame(parent, fg_color="transparent") cloud.pack(fill="x", padx=12, pady=(0, 8)) for term in terms[:2]: display = f"{term[:18]}{'…' if len(term) > 18 else ''} ×" ctk.CTkLabel( cloud, text=display, width=150, anchor="w", text_color=color, fg_color=p["btn_sec_bg"], corner_radius=99, font=ui_kit.font(10), ).pack(anchor="w", fill="x", pady=2, ipadx=5, ipady=2) def _slider_row(self, parent, label: str, variable: ctk.IntVar, command) -> None: p = self._p row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=12, pady=2) ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(12)).pack(side="left") ctk.CTkLabel(row, textvariable=variable, text_color=p["primary"], font=ui_kit.font(11, "bold"), width=20).pack( side="right" ) slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=96, command=command) slider.set(variable.get()) slider.pack(side="right", padx=(8, 4)) def _toolbar_button(self, parent, text: str, command, width: int = 42): p = self._p return ctk.CTkButton( parent, text=text, command=command, fg_color=p["btn_sec_bg"], hover_color=p["card_border"], text_color=p["text"], border_color=p["btn_sec_border"], border_width=1, corner_radius=8, height=32, width=width, font=ui_kit.font(13, "bold"), ) def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None: p = self._p label, rule_type, target, status = values row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=12, pady=1) ctk.CTkLabel(row, text=label, width=190, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left") ctk.CTkLabel(row, text=rule_type, width=80, anchor="w", text_color=p["text_dim"], font=ui_kit.font(11)).pack(side="left") ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left") color = p["success"] if status == "Actif" else p["warning"] ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left") ui_kit.secondary_button(row, p, "▶ Tester", command=lambda: None).pack(side="left")