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

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