171 lines
9.1 KiB
Python
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)
|