feat(reports): PDF build infrastructure — premium WeasyPrint template

- report.css: full-bleed navy cover, Padelnomics logo watermark at 3.5%
  opacity (position:fixed, repeats every page), gold/teal accents, Georgia
  headings, running headers via CSS named strings, metric boxes, insight-box
- report.html: Jinja2 template with cover stats, TOC, body, disclaimer
- build_report_pdf.py: builds EN+DE PDFs from data/content/reports/*.md
  (WeasyPrint, mistune, PyYAML; reads logo as file:// URI for watermark)
- Makefile: report-pdf target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-27 07:49:40 +01:00
parent 336ca67fdc
commit b50ca5a8cd
4 changed files with 833 additions and 1 deletions

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Build market intelligence report PDFs from data/content/reports/*.md.
Reads YAML frontmatter + Markdown body from each .md file, renders the
HTML template (web/src/padelnomics/templates/reports/report.html) with
the report content, and generates a PDF via WeasyPrint.
Output: data/content/reports/_build/<slug>-<lang>.pdf
Usage:
uv run python web/scripts/build_report_pdf.py [--slug q1-2026] [--lang en]
The --slug and --lang flags filter which reports to build. With no flags,
all .md files in data/content/reports/ are built.
"""
import argparse
import re
import sys
from pathlib import Path
import mistune
import yaml
from jinja2 import Template
from weasyprint import HTML
REPO_ROOT = Path(__file__).parent.parent.parent
REPORTS_DIR = REPO_ROOT / "data" / "content" / "reports"
BUILD_DIR = REPORTS_DIR / "_build"
TEMPLATE_DIR = REPO_ROOT / "web" / "src" / "padelnomics" / "templates" / "reports"
LOGO_PATH = REPO_ROOT / "web" / "src" / "padelnomics" / "static" / "images" / "logo.png"
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _parse_md(path: Path) -> tuple[dict, str]:
"""Return (frontmatter_dict, markdown_body) for a .md file."""
raw = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(raw)
assert m, f"No YAML frontmatter found in {path}"
fm = yaml.safe_load(m.group(1)) or {}
body = raw[m.end():]
return fm, body
def _make_toc(body_md: str) -> list[dict]:
"""Extract H2 headings from markdown for the TOC."""
toc = []
for line in body_md.splitlines():
if line.startswith("## "):
toc.append({"title": line[3:].strip(), "is_section": False})
return toc
def _cover_stats_en() -> list[dict]:
return [
{"value": "77,355", "label": "Courts Worldwide"},
{"value": "+29%", "label": "Growth in 18 Months"},
{"value": "80", "label": "Countries Tracked"},
{"value": "12,441", "label": "Venues in Pipeline"},
]
def _cover_stats_de() -> list[dict]:
return [
{"value": "77.355", "label": "Plätze weltweit"},
{"value": "+29 %", "label": "Wachstum in 18 Monaten"},
{"value": "80", "label": "Länder erfasst"},
{"value": "12.441", "label": "Venues im Datensatz"},
]
def _labels_en(fm: dict) -> dict:
return {
"report_slug_label": "State of Padel Q1 2026",
"edition_label": "Q1 2026 Edition",
"report_type_label": "Global Market Intelligence Report",
"subtitle": (
"77,355 courts. +29% growth. 80 countries. "
"The most complete independent picture of the global padel market."
),
"published_label": "Published Q1 2026",
"confidential_label": "For registered recipients",
"toc_heading": "Contents",
"disclaimer": (
"This report has been prepared by Padelnomics for informational purposes only. "
"All data is sourced from publicly available reports (FIP, Playtomic/PwC) and "
"Padelnomics' proprietary data pipeline. Market figures reflect the best available "
"data at time of publication and may differ from subsequently reported figures. "
"Nothing in this report constitutes investment advice. "
"© 2026 Padelnomics — padelnomics.io"
),
}
def _labels_de(fm: dict) -> dict:
return {
"report_slug_label": "State of Padel Q1 2026",
"edition_label": "Ausgabe Q1 2026",
"report_type_label": "Globaler Marktintelligenz-Bericht",
"subtitle": (
"77.355 Plätze. +29 % Wachstum. 80 Länder. "
"Das vollständigste unabhängige Bild des globalen Padel-Markts."
),
"published_label": "Veröffentlicht Q1 2026",
"confidential_label": "Für registrierte Empfänger",
"toc_heading": "Inhalt",
"disclaimer": (
"Dieser Bericht wurde von Padelnomics ausschließlich zu Informationszwecken erstellt. "
"Alle Daten stammen aus öffentlich zugänglichen Berichten (FIP, Playtomic/PwC) sowie "
"der proprietären Datenpipeline von Padelnomics. Marktdaten spiegeln den besten "
"verfügbaren Stand zum Zeitpunkt der Veröffentlichung wider. "
"Der Bericht stellt keine Anlageberatung dar. "
"© 2026 Padelnomics — padelnomics.io"
),
}
def build_one(md_path: Path, output_dir: Path) -> Path:
"""Build a single PDF from a .md report file. Returns the output path."""
fm, body_md = _parse_md(md_path)
lang = fm.get("language", "en")
slug = fm.get("slug", md_path.stem)
title = fm.get("title", slug)
body_html = mistune.html(body_md)
labels = _labels_de(fm) if lang == "de" else _labels_en(fm)
cover_stats = _cover_stats_de() if lang == "de" else _cover_stats_en()
toc = _make_toc(body_md)
template_html = (TEMPLATE_DIR / "report.html").read_text(encoding="utf-8")
css = (TEMPLATE_DIR / "report.css").read_text(encoding="utf-8")
# WeasyPrint resolves relative URLs from base_url; pass logo as file:// path
logo_file_url = LOGO_PATH.as_uri()
rendered = Template(template_html).render(
language=lang,
title=title,
css=css,
logo_path=logo_file_url,
body_html=body_html,
toc=toc,
cover_stats=cover_stats,
**labels,
)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{slug}-{lang}.pdf"
HTML(string=rendered).write_pdf(str(output_path))
print(f" ✓ Built {output_path.relative_to(REPO_ROOT)}")
return output_path
def main() -> None:
parser = argparse.ArgumentParser(description="Build market intelligence report PDFs")
parser.add_argument("--slug", help="Only build report with this slug substring")
parser.add_argument("--lang", help="Only build this language (en or de)")
args = parser.parse_args()
md_files = sorted(REPORTS_DIR.glob("*.md"))
if not md_files:
print(f"No .md files found in {REPORTS_DIR}")
sys.exit(0)
built = 0
for md_path in md_files:
if args.slug and args.slug not in md_path.stem:
continue
if args.lang and not md_path.stem.endswith(f"-{args.lang}"):
continue
try:
build_one(md_path, BUILD_DIR)
built += 1
except Exception as exc:
print(f" ✗ Failed {md_path.name}: {exc}", file=sys.stderr)
raise
print(f"\n✓ Built {built} PDF(s) → {BUILD_DIR}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,549 @@
/* ============================================================
Padelnomics Market Intelligence Report — Premium PDF Stylesheet
Rendered by WeasyPrint (A4, CSS3, no JavaScript)
============================================================ */
/* ---------- Design tokens ---------- */
:root {
--navy: #0F2651;
--navy-deep: #091a3a;
--navy-mid: #1e3a6e;
--gold: #C9922C;
--gold-lt: #EDD48A;
--teal: #0D9488;
--teal-lt: #CCFBF1;
--text: #1C2333;
--muted: #5E6E8A;
--border: #DDE3EE;
--bg-light: #F6F8FC;
--white: #FFFFFF;
--green: #166534;
--green-bg: #DCFCE7;
--red: #991B1B;
--red-bg: #FEE2E2;
}
/* ---------- Page setup ---------- */
@page {
size: A4;
margin: 22mm 20mm 22mm 20mm;
@top-left {
content: string(doc-brand);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 7pt;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@top-right {
content: string(doc-title);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 7pt;
color: var(--muted);
font-style: italic;
}
@bottom-left {
content: "© 2026 Padelnomics — padelnomics.io";
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 6.5pt;
color: var(--border);
letter-spacing: 0.03em;
}
@bottom-center {
content: counter(page);
font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif;
font-size: 8pt;
color: var(--muted);
}
}
/* Cover page: full-bleed, no running headers/footers */
@page cover {
size: A4;
margin: 0;
@top-left { content: none; }
@top-right { content: none; }
@bottom-left { content: none; }
@bottom-center { content: none; }
}
/* TOC page: suppress page number */
@page toc {
@bottom-center { content: none; }
}
/* ---------- Named strings for running headers ---------- */
.doc-brand-anchor { string-set: doc-brand content(); }
.doc-title-anchor { string-set: doc-title content(); }
/* ---------- Watermark (WeasyPrint repeats position:fixed on every content page) ---------- */
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-30deg);
opacity: 0.035;
z-index: -1;
pointer-events: none;
}
.watermark img {
width: 400pt;
height: auto;
}
/* ---------- Base typography ---------- */
body {
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
font-size: 9.5pt;
line-height: 1.58;
color: var(--text);
background: var(--white);
}
p { margin: 0 0 7pt; }
/* ---------- Cover page — full-bleed navy ---------- */
.cover {
page: cover;
page-break-after: always;
width: 210mm;
height: 297mm;
background: var(--navy);
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 22mm 20mm 18mm 22mm;
}
/* Decorative geometric accent — bottom-right corner stripe */
.cover::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 90mm;
height: 90mm;
background: linear-gradient(135deg, transparent 50%, rgba(201,146,44,0.12) 50%);
pointer-events: none;
}
.cover__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.cover__logo img {
height: 22pt;
filter: brightness(0) invert(1);
opacity: 0.92;
}
.cover__edition {
font-size: 7pt;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
background: rgba(201,146,44,0.12);
padding: 3pt 9pt;
border-radius: 3pt;
border: 1pt solid rgba(201,146,44,0.3);
}
.cover__body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20mm 0;
}
.cover__type-label {
font-size: 8pt;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold);
margin-bottom: 12pt;
}
.cover__rule {
width: 48pt;
height: 2.5pt;
background: var(--gold);
margin-bottom: 16pt;
}
.cover__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 32pt;
font-weight: 700;
color: var(--white);
line-height: 1.18;
letter-spacing: -0.02em;
margin: 0 0 12pt;
}
.cover__subtitle {
font-size: 11pt;
color: rgba(255,255,255,0.65);
margin-bottom: 20pt;
line-height: 1.5;
}
/* 4-stat row on cover */
.cover__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
border-top: 1pt solid rgba(255,255,255,0.15);
border-left: 1pt solid rgba(255,255,255,0.15);
border-radius: 4pt;
overflow: hidden;
}
.cover__stat {
padding: 10pt 12pt;
border-right: 1pt solid rgba(255,255,255,0.15);
border-bottom: 1pt solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.04);
}
.cover__stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 16pt;
font-weight: 700;
color: var(--gold);
line-height: 1.1;
}
.cover__stat-label {
font-size: 6.5pt;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255,255,255,0.5);
margin-top: 3pt;
}
.cover__footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-top: 14pt;
border-top: 1pt solid rgba(255,255,255,0.12);
}
.cover__footer-left {
font-size: 7.5pt;
color: rgba(255,255,255,0.35);
line-height: 1.6;
}
.cover__footer-right {
font-size: 7.5pt;
color: rgba(255,255,255,0.35);
text-align: right;
}
.cover__footer a {
color: var(--gold);
text-decoration: none;
opacity: 0.7;
}
/* ---------- Table of Contents ---------- */
.toc-page {
page: toc;
page-break-after: always;
}
.toc-heading {
font-family: Georgia, 'Times New Roman', serif;
font-size: 18pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 18pt;
padding-bottom: 8pt;
border-bottom: 2pt solid var(--navy);
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-item {
display: flex;
align-items: baseline;
padding: 5pt 0;
border-bottom: 0.5pt dotted var(--border);
font-size: 9pt;
}
.toc-item--section {
font-weight: 700;
color: var(--navy);
padding-top: 10pt;
border-bottom: none;
font-size: 9.5pt;
padding-bottom: 2pt;
}
.toc-num {
font-size: 8pt;
color: var(--gold);
font-weight: 700;
width: 22pt;
flex-shrink: 0;
}
.toc-text { flex: 1; color: var(--text); }
.toc-dots {
flex: 1;
border-bottom: 1pt dotted var(--border);
margin: 0 6pt 2pt;
min-width: 20pt;
}
/* ---------- Section headings ---------- */
h1 {
font-family: Georgia, 'Times New Roman', serif;
font-size: 20pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 6pt;
letter-spacing: -0.02em;
}
h2 {
font-family: Georgia, 'Times New Roman', serif;
font-size: 14pt;
font-weight: 700;
color: var(--navy);
margin: 0 0 10pt;
padding: 8pt 0 8pt 12pt;
border-left: 3.5pt solid var(--gold);
page-break-after: avoid;
break-after: avoid;
}
h3 {
font-size: 8.5pt;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.09em;
margin: 14pt 0 5pt;
page-break-after: avoid;
}
.section {
margin-top: 22pt;
page-break-inside: avoid;
}
.section--break {
page-break-before: always;
break-before: page;
margin-top: 0;
padding-top: 6pt;
}
/* ---------- Key metrics grid ---------- */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7pt;
margin: 8pt 0 12pt;
}
.metric-box {
border: 1pt solid var(--border);
border-top: 3pt solid var(--navy);
border-radius: 0 0 4pt 4pt;
padding: 8pt 9pt 7pt;
text-align: center;
background: var(--white);
page-break-inside: avoid;
}
.metric-box--gold { border-top-color: var(--gold); }
.metric-box--teal { border-top-color: var(--teal); }
.metric-label {
font-size: 6.5pt;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin-bottom: 4pt;
}
.metric-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 16pt;
font-weight: 700;
color: var(--navy);
line-height: 1.1;
}
.metric-value--gold { color: var(--gold); }
.metric-value--teal { color: var(--teal); }
.metric-sub {
font-size: 7pt;
color: var(--muted);
margin-top: 2pt;
}
/* ---------- Tables ---------- */
table {
width: 100%;
border-collapse: collapse;
margin: 6pt 0 12pt;
font-size: 8.5pt;
page-break-inside: auto;
}
thead th {
background: var(--navy);
color: var(--white);
font-size: 7pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5.5pt 8pt;
text-align: left;
}
tbody tr:nth-child(even) { background: var(--bg-light); }
tbody tr:nth-child(odd) { background: var(--white); }
tbody td {
padding: 5pt 8pt;
border-bottom: 0.5pt solid var(--border);
color: var(--text);
vertical-align: top;
}
tbody tr:last-child td { border-bottom: none; }
.total-row td {
font-weight: 700;
color: var(--navy);
background: #E8EDF5 !important;
border-top: 1.5pt solid var(--navy);
border-bottom: none;
}
.highlight-row td {
background: #FEF9EE !important;
font-weight: 700;
color: var(--navy);
border-top: 1pt solid var(--gold);
border-bottom: 1pt solid var(--gold);
}
.positive-cell { color: var(--green); font-weight: 600; }
.negative-cell { color: var(--red); font-weight: 600; }
td.r, th.r { text-align: right; }
td.c, th.c { text-align: center; }
/* ---------- Callout / pull quote ---------- */
blockquote {
border-left: 4pt solid var(--teal);
background: var(--teal-lt);
padding: 9pt 12pt;
margin: 8pt 0 12pt;
font-style: italic;
font-size: 9.5pt;
color: var(--text);
page-break-inside: avoid;
}
blockquote p { margin: 0; }
/* ---------- Insight box (key takeaway) ---------- */
.insight-box {
border: 1pt solid var(--gold);
border-left: 4pt solid var(--gold);
background: #FEF9EE;
padding: 9pt 12pt;
margin: 8pt 0 12pt;
font-size: 9pt;
page-break-inside: avoid;
}
.insight-box strong {
display: block;
color: var(--navy);
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3pt;
}
/* ---------- Market callout cards ---------- */
.market-callout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8pt;
margin: 8pt 0 12pt;
}
.market-stat {
background: var(--bg-light);
border: 1pt solid var(--border);
border-radius: 4pt;
padding: 8pt 10pt;
text-align: center;
page-break-inside: avoid;
}
.market-stat-value {
font-family: Georgia, 'Times New Roman', serif;
font-size: 14pt;
font-weight: 700;
color: var(--navy);
}
.market-stat-label {
font-size: 7pt;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2pt;
}
/* ---------- Data note / footnote ---------- */
.data-note {
font-size: 7.5pt;
color: var(--muted);
font-style: italic;
margin: -8pt 0 10pt;
padding-left: 6pt;
border-left: 2pt solid var(--border);
}
/* ---------- Two-column layout helper ---------- */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12pt;
}
/* ---------- Disclaimer / legal ---------- */
.disclaimer {
font-size: 7pt;
color: var(--muted);
line-height: 1.55;
padding-top: 10pt;
border-top: 0.5pt solid var(--border);
margin-top: 20pt;
}

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>{{ css }}</style>
</head>
<body>
{# ── Named strings for running headers (invisible anchors) ── #}
<span class="doc-brand-anchor">Padelnomics</span>
<span class="doc-title-anchor">{{ report_slug_label }}</span>
{# ── Watermark: Padelnomics logo, repeated on every content page via position:fixed ── #}
<div class="watermark">
<img src="{{ logo_path }}" alt="">
</div>
{# ══════════════════════════════════════════════════════════════════
COVER PAGE — full-bleed navy, no running headers
════════════════════════════════════════════════════════════════ #}
<div class="cover">
<div class="cover__header">
<div class="cover__logo">
<img src="{{ logo_path }}" alt="Padelnomics">
</div>
<div class="cover__edition">{{ edition_label }}</div>
</div>
<div class="cover__body">
<div class="cover__type-label">{{ report_type_label }}</div>
<div class="cover__rule"></div>
<h1 class="cover__title">{{ title }}</h1>
<p class="cover__subtitle">{{ subtitle }}</p>
<div class="cover__stats">
{% for stat in cover_stats %}
<div class="cover__stat">
<div class="cover__stat-value">{{ stat.value }}</div>
<div class="cover__stat-label">{{ stat.label }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="cover__footer">
<div class="cover__footer-left">
{{ published_label }}<br>
<a href="https://padelnomics.io">padelnomics.io</a>
</div>
<div class="cover__footer-right">
{{ confidential_label }}
</div>
</div>
</div>
{# ══════════════════════════════════════════════════════════════════
TABLE OF CONTENTS
════════════════════════════════════════════════════════════════ #}
{% if toc %}
<div class="toc-page">
<h2 class="toc-heading">{{ toc_heading }}</h2>
<ol class="toc-list">
{% for item in toc %}
<li class="toc-item{% if item.is_section %} toc-item--section{% endif %}">
{% if not item.is_section %}<span class="toc-num">{{ loop.index }}.</span>{% endif %}
<span class="toc-text">{{ item.title }}</span>
{% if not item.is_section %}
<span class="toc-dots"></span>
{% endif %}
</li>
{% endfor %}
</ol>
</div>
{% endif %}
{# ══════════════════════════════════════════════════════════════════
REPORT BODY (markdown → HTML, injected by build script)
════════════════════════════════════════════════════════════════ #}
<div class="report-body">
{{ body_html }}
</div>
{# ══════════════════════════════════════════════════════════════════
DISCLAIMER
════════════════════════════════════════════════════════════════ #}
<div class="disclaimer">
{{ disclaimer }}
</div>
</body>
</html>