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