diff --git a/Makefile b/Makefile index 2e11d36..443e771 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ TAILWIND_VERSION := v4.1.18 TAILWIND := ./bin/tailwindcss SOPS_DOTENV := sops --input-type dotenv --output-type dotenv -.PHONY: help dev init-landing-seeds css-build css-watch \ +.PHONY: help dev init-landing-seeds css-build css-watch report-pdf \ secrets-decrypt-dev secrets-decrypt-prod \ secrets-edit-dev secrets-edit-prod \ secrets-encrypt-dev secrets-encrypt-prod @@ -13,6 +13,7 @@ help: @echo " init-landing-seeds Create seed landing files for SQLMesh (run once after clone)" @echo " css-build Build + minify Tailwind CSS" @echo " css-watch Watch + rebuild Tailwind CSS" + @echo " report-pdf Build market intelligence report PDFs (WeasyPrint)" @echo " secrets-decrypt-dev Decrypt .env.dev.sops → .env" @echo " secrets-decrypt-prod Decrypt .env.prod.sops → .env" @echo " secrets-edit-dev Edit .env.dev.sops in \$$EDITOR" @@ -45,6 +46,9 @@ css-build: bin/tailwindcss css-watch: bin/tailwindcss $(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch +report-pdf: + uv run python web/scripts/build_report_pdf.py + # ── Secrets (SOPS + age) ───────────────────────────────────────────────────── # Requires: sops (https://github.com/getsops/sops) + age (https://github.com/FiloSottile/age) # Keys config: .sops.yaml diff --git a/web/scripts/build_report_pdf.py b/web/scripts/build_report_pdf.py new file mode 100644 index 0000000..2015480 --- /dev/null +++ b/web/scripts/build_report_pdf.py @@ -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/-.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() diff --git a/web/src/padelnomics/templates/reports/report.css b/web/src/padelnomics/templates/reports/report.css new file mode 100644 index 0000000..98354e5 --- /dev/null +++ b/web/src/padelnomics/templates/reports/report.css @@ -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; +} diff --git a/web/src/padelnomics/templates/reports/report.html b/web/src/padelnomics/templates/reports/report.html new file mode 100644 index 0000000..d886c20 --- /dev/null +++ b/web/src/padelnomics/templates/reports/report.html @@ -0,0 +1,94 @@ + + + + + {{ title }} + + + + +{# ── Named strings for running headers (invisible anchors) ── #} +Padelnomics +{{ report_slug_label }} + +{# ── Watermark: Padelnomics logo, repeated on every content page via position:fixed ── #} +
+ +
+ +{# ══════════════════════════════════════════════════════════════════ + COVER PAGE — full-bleed navy, no running headers + ════════════════════════════════════════════════════════════════ #} +
+ +
+ +
{{ edition_label }}
+
+ +
+
{{ report_type_label }}
+
+

{{ title }}

+

{{ subtitle }}

+ +
+ {% for stat in cover_stats %} +
+
{{ stat.value }}
+
{{ stat.label }}
+
+ {% endfor %} +
+
+ + + +
+ +{# ══════════════════════════════════════════════════════════════════ + TABLE OF CONTENTS + ════════════════════════════════════════════════════════════════ #} +{% if toc %} +
+

{{ toc_heading }}

+
    + {% for item in toc %} +
  1. + {% if not item.is_section %}{{ loop.index }}.{% endif %} + {{ item.title }} + {% if not item.is_section %} + + {% endif %} +
  2. + {% endfor %} +
+
+{% endif %} + +{# ══════════════════════════════════════════════════════════════════ + REPORT BODY (markdown → HTML, injected by build script) + ════════════════════════════════════════════════════════════════ #} +
+ {{ body_html }} +
+ +{# ══════════════════════════════════════════════════════════════════ + DISCLAIMER + ════════════════════════════════════════════════════════════════ #} +
+ {{ disclaimer }} +
+ + +