feat(v50.2.0): Gesamt-PDF fuer jede Testakte und Inhaltsverbesserungen (#150)

User-Wunsch: jede Testakte soll im ZIP-Release zusaetzlich ein
'doppelt gemoppeltes' Gesamt-PDF mit allen Aktenstuecken haben.

- Neues Skript scripts/build-testakte-gesamt-pdf.py buendelt
  MD/TXT/EML/CSV/XLSX/DOCX/PDF einer Testakte zu einem PDF mit
  Cover, Inhaltsverzeichnis, Seitenzahlen und Trennblaettern.
  Sehr lange Tabellenzellen werden in Absaetze umgewandelt, damit
  ReportLab nicht ueberlauft (Wandeldarlehen-Bilingual).
- Neues Skript scripts/inject-gesamt-pdf-section.py ergaenzt jede
  Testakte-README idempotent um eine Gesamt-PDF-Sektion direkt
  unter dem H1.
- 63 Testakten erhalten gesamt-pdf/<name>_gesamt.pdf
- Stichprobensichtung des Repos: keine TODO/FIXME-Marker, keine
  Lorem-Ipsum-Reste, keine leeren Quelldateien.
- Versionen: Marketplace 50.1.1 -> 50.2.0
- Plugin-Manifeste unveraendert.

Validatoren gruen.
This commit is contained in:
Klotzkette
2026-05-30 12:40:51 +02:00
committed by GitHub
parent b4ada07d77
commit a347259228
130 changed files with 168855 additions and 1 deletions
+767
View File
@@ -0,0 +1,767 @@
#!/usr/bin/env python3
"""Baut für jede Testakte ein 'gesamt-pdf/<name>_gesamt.pdf', das alle
Aktenstücke (Markdown, TXT, EML, CSV, XLSX, DOCX, PDF) in ein einziges,
sauber gerendertes Dokument mit Cover, Inhaltsverzeichnis und Seitenzahlen
zusammenfasst.
Aufruf:
python3 scripts/build-testakte-gesamt-pdf.py # alle Testakten
python3 scripts/build-testakte-gesamt-pdf.py <name1> <name2> # gezielt
"""
from __future__ import annotations
import io
import re
import sys
import csv
from email import policy
from email.parser import BytesParser
from pathlib import Path
# Drittabhaengigkeiten
from openpyxl import load_workbook
from pypdf import PdfReader, PdfWriter
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib.colors import HexColor, black
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
PageBreak,
Table,
TableStyle,
)
from reportlab.lib.enums import TA_LEFT
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# DOCX
try:
from docx import Document
except ImportError:
Document = None # type: ignore
REPO_ROOT = Path(__file__).resolve().parent.parent
TESTAKTEN = REPO_ROOT / "testakten"
# Design
TEAL = HexColor("#01696F")
MUTED = HexColor("#7A7974")
BORDER = HexColor("#D4D1CA")
SURFACE = HexColor("#F7F6F2")
# Font: System-Helvetica als Fallback. Inter waere schoener, aber wir verzichten
# auf Netzwerk-Downloads, damit das Skript offline laeuft.
FONT_REG = "Helvetica"
FONT_BOLD = "Helvetica-Bold"
styles = getSampleStyleSheet()
s_cover_label = ParagraphStyle(
"CoverLabel",
fontName=FONT_REG, fontSize=14, leading=18,
textColor=MUTED, spaceAfter=6,
)
s_cover_title = ParagraphStyle(
"CoverTitle",
fontName=FONT_BOLD, fontSize=28, leading=34,
textColor=TEAL, alignment=TA_LEFT, spaceAfter=14,
)
s_cover_sub = ParagraphStyle(
"CoverSub",
fontName=FONT_REG, fontSize=12, leading=16,
textColor=black, spaceAfter=4,
)
s_cover_meta = ParagraphStyle(
"CoverMeta",
fontName=FONT_REG, fontSize=9, leading=12,
textColor=MUTED, spaceAfter=3,
)
s_h1 = ParagraphStyle(
"H1", parent=styles["Heading1"],
fontName=FONT_BOLD, fontSize=18, leading=22, textColor=TEAL,
spaceBefore=18, spaceAfter=8,
)
s_h2 = ParagraphStyle(
"H2", parent=styles["Heading2"],
fontName=FONT_BOLD, fontSize=14, leading=18, textColor=black,
spaceBefore=12, spaceAfter=6,
)
s_h3 = ParagraphStyle(
"H3", parent=styles["Heading3"],
fontName=FONT_BOLD, fontSize=11, leading=14, textColor=black,
spaceBefore=8, spaceAfter=4,
)
s_body = ParagraphStyle(
"Body", parent=styles["BodyText"],
fontName=FONT_REG, fontSize=10, leading=14, textColor=black,
spaceAfter=6,
)
s_meta = ParagraphStyle(
"Meta", parent=styles["BodyText"],
fontName=FONT_REG, fontSize=9, leading=12, textColor=MUTED,
spaceAfter=4,
)
s_partlabel = ParagraphStyle(
"PartLabel", parent=styles["BodyText"],
fontName=FONT_BOLD, fontSize=11, leading=14, textColor=MUTED,
spaceAfter=2,
)
# Reihenfolge der Datei-Typen im Gesamt-PDF
TYPE_ORDER = ["md", "txt", "eml", "csv", "xlsx", "docx", "pdf"]
TYPE_LABEL = {
"md": "Aktenstücke (Markdown)",
"txt": "Notizen und Textdateien",
"eml": "E-Mails",
"csv": "CSV-Tabellen",
"xlsx": "Excel-Tabellen",
"docx": "Word-Dokumente",
"pdf": "PDF-Anhänge (Originaldokumente)",
}
def escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def md_to_flowables(md_text: str) -> list:
out: list = []
lines = md_text.splitlines()
i = 0
while i < len(lines):
line = lines[i].rstrip()
if not line:
i += 1
continue
if line.startswith("# "):
out.append(Paragraph(escape(line[2:].strip()), s_h1))
i += 1
continue
if line.startswith("## "):
out.append(Paragraph(escape(line[3:].strip()), s_h2))
i += 1
continue
if line.startswith("### "):
out.append(Paragraph(escape(line[4:].strip()), s_h3))
i += 1
continue
if line.startswith("---"):
out.append(Spacer(1, 6))
i += 1
continue
# Tabelle?
if (
line.startswith("|")
and i + 1 < len(lines)
and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1])
):
header = [c.strip() for c in line.strip("|").split("|")]
i += 2
rows = [header]
while i < len(lines) and lines[i].startswith("|"):
cells = [c.strip() for c in lines[i].strip("|").split("|")]
rows.append(cells)
i += 1
col_count = max(1, len(header))
avail_width = 16 * cm
col_widths = [avail_width / col_count] * col_count
if col_count >= 2:
col_widths[0] = min(4 * cm, avail_width / col_count * 1.5)
rest = (avail_width - col_widths[0]) / (col_count - 1)
for k in range(1, col_count):
col_widths[k] = rest
tbl_data = []
for r in rows:
tbl_data.append([Paragraph(escape(c), s_body) for c in r])
tbl = Table(tbl_data, colWidths=col_widths, repeatRows=1)
tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), SURFACE),
("FONTNAME", (0, 0), (-1, 0), FONT_BOLD),
("BOX", (0, 0), (-1, -1), 0.4, BORDER),
("INNERGRID", (0, 0), (-1, -1), 0.3, BORDER),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
out.append(tbl)
out.append(Spacer(1, 6))
continue
if line.startswith("- ") or line.startswith("* "):
text = line[2:].strip()
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
out.append(Paragraph("" + _inline_markup(text), s_body))
i += 1
continue
if re.match(r"^\d+\.\s", line):
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", line)
out.append(Paragraph(_inline_markup(text), s_body))
i += 1
continue
# Sammle normalen Absatz bis zur naechsten Leerzeile/Sondersyntax
block = [line]
j = i + 1
while (
j < len(lines)
and lines[j].strip()
and not lines[j].startswith(("#", "-", "*", "|", "---"))
and not re.match(r"^\d+\.\s", lines[j])
):
block.append(lines[j].rstrip())
j += 1
text = " ".join(block).strip()
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"`([^`]+)`", r"<font face='Courier'>\1</font>", text)
out.append(Paragraph(_inline_markup(text), s_body))
i = j
return out
def _inline_markup(s: str) -> str:
"""Escape minimal, lasse erlaubte Inline-Tags."""
s = s.replace("&", "&amp;")
# Erlaubte Tags wieder herstellen
s = re.sub(r"&lt;(/?(?:b|i|sub|sup))&gt;", r"<\1>", s)
s = s.replace("&lt;font face='Courier'&gt;", "<font face='Courier'>")
s = s.replace("&lt;/font&gt;", "</font>")
# Eckige Klammern in normalem Text: behalten, aber nicht als Tag
return s
def txt_to_flowables(text: str) -> list:
out = []
for para in text.split("\n\n"):
para = para.strip()
if not para:
continue
# Zeilenumbrueche im Absatz erhalten
out.append(Paragraph(escape(para).replace("\n", "<br/>"), s_body))
return out
def eml_to_flowables(path: Path) -> list:
out = []
try:
with open(path, "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
headers = [
("Von", msg.get("From", "")),
("An", msg.get("To", "")),
("Datum", msg.get("Date", "")),
("Betreff", msg.get("Subject", "")),
]
body_part = msg.get_body(preferencelist=("plain", "html"))
body = body_part.get_content() if body_part else ""
except Exception as e:
out.append(Paragraph(f"<i>E-Mail konnte nicht gelesen werden: {escape(str(e))}</i>", s_meta))
return out
rows = [
[Paragraph(label, s_meta), Paragraph(escape(value), s_meta)]
for label, value in headers
]
tbl = Table(rows, colWidths=[2.5 * cm, 13.5 * cm])
tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (0, -1), SURFACE),
("BOX", (0, 0), (-1, -1), 0.3, BORDER),
("INNERGRID", (0, 0), (-1, -1), 0.2, BORDER),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
out.append(tbl)
out.append(Spacer(1, 6))
out.extend(txt_to_flowables(body))
return out
def csv_to_flowables(path: Path) -> list:
out = []
try:
with open(path, encoding="utf-8") as f:
reader = csv.reader(f)
rows = list(reader)
except UnicodeDecodeError:
with open(path, encoding="latin-1") as f:
reader = csv.reader(f)
rows = list(reader)
except Exception as e:
out.append(Paragraph(f"<i>CSV konnte nicht gelesen werden: {escape(str(e))}</i>", s_meta))
return out
if not rows:
return out
max_cols = max(len(r) for r in rows)
rows = [r + [""] * (max_cols - len(r)) for r in rows]
out.extend(_render_table(rows, header=True))
return out
def xlsx_to_flowables(path: Path) -> list:
out = []
try:
wb = load_workbook(path, data_only=True)
except Exception as e:
out.append(Paragraph(f"<i>XLSX konnte nicht gelesen werden: {escape(str(e))}</i>", s_meta))
return out
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
out.append(Paragraph(f"Tabellenblatt: {escape(sheet_name)}", s_h3))
rows = []
for row in ws.iter_rows(values_only=True):
rows.append(
[_format_cell(c) for c in row]
)
if not rows:
continue
# Leere Zeilen/Spalten am Ende abschneiden
while rows and not any(c.strip() for c in rows[-1]):
rows.pop()
if not rows:
continue
max_cols = max(len(r) for r in rows)
if max_cols == 0:
continue
# Hinten leere Spalten abschneiden
while max_cols > 0 and all(
(len(r) <= max_cols - 1) or (not r[max_cols - 1].strip()) for r in rows
):
max_cols -= 1
if max_cols == 0:
continue
rows = [r[:max_cols] + [""] * (max_cols - len(r[:max_cols])) for r in rows]
out.extend(_render_table(rows, header=True))
out.append(Spacer(1, 6))
return out
def _format_cell(c) -> str:
if c is None:
return ""
if isinstance(c, float):
if c == int(c):
return str(int(c))
return f"{c:.4f}".rstrip("0").rstrip(".")
return str(c)
# Maximalzeichen pro Zelle, ab denen die Tabelle nicht mehr als Table gerendert wird,
# sondern als sequentielle Absatzfolge (verhindert ReportLab-Overflow).
_MAX_CELL_CHARS = 1200
def _split_long_text(text: str, chunk: int = 800) -> list:
"""Schneidet sehr langen Text an Absatz- oder Satzgrenzen in Stuecke."""
text = text.replace("\r", "")
if len(text) <= chunk:
return [text]
# Erst Absaetze probieren
paras = [p for p in text.split("\n") if p.strip()]
if any(len(p) > chunk for p in paras):
# Weiter an Saetzen schneiden
out = []
for p in paras:
if len(p) <= chunk:
out.append(p)
continue
buf = ""
for sent in p.replace("; ", "; |").replace(". ", ". |").split("|"):
if len(buf) + len(sent) > chunk and buf:
out.append(buf)
buf = sent
else:
buf += sent
if buf:
out.append(buf)
return out
return paras
def _render_table(rows: list, header: bool = False) -> list:
"""Rendert eine Tabelle. Falls Zellen zu lang werden, faellt es auf eine
sequentielle Absatzdarstellung zurueck (Reihe fuer Reihe), damit ReportLab
keine Overflow-Fehler wirft."""
max_cell_len = max((len(c) for r in rows for c in r), default=0)
if max_cell_len > _MAX_CELL_CHARS:
out = []
header_row = rows[0] if header else None
body_rows = rows[1:] if header else rows
for ri, r in enumerate(body_rows):
if header_row:
# Reihen-Trennlinie + Spaltenkopf pro Zelle
for ci, cell in enumerate(r):
label = header_row[ci] if ci < len(header_row) else f"Spalte {ci+1}"
if label.strip():
out.append(Paragraph(f"<b>{escape(label)}</b>", s_meta))
for chunk in _split_long_text(cell):
out.append(Paragraph(escape(chunk), s_body))
else:
for ci, cell in enumerate(r):
for chunk in _split_long_text(cell):
out.append(Paragraph(escape(chunk), s_body))
out.append(Spacer(1, 4))
out.append(HRFlowable(width="100%", thickness=0.2, color=BORDER))
out.append(Spacer(1, 4))
return out
max_cols = max(len(r) for r in rows)
avail_width = 16 * cm
col_widths = [avail_width / max_cols] * max_cols
data = [
[Paragraph(escape(c), s_meta) for c in r]
for r in rows
]
tbl = Table(data, colWidths=col_widths, repeatRows=1 if header else 0, splitByRow=1)
cmds = [
("BOX", (0, 0), (-1, -1), 0.3, BORDER),
("INNERGRID", (0, 0), (-1, -1), 0.2, BORDER),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
]
if header:
cmds.insert(0, ("BACKGROUND", (0, 0), (-1, 0), SURFACE))
cmds.insert(1, ("FONTNAME", (0, 0), (-1, 0), FONT_BOLD))
tbl.setStyle(TableStyle(cmds))
return [tbl]
def docx_to_flowables(path: Path) -> list:
out = []
if Document is None:
out.append(Paragraph("<i>python-docx nicht installiert, Inhalt wird uebersprungen.</i>", s_meta))
return out
try:
doc = Document(str(path))
except Exception as e:
out.append(Paragraph(f"<i>DOCX konnte nicht gelesen werden: {escape(str(e))}</i>", s_meta))
return out
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
style = para.style.name if para.style else ""
if style.startswith("Heading 1"):
out.append(Paragraph(escape(text), s_h2))
elif style.startswith("Heading 2"):
out.append(Paragraph(escape(text), s_h3))
elif style.startswith("Heading"):
out.append(Paragraph(escape(text), s_h3))
else:
out.append(Paragraph(escape(text), s_body))
for table in doc.tables:
rows = []
for r in table.rows:
rows.append([c.text.strip() for c in r.cells])
if rows:
out.extend(_render_table(rows, header=True))
out.append(Spacer(1, 6))
return out
def header_footer_factory(testakte_name: str):
def hf(canv: canvas.Canvas, doc) -> None:
canv.saveState()
canv.setFont(FONT_REG, 8)
canv.setFillColor(MUTED)
canv.drawString(2 * cm, 1.2 * cm, f"Testakte: {testakte_name}")
canv.drawRightString(19 * cm, 1.2 * cm, f"Seite {doc.page}")
canv.setStrokeColor(BORDER)
canv.setLineWidth(0.3)
canv.line(2 * cm, 1.6 * cm, 19 * cm, 1.6 * cm)
canv.restoreState()
return hf
def build_cover(name: str, readme_summary: str | None, h1: str | None = None) -> list:
title = h1 if h1 else name
out = [
Spacer(1, 4 * cm),
Paragraph("Testakte", s_cover_label),
Paragraph(escape(title), s_cover_title),
Paragraph(escape(name), s_cover_meta),
Spacer(1, 0.6 * cm),
]
if readme_summary:
out.append(Paragraph(escape(readme_summary), s_cover_sub))
out.append(Spacer(1, 0.8 * cm))
else:
out.append(Spacer(1, 0.8 * cm))
out.append(Paragraph(
"Diese Datei buendelt alle Aktenstuecke der Testakte in einem Dokument. "
"Die Einzeldateien liegen im Ordner derselben Testakte ebenfalls vor.",
s_cover_meta,
))
return out
def extract_readme_summary(readme_path: Path) -> tuple[str | None, str | None]:
"""Liest aus der README den H1-Titel und einen kurzen beschreibenden Absatz.
Der beschreibende Absatz wird absichtlich erst gesucht, NACHDEM Download-Bloecke
und Aktenstruktur-Blocks uebersprungen wurden, damit nicht der ZIP-Hinweis
auf dem Cover landet.
"""
if not readme_path.is_file():
return None, None
text = readme_path.read_text(encoding="utf-8")
# H1
h1_match = re.search(r"^#\s+(.+?)\s*$", text, flags=re.MULTILINE)
h1 = h1_match.group(1).strip() if h1_match else None
# Suche eine Sektion mit beschreibendem Inhalt: 'Kurzbild', 'Worum',
# 'Sachverhalt', 'Ueberblick', 'Mandat', 'Fall' o.ae.
section_pattern = re.compile(
r"^##[^\n]*?(?:kurzbild|worum geht|sachverhalt|ueberblick|\u00fcberblick|mandat|fall|der fall|akte|kontext|ausgangslage|ausgangs|zweck|szenario|idee|einsatz|\u00fcbersicht|uebersicht|verfahrenseckdaten|aktenkern|aktenbestand|mandantenkonstellation|politische vorgabe|enthaltene arbeitsdateien|dateien)[^\n]*\n([\s\S]*?)(?=^## |\Z)",
re.IGNORECASE | re.MULTILINE,
)
m = section_pattern.search(text)
candidate_text = m.group(1) if m else text
for para in candidate_text.split("\n\n"):
para = para.strip()
if not para:
continue
if para.startswith(("#", "-", "*", "|", "<!--", "```")):
continue
# Download-/ZIP-Hinweise ueberspringen
lower = para.lower()
if any(
kw in lower
for kw in (
"zip-datei",
"zip datei",
"direkt-download",
"als zip",
"github-release",
"github release",
"download",
)
):
continue
para = re.sub(r"\*\*([^*]+)\*\*", r"\1", para)
para = re.sub(r"\s+", " ", para)
return h1, para[:400]
return h1, None
def collect_files(testakte_dir: Path) -> dict[str, list[Path]]:
files_by_type: dict[str, list[Path]] = {t: [] for t in TYPE_ORDER}
for f in testakte_dir.rglob("*"):
if not f.is_file():
continue
# README und Gesamt-PDF ausschliessen
if f.name == "README.md" and f.parent == testakte_dir:
continue
if "gesamt-pdf" in f.parts:
continue
ext = f.suffix.lower().lstrip(".")
if ext not in TYPE_ORDER:
continue
files_by_type[ext].append(f)
for t in files_by_type:
files_by_type[t].sort(key=lambda p: str(p.relative_to(testakte_dir)).lower())
return files_by_type
def build_text_pdf(testakte_dir: Path, files: dict[str, list[Path]], cover: list, tmp_path: Path) -> tuple[bool, list[Path]]:
"""Baut den Text-Teil als PDF, sammelt PDF-Anhaenge separat."""
doc = SimpleDocTemplate(
str(tmp_path),
pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm,
title=f"Testakte {testakte_dir.name}",
author="Perplexity Computer",
)
flow = list(cover)
flow.append(PageBreak())
# Inhaltsverzeichnis (rudimentaer)
toc_rows: list[list] = [["Teil", "Inhalt"]]
teil_no = 1
for t in TYPE_ORDER:
if not files[t]:
continue
toc_rows.append([f"Teil {teil_no}", f"{TYPE_LABEL[t]} ({len(files[t])})"])
teil_no += 1
if len(toc_rows) > 1:
flow.append(Paragraph("Inhaltsverzeichnis", s_h1))
flow.append(Spacer(1, 8))
flow.extend(_render_table(toc_rows, header=True))
flow.append(PageBreak())
pdf_attachments: list[Path] = []
teil_no = 1
for t in TYPE_ORDER:
if not files[t]:
continue
if t == "pdf":
# PDFs werden separat angehaengt (Original-Layout bewahren)
pdf_attachments = files[t]
teil_no += 1
continue
flow.append(Paragraph(f"Teil {teil_no}{TYPE_LABEL[t]}", s_partlabel))
flow.append(Paragraph(TYPE_LABEL[t], s_h1))
flow.append(Spacer(1, 4))
for f in files[t]:
rel = f.relative_to(testakte_dir)
flow.append(Paragraph(f"<b>Datei:</b> {escape(str(rel))}", s_meta))
flow.append(Spacer(1, 4))
try:
if t == "md":
flow.extend(md_to_flowables(f.read_text(encoding="utf-8", errors="replace")))
elif t == "txt":
flow.extend(txt_to_flowables(f.read_text(encoding="utf-8", errors="replace")))
elif t == "eml":
flow.extend(eml_to_flowables(f))
elif t == "csv":
flow.extend(csv_to_flowables(f))
elif t == "xlsx":
flow.extend(xlsx_to_flowables(f))
elif t == "docx":
flow.extend(docx_to_flowables(f))
except Exception as e:
flow.append(Paragraph(f"<i>Inhalt konnte nicht gerendert werden: {escape(str(e))}</i>", s_meta))
flow.append(Spacer(1, 14))
flow.append(PageBreak())
teil_no += 1
if len(flow) == len(cover) + 1:
# Nichts ausser Cover -> trotzdem bauen, aber Hinweis
flow.append(Paragraph(
"Diese Testakte enthält keine renderbaren Inhalte ausserhalb der angefuegten PDFs.",
s_body,
))
hf = header_footer_factory(testakte_dir.name)
try:
doc.build(flow, onFirstPage=hf, onLaterPages=hf)
except Exception as e:
print(f" FEHLER beim Bauen: {e}")
return False, pdf_attachments
return True, pdf_attachments
def append_pdf_with_separator(writer: PdfWriter, label: str, pdf_path: Path, testakte_name: str) -> None:
sep = io.BytesIO()
c = canvas.Canvas(sep, pagesize=A4)
c.setTitle(label)
c.setAuthor("Perplexity Computer")
c.setFont(FONT_BOLD, 16)
c.setFillColor(TEAL)
c.drawString(2 * cm, 25 * cm, label)
c.setFont(FONT_REG, 9)
c.setFillColor(MUTED)
c.drawString(2 * cm, 24.2 * cm, f"Datei: {pdf_path.name}")
c.setStrokeColor(BORDER)
c.setLineWidth(0.3)
c.line(2 * cm, 1.6 * cm, 19 * cm, 1.6 * cm)
c.setFont(FONT_REG, 8)
c.drawString(2 * cm, 1.2 * cm, f"Testakte: {testakte_name}")
c.showPage()
c.save()
sep.seek(0)
for p in PdfReader(sep).pages:
writer.add_page(p)
try:
for p in PdfReader(str(pdf_path)).pages:
writer.add_page(p)
except Exception as e:
# PDF defekt oder verschluesselt -> Hinweisseite einfuegen
sep2 = io.BytesIO()
c2 = canvas.Canvas(sep2, pagesize=A4)
c2.setFont(FONT_REG, 10)
c2.drawString(2 * cm, 25 * cm, f"PDF konnte nicht eingebunden werden: {e}")
c2.showPage()
c2.save()
sep2.seek(0)
for p in PdfReader(sep2).pages:
writer.add_page(p)
def build_gesamt_pdf(testakte_dir: Path) -> tuple[str, str]:
"""Gibt (status, info) zurueck. status in {ok, skip, error}."""
name = testakte_dir.name
out_dir = testakte_dir / "gesamt-pdf"
out_dir.mkdir(exist_ok=True)
out_path = out_dir / f"{name}_gesamt.pdf"
files = collect_files(testakte_dir)
total_files = sum(len(v) for v in files.values())
if total_files == 0:
return "skip", "keine Quelldateien"
h1, summary = extract_readme_summary(testakte_dir / "README.md")
cover = build_cover(name, summary, h1)
tmp_text = Path(f"/tmp/_gesamt_text_{name}.pdf")
ok, pdf_attachments = build_text_pdf(testakte_dir, files, cover, tmp_text)
if not ok:
return "error", "Text-PDF konnte nicht erzeugt werden"
writer = PdfWriter()
try:
for page in PdfReader(str(tmp_text)).pages:
writer.add_page(page)
except Exception as e:
return "error", f"Text-PDF nicht lesbar: {e}"
for pdf in pdf_attachments:
rel = pdf.relative_to(testakte_dir)
label = f"PDF-Anhang: {rel}"
append_pdf_with_separator(writer, label, pdf, name)
writer.add_metadata(
{
"/Title": f"Testakte {name}",
"/Author": "Perplexity Computer",
"/Subject": "Gesamtakte fuer claude-fuer-deutsches-recht",
}
)
with open(out_path, "wb") as f:
writer.write(f)
size_kb = out_path.stat().st_size / 1024
try:
tmp_text.unlink()
except Exception:
pass
return "ok", f"{out_path.relative_to(REPO_ROOT)} ({size_kb:.0f} KB, {total_files} Quelldateien)"
def main() -> None:
targets = sys.argv[1:]
all_dirs = sorted([d for d in TESTAKTEN.iterdir() if d.is_dir()])
if targets:
all_dirs = [d for d in all_dirs if d.name in targets]
print(f"Verarbeite {len(all_dirs)} Testakten")
print()
counts = {"ok": 0, "skip": 0, "error": 0}
for d in all_dirs:
status, info = build_gesamt_pdf(d)
counts[status] += 1
sigil = {"ok": "OK ", "skip": "SK ", "error": "ERR"}[status]
print(f" {sigil} {d.name}: {info}")
print()
print(f"Fertig: {counts['ok']} OK, {counts['skip']} skip, {counts['error']} Fehler")
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Fuegt in jede testakten/<name>/README.md prominent ganz oben eine
Gesamt-PDF-Sektion ein, die auf gesamt-pdf/<name>_gesamt.pdf verlinkt.
Idempotent ueber HTML-Marker. Position: direkt nach dem H1, vor allen
weiteren Sektionen (insbesondere vor dem Direkt-Download-Block).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TESTAKTEN_DIR = REPO_ROOT / "testakten"
MARKER_BEGIN = "<!-- BEGIN gesamt-pdf-section (autogen) -->"
MARKER_END = "<!-- END gesamt-pdf-section (autogen) -->"
def section_block(slug: str, pdf_rel: str, size_kb: int) -> str:
return f"""{MARKER_BEGIN}
## 📕 Gesamt-PDF (alles in einer Datei)
> **Doppelt gemoppelt:** Diese Akte gibt es als ein einziges, durchsuchbares Gesamt-PDF mit allen Aktenstuecken (Schriftsaetze, Tabellen, Anhaenge) hintereinander ideal zum Lesen oder Ausdrucken.
| Datei | Format | Groesse |
| --- | --- | --- |
| [`{pdf_rel}`]({pdf_rel}) | PDF | {size_kb} KB |
Im Release-ZIP `testakte-{slug}.zip` ist das Gesamt-PDF mit enthalten.
{MARKER_END}
"""
H1_RE = re.compile(r"^# .+$", re.MULTILINE)
def inject(readme: Path, slug: str) -> str:
pdf = readme.parent / "gesamt-pdf" / f"{slug}_gesamt.pdf"
if not pdf.exists():
return "skip (kein Gesamt-PDF)"
size_kb = max(1, round(pdf.stat().st_size / 1024))
pdf_rel = f"gesamt-pdf/{slug}_gesamt.pdf"
new_section = section_block(slug, pdf_rel, size_kb)
text = readme.read_text(encoding="utf-8")
# Falls bereits eingefuegt: ersetzen
pat = re.compile(
re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END) + r"\n?",
re.DOTALL,
)
if pat.search(text):
new_text = pat.sub(new_section, text, count=1)
if new_text == text:
return "unchanged"
readme.write_text(new_text, encoding="utf-8")
return "updated"
# Erstmaliges Einfuegen nach dem ersten H1
m = H1_RE.search(text)
if not m:
# Kein H1 - oben einfuegen
new_text = new_section + "\n" + text
else:
end = m.end()
# Falls nach H1 noch eine Leerzeile, dahinter setzen
rest = text[end:]
# konsumiere genau eine Leerzeile, falls vorhanden
if rest.startswith("\n\n"):
insert_at = end + 2
elif rest.startswith("\n"):
insert_at = end + 1
else:
insert_at = end
new_text = text[:insert_at] + "\n" + new_section + "\n" + text[insert_at:]
readme.write_text(new_text, encoding="utf-8")
return "inserted"
def main() -> int:
if not TESTAKTEN_DIR.exists():
print(f"Testakten-Verzeichnis nicht gefunden: {TESTAKTEN_DIR}", file=sys.stderr)
return 1
stats = {"inserted": 0, "updated": 0, "unchanged": 0, "skip": 0}
for sub in sorted(TESTAKTEN_DIR.iterdir()):
if not sub.is_dir():
continue
readme = sub / "README.md"
if not readme.exists():
# Fallback: erstes 00_*.md oder aktenuebersicht*.md
candidates = sorted(sub.glob("00_*.md")) + sorted(sub.glob("aktenuebersicht*.md"))
if candidates:
readme = candidates[0]
else:
print(f" SKIP {sub.name}: keine README.md / 00_*.md")
stats["skip"] += 1
continue
result = inject(readme, sub.name)
key = result.split()[0] if result.startswith("skip") else result
if key not in stats:
key = "skip"
stats[key] += 1
print(f" {result.upper():<9} {sub.name}")
print(
f"\nFertig: {stats['inserted']} neu, {stats['updated']} aktualisiert, "
f"{stats['unchanged']} unveraendert, {stats['skip']} uebersprungen"
)
return 0
if __name__ == "__main__":
sys.exit(main())