Files
anonymisation/gui_v6/mask_editor_window.py
Domi31tls 13b79db417 feat(gui): éditeur de masques en fenêtre dédiée (GUI V6)
Remplace l'éditeur de masquage encastré dans l'onglet Configuration —
jugé inutilisable par Dom (document trop à l'étroit, non défilable) —
par une fenêtre dédiée où le document est majoritaire et réellement
navigable.

- gui_v6/mask_editor_model.py : couche logique pure (rectangles par
  page, conversions écran↔PDF, hit-test, sérialisation template)
  testable sans display ; réutilise MaskRect/Template de
  pdf_mask_designer → format de template inchangé (compat moteur).
- gui_v6/mask_editor_window.py : MaskEditorWindow (CTkToplevel)
  redimensionnable — canvas + scrollbars H+V câblées + molette (le
  manque qui rendait l'éditeur inutilisable), zoom + ajuster
  largeur/page, navigation pages, rectangles au glisser-déposer,
  sélection (clic) + suppression (Suppr / clic-droit), templates
  JSON/YAML, mode aperçu d'exemple sans PDF.
- tab_config.py : l'onglet Masquage lance la fenêtre dédiée ; retrait
  du canvas encastré et de ~290 lignes de code mort associé.
- tests/unit/test_gui_v6_mask_editor.py : 13 tests logique + 3 smoke
  headless (scrollbars, ajout/sélection/suppression, save/load
  roundtrip, câblage onglet→fenêtre).

Sans nouvelle dépendance. V5, moteur et app_aivanov non touchés.
221 tests unit OK (0 régression), self-test GUI V6 OK.
Verdict Qwen requis avant push/build/diffusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:05:57 +02:00

498 lines
20 KiB
Python
Raw Permalink 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.
"""Fenêtre dédiée d'édition des masques de zones fixes (GUI V6).
Remplace l'éditeur encastré dans l'onglet Configuration : ici le document est
majoritaire et réellement navigable (scroll H+V + molette, zoom, ajuster
largeur/page, navigation pages), avec création de rectangles au glisser-déposer
et sélection/suppression individuelle. Le format de template (JSON/YAML) reste
celui de `pdf_mask_designer.Template` (compat moteur).
La logique métier est dans `gui_v6.mask_editor_model` (testée sans display) ; ce
module ne contient que la fine couche Tk + des coutures testables en smoke.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable, Optional
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
from gui_v6.mask_editor_model import (
MaskEditorModel,
clamp_zoom,
fit_page_zoom,
fit_width_zoom,
pdf_to_screen,
screen_to_pdf,
)
WINDOW_TITLE = "Éditeur de masques PDF"
_CLICK_DRAG_THRESHOLD = 4.0 # px : en-deçà, un relâchement = sélection, pas un tracé
class MaskEditorWindow(ctk.CTkToplevel):
def __init__(
self,
master,
*,
templates_dir: Optional[Path] = None,
initial_pdf: Optional[Path] = None,
initial_template: Optional[Path] = None,
on_template_saved: Optional[Callable[[Path], None]] = None,
) -> None:
super().__init__(master)
self.title(WINDOW_TITLE)
self.geometry("1280x900")
self.minsize(900, 640)
self.model = MaskEditorModel()
self.zoom = 1.25
self.templates_dir = Path(templates_dir) if templates_dir else None
self._on_template_saved = on_template_saved
self._doc = None # fitz.Document si PyMuPDF dispo
self._doc_path: Optional[Path] = None
self._photo = None # garde une réf à l'image Tk
self._selected: Optional[Any] = None # MaskRect sélectionné
self._drag_start: Optional[tuple[float, float]] = None
self._preview_item: Optional[int] = None
self._template_name = tk.StringVar(value="template_masques")
self._status = tk.StringVar(value="Ouvrez un PDF modèle, ou dessinez sur l'aperçu d'exemple.")
self._page_label = tk.StringVar(value="")
self._zoom_label = tk.StringVar(value="125 %")
self._count_label = tk.StringVar(value="0 masque")
self._h_scroll: Optional[tk.Scrollbar] = None
self._v_scroll: Optional[tk.Scrollbar] = None
self._canvas: Optional[tk.Canvas] = None
self._build_ui()
self._render()
if initial_pdf:
self.open_pdf_path(Path(initial_pdf))
# Continuité : si un template est déjà actif, on le charge pour que
# l'utilisateur reprenne son travail au lieu de repartir d'une page vierge.
if initial_template:
try:
self.load_template_path(Path(initial_template))
except Exception:
pass
try:
self.transient(master)
self.after(120, self._safe_grab)
except Exception:
pass
# ------------------------------------------------------------------ UI
def _safe_grab(self) -> None:
try:
self.lift()
self.focus_force()
except Exception:
pass
def _build_ui(self) -> None:
toolbar = ctk.CTkFrame(self)
toolbar.pack(side="top", fill="x", padx=8, pady=(8, 4))
def tbtn(text: str, command, width: int = 0) -> ctk.CTkButton:
btn = ctk.CTkButton(toolbar, text=text, command=command, width=width or 0)
btn.pack(side="left", padx=3, pady=4)
return btn
tbtn("📄 Ouvrir PDF…", self.open_pdf)
ctk.CTkLabel(toolbar, text="").pack(side="left", padx=2)
tbtn("", self.prev_page, width=36)
ctk.CTkLabel(toolbar, textvariable=self._page_label, width=70).pack(side="left", padx=2)
tbtn("", self.next_page, width=36)
ctk.CTkLabel(toolbar, text="").pack(side="left", padx=2)
tbtn("", self.zoom_out, width=36)
ctk.CTkLabel(toolbar, textvariable=self._zoom_label, width=58).pack(side="left", padx=2)
tbtn("+", self.zoom_in, width=36)
tbtn("↔ Largeur", self.fit_width)
tbtn("⤢ Page", self.fit_page)
# Deuxième ligne d'outils (templates + suppression).
toolbar2 = ctk.CTkFrame(self)
toolbar2.pack(side="top", fill="x", padx=8, pady=(0, 4))
ctk.CTkLabel(toolbar2, text="Nom :").pack(side="left", padx=(6, 2), pady=4)
ctk.CTkEntry(toolbar2, textvariable=self._template_name, width=200).pack(side="left", padx=2)
ctk.CTkButton(toolbar2, text="💾 Sauver…", command=self.save_template).pack(side="left", padx=3)
ctk.CTkButton(toolbar2, text="📂 Charger…", command=self.load_template).pack(side="left", padx=3)
ctk.CTkLabel(toolbar2, text="").pack(side="left", padx=2)
ctk.CTkButton(toolbar2, text="🗑 Supprimer sélection", command=self.delete_selected).pack(side="left", padx=3)
ctk.CTkButton(toolbar2, text="Effacer page", command=self.clear_page).pack(side="left", padx=3)
ctk.CTkButton(toolbar2, text="Effacer tout", command=self.clear_all).pack(side="left", padx=3)
ctk.CTkLabel(toolbar2, textvariable=self._count_label, width=90).pack(side="right", padx=8)
# Zone document = canvas + scrollbars.
body = ctk.CTkFrame(self)
body.pack(side="top", fill="both", expand=True, padx=8, pady=(0, 4))
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=1)
canvas = tk.Canvas(body, bg="#e9edf4", highlightthickness=0)
canvas.grid(row=0, column=0, sticky="nsew")
self._v_scroll = tk.Scrollbar(body, orient="vertical", command=canvas.yview)
self._v_scroll.grid(row=0, column=1, sticky="ns")
self._h_scroll = tk.Scrollbar(body, orient="horizontal", command=canvas.xview)
self._h_scroll.grid(row=1, column=0, sticky="ew")
canvas.configure(xscrollcommand=self._h_scroll.set, yscrollcommand=self._v_scroll.set)
canvas.bind("<ButtonPress-1>", self._on_down)
canvas.bind("<B1-Motion>", self._on_drag)
canvas.bind("<ButtonRelease-1>", self._on_up)
canvas.bind("<Button-3>", self._on_right_click)
# Molette (Windows/Mac : <MouseWheel> ; X11 : Button-4/5).
canvas.bind("<MouseWheel>", self._on_wheel)
canvas.bind("<Shift-MouseWheel>", self._on_wheel_h)
canvas.bind("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
canvas.bind("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
self.bind("<Delete>", lambda e: self.delete_selected())
self._canvas = canvas
statusbar = ctk.CTkLabel(self, textvariable=self._status, anchor="w")
statusbar.pack(side="bottom", fill="x", padx=10, pady=(0, 6))
# ----------------------------------------------------------- coutures test
def has_scrollbars(self) -> bool:
return self._h_scroll is not None and self._v_scroll is not None
def add_mask_rect_pdf(
self, page: int, x0: float, y0: float, x1: float, y1: float, label: str = "MASK"
):
rect = self.model.add_rect(page, x0, y0, x1, y1, label=label)
if rect is not None:
self._update_count()
self._render()
return rect
def select_rect(self, rect) -> None:
self._selected = rect
self._render()
def save_template_to(self, path: Path) -> Path:
path = Path(path)
payload = self._payload()
if path.suffix.lower() in {".yml", ".yaml"}:
import yaml
path.write_text(
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8"
)
else:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
if self._on_template_saved:
try:
self._on_template_saved(path)
except Exception:
pass
return path
def load_template_path(self, path: Path) -> None:
path = Path(path)
if path.suffix.lower() in {".yml", ".yaml"}:
import yaml
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
else:
payload = json.loads(path.read_text(encoding="utf-8"))
loaded = MaskEditorModel.from_payload(payload)
# On conserve les pages/tailles du PDF courant, on remplace les masques.
self.model.masks = loaded.masks
self.model.name = loaded.name
if not self.model.page_sizes:
self.model.page_sizes = loaded.page_sizes
self._template_name.set(loaded.name)
self._selected = None
self._update_count()
self._render()
self._status.set(f"Template chargé : {path.name}")
# ------------------------------------------------------------------ PDF
def open_pdf(self) -> None:
path = filedialog.askopenfilename(title="PDF modèle", filetypes=[("PDF", "*.pdf")])
if path:
self.open_pdf_path(Path(path))
def open_pdf_path(self, path: Path) -> None:
try:
import fitz
doc = fitz.open(str(path))
if len(doc) == 0:
raise ValueError("PDF vide")
if self._doc is not None:
try:
self._doc.close()
except Exception:
pass
self._doc = doc
self._doc_path = Path(path)
self.model.page_count = len(doc)
self.model.page_sizes = [
(float(doc[i].rect.width), float(doc[i].rect.height)) for i in range(len(doc))
]
self.model.set_page(0)
self.model.clear_all()
self._selected = None
self._template_name.set(f"{self._doc_path.stem}_template")
self._status.set(f"{self._doc_path.name}{len(doc)} page(s)")
self.fit_width()
except Exception as exc: # pragma: no cover - dépend de l'environnement
messagebox.showerror("Masques PDF", f"Impossible d'ouvrir le PDF : {exc}")
# --------------------------------------------------------------- navigation
def prev_page(self) -> None:
self.model.set_page(self.model.page_index - 1)
self._selected = None
self._render()
def next_page(self) -> None:
self.model.set_page(self.model.page_index + 1)
self._selected = None
self._render()
def set_zoom(self, zoom: float) -> None:
self.zoom = clamp_zoom(zoom)
self._render()
def zoom_in(self) -> None:
self.set_zoom(self.zoom + 0.15)
def zoom_out(self) -> None:
self.set_zoom(self.zoom - 0.15)
def fit_width(self) -> None:
page_w, _ = self.model.current_page_size()
vp = self._canvas.winfo_width() if self._canvas else 0
if vp <= 1:
vp = 1200
self.set_zoom(fit_width_zoom(page_w, vp))
def fit_page(self) -> None:
page_w, page_h = self.model.current_page_size()
vw = self._canvas.winfo_width() if self._canvas else 0
vh = self._canvas.winfo_height() if self._canvas else 0
if vw <= 1:
vw = 1200
if vh <= 1:
vh = 800
self.set_zoom(fit_page_zoom(page_w, page_h, vw, vh))
# ----------------------------------------------------------- masques
def clear_page(self) -> None:
removed = self.model.clear_page(self.model.page_index)
self._selected = None
self._update_count()
self._render()
self._status.set(f"{removed} masque(s) supprimé(s) sur la page {self.model.page_index + 1}.")
def clear_all(self) -> None:
removed = self.model.clear_all()
self._selected = None
self._update_count()
self._render()
self._status.set(f"{removed} masque(s) supprimé(s).")
def delete_selected(self) -> None:
if self._selected is None:
self._status.set("Aucun masque sélectionné.")
return
if self.model.delete_rect(self._selected):
self._selected = None
self._update_count()
self._render()
self._status.set("Masque supprimé.")
# ----------------------------------------------------------- templates UI
def _payload(self) -> dict[str, Any]:
self.model.name = self._template_name.get().strip() or "template_masques"
return self.model.to_payload()
def _templates_initialdir(self) -> Path:
if self.templates_dir is not None:
self.templates_dir.mkdir(parents=True, exist_ok=True)
return self.templates_dir
if self._doc_path is not None:
return self._doc_path.parent
return Path.cwd()
def save_template(self) -> Optional[Path]:
if not self.model.masks:
messagebox.showwarning("Masques PDF", "Dessinez au moins un masque avant de sauvegarder.")
return None
name = self._template_name.get().strip() or "template_masques"
selected = filedialog.asksaveasfilename(
title="Sauver le template",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("YAML", "*.yml *.yaml")],
initialdir=str(self._templates_initialdir()),
initialfile=f"{name}.json",
)
if not selected:
return None
try:
path = self.save_template_to(Path(selected))
self._status.set(f"Template sauvegardé : {path.name}")
return path
except Exception as exc: # pragma: no cover
messagebox.showerror("Masques PDF", f"Impossible d'écrire le template : {exc}")
return None
def load_template(self) -> None:
selected = filedialog.askopenfilename(
title="Charger un template",
filetypes=[("YAML/JSON", "*.yml *.yaml *.json")],
initialdir=str(self._templates_initialdir()),
)
if not selected:
return
try:
self.load_template_path(Path(selected))
except Exception as exc: # pragma: no cover
messagebox.showerror("Masques PDF", f"Template invalide : {exc}")
# ----------------------------------------------------------- canvas events
def _on_down(self, event) -> None:
canvas = self._canvas
self._drag_start = (canvas.canvasx(event.x), canvas.canvasy(event.y))
cx, cy = self._drag_start
self._preview_item = canvas.create_rectangle(cx, cy, cx, cy, outline="#e94560", width=2)
def _on_drag(self, event) -> None:
if self._drag_start is None or self._preview_item is None:
return
canvas = self._canvas
sx, sy = self._drag_start
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
canvas.coords(self._preview_item, sx, sy, cx, cy)
def _on_up(self, event) -> None:
if self._drag_start is None:
return
canvas = self._canvas
sx, sy = self._drag_start
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
if self._preview_item is not None:
canvas.delete(self._preview_item)
self._preview_item = None
self._drag_start = None
page = self.model.page_index
if abs(cx - sx) < _CLICK_DRAG_THRESHOLD and abs(cy - sy) < _CLICK_DRAG_THRESHOLD:
# Simple clic -> sélection du rectangle sous le curseur.
px, py = screen_to_pdf(cx, cy, self.zoom)
self._selected = self.model.rect_at(page, px, py)
self._render()
if self._selected is not None:
self._status.set("Masque sélectionné (Suppr pour effacer).")
return
x0, y0 = screen_to_pdf(sx, sy, self.zoom)
x1, y1 = screen_to_pdf(cx, cy, self.zoom)
rect = self.model.add_rect(page, x0, y0, x1, y1)
if rect is not None:
self._selected = rect
self._update_count()
self._render()
self._status.set(
f"Masque ajouté p.{page + 1} ({int(rect.x0)},{int(rect.y0)})({int(rect.x1)},{int(rect.y1)})."
)
def _on_right_click(self, event) -> None:
canvas = self._canvas
px, py = screen_to_pdf(canvas.canvasx(event.x), canvas.canvasy(event.y), self.zoom)
target = self.model.rect_at(self.model.page_index, px, py)
if target is not None:
self.model.delete_rect(target)
if self._selected is target:
self._selected = None
self._update_count()
self._render()
self._status.set("Masque supprimé.")
def _on_wheel(self, event) -> None:
delta = -1 if event.delta > 0 else 1
self._canvas.yview_scroll(delta, "units")
def _on_wheel_h(self, event) -> None:
delta = -1 if event.delta > 0 else 1
self._canvas.xview_scroll(delta, "units")
# ----------------------------------------------------------- rendering
def _update_count(self) -> None:
total = self.model.count_total()
page = self.model.count_page(self.model.page_index)
self._count_label.set(f"{page} / {total} masque(s)")
def _render(self) -> None:
canvas = self._canvas
if canvas is None:
return
canvas.delete("all")
page_w, page_h = self.model.current_page_size()
scale = self.zoom
img_w = page_w * scale
img_h = page_h * scale
drawn = False
if self._doc is not None:
try:
import fitz
from PIL import Image, ImageTk
page = self._doc[self.model.page_index]
pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), annots=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
self._photo = ImageTk.PhotoImage(img)
canvas.create_image(0, 0, image=self._photo, anchor="nw")
img_w, img_h = pix.width, pix.height
drawn = True
except Exception:
drawn = False
if not drawn:
self._draw_sample_page(canvas, img_w, img_h)
self._draw_rectangles(canvas)
canvas.configure(scrollregion=(0, 0, img_w, img_h))
self._page_label.set(f"{self.model.page_index + 1} / {max(1, self.model.page_count)}")
self._zoom_label.set(f"{round(self.zoom * 100)} %")
self._update_count()
def _draw_sample_page(self, canvas: tk.Canvas, w: float, h: float) -> None:
canvas.create_rectangle(0, 0, w, h, fill="#ffffff", outline="#b9c2d0", width=1)
header_h = min(60.0, h * 0.14)
canvas.create_rectangle(0, 0, w, header_h, fill="#e5e7eb", outline="")
scale = self.zoom
canvas.create_text(18 * scale, 30 * scale, text="EN-TÊTE ÉTABLISSEMENT [LOGO]", fill="#6b7280", anchor="w")
for idx, line in enumerate(
[
"Patient : Dupont Jean Né le : 12/03/1955",
"IPP : 1234567 NDA : 8901234",
"Motif : Insuffisance cardiaque décompensée.",
"Signé : Dr Martin RPPS 12345678",
]
):
canvas.create_text(28 * scale, (120 + idx * 30) * scale, text=line, fill="#111827", anchor="w")
def _draw_rectangles(self, canvas: tk.Canvas) -> None:
for rect in self.model.masks_for_page(self.model.page_index):
x0, y0 = pdf_to_screen(rect.x0, rect.y0, self.zoom)
x1, y1 = pdf_to_screen(rect.x1, rect.y1, self.zoom)
if rect is self._selected:
canvas.create_rectangle(x0, y0, x1, y1, fill="#000000", stipple="gray50", outline="#e94560", width=3)
else:
canvas.create_rectangle(x0, y0, x1, y1, fill="#000000", stipple="gray25", outline="#111827", width=2)