Vectrum
Sign In
111 lines · 3.6 KB · Python
#!/usr/bin/env python3
"""Turns Lucide SVGs into Android vector drawables (res/drawable/lucide_*.xml).

Lucide draws 24x24 strokes 2 wide with round caps and joins; the drawables keep
that, in black, and Compose tints them. Usage: lucide.py <lucide svg dir> <res/drawable dir> name...
"""
import os
import sys
import xml.etree.ElementTree as ET


def num(value, default=0.0):
    return float(value) if value is not None else default


def fmt(value):
    text = f"{value:.3f}".rstrip("0").rstrip(".")
    return text or "0"


def rect(el):
    x, y = num(el.get("x")), num(el.get("y"))
    w, h = num(el.get("width")), num(el.get("height"))
    rx = el.get("rx")
    ry = el.get("ry")
    rx = num(rx if rx is not None else ry)
    ry = num(ry if ry is not None else rx)
    rx, ry = min(rx, w / 2), min(ry, h / 2)
    if rx == 0:
        return f"M{fmt(x)},{fmt(y)}h{fmt(w)}v{fmt(h)}h{fmt(-w)}Z"
    return (
        f"M{fmt(x + rx)},{fmt(y)}h{fmt(w - 2 * rx)}a{fmt(rx)},{fmt(ry)} 0 0 1 {fmt(rx)},{fmt(ry)}"
        f"v{fmt(h - 2 * ry)}a{fmt(rx)},{fmt(ry)} 0 0 1 {fmt(-rx)},{fmt(ry)}"
        f"h{fmt(-(w - 2 * rx))}a{fmt(rx)},{fmt(ry)} 0 0 1 {fmt(-rx)},{fmt(-ry)}"
        f"v{fmt(-(h - 2 * ry))}a{fmt(rx)},{fmt(ry)} 0 0 1 {fmt(rx)},{fmt(-ry)}Z"
    )


def ellipse(cx, cy, rx, ry):
    return (
        f"M{fmt(cx - rx)},{fmt(cy)}a{fmt(rx)},{fmt(ry)} 0 1 0 {fmt(2 * rx)},0"
        f"a{fmt(rx)},{fmt(ry)} 0 1 0 {fmt(-2 * rx)},0Z"
    )


def points(el, close):
    values = el.get("points").replace(",", " ").split()
    pairs = [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)]
    data = "M" + " L".join(f"{x},{y}" for x, y in pairs)
    return data + ("Z" if close else "")


def path_data(el):
    tag = el.tag.split("}")[-1]
    if tag == "path":
        return el.get("d")
    if tag == "rect":
        return rect(el)
    if tag == "circle":
        r = num(el.get("r"))
        return ellipse(num(el.get("cx")), num(el.get("cy")), r, r)
    if tag == "ellipse":
        return ellipse(num(el.get("cx")), num(el.get("cy")), num(el.get("rx")), num(el.get("ry")))
    if tag == "line":
        return f"M{el.get('x1')},{el.get('y1')} L{el.get('x2')},{el.get('y2')}"
    if tag == "polyline":
        return points(el, False)
    if tag == "polygon":
        return points(el, True)
    return None


def convert(svg_path):
    root = ET.parse(svg_path).getroot()
    paths = []
    for el in root.iter():
        data = path_data(el)
        if data:
            fill = el.get("fill")
            paths.append((data, fill not in (None, "none")))
    out = [
        '<?xml version="1.0" encoding="utf-8"?>',
        '<vector xmlns:android="http://schemas.android.com/apk/res/android"',
        '    android:width="24dp" android:height="24dp"',
        '    android:viewportWidth="24" android:viewportHeight="24">',
    ]
    for data, filled in paths:
        fill = ' android:fillColor="#FF000000"' if filled else ""
        out.append(
            f'    <path android:pathData="{data}"{fill} android:strokeColor="#FF000000" android:strokeWidth="2"'
            ' android:strokeLineCap="round" android:strokeLineJoin="round" />'
        )
    out.append("</vector>")
    return "\n".join(out) + "\n"


def main():
    source, target, names = sys.argv[1], sys.argv[2], sys.argv[3:]
    os.makedirs(target, exist_ok=True)
    for name in names:
        svg = os.path.join(source, f"{name}.svg")
        if not os.path.exists(svg):
            print(f"missing: {name}", file=sys.stderr)
            continue
        out = os.path.join(target, "lucide_" + name.replace("-", "_") + ".xml")
        with open(out, "w") as handle:
            handle.write(convert(svg))


if __name__ == "__main__":
    main()