175 lines
6.6 KiB
Python
175 lines
6.6 KiB
Python
"""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, group_into_chapters
|
|
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).
|
|
|
|
Les extraits reliés entre eux (`Clip.linked_to_next`, voir
|
|
models.group_into_chapters) forment un seul chapitre : un seul titre —
|
|
celui du premier extrait du groupe, personnalisable, voir
|
|
MainWindow._rename_selected_clip — et une seule entrée de sommaire,
|
|
même si le chapitre est réparti sur plusieurs extraits (pages)
|
|
capturés séparément. Chaque chapitre démarre — selon
|
|
`style.page_break_between_clips` — sur une nouvelle page ou juste
|
|
après un espacement ; à l'intérieur d'un chapitre, les extraits
|
|
s'enchaînent sans nouveau titre ni saut de page. Si `style.include_toc`
|
|
est actif et qu'il y a plus d'un chapitre, 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)]
|
|
|
|
# Les extraits reliés (Clip.linked_to_next) forment un seul chapitre :
|
|
# un chapitre = une liste d'indices dans clips, le premier portant le
|
|
# titre/l'ancre/l'entrée de sommaire du chapitre entier.
|
|
chapters = group_into_chapters(clips)
|
|
|
|
show_toc = style.include_toc and len(chapters) > 1
|
|
if show_toc:
|
|
story.append(Paragraph("Sommaire", toc_heading_style))
|
|
for chapter in chapters:
|
|
head_clip = clips[chapter[0]]
|
|
anchor = f"pdfscrapper_clip_{chapter[0]}"
|
|
entry_text = (
|
|
f'<a href="#{anchor}" color="{style.heading_color}"><u>'
|
|
f"{escape(head_clip.title or 'Sans titre')}</u></a>"
|
|
)
|
|
story.append(Paragraph(entry_text, toc_entry_style))
|
|
story.append(PageBreak())
|
|
|
|
for chapter_position, chapter in enumerate(chapters):
|
|
if chapter_position > 0:
|
|
if style.page_break_between_clips:
|
|
story.append(PageBreak())
|
|
else:
|
|
story.append(Spacer(1, 0.6 * cm))
|
|
|
|
for part_position, index in enumerate(chapter):
|
|
clip = clips[index]
|
|
if part_position > 0:
|
|
# Extraits d'un même chapitre : simple espacement léger,
|
|
# jamais de saut de page ni de nouveau titre entre eux.
|
|
story.append(Spacer(1, 0.3 * cm))
|
|
|
|
if part_position == 0:
|
|
# 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:
|
|
part_prefix = f"↳ Partie {part_position + 1} — " if part_position > 0 else ""
|
|
meta_text = (
|
|
f"{part_prefix}{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()
|