template formatting improved #31

Merged
samuelbias merged 2 commits from dev into main 2026-09-06 05:24:47 +02:00
6 changed files with 90 additions and 31 deletions
+2
View File
@@ -51,6 +51,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \ /var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \ /var/www/html/intranet_repo/projects/dashboard/month \
&& chown -R www-data:www-data \ && chown -R www-data:www-data \
/data \ /data \
@@ -58,6 +59,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \ /var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \ /var/www/html/intranet_repo/projects/dashboard/month \
&& chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py && chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py
@@ -74,6 +74,7 @@ body.dashboard {
.text-page { color: var(--dashboard-text); } .text-page { color: var(--dashboard-text); }
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); } .text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
.text-page p:last-child { margin-bottom: 0; } .text-page p:last-child { margin-bottom: 0; }
.text-page .announcement-title { font-size: var(--announcement-detail-font); }
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; } .text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); } .text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); } .text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
+51 -21
View File
@@ -35,7 +35,7 @@ def validate_template(template: Path) -> None:
_placeholders(minidom.parseString(archive.read('word/document.xml'))) _placeholders(minidom.parseString(archive.read('word/document.xml')))
def _set_text(paragraph, text): def _set_text(paragraph, text):
if any(ord(char) < 32 and char not in '\t\n\r' for char in text): if any(ord(char) < 32 and char not in '\t\n\r' for char in text):
raise TemplateError('Text enthält ungültige Steuerzeichen.') raise TemplateError('Text enthält ungültige Steuerzeichen.')
nodes = paragraph.getElementsByTagNameNS(W, 't') nodes = paragraph.getElementsByTagNameNS(W, 't')
@@ -44,22 +44,49 @@ def _set_text(paragraph, text):
node.removeChild(node.firstChild) node.removeChild(node.firstChild)
document, anchor = paragraph.ownerDocument, nodes[0] document, anchor = paragraph.ownerDocument, nodes[0]
text = text.replace('\r\n', '\n').replace('\r', '\n') text = text.replace('\r\n', '\n').replace('\r', '\n')
for part in re.split(r'(\n|\t)', text): for part in re.split(r'(\n|\t)', text):
if part in ('\n', '\t'): if part in ('\n', '\t'):
node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab') node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab')
else: else:
node = document.createElementNS(W, 'w:t') node = document.createElementNS(W, 'w:t')
node.setAttribute('xml:space', 'preserve') node.setAttribute('xml:space', 'preserve')
node.appendChild(document.createTextNode(part)) node.appendChild(document.createTextNode(part))
anchor.parentNode.insertBefore(node, anchor) anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor) anchor.parentNode.removeChild(anchor)
# Preserve verse/signature lines without stretching them to the full width. if '\n' in text:
if '\n' in text: paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
alignment.setAttributeNS(W, 'w:val', 'left')
def _items(text):
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None: bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
if not any(bullets):
return [(text, None)]
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
for i, (line, bullet) in enumerate(zip(lines, bullets))]
def _format_bullet(paragraph, compact):
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
document = paragraph.ownerDocument
tabs = document.createElementNS(W, 'w:tabs')
tab = document.createElementNS(W, 'w:tab')
tab.setAttributeNS(W, 'w:val', 'left')
tab.setAttributeNS(W, 'w:pos', '480')
tabs.appendChild(tab)
properties.insertBefore(tabs, anchor)
if compact:
spacing = document.createElementNS(W, 'w:spacing')
spacing.setAttributeNS(W, 'w:after', '0')
properties.insertBefore(spacing, anchor)
indent = document.createElementNS(W, 'w:ind')
indent.setAttributeNS(W, 'w:left', '480')
indent.setAttributeNS(W, 'w:hanging', '360')
properties.insertBefore(indent, anchor)
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
paragraphs = [p.strip() for p in paragraphs if p.strip()] paragraphs = [p.strip() for p in paragraphs if p.strip()]
if not title.strip() or not date_label.strip() or not paragraphs: if not title.strip() or not date_label.strip() or not paragraphs:
raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.') raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.')
@@ -69,16 +96,19 @@ def fill_template(template: Path, output: Path, title: str, date_label: str, par
_set_text(placeholders[TITLE_TOKEN], title.strip()) _set_text(placeholders[TITLE_TOKEN], title.strip())
_set_text(placeholders[DATE_TOKEN], date_label.strip()) _set_text(placeholders[DATE_TOKEN], date_label.strip())
body = placeholders[BODY_TOKEN] body = placeholders[BODY_TOKEN]
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')} used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
index = 1 index = 1
for text in paragraphs: for text in paragraphs:
paragraph = body.cloneNode(deep=True) for value, compact in _items(text):
while f'{index:08X}' in used: paragraph = body.cloneNode(deep=True)
index += 1 while f'{index:08X}' in used:
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}') index += 1
used.add(f'{index:08X}') paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
_set_text(paragraph, text) used.add(f'{index:08X}')
body.parentNode.insertBefore(paragraph, body) _set_text(paragraph, f'•\t{value}' if compact is not None else value)
if compact is not None:
_format_bullet(paragraph, compact)
body.parentNode.insertBefore(paragraph, body)
body.parentNode.removeChild(body) body.parentNode.removeChild(body)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, 'w') as target: with zipfile.ZipFile(output, 'w') as target:
@@ -139,7 +139,7 @@ class PipelineTests(unittest.TestCase):
self.assertEqual(xml.count(DATE_TOKEN), 1) self.assertEqual(xml.count(DATE_TOKEN), 1)
self.assertEqual(xml.count(BODY_TOKEN), 1) self.assertEqual(xml.count(BODY_TOKEN), 1)
def test_original_line_breaks_use_word_elements(self): def test_original_line_breaks_use_word_elements(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'lines.docx' target = Path(temp) / 'lines.docx'
fill_template(TEMPLATE, target, 'Gruß', _date_label('2026-09-02'), ['Von Herzen\nEure Gastgeber']) fill_template(TEMPLATE, target, 'Gruß', _date_label('2026-09-02'), ['Von Herzen\nEure Gastgeber'])
@@ -148,10 +148,34 @@ class PipelineTests(unittest.TestCase):
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
paragraph = next(p for p in document.findall('.//w:p', ns) if p.find('.//w:br', ns) is not None) paragraph = next(p for p in document.findall('.//w:p', ns) if p.find('.//w:br', ns) is not None)
self.assertEqual(len(paragraph.findall('.//w:br', ns)), 1) self.assertEqual(len(paragraph.findall('.//w:br', ns)), 1)
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber']) self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left') self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
def test_template_fill_creates_multiple_paragraphs(self): def test_bullet_lines_become_hanging_list_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'bullets.docx'
fill_template(TEMPLATE, target, 'Einladung', _date_label('2026-09-04'), [
'Einleitung.',
'SEPTEMBER\n•05.09.2026: Erster Stichpunkt.\n• Zweiter Stichpunkt.\nOKTOBER\n• Dritter Stichpunkt.',
'Abschluss.',
])
with zipfile.ZipFile(target) as archive:
document = ET.fromstring(archive.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
q = lambda name: '{' + ns['w'] + '}' + name
bullets = [paragraph for paragraph in document.findall('.//w:p', ns)
if ''.join(paragraph.itertext()).startswith('•')]
self.assertEqual(len(bullets), 3)
self.assertTrue({'SEPTEMBER', 'OKTOBER'} <= {''.join(p.itertext()) for p in document.findall('.//w:p', ns)})
for paragraph in bullets:
self.assertIsNotNone(paragraph.find('w:r/w:tab', ns))
self.assertEqual(paragraph.find('w:pPr/w:tabs/w:tab', ns).get(q('pos')), '480')
indent = paragraph.find('w:pPr/w:ind', ns)
self.assertEqual((indent.get(q('left')), indent.get(q('hanging'))), ('480', '360'))
self.assertEqual(bullets[0].find('w:pPr/w:spacing', ns).get(q('after')), '0')
self.assertIsNone(bullets[1].find('w:pPr/w:spacing', ns))
def test_template_fill_creates_multiple_paragraphs(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / "filled.docx" target = Path(temp) / "filled.docx"
paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)] paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)]
@@ -32,11 +32,13 @@ class RenderingTests(unittest.TestCase):
cls.document = cls.output / "verified.docx" cls.document = cls.output / "verified.docx"
with zipfile.ZipFile(TEMPLATE) as archive: with zipfile.ZipFile(TEMPLATE) as archive:
(cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png")) (cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png"))
paragraphs = [ paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip() ("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
for _ in range(3) for _ in range(3)
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber", ] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."] "• Ein zweiter Stichpunkt bestätigt den gleichmäßigen Abstand der Liste.",
"Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
fill_template(TEMPLATE, cls.document, fill_template(TEMPLATE, cls.document,
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf", "Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
_date_label("2026-09-02"), paragraphs) _date_label("2026-09-02"), paragraphs)