From 068f84bf1dcbbc497685d8fcc3a4be01a746fc00 Mon Sep 17 00:00:00 2001 From: Ange-Marie MAURIN Date: Tue, 11 Aug 2026 15:41:55 +0200 Subject: [PATCH] First commit --- README.md | 109 +++++++ pdfscrapper/__init__.py | 3 + pdfscrapper/block_picker.py | 151 ++++++++++ pdfscrapper/bridge.py | 56 ++++ pdfscrapper/link_detector.py | 144 ++++++++++ pdfscrapper/main.py | 24 ++ pdfscrapper/main_window.py | 518 ++++++++++++++++++++++++++++++++++ pdfscrapper/models.py | 23 ++ pdfscrapper/pdf_builder.py | 152 ++++++++++ pdfscrapper/pdf_style.py | 54 ++++ pdfscrapper/preview_window.py | 460 ++++++++++++++++++++++++++++++ requirements.txt | 2 + run.py | 6 + 13 files changed, 1702 insertions(+) create mode 100644 pdfscrapper/__init__.py create mode 100644 pdfscrapper/block_picker.py create mode 100644 pdfscrapper/bridge.py create mode 100644 pdfscrapper/link_detector.py create mode 100644 pdfscrapper/main.py create mode 100644 pdfscrapper/main_window.py create mode 100644 pdfscrapper/models.py create mode 100644 pdfscrapper/pdf_builder.py create mode 100644 pdfscrapper/pdf_style.py create mode 100644 pdfscrapper/preview_window.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/README.md b/README.md index a5d18bc..c7a3c38 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,111 @@ # PdfScrapper +Navigateur visuel qui permet de sélectionner du texte sur des pages web +et de l'accumuler dans un PDF, au fil de la navigation. + +## Fonctionnalités + +- **Navigateur intégré** (moteur Chromium via QtWebEngine) : barre d'adresse, + précédent / suivant / recharger, session persistante (cookies/connexions + conservées entre deux lancements). +- **Capture de texte (sélection manuelle)** : sélectionnez du texte dans la + page puis cliquez sur *"➕ Ajouter la sélection au PDF"* (ou + `Ctrl+Shift+A`). L'extrait est stocké avec le titre de la page, son URL et + l'heure de capture. +- **Capture de bloc au survol** : le bloc de contenu sous le curseur + (paragraphe, item de liste, section, article...) se surligne + automatiquement quand le mode est actif ; un clic dessus l'ajoute + directement au PDF, sans avoir à sélectionner le texte à la main. Deux + façons de l'activer, combinables : + - **Maintenir `Ctrl` enfoncé** dans la page : actif tant que la touche est + tenue, désactivé dès qu'on la relâche (pratique pour capturer un bloc + à la volée sans repasser par la souris ensuite). + - **`Ctrl+Shift+B`** (ou le bouton *"🎯 Mode capture au survol"*) : reste + actif en continu jusqu'à nouvelle bascule, y compris après avoir suivi + un lien vers la page suivante. + + Tant que le mode est actif (par l'une ou l'autre méthode), le clic normal + (suivre les liens) est suspendu sur la page. +- **Panneau latéral** : liste des extraits capturés (réorganisables, + supprimables, **renommables**), export du PDF final quand vous le + souhaitez. Si un extrait est sélectionné dans la liste, chaque nouvelle + capture s'insère juste à côté de lui — **au-dessus** ou **en dessous** + selon la case à cocher *"Insérer les nouvelles captures au-dessus de la + sélection"* (cochée par défaut = au-dessus) — et les captures suivantes + continuent de s'enchaîner du même côté du repère sans avoir à + re-sélectionner à chaque fois. Sans sélection, les captures s'accumulent + simplement à la fin, quel que soit ce réglage. +- **Renommer un extrait** : double-cliquez sur un extrait dans la liste (ou + sélectionnez-le puis *"✏️ Renommer"*) pour changer son titre affiché — + utilisé aussi comme titre de section dans le PDF exporté. +- **Aperçu et style du PDF** (*"🖌 Aperçu et style du PDF…"*) : fenêtre + dédiée qui affiche un rendu en direct du PDF final (via QtPdf) pendant + que vous ajustez les réglages, répartis en deux onglets : + - **🎨 Style** : titre du document, format de page (A4/Letter), police + globale (Helvetica/Times/Courier — s'applique à tout le document), + taille du texte et des titres, couleur des titres, couleur du texte, + **couleur d'arrière-plan**, marges, affichage ou non de l'URL/date + sous chaque titre, saut de page entre extraits ou non, et + **inclusion d'un sommaire** (voir ci-dessous). + - **✏️ Corriger le texte** : liste des extraits, avec un champ titre et + une zone de texte éditable pour corriger le contenu capturé + directement (fautes de frappe, nettoyage de texte scrappé, etc.) — les + modifications s'appliquent immédiatement à l'extrait (y compris dans + le panneau latéral de la fenêtre principale) et l'aperçu se + régénère automatiquement. + + L'aperçu se régénère automatiquement (avec un léger délai anti-rebond) + à chaque changement. Les réglages de style et les corrections de texte + faits ici restent actifs pour l'export "normal" depuis le panneau + latéral, même après avoir fermé cette fenêtre. +- **Sommaire cliquable** : activable depuis l'onglet Style (*"Inclure un + sommaire"*, actif par défaut dès qu'il y a plus d'un extrait). Insère une + page de sommaire juste après le titre du document, avec un lien + cliquable vers chaque section — liens internes PDF standard (annotations + `/Link` avec destination directe), utilisables dans n'importe quel + lecteur PDF. +- **Détection de liens de "suite"** : les liens dont le texte ressemble à + *suivant / next / chapitre suivant / lire la suite / part 2* etc. (ou une + balise ``) sont surlignés en orange et listés dans le + panneau, pour enchaîner facilement la lecture et la capture sans les + chercher manuellement. La détection tourne en continu (`MutationObserver` + JS, résultats poussés vers Python via le pont QWebChannel) plutôt qu'une + seule fois au chargement — nécessaire car beaucoup de sites (SPA, + pagination injectée après coup en JavaScript) ajoutent ce genre de lien + bien après le signal de fin de chargement. + +## Installation + +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +``` + +## Lancement + +```bash +python run.py +``` + +## Structure du projet + +``` +pdfscrapper/ + main.py # point d'entrée (QApplication) + main_window.py # fenêtre principale : navigateur + panneau latéral + link_detector.py # heuristiques JS de détection des liens "suivant" + block_picker.py # heuristique JS de surlignage/capture de bloc au survol + bridge.py # pont QWebChannel (JS -> Python) pour la capture de bloc + pdf_style.py # dataclass PdfStyle (réglages de mise en forme du PDF) + pdf_builder.py # génération du PDF (reportlab) : style, fond, sommaire/liens + preview_window.py # fenêtre d'aperçu en direct (QtPdf), style et correction du texte + models.py # dataclass Clip (un extrait capturé) +run.py # lance l'application +``` + +## Pistes d'évolution + +- Sauvegarde/chargement d'une session de capture en cours (JSON). +- Réglage des motifs de détection de liens depuis l'interface. +- Export additionnel en `.txt` / `.epub`. diff --git a/pdfscrapper/__init__.py b/pdfscrapper/__init__.py new file mode 100644 index 0000000..4c58369 --- /dev/null +++ b/pdfscrapper/__init__.py @@ -0,0 +1,3 @@ +"""PdfScrapper — navigateur visuel pour capturer du texte web vers un PDF.""" + +__version__ = "0.1.0" diff --git a/pdfscrapper/block_picker.py b/pdfscrapper/block_picker.py new file mode 100644 index 0000000..4d4ec38 --- /dev/null +++ b/pdfscrapper/block_picker.py @@ -0,0 +1,151 @@ +"""Mode "capture de bloc au survol". + +Ce script est injecté une seule fois par page (au tout début du chargement, +comme le pont QWebChannel) et reste actif en permanence. Il gère deux façons +indépendantes d'activer la capture, combinées via un petit état : + + - "sticky" : activé/désactivé depuis Python (bouton de la barre d'outils + ou raccourci Ctrl+Shift+B) — reste actif jusqu'à nouvelle bascule. + - "ctrlHeld" : activé tant que la touche Ctrl est physiquement maintenue + enfoncée dans la page, désactivé au relâchement (ou si la fenêtre perd + le focus pendant que Ctrl est tenu, pour éviter un blocage). + +Le mode est actif dès que l'un des deux est vrai. Quand actif : + 1) le bloc de contenu (paragraphe, article, div, item de liste, etc.) + sous le curseur se surligne via un overlay flottant qui ne modifie + pas le DOM de la page ; + 2) un clic sur ce bloc empêche la navigation normale et transmet son + texte à Python via le pont QWebChannel (window.pdfscrapperBridge, + voir bridge.py). + +Le bloc retenu est le plus proche ancêtre "de type bloc" de l'élément +survolé (via Element.closest) : un

