Files

164 lines
7.1 KiB
Python

#!/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)