A headless browser doubles as an image renderer, once you wait for the right signal

Ghostwriter · No. 003

Shipped

Ghostwriter can now attach a visual to a post: a Mermaid diagram for something structural, or a designed HTML/CSS card for one punchy stat or headline. Both render locally to a high-DPI PNG through a headless Chromium driven by Playwright, so nothing leaves the machine. It’s opt-in; text-only stays the default, and an image is only attached to the post after it’s rendered and approved. A same-day follow-up made the rendered PNG pop open in the OS image viewer automatically instead of sitting unopened in a folder, and added a second card type where a date is the whole point.

The rendering itself is unremarkable until you hit the one thing that actually breaks it: knowing when a page is “done.” A page finishes loading long before a JavaScript library has drawn anything into it, and the usual instinct for handling that turns out to be a documented anti-pattern. That’s the technique worth taking from this release, along with a smaller one about where personal styling belongs when a tool is meant to be cloned by other people.

Set up: an image renderer that needs nothing but a browser

Playwright ships and manages its own browser binaries, so there’s no system Chrome to find or pin. Scope the dependency to only the feature that needs it; a tool whose main job is drafting and posting text has no business requiring a browser automation library just to start up.

python3 -m venv .venv
.venv/bin/pip install -r requirements-render.txt   # playwright>=1.40, nothing else
.venv/bin/playwright install chromium
# requirements-render.txt
# Optional, only needed for local image rendering.
# The rest of the tool needs no third-party packages.
playwright>=1.40

Keeping this dependency in its own requirements file (or its own lazy import, if you’re not splitting files) means a fresh clone that only wants the text features never has to touch a venv at all. The render script itself should say so plainly the moment it’s missing:

INSTALL_HINT = (
    "Rendering needs Playwright + Chromium (optional).\n"
    "  python3 -m venv .venv\n"
    "  .venv/bin/pip install -r requirements-render.txt\n"
    "  .venv/bin/playwright install chromium\n"
    "Then run this script with .venv/bin/python."
)

Build it: a completion signal beats “page loaded”

A headless browser lays out HTML and CSS exactly like a real one, and Playwright can screenshot the result. That covers a styled card directly, since it’s already HTML. A Mermaid diagram needs one extra step: the diagram source isn’t HTML yet, so the page has to load Mermaid’s client library and run it before there’s anything to screenshot.

The trap is that mermaid.run() is asynchronous and fires well after the page has technically “loaded.” Reaching for wait_until="networkidle" to paper over that is the wrong move; Playwright’s own docs mark it explicitly:

“‘networkidle’ - DISCOURAGED wait until there are no network connections for at least 500 ms. Don’t use this method for testing, rely on web assertions to assess readiness instead.” (Playwright: wait_for_load_state)

Even setting that aside, networkidle wouldn’t help here: once Mermaid’s JS is vendored locally there’s barely any network traffic to begin with, so “idle” resolves before the SVG exists and the screenshot captures an empty container. The fix is to have the page itself raise a flag when it’s actually done, and poll for that flag with wait_for_function, which returns as soon as the JS expression you give it evaluates truthy.

<!-- mermaid-template.html: %%DIAGRAM%% gets replaced with the diagram source -->
<!doctype html><html><head>
  <link rel="stylesheet" href="diagram.css" />
  <script src="vendor/mermaid.min.js"></script>
</head><body>
  <div id="canvas"><pre class="mermaid">%%DIAGRAM%%</pre></div>
  <script>
    mermaid.initialize({ startOnLoad: false });
    window.__renderDone = false;
    mermaid.run({ querySelector: ".mermaid" })
      .then(() => { window.__renderDone = true; })
      .catch((e) => { window.__renderError = String(e); });
  </script>
</body></html>
# render.py
from __future__ import annotations
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright

def render(kind: str, html: str, out: Path, width: int, height: int) -> None:
    try:
        from playwright.sync_api import sync_playwright
    except ModuleNotFoundError:
        sys.exit(f"ERROR: playwright not installed.\n{INSTALL_HINT}")

    out.parent.mkdir(parents=True, exist_ok=True)
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(viewport={"width": width, "height": height},
                                 device_scale_factor=2)   # 2 device px per CSS px
        page.set_content(html, wait_until="load")
        if kind == "mermaid":
            # Poll the page's own flag, not the network. Surface the JS error
            # instead of a blank screenshot if mermaid.run() rejects.
            page.wait_for_function(
                "window.__renderDone === true || window.__renderError", timeout=15000
            )
            err = page.evaluate("window.__renderError || null")
            if err:
                browser.close()
                sys.exit(f"ERROR: mermaid failed to render:\n{err}")
        page.wait_for_selector("#canvas", timeout=5000)
        page.locator("#canvas").screenshot(path=str(out))
        browser.close()

