S07: Iteration B3 und B3.5 fuer Teilgebiet 01 abgeschlossen. B3 in build/build-reference-docx.py ergaenzt: DocDefault widowControl plus keepNext und keepLines auf Heading 1/2/3 und FirstParagraph (Pandoc-Stil fuer ersten Absatz nach einem Heading, deckt die fett formatierten Kenntnisse-Subsection-Labels KI Software-Design Methodik IT etc ab). Erster Versuch Compact-Stil mit keepNext hat Listen komplett unteilbar gemacht (Job-Stationen begannen jedes Mal auf einer neuen Seite, ungenutzte Seitenenden) und wurde verworfen. Auf Wunsch von Thomas auf 3-3-Regel umgestellt: bei Listen mit mindestens 6 Bullets duerfen Trennungen passieren, aber mindestens 3 Bullets bleiben jeweils zusammen vor und nach dem Umbruch. Bei kuerzeren Listen alles zusammen. Da das stilbasiert nicht abbildbar ist (alle Bullets haben pStyle Compact), neues Post-Processing-Skript build/post-process-docx.py: scannt das fertige DOCX, findet Sequenzen aufeinanderfolgender Bullets mit numPr-Eigenschaft ausserhalb von Tabellen-Zellen, setzt keepNext auf den ersten 2 und den N-3 N-2 Bullets jeder Liste mit n groesser gleich 6 (bei n kleiner 6 alle keepNext). build.ps1 erweitert auf 3 Schritte und ruft das Post-Processing-Skript automatisch nach erfolgreichem DOCX-Build auf, mit Console-Output und Log-Statistiken (Anzahl Listen Bullets keepNext-Markierungen). Sandbox-Verifikation 26 Listen 184 Bullets 93 keepNext, Pattern fuer 11-Bullet-Liste KK......KK.. Auf Thomas System visuell bestaetigt: Listen werden an guten Stellen getrennt, keine ungenutzten Seitenenden, keine einzelnen Bullets allein am Seitenrand. teilgebiete/01-lebenslauf.md um B3- und B3.5-Bloecke ergaenzt sowie Naechste-Schritte-Liste auf B4 C D umstrukturiert. agent-prompt.md Aktueller-Stand-Abschnitt fortgeschrieben mit B3 und B3.5, Hinweis auf 3-stufige DOCX-Pipeline und Edit-Tool-Truncation an build.ps1 ergaenzt. Naechste Session startet mit B4 (Heading-Farben oder Trennlinien analog PDF).

This commit is contained in:
tlg
2026-04-26 16:40:20 +02:00
parent 3cec98d9d9
commit 8fa36ac88c
11 changed files with 319 additions and 74 deletions

View File

