From 055b6d10c47fde66ad3b93b97c7fe568e74731fe Mon Sep 17 00:00:00 2001 From: samuelbiasgit Date: Sun, 6 Sep 2026 05:17:37 +0200 Subject: [PATCH] contentpanel - template formatting improved --- Dockerfile | 2 + .../insertion/template/aushang-template.docx | Bin 66592 -> 66620 bytes .../projects/dashboard/stylo.css | 1 + worker/contentpanel_aushang/docx_template.py | 72 +++++++++++++----- .../tests/test_pipeline.py | 34 +++++++-- .../tests/test_rendering.py | 12 +-- 6 files changed, 90 insertions(+), 31 deletions(-) diff --git a/Dockerfile b/Dockerfile index d677ba9..11318e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,7 @@ RUN mkdir -p \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/seelsorge/data \ + /var/www/html/intranet_repo/projects/dashboard/cache \ /var/www/html/intranet_repo/projects/dashboard/month \ && chown -R www-data:www-data \ /data \ @@ -58,6 +59,7 @@ RUN mkdir -p \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/seelsorge/data \ + /var/www/html/intranet_repo/projects/dashboard/cache \ /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 diff --git a/web/intranet_repo/projects/contentpanel/insertion/template/aushang-template.docx b/web/intranet_repo/projects/contentpanel/insertion/template/aushang-template.docx index 7b9f04f6e6357f155d7512c95e12c5e83d0e375a..2133f2ee41f7e8bb46ae1f2e92ae49e1880b6ddd 100644 GIT binary patch delta 4288 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 diff --git a/web/intranet_repo/projects/dashboard/stylo.css b/web/intranet_repo/projects/dashboard/stylo.css index ae36a68..f00e155 100644 --- a/web/intranet_repo/projects/dashboard/stylo.css +++ b/web/intranet_repo/projects/dashboard/stylo.css @@ -74,6 +74,7 @@ body.dashboard { .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: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-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); } diff --git a/worker/contentpanel_aushang/docx_template.py b/worker/contentpanel_aushang/docx_template.py index 4967891..ff56445 100644 --- a/worker/contentpanel_aushang/docx_template.py +++ b/worker/contentpanel_aushang/docx_template.py @@ -35,7 +35,7 @@ def validate_template(template: Path) -> None: _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): raise TemplateError('Text enthält ungültige Steuerzeichen.') nodes = paragraph.getElementsByTagNameNS(W, 't') @@ -44,22 +44,49 @@ def _set_text(paragraph, text): node.removeChild(node.firstChild) document, anchor = paragraph.ownerDocument, nodes[0] 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'): node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab') else: node = document.createElementNS(W, 'w:t') node.setAttribute('xml:space', 'preserve') node.appendChild(document.createTextNode(part)) - anchor.parentNode.insertBefore(node, anchor) - anchor.parentNode.removeChild(anchor) - # Preserve verse/signature lines without stretching them to the full width. - if '\n' in text: - for alignment in paragraph.getElementsByTagNameNS(W, 'jc'): - alignment.setAttributeNS(W, 'w:val', 'left') - - -def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None: + anchor.parentNode.insertBefore(node, anchor) + anchor.parentNode.removeChild(anchor) + if '\n' in text: + paragraph.getElementsByTagNameNS(W, 'jc')[0].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()] + 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()] if not title.strip() or not date_label.strip() or not paragraphs: 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[DATE_TOKEN], date_label.strip()) body = placeholders[BODY_TOKEN] - used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')} - index = 1 - for text in paragraphs: - paragraph = body.cloneNode(deep=True) - while f'{index:08X}' in used: - index += 1 - paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}') - used.add(f'{index:08X}') - _set_text(paragraph, text) - body.parentNode.insertBefore(paragraph, body) + used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')} + index = 1 + for text in paragraphs: + for value, compact in _items(text): + paragraph = body.cloneNode(deep=True) + while f'{index:08X}' in used: + index += 1 + paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}') + used.add(f'{index:08X}') + _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) output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, 'w') as target: diff --git a/worker/contentpanel_aushang/tests/test_pipeline.py b/worker/contentpanel_aushang/tests/test_pipeline.py index 7aa6855..f7c0da0 100644 --- a/worker/contentpanel_aushang/tests/test_pipeline.py +++ b/worker/contentpanel_aushang/tests/test_pipeline.py @@ -139,7 +139,7 @@ class PipelineTests(unittest.TestCase): self.assertEqual(xml.count(DATE_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: target = Path(temp) / 'lines.docx' 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'} 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([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') - - def test_template_fill_creates_multiple_paragraphs(self): + 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') + + 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: target = Path(temp) / "filled.docx" paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)] diff --git a/worker/contentpanel_aushang/tests/test_rendering.py b/worker/contentpanel_aushang/tests/test_rendering.py index dc08d03..3f00b92 100644 --- a/worker/contentpanel_aushang/tests/test_rendering.py +++ b/worker/contentpanel_aushang/tests/test_rendering.py @@ -32,11 +32,13 @@ class RenderingTests(unittest.TestCase): cls.document = cls.output / "verified.docx" with zipfile.ZipFile(TEMPLATE) as archive: (cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png")) - paragraphs = [ - ("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip() - for _ in range(3) - ] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber", - "Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."] + paragraphs = [ + ("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip() + for _ in range(3) + ] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n" + "• 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, "Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf", _date_label("2026-09-02"), paragraphs)