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/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
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
+