First commit
This commit is contained in:
1 parent
b1a18f78ce
commit
068f84bf1d
13 files changed
+1702
No files matched your search
@@ -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`.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""PdfScrapper — navigateur visuel pour capturer du texte web vers un PDF."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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});"
|
||||
@@ -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;
|
||||
});
|
||||
"""
|
||||
)
|
||||
@@ -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();"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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() + "…"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,2 @@
|
||||
PySide6>=6.6
|
||||
reportlab>=4.0
|
||||
Reference in new issue
Block a user