From 1b5a8d967edfbf67e687e644e83cd4384e1827bb Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 13:34:18 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(update):=20Windows=20=E2=80=94=20ne=20p?= =?UTF-8?q?lus=20fermer=20l'application=20avant=20l'installeur=20NSIS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'installeur détecte l'application ouverte (CHECK_APP_RUNNING) et propose de la fermer ; la fermeture automatique à 1,5 s le court-circuitait. _quit_for_installer reste utilisé par l'AppImage, qui n'a pas d'installeur pour le faire. Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/ui/update_dialog.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pdf_equilibrist/ui/update_dialog.py b/src/pdf_equilibrist/ui/update_dialog.py index f25311e..30a7f94 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__ @@ -556,9 +556,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: From 12fcbd16972244c5921ff9097a980dd19b2f0e5a Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 13:48:01 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(close):=20protection=20contre=20la=20p?= =?UTF-8?q?erte=20de=20modifications=20non=20enregistr=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document : suivi des modifications (is_modified, modified_changed) ; marqué par checkpoint(), les déplacements/insertions de pages, l'OCR et l'édition de texte ; remis à zéro seulement quand l'écriture réussit. - Onglets : astérisque gras vert juste avant le titre, retiré à l'enregistrement. - Fermeture de la fenêtre, d'un onglet (Fichier › Fermer l'onglet / ✕) : boîte Enregistrer / Ne pas enregistrer / Annuler, document par document ; Annuler interrompt tout. - Échec d'enregistrement : le document reste ouvert, l'utilisateur est prévenu. - Mise à jour AppImage et redémarrage (langue/thème) : mêmes confirmations avant de relancer ou quitter ; sous Windows la fermeture demandée par l'installeur NSIS (WM_CLOSE) passe par closeEvent. Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/core/document.py | 24 +++++ src/pdf_equilibrist/ui/main_window.py | 109 +++++++++++++++++--- src/pdf_equilibrist/ui/tabs/tab_modifier.py | 2 + src/pdf_equilibrist/ui/thumbnail_panel.py | 2 + src/pdf_equilibrist/ui/title_bar.py | 25 +++++ src/pdf_equilibrist/ui/update_dialog.py | 15 +++ src/pdf_equilibrist/ui/viewer.py | 2 + tests/test_core/test_document_modified.py | 82 +++++++++++++++ translations/pdf_equilibrist_en.ts | 38 +++++++ 9 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 tests/test_core/test_document_modified.py 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 30a7f94..b50628e 100644 --- a/src/pdf_equilibrist/ui/update_dialog.py +++ b/src/pdf_equilibrist/ui/update_dialog.py @@ -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): 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.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 From e06071e2a396aecc419d5774b4bcc07b2e812f43 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 13:56:21 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(i18n):=20recompiler=20le=20.qm=20angl?= =?UTF-8?q?ais=20(modifications=20non=20enregistr=C3=A9es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- translations/pdf_equilibrist_en.qm | Bin 42876 -> 44242 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/translations/pdf_equilibrist_en.qm b/translations/pdf_equilibrist_en.qm index fbbb2cb09e7e4d82bdee849ae23832cbd592b8a2..14bb88c1e248366d8009ce149cf34ecb338eeecb 100644 GIT binary patch delta 4270 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 Qrv+T=^1Uj*DcaWJKa6|W)&Kwi delta 3219 zcmX9=cU%m0TjupQvCw zQN&cDz^=q9+lefaiSs&2G%BCC>Gec*FNiyMn&|T*q_e_W?~A0jt0uBM5`+)*!~ES5-ia08NG>oENJ+F z1fu*aG|CqT_CHQh6Z;ZLt^RN%~W!s;99D4<%mM! z=<2VyN%v&B(YJ|+ze3M?3q%{N@&YxsNz`)^*pcIlTdF1#0q!3GVYc*I4>G=IjtAKht-rz?zk~PYIummy_!ZHYlbAD&KZ3?f%9Z-+t=aeP+zS}pE#nha&1^dS0ew{+991lOhz|t z#CC> z9jSqWGY7J9S{yj|DI3=ej6csV(w)SCPAYm&VHeMUt$bV9Zwv8#?$v)hCB zAqS0Y>1=$zs0j#rc5YzHawiPK!h7tVM%>sbi~V_g6E<*RtKzp4^*Yb~QVwRO-es$^ z@M6C}6-SbaF^^RopRVG>y?M8_R?3X4?1elmU;L2$eJg~_oX=jXLjAqk*hhDsz@0Vh zbN4kwiR;+rT{te~4QDKEAewICI{a}R`TMA1fx=lH&LCRzCHI+C5Crk!zWCFXXzgRJ z*W7fXu>$A4DxYZdOKz|~^qn}J3v=xOLI>~TreB3czgW#Jd~_fA+QBVy_!UO5;uaS| zIqNB0+P}aNhq+}66tijJvi3lLL6KbkeH1=+6j!FjL1PzlyB)@WpIzKfH8{|=M#a$q z+$lFG*}s{)uo3IyY`F`UCnI8BTT6aR{mp&5;pVj%F*h@6PR_Ff| zIM7WujdMg49_VJBLd3HF)-9jsMU)<(%U+|@;HG9>Zox`~HbIw{4$7T>Q89mu?)&BQ z@%?38*{)i&#C+ZE&ry(ftL}hXf1)vibk~>RIby!<#R7yliRX=dvWaq!@Y2q3qE7aF zhf!cEZw%k5`W7-)$2-V-z|=Or+f@|Wt%UbbHX`Ij2YF8p)Gn>zeS~77cDMKdYgo*q znU8n;0a~{5Q!c^d{y}_F&SRn}1Nf9(9f`W0=NDEnM3Z@b#h*9f@frNLrgTWS6;P@Q)2e=vI;Z)8ICuj#GH$>4Gb$JeO~>noCri&p)4u9%@N^ z^Ge)oMIis;1*D-y{!M&uw6k;-JxB6wFT=o8KfT2!glpCay>(bBQL?OeE4C!k^i$El zOvRWj`ly*G%%V`m-ah)Mlb@bz74<{sI2FB?sOVR&;?S2WCf`?a{dWDhHZYSsRiD}# zkHSd5It^`Zc(eXHTX>=8zxu)oc*AF)e%l>fpx9dfQ&AIMu0xM0)4wf7|E@t=;t3&SNZQJLqq0IE0jpHqe>< zL|WiEFc(^8&@HPca%nX1nUG*wnZfdSHqr7;2CouGy3TB9%zUv{uIkahGHi> zFqCO9+k0a?+O`_1bN!%ssG){r%;PbJ3%h_ba|{m`I$>JX3YwU^pt?!W*5u5T3wD+Z`P}T%4!J28pbw z+;oFy=ZfiX;mIf`F|#x9t6UXF?-g?%;CI(bv84GRtYs_i@SH&u&|Afkv&5aZ8_+;K zQbluk0%q=c@yHGk>G(=Kw+z-2TN-kCT2-Rk(?}=|vkXDKu6hY+qUYZal5v?qj62HWAcY`#$ zt6~YSveF_u9Kew=_*Uef$t++cBvo2NF${w*}{c|bxz8TH(Nkmt%{?xa?gKz<8RkQ zIpnW-Xpj~1;0LLYELFwu2l6aMSNOwV%4MLlsl_ zqi8g=rKZ?6Xq#1IntI9)e^;_h^KCv+IL@>{YlbJPRPpHM62n*k04Mw_r$#GhP1=3UW7^-h_aO!k