Files
PdfScrapper/pdfscrapper/preview_window.py
T
2026-08-11 15:41:55 +02:00

461 lines
18 KiB
Python

"""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}")