S08: Teilgebiet 01 Iteration B4 fuer DOCX umgesetzt. Heading 1/2/3 in destengsblue (build/build-reference-docx.py Funktion set_heading_colors mit explizitem color val=0B5394, themeColor accent1 entfernt). Heading-Bottom-Borders direkt am Stil verworfen, weil Word die Border bei hanging-Indent linksbuendig statt zentriert rendert und der right-Indent sowohl Text als auch Border begrenzt. 21 Markdown-HRs aus cv.md entfernt - Quelle der wahrgenommenen Doppellinien war Pandocs DOCX-Konvertierung von --- Zeilen zu VML-rect mit o:hr=t (Embossed-Look). Tabellen-Strich-Zeilen blieben unangetastet. Zwischenfall: NTFS-Mount-Stale-Read der cv.md (20043 statt 20201 Bytes) haette fast die Live-Datei truncated, sofortige Wiederherstellung aus git show HEAD und HR-Removal erneut mit git-Version als Input. H2-Trennlinien via Post-Processing eingefuehrt (build/post-process-docx.py um Logik erweitert): nach jedem H2 wird ein leerer Trenn-Absatz mit linksbuendiger Bottom-Border eingefuegt, schwarz (000000), 8,6 cm Linienlaenge (right-Indent 4196 dxa), 1,25 pt Dicke (sz=10). Sandbox-Verifikation 7 H2 zu 7 Trenner. Visuelle Bestaetigung durch Thomas. teilgebiete/01-lebenslauf.md um Iteration-B4-Block ergaenzt (B4.1 Farben, B4.2 Heading-Border-Sackgasse, B4.3 HR-Removal inkl. Zwischenfall, B4.4 H2-Trennlinien) und Naechste-Schritte-Liste auf C/D verkuerzt.

This commit is contained in:
tlg
2026-04-26 20:35:41 +02:00
parent 8fa36ac88c
commit 6429ca5f84
12 changed files with 478 additions and 455 deletions

View File

@@ -3,23 +3,33 @@
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:
Wird auf das von Pandoc erzeugte DOCX angewendet, NACH `build.ps1`. Macht
zwei XML-Modifikationen, die ein Stil oder die `reference.docx` nicht
abbilden koennen:
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.
1. 3-3-Regel fuer Listen-Bullets (B3.5):
- 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.
Bullets in Tabellen-Zellen werden uebersprungen — Compact wird auch fuer
Tabellen-Zellen-Inhalte verwendet, dort wollen wir kein keepNext.
2. H2-Trennlinie (S08):
- Nach jedem H2-Absatz wird ein leerer Trenn-Absatz eingefuegt.
- Trenn-Absatz: linksbuendige Bottom-Border, schwarz (000000),
1,25 pt (sz=10), 8,6 cm Linienlaenge (right-Indent 4196 dxa bei
9072 dxa Textbreite).
- Run-Properties auf sz=2 (1 pt), damit der Absatz selbst minimale
Hoehe hat.
- Ein schmaler-als-Heading-Border ist ueber den Heading-Stil selbst
nicht moeglich, weil Words right-Indent sowohl Text als auch
Border begrenzt. Deshalb separater Trenn-Absatz.
Voraussetzungen: nur Python-Stdlib.
"""
@@ -37,52 +47,64 @@ 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:
# H2-Trenn-Absatz: linksbuendige Bottom-Border, schwarz, 8,6 cm lang, 1,25 pt dick.
# Textbreite = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT = 11906 - 1417 - 1417 = 9072 dxa
# 8,6 cm = 8,6 * 567 dxa/cm = 4876 dxa
# right-Indent = 9072 - 4876 = 4196 dxa
# Border sz ist in 1/8 pt: 1,25 pt * 8 = 10
H2_SEP_XML = (
'<w:p>'
'<w:pPr>'
'<w:spacing w:before="0" w:after="80"/>'
'<w:ind w:right="4196"/>'
'<w:pBdr>'
'<w:bottom w:val="single" w:sz="10" w:space="2" w:color="000000"/>'
'</w:pBdr>'
'<w:rPr><w:sz w:val="2"/><w:szCs w:val="2"/></w:rPr>'
'</w:pPr>'
'</w:p>'
)
H2_STYLE_RE = re.compile(r'<w:pStyle\s+w:val="Heading2"\s*/?>')
def log(msg):
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)."""
def is_bullet_paragraph(p_xml):
return "<w:numPr" in p_xml
def has_keep_next(p_xml: str) -> bool:
def is_h2_paragraph(p_xml):
return bool(H2_STYLE_RE.search(p_xml))
def has_keep_next(p_xml):
return "<w:keepNext" in p_xml
def add_keep_next(p_xml: str) -> str:
def add_keep_next(p_xml):
"""Fuegt <w:keepNext/> in das pPr-Element ein. Falls kein pPr existiert,
wird es angelegt. Idempotent (wenn schon vorhanden, unveraendert)."""
wird es angelegt. Idempotent."""
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
if "<w:r" in p_xml and p_xml.startswith("<w:p>"):
return p_xml.replace("<w:p>", "<w:p>" + new_ppr, 1)
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)
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.
def process_document_xml(xml):
"""Tokenisiert den Body, wendet 3-3-Regel auf Bullet-Listen an und
fuegt nach jedem H2-Heading einen Trenn-Absatz ein."""
out = []
pos = 0
bullet_run = []
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}
"skipped_in_tables": 0, "h2_headings": 0, "separators_added": 0}
def flush_run():
if not bullet_run:
@@ -102,9 +124,6 @@ def process_document_xml(xml: str) -> tuple[str, dict]:
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")"
@@ -113,49 +132,46 @@ def process_document_xml(xml: str) -> tuple[str, dict]:
)
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
flush_run()
table_depth += 1
out.append(m.group())
elif m.group("tblclose"):
flush_run() # innerhalb-Tabellen-Listen wir flushen, aber haben
# sie eh nicht angesammelt
flush_run()
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()
continue
flush_run()
if is_h2_paragraph(p_xml):
out.append(H2_SEP_XML)
stats["h2_headings"] += 1
stats["separators_added"] += 1
# Rest hinten dranhaengen
if last_end < len(xml):
out.append(xml[last_end:])
flush_run() # falls Liste am Body-Ende
flush_run()
return "".join(out), stats
def main() -> int:
def main():
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()}
@@ -163,12 +179,10 @@ def main() -> int:
new_xml, stats = process_document_xml(doc_xml)
if new_xml == doc_xml:
log(" keine Aenderung — keine bullet-Listen gefunden oder bereits gesetzt")
log(" keine Aenderung")
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:
@@ -178,6 +192,8 @@ def main() -> int:
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(f" H2-Headings gefunden: {stats['h2_headings']}")
log(f" H2-Trenn-Absaetze eingefuegt: {stats['separators_added']}")
log("Fertig.")
return 0