"""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("", self._on_down) canvas.bind("", self._on_drag) canvas.bind("", self._on_up) canvas.bind("", self._on_right_click) # Molette (Windows/Mac : ; X11 : Button-4/5). canvas.bind("", self._on_wheel) canvas.bind("", self._on_wheel_h) canvas.bind("", lambda e: canvas.yview_scroll(-1, "units")) canvas.bind("", lambda e: canvas.yview_scroll(1, "units")) self.bind("", 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)