skip to content
Adam Coates
Table of Contents

Why SVG images work better with HTML

SVG format is basically an XML format. This means that the “code” of the image is not stored in raw bytes like a .png or a .jpeg but is stored as plain text. As such each element in an .svg can be directly manipulated through text.

The cool thing about svg’s is that you can use .css variables for the color of elements. Take this code for example:

<svg xmlns:ns1="http://www.w3.org/1999/xlink" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" width="133.34pt" height="133.34pt" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="40" fill="var(--color-global-text)"/>
</svg>

It produces a single filled in circle with a fill variable of var(--color-global-text)

This is special because it means that this circle changes color depending on if you’re viewing this website in dark or light mode. (Try it out here! Click the icon to change theme: and watch the circle change colors!)

This means that the svg content is essentially “theme-aware”. It means that the color assigned to the filled in circle will change depending on if the website is in dark or light mode.

Integrating with Python plots in the blog

On this blog, I want to showcase some more plotting in python and R. Before, this was no issue as the website was made using Quarto, which is able to run python and R code on the fly and render output inline with markdown text. Having switched to Astro for my blog this is now not natively possible (check out this blog about why I switched to Astro).

Despite the shortcomings of Astro’s ability in rendering python and R code, it is still possible of course to embed the plots image in the website. The benefit of this, is that I can render plots to theme-aware svgs instead of just using .pngs or .jpgs.

How to create theme-aware python plots in Astro

Since Quarto had its own markdown format: .qmd the rendering of python and R code happened each time I rendered the website. Since this doesn’t happen with Astro the simple solution is to keep the python and R code separate from the blog and just embed the image that the plot produces and provide code used to create the plot.

Below is an example of a theme-aware plot made in python. It is just a simple Fourier Transform showing the constituent frequencies plotted as a function of amplitude.

And here is the script used to generate it:

import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
# theme-aware SVG exporter
from mpl_theme_svg import save_theme_svg, configure_mpl_for_svg
def create_fourier_transform_plot(output_path="fourier_transform.svg"):
configure_mpl_for_svg()
sampling_rate = 1000
duration = 2.0
t = np.linspace(0, duration, int(sampling_rate * duration), endpoint=False)
frequencies = [5, 15, 30]
amplitudes = [1.0, 0.6, 0.3]
phases = [0, np.pi/4, np.pi/2]
waves = []
for freq, amp, phase in zip(frequencies, amplitudes, phases):
wave = amp * np.sin(2 * np.pi * freq * t + phase)
waves.append(wave)
complex_wave = np.sum(waves, axis=0)
fft_result = np.fft.fft(complex_wave)
fft_freq = np.fft.fftfreq(len(t), 1/sampling_rate)
positive_freq_idx = fft_freq > 0
fft_freq = fft_freq[positive_freq_idx]
fft_magnitude = np.abs(fft_result[positive_freq_idx]) / len(t)
fig = plt.figure(figsize=(12, 10))
gs = fig.add_gridspec(4, 2, hspace=0.4, wspace=0.3)
# Plot 1: Complex wave (top, spanning both columns)
ax1 = fig.add_subplot(gs[0, :])
ax1.plot(t[:500], complex_wave[:500], linewidth=2)
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("Amplitude")
ax1.set_title("Complex Wave (Sum of Components)", fontweight='bold', fontsize=12)
ax1.grid(True, alpha=0.3)
ax1.spines['top'].set_visible(False)
ax1.spines['right'].set_visible(False)
# Plots 2-4: Individual constituent waves
for i, (wave, freq, amp) in enumerate(zip(waves, frequencies, amplitudes)):
ax = fig.add_subplot(gs[i+1, 0])
ax.plot(t[:500], wave[:500], linewidth=2)
ax.set_xlabel("Time (s)" if i == 2 else "")
ax.set_ylabel("Amplitude")
ax.set_title(f"Component {i+1}: {freq} Hz, Amp={amp}", fontsize=10)
ax.grid(True, alpha=0.3)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.set_ylim(-1.2, 1.2)
# Plot 5: Frequency spectrum (Fourier Transform)
ax5 = fig.add_subplot(gs[1:, 1])
bars = ax5.bar(fft_freq[:100], fft_magnitude[:100], width=0.8, edgecolor='none')
# Highlight the main frequency peaks
for freq in frequencies:
idx = np.argmin(np.abs(fft_freq - freq))
if idx < 100:
bars[idx].set_height(fft_magnitude[idx])
ax5.set_xlabel("Frequency (Hz)")
ax5.set_ylabel("Magnitude")
ax5.set_title("Frequency Spectrum (Fourier Transform)", fontweight='bold', fontsize=12)
ax5.grid(True, alpha=0.3, axis='y')
ax5.spines['top'].set_visible(False)
ax5.spines['right'].set_visible(False)
ax5.set_xlim(0, 50)
# Add annotations for peaks
for freq, amp in zip(frequencies, amplitudes):
idx = np.argmin(np.abs(fft_freq - freq))
if idx < 100:
ax5.annotate(f'{freq}Hz',
xy=(freq, fft_magnitude[idx]),
xytext=(0, 10), textcoords='offset points',
ha='center', fontsize=8)
plt.suptitle("Fourier Transform Decomposition",
fontsize=14, fontweight='bold', y=0.995)
# Save with theme-aware colors
wave_colors = [
"var(--svg-color-1)",
"var(--svg-color-2)",
"var(--svg-color-3)",
"var(--svg-color-4)"
]
save_theme_svg(
fig,
output_path,
series_colors=wave_colors,
max_width=800,
close_fig=True
)
return output_path
if __name__ == "__main__":
output_file = create_fourier_transform_plot()
print(f"\n✓ Fourier transform visualization saved to: {output_file}")

