"""Make a drawing draw itself: an animated SVG (SMIL, no script) that replays how the sheet is built. python3 drawon.py IN.svg OUT.svg [--budget 12] [--preview OUT.mp4] Four passes, the way an illustrator works: 1. inks every ink line is traced with stroke-dashoffset by one pen. A tube (an ink stroke with its colour core on the same path) grows as an outlined noodle; a closed shape fills with paper the moment its outline closes, so from then on it hides what is behind it. Labels and solid ink marks appear in their turn. 2. flats each paper fill and each tube core floods to its colour, in the same order. 3. shading the saturated shadows (class "s", and the clip groups that hold them) fade in. 4. fx strokeless matter (class "fx": blush, inner ears, sparkles, confetti) appears last. Order: document order, except inside a character drawn by house.figure() with HOUSE_TAGS=1, whose parts are drawn the way an artist would: head, hair, ears, torso, arms, tail, skirt, legs, shoes. The last frame is the original drawing. How long the line work takes grows with the square root of the number of strokes (--budget caps it, in seconds). No browser needed to build: path lengths are measured here (lines, cubic/quadratic Béziers, arcs, transforms). --preview renders an MP4 (and a GIF next to it) by seeking the SMIL clock in Chromium; that part needs Playwright. Import it to use from Python: animate(svg_text) -> (animated_svg_text, seconds). """ import argparse import math import os import re import xml.etree.ElementTree as ET SVG = "http://www.w3.org/2000/svg" ET.register_namespace("", SVG) PAPER, INK = "#FFEAD2", "#2B2135" SKIP = {"style", "defs", "clipPath", "title", "desc", "metadata", "linearGradient", "radialGradient", "mask", "pattern", "symbol", "filter", "animate", "set"} LEAVES = {"path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text"} PAINT = ("fill", "stroke", "stroke-width", "stroke-dasharray", "opacity", "fill-opacity", "stroke-linecap", "stroke-linejoin", "stroke-opacity") # parts from house.figure(HOUSE_TAGS=1), in the order an artist draws a character: head first, outward from there PART_RANK = {"head": 0, "over": 1, "under": 2, "accessory": 3, "collar": 4, "torso": 5, "arm-near": 6, "arm": 6, "arm-far": 7, "mid": 8, "skirt": 9, "legs": 10, "shoes": 11, "ground": 12} def tag(el): return el.tag.split("}", 1)[-1] # ---------- geometry: sample every element as polylines, in its own coordinates ---------- NUM = r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?" TOK = re.compile(r"[MmLlHhVvCcSsQqTtAaZz]|" + NUM) def _arc(x1, y1, rx, ry, phi, fa, fs, x2, y2): """SVG endpoint arc -> points (implementation notes F.6.5)""" if rx == 0 or ry == 0 or (x1 == x2 and y1 == y2): return [(x2, y2)] rx, ry = abs(rx), abs(ry) c, s = math.cos(math.radians(phi)), math.sin(math.radians(phi)) dx, dy = (x1 - x2) / 2, (y1 - y2) / 2 x1p, y1p = c * dx + s * dy, -s * dx + c * dy lam = x1p ** 2 / rx ** 2 + y1p ** 2 / ry ** 2 if lam > 1: rx, ry = rx * math.sqrt(lam), ry * math.sqrt(lam) num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p den = rx * rx * y1p * y1p + ry * ry * x1p * x1p k = math.sqrt(max(0.0, num / den)) if den else 0.0 if fa == fs: k = -k cxp, cyp = k * rx * y1p / ry, -k * ry * x1p / rx cx, cy = c * cxp - s * cyp + (x1 + x2) / 2, s * cxp + c * cyp + (y1 + y2) / 2 def ang(ux, uy, vx, vy): a = math.atan2(ux * vy - uy * vx, ux * vx + uy * vy) return a t1 = ang(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry) dt = ang((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry) if not fs and dt > 0: dt -= 2 * math.pi elif fs and dt < 0: dt += 2 * math.pi n = max(4, int(abs(dt) / (math.pi / 24))) pts = [] for i in range(1, n + 1): t = t1 + dt * i / n pts.append((cx + rx * math.cos(t) * c - ry * math.sin(t) * s, cy + rx * math.cos(t) * s + ry * math.sin(t) * c)) return pts def _bez(p, steps): out = [] for i in range(1, steps + 1): t = i / steps u = 1 - t if len(p) == 4: x = u ** 3 * p[0][0] + 3 * u * u * t * p[1][0] + 3 * u * t * t * p[2][0] + t ** 3 * p[3][0] y = u ** 3 * p[0][1] + 3 * u * u * t * p[1][1] + 3 * u * t * t * p[2][1] + t ** 3 * p[3][1] else: x = u * u * p[0][0] + 2 * u * t * p[1][0] + t * t * p[2][0] y = u * u * p[0][1] + 2 * u * t * p[1][1] + t * t * p[2][1] out.append((x, y)) return out def path_polylines(d): toks = TOK.findall(d or "") polys, cur = [], None x = y = sx = sy = 0.0 lc = None # last control point, for S/T cmd, i = None, 0 def nums(k): nonlocal i v = [float(t) for t in toks[i:i + k]] i += k return v while i < len(toks): if toks[i].isalpha(): cmd = toks[i] i += 1 if cmd in "Zz": if cur: cur.append((sx, sy)) x, y, lc = sx, sy, None continue elif cmd is None: break rel = cmd.islower() C = cmd.upper() ox, oy = (x, y) if rel else (0.0, 0.0) if C == "M": a, b = nums(2) x, y = ox + a, oy + b sx, sy = x, y cur = [(x, y)] polys.append(cur) cmd = "l" if rel else "L" # implicit lineto after a moveto lc = None continue if cur is None: cur = [(x, y)] polys.append(cur) if C == "L": a, b = nums(2) x, y = ox + a, oy + b cur.append((x, y)) lc = None elif C == "H": (a,) = nums(1) x = ox + a cur.append((x, y)) lc = None elif C == "V": (b,) = nums(1) y = oy + b cur.append((x, y)) lc = None elif C in "CS": if C == "C": v = nums(6) c1 = (ox + v[0], oy + v[1]) c2, e = (ox + v[2], oy + v[3]), (ox + v[4], oy + v[5]) else: v = nums(4) c1 = (2 * x - lc[0], 2 * y - lc[1]) if lc else (x, y) c2, e = (ox + v[0], oy + v[1]), (ox + v[2], oy + v[3]) chord = math.dist((x, y), e) + math.dist((x, y), c1) + math.dist(c1, c2) + math.dist(c2, e) cur.extend(_bez([(x, y), c1, c2, e], max(6, min(64, int(chord / 6))))) lc = c2 x, y = e elif C in "QT": if C == "Q": v = nums(4) c1, e = (ox + v[0], oy + v[1]), (ox + v[2], oy + v[3]) else: v = nums(2) c1 = (2 * x - lc[0], 2 * y - lc[1]) if lc else (x, y) e = (ox + v[0], oy + v[1]) chord = math.dist((x, y), c1) + math.dist(c1, e) cur.extend(_bez([(x, y), c1, e], max(6, min(64, int(chord / 6))))) lc = c1 x, y = e elif C == "A": v = nums(7) e = (ox + v[5], oy + v[6]) cur.extend(_arc(x, y, v[0], v[1], v[2], int(v[3]), int(v[4]), e[0], e[1])) x, y = e lc = None else: i += 1 return polys def ellipse_pts(cx, cy, rx, ry, n=96): return [[(cx + rx * math.cos(2 * math.pi * k / n), cy + ry * math.sin(2 * math.pi * k / n)) for k in range(n + 1)]] def geometry(el): t = tag(el) f = lambda k, dv=0.0: float(re.match(NUM, el.get(k, str(dv))).group(0)) if el.get(k) else dv if t == "path": return path_polylines(el.get("d")) if t == "circle": return ellipse_pts(f("cx"), f("cy"), f("r"), f("r")) if t == "ellipse": return ellipse_pts(f("cx"), f("cy"), f("rx"), f("ry")) if t == "rect": x, y, w, h = f("x"), f("y"), f("width"), f("height") return [[(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]] if t == "line": return [[(f("x1"), f("y1")), (f("x2"), f("y2"))]] if t in ("polyline", "polygon"): v = [float(n) for n in re.findall(NUM, el.get("points", ""))] p = list(zip(v[0::2], v[1::2])) return [p + ([p[0]] if t == "polygon" and p else [])] return [] # ---------- transforms ---------- def mat_mul(a, b): return (a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]) def parse_transform(s): m = (1, 0, 0, 1, 0, 0) for name, args in re.findall(r"(\w+)\s*\(([^)]*)\)", s or ""): v = [float(n) for n in re.findall(NUM, args)] if name == "translate": t = (1, 0, 0, 1, v[0], v[1] if len(v) > 1 else 0) elif name == "scale": t = (v[0], 0, 0, v[1] if len(v) > 1 else v[0], 0, 0) elif name == "rotate": a = math.radians(v[0]) r = (math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0, 0) if len(v) == 3: t = mat_mul(mat_mul((1, 0, 0, 1, v[1], v[2]), r), (1, 0, 0, 1, -v[1], -v[2])) else: t = r elif name == "matrix": t = tuple(v[:6]) elif name == "skewX": t = (1, 0, math.tan(math.radians(v[0])), 1, 0, 0) elif name == "skewY": t = (1, math.tan(math.radians(v[0])), 0, 1, 0, 0) else: continue m = mat_mul(m, t) return m def apply(m, p): return (m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]) # ---------- styles: inline the few class rules the early katas use, so SMIL can override everything ---------- def flatten_styles(root): rules = {} for st in list(root.iter(f"{{{SVG}}}style")): for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", st.text or ""): props = dict((k.strip(), v.strip()) for k, v in (kv.split(":", 1) for kv in body.split(";") if ":" in kv)) for s in sel.split(","): s = s.strip() if re.fullmatch(r"\.[\w-]+", s): rules.setdefault(s[1:], {}).update(props) for parent in list(root.iter()): for st in [c for c in parent if tag(c) == "style"]: parent.remove(st) for el in root.iter(): eff = {} for c in el.get("class", "").split(): eff.update(rules.get(c, {})) if el.get("style"): eff.update(dict((k.strip(), v.strip()) for k, v in (kv.split(":", 1) for kv in el.get("style").split(";") if ":" in kv))) del el.attrib["style"] for k, v in eff.items(): el.set(k, v) # class rules and inline style both beat presentation attributes # ---------- the animation ---------- class Leaf: __slots__ = ("el", "parent", "idx", "kind", "local_len", "len", "bbox", "fig", "part", "paint") def classify(el, paint): t = tag(el) cls = el.get("class", "").split() fill, stroke = paint.get("fill", "#000000"), paint.get("stroke", "none") if t == "text": return "text" if "s" in cls: return "shade" if "fx" in cls: return "fx" has_stroke = stroke not in ("none", "") and float(paint.get("stroke-width", "1") or 1) > 0 has_fill = fill not in ("none", "") if has_stroke and paint.get("stroke-dasharray", "none") not in ("none", ""): return "fadeline" # a dashed construction line: it appears, it isn't drawn if has_stroke: return "shape" if has_fill else "stroke" if has_fill: return "inkfill" if fill.upper() == INK else "flat" return None def walk(root): """leaves in document order, with inherited paint, the figure/part they belong to and their transform""" leaves, groups = [], [] def rec(el, inh, m, fig, part): for ch in list(el): t = tag(ch) if t in SKIP or not isinstance(ch.tag, str): continue paint = dict(inh) for k in PAINT: if ch.get(k) is not None and k not in ("opacity",): paint[k] = ch.get(k) m2 = mat_mul(m, parse_transform(ch.get("transform"))) if ch.get("transform") else m if t == "g": f2 = ch.get("data-figure") and (ch, len(leaves)) or fig p2 = ch.get("data-part") or part if ch.get("clip-path"): groups.append((ch, el, len(leaves))) rec(ch, paint, m2, f2, p2) continue if t not in LEAVES: continue lf = Leaf() lf.el, lf.parent, lf.idx, lf.fig, lf.part, lf.paint = ch, el, len(leaves), fig, part, paint lf.kind = classify(ch, paint) polys = geometry(ch) loc = can = 0.0 xs, ys = [], [] for poly in polys: tp = [apply(m2, p) for p in poly] for a, b in zip(poly, poly[1:]): loc += math.dist(a, b) for a, b in zip(tp, tp[1:]): can += math.dist(a, b) xs += [p[0] for p in tp] ys += [p[1] for p in tp] lf.local_len, lf.len = loc, can lf.bbox = (min(xs), min(ys), max(xs), max(ys)) if xs else (0, 0, 0, 0) leaves.append(lf) rec(root, {}, (1, 0, 0, 1, 0, 0), None, None) return leaves, groups def anim(el, attr, frm, to, begin, dur, spline=None): a = ET.SubElement(el, f"{{{SVG}}}animate", attributeName=attr, values=f"{frm};{to}", begin=f"{begin:.3f}s", dur=f"{max(dur, 0.01):.3f}s", fill="freeze") if spline: a.set("calcMode", "spline") a.set("keyTimes", "0;1") a.set("keySplines", spline) def fmt(v): return f"{v:.2f}".rstrip("0").rstrip(".") def animate_tree(root, budget=12.0): flatten_styles(root) W = float(re.match(NUM, root.get("width", "0")).group(0)) if root.get("width") else 0 H = float(re.match(NUM, root.get("height", "0")).group(0)) if root.get("height") else 0 if (not W or not H) and root.get("viewBox"): W, H = [float(n) for n in re.findall(NUM, root.get("viewBox"))[2:4]] area = max(W * H, 1) leaves, groups = walk(root) # clip groups that only hold shadows fade in as one piece in the shading pass shade_groups = [] for g, parent, first in groups: inner = [lf for lf in leaves if _inside(lf.el, g)] if inner and all(lf.kind == "shade" for lf in inner): shade_groups.append((g, first, inner)) for lf in inner: lf.kind = None # handled by the group # big fill-only shapes are the ground the drawing is made on (a sky, a dark panel): they stay put for lf in leaves: if lf.kind == "flat": x0, y0, x1, y1 = lf.bbox if (x1 - x0) * (y1 - y0) > 0.3 * area: lf.kind = None if lf.kind and tag(lf.el) == "rect" and lf.idx == 0: lf.kind = None # the background sheet def key(lf): if lf.fig is not None: return (lf.fig[1], PART_RANK.get(lf.part, 13), lf.idx) return (lf.idx, 0, lf.idx) # units: a tube's ink and its core(s) share one path in one parent, so they are traced as one stroke units, by_geo = [], {} for lf in leaves: if lf.kind in ("shape", "stroke"): gk = (id(lf.parent), tag(lf.el), lf.el.get("d"), lf.el.get("cx"), lf.el.get("cy"), lf.el.get("r"), lf.el.get("rx"), lf.el.get("ry"), lf.el.get("points"), lf.el.get("x1"), lf.el.get("y1")) if gk in by_geo: by_geo[gk].append(lf) continue by_geo[gk] = [lf] units.append(by_geo[gk]) elif lf.kind in ("inkfill", "text", "fadeline"): units.append([lf]) units.sort(key=lambda u: min(key(lf) for lf in u)) # time budget: the line work takes ~1.1·√(strokes) seconds (capped), at one steady pen speed n = max(len(units), 1) B = min(max(1.1 * math.sqrt(n), 3.0), budget) gap = min(0.04, 0.25 * B / n) dmin = min(0.07, 0.45 * B / n) fixed = lambda u: u[0].kind in ("inkfill", "text", "fadeline") lens = [max(lf.len for lf in u) for u in units] def total(v): return sum(0.9 * (min(0.12, dmin * 2) if fixed(u) else min(max(L / v, dmin), 0.5)) + gap for u, L in zip(units, lens)) lo, hi = 1500.0, 1e7 if total(lo) > B: for _ in range(60): mid = math.sqrt(lo * hi) lo, hi = (mid, hi) if total(mid) > B else (lo, mid) speed = hi else: speed = lo t = 0.3 flood = [] # (key, element, attribute, colour) for u, L in zip(units, lens): lead = u[0] if fixed(u): dur = min(0.12, dmin * 2) for lf in u: op = lf.el.get("opacity", "1") lf.el.set("opacity", "0") anim(lf.el, "opacity", 0, op, t, dur) else: dur = min(max(L / speed, dmin), 0.5) has_ink = any(lf.paint.get("stroke", "").upper() == INK for lf in u) for lf in u: e = lf.el D = 1.02 * lf.local_len + 2 e.set("stroke-dasharray", f"{fmt(D)} {fmt(2 * D + 4)}") e.set("stroke-dashoffset", fmt(D + 1)) anim(e, "stroke-dashoffset", fmt(D + 1), 0, t, dur, "0.45 0 0.55 1") if lf.kind == "shape": # paper fill the moment the outline closes, colour in the flats pass col, fo = lf.paint.get("fill"), e.get("fill-opacity", "1") e.set("fill", PAPER) e.set("fill-opacity", "0") anim(e, "fill-opacity", 0, fo, t + dur, 0.06) if col.upper() != PAPER: flood.append((key(lf), e, "fill", col)) elif has_ink and lf.paint.get("stroke", "").upper() != INK: # a tube's core: paper until the flats col = lf.paint.get("stroke") e.set("stroke", PAPER) if col.upper() != PAPER: flood.append((key(lf), e, "stroke", col)) t += dur * 0.9 + gap t_inks = t + 0.3 # flats: flood paper fills and tube cores to colour, and let the flat shapes in, in drawing order flats = sorted([(k, e, a, c) for k, e, a, c in flood] + [(key(lf), lf.el, "opacity", None) for lf in leaves if lf.kind == "flat"], key=lambda x: x[0]) step = min(0.045, 1.8 / max(len(flats), 1)) t = t_inks for k, e, a, c in flats: if a == "opacity": op = e.get("opacity", "1") e.set("opacity", "0") anim(e, "opacity", 0, op, t, 0.22) else: anim(e, a, PAPER, c, t, 0.22) t += step t_flats = t + (0.25 if flats else 0) def fade(items, t, step, dur): for e in items: op = e.get("opacity", "1") e.set("opacity", "0") anim(e, "opacity", 0, op, t, dur) t += step return t shade = sorted([(key(lf), lf.el) for lf in leaves if lf.kind == "shade"] + [((first, 0, first), g) for g, first, inner in shade_groups], key=lambda x: x[0]) shade_key = {} for g, first, inner in shade_groups: # a clip group sits where its first shadow sits shade_key[id(g)] = min(key(lf) for lf in inner) shade = sorted(shade, key=lambda x: shade_key.get(id(x[1]), x[0])) t_shade = fade([e for k, e in shade], t_flats, min(0.06, 0.9 / max(len(shade), 1)), 0.3) + (0.2 if shade else 0) fx = sorted([(key(lf), lf.el) for lf in leaves if lf.kind == "fx"], key=lambda x: x[0]) end = fade([e for k, e in fx], t_shade, min(0.08, 0.9 / max(len(fx), 1)), 0.25) + 0.25 root.set("data-duration", f"{end:.2f}") return end, {"units": len(units), "inks": round(t_inks, 2), "flats": round(t_flats, 2), "shading": round(t_shade, 2), "end": round(end, 2), "speed": round(speed)} def _inside(el, g): for d in g.iter(): if d is el: return True return False def animate(svg_text, budget=12.0): root = ET.fromstring(svg_text) end, marks = animate_tree(root, budget) return ET.tostring(root, encoding="unicode"), end def build(src, out, budget=12.0): tree = ET.parse(src) end, marks = animate_tree(tree.getroot(), budget) tree.write(out, encoding="unicode", xml_declaration=False) return end, marks def preview(svg, out, end, hold=2.0, fps=30, width=None): """frames by seeking the SMIL clock, then ffmpeg -> mp4 (and a gif next to it)""" import shutil import subprocess import tempfile from playwright.sync_api import sync_playwright root = ET.parse(svg).getroot() w, h = int(float(root.get("width"))), int(float(root.get("height"))) fd = tempfile.mkdtemp(prefix=".frames-", dir=os.path.dirname(os.path.abspath(out))) n = int((end + hold) * fps) with sync_playwright() as p: b = p.chromium.launch() pg = b.new_page(viewport={"width": w, "height": h}) pg.goto("file://" + os.path.abspath(svg)) pg.evaluate("document.documentElement.pauseAnimations()") for k in range(n): pg.evaluate(f"document.documentElement.setCurrentTime({min(k / fps, end + 0.01):.4f})") pg.screenshot(path=f"{fd}/f{k:05d}.png") b.close() vf = f"scale={width}:-2:flags=lanczos," if width else "" subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps), "-i", f"{fd}/f%05d.png", "-vf", vf + "format=yuv420p", "-c:v", "libx264", "-crf", "18", out], check=True) gif = out.rsplit(".", 1)[0] + ".gif" subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", out, "-vf", "fps=15,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96[p];[b][p]paletteuse", gif], check=True) shutil.rmtree(fd) return out, gif if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("src") ap.add_argument("out") ap.add_argument("--budget", type=float, default=12.0, help="most seconds the line work may take") ap.add_argument("--preview", default="") a = ap.parse_args() end, marks = build(a.src, a.out, a.budget) print("wrote", a.out, marks) if a.preview: print("wrote", *preview(a.out, a.preview, end))