diff --git a/src/pdf_equilibrist/core/document.py b/src/pdf_equilibrist/core/document.py
index 5299362..b9001ea 100644
--- a/src/pdf_equilibrist/core/document.py
+++ b/src/pdf_equilibrist/core/document.py
@@ -65,12 +65,15 @@ class Document(QObject):
# Signal émis à chaque modification — abonné par PdfViewer et ThumbnailPanel
changed = pyqtSignal()
+ # Émis quand le document passe de « enregistré » à « modifié » (True) ou l'inverse (False)
+ modified_changed = pyqtSignal(bool)
def __init__(self):
super().__init__()
self.fitz_doc: pymupdf.Document | None = None
self.path: Path | None = None
self._undo_stack: deque[bytes] = deque(maxlen=_UNDO_MAX)
+ self._modified = False
# ── Propriété ──────────────────────────────────────────────────────────────
@@ -79,6 +82,20 @@ def is_open(self) -> bool:
"""``True`` si un fichier PDF est actuellement chargé en mémoire."""
return self.fitz_doc is not None
+ @property
+ def is_modified(self) -> bool:
+ """``True`` si le document a des modifications non enregistrées."""
+ return self._modified
+
+ def mark_modified(self) -> None:
+ """Signale une modification non enregistrée (appelé avant chaque opération qui change le PDF)."""
+ self._set_modified(True)
+
+ def _set_modified(self, value: bool) -> None:
+ if self._modified != value:
+ self._modified = value
+ self.modified_changed.emit(value)
+
# ── Opérations fichier ────────────────────────────────────────────────────
def open(self, path: str | Path):
@@ -100,6 +117,7 @@ def open(self, path: str | Path):
self.path = Path(path)
self.fitz_doc = pymupdf.open(str(self.path))
self._undo_stack.clear()
+ self._set_modified(False)
self.changed.emit()
def save(self, path: str | Path | None = None):
@@ -145,6 +163,9 @@ def save(self, path: str | Path | None = None):
tmp_path.unlink()
except OSError:
pass
+ # Atteint seulement si l'écriture a réussi (une exception remonte avant) :
+ # le document n'est « enregistré » que si le fichier l'est vraiment.
+ self._set_modified(False)
def close(self):
"""
@@ -157,6 +178,7 @@ def close(self):
self.fitz_doc.close()
self.fitz_doc = None
self.path = None
+ self._set_modified(False)
self.changed.emit()
# ── Annuler / Rétablir ────────────────────────────────────────────────────
@@ -165,6 +187,7 @@ def checkpoint(self):
"""Sauvegarde l'état courant dans la pile d'annulation (avant toute modif)."""
if self.fitz_doc:
self._undo_stack.append(self.fitz_doc.tobytes())
+ self.mark_modified() # toute modification du PDF passe par ici avant d'agir
def undo(self) -> bool:
"""Restaure le dernier état sauvegardé. Retourne True si annulation effectuée."""
@@ -173,6 +196,7 @@ def undo(self) -> bool:
state = self._undo_stack.pop()
self.fitz_doc.close()
self.fitz_doc = pymupdf.open(stream=state, filetype="pdf")
+ self.mark_modified() # l'état restauré peut différer du fichier : on reste prudent
self.changed.emit()
return True
diff --git a/src/pdf_equilibrist/ui/main_window.py b/src/pdf_equilibrist/ui/main_window.py
index 9e6cbb1..0db51d9 100644
--- a/src/pdf_equilibrist/ui/main_window.py
+++ b/src/pdf_equilibrist/ui/main_window.py
@@ -51,7 +51,7 @@
from pathlib import Path
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout,
- QTabBar, QStackedWidget, QFileDialog, QLabel, QSplitter,
+ QTabBar, QStackedWidget, QFileDialog, QLabel, QSplitter, QMessageBox,
)
from PyQt6.QtGui import QAction, QKeySequence, QColor, QPainter, QPen
from PyQt6.QtCore import Qt, QPoint, QSettings, QThread, pyqtSignal
@@ -65,7 +65,7 @@
from pdf_equilibrist.ui.tabs.tab_proteger import TabProteger
from pdf_equilibrist.ui.viewer import PdfViewer
from pdf_equilibrist.ui.thumbnail_panel import ThumbnailPanel
-from pdf_equilibrist.ui.dialogs import ensure_suffix
+from pdf_equilibrist.ui.dialogs import ensure_suffix, show_error
from pdf_equilibrist.core.document import Document
_RECENT_MAX = 10 # nombre maximum de fichiers dans "Ouvrir récemment"
@@ -143,6 +143,7 @@ def __init__(self):
# ── Gestion multi-documents ───────────────────────────────────────────
self._documents: list[Document] = [] # un Document par onglet ouvert
+ self._close_confirmed = False # True seulement après confirm_close_all() (mise à jour)
self._active_idx = -1 # -1 = aucun document actif
# ── Barre de titre ───────────────────────────────────────────────────
@@ -591,6 +592,7 @@ def _open_document(self, path: str):
# Créer un nouveau Document et l'ouvrir
doc = Document()
doc.open(path)
+ doc.modified_changed.connect(lambda _m, d=doc: self._refresh_tab_modified(d))
self._documents.append(doc)
idx = len(self._documents) - 1
@@ -616,6 +618,10 @@ def _close_tab(self, idx: int):
if idx < 0 or idx >= len(self._documents):
return
+ # Modifications non enregistrées : Enregistrer / Ne pas enregistrer / Annuler
+ if not self._confirm_close_doc(self._documents[idx]):
+ return
+
self._documents[idx].close() # libère pymupdf.Document (mémoire)
self._documents.pop(idx)
self._title_bar.remove_tab(idx)
@@ -709,6 +715,7 @@ def _on_doc_changed(self):
if doc.is_open:
n = len(doc.fitz_doc)
self._title_bar.set_tab_title(self._active_idx, doc.path.name)
+ self._title_bar.set_tab_modified(self._active_idx, doc.is_modified)
self._status_file.setText(str(doc.path))
self._status_page.setText(
self.tr("{0} page").format(n) if n <= 1 else self.tr("{0} pages").format(n))
@@ -731,22 +738,92 @@ def _update_status(self):
# ── Sauvegarde ───────────────────────────────────────────────────────────
- def _save(self):
- """Sauvegarde le document actif en place (Ctrl+S)."""
- if self._current_doc.is_open:
- self._current_doc.save()
- self._status_file.setText(
- self.tr("Enregistré : {0}").format(self._current_doc.path.name))
-
- def _save_as(self):
- """Sauvegarde le document actif sous un nouveau chemin (Ctrl+Shift+S)."""
- if not self._current_doc.is_open:
- return
+ def _save(self) -> bool:
+ """Sauvegarde le document actif en place (Ctrl+S). ``True`` si enregistré."""
+ doc = self._current_doc
+ if not doc.is_open:
+ return False
+ if self._save_doc(doc):
+ self._status_file.setText(self.tr("Enregistré : {0}").format(doc.path.name))
+ return True
+ return False
+
+ def _save_doc(self, doc: Document, path: Path | None = None) -> bool:
+ """Enregistre ``doc`` (en place, ou sous ``path``). En cas d'échec : le document reste
+ ouvert et modifié, et l'utilisateur est prévenu. ``True`` si l'écriture a réussi."""
+ try:
+ doc.save(path)
+ except Exception as e:
+ show_error(self, self.tr("Enregistrement impossible"),
+ self.tr("« {0} » n'a pas pu être enregistré :\n{1}\n\n"
+ "Le document reste ouvert avec vos modifications.").format(
+ (path or doc.path).name, e))
+ return False
+ return True
+
+ def _save_as(self) -> bool:
+ """Sauvegarde le document actif sous un nouveau chemin (Ctrl+Shift+S). ``True`` si enregistré."""
+ doc = self._current_doc
+ if not doc.is_open:
+ return False
path, _ = QFileDialog.getSaveFileName(
self, self.tr("Enregistrer sous"),
- str(self._current_doc.path), self.tr("PDF (*.pdf)"))
- if path:
- self._current_doc.save(ensure_suffix(path, ".pdf"))
+ str(doc.path), self.tr("PDF (*.pdf)"))
+ if not path:
+ return False
+ return self._save_doc(doc, Path(ensure_suffix(path, ".pdf")))
+
+ # ── Modifications non enregistrées ────────────────────────────────────────
+
+ def _refresh_tab_modified(self, doc: Document):
+ """Met l'astérisque de l'onglet de ``doc`` à jour (signal ``modified_changed``)."""
+ if doc in self._documents:
+ self._title_bar.set_tab_modified(self._documents.index(doc), doc.is_modified)
+
+ def _confirm_close_doc(self, doc: Document) -> bool:
+ """``True`` si ``doc`` peut être fermé : pas de modification, ou l'utilisateur a choisi
+ d'enregistrer (et l'écriture a réussi) ou de ne pas enregistrer. ``False`` = annulé / échec."""
+ if not doc.is_modified:
+ return True
+ if doc in self._documents: # montrer de quel document il s'agit
+ self._title_bar.set_current_index(self._documents.index(doc))
+ name = doc.path.name if doc.path else ""
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Icon.Warning)
+ box.setWindowTitle(self.tr("Modifications non enregistrées"))
+ box.setText(self.tr("Voulez-vous enregistrer les modifications apportées à « {0} » ?").format(name))
+ box.setInformativeText(self.tr("Vos modifications seront perdues si vous ne les enregistrez pas."))
+ b_save = box.addButton(self.tr("Enregistrer"), QMessageBox.ButtonRole.AcceptRole)
+ b_discard = box.addButton(self.tr("Ne pas enregistrer"), QMessageBox.ButtonRole.DestructiveRole)
+ b_cancel = box.addButton(self.tr("Annuler"), QMessageBox.ButtonRole.RejectRole)
+ box.setDefaultButton(b_save)
+ box.setEscapeButton(b_cancel) # Échap / croix = Annuler : jamais de fermeture involontaire
+ box.exec()
+ clicked = box.clickedButton()
+ if clicked is b_save:
+ return self._save_doc(doc) # échec → False : le document reste ouvert
+ return clicked is b_discard
+
+ def confirm_close_all(self) -> bool:
+ """Demande, document par document, que faire des modifications non enregistrées.
+ ``False`` dès qu'un seul choix est « Annuler » (ou qu'un enregistrement échoue) :
+ l'appelant ne doit alors rien fermer. Sert à la fermeture de la fenêtre et aux mises à jour."""
+ for doc in list(self._documents):
+ if not self._confirm_close_doc(doc):
+ return False
+ return True
+
+ def closeEvent(self, event):
+ """Fermeture de la fenêtre (croix, Fichier › Quitter, Alt+F4, fermeture demandée par un
+ installeur) : refusée tant qu'une modification non enregistrée n'a pas été traitée."""
+ if self._close_confirmed or self.confirm_close_all():
+ event.accept()
+ else:
+ event.ignore()
+
+ def allow_close_without_prompt(self):
+ """À appeler seulement juste après un ``confirm_close_all()`` réussi, avant de fermer."""
+ self._close_confirmed = True
# ── Thumbnails ────────────────────────────────────────────────────────────
diff --git a/src/pdf_equilibrist/ui/tabs/tab_modifier.py b/src/pdf_equilibrist/ui/tabs/tab_modifier.py
index 08cd6ac..43e68fb 100644
--- a/src/pdf_equilibrist/ui/tabs/tab_modifier.py
+++ b/src/pdf_equilibrist/ui/tabs/tab_modifier.py
@@ -241,6 +241,8 @@ def _work(progress_cb, cancel_cb):
return apply_ocr_to_pdf(fitz_doc, progress_cb, cancel_cb)
def _on_done(n):
+ if n:
+ self.document.mark_modified()
self.document.changed.emit()
if n == 0:
show_info(self, "OCR", self.tr(
diff --git a/src/pdf_equilibrist/ui/thumbnail_panel.py b/src/pdf_equilibrist/ui/thumbnail_panel.py
index 7dc2347..6059ad2 100644
--- a/src/pdf_equilibrist/ui/thumbnail_panel.py
+++ b/src/pdf_equilibrist/ui/thumbnail_panel.py
@@ -396,6 +396,7 @@ def _apply_move(self, src: int, ins: int):
n = len(doc)
to = -1 if ins >= n else (ins - 1 if ins > src else ins)
doc.move_page(src, to)
+ self.document.mark_modified()
self.document.changed.emit()
self._list.setCurrentRow((n - 1) if to == -1 else to)
@@ -415,6 +416,7 @@ def _on_pdf_insert(self, path: str, insert_before: int):
at = insert_before if insert_before < n_before else -1
doc.insert_pdf(src, start_at=at)
src.close()
+ self.document.mark_modified()
self.document.changed.emit()
self._list.setCurrentRow(
insert_before if insert_before < n_before else n_before)
diff --git a/src/pdf_equilibrist/ui/title_bar.py b/src/pdf_equilibrist/ui/title_bar.py
index f77e698..9e28e25 100644
--- a/src/pdf_equilibrist/ui/title_bar.py
+++ b/src/pdf_equilibrist/ui/title_bar.py
@@ -380,6 +380,24 @@ def set_tab_title(self, idx: int, title: str):
if 0 <= idx < self._tabs.count():
self._tabs.setTabText(idx, title)
+ def set_tab_modified(self, idx: int, modified: bool):
+ """Affiche (ou retire) un astérisque gras juste avant le titre de l'onglet."""
+ if not 0 <= idx < self._tabs.count():
+ return
+ side = QTabBar.ButtonPosition.LeftSide
+ old = self._tabs.tabButton(idx, side)
+ if modified:
+ if old is not None:
+ return
+ star = QLabel("*")
+ star.setStyleSheet("color: #6BBF4E; font-size: 17px; font-weight: bold;"
+ " background: transparent; padding: 0 1px 3px 4px;")
+ star.setToolTip(self.tr("Modifications non enregistrées"))
+ self._tabs.setTabButton(idx, side, star)
+ elif old is not None:
+ self._tabs.setTabButton(idx, side, None)
+ old.deleteLater()
+
def current_index(self) -> int:
"""Retourne l'index de l'onglet actuellement sélectionné."""
return self._tabs.currentIndex()
@@ -476,6 +494,13 @@ def _ask_restart(self, title: str, text: str):
msg.exec()
if msg.clickedButton() is btn_now:
+ # QApplication.quit() ne passe pas par closeEvent : on traite nous-mêmes les modifications
+ # non enregistrées. « Annuler » → on ne relance rien et on ne quitte pas.
+ main = self.window()
+ if hasattr(main, "confirm_close_all"):
+ if not main.confirm_close_all():
+ return
+ main.allow_close_without_prompt()
QProcess.startDetached(sys.executable, sys.argv)
QApplication.quit()
diff --git a/src/pdf_equilibrist/ui/update_dialog.py b/src/pdf_equilibrist/ui/update_dialog.py
index f25311e..b50628e 100644
--- a/src/pdf_equilibrist/ui/update_dialog.py
+++ b/src/pdf_equilibrist/ui/update_dialog.py
@@ -6,7 +6,7 @@
QDialog, QVBoxLayout, QLabel, QHBoxLayout, QPushButton,
QProgressBar, QFrame, QLineEdit, QApplication, QMessageBox, QInputDialog,
)
-from PyQt6.QtCore import QProcess, QProcessEnvironment, QThread, QTimer, pyqtSignal, Qt
+from PyQt6.QtCore import QProcess, QProcessEnvironment, QThread, pyqtSignal, Qt
from PyQt6.QtGui import QPixmap
from pdf_equilibrist import __version__
@@ -528,7 +528,20 @@ def _on_install_finished(self, exit_code: int, _status):
detail = "\n".join(lines[-3:])
self._lbl.setText(self.tr("L'installation a échoué (code {0}).\n{1}").format(exit_code, detail))
+ def _main_window(self):
+ """Fenêtre principale (parent du dialogue), si elle sait gérer les modifications non enregistrées."""
+ parent = self.parent()
+ return parent if hasattr(parent, "confirm_close_all") else None
+
def _relaunch(self, path):
+ # Avant de lancer la nouvelle version puis de quitter : traiter les modifications non
+ # enregistrées. « Annuler » arrête tout (rien n'est relancé, rien ne se ferme) ; la nouvelle
+ # version reste installée et se lancera à la prochaine ouverture.
+ main = self._main_window()
+ if main is not None and not main.confirm_close_all():
+ self._lbl.setText(self.tr(
+ "Fermeture annulée : la nouvelle version est installée, relancez l'application quand vous voulez."))
+ return
try:
subprocess.Popen( # nosec B603
[str(path)], env=updater.relaunch_env(), start_new_session=True,
@@ -537,6 +550,8 @@ def _relaunch(self, path):
except OSError as e:
self._lbl.setText(self.tr("Impossible de relancer l'application : {0}").format(e))
return
+ if main is not None:
+ main.allow_close_without_prompt() # déjà demandé juste au-dessus : pas de seconde question
self._quit_for_installer()
def _download_and_run(self):
@@ -556,9 +571,10 @@ def _installer_ready(self, target):
if os.name == "nt":
# Fichier qu'on vient nous-même de télécharger dans le temp
os.startfile(str(target)) # nosec B606
- # L'installeur refuse de continuer tant que l'application tourne
- # (fichiers verrouillés) : on se ferme pour lui laisser la place.
- QTimer.singleShot(1500, self._quit_for_installer)
+ # Pas de fermeture automatique ici : c'est l'installeur NSIS qui détecte
+ # l'application ouverte et propose de la fermer (macro CHECK_APP_RUNNING,
+ # installer/PDF-Equilibrist.nsi). _quit_for_installer reste utilisé par
+ # l'AppImage (_download_appimage), qui n'a pas d'installeur pour le faire.
else:
open_external(str(target))
except Exception:
diff --git a/src/pdf_equilibrist/ui/viewer.py b/src/pdf_equilibrist/ui/viewer.py
index 3505d2f..f36bc16 100644
--- a/src/pdf_equilibrist/ui/viewer.py
+++ b/src/pdf_equilibrist/ui/viewer.py
@@ -522,6 +522,8 @@ def enter_edit_mode(self, blocks_by_page: dict[int, list]) -> bool:
page_index=page_num,
page_origin=(0, 0),
)
+ # Une édition de texte modifie le PDF sans passer par checkpoint()
+ new_w.page_changed.connect(self.document.mark_modified)
new_w.setStyleSheet(
"background: white; border: 1px solid #3A3A3A; border-radius: 2px;"
)
diff --git a/tests/test_core/test_document_modified.py b/tests/test_core/test_document_modified.py
new file mode 100644
index 0000000..50e0a94
--- /dev/null
+++ b/tests/test_core/test_document_modified.py
@@ -0,0 +1,82 @@
+"""Document : suivi des modifications non enregistrées (astérisque d'onglet, confirmation à la fermeture)."""
+import pymupdf
+import pytest
+
+from pdf_equilibrist.core.document import Document
+
+
+@pytest.fixture
+def pdf_path(tmp_path):
+ path = tmp_path / "a.pdf"
+ d = pymupdf.open()
+ d.new_page()
+ d.save(path)
+ d.close()
+ return path
+
+
+def test_fresh_document_is_not_modified(pdf_path):
+ doc = Document()
+ doc.open(pdf_path)
+ assert not doc.is_modified
+
+
+def test_checkpoint_marks_modified_and_signals_once(pdf_path):
+ doc = Document()
+ doc.open(pdf_path)
+ seen = []
+ doc.modified_changed.connect(seen.append)
+ doc.checkpoint()
+ doc.checkpoint()
+ assert doc.is_modified
+ assert seen == [True] # une seule bascule, pas un signal par modification
+
+
+def test_mark_modified_without_checkpoint(pdf_path):
+ doc = Document()
+ doc.open(pdf_path)
+ doc.mark_modified() # ex. édition de texte, déplacement de page
+ assert doc.is_modified
+
+
+def test_save_clears_modified(pdf_path, tmp_path):
+ doc = Document()
+ doc.open(pdf_path)
+ doc.checkpoint()
+ seen = []
+ doc.modified_changed.connect(seen.append)
+ doc.save(tmp_path / "b.pdf")
+ assert not doc.is_modified
+ assert seen == [False]
+
+
+def test_failed_save_keeps_modified(pdf_path, tmp_path, monkeypatch):
+ doc = Document()
+ doc.open(pdf_path)
+ doc.checkpoint()
+
+ def boom(*_a, **_k):
+ raise OSError("disque plein")
+
+ monkeypatch.setattr(doc.fitz_doc, "save", boom, raising=False)
+ with pytest.raises(OSError):
+ doc.save(tmp_path / "c.pdf")
+ assert doc.is_modified
+
+
+def test_undo_stays_modified(pdf_path):
+ doc = Document()
+ doc.open(pdf_path)
+ doc.checkpoint()
+ assert doc.undo()
+ assert doc.is_modified
+
+
+def test_close_and_reopen_reset(pdf_path):
+ doc = Document()
+ doc.open(pdf_path)
+ doc.mark_modified()
+ doc.close()
+ assert not doc.is_modified
+ doc.open(pdf_path)
+ assert not doc.is_modified
diff --git a/translations/pdf_equilibrist_en.qm b/translations/pdf_equilibrist_en.qm
index fbbb2cb..14bb88c 100644
Binary files a/translations/pdf_equilibrist_en.qm and b/translations/pdf_equilibrist_en.qm differ
diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts
index 67a9f24..00323ba 100644
--- a/translations/pdf_equilibrist_en.ts
+++ b/translations/pdf_equilibrist_en.ts
@@ -423,6 +423,36 @@
PDF (*.pdf)
PDF (*.pdf)
+
+ Enregistrement impossible
+ Could not save
+
+
+ « {0} » n'a pas pu être enregistré :
+{1}
+
+Le document reste ouvert avec vos modifications.
+ "{0}" could not be saved:
+{1}
+
+The document stays open with your changes.
+
+
+ Modifications non enregistrées
+ Unsaved changes
+
+
+ Voulez-vous enregistrer les modifications apportées à « {0} » ?
+ Do you want to save the changes made to "{0}"?
+
+
+ Vos modifications seront perdues si vous ne les enregistrez pas.
+ Your changes will be lost if you don't save them.
+
+
+ Ne pas enregistrer
+ Don't save
+
PrintDialog
@@ -1764,6 +1794,10 @@ rypt
Plus tard
Later
+
+ Modifications non enregistrées
+ Unsaved changes
+
UpdateDialog
@@ -2052,6 +2086,10 @@ Dans un terminal : {0}{1}
Automatic installation is not available on this system (pkexec, sudo or the script is missing).
In a terminal: {0}{1}
+
+ Fermeture annulée : la nouvelle version est installée, relancez l'application quand vous voulez.
+ Closing cancelled: the new version is installed, restart the application whenever you like.
+
_PageLabel