Files
anonymisation/gui_v6/tabs/tab_config.py

1105 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("<ButtonPress-1>", self._on_mask_canvas_down)
self._mask_canvas.bind("<B1-Motion>", self._on_mask_canvas_drag)
self._mask_canvas.bind("<ButtonRelease-1>", self._on_mask_canvas_up)
self._mask_canvas.bind("<Configure>", 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")