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:
2026-06-15 12:01:13 +02:00
parent 696f6bf27c
commit 13b79db417
4 changed files with 1004 additions and 462 deletions

172
gui_v6/mask_editor_model.py Normal file
View 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 []),
)

View 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)

View File

@@ -8,13 +8,10 @@ Partage/Règles.
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from tkinter import filedialog, messagebox from tkinter import filedialog, messagebox
import tkinter as tk
from typing import Any
import customtkinter as ctk import customtkinter as ctk
@@ -109,10 +106,6 @@ def _app_base_dir() -> Path:
return Path.cwd() return Path.cwd()
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
class ConfigTab(ctk.CTkFrame): class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs): 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) 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_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_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_rounded_var = ctk.BooleanVar(value=self._state.mask_rounded_corners)
self._mask_template_name = ctk.StringVar(value="template_masques") self._mask_status_text = ctk.StringVar(value="Éditeur de masques en fenêtre dédiée.")
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_doc = None # L'édition interactive des masques se fait dans une fenêtre séparée
self._mask_pdf_path: Path | None = None # (gui_v6.mask_editor_window) ; on garde juste une référence à l'instance
self._mask_page_index = 0 # ouverte pour éviter d'en empiler plusieurs.
self._mask_page_size = (595.0, 842.0) self._mask_editor_window = None
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
self._build() self._build()
@@ -212,8 +193,6 @@ class ConfigTab(ctk.CTkFrame):
font=ui_kit.font(13, "bold" if active else "normal"), font=ui_kit.font(13, "bold" if active else "normal"),
) )
self._panels[key].tkraise() self._panels[key].tkraise()
if key == "msk":
self.after_idle(self._refresh_mask_canvas)
# -- Réglages --------------------------------------------------------- # -- Réglages ---------------------------------------------------------
@@ -410,109 +389,35 @@ class ConfigTab(ctk.CTkFrame):
editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes") editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes")
editor.pack(fill="x", pady=(8, 0)) editor.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
toolbar = ctk.CTkFrame(editor, fg_color="transparent") editor,
toolbar.pack(fill="x", padx=12, pady=(0, 8)) text=(
ui_kit.primary_button(toolbar, p, "📄 Ouvrir PDF…", command=self._open_mask_pdf).pack( "Définissez les zones à masquer (en-têtes, blocs identité…) directement sur "
side="left", padx=(0, 6) "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 "
ui_kit.secondary_button(toolbar, p, "↙ Fenêtre complète", command=self._open_full_mask_editor).pack( "apparaissent ensuite dans « Template de masque manuel » (onglet Réglages)."
side="left", padx=(0, 6) ),
) text_color=p["text_muted"],
self._toolbar_button(toolbar, "", lambda: self._move_mask_page(-1), width=38).pack(side="left", padx=(0, 4)) font=ui_kit.font(12),
self._toolbar_button(toolbar, "", lambda: self._move_mask_page(1), width=38).pack(side="left", padx=(0, 8)) justify="left",
self._toolbar_button(toolbar, "", lambda: self._zoom_mask(-0.15), width=38).pack(side="left", padx=(0, 4)) wraplength=760,
ctk.CTkLabel(toolbar, textvariable=self._mask_zoom_text, text_color=p["text_muted"], font=ui_kit.font(12), width=46).pack( anchor="w",
side="left" ).pack(fill="x", padx=14, pady=(0, 10))
) actions = ctk.CTkFrame(editor, fg_color="transparent")
self._toolbar_button(toolbar, "+", lambda: self._zoom_mask(0.15), width=38).pack(side="left", padx=(4, 8)) actions.pack(fill="x", padx=14, pady=(0, 6))
ui_kit.primary_button(
work = ctk.CTkFrame(editor, fg_color="transparent") actions, p, "🖊 Ouvrir l'éditeur de masques", command=self._open_full_mask_editor
work.pack(fill="x", padx=12, pady=(0, 12)) ).pack(side="left")
canvas_wrap = ctk.CTkFrame( ui_kit.secondary_button(
work, actions, p, "📁 Dossier des templates", command=self._open_templates_dir
fg_color=p["divider"], ).pack(side="left", padx=(8, 0))
border_color=p["card_border"], ctk.CTkLabel(
border_width=1, editor,
corner_radius=8, textvariable=self._mask_status_text,
height=292, text_color=p["text_muted"],
) font=ui_kit.font(11),
canvas_wrap.pack(side="left", fill="both", expand=True, padx=(0, 8)) anchor="w",
canvas_wrap.pack_propagate(False) ).pack(fill="x", padx=14, pady=(2, 12))
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)
)
# -- Partage / Règles ------------------------------------------------- # -- Partage / Règles -------------------------------------------------
@@ -625,7 +530,6 @@ class ConfigTab(ctk.CTkFrame):
border_color=p["primary"] if value == color else p["card_border"], border_color=p["primary"] if value == color else p["card_border"],
border_width=3 if value == color else 1, border_width=3 if value == color else 1,
) )
self._refresh_mask_canvas()
def _on_mask_margin_x(self, value: float) -> None: def _on_mask_margin_x(self, value: float) -> None:
self._state.mask_margin_x = int(round(value)) self._state.mask_margin_x = int(round(value))
@@ -651,337 +555,63 @@ class ConfigTab(ctk.CTkFrame):
# -- éditeur masques -------------------------------------------------- # -- é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: def _open_full_mask_editor(self) -> None:
existing = self._mask_editor_window
if existing is not None:
try: try:
from pdf_mask_designer import MaskDesignerApp if existing.winfo_exists():
existing.lift()
win = tk.Toplevel(self) existing.focus_force()
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 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:
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")
except Exception: except Exception:
self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) pass
else: try:
self._draw_sample_page(canvas, origin_x, origin_y, page_px_w, page_px_h) from gui_v6.mask_editor_window import MaskEditorWindow
self._draw_mask_rectangles(canvas) active = self._state.manual_mask_template
if self._mask_doc is None: initial_template = active if (active and Path(active).exists()) else None
canvas.create_text( win = MaskEditorWindow(
width / 2, self.winfo_toplevel(),
height - 18, templates_dir=ensure_mask_templates_dir(_app_base_dir()),
text="Ouvrez un PDF modèle, ou dessinez directement sur l'aperçu d'exemple.", initial_template=initial_template,
fill=p["text_muted"], on_template_saved=self._on_mask_template_saved,
font=("TkDefaultFont", 9),
) )
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 ------------------------------------------------------- # -- helpers UI -------------------------------------------------------
@@ -1074,23 +704,6 @@ class ConfigTab(ctk.CTkFrame):
slider.set(variable.get()) slider.set(variable.get())
slider.pack(side="right", padx=(8, 4)) 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: def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None:
p = self._p p = self._p
label, rule_type, target, status = values label, rule_type, target, status = values

View 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()