You’ll notice that the plot above is just a matplotlib.pyplot and that there really isn’t that much special about it. However, the important parts have been highlighted that call functions I have made that create the plot as a theme-aware svg.

I have split the code up into each respective function using expandable, clickable elements. I hope this is the most readable. Here is the code:

Break down of the code

configure_mpl_for_svg()

configure_mpl_for_svg()

Firstly, configure_mpl_for_svg() is a function to change the settings of the default matplotlib settings. By default text in the figure is save as shape data this makes it harder to change the color of the text in the plot. This function also calls another function quick_save which sets up some default color variables to be used in the svg. This makes it so that I don’t necessarily have to specify the svg variables that I want to use every single time.

Configure matplotlib for saving to svg

What it does: Changes Matplotlib’s internal settings so that:

  • Text stays as real text (not converted to shapes).
  • A basic, clean font is used.

In plain terms:

Prepare Matplotlib to export clean, high-quality SVGs.

This ensures that text remains searchable and style-able in the SVG.

def configure_mpl_for_svg():
"""
Configure Matplotlib for optimal SVG output.
Call this once at the start of your script.
"""
mpl.rcParams['svg.fonttype'] = 'none'
mpl.rcParams['font.family'] = 'sans-serif'
def quick_save(fig, filepath, color_palette="default", **kwargs):
"""
Save with predefined color palettes.
Parameters
----------
fig : matplotlib.figure.Figure
Figure to save
filepath : str or Path
Output path
color_palette : str, default "default"
Palette name: "default", "vibrant", "pastel", "monochrome"
**kwargs : dict
Additional arguments passed to save_theme_svg
"""
palettes = {
"default": [
"var(--svg-color-1)",
"var(--svg-color-2)",
"var(--svg-color-3)",
"var(--svg-color-4)",
],
"vibrant": [
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-highlight)",
],
"pastel": [
"var(--pastel-1)",
"var(--pastel-2)",
"var(--pastel-3)",
"var(--pastel-4)",
],
"monochrome": [
"var(--gray-700)",
"var(--gray-600)",
"var(--gray-500)",
"var(--gray-400)",
],
}
colors = palettes.get(color_palette, palettes["default"])
return save_theme_svg(fig, filepath, series_colors=colors, **kwargs)

