Ctrl K
ComunidadGuías…

figma-export/figma.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Figma export helper for the Phonix store project.

Usage:
  python figma.py structure          # list pages -> top-level frames
  python figma.py node <id>          # dump a node subtree summary
  python figma.py export <id> [...]  # export node(s) as assets (svg/png)

Reads the access token from the FIGMA_TOKEN environment variable.
"""
import json
import os
import sys
import time
import urllib.parse
import urllib.request

try:
    sys.stdout.reconfigure(encoding="utf-8")
except Exception:
    pass

FILE_KEY = "xE830tWxx3pq0TOTm3J7A2"
API = "https://api.figma.com/v1"
TOKEN = os.environ.get("FIGMA_TOKEN", "").strip()

HERE = os.path.dirname(os.path.abspath(__file__))


def _require_https(url):
    # urllib will happily open file:// and other local schemes; pin every request to https so a
    # crafted/redirected value can never turn a fetch into an arbitrary-file read.
    if not url.lower().startswith("https://"):
        raise RuntimeError(f"refusing non-https URL: {url}")
    return url


def api_get(path, params=None):
    url = f"{API}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(_require_https(url), headers={"X-Figma-Token": TOKEN})
    try:
        # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- URL is https-pinned via _require_https and built from the constant Figma API base.
        with urllib.request.urlopen(req, timeout=180) as r:  # noqa
            return json.loads(r.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        raise RuntimeError(f"HTTP {e.code} for {url}\n{body}") from None


def fmt_box(node):
    b = node.get("absoluteBoundingBox")
    if not b:
        return "?x?"
    return f"{round(b.get('width', 0))}x{round(b.get('height', 0))}"


def cmd_structure():
    data = api_get(f"/files/{FILE_KEY}/nodes",
                   {"ids": "0:1", "depth": 2})
    nodes = data.get("nodes", {})
    for nid, wrapper in nodes.items():
        page = wrapper["document"]
        children = page.get("children", [])
        print(f"PAGE: {page['name']}  ({nid})  -> {len(children)} top-level nodes")
        print("-" * 70)
        for c in children:
            print(f"[{c['id']:>12}]  {c.get('name','?'):<35} {c['type']:<10} {fmt_box(c)}")


def cmd_node(node_id):
    data = api_get(f"/files/{FILE_KEY}/nodes", {"ids": node_id, "depth": 4})
    nodes = data.get("nodes", {})
    for nid, wrapper in nodes.items():
        root = wrapper["document"]
        print(f"NODE: {root.get('name')}  ({nid})  type={root['type']}  {fmt_box(root)}")
        def walk(n, d=0):
            if d > 4:
                return
            print("  " * d + f"- {n.get('name','?')} [{n['type']}] {fmt_box(n)} ({n['id']})")
            for ch in n.get("children", []):
                walk(ch, d + 1)
        for ch in root.get("children", []):
            walk(ch, 1)


def _hex(c):
    if not c:
        return None
    r = round(c.get("r", 0) * 255)
    g = round(c.get("g", 0) * 255)
    b = round(c.get("b", 0) * 255)
    a = c.get("a", 1)
    if a >= 0.999:
        return f"#{r:02X}{g:02X}{b:02X}"
    return f"rgba({r}, {g}, {b}, {round(a, 3)})"


def _fill_desc(fills):
    out = []
    for f in fills or []:
        if f.get("visible") is False:
            continue
        t = f.get("type")
        if t == "SOLID":
            out.append(_hex(f.get("color")))
        elif t and t.startswith("GRADIENT"):
            stops = ", ".join(_hex(s.get("color")) for s in f.get("gradientStops", []))
            out.append(f"{t.lower()}({stops})")
        elif t == "IMAGE":
            out.append(f"image(ref={f.get('imageRef')})")
    return [x for x in out if x]


def _effect_desc(effects):
    out = []
    for e in effects or []:
        if e.get("visible") is False:
            continue
        t = e.get("type")
        if t in ("DROP_SHADOW", "INNER_SHADOW"):
            off = e.get("offset", {})
            out.append(f"{t.lower()}: x={round(off.get('x',0))} y={round(off.get('y',0))} "
                       f"blur={round(e.get('radius',0))} spread={round(e.get('spread',0))} "
                       f"color={_hex(e.get('color'))}")
        elif t in ("LAYER_BLUR", "BACKGROUND_BLUR"):
            out.append(f"{t.lower()}: {round(e.get('radius',0))}")
    return out


def _inspect_node(n, depth=0, max_depth=6):
    if depth > max_depth:
        return
    pad = "  " * depth
    b = n.get("absoluteBoundingBox") or {}
    box = f"{round(b.get('width',0))}x{round(b.get('height',0))} @({round(b.get('x',0))},{round(b.get('y',0))})"
    print(f"{pad}> {n.get('name','?')}  [{n['type']}]  {box}  ({n['id']})")
    info = []
    if n.get("layoutMode") and n["layoutMode"] != "NONE":
        info.append(f"auto-layout={n['layoutMode']} gap={n.get('itemSpacing',0)} "
                    f"pad=({n.get('paddingTop',0)},{n.get('paddingRight',0)},"
                    f"{n.get('paddingBottom',0)},{n.get('paddingLeft',0)}) "
                    f"align={n.get('primaryAxisAlignItems','')}/{n.get('counterAxisAlignItems','')}")
    fills = _fill_desc(n.get("fills"))
    if fills:
        info.append("fill=" + ", ".join(fills))
    strokes = _fill_desc(n.get("strokes"))
    if strokes:
        info.append(f"stroke={', '.join(strokes)} w={n.get('strokeWeight',0)}")
    if n.get("cornerRadius"):
        info.append(f"radius={n['cornerRadius']}")
    elif n.get("rectangleCornerRadii"):
        info.append(f"radius={n['rectangleCornerRadii']}")
    eff = _effect_desc(n.get("effects"))
    if eff:
        info.append("; ".join(eff))
    if n.get("opacity") is not None and n.get("opacity") != 1:
        info.append(f"opacity={n['opacity']}")
    if n["type"] == "TEXT":
        st = n.get("style", {})
        info.append(f'text="{n.get("characters","")[:60]}"')
        info.append(f"font={st.get('fontFamily')} {st.get('fontWeight')} "
                    f"{st.get('fontSize')}px lh={st.get('lineHeightPx')} "
                    f"ls={st.get('letterSpacing')} align={st.get('textAlignHorizontal')}")
    for line in info:
        print(f"{pad}    {line}")
    for ch in n.get("children", []):
        _inspect_node(ch, depth + 1, max_depth)


def _collect(n, texts, images, vectors, path=""):
    here = f"{path}/{n.get('name','?')}"
    if n["type"] == "TEXT":
        st = n.get("style", {})
        texts.append({
            "path": here, "id": n["id"], "text": n.get("characters", ""),
            "font": st.get("fontFamily"), "weight": st.get("fontWeight"),
            "size": st.get("fontSize"), "lh": st.get("lineHeightPx"),
            "color": (_fill_desc(n.get("fills")) or [None])[0],
            "align": st.get("textAlignHorizontal"),
        })
    for f in n.get("fills") or []:
        if f.get("type") == "IMAGE" and f.get("imageRef"):
            b = n.get("absoluteBoundingBox") or {}
            images.append({"path": here, "id": n["id"], "ref": f["imageRef"],
                           "w": round(b.get("width", 0)), "h": round(b.get("height", 0))})
    if n["type"] in ("VECTOR", "BOOLEAN_OPERATION") and path.count("/") < 3:
        vectors.append({"path": here, "id": n["id"]})
    for ch in n.get("children", []):
        _collect(ch, texts, images, vectors, here)


def cmd_content(node_id):
    data = api_get(f"/files/{FILE_KEY}/nodes", {"ids": node_id, "depth": 30})
    texts, images, vectors = [], [], []
    for wrapper in data.get("nodes", {}).values():
        _collect(wrapper["document"], texts, images, vectors)
    print("===== TEXT NODES (%d) =====" % len(texts))
    for t in texts:
        txt = (t["text"] or "").replace("\n", " \\n ")
        print(f'[{t["id"]}] {t["font"]} {t["weight"]} {t["size"]}px lh={t["lh"]} '
              f'{t["color"]} {t["align"]}  ::  "{txt}"')
    print("\n===== IMAGE FILLS (%d) =====" % len(images))
    for im in images:
        print(f'[{im["id"]}] {im["w"]}x{im["h"]}  ref={im["ref"]}  @ {im["path"]}')
    # save mapping json
    out = os.path.join(HERE, f"content_{node_id.replace(':','-')}.json")
    with open(out, "w", encoding="utf-8") as f:
        json.dump({"texts": texts, "images": images, "vectors": vectors},
                  f, ensure_ascii=False, indent=2)
    print(f"\nSaved -> {out}")


def cmd_inspect(node_id, max_depth=6):
    data = api_get(f"/files/{FILE_KEY}/nodes", {"ids": node_id, "depth": max_depth})
    for nid, wrapper in data.get("nodes", {}).items():
        _inspect_node(wrapper["document"], 0, max_depth)


def cmd_export(node_ids, fmt="png", scale=2.0):
    out_dir = os.path.join(HERE, "assets", "exports")
    os.makedirs(out_dir, exist_ok=True)
    for nid in node_ids:
        try:
            res = api_get(f"/images/{FILE_KEY}",
                          {"ids": nid, "format": fmt, "scale": scale})
            url = (res.get("images") or {}).get(nid)
            if not url:
                print(f"  ! no image for {nid} err={res.get('err')}")
                continue
            dest = os.path.join(out_dir, f"{nid.replace(':', '-')}.{fmt}")
            n = _download(url, dest)
            print(f"  ok  {os.path.basename(dest)}  ({round(n/1024)} KB)")
        except Exception as e:
            print(f"  ! {nid}: {str(e).splitlines()[0]}")
        time.sleep(0.2)


def _slug(name, nid):
    keep = []
    for ch in name.strip():
        if ch.isalnum() or ch in " -_":
            keep.append(ch)
        elif ch.isalpha():  # keep non-latin letters (Persian)
            keep.append(ch)
    s = "".join(keep).strip().replace(" ", "_")
    s = s[:50] if s else "node"
    return f"{s}__{nid.replace(':', '-')}"


def _download(url, dest):
    req = urllib.request.Request(_require_https(url), headers={"User-Agent": "phonix-figma/1.0"})
    # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- URL is https-pinned via _require_https; it comes from Figma's own image CDN response.
    with urllib.request.urlopen(req, timeout=300) as r:  # noqa
        data = r.read()
    with open(dest, "wb") as f:
        f.write(data)
    return len(data)


def cmd_export_screens(scale=1.0, fmt="png"):
    out_dir = os.path.join(HERE, "screens")
    os.makedirs(out_dir, exist_ok=True)
    # get top-level nodes
    data = api_get(f"/files/{FILE_KEY}/nodes", {"ids": "0:1", "depth": 2})
    page = list(data["nodes"].values())[0]["document"]
    targets = []
    for c in page.get("children", []):
        t = c["type"]
        b = c.get("absoluteBoundingBox") or {}
        w = b.get("width", 0)
        # real screens/sections: frames, or large groups; skip tiny text/labels
        if t == "FRAME" or (t == "GROUP" and w >= 1000):
            targets.append(c)
    print(f"Rendering {len(targets)} screens at scale={scale} ({fmt}) ...")
    manifest = []
    for c in targets:
        name = _slug(c.get("name", "node"), c["id"])
        try:
            res = api_get(f"/images/{FILE_KEY}",
                          {"ids": c["id"], "format": fmt, "scale": scale})
            url = (res.get("images") or {}).get(c["id"])
            if not url:
                print(f"  ! no image for {c['id']} ({c.get('name')}) err={res.get('err')}")
                continue
            dest = os.path.join(out_dir, f"{name}.{fmt}")
            n = _download(url, dest)
            manifest.append({"id": c["id"], "name": c.get("name"),
                             "file": os.path.basename(dest), "bytes": n})
            print(f"  ok  {os.path.basename(dest)}  ({round(n/1024)} KB)")
        except Exception as e:
            msg = str(e).splitlines()[0] if str(e) else repr(e)
            print(f"  ! {c['id']} ({c.get('name')}): {msg}")
        time.sleep(0.3)
    with open(os.path.join(out_dir, "_manifest.json"), "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)
    print(f"Done. {len(manifest)} files -> {out_dir}")


def cmd_export_fills():
    out_dir = os.path.join(HERE, "assets", "images")
    os.makedirs(out_dir, exist_ok=True)
    res = api_get(f"/files/{FILE_KEY}/images")
    meta = res.get("meta", {})
    images = meta.get("images", {})
    print(f"Found {len(images)} raw image fills. Downloading ...")
    manifest = []
    ok = 0
    for ref, url in images.items():
        if not url:
            continue
        dest = os.path.join(out_dir, f"{ref}.png")
        try:
            n = _download(url, dest)
            manifest.append({"ref": ref, "file": os.path.basename(dest), "bytes": n})
            ok += 1
            if ok % 10 == 0:
                print(f"  ... {ok}/{len(images)}")
        except Exception as e:
            print(f"  ! failed {ref}: {e}")
    with open(os.path.join(out_dir, "_manifest.json"), "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)
    print(f"Done. {ok} image fills -> {out_dir}")


if __name__ == "__main__":
    if not TOKEN:
        print("ERROR: FIGMA_TOKEN env var is empty", file=sys.stderr)
        sys.exit(1)
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(0)
    cmd = sys.argv[1]
    if cmd == "structure":
        cmd_structure()
    elif cmd == "node" and len(sys.argv) >= 3:
        cmd_node(sys.argv[2])
    elif cmd == "content" and len(sys.argv) >= 3:
        cmd_content(sys.argv[2])
    elif cmd == "inspect" and len(sys.argv) >= 3:
        md = int(sys.argv[3]) if len(sys.argv) >= 4 else 6
        cmd_inspect(sys.argv[2], md)
    elif cmd == "export" and len(sys.argv) >= 3:
        fmt = sys.argv[3] if len(sys.argv) >= 4 else "png"
        sc = float(sys.argv[4]) if len(sys.argv) >= 5 else 2.0
        cmd_export(sys.argv[2].split(","), fmt, sc)
    elif cmd == "export-screens":
        sc = float(sys.argv[2]) if len(sys.argv) >= 3 else 1.0
        cmd_export_screens(scale=sc)
    elif cmd == "export-fills":
        cmd_export_fills()
    else:
        print(__doc__)
        sys.exit(1)