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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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é
15 changes: 9 additions & 6 deletions docs/0.FONCTIONNALITES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<details>
<summary>🛠 Pour les développeurs</summary>

- `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.

</details>

Expand Down
13 changes: 8 additions & 5 deletions docs/en/0.FONCTIONNALITES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<details>
<summary>🛠 For developers</summary>

- `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.

</details>

Expand Down
4 changes: 3 additions & 1 deletion packaging/copr/pdf-equilibrist.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paulwoisard@gmail.com> - 0.2.2-1
- New upstream release: font, size, style and colour for added text.
Expand Down
10 changes: 10 additions & 0 deletions packaging/ppa/debian/changelog
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paulwoisard@gmail.com> Mon, 02 Nov 2026 15:00:00 +0000
Expand All @@ -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 <paulwoisard@gmail.com> Mon, 02 Nov 2026 15:00:00 +0000
Expand Down
35 changes: 35 additions & 0 deletions src/pdf_equilibrist/operations/annotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
30 changes: 27 additions & 3 deletions src/pdf_equilibrist/operations/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""
Expand All @@ -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):
"""
Expand Down
40 changes: 40 additions & 0 deletions src/pdf_equilibrist/ui/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
Loading
Loading