save_theme_svg

save_theme_svg(
fig,
output_path,
series_colors=wave_colors,
max_width=800,
close_fig=True
)

Secondly, the script uses save_theme_svg this does the bulk of the work. It firstly identifies each element in the plot. Then it colors the each element accordingly. Then it colors the text in the plot accordingly and finally saves the plot to an svg. The function tries to deal with elements that just have a fillcolor, or a strokecolor or both! This in itself, makes it a bit tricky and a bit convoluted, so if you happen to know of an easier way please let me know in the comments below .

In basic terms its just a convoluted glorified search-and-replace

Save theme svg

Save theme svg has 4 functions.

1. Identify elements

What it does: Scans the SVG to figure out which shapes represent actual data (lines, bars, scatter points) and which ones are just chart frames or backgrounds.

In plain terms:

Figure out which parts of the SVG are real data that should get nice colors, and which parts are just axes or backgrounds.

This is needed because Matplotlib labels everything in SVG format, and we only want to recolor the actual data.

def _identify_series_elements(root):
"""
Identify elements that represent data series.
Returns a set of elements that are lines, patches, or collections
that represent actual data rather than axes, spines, or other structural elements.
"""
series_elements = set()
axes_backgrounds = set()
for elem in root.iter():
elem_id = elem.get("id", "")
if elem_id.startswith("axes_"):
for child in elem:
child_id = child.get("id", "")
if child_id.startswith("patch_"):
axes_backgrounds.add(child)
break
for elem in root.iter():
elem_id = elem.get("id", "")
if elem in axes_backgrounds:
continue
if re.match(r'line2d_(\d+)', elem_id):
has_path = False
for child in elem:
if child.tag == "path":
has_path = True
break
if has_path:
series_elements.add(elem)
elif re.match(r'patch_(\d+)', elem_id):
patch_num = int(re.match(r'patch_(\d+)', elem_id).group(1))
if patch_num > 1 and elem not in axes_backgrounds:
parent = elem
series_elements.add(elem)
elif elem_id.startswith("PathCollection"):
series_elements.add(elem)
elif elem_id.startswith("PolyCollection") or elem_id.startswith("FillBetween"):
series_elements.add(elem)
elif elem_id.startswith("LineCollection") and "errorbar" not in elem_id.lower():
series_elements.add(elem)
return series_elements
2. Apply colors to elements

What it does: Goes through every element of the SVG file (lines, bars, shapes, labels) and replaces hard-coded colors with CSS variables.

In plain terms:

color all parts of the chart using the theme colors so the website’s dark/light mode can control them.

It:

  • colors text using the text color variable.
  • Detects which parts are actual data (lines, bars, scatter points).
  • Assigns each data series a color from your chosen list.
  • Makes the backgrounds and borders use CSS variables too.
def _apply_theme_colors(root, series_colors, bg_var, stroke_var, text_var):
"""Apply CSS color variables to SVG elements."""
series_elements = _identify_series_elements(root)
parent_map = {c: p for p in root.iter() for c in p}
series_index = 0
processed_series = set()
for elem in root.iter():
if elem.tag == "text":
_apply_text_color(elem, text_var)
continue
elem_id = elem.get("id", "")
parent = parent_map.get(elem)
parent_id = parent.get("id", "") if parent is not None else ""
is_series = elem in series_elements
parent_is_series = parent in series_elements if parent is not None else False
if "style" in elem.attrib:
style = elem.attrib["style"]
if (is_series or parent_is_series) and series_colors:
series_elem = elem if is_series else parent
if series_elem not in processed_series:
processed_series.add(series_elem)
series_list = list(series_elements)
if series_elem in series_list:
idx = series_list.index(series_elem)
color = series_colors[idx % len(series_colors)]
else:
color = series_colors[0]
if elem_id.startswith("line2d_") or parent_id.startswith("line2d_"):
style = re.sub(r'stroke:\s*[^;]+', f"stroke: {color}", style)
else:
if "fill:" in style:
style = re.sub(r'fill:\s*[^;]+', f"fill: {color}", style)
if "stroke:" in style and "fill:" not in style:
style = re.sub(r'stroke:\s*[^;]+', f"stroke: {stroke_var}", style)
else:
if "stroke:" in style:
style = re.sub(r'stroke:\s*[^;]+', f"stroke: {stroke_var}", style)
if "fill:" in style:
style = re.sub(r'fill:\s*[^;]+', f"fill: {bg_var}", style)
elem.attrib["style"] = style
3. Change text color

