Ghostwriter

You already have an image renderer: HTML and Mermaid into PNGs on your own machine

Shipped

Ghostwriter writes LinkedIn posts in my voice. This change lets a post optionally carry a visual: a technical diagram written in Mermaid, or a designed card built from plain HTML and CSS for a single punchy idea. Both render locally through a headless Chromium driven by Playwright, into a high-DPI PNG, so the content never leaves my machine for a third-party renderer. It stays opt-in. Text-only posts are still the default, and an image only attaches after I approve the rendered PNG. On the publish side, the poster runs the LinkedIn image-upload flow only when an image is actually provided, so the image flow never runs on the text-only path. A same-day follow-up made rendered files auto-open in the image viewer instead of being written and forgotten, added a reusable date and deadline card type that makes the date the hero, and moved the repo onto a branch-and-PR workflow.

The feature is small. The parts worth writing about are how the rendering works without a hosted service, and how I kept my personal look out of a repo other people can clone. Here is the full build, from install to a test that protects the text-only path.

Setup: install a browser you already trust

The whole approach rests on one dependency, Playwright, which ships and manages its own browser binaries so you do not have to find or pin a system Chrome. Install the library, then ask it to fetch Chromium along with the OS-level libraries the browser needs to run headless (Playwright: Installing browsers). The --with-deps flag is what makes this reproducible on a fresh machine or in CI, where the bare browser would otherwise fail to start.

python -m venv .venv && source .venv/bin/activate
pip install playwright              # core dependency
pip install pytest pillow          # for the tests below (pillow reads PNG dimensions)
python -m playwright install --with-deps chromium   # the browser binary + its OS libs

# vendor Mermaid locally so the render needs no CDN (pin the major version)
mkdir -p vendor
curl -L https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js -o vendor/mermaid.min.js

# the personal brand file is generated locally and never committed (see below)
echo "diagram.css" >> .gitignore

That is the entire infrastructure. No renderer service, no API key, no account. The cost is carrying a browser binary, which Playwright installs and updates for you.

A headless browser is a capable image renderer

The instinct when you need “HTML turned into an image” is to reach for a hosted screenshot or diagram API. You don’t have to. A headless browser already lays out HTML and CSS exactly the way a real browser would, and Playwright can screenshot the result. That covers both cases here: a styled card is just an HTML document, and a Mermaid diagram is HTML and CSS once Mermaid’s client library has rendered it in the page. The browser does the hard part, which is layout and font rendering, and you take a picture of the output.

The one detail that matters for quality is pixel density. By default a screenshot uses one image pixel per CSS pixel, which looks soft on retina displays and worse when someone zooms a LinkedIn image. Playwright lets you set device_scale_factor on the browser context to emulate a high-DPI display, so a value of 2 renders two device pixels for every CSS pixel and you get a crisp, retina-grade PNG (Playwright: Emulation). Screenshotting a specific element instead of the whole page crops the output to just the card, with no surrounding whitespace to trim (Playwright: Locator API).

# ghostwriter/render.py
from __future__ import annotations  # so `str | None` works on Python 3.7+
from playwright.sync_api import sync_playwright

def render_to_png(html: str, selector: str, out_png: str, scale: int = 2,
                  wait_for: str | None = None) -> None:
    """Render an HTML string to a cropped, high-DPI PNG with local headless Chromium."""
    with sync_playwright() as p:
        browser = p.chromium.launch()  # headless by default; nothing leaves the machine
        # device_scale_factor=2 => 2 device pixels per CSS pixel, i.e. retina-grade output
        context = browser.new_context(device_scale_factor=scale)
        page = context.new_page()
        page.set_content(html)
        # Wait on a concrete render signal: the node we are about to shoot must exist.
        page.wait_for_selector(wait_for or selector)
        page.locator(selector).screenshot(path=out_png)   # crop to just that element
        browser.close()

The wait is what guarantees a finished diagram, and it has to key off the rendered output, not the network. The tempting choice, wait_until="networkidle", is the wrong tool here: it tracks network traffic, not the JavaScript that draws the diagram, and Playwright explicitly discourages it (Playwright: Page API). Mermaid renders client-side after the page loads, and if its library is vendored locally there is barely any network traffic, so networkidle resolves before the SVG exists and you screenshot an empty container. Waiting for the actual node, the .card for a static card or the injected .mermaid svg once Mermaid finishes, is what holds the screenshot until the picture is really there.

