// 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.
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 resultNo 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.
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.
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:
@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:
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
2026-06-10
Why an LLM won't replace a good parser. A case with auto parts
A client came to me with a task that at first glance looks almost naively simple — so much so that it’s a little suspicious. There is a catalog: more than …
2026-06-01
Why I decided to build the billionth ToDo app in my spare time
A todo app is a kind of Hello World in the programming world. Every developer has made one at least once, usually at the very beginning, when you don’t …
2026-05-30
Two days on a task that seemed trivial: asynchronous loading in Telegram bots
There’s a class of tasks that look like fifteen minutes of work. Then you sit down to them and discover it’s not about the code — it’s about how the system is …
// 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.
2026-05-27
Automatic management of a Telegram channel network for a travel agent
An automated publishing system for 150 Telegram channels with tour and flight selection, image generation, and scheduled posting.
2026-04-29
Automated call transcript: from recording to a structured document
Automatic call minutes: from recording to a structured document Distributed teams spend a lot of time on calls. They discuss tasks, make decisions, assign …
// Contact
Need help?
Get in touch with me and I'll help solve the problem
Message on TelegramОтвечаю в течение рабочего дня (03:00–13:00 GMT)
Или оставьте заявку здесь: