429 lines
12 KiB
Python
429 lines
12 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
|
||
|
||
UTILS = Path(__file__).parent
|
||
ET.register_namespace("", "http://www.w3.org/2000/svg")
|
||
|
||
ICON_SOURCES = {
|
||
"material": "https://github.com/google/material-design-icons.git",
|
||
"font_awesome": "https://github.com/FortAwesome/Font-Awesome/archive/refs/tags/6.7.2.zip",
|
||
"remix": "https://github.com/Remix-Design/RemixIcon/archive/refs/tags/v4.6.0.zip",
|
||
}
|
||
ICON_EXTRACT = {
|
||
"material": "material-design-icons",
|
||
"font_awesome": "Font-Awesome-6.7.2",
|
||
"remix": "RemixIcon-4.6.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",
|
||
|
||
"prj_folder",
|
||
"prj_document",
|
||
"prj_title",
|
||
"prj_chapter",
|
||
"prj_scene",
|
||
"prj_note",
|
||
|
||
"fmt_bold",
|
||
"fmt_italic",
|
||
"fmt_mark",
|
||
"fmt_strike",
|
||
"fmt_subscript",
|
||
"fmt_superscript",
|
||
"fmt_underline",
|
||
"fmt_toolbar",
|
||
|
||
"search",
|
||
"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_details",
|
||
"sb_novel",
|
||
"sb_outline",
|
||
"sb_project",
|
||
"sb_search",
|
||
"sb_stats",
|
||
|
||
"theme_light",
|
||
"theme_dark",
|
||
"theme_auto",
|
||
|
||
"btn_ok",
|
||
"btn_cancel",
|
||
"btn_yes",
|
||
"btn_no",
|
||
"btn_open",
|
||
"btn_close",
|
||
"btn_browse",
|
||
"btn_list",
|
||
"btn_new",
|
||
"btn_create",
|
||
"btn_reset",
|
||
|
||
"add",
|
||
"bookmarks",
|
||
"browse",
|
||
"build_settings",
|
||
"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",
|
||
"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:
|
||
print(f"- Missing: {key}")
|
||
return icons
|
||
|
||
|
||
def _fixXml(svg: ET.Element) -> str:
|
||
"""Clean up the SVG XML and add needed fields."""
|
||
svg.set("fill", "#000000")
|
||
svg.set("height", "128")
|
||
svg.set("width", "128")
|
||
return ET.tostring(svg).decode()
|
||
|
||
|
||
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():
|
||
out.write(f"icon:{key:<15s} = {_fixXml(svg)}\n")
|
||
print(f"- Wrote: {len(icons)} icons")
|
||
print(f"- Target: {path.relative_to(UTILS.parent)}")
|
||
|
||
|
||
def _updateRepo(path: Path, name: str) -> None:
|
||
"""Clone or update a local repo of icons."""
|
||
print(f"Updating: {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)
|
||
print("")
|
||
|
||
|
||
def _downloadIconPack(path: Path, name: str) -> None:
|
||
"""Download and extract icon pack releases."""
|
||
print(f"Downloading: {ICON_SOURCES[name]}")
|
||
zipFile = path / f"{name}.zip"
|
||
urllib.request.urlretrieve(ICON_SOURCES[name], zipFile)
|
||
print(f"Extracting: {zipFile.name}")
|
||
with zipfile.ZipFile(zipFile, "r") as inFile:
|
||
inFile.extractall(path)
|
||
print("")
|
||
|
||
|
||
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 ""
|
||
|
||
print(f"Processing: {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():
|
||
icons[key] = ET.fromstring(iconFile.read_text(encoding="utf-8"))
|
||
else:
|
||
print(f"Not Found: {iconFile}")
|
||
|
||
target = iconsDir / f"{file}.icons"
|
||
_writeThemeFile(target, name, "Google Inc", "Apache 2.0", icons)
|
||
|
||
print("")
|
||
|
||
|
||
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"]
|
||
print(f"Processing: {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":
|
||
iconFile = iconSolid
|
||
elif iconSolid.is_file():
|
||
iconFile = iconSolid
|
||
elif iconRegular.is_file():
|
||
iconFile = iconRegular
|
||
else:
|
||
print(f"Not Found: {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]
|
||
svg.set("viewBox", " ".join(str(x) for x in viewbox))
|
||
for elem in svg.iter():
|
||
elem.attrib.pop("fill", None)
|
||
icons[key] = svg
|
||
else:
|
||
print(f"Not Found: {icon}.svg")
|
||
continue
|
||
|
||
target = iconsDir / f"{file}.icons"
|
||
_writeThemeFile(target, name, "Fonticons Inc", "CC BY 4.0", icons)
|
||
|
||
print("")
|
||
|
||
|
||
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"
|
||
|
||
print(f"Processing: {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:
|
||
print(f"Not Found: {fileName}")
|
||
continue
|
||
|
||
icons[key] = ET.fromstring(iconFile.read_text(encoding="utf-8"))
|
||
|
||
target = iconsDir / f"{file}.icons"
|
||
_writeThemeFile(target, name, "Remix Icon", "Apache 2.0", icons)
|
||
|
||
print("")
|
||
|
||
|
||
def main(args: argparse.Namespace) -> None:
|
||
"""Build icon themes entry point."""
|
||
print("")
|
||
print("Build Icon Themes")
|
||
print("=================")
|
||
print("")
|
||
|
||
workDir = Path(args.work_dir).absolute()
|
||
workDir.mkdir(exist_ok=True)
|
||
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 6",
|
||
},
|
||
})
|
||
|
||
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,
|
||
},
|
||
})
|
||
|
||
print("Done")
|
||
print("")
|