Personal styling is config, and config stays out of the shared repo

The card and diagram styling, including the byline that signs each image, is the part that makes the output look like mine. It would be tempting to bake that into the rendering code, and that’s exactly the wrong place for it. Styling and byline live in a gitignored diagram.css, so a fresh clone of the tool starts from a neutral template with no personal branding committed. The byline itself is a single CSS variable that auto-applies to the bottom of every card and diagram, set once rather than repeated per template.

This is the twelve-factor config principle applied to a personal tool: config is everything that varies between users or deploys, and it should be stored outside the code, not embedded in it (12factor: Config). The methodology even gives a litmus test, which is whether you could open-source the codebase at any moment without exposing anything that should stay private. A gitignored brand file passes that test cleanly, and keeping a checked-in .example neutral template is the standard way to ship the shape of the config without the contents (GitGuardian: Secrets management best practices).

The neutral template is what ships in the repo. It defines the variables every template reads, with empty or generic values.

/* diagram.css.example  (checked in, neutral)
   Copy to diagram.css (gitignored) and personalize. */
:root {
  --byline: "";                         /* e.g. "— Jane Doe · jane.dev" */
  --brand-fg: #111;
  --brand-bg: #fff;
  --brand-accent: #2563eb;
  --brand-font: system-ui, sans-serif;
}

.card { color: var(--brand-fg); background: var(--brand-bg); font-family: var(--brand-font); }

/* One rule signs every image; set --byline once instead of editing each template.
   With the neutral default (--byline: ""), this renders an empty signature line:
   the box and its margin still reserve space at the bottom of every card. That is
   intentional for the template; if you want zero footprint until you brand it,
   delete this rule here and move it into your personal diagram.css. */
.card::after {
  content: var(--byline);
  display: block;
  margin-top: 1.5rem;
  color: var(--brand-accent);
  font: 600 0.85rem var(--brand-font);
}

The rendering code stays generic. It loads the personal brand if it exists and falls back to the neutral template otherwise, then inlines it so the screenshot picks it up with no network fetch.

# ghostwriter/brand.py
from pathlib import Path

BRAND = Path("diagram.css")            # gitignored, personal
NEUTRAL = Path("diagram.css.example")  # checked in, generic

def brand_css() -> str:
    """Personal brand if present; otherwise the neutral checked-in template."""
    path = BRAND if BRAND.exists() else NEUTRAL
    return path.read_text(encoding="utf-8")

def wrap_card(body_html: str) -> str:
    """Inline the brand stylesheet so render_to_png needs no external request.

    body_html must be the `.card` element itself, e.g.
    "<div class='card'>...</div>", because the render selector and the brand
    CSS both target `.card`; bare inner content would never match and the
    screenshot's wait_for_selector would hang.
    """
    return (
        "<!doctype html><html><head>"
        f"<style>{brand_css()}</style>"
        f"</head><body>{body_html}</body></html>"
    )

The rendering code is shared and generic; the look is supplied. That split is what lets the same tool belong to anyone who clones it.

The Mermaid case is the same idea with one extra step: the diagram source is not HTML yet, so the page has to run Mermaid’s client library to turn it into an SVG before there is anything to screenshot. I vendor mermaid.min.js locally and inline it for the same reason I inline the CSS, so the render needs no network at all. The diagram source goes into a <pre class="mermaid">, and mermaid.run() injects an <svg> inside that node and marks it processed (Mermaid: Usage). Because mermaid.run() is asynchronous, the render function’s wait_for=".mermaid svg" is exactly what holds the screenshot until that SVG has been injected.

# ghostwriter/mermaid.py
import html
from pathlib import Path
from ghostwriter.brand import brand_css

MERMAID_JS = Path("vendor/mermaid.min.js")  # vendored, no CDN (see setup)

