studio_icon_from_lucide.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. """
  2. Studio app icons from Lucide glyphs, with the glyph flattened to one outline.
  3. Lucide draws a glyph as several stroked paths. Layered with a translucent gradient and a 10% shadow copy, every
  4. overlap between two strokes shows twice as dark. So each stroke is expanded to its outline (2 units wide, round
  5. caps and joins, as Lucide draws it), all outlines are united into one shape, and that shape is filled instead.
  6. Usage: python3 scripts/studio_icon_from_lucide.py <repo root> (needs: pip install shapely svgpathtools)
  7. """
  8. import math
  9. import re
  10. import sys
  11. import xml.etree.ElementTree as ET
  12. from shapely.geometry import LineString, Polygon, MultiPolygon
  13. from shapely.ops import unary_union
  14. from svgpathtools import parse_path
  15. ROOT = sys.argv[1].rstrip('/')
  16. LUCIDE = ROOT + '/plugins_public/lucide/icons/'
  17. PLATES = {
  18. 'gray': ('#71717A', '#3F3F46'),
  19. 'green': ('#059669', '#065F46'),
  20. 'orange': ('#D97706', '#92400E'),
  21. 'purple': ('#7C3AED', '#5B21B6'),
  22. 'red': ('#F43F5E', '#BE123C'),
  23. 'white': ('#F3F4F6', '#E5E7EB'),
  24. 'rose': ('#984F65', '#691C35'),
  25. }
  26. # glyph gradient (top, bottom): white fading out on a coloured plate, zinc on the white plate
  27. GLYPH = {
  28. 'white': ('#71717A', '#3F3F46'),
  29. }
  30. ICONS = [
  31. ('modules/boonex/api/template/images/icons/std-icon.svg', 'api', 'gray', 'webhook', 80),
  32. ('modules/boonex/anon_follow/template/images/icons/std-icon.svg', 'anon_follow', 'orange', 'hat-glasses', 80),
  33. ('modules/boonex/decorous/template/images/icons/std-icon.svg', 'decorous', 'purple', 'panels-top-left', 80),
  34. ('modules/boonex/editor/template/images/icons/std-icon.svg', 'editor', 'gray', 'text-initial', 80),
  35. ('modules/boonex/lucid/template/images/icons/std-icon.svg', 'lucid', 'purple', 'layout-panel-top', 80),
  36. ('modules/boonex/mapshow/template/images/icons/std-icon.svg', 'mapshow', 'green', 'map', 80),
  37. ('studio/template/images/modules/bx_polls.svg', 'polls', 'green', 'vote', 72),
  38. ('modules/boonex/cas_connect/template/images/icons/std-icon.svg', 'cas', 'white', 'fingerprint-pattern', 80),
  39. # discontinued apps and integrations whose service is gone: a dashed placeholder on the gray plate
  40. ('modules/boonex/datafox/template/images/icons/std-icon.svg', 'datafox', 'gray', 'square-dashed', 80),
  41. ('modules/boonex/dolphin_migration/template/images/icons/std-icon.svg', 'dolphin_migration', 'gray', 'square-dashed', 80),
  42. ('modules/boonex/froala/template/images/icons/std-icon.svg', 'froala', 'gray', 'square-dashed', 80),
  43. ('modules/boonex/dolphin_connect/template/images/icons/std-icon.svg', 'dolcon', 'gray', 'square-dashed', 80),
  44. ('modules/boonex/jobs/template/images/icons/std-icon.svg', 'jobs', 'green', 'briefcase', 80),
  45. ('modules/boonex/reminders/template/images/icons/std-icon.svg', 'reminders', 'gray', 'clipboard-clock', 80),
  46. ('modules/boonex/reputation/template/images/icons/std-icon.svg', 'reputation', 'gray', 'star', 80),
  47. ('modules/boonex/ads/template/images/icons/std-icon.svg', 'ads', 'green', 'scan-square', 80),
  48. ('modules/boonex/workspaces/template/images/icons/std-icon.svg', 'workspaces', 'red', 'target', 80),
  49. ('modules/boonex/videos/template/images/icons/std-icon.svg', 'videos', 'green', 'tv-minimal-play', 80),
  50. ('modules/boonex/stories/template/images/icons/std-icon.svg', 'stories', 'green', 'image-play', 80),
  51. ('modules/boonex/protean/template/images/icons/std-icon.svg', 'protean', 'purple', 'panel-top-dashed', 80),
  52. ('modules/boonex/spaces/template/images/icons/std-icon.svg', 'spaces', 'red', 'hexagons-3', 80),
  53. ('modules/boonex/feedback/template/images/icons/std-icon.svg', 'feedback', 'gray', 'message-square-text', 80),
  54. ('modules/boonex/help_tours/template/images/icons/std-icon.svg', 'help_tours', 'gray', 'route', 80),
  55. ('modules/boonex/invites/template/images/icons/std-icon.svg', 'invites', 'gray', 'mail-plus', 80),
  56. ('modules/boonex/new_comments/template/images/icons/std-icon.svg', 'new_comments', 'gray', 'message-square-dot', 80),
  57. ('modules/boonex/oauth2/template/images/icons/std-icon.svg', 'oauth2', 'gray', 'key-round', 80),
  58. ('modules/boonex/reviews/template/images/icons/std-icon.svg', 'reviews', 'green', 'thumbs-up', 80),
  59. ('modules/boonex/se_migration/template/images/icons/std-icon.svg', 'se_migration', 'gray', 'arrow-right-left', 80),
  60. ('modules/boonex/nexus/template/images/icons/std-icon.svg', 'nexus', 'gray', 'galaxy', 80),
  61. ('modules/boonex/stream/template/images/icons/std-icon.svg', 'stream', 'green', 'circle-play', 80),
  62. ('modules/boonex/resources/template/images/icons/std-icon.svg', 'resources', 'green', 'rows-3', 80),
  63. ('modules/boonex/ai_proxy/template/images/icons/std-icon.svg', 'ai_proxy', 'rose', 'bot', 80),
  64. ]
  65. # Brand artwork on the white plate: the source SVG's shapes are copied as they are (fills, gradients), scaled to
  66. # ART_WIDTH px and centred; the shadow is the united outline of every shape, filled black/10 and shifted 1px down.
  67. # 'skip' drops shapes by class or id (wordmarks, registered marks); optional plate (a PLATES key or a (top, bottom)
  68. # pair), fill override and width follow. The source files are not part of the repo.
  69. ART_WIDTH = 50
  70. ART = [
  71. # ('modules/boonex/azure_b2c_con/template/images/icons/std-icon.svg', 'azrb2c', '<thesvg.org azure-azure-ad-b2c default.svg>', ()),
  72. # ('modules/boonex/azure_connect/template/images/icons/std-icon.svg', 'azrcon', '<thesvg.org azure-entra-connect default.svg>', ()),
  73. # ('modules/boonex/cidaas_connect/template/images/icons/std-icon.svg', 'cidaascon', '<cidaas logo-cidaas-vertical-color-print.svg>', ('st5',)),
  74. # ('modules/boonex/shopify/template/images/icons/std-icon.svg', 'shopify', '<thesvg.org shopify default.svg>', ()),
  75. # ('modules/boonex/xero/template/images/icons/std-icon.svg', 'xero', '<thesvg.org xero default.svg>', ()),
  76. # ('modules/boonex/okta_connect/template/images/icons/std-icon.svg', 'oktacon', '<thesvg.org okta default.svg>', ()),
  77. # ('modules/boonex/google_tagmanager/template/images/icons/std-icon.svg', 'googletagman', '<thesvg.org google-tag-manager default.svg>', ()),
  78. # ('modules/boonex/mailchimp/template/images/icons/std-icon.svg', 'mailchimp', '<thesvg.org mailchimp default.svg>', (), ('#FFE01B', '#EAC900'), '#241C15'),
  79. # ('modules/boonex/stripe_connect/template/images/icons/std-icon.svg', 'stripe_connect', '<thesvg.org stripe default.svg>', (), ('#6F68FF', '#4F48D9'), 'white', 52),
  80. # ('modules/boonex/snipcart/template/images/icons/std-icon.svg', 'snipcart', '<snipcart.com logo, wordmark path id=wordmark>', ('wordmark',), ('#475569', '#1E293B'), '#FACC15'),
  81. # ('modules/boonex/drupal_connect/template/images/icons/std-icon.svg', 'drupal', '<thesvg.org drupal default.svg>', ()),
  82. # ('modules/boonex/elasticsearch/template/images/icons/std-icon.svg', 'elasticsearch', '<thesvg.org elasticsearch default.svg>', ()),
  83. # ('modules/boonex/facebook_connect/template/images/icons/std-icon.svg', 'facebook', '<thesvg.org facebook default.svg>', ()),
  84. # ('modules/boonex/github/template/images/icons/std-icon.svg', 'github', '<thesvg.org github default.svg>', ()),
  85. # ('modules/boonex/linkedin_connect/template/images/icons/std-icon.svg', 'linkedin', '<thesvg.org linkedin default.svg>', ()),
  86. # ('modules/boonex/twitter_connect/template/images/icons/std-icon.svg', 'twitter', '<thesvg.org x default.svg>', ()),
  87. # ('modules/boonex/opencv/template/images/icons/std-icon.svg', 'opencv', '<thesvg.org opencv default.svg>', ()),
  88. # ('modules/boonex/fontawesome/template/images/icons/std-icon.svg', 'fontawesome', '<thesvg.org font-awesome default.svg>', ()),
  89. # ('modules/boonex/google_connect/template/images/icons/std-icon.svg', 'googlecon', '<thesvg.org google default.svg>', ()),
  90. # ('modules/boonex/intercom/template/images/icons/std-icon.svg', 'intercom', '<thesvg.org intercom default.svg>', (), ('#27272A', '#09090B')),
  91. # ('modules/boonex/chat_plus/template/images/icons/std-icon.svg', 'chat_plus', '<simpleicons rocketdotchat>', ()),
  92. # ('modules/boonex/plyr/template/images/icons/std-icon.svg', 'plyr', '<Plyr wordmark path of the previous tile>', (), ('#475569', '#1E293B'), '#38BDF8', 52),
  93. # ('modules/boonex/markerio/template/images/icons/std-icon.svg', 'markerio', '<Marker.io mark path of the previous tile>', (), ('#475569', '#1E293B'), 'white'),
  94. ]
  95. STROKE = 2.0 # Lucide stroke width, in its 24-unit space
  96. STEP = 0.02 # sampling step along curves
  97. TOL = 0.003 # simplification tolerance of the united outline
  98. def element_to_d(el):
  99. """A Lucide shape element as a path 'd' string."""
  100. tag = el.tag.split('}')[-1]
  101. a = el.attrib
  102. f = lambda k, d='0': float(a.get(k, d))
  103. if tag == 'path':
  104. return a['d']
  105. if tag == 'circle':
  106. cx, cy, r = f('cx'), f('cy'), f('r')
  107. return f'M{cx - r},{cy} A{r},{r} 0 1 0 {cx + r},{cy} A{r},{r} 0 1 0 {cx - r},{cy} Z'
  108. if tag == 'ellipse':
  109. cx, cy, rx, ry = f('cx'), f('cy'), f('rx'), f('ry')
  110. return f'M{cx - rx},{cy} A{rx},{ry} 0 1 0 {cx + rx},{cy} A{rx},{ry} 0 1 0 {cx - rx},{cy} Z'
  111. if tag == 'rect':
  112. x, y, w, h = f('x'), f('y'), f('width'), f('height')
  113. rx = f('rx', a.get('ry', '0'))
  114. ry = f('ry', str(rx))
  115. if rx == 0 and ry == 0:
  116. return f'M{x},{y} h{w} v{h} h{-w} Z'
  117. return (f'M{x + rx},{y} h{w - 2 * rx} a{rx},{ry} 0 0 1 {rx},{ry} v{h - 2 * ry} a{rx},{ry} 0 0 1 {-rx},{ry} '
  118. f'h{-(w - 2 * rx)} a{rx},{ry} 0 0 1 {-rx},{-ry} v{-(h - 2 * ry)} a{rx},{ry} 0 0 1 {rx},{-ry} Z')
  119. if tag == 'line':
  120. return f'M{f("x1")},{f("y1")} L{f("x2")},{f("y2")}'
  121. if tag in ('polyline', 'polygon'):
  122. pts = re.findall(r'-?\d*\.?\d+', a['points'])
  123. d = 'M' + ' L'.join(f'{pts[i]},{pts[i + 1]}' for i in range(0, len(pts), 2))
  124. return d + (' Z' if tag == 'polygon' else '')
  125. return None
  126. def sample(d):
  127. """Each continuous subpath of 'd' as a list of (x, y) points."""
  128. path = parse_path(d)
  129. runs = []
  130. for sub in path.continuous_subpaths():
  131. pts = []
  132. for seg in sub:
  133. n = max(2, int(math.ceil(seg.length() / STEP)))
  134. for i in range(n):
  135. p = seg.point(i / n)
  136. pts.append((p.real, p.imag))
  137. end = sub[-1].point(1.0)
  138. pts.append((end.real, end.imag))
  139. runs.append(pts)
  140. return runs
  141. LUCIDE_LAB = 'https://raw.githubusercontent.com/lucide-icons/lucide-lab/main/icons/'
  142. def glyph_source(name):
  143. """The vendored Lucide file, or the Lucide Lab icon of that name fetched from its repository."""
  144. import os
  145. path = LUCIDE + name + '.svg'
  146. if os.path.exists(path):
  147. return path
  148. from urllib.request import urlopen
  149. with urlopen(LUCIDE_LAB + name + '.svg') as r:
  150. data = r.read()
  151. if b'<svg' not in data:
  152. raise FileNotFoundError(name + ': not in the vendored set nor in Lucide Lab')
  153. return data
  154. def glyph_outline(name):
  155. src = glyph_source(name)
  156. root = ET.fromstring(src) if isinstance(src, bytes) else ET.parse(src).getroot()
  157. shapes = []
  158. for el in root.iter():
  159. d = element_to_d(el)
  160. if not d:
  161. continue
  162. for pts in sample(d):
  163. if len(pts) < 2:
  164. continue
  165. shapes.append(LineString(pts).buffer(STROKE / 2, quad_segs=48, cap_style='round', join_style='round'))
  166. shape = unary_union(shapes).simplify(TOL, preserve_topology=True)
  167. return shape
  168. def ring_to_d(coords):
  169. parts = []
  170. for i, (x, y) in enumerate(coords[:-1]):
  171. parts.append(('M' if i == 0 else 'L') + f'{x:.2f} {y:.2f}')
  172. return ' '.join(parts) + ' Z'
  173. def shape_to_d(shape):
  174. polys = shape.geoms if isinstance(shape, MultiPolygon) else [shape]
  175. d = []
  176. for poly in polys:
  177. d.append(ring_to_d(list(poly.exterior.coords)))
  178. for ring in poly.interiors:
  179. d.append(ring_to_d(list(ring.coords)))
  180. return ' '.join(d)
  181. def icon(uid, plate, d, size):
  182. c1, c2 = PLATES[plate]
  183. g1, g2 = GLYPH.get(plate, ('white', 'white'))
  184. g2_opacity = '' if plate in GLYPH else ' stop-opacity="0.6"'
  185. if size == 80:
  186. t, ts, s = '13.6 13.6', '13.6 14.6', '2.2'
  187. else:
  188. t, ts, s = '12 12', '12 13', '2'
  189. return f'''<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" fill="none" xmlns="http://www.w3.org/2000/svg">
  190. <rect width="{size}" height="{size}" fill="url(#paint0_linear_{uid})"/>
  191. <g transform="translate({t}) scale({s})">
  192. <path fill-rule="evenodd" fill="url(#paint1_linear_{uid})" d="{d}"/>
  193. </g>
  194. <g transform="translate({ts}) scale({s})">
  195. <path fill-rule="evenodd" fill="black" fill-opacity="0.1" d="{d}"/>
  196. </g>
  197. <defs>
  198. <linearGradient id="paint0_linear_{uid}" x1="{size / 2:g}" y1="0" x2="{size / 2:g}" y2="{size}" gradientUnits="userSpaceOnUse">
  199. <stop stop-color="{c1}"/>
  200. <stop offset="1" stop-color="{c2}"/>
  201. </linearGradient>
  202. <linearGradient id="paint1_linear_{uid}" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
  203. <stop stop-color="{g1}"/>
  204. <stop offset="1" stop-color="{g2}"{g2_opacity}/>
  205. </linearGradient>
  206. </defs>
  207. </svg>
  208. '''
  209. def parse_transform(t):
  210. """An SVG transform attribute as a 3x3 affine matrix (row-major, last row 0 0 1)."""
  211. m = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
  212. for name, args in re.findall(r'(matrix|translate|scale|rotate)\s*\(([^)]*)\)', t or ''):
  213. v = [float(x) for x in re.findall(r'-?\d*\.?\d+(?:e-?\d+)?', args)]
  214. if name == 'matrix':
  215. n = [v[0], v[2], v[4], v[1], v[3], v[5]]
  216. elif name == 'translate':
  217. n = [1, 0, v[0], 0, 1, v[1] if len(v) > 1 else 0]
  218. elif name == 'scale':
  219. n = [v[0], 0, 0, 0, v[1] if len(v) > 1 else v[0], 0]
  220. else:
  221. a = math.radians(v[0])
  222. n = [math.cos(a), -math.sin(a), 0, math.sin(a), math.cos(a), 0]
  223. m = [m[0] * n[0] + m[1] * n[3], m[0] * n[1] + m[1] * n[4], m[0] * n[2] + m[1] * n[5] + m[2],
  224. m[3] * n[0] + m[4] * n[3], m[3] * n[1] + m[4] * n[4], m[3] * n[2] + m[4] * n[5] + m[5]]
  225. return m
  226. def compose(a, b):
  227. """a then b applied to a point means point -> a(b(point)): the parent's matrix times the child's."""
  228. return [a[0] * b[0] + a[1] * b[3], a[0] * b[1] + a[1] * b[4], a[0] * b[2] + a[1] * b[5] + a[2],
  229. a[3] * b[0] + a[4] * b[3], a[3] * b[1] + a[4] * b[4], a[3] * b[2] + a[4] * b[5] + a[5]]
  230. def apply_transform(m, pts):
  231. return [(m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]) for x, y in pts]
  232. def art_icon(uid, src, skip, plate='white', fill=None, width=ART_WIDTH, size=80):
  233. """The artwork scaled to `width` and centred on the plate, its united outline as the shadow. `fill` recolours every
  234. shape (a one-colour mark or wordmark on its brand plate); otherwise a fill set on the source's root is passed down."""
  235. from shapely.geometry import Polygon
  236. ns = '{http://www.w3.org/2000/svg}'
  237. root = ET.parse(src).getroot()
  238. # class fills from a <style> block become attributes, so the art survives being inlined next to other icons
  239. css = {}
  240. for st in root.iter(ns + 'style'):
  241. for cls, body in re.findall(r'\.([\w-]+)\s*\{([^}]*)\}', st.text or ''):
  242. css[cls] = dict(kv.split(':', 1) for kv in body.split(';') if ':' in kv)
  243. inner, polys = [], []
  244. for el in list(root):
  245. if el.tag in (ns + 'style', ns + 'defs') and el.tag == ns + 'style':
  246. continue
  247. defs = ''.join(ET.tostring(el, encoding='unicode') for el in root.iter(ns + 'defs'))
  248. root_fill = fill or root.attrib.get('fill')
  249. xs, ys = [], []
  250. def walk(el, ctm):
  251. tag = el.tag.split('}')[-1]
  252. if tag in ('style', 'defs', 'title', 'desc'):
  253. return
  254. if el.attrib.get('class') in skip or el.attrib.get('id') in skip:
  255. return
  256. if el.attrib.get('transform'):
  257. ctm = parse_transform(el.attrib['transform']) if ctm is None else compose(ctm, parse_transform(el.attrib['transform']))
  258. d = element_to_d(el)
  259. if d:
  260. for cls in el.attrib.pop('class', '').split():
  261. for k, v in css.get(cls, {}).items():
  262. el.set(k.strip(), v.strip())
  263. if fill:
  264. el.set('fill', fill)
  265. elif root_fill and 'fill' not in el.attrib:
  266. el.set('fill', root_fill)
  267. for pts in sample(d):
  268. if ctm:
  269. pts = apply_transform(ctm, pts)
  270. if len(pts) > 2:
  271. polys.append(Polygon(pts).buffer(0))
  272. xs.extend(x for x, _ in pts)
  273. ys.extend(y for _, y in pts)
  274. inner.append(ET.tostring(el, encoding='unicode'))
  275. return
  276. # a group keeps the attributes that change how its shapes draw (transform, clip, opacity, paint); ids and classes go
  277. attrs = {k: v for k, v in el.attrib.items() if k not in ('id', 'class')}
  278. if attrs:
  279. inner.append('<g ' + ' '.join(f'{k}="{v}"' for k, v in attrs.items()) + '>')
  280. for c in el:
  281. walk(c, ctm)
  282. if attrs:
  283. inner.append('</g>')
  284. for el in list(root):
  285. walk(el, None)
  286. x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
  287. s = width / (x1 - x0)
  288. tx, ty = (size - (x1 - x0) * s) / 2 - x0 * s, (size - (y1 - y0) * s) / 2 - y0 * s
  289. shadow = shape_to_d(unary_union(polys).simplify(TOL / s, preserve_topology=True))
  290. body = ''.join(inner).replace('xmlns:ns0="http://www.w3.org/2000/svg"', '').replace('ns0:', '')
  291. c1, c2 = PLATES[plate] if plate in PLATES else plate
  292. return f'''<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" fill="none" xmlns="http://www.w3.org/2000/svg">
  293. <rect width="{size}" height="{size}" fill="url(#paint0_linear_{uid})"/>
  294. <g transform="translate({tx:.3f} {ty:.3f}) scale({s:.5f})">
  295. {body}
  296. </g>
  297. <g transform="translate({tx:.3f} {ty + 1:.3f}) scale({s:.5f})">
  298. <path fill-rule="evenodd" fill="black" fill-opacity="0.1" d="{shadow}"/>
  299. </g>
  300. <defs>
  301. <linearGradient id="paint0_linear_{uid}" x1="{size / 2:g}" y1="0" x2="{size / 2:g}" y2="{size}" gradientUnits="userSpaceOnUse">
  302. <stop stop-color="{c1}"/>
  303. <stop offset="1" stop-color="{c2}"/>
  304. </linearGradient>
  305. {defs}
  306. </defs>
  307. </svg>
  308. '''
  309. for rel, uid, plate, glyph, size in ICONS:
  310. shape = glyph_outline(glyph)
  311. d = shape_to_d(shape)
  312. n = 1 if isinstance(shape, Polygon) else len(shape.geoms)
  313. with open(ROOT + '/' + rel, 'w') as fh:
  314. fh.write(icon(uid, plate, d, size))
  315. print(f'{rel}: {glyph} -> {n} outline(s), {len(d)} chars')
  316. for rel, uid, src, skip, *opts in ART:
  317. with open(ROOT + '/' + rel, 'w') as fh:
  318. fh.write(art_icon(uid, src, skip, *opts))
  319. print(f'{rel}: art from {src}')