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). 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. 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..007393b 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) @@ -160,13 +183,30 @@ 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 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 +291,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 +379,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(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(self.tr("Italique")) + self._bt_color = QPushButton("A") + 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) + 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, self.tr("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..fffb496 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 @@ -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: @@ -204,14 +206,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/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_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_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/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 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 +