Files
UmbraEdit/utils/icon_themes.py
2026-10-02 00:09:56 +02:00

512 lines
15 KiB
Python

"""
novelWriter - Icon Theme Utils
==============================
This file is a part of novelWriter
Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import argparse
import json
import subprocess
import urllib.request
import zipfile
from pathlib import Path
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")
# FontAwesome
# Repo: https://github.com/FortAwesome/Font-Awesome/
# Website: https://fontawesome.com/
# Remix
# Repo: https://github.com/Remix-Design/RemixIcon
# Website: https://remixicon.com/
# Lucide
# Repo: https://github.com/lucide-icons/lucide
# Website: https://lucide.dev/
ICON_SOURCES = {
"material": "https://github.com/google/material-design-icons.git",
"font_awesome": "https://github.com/FortAwesome/Font-Awesome/archive/refs/tags/7.3.1.zip",
"remix": "https://github.com/Remix-Design/RemixIcon/archive/refs/tags/v4.9.1.zip",
"lucide": "https://github.com/lucide-icons/lucide/archive/refs/tags/1.49.0.zip",
}
ICON_EXTRACT = {
"material": "material-design-icons",
"font_awesome": "Font-Awesome-7.3.1",
"remix": "RemixIcon-4.9.1",
"lucide": "lucide-1.49.0",
}
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_mention",
"cmn_general",
"cmn_note",
"cmn_story",
"prj_folder",
"prj_document",
"prj_title",
"prj_chapter",
"prj_scene",
"prj_note",
"fmt_bold",
"fmt_italic",
"fmt_mark",
"fmt_link",
"fmt_size",
"fmt_strike",
"fmt_subscript",
"fmt_superscript",
"fmt_underline",
"fmt_toolbar",
"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",
"lines",
"link",
"list",
"margin_bottom",
"margin_left",
"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",
]
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():
icon = ET.tostring(svg).decode().replace("\n", "")
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_symbols").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 processFontAwesome(workDir: Path, iconsDir: Path, jobs: dict) -> None:
"""Process Font Awesome icons of a given spec and write output file."""
srcRepo = workDir / ICON_EXTRACT["font_awesome"]
if not srcRepo.is_dir():
_downloadIconPack(workDir, "font_awesome")
for file, job in jobs.items():
name: str = job["name"]
log(f"[b]Processing:[e] {name}")
icons: dict[str, ET.Element] = {}
iconSrc = srcRepo / "svgs"
for key, value in _loadMap("font_awesome").items():
icon, _, forced = value.partition(":")
iconSolid = iconSrc / "solid" / f"{icon}.svg"
iconRegular = iconSrc / "regular" / f"{icon}.svg"
if forced == "regular":
iconFile = iconRegular
elif forced == "solid" or iconSolid.is_file():
iconFile = iconSolid
elif iconRegular.is_file():
iconFile = iconRegular
else:
log(f"[cr]Not Found:[e] {icon}.svg")
continue
if iconFile.is_file():
svg = ET.fromstring(iconFile.read_text(encoding="utf-8"))
viewbox = [int(x) for x in svg.get("viewBox", "").split()]
viewbox = [viewbox[2] // 2 - 256, 0, 512, 512]
for elem in svg.iter():
elem.attrib.pop("fill", None)
svg.set("viewBox", " ".join(str(x) for x in viewbox))
svg.set("fill", "#000000")
svg.set("height", "128")
svg.set("width", "128")
icons[key] = svg
else:
log(f"[cr]Not Found:[e] {icon}.svg")
continue
target = iconsDir / f"{file}.icons"
_writeThemeFile(target, name, "Fonticons Inc", "CC BY 4.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("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 processRemix(workDir: Path, iconsDir: Path, jobs: dict) -> None:
"""Process Remix icons of a given spec and write output file."""
srcRepo = workDir / ICON_EXTRACT["remix"]
if not srcRepo.is_dir():
_downloadIconPack(workDir, "remix")
for file, job in jobs.items():
name: str = job["name"]
style = "fill" if job["filled"] else "line"
log(f"[b]Processing:[e] {name}")
icons: dict[str, ET.Element] = {}
iconSrc = srcRepo / "icons"
iconGroups = [x for x in iconSrc.iterdir() if x.is_dir()]
for key, icon in _loadMap("remix").items():
if icon.endswith(("-line", "-fill")):
fileName = f"{icon}.svg"
else:
fileName = f"{icon}-{style}.svg"
for group in iconGroups:
iconFile = group / fileName
if iconFile.is_file():
break
else:
fileName = f"{icon}.svg"
for group in iconGroups:
iconFile = group / fileName
if iconFile.is_file():
break
else:
log(f"[cr]Not Found:[e] {fileName}")
continue
svg = ET.fromstring(iconFile.read_text(encoding="utf-8"))
svg.set("fill", "#000000")
svg.set("height", "128")
svg.set("width", "128")
icons[key] = svg
target = iconsDir / f"{file}.icons"
_writeThemeFile(target, name, "Remix Icon", "Apache 2.0", icons)
log("")
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 / "novelwriter" / "assets" / "icons"
style = args.style
if style in ("all", "default", "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", "optional", "free", "font_awesome"):
processFontAwesome(
workDir,
iconsDir,
{
"font_awesome": {
"name": "Font Awesome 7",
},
},
)
if style in ("all", "optional", "free", "lucide"):
processLucide(
workDir,
iconsDir,
{
"lucide": {
"name": "Lucide",
},
},
)
if style in ("all", "optional", "non-free", "remix"):
processRemix(
workDir,
iconsDir,
{
"remix_outline": {
"name": "Remix Icon - Outline",
"filled": False,
},
"remix_filled": {
"name": "Remix Icon - Filled",
"filled": True,
},
},
)
log("[cg]Done[e]")
log("")