512 lines
15 KiB
Python
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 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'<img src="{quote(iconFile.relative_to(workDir).as_posix())}">'
|
|
cells.append(f"<div>{imgs}<br>{folder.name}</div>")
|
|
|
|
target = workDir / "fluentui.html"
|
|
target.write_text(
|
|
"<!DOCTYPE html>\n<html><head><meta charset='utf-8'><title>Fluent UI Icons</title>\n"
|
|
"<style>\n"
|
|
"body {background: #fff; display: grid; grid-template-columns: repeat(auto-fill, 140px); gap: 4px;}\n"
|
|
"div {border: 1px solid #000; height: 100px; padding: 4px; text-align: center; font-size: 11px;}\n"
|
|
"img {width: 48px; height: 48px; margin: 8px;}\n"
|
|
"</style></head><body>\n" + "\n".join(cells) + "\n</body></html>\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 / "novelwriter" / "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("")
|