contentpanel - Aushangworkflow um docx Vorlage erweitern
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user