@@ -6,7 +6,7 @@ build-reference-docx.py
Baut die templates/reference.docx fuer die Pandoc-DOCX-Pipeline aus der
Pandoc-Default-Reference, mit gezielten Anpassungen.
Iteration B1 + B1.5 + B2 (aktuell):
Iteration B1 + B1.5 + B2 + B3 (aktuell):
B1 - Theme-Schriften (majorFont und minorFont) beide auf Calibri.
B1 - Direkte Schriftnamen-Referenzen in styles.xml auf Calibri
(Code-Schriften wie Consolas bleiben).
@@ -14,24 +14,20 @@ Iteration B1 + B1.5 + B2 (aktuell):
B1.5 - Body-DocDefault 11pt, Heading 1/2/3 auf 15/13/12 pt.
B2 - Header (Name links, "Lebenslauf" rechts) ab Seite 2; Seite 1 mit
leerem Header (titlePg-Mechanik). Footer (rechts: Seite n / m) auf
allen Seiten inkl. Seite 1 (footer-Ref fuer "first" zeigt auf den
gleichen Footer wie "default"). Page-Setup explizit: A4, Raender
analog PDF (top/bottom 2.2 cm, left/right 2.5 cm). Damit ist der
Tab-Stop deterministisch unabhaengig von Word-Locale-Defaults.
allen Seiten inkl. Seite 1. Page-Setup explizit: A4, Raender
analog PDF (top/bottom 2.2 cm, left/right 2.5 cm).
B3 - DocDefault widowControl. Heading 1/2/3 mit keepNext + keepLines.
Zusaetzlich 'FirstParagraph' (Pandoc-Stil fuer den ersten Absatz
nach einem Heading) — deckt die fett formatierten Kenntnisse-
Subsection-Labels ab. Hinweis: Listen-Bullet-Schutz (3-3-Regel)
passiert nicht hier, sondern im Post-Processing
(build/post-process-docx.py), das auf das fertige DOCX angewendet
wird — ein Stil kann keine Per-Bullet-Logik abbilden.
Geplant in Folge-Iterationen:
B3 - Heading-Stile mit "keep with next", Widow/Orphan-Control auf Stilebene
B4 - optional Heading-Farben auf DesTEngS-Blau analog PDF
Vorgehen:
1. Pandoc-Default-Reference per `pandoc --print-default-data-file
reference.docx` extrahieren.
2. Als ZIP entpacken.
3. Relevante XML-Dateien anpassen, neue Header/Footer-XMLs anlegen,
Beziehungen und ContentTypes ergaenzen, sectPr setzen.
4. Als neue ZIP-Datei (templates/reference.docx) speichern.
Voraussetzungen: nur Python-Stdlib + Pandoc im PATH.
C - Foto-Einbindung
D - Hyphenation-Feintuning fuer PDF
"""
from __future__ import annotations
@@ -44,15 +40,11 @@ import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
# --- Pfade -----------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
BASE_DIR = SCRIPT_DIR.parent
TEMPLATES_DIR = BASE_DIR / "templates"
OUTPUT_FILE = TEMPLATES_DIR / "reference.docx"
# --- XML-Namespaces --------------------------------------------------------
NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
@@ -67,8 +59,6 @@ ET.register_namespace("", NS["rel"])
W = "{%s}" % NS["w"]
A = "{%s}" % NS["a"]
# --- Konfiguration ---------------------------------------------------------
CODE_FONTS = {"consolas", "courier", "courier new", "liberation mono",
"monaco", "menlo", "fira mono", "fira code"}
TARGET_FONT = "Calibri"
@@ -80,25 +70,23 @@ SIZE_HEADING3 = 24
HEADING_SIZES = {"Heading1": SIZE_HEADING1,
"Heading2": SIZE_HEADING2,
"Heading3": SIZE_HEADING3}
# Compact NICHT mehr in dieser Liste — Listen-Bullet-Schutz uebernimmt das
# Post-Processing-Skript pro-Bullet.
KEEP_STYLES = ("Heading1", "Heading2", "Heading3", "FirstParagraph")
# Page-Setup (in DXA, 1cm = 566.929 dxa; 1 inch = 1440 dxa)
# A4: 21.0 x 29.7 cm
PAGE_W = 11906 # A4 Breite
PAGE_H = 16838 # A4 Hoehe
MARGIN_TOP = 1247 # 2.2 cm
MARGIN_BOT = 1247 # 2.2 cm
MARGIN_LEFT = 1417 # 2.5 cm
MARGIN_RIGHT = 1417 # 2.5 cm
HEADER_POS = 720 # 1.27 cm vom oberen Seitenrand
FOOTER_POS = 720 # 1.27 cm vom unteren Seitenrand
# Tab-Stop am rechten Textrand: PAGE_W - LEFT - RIGHT = 9072 dxa = 16 cm
PAGE_W = 11906
PAGE_H = 16838
MARGIN_TOP = 1247
MARGIN_BOT = 1247
MARGIN_LEFT = 1417
MARGIN_RIGHT = 1417
HEADER_POS = 720
FOOTER_POS = 720
HEADER_RIGHT_TAB = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT
HEADER_LEFT = "Dr.-Ing. Thomas Langer"
HEADER_RIGHT = "Lebenslauf"
# --- Hilfsfunktionen -------------------------------------------------------
def log(msg: str) -> None:
print(f"[build-reference-docx] {msg}", flush=True)
@@ -178,8 +166,6 @@ def replace_direct_fonts_in_styles(styles_xml: Path) -> None:
f" gesetzt (Code-Fonts unangetastet: {skipped})")
write_xml(tree, styles_xml)
# --- B1: Tabellen ----------------------------------------------------------
def set_table_borders_none(styles_xml: Path) -> None:
tree = ET.parse(styles_xml)
root = tree.getroot()
@@ -201,8 +187,6 @@ def set_table_borders_none(styles_xml: Path) -> None:
log(" Style 'Table': tblBorders=none auf allen Sides")
write_xml(tree, styles_xml)
# --- B1.5: Schriftgroessen ------------------------------------------------
def set_default_body_size(styles_xml: Path) -> None:
tree = ET.parse(styles_xml)
root = tree.getroot()
@@ -230,7 +214,35 @@ def set_heading_sizes(styles_xml: Path) -> None:
log(f" Stil {sid!r}: Schriftgroesse {target/2} pt")
write_xml(tree, styles_xml)
# --- B2: Header und Footer ------------------------------------------------
def set_widow_control_default(styles_xml: Path) -> None:
tree = ET.parse(styles_xml)
root = tree.getroot()
docDefaults = root.find(f"{W}docDefaults") or ET.SubElement(root, f"{W}docDefaults")
pPrDefault = docDefaults.find(f"{W}pPrDefault") or ET.SubElement(docDefaults, f"{W}pPrDefault")
pPr = pPrDefault.find(f"{W}pPr") or ET.SubElement(pPrDefault, f"{W}pPr")
if pPr.find(f"{W}widowControl") is None:
ET.SubElement(pPr, f"{W}widowControl")
log(" pPrDefault: widowControl aktiviert")
write_xml(tree, styles_xml)
def set_keep_next_styles(styles_xml: Path) -> None:
tree = ET.parse(styles_xml)
root = tree.getroot()
seen = set()
for style in root.findall(f"{W}style"):
sid = style.get(f"{W}styleId")
if sid not in KEEP_STYLES:
continue
pPr = style.find(f"{W}pPr") or ET.SubElement(style, f"{W}pPr")
for tag in (f"{W}keepNext", f"{W}keepLines"):
if pPr.find(tag) is None:
ET.SubElement(pPr, tag)
log(f" Stil {sid!r}: keepNext + keepLines")
seen.add(sid)
missing = set(KEEP_STYLES) - seen
if missing:
log(f" Hinweis: Stil(e) {sorted(missing)!r} nicht gefunden, uebersprungen")
write_xml(tree, styles_xml)
def header_default_xml() -> bytes:
return (
@@ -305,9 +317,6 @@ def update_sectpr_with_headers(document_xml: Path,
header_default_rid: str,
header_first_rid: str,
footer_default_rid: str) -> None:
"""Ersetzt sectPr durch Page-Setup + Header/Footer-Refs + titlePg.
Footer-Ref wird zweimal eingebaut (default und first), beide auf
den gleichen Footer — dann hat Seite 1 trotz titlePg den Footer."""
text = document_xml.read_text(encoding="utf-8")
new_sectpr = (
f'<w:sectPr>'
@@ -359,8 +368,6 @@ def add_header_footer(unpacked: Path) -> None:
update_sectpr_with_headers(doc_xml, rid_h_def, rid_h_first, rid_f_def)
# --- Hauptablauf -----------------------------------------------------------
def main() -> int:
log(f"Ziel: {OUTPUT_FILE}")
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
@@ -387,6 +394,10 @@ def main() -> int:
set_default_body_size(styles_xml)
log("Anpassung: Heading-Schriftgroessen 15/13/12 pt")
set_heading_sizes(styles_xml)
log("Anpassung: Widow/Orphan-Control im DocDefault (B3)")
set_widow_control_default(styles_xml)
log("Anpassung: keepNext + keepLines auf Heading 1/2/3 + FirstParagraph (B3)")
set_keep_next_styles(styles_xml)
log("Anpassung: Header und Footer einbauen (B2)")
add_header_footer(unpacked)

View File

@@ -7,17 +7,24 @@
Deterministischer Build ohne GUI oder Komfortfunktionen.
- PDF via Pandoc + LuaLaTeX, nutzt templates/template.tex
- DOCX via Pandoc, nutzt templates/reference.docx
- DOCX-Post-Processing via build/post-process-docx.py
(Listen-Bullet-Schutz nach 3-3-Regel)
- Log in output/build.log (ueberschrieben pro Build)
- Fortschritt wird zusaetzlich in der Konsole angezeigt
- Exit-Code 0 = beide Ausgaben erfolgreich, 1 = ein oder beide Schritte fehlgeschlagen
- Exit-Code 0 = alle Schritte erfolgreich, 1 = mindestens ein Fehler
.NOTES
Voraussetzungen auf dem System:
- Pandoc (im PATH)
- MiKTeX mit LuaLaTeX
- Python 3 (im PATH) fuer Post-Processing
- System-Fonts: IBM Plex Sans und IBM Plex Mono fuer Windows installiert
MiKTeX mit "Install missing packages on the fly: Yes" zieht fehlende
LaTeX-Pakete beim ersten Lauf automatisch.
Hinweis: templates/reference.docx wird NICHT bei jedem Build neu gebaut.
Bei Stiländerungen vorher manuell `python build/build-reference-docx.py`
aufrufen.
#>
$ErrorActionPreference = 'Continue'
@@ -77,7 +84,7 @@ if ($overallExit -ne 0) {
# --- PDF-Build ---------------------------------------------------------------
Write-Host ""
Write-Host "[1/2] PDF wird erzeugt (Pandoc + LuaLaTeX) ..." -ForegroundColor Yellow
Write-Host "[1/3] PDF wird erzeugt (Pandoc + LuaLaTeX) ..." -ForegroundColor Yellow
Write-Log "--- Pandoc -> PDF (LuaLaTeX) ---"
$pdfArgs = @(
'--from=markdown+smart',
@@ -99,13 +106,12 @@ if ($pdfExit -eq 0 -and (Test-Path $outputPdf)) {
Write-Host " PDF FEHLER (Exit $pdfExit) - Details siehe build.log" -ForegroundColor Red
Write-Log "PDF FEHLER (Exit $pdfExit)"
$overallExit = 1
# Kurz pausieren, damit die rote Fehlerzeile lesbar bleibt, falls das Fenster danach zugeht
Start-Sleep -Seconds 3
}
# --- DOCX-Build --------------------------------------------------------------
Write-Host ""
Write-Host "[2/2] DOCX wird erzeugt (Pandoc) ..." -ForegroundColor Yellow
Write-Host "[2/3] DOCX wird erzeugt (Pandoc) ..." -ForegroundColor Yellow
Write-Log "--- Pandoc -> DOCX ---"
$docxArgs = @(
'--from=markdown+smart',
@@ -122,11 +128,33 @@ if ($docxExit -eq 0 -and (Test-Path $outputDocx)) {
$sizeKB = [math]::Round((Get-Item $outputDocx).Length / 1KB, 1)
Write-Host " DOCX OK ($sizeKB KB): $outputDocx" -ForegroundColor Green
Write-Log "DOCX OK: $outputDocx ($sizeKB KB)"
# --- Post-Processing: Listen-Bullet-Schutz (3-3-Regel) ------------------
Write-Host ""
Write-Host "[3/3] DOCX-Post-Processing (Listen-Bullet-Schutz) ..." -ForegroundColor Yellow
Write-Log "--- Post-Process DOCX ---"
$postScript = Join-Path $scriptDir 'post-process-docx.py'
if (Test-Path $postScript) {
$ppOutput = & python $postScript 2>&1
$ppExit = $LASTEXITCODE
$ppOutput | ForEach-Object {
Write-Log ([string]$_)
Write-Host " $_"
}
if ($ppExit -ne 0) {
Write-Host " POST-PROCESS FEHLER (Exit $ppExit)" -ForegroundColor Red
Write-Log "POST-PROCESS FEHLER (Exit $ppExit)"
$overallExit = 1
Start-Sleep -Seconds 3
}
} else {
Write-Host " Hinweis: $postScript nicht vorhanden, uebersprungen" -ForegroundColor Yellow
Write-Log "Hinweis: post-process-docx.py nicht vorhanden, uebersprungen"
}
} else {
Write-Host " DOCX FEHLER (Exit $docxExit) - Details siehe build.log" -ForegroundColor Red
Write-Log "DOCX FEHLER (Exit $docxExit)"
$overallExit = 1
# Kurz pausieren, damit die rote Fehlerzeile lesbar bleibt, falls das Fenster danach zugeht
Start-Sleep -Seconds 3
}

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
post-process-docx.py
====================
Wird auf das von Pandoc erzeugte DOCX angewendet, NACH `build.ps1`. Setzt
Per-Bullet-keepNext-Markierungen, die ein Stil nicht abbilden kann:
3-3-Regel fuer Listen-Bullets:
- Eine Liste ist eine Sequenz aufeinanderfolgender Absaetze mit
<w:numPr>-Eigenschaft im Body (nicht innerhalb von Tabellen-Zellen).
- Bei einer Liste mit weniger als 6 Bullets: alle Bullets bekommen
<w:keepNext/> (Liste bleibt unteilbar — bei <6 ist die 3-3-Regel
sowieso nur durch Zusammenhalten aller erfuellbar).
- Bei einer Liste mit 6 oder mehr Bullets: die ersten 2 und die
drittletzten und vorletzten Bullets bekommen <w:keepNext/>.
Damit gilt: nach Bullet 1 darf nicht getrennt werden (1+2+3 zusammen),
und nach Bullet N-3 darf nicht getrennt werden (N-2+N-1+N zusammen).
Trennen ist erlaubt zwischen den Bullets in der Mitte.
Bullets in Tabellen-Zellen werden uebersprungen — Compact wird auch fuer
Tabellen-Zellen-Inhalte verwendet, dort wollen wir kein keepNext.
Voraussetzungen: nur Python-Stdlib.
"""
from __future__ import annotations
import re
import sys
import zipfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
BASE_DIR = SCRIPT_DIR.parent
DOCX_FILE = BASE_DIR / "output" / "Lebenslauf_Dr-Ing_Thomas_Langer.docx"
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def log(msg: str) -> None:
print(f"[post-process-docx] {msg}", flush=True)
def is_bullet_paragraph(p_xml: str) -> bool:
"""True wenn Absatz-XML eine numPr-Eigenschaft hat (= Listen-Bullet)."""
return "<w:numPr" in p_xml
def has_keep_next(p_xml: str) -> bool:
return "<w:keepNext" in p_xml
def add_keep_next(p_xml: str) -> str:
"""Fuegt <w:keepNext/> in das pPr-Element ein. Falls kein pPr existiert,
wird es angelegt. Idempotent (wenn schon vorhanden, unveraendert)."""
if has_keep_next(p_xml):
return p_xml
if "<w:pPr>" in p_xml:
return p_xml.replace("<w:pPr>", "<w:pPr><w:keepNext/>", 1)
if "<w:pPr/>" in p_xml:
return p_xml.replace("<w:pPr/>", "<w:pPr><w:keepNext/></w:pPr>", 1)
# kein pPr: vor <w:r ...> oder vor </w:p>
new_ppr = "<w:pPr><w:keepNext/></w:pPr>"
if "<w:r" in p_xml:
return p_xml.replace("<w:p>", "<w:p>" + new_ppr, 1) \
if p_xml.startswith("<w:p>") else p_xml
return p_xml.replace("</w:p>", new_ppr + "</w:p>", 1)
# Regex: ein <w:p ...>...</w:p>, optional gefolgt vom oeffnenden Marker fuer
# Tabelle (<w:tbl>) oder schliessenden Body (</w:body>). Wir splitten nicht,
# sondern iterieren paragraphenweise und tracken Tabellen-Schachtelung.
P_RE = re.compile(r"<w:p\b[^>]*>.*?</w:p>", re.DOTALL)
TBL_OPEN = "<w:tbl>"
TBL_CLOSE = "</w:tbl>"
def process_document_xml(xml: str) -> tuple[str, dict]:
"""Findet Listen-Sequenzen ausserhalb von Tabellen, wendet 3-3-Regel an.
Gibt das modifizierte XML und Statistiken zurueck."""
# Tokenize: <w:tbl>...</w:tbl>-Bereiche markieren, damit wir sie ueberspringen.
# Ansatz: wir gehen durch das XML und tracken aktuelle Tabellen-Tiefe.
# Wenn Tiefe > 0: Bullets in Tabellen-Zellen ueberspringen.
out = []
pos = 0
table_depth = 0
bullet_run: list[tuple[int, str]] = [] # (out_idx, p_xml) Indizes in out
stats = {"lists": 0, "bullets_in_lists": 0, "bullets_keepnext": 0,
"skipped_in_tables": 0}
def flush_run():
if not bullet_run:
return
n = len(bullet_run)
stats["lists"] += 1
stats["bullets_in_lists"] += n
if n < 6:
indices_keep = list(range(n))
else:
indices_keep = [0, 1, n-3, n-2]
for k in indices_keep:
idx, p_xml = bullet_run[k]
new_xml = add_keep_next(p_xml)
if new_xml != p_xml:
out[idx] = new_xml
stats["bullets_keepnext"] += 1
bullet_run.clear()
# Wir scannen das XML linear nach <w:p ...>...</w:p>, <w:tbl>, </w:tbl>
# und sammeln Bullet-Sequenzen ausserhalb von Tabellen.
# Dafuer iterieren wir mit einem regex der ALLE drei Token findet.
token_re = re.compile(
r"(?P<tblopen>" + re.escape(TBL_OPEN) + r")"
r"|(?P<tblclose>" + re.escape(TBL_CLOSE) + r")"
r"|(?P<para><w:p\b[^>]*>.*?</w:p>)",
re.DOTALL,
)
last_end = 0
for m in token_re.finditer(xml):
# nicht-tokenisierten Text dazwischen anhaengen
if m.start() > last_end:
out.append(xml[last_end:m.start()])
last_end = m.end()
if m.group("tblopen"):
flush_run() # Listen vor Tabelle abschliessen
table_depth += 1
out.append(m.group())
elif m.group("tblclose"):
flush_run() # innerhalb-Tabellen-Listen wir flushen, aber haben
# sie eh nicht angesammelt
table_depth -= 1
out.append(m.group())
else:
p_xml = m.group("para")
out.append(p_xml)
if table_depth > 0:
# Bullets in Tabellen-Zellen ignorieren
if is_bullet_paragraph(p_xml):
stats["skipped_in_tables"] += 1
# nicht-bullet-paragraph in tabelle: kein effekt
continue
if is_bullet_paragraph(p_xml):
bullet_run.append((len(out) - 1, p_xml))
else:
# Sequenz-Ende: 3-3-Regel anwenden
flush_run()
# Rest hinten dranhaengen
if last_end < len(xml):
out.append(xml[last_end:])
flush_run() # falls Liste am Body-Ende
return "".join(out), stats
def main() -> int:
if not DOCX_FILE.exists():
sys.stderr.write(f"FEHLER: {DOCX_FILE} existiert nicht. "
f"Erst build.ps1 laufen lassen.\n")
return 1
log(f"Verarbeite: {DOCX_FILE}")
# DOCX in memory einlesen
with zipfile.ZipFile(DOCX_FILE, "r") as z:
members = {name: z.read(name) for name in z.namelist()}
doc_xml = members["word/document.xml"].decode("utf-8")
new_xml, stats = process_document_xml(doc_xml)
if new_xml == doc_xml:
log(" keine Aenderung — keine bullet-Listen gefunden oder bereits gesetzt")
members["word/document.xml"] = new_xml.encode("utf-8")
# DOCX zurueckschreiben (mode='w' truncatet)
with zipfile.ZipFile(DOCX_FILE, "w", zipfile.ZIP_DEFLATED) as z:
# [Content_Types].xml zuerst
order = sorted(members.keys(),
key=lambda n: (0 if n == "[Content_Types].xml" else 1, n))
for name in order:
z.writestr(name, members[name])
log(f" Listen gefunden: {stats['lists']}")
log(f" Bullets in Listen: {stats['bullets_in_lists']}")
log(f" keepNext gesetzt: {stats['bullets_keepnext']}")
log(f" Bullets in Tabellen uebersprungen: {stats['skipped_in_tables']}")
log("Fertig.")
return 0
if __name__ == "__main__":
sys.exit(main())