#!/usr/bin/env python3 from __future__ import annotations import argparse import http.server import os import sys import threading from pathlib import Path from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright from docx_template import FONT_FACES, renderer_document class RenderError(RuntimeError): pass def _server(root: Path, document: Path, font_dir: Path): assets = {"/input.docx": (renderer_document(document), "application/octet-stream")} assets.update({f"/fonts/{face['file']}": ((font_dir / face['file']).read_bytes(), "font/otf") for face in FONT_FACES}) class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(root), **kwargs) def log_message(self, *args): pass def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") self.send_header("Cache-Control", "no-store") super().end_headers() def do_GET(self): asset = assets.get(self.path.split("?", 1)[0]) if asset is None: return super().do_GET() data, mime = asset self.send_response(200) self.send_header("Content-Type", mime) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) def _render_scale() -> float: try: value = float(os.getenv("CONTENTPANEL_RENDER_SCALE", "3")) except ValueError as exc: raise RenderError("CONTENTPANEL_RENDER_SCALE ist ungültig.") from exc if not 1 <= value <= 4: raise RenderError("CONTENTPANEL_RENDER_SCALE muss zwischen 1 und 4 liegen.") return value def _check_renderer(page, errors: list[str]) -> None: error = page.evaluate("window.__betterofficeError") if error or errors: raise RenderError("BetterOffice-Rendering unvollständig: " + "; ".join(([str(error)] if error else []) + errors[-3:])) def render(document: Path, output_dir: Path) -> int: renderer_root = Path(__file__).with_name("renderer") / "dist" if not (renderer_root / "index.html").is_file() or not (renderer_root / "app.js").is_file(): raise RenderError("BetterOffice-Renderer wurde im Docker-Image nicht gebaut.") font_dir = Path(os.getenv("CONTENTPANEL_FONT_DIR", str(Path(__file__).with_name("fonts")))) for face in FONT_FACES: if not (font_dir / face["file"]).is_file(): raise RenderError(f"Schriftdatei fehlt: {face['file']}") output_dir.mkdir(parents=True, exist_ok=True) for old in output_dir.glob("page-*.png"): old.unlink() server = _server(renderer_root, document, font_dir) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() url = f"http://127.0.0.1:{server.server_address[1]}/index.html" try: with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True, args=["--disable-dev-shm-usage"]) context = browser.new_context( viewport={"width": 1600, "height": 16000}, device_scale_factor=_render_scale(), ) page = context.new_page() errors = [] page.on("pageerror", lambda exc: errors.append(str(exc))) page.on("console", lambda msg: errors.append(msg.text) if any(marker in msg.text for marker in ( "[fontRegistry]", "synthetic text metrics active", "glyph outlines unavailable")) else None) page.on("requestfailed", lambda request: errors.append(f"{request.url}: {request.failure}")) page.on("response", lambda response: errors.append(f"HTTP {response.status}: {response.url}") if response.status >= 400 else None) page.goto(url, wait_until="domcontentloaded", timeout=60000) try: page.wait_for_function( "window.__betterofficeError || " "(window.__betterofficeFontsReady && window.__betterofficeEditor?.getTotalPages() > 0)", timeout=90000, ) except PlaywrightTimeoutError as exc: _check_renderer(page, errors) raise RenderError("BetterOffice hat innerhalb von 90 Sekunden keine Seiten geliefert.") from exc _check_renderer(page, errors) count = page.evaluate("window.__betterofficeEditor.getTotalPages()") limit = int(os.getenv("CONTENTPANEL_MAX_RENDERED_PAGES", "10")) if count > limit: raise RenderError(f"Der Aushang umfasst {count} Seiten; maximal {limit} sind erlaubt.") # Mount every page even when the editor virtualizes long documents. page.set_viewport_size({"width": 1600, "height": max(16000, count * 1200)}) try: page.wait_for_function("""() => { if (window.__betterofficeError) return true; const canvases = [...document.querySelectorAll('canvas[data-page-index]')]; const ready = canvases.length === window.__betterofficeEditor.getTotalPages() && canvases.every(c => c.width > 0 && c.height > 0); const pixels = ready ? canvases.map(c => c.toDataURL()).join('|') : null; if (!pixels || pixels !== window.__previousPagePixels) window.__stableSince = performance.now(); window.__previousPagePixels = pixels; return pixels && performance.now() - window.__stableSince >= 1000; }""", polling=200, timeout=30000) except PlaywrightTimeoutError as exc: _check_renderer(page, errors) raise RenderError("BetterOffice-Seiten wurden nicht vollständig und stabil gezeichnet.") from exc _check_renderer(page, errors) canvases = page.locator("canvas[data-page-index]") count = canvases.count() if count > limit: raise RenderError(f"Der Aushang umfasst {count} Seiten; maximal {limit} sind erlaubt.") for index in range(count): canvases.nth(index).screenshot(path=str(output_dir / f"page-{index + 1}.png")) _check_renderer(page, errors) browser.close() finally: server.shutdown() server.server_close() thread.join(timeout=2) return count def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("document", type=Path) parser.add_argument("output_dir", type=Path) args = parser.parse_args() print(render(args.document, args.output_dir)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(str(exc), file=sys.stderr) raise SystemExit(1)