"""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) ] + ["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)