One JSON file, two renderers: theming generated PDFs and charts
Shipped
My fitness agent generates a PDF morning report with embedded charts. The report’s stylesheet had its colors hardcoded in a CSS f-string, and the chart renderer had the same colors hardcoded again in matplotlib calls a few hundred lines away. They had already drifted. This release pulled both into one token module and made the whole thing overridable from a JSON file that never gets committed. It also patched the release workflow to retry, after a single HTTP 503 ate the previous release entirely; more on that at the end.
The interesting part is not the palette. It is that a public repo needs a default that looks good for strangers, while I want my own brand on my own laptop, and those two needs are usually solved by hardcoding one and giving up on the other.
What a token layer actually buys you
A design token is deliberately boring. The W3C community group’s format spec
defines it as “information associated with a human readable name, at minimum a
name/value pair”, like
color-text-primary: #000000. The spec exists because every design tool invented
its own format, forcing teams to write custom glue to move token data between
them, and its stated goal is to “facilitate better interoperability between
tools” with one shared file
format.
You get a smaller version of the same win inside a single application. Your PDF stylesheet and your chart library are two “tools” that will never agree on anything else. A token dict is the one thing they can both read.
Step 1: the default theme and a merge that does not lose keys
The default has to be complete and good on its own, because a fresh clone with no configuration renders with it.
# theme.py
"""The one source of brand tokens for every generated artifact."""
from __future__ import annotations
import copy
import json
import logging
import os
from pathlib import Path
LOG = logging.getLogger(__name__)
BRAND_FILE_ENV = "BRAND_FILE"
DEFAULT_THEME: dict = {
"name": "press",
"colors": {
"paper": "#F5F0E6", # flat warm cream, never gradiented
"ink": "#181510", # text, headlines, and every structural rule
"dim": "#6E675C", # secondary text and gridlines
"accent": "#E8501F", # the ONE loud color, used sparingly
},
"fonts": {
"display_stack": "-apple-system, 'Segoe UI', Arial, sans-serif",
"mono_stack": "ui-monospace, 'SF Mono', Menlo, monospace",
"mono_file": None, # optional path to a real .ttf
},
"identity": {"brand_line": "LOCAL FITNESS", "stamp": "NS"},
}
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge `override` into a copy of `base`. Non-dict values
replace; unknown keys are kept so an override may carry keys a newer
default has not named yet."""
out = copy.deepcopy(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = copy.deepcopy(v)
return out
def load_theme() -> dict:
"""DEFAULT_THEME, deep-merged with the JSON file named by $BRAND_FILE.
Never raises. A missing, unreadable, or malformed brand file logs a
warning and yields the default theme, because a broken theme must not
be able to break a render.
"""
theme = copy.deepcopy(DEFAULT_THEME)
brand_file = os.environ.get(BRAND_FILE_ENV)
if brand_file:
try:
override = json.loads(Path(brand_file).expanduser().read_text("utf-8"))
if isinstance(override, dict):
theme = deep_merge(theme, override)
else:
LOG.warning("brand file %s is not a JSON object", brand_file)
except (OSError, ValueError):
LOG.warning("could not load brand file %s, using default", brand_file)
mono_file = theme.get("fonts", {}).get("mono_file")
if mono_file:
theme["fonts"]["mono_file"] = str(Path(mono_file).expanduser())
return theme
Two decisions worth copying.
load_theme() is called per render rather than cached at import. Editing the
brand file then takes effect on the next document without restarting a
long-running process, which matters when your renderer lives inside a server or a
tool process that stays up for hours.
The ~ expansion on mono_file is not cosmetic. A path like ~/fonts/Plex.ttf
resolves through HOME, and a scheduled job or a container often runs with a
different HOME than your shell. Expanding at load time means you find out at
render, in one place, instead of in whichever renderer touched it first.
Step 2: spend the tokens in both renderers
Here is the payoff. The stylesheet builder and the chart function read the same dict, so there is no third place where a color can hide.
# render.py
"""Two renderers, one theme: print CSS and a matplotlib PNG."""
from __future__ import annotations
import base64
import io
from pathlib import Path
from theme import load_theme
def font_face_css(theme: dict) -> tuple[str, str]:
"""(optional @font-face block, mono font-family stack).
When fonts.mono_file names a real font file, embed it as a data: URI so
the stylesheet stays self-contained and needs no filesystem access at
render time. Falls back to the stack when the file is absent.
"""
stack = theme["fonts"]["mono_stack"]
mono_file = theme["fonts"].get("mono_file")
if not mono_file:
return "", stack
try:
raw = Path(mono_file).read_bytes()
except OSError:
return "", stack
uri = "data:font/ttf;base64," + base64.b64encode(raw).decode("ascii")
face = f"@font-face {{ font-family: 'BrandMono'; src: url('{uri}'); }}"
return face, f"'BrandMono', {stack}"
def build_css(theme: dict) -> str:
"""The print stylesheet, built entirely from tokens."""
c, f = theme["colors"], theme["fonts"]
face, mono = font_face_css(theme)
return f"""{face}
body {{ background: {c['paper']}; color: {c['ink']}; font-family: {f['display_stack']}; }}
h1 {{ font-weight: 900; letter-spacing: -0.02em; border-bottom: 2px solid {c['ink']}; }}
.caption {{ color: {c['dim']}; font-style: italic; }}
.data {{ font-family: {mono}; }}
.critical {{ color: {c['accent']}; }}"""
def render_chart_png(series: list[tuple[str, float]], theme: dict) -> bytes:
"""A bar chart wearing the same tokens as the stylesheet."""
import matplotlib
matplotlib.use("Agg")
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
c = theme["colors"]
labels = [d for d, _ in series]
values = [v for _, v in series]
fig = Figure(figsize=(8, 4.5), dpi=150, facecolor=c["paper"])
FigureCanvasAgg(fig) # explicit canvas attach, never via pyplot
ax = fig.add_subplot(111)
ax.set_facecolor(c["paper"])
ax.bar(range(len(values)), values, color=c["ink"])
ax.grid(axis="y", color=c["dim"], linewidth=0.6, alpha=0.35)
ax.set_axisbelow(True)
for spine in ("top", "right"):
ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(c["dim"])
ax.spines["bottom"].set_color(c["dim"])
ax.tick_params(colors=c["ink"])
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, fontsize=8)
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight")
return buf.getvalue()
if __name__ == "__main__":
theme = load_theme()
print(f"theme: {theme['name']} accent: {theme['colors']['accent']}")
print(build_css(theme))
png = render_chart_png([("Mon", 3.1), ("Tue", 0), ("Wed", 6.2)], theme)
Path("chart.png").write_bytes(png)
print(f"\nwrote chart.png ({len(png)} bytes)")
Note the chart never touches pyplot. pyplot keeps global figure state, which
is a memory leak and a thread hazard inside a server process. Constructing a
Figure and attaching FigureCanvasAgg to it explicitly keeps the render local
to the call.
build_css returns a plain string, which is what a print pipeline wants. In
WeasyPrint you hand it in through the stylesheets option on write_pdf, which
accepts CSS objects, filenames, URLs, or file-like
objects.
Run it, then override it
Install matplotlib, put both files in a directory, and run python render.py.
You should see:
theme: press accent: #E8501F
body { background: #F5F0E6; color: #181510; font-family: -apple-system, 'Segoe UI', Arial, sans-serif; }
h1 { font-weight: 900; letter-spacing: -0.02em; border-bottom: 2px solid #181510; }
.caption { color: #6E675C; font-style: italic; }
.data { font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
.critical { color: #E8501F; }
wrote chart.png (9789 bytes)
Now the part that proves the merge. Write a brand file that changes two colors and nothing else, then run again with it:
printf '{"name": "midnight", "colors": {"paper": "#0E0E12", "ink": "#EDEDED"}}' > brand.json
BRAND_FILE=brand.json python render.py
theme: midnight accent: #E8501F
body { background: #0E0E12; color: #EDEDED; font-family: -apple-system, 'Segoe UI', Arial, sans-serif; }
h1 { font-weight: 900; letter-spacing: -0.02em; border-bottom: 2px solid #EDEDED; }
.caption { color: #6E675C; font-style: italic; }
.data { font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
.critical { color: #E8501F; }
That is the check to actually look at. paper and ink moved, dim and
accent and every font survived, and chart.png picked up the same background
without the chart code knowing an override happened. Open the PNG and confirm it
went dark too.
Gotchas
A shallow merge silently deletes the keys you did not mention. The tempting
one-liner is {**DEFAULT_THEME, **override}. Symptom: your brand file sets
colors.paper alone, and suddenly ink, dim, and accent are gone, so the
render either crashes on a KeyError or, worse, falls through to a library
default and looks almost right. Top-level colors gets replaced wholesale by the
one-key dict. The escape is the recursive deep_merge above, and the two-color
override in the verify step is the test that catches it.
A broken theme file must not be able to break a render. Truncate the JSON and try it:
printf '{"colors": {' > broken.json
BRAND_FILE=broken.json python render.py
could not load brand file broken.json, using default
theme: press accent: #E8501F
Catching (OSError, ValueError) covers both the missing file and the malformed
one, since json.JSONDecodeError subclasses ValueError. Warn and continue.
Somebody editing a color at 9pm should get an ugly document, not a failed job.
A print engine may refuse to load your font from disk. WeasyPrint’s
url_fetcher is a pluggable callable, and its URLFetcher takes an
allowed_protocols parameter described as “a set of authorized protocols, None
means all”.
Restricting a generated document to data: only is a sensible thing to do, and
it means a src: url('/Users/you/fonts/Plex.ttf') will simply never load. Symptom:
the PDF renders in a fallback face with no error anywhere. The escape is
font_face_css above, embedding the font as a data URL. Budget for the size:
base64 increases the payload by about
33%,
so a 200KB font becomes roughly 270KB of stylesheet. Skipping the format()
hint is deliberate here: it exists so a user agent can avoid downloading a
resource it cannot use, and if the value is invalid the browser may not download
the resource at all,
which is a needless way to lose a font that is already inlined.
A release can be lost to one HTTP 503. The other commit in this release fixed
the workflow that publishes tags. The previous version’s release job called
gh release create exactly once, hit a transient GitHub API failure, and never
cut the tag; the only signal was a red run nobody was watching. It now tries up
to five times, thirty seconds apart, with the already-released check moved
inside the loop, so a create that succeeded server-side but failed to respond
reads as “already released” on the next pass instead of creating a duplicate.
That ordering detail is the whole fix. A retry loop wrapped around a
non-idempotent call without a check inside it just gives you the failure twice.
Sources
- Design Tokens Format Module, W3C Design Tokens Community Group — the definition of a design token and the interoperability problem a shared format solves.
- WeasyPrint API reference — the
url_fetcherandallowed_protocolsmechanism, and how user stylesheets are passed towrite_pdf. - Data URLs, MDN — data URL syntax and the base64 size overhead.
@font-facesrc descriptor, MDN — accepted formats and what theformat()hint does.