""" 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 . """ # 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_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", "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("")