#!/usr/bin/env python3 import json import re import sys from pathlib import Path from urllib.error import HTTPError from urllib.request import urlopen from xml.etree import ElementTree as ET # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- DEVICON_VERSION = "v2.17.0" ICONS = { "c": "c", "cpp": "cplusplus", "csharp": "csharp", "python": "python", "rust": "rust", "javascript": "javascript", "typescript": "typescript", "bash": "bash", "zsh": "zsh", "powershell": "powershell", "cmake": "cmake", "lua": "lua", "java": "java", "kotlin": "kotlin", "swift": "swift", "go": "go", "dart": "dart", "php": "php", "ruby": "ruby", "scala": "scala", "haskell": "haskell", "elixir": "elixir", "erlang": "erlang", "clojure": "clojure", "r": "r", "perl": "perl", "zig": "zig", "nim": "nim", "ocaml": "ocaml", "fsharp": "fsharp", "visualbasic": "visualbasic", "fortran": "fortran", "crystal": "crystal", "gleam": "gleam", "julia": "julia", "objectivec": "objectivec", "vala": "vala", "groovy": "groovy", "racket": "racket", "haxe": "haxe", "purescript": "purescript", "delphi": "delphi", "coffeescript": "coffeescript", "elm": "elm", "awk": "awk", "matlab": "matlab", "solidity": "solidity", "wasm": "wasm", "vim": "vim", "sql": "sqlite", "json": "json", "yaml": "yaml", "xml": "xml", "html": "html5", "css": "css3", "sass": "sass", "markdown": "markdown", "docker": "docker", "latex": "latex", "graphql": "graphql", } # Markdown / syntax-highlighter aliases -> Devicon icon name. ALIASES = { "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "cs": "csharp", "js": "javascript", "jsx": "javascript", "ts": "typescript", "tsx": "typescript", "py": "python", "sh": "bash", "shell": "bash", "ps1": "powershell", "vb": "visualbasic", "objective-c": "objectivec", "obj-c": "objectivec", "groovyscript": "groovy", "pascal": "delphi", "coffee": "coffeescript", "mysql": "sql", "postgres": "sql", "postgresql": "sql", "sqlite": "sql", "yml": "yaml", "htm": "html", "scss": "sass", "md": "markdown", "dockerfile": "docker", "docker-compose": "docker", "tex": "latex", "gql": "graphql", } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- ICON_VARIANTS = ( "plain", "original", ) def fetch_svg(devicon_name: str) -> str: for variant in ICON_VARIANTS: url = ( f"https://raw.githubusercontent.com/devicons/devicon/" f"{DEVICON_VERSION}/icons/{devicon_name}/" f"{devicon_name}-{variant}.svg" ) print(f"Fetching {devicon_name} ({variant}): {url}") try: with urlopen(url) as response: return response.read().decode("utf-8") except HTTPError as error: if error.code != 404: raise raise RuntimeError( f"No usable SVG variant found for '{devicon_name}'." ) def element_to_path(element) -> str: """Convert a basic SVG shape element to equivalent path data.""" tag = element.tag.rsplit("}", 1)[-1] attrib = element.attrib if tag == "path": return attrib.get("d", "") if tag == "circle": cx = float(attrib["cx"]) cy = float(attrib["cy"]) r = float(attrib["r"]) return ( f"M {cx - r},{cy} " f"a {r},{r} 0 1,0 {2 * r},0 " f"a {r},{r} 0 1,0 {-2 * r},0 Z" ) if tag == "ellipse": cx = float(attrib["cx"]) cy = float(attrib["cy"]) rx = float(attrib["rx"]) ry = float(attrib["ry"]) return ( f"M {cx - rx},{cy} " f"a {rx},{ry} 0 1,0 {2 * rx},0 " f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z" ) if tag == "rect": x = float(attrib["x"]) y = float(attrib["y"]) width = float(attrib["width"]) height = float(attrib["height"]) return f"M {x},{y} h {width} v {height} h {-width} Z" if tag in ("polygon", "polyline"): points = attrib["points"].split() commands = [ f"{float(points[i])},{float(points[i + 1])}" for i in range(0, len(points) - 1, 2) ] data = "M " + " L ".join(commands) if tag == "polygon": data += " Z" return data if tag == "line": return ( f"M {float(attrib['x1'])},{float(attrib['y1'])} " f"L {float(attrib['x2'])},{float(attrib['y2'])}" ) return "" # --------------------------------------------------------------------------- # SVG path data normalization # # Devicon path data crams arc flags against the following coordinate # (e.g. "a28.78 28.78 0 00-2.65-7.58"), which Qt's PathSvg parser handles # unreliably. Parse every path and re-emit it with canonical spacing. # --------------------------------------------------------------------------- PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz" _NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?") def _skip_separators(data: str, i: int) -> int: while i < len(data) and data[i] in " \t\r\n,": i += 1 return i def _read_number(data: str, i: int) -> tuple[str, int]: i = _skip_separators(data, i) match = _NUMBER.match(data, i) if not match: raise ValueError( f"Expected number in path data at {i}: {data[i:i + 24]!r}" ) return match.group(0), match.end() def parse_path(data: str) -> list[tuple[str, list[str]]]: """Parse SVG path data into (command, parameters) tuples.""" commands: list[tuple[str, list[str]]] = [] i, length = 0, len(data) while i < length: i = _skip_separators(data, i) if i >= length: break command = data[i] if command not in PATH_COMMANDS: raise ValueError( f"Unexpected character {command!r} at {i} in {data!r}" ) i += 1 if command in "Zz": commands.append((command, [])) continue implicit = command while i < length: i = _skip_separators(data, i) if i >= length or data[i] in PATH_COMMANDS: break if implicit in "Mm": x, i = _read_number(data, i) y, i = _read_number(data, i) commands.append((implicit, [x, y])) # Subsequent coordinate pairs after a moveto are linetos. implicit = "L" if implicit == "M" else "l" elif implicit in "LlTt": x, i = _read_number(data, i) y, i = _read_number(data, i) commands.append((implicit, [x, y])) elif implicit in "Hh": x, i = _read_number(data, i) commands.append((implicit, [x])) elif implicit in "Vv": y, i = _read_number(data, i) commands.append((implicit, [y])) elif implicit in "Cc": # Cubic bezier: x1 y1 x2 y2 x y p = [] for _ in range(6): value, i = _read_number(data, i) p.append(value) commands.append((implicit, p)) elif implicit in "SsQq": # Smooth cubic / quadratic: x1 y1 x y p = [] for _ in range(4): value, i = _read_number(data, i) p.append(value) commands.append((implicit, p)) elif implicit in "Aa": rx, i = _read_number(data, i) ry, i = _read_number(data, i) rotation, i = _read_number(data, i) # Arc flags are single digits, possibly crammed against # the following coordinate, so read them positionally. i = _skip_separators(data, i) large_arc = data[i] i += 1 i = _skip_separators(data, i) sweep = data[i] i += 1 if large_arc not in "01" or sweep not in "01": raise ValueError( f"Invalid arc flags near {i - 2} in {data!r}" ) x, i = _read_number(data, i) y, i = _read_number(data, i) commands.append( (implicit, [rx, ry, rotation, large_arc, sweep, x, y]) ) else: raise ValueError(f"Unhandled path command {implicit!r}") return commands def normalize_path(data: str) -> str: """Re-emit path data with canonical spacing (Qt PathSvg friendly).""" return " ".join( command if not params else command + " " + " ".join(params) for command, params in parse_path(data) ) def extract_paths(svg: str) -> str: root = ET.fromstring(svg) paths = [] for element in root.iter(): path = element_to_path(element) if not path: continue normalized = normalize_path(path) # Guard against a tokenizer that changes the path it normalizes. if parse_path(normalized) != parse_path(path): raise RuntimeError( "Path normalization round-trip mismatch:\n" f" in : {path[:80]}\n out: {normalized[:80]}" ) paths.append(normalized) if not paths: raise RuntimeError("SVG contains no drawable shape elements") # Multiple path elements are valid SVG path data when concatenated. return " ".join(paths) def qml_string(value: str) -> str: # JSON string escaping is valid for the string syntax we need in QML. return json.dumps(value, ensure_ascii=False) def qml_identifier(name: str) -> str: name = re.sub(r"[^A-Za-z0-9_]", "_", name) if name and name[0].isdigit(): name = "_" + name return name # --------------------------------------------------------------------------- # Generation # --------------------------------------------------------------------------- def generate(output: Path) -> None: icons = {} for name, devicon_name in ICONS.items(): svg = fetch_svg(devicon_name) icons[name] = extract_paths(svg) output.parent.mkdir(parents=True, exist_ok=True) with output.open("w", encoding="utf-8") as file: file.write( """pragma Singleton import Quickshell import QtQuick Singleton { """ ) # ------------------------------------------------------------------- # Icon path properties # ------------------------------------------------------------------- for icon, path in icons.items(): identifier = qml_identifier(icon) file.write( f" readonly property string {identifier}: " f"{qml_string(path)}\n" ) file.write("\n") # ------------------------------------------------------------------- # Language -> icon lookup # ------------------------------------------------------------------- file.write( """ function path(language) { const key = language.toLowerCase() switch (key) { """ ) for icon in icons: identifier = qml_identifier(icon) file.write( f' case "{icon}":\n' f" return {identifier}\n" ) for alias, icon in ALIASES.items(): if icon not in icons: raise RuntimeError( f"Alias '{alias}' points to unavailable icon '{icon}'" ) identifier = qml_identifier(icon) file.write( f' case "{alias}":\n' f" return {identifier}\n" ) file.write( """ default: return "" } } function name(language) { switch (language.toLowerCase()) { """ ) # Human-readable names can initially be handled here. # Add/remove these as your codeblock UI evolves. names = { "c": "C", "cpp": "C++", "csharp": "C#", "python": "Python", "rust": "Rust", "javascript": "JavaScript", "typescript": "TypeScript", "qml": "QML", "bash": "Bash", "zsh": "Zsh", "powershell": "PowerShell", "cmake": "CMake", "lua": "Lua", "java": "Java", "kotlin": "Kotlin", "swift": "Swift", "go": "Go", "dart": "Dart", "php": "PHP", "ruby": "Ruby", "scala": "Scala", "haskell": "Haskell", "elixir": "Elixir", "erlang": "Erlang", "clojure": "Clojure", "r": "R", "perl": "Perl", "zig": "Zig", "nim": "Nim", "ocaml": "OCaml", "fsharp": "F#", "visualbasic": "Visual Basic", "fortran": "Fortran", "crystal": "Crystal", "gleam": "Gleam", "julia": "Julia", "objectivec": "Objective-C", "vala": "Vala", "groovy": "Groovy", "racket": "Racket", "haxe": "Haxe", "purescript": "PureScript", "delphi": "Delphi", "coffeescript": "CoffeeScript", "elm": "Elm", "awk": "AWK", "matlab": "MATLAB", "solidity": "Solidity", "wasm": "Wasm", "vim": "Vim", "sql": "SQL", "json": "JSON", "yaml": "YAML", "xml": "XML", "html": "HTML", "css": "CSS", "sass": "Sass", "markdown": "Markdown", "docker": "Docker", "latex": "LaTeX", "graphql": "GraphQL", } for icon in icons: if icon not in names: continue name = names[icon] file.write( f' case "{icon}":\n' f' return "{name}"\n' ) for alias, icon in ALIASES.items(): if icon not in names: continue file.write( f' case "{alias}":\n' f' return "{names[icon]}"\n' ) file.write( """ default: return language } } } """ ) print(f"Generated {output}") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) sys.exit(1) generate(Path(sys.argv[1]))