device_scale_factor=2 is what makes the output crisp instead of soft on a retina display or a zoomed-in feed: it renders two device pixels for every CSS pixel. Screenshotting the #canvas locator instead of the full page crops the PNG to exactly the content, with no surrounding whitespace to trim by hand.

Build it: personal styling is config, and config stays out of the shared repo

The card and diagram styling, including a byline that signs each image, is what makes the output look like it belongs to one specific person. Baking that into the render script would be exactly backwards. It lives in a gitignored diagram.css; a fresh clone falls back to a checked-in diagram.css.example with neutral defaults, so the tool starts from a blank slate with no one’s personal branding committed.

/* diagram.css.example (checked in, neutral).
   Copy to diagram.css (gitignored) and make it yours. */
:root {
  --byline: "Your Name · yoursite.com";
  --bg: #0D1117;
  --text: #E6EDF3;
  --accent: #58A6FF;
}
.card { background: var(--bg); color: var(--text); }
.card .footer.brand::before { content: var(--byline); }
def brand_css_path() -> Path:
    """Personal brand file if it exists; otherwise the neutral checked-in template."""
    return CSS if CSS.exists() else CSS_EXAMPLE

This is the twelve-factor config principle applied to a one-person tool: config is what varies between users, and it shouldn’t live embedded in the code. The methodology’s own litmus test is whether you could open the codebase to anyone at any moment without exposing something that should stay private (12-Factor App: Config). A gitignored brand file with a checked-in .example sibling passes that test without any extra ceremony.

One detail bites while wiring both files into the same page: the render script inlines the CSS (and the vendored Mermaid JS) into the HTML with re.sub, so the page needs no relative-path lookups. If the replacement is a plain string, Python treats backslashes in it as escape sequences, per the standard library docs on re.sub: “if it is a string, any backslash escapes in it are processed” (Python docs: re.sub). CSS and JS both contain backslashes often enough that this silently mangles the injected content. Passing a function as the replacement sidesteps it entirely, since a function’s return value is used verbatim:

import re

def inline_assets(html: str, css: str) -> str:
    # A lambda as repl is returned verbatim; a string repl would have its own
    # backslashes reinterpreted as regex escapes before insertion.
    return re.sub(r'<link[^>]*href="[^"]*diagram\.css"[^>]*>',
                   lambda _m: f"<style>\n{css}\n</style>", html)

The last piece is gluing the template and the brand file into one page before render() ever sees it:

CSS, CSS_EXAMPLE = Path("diagram.css"), Path("diagram.css.example")

def build_html(template_path: Path, diagram_src: str) -> str:
    html = template_path.read_text(encoding="utf-8").replace("%%DIAGRAM%%", diagram_src)
    css = (CSS if CSS.exists() else CSS_EXAMPLE).read_text(encoding="utf-8")
    return inline_assets(html, css)

Use it and verify

html = build_html(Path("mermaid-template.html"), "graph TD; A[draft] --> B[render] --> C[approve]")
render("mermaid", html, Path("diagram.png"), width=1280, height=1280)
Rendered mermaid -> diagram.png  (viewport 1280x1280 @2x), opened in viewer

Wire build_html() and render() up to argparse with --type/--in/--out flags and you have a real CLI; that part is ordinary plumbing, left out here since it adds nothing new to learn. The PNG should pop open in your OS image viewer without you doing anything, and its pixel dimensions should come out to roughly double the CSS layout size, which is what device_scale_factor=2 buys you. If the diagram never appears and the call hangs until the 15-second timeout, that’s the completion-flag check doing its job: something in the Mermaid source is failing to parse, and window.__renderError will have the message.

Gotchas

  • networkidle will not save you from an async render. Even with the flag fixed, a “page loaded” signal fires before client-side JS has drawn anything. Wait on the actual output, via a flag the page sets itself.
  • A string repl in re.sub reprocesses backslashes. Injecting arbitrary CSS or JS as a plain string replacement is a latent bug waiting for the first backslash in a font path or regex-flavored comment. Pass a function instead and it’s a non-issue.
  • A side effect should never be allowed to fail the main action. Auto-opening the rendered file in a viewer is a convenience, not a requirement; wrap it in a bare try/except: pass so a missing xdg-open on some Linux box doesn’t turn a successful render into a crash.
  • Test that the optional path leaves the default path untouched. Adding --image/--alt to a script that posts to a live account is exactly the kind of change you don’t eyeball. Confirm the payload built without an image is byte-for-byte the same as before the image feature existed, not just “looks right.”

Sources

Changelog

  • feat: optional diagrams & cards to accompany posts (d5c6e2b)
  • feat: per-user image brand guide with configurable byline (13fcfa9)
  • feat(diagrams): auto-open the rendered PNG so it’s actually viewed (d69e99a)
  • feat(diagrams): add a reusable date/deadline card type (c5b6794)