First commit

This commit is contained in:
ange committed 2026-08-11 15:41:55 +02:00
1 parent b1a18f78ce
commit 068f84bf1d
13 files changed
+1702

No files matched your search

+109
View File
@@ -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 `<link rel="next">`) 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`.
+3
View File
@@ -0,0 +1,3 @@
"""PdfScrapper — navigateur visuel pour capturer du texte web vers un PDF."""
__version__ = "0.1.0"
+151
View File
@@ -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 <p> si on survole un paragraphe, un
<article>/<div> 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
# <body> 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});"
+56
View File
@@ -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;
});
"""
)
+144
View File
@@ -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 <a>, qui ne contient donc
lui-même que le titre du chapitre. Un scan qui ne regarde que le texte du
<a> (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();"
+24
View File
@@ -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()
+518
View File
@@ -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
+23
View File
@@ -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() + "…"
+152
View File
@@ -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'<a href="#{anchor}" color="{style.heading_color}"><u>'
f"{escape(clip.title or 'Sans titre')}</u></a>"
)
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 <a href="#nom"> vers <a name="nom"/> n'importe où dans le
# document, y compris sur une page ultérieure).
anchor_tag = f'<a name="pdfscrapper_clip_{index}"/>' 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()
+54
View File
@@ -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
+460
View File
@@ -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}")
+2
View File
@@ -0,0 +1,2 @@
PySide6>=6.6
reportlab>=4.0
+6
View File
@@ -0,0 +1,6 @@
"""Lance l'application PdfScrapper : `python run.py`."""
from pdfscrapper.main import main
if __name__ == "__main__":
main()