"""Integration checks against the production Chromium/Rust renderer, no LLM.""" from __future__ import annotations import json import os import shutil import sys import tempfile import threading import unittest import zipfile from pathlib import Path from unittest.mock import patch from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from betteroffice_render import RenderError, _check_renderer, _server, render from docx_template import fill_template from process import _date_label TEMPLATE = Path(os.getenv("CONTENTPANEL_TEST_TEMPLATE", "/var/www/html/intranet_repo/projects/contentpanel/insertion/template/aushang-template.docx")) class RenderingTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.temp = tempfile.TemporaryDirectory(prefix="contentpanel_render_test_") cls.output = Path(os.getenv("CONTENTPANEL_TEST_OUTPUT", cls.temp.name)) cls.output.mkdir(parents=True, exist_ok=True) 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) ] + ["• 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) with patch.dict(os.environ, {"CONTENTPANEL_RENDER_SCALE": "1"}): cls.page_count = render(cls.document, cls.output) cls.metrics = [] server = _server(cls.output, cls.document, ROOT / "fonts") thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True, args=["--disable-dev-shm-usage"]) page = browser.new_page() for number in range(1, cls.page_count + 1): page.goto(f"http://127.0.0.1:{server.server_address[1]}/page-{number}.png") page.wait_for_function("document.querySelector('img')?.complete") cls.metrics.append(page.evaluate("""async () => { const img = document.querySelector('img'); const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const {data} = ctx.getImageData(0, 0, canvas.width, canvas.height); const blue = {left:canvas.width, top:canvas.height, right:0, bottom:0, count:0}; const rows = Array(canvas.height).fill(0); let rightOverflow = 0; for (let y=0;y130 && r<90 && g>60 && b>g+20) { blue.left=Math.min(blue.left,x); blue.right=Math.max(blue.right,x); blue.top=Math.min(blue.top,y); blue.bottom=Math.max(blue.bottom,y); blue.count++; } if (r<100 && g<100 && b<100) { if (x>=50 && x<725) rows[y]++; if (x>=725 && y>300 && y<930) rightOverflow++; } } const bands=[]; let start=null; rows.forEach((count,y)=>{ if(count>=4 && start===null) start=y; if((count<4 || y===rows.length-1) && start!==null) { bands.push({top:start,bottom:y-1,height:y-start}); start=null; } }); // Compare the full white symbol, not just its blue box. // A shifted duplicate used to pass the position check. const reference = new Image(); reference.src = '/original-logo.png'; await reference.decode(); const refCanvas = document.createElement('canvas'); const w=blue.right-blue.left+1, h=blue.bottom-blue.top+1; refCanvas.width=w; refCanvas.height=h; const refCtx=refCanvas.getContext('2d'); refCtx.drawImage(reference,0,0,w,h); const ref=refCtx.getImageData(0,0,w,h).data; const actual=ctx.getImageData(blue.left,blue.top,w,h).data; const white=(pixels,i)=>pixels[i]>150 && pixels[i+1]>180 && pixels[i+2]>200; let tp=0, fp=0, fn=0; for(let i=0;i 10] self.assertEqual(len(title), 3) self.assertTrue(all(band["height"] >= 30 for band in title), title) body = [band for band in first["bands"] if 325 < band["top"] < 930] self.assertGreater(len(body), 15) self.assertTrue(all(metric["rightOverflow"] == 0 for metric in self.metrics)) def test_logo_is_on_the_right_and_complete_on_every_page(self): for number, metric in enumerate(self.metrics, 1): with self.subTest(page=number): self.assertEqual((metric["width"], metric["height"]), (794, 1123)) logo = metric["blue"] self.assertGreater(logo["count"], 3500) self.assertAlmostEqual(logo["left"], 643, delta=2) self.assertAlmostEqual(logo["right"], 718, delta=2) self.assertGreater(logo["top"], 950) self.assertLess(logo["bottom"], 1060) self.assertAlmostEqual(logo["right"]-logo["left"], logo["bottom"]-logo["top"], delta=2) self.assertGreater(metric["logoF1"], .78, metric) def test_missing_font_stops_before_png_export(self): with tempfile.TemporaryDirectory() as temp: with patch.dict(os.environ, {"CONTENTPANEL_FONT_DIR": temp}): with self.assertRaisesRegex(RenderError, "Schriftdatei fehlt"): render(self.document, Path(temp) / "output") self.assertFalse(list(Path(temp).rglob("*.png"))) def test_corrupt_font_stops_before_png_export(self): with tempfile.TemporaryDirectory() as temp: fonts = Path(temp) / "fonts" shutil.copytree(ROOT / "fonts", fonts) (fonts / "SuisseIntl-Light.otf").write_bytes(b"invalid font") with patch.dict(os.environ, {"CONTENTPANEL_FONT_DIR": str(fonts)}): with self.assertRaises(RenderError): render(self.document, Path(temp) / "output") self.assertFalse(list(Path(temp).rglob("*.png"))) def test_synthetic_metrics_are_not_accepted_as_success(self): class Page: def evaluate(self, _): return None with self.assertRaisesRegex(RenderError, "synthetic text metrics"): _check_renderer(Page(), ["warning: [canvasBackend] synthetic text metrics active"]) if __name__ == "__main__": unittest.main(verbosity=2)