blog-figure-svg
/install blog-figure-svg
blog-figure-svg
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 complements ghost-blog-writer (publishes to Ghost CMS) 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.
/blog-figure-svg flow "\x3Ctitle>" --steps "Trigger -> Filter -> HTTP -> Slack"
/blog-figure-svg compare "\x3Ctitle>" --bars "Zapier:0.03,Make:0.015,n8n:0.008" --unit "$ per task"
/blog-figure-svg taxonomy "\x3Ctitle>" --groups "Workflows,Agents,RPA" --notes "see references/style-examples.md"
/blog-figure-svg terminal "\x3Ctitle>" --lines "$ npm install\
added 42 packages"
/blog-figure-svg feature "\x3Cheadline>" --accent "#4F46E5" --pill "How To"
All variants write to tmp/blog-drafts/\x3Cslug>-\x3CN>-\x3Cshort-name>.svg (editable source, gitignored), then rasterize to \x3Cslug>-\x3CN>-\x3Cshort-name>.png (uploaded to the blog CDN).
Before you start
The skill expects a working directory it can write into. Default: tmp/blog-drafts/. The PNG rasterizer requires one of:
- ImageMagick (
magickcommand) — preferred.magick -density 192 -background white in.svg -resize 1600x out.png. - rsvg-convert —
rsvg-convert -w 1600 -b white in.svg -o out.png. - inkscape (CLI) —
inkscape --export-type=png --export-width=1600 in.svg. - cairosvg (Python) —
pip install cairosvg;cairosvg in.svg -W 1600 -o out.png.
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:
| Hex | Role |
|---|---|
#3b82f6 |
accent blue — primary data series |
#fb923c |
orange — secondary series |
#10b981 |
green — tertiary / positive |
#0b0b11 |
text — titles, primary callouts |
#475569 / #6b7280 / #9ca3af |
greys — secondary labels, axis ticks |
#cbd5e1 / #94a3b8 |
light greys — gridlines, weak series |
#fafafa |
background fill |
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 \x3Cheight>" for inline figures (Casper-style content columns); viewBox="0 0 1600 840" for OG cards. Do not set root width/height attributes — let the host theme scale.
SVG skeleton (every figure)
\x3Csvg 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">
\x3Ctitle id="t1">Short, informative title - what the figure shows\x3C/title>
\x3Cdesc id="d1">Long-form description for screen readers - what the bars/circles/lines depict, including all numbers shown on screen\x3C/desc>
\x3Crect width="800" height="360" fill="#fafafa"/>
\x3C!-- bars / circles / paths / labels -->
\x3C/svg>
Accessibility checklist:
role="img"on the root\x3Csvg>.\x3Ctitle>+\x3Cdesc>referenced viaaria-labelledby(NOTaria-describedby— the former covers both).- Suffix IDs with the figure number (
t1/d1,t2/d2, ...) so multiple figures on one page don't collide. \x3Cdesc>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 \x3Cdesc> and in a small grey caption inside the figure.
Variant: flow — horizontal process flow
For: "how to" sequences, integration topology, decision trees.
# Args: title, steps (--steps "Trigger -> Filter -> HTTP -> Slack")
import sys, html, pathlib
title, steps_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
steps = [s.strip() for s in steps_arg.split('->') if s.strip()]
n = len(steps)
assert 2 \x3C= n \x3C= 7, f"flow needs 2-7 steps, got {n}"
W, H = 800, 240
margin_x = 60
gap = (W - 2*margin_x) / (n - 1) if n > 1 else 0
box_w, box_h = 130, 64
cy = H // 2 + 10
nodes = []
arrows = []
for i, s in enumerate(steps):
cx = margin_x + i * gap
x = cx - box_w / 2
y = cy - box_h / 2
nodes.append(
f'\x3Crect x="{x:.0f}" y="{y:.0f}" width="{box_w}" height="{box_h}" '
f'rx="8" fill="#fff" stroke="#3b82f6" stroke-width="2"/>'
f'\x3Ctext x="{cx:.0f}" y="{cy + 5:.0f}" text-anchor="middle" '
f'font-size="14" font-weight="600" fill="#0b0b11">{html.escape(s)}\x3C/text>'
)
if i \x3C n - 1:
x1 = cx + box_w / 2
x2 = margin_x + (i + 1) * gap - box_w / 2
arrows.append(
f'\x3Cline x1="{x1:.0f}" y1="{cy}" x2="{x2 - 8:.0f}" y2="{cy}" '
f'stroke="#6b7280" stroke-width="2" marker-end="url(#arrow)"/>'
)
desc = f"Flow diagram showing steps: {' to '.join(steps)}."
svg = f'''\x3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}"
font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
role="img" aria-labelledby="t1 d1">
\x3Ctitle id="t1">{html.escape(title)}\x3C/title>
\x3Cdesc id="d1">{html.escape(desc)}\x3C/desc>
\x3Cdefs>
\x3Cmarker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
\x3Cpath d="M0,0 L10,5 L0,10 z" fill="#6b7280"/>
\x3C/marker>
\x3C/defs>
\x3Crect width="{W}" height="{H}" fill="#fafafa"/>
\x3Ctext x="{W//2}" y="40" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}\x3C/text>
{"".join(arrows)}
{"".join(nodes)}
\x3C/svg>'''
pathlib.Path(out_path).write_text(svg, encoding='utf-8')
print(f"wrote {out_path} ({n} steps)")
Variant: compare — bar chart
For: numeric comparisons (prices, latencies, accuracy, counts). 2-7 bars.
# Args: title, bars (--bars "Zapier:0.03,Make:0.015,n8n:0.008"), unit
import sys, html, pathlib
title, bars_arg, unit, out_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
pairs = []
for chunk in bars_arg.split(','):
label, val = chunk.split(':')
pairs.append((label.strip(), float(val.strip())))
n = len(pairs)
assert 2 \x3C= n \x3C= 7, f"compare needs 2-7 bars, got {n}"
W, H = 800, 360
margin_x, margin_top, margin_bottom = 80, 70, 70
plot_w = W - 2 * margin_x
plot_h = H - margin_top - margin_bottom
max_v = max(v for _, v in pairs)
bar_w = plot_w / (n * 1.5)
gap = bar_w * 0.5
colors = ['#3b82f6', '#fb923c', '#10b981', '#94a3b8', '#6b7280', '#cbd5e1', '#475569']
bars = []
labels = []
for i, (label, val) in enumerate(pairs):
h = (val / max_v) * plot_h if max_v else 0
x = margin_x + i * (bar_w + gap)
y = margin_top + (plot_h - h)
bars.append(
f'\x3Crect x="{x:.0f}" y="{y:.0f}" width="{bar_w:.0f}" height="{h:.0f}" fill="{colors[i % len(colors)]}"/>'
f'\x3Ctext x="{x + bar_w/2:.0f}" y="{y - 8:.0f}" text-anchor="middle" font-size="13" font-weight="600" fill="#0b0b11">{val:g}\x3C/text>'
)
labels.append(
f'\x3Ctext x="{x + bar_w/2:.0f}" y="{H - margin_bottom + 24:.0f}" text-anchor="middle" font-size="13" fill="#475569">{html.escape(label)}\x3C/text>'
)
desc = f"Bar chart comparing {unit}: " + ", ".join(f"{label} {val:g}" for label, val in pairs) + "."
svg = f'''\x3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}"
font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
role="img" aria-labelledby="t1 d1">
\x3Ctitle id="t1">{html.escape(title)}\x3C/title>
\x3Cdesc id="d1">{html.escape(desc)}\x3C/desc>
\x3Crect width="{W}" height="{H}" fill="#fafafa"/>
\x3Ctext x="{W//2}" y="36" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}\x3C/text>
\x3Ctext x="{W//2}" y="56" text-anchor="middle" font-size="12" fill="#6b7280">{html.escape(unit)}\x3C/text>
\x3Cline x1="{margin_x}" y1="{H - margin_bottom}" x2="{W - margin_x}" y2="{H - margin_bottom}" stroke="#cbd5e1" stroke-width="1"/>
{"".join(bars)}
{"".join(labels)}
\x3C/svg>'''
pathlib.Path(out_path).write_text(svg, encoding='utf-8')
print(f"wrote {out_path} ({n} bars)")
Variant: taxonomy — labelled groups (Venn-lite)
For: introducing named categories. 2-4 groups.
# Args: title, groups (--groups "Workflows,Agents,RPA"), notes
import sys, html, pathlib, math
title, groups_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
groups = [g.strip() for g in groups_arg.split(',') if g.strip()]
n = len(groups)
assert 2 \x3C= n \x3C= 4, f"taxonomy needs 2-4 groups, got {n}"
W, H = 800, 400
cx, cy = W // 2, H // 2 + 20
r = 110
colors = ['#3b82f6', '#fb923c', '#10b981', '#94a3b8']
opacity = 0.4
circles = []
labels = []
if n == 2:
positions = [(cx - 60, cy), (cx + 60, cy)]
elif n == 3:
positions = [(cx, cy - 50), (cx - 70, cy + 40), (cx + 70, cy + 40)]
else: # 4
positions = [(cx - 70, cy - 50), (cx + 70, cy - 50), (cx - 70, cy + 50), (cx + 70, cy + 50)]
for i, ((x, y), label) in enumerate(zip(positions, groups)):
circles.append(
f'\x3Ccircle cx="{x}" cy="{y}" r="{r}" fill="{colors[i]}" fill-opacity="{opacity}" stroke="{colors[i]}" stroke-width="2"/>'
)
# Label outside the circle, away from center
dx, dy = x - cx, y - cy
mag = math.sqrt(dx*dx + dy*dy) or 1
lx = x + (dx / mag) * (r + 30)
ly = y + (dy / mag) * (r + 30)
labels.append(
f'\x3Ctext x="{lx:.0f}" y="{ly:.0f}" text-anchor="middle" font-size="15" font-weight="600" fill="#0b0b11">{html.escape(label)}\x3C/text>'
)
desc = f"Taxonomy diagram showing groups: {', '.join(groups)}, with overlapping regions indicating shared concepts."
svg = f'''\x3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}"
font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
role="img" aria-labelledby="t1 d1">
\x3Ctitle id="t1">{html.escape(title)}\x3C/title>
\x3Cdesc id="d1">{html.escape(desc)}\x3C/desc>
\x3Crect width="{W}" height="{H}" fill="#fafafa"/>
\x3Ctext x="{W//2}" y="40" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}\x3C/text>
{"".join(circles)}
{"".join(labels)}
\x3C/svg>'''
pathlib.Path(out_path).write_text(svg, encoding='utf-8')
print(f"wrote {out_path} ({n} groups)")
Variant: terminal — annotated terminal mock
For: command output, error messages, config blobs.
# Args: title, lines (newline-separated), out_path
import sys, html, pathlib
title, lines_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
lines = lines_arg.split('\
')
assert 1 \x3C= len(lines) \x3C= 16, f"terminal needs 1-16 lines, got {len(lines)}"
W = 800
line_h = 22
H = 80 + line_h * len(lines) + 30
chrome_h = 36
rows = []
for i, ln in enumerate(lines):
y = 80 + chrome_h + i * line_h
# Highlight error lines red, prompt lines green
color = '#fb923c' if 'error' in ln.lower() or 'fail' in ln.lower() else '#10b981' if ln.startswith('$') else '#cbd5e1'
rows.append(
f'\x3Ctext x="32" y="{y}" font-family="ui-monospace, Menlo, Consolas, monospace" '
f'font-size="14" fill="{color}" xml:space="preserve">{html.escape(ln)}\x3C/text>'
)
desc = "Terminal mock showing: " + " | ".join(ln for ln in lines if ln.strip())[:200]
svg = f'''\x3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}"
font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
role="img" aria-labelledby="t1 d1">
\x3Ctitle id="t1">{html.escape(title)}\x3C/title>
\x3Cdesc id="d1">{html.escape(desc)}\x3C/desc>
\x3Crect width="{W}" height="{H}" fill="#fafafa"/>
\x3Ctext x="{W//2}" y="36" text-anchor="middle" font-size="18" font-weight="700" fill="#0b0b11">{html.escape(title)}\x3C/text>
\x3Crect x="20" y="60" width="{W - 40}" height="{H - 80}" rx="8" fill="#0b0b11"/>
\x3Ccircle cx="40" cy="78" r="6" fill="#fb923c"/>
\x3Ccircle cx="58" cy="78" r="6" fill="#10b981"/>
\x3Ccircle cx="76" cy="78" r="6" fill="#94a3b8"/>
{"".join(rows)}
\x3C/svg>'''
pathlib.Path(out_path).write_text(svg, encoding='utf-8')
print(f"wrote {out_path} ({len(lines)} lines)")
Variant: feature — OG / feature card (1600x840)
For: the post's hero image (Ghost feature_image, 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.
# Args: headline, accent (hex), pill (short tag like "How To"), brand_wordmark, out_path
import sys, html, textwrap, re, pathlib
headline, accent, pill, brand, out_path = sys.argv[1:6]
# Auto-fit headline: 3-line cap on common tiers (longest tier may use 4).
n = len(headline)
if n \x3C= 32: size, wrap, max_lines = 120, 14, 2
elif n \x3C= 60: size, wrap, max_lines = 92, 20, 3
elif n \x3C= 90: size, wrap, max_lines = 76, 26, 3
else: size, wrap, max_lines = 60, 32, 4
lines = textwrap.wrap(headline, wrap)[:max_lines]
line_h = int(size * 1.15)
total_h = line_h * (len(lines) - 1) + size
y0 = 420 - total_h // 2 + size # vertical center inside 1600x840
tspans = "".join(
f'\x3Ctspan x="120" dy="{0 if i==0 else line_h}">{html.escape(line)}\x3C/tspan>'
for i, line in enumerate(lines)
)
# Hero element: number-as-hero when the headline has a 1-3 digit number,
# otherwise a clean geometric placeholder. Skips 4-digit matches (years).
m = re.search(r'\b(\d{1,3})\b', headline)
if m:
hero = (
f'\x3Ctext x="1500" y="640" text-anchor="end" font-family="ui-sans-serif, system-ui, sans-serif" '
f'font-weight="800" font-size="500" fill="{accent}" '
f'opacity="0.20" letter-spacing="-20">{m.group(1)}\x3C/text>'
)
else:
# Default placeholder icon: stacked geometric shapes
hero = (
f'\x3Cg transform="translate(1190,260) scale(1.0)" fill="none" stroke="{accent}" stroke-width="7" stroke-linecap="round">'
f'\x3Ccircle cx="140" cy="140" r="100" opacity="0.4"/>'
f'\x3Ccircle cx="140" cy="140" r="60" opacity="0.6"/>'
f'\x3Ccircle cx="140" cy="140" r="20" fill="{accent}" opacity="1"/>'
f'\x3C/g>'
)
svg = f'''\x3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 840" role="img" aria-labelledby="t1 d1">
\x3Ctitle id="t1">{html.escape(headline)}\x3C/title>
\x3Cdesc id="d1">Feature card for blog post: {html.escape(headline)}. Pill label: {html.escape(pill)}.\x3C/desc>
\x3Cdefs>
\x3ClinearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
\x3Cstop offset="0%" stop-color="#F8FAFC"/>
\x3Cstop offset="100%" stop-color="#E2E8F0"/>
\x3C/linearGradient>
\x3CradialGradient id="hi" cx="0.15" cy="0.1" r="0.7">
\x3Cstop offset="0%" stop-color="{accent}" stop-opacity="0.18"/>
\x3Cstop offset="100%" stop-color="{accent}" stop-opacity="0"/>
\x3C/radialGradient>
\x3Cpattern id="grid" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
\x3Cpath d="M 24 0 L 0 0 0 24" fill="none" stroke="{accent}" stroke-width="1" opacity="0.07"/>
\x3C/pattern>
\x3C/defs>
\x3Crect width="1600" height="840" fill="url(#bg)"/>
\x3Crect width="1600" height="840" fill="url(#grid)"/>
\x3Crect width="1600" height="840" fill="url(#hi)"/>
\x3Crect x="0" y="0" width="14" height="840" fill="{accent}"/>
{hero}
\x3Ctext x="120" y="{y0}" font-family="ui-sans-serif, system-ui, sans-serif" font-size="{size}" font-weight="800" fill="#0F172A" letter-spacing="-2">{tspans}\x3C/text>
\x3Ctext x="120" y="760" font-family="ui-sans-serif, system-ui, sans-serif" font-size="28" font-weight="700" fill="{accent}" letter-spacing="2">{html.escape(brand.upper())}\x3C/text>
\x3Ctext x="1480" y="760" text-anchor="end" font-family="ui-sans-serif, system-ui, sans-serif" font-size="24" font-weight="600" fill="#475569" letter-spacing="1">{html.escape(pill)}\x3C/text>
\x3C/svg>'''
pathlib.Path(out_path).write_text(svg, encoding='utf-8')
print(f"wrote {out_path} ({len(lines)} lines, hero={'number' if m else 'icon'})")
Customising the hero icon: replace the placeholder \x3Cg> 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/\x3Cslug>-*.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/\x3Cslug>-*.png; do
pngquant --skip-if-larger --strip --output "$png" --force 256 "$png" || true
done
ls -lh tmp/blog-drafts/\x3Cslug>-*.png
If pngquant isn't installed, oxipng -o 4 tmp/blog-drafts/\x3Cslug>-*.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/\x3Cslug>-*.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/\x3Cslug>-*.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 \x3C/p> of the paragraph the figure should appear after. Pick a phrase distinctive enough that str.replace finds exactly one match.
Insert with a generic \x3Cfigure> block (Ghost's Casper theme renders this cleanly; most other Ghost themes do too):
\x3Cfigure>
\x3Cimg src="\x3Cuploaded-png-url>" alt="\x3Cfull description with all numbers and labels>" loading="lazy">
\x3Cfigcaption>One sentence restating the takeaway in plain English (15-30 words).\x3C/figcaption>
\x3C/figure>
Caption rules:
- Required on every figure. No bare
\x3Cimg>and no\x3Cfigure>without a\x3Cfigcaption>. Theghost-blog-writerskill's payload 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).
- Allowed tags inside
\x3Cfigcaption>:\x3Ca>(withrel="nofollow noopener"for external),\x3Cem>. Nothing else. - No "Figure 1." numbering.
Alt text rules:
- 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/\x3Cslug>.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 publish skill (e.g. ghost-blog-writer) handles auth and the upload endpoint. After generating PNGs:
- For Ghost: use the
ghost-blog-writerskill's Step 6 image-upload snippet (POST to/ghost/api/admin/images/upload/with the Admin API JWT). - For WordPress: use the REST API
/wp/v2/mediaendpoint with application password auth. - For static-site generators (Hugo, Astro, Eleventy): drop the PNGs into the project's static directory and reference relative paths.
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 \x3Csvg> |
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 \x3Cfigure> |
Manually pasted \x3Cimg> not wrapped in \x3Cfigure> |
Wrap in \x3Cfigure>...\x3Cfigcaption>...\x3C/figcaption>\x3C/figure> — every figure needs a caption |
Companion skills
blog-topic-research— validates that a long-tail topic has real demand signals before drafting.ghost-blog-writer— drafts, scrubs, and publishes the post to Ghost CMS via the Admin API.
Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.
- 确保已安装 OpenClaw(本地或 Docker 部署)
- 在对话框中输入安装命令:
/install blog-figure-svg - 安装完成后,直接呼叫该 Skill 的名称或使用
/blog-figure-svg触发 - 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
blog-figure-svg 是什么?
Generate accessible, lightweight SVG figures for blog posts: flow diagrams, comparison bar charts, taxonomy/Venn diagrams, annotated terminal mocks, and temp... 它是一个面向 Claude Code / OpenClaw 的 AI Agent Skill 插件,目前累计下载 30 次。
如何安装 blog-figure-svg?
在 OpenClaw 或 Claude Code 对话框中运行命令「/install blog-figure-svg」即可一键安装,无需额外配置。
blog-figure-svg 是免费的吗?
是的,blog-figure-svg 完全免费,采用 MIT-0 许可证,可自由下载、安装和使用。
blog-figure-svg 支持哪些平台?
blog-figure-svg 跨平台运行,可在任意部署了 OpenClaw / Claude Code 的环境中使用(cross-platform)。
谁开发了 blog-figure-svg?
由 Artyom Rabzonov(@ratamaha-git)开发并维护,当前版本 v1.0.0。