← All ivy-nodes
IVYXSTUDIO · IVY NODE
D
Design: a card, poster or banner as SVG
ivy.node.design-svg-card · v0.1.0
ivyx✓
Draws a social post, a poster or a banner as SVG from a spec (headline, subline, cta, palette, layout, brand) in the sizes asked for. The spec is what a model wrote from the brief; everything in it is validated here and a missing or broken field gets a default, so the picture is always drawn. No model, no network.
#design#svg#social#banner#agency
Inputs
| Field | Type | Description |
|---|---|---|
| spec | object | headline (≤60 chars), subline (≤120), cta (≤24), palette {bg, accent, text}, layout (poster, split, band, minimal), brand, caption. |
| sizes | array | Sizes as WxH, e.g. 1080x1080, 1080x1350, 1200x628, 728x90, 300x250; one square card when omitted. |
| fallback_text | string | The brief, for a headline when the spec has none. |
| brand | string | Not declared |
| name_prefix | string | The files' name stem. |
Outputs
| Field | Type | Description |
|---|---|---|
| filesrequired | array | The files made, each with its bytes as base64. |
| countrequired | integer | Not declared |
| headline | string | Not declared |
| palette | object | bg, accent, text as hex colours; a colour that is not a hex is replaced from a safe pair. |
| layout | string | Not declared |
Source
python
# Input preparation
inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]
# Compute
# Shared by every design node (built into each by build.mjs): colours, text, files.
import base64, re, math
SAFE_PALETTES = [
{"bg": "#1E1B4B", "accent": "#F6A23B", "text": "#F8F7FF"},
{"bg": "#F4F2FF", "accent": "#6C3BFF", "text": "#1E1B4B"},
{"bg": "#0F3D3E", "accent": "#E8C547", "text": "#F3F7F6"},
{"bg": "#FFF4E6", "accent": "#D9480F", "text": "#2B1D12"},
{"bg": "#101418", "accent": "#3DDC97", "text": "#F2F4F6"},
]
def hex_ok(s):
return isinstance(s, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", s.strip()) is not None
def palette_of(p, seed=0):
"""A palette with three valid hex colours; what the model got wrong is replaced from a safe pair."""
base = SAFE_PALETTES[abs(int(seed)) % len(SAFE_PALETTES)]
out = {}
p = p if isinstance(p, dict) else {}
for k in ("bg", "accent", "text"):
v = p.get(k)
out[k] = v.strip().upper() if hex_ok(v) else base[k]
if out["bg"].upper() == out["text"].upper():
out["text"] = base["text"] if base["text"].upper() != out["bg"].upper() else base["bg"]
return out
def rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def mix(a, b, t):
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
def to_hex(c):
return "#%02X%02X%02X" % c
def lum(h):
r, g, b = [x / 255 for x in rgb(h)]
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def esc(s):
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
def text_of(v, limit, default=""):
s = " ".join(str(v).split()) if isinstance(v, (str, int, float)) else ""
if not s:
s = default
return s[:limit].rstrip() if len(s) > limit else s
def wrap(s, max_chars):
words = s.split()
lines, cur = [], ""
for w in words:
if cur and len(cur) + 1 + len(w) > max_chars:
lines.append(cur)
cur = w
else:
cur = (cur + " " + w).strip()
if cur:
lines.append(cur)
return lines or [""]
def b64(data):
return base64.b64encode(data).decode("ascii")
def file_entry(name, mime, data, kind, width=None, height=None, caption=None, variant=None, extra=None):
f = {"name": name, "mime": mime, "kind": kind, "bytes": len(data), "data": b64(data)}
if width:
f["width"] = int(width)
if height:
f["height"] = int(height)
if caption:
f["caption"] = caption
if variant:
f["variant"] = variant
if extra:
f.update(extra)
return f
def slug(s):
s = re.sub(r"[^a-z0-9]+", "-", str(s).lower()).strip("-")
return s[:40] or "design"
def font():
return "Inter, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
class DesignSvgCard:
"Design: a card, poster or banner as SVG"
def run(self, inp):
out = {}
# design-svg-card: a social post, poster or banner as SVG, from a spec the model wrote (validated here) in the sizes asked for.
spec = inp.get("spec") if isinstance(inp.get("spec"), dict) else {}
fallback = inp.get("fallback_text") or ""
seed = sum(ord(c) for c in str(spec.get("brand") or fallback or "x"))
pal = palette_of(spec.get("palette"), seed)
headline = text_of(spec.get("headline"), 60, text_of(fallback, 60, "Something new is here"))
subline = text_of(spec.get("subline"), 120, "")
cta = text_of(spec.get("cta"), 24, "")
brand = text_of(spec.get("brand") or inp.get("brand"), 40, "")
layout = spec.get("layout") if spec.get("layout") in ("poster", "split", "band", "minimal") else "poster"
caption = text_of(spec.get("caption"), 160, "")
sizes = inp.get("sizes") if isinstance(inp.get("sizes"), list) and inp.get("sizes") else ["1080x1080"]
prefix = slug(inp.get("name_prefix") or brand or "card")
FF = font()
def size_of(s):
try:
w, h = str(s).lower().split("x")
w, h = int(w), int(h)
if 50 <= w <= 4000 and 50 <= h <= 4000:
return w, h
except Exception:
pass
return 1080, 1080
def card(w, h):
short = min(w, h)
compact = h < 200 or (w / h > 2.6)
tiny = h < 140
out = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">' % (w, h, w, h, esc(headline))]
out.append('<title>%s</title>' % esc(headline))
out.append('<rect width="%d" height="%d" fill="%s"/>' % (w, h, pal["bg"]))
accent, text = pal["accent"], pal["text"]
soft = to_hex(mix(rgb(pal["bg"]), rgb(accent), 0.18))
pad = int(short * (0.08 if not compact else 0.12))
if compact:
# a banner: one line, a cta pill at the right
fs = max(14, int(h * (0.34 if tiny else 0.26)))
out.append('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>' % (int(w * 0.012), h, accent))
line = wrap(headline, max(8, int((w * 0.62) / (fs * 0.55))))[0]
y = h / 2 + fs * 0.35 if not subline or tiny else h * 0.42
out.append('<text x="%d" y="%.0f" font-family="%s" font-size="%d" font-weight="700" fill="%s">%s</text>' % (pad, y, FF, fs, text, esc(line)))
if subline and not tiny:
sf = max(11, int(fs * 0.55))
out.append('<text x="%d" y="%.0f" font-family="%s" font-size="%d" fill="%s" opacity="0.85">%s</text>' % (pad, h * 0.42 + sf * 1.5, FF, sf, text, esc(wrap(subline, max(10, int((w * 0.6) / (sf * 0.52))))[0])))
if cta:
cf = max(11, int(h * 0.22))
cw = int(len(cta) * cf * 0.6 + cf * 1.6)
out.append('<rect x="%d" y="%.0f" width="%d" height="%.0f" rx="%.0f" fill="%s"/>' % (w - pad - cw, h / 2 - cf * 0.95, cw, cf * 1.9, cf * 0.95, accent))
out.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="650" fill="%s">%s</text>' % (w - pad - cw / 2, h / 2 + cf * 0.35, FF, cf, pal["bg"], esc(cta)))
else:
hf = int(short * 0.085)
sf = int(short * 0.036)
bf = int(short * 0.028)
if layout == "split":
out.append('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>' % (int(w * 0.42), h, accent))
out.append('<circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.35"/>' % (int(w * 0.21), int(h * 0.42), int(short * 0.16), pal["bg"]))
if brand:
out.append('<text x="%d" y="%d" font-family="%s" font-size="%d" font-weight="700" fill="%s">%s</text>' % (pad, h - pad, FF, bf, pal["bg"], esc(brand)))
x0 = int(w * 0.42) + pad
maxc = max(8, int((w - x0 - pad) / (hf * 0.52)))
elif layout == "band":
bh = int(h * 0.22)
out.append('<rect x="0" y="%d" width="%d" height="%d" fill="%s"/>' % (h - bh, w, bh, accent))
out.append('<circle cx="%d" cy="%d" r="%d" fill="%s"/>' % (w - pad - int(short * 0.14), pad + int(short * 0.14), int(short * 0.14), soft))
x0 = pad
maxc = max(8, int((w - 2 * pad) / (hf * 0.52)))
elif layout == "minimal":
out.append('<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>' % (pad, pad + bf + int(short * 0.02), int(short * 0.12), int(short * 0.012), accent))
x0 = pad
maxc = max(8, int((w - 2 * pad) / (hf * 0.52)))
else: # poster
out.append('<rect x="%d" y="%d" width="%d" height="%d" rx="%d" fill="%s"/>' % (w - pad - int(short * 0.34), pad, int(short * 0.34), int(short * 0.34), int(short * 0.06), accent))
out.append('<circle cx="%d" cy="%d" r="%d" fill="%s" opacity="0.9"/>' % (w - pad - int(short * 0.12), pad + int(short * 0.22), int(short * 0.08), pal["bg"]))
x0 = pad
# a wide card keeps the headline left of the block; a tall or square one writes under it, full width
maxc = max(8, int((w - 2 * pad - (int(short * 0.34) + pad if w > h * 1.3 else 0)) / (hf * 0.52)))
lines = wrap(headline, maxc)
if len(lines) > 3: # a long headline is set smaller rather than cut
hf = int(hf * 0.78)
maxc = int(maxc / 0.78)
lines = wrap(headline, maxc)[:4]
slines = wrap(subline, max(12, int((w - x0 - pad) / (sf * 0.5))))[:3] if subline else []
block = len(lines) * hf * 1.12 + (len(slines) * sf * 1.5 + sf if slines else 0) + (bf * 3.2 if cta else 0)
y = (h - block) / 2 + hf * 0.9 if layout != "band" else max(pad + hf, (h - int(h * 0.22) - block) / 2 + hf * 0.9)
if layout == "poster" and w <= h * 1.3:
y = max(y, pad + int(short * 0.34) + hf * 1.2)
for ln in lines:
out.append('<text x="%d" y="%.0f" font-family="%s" font-size="%d" font-weight="750" fill="%s">%s</text>' % (x0, y, FF, hf, text, esc(ln)))
y += hf * 1.12
y += sf * 0.6
for ln in slines:
out.append('<text x="%d" y="%.0f" font-family="%s" font-size="%d" fill="%s" opacity="0.85">%s</text>' % (x0, y, FF, sf, text, esc(ln)))
y += sf * 1.5
if cta:
y += bf * 0.8
cw = int(len(cta) * bf * 0.62 + bf * 2.2)
fill = pal["bg"] if layout == "band" else accent
on = text if layout == "band" else pal["bg"]
cy = h - int(h * 0.22) / 2 if layout == "band" else y + bf * 0.2
cx = x0 if layout != "band" else pad
out.append('<rect x="%d" y="%.0f" width="%d" height="%.0f" rx="%.0f" fill="%s"/>' % (cx, cy - bf * 1.0, cw, bf * 2.0, bf, fill))
out.append('<text x="%.0f" y="%.0f" text-anchor="middle" font-family="%s" font-size="%d" font-weight="650" fill="%s">%s</text>' % (cx + cw / 2, cy + bf * 0.36, FF, bf, on, esc(cta)))
if brand and layout != "split": # the brand sits at the top left, out of the way of the cta
out.append('<text x="%d" y="%d" font-family="%s" font-size="%d" font-weight="600" fill="%s" opacity="0.8">%s</text>' % (pad, pad + bf, FF, bf, text, esc(brand)))
out.append("</svg>")
return "\n".join(out)
files = []
# A size that is not one falls back to the square, so two bad sizes (or one and the square) would draw the same file twice.
drawn = []
for s in sizes:
if size_of(s) not in drawn:
drawn.append(size_of(s))
for w, h in drawn:
svg = card(w, h).encode("utf-8")
files.append(file_entry("%s-%dx%d.svg" % (prefix, w, h), "image/svg+xml", svg, "vector", w, h, caption or ("%s (%dx%d)" % (headline, w, h)), "%dx%d" % (w, h)))
out = {"files": files, "count": len(files), "headline": headline, "palette": pal, "layout": layout}
return out
result = DesignSvgCard().run(inp)
# Output collection (the runner reads it here)
out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out.update(result)Tests
Requires: python:3.9
- two-sizes
A poster spec in two sizes is two SVG files named after the brand, each its own width and height.
- defaults
An empty spec still draws one square card with a default headline, layout and palette.
- bad-sizes
Sizes that are not sizes fall back to the square once, not once each.