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 7b9f04f..2133f2e 100644 Binary files a/web/intranet_repo/projects/contentpanel/insertion/template/aushang-template.docx and b/web/intranet_repo/projects/contentpanel/insertion/template/aushang-template.docx differ 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)