What it does: Changes the color of a text element (axis labels, titles, tick labels) so it uses a CSS variable instead of a fixed color.

In plain terms:

Make all the text in the chart use the theme’s text color.

def _apply_text_color(elem, text_var):
"""Apply text color to a text element."""
if "fill" in elem.attrib:
elem.attrib["fill"] = text_var
if "style" in elem.attrib:
style = elem.attrib["style"]
style = re.sub(r'fill:\s*[^;]+', f"fill: {text_var}", style)
elem.attrib["style"] = style
if "fill" not in elem.attrib:
elem.attrib["fill"] = text_var
4. Save theme svg

What it does: Takes a Matplotlib plot (a chart you made in Python) and saves it as an SVG image that can automatically change colors when a website switches themes (e.g., light mode → dark mode).

In plain terms:

Save this graph as a flexible SVG that uses CSS color variables, so it will adapt to the website’s theme.

Main steps it performs:

  • Saves the initial SVG temporarily.
  • Opens the SVG file and edits it.
  • Replaces all colors with CSS variables like var(—svg-text-color).
  • Makes sure the SVG resizes to fit the webpage.
  • Applies special colors to each data series (e.g., each line or bar).
  • Deletes the temporary file.
def save_theme_svg(
fig,
filepath,
series_colors=None,
bg_var="var(--svg-bg-color)",
stroke_var="var(--svg-border-color)",
text_var="var(--svg-text-color)",
max_width=600,
close_fig=True
):
"""
Save a Matplotlib figure as a CSS-themeable SVG.
Parameters
----------
fig : matplotlib.figure.Figure
The figure to save
filepath : str or Path
Output filepath for the SVG
series_colors : list of str, optional
CSS color values for data series (e.g., ["var(--color-1)", "#ff0000"])
If None, uses bg_var for all fills
bg_var : str, default "var(--svg-bg-color)"
CSS variable for background fills
stroke_var : str, default "var(--svg-border-color)"
CSS variable for borders/strokes
text_var : str, default "var(--svg-text-color)"
CSS variable for text color
max_width : int, default 600
Maximum width in pixels for the SVG
close_fig : bool, default True
Whether to close the figure after saving
Returns
-------
Path
The path to the saved SVG file
"""
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
tmp = filepath.with_suffix(".tmp.svg")
fig.savefig(tmp, format="svg")
if close_fig:
plt.close(fig)
tree = ET.parse(tmp)
root = tree.getroot()
root.attrib["width"] = str(max_width)
root.attrib["style"] = "max-width: 100%; height: auto;"
for elem in root.iter():
elem.tag = re.sub(r'\{.*\}', '', elem.tag)
_apply_theme_colors(root, series_colors, bg_var, stroke_var, text_var)
tree.write(filepath, encoding="utf-8", xml_declaration=True)
tmp.unlink()
print(f"✓ Saved theme-aware SVG → {filepath}")
return filepath

Theme aware Mermaid.js diagrams

After moving the blog over to Astro, I realised that I also wanted some support for mermaid.js diagrams. Natively, these were supported by Quarto, however, in Astro they aren’t. But this was a simple fix. Firstly I just needed to import the mermaid javascript module like so:

<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>

Then inside of the ./src/layouts/BlogPost.astro I just needed to create a javascript function that would interject, upon the rendering of a mermaid.js diagram, css variables that would make the diagram theme-aware.

