Stop using stock photos. Generate accessible, lightweight SVG figures for any blog or CMS - flow diagrams, comparison bar charts, taxonomy/Venn diagrams, ann...
Produces SVG figures intended for blog posts: in-line illustrations (1 per ~500 body words is the rule of thumb) and a templated OG feature card. Output is a clean SVG file (the editable source) rasterized to a compressed PNG (what the post references). Every figure carries title + desc + role="img" so screen readers can read it.
This skill is platform-agnostic — the SVG and PNG it produces work in any CMS (Ghost, WordPress, Webflow, Sanity) or static-site generator (Hugo, Astro, Eleventy, Jekyll, Next-MDX). It complements seo-blog-writer (handles the publish step for whatever platform you're on) and blog-topic-research (validates the topic). Use it during the illustration step of writing a post — after the prose is stable so the anchor sentences are final.
Plus pngquant (or oxipng) for compression — typical 60-80% size reduction with no visible quality loss. Core Web Vitals and ad-network reviews (Mediavine, Raptive) care about image weight.
command -v magick || command -v rsvg-convert || command -v inkscape || python3 -c "import cairosvg" 2>/dev/null \
|| echo "no SVG rasterizer found - install one of magick, rsvg-convert, inkscape, cairosvg"
command -v pngquant || command -v oxipng || echo "no PNG compressor - install pngquant or oxipng"
The three illustration shapes
Match each figure to a paragraph the reader has just finished, and to one concrete information structure:
| Shape | Use when the post... | Variant |
|---|---|---|
| Comparison | ...cites two or more numbers (prices, latencies, accuracy, counts) | compare (bar chart) |
| Taxonomy | ...introduces named categories (e.g. workflow / agent / RPA, or trigger / action / filter) | taxonomy (Venn, hierarchy, or labelled groups) |
| Process / flow | ...describes a "how to" sequence, integration topology, or decision tree | flow (horizontal flow with named steps) |
| CLI / API mock | ...shows command output, an error message, or a config blob | terminal (annotated terminal mock) |
| Title card | ...needs an OG feature image | feature (1600x840 templated card) |
Never plot data the post doesn't already cite. If you can't identify even one information structure to illustrate, skip — note in the report "no figures: post is too short / too definitional."
Hard rule for editorial pipelines: any post >=800 words needs at least 1 figure; figure count = max(1, body_words // 500). Sub-800-word definitional explainers are the only legitimate zero-figure case.
Palette and typography
Pick from these hex values. No new hues — consistency across figures is the brand:
Typography:font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" only. No embedded web fonts — they fail to load in feed readers, dark-mode previews, and AMP renders. Sizes: title 20px bold, section labels 14-16px, axis labels 11-13px.
ViewBox:viewBox="0 0 800 " for inline figures (a sane width for most CMS content columns, including Ghost's Casper, the WordPress block editor, and Hugo / Astro defaults); viewBox="0 0 1600 840" for OG cards. Do not set root width/height attributes — let the host theme scale.
SVG skeleton (every figure)
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 360"
font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
role="img" aria-labelledby="t1 d1">
<title id="t1">Short, informative title - what the figure shows</title>
<desc id="d1">Long-form description for screen readers - what the bars/circles/lines depict, including all numbers shown on screen</desc>
<rect width="800" height="360" fill="#fafafa"/>
<!-- bars / circles / paths / labels -->
</svg>
Accessibility checklist:
role="img" on the root .
+ referenced via aria-labelledby (NOT aria-describedby — the former covers both).
Suffix IDs with the figure number (t1/d1, t2/d2, ...) so multiple figures on one page don't collide.
includes every number visible in the figure (screen readers can't OCR the chart).
Honesty: never round towards a more dramatic gap, never anchor an axis to inflate differences. If the data is "practitioner observation, not a measured study," say so in and in a small grey caption inside the figure.
For: the post's hero image (Ghost feature_image, WordPress featured_media, the static-site front-matter feature_image field, OG previews, social cards). One per post.
The card uses a tinted gradient background, a 24px grid pattern at 7% opacity, a soft radial highlight, and either a giant accent number (when the headline contains a 1-3 digit number) or a placeholder icon slot. Brand text (your wordmark, pill label) is configurable.
Customising the hero icon: replace the placeholder block with cluster-specific iconography from your project. Keep stroke width 5-9, viewBox-relative coordinates (drawn for a 280x280 box), and stroke-only fills so the icon reads at thumbnail size in social previews. Examples (n8n nodes, code brackets, agent graph, RPA grid) are easy to author — see the feature script's structure.
Rasterize SVG to PNG
The SVG is the editable source. The blog references PNG only — most CMSes deliver PNG more reliably through their CDN than SVG.
# Preferred: ImageMagick at 192 DPI (renders text at 2x for sharpness)
for svg in tmp/blog-drafts/<slug>-*.svg; do
png="${svg%.svg}.png"
magick -density 192 -background white "$svg" -resize 1600x "$png"
done
# Or one of the fallbacks:
rsvg-convert -w 1600 -b white in.svg -o out.png
inkscape --export-type=png --export-width=1600 in.svg
python3 -c "import cairosvg; cairosvg.svg2png(url='in.svg', write_to='out.png', output_width=1600)"
-density 192 renders text at 2x before resize (sharpness). -background white prevents black halos around antialiased edges. -resize 1600x is the practical ceiling for a CMS content column.
Compress before upload
ImageMagick output is 200-400 KB per figure; pngquant typically cuts that 60-80% with no visible quality loss.
for png in tmp/blog-drafts/<slug>-*.png; do
pngquant --skip-if-larger --strip --output "$png" --force 256 "$png" || true
done
ls -lh tmp/blog-drafts/<slug>-*.png
If pngquant isn't installed, oxipng -o 4 tmp/blog-drafts/-*.png is a slower fallback. If neither is available, surface to the user and proceed — don't block the post on compression.
Verify the PNG
# Confirm dimensions and bit depth
magick identify tmp/blog-drafts/<slug>-*.png 2>/dev/null \
|| python3 -c "from PIL import Image; import sys; [print(p, Image.open(p).size) for p in sys.argv[1:]]" tmp/blog-drafts/<slug>-*.png
Open each PNG locally and confirm: text is sharp at 100% zoom, no missing glyphs, no black halos.
Embed in the post
For each figure, identify the anchor sentence in the draft — the closing
of the paragraph the figure should appear after. Pick a phrase distinctive enough that str.replace finds exactly one match.
Insert with a generic block (renders cleanly in every major CMS theme and every static-site generator's default Markdown→HTML pipeline):
<figure>
<img src="<uploaded-png-url-or-relative-path>" alt="<full description with all numbers and labels>" loading="lazy">
<figcaption>One sentence restating the takeaway in plain English (15-30 words).</figcaption>
</figure>
Caption rules:
Required on every figure. No bare and no without a . The seo-blog-writer skill's bundle validation refuses figures without captions.
One sentence, 15-30 words, restating the takeaway in plain English (not "Figure showing X" — say what the reader should conclude).
Restate every label and number visible in the figure. Screen readers read alt, not the figure.
50-200 chars. Longer than the caption.
Verify each PNG URL appears exactly once in the draft:
python3 -c "
import pathlib, re, sys
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
for m in re.finditer(r'src=\"([^\"]+\.png)\"', html):
print(m.group(1))
" tmp/blog-drafts/<slug>.draft.html | sort | uniq -c
Each URL should print 1. Zero = anchor missed; >1 = anchor matched multiple paragraphs (extend the anchor).
Upload to your CMS
This skill doesn't ship a CMS uploader — the seo-blog-writer skill handles auth and the upload endpoint for each platform it targets. After generating PNGs:
For Ghost:seo-blog-writer's Ghost adapter exposes an image-upload snippet (POST to /ghost/api/admin/images/upload/ with the Admin API JWT).
For WordPress:seo-blog-writer's WordPress adapter posts to /wp-json/wp/v2/media with application-password auth.
For static-site generators (Hugo, Astro, Eleventy, Jekyll, Next-MDX): drop the PNGs into the project's static / public / assets directory and reference relative paths in the figure tag.
For other CMSes (Webflow, Sanity, Strapi, Contentful): write a 20-line adapter that POSTs the PNG to the platform's media endpoint, then splice the returned URL.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| magick: no decode delegate on .svg | ImageMagick built without rsvg | Fallback: rsvg-convert, inkscape, or cairosvg |
| Text rendered as boxes / missing glyphs in PNG | Embedded font referenced but not installed | Use only generic ui-sans-serif, system-ui font families; no @font-face |
| Black halos around shapes in PNG | Antialiased SVG rendered against a transparent background | Pass -background white to ImageMagick |
| PNG looks blurry | Rasterized at 96 DPI | Use -density 192 (or -w 1600 with rsvg/cairosvg) |
| aria-labelledby ignored by screen readers | Missing role="img" on the root | Add role="img" — without it, the SVG is treated as a graphic group |
| Feature card text overflows the 1600x840 canvas | Headline longer than ~120 chars | Truncate headline or use the longest tier (60pt, 4 lines, 32 chars/line) |
| Figcaption missing on a | Manually pasted not wrapped in | Wrap in ...... — every figure needs a caption |
Companion skills
blog-topic-research — validates that a long-tail topic has real demand signals before drafting.
seo-blog-writer — drafts, scrubs, AI-SEO-audits, and publishes the post to your CMS (Ghost, WordPress, or static-site) via the platform adapter.
Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.