Files

493 lines
20 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, group_into_chapters
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
# (nombre d'extraits, état des liens) : signature utilisée pour
# éviter de reconstruire toute la liste de correction (et donc de
# perdre le curseur/la sélection) à chaque frappe, tout en
# détectant aussi un changement de liens fait depuis le panneau
# latéral (ajout/suppression seul ne suffirait pas à le détecter).
self._known_list_signature: tuple | None = None
# Indices des extraits qui sont la "suite" d'un chapitre (pas le
# premier de leur groupe, voir models.group_into_chapters) —
# recalculé à chaque _sync_editor_list, réutilisé pour préfixer
# leur libellé dans la liste (cohérent avec le panneau latéral).
self._continuation_indices: set[int] = set()
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()
# ------------------------------------------------------------------ #
# Synchronisation après un changement externe (ex. chargement d'un
# projet — voir MainWindow._apply_project_state) : self.style est
# muté en place, mais les contrôles affichés ici ne le savent pas
# tant qu'on ne les recharge pas explicitement.
# ------------------------------------------------------------------ #
def sync_from_external_change(self) -> None:
self._load_style_into_controls()
self._sync_editor_list(force=True)
self.refresh_preview()
# ------------------------------------------------------------------ #
# 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()
signature = (len(clips), tuple(clip.linked_to_next for clip in clips))
if not force and signature == self._known_list_signature:
return
self._known_list_signature = signature
self._continuation_indices = {
index for chapter in group_into_chapters(clips) for index in chapter[1:]
}
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(self._editor_label(index, clip.title))
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 _editor_label(self, index: int, title: str) -> str:
# "↳" : cet extrait s'enchaîne dans le chapitre du précédent (pas
# de titre propre à l'export) — voir models.group_into_chapters.
prefix = "↳ " if index in self._continuation_indices else ""
return f"{prefix}{index + 1}. {title or 'Sans titre'}"
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(self._editor_label(self._editing_clip_index, text))
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}")