Refactor PDF generation workflow, integrate YAML-based event config, and simplify Makefile scripts.

- Replace `generate-personalized` with unified `generate` target in Makefile, supporting event config integration.
- Add YAML parsing to `generate_cards.py` for event-level placeholders and back side generation.
- Update templates to include `EVENT_*` placeholders and dynamic content rendering.
- Simplify `build` and `build-personalized` targets; consolidate redundant logic.
- Enhance `make help` documentation for updated workflow.
- Adjust LaTeX formatting for back side templates, removing hardcoded spacing.
This commit is contained in:
Peter Adam
2026-02-28 10:43:57 +01:00
parent f59c52551b
commit 9c4c7c6ccb
4 changed files with 156 additions and 143 deletions

View File

@@ -1,7 +1,6 @@
# Makefile for building Brevet card PDF in Docker
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
@@ -10,23 +9,20 @@ 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 generate-personalized build-personalized run shell help
.PHONY: all build clean build-image build-front build-back generate build-personalized run shell help
# Default target
all: build
# Build both PDFs (builds image if needed, then compiles)
build: build-image build-front build-back
# Build static demo front side (builds image if needed)
build: build-image build-front
# Build Docker image
build-image:
@echo "Building Docker image..."
docker build -t $(IMAGE_NAME) .
# Compile both PDFs in Docker container
build-pdf: build-front build-back
# Compile front side PDF
# Compile static demo front side PDF
build-front:
@echo "Compiling front side LaTeX to PDF..."
docker run --rm \
@@ -35,8 +31,8 @@ build-front:
pdflatex -interaction=nonstopmode $(TEX_FILE_FRONT)
@echo "PDF generated: $(PDF_FILE_FRONT)"
# Compile back side PDF
build-back:
# Compile back side PDF (always generated from event.yml via generate)
build-back: build-image
@echo "Compiling back side LaTeX to PDF..."
docker run --rm \
-v $(PWD):/workspace \
@@ -44,24 +40,29 @@ 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)..."
# Generate all tex files from CSV + event.yml
generate:
@echo "Generating cards from $(CSV_FILE) + event.yml..."
@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..."
# Build personalized front + event back side PDFs
build-personalized: generate build-image
@echo "Compiling personalized front side to PDF..."
docker run --rm \
-v $(PWD):/workspace \
$(IMAGE_NAME) \
pdflatex -interaction=nonstopmode $(TEX_FILE_PERSONALIZED)
@echo "PDF generated: $(PDF_FILE_PERSONALIZED)"
@echo "Compiling back side to PDF..."
docker run --rm \
-v $(PWD):/workspace \
$(IMAGE_NAME) \
pdflatex -interaction=nonstopmode $(TEX_FILE_BACK)
@echo "PDF generated: $(PDF_FILE_BACK)"
# Run container interactively
run:
@@ -95,15 +96,14 @@ 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 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"
@echo " make build - Build Docker image and compile static front side (default)"
@echo " make build-image - Build Docker image only"
@echo " make build-front - Compile static demo front side PDF"
@echo " make generate - Generate tex files from CSV + event.yml"
@echo " make build-personalized - Generate and compile front + back side PDFs"
@echo " make build-back - Compile back side PDF (after generate)"
@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"

View File