const originalMermaidCode = new Map();
function getCurrentTheme() {
const dataTheme = document.documentElement.getAttribute('data-theme');
return dataTheme === 'light' ? 'default' : 'dark';
}
function getThemeVariables(theme) {
if (theme === 'dark') {
return {
primaryColor: '#fe8019',
primaryTextColor: '#ebdbb2',
primaryBorderColor: '#d65d0e',
lineColor: '#ebdbb2',
secondaryColor: '#b8bb26',
tertiaryColor: '#fabd2f',
background: '#282828',
mainBkg: '#3c3836',
secondBkg: '#504945',
mainContrastColor: '#ebdbb2',
darkTextColor: '#282828',
textColor: '#ebdbb2',
border1: '#665c54',
border2: '#7c6f64',
arrowheadColor: '#ebdbb2',
fontFamily: 'ui-monospace, monospace',
fontSize: '16px',
nodeBorder: '#d65d0e',
clusterBkg: '#3c3836',
clusterBorder: '#665c54',
defaultLinkColor: '#83a598',
titleColor: '#fb4934',
edgeLabelBackground: '#282828',
nodeTextColor: '#ebdbb2',
actorBorder: '#d65d0e',
actorBkg: '#3c3836',
actorTextColor: '#ebdbb2',
actorLineColor: '#ebdbb2',
signalColor: '#ebdbb2',
signalTextColor: '#ebdbb2',
labelBoxBkgColor: '#3c3836',
labelBoxBorderColor: '#665c54',
labelTextColor: '#ebdbb2',
loopTextColor: '#ebdbb2',
noteBorderColor: '#d65d0e',
noteBkgColor: '#504945',
noteTextColor: '#ebdbb2',
activationBorderColor: '#d65d0e',
activationBkgColor: '#3c3836',
sequenceNumberColor: '#282828',
pie1: '#fe8019',
pie2: '#b8bb26',
pie3: '#fabd2f',
pie4: '#83a598',
pie5: '#d3869b',
pie6: '#8ec07c',
pie7: '#fb4934',
pie8: '#d65d0e',
pie9: '#98971a',
pie10: '#d79921',
pie11: '#458588',
pie12: '#b16286'
};
}
return {};
}
async function initMermaid() {
const theme = getCurrentTheme();
const diagrams = document.querySelectorAll('.mermaid');
diagrams.forEach((diagram, index) => {
if (!originalMermaidCode.has(index)) {
originalMermaidCode.set(index, diagram.textContent.trim());
}
});
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: getThemeVariables(theme)
});
await mermaid.run({
querySelector: '.mermaid'
});
}
async function updateMermaidTheme() {
const newTheme = getCurrentTheme();
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: getThemeVariables(newTheme)
});
const diagrams = document.querySelectorAll('.mermaid');
diagrams.forEach((diagram, index) => {
const originalContent = originalMermaidCode.get(index);
if (originalContent) {
diagram.innerHTML = originalContent;
diagram.removeAttribute('data-processed');
}
});
await mermaid.run({
querySelector: '.mermaid'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMermaid);
} else {
setTimeout(initMermaid, 0);
}
document.addEventListener('theme-change', () => {
updateMermaidTheme();
});
document.addEventListener('astro:after-swap', () => {
originalMermaidCode.clear();
initMermaid();
});

Here we specify colors that should be used with mermaid js diagrams. There is then a function that updates the theme of the mermaid diagram when the page is actually loaded.

An event listener is also added for when the theme is changed from light to dark mode and as such just reloads the mermaid diagram and interjects the corresponding theme. Here is an example (another example was used on the previous blog post:

xychart title "Amount of Fruit" x-axis "Fruit" [apples, oranges, banana, pineapple] y-axis "Quantity" bar [3, 1, 5, 10]

Summary

Theme-aware SVGs create a consistent style across the blog and make it genuinely more pleasant to read. This matters because, let’s be honest: if a site looks “off” or breaks your immersion with clashing themes, you aren’t going to stick around for long. Good content deserves good presentation, and SVGs give us that control.

Reactions

Comments

Loading comments...