{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Export Pro Symbols to SVG/PNG\n", "\n", "Extract ArcGIS Pro point symbols from a **.stylx** file to clean, scalable **SVG** (and, optionally, rasterized **PNG** at a fixed pixel size). Export everything in a style, selected categories, or a single symbol -- ideal for CDN/popup hero icons, web legends, and design handoff.\n", "\n", "**How it works:** a `.stylx` is just a **SQLite** database whose `ITEMS` table holds each symbol's **CIM JSON**. This notebook reads that JSON with the standard library and composes an SVG directly -- **no arcpy needed for the rendering core**, so it runs anywhere. PNG is produced by rasterizing the SVG with `cairosvg` or Inkscape.\n", "\n", "**How to use:** edit the **CONFIG** cell, then *Run All*. Leave `STYLX_PATH` blank/`None` to auto-detect your ArcGIS Pro **Favorites.stylx**. SVGs are always written; PNGs are written when `WRITE_PNG = True` and a rasterizer is available. Adapted from an All Things Spatial workflow.\n", "\n", "_Runtime: any Python 3 for SVG (stdlib `sqlite3` + `json`). PNG needs `cairosvg` (`pip install cairosvg --break-system-packages`) OR a local Inkscape install._\n", "\n", "---\n", "MIT License -- Copyright (c) 2026 All Things Spatial LLC. Provided \"as is\", without warranty of any kind." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1 - Config" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# ============================================================================\n", "# CONFIG -- edit these, then Run All. Nothing else needs changing for a basic run.\n", "# ============================================================================\n", "STYLX_PATH = None # path to a .stylx; None = auto-detect your Pro Favorites\n", "SCOPE = \"all\" # \"all\" | \"categories\" | \"single\"\n", "CATEGORIES = [] # used when SCOPE=\"categories\", e.g. [\"Basic\", \"Arrows\"]\n", "SYMBOL = None # used when SCOPE=\"single\", e.g. \"Circle 1\"\n", "SVG_DIR = \"symbol_svg\" # output folder for .svg files\n", "PNG_DIR = None # None = same folder as SVG\n", "WRITE_PNG = True # also rasterize a PNG per symbol\n", "TARGET_PX = 200 # output width = height, in pixels" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2 - .stylx discovery + enumeration\n", "\n", "A `.stylx` is a SQLite DB with an `ITEMS` table: `NAME, CATEGORY, CLASS, CONTENT` -- where `CONTENT` is the symbol's CIM JSON." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import base64\n", "import json\n", "import math\n", "import os\n", "import re\n", "import shutil\n", "import subprocess\n", "import sqlite3\n", "from pathlib import Path\n", "\n", "_DATA_URL = re.compile(r\"^data:(?P[^;]+);base64,(?P.+)$\", re.DOTALL)\n", "_RASTER_EXT = {\n", " \"image/png\": \".png\", \"image/jpeg\": \".jpg\", \"image/jpg\": \".jpg\",\n", " \"image/gif\": \".gif\", \"image/bmp\": \".bmp\",\n", "}\n", "\n", "\n", "def _noop(*_a, **_k):\n", " pass\n", "\n", "\n", "def find_favorites_stylx():\n", " \"\"\"Probe ArcGIS Pro's standard locations for Favorites.stylx; return Path or None.\"\"\"\n", " appdata = Path(os.environ.get(\"APPDATA\", \"\"))\n", " localappdata = Path(os.environ.get(\"LOCALAPPDATA\", \"\"))\n", " candidates = [\n", " appdata / \"ESRI\" / \"ArcGISPro\" / \"ArcGIS\" / \"Favorites.stylx\",\n", " appdata / \"ESRI\" / \"ArcGISPro\" / \"Favorites.stylx\",\n", " localappdata / \"ESRI\" / \"ArcGISPro\" / \"ArcGIS\" / \"Favorites.stylx\",\n", " localappdata / \"ESRI\" / \"ArcGISPro\" / \"Favorites.stylx\",\n", " ]\n", " for p in candidates:\n", " if p.exists():\n", " return p\n", " for root in (appdata, localappdata):\n", " if root and root.exists():\n", " for hit in root.rglob(\"Favorites.stylx\"):\n", " return hit\n", " return None\n", "\n", "\n", "def list_categories(stylx_path):\n", " \"\"\"Distinct, sorted category names in the style ('' -> '(uncategorized)').\"\"\"\n", " con = sqlite3.connect(str(stylx_path))\n", " try:\n", " rows = con.execute(\"SELECT DISTINCT CATEGORY FROM ITEMS\").fetchall()\n", " finally:\n", " con.close()\n", " cats = sorted({(r[0] or \"(uncategorized)\") for r in rows}, key=str.lower)\n", " return cats\n", "\n", "\n", "def list_names(stylx_path, categories=None):\n", " \"\"\"Sorted symbol names, optionally filtered to the given categories.\"\"\"\n", " con = sqlite3.connect(str(stylx_path))\n", " try:\n", " if categories:\n", " wanted = set(categories)\n", " rows = con.execute(\"SELECT NAME, CATEGORY FROM ITEMS\").fetchall()\n", " names = [n for (n, c) in rows if (c or \"(uncategorized)\") in wanted]\n", " else:\n", " names = [r[0] for r in con.execute(\"SELECT NAME FROM ITEMS\").fetchall()]\n", " finally:\n", " con.close()\n", " return sorted({n for n in names if n}, key=str.lower)\n", "\n", "\n", "def fetch_symbol(stylx_path, name):\n", " \"\"\"Read the named symbol's CIM JSON dict from a .stylx (SQLite) file.\"\"\"\n", " con = sqlite3.connect(str(stylx_path))\n", " try:\n", " rows = con.execute(\n", " \"SELECT NAME, CATEGORY, CLASS, CONTENT FROM ITEMS \"\n", " \"WHERE NAME = ? COLLATE NOCASE\", (name,),\n", " ).fetchall()\n", " finally:\n", " con.close()\n", " if not rows:\n", " raise LookupError(\"No style item named {!r}\".format(name))\n", " raw = rows[0][3]\n", " if isinstance(raw, bytes):\n", " if raw.startswith(b\"\\xef\\xbb\\xbf\"):\n", " raw = raw[3:]\n", " raw = raw.decode(\"utf-8\", errors=\"replace\")\n", " elif raw.startswith(\"\\ufeff\"):\n", " raw = raw[1:]\n", " obj, _end = json.JSONDecoder().raw_decode(raw.lstrip())\n", " return obj\n", "\n", "\n", "def safe_filename(name):\n", " \"\"\"Filesystem-safe base name from a symbol name.\"\"\"\n", " keep = [c if (c.isalnum() or c in (\" \", \"-\", \"_\")) else \"_\" for c in (name or \"symbol\")]\n", " return \"_\".join(\"\".join(keep).split()) or \"symbol\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3 - CIM -> SVG composition" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def all_marker_layers(symbol_cim):\n", " sym = symbol_cim.get(\"symbol\", symbol_cim)\n", " out = [l for l in sym.get(\"symbolLayers\", []) if \"Marker\" in l.get(\"type\", \"\")]\n", " if not out:\n", " raise ValueError(\"No marker layers found in symbol\")\n", " return out\n", "\n", "\n", "def _safe_size(layer, fallback=64.0):\n", " try:\n", " s = float(layer.get(\"size\"))\n", " except (TypeError, ValueError):\n", " s = 0.0\n", " return s if s > 0 else fallback\n", "\n", "\n", "def _split_color(values):\n", " if not values or len(values) < 3:\n", " return (\"rgb(0,0,0)\", 1.0)\n", " r, g, b = int(values[0]), int(values[1]), int(values[2])\n", " a = (values[3] / 100.0) if len(values) >= 4 else 1.0\n", " return (\"rgb({},{},{})\".format(r, g, b), a)\n", "\n", "\n", "def _extract_paint(symbol):\n", " paint = {\"fill\": \"none\", \"fill_opacity\": 1.0, \"stroke\": \"none\",\n", " \"stroke_opacity\": 1.0, \"stroke_width\": 0.0}\n", " for sl in (symbol or {}).get(\"symbolLayers\", []):\n", " if not sl.get(\"enable\", True):\n", " continue\n", " t = sl.get(\"type\", \"\")\n", " rgb, op = _split_color((sl.get(\"color\") or {}).get(\"values\", [0, 0, 0, 100]))\n", " if \"Solid\" in t and \"Fill\" in t:\n", " paint[\"fill\"], paint[\"fill_opacity\"] = rgb, op\n", " elif \"Solid\" in t and \"Stroke\" in t:\n", " paint[\"stroke\"], paint[\"stroke_opacity\"] = rgb, op\n", " paint[\"stroke_width\"] = float(sl.get(\"width\", 0))\n", " return paint\n", "\n", "\n", "def _arc_to_svg(start, seg):\n", " end, center = seg[0], seg[1]\n", " cw = bool(seg[3]) if len(seg) > 3 else False\n", " sx, sy = start; ex, ey = end; cx, cy = center\n", " rx = math.hypot(sx - cx, sy - cy)\n", " sweep = 0 if cw else 1\n", " EPS = 1e-9\n", " if abs(sx - ex) < EPS and abs(sy - ey) < EPS:\n", " mx, my = 2 * cx - sx, 2 * cy - sy\n", " cmd = (\"A{0} {0} 0 0 {1} {2} {3} A{0} {0} 0 0 {1} {4} {5}\"\n", " .format(rx, sweep, mx, my, ex, ey))\n", " return cmd, end\n", " a0 = math.atan2(sy - cy, sx - cx)\n", " a1 = math.atan2(ey - cy, ex - cx)\n", " da = a1 - a0\n", " while da > math.pi: da -= 2 * math.pi\n", " while da <= -math.pi: da += 2 * math.pi\n", " large = 1 if abs(da) > math.pi else 0\n", " return \"A{0} {0} 0 {1} {2} {3} {4}\".format(rx, large, sweep, ex, ey), end\n", "\n", "\n", "def _segments_to_d(rings_or_paths, closed):\n", " parts, skipped = [], 0\n", " for ring in rings_or_paths:\n", " if not ring:\n", " continue\n", " first = ring[0]\n", " if isinstance(first, dict):\n", " skipped += 1\n", " continue\n", " cx, cy = first\n", " parts.append(\"M{} {}\".format(cx, cy))\n", " for seg in ring[1:]:\n", " if isinstance(seg, list) and len(seg) == 2 and not isinstance(seg[0], (list, dict)):\n", " cx, cy = seg\n", " parts.append(\"L{} {}\".format(cx, cy))\n", " elif isinstance(seg, dict):\n", " if \"c\" in seg or \"b\" in seg:\n", " pts = seg.get(\"c\", seg.get(\"b\"))\n", " end = pts[0]\n", " if len(pts) >= 3:\n", " c1, c2 = pts[1], pts[2]\n", " parts.append(\"C{} {} {} {} {} {}\".format(\n", " c1[0], c1[1], c2[0], c2[1], end[0], end[1]))\n", " elif len(pts) == 2:\n", " ctrl = pts[1]\n", " parts.append(\"Q{} {} {} {}\".format(ctrl[0], ctrl[1], end[0], end[1]))\n", " else:\n", " skipped += 1\n", " continue\n", " cx, cy = end\n", " elif \"q\" in seg:\n", " end, ctrl = seg[\"q\"]\n", " parts.append(\"Q{} {} {} {}\".format(ctrl[0], ctrl[1], end[0], end[1]))\n", " cx, cy = end\n", " elif \"a\" in seg:\n", " cmd, end = _arc_to_svg((cx, cy), seg[\"a\"])\n", " parts.append(cmd); cx, cy = end\n", " else:\n", " skipped += 1\n", " else:\n", " skipped += 1\n", " if closed:\n", " parts.append(\"Z\")\n", " return \" \".join(parts), skipped\n", "\n", "\n", "def _path_attrs(paint, override_fill=None):\n", " fill = override_fill if override_fill is not None else paint[\"fill\"]\n", " parts = ['fill=\"{}\"'.format(fill)]\n", " if fill != \"none\":\n", " parts.append('fill-opacity=\"{:.4f}\"'.format(paint[\"fill_opacity\"]))\n", " parts.append('fill-rule=\"evenodd\"')\n", " parts.append('stroke=\"{}\"'.format(paint[\"stroke\"]))\n", " if paint[\"stroke\"] != \"none\":\n", " parts.append('stroke-opacity=\"{:.4f}\"'.format(paint[\"stroke_opacity\"]))\n", " parts.append('stroke-width=\"{}\"'.format(paint[\"stroke_width\"]))\n", " return \" \".join(parts)\n", "\n", "\n", "def build_vector_layer_body(layer):\n", " out, processed, skipped = [], 0, 0\n", " for g in layer.get(\"markerGraphics\", []):\n", " geom = g.get(\"geometry\", {})\n", " paint = _extract_paint(g.get(\"symbol\", {}))\n", " d, closed = None, True\n", " if \"rings\" in geom:\n", " d, sk = _segments_to_d(geom[\"rings\"], True)\n", " elif \"curveRings\" in geom:\n", " d, sk = _segments_to_d(geom[\"curveRings\"], True)\n", " elif \"paths\" in geom:\n", " d, sk = _segments_to_d(geom[\"paths\"], False); closed = False\n", " elif \"curvePaths\" in geom:\n", " d, sk = _segments_to_d(geom[\"curvePaths\"], False); closed = False\n", " elif \"x\" in geom and \"y\" in geom:\n", " r = float(g.get(\"size\", 1))\n", " out.append(''.format(\n", " geom[\"x\"], geom[\"y\"], r, _path_attrs(paint)))\n", " processed += 1\n", " continue\n", " else:\n", " skipped += 1\n", " continue\n", " if d:\n", " override = None if closed else \"none\"\n", " out.append(''.format(d, _path_attrs(paint, override_fill=override)))\n", " processed += 1\n", " skipped += sk\n", " return \"\\n \".join(out), processed, skipped\n", "\n", "\n", "def build_picture_layer_body(layer, raster_base):\n", " m = _DATA_URL.match(layer.get(\"url\", \"\"))\n", " if not m:\n", " raise ValueError(\"CIMPictureMarker.url is not a base64 data URL\")\n", " mime = m.group(\"mime\").lower()\n", " raw = base64.b64decode(m.group(\"data\"))\n", " ext = \".svg\" if \"svg\" in mime else _RASTER_EXT.get(mime, \".bin\")\n", " raster_path = Path(str(raster_base) + ext)\n", " raster_path.write_bytes(raw)\n", " size = _safe_size(layer)\n", " href = raster_path.name\n", " return (''\n", " .format(href, size)), 1, 0\n", "\n", "\n", "def _layer_transform(layer, viewbox_size):\n", " size = _safe_size(layer)\n", " frame = layer.get(\"frame\", {\"xmin\": 0, \"ymin\": 0, \"xmax\": size, \"ymax\": size})\n", " fx, fy = float(frame[\"xmin\"]), float(frame[\"ymin\"])\n", " fw = float(frame[\"xmax\"]) - fx\n", " fh = float(frame[\"ymax\"]) - fy\n", " fmax = max(fw, fh) if max(fw, fh) > 0 else 1.0\n", " scale = size / fmax\n", " pad = (viewbox_size - size) / 2.0\n", " tx = pad - scale * fx\n", " ty = pad + scale * (fy + fh)\n", " return \"translate({} {}) scale({} {})\".format(tx, ty, scale, -scale), scale\n", "\n", "\n", "def build_symbol_svg(symbol_cim, target_size_px, raster_base=None, say=_noop):\n", " \"\"\"Compose every marker layer into one SVG string. `raster_base` is a path\n", " stem for any picture-marker sidecar files (defaults to cwd 'layer').\"\"\"\n", " layers = all_marker_layers(symbol_cim)\n", " sizes = [_safe_size(l) for l in layers]\n", " viewbox = max(sizes) if sizes else 64.0\n", "\n", " max_half_stroke_vb = 0.0\n", " for layer in layers:\n", " layer_size = _safe_size(layer)\n", " frame = layer.get(\"frame\", {\"xmin\": 0, \"ymin\": 0, \"xmax\": layer_size, \"ymax\": layer_size})\n", " fw = float(frame[\"xmax\"]) - float(frame[\"xmin\"])\n", " fh = float(frame[\"ymax\"]) - float(frame[\"ymin\"])\n", " fmax = max(fw, fh) if max(fw, fh) > 0 else 1.0\n", " layer_scale = layer_size / fmax\n", " for g in layer.get(\"markerGraphics\", []):\n", " half = (_extract_paint(g.get(\"symbol\", {}))[\"stroke_width\"] * layer_scale) / 2.0\n", " max_half_stroke_vb = max(max_half_stroke_vb, half)\n", "\n", " pad = max_half_stroke_vb + viewbox * 0.01\n", " vb_origin = -pad\n", " vb_size = viewbox + 2 * pad\n", "\n", " groups = []\n", " for i, layer in enumerate(reversed(layers)):\n", " cim_index = len(layers) - 1 - i\n", " kind = layer.get(\"type\")\n", " transform, _scale = _layer_transform(layer, viewbox)\n", " try:\n", " if kind == \"CIMVectorMarker\":\n", " body, p, sk = build_vector_layer_body(layer)\n", " elif kind == \"CIMPictureMarker\":\n", " base = \"{}_layer{}\".format(raster_base or \"layer\", cim_index)\n", " body, p, sk = build_picture_layer_body(layer, base)\n", " else:\n", " say(\" skip layer {}: {} (not handled)\".format(cim_index, kind))\n", " continue\n", " except Exception as e:\n", " say(\" layer {} ({}) failed: {}\".format(cim_index, kind, e))\n", " continue\n", " say(\" layer {}: {} -> {} graphics, {} skipped\".format(cim_index, kind, p, sk))\n", " groups.append(' \\n {}\\n '\n", " .format(cim_index, transform, body))\n", "\n", " return ('\\n{3}\\n\\n'\n", " .format(vb_origin, vb_size, target_size_px, \"\\n\".join(groups)))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4 - PNG rasterization (cairosvg -> Inkscape -> skip)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def _find_inkscape():\n", " on_path = shutil.which(\"inkscape\")\n", " if on_path:\n", " return on_path\n", " for c in (r\"C:\\Program Files\\Inkscape\\bin\\inkscape.exe\",\n", " r\"C:\\Program Files\\Inkscape\\inkscape.exe\"):\n", " if Path(c).exists():\n", " return c\n", " return None\n", "\n", "\n", "def export_png_from_svg(svg_content, png_path, target_size, say=_noop):\n", " try:\n", " import cairosvg\n", " cairosvg.svg2png(bytestring=svg_content.encode(\"utf-8\"),\n", " output_width=target_size, output_height=target_size,\n", " write_to=str(png_path))\n", " return png_path\n", " except ImportError:\n", " pass\n", " except Exception as e:\n", " say(\"cairosvg failed: {}\".format(e))\n", " ink = _find_inkscape()\n", " if ink:\n", " tmp = Path(str(png_path) + \".tmp.svg\")\n", " tmp.write_text(svg_content, encoding=\"utf-8\")\n", " try:\n", " r = subprocess.run([ink, str(tmp), \"--export-filename=\" + str(png_path),\n", " \"--export-width=\" + str(target_size),\n", " \"--export-height=\" + str(target_size)],\n", " capture_output=True, timeout=60)\n", " if r.returncode == 0 and png_path.exists():\n", " return png_path\n", " except Exception as e:\n", " say(\"Inkscape failed: {}\".format(e))\n", " finally:\n", " if tmp.exists():\n", " tmp.unlink()\n", " say(\"PNG skipped for {} -- install cairosvg (`pip install cairosvg \"\n", " \"--break-system-packages`) or Inkscape.\".format(png_path.name))\n", " return None" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5 - One symbol -> files, and the batch driver" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def export_one(stylx_path, name, svg_dir, png_dir, target_px, write_png, say=_noop):\n", " \"\"\"Render one symbol to /.svg (+ /.png).\n", " Returns (svg_path, png_path_or_None).\"\"\"\n", " svg_dir = Path(svg_dir); svg_dir.mkdir(parents=True, exist_ok=True)\n", " cim = fetch_symbol(Path(stylx_path), name)\n", " safe = safe_filename(name)\n", " svg_path = svg_dir / (safe + \".svg\")\n", " svg = build_symbol_svg(cim, target_px, raster_base=str(svg_dir / safe), say=say)\n", " svg_path.write_text(svg, encoding=\"utf-8\")\n", " png_path = None\n", " if write_png:\n", " pdir = Path(png_dir) if png_dir else svg_dir\n", " pdir.mkdir(parents=True, exist_ok=True)\n", " png_path = export_png_from_svg(svg, pdir / (safe + \".png\"), target_px, say=say)\n", " return svg_path, png_path\n", "\n", "\n", "def resolve_target_names(stylx_path, scope, categories=None, name=None):\n", " \"\"\"Turn the chosen scope into a concrete list of symbol names.\"\"\"\n", " if scope == \"single\":\n", " return [name] if name else []\n", " if scope == \"categories\":\n", " return list_names(stylx_path, categories=categories)\n", " return list_names(stylx_path) # \"all\"\n", "\n", "\n", "def export_symbols(stylx_path, scope, svg_dir, png_dir=None, categories=None,\n", " name=None, target_px=200, write_png=True, say=_noop):\n", " \"\"\"Batch driver. Returns (ok_count, fail_count).\"\"\"\n", " names = resolve_target_names(stylx_path, scope, categories, name)\n", " say(\"{} symbol(s) to export.\".format(len(names)))\n", " ok, fail = 0, 0\n", " for n in names:\n", " try:\n", " svg_path, png_path = export_one(stylx_path, n, svg_dir, png_dir,\n", " target_px, write_png, say=say)\n", " ok += 1\n", " say(\"OK {} -> {}{}\".format(\n", " n, svg_path.name, \"\" if not png_path else \" + \" + png_path.name))\n", " except Exception as e:\n", " fail += 1\n", " say(\"ERR {} -- {}\".format(n, e))\n", " return ok, fail" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 6 - Run" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Resolve the .stylx: explicit CONFIG path wins; otherwise auto-detect Favorites.\n", "stylx = STYLX_PATH or find_favorites_stylx()\n", "if not stylx or not Path(stylx).exists():\n", " raise SystemExit(\"No .stylx found. Set STYLX_PATH in the CONFIG cell to a real .stylx file.\")\n", "\n", "print(\"Style:\", stylx)\n", "print(\"Categories in style:\", \", \".join(list_categories(stylx)) or \"(none)\")\n", "\n", "ok, fail = export_symbols(\n", " str(stylx), SCOPE, SVG_DIR, png_dir=PNG_DIR, categories=CATEGORIES,\n", " name=SYMBOL, target_px=TARGET_PX, write_png=WRITE_PNG, say=print)\n", "\n", "print(\"\\nDone. {} exported, {} failed.\".format(ok, fail))" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3" } }, "nbformat": 4, "nbformat_minor": 5 }