si on survole un paragraphe, un +

/
plus large si on survole une zone qui n'a pas de bloc plus +précis en dessous. +""" + +# Script persistant, injecté une fois par page (voir main_window.py : +# QWebEngineScript à InjectionPoint.DocumentCreation). Attention : le +# n'existe pas encore à ce stade, d'où l'accès différé via +# ensureOverlay()/setCursor(). +BLOCK_PICKER_INIT_JS = r""" +(function () { + if (window.__pdfscrapperInitialized) { return; } + window.__pdfscrapperInitialized = true; + + var BLOCK_SELECTOR = 'p, li, blockquote, h1, h2, h3, h4, h5, h6, pre, td, th, figcaption, article, section, div'; + + var state = { sticky: false, ctrlHeld: false, active: false }; + var prevCursor = ''; + var overlay = null; + + function ensureOverlay() { + if (overlay || !document.body) { return overlay; } + overlay = document.createElement('div'); + overlay.id = '__pdfscrapper_overlay'; + overlay.style.position = 'fixed'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '2147483647'; + overlay.style.background = 'rgba(33, 150, 243, 0.22)'; + overlay.style.border = '2px solid #2196f3'; + overlay.style.borderRadius = '2px'; + overlay.style.boxSizing = 'border-box'; + overlay.style.display = 'none'; + document.body.appendChild(overlay); + return overlay; + } + + function hideOverlay() { + if (overlay) { overlay.style.display = 'none'; } + } + + function positionOverlay(el) { + var ov = ensureOverlay(); + if (!ov) { return; } + var rect = el.getBoundingClientRect(); + ov.style.left = rect.left + 'px'; + ov.style.top = rect.top + 'px'; + ov.style.width = rect.width + 'px'; + ov.style.height = rect.height + 'px'; + ov.style.display = 'block'; + } + + function setCursor(value) { + if (document.body) { document.body.style.cursor = value; } + } + + function updateActive() { + var shouldBeActive = state.sticky || state.ctrlHeld; + if (shouldBeActive === state.active) { return; } + state.active = shouldBeActive; + if (state.active) { + prevCursor = document.body ? document.body.style.cursor : ''; + setCursor('crosshair'); + } else { + setCursor(prevCursor || ''); + hideOverlay(); + window.__pdfscrapperCurrentBlock = null; + } + } + + function findBlock(el) { + if (!el || typeof el.closest !== 'function') { return null; } + var block = el.closest(BLOCK_SELECTOR); + if (!block || block === document.body || block === document.documentElement) { + return null; + } + return block; + } + + document.addEventListener('mousemove', function (e) { + if (!state.active) { return; } + var el = findBlock(e.target); + window.__pdfscrapperCurrentBlock = el; + if (!el) { hideOverlay(); return; } + positionOverlay(el); + }, true); + + document.addEventListener('click', function (e) { + if (!state.active) { return; } + var el = window.__pdfscrapperCurrentBlock || findBlock(e.target); + if (!el) { return; } + e.preventDefault(); + e.stopPropagation(); + var text = (el.innerText || el.textContent || '').trim(); + if (!text || !window.pdfscrapperBridge) { return; } + window.pdfscrapperBridge.captureBlock(text); + }, true); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Control') { + state.ctrlHeld = true; + updateActive(); + } + }, true); + + document.addEventListener('keyup', function (e) { + if (e.key === 'Control') { + state.ctrlHeld = false; + updateActive(); + } + }, true); + + // Filet de sécurité : si la fenêtre perd le focus pendant que Ctrl est + // tenu (ex. Alt-Tab), on ne recevra jamais le keyup correspondant. + window.addEventListener('blur', function () { + state.ctrlHeld = false; + updateActive(); + }); + + // Appelé depuis Python (bouton de la barre d'outils / Ctrl+Shift+B). + window.__pdfscrapperSetSticky = function (value) { + state.sticky = !!value; + updateActive(); + }; +})(); +""" + +# Appelé après chaque chargement de page pour reporter l'état "sticky" +# (activé via le bouton/raccourci) sur la nouvelle page — l'état JS +# précédent ne survit pas à une navigation. +SET_STICKY_JS_TEMPLATE = "window.__pdfscrapperSetSticky && window.__pdfscrapperSetSticky({value});" diff --git a/pdfscrapper/bridge.py b/pdfscrapper/bridge.py new file mode 100644 index 0000000..1ee903c --- /dev/null +++ b/pdfscrapper/bridge.py @@ -0,0 +1,56 @@ +"""Pont JS ↔ Python (QWebChannel) utilisé par le mode "capture au survol" +et par la détection continue des liens de "suite" (voir link_detector.py). + +QtWebEngine exécute la page dans un processus Chromium séparé : le seul +canal officiellement supporté pour qu'un script de la page appelle du code +Python est QWebChannel. On expose ici un unique objet `pdfscrapperBridge` +côté JavaScript, avec : + - une méthode `captureBlock(text)` que le script d'overlay (voir + block_picker.py) appelle au clic sur un bloc ; + - une méthode `reportLinks(json)` que le MutationObserver de + link_detector.py appelle à chaque (re)scan de la page. +""" + +from __future__ import annotations + +from PySide6.QtCore import QFile, QIODevice, QObject, Signal, Slot + + +class Bridge(QObject): + """Objet exposé côté JS sous `window.pdfscrapperBridge`.""" + + block_captured = Signal(str) + links_detected = Signal(str) + + @Slot(str) + def captureBlock(self, text: str) -> None: # noqa: N802 (nom appelé depuis JS) + if text and text.strip(): + self.block_captured.emit(text.strip()) + + @Slot(str) + def reportLinks(self, links_json: str) -> None: # noqa: N802 (nom appelé depuis JS) + self.links_detected.emit(links_json) + + +def load_qwebchannel_js() -> str: + """Charge le script qwebchannel.js embarqué dans les ressources Qt.""" + resource = QFile(":/qtwebchannel/qwebchannel.js") + if not resource.open(QIODevice.OpenModeFlag.ReadOnly): + raise RuntimeError("Impossible de charger la ressource Qt qtwebchannel/qwebchannel.js") + try: + return bytes(resource.readAll().data()).decode("utf-8") + finally: + resource.close() + + +def build_bridge_init_script() -> str: + """Script injecté au tout début de chaque page pour établir le canal + QWebChannel et exposer `window.pdfscrapperBridge` une fois prêt.""" + return ( + load_qwebchannel_js() + + """ + new QWebChannel(qt.webChannelTransport, function (channel) { + window.pdfscrapperBridge = channel.objects.pdfscrapperBridge; + }); + """ + ) diff --git a/pdfscrapper/link_detector.py b/pdfscrapper/link_detector.py new file mode 100644 index 0000000..30e8248 --- /dev/null +++ b/pdfscrapper/link_detector.py @@ -0,0 +1,144 @@ +"""Détection heuristique des liens de "suite de l'histoire" sur une page. + +Première version : un scan JS unique déclenché au signal Qt `loadFinished` +(page().runJavaScript(...) avec callback synchrone). Problème constaté : +sur de nombreux sites (SPA, contenu/pagination injecté en JavaScript après +coup, hydratation différée), le lien "suivant" n'existe pas encore dans le +DOM au moment de `loadFinished` — il apparaît quelques centaines de ms +plus tard. Un scan unique le manque systématiquement, ce qui donnait +l'impression que la détection ne s'activait jamais (elle semblait analyser +une version figée de la page plutôt que la page telle qu'effectivement +rendue). + +Cette version scanne en continu via un MutationObserver JS : un premier +scan a lieu dès que le DOM est prêt, puis à chaque mutation significative +du contenu (avec anti-rebond), ce qui couvre aussi bien le contenu chargé +en différé que les sites à navigation interne (SPA/pushState) qui ne +redéclenchent jamais `loadFinished`. Les résultats sont poussés vers +Python via le pont QWebChannel déjà utilisé par block_picker.py (voir +bridge.py) plutôt que remontés ponctuellement par callback synchrone. + +Deuxième problème constaté (ex. literotica.com) : certains sites affichent +l'indice "suivant" en dehors du lien lui-même — une icône + légende +("Partie Suivante") posée en élément voisin du , qui ne contient donc +lui-même que le titre du chapitre. Un scan qui ne regarde que le texte du + (a.innerText) ne peut structurellement pas le voir. On regarde donc +aussi le texte du conteneur parent immédiat du lien (borné en taille pour +limiter les faux positifs sur de gros conteneurs sans rapport) : si ce +contexte proche matche un motif, le lien est quand même retenu — son +propre texte (le titre) reste ce qui est affiché/surligné. +""" + +# Script persistant, injecté une fois par page (voir main_window.py : +# QWebEngineScript à InjectionPoint.DocumentCreation, comme le pont +# QWebChannel et le mode "capture au survol"). +LINK_DETECTOR_INIT_JS = r""" +(function () { + if (window.__pdfscrapperLinkDetectorInitialized) { return; } + window.__pdfscrapperLinkDetectorInitialized = true; + + var patterns = [ + /\bsuivante?\b/i, + /\bnext\b/i, + /chapitre\s*suivante?/i, + /chapitre\s*\d+/i, + /chapter\s*\d+/i, + /next\s*chapter/i, + /lire la suite/i, + /\bcontinuer\b/i, + /\bpart(ie)?\s*\d+\b/i, + />>+/, + /→/, + /\bprochain(e)?\b/i, + /nouveau chapitre/i, + /read more/i + ]; + + function reportResult(result) { + if (window.pdfscrapperBridge) { + window.pdfscrapperBridge.reportLinks(JSON.stringify(result)); + } else { + // Le pont QWebChannel s'établit de façon asynchrone : s'il + // n'est pas encore prêt (scan très précoce), on réessaie. + setTimeout(function () { reportResult(result); }, 150); + } + } + + function scan() { + if (!document.body) { return; } + + document.querySelectorAll('a[data-pdfscrapper-suggested]').forEach(function (a) { + a.style.outline = ''; + a.style.backgroundColor = ''; + a.removeAttribute('data-pdfscrapper-suggested'); + }); + + document.querySelectorAll('a[href]').forEach(function (a) { + var text = (a.innerText || a.textContent || '').trim(); + // Le lien doit rester un texte de navigation plausible (un + // titre, pas un paragraphe entier) même si l'indice "suivant" + // est trouvé dans son contexte plutôt que dans son propre texte. + if (!text || text.length > 60) { return; } + + var matchedOwnText = patterns.some(function (p) { return p.test(text); }); + + var matchedContext = false; + if (!matchedOwnText && a.parentElement) { + var contextText = (a.parentElement.innerText || a.parentElement.textContent || '').trim(); + // Borné : on ne veut capter qu'une légende/icône voisine + // proche (ex. "Partie Suivante" à côté du lien), pas + // n'importe quel "suivant" perdu dans un gros conteneur. + if (contextText && contextText.length <= 150) { + matchedContext = patterns.some(function (p) { return p.test(contextText); }); + } + } + + if (matchedOwnText || matchedContext) { + a.setAttribute('data-pdfscrapper-suggested', '1'); + a.style.outline = '3px solid #ff9800'; + a.style.outlineOffset = '2px'; + a.style.backgroundColor = 'rgba(255,152,0,0.18)'; + } + }); + + var relNextEl = document.querySelector('link[rel="next"]'); + var result = { relNext: relNextEl ? relNextEl.href : null, links: [] }; + document.querySelectorAll('a[data-pdfscrapper-suggested="1"]').forEach(function (a) { + var t = (a.innerText || a.textContent || '').trim(); + result.links.push({ text: t.slice(0, 80), href: a.href }); + }); + reportResult(result); + } + + var debounceHandle = null; + function scheduleScan() { + if (debounceHandle) { clearTimeout(debounceHandle); } + debounceHandle = setTimeout(scan, 400); + } + + function setupObserver() { + if (!document.body) { + setTimeout(setupObserver, 100); + return; + } + scan(); + // Rescanne à chaque ajout/suppression de nœuds n'importe où dans + // la page : couvre le contenu injecté après coup (fetch/AJAX, + // hydratation React/Vue...) et la navigation interne des SPA. + var observer = new MutationObserver(scheduleScan); + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupObserver); + } else { + setupObserver(); + } + // Filet de sécurité : un dernier scan au chargement complet (images, + // etc.), qui peut réordonner/ajouter des liens sur certains sites. + window.addEventListener('load', scheduleScan); +})(); +""" + +# Récupère le texte actuellement sélectionné par l'utilisateur dans la page. +SELECTION_JS = "window.getSelection().toString();" diff --git a/pdfscrapper/main.py b/pdfscrapper/main.py new file mode 100644 index 0000000..f2a618d --- /dev/null +++ b/pdfscrapper/main.py @@ -0,0 +1,24 @@ +"""Point d'entrée de l'application PdfScrapper.""" + +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +from .main_window import MainWindow + + +def main() -> None: + app = QApplication(sys.argv) + app.setApplicationName("PdfScrapper") + + window = MainWindow() + window.resize(1280, 860) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/pdfscrapper/main_window.py b/pdfscrapper/main_window.py new file mode 100644 index 0000000..cd8624f --- /dev/null +++ b/pdfscrapper/main_window.py @@ -0,0 +1,518 @@ +"""Fenêtre principale : navigateur + panneau latéral de capture PDF.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from PySide6.QtCore import QStandardPaths, QUrl, Qt +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWebChannel import QWebChannel +from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript +from PySide6.QtWebEngineWidgets import QWebEngineView +from PySide6.QtWidgets import ( + QCheckBox, + QDockWidget, + QFileDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QToolBar, + QVBoxLayout, + QWidget, +) + +from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE +from .bridge import Bridge, build_bridge_init_script +from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS +from .models import Clip +from .pdf_builder import build_pdf +from .pdf_style import PdfStyle +from .preview_window import PdfPreviewWindow + +HOME_URL = "https://www.google.com/" + + +class BrowserPage(QWebEnginePage): + """Page web qui ouvre les liens target="_blank" dans la même vue + plutôt que de les laisser disparaître silencieusement.""" + + def createWindow(self, _window_type): # noqa: N802 (nom imposé par Qt) + return self + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("PdfScrapper") + + self.clips: list[Clip] = [] + self.last_suggested_relnext: str | None = None + self.block_picker_enabled = False + self.insert_above = True + self.pdf_style = PdfStyle() + self.preview_window: PdfPreviewWindow | None = None + + self._build_webview() + self._build_toolbar() + self._build_sidebar() + + self.webview.setUrl(QUrl(HOME_URL)) + + # ------------------------------------------------------------------ # + # Construction de l'UI + # ------------------------------------------------------------------ # + def _build_webview(self) -> None: + storage_dir = Path( + QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) + ) / "pdfscrapper" + storage_dir.mkdir(parents=True, exist_ok=True) + + profile = QWebEngineProfile("pdfscrapper_profile", self) + profile.setPersistentCookiesPolicy( + QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies + ) + profile.setPersistentStoragePath(str(storage_dir)) + profile.setCachePath(str(storage_dir / "cache")) + + # Injecté au tout début de chaque page pour établir le pont + # QWebChannel (window.pdfscrapperBridge) utilisé par le mode + # "capture au survol" — voir bridge.py / block_picker.py. + bridge_script = QWebEngineScript() + bridge_script.setName("pdfscrapper_bridge_init") + bridge_script.setSourceCode(build_bridge_init_script()) + bridge_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + bridge_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + bridge_script.setRunsOnSubFrames(False) + profile.scripts().insert(bridge_script) + + # Script persistant du mode "capture au survol" (surlignage de + # bloc + activation par Ctrl maintenu ou par bouton) — voir + # block_picker.py pour le détail du fonctionnement. + block_picker_script = QWebEngineScript() + block_picker_script.setName("pdfscrapper_block_picker_init") + block_picker_script.setSourceCode(BLOCK_PICKER_INIT_JS) + block_picker_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + block_picker_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + block_picker_script.setRunsOnSubFrames(False) + profile.scripts().insert(block_picker_script) + + # Script persistant de détection des liens de "suite" — scanne en + # continu via un MutationObserver (plutôt qu'une seule fois au + # chargement) pour détecter aussi les liens ajoutés dynamiquement + # par la page elle-même (SPA, contenu chargé après coup). Voir + # link_detector.py. + link_detector_script = QWebEngineScript() + link_detector_script.setName("pdfscrapper_link_detector_init") + link_detector_script.setSourceCode(LINK_DETECTOR_INIT_JS) + link_detector_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + link_detector_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + link_detector_script.setRunsOnSubFrames(False) + profile.scripts().insert(link_detector_script) + + self.webview = QWebEngineView(self) + self.webview.setPage(BrowserPage(profile, self.webview)) + self.setCentralWidget(self.webview) + + self.bridge = Bridge(self) + self.bridge.block_captured.connect(self._handle_block_capture) + self.bridge.links_detected.connect(self._on_links_detected) + self.web_channel = QWebChannel(self) + self.web_channel.registerObject("pdfscrapperBridge", self.bridge) + self.webview.page().setWebChannel(self.web_channel) + + self.webview.urlChanged.connect(self._on_url_changed) + self.webview.loadFinished.connect(self._on_load_finished) + self.webview.titleChanged.connect(self._on_title_changed) + + def _build_toolbar(self) -> None: + toolbar = QToolBar("Navigation", self) + toolbar.setMovable(False) + self.addToolBar(toolbar) + + back_action = QAction("◀", self) + back_action.setToolTip("Page précédente") + back_action.triggered.connect(self.webview.back) + toolbar.addAction(back_action) + + forward_action = QAction("▶", self) + forward_action.setToolTip("Page suivante") + forward_action.triggered.connect(self.webview.forward) + toolbar.addAction(forward_action) + + reload_action = QAction("⟳", self) + reload_action.setToolTip("Recharger") + reload_action.triggered.connect(self.webview.reload) + toolbar.addAction(reload_action) + + home_action = QAction("🏠", self) + home_action.setToolTip("Page d'accueil") + home_action.triggered.connect(lambda: self.webview.setUrl(QUrl(HOME_URL))) + toolbar.addAction(home_action) + + self.address_bar = QLineEdit(self) + self.address_bar.returnPressed.connect(self._navigate_to_address_bar) + toolbar.addWidget(self.address_bar) + + go_action = QAction("Go", self) + go_action.triggered.connect(self._navigate_to_address_bar) + toolbar.addAction(go_action) + + toolbar.addSeparator() + + capture_action = QAction("➕ Ajouter la sélection au PDF", self) + capture_action.setShortcut(QKeySequence("Ctrl+Shift+A")) + capture_action.setToolTip("Ajoute le texte actuellement sélectionné dans la page (Ctrl+Shift+A)") + capture_action.triggered.connect(self.capture_selection) + toolbar.addAction(capture_action) + + self.block_picker_action = QAction("🎯 Mode capture au survol", self) + self.block_picker_action.setCheckable(True) + self.block_picker_action.setShortcut(QKeySequence("Ctrl+Shift+B")) + self.block_picker_action.setToolTip( + "Survolez un paragraphe/bloc pour le surligner, cliquez pour l'ajouter au PDF.\n" + "• Ctrl+Shift+B (ou ce bouton) : reste activé jusqu'à nouvelle bascule.\n" + "• Maintenir Ctrl enfoncé dans la page : activation temporaire, désactivée au " + "relâchement.\n" + "Désactive temporairement le clic normal sur la page tant qu'actif." + ) + self.block_picker_action.toggled.connect(self._toggle_block_picker) + toolbar.addAction(self.block_picker_action) + + def _build_sidebar(self) -> None: + dock = QDockWidget("Capture PDF", self) + dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable + | QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + + panel = QWidget() + layout = QVBoxLayout(panel) + + self.clip_count_label = QLabel("Extraits capturés : 0") + layout.addWidget(self.clip_count_label) + + self.insert_above_checkbox = QCheckBox("Insérer les nouvelles captures au-dessus de la sélection") + self.insert_above_checkbox.setChecked(self.insert_above) + self.insert_above_checkbox.setToolTip( + "Coché : chaque nouvelle capture s'insère juste au-dessus de l'extrait " + "sélectionné dans la liste.\n" + "Décoché : elle s'insère juste en dessous.\n" + "Sans sélection, elle s'ajoute toujours à la fin." + ) + self.insert_above_checkbox.toggled.connect(self._toggle_insert_direction) + layout.addWidget(self.insert_above_checkbox) + + self.clip_list = QListWidget() + self.clip_list.setToolTip("Double-cliquez sur un extrait pour le renommer.") + self.clip_list.itemDoubleClicked.connect(lambda _item: self._rename_selected_clip()) + layout.addWidget(self.clip_list, stretch=2) + + clip_buttons = QHBoxLayout() + rename_btn = QPushButton("✏️ Renommer") + up_btn = QPushButton("▲ Monter") + down_btn = QPushButton("▼ Descendre") + delete_btn = QPushButton("🗑 Supprimer") + rename_btn.clicked.connect(self._rename_selected_clip) + up_btn.clicked.connect(lambda: self._move_clip(-1)) + down_btn.clicked.connect(lambda: self._move_clip(1)) + delete_btn.clicked.connect(self._delete_selected_clip) + clip_buttons.addWidget(rename_btn) + clip_buttons.addWidget(up_btn) + clip_buttons.addWidget(down_btn) + clip_buttons.addWidget(delete_btn) + layout.addLayout(clip_buttons) + + clear_btn = QPushButton("Vider tout") + clear_btn.clicked.connect(self._clear_clips) + layout.addWidget(clear_btn) + + preview_btn = QPushButton("🖌 Aperçu et style du PDF…") + preview_btn.clicked.connect(self.open_pdf_preview) + layout.addWidget(preview_btn) + + layout.addWidget(_separator_label("Liens suggérés sur cette page")) + self.suggested_list = QListWidget() + self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested) + layout.addWidget(self.suggested_list, stretch=1) + + self.next_page_btn = QPushButton("→ Aller à la suite détectée") + self.next_page_btn.setEnabled(False) + self.next_page_btn.clicked.connect(self._go_to_relnext) + layout.addWidget(self.next_page_btn) + + export_btn = QPushButton("📄 Exporter en PDF…") + export_btn.clicked.connect(self.export_pdf) + layout.addWidget(export_btn) + + dock.setWidget(panel) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) + + # ------------------------------------------------------------------ # + # Navigation + # ------------------------------------------------------------------ # + def _navigate_to_address_bar(self) -> None: + text = self.address_bar.text().strip() + if not text: + return + if "://" not in text and " " not in text and "." in text: + text = "https://" + text + elif "://" not in text: + text = "https://www.google.com/search?q=" + text.replace(" ", "+") + self.webview.setUrl(QUrl(text)) + + def _on_url_changed(self, url: QUrl) -> None: + self.address_bar.setText(url.toString()) + # Efface les suggestions de la page précédente : le script de + # détection (persistant, voir link_detector.py) rescanne tout seul + # et repoussera des résultats frais sous peu — inutile d'attendre + # loadFinished, qui ne se déclenche d'ailleurs pas pour les + # navigations internes de type SPA (pushState). + self.suggested_list.clear() + self.next_page_btn.setEnabled(False) + self.last_suggested_relnext = None + + def _on_title_changed(self, title: str) -> None: + self.setWindowTitle(f"PdfScrapper — {title}" if title else "PdfScrapper") + + def _on_load_finished(self, ok: bool) -> None: + if not ok: + return + # L'état JS "sticky" (bascule bouton/raccourci) ne survit pas à une + # navigation : on le reporte sur la page fraîchement chargée. Le + # maintien de Ctrl, lui, est purement géré côté JS en continu. La + # détection de liens, elle, est poussée en continu par le script + # persistant (MutationObserver) — rien à déclencher ici. + self._sync_sticky_state() + + def _sync_sticky_state(self) -> None: + value = "true" if self.block_picker_enabled else "false" + self.webview.page().runJavaScript(SET_STICKY_JS_TEMPLATE.format(value=value)) + + def _on_links_detected(self, links_json: str) -> None: + try: + result = json.loads(links_json) + except (TypeError, ValueError): + return + + self.suggested_list.clear() + for link in result.get("links", []): + item = QListWidgetItem(link["text"] or link["href"]) + item.setData(Qt.ItemDataRole.UserRole, link["href"]) + item.setToolTip(link["href"]) + self.suggested_list.addItem(item) + + self.last_suggested_relnext = result.get("relNext") + self.next_page_btn.setEnabled(bool(self.last_suggested_relnext)) + + def _navigate_to_suggested(self, item: QListWidgetItem) -> None: + href = item.data(Qt.ItemDataRole.UserRole) + if href: + self.webview.setUrl(QUrl(href)) + + def _go_to_relnext(self) -> None: + if self.last_suggested_relnext: + self.webview.setUrl(QUrl(self.last_suggested_relnext)) + + # ------------------------------------------------------------------ # + # Capture de texte + # ------------------------------------------------------------------ # + def capture_selection(self) -> None: + self.webview.page().runJavaScript(SELECTION_JS, self._on_selection_captured) + + def _on_selection_captured(self, selected_text: str | None) -> None: + if not selected_text or not selected_text.strip(): + self.statusBar().showMessage("Aucun texte sélectionné dans la page.", 4000) + return + self._add_clip(selected_text) + + def _toggle_block_picker(self, enabled: bool) -> None: + self.block_picker_enabled = enabled + self._sync_sticky_state() + if enabled: + self.statusBar().showMessage( + "Mode capture au survol activé en continu : survolez un bloc puis cliquez " + "pour l'ajouter au PDF (le clic normal est suspendu tant que ce mode est actif).", + 6000, + ) + else: + self.statusBar().showMessage( + "Mode capture au survol désactivé (astuce : maintenez Ctrl enfoncé dans la " + "page pour l'activer temporairement sans bouton).", + 4000, + ) + + def _handle_block_capture(self, text: str) -> None: + self._add_clip(text) + + def _toggle_insert_direction(self, above: bool) -> None: + self.insert_above = above + self.statusBar().showMessage( + "Nouvelles captures insérées au-dessus de la sélection." if above + else "Nouvelles captures insérées en dessous de la sélection.", + 3000, + ) + + def _add_clip(self, text: str) -> None: + clip = Clip( + text=text.strip(), + title=self.webview.title() or self.webview.url().toString(), + url=self.webview.url().toString(), + captured_at=datetime.now(), + ) + + # Insère juste au-dessus ou en dessous de l'extrait actuellement + # sélectionné (selon self.insert_above), plutôt que toujours à la + # fin — pour pouvoir intercaler une capture au milieu d'un ordre + # déjà établi. + selected_row = self.clip_list.currentRow() + has_selection = 0 <= selected_row < len(self.clips) + + if not has_selection: + # Pas de repère : simple accumulation chronologique à la fin. + # On ne sélectionne rien après coup, pour que les captures + # suivantes continuent d'être de simples ajouts à la fin, + # quel que soit le mode — le mode au-dessus/en dessous ne + # prend effet que si l'utilisateur choisit lui-même un repère. + insert_at = len(self.clips) + self.clips.insert(insert_at, clip) + self._refresh_clip_list() + self.clip_list.setCurrentRow(-1) + elif self.insert_above: + insert_at = selected_row + self.clips.insert(insert_at, clip) + self._refresh_clip_list() + # On garde le repère (l'extrait qui était sélectionné, décalé + # d'un cran) comme sélection courante, pour que les captures + # suivantes continuent de s'empiler au-dessus de lui, dans + # l'ordre de capture. + self.clip_list.setCurrentRow(selected_row + 1) + else: + insert_at = selected_row + 1 + self.clips.insert(insert_at, clip) + self._refresh_clip_list() + # La nouvelle capture devient elle-même le repère, pour que + # les suivantes s'enchaînent juste en dessous d'elle. + self.clip_list.setCurrentRow(insert_at) + + self.statusBar().showMessage("Extrait ajouté au PDF.", 3000) + + def _refresh_clip_list(self) -> None: + # Préserve la sélection courante : cette méthode est aussi appelée + # en arrière-plan par la fenêtre d'aperçu (à chaque correction de + # texte), qui ne doit pas faire perdre le repère d'insertion + # au-dessus/en dessous choisi dans le panneau latéral. + preserved_row = self.clip_list.currentRow() + + self.clip_list.clear() + for clip in self.clips: + label = f"[{clip.captured_at.strftime('%H:%M')}] {clip.title} — {clip.preview()}" + item = QListWidgetItem(label) + item.setToolTip(clip.url) + self.clip_list.addItem(item) + self.clip_count_label.setText(f"Extraits capturés : {len(self.clips)}") + + if 0 <= preserved_row < len(self.clips): + self.clip_list.setCurrentRow(preserved_row) + + def _move_clip(self, offset: int) -> None: + row = self.clip_list.currentRow() + new_row = row + offset + if row < 0 or not (0 <= new_row < len(self.clips)): + return + self.clips[row], self.clips[new_row] = self.clips[new_row], self.clips[row] + self._refresh_clip_list() + self.clip_list.setCurrentRow(new_row) + + def _delete_selected_clip(self) -> None: + row = self.clip_list.currentRow() + if row < 0: + return + del self.clips[row] + self._refresh_clip_list() + + def _rename_selected_clip(self) -> None: + row = self.clip_list.currentRow() + if not (0 <= row < len(self.clips)): + self.statusBar().showMessage("Sélectionnez d'abord un extrait à renommer.", 3000) + return + + clip = self.clips[row] + new_title, ok = QInputDialog.getText( + self, + "Renommer l'extrait", + "Titre affiché (utilisé aussi comme titre de section dans le PDF) :", + QLineEdit.EchoMode.Normal, + clip.title, + ) + if ok and new_title.strip(): + clip.title = new_title.strip() + self._refresh_clip_list() + self.clip_list.setCurrentRow(row) + + def _clear_clips(self) -> None: + if not self.clips: + return + confirm = QMessageBox.question( + self, + "Vider les extraits", + "Supprimer tous les extraits capturés ?", + ) + if confirm == QMessageBox.StandardButton.Yes: + self.clips.clear() + self._refresh_clip_list() + + # ------------------------------------------------------------------ # + # Aperçu & style du PDF + # ------------------------------------------------------------------ # + def open_pdf_preview(self) -> None: + if self.preview_window is None: + self.preview_window = PdfPreviewWindow( + style=self.pdf_style, + get_clips=lambda: self.clips, + parent=self, + on_clips_changed=self._refresh_clip_list, + ) + self.preview_window.refresh_preview() + self.preview_window.show() + self.preview_window.raise_() + self.preview_window.activateWindow() + + # ------------------------------------------------------------------ # + # Export PDF + # ------------------------------------------------------------------ # + def export_pdf(self) -> None: + if not self.clips: + QMessageBox.warning(self, "Export PDF", "Aucun extrait à exporter.") + return + + default_name = f"extraits_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf" + path, _ = QFileDialog.getSaveFileName( + self, "Exporter en PDF", default_name, "Fichiers PDF (*.pdf)" + ) + if not path: + return + if not path.lower().endswith(".pdf"): + path += ".pdf" + + try: + build_pdf(self.clips, path, self.pdf_style) + except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur + QMessageBox.critical(self, "Export PDF", f"Échec de l'export :\n{exc}") + return + + self.statusBar().showMessage(f"PDF exporté : {path}", 5000) + QMessageBox.information(self, "Export PDF", f"PDF généré avec succès :\n{path}") + + +def _separator_label(text: str) -> QLabel: + label = QLabel(text) + label.setStyleSheet("font-weight: bold; margin-top: 8px;") + return label diff --git a/pdfscrapper/models.py b/pdfscrapper/models.py new file mode 100644 index 0000000..b54623a --- /dev/null +++ b/pdfscrapper/models.py @@ -0,0 +1,23 @@ +"""Modèles de données partagés par l'application.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Clip: + """Un extrait de texte capturé sur une page web.""" + + text: str + title: str + url: str + captured_at: datetime + + def preview(self, length: int = 60) -> str: + """Retourne un aperçu court du texte, sur une seule ligne.""" + flat = " ".join(self.text.split()) + if len(flat) <= length: + return flat + return flat[: length - 1].rstrip() + "…" diff --git a/pdfscrapper/pdf_builder.py b/pdfscrapper/pdf_builder.py new file mode 100644 index 0000000..ffcf5fa --- /dev/null +++ b/pdfscrapper/pdf_builder.py @@ -0,0 +1,152 @@ +"""Construction du PDF final à partir des extraits capturés.""" + +from __future__ import annotations + +from functools import partial +from xml.sax.saxutils import escape + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, LETTER +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer + +from .models import Clip +from .pdf_style import PdfStyle, bold_variant + +_PAGE_SIZES = {"A4": A4, "LETTER": LETTER} + + +def build_pdf(clips: list[Clip], output_path: str, style: PdfStyle | None = None) -> None: + """Génère un PDF regroupant tous les extraits, dans l'ordre fourni, + selon la mise en forme décrite par `style` (voir pdf_style.py). + + Chaque extrait démarre — selon `style.page_break_between_clips` — sur + une nouvelle page ou juste après un espacement, précédé de son titre + (personnalisable, voir MainWindow._rename_selected_clip) et, + optionnellement, de son URL d'origine et de la date/heure de capture. + Si `style.include_toc` est actif et qu'il y a plus d'un extrait, un + sommaire avec liens cliquables vers chaque section est inséré après + le titre du document. + """ + if not clips: + raise ValueError("Aucun extrait à exporter.") + + style = style or PdfStyle() + pagesize = _PAGE_SIZES.get(style.page_size, A4) + margin = style.margin_cm * cm + + doc = SimpleDocTemplate( + output_path, + pagesize=pagesize, + leftMargin=margin, + rightMargin=margin, + topMargin=margin, + bottomMargin=margin, + title=style.book_title, + ) + + base_styles = getSampleStyleSheet() + bold_font = bold_variant(style.font_name) + + title_style = ParagraphStyle( + "BookTitle", + parent=base_styles["Title"], + fontName=bold_font, + textColor=colors.HexColor(style.text_color), + ) + toc_heading_style = ParagraphStyle( + "TocHeading", + parent=base_styles["Heading1"], + fontName=bold_font, + textColor=colors.HexColor(style.heading_color), + spaceAfter=8, + ) + toc_entry_style = ParagraphStyle( + "TocEntry", + parent=base_styles["Normal"], + fontName=style.font_name, + fontSize=style.body_font_size, + leftIndent=0.5 * cm, + spaceAfter=6, + ) + heading_style = ParagraphStyle( + "ClipHeading", + parent=base_styles["Heading2"], + fontName=bold_font, + fontSize=style.heading_font_size, + leading=style.heading_font_size * 1.2, + textColor=colors.HexColor(style.heading_color), + spaceBefore=0, + spaceAfter=2, + ) + meta_style = ParagraphStyle( + "ClipMeta", + parent=base_styles["Normal"], + fontName=style.font_name, + fontSize=8, + textColor=colors.grey, + spaceAfter=10, + ) + body_style = ParagraphStyle( + "ClipBody", + parent=base_styles["BodyText"], + fontName=style.font_name, + fontSize=style.body_font_size, + leading=style.body_font_size * 1.4, + textColor=colors.HexColor(style.text_color), + spaceAfter=10, + ) + + story: list = [Paragraph(escape(style.book_title), title_style), Spacer(1, 1 * cm)] + + show_toc = style.include_toc and len(clips) > 1 + if show_toc: + story.append(Paragraph("Sommaire", toc_heading_style)) + for index, clip in enumerate(clips): + anchor = f"pdfscrapper_clip_{index}" + entry_text = ( + f'' + f"{escape(clip.title or 'Sans titre')}" + ) + story.append(Paragraph(entry_text, toc_entry_style)) + story.append(PageBreak()) + + for index, clip in enumerate(clips): + if index > 0: + if style.page_break_between_clips: + story.append(PageBreak()) + else: + story.append(Spacer(1, 0.6 * cm)) + + # Ancre invisible utilisée par les liens du sommaire (reportlab + # résout vers n'importe où dans le + # document, y compris sur une page ultérieure). + anchor_tag = f'' if show_toc else "" + story.append(Paragraph(anchor_tag + escape(clip.title or "Sans titre"), heading_style)) + + if style.show_meta: + meta_text = ( + f"{escape(clip.url)} — capturé le " + f"{clip.captured_at.strftime('%d/%m/%Y %H:%M')}" + ) + story.append(Paragraph(meta_text, meta_style)) + + for paragraph in clip.text.split("\n"): + paragraph = paragraph.strip() + if paragraph: + story.append(Paragraph(escape(paragraph), body_style)) + + paint_background = partial(_paint_background, style=style) + doc.build(story, onFirstPage=paint_background, onLaterPages=paint_background) + + +def _paint_background(canvas, doc, style: PdfStyle) -> None: + """Remplit la page d'une couleur de fond avant que le contenu ne soit + dessiné par-dessus (appelé par reportlab pour chaque page).""" + if style.background_color.lower() in ("#ffffff", "white", "#fff"): + return + canvas.saveState() + canvas.setFillColor(colors.HexColor(style.background_color)) + canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=1, stroke=0) + canvas.restoreState() diff --git a/pdfscrapper/pdf_style.py b/pdfscrapper/pdf_style.py new file mode 100644 index 0000000..7079de9 --- /dev/null +++ b/pdfscrapper/pdf_style.py @@ -0,0 +1,54 @@ +"""Options de mise en forme du PDF exporté, réglables depuis la fenêtre +d'aperçu (voir preview_window.py) et appliquées par pdf_builder.build_pdf. + +On s'appuie uniquement sur les 14 polices standard PDF (toujours +disponibles sans embarquer de fichier de police), ce qui évite toute +dépendance supplémentaire. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Libellé affiché dans l'UI -> nom de police reportlab (base, non-gras). +FONT_CHOICES: dict[str, str] = { + "Helvetica (sans-serif)": "Helvetica", + "Times (serif)": "Times-Roman", + "Courier (monospace)": "Courier", +} + +# Variante grasse de chaque police de base, utilisée pour les titres. +BOLD_FONT_VARIANTS: dict[str, str] = { + "Helvetica": "Helvetica-Bold", + "Times-Roman": "Times-Bold", + "Courier": "Courier-Bold", +} + +# Libellé affiché dans l'UI -> clé interne du format de page. +PAGE_SIZE_CHOICES: dict[str, str] = { + "A4": "A4", + "Letter (US)": "LETTER", +} + + +def bold_variant(font_name: str) -> str: + """Retourne la variante grasse d'une police de base, avec repli sûr.""" + return BOLD_FONT_VARIANTS.get(font_name, font_name) + + +@dataclass +class PdfStyle: + """Ensemble des réglages de mise en forme appliqués à l'export PDF.""" + + book_title: str = "Extraits capturés" + page_size: str = "A4" # une clé de PAGE_SIZE_CHOICES + font_name: str = "Helvetica" # une valeur de FONT_CHOICES, appliquée à tout le document + body_font_size: int = 11 + heading_font_size: int = 14 + heading_color: str = "#1a1a2e" + text_color: str = "#000000" + background_color: str = "#ffffff" + margin_cm: float = 2.0 + show_meta: bool = True # afficher l'URL + la date sous chaque titre + page_break_between_clips: bool = True + include_toc: bool = True # sommaire avec liens cliquables vers chaque section diff --git a/pdfscrapper/preview_window.py b/pdfscrapper/preview_window.py new file mode 100644 index 0000000..50f4846 --- /dev/null +++ b/pdfscrapper/preview_window.py @@ -0,0 +1,460 @@ +"""Fenêtre utilitaire : aperçu en direct du PDF, correction du texte et +réglages de style. + +Régénère un PDF temporaire (via pdf_builder.build_pdf) à chaque changement +— avec un léger anti-rebond pour éviter de reconstruire à chaque frappe — +et l'affiche avec QtPdf (module fourni par PySide6, sans dépendance +supplémentaire). + +Les réglages de style sont stockés dans l'objet PdfStyle partagé avec +MainWindow (passé par référence et modifié en place) : ce qui est choisi +ici reste donc actif pour l'export "normal" depuis le panneau latéral, +même après avoir fermé cette fenêtre. Les corrections de texte modifient +directement les objets Clip partagés (idem), et remontent vers la liste +de MainWindow via le callback `on_clips_changed`. +""" + +from __future__ import annotations + +import os +import tempfile +from datetime import datetime +from typing import Callable + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtGui import QColor +from PySide6.QtPdf import QPdfDocument +from PySide6.QtPdfWidgets import QPdfView +from PySide6.QtWidgets import ( + QCheckBox, + QColorDialog, + QComboBox, + QDialog, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QMessageBox, + QPushButton, + QSpinBox, + QStackedWidget, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from .models import Clip +from .pdf_builder import build_pdf +from .pdf_style import FONT_CHOICES, PAGE_SIZE_CHOICES, PdfStyle + +_REFRESH_DEBOUNCE_MS = 400 + + +class PdfPreviewWindow(QDialog): + def __init__( + self, + style: PdfStyle, + get_clips: Callable[[], list[Clip]], + parent: QWidget | None = None, + on_clips_changed: Callable[[], None] | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Aperçu et style du PDF") + self.resize(1150, 780) + + self.style = style + self._get_clips = get_clips + self._on_clips_changed = on_clips_changed + self._updating_controls = False + self._editing_clip_index = -1 + self._known_clip_count = -1 + + fd, temp_path = tempfile.mkstemp( + prefix=f"pdfscrapper_preview_{os.getpid()}_", suffix=".pdf" + ) + os.close(fd) + self._preview_path = temp_path + + self._refresh_timer = QTimer(self) + self._refresh_timer.setSingleShot(True) + self._refresh_timer.timeout.connect(self.refresh_preview) + + self._build_ui() + self._load_style_into_controls() + + # ------------------------------------------------------------------ # + # Construction de l'UI + # ------------------------------------------------------------------ # + def _build_ui(self) -> None: + root_layout = QHBoxLayout(self) + + # --- Colonne de gauche : aperçu --------------------------------- + self.pdf_document = QPdfDocument(self) + self.pdf_view = QPdfView(self) + self.pdf_view.setDocument(self.pdf_document) + self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage) + self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth) + + self.placeholder_label = QLabel( + "Aucun extrait à prévisualiser.\n\n" + "Capturez du texte dans le navigateur (sélection manuelle ou " + "mode survol) puis revenez ici." + ) + self.placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.placeholder_label.setWordWrap(True) + + self.preview_stack = QStackedWidget() + self.preview_stack.addWidget(self.placeholder_label) # index 0 + self.preview_stack.addWidget(self.pdf_view) # index 1 + root_layout.addWidget(self.preview_stack, stretch=3) + + # --- Colonne de droite : onglets Style / Texte + actions -------- + right_column = QVBoxLayout() + + tabs = QTabWidget() + tabs.addTab(self._build_style_tab(), "🎨 Style") + tabs.addTab(self._build_text_tab(), "✏️ Corriger le texte") + right_column.addWidget(tabs, stretch=1) + + refresh_btn = QPushButton("🔄 Actualiser l'aperçu") + refresh_btn.clicked.connect(self.refresh_preview) + right_column.addWidget(refresh_btn) + + export_btn = QPushButton("📄 Exporter en PDF…") + export_btn.clicked.connect(self._export_pdf) + right_column.addWidget(export_btn) + + close_btn = QPushButton("Fermer") + close_btn.clicked.connect(self.close) + right_column.addWidget(close_btn) + + right_panel = QWidget() + right_panel.setLayout(right_column) + right_panel.setMaximumWidth(360) + root_layout.addWidget(right_panel, stretch=1) + + def _build_style_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + form = QFormLayout() + + self.title_edit = QLineEdit() + self.title_edit.textChanged.connect(self._on_title_changed) + form.addRow("Titre du document", self.title_edit) + + self.page_size_combo = QComboBox() + self.page_size_combo.addItems(PAGE_SIZE_CHOICES.keys()) + self.page_size_combo.currentTextChanged.connect(self._on_page_size_changed) + form.addRow("Format de page", self.page_size_combo) + + self.font_combo = QComboBox() + self.font_combo.addItems(FONT_CHOICES.keys()) + self.font_combo.setToolTip("S'applique à tout le document (titres, texte, méta-infos).") + self.font_combo.currentTextChanged.connect(self._on_font_changed) + form.addRow("Police (globale)", self.font_combo) + + self.body_size_spin = QSpinBox() + self.body_size_spin.setRange(8, 24) + self.body_size_spin.valueChanged.connect(self._on_body_size_changed) + form.addRow("Taille du texte", self.body_size_spin) + + self.heading_size_spin = QSpinBox() + self.heading_size_spin.setRange(10, 36) + self.heading_size_spin.valueChanged.connect(self._on_heading_size_changed) + form.addRow("Taille des titres", self.heading_size_spin) + + self.heading_color_btn = QPushButton() + self.heading_color_btn.clicked.connect(self._pick_heading_color) + form.addRow("Couleur des titres", self.heading_color_btn) + + self.text_color_btn = QPushButton() + self.text_color_btn.clicked.connect(self._pick_text_color) + form.addRow("Couleur du texte", self.text_color_btn) + + self.background_color_btn = QPushButton() + self.background_color_btn.clicked.connect(self._pick_background_color) + form.addRow("Couleur d'arrière-plan", self.background_color_btn) + + self.margin_spin = QDoubleSpinBox() + self.margin_spin.setRange(0.5, 5.0) + self.margin_spin.setSingleStep(0.5) + self.margin_spin.setSuffix(" cm") + self.margin_spin.valueChanged.connect(self._on_margin_changed) + form.addRow("Marges", self.margin_spin) + + self.show_meta_check = QCheckBox("Afficher l'URL et la date sous chaque titre") + self.show_meta_check.toggled.connect(self._on_show_meta_changed) + form.addRow(self.show_meta_check) + + self.page_break_check = QCheckBox("Nouvelle page pour chaque extrait") + self.page_break_check.toggled.connect(self._on_page_break_changed) + form.addRow(self.page_break_check) + + self.toc_check = QCheckBox("Inclure un sommaire (liens cliquables)") + self.toc_check.setToolTip( + "Ajoute une page de sommaire après le titre, avec un lien cliquable vers " + "chaque section. Sans effet s'il n'y a qu'un seul extrait." + ) + self.toc_check.toggled.connect(self._on_toc_changed) + form.addRow(self.toc_check) + + layout.addLayout(form) + layout.addStretch(1) + return tab + + def _build_text_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + + layout.addWidget(QLabel("Extraits (sélectionnez-en un pour le corriger) :")) + self.editor_list = QListWidget() + self.editor_list.currentRowChanged.connect(self._load_clip_into_editor) + layout.addWidget(self.editor_list, stretch=1) + + layout.addWidget(QLabel("Titre de section :")) + self.editor_title_edit = QLineEdit() + self.editor_title_edit.setEnabled(False) + self.editor_title_edit.textChanged.connect(self._on_editor_title_changed) + layout.addWidget(self.editor_title_edit) + + layout.addWidget(QLabel("Texte :")) + self.editor_text_edit = QTextEdit() + self.editor_text_edit.setEnabled(False) + self.editor_text_edit.textChanged.connect(self._on_editor_text_changed) + layout.addWidget(self.editor_text_edit, stretch=3) + + return tab + + # ------------------------------------------------------------------ # + # Synchronisation contrôles <-> style + # ------------------------------------------------------------------ # + def _load_style_into_controls(self) -> None: + """Initialise les contrôles à partir du style courant, sans + déclencher de rafraîchissement en boucle pendant le peuplement.""" + self._updating_controls = True + try: + self.title_edit.setText(self.style.book_title) + + page_size_label = next( + (k for k, v in PAGE_SIZE_CHOICES.items() if v == self.style.page_size), + "A4", + ) + self.page_size_combo.setCurrentText(page_size_label) + + font_label = next( + (k for k, v in FONT_CHOICES.items() if v == self.style.font_name), + "Helvetica (sans-serif)", + ) + self.font_combo.setCurrentText(font_label) + + self.body_size_spin.setValue(self.style.body_font_size) + self.heading_size_spin.setValue(self.style.heading_font_size) + self.margin_spin.setValue(self.style.margin_cm) + self.show_meta_check.setChecked(self.style.show_meta) + self.page_break_check.setChecked(self.style.page_break_between_clips) + self.toc_check.setChecked(self.style.include_toc) + self._update_color_button(self.heading_color_btn, self.style.heading_color) + self._update_color_button(self.text_color_btn, self.style.text_color) + self._update_color_button(self.background_color_btn, self.style.background_color) + finally: + self._updating_controls = False + + @staticmethod + def _update_color_button(button: QPushButton, hex_color: str) -> None: + button.setText(hex_color) + # Texte du bouton en blanc ou noir selon la luminosité du fond, + # pour rester lisible quelle que soit la couleur choisie. + color = QColor(hex_color) + luminance = 0.299 * color.redF() + 0.587 * color.greenF() + 0.114 * color.blueF() + text_color = "black" if luminance > 0.6 else "white" + button.setStyleSheet(f"background-color: {hex_color}; color: {text_color};") + + def _schedule_refresh(self) -> None: + if self._updating_controls: + return + self._refresh_timer.start(_REFRESH_DEBOUNCE_MS) + + def _on_title_changed(self, text: str) -> None: + self.style.book_title = text + self._schedule_refresh() + + def _on_page_size_changed(self, label: str) -> None: + self.style.page_size = PAGE_SIZE_CHOICES.get(label, "A4") + self._schedule_refresh() + + def _on_font_changed(self, label: str) -> None: + self.style.font_name = FONT_CHOICES.get(label, "Helvetica") + self._schedule_refresh() + + def _on_body_size_changed(self, value: int) -> None: + self.style.body_font_size = value + self._schedule_refresh() + + def _on_heading_size_changed(self, value: int) -> None: + self.style.heading_font_size = value + self._schedule_refresh() + + def _on_margin_changed(self, value: float) -> None: + self.style.margin_cm = value + self._schedule_refresh() + + def _on_show_meta_changed(self, checked: bool) -> None: + self.style.show_meta = checked + self._schedule_refresh() + + def _on_page_break_changed(self, checked: bool) -> None: + self.style.page_break_between_clips = checked + self._schedule_refresh() + + def _on_toc_changed(self, checked: bool) -> None: + self.style.include_toc = checked + self._schedule_refresh() + + def _pick_heading_color(self) -> None: + color = QColorDialog.getColor(QColor(self.style.heading_color), self, "Couleur des titres") + if color.isValid(): + self.style.heading_color = color.name() + self._update_color_button(self.heading_color_btn, self.style.heading_color) + self._schedule_refresh() + + def _pick_text_color(self) -> None: + color = QColorDialog.getColor(QColor(self.style.text_color), self, "Couleur du texte") + if color.isValid(): + self.style.text_color = color.name() + self._update_color_button(self.text_color_btn, self.style.text_color) + self._schedule_refresh() + + def _pick_background_color(self) -> None: + color = QColorDialog.getColor( + QColor(self.style.background_color), self, "Couleur d'arrière-plan" + ) + if color.isValid(): + self.style.background_color = color.name() + self._update_color_button(self.background_color_btn, self.style.background_color) + self._schedule_refresh() + + # ------------------------------------------------------------------ # + # Correction du texte des extraits + # ------------------------------------------------------------------ # + def _sync_editor_list(self, force: bool = False) -> None: + clips = self._get_clips() + if not force and len(clips) == self._known_clip_count: + return + self._known_clip_count = len(clips) + + preserved_row = self.editor_list.currentRow() + self.editor_list.blockSignals(True) + self.editor_list.clear() + for index, clip in enumerate(clips): + self.editor_list.addItem(f"{index + 1}. {clip.title or 'Sans titre'}") + self.editor_list.blockSignals(False) + + if clips: + target_row = preserved_row if 0 <= preserved_row < len(clips) else 0 + self.editor_list.setCurrentRow(target_row) + self._load_clip_into_editor(target_row) + else: + self._load_clip_into_editor(-1) + + def _load_clip_into_editor(self, row: int) -> None: + clips = self._get_clips() + self._editing_clip_index = row if 0 <= row < len(clips) else -1 + + if self._editing_clip_index < 0: + self.editor_title_edit.blockSignals(True) + self.editor_text_edit.blockSignals(True) + self.editor_title_edit.clear() + self.editor_text_edit.clear() + self.editor_title_edit.blockSignals(False) + self.editor_text_edit.blockSignals(False) + self.editor_title_edit.setEnabled(False) + self.editor_text_edit.setEnabled(False) + return + + clip = clips[self._editing_clip_index] + self.editor_title_edit.setEnabled(True) + self.editor_text_edit.setEnabled(True) + + # On n'écrase le champ que si son contenu diffère réellement, pour + # ne pas faire sauter le curseur pendant que l'utilisateur tape + # (le rechargement périodique via refresh_preview passe par ici). + if self.editor_title_edit.text() != clip.title: + self.editor_title_edit.blockSignals(True) + self.editor_title_edit.setText(clip.title) + self.editor_title_edit.blockSignals(False) + if self.editor_text_edit.toPlainText() != clip.text: + self.editor_text_edit.blockSignals(True) + self.editor_text_edit.setPlainText(clip.text) + self.editor_text_edit.blockSignals(False) + + def _on_editor_title_changed(self, text: str) -> None: + clips = self._get_clips() + if not (0 <= self._editing_clip_index < len(clips)): + return + clips[self._editing_clip_index].title = text + item = self.editor_list.item(self._editing_clip_index) + if item is not None: + item.setText(f"{self._editing_clip_index + 1}. {text or 'Sans titre'}") + if self._on_clips_changed: + self._on_clips_changed() + self._schedule_refresh() + + def _on_editor_text_changed(self) -> None: + clips = self._get_clips() + if not (0 <= self._editing_clip_index < len(clips)): + return + clips[self._editing_clip_index].text = self.editor_text_edit.toPlainText() + if self._on_clips_changed: + self._on_clips_changed() + self._schedule_refresh() + + # ------------------------------------------------------------------ # + # Aperçu + # ------------------------------------------------------------------ # + def refresh_preview(self) -> None: + self._sync_editor_list() + + clips = self._get_clips() + if not clips: + self.preview_stack.setCurrentWidget(self.placeholder_label) + return + + try: + build_pdf(clips, self._preview_path, self.style) + except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur + self.placeholder_label.setText(f"Erreur lors de la génération de l'aperçu :\n{exc}") + self.preview_stack.setCurrentWidget(self.placeholder_label) + return + + self.pdf_document.load(self._preview_path) + self.preview_stack.setCurrentWidget(self.pdf_view) + + # ------------------------------------------------------------------ # + # Export + # ------------------------------------------------------------------ # + def _export_pdf(self) -> None: + clips = self._get_clips() + if not clips: + QMessageBox.warning(self, "Export PDF", "Aucun extrait à exporter.") + return + + default_name = f"extraits_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf" + path, _ = QFileDialog.getSaveFileName( + self, "Exporter en PDF", default_name, "Fichiers PDF (*.pdf)" + ) + if not path: + return + if not path.lower().endswith(".pdf"): + path += ".pdf" + + try: + build_pdf(clips, path, self.style) + except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur + QMessageBox.critical(self, "Export PDF", f"Échec de l'export :\n{exc}") + return + + QMessageBox.information(self, "Export PDF", f"PDF généré avec succès :\n{path}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..72f3b9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6 +reportlab>=4.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..96566d3 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +"""Lance l'application PdfScrapper : `python run.py`.""" + +from pdfscrapper.main import main + +if __name__ == "__main__": + main()