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>
498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""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)
|