573 lines
15 KiB
Python
573 lines
15 KiB
Python
#!/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]))
|