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()