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>
This commit is contained in:
172
gui_v6/mask_editor_model.py
Normal file
172
gui_v6/mask_editor_model.py
Normal file
@@ -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 []),
|
||||
)
|
||||
497
gui_v6/mask_editor_window.py
Normal file
497
gui_v6/mask_editor_window.py
Normal file
@@ -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("<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)
|
||||
@@ -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("<ButtonPress-1>", self._on_mask_canvas_down)
|
||||
self._mask_canvas.bind("<B1-Motion>", self._on_mask_canvas_drag)
|
||||
self._mask_canvas.bind("<ButtonRelease-1>", self._on_mask_canvas_up)
|
||||
self._mask_canvas.bind("<Configure>", lambda _event: self._refresh_mask_canvas())
|
||||
|
||||
side = ctk.CTkFrame(
|
||||
work,
|
||||
fg_color=p["divider"],
|
||||
border_color=p["card_border"],
|
||||
border_width=1,
|
||||
corner_radius=8,
|
||||
width=230,
|
||||
)
|
||||
side.pack(side="left", fill="y")
|
||||
|
||||
ctk.CTkLabel(side, text="Template", text_color=p["text_dim"], font=ui_kit.font(11, "bold"), anchor="w").pack(
|
||||
fill="x", padx=12, pady=(12, 2)
|
||||
)
|
||||
ctk.CTkEntry(
|
||||
side,
|
||||
textvariable=self._mask_template_name,
|
||||
fg_color=p["btn_sec_bg"],
|
||||
border_color=p["btn_sec_border"],
|
||||
text_color=p["text"],
|
||||
height=30,
|
||||
).pack(fill="x", padx=12, pady=(0, 8))
|
||||
|
||||
dpi_row = ctk.CTkFrame(side, fg_color="transparent")
|
||||
dpi_row.pack(fill="x", padx=12, pady=(0, 8))
|
||||
ctk.CTkLabel(dpi_row, text="DPI raster", text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left")
|
||||
ctk.CTkEntry(
|
||||
dpi_row,
|
||||
textvariable=self._mask_dpi,
|
||||
fg_color=p["btn_sec_bg"],
|
||||
border_color=p["btn_sec_border"],
|
||||
text_color=p["text"],
|
||||
width=62,
|
||||
height=28,
|
||||
justify="center",
|
||||
).pack(side="right")
|
||||
|
||||
ui_kit.secondary_button(side, p, "💾 Sauver JSON", command=self._save_mask_template).pack(
|
||||
fill="x", padx=12, pady=(0, 6)
|
||||
)
|
||||
ui_kit.secondary_button(side, p, "📁 Charger", command=self._load_mask_template).pack(
|
||||
fill="x", padx=12, pady=(0, 6)
|
||||
)
|
||||
ui_kit.secondary_button(side, p, "👁 Prévisualiser", command=self._preview_mask_template).pack(
|
||||
fill="x", padx=12, pady=(0, 6)
|
||||
)
|
||||
ui_kit.primary_button(side, p, "▶ Appliquer", command=self._apply_mask_template_selection).pack(
|
||||
fill="x", padx=12, pady=(0, 10)
|
||||
)
|
||||
|
||||
clear_row = ctk.CTkFrame(side, fg_color="transparent")
|
||||
clear_row.pack(fill="x", padx=12, pady=(0, 8))
|
||||
ui_kit.secondary_button(clear_row, p, "Page", command=self._clear_mask_page).pack(side="left", fill="x", expand=True, padx=(0, 4))
|
||||
ui_kit.secondary_button(clear_row, p, "Tout", command=self._clear_all_masks).pack(side="left", fill="x", expand=True, padx=(4, 0))
|
||||
|
||||
ctk.CTkLabel(side, textvariable=self._mask_status_text, text_color=p["text_muted"], font=ui_kit.font(11), wraplength=190, justify="left").pack(
|
||||
fill="x", padx=12, pady=(4, 4)
|
||||
)
|
||||
ctk.CTkLabel(side, textvariable=self._mask_count_text, text_color=p["primary"], font=ui_kit.font(13, "bold")).pack(
|
||||
fill="x", padx=12, pady=(0, 12)
|
||||
)
|
||||
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
|
||||
|
||||
260
tests/unit/test_gui_v6_mask_editor.py
Normal file
260
tests/unit/test_gui_v6_mask_editor.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user