Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/pdf_equilibrist/core/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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é ──────────────────────────────────────────────────────────────

Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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 ────────────────────────────────────────────────────
Expand All @@ -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."""
Expand All @@ -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

Expand Down
109 changes: 93 additions & 16 deletions src/pdf_equilibrist/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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 ────────────────────────────────────────────────────────────

Expand Down
2 changes: 2 additions & 0 deletions src/pdf_equilibrist/ui/tabs/tab_modifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/pdf_equilibrist/ui/thumbnail_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions src/pdf_equilibrist/ui/title_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down
24 changes: 20 additions & 4 deletions src/pdf_equilibrist/ui/update_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/pdf_equilibrist/ui/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
)
Expand Down
Loading
Loading