461 lines
18 KiB
Python
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}")
|