@@ -12,15 +12,6 @@
\setlength{\tabcolsep}{3pt}
\pagestyle{empty}
% A4 landscape height: 21cm
% Half height: 10.5cm from top of page
% Top margin: 0.8cm
% Row 4 should start at: 10.5 - 0.8 = 9.7cm from start of content
%
% Front side: each card is 8.5cm tall
% So rows 1-3 should be: 8.5cm total (2.833cm each)
% Gap after row 3: 9.7 - 8.5 = 1.2cm
\newcommand{\rowheight}{2.833cm}
\begin{document}
@@ -29,91 +20,79 @@
\noindent
\begin{tabular}{|p{6.6cm}|p{6.6cm}|p{6.6cm}|p{6.6cm}|}
\hline
% Row 1 (Upper card) - height 2.833cm
% Row 1
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 1:} Km 0 - Unisport\\
\textbf{Nr. 1:} Km 0 -- Unisport\\
Nachtigallenweg 86, Bonn\\
\\
\textbf{Kontrollzeit}\\
von: 7:30\\
bis: 8:30
}
bis: 8:30}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 4:} Km 165 Mahlberg Ecke K50,\\
\textbf{Nr. 4:} Km 165 -- Mahlberg Ecke K50,\\
Breitestraße\\
\textbf{Kontrollzeit}\\
von: 13:21\\
bis: 19:30
}
bis: 19:30}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Kontrollfrage:}\\
Wann wurde das Kriegerdenkmal\\
eingerichtet?
}
eingerichtet?}
\\
\hline
% Row 2 (Upper card) - height 2.833cm
% Row 2
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 2:} Km 57 -- Nationalpark-Tor" im\\
\textbf{Nr. 2:} Km 57 -- ,,Nationalpark-Tor`` im\\
alten Bahnhofsgebäude, Heimbach\\
\\
\textbf{Kontrollzeit}\\
von: 9:11\\
bis: 12:21
}
bis: 12:21}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 5:} Km 205 - Unisport\\
\textbf{Nr. 5:} Km 205 -- Unisport\\
Nachtigallenweg 86, Bonn\\
\\
\textbf{Kontrollzeit}\\
von: 13:23\\
bis: 21:00
}
bis: 21:00}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
\parbox[c][\rowheight][t]{6.5cm}{%
}
\\
\hline
% Row 3 (Upper card) - height 2.833cm
% Row 3
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 3:} Km 100 - Friterie Au Petit\\
Creux" oder Total-Tankstelle, Ecke Rue\\
\textbf{Nr. 3:} Km 100 -- Friterie ,,Au Petit\\
Creux`` oder Total-Tankstelle, Ecke Rue\\
de Botrange/Rue de Charmilles, Waimes\\
\textbf{Kontrollzeit}\\
von: 11:26\\
bis: 15:10
bis: 15:10}
&
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
\parbox[c][\rowheight][t]{6.5cm}{%
}
\\
\hline
@@ -121,95 +100,83 @@ bis: 15:10
\vspace{1.8cm}
% Lower card table (rows 4-6)
% Lower card table (rows 1-3, identical)
\noindent
\begin{tabular}{|p{6.6cm}|p{6.6cm}|p{6.6cm}|p{6.6cm}|}
\hline
% Row 1 (Lower card)
% Row 1
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 1:} Km 0 - Unisport\\
\textbf{Nr. 1:} Km 0 -- Unisport\\
Nachtigallenweg 86, Bonn\\
\\
\textbf{Kontrollzeit}\\
von: 7:30\\
bis: 8:30
}
bis: 8:30}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 4:} Km 165 Mahlberg Ecke K50,\\
\textbf{Nr. 4:} Km 165 -- Mahlberg Ecke K50,\\
Breitestraße\\
\textbf{Kontrollzeit}\\
von: 13:21\\
bis: 19:30
}
bis: 19:30}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Kontrollfrage:}\\
Wann wurde das Kriegerdenkmal\\
eingerichtet?
}
eingerichtet?}
\\
\hline
% Row 2 (Lower card) - height 2.833cm
% Row 2
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 2:} Km 57 -- Nationalpark-Tor" im\\
\textbf{Nr. 2:} Km 57 -- ,,Nationalpark-Tor`` im\\
alten Bahnhofsgebäude, Heimbach\\
\\
\textbf{Kontrollzeit}\\
von: 9:11\\
bis: 12:21
}
bis: 12:21}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 5:} Km 214 - Unisport\\
\textbf{Nr. 5:} Km 205 -- Unisport\\
Nachtigallenweg 86, Bonn\\
\\
\textbf{Kontrollzeit}\\
von: 13:23\\
bis: 21:00
}
bis: 21:00}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
\parbox[c][\rowheight][t]{6.5cm}{%
}
\\
\hline
% Row 3 (Lower card) - height 2.833cm
% Row 3
\parbox[c][\rowheight][t]{6.5cm}{%
\vspace{2mm}
\textbf{Nr. 3:} Km 100 - Friterie Au Petit\\
Creux" oder Total-Tankstelle, Ecke Rue\\
\textbf{Nr. 3:} Km 100 -- Friterie ,,Au Petit\\
Creux`` oder Total-Tankstelle, Ecke Rue\\
de Botrange/Rue de Charmilles, Waimes\\
\textbf{Kontrollzeit}\\
von: 11:26\\
bis: 15:10
bis: 15:10}
&
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty for stamps
\parbox[c][\rowheight][t]{6.5cm}{%
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
}
&
\parbox[c][\rowheight][c]{6.5cm}{%
% Empty
\parbox[c][\rowheight][t]{6.5cm}{%
}
\\
\hline

View File

@@ -71,7 +71,6 @@
\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) {
@@ -101,7 +100,7 @@
PLZ/Ort: {{PLZ_ORT}}\\[0.4cm]
Land: {{LAND}}\\[0.4cm]
Medaille: {{MEDAILLE}}\\[0.6cm]
Startzeit: 8:30
Startzeit: {{EVENT_STARTZEIT}}
};
% Column 4 - Event Info (right section)
@@ -110,12 +109,12 @@
};
\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}
\textbf{{{EVENT_TITLE}}}\\
Randonnée über \textbf{{{EVENT_KM}}} km\\
am \textbf{{{EVENT_DATE}}}\\
mit Start in \textbf{{{EVENT_START}}}\\
von \textbf{{{EVENT_CLUB}}}\\
N° ACP du Club \textbf{{{EVENT_CLUB_NR}}}
};
\node[anchor=south,text width=6.6cm,font=\scriptsize,align=center] at (24.15,-7.0) {

View File

@@ -1,11 +1,18 @@
#!/usr/bin/env python3
"""
Generate personalized brevet cards from CSV data.
Generate personalized brevet cards from CSV and event config.
"""
import csv
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("Error: PyYAML not installed. Run: pip install pyyaml", file=sys.stderr)
sys.exit(1)
def escape_latex(text):
"""Escape special LaTeX characters."""
if not text:
@@ -27,8 +34,47 @@ def escape_latex(text):
result = result.replace(char, replacement)
return result
def load_event_config(config_file):
"""Load event configuration from YAML file."""
with open(config_file, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def apply_event_placeholders(text, config):
"""Replace event-level placeholders in a template string."""
event = config.get('event', {})
replacements = {
'{{EVENT_TITLE}}': escape_latex(event.get('title', '')),
'{{EVENT_KM}}': escape_latex(event.get('km', '')),
'{{EVENT_DATE}}': escape_latex(event.get('date', '')),
'{{EVENT_START}}': escape_latex(event.get('start_location', '')),
'{{EVENT_CLUB}}': escape_latex(event.get('club', '')),
'{{EVENT_CLUB_NR}}': escape_latex(event.get('club_nr', '')),
'{{EVENT_STARTZEIT}}': escape_latex(event.get('startzeit', '')),
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def generate_backside(template, config):
"""Fill back side template with cell content from event config."""
cells = config.get('backside', {})
result = template
for row in range(1, 4):
for col in range(1, 5):
key = f"{row}_{col}"
placeholder = f"{{{{CELL_{row}_{col}}}}}"
content = cells.get(key, "")
if content is None:
content = ""
result = result.replace(placeholder, content.strip())
return result
def generate_card_from_template(template, data):
"""Replace placeholders in template with participant data."""
"""Replace participant 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'])}"
@@ -36,7 +82,6 @@ def generate_card_from_template(template, data):
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)
@@ -46,30 +91,33 @@ def generate_card_from_template(template, data):
return card
def main():
csv_file = Path("Export Brevetkarte.csv")
template_file = Path("brevetkarte-template.tex")
backside_template_file = Path("brevetkarte-rueckseite-template.tex")
event_config_file = Path("event.yml")
output_file = Path("brevetkarte-personalized.tex")
backside_output_file = Path("brevetkarte-rueckseite.tex")
if not csv_file.exists():
print(f"Error: {csv_file} not found!", file=sys.stderr)
sys.exit(1)
for f in [csv_file, template_file, backside_template_file, event_config_file]:
if not f.exists():
print(f"Error: {f} 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)
print(f"Reading event config from {event_config_file}...")
event_config = load_event_config(event_config_file)
# Read template
print(f"Reading template from {template_file}...")
template = template_file.read_text(encoding='utf-8')
template = apply_event_placeholders(template, event_config)
# 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
with open(csv_file, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
if row['Startnr']: # Skip empty rows
if row['Startnr']:
participants.append(row)
if not participants:
@@ -78,35 +126,34 @@ def main():
print(f"Found {len(participants)} participant(s)")
# Generate document with all cards
# Generate personalized front side
cards = []
for i, participant in enumerate(participants):
for participant in 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')
output_file.write_text(''.join(document_parts), encoding='utf-8')
print(f"Generated {output_file}")
# Generate personalized back side
print(f"Reading back side template from {backside_template_file}...")
backside_template = backside_template_file.read_text(encoding='utf-8')
backside_output = generate_backside(backside_template, event_config)
backside_output_file.write_text(backside_output, encoding='utf-8')
print(f"Generated {backside_output_file}")
print(f"Total pages: {(len(participants) + 1) // 2}")
print(f"Compile with: make build-personalized")
if __name__ == "__main__":
main()