// Python Dev

Generating images from a template: SVG + Python + Playwright

Published on 2026-06-17

One of the tasks in the airline ticket mailing project is to automatically generate cards with offers for posting in public pages. Price, route, date — the data changes, the image must be updated on a schedule without human intervention.

The task seems simple, but it’s solved in different ways. Often — more complicated than necessary.

Ways to solve this task

Before explaining the chosen approach, it’s worth going over the alternatives — there are several, and each has its trade-offs.

Pillow — drawing directly on a raster. It works, but laying out in pixel coordinates is inconvenient: any design change requires changing the code. Fragile and tedious.

HTML + Playwright — essentially the same approach as described below, only the template is in HTML/CSS instead of SVG. More flexibility for complex layouts, but SVG controls positioning and sizes more precisely — important for fixed-format cards.

Jinja2 + SVG — a full-featured templating engine instead of string replace. Justified if the template needs conditionals or loops. For simple value substitution — overkill.

cairosvg — rendering SVG without a browser, faster and lighter than Playwright. But it handles non-standard fonts and embedded images poorly. That’s why it was discarded.

External SaaS services — Bannerbear, Placid and similar. They work, they have an API and a template editor. And a monthly subscription. A task that can be solved locally in 100 lines of Python costs money there every month. Paying makes sense if there’s absolutely no developer on the team. If there is a developer — it’s just an unnecessary dependency and operational expense that won’t go away.

SVG as a templating engine

SVG is XML. XML is text. And text can be parsed as a string and you can substitute whatever you want into it.

The template looks like this: in the SVG file in the necessary places, placeholders in double curly braces stand instead of real data — {{PRICE}}, {{DEPARTURE_CITY}}, {{DATE}}. Then Python reads the file as a string and performs replacements.

python
def replace_svg_data(svg_content: str, data: dict) -> str:
    result = svg_content
    result = result.replace("{{PRICE}}", data["price"])
    result = result.replace("{{DEPARTURE_CITY}}", data["departure"]["city"])
    result = result.replace("{{ARRIVAL_CITY}}", data["arrival"]["city"])
    result = result.replace("{{DATE}}", data["departure"]["date"])
    return result

No magic. No special templating engine. Just string replace.

Rendering via Playwright

The finished SVG needs to be turned into a PNG. For this Playwright is used — it loads the SVG content directly into the browser and takes a screenshot.

python
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    context = browser.new_context(
        viewport={"width": 1000, "height": 850},
        device_scale_factor=2  # for sharpness on Retina displays
    )
    page = context.new_page()
    page.set_content(modified_svg)
    screenshot = page.screenshot(type="png", full_page=True)

Playwright is not the lightest solution: it pulls in a full browser. But it correctly handles external resources, CSS and fonts where lighter tools break.

Since Playwright is synchronous, and the rest of the code is asynchronous, the call is wrapped in asyncio.to_thread — so as not to block the event loop.

python
result = await asyncio.to_thread(
    _generate_png_from_svg_sync,
    svg_path,
    ticket_data
)

Fonts — the least obvious part

The first working variant used @font-face in the SVG with relative paths to font files:

css
@font-face {
    font-family: 'Inter';
    src: url('../fonts/Inter-Regular.ttf') format('truetype');
}

It worked locally. In the Docker container — not. The browser couldn’t find files by relative paths inside the container, and the system default font was rendered instead of the desired font.

The solution turned out to be simple: install the required font into the system inside the container and remove the @font-face rules from the SVG altogether. The browser finds the system font via fontconfig automatically. To make sure the font has definitely loaded before the screenshot, an explicit wait is added:

python
page.evaluate("document.fonts.ready")
page.wait_for_timeout(2000)

It looks like a hack, but it’s the standard way to wait for fonts to load in the browser before taking a screenshot.

Conclusion

SVG as a template, Python for substituting data, Playwright for rendering. The only non-trivial part is fonts in Docker, and there’s a simple solution for that too — just not obvious at first.

Boring technology. Boring technology works.

// Python Dev

Другие статьи Python Dev

Все статьи

// Python Projects

Проекты Python Dev

Все проекты

2026-05-28

Robot collector: automatic debtor calls

An automated voice-calling system for debt collection with Google Sheets integration, speech synthesis, response recognition, and repeated call attempts.

// Contact

Need help?

Get in touch with me and I'll help solve the problem

Message on Telegram

Отвечаю в течение рабочего дня (03:00–13:00 GMT)

Или оставьте заявку здесь:

Send request
Write and get a quick reply