---
title: "ADR-002: Node + Playwright Renderer for Image Composition"
manual: "nr_repurpose"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-repurpose:adr-002@main"
source: "Adr/Adr002NodePlaywrightRenderer.rst"
rendered: "2026-09-30T16:39:51+00:00"
---

# ADR-002: Node + Playwright Renderer for Image Composition {#adr-002}

-   *Status:* Accepted
-   *Date:* 2026-06-09
-   *Authors:* Netresearch DTT GmbH

## Context {#adr-002-context}

Two of the three artifacts are images built from branded HTML: the Schaubild
diagram and the Instagram story. The reference Schaubild variant and the flat
story are *exact* renderings of an LLM-authored, theme-wrapped HTML fragment —
every label, number and term must come out pixel-correct, with web fonts and CSS
layout honoured. A PHP imaging library cannot lay out and rasterise arbitrary
HTML/CSS; only a real browser engine can.

The artifacts must also support an AI-background variant: a generated background
image with the HTML text layer composited over it. The text layer therefore has
to be renderable on a transparent canvas so the background shows through.

## Decision {#adr-002-decision}

Render HTML to PNG with a bundled Node script that drives the system Chromium
through Playwright, and composite separately with GD.

1.  **A single CommonJS renderer.**
    `Resources/Private/NodeRenderer/render.cjs` reads HTML from **stdin**
    and writes a PNG, taking `--width`, `--height` (an integer or `auto`),
    `--scale`, `--out` and `--transparent` / `--opaque`. It uses
    `playwright-core` only — Chromium is the host's apt binary, located via
    `CHROMIUM_PATH`, with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` so Playwright
    never downloads its own. `waitUntil: 'networkidle'` and
    `document.fonts.ready` ensure web fonts are loaded before the screenshot.

    *Hardening (2026-09).* The HTML contains LLM output derived from the fetched
    source, so it is rendered as untrusted: the browser context has JavaScript
    disabled and service workers blocked, and a context-level route aborts every
    request except `data:` and `blob:` URLs. The route does not see a
    `<link rel="prefetch">` or the target of a redirect, so Chromium is also
    launched with a proxy address nothing listens on and no bypass list: any
    request the route misses fails at the network layer, loopback addresses
    included. The renderer has no network access at all.

    The templates still `@import` their web fonts (Raleway, Open Sans, Inter)
    from Google Fonts. `render.cjs` replaces each such rule, before the HTML
    reaches the page, with `@font-face` rules over the unmodified upstream
    variable fonts in `Resources/Private/Fonts` (SIL Open Font License,
    source commit and checksums in its `fonts.json`), inlined as `data:`
    URLs; the stored `source_html` keeps the `@import`. Inter is pinned to
    `opsz` 14, the instance Google Fonts serves. The Schaubild and Story PNGs
    render byte-identical to the Google-loaded renders, and the PDFs rasterise
    identically.
1.  **A PHP boundary that shells out safely.**
    `PlaywrightHtmlToImageRenderer` builds the argv, passes HTML on stdin
    (avoiding argv length limits and shell quoting), and runs the process through
    a `ProcessRunnerInterface` (Symfony Process) seam that unit tests can
    replace. `height=null` renders full-page/auto-height (the diagram); a fixed
    height clips to the viewport (the story); `transparent` maps to
    `omitBackground` for the overlay layers.
1.  **Composition stays in PHP with GD.** `GdImageCompositor` overlays the
    transparent foreground PNG onto the AI background. The foreground defines the
    output canvas; the background — whose aspect ratio rarely matches, since
    `gpt-image-1` only emits 1:1 / 3:2 / 2:3 — is scaled to *cover*
    (centre-cropped, no distortion), then alpha-composited under the foreground.
    GD is used because Imagick is not installed in this stack.

## Consequences {#adr-002-consequences}

-   HTML renderings are pixel-accurate: web fonts, gradients and layout match the
    branded templates, and labels stay exactly as authored.
-   A transparent text layer over a cover-scaled AI background gives the
    `html_bg` and KI-background variants without distorting the designed layout.
-   The runtime gains a hard dependency on a Node.js runtime plus a system
    `chromium` binary on every host that generates — including the worker. This
    is documented as an explicit requirement (see [Installation](https://docs.typo3.org/permalink/netresearch/nr-repurpose:installation@main)).
-   Each render is a process spawn; it is bounded by a process timeout and a
    `--no-sandbox` Chromium launch suited to containerised execution.
-   The process boundary (stdin HTML, argv flags, a swappable process runner)
    keeps the renderer fully unit-testable without a browser.
