From fbe94ffa976f97bd143b8b3c2a03c007d11cbf76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:06:42 +0000 Subject: [PATCH 1/8] feat(modifier): dialogue de mise en forme pour l'ajout de texte (police, taille, gras/italique, couleur) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- src/pdf_equilibrist/ui/dialogs.py | 90 ++++++++++++++++++++- src/pdf_equilibrist/ui/floating_item.py | 8 +- src/pdf_equilibrist/ui/tabs/tab_modifier.py | 10 +-- tests/test_ui/test_text_format.py | 20 +++++ 4 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 tests/test_ui/test_text_format.py diff --git a/src/pdf_equilibrist/ui/dialogs.py b/src/pdf_equilibrist/ui/dialogs.py index f4442c1..79cd7bb 100644 --- a/src/pdf_equilibrist/ui/dialogs.py +++ b/src/pdf_equilibrist/ui/dialogs.py @@ -18,17 +18,26 @@ - ``ask_page_index`` : numéro de page via QSpinBox - ``ask_watermark_text`` : texte du filigrane - ``ask_text_input`` : saisie de texte libre (générique) +- ``ask_text_format`` : texte + police, taille, gras/italique, couleur - ``ask_image_format`` : sélection du format image (combo png/jpg/bmp/tiff) - ``show_info`` : message d'information (non bloquant pour l'UX) - ``show_error`` : message d'erreur critique - ``ensure_suffix`` : force l'extension d'un chemin issu de getSaveFileName """ from PyQt6.QtCore import QCoreApplication +from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QSpinBox, QComboBox, QMessageBox, + QPushButton, QSpinBox, QComboBox, QMessageBox, QCheckBox, QColorDialog, ) +# Polices PDF standard (base 14) : famille -> (normal, gras, italique, gras+italique) +PDF_FONTS = { + "Helvetica": ("helv", "hebo", "heit", "hebi"), + "Times": ("tiro", "tibo", "tiit", "tibi"), + "Courier": ("cour", "cobo", "coit", "cobi"), +} + DIALOG_STYLE = """ QDialog { background: #2D2D2D; color: #F0F0F0; } @@ -141,6 +150,85 @@ def ask_text_input(parent, title: str, label: str) -> str | None: return txt.text().strip() or None if dlg.exec() == QDialog.DialogCode.Accepted else None +def pdf_fontname(family: str, bold: bool, italic: bool) -> str: + """Nom de police PDF (base 14) pour une famille et un style.""" + names = PDF_FONTS.get(family, PDF_FONTS["Helvetica"]) + return names[(1 if bold else 0) + (2 if italic else 0)] + + +def ask_text_format(parent, title: str, label: str) -> dict | None: + """Saisie d'un texte avec police, taille, style et couleur. + + Retourne ``{'text', 'fontname', 'family', 'bold', 'italic', 'fontsize', + 'color'}`` (couleur RGB 0–1) ou ``None`` si annulé / texte vide. + """ + tr = QCoreApplication.translate + dlg = QDialog(parent) + dlg.setWindowTitle(title) + dlg.setStyleSheet(DIALOG_STYLE) + dlg.setMinimumWidth(380) + layout = QVBoxLayout(dlg) + layout.addWidget(QLabel(label)) + txt = QLineEdit() + layout.addWidget(txt) + + row = QHBoxLayout() + family = QComboBox() + family.addItems(list(PDF_FONTS)) + size = QSpinBox() + size.setRange(4, 200) + size.setValue(14) + size.setSuffix(" pt") + bold = QCheckBox(tr("dialogs", "Gras")) + italic = QCheckBox(tr("dialogs", "Italique")) + row.addWidget(QLabel(tr("dialogs", "Police :"))) + row.addWidget(family, 1) + row.addWidget(QLabel(tr("dialogs", "Taille :"))) + row.addWidget(size) + layout.addLayout(row) + + row2 = QHBoxLayout() + row2.addWidget(bold) + row2.addWidget(italic) + row2.addStretch() + color_btn = QPushButton(tr("dialogs", "Couleur…")) + chosen = {"c": QColor(0, 0, 0)} + + def _refresh(): + c = chosen["c"] + fg = "#000" if c.lightness() > 128 else "#FFF" + color_btn.setStyleSheet( + f"background:{c.name()}; color:{fg}; border:1px solid #4A4A4A;") + + def _pick(): + c = QColorDialog.getColor(chosen["c"], dlg, tr("dialogs", "Couleur du texte")) + if c.isValid(): + chosen["c"] = c + _refresh() + + color_btn.clicked.connect(_pick) + _refresh() + row2.addWidget(color_btn) + layout.addLayout(row2) + + _ok_cancel(dlg, layout) + if dlg.exec() != QDialog.DialogCode.Accepted: + return None + text = txt.text().strip() + if not text: + return None + c = chosen["c"] + return { + "text": text, + "family": family.currentText(), + "bold": bold.isChecked(), + "italic": italic.isChecked(), + "fontname": pdf_fontname(family.currentText(), bold.isChecked(), italic.isChecked()), + "fontsize": size.value(), + "color": (c.redF(), c.greenF(), c.blueF()), + } + + def ask_image_format(parent) -> str | None: dlg = QDialog(parent) dlg.setWindowTitle(QCoreApplication.translate("dialogs", "Format d'image")) diff --git a/src/pdf_equilibrist/ui/floating_item.py b/src/pdf_equilibrist/ui/floating_item.py index cb9684d..ed9b0c5 100644 --- a/src/pdf_equilibrist/ui/floating_item.py +++ b/src/pdf_equilibrist/ui/floating_item.py @@ -52,7 +52,8 @@ class FloatingItem(QWidget): Widget interactif superposé sur une page PDF pour le placement visuel. Supporte trois types d'éléments via le dict ``data`` : - - ``{'type': 'text', 'text': str, 'fontsize': int, 'color': tuple}`` + - ``{'type': 'text', 'text': str, 'fontsize': int, 'color': tuple, + 'fontname': str (base 14), 'family': str, 'bold': bool, 'italic': bool}`` - ``{'type': 'image', 'path': str}`` - ``{'type': 'stamp', 'text': str, 'color': tuple}`` @@ -342,7 +343,10 @@ def _draw_content(self, p: QPainter, r: QRectF): r_val, g_val, b_val = (int(c * 255) for c in color) p.setPen(QPen(QColor(r_val, g_val, b_val))) fs = max(8, int(self._data.get("fontsize", 12) * self._zoom)) - p.setFont(QFont("Segoe UI", fs)) + qf = QFont(self._data.get("family", "Segoe UI"), fs) + qf.setBold(bool(self._data.get("bold"))) + qf.setItalic(bool(self._data.get("italic"))) + p.setFont(qf) p.drawText(r.adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._data.get("text", "")) diff --git a/src/pdf_equilibrist/ui/tabs/tab_modifier.py b/src/pdf_equilibrist/ui/tabs/tab_modifier.py index 43e68fb..6a4c44d 100644 --- a/src/pdf_equilibrist/ui/tabs/tab_modifier.py +++ b/src/pdf_equilibrist/ui/tabs/tab_modifier.py @@ -29,7 +29,7 @@ from pathlib import Path from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog from pdf_equilibrist.ui.widgets import RibbonButton, RibbonGroup -from pdf_equilibrist.ui.dialogs import ask_watermark_text, ask_text_input, show_info, show_error, ensure_suffix +from pdf_equilibrist.ui.dialogs import ask_watermark_text, ask_text_format, show_info, show_error, ensure_suffix from pdf_equilibrist.core.document import Document from pdf_equilibrist.operations.edit import ( compress, add_watermark, @@ -152,11 +152,10 @@ def _reset_edit_ui(self): def _add_text(self): if not self.document.is_open: return - text = ask_text_input(self, self.tr("Ajouter du texte"), self.tr("Texte à insérer :")) - if not text: + fmt = ask_text_format(self, self.tr("Ajouter du texte"), self.tr("Texte à insérer :")) + if not fmt: return - data = {"type": "text", "text": text, - "fontsize": 14, "color": (0, 0, 0)} + data = {"type": "text", **fmt} self.viewer.show_floating_item(data, self.viewer.current_page_index, self._on_placement_commit, None) @@ -208,6 +207,7 @@ def _on_placement_commit(self, result: dict, page_index: int): page.insert_text( pymupdf.Point(x0, y1), result.get("text", ""), + fontname=result.get("fontname", "helv"), fontsize=result.get("fontsize", 14), color=result.get("color", (0, 0, 0)), rotate=int(-angle), diff --git a/tests/test_ui/test_text_format.py b/tests/test_ui/test_text_format.py new file mode 100644 index 0000000..ebcd384 --- /dev/null +++ b/tests/test_ui/test_text_format.py @@ -0,0 +1,20 @@ +"""Choix de police pour l'ajout de texte.""" +import pymupdf +import pytest + +from pdf_equilibrist.ui.dialogs import PDF_FONTS, pdf_fontname + + +@pytest.mark.parametrize("family", list(PDF_FONTS)) +@pytest.mark.parametrize("bold", [False, True]) +@pytest.mark.parametrize("italic", [False, True]) +def test_pdf_fontname_is_insertable(family, bold, italic): + name = pdf_fontname(family, bold, italic) + doc = pymupdf.open() + page = doc.new_page() + page.insert_text(pymupdf.Point(50, 50), "Éàç test", fontname=name, fontsize=12) + assert "test" in page.get_text() + + +def test_pdf_fontname_unknown_family_falls_back(): + assert pdf_fontname("Inconnue", False, False) == "helv" From 53f0d16a1e4cf1db80dbe82316a0dbe698408594 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:00:12 +0000 Subject: [PATCH 2/8] =?UTF-8?q?chore(release):=20pr=C3=A9pare=200.2.2=20(c?= =?UTF-8?q?hangelog=20Debian=20noble/resolute,=20spec=20COPR,=20checklist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- packaging/RELEASE_CHECKLIST.md | 6 +++--- packaging/copr/pdf-equilibrist.spec | 7 ++++++- packaging/ppa/debian/changelog | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packaging/RELEASE_CHECKLIST.md b/packaging/RELEASE_CHECKLIST.md index e67fcad..4c06090 100644 --- a/packaging/RELEASE_CHECKLIST.md +++ b/packaging/RELEASE_CHECKLIST.md @@ -5,7 +5,7 @@ > et la mise à jour du site (`update-site-version.yml`). Un tag ne se « retire » pas proprement : > **ne le créer qu'une fois toute la checklist cochée.** -## 1. Préparation (avant le tag) — fait pour 0.2.1 +## 1. Préparation (avant le tag) — fait pour 0.2.2 - [x] `packaging/ppa/debian/changelog` : entrées `X.Y.Z-1~noble1` **et** `X.Y.Z-1~resolute1` en tête (le workflow PPA échoue explicitement si la version ne correspond pas au tag) @@ -49,8 +49,8 @@ OCR → PDF cherchable, OCR → Word, OCR → Excel. Mesure synthétique de réf ```bash git switch main && git pull -git tag -a v0.2.1 -m "PDF-Equilibrist 0.2.1" -git push origin v0.2.1 # ← déclenche TOUT : release publique + AUR + PPA + COPR + site +git tag -a v0.2.2 -m "PDF-Equilibrist 0.2.2" +git push origin v0.2.2 # ← déclenche TOUT : release publique + AUR + PPA + COPR + site ``` ## 5. Après le tag — à surveiller diff --git a/packaging/copr/pdf-equilibrist.spec b/packaging/copr/pdf-equilibrist.spec index d9f1beb..3158917 100644 --- a/packaging/copr/pdf-equilibrist.spec +++ b/packaging/copr/pdf-equilibrist.spec @@ -1,5 +1,5 @@ Name: pdf-equilibrist -Version: 0.2.1 +Version: 0.2.2 Release: 1%{?dist} Summary: Free and open-source desktop PDF editor @@ -197,6 +197,11 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop %{_datadir}/icons/hicolor/256x256/apps/%{name}.png %changelog +* Mon Nov 02 2026 Paul Woisard - 0.2.2-1 +- New upstream release: font, size, style and colour for added text. + "Add text" now opens a formatting dialog (font, size, bold, italic, + colour); the preview and the text written into the PDF follow it. + * Sun Oct 18 2026 Paul Woisard - 0.2.1-1 - New upstream release: no more lost work, and a quick access toolbar. Closing the window, a tab or a document with unsaved changes now asks what diff --git a/packaging/ppa/debian/changelog b/packaging/ppa/debian/changelog index 0b1e3b1..cabd1a9 100644 --- a/packaging/ppa/debian/changelog +++ b/packaging/ppa/debian/changelog @@ -1,3 +1,21 @@ +pdf-equilibrist (0.2.2-1~noble1) noble; urgency=medium + + * New upstream release: font, size, style and colour for added text. + - "Add text" now opens a formatting dialog: font (Helvetica, Times or + Courier), size, bold, italic and colour. The floating preview and the + text written into the PDF use the chosen font, style and colour. + + -- Paul Woisard Mon, 02 Nov 2026 12:00:00 +0000 + +pdf-equilibrist (0.2.2-1~resolute1) resolute; urgency=medium + + * New upstream release: font, size, style and colour for added text. + - "Add text" now opens a formatting dialog: font (Helvetica, Times or + Courier), size, bold, italic and colour. The floating preview and the + text written into the PDF use the chosen font, style and colour. + + -- Paul Woisard Mon, 02 Nov 2026 12:00:00 +0000 + pdf-equilibrist (0.2.1-1~noble1) noble; urgency=medium * New upstream release: no more lost work, and a quick access toolbar. From d252b60cfe51bccfbeadb3a1e9c6b973665dfdb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:12:48 +0000 Subject: [PATCH 3/8] feat(modifier): taille de police dynamique et barre de mise en forme sur le texte flottant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - la taille suit la boîte (largeur ou hauteur, la contrainte la plus forte) quand on la redimensionne - police, taille, gras, italique et couleur restent modifiables jusqu'à la validation - gravure à la taille ajustée, centrée verticalement, rotation à angle libre (operations.edit.add_text_fitted) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- src/pdf_equilibrist/operations/edit.py | 51 ++++++ src/pdf_equilibrist/ui/floating_item.py | 187 ++++++++++++++++++-- src/pdf_equilibrist/ui/tabs/tab_modifier.py | 11 +- tests/test_operations/test_text_fit.py | 50 ++++++ tests/test_ui/test_floating_text.py | 55 ++++++ 5 files changed, 329 insertions(+), 25 deletions(-) create mode 100644 tests/test_operations/test_text_fit.py create mode 100644 tests/test_ui/test_floating_text.py diff --git a/src/pdf_equilibrist/operations/edit.py b/src/pdf_equilibrist/operations/edit.py index 5fb9254..0887a88 100644 --- a/src/pdf_equilibrist/operations/edit.py +++ b/src/pdf_equilibrist/operations/edit.py @@ -423,6 +423,57 @@ def add_text( ) +def text_line_metrics(fontname: str) -> tuple[float, float]: + """Ascendante et descendante (positive) d'une police, pour une taille de 1 pt.""" + font = pymupdf.Font(fontname) + return font.ascender, -font.descender + + +def text_natural_size(text: str, fontname: str, fontsize: float) -> tuple[float, float]: + """Largeur et hauteur (pts) d'une ligne de ``text`` à ``fontsize``.""" + asc, desc = text_line_metrics(fontname) + width = pymupdf.Font(fontname).text_length(text, fontsize=1) * fontsize + return width, (asc + desc) * fontsize + + +def fit_fontsize(text: str, fontname: str, box_w: float, box_h: float, + min_size: float = 1.0, max_size: float = 1000.0) -> float: + """ + Plus grande taille de police pour laquelle ``text`` tient dans la boîte. + + C'est la contrainte la plus forte (largeur ou hauteur) qui l'emporte : + la boîte est remplie au maximum sans que le texte ne déborde. + """ + asc, desc = text_line_metrics(fontname) + unit_w = pymupdf.Font(fontname).text_length(text, fontsize=1) + by_h = box_h / (asc + desc) + by_w = box_w / unit_w if unit_w > 0 else max_size + return max(min_size, min(by_w, by_h, max_size)) + + +def add_text_fitted(page: pymupdf.Page, rect: pymupdf.Rect, text: str, + fontname: str = "helv", color: tuple = (0, 0, 0), + angle: float = 0.0) -> float: + """ + Écrit ``text`` sur une ligne, à la taille maximale qui tient dans ``rect``. + + Le texte est aligné à gauche et centré verticalement dans ``rect``. + ``angle`` (degrés, sens horaire à l'écran) pivote autour du centre de ``rect``. + Retourne la taille de police utilisée (pts). + """ + size = fit_fontsize(text, fontname, rect.width, rect.height) + asc, desc = text_line_metrics(fontname) + baseline_y = rect.y0 + (rect.height - (asc + desc) * size) / 2 + asc * size + morph = None + if angle % 360: + center = pymupdf.Point((rect.x0 + rect.x1) / 2, (rect.y0 + rect.y1) / 2) + morph = (center, pymupdf.Matrix(-angle)) + page.insert_text(pymupdf.Point(rect.x0, baseline_y), text, + fontname=fontname, fontsize=size, color=color, + morph=morph, overlay=True) + return size + + def add_image(doc: pymupdf.Document, page_index: int, image_path: Path, rect: pymupdf.Rect): """ diff --git a/src/pdf_equilibrist/ui/floating_item.py b/src/pdf_equilibrist/ui/floating_item.py index ed9b0c5..55f4181 100644 --- a/src/pdf_equilibrist/ui/floating_item.py +++ b/src/pdf_equilibrist/ui/floating_item.py @@ -36,15 +36,29 @@ """ from __future__ import annotations import math -from PyQt6.QtWidgets import QWidget, QPushButton, QHBoxLayout +from PyQt6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QComboBox, + QDoubleSpinBox, QColorDialog) from PyQt6.QtGui import (QPainter, QPen, QBrush, QColor, QPixmap, - QFont) + QFont, QFontMetricsF) from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QPointF, QRectF, QSizeF +from pdf_equilibrist.operations.edit import ( + fit_fontsize, text_line_metrics, text_natural_size, +) +from pdf_equilibrist.ui.dialogs import PDF_FONTS, pdf_fontname + ACCENT = "#6BBF4E" HANDLE_R = 6 # rayon des poignées de redimensionnement (px) ROT_DIST = 28 # distance de la poignée de rotation au-dessus du bord haut (px) MIN_SIZE = 40 # taille minimale de l'élément lors du redimensionnement (px) +BAR_H = 32 # hauteur d'une barre d'outils sous l'élément (px) + +# Familles Qt de repli pour l'aperçu (la gravure utilise les polices PDF standard) +_QT_FAMILIES = { + "Helvetica": ["Helvetica", "Arial", "Liberation Sans"], + "Times": ["Times New Roman", "Times", "Liberation Serif"], + "Courier": ["Courier New", "Courier", "Liberation Mono"], +} class FloatingItem(QWidget): @@ -57,6 +71,11 @@ class FloatingItem(QWidget): - ``{'type': 'image', 'path': str}`` - ``{'type': 'stamp', 'text': str, 'color': tuple}`` + Pour un texte, la **taille de police est dynamique** : elle suit la boîte + (largeur ou hauteur, la contrainte la plus forte) quand on la redimensionne, + et une barre de mise en forme (police, taille, gras, italique, couleur) + reste utilisable jusqu'à la validation. + Signals ------- committed : pyqtSignal(dict) @@ -102,6 +121,7 @@ def __init__(self, data: dict, zoom: float, parent: QWidget): self._item_origin = QPoint() # position avant drag self._item_size = QSizeF(200, 60) self._resize_anchor = QPointF() + self._fmt_bar: QWidget | None = None # Pré-charger pixmap si image self._pixmap: QPixmap | None = None @@ -142,9 +162,12 @@ def __init__(self, data: dict, zoom: float, parent: QWidget): ph = min(self._pixmap.height(), int(200 * zoom)) self._item_size = QSizeF(pw, ph) elif data.get("type") == "text": - fs = data.get("fontsize", 12) - w = max(120, len(data.get("text", "")) * int(fs * zoom * 0.7)) - self._item_size = QSizeF(w, fs * zoom * 2) + self._data.setdefault("fontname", "helv") + self._data.setdefault("family", "Helvetica") + fs = float(data.get("fontsize", 12)) + w, h = text_natural_size(data.get("text", ""), self._data["fontname"], fs) + self._item_size = QSizeF(max(MIN_SIZE, w * zoom), max(MIN_SIZE / 2, h * zoom)) + self._build_format_bar() elif data.get("type") == "stamp": self._item_size = QSizeF(200, 44) @@ -165,8 +188,15 @@ def _update_size(self): extra = ROT_DIST + HANDLE_R w = int(self._item_size.width()) + margin * 2 h = int(self._item_size.height()) + margin * 2 + extra - self.resize(w, h + 32) # +32 pour la toolbar + bars = BAR_H self._toolbar.move(0, h + 2) + if self._fmt_bar is not None: + self._fmt_bar.move(0, h + 2 + BAR_H) + bars += BAR_H + w = max(w, self._fmt_bar.width()) + # le widget ne doit pas rogner les barres d'outils quand la boîte est petite + w = max(w, self._toolbar.width()) + self.resize(w, h + bars) # ── Géométrie interne ──────────────────────────────────────────────────── @@ -251,6 +281,7 @@ def mouseMoveEvent(self, event): if h_idx in (4,): new_h = max(MIN_SIZE, new_h + delta.y()) if h_idx in (5,): new_h = max(MIN_SIZE, new_h - delta.y()) self._item_size = QSizeF(new_w, new_h) + self._refit_text() self._update_size() self.update() @@ -338,18 +369,138 @@ def _draw_content(self, p: QPainter, r: QRectF): elif dtype == "text": p.fillRect(r, QColor(255, 255, 200, 180)) - p.setPen(QPen(QColor("#333333"))) - color = self._data.get("color", (0, 0, 0)) - r_val, g_val, b_val = (int(c * 255) for c in color) - p.setPen(QPen(QColor(r_val, g_val, b_val))) - fs = max(8, int(self._data.get("fontsize", 12) * self._zoom)) - qf = QFont(self._data.get("family", "Segoe UI"), fs) - qf.setBold(bool(self._data.get("bold"))) - qf.setItalic(bool(self._data.get("italic"))) - p.setFont(qf) - p.drawText(r.adjusted(4, 4, -4, -4), - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, - self._data.get("text", "")) + self._draw_text(p, r) + + # ── Texte : taille dynamique et mise en forme ──────────────────────────── + + def _text_font(self) -> str: + return self._data.get("fontname", "helv") + + def _refit_text(self): + """Recalcule la taille de police pour que le texte remplisse la boîte.""" + if self._data.get("type") != "text": + return + z = max(self._zoom, 0.01) + size = fit_fontsize(self._data.get("text", ""), self._text_font(), + self._item_size.width() / z, + self._item_size.height() / z) + self._data["fontsize"] = round(size, 2) + self._sync_size_spin() + + def _draw_text(self, p: QPainter, r: QRectF): + text = self._data.get("text", "") + if not text: + return + z = max(self._zoom, 0.01) + size_px = float(self._data.get("fontsize", 12)) * z + asc, desc = text_line_metrics(self._text_font()) + # Aperçu fidèle à la gravure : la largeur suit les métriques de la police + # PDF, même si la police Qt de remplacement est un peu différente. + want_w = text_natural_size(text, self._text_font(), + float(self._data.get("fontsize", 12)))[0] * z + qf = QFont() + qf.setFamilies(_QT_FAMILIES.get(self._data.get("family", "Helvetica"), + _QT_FAMILIES["Helvetica"])) + qf.setPixelSize(100) + qf.setBold(bool(self._data.get("bold"))) + qf.setItalic(bool(self._data.get("italic"))) + qt_w = QFontMetricsF(qf).horizontalAdvance(text) or 1.0 + color = self._data.get("color", (0, 0, 0)) + p.save() + p.setClipRect(r) + p.setPen(QPen(QColor(*(int(c * 255) for c in color)))) + p.setFont(qf) + baseline = r.top() + (r.height() - (asc + desc) * size_px) / 2 + asc * size_px + p.translate(r.left(), baseline) + p.scale(want_w / qt_w, size_px / 100.0) + p.drawText(QPointF(0, 0), text) + p.restore() + + def _build_format_bar(self): + """Barre sous la boîte : police, taille, gras, italique, couleur.""" + bar = QWidget(self) + lay = QHBoxLayout(bar) + lay.setContentsMargins(2, 2, 2, 2) + lay.setSpacing(4) + style = ("QWidget { background:#2D2D2D; color:#F0F0F0; font-size:11px; }" + "QComboBox, QDoubleSpinBox { background:#1E1E1E; border:1px solid #3A3A3A;" + " border-radius:4px; padding:1px 4px; }" + "QPushButton { background:#3A3A3A; border:1px solid #4A4A4A;" + " border-radius:4px; padding:2px 8px; }" + f"QPushButton:checked {{ background:{ACCENT}; color:#1E1E1E; }}") + bar.setStyleSheet(style) + + self._cb_family = QComboBox() + self._cb_family.addItems(list(PDF_FONTS)) + self._cb_family.setCurrentText(self._data.get("family", "Helvetica")) + self._sp_size = QDoubleSpinBox() + self._sp_size.setRange(1.0, 400.0) + self._sp_size.setDecimals(1) + self._sp_size.setSuffix(" pt") + self._sp_size.setKeyboardTracking(False) + self._bt_bold = QPushButton("G") + self._bt_bold.setCheckable(True) + self._bt_bold.setChecked(bool(self._data.get("bold"))) + self._bt_bold.setToolTip("Gras") + self._bt_italic = QPushButton("I") + self._bt_italic.setCheckable(True) + self._bt_italic.setChecked(bool(self._data.get("italic"))) + self._bt_italic.setToolTip("Italique") + self._bt_color = QPushButton("A") + self._bt_color.setToolTip("Couleur") + for w in (self._cb_family, self._sp_size, self._bt_bold, + self._bt_italic, self._bt_color): + lay.addWidget(w) + bar.adjustSize() + + self._fmt_bar = bar + self._sync_size_spin() + self._paint_color_button() + self._cb_family.currentTextChanged.connect(self._on_style_changed) + self._bt_bold.toggled.connect(self._on_style_changed) + self._bt_italic.toggled.connect(self._on_style_changed) + self._sp_size.valueChanged.connect(self._on_size_edited) + self._bt_color.clicked.connect(self._pick_color) + + def _sync_size_spin(self): + if self._fmt_bar is None: + return + self._sp_size.blockSignals(True) + self._sp_size.setValue(float(self._data.get("fontsize", 12))) + self._sp_size.blockSignals(False) + + def _paint_color_button(self): + c = QColor(*(int(v * 255) for v in self._data.get("color", (0, 0, 0)))) + fg = "#000000" if c.lightness() > 128 else "#FFFFFF" + self._bt_color.setStyleSheet( + f"QPushButton {{ background:{c.name()}; color:{fg};" + f" border:1px solid #4A4A4A; font-weight:bold; }}") + + def _on_style_changed(self, *_): + """Police / gras / italique : la boîte ne bouge pas, la taille se réajuste.""" + family = self._cb_family.currentText() + bold, italic = self._bt_bold.isChecked(), self._bt_italic.isChecked() + self._data.update(family=family, bold=bold, italic=italic, + fontname=pdf_fontname(family, bold, italic)) + self._refit_text() + self.update() + + def _on_size_edited(self, value: float): + """Taille saisie : la boîte s'ajuste au texte à cette taille (coin haut-gauche fixe).""" + self._data["fontsize"] = round(float(value), 2) + w, h = text_natural_size(self._data.get("text", ""), self._text_font(), value) + z = max(self._zoom, 0.01) + self._item_size = QSizeF(max(MIN_SIZE / 2, w * z), max(MIN_SIZE / 4, h * z)) + self._update_size() + self.update() + + def _pick_color(self): + c0 = QColor(*(int(v * 255) for v in self._data.get("color", (0, 0, 0)))) + c = QColorDialog.getColor(c0, self, "Couleur du texte") + if c.isValid(): + self._data["color"] = (c.redF(), c.greenF(), c.blueF()) + self._paint_color_button() + self.update() # ── Validation ─────────────────────────────────────────────────────────── diff --git a/src/pdf_equilibrist/ui/tabs/tab_modifier.py b/src/pdf_equilibrist/ui/tabs/tab_modifier.py index 6a4c44d..668c0cd 100644 --- a/src/pdf_equilibrist/ui/tabs/tab_modifier.py +++ b/src/pdf_equilibrist/ui/tabs/tab_modifier.py @@ -32,7 +32,7 @@ from pdf_equilibrist.ui.dialogs import ask_watermark_text, ask_text_format, show_info, show_error, ensure_suffix from pdf_equilibrist.core.document import Document from pdf_equilibrist.operations.edit import ( - compress, add_watermark, + compress, add_watermark, add_text_fitted, extract_text_blocks, ) import pymupdf @@ -204,14 +204,11 @@ def _on_placement_commit(self, result: dict, page_index: int): dtype = result.get("type") if dtype == "text": - page.insert_text( - pymupdf.Point(x0, y1), - result.get("text", ""), + add_text_fitted( + page, rect, result.get("text", ""), fontname=result.get("fontname", "helv"), - fontsize=result.get("fontsize", 14), color=result.get("color", (0, 0, 0)), - rotate=int(-angle), - overlay=True, + angle=angle, ) elif dtype == "image": page.insert_image(rect, filename=result["path"]) diff --git a/tests/test_operations/test_text_fit.py b/tests/test_operations/test_text_fit.py new file mode 100644 index 0000000..e0de77a --- /dev/null +++ b/tests/test_operations/test_text_fit.py @@ -0,0 +1,50 @@ +"""Taille de police dynamique : le texte remplit la boîte (largeur ou hauteur).""" +import pymupdf +import pytest + +from pdf_equilibrist.operations.edit import ( + add_text_fitted, fit_fontsize, text_natural_size, +) + + +@pytest.mark.parametrize("fontname", ["helv", "hebo", "tiit", "cour"]) +def test_text_fits_inside_box(fontname): + for box in [(200, 40), (60, 100), (400, 20), (30, 30)]: + size = fit_fontsize("Bonjour le monde", fontname, *box) + w, h = text_natural_size("Bonjour le monde", fontname, size) + assert w <= box[0] + 1e-6 and h <= box[1] + 1e-6 + # la contrainte la plus forte est atteinte (largeur OU hauteur) + assert abs(w - box[0]) < 1e-6 or abs(h - box[1]) < 1e-6 + + +def test_shrinking_box_shrinks_font_and_back(): + big = fit_fontsize("Texte", "helv", 300, 100) + small = fit_fontsize("Texte", "helv", 150, 50) + assert small == pytest.approx(big / 2) + assert fit_fontsize("Texte", "helv", 300, 100) == pytest.approx(big) + + +def test_narrow_box_is_limited_by_width_wide_box_by_height(): + narrow = fit_fontsize("Un texte assez long", "helv", 50, 200) + wide = fit_fontsize("Un texte assez long", "helv", 500, 20) + assert narrow < 20 + assert wide == pytest.approx(20 / (1.075 + 0.299), rel=1e-2) + + +def test_add_text_fitted_stays_in_rect_and_returns_size(): + doc = pymupdf.open() + page = doc.new_page() + rect = pymupdf.Rect(100, 100, 300, 140) + size = add_text_fitted(page, rect, "Hello world", "helv", (1, 0, 0)) + x0, y0, x1, y1 = page.get_text("words")[0][:4] + xe, ye = page.get_text("words")[-1][2:4] + assert size > 0 + assert x0 >= rect.x0 - 1 and xe <= rect.x1 + 1 + assert y0 >= rect.y0 - 1 and ye <= rect.y1 + 1 + + +def test_add_text_fitted_rotation_arbitrary_angle(): + doc = pymupdf.open() + page = doc.new_page() + add_text_fitted(page, pymupdf.Rect(100, 100, 300, 140), "Hello", "helv", angle=33.0) + assert "Hello" in page.get_text() diff --git a/tests/test_ui/test_floating_text.py b/tests/test_ui/test_floating_text.py new file mode 100644 index 0000000..793e2f1 --- /dev/null +++ b/tests/test_ui/test_floating_text.py @@ -0,0 +1,55 @@ +"""FloatingItem texte : taille dynamique et barre de mise en forme.""" +import pytest + +pytest.importorskip("PyQt6.QtWidgets") +from PyQt6.QtCore import QSizeF # noqa: E402 +from PyQt6.QtWidgets import QApplication, QWidget # noqa: E402 + +from pdf_equilibrist.ui.floating_item import FloatingItem # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def item(app): + page = QWidget() + page.resize(800, 1000) + data = {"type": "text", "text": "Bonjour le monde", "fontsize": 20, + "color": (1, 0, 0), "family": "Helvetica", "bold": False, + "italic": False, "fontname": "helv"} + it = FloatingItem(data, 1.5, page) + it._page = page # garde le parent vivant + return it + + +def test_resizing_box_rescales_font(item): + base = item._data["fontsize"] + item._item_size = QSizeF(item._item_size.width() / 2, item._item_size.height() / 2) + item._refit_text() + assert item._data["fontsize"] == pytest.approx(base / 2, rel=0.02) + item._item_size = QSizeF(item._item_size.width() * 2, item._item_size.height() * 2) + item._refit_text() + assert item._data["fontsize"] == pytest.approx(base, rel=0.02) + + +def test_narrower_box_is_limited_by_width(item): + base = item._data["fontsize"] + item._item_size = QSizeF(item._item_size.width() / 4, item._item_size.height()) + item._refit_text() + assert item._data["fontsize"] == pytest.approx(base / 4, rel=0.02) + + +def test_style_controls_still_editable_before_commit(item): + item._bt_bold.setChecked(True) + item._bt_italic.setChecked(True) + item._cb_family.setCurrentText("Times") + assert item._data["fontname"] == "tibi" + item._sp_size.setValue(30.0) + assert item._data["fontsize"] == 30.0 + got = [] + item.committed.connect(got.append) + item._commit() + assert got[0]["fontname"] == "tibi" and got[0]["fontsize"] == 30.0 From 570e0d01059338be15651044bf03c52bc64771ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:16:03 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat(modifier):=20=C2=AB=20Cliquez=20sur=20?= =?UTF-8?q?la=20page=20pour=20poser=20le=20texte=20=C2=BB=20=E2=80=94=20po?= =?UTF-8?q?se=20par=20clic=20sur=20le=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajouter du texte / image : bandeau d'aide, curseur en croix, clic = pose centrée sur la page cliquée, Échap ou Annuler pour abandonner - plus de collage automatique au milieu de la page courante - traductions EN (PdfViewer, FloatingItem, dialogs) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- src/pdf_equilibrist/ui/floating_item.py | 18 +++- src/pdf_equilibrist/ui/tabs/tab_modifier.py | 6 +- src/pdf_equilibrist/ui/viewer.py | 108 +++++++++++++++++++- tests/test_ui/test_click_to_place.py | 64 ++++++++++++ translations/pdf_equilibrist_en.ts | 64 ++++++++++++ 5 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 tests/test_ui/test_click_to_place.py diff --git a/src/pdf_equilibrist/ui/floating_item.py b/src/pdf_equilibrist/ui/floating_item.py index 55f4181..007393b 100644 --- a/src/pdf_equilibrist/ui/floating_item.py +++ b/src/pdf_equilibrist/ui/floating_item.py @@ -183,6 +183,16 @@ def _place_at_center(self): self.move(x, y) self._update_size() + def place_center_at(self, pos: QPoint): + """Centre la boîte de l'élément sur ``pos`` (coordonnées de la page), bornée à la page.""" + r = self._item_rect() + x = int(pos.x() - r.center().x()) + y = int(pos.y() - r.center().y()) + if self.parent(): + x = max(0, min(x, self.parent().width() - self.width())) + y = max(0, min(y, self.parent().height() - self.height())) + self.move(x, y) + def _update_size(self): margin = HANDLE_R + 2 extra = ROT_DIST + HANDLE_R @@ -441,13 +451,13 @@ def _build_format_bar(self): self._bt_bold = QPushButton("G") self._bt_bold.setCheckable(True) self._bt_bold.setChecked(bool(self._data.get("bold"))) - self._bt_bold.setToolTip("Gras") + self._bt_bold.setToolTip(self.tr("Gras")) self._bt_italic = QPushButton("I") self._bt_italic.setCheckable(True) self._bt_italic.setChecked(bool(self._data.get("italic"))) - self._bt_italic.setToolTip("Italique") + self._bt_italic.setToolTip(self.tr("Italique")) self._bt_color = QPushButton("A") - self._bt_color.setToolTip("Couleur") + self._bt_color.setToolTip(self.tr("Couleur")) for w in (self._cb_family, self._sp_size, self._bt_bold, self._bt_italic, self._bt_color): lay.addWidget(w) @@ -496,7 +506,7 @@ def _on_size_edited(self, value: float): def _pick_color(self): c0 = QColor(*(int(v * 255) for v in self._data.get("color", (0, 0, 0)))) - c = QColorDialog.getColor(c0, self, "Couleur du texte") + c = QColorDialog.getColor(c0, self, self.tr("Couleur du texte")) if c.isValid(): self._data["color"] = (c.redF(), c.greenF(), c.blueF()) self._paint_color_button() diff --git a/src/pdf_equilibrist/ui/tabs/tab_modifier.py b/src/pdf_equilibrist/ui/tabs/tab_modifier.py index 668c0cd..fffb496 100644 --- a/src/pdf_equilibrist/ui/tabs/tab_modifier.py +++ b/src/pdf_equilibrist/ui/tabs/tab_modifier.py @@ -157,7 +157,8 @@ def _add_text(self): return data = {"type": "text", **fmt} self.viewer.show_floating_item(data, self.viewer.current_page_index, - self._on_placement_commit, None) + self._on_placement_commit, None, + click_to_place=True) def _add_image(self): if not self.document.is_open: @@ -169,7 +170,8 @@ def _add_image(self): return data = {"type": "image", "path": path} self.viewer.show_floating_item(data, self.viewer.current_page_index, - self._on_placement_commit, None) + self._on_placement_commit, None, + click_to_place=True) def _watermark(self): if not self.document.is_open: diff --git a/src/pdf_equilibrist/ui/viewer.py b/src/pdf_equilibrist/ui/viewer.py index f36bc16..674db16 100644 --- a/src/pdf_equilibrist/ui/viewer.py +++ b/src/pdf_equilibrist/ui/viewer.py @@ -47,11 +47,16 @@ ``show_floating_item()`` crée un ``FloatingItem`` posé sur le widget de page. Le ``FloatingItem`` est un enfant direct du widget de page (pas du viewer), ce qui lui permet d'utiliser les coordonnées locales de la page. + +Avec ``click_to_place=True``, le viewer attend d'abord un clic sur le PDF +(bandeau d'aide « Cliquez sur la page… », Échap pour annuler) et pose +l'élément à l'endroit cliqué, sur la page cliquée. """ from __future__ import annotations from pathlib import Path -from PyQt6.QtWidgets import QScrollArea, QLabel, QWidget, QVBoxLayout -from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect +from PyQt6.QtWidgets import (QScrollArea, QLabel, QWidget, QVBoxLayout, + QHBoxLayout, QPushButton) +from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect, QEvent from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen import pymupdf from pdf_equilibrist.core.document import Document @@ -389,6 +394,8 @@ def __init__(self, document: Document): self.zoom = 1.0 self._edit_mode = False self._page_widgets: list[QWidget] = [] + self._placing: tuple | None = None # (data, on_commit, on_cancel) en attente d'un clic + self._place_bar: QWidget | None = None # ── Page d'accueil ──────────────────────────────────────────────────── self._welcome = _WelcomePage() @@ -442,6 +449,7 @@ def refresh(self): Affiche la page d'accueil si aucun document n'est ouvert. """ + self._cancel_placement() # Vider la liste de références et supprimer les anciens widgets self._page_widgets.clear() for i in reversed(range(self._pages_layout.count())): @@ -552,7 +560,7 @@ def is_edit_mode(self) -> bool: # ── Placement flottant ──────────────────────────────────────────────────── def show_floating_item(self, data: dict, page_index: int, - on_commit, on_cancel): + on_commit, on_cancel, click_to_place: bool = False): """ Crée et affiche un ``FloatingItem`` sur la page indiquée. @@ -566,13 +574,24 @@ def show_floating_item(self, data: dict, page_index: int, Données de l'élément à placer : ``{'type': 'text'|'image'|'stamp', ...}`` page_index : int - Numéro de page 0-based sur laquelle afficher l'élément. + Numéro de page 0-based sur laquelle afficher l'élément + (ignoré si ``click_to_place``). on_commit : callable Callback ``(result_dict, page_index)`` appelé quand l'utilisateur valide. ``result_dict`` contient ``pdf_rect`` en coordonnées PDF. on_cancel : callable | None Callback appelé si l'utilisateur annule. Peut être ``None``. + click_to_place : bool + Si vrai, attend un clic sur une page et pose l'élément à cet endroit + au lieu de le centrer sur ``page_index``. """ + if click_to_place: + self._begin_placement(data, on_commit, on_cancel) + return + self._spawn_floating_item(data, page_index, on_commit, on_cancel) + + def _spawn_floating_item(self, data: dict, page_index: int, on_commit, on_cancel, + center_at: QPoint | None = None): from pdf_equilibrist.ui.floating_item import FloatingItem if page_index >= len(self._page_widgets): @@ -580,6 +599,8 @@ def show_floating_item(self, data: dict, page_index: int, page_widget = self._page_widgets[page_index] item = FloatingItem(data, self.zoom, page_widget) + if center_at is not None: + item.place_center_at(center_at) item.show() item.raise_() # au-dessus de tous les autres widgets enfants @@ -595,6 +616,85 @@ def _on_cancel(): item.committed.connect(_on_commit) item.cancelled.connect(_on_cancel) + # ── Pose par clic ───────────────────────────────────────────────────────── + + def _begin_placement(self, data: dict, on_commit, on_cancel): + """Attend un clic sur le PDF : bandeau d'aide, curseur en croix, Échap annule.""" + self._cancel_placement() + if not self._page_widgets: + return + self._placing = (data, on_commit, on_cancel) + for w in self._page_widgets: + w.installEventFilter(self) + w.setCursor(Qt.CursorShape.CrossCursor) + kind = {"text": self.tr("le texte"), "image": self.tr("l'image")}.get( + data.get("type"), self.tr("l'élément")) + self._show_place_bar(self.tr("Cliquez sur la page pour poser %1 — Échap pour annuler") + .replace("%1", kind)) + self.setFocus() + + def _show_place_bar(self, message: str): + bar = QWidget(self.viewport()) + bar.setStyleSheet( + f"QWidget {{ background:{theme.ACCENT}; border-radius:6px; }}" + "QLabel { color:#1E1E1E; font-weight:bold; background:transparent; }" + "QPushButton { background:#1E1E1E; color:#F0F0F0; border:none;" + " border-radius:4px; padding:3px 10px; }" + "QPushButton:hover { background:#3A3A3A; }") + lay = QHBoxLayout(bar) + lay.setContentsMargins(12, 6, 8, 6) + lay.addWidget(QLabel(message)) + cancel = QPushButton(self.tr("Annuler")) + cancel.clicked.connect(self._cancel_placement) + lay.addWidget(cancel) + bar.adjustSize() + bar.move(max(0, (self.viewport().width() - bar.width()) // 2), 10) + bar.show() + bar.raise_() + self._place_bar = bar + + def _end_placement(self): + """Nettoie le mode « clic pour poser » sans appeler de callback.""" + self._placing = None + for w in self._page_widgets: + w.removeEventFilter(self) + w.unsetCursor() + if self._place_bar is not None: + self._place_bar.hide() + self._place_bar.deleteLater() + self._place_bar = None + + def _cancel_placement(self): + if self._placing is None: + return + on_cancel = self._placing[2] + self._end_placement() + if on_cancel: + on_cancel() + + def eventFilter(self, obj, event): + if self._placing is not None and obj in self._page_widgets: + et = event.type() + if et == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.LeftButton: + data, on_commit, on_cancel = self._placing + page_index = self._page_widgets.index(obj) + pos = event.position().toPoint() + self._end_placement() + self._spawn_floating_item(data, page_index, on_commit, + on_cancel, center_at=pos) + return True + if et in (QEvent.Type.MouseButtonRelease, QEvent.Type.MouseMove, + QEvent.Type.MouseButtonDblClick): + return True # pas de sélection de texte ni de lien pendant la pose + return super().eventFilter(obj, event) + + def keyPressEvent(self, event): + if self._placing is not None and event.key() == Qt.Key.Key_Escape: + self._cancel_placement() + return + super().keyPressEvent(event) + # ── Page courante / sélection ───────────────────────────────────────────── @property diff --git a/tests/test_ui/test_click_to_place.py b/tests/test_ui/test_click_to_place.py new file mode 100644 index 0000000..45fed9b --- /dev/null +++ b/tests/test_ui/test_click_to_place.py @@ -0,0 +1,64 @@ +"""Pose d'un texte par clic sur le PDF (« Cliquez sur la page pour poser… »).""" +import pytest + +pytest.importorskip("PyQt6.QtWidgets") +import pymupdf # noqa: E402 +from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt # noqa: E402 +from PyQt6.QtGui import QKeyEvent, QMouseEvent # noqa: E402 +from PyQt6.QtWidgets import QApplication, QLabel # noqa: E402 + +from pdf_equilibrist.core.document import Document # noqa: E402 +from pdf_equilibrist.ui.floating_item import FloatingItem # noqa: E402 +from pdf_equilibrist.ui.viewer import PdfViewer # noqa: E402 + +DATA = {"type": "text", "text": "Salut", "fontsize": 14, "color": (0, 0, 0)} + + +@pytest.fixture +def viewer(tmp_path): + app = QApplication.instance() or QApplication([]) + pdf = pymupdf.open() + pdf.new_page() + pdf.new_page() + path = tmp_path / "two.pdf" + pdf.save(path) + doc = Document() + doc.open(str(path)) + v = PdfViewer(doc) + v.resize(900, 700) + v.show() + app.processEvents() + yield v + v.close() + + +def _click(widget, x, y): + ev = QMouseEvent(QEvent.Type.MouseButtonPress, QPointF(x, y), + widget.mapToGlobal(QPointF(x, y)), Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) + QApplication.sendEvent(widget, ev) + + +def test_item_is_placed_where_the_user_clicks(viewer): + viewer.show_floating_item(DATA.copy(), 0, lambda *a: None, None, click_to_place=True) + assert viewer._placing is not None + assert "Cliquez sur la page" in viewer._place_bar.findChild(QLabel).text() + assert not viewer._page_widgets[0].findChildren(FloatingItem) # rien avant le clic + + page = viewer._page_widgets[1] # on clique sur la 2e page, pas la « courante » + _click(page, 200, 300) + assert viewer._placing is None and viewer._place_bar is None + items = page.findChildren(FloatingItem) + assert len(items) == 1 and not viewer._page_widgets[0].findChildren(FloatingItem) + r = items[0]._item_rect() + center = items[0].pos() + QPoint(int(r.center().x()), int(r.center().y())) + assert (center.x(), center.y()) == (200, 300) + + +def test_escape_cancels_and_calls_back(viewer): + called = [] + viewer.show_floating_item(DATA.copy(), 0, lambda *a: None, + lambda: called.append("cancel"), click_to_place=True) + QApplication.sendEvent(viewer, QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier)) + assert called == ["cancel"] and viewer._placing is None and viewer._place_bar is None diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts index d384984..c1c8d80 100644 --- a/translations/pdf_equilibrist_en.ts +++ b/translations/pdf_equilibrist_en.ts @@ -2233,6 +2233,30 @@ Open it in your browser? Format : Format: + + Gras + Bold + + + Italique + Italic + + + Police : + Font: + + + Taille : + Size: + + + Couleur… + Color… + + + Couleur du texte + Text color + main @@ -2275,4 +2299,44 @@ Open it in your browser? PDF Equilibrist n'a pas pu démarrer. PDF Equilibrist could not start. + + PdfViewer + + le texte + the text + + + l'image + the image + + + l'élément + the item + + + Cliquez sur la page pour poser %1 — Échap pour annuler + Click on the page to place %1 — Esc to cancel + + + Annuler + Cancel + + + FloatingItem + + Gras + Bold + + + Italique + Italic + + + Couleur + Color + + + Couleur du texte + Text color + From c8050f9c5753a7755612822f2556c42beda39061 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:16:54 +0000 Subject: [PATCH 5/8] =?UTF-8?q?chore(release):=20pr=C3=A9pare=200.2.3=20(c?= =?UTF-8?q?hangelog=20Debian=20noble/resolute,=20spec=20COPR,=20checklist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- packaging/RELEASE_CHECKLIST.md | 6 ++--- packaging/copr/pdf-equilibrist.spec | 10 ++++++++- packaging/ppa/debian/changelog | 34 +++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packaging/RELEASE_CHECKLIST.md b/packaging/RELEASE_CHECKLIST.md index 4c06090..2099bf9 100644 --- a/packaging/RELEASE_CHECKLIST.md +++ b/packaging/RELEASE_CHECKLIST.md @@ -5,7 +5,7 @@ > et la mise à jour du site (`update-site-version.yml`). Un tag ne se « retire » pas proprement : > **ne le créer qu'une fois toute la checklist cochée.** -## 1. Préparation (avant le tag) — fait pour 0.2.2 +## 1. Préparation (avant le tag) — fait pour 0.2.3 - [x] `packaging/ppa/debian/changelog` : entrées `X.Y.Z-1~noble1` **et** `X.Y.Z-1~resolute1` en tête (le workflow PPA échoue explicitement si la version ne correspond pas au tag) @@ -49,8 +49,8 @@ OCR → PDF cherchable, OCR → Word, OCR → Excel. Mesure synthétique de réf ```bash git switch main && git pull -git tag -a v0.2.2 -m "PDF-Equilibrist 0.2.2" -git push origin v0.2.2 # ← déclenche TOUT : release publique + AUR + PPA + COPR + site +git tag -a v0.2.3 -m "PDF-Equilibrist 0.2.3" +git push origin v0.2.3 # ← déclenche TOUT : release publique + AUR + PPA + COPR + site ``` ## 5. Après le tag — à surveiller diff --git a/packaging/copr/pdf-equilibrist.spec b/packaging/copr/pdf-equilibrist.spec index 3158917..89fc662 100644 --- a/packaging/copr/pdf-equilibrist.spec +++ b/packaging/copr/pdf-equilibrist.spec @@ -1,5 +1,5 @@ Name: pdf-equilibrist -Version: 0.2.2 +Version: 0.2.3 Release: 1%{?dist} Summary: Free and open-source desktop PDF editor @@ -197,6 +197,14 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop %{_datadir}/icons/hicolor/256x256/apps/%{name}.png %changelog +* Mon Nov 02 2026 Paul Woisard - 0.2.3-1 +- New upstream release: add text where you click, with a text size that + follows the box. Add text and Add image wait for a click on the PDF and + drop the item where you clicked (Escape cancels). The text size now scales + with the box (width or height, whichever is tighter), and font, size, bold, + italic and colour stay editable until validation. Text can be placed at + any angle. + * Mon Nov 02 2026 Paul Woisard - 0.2.2-1 - New upstream release: font, size, style and colour for added text. "Add text" now opens a formatting dialog (font, size, bold, italic, diff --git a/packaging/ppa/debian/changelog b/packaging/ppa/debian/changelog index cabd1a9..965d5dc 100644 --- a/packaging/ppa/debian/changelog +++ b/packaging/ppa/debian/changelog @@ -1,3 +1,37 @@ +pdf-equilibrist (0.2.3-1~noble1) noble; urgency=medium + + * New upstream release: add text where you click, with a text size that + follows the box. + - Add text and Add image now wait for a click on the PDF ("Click on the + page to place the text") and drop the item where you clicked, on the + page you clicked, instead of pasting it in the middle of the current + page. Escape or Cancel abandons the placement. + - The text size is now dynamic: resizing the text box scales the font + to fit its width or height, whichever is the tighter constraint. + - Font, size, bold, italic and colour stay editable until the text is + validated. + - Text can be placed at any angle, not only multiples of 90 degrees. + - New English translations. + + -- Paul Woisard Mon, 02 Nov 2026 15:00:00 +0000 + +pdf-equilibrist (0.2.3-1~resolute1) resolute; urgency=medium + + * New upstream release: add text where you click, with a text size that + follows the box. + - Add text and Add image now wait for a click on the PDF ("Click on the + page to place the text") and drop the item where you clicked, on the + page you clicked, instead of pasting it in the middle of the current + page. Escape or Cancel abandons the placement. + - The text size is now dynamic: resizing the text box scales the font + to fit its width or height, whichever is the tighter constraint. + - Font, size, bold, italic and colour stay editable until the text is + validated. + - Text can be placed at any angle, not only multiples of 90 degrees. + - New English translations. + + -- Paul Woisard Mon, 02 Nov 2026 15:00:00 +0000 + pdf-equilibrist (0.2.2-1~noble1) noble; urgency=medium * New upstream release: font, size, style and colour for added text. From 5dd8047f49aa246715159e847bd0c6e4a8fc56b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:17:37 +0000 Subject: [PATCH 6/8] =?UTF-8?q?docs(claude):=20toujours=20pr=C3=A9parer=20?= =?UTF-8?q?la=20release=20avant=20tout=20push=20de=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bc3c1e1..229b4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,21 @@ src/pdf_equilibrist/ ci.yml et par tous les workflows de publication — rien n'est buildé ni publié s'il échoue. - En local : `flake8 src tests && bandit -r src -q && pytest -q` (`QT_QPA_PLATFORM=offscreen`). +## Release et tags + +**Toujours préparer la prochaine release avant tout push de tag** (ou toute création de release GitHub, qui crée le tag). +Un tag `v*` déclenche automatiquement et publiquement : release Windows, AppImage, AUR, PPA, COPR et site. Un tag ne se « retire » pas proprement. + +1. Préparer dans une PR, mergée **avant** le tag (voir `packaging/RELEASE_CHECKLIST.md`) : + - `packaging/ppa/debian/changelog` : entrées `X.Y.Z-1~noble1` **et** `X.Y.Z-1~resolute1` en tête + (le workflow PPA échoue si le haut du changelog ne correspond pas au tag) ; + - `packaging/copr/pdf-equilibrist.spec` : `Version:` + entrée `%changelog` ; + - exemples de `RELEASE_CHECKLIST.md` alignés sur la nouvelle version. +2. Ne **pas** toucher à `PKGBUILD` / `.SRCINFO` (mis à jour par `linux-aur-publish.yml` après le tag) ni à + `__version__` / `pyproject.toml` (injectés par les workflows à partir du tag). +3. Poser le tag sur `main` seulement quand la PR est mergée et que CI + « Linux packages check » sont verts sur `main`. +4. Demander la confirmation de l'utilisateur avant de pousser le tag, sauf demande explicite. + ## PyMuPDF Toujours `import pymupdf` (jamais `import fitz` : module de compatibilité déprécié qui affiche un avertissement). From 4d9fe28df7c2f0fc2c3374d0b32ab693f4c9c3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:48:55 +0000 Subject: [PATCH 7/8] feat(modifier): pose par clic et taille dynamique pour images, signatures, tampons ; bouton Lien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - images/signatures PNG : ratio conservé au redimensionnement, taille initiale sans déformation - tampons : texte ajusté à la boîte, cadre et texte pivotent ensemble (add_stamp_fitted) - signature/tampon : clic sur le PDF pour poser (plus de page/X/Y dans le dialogue) - Lien : zone cliquable posée par clic, cible URL http/https/mailto ou page (add_link) - gravure faite dans une fonction dédiée pour libérer l'objet page avant le rafraîchissement (cache PyMuPDF) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- CLAUDE.md | 17 ++-- docs/0.FONCTIONNALITES.md | 15 +-- docs/en/0.FONCTIONNALITES.md | 13 ++- src/pdf_equilibrist/operations/annotate.py | 35 +++++++ src/pdf_equilibrist/operations/edit.py | 30 +++++- src/pdf_equilibrist/ui/dialogs.py | 40 ++++++++ src/pdf_equilibrist/ui/floating_item.py | 81 +++++++++++---- src/pdf_equilibrist/ui/signature_dialog.py | 50 +--------- src/pdf_equilibrist/ui/tabs/tab_modifier.py | 50 +++++++--- tests/test_operations/test_stamp_link.py | 54 ++++++++++ tests/test_ui/test_floating_image.py | 105 ++++++++++++++++++++ translations/pdf_equilibrist_en.ts | 28 ++++++ 12 files changed, 415 insertions(+), 103 deletions(-) create mode 100644 tests/test_operations/test_stamp_link.py create mode 100644 tests/test_ui/test_floating_image.py diff --git a/CLAUDE.md b/CLAUDE.md index 229b4a2..6975b53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,11 +73,11 @@ src/pdf_equilibrist/ │ └── tab_proteger.py chiffrer (AES-256), déchiffrer │ └── operations/ # logique PDF pure, sans UI, testable unitairement - ├── edit.py # extract_text_blocks, apply_text_edits, compress, - │ # add_watermark, add_text, add_image + ├── edit.py # extract_text_blocks, apply_text_edits, compress, add_watermark, + │ # add_text, add_text_fitted, add_stamp_fitted, add_image ├── pages.py # rotate_pages, invert_pages, split_pdf, merge_pdfs, │ # insert_page, crop_page, set_page_size - ├── annotate.py # highlight, strikeout, underline, add_text_box, add_stamp + ├── annotate.py # highlight, strikeout, underline, add_text_box, add_stamp, add_link ├── convert.py # to_images, to_word, to_excel, to_powerpoint, │ # image_to_pdf, office_to_pdf, detect_office_engine ├── ocr.py # OCR RapidOCR/ONNX — modèles .onnx embarqués dans la wheel, @@ -154,10 +154,16 @@ MainWindow._rebind_doc(doc) ← déconnecte l'ancien, reconnecte le ## Placement flottant (texte / image / tampon) -1. `viewer.show_floating_item(data, page_index, on_commit, on_cancel)` +1. `viewer.show_floating_item(data, page_index, on_commit, on_cancel, click_to_place=True)` : + bandeau « Cliquez sur la page pour poser… » (Échap / Annuler), l'élément est posé centré sur le clic, sur la page cliquée 2. `FloatingItem` apparaît sur la page — drag pour déplacer, coins pour redimensionner, poignée verte pour pivoter + - **texte / tampon** : la taille de police est dynamique (suit la boîte, largeur ou hauteur selon la contrainte la plus forte) ; + barre police/taille/gras/italique/couleur pour le texte jusqu'à « Valider » + - **image / signature PNG** : le ratio est conservé au redimensionnement + - **lien** : zone cliquable (URL http/https/mailto ou page du document), sans rotation 3. Clic "Valider" → `on_commit(result_dict, page_index)` avec `pdf_rect` en coordonnées PDF -4. Le tab grave l'élément dans `fitz.Document` puis `document.changed.emit()` +4. Le tab grave l'élément (`add_text_fitted`, `add_stamp_fitted`, `insert_image`, `add_link`) dans `fitz.Document` puis + `document.changed.emit()` — **après** avoir libéré l'objet page (cache PyMuPDF : sinon le viewer ne voit pas les liens ajoutés) ## Conversion Office → PDF @@ -217,7 +223,6 @@ Appelée après `StretchDIBits`, re-trace en GDI les paths stroke-only du PDF : ## Fonctionnalités non câblées (nécessitent canvas interactif) - Crayon (dessin libre sur page) -- Lien (sélection zone cliquable) - Signature manuscrite dessinée (import PNG OK via FloatingItem) - Rogner la page (sélection rectangle) - Office en PDF nécessite MS Office ou LibreOffice installé diff --git a/docs/0.FONCTIONNALITES.md b/docs/0.FONCTIONNALITES.md index 7e2989e..1736094 100644 --- a/docs/0.FONCTIONNALITES.md +++ b/docs/0.FONCTIONNALITES.md @@ -64,18 +64,21 @@ Cliquez sur un paragraphe pour le réécrire sur place, avec la même police, ta ### Ajouter texte, image, filigrane, signature, tampon -L'élément apparaît sur la page : déplacez-le, redimensionnez-le, faites-le pivoter, puis validez. -Le filigrane s'applique à toutes les pages ; les tampons prédéfinis et l'import de votre -signature (PNG) sont disponibles. +Cliquez sur le PDF à l'endroit voulu : l'élément y est posé. Déplacez-le, redimensionnez-le, faites-le +pivoter, puis validez. La taille du texte et des tampons suit la boîte (agrandissez ou réduisez-la), les images +et signatures gardent leurs proportions, et la police, la taille, le style et la couleur d'un texte restent +modifiables jusqu'à la validation. Le filigrane s'applique à toutes les pages ; les tampons prédéfinis et +l'import de votre signature (PNG) sont disponibles. Le bouton « Lien » rend une zone cliquable (adresse web, +e-mail ou page du document).
🛠 Pour les développeurs - `ui/floating_item.py` : `FloatingItem` (drag, coins pour redimensionner, poignée de rotation) ; - `viewer.show_floating_item(data, page_index, on_commit, on_cancel)` renvoie un `pdf_rect` + `viewer.show_floating_item(data, page_index, on_commit, on_cancel, click_to_place=True)` renvoie un `pdf_rect` en coordonnées PDF. -- `operations/edit.py` : `add_text()`, `add_image()`, `add_watermark()` ; - `operations/annotate.py` : `add_stamp()` ; `ui/signature_dialog.py` pour les tampons. +- `operations/edit.py` : `add_text()`, `add_text_fitted()`, `add_stamp_fitted()`, `add_image()`, `add_watermark()` ; + `operations/annotate.py` : `add_stamp()`, `add_link()` ; `ui/signature_dialog.py` pour les tampons.
diff --git a/docs/en/0.FONCTIONNALITES.md b/docs/en/0.FONCTIONNALITES.md index f767d79..5d865ef 100644 --- a/docs/en/0.FONCTIONNALITES.md +++ b/docs/en/0.FONCTIONNALITES.md @@ -64,17 +64,20 @@ Click a paragraph to rewrite it in place, with the same font, size and colour. ### Add text, image, watermark, signature, stamp -The element appears on the page: move it, resize it, rotate it, then confirm. The watermark applies -to every page; predefined stamps and import of your signature (PNG) are available. +Click on the PDF where you want it: the element is placed there. Move it, resize it, rotate it, then +confirm. The size of text and stamps follows the box (enlarge or shrink it), images and signatures keep +their proportions, and a text's font, size, style and colour stay editable until you confirm. The +watermark applies to every page; predefined stamps and import of your signature (PNG) are available. +The "Link" button makes an area clickable (web address, e-mail or a page of the document).
🛠 For developers - `ui/floating_item.py`: `FloatingItem` (drag, corners to resize, rotation handle); - `viewer.show_floating_item(data, page_index, on_commit, on_cancel)` returns a `pdf_rect` in + `viewer.show_floating_item(data, page_index, on_commit, on_cancel, click_to_place=True)` returns a `pdf_rect` in PDF coordinates. -- `operations/edit.py`: `add_text()`, `add_image()`, `add_watermark()`; - `operations/annotate.py`: `add_stamp()`; `ui/signature_dialog.py` for stamps. +- `operations/edit.py`: `add_text()`, `add_text_fitted()`, `add_stamp_fitted()`, `add_image()`, `add_watermark()`; + `operations/annotate.py`: `add_stamp()`, `add_link()`; `ui/signature_dialog.py` for stamps.
diff --git a/src/pdf_equilibrist/operations/annotate.py b/src/pdf_equilibrist/operations/annotate.py index 809b14f..90ba9c4 100644 --- a/src/pdf_equilibrist/operations/annotate.py +++ b/src/pdf_equilibrist/operations/annotate.py @@ -174,3 +174,38 @@ def add_stamp( annot = page.add_stamp_annot(rect, stamp=stamp_id) annot.update() return annot + + +LINK_SCHEMES = ("http", "https", "mailto") + + +def add_link( + page: pymupdf.Page, + rect: pymupdf.Rect, + uri: str | None = None, + goto_page: int | None = None, +) -> None: + """ + Rend la zone ``rect`` cliquable : lien vers une adresse (``uri``) ou une page. + + Parameters + ---------- + page : pymupdf.Page + Page qui porte la zone cliquable. + rect : pymupdf.Rect + Zone cliquable en coordonnées PDF. + uri : str | None + Adresse ``http://``, ``https://`` ou ``mailto:`` (autres schémas refusés). + goto_page : int | None + Numéro de page 0-based à atteindre (si ``uri`` est ``None``). + """ + if uri is not None: + scheme = uri.split(":", 1)[0].lower() if ":" in uri else "" + if scheme not in LINK_SCHEMES: + raise ValueError(f"Schéma de lien non autorisé : {uri!r}") + page.insert_link({"kind": pymupdf.LINK_URI, "from": rect, "uri": uri}) + elif goto_page is not None: + page.insert_link({"kind": pymupdf.LINK_GOTO, "from": rect, + "page": goto_page, "to": pymupdf.Point(0, 0)}) + else: + raise ValueError("add_link : fournir uri ou goto_page") diff --git a/src/pdf_equilibrist/operations/edit.py b/src/pdf_equilibrist/operations/edit.py index 0887a88..3941325 100644 --- a/src/pdf_equilibrist/operations/edit.py +++ b/src/pdf_equilibrist/operations/edit.py @@ -451,13 +451,16 @@ def fit_fontsize(text: str, fontname: str, box_w: float, box_h: float, return max(min_size, min(by_w, by_h, max_size)) +STAMP_PAD = 4.0 # marge (pts) entre le cadre d'un tampon et son texte + + def add_text_fitted(page: pymupdf.Page, rect: pymupdf.Rect, text: str, fontname: str = "helv", color: tuple = (0, 0, 0), - angle: float = 0.0) -> float: + angle: float = 0.0, center: bool = False) -> float: """ Écrit ``text`` sur une ligne, à la taille maximale qui tient dans ``rect``. - Le texte est aligné à gauche et centré verticalement dans ``rect``. + Le texte est aligné à gauche (ou centré si ``center``) et centré verticalement. ``angle`` (degrés, sens horaire à l'écran) pivote autour du centre de ``rect``. Retourne la taille de police utilisée (pts). """ @@ -468,12 +471,33 @@ def add_text_fitted(page: pymupdf.Page, rect: pymupdf.Rect, text: str, if angle % 360: center = pymupdf.Point((rect.x0 + rect.x1) / 2, (rect.y0 + rect.y1) / 2) morph = (center, pymupdf.Matrix(-angle)) - page.insert_text(pymupdf.Point(rect.x0, baseline_y), text, + x = rect.x0 + if center: + x += (rect.width - text_natural_size(text, fontname, size)[0]) / 2 + page.insert_text(pymupdf.Point(x, baseline_y), text, fontname=fontname, fontsize=size, color=color, morph=morph, overlay=True) return size +def add_stamp_fitted(page: pymupdf.Page, rect: pymupdf.Rect, text: str, + color: tuple = (0, 0.5, 0), angle: float = 0.0) -> float: + """ + Grave un tampon texte : cadre de ``rect`` + texte gras centré à la taille maximale. + + Le cadre et le texte pivotent ensemble de ``angle`` degrés (sens horaire à l'écran) + autour du centre de ``rect``. Retourne la taille de police utilisée (pts). + """ + morph = None + if angle % 360: + center = pymupdf.Point((rect.x0 + rect.x1) / 2, (rect.y0 + rect.y1) / 2) + morph = (center, pymupdf.Matrix(-angle)) + page.draw_rect(rect, color=color, fill=None, width=2, morph=morph) + inner = pymupdf.Rect(rect.x0 + STAMP_PAD, rect.y0 + STAMP_PAD, + rect.x1 - STAMP_PAD, rect.y1 - STAMP_PAD) + return add_text_fitted(page, inner, text, "hebo", color, angle, center=True) + + def add_image(doc: pymupdf.Document, page_index: int, image_path: Path, rect: pymupdf.Rect): """ diff --git a/src/pdf_equilibrist/ui/dialogs.py b/src/pdf_equilibrist/ui/dialogs.py index 79cd7bb..764e864 100644 --- a/src/pdf_equilibrist/ui/dialogs.py +++ b/src/pdf_equilibrist/ui/dialogs.py @@ -19,6 +19,7 @@ - ``ask_watermark_text`` : texte du filigrane - ``ask_text_input`` : saisie de texte libre (générique) - ``ask_text_format`` : texte + police, taille, gras/italique, couleur +- ``ask_link_target`` : cible d'un lien (adresse web ou page du document) - ``ask_image_format`` : sélection du format image (combo png/jpg/bmp/tiff) - ``show_info`` : message d'information (non bloquant pour l'UX) - ``show_error`` : message d'erreur critique @@ -229,6 +230,45 @@ def _pick(): } +def ask_link_target(parent, max_page: int) -> dict | None: + """Choisit la cible d'un lien : ``{'uri': str}`` ou ``{'goto_page': int}`` (0-based).""" + tr = QCoreApplication.translate + dlg = QDialog(parent) + dlg.setWindowTitle(tr("dialogs", "Ajouter un lien")) + dlg.setStyleSheet(DIALOG_STYLE) + dlg.setMinimumWidth(360) + layout = QVBoxLayout(dlg) + kind = QComboBox() + kind.addItems([tr("dialogs", "Adresse web ou e-mail"), tr("dialogs", "Page du document")]) + layout.addWidget(kind) + url = QLineEdit() + url.setPlaceholderText(tr("dialogs", "https://exemple.fr ou nom@exemple.fr")) + layout.addWidget(url) + page = QSpinBox() + page.setRange(1, max(1, max_page)) + page.setPrefix(tr("dialogs", "Page ")) + page.hide() + layout.addWidget(page) + + def _switch(i): + url.setVisible(i == 0) + page.setVisible(i == 1) + kind.currentIndexChanged.connect(_switch) + _ok_cancel(dlg, layout) + if dlg.exec() != QDialog.DialogCode.Accepted: + return None + if kind.currentIndex() == 1: + return {"goto_page": page.value() - 1} + text = url.text().strip() + if not text: + return None + if "@" in text and ":" not in text: + text = "mailto:" + text + elif "://" not in text and not text.lower().startswith("mailto:"): + text = "https://" + text + return {"uri": text} + + def ask_image_format(parent) -> str | None: dlg = QDialog(parent) dlg.setWindowTitle(QCoreApplication.translate("dialogs", "Format d'image")) diff --git a/src/pdf_equilibrist/ui/floating_item.py b/src/pdf_equilibrist/ui/floating_item.py index 007393b..078bb55 100644 --- a/src/pdf_equilibrist/ui/floating_item.py +++ b/src/pdf_equilibrist/ui/floating_item.py @@ -43,7 +43,7 @@ from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QPointF, QRectF, QSizeF from pdf_equilibrist.operations.edit import ( - fit_fontsize, text_line_metrics, text_natural_size, + STAMP_PAD, fit_fontsize, text_line_metrics, text_natural_size, ) from pdf_equilibrist.ui.dialogs import PDF_FONTS, pdf_fontname @@ -65,16 +65,19 @@ class FloatingItem(QWidget): """ Widget interactif superposé sur une page PDF pour le placement visuel. - Supporte trois types d'éléments via le dict ``data`` : + Supporte quatre types d'éléments via le dict ``data`` : - ``{'type': 'text', 'text': str, 'fontsize': int, 'color': tuple, 'fontname': str (base 14), 'family': str, 'bold': bool, 'italic': bool}`` - ``{'type': 'image', 'path': str}`` - - ``{'type': 'stamp', 'text': str, 'color': tuple}`` + - ``{'type': 'stamp', 'text': str, 'color': tuple}`` (texte ajusté à la boîte) + - ``{'type': 'link', 'uri': str}`` ou ``{'type': 'link', 'goto_page': int}`` + (zone cliquable, sans rotation) Pour un texte, la **taille de police est dynamique** : elle suit la boîte (largeur ou hauteur, la contrainte la plus forte) quand on la redimensionne, et une barre de mise en forme (police, taille, gras, italique, couleur) - reste utilisable jusqu'à la validation. + reste utilisable jusqu'à la validation. Une image garde ses proportions : + la redimensionner agrandit ou réduit l'image entière. Signals ------- @@ -122,6 +125,7 @@ def __init__(self, data: dict, zoom: float, parent: QWidget): self._item_size = QSizeF(200, 60) self._resize_anchor = QPointF() self._fmt_bar: QWidget | None = None + self._aspect: float | None = None # ratio L/H imposé (images) # Pré-charger pixmap si image self._pixmap: QPixmap | None = None @@ -158,9 +162,13 @@ def __init__(self, data: dict, zoom: float, parent: QWidget): # Taille initiale selon le contenu if self._pixmap and not self._pixmap.isNull(): - pw = min(self._pixmap.width(), int(300 * zoom)) - ph = min(self._pixmap.height(), int(200 * zoom)) - self._item_size = QSizeF(pw, ph) + # Taille initiale : l'image tient dans 300×300 pt (zoom compris) en gardant + # ses proportions ; le redimensionnement conserve ensuite ce ratio. + iw, ih = self._pixmap.width(), self._pixmap.height() + self._aspect = iw / ih + k = min(1.0, 300 * zoom / iw, 300 * zoom / ih) + k = max(k, MIN_SIZE / min(iw, ih)) + self._item_size = QSizeF(iw * k, ih * k) elif data.get("type") == "text": self._data.setdefault("fontname", "helv") self._data.setdefault("family", "Helvetica") @@ -169,7 +177,14 @@ def __init__(self, data: dict, zoom: float, parent: QWidget): self._item_size = QSizeF(max(MIN_SIZE, w * zoom), max(MIN_SIZE / 2, h * zoom)) self._build_format_bar() elif data.get("type") == "stamp": - self._item_size = QSizeF(200, 44) + self._data.setdefault("text", "TAMPON") + self._data.update(fontname="hebo", family="Helvetica", bold=True, italic=False) + fs = float(data.get("fontsize", 16)) + w, h = text_natural_size(self._data["text"], "hebo", fs) + self._data["fontsize"] = fs + self._item_size = QSizeF((w + 2 * STAMP_PAD) * zoom, (h + 2 * STAMP_PAD) * zoom) + elif data.get("type") == "link": + self._item_size = QSizeF(160 * zoom, 28 * zoom) self._place_at_center() @@ -183,6 +198,18 @@ def _place_at_center(self): self.move(x, y) self._update_size() + def _keep_aspect(self, new_w: float, new_h: float) -> tuple[float, float]: + """Ajuste (largeur, hauteur) au ratio de l'image : l'axe le plus tiré commande.""" + w0, h0 = self._size_at_drag.width(), self._size_at_drag.height() + if abs(new_w - w0) / w0 >= abs(new_h - h0) / h0: + new_h = new_w / self._aspect + else: + new_w = new_h * self._aspect + low = min(new_w, new_h) + if low < MIN_SIZE: + new_w, new_h = new_w * MIN_SIZE / low, new_h * MIN_SIZE / low + return new_w, new_h + def place_center_at(self, pos: QPoint): """Centre la boîte de l'élément sur ``pos`` (coordonnées de la page), bornée à la page.""" r = self._item_rect() @@ -290,6 +317,8 @@ def mouseMoveEvent(self, event): if h_idx in (0, 1, 7): new_h = max(MIN_SIZE, new_h - delta.y()) if h_idx in (4,): new_h = max(MIN_SIZE, new_h + delta.y()) if h_idx in (5,): new_h = max(MIN_SIZE, new_h - delta.y()) + if self._aspect: + new_w, new_h = self._keep_aspect(new_w, new_h) self._item_size = QSizeF(new_w, new_h) self._refit_text() self._update_size() @@ -370,12 +399,21 @@ def _draw_content(self, p: QPainter, r: QRectF): qc = QColor(r_val, g_val, b_val) p.setPen(QPen(qc, 2)) p.setBrush(QColor(r_val, g_val, b_val, 20)) - p.drawRoundedRect(r, 6, 6) - p.setPen(QPen(qc)) - font = QFont("Segoe UI", 14, QFont.Weight.Bold) - p.setFont(font) - p.drawText(r, Qt.AlignmentFlag.AlignCenter, - self._data.get("text", "TAMPON")) + p.drawRect(r) + pad = self._pad_pt() * max(self._zoom, 0.01) + self._draw_text(p, r.adjusted(pad, pad, -pad, -pad), center=True) + + elif dtype == "link": + p.setPen(QPen(QColor("#3B82F6"), 2, Qt.PenStyle.DashLine)) + p.setBrush(QColor(59, 130, 246, 55)) + p.drawRect(r) + p.setPen(QPen(QColor("#FFFFFF"))) + p.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) + label = self._data.get("uri") or self.tr("Page %1").replace( + "%1", str(int(self._data.get("goto_page", 0)) + 1)) + p.drawText(r.adjusted(6, 0, -6, 0), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + "🔗 " + label) elif dtype == "text": p.fillRect(r, QColor(255, 255, 200, 180)) @@ -386,18 +424,23 @@ def _draw_content(self, p: QPainter, r: QRectF): def _text_font(self) -> str: return self._data.get("fontname", "helv") + def _pad_pt(self) -> float: + """Marge intérieure (pts) entre le cadre et le texte : tampon seulement.""" + return STAMP_PAD if self._data.get("type") == "stamp" else 0.0 + def _refit_text(self): """Recalcule la taille de police pour que le texte remplisse la boîte.""" - if self._data.get("type") != "text": + if self._data.get("type") not in ("text", "stamp"): return z = max(self._zoom, 0.01) + pad = 2 * self._pad_pt() size = fit_fontsize(self._data.get("text", ""), self._text_font(), - self._item_size.width() / z, - self._item_size.height() / z) + self._item_size.width() / z - pad, + self._item_size.height() / z - pad) self._data["fontsize"] = round(size, 2) self._sync_size_spin() - def _draw_text(self, p: QPainter, r: QRectF): + def _draw_text(self, p: QPainter, r: QRectF, center: bool = False): text = self._data.get("text", "") if not text: return @@ -421,7 +464,7 @@ def _draw_text(self, p: QPainter, r: QRectF): p.setPen(QPen(QColor(*(int(c * 255) for c in color)))) p.setFont(qf) baseline = r.top() + (r.height() - (asc + desc) * size_px) / 2 + asc * size_px - p.translate(r.left(), baseline) + p.translate(r.left() + ((r.width() - want_w) / 2 if center else 0), baseline) p.scale(want_w / qt_w, size_px / 100.0) p.drawText(QPointF(0, 0), text) p.restore() diff --git a/src/pdf_equilibrist/ui/signature_dialog.py b/src/pdf_equilibrist/ui/signature_dialog.py index 4a0114c..9876331 100644 --- a/src/pdf_equilibrist/ui/signature_dialog.py +++ b/src/pdf_equilibrist/ui/signature_dialog.py @@ -3,7 +3,6 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QPushButton, QFileDialog, QGridLayout, - QSpinBox, ) from PyQt6.QtGui import QPixmap from PyQt6.QtCore import Qt @@ -73,8 +72,8 @@ def __init__(self, label: str, color: tuple, parent=None): class SignatureDialog(QDialog): """ Retourne via .result() un dict : - {'type': 'stamp', 'text': str, 'color': tuple, 'page': int, 'x': int, 'y': int} - {'type': 'image', 'path': str, 'page': int, 'x': int, 'y': int, 'w': int, 'h': int} + {'type': 'stamp', 'text': str, 'color': tuple} + {'type': 'image', 'path': str} """ def __init__(self, doc: pymupdf.Document, parent=None): @@ -93,29 +92,6 @@ def __init__(self, doc: pymupdf.Document, parent=None): tabs.addTab(self._make_image_tab(), self.tr("Signature PNG")) layout.addWidget(tabs) - # ── Position ───────────────────────────────────────────────────────── - pos_row = QHBoxLayout() - pos_row.addWidget(QLabel(self.tr("Page :"))) - self._spin_page = QSpinBox() - self._spin_page.setRange(1, len(doc)) - self._spin_page.setValue(1) - pos_row.addWidget(self._spin_page) - - pos_row.addSpacing(16) - pos_row.addWidget(QLabel("X :")) - self._spin_x = QSpinBox() - self._spin_x.setRange(0, 1000) - self._spin_x.setValue(50) - pos_row.addWidget(self._spin_x) - - pos_row.addWidget(QLabel("Y :")) - self._spin_y = QSpinBox() - self._spin_y.setRange(0, 1000) - self._spin_y.setValue(50) - pos_row.addWidget(self._spin_y) - pos_row.addStretch() - layout.addLayout(pos_row) - # ── Boutons ─────────────────────────────────────────────────────────── btn_row = QHBoxLayout() btn_row.addStretch() @@ -144,9 +120,6 @@ def _apply_stamp(self, color: tuple, text: str): "type": "stamp", "text": text, "color": color, - "page": self._spin_page.value() - 1, - "x": self._spin_x.value(), - "y": self._spin_y.value(), } self.accept() @@ -171,20 +144,6 @@ def _make_image_tab(self) -> QWidget: btn_pick.clicked.connect(self._pick_image) layout.addWidget(btn_pick) - size_row = QHBoxLayout() - size_row.addWidget(QLabel(self.tr("Largeur :"))) - self._spin_w = QSpinBox() - self._spin_w.setRange(10, 500) - self._spin_w.setValue(150) - size_row.addWidget(self._spin_w) - size_row.addWidget(QLabel(self.tr("px Hauteur :"))) - self._spin_h = QSpinBox() - self._spin_h.setRange(10, 500) - self._spin_h.setValue(80) - size_row.addWidget(self._spin_h) - size_row.addStretch() - layout.addLayout(size_row) - self._btn_insert_img = QPushButton(self.tr("Insérer la signature")) self._btn_insert_img.setObjectName("primary") self._btn_insert_img.setEnabled(False) @@ -210,11 +169,6 @@ def _apply_image(self): self._result = { "type": "image", "path": self._img_path, - "page": self._spin_page.value() - 1, - "x": self._spin_x.value(), - "y": self._spin_y.value(), - "w": self._spin_w.value(), - "h": self._spin_h.value(), } self.accept() diff --git a/src/pdf_equilibrist/ui/tabs/tab_modifier.py b/src/pdf_equilibrist/ui/tabs/tab_modifier.py index fffb496..2a7e452 100644 --- a/src/pdf_equilibrist/ui/tabs/tab_modifier.py +++ b/src/pdf_equilibrist/ui/tabs/tab_modifier.py @@ -29,12 +29,15 @@ from pathlib import Path from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog from pdf_equilibrist.ui.widgets import RibbonButton, RibbonGroup -from pdf_equilibrist.ui.dialogs import ask_watermark_text, ask_text_format, show_info, show_error, ensure_suffix +from pdf_equilibrist.ui.dialogs import ( + ask_watermark_text, ask_text_format, ask_link_target, show_info, show_error, ensure_suffix, +) from pdf_equilibrist.core.document import Document from pdf_equilibrist.operations.edit import ( - compress, add_watermark, add_text_fitted, + compress, add_watermark, add_text_fitted, add_stamp_fitted, extract_text_blocks, ) +from pdf_equilibrist.operations.annotate import add_link import pymupdf ACCENT = "#6BBF4E" @@ -94,13 +97,15 @@ def __init__(self, document: Document, viewer): grp_sign.add(self._btn_sign) layout.addWidget(grp_sign) - grp_misc = RibbonGroup(self.tr("À venir")) - grp_misc.add(RibbonButton("🔗", self.tr("Lien"))) + grp_misc = RibbonGroup(self.tr("Liens")) + self._btn_link = RibbonButton("🔗", self.tr("Lien")) + grp_misc.add(self._btn_link) layout.addWidget(grp_misc) layout.addStretch() self._btn_ocr.clicked.connect(self._run_ocr) self._btn_sign.clicked.connect(self._open_signature) + self._btn_link.clicked.connect(self._add_link) self._btn_edit.clicked.connect(self._toggle_edit) self._btn_commit.clicked.connect(self._commit) self._btn_cancel.clicked.connect(self._cancel) @@ -192,13 +197,30 @@ def _open_signature(self): r = dlg.get_result() if not r: return - page_idx = r.get("page", 0) - self.viewer.show_floating_item(r, page_idx, - self._on_placement_commit, None) + self.viewer.show_floating_item(r, self.viewer.current_page_index, + self._on_placement_commit, None, + click_to_place=True) + + def _add_link(self): + if not self.document.is_open: + return + target = ask_link_target(self, len(self.document.fitz_doc)) + if not target: + return + self.viewer.show_floating_item({"type": "link", **target}, + self.viewer.current_page_index, + self._on_placement_commit, None, + click_to_place=True) def _on_placement_commit(self, result: dict, page_index: int): """Grave l'élément flottant dans le PDF à la position validée.""" self.document.checkpoint() + self._engrave(result, page_index) + # L'objet page de _engrave est libéré ici : sinon PyMuPDF renverrait au viewer + # la même page en cache, sans les liens qu'on vient d'ajouter. + self.document.changed.emit() + + def _engrave(self, result: dict, page_index: int): x0, y0, x1, y1 = result["pdf_rect"] angle = result.get("angle", 0.0) page = self.document.fitz_doc[page_index] @@ -215,15 +237,11 @@ def _on_placement_commit(self, result: dict, page_index: int): elif dtype == "image": page.insert_image(rect, filename=result["path"]) elif dtype == "stamp": - color = result.get("color", (0, 0.5, 0)) - text = result.get("text", "TAMPON") - page.draw_rect(rect, color=color, fill=None, width=2) - page.insert_text( - pymupdf.Point(x0 + 4, y1 - 6), - text, fontsize=14, color=color, - rotate=int(-angle), overlay=True, - ) - self.document.changed.emit() + add_stamp_fitted(page, rect, result.get("text", "TAMPON"), + result.get("color", (0, 0.5, 0)), angle) + elif dtype == "link": + add_link(page, rect, uri=result.get("uri"), + goto_page=result.get("goto_page")) def _run_ocr(self): if not self.document.is_open: diff --git a/tests/test_operations/test_stamp_link.py b/tests/test_operations/test_stamp_link.py new file mode 100644 index 0000000..54f5145 --- /dev/null +++ b/tests/test_operations/test_stamp_link.py @@ -0,0 +1,54 @@ +"""Tampon à taille dynamique et zones cliquables (liens).""" +import pymupdf +import pytest + +from pdf_equilibrist.operations.annotate import add_link +from pdf_equilibrist.operations.edit import STAMP_PAD, add_stamp_fitted + + +def _page(): + doc = pymupdf.open() + return doc, doc.new_page() + + +def test_stamp_text_fills_its_box_and_scales_with_it(): + _, page = _page() + big = add_stamp_fitted(page, pymupdf.Rect(50, 50, 350, 130), "APPROUVÉ", (0, 0.5, 0)) + _, page2 = _page() + small = add_stamp_fitted(page2, pymupdf.Rect(50, 50, 200, 90), "APPROUVÉ", (0, 0.5, 0)) + assert big > small + x0, y0, x1, y1 = page.get_text("words")[0][:4] + assert x0 >= 50 + STAMP_PAD - 1 and x1 <= 350 - STAMP_PAD + 1 + assert y0 >= 50 + STAMP_PAD - 1 and y1 <= 130 - STAMP_PAD + 1 + + +def test_stamp_rotation_any_angle(): + _, page = _page() + add_stamp_fitted(page, pymupdf.Rect(100, 100, 300, 160), "BROUILLON", angle=25.0) + assert "BROUILLON" in page.get_text() + + +def test_add_link_uri_and_page(): + doc = pymupdf.open() + doc.new_page() + doc.new_page() + page = doc[0] # relue après l'ajout des pages, comme le fait l'application + add_link(page, pymupdf.Rect(10, 10, 110, 30), uri="https://example.org") + add_link(page, pymupdf.Rect(10, 50, 110, 70), goto_page=1) + links = pymupdf.open("pdf", doc.tobytes())[0].get_links() # relecture du PDF écrit + assert {lk["kind"] for lk in links} == {pymupdf.LINK_URI, pymupdf.LINK_GOTO} + assert any(lk.get("uri") == "https://example.org" for lk in links) + assert any(lk.get("page") == 1 for lk in links) + + +@pytest.mark.parametrize("bad", ["javascript:alert(1)", "file:///etc/passwd", "ftp://x", "abc"]) +def test_add_link_refuses_dangerous_schemes(bad): + _, page = _page() + with pytest.raises(ValueError): + add_link(page, pymupdf.Rect(0, 0, 10, 10), uri=bad) + + +def test_add_link_needs_a_target(): + _, page = _page() + with pytest.raises(ValueError): + add_link(page, pymupdf.Rect(0, 0, 10, 10)) diff --git a/tests/test_ui/test_floating_image.py b/tests/test_ui/test_floating_image.py new file mode 100644 index 0000000..05a7f33 --- /dev/null +++ b/tests/test_ui/test_floating_image.py @@ -0,0 +1,105 @@ +"""FloatingItem image : proportions conservées, pose par clic.""" +import pytest + +pytest.importorskip("PyQt6.QtWidgets") +import pymupdf # noqa: E402 +from PyQt6.QtCore import QEvent, QPoint, QPointF, Qt # noqa: E402 +from PyQt6.QtGui import QColor, QImage, QMouseEvent # noqa: E402 +from PyQt6.QtWidgets import QApplication, QWidget # noqa: E402 + +from pdf_equilibrist.core.document import Document # noqa: E402 +from pdf_equilibrist.ui.floating_item import FloatingItem # noqa: E402 +from pdf_equilibrist.ui.viewer import PdfViewer # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def png(tmp_path): + img = QImage(800, 400, QImage.Format.Format_RGB32) # ratio 2:1 + img.fill(QColor("red")) + path = tmp_path / "wide.png" + img.save(str(path)) + return str(path) + + +def _drag(item, handle_idx, dx, dy): + item._mode = item._MODE_RESIZE + item._resize_handle_idx = handle_idx + item._drag_start = QPointF(0, 0) + item._size_at_drag = item._item_size + from PyQt6.QtGui import QMouseEvent as ME + ev = ME(QEvent.Type.MouseMove, QPointF(dx, dy), QPointF(dx, dy), + Qt.MouseButton.NoButton, Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier) + item.mouseMoveEvent(ev) + item._mode = item._MODE_NONE + + +def test_initial_size_keeps_ratio_and_fits(app, png): + page = QWidget() + page.resize(800, 1000) + it = FloatingItem({"type": "image", "path": png}, 1.0, page) + w, h = it._item_size.width(), it._item_size.height() + assert w / h == pytest.approx(2.0, rel=1e-3) + assert w <= 300 and h <= 300 + + +@pytest.mark.parametrize("idx,dx,dy", [(3, 80, 5), (3, 5, 60), (7, -50, 0), (1, 40, -10)]) +def test_resize_keeps_ratio(app, png, idx, dx, dy): + page = QWidget() + page.resize(800, 1000) + it = FloatingItem({"type": "image", "path": png}, 1.0, page) + _drag(it, idx, dx, dy) + assert it._item_size.width() / it._item_size.height() == pytest.approx(2.0, rel=1e-3) + + +def test_click_to_place_image(app, png, tmp_path): + pdf = pymupdf.open() + pdf.new_page() + path = tmp_path / "p.pdf" + pdf.save(path) + doc = Document() + doc.open(str(path)) + v = PdfViewer(doc) + v.resize(900, 700) + v.show() + app.processEvents() + v.show_floating_item({"type": "image", "path": png}, 0, lambda *a: None, None, + click_to_place=True) + assert "l'image" in v._place_bar.findChildren(__import__("PyQt6.QtWidgets", fromlist=["QLabel"]).QLabel)[0].text() + page = v._page_widgets[0] + app.sendEvent(page, QMouseEvent(QEvent.Type.MouseButtonPress, QPointF(250, 300), + page.mapToGlobal(QPointF(250, 300)), + Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier)) + item = page.findChildren(FloatingItem)[0] + r = item._item_rect() + c = item.pos() + QPoint(int(r.center().x()), int(r.center().y())) + assert (c.x(), c.y()) == (250, 300) + v.close() + + +def test_stamp_font_follows_box(app): + from PyQt6.QtCore import QSizeF + page = QWidget() + page.resize(800, 1000) + it = FloatingItem({"type": "stamp", "text": "APPROUVÉ", "color": (0, 0.5, 0)}, 1.0, page) + base = it._data["fontsize"] + it._item_size = QSizeF(it._item_size.width() * 2, it._item_size.height() * 2) + it._refit_text() + assert it._data["fontsize"] > base * 1.7 + + +def test_link_item_has_a_zone_and_commits_rect(app): + page = QWidget() + page.resize(800, 1000) + it = FloatingItem({"type": "link", "uri": "https://example.org"}, 1.0, page) + got = [] + it.committed.connect(got.append) + it._commit() + x0, y0, x1, y1 = got[0]["pdf_rect"] + assert x1 > x0 and y1 > y0 and got[0]["uri"] == "https://example.org" diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts index c1c8d80..2452fe5 100644 --- a/translations/pdf_equilibrist_en.ts +++ b/translations/pdf_equilibrist_en.ts @@ -1433,6 +1433,10 @@ Remember to save the document. Erreur Error + + Liens + Links + TabPage @@ -2257,6 +2261,26 @@ Open it in your browser? Couleur du texte Text color + + Ajouter un lien + Add a link + + + Adresse web ou e-mail + Web address or e-mail + + + Page du document + Document page + + + https://exemple.fr ou nom@exemple.fr + https://example.org or name@example.org + + + Page + Page + main @@ -2339,4 +2363,8 @@ Open it in your browser? Couleur du texte Text color + + Page %1 + Page %1 + From 6eb3b641d4e4c9bb6dd7601123aa2fefa6dbb8c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:57:54 +0000 Subject: [PATCH 8/8] =?UTF-8?q?chore(release):=200.2.3=20=E2=80=94=20chang?= =?UTF-8?q?elogs=20:=20tampons,=20signatures=20et=20liens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bcezot9aEcwSRbdFrScYjB --- packaging/copr/pdf-equilibrist.spec | 4 +++- packaging/ppa/debian/changelog | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packaging/copr/pdf-equilibrist.spec b/packaging/copr/pdf-equilibrist.spec index 89fc662..314e6bd 100644 --- a/packaging/copr/pdf-equilibrist.spec +++ b/packaging/copr/pdf-equilibrist.spec @@ -203,7 +203,9 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop drop the item where you clicked (Escape cancels). The text size now scales with the box (width or height, whichever is tighter), and font, size, bold, italic and colour stay editable until validation. Text can be placed at - any angle. + any angle. Images, PNG signatures and stamps are placed with a click too + and keep their proportions or fit their text to the box. New Link button: + a clickable area pointing to a web address, an e-mail or a page. * Mon Nov 02 2026 Paul Woisard - 0.2.2-1 - New upstream release: font, size, style and colour for added text. diff --git a/packaging/ppa/debian/changelog b/packaging/ppa/debian/changelog index 965d5dc..64d42c2 100644 --- a/packaging/ppa/debian/changelog +++ b/packaging/ppa/debian/changelog @@ -11,6 +11,11 @@ pdf-equilibrist (0.2.3-1~noble1) noble; urgency=medium - Font, size, bold, italic and colour stay editable until the text is validated. - Text can be placed at any angle, not only multiples of 90 degrees. + - Images, PNG signatures and stamps are placed with a click too, and + keep their proportions (images, signatures) or fit their text to the + box (stamps) when resized. + - New "Link" button: place a clickable area by clicking on the page and + point it to a web address, an e-mail address or a page of the document. - New English translations. -- Paul Woisard Mon, 02 Nov 2026 15:00:00 +0000 @@ -28,6 +33,11 @@ pdf-equilibrist (0.2.3-1~resolute1) resolute; urgency=medium - Font, size, bold, italic and colour stay editable until the text is validated. - Text can be placed at any angle, not only multiples of 90 degrees. + - Images, PNG signatures and stamps are placed with a click too, and + keep their proportions (images, signatures) or fit their text to the + box (stamps) when resized. + - New "Link" button: place a clickable area by clicking on the page and + point it to a web address, an e-mail address or a page of the document. - New English translations. -- Paul Woisard Mon, 02 Nov 2026 15:00:00 +0000