Files
intranet/worker/contentpanel_aushang/tests/test_rendering.py
T

173 lines
9.3 KiB
Python

"""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;y<canvas.height;y++) for (let x=0;x<canvas.width;x++) {
const i=(y*canvas.width+x)*4, r=data[i], g=data[i+1], b=data[i+2];
if (b>130 && 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<ref.length;i+=4) {
const expected=white(ref,i), rendered=white(actual,i);
if(expected && rendered) tp++;
else if(rendered) fp++;
else if(expected) fn++;
}
const logoF1=2*tp/(2*tp+fp+fn);
return {width:canvas.width,height:canvas.height,blue,bands,rightOverflow,logoF1};
}"""))
browser.close()
finally:
server.shutdown(); server.server_close(); thread.join(timeout=2)
(cls.output / "pixel-metrics.json").write_text(json.dumps(cls.metrics, indent=2), encoding="utf-8")
@classmethod
def tearDownClass(cls):
cls.temp.cleanup()
def test_long_text_paginates_and_last_paragraph_survives(self):
import subprocess
self.assertGreaterEqual(self.page_count, 2)
self.assertLessEqual(self.page_count, 4)
text = subprocess.run(["tesseract", str(self.output / f"page-{self.page_count}.png"),
"stdout", "-l", "deu", "--psm", "11"],
capture_output=True, text=True, check=True, timeout=30).stdout
self.assertIn("Abschlussmarker", text)
def test_real_pixels_have_large_title_and_wrapped_body(self):
first = self.metrics[0]
title = [band for band in first["bands"] if 90 < band["top"] < 275 and band["height"] > 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)