164 lines
7.1 KiB
Python
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)
|