Rendering HTML cards to PNG with a headless browser, and why the bars don't have to be to scale
Shipped
Ghostwriter’s 0.1.0 release added one new card type to its LinkedIn image system: ramp, three ascending steps with the last one highlighted, for a post about an accelerating progression like a growth curve or a compounding streak. It had been sitting on an unmerged branch from before the skill moved into the monorepo, so shipping it meant porting the template and its CSS in behind the scorer and test suite. The card itself is small, but building it is a clean example of a pattern worth having in any project that turns HTML into a shareable image: a CSS custom-property brand guide, a headless browser that screenshots one element, and bars that are drawn for effect rather than computed from the figures next to them. That last part is a real design decision with a real failure mode, and this post walks through the whole build, brand guide to a check that the honesty claim is actually true.
Setup: a brand guide in CSS custom properties
A card is a fixed-size div that a headless browser will screenshot. Before adding a new card type, the base needs somewhere to hang shared values: colors, font, and the byline that goes on every card. CSS custom properties on :root are the standard way to do that; declare a value once and every rule that uses var() picks it up, so a palette change is a one-line edit instead of a find-and-replace (MDN: Using CSS custom properties).
/* brand.css: shared engine every card type builds on. */
:root {
--byline: "Your Name · yoursite.com";
--bg: #0d1117;
--text: #e6edf3;
--muted: #9da7b3;
--accent: #58a6ff;
--border: #30363d;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); }
body { font-family: var(--font); color: var(--text); }
/* The canvas every card type shares: fixed size, centered content, a byline
pinned to the bottom-left corner. */
#canvas.card {
width: 1200px; height: 1200px; padding: 96px;
display: flex; flex-direction: column; justify-content: center; gap: 36px;
position: relative;
}
.card .eyebrow {
font: 600 28px/1.2 var(--font); letter-spacing: 0.14em;
text-transform: uppercase; color: var(--accent);
}
.card h1 { font: 800 84px/1.04 var(--font); margin: 0; }
.card .footer {
position: absolute; left: 96px; bottom: 72px;
font: 500 28px/1 var(--font); color: var(--muted);
}
.card .footer.brand::before { content: var(--byline); }
#canvas is the element the render step will screenshot, so its size is the size of the final image. Everything a new card type needs, it gets by building on top of this file, not by replacing it.
Build a card type as a CSS modifier
A new card type is a modifier class layered on .card, not a new stylesheet. ramp needed three ascending steps with the last one visually the tallest, so the rule is .card.ramp, and the step heights are fixed pixel values rather than something computed from the numbers being displayed:
/* Append to brand.css. Bar heights are fixed and illustrative, NOT computed
from the .val figures next to them; the labels carry the real numbers. */
#canvas.card.ramp { justify-content: center; gap: 0; }
.card.ramp h1 { font-size: 64px; margin-bottom: 64px; }
.card .ramp-row { display: flex; align-items: flex-end; justify-content: center; gap: 60px; }
.card .step { display: flex; flex-direction: column; align-items: center; gap: 26px; }
.card .step .val { font: 800 60px/1 var(--font); color: var(--text); }
.card .step .bar { width: 210px; border-radius: 22px 22px 0 0; background: #1f2630; border: 1px solid var(--border); border-bottom: none; }
.card .step.s1 .bar { height: 130px; }
.card .step.s2 .bar { height: 290px; }
.card .step.s3 .bar { height: 470px; background: var(--accent); border-color: var(--accent); }
.card .step.s3 .val { color: var(--accent); }
That’s a deliberate choice, not an oversight. A bar drawn to scale for three numbers as different as 1, 4, and 9 either makes the small ones invisible or blows the canvas out of its fixed square, and the card only has room for a shape, not an axis. So the shape stays fixed and the number next to each bar carries the actual figure. Whether that trade-off is fine or a trap depends entirely on whether the reader treats the shape as decoration or as data, which is exactly the question the verification step below checks.
The HTML is the base structure plus the ramp class and three .step blocks:
<!-- card-ramp.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="brand.css" />
</head>
<body>
<div id="canvas" class="card ramp">
<div class="eyebrow">RELEASES SHIPPED</div>
<h1>Three releases, one compounding streak.</h1>
<div class="ramp-row">
<div class="step s1"><div class="val">1</div><div class="bar"></div></div>
<div class="step s2"><div class="val">4</div><div class="bar"></div></div>
<div class="step s3"><div class="val">9</div><div class="bar"></div></div>
</div>
<div class="footer brand"></div>
</div>
</body>
</html>
Render it: HTML to PNG with a headless browser
A headless browser renders the markup exactly as a real browser would, flex layout and fonts included, and screenshots it. Locator.screenshot() captures a single element rather than the whole page, so the output is exactly the card’s square, nothing more (Playwright: Screenshots). Before the browser sees the file, the <link> to the stylesheet gets swapped for its literal contents, so the HTML is self-contained no matter where it’s opened from:
#!/usr/bin/env python3
# render.py: turn a card.html into a PNG. Usage: python render.py card-ramp.html out.png
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
HERE = Path(__file__).resolve().parent
def inline_css(html: str) -> str:
"""Replace the <link> to brand.css with its actual contents, so the page is
self-contained and renders the same no matter where the file lives."""
css = (HERE / "brand.css").read_text(encoding="utf-8")
return re.sub(
r'<link[^>]*href="[^"]*brand\.css"[^>]*>',
lambda _m: f"<style>\n{css}\n</style>",
html,
)
def render(src: Path, out: Path, size: int = 1200) -> None:
html = inline_css(src.read_text(encoding="utf-8"))
with sync_playwright() as p:
browser = p.chromium.launch()
# 2x device_scale_factor for a crisp image after the platform recompresses it.
page = browser.new_page(viewport={"width": size, "height": size}, device_scale_factor=2)
page.set_content(html, wait_until="load")
page.locator("#canvas").screenshot(path=str(out))
browser.close()
if __name__ == "__main__":
render(Path(sys.argv[1]), Path(sys.argv[2]))
print(f"rendered -> {sys.argv[2]}")
Use it: render the card
pip install playwright && playwright install chromium
python render.py card-ramp.html ramp.png
Running that against the files above prints:
rendered -> ramp.png
and the PNG lands at 2400x2400, twice the 1200px canvas, because of the device_scale_factor=2 set on the page. Text and the bar edges stay sharp once the image gets uploaded and recompressed; drop the factor to 1 if a project needs the file to match the CSS pixel size exactly.
Verify: the render is right, and the bars are honestly illustrative
Two things should hold: the file is the size the render call promised, and the bar heights are demonstrably not tracking the numbers next to them, so nobody downstream mistakes the card for a real chart. Both are checkable from the same render:
# verify.py: check the PNG dimensions, then confirm the bars are illustrative,
# not proportional to the labels next to them.
import struct
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
from render import inline_css
png = Path(sys.argv[1])
data = png.read_bytes()[:33]
w, h = struct.unpack(">II", data[16:24])
print(f"PNG dimensions: {w}x{h}")
assert (w, h) == (2400, 2400), "expected 2x of the 1200 viewport"
html = inline_css(Path("card-ramp.html").read_text(encoding="utf-8"))
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 1200})
page.set_content(html, wait_until="load")
bars = [b.bounding_box()["height"] for b in page.locator(".step .bar").all()]
vals = [int(v.inner_text()) for v in page.locator(".step .val").all()]
browser.close()
bar_ratio = [round(b / bars[0], 2) for b in bars]
val_ratio = [v / vals[0] for v in vals]
print(f"labels: {vals} ratio {val_ratio}")
print(f"bar height: {bars} ratio {bar_ratio}")
assert bar_ratio != val_ratio, "bars are illustrative, heights are fixed, not computed from vals"
print("confirmed: bar heights are illustrative, not proportional to the labels")
Running python verify.py ramp.png against the files above prints:
PNG dimensions: 2400x2400
labels: [1, 4, 9] ratio [1.0, 4.0, 9.0]
bar height: [130, 290, 470] ratio [1.0, 2.23, 3.62]
confirmed: bar heights are illustrative, not proportional to the labels
The labels scale 1:4:9. The bars scale roughly 1:2.2:3.6. That gap is the whole point: the card is a decoration with the real numbers printed on it, not a chart, and the assertion makes sure a future edit can’t quietly turn it into something that looks like one without actually being one.
Gotchas
A bar shape reads as data even when it’s labeled as illustration. Viewers trust the shape of a bar over the number next to it, and the effect is stubborn: a study of axis-truncated bar graphs found the distortion persisted in 83.5% of participants across five studies, even after they were explicitly taught about it (Truncating Bar Graphs Persistently Misleads Viewers). Labels don’t fix that by existing; they fix it by being accurate and legible, which is why “few things distinguish good and great visualisations better than a proper annotation layer” (data.europa.eu: Honest charts). If a card’s bars are ever going to be read as a real comparison rather than a decoration, compute the heights from the values instead of hardcoding them, the same way the base engine’s other card types keep their numbers unstyled and literal.
The CSS-inlining regex fails silently, not loudly. The inline_css regex only matches a double-quoted href="brand.css". Change the attribute to single quotes, or reorder it before rel, and the function returns the HTML unchanged, no exception, no warning. Playwright still renders something, but without the stylesheet the #canvas element has no explicit size, so it collapses to the natural size of its content instead of the fixed 1200px square. Testing this directly: swapping the href to single quotes produced a PNG at 2368x304, plain unstyled text with no background, no bars, no byline, and the script exited 0 like nothing was wrong. The fix is the same assertion pattern as the verification step: check the output looks like what was asked for, don’t assume a successful process exit means a correct file.
device_scale_factor changes the output’s pixel dimensions, not just its sharpness. A size=1200 render at device_scale_factor=2 produces a 2400x2400 file. If a downstream step (an upload API, a dimension check, a thumbnail generator) expects exactly 1200px, it’ll get a file twice that and either reject it or scale it down again, undoing the crispness the factor was there to buy. Decide the target pixel size first, then pick a viewport and scale factor that multiply out to it.
Sources
- MDN: Using CSS custom properties — declaring a value once on
:rootand reusing it withvar()across a stylesheet. - Playwright: Screenshots — capturing a single element to a PNG with
Locator.screenshot(). - Truncating Bar Graphs Persistently Misleads Viewers — viewers misread non-proportional bars even after being taught about the effect, in 83.5% of participants across five studies.
- data.europa.eu: Honest charts — accurate data alone isn’t enough; a clear annotation layer is what keeps a visualization from being misread.
Changelog
- feat(ghostwriter): add ramp card type for accelerating progressions (#1) (c2c6b2b)