← 返回 Skills 市场
ratamaha-git

blog-figure-svg

作者 Artyom Rabzonov · GitHub ↗ · v1.0.0 · MIT-0
cross-platform ⚠ suspicious
30
总下载
0
收藏
0
当前安装
1
版本数
在 OpenClaw 中安装
/install blog-figure-svg
功能描述
Generate accessible, lightweight SVG figures for blog posts: flow diagrams, comparison bar charts, taxonomy/Venn diagrams, annotated terminal mocks, and temp...
使用说明 (SKILL.md)

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 (magick command) — preferred. magick -density 192 -background white in.svg -resize 1600x out.png.
  • rsvg-convertrsvg-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 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.
  • \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>. The ghost-blog-writer skill'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> (with rel="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-writer skill'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/media endpoint 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.

安全使用建议
This looks safe for its stated purpose. Before using it, make sure any optional rasterizer/compressor tools are installed from trusted sources, run it in the correct project directory, and review generated images before uploading them to a blog or CDN.
功能分析
Type: OpenClaw Skill Name: blog-figure-svg Version: 1.0.0 The skill provides Python scripts and shell command templates for generating SVG blog figures and converting them to PNG. A vulnerability exists in SKILL.md where shell command examples for rasterization and compression (using tools like 'magick' and 'pngquant') use unquoted placeholders such as '<slug>'. If the AI agent populates these placeholders with unsanitized user input, it could lead to shell injection and arbitrary command execution. Additionally, the Python scripts lack path sanitization for the 'out_path' argument, potentially allowing for arbitrary file write or path traversal if the agent is manipulated. No evidence of intentional malice or data exfiltration was found.
能力评估
Purpose & Capability
The stated purpose and visible instructions are coherent: generate accessible SVG figures and PNG versions for blog posts.
Instruction Scope
The instructions are bounded to editorial figure creation, design rules, accessibility metadata, and when to skip figures; they do not show goal hijacking or unrelated account/system access.
Install Mechanism
The skill is instruction-only but documents optional external rasterizers/compressors and a possible pip install step; users should install these only from trusted sources.
Credentials
The workflow expects local write access under tmp/blog-drafts and uses local image-conversion commands, which is proportionate for SVG-to-PNG generation.
Persistence & Privilege
No credentials, privileged paths, background services, persistent memory, or autonomous long-running behavior are shown; generated image files are the expected output.
如何使用
  1. 确保已安装 OpenClaw(本地或 Docker 部署)
  2. 在对话框中输入安装命令:/install blog-figure-svg
  3. 安装完成后,直接呼叫该 Skill 的名称或使用 /blog-figure-svg 触发
  4. 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
版本历史
v1.0.0
Initial publish: generates accessible SVG figures (flow, compare, taxonomy, terminal, feature card) for blog posts. Hand-authored SVG with title/desc accessibility, system-font palette, rasterizes to compressed PNG.
元数据
Slug blog-figure-svg
版本 1.0.0
许可证 MIT-0
累计安装 0
当前安装数 0
历史版本数 1
常见问题

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。

💬 留言讨论