""" UmbraEdit - Icon Theme Utils ============================== This file is a part of UmbraEdit. Copyright (C) 2025 Veronica Berglyd Olsen and UmbraEdit contributors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ # noqa from __future__ import annotations import argparse import json import subprocess import urllib.request import zipfile from pathlib import Path from urllib.parse import quote from xml.etree import ElementTree as ET from utils.common import ROOT_DIR, log, readEnvFile UTILS = Path(__file__).parent ET.register_namespace("", "http://www.w3.org/2000/svg") # Material # Repo: https://github.com/google/material-design-icons # Website: https://fonts.google.com/icons # Lucide # Repo: https://github.com/lucide-icons/lucide # Website: https://lucide.dev/ # Fluent UI # Repo: https://github.com/microsoft/fluentui-system-icons # Website: https://fluenticon.com/ ICON_SOURCES = { "material": "https://github.com/google/material-design-icons.git", "lucide": "https://github.com/lucide-icons/lucide/archive/refs/tags/1.49.0.zip", "fluentui": "https://github.com/microsoft/fluentui-system-icons/archive/refs/tags/1.1.343.zip", } ICON_EXTRACT = { "material": "material-design-icons", "lucide": "lucide-1.49.0", "fluentui": "fluentui-system-icons-1.1.343", } # fmt: off ICONS = [ # Remember to also update tests/_files/all_icons.json for test coverage "alert_error", "alert_info", "alert_question", "alert_warn", "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", "cls_object", "cls_plot", "cls_template", "cls_timeline", "cls_trash", "cls_world", "key_pov", "key_focus", "key_mention", "cmn_general", "cmn_note", "cmn_story", "prj_folder", "prj_document", "prj_title", "prj_chapter", "prj_scene", "prj_note", "fmt_align_center", "fmt_align_left", "fmt_align_right", "fmt_bold", "fmt_italic", "fmt_link", "fmt_list_o", "fmt_list_o_rtl", "fmt_list_u", "fmt_mark", "fmt_size", "fmt_strike", "fmt_subscript", "fmt_superscript", "fmt_toolbar", "fmt_underline", "search", "search_auto", "search_cancel", "search_case", "search_loop", "search_preserve", "search_project", "search_regex", "search_replace", "search_word", "bullet-off", "bullet-on", "unfold-hide", "unfold-show", "sb_build", "sb_novel", "sb_project", "sb_search", "sb_stats", "sb_story", "theme_light", "theme_dark", "theme_auto", "btn_ok", "btn_cancel", "btn_yes", "btn_no", "btn_open", "btn_close", "btn_save", "btn_browse", "btn_list", "btn_new", "btn_create", "btn_reset", "btn_insert", "btn_apply", "btn_build", "btn_print", "btn_preview", "add", "bookmarks", "browse", "build_settings", "calendar", "cancel", "checked", "chevron_down", "chevron_left", "chevron_right", "chevron_up", "close", "copy", "document_add", "document", "edit", "exclude", "export", "filter", "fit_height", "fit_width", "folder", "font", "import", "language", "link", "list", "margin_bottom", "margin_inner", "margin_left", "margin_outer", "margin_right", "margin_top", "maximise", "minimise", "more_arrow", "more_vertical", "noncheckable", "panel", "pin", "project_copy", "quote", "refresh", "remove", "revert", "settings", "stats", "text", "timer_off", "timer", "unchecked", "view", "zoom_in", "zoom_out", ] # fmt: on def _loadMap(name: str) -> dict[str, str]: """Load a theme map file.""" data = json.loads((UTILS / "icon_themes" / f"{name}.json").read_text(encoding="utf-8")) icons = {} for key in ICONS: if icon := data.get(key, ""): icons[key] = icon else: log(f"- [cy]Missing:[e] {key}") return icons def _writeThemeFile(path: Path, name: str, author: str, license_: str, icons: dict[str, ET.Element]) -> None: """Write an icon theme file.""" with open(path.with_suffix(".icons"), mode="w", encoding="utf-8") as out: out.write("# This file is automatically generated. Do not edit.\n\n") out.write("# Meta\n") out.write(f"meta:name = {name}\n") out.write(f"meta:author = {author}\n") out.write(f"meta:license = {license_}\n") out.write("\n") out.write("# Icons\n") for key, svg in icons.items(): for elem in svg.iter(): if elem.text and not elem.text.strip(): elem.text = None if elem.tail and not elem.tail.strip(): elem.tail = None icon = ET.tostring(svg).decode() out.write(f"icon:{key:<15s} = {icon}\n") log(f"- [cg]Wrote:[e] {len(icons)} icons") log(f"- Target: {path.relative_to(UTILS.parent)}") def _updateRepo(path: Path, name: str) -> None: """Clone or update a local repo of icons.""" log(f"[b]Updating:[e] {ICON_SOURCES[name]}") if not path.is_dir(): subprocess.call(["git", "clone", ICON_SOURCES[name], "--depth", "1"], cwd=path.parent) else: subprocess.call(["git", "pull"], cwd=path) log("") def _downloadIconPack(path: Path, name: str) -> None: """Download and extract icon pack releases.""" log(f"[b]Downloading:[e] {ICON_SOURCES[name]}") zipFile = path / f"{name}.zip" urllib.request.urlretrieve(ICON_SOURCES[name], zipFile) log(f"[b]Extracting:[e] {zipFile.name}") with zipfile.ZipFile(zipFile, "r") as inFile: inFile.extractall(path) log("") def processMaterialIcons(workDir: Path, iconsDir: Path, jobs: dict) -> None: """Process material icons of a given spec and write output file.""" srcRepo = workDir / ICON_EXTRACT["material"] _updateRepo(srcRepo, "material") for file, job in jobs.items(): name: str = job["name"] style: str = job["style"] filled: bool = job["filled"] weight: int = job["weight"] kind = f"wght{weight}" if weight != 400 else "" kind += "fill1" if filled else "" log(f"[b]Processing:[e] {name}") icons: dict[str, ET.Element] = {} iconSrc = srcRepo / "symbols" / "web" for key, icon in _loadMap("material").items(): if kind: fileName = f"{icon}_{kind}_24px.svg" else: fileName = f"{icon}_24px.svg" iconFile = iconSrc / icon / f"materialsymbols{style}" / fileName if iconFile.is_file(): svg = ET.fromstring(iconFile.read_text(encoding="utf-8")) svg.set("fill", "#000000") svg.set("height", "128") svg.set("width", "128") icons[key] = svg else: log(f"[cr]Not Found:[e] {iconFile}") target = iconsDir / f"{file}.icons" _writeThemeFile(target, name, "Google Inc", "Apache 2.0", icons) log("") def processLucide(workDir: Path, iconsDir: Path, jobs: dict) -> None: """Process Lucide icons of a given spec and write output file.""" srcRepo = workDir / ICON_EXTRACT["lucide"] if not srcRepo.is_dir(): _downloadIconPack(workDir, "lucide") for file, job in jobs.items(): name: str = job["name"] log(f"[b]Processing:[e] {name}") icons: dict[str, ET.Element] = {} iconSrc = srcRepo / "icons" for key, icon in _loadMap("lucide").items(): iconFile = iconSrc / f"{icon}.svg" if iconFile.is_file(): svg = ET.fromstring(iconFile.read_text(encoding="utf-8")) ET.indent(svg, space="") svg.set("viewBox", "-1 -1 26 26") # Pad to match Material icons svg.set("fill", "none") svg.set("stroke", "#000000") svg.set("height", "128") svg.set("width", "128") icons[key] = svg else: log(f"[cr]Not Found:[e] {iconFile}") target = iconsDir / f"{file}.icons" _writeThemeFile(target, name, "Cole Bemis, Lucide Contributors", "ISC/MIT License", icons) log("") def processFluentUIIcons(workDir: Path, iconsDir: Path, jobs: dict) -> None: """Process Fluent UI icons of a given spec and write output file.""" srcRepo = workDir / ICON_EXTRACT["fluentui"] if not srcRepo.is_dir(): _downloadIconPack(workDir, "fluentui") listFluentUIIcons(workDir) for file, job in jobs.items(): name: str = job["name"] style: str = job["style"] log(f"[b]Processing:[e] {name}") icons: dict[str, ET.Element] = {} iconSrc = srcRepo / "assets" for key, icon in _loadMap("fluentui").items(): if ":" in icon: icon, _, select = icon.partition(":") else: select = style uname = icon.lower().replace(" ", "_") iconFile = None for size in (24, 28, 20, 32): if (sized := iconSrc / icon / "SVG" / f"ic_fluent_{uname}_{size}_{select}.svg").is_file(): iconFile = sized break if iconFile and iconFile.is_file(): svg = ET.fromstring(iconFile.read_text(encoding="utf-8")) for elem in svg.iter(): elem.attrib.pop("fill", None) svg.set("fill", "#000000") svg.set("height", "128") svg.set("width", "128") icons[key] = svg else: log(f"[cr]Not Found:[e] {icon} -> {iconFile}") target = iconsDir / f"{file}.icons" _writeThemeFile(target, name, "Microsoft", "MIT", icons) log("") def listFluentUIIcons(workDir: Path) -> None: """Write an HTML page listing all Fluent UI icons.""" srcRepo = workDir / ICON_EXTRACT["fluentui"] if not srcRepo.is_dir(): _downloadIconPack(workDir, "fluentui") cells = [] for folder in sorted((srcRepo / "assets").iterdir()): uname = folder.name.lower().replace(" ", "_") imgs = "" for style in ("regular", "filled"): iconFile = folder / "SVG" / f"ic_fluent_{uname}_24_{style}.svg" for size in (24, 28, 20, 32, 16, 48, 12, 10): if (sized := folder / "SVG" / f"ic_fluent_{uname}_{size}_{style}.svg").is_file(): iconFile = sized break if style == "regular" or iconFile.is_file(): imgs += f'' cells.append(f"
{imgs}
{folder.name}
") target = workDir / "fluentui.html" target.write_text( "\nFluent UI Icons\n" "\n" + "\n".join(cells) + "\n\n", encoding="utf-8", ) log(f"[cg]Wrote:[e] {len(cells)} icons to {target}") def main(args: argparse.Namespace) -> None: """Build icon themes entry point.""" log("") log("[b]Build Icon Themes[e]") log("[b]=================[e]") if args.work_dir: workDir = Path(args.work_dir).absolute() elif cacheDir := readEnvFile().get("CACHE_DIR"): workDir = Path(cacheDir).expanduser().absolute() / "icons" else: workDir = ROOT_DIR / "build_icons" workDir.mkdir(parents=True, exist_ok=True) log(f"Work Dir: {workDir}") log("") iconsDir = ROOT_DIR / "umbraedit" / "assets" / "icons" style = args.style if style in ("all", "default", "free", "material"): processMaterialIcons( workDir, iconsDir, { "material_rounded_thin": { "name": "Material Symbols - Rounded Thin", "style": "rounded", "filled": False, "weight": 200, }, "material_rounded_normal": { "name": "Material Symbols - Rounded", "style": "rounded", "filled": False, "weight": 400, }, "material_filled_thin": { "name": "Material Symbols - Filled Thin", "style": "rounded", "filled": True, "weight": 200, }, "material_filled_normal": { "name": "Material Symbols - Filled", "style": "rounded", "filled": True, "weight": 400, }, "material_sharp_thin": { "name": "Material Symbols - Sharp Thin", "style": "sharp", "filled": False, "weight": 200, }, "material_sharp_normal": { "name": "Material Symbols - Sharp", "style": "sharp", "filled": False, "weight": 400, }, }, ) if style in ("all", "default", "free", "lucide"): processLucide( workDir, iconsDir, { "lucide": { "name": "Lucide", }, }, ) if style in ("all", "default", "free", "fluentui"): processFluentUIIcons( workDir, iconsDir, { "fluentui_regular": { "name": "Fluent UI - Regular", "style": "regular", }, "fluentui_filled": { "name": "Fluent UI - Filled", "style": "filled", }, }, ) log("[cg]Done[e]") log("")