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 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@
|
|||||||
# Rendered PDFs
|
# Rendered PDFs
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
brevetkarte-personalized.tex
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -4,10 +4,13 @@ IMAGE_NAME := brevetcard-builder
|
|||||||
CONTAINER_NAME := brevetcard-build
|
CONTAINER_NAME := brevetcard-build
|
||||||
TEX_FILE_FRONT := brevetkarte.tex
|
TEX_FILE_FRONT := brevetkarte.tex
|
||||||
TEX_FILE_BACK := brevetkarte-rueckseite.tex
|
TEX_FILE_BACK := brevetkarte-rueckseite.tex
|
||||||
|
TEX_FILE_PERSONALIZED := brevetkarte-personalized.tex
|
||||||
PDF_FILE_FRONT := brevetkarte.pdf
|
PDF_FILE_FRONT := brevetkarte.pdf
|
||||||
PDF_FILE_BACK := brevetkarte-rueckseite.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
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
@@ -41,6 +44,25 @@ build-back:
|
|||||||
pdflatex -interaction=nonstopmode $(TEX_FILE_BACK)
|
pdflatex -interaction=nonstopmode $(TEX_FILE_BACK)
|
||||||
@echo "PDF generated: $(PDF_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 container interactively
|
||||||
run:
|
run:
|
||||||
docker run --rm -it \
|
docker run --rm -it \
|
||||||
@@ -78,6 +100,8 @@ help:
|
|||||||
@echo " make build-pdf - Compile both front and back PDFs"
|
@echo " make build-pdf - Compile both front and back PDFs"
|
||||||
@echo " make build-front - Compile front side PDF only"
|
@echo " make build-front - Compile front side PDF only"
|
||||||
@echo " make build-back - Compile back 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 shell - Open interactive shell in container"
|
||||||
@echo " make clean - Remove generated files (aux, log, pdf)"
|
@echo " make clean - Remove generated files (aux, log, pdf)"
|
||||||
@echo " make clean-all - Remove all files and Docker image"
|
@echo " make clean-all - Remove all files and Docker image"
|
||||||
|
|||||||
126
brevetkarte-template.tex
Normal file
126
brevetkarte-template.tex
Normal file
@@ -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}
|
||||||
112
generate_cards.py
Executable file
112
generate_cards.py
Executable file
@@ -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()
|
||||||
Reference in New Issue
Block a user