def wrap_mermaid(diagram_src: str) -> str:
    """Inline mermaid.js + brand CSS; mermaid.run() draws the SVG after load."""
    # Read lazily, not at import, so importing the module never crashes when the
    # vendored file is missing; the error only surfaces if you actually render.
    mermaid_js = MERMAID_JS.read_text(encoding="utf-8")
    # Escape the source before it lands in innerHTML: node labels containing
    # <, >, or & would otherwise be parsed as HTML before Mermaid reads them.
    safe_src = html.escape(diagram_src)
    return (
        "<!doctype html><html><head>"
        f"<style>{brand_css()}</style>"
        f"<script>{mermaid_js}</script>"
        "</head><body>"
        f"<pre class='mermaid'>{safe_src}</pre>"
        "<script>mermaid.initialize({startOnLoad: false}); mermaid.run();</script>"
        "</body></html>"
    )
# rendering a diagram: wait for the SVG mermaid injects, then crop to it
html = wrap_mermaid("graph TD; A[draft] --> B[render] --> C[approve]")
render_to_png(html, ".mermaid", "out/diagram.png", wait_for=".mermaid svg")

Make the optional path optional, and leave the existing path alone

There’s a quieter discipline in this change that I want to name. Adding the image feature could not be allowed to change how text-only posts behave, because text-only is the default and the path I trust. publish_post itself grew a new parameter and a branch, but the text-only request is the same call it always made, and the image steps run only when an image is provided. That is the spirit of the open-closed principle, where you add new behavior without disturbing the code path that already works (Wikipedia: Open–closed principle). When a new feature is genuinely opt-in, the safest version is the one where the default invocation still issues the same request it always did, with the new work isolated to a branch the trusted path never enters.

# ghostwriter/publish.py
from __future__ import annotations  # so `str | None` works on Python 3.7+
from ghostwriter import linkedin  # thin client around the LinkedIn API

def publish_post(text: str, image_png: str | None = None) -> str:
    """Text-only is the default and untouched. Image upload runs only when given one."""
    if image_png is None:
        return linkedin.create_text_post(text)         # the same call as before

    # opt-in branch: the extra image round-trips live entirely here
    asset = linkedin.register_image_upload()
    linkedin.upload_binary(asset.upload_url, image_png)
    return linkedin.create_image_post(text, asset.urn)

The linkedin.* calls here are a placeholder for LinkedIn’s actual asset flow: register an upload to get an upload URL and an asset URN, PUT the image bytes to that URL, then create the post referencing the URN (LinkedIn: Vector asset upload). Note that the cited doc is LinkedIn’s legacy asset flow; the modern equivalent for posting an image is the Images API (rest/images?action=initializeUpload), but the register/upload/reference shape is the same. upload_binary takes a path here for brevity; a real client reads the file and PUTs the bytes. What matters for this post is the branch structure, not the wire details.

The call site stays honest about this. The default invocation passes no image and takes the original branch; the image only enters after I have rendered and approved a PNG.

png = None
if attach_visual:                       # only after I approve the rendered image
    html = wrap_card(card_body)
    render_to_png(html, ".card", "out/card.png")
    png = "out/card.png"

publish_post(draft_text, image_png=png)  # png stays None on the trusted text-only path

Verify it

Two things are worth a regression test: that the text-only path never touches the image flow, and that the PNG really is high-DPI rather than nominally so. Both are cheap to assert and both fail loudly if a future change breaks them.

# tests/test_publish_and_render.py
from unittest.mock import MagicMock
from PIL import Image  # test-only dependency, to read PNG dimensions
import ghostwriter.publish as publish
from ghostwriter.render import render_to_png

def test_text_only_path_is_unchanged(monkeypatch):
    li = MagicMock()
    monkeypatch.setattr(publish, "linkedin", li)

    publish.publish_post("hello world")               # no image argument

    li.create_text_post.assert_called_once_with("hello world")
    li.register_image_upload.assert_not_called()       # the image branch never runs

def test_png_is_high_dpi(tmp_path):
    out = tmp_path / "card.png"
    html = "<div class='card' style='width:200px;height:100px'>hi</div>"

    render_to_png(html, ".card", str(out), scale=2)

    width, height = Image.open(out).size
    assert (width, height) == (400, 200)               # 2 device px per CSS px

The first test fails the moment text-only posts start calling the upload flow. The second fails if device_scale_factor is dropped or set back to 1, which is exactly the silent regression that would make every image soft again without any error to notice.

Next

I’ll keep building card types as new post shapes call for them, and keep tightening the voice rules that govern the text. The bar for a new card type is that a post shape recurs often enough to deserve its own treatment, rather than adding a card because it looked good once.

Sources

Changelog

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