diff --git a/gui_v6/mask_editor_model.py b/gui_v6/mask_editor_model.py new file mode 100644 index 0000000..da4ba09 --- /dev/null +++ b/gui_v6/mask_editor_model.py @@ -0,0 +1,172 @@ +"""Couche logique pure de l'éditeur de masques (testable sans display Tk). + +Sépare la logique métier (rectangles par page, conversions écran↔PDF, hit-test, +sérialisation de templates) de la fenêtre Tk `MaskEditorWindow`. Le format de +template reste strictement compatible avec `pdf_mask_designer.Template` (et donc +avec le moteur d'anonymisation) : on réutilise `MaskRect`/`Template`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from pdf_mask_designer import MaskRect, Template, clamp, rect_norm + +# Bornes d'affichage de l'éditeur. +ZOOM_MIN = 0.25 +ZOOM_MAX = 4.0 + +# Côté minimal (en points PDF) d'un rectangle dessiné ; en dessous, on ignore +# (évite les masques fantômes créés par un simple clic). +MIN_RECT_SIZE = 3.0 + +_DEFAULT_PAGE_SIZE = (595.0, 842.0) + + +def clamp_zoom(zoom: float) -> float: + return clamp(float(zoom), ZOOM_MIN, ZOOM_MAX) + + +def clamp_page(index: int, page_count: int) -> int: + if page_count <= 0: + return 0 + return int(clamp(int(index), 0, page_count - 1)) + + +def screen_to_pdf(cx: float, cy: float, zoom: float) -> tuple[float, float]: + """Coordonnées canvas (espace scrollregion, origine 0,0) -> points PDF.""" + z = zoom or 1.0 + return (cx / z, cy / z) + + +def pdf_to_screen(px: float, py: float, zoom: float) -> tuple[float, float]: + return (px * zoom, py * zoom) + + +def fit_width_zoom(page_width_pt: float, viewport_px: float, padding: float = 24.0) -> float: + if page_width_pt <= 0: + return 1.0 + usable = max(1.0, viewport_px - padding) + return usable / page_width_pt + + +def fit_page_zoom( + page_width_pt: float, + page_height_pt: float, + viewport_w_px: float, + viewport_h_px: float, + padding: float = 24.0, +) -> float: + if page_width_pt <= 0 or page_height_pt <= 0: + return 1.0 + zw = max(1.0, viewport_w_px - padding) / page_width_pt + zh = max(1.0, viewport_h_px - padding) / page_height_pt + return min(zw, zh) + + +@dataclass +class MaskEditorModel: + """État pur de l'éditeur : rectangles (toutes pages) + position courante.""" + + name: str = "template_masques" + page_index: int = 0 + page_count: int = 0 + page_sizes: list[tuple[float, float]] = field(default_factory=list) + masks: list[MaskRect] = field(default_factory=list) + + # --- rectangles --------------------------------------------------------- + def add_rect( + self, + page: int, + x0: float, + y0: float, + x1: float, + y1: float, + label: str = "MASK", + ) -> Optional[MaskRect]: + nx0, ny0, nx1, ny1 = rect_norm(float(x0), float(y0), float(x1), float(y1)) + if (nx1 - nx0) < MIN_RECT_SIZE or (ny1 - ny0) < MIN_RECT_SIZE: + return None + rect = MaskRect(page=int(page), x0=nx0, y0=ny0, x1=nx1, y1=ny1, label=label) + self.masks.append(rect) + return rect + + def masks_for_page(self, page: int) -> list[MaskRect]: + return [m for m in self.masks if m.page == page] + + def rect_at(self, page: int, px: float, py: float) -> Optional[MaskRect]: + """Rectangle le plus en avant (dernier dessiné) contenant le point.""" + for rect in reversed(self.masks): + if rect.page != page: + continue + if rect.x0 <= px <= rect.x1 and rect.y0 <= py <= rect.y1: + return rect + return None + + def delete_rect(self, rect: MaskRect) -> bool: + for idx, existing in enumerate(self.masks): + if existing is rect: + del self.masks[idx] + return True + return False + + def clear_page(self, page: int) -> int: + before = len(self.masks) + self.masks = [m for m in self.masks if m.page != page] + return before - len(self.masks) + + def clear_all(self) -> int: + count = len(self.masks) + self.masks.clear() + return count + + def count_page(self, page: int) -> int: + return sum(1 for m in self.masks if m.page == page) + + def count_total(self) -> int: + return len(self.masks) + + # --- pages -------------------------------------------------------------- + def set_page(self, index: int) -> int: + self.page_index = clamp_page(index, self.page_count) + return self.page_index + + def current_page_size(self) -> tuple[float, float]: + if 0 <= self.page_index < len(self.page_sizes): + return self.page_sizes[self.page_index] + return _DEFAULT_PAGE_SIZE + + def reference_page_size(self) -> tuple[float, float]: + if self.page_sizes: + return self.page_sizes[0] + return _DEFAULT_PAGE_SIZE + + # --- templates (compat pdf_mask_designer.Template) ---------------------- + def to_payload(self) -> dict[str, Any]: + width, height = self.reference_page_size() + return { + "version": 1, + "name": (self.name or "template_masques").strip() or "template_masques", + "page_size": {"width": float(width), "height": float(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.label), + } + for m in self.masks + ], + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "MaskEditorModel": + tpl = Template.from_dict(payload or {}) + return cls( + name=tpl.name, + page_sizes=[tpl.page_size], + masks=list(tpl.masks or []), + ) diff --git a/gui_v6/mask_editor_window.py b/gui_v6/mask_editor_window.py new file mode 100644 index 0000000..9bb0c55 --- /dev/null +++ b/gui_v6/mask_editor_window.py @@ -0,0 +1,497 @@ +"""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) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index 2923e87..b7f19b4 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -8,13 +8,10 @@ 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 @@ -109,10 +106,6 @@ def _app_base_dir() -> Path: 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) @@ -131,24 +124,12 @@ class ConfigTab(ctk.CTkFrame): 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_status_text = ctk.StringVar(value="Éditeur de masques en fenêtre dédiée.") - 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 + # L'édition interactive des masques se fait dans une fenêtre séparée + # (gui_v6.mask_editor_window) ; on garde juste une référence à l'instance + # ouverte pour éviter d'en empiler plusieurs. + self._mask_editor_window = None self._build() @@ -212,8 +193,6 @@ class ConfigTab(ctk.CTkFrame): 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 --------------------------------------------------------- @@ -410,109 +389,35 @@ class ConfigTab(ctk.CTkFrame): editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes") editor.pack(fill="x", pady=(8, 0)) - - toolbar = ctk.CTkFrame(editor, fg_color="transparent") - toolbar.pack(fill="x", padx=12, pady=(0, 8)) - ui_kit.primary_button(toolbar, p, "📄 Ouvrir PDF…", command=self._open_mask_pdf).pack( - side="left", padx=(0, 6) - ) - ui_kit.secondary_button(toolbar, p, "↙ Fenêtre complète", command=self._open_full_mask_editor).pack( - side="left", padx=(0, 6) - ) - self._toolbar_button(toolbar, "←", lambda: self._move_mask_page(-1), width=38).pack(side="left", padx=(0, 4)) - self._toolbar_button(toolbar, "→", lambda: self._move_mask_page(1), width=38).pack(side="left", padx=(0, 8)) - self._toolbar_button(toolbar, "−", lambda: self._zoom_mask(-0.15), width=38).pack(side="left", padx=(0, 4)) - ctk.CTkLabel(toolbar, textvariable=self._mask_zoom_text, text_color=p["text_muted"], font=ui_kit.font(12), width=46).pack( - side="left" - ) - self._toolbar_button(toolbar, "+", lambda: self._zoom_mask(0.15), width=38).pack(side="left", padx=(4, 8)) - - work = ctk.CTkFrame(editor, fg_color="transparent") - work.pack(fill="x", padx=12, pady=(0, 12)) - canvas_wrap = ctk.CTkFrame( - work, - fg_color=p["divider"], - border_color=p["card_border"], - border_width=1, - corner_radius=8, - height=292, - ) - canvas_wrap.pack(side="left", fill="both", expand=True, padx=(0, 8)) - canvas_wrap.pack_propagate(False) - self._mask_canvas = tk.Canvas( - canvas_wrap, - bg=p["divider"], - highlightthickness=0, - bd=0, - height=288, - cursor="crosshair", - ) - self._mask_canvas.pack(fill="both", expand=True, padx=6, pady=6) - self._mask_canvas.bind("", self._on_mask_canvas_down) - self._mask_canvas.bind("", self._on_mask_canvas_drag) - self._mask_canvas.bind("", self._on_mask_canvas_up) - self._mask_canvas.bind("", lambda _event: self._refresh_mask_canvas()) - - side = ctk.CTkFrame( - work, - fg_color=p["divider"], - border_color=p["card_border"], - border_width=1, - corner_radius=8, - width=230, - ) - side.pack(side="left", fill="y") - - ctk.CTkLabel(side, text="Template", text_color=p["text_dim"], font=ui_kit.font(11, "bold"), anchor="w").pack( - fill="x", padx=12, pady=(12, 2) - ) - ctk.CTkEntry( - side, - textvariable=self._mask_template_name, - fg_color=p["btn_sec_bg"], - border_color=p["btn_sec_border"], - text_color=p["text"], - height=30, - ).pack(fill="x", padx=12, pady=(0, 8)) - - dpi_row = ctk.CTkFrame(side, fg_color="transparent") - dpi_row.pack(fill="x", padx=12, pady=(0, 8)) - ctk.CTkLabel(dpi_row, text="DPI raster", text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left") - ctk.CTkEntry( - dpi_row, - textvariable=self._mask_dpi, - fg_color=p["btn_sec_bg"], - border_color=p["btn_sec_border"], - text_color=p["text"], - width=62, - height=28, - justify="center", - ).pack(side="right") - - ui_kit.secondary_button(side, p, "💾 Sauver JSON", command=self._save_mask_template).pack( - fill="x", padx=12, pady=(0, 6) - ) - ui_kit.secondary_button(side, p, "📁 Charger", command=self._load_mask_template).pack( - fill="x", padx=12, pady=(0, 6) - ) - ui_kit.secondary_button(side, p, "👁 Prévisualiser", command=self._preview_mask_template).pack( - fill="x", padx=12, pady=(0, 6) - ) - ui_kit.primary_button(side, p, "▶ Appliquer", command=self._apply_mask_template_selection).pack( - fill="x", padx=12, pady=(0, 10) - ) - - clear_row = ctk.CTkFrame(side, fg_color="transparent") - clear_row.pack(fill="x", padx=12, pady=(0, 8)) - ui_kit.secondary_button(clear_row, p, "Page", command=self._clear_mask_page).pack(side="left", fill="x", expand=True, padx=(0, 4)) - ui_kit.secondary_button(clear_row, p, "Tout", command=self._clear_all_masks).pack(side="left", fill="x", expand=True, padx=(4, 0)) - - ctk.CTkLabel(side, textvariable=self._mask_status_text, text_color=p["text_muted"], font=ui_kit.font(11), wraplength=190, justify="left").pack( - fill="x", padx=12, pady=(4, 4) - ) - ctk.CTkLabel(side, textvariable=self._mask_count_text, text_color=p["primary"], font=ui_kit.font(13, "bold")).pack( - fill="x", padx=12, pady=(0, 12) - ) + ctk.CTkLabel( + editor, + text=( + "Définissez les zones à masquer (en-têtes, blocs identité…) directement sur " + "votre PDF, dans une fenêtre dédiée où le document est affiché en grand et " + "défilable (scroll, zoom, ajuster largeur/page). Les templates enregistrés " + "apparaissent ensuite dans « Template de masque manuel » (onglet Réglages)." + ), + text_color=p["text_muted"], + font=ui_kit.font(12), + justify="left", + wraplength=760, + anchor="w", + ).pack(fill="x", padx=14, pady=(0, 10)) + actions = ctk.CTkFrame(editor, fg_color="transparent") + actions.pack(fill="x", padx=14, pady=(0, 6)) + ui_kit.primary_button( + actions, p, "🖊 Ouvrir l'éditeur de masques", command=self._open_full_mask_editor + ).pack(side="left") + ui_kit.secondary_button( + actions, p, "📁 Dossier des templates", command=self._open_templates_dir + ).pack(side="left", padx=(8, 0)) + ctk.CTkLabel( + editor, + textvariable=self._mask_status_text, + text_color=p["text_muted"], + font=ui_kit.font(11), + anchor="w", + ).pack(fill="x", padx=14, pady=(2, 12)) # -- Partage / Règles ------------------------------------------------- @@ -625,7 +530,6 @@ class ConfigTab(ctk.CTkFrame): 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)) @@ -651,337 +555,63 @@ class ConfigTab(ctk.CTkFrame): # -- é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: + existing = self._mask_editor_window + if existing 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") + if existing.winfo_exists(): + existing.lift() + existing.focus_force() + return 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) + pass + try: + from gui_v6.mask_editor_window import MaskEditorWindow - 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), + active = self._state.manual_mask_template + initial_template = active if (active and Path(active).exists()) else None + win = MaskEditorWindow( + self.winfo_toplevel(), + templates_dir=ensure_mask_templates_dir(_app_base_dir()), + initial_template=initial_template, + on_template_saved=self._on_mask_template_saved, ) + self._mask_editor_window = win + self._mask_status_text.set("Éditeur de masques ouvert.") + except Exception as exc: + messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}") + + def _on_mask_template_saved(self, path: Path) -> None: + """Callback déclenché par la fenêtre dédiée après sauvegarde d'un template.""" + self._refresh_manual_mask_templates() + try: + self._manual_mask_var.set(mask_template_label(path, _app_base_dir())) + self._state.manual_mask_template = path + except Exception: + pass + self._mask_status_text.set(f"Template enregistré : {path.name}") + + + + + + + + + + + - 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 ------------------------------------------------------- @@ -1074,23 +704,6 @@ class ConfigTab(ctk.CTkFrame): 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 diff --git a/tests/unit/test_gui_v6_mask_editor.py b/tests/unit/test_gui_v6_mask_editor.py new file mode 100644 index 0000000..b619009 --- /dev/null +++ b/tests/unit/test_gui_v6_mask_editor.py @@ -0,0 +1,260 @@ +"""Couche logique de l'éditeur de masques en fenêtre dédiée (sans display). + +Ces tests valident le modèle pur (rectangles/pages/conversions/templates) qui +sous-tend `gui_v6/mask_editor_window.py`. La fenêtre Tk elle-même est couverte par +un smoke headless séparé. +""" + +from __future__ import annotations + +import json + +import pytest + +from gui_v6.mask_editor_model import ( + ZOOM_MAX, + ZOOM_MIN, + MaskEditorModel, + clamp_page, + clamp_zoom, + fit_page_zoom, + fit_width_zoom, + pdf_to_screen, + screen_to_pdf, +) + + +# --- Conversions / clamps --------------------------------------------------- + +def test_clamp_zoom_bounds(): + assert clamp_zoom(0.001) == ZOOM_MIN + assert clamp_zoom(999.0) == ZOOM_MAX + assert clamp_zoom(1.0) == 1.0 + + +def test_clamp_page_bounds(): + assert clamp_page(-3, 5) == 0 + assert clamp_page(10, 5) == 4 + assert clamp_page(2, 5) == 2 + assert clamp_page(0, 0) == 0 # aucun document + + +def test_screen_pdf_roundtrip_applies_zoom(): + assert screen_to_pdf(200.0, 100.0, zoom=2.0) == (100.0, 50.0) + assert pdf_to_screen(100.0, 50.0, zoom=2.0) == (200.0, 100.0) + + +def test_fit_width_zoom(): + assert fit_width_zoom(595.0, 1190.0, padding=0.0) == 2.0 + + +def test_fit_page_zoom_uses_min_ratio(): + # ratio largeur = 2.0, ratio hauteur = 1.0 -> on prend le plus petit + assert fit_page_zoom(595.0, 842.0, 1190.0, 842.0, padding=0.0) == 1.0 + + +# --- Modèle rectangles / pages --------------------------------------------- + +def test_add_rect_normalizes_corners(): + m = MaskEditorModel() + r = m.add_rect(page=0, x0=100, y0=80, x1=40, y1=20) + assert r is not None + assert (r.x0, r.y0, r.x1, r.y1) == (40.0, 20.0, 100.0, 80.0) + assert r.page == 0 + assert m.count_total() == 1 + + +def test_add_rect_ignores_tiny_rectangles(): + m = MaskEditorModel() + assert m.add_rect(page=0, x0=10, y0=10, x1=11, y1=11) is None + assert m.count_total() == 0 + + +def test_rect_at_returns_topmost_then_none(): + m = MaskEditorModel() + a = m.add_rect(0, 0, 0, 100, 100) + b = m.add_rect(0, 50, 50, 150, 150) + assert m.rect_at(0, 60, 60) is b # zone de recouvrement -> dernier dessiné + assert m.rect_at(0, 10, 10) is a # seulement dans a + assert m.rect_at(0, 500, 500) is None + assert m.rect_at(1, 60, 60) is None # autre page + + +def test_delete_rect(): + m = MaskEditorModel() + a = m.add_rect(0, 0, 0, 50, 50) + assert m.delete_rect(a) is True + assert m.count_total() == 0 + assert m.delete_rect(a) is False + + +def test_clear_page_and_clear_all(): + m = MaskEditorModel() + m.add_rect(0, 0, 0, 50, 50) + m.add_rect(0, 60, 60, 90, 90) + m.add_rect(1, 0, 0, 50, 50) + assert m.count_page(0) == 2 + assert m.clear_page(0) == 2 + assert m.count_page(0) == 0 + assert m.count_total() == 1 + assert m.clear_all() == 1 + assert m.count_total() == 0 + + +def test_set_page_clamps_and_updates_index(): + m = MaskEditorModel(page_count=3) + assert m.set_page(5) == 2 + assert m.page_index == 2 + assert m.set_page(-1) == 0 + assert m.page_index == 0 + + +# --- Templates (compat moteur / pdf_mask_designer) -------------------------- + +def test_payload_shape_and_roundtrip(): + m = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="demo") + m.add_rect(0, 10, 20, 110, 60, label="NOM") + payload = m.to_payload() + assert payload["version"] == 1 + assert payload["name"] == "demo" + assert payload["page_size"] == {"width": 595.0, "height": 842.0} + assert payload["masks"][0] == { + "page": 0, + "x0": 10.0, + "y0": 20.0, + "x1": 110.0, + "y1": 60.0, + "label": "NOM", + } + # le payload doit survivre à une sérialisation JSON + payload2 = json.loads(json.dumps(payload)) + m2 = MaskEditorModel.from_payload(payload2) + assert m2.name == "demo" + assert m2.count_total() == 1 + r = m2.masks_for_page(0)[0] + assert (r.x0, r.y0, r.x1, r.y1, r.label) == (10.0, 20.0, 110.0, 60.0, "NOM") + + +def test_payload_is_compatible_with_pdf_mask_designer_template(): + from pdf_mask_designer import Template + + m = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="demo") + m.add_rect(0, 10, 20, 110, 60, label="NOM") + tpl = Template.from_dict(m.to_payload()) + assert tpl.name == "demo" + assert tpl.page_size == (595.0, 842.0) + assert len(tpl.masks) == 1 + assert tpl.masks[0].label == "NOM" + + +# --- Smoke headless de la fenêtre Tk (skip si pas de display) --------------- + +@pytest.fixture +def ctk_root(): + ctk = pytest.importorskip("customtkinter") + try: + root = ctk.CTk() + except Exception as exc: # pas de display disponible + pytest.skip(f"display Tk indisponible: {exc}") + root.withdraw() + try: + yield root + finally: + try: + root.destroy() + except Exception: + pass + + +def test_window_smoke_scrollbars_add_select_delete_save(ctk_root, tmp_path): + from gui_v6.mask_editor_window import MaskEditorWindow + + win = MaskEditorWindow(ctk_root, templates_dir=tmp_path) + win.update_idletasks() + + # 1) la fenêtre expose bien deux scrollbars (le manque qui rendait l'éditeur inutilisable) + assert win.has_scrollbars() + + # 2) ajout d'un rectangle (sur l'aperçu d'exemple, sans PDF réel) + win.model.page_count = 1 + win.model.page_sizes = [(595.0, 842.0)] + rect = win.add_mask_rect_pdf(0, 30, 40, 200, 120, label="NOM") + assert rect is not None + assert win.model.count_total() == 1 + + # 3) sauvegarde du template (JSON) -> relisible et compatible + out = win.save_template_to(tmp_path / "demo.json") + assert out.exists() + reloaded = json.loads(out.read_text(encoding="utf-8")) + assert reloaded["masks"][0]["label"] == "NOM" + + # 4) sélection + suppression individuelle + win.select_rect(win.model.masks_for_page(0)[0]) + win.delete_selected() + assert win.model.count_total() == 0 + + win.destroy() + + +def test_window_smoke_load_template_roundtrip(ctk_root, tmp_path): + from gui_v6.mask_editor_window import MaskEditorWindow + + win = MaskEditorWindow(ctk_root, templates_dir=tmp_path) + win.model.page_count = 1 + win.model.page_sizes = [(595.0, 842.0)] + win.add_mask_rect_pdf(0, 10, 20, 110, 60, label="ADRESSE") + path = win.save_template_to(tmp_path / "rt.yml") + + win.clear_all() + assert win.model.count_total() == 0 + + win.load_template_path(path) + assert win.model.count_total() == 1 + assert win.model.masks_for_page(0)[0].label == "ADRESSE" + win.destroy() + + +def test_config_tab_launches_dedicated_window(ctk_root, tmp_path, monkeypatch): + """Garde-fou de recâblage : l'onglet Configuration construit sans l'éditeur + encastré et ouvre bien la fenêtre dédiée (avec scrollbars).""" + from gui_v6.tabs import tab_config + + monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path) + tab = tab_config.ConfigTab(ctk_root) + tab.update_idletasks() + + # plus d'éditeur encastré + assert not hasattr(tab, "_mask_canvas") + + tab._open_full_mask_editor() + win = tab._mask_editor_window + assert win is not None + assert win.has_scrollbars() + + # un template sauvé via la fenêtre rafraîchit la sélection côté Réglages + win.model.page_count = 1 + win.model.page_sizes = [(595.0, 842.0)] + win.add_mask_rect_pdf(0, 10, 20, 110, 60, label="NOM") + saved = win.save_template_to(tmp_path / "config" / "mask_templates" / "depuis_fenetre.json") + assert saved.exists() + # A14 : sauver depuis la fenêtre active le template comme masque manuel du run + assert tab._state.manual_mask_template == saved + + win.destroy() + tab.destroy() + + +def test_window_loads_initial_template(ctk_root, tmp_path): + """B5 / continuité : un template fourni à l'ouverture est chargé (l'utilisateur + reprend son travail au lieu de repartir d'une page vierge).""" + from gui_v6.mask_editor_window import MaskEditorWindow + + seed = MaskEditorModel(page_count=1, page_sizes=[(595.0, 842.0)], name="seed") + seed.add_rect(0, 5, 5, 90, 40, label="IPP") + path = tmp_path / "seed.json" + path.write_text(json.dumps(seed.to_payload(), ensure_ascii=False), encoding="utf-8") + + win = MaskEditorWindow(ctk_root, templates_dir=tmp_path, initial_template=path) + assert win.model.count_total() == 1 + assert win.model.masks_for_page(0)[0].label == "IPP" + win.destroy()