From b0e1dde22f4e2a667dc1c257c4630931cfa6871c Mon Sep 17 00:00:00 2001
From: Peter Adam
Date: Tue, 3 Feb 2026 14:47:56 +0100
Subject: [PATCH] Add CSV-based personalized card generation
Add template-based system for generating personalized brevet cards
from CSV data. Uses proper separation of concerns with template file
and Python script.
- Add brevetkarte-template.tex with placeholders
- Add generate_cards.py to read CSV and populate template
- Update Makefile with generate-personalized and build-personalized targets
- Update .gitignore to exclude generated files
Co-Authored-By: Claude Sonnet 4.5
---
.gitignore | 3 +
Makefile | 46 ++++++++++----
brevetkarte-template.tex | 126 +++++++++++++++++++++++++++++++++++++++
generate_cards.py | 112 ++++++++++++++++++++++++++++++++++
4 files changed, 276 insertions(+), 11 deletions(-)
create mode 100644 brevetkarte-template.tex
create mode 100755 generate_cards.py
diff --git a/.gitignore b/.gitignore
index 96bfe5b..3be11ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@
# Rendered PDFs
*.pdf
+# Generated files
+brevetkarte-personalized.tex
+
# macOS
.DS_Store
diff --git a/Makefile b/Makefile
index 8220941..ab7ec74 100644
--- a/Makefile
+++ b/Makefile
@@ -4,10 +4,13 @@ IMAGE_NAME := brevetcard-builder
CONTAINER_NAME := brevetcard-build
TEX_FILE_FRONT := brevetkarte.tex
TEX_FILE_BACK := brevetkarte-rueckseite.tex
+TEX_FILE_PERSONALIZED := brevetkarte-personalized.tex
PDF_FILE_FRONT := brevetkarte.pdf
PDF_FILE_BACK := brevetkarte-rueckseite.pdf
+PDF_FILE_PERSONALIZED := brevetkarte-personalized.pdf
+CSV_FILE := Export Brevetkarte.csv
-.PHONY: all build clean build-image build-pdf build-front build-back run shell help
+.PHONY: all build clean build-image build-pdf build-front build-back generate-personalized build-personalized run shell help
# Default target
all: build
@@ -41,6 +44,25 @@ build-back:
pdflatex -interaction=nonstopmode $(TEX_FILE_BACK)
@echo "PDF generated: $(PDF_FILE_BACK)"
+# Generate personalized cards from CSV
+generate-personalized:
+ @echo "Generating personalized cards from $(CSV_FILE)..."
+ @if [ ! -f "$(CSV_FILE)" ]; then \
+ echo "Error: $(CSV_FILE) not found!"; \
+ exit 1; \
+ fi
+ python3 generate_cards.py
+ @echo "Generated $(TEX_FILE_PERSONALIZED)"
+
+# Build personalized cards PDF
+build-personalized: generate-personalized build-image
+ @echo "Compiling personalized cards to PDF..."
+ docker run --rm \
+ -v $(PWD):/workspace \
+ $(IMAGE_NAME) \
+ pdflatex -interaction=nonstopmode $(TEX_FILE_PERSONALIZED)
+ @echo "PDF generated: $(PDF_FILE_PERSONALIZED)"
+
# Run container interactively
run:
docker run --rm -it \
@@ -73,13 +95,15 @@ help:
@echo "Brevet Card PDF Builder"
@echo ""
@echo "Available targets:"
- @echo " make build - Build Docker image and compile both PDFs (default)"
- @echo " make build-image - Build Docker image only"
- @echo " make build-pdf - Compile both front and back PDFs"
- @echo " make build-front - Compile front side PDF only"
- @echo " make build-back - Compile back side PDF only"
- @echo " make shell - Open interactive shell in container"
- @echo " make clean - Remove generated files (aux, log, pdf)"
- @echo " make clean-all - Remove all files and Docker image"
- @echo " make rebuild - Clean everything and rebuild"
- @echo " make help - Show this help message"
+ @echo " make build - Build Docker image and compile both PDFs (default)"
+ @echo " make build-image - Build Docker image only"
+ @echo " make build-pdf - Compile both front and back PDFs"
+ @echo " make build-front - Compile front side PDF only"
+ @echo " make build-back - Compile back side PDF only"
+ @echo " make generate-personalized - Generate personalized cards from CSV"
+ @echo " make build-personalized - Generate and compile personalized cards"
+ @echo " make shell - Open interactive shell in container"
+ @echo " make clean - Remove generated files (aux, log, pdf)"
+ @echo " make clean-all - Remove all files and Docker image"
+ @echo " make rebuild - Clean everything and rebuild"
+ @echo " make help - Show this help message"
diff --git a/brevetkarte-template.tex b/brevetkarte-template.tex
new file mode 100644
index 0000000..5417697
--- /dev/null
+++ b/brevetkarte-template.tex
@@ -0,0 +1,126 @@
+\documentclass[a4paper,10pt,landscape]{article}
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage[landscape,top=0.8cm,bottom=0.8cm,left=0.8cm,right=0.8cm]{geometry}
+\usepackage{graphicx}
+\usepackage{xcolor}
+\usepackage{tikz}
+\usepackage{helvet}
+\usepackage{hyperref}
+\usepackage{enumitem}
+
+% Set sans-serif font as default
+\renewcommand{\familydefault}{\sfdefault}
+
+% Define colors
+\definecolor{headerblack}{RGB}{0,0,0}
+
+% Configure hyperlinks to be black
+\hypersetup{
+ colorlinks=true,
+ linkcolor=black,
+ urlcolor=black,
+ citecolor=black
+}
+
+\setlength{\parindent}{0pt}
+\pagestyle{empty}
+
+\begin{document}
+
+% Brevet card for {{NAME}} (Start #{{STARTNR}})
+\noindent
+\begin{tikzpicture}[x=1cm,y=1cm]
+
+% Black vertical separator lines (drawn first, extend through headers)
+\draw[black,line width=0.5pt] (6.9,-7.2) -- (6.9,1.3);
+\draw[black,line width=0.5pt] (13.8,-7.2) -- (13.8,1.3);
+\draw[black,line width=0.5pt] (20.7,-7.2) -- (20.7,1.3);
+
+% Black header boxes (drawn on top)
+\fill[headerblack] (0,0) rectangle (6.9,1.3);
+\node[white,align=center,font=\tiny,text width=6.6cm] at (3.45,0.65) {
+ Jeder Teilnehmer muss diese Brevetkarte zu jeder Zeit\\
+ mit sich führen und an den Kontrollen abstempeln lassen\\
+ bzw. Fotos erstellen.\\
+ \textbf{Ohne Kontrollzeiten und Zielzeit keine Wertung!}
+};
+
+\fill[headerblack] (6.9,0) rectangle (13.8,1.3);
+\node[white,font=\Large] at (10.35,0.65) {HOMOLOGATION};
+
+\fill[headerblack] (13.8,0) rectangle (20.7,1.3);
+\node[white,font=\Large] at (17.25,0.65) {TEILNEHMER/-IN};
+
+\fill[headerblack] (20.7,0) rectangle (27.6,1.3);
+\node[white,align=center,font=\normalsize] at (24.15,0.75) {BREVET DES RANDONNEURS};
+\node[white,font=\Large] at (24.15,0.35) {MONDIAUX};
+
+% Column 1 - Rules (left section)
+\node[anchor=north west,text width=6.6cm,font=\small,align=left] at (0.2,-0.3) {
+ \textbf{Es gelten die Regeln von}\\
+ \textbf{Randonneur Mondiaux}\\
+ \textbf{insbesondere:}
+ \begin{itemize}[leftmargin=0.4cm,itemsep=1pt,topsep=2pt,parsep=0pt]
+ \item Einhaltung der StVO
+ \item Beleuchtung und Sicherheitsweste/-Gurt
+ \item keine Abkürzungen
+ \item keine Begleitfahrzeuge
+ \item Rücksicht auf Teilnehmer und Umwelt
+ \item Rücksicht in den Kontrollen
+ \end{itemize}
+ \hspace{0.3cm}$\Rightarrow$ \textbf{\underline{Bei Verstoß keine Wertung!}}\\[0.3cm]
+ -
+};
+
+\node[anchor=south west,text width=6.6cm,font=\small,align=left] at (0.2,-7.0) {
+ \textbf{AUDAX RANDONNEURS ALLEMAGNE E.V.}\\
+ \href{http://www.audax-randonneure.de}{www.audax-randonneure.de}\\
+ - gegründet 1992 in Hamburg -
+};
+
+% Column 2 - Homologation (middle-left section)
+\node[anchor=north,text width=6.6cm,font=\small,align=center] at (10.35,-0.5) {
+ Der Randonnée wurde beendet in:\\[0.6cm]
+ \makebox[2cm]{\dotfill}h\makebox[2cm]{\dotfill}min
+};
+
+\node[anchor=center,font=\Large] at (10.35,-4.0) {
+ HOMOLOGATION
+};
+
+\node[anchor=south,text width=6.6cm,font=\small,align=center] at (10.35,-6.8) {
+ Brevet N° \makebox[5cm]{\dotfill}
+};
+
+% Column 3 - Participant Info (middle-right section)
+\node[anchor=north west,text width=6.6cm,font=\small,align=left] at (14.0,-0.5) {
+ Name: {{NAME}}\\[0.4cm]
+ Straße: {{STREET}}\\[0.4cm]
+ PLZ/Ort: {{PLZ_ORT}}\\[0.4cm]
+ Land: {{LAND}}\\[0.4cm]
+ Medaille: {{MEDAILLE}}\\[0.6cm]
+ Startzeit: 8:30
+};
+
+% Column 4 - Event Info (right section)
+\node[anchor=north,text width=6.6cm,align=center] at (24.15,-0.4) {
+ \includegraphics[width=5.5cm]{cyclist-logo.png}
+};
+
+\node[anchor=north,text width=6.6cm,font=\small,align=center] at (24.15,-2.2) {
+ \textbf{Auf eine Pommes nach Belgien}\\
+ Randonnée über \textbf{200} km\\
+ am \textbf{20. September 2025}\\
+ mit Start in \textbf{Bonn, Uni-Sportgelände}\\
+ von \textbf{ARA Rheinland}\\
+ N° ACP du Club \textbf{111011}
+};
+
+\node[anchor=south,text width=6.6cm,font=\scriptsize,align=center] at (24.15,-7.0) {
+ CONTRÔLÉE ET HOMOLOGUÉE EXCLUSIVEMENT PAR\\
+ \href{http://www.audax-club-parisien.com}{www.audax-club-parisien.com}\\
+ - Société fondée en 1904 -
+};
+
+\end{tikzpicture}
diff --git a/generate_cards.py b/generate_cards.py
new file mode 100755
index 0000000..933bba1
--- /dev/null
+++ b/generate_cards.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""
+Generate personalized brevet cards from CSV data.
+"""
+import csv
+import sys
+from pathlib import Path
+
+def escape_latex(text):
+ """Escape special LaTeX characters."""
+ if not text:
+ return ""
+ replacements = {
+ '&': r'\&',
+ '%': r'\%',
+ '$': r'\$',
+ '#': r'\#',
+ '_': r'\_',
+ '{': r'\{',
+ '}': r'\}',
+ '~': r'\textasciitilde{}',
+ '^': r'\textasciicircum{}',
+ '\\': r'\textbackslash{}',
+ }
+ result = str(text)
+ for char, replacement in replacements.items():
+ result = result.replace(char, replacement)
+ return result
+
+def generate_card_from_template(template, data):
+ """Replace placeholders in template with participant data."""
+ name = f"{escape_latex(data['Vorname'])} {escape_latex(data['Nachname'])}"
+ street = escape_latex(data['Straße'])
+ plz_ort = f"{escape_latex(data['PLZ'])} {escape_latex(data['Ort'])}"
+ land = escape_latex(data['Land'])
+ medaille = "Ja" if data['Medaille'].lower() == 'ja' else "Nein"
+ startnr = escape_latex(data['Startnr'])
+
+ # Replace placeholders
+ card = template.replace('{{NAME}}', name)
+ card = card.replace('{{STREET}}', street)
+ card = card.replace('{{PLZ_ORT}}', plz_ort)
+ card = card.replace('{{LAND}}', land)
+ card = card.replace('{{MEDAILLE}}', medaille)
+ card = card.replace('{{STARTNR}}', startnr)
+
+ return card
+
+def main():
+ csv_file = Path("Export Brevetkarte.csv")
+ template_file = Path("brevetkarte-template.tex")
+ output_file = Path("brevetkarte-personalized.tex")
+
+ if not csv_file.exists():
+ print(f"Error: {csv_file} not found!", file=sys.stderr)
+ sys.exit(1)
+
+ if not template_file.exists():
+ print(f"Error: {template_file} not found!", file=sys.stderr)
+ sys.exit(1)
+
+ # Read template
+ print(f"Reading template from {template_file}...")
+ template = template_file.read_text(encoding='utf-8')
+
+ # Read CSV data
+ print(f"Reading participant data from {csv_file}...")
+ participants = []
+ with open(csv_file, 'r', encoding='utf-8-sig') as f: # utf-8-sig handles BOM
+ reader = csv.DictReader(f)
+ for row in reader:
+ if row['Startnr']: # Skip empty rows
+ participants.append(row)
+
+ if not participants:
+ print("No participants found in CSV file!", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Found {len(participants)} participant(s)")
+
+ # Generate document with all cards
+ cards = []
+ for i, participant in enumerate(participants):
+ card = generate_card_from_template(template, participant)
+ cards.append(card)
+
+ # Combine cards with spacing/page breaks
+ document_parts = []
+ for i, card in enumerate(cards):
+ document_parts.append(card)
+
+ # Add vertical space between cards on same page
+ if i % 2 == 0 and i < len(cards) - 1:
+ document_parts.append("\n\\vspace{0.8cm}\n\n")
+
+ # Add page break after every 2 cards (except at the end)
+ if i % 2 == 1 and i < len(cards) - 1:
+ document_parts.append("\n\\newpage\n\n")
+
+ # Close document
+ document_parts.append("\n\\end{document}\n")
+
+ # Write output
+ document = ''.join(document_parts)
+ output_file.write_text(document, encoding='utf-8')
+
+ print(f"Generated {output_file}")
+ print(f"Total pages: {(len(participants) + 1) // 2}")
+ print(f"Compile with: make build-personalized")
+
+if __name__ == "__main__":
+ main()