Files

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()