Source code for jvconnected.ui.tools.fontawesome


from loguru import logger
import argparse
from typing import List, Dict, ClassVar, Optional, Iterable, Union, Tuple, Sequence
from pathlib import Path
from enum import Flag, auto
import json
import shutil
import subprocess
import shlex
import tempfile
import dataclasses
from dataclasses import dataclass, field

import PySide2
PYSIDE_DIR = Path(PySide2.__file__).parent

import httpx

from ruamel.yaml import YAML
yaml = YAML(typ='safe')

from jvconnected.ui import get_resource_filename
from jvconnected.ui.tools.qrc_utils import QRCDocument, QRCResource, QRCFile

# BASE_PATH = Path(__file__).parent.parent
RESOURCE_QRC = get_resource_filename('resources.qrc')
RESOURCE_SCRIPT = get_resource_filename('rc_resources.py')
ICON_QML_FILE = get_resource_filename('qml/Fonts/IconFontNames.qml')

RESOURCE_DIR = get_resource_filename('resources')
BASE_PATH = RESOURCE_DIR.parent
FONT_ROOT = RESOURCE_DIR / 'fonts'
ICON_ROOT = RESOURCE_DIR / 'icons'
METADATA_DIR = RESOURCE_DIR / 'fa_metadata'


[docs]class FaDownload(object): """Context manager to download a fontawesome archive to a temporary directory """ url: str = 'https://use.fontawesome.com/releases/v5.15.2/fontawesome-free-5.15.2-desktop.zip' """The url of the archive to download""" root: Optional[Path] = None """Root of the temporary directory. Will be ``None`` until the context is acquired """ archive_file: Optional[Path] = None """Path of the archive file once it has been downloaded""" archive_dir: Optional[Path] = None """The root of the extracted :attr:`archive_file`""" def __init__(self, url: Optional[str] = None): if url is not None: self.url = url def __enter__(self): self.archive_dir = Path(tempfile.mkdtemp()) self.archive_file = self.archive_dir / self.url.split('/')[-1] logger.debug(f'Downloading fontawesome to {self.archive_file}') with httpx.stream('GET', self.url) as r: with self.archive_file.open('wb') as fd: for chunk in r.iter_bytes(): fd.write(chunk) self.root = Path(tempfile.mkdtemp()) logger.debug(f'Unpacking to {self.root}') shutil.unpack_archive(self.archive_file, self.root) return self.root / self.archive_file.stem def __exit__(self, *args): if self.root is not None: logger.debug(f'Removing {self.root}') shutil.rmtree(self.root) self.root = None if self.archive_file is not None: logger.debug(f'Removing {self.archive_file}') self.archive_file.unlink() self.archive_file = None if self.archive_dir is not None: logger.debug(f'Removing {self.archive_dir}') self.archive_dir.rmdir() self.archive_dir = None
[docs]class Style(Flag): """Flags to indicate which styles are available for :class:`Icon` """ NONE = auto() """No value, used as a default""" BRANDS = auto() """Brands style""" REGULAR = auto() """Regular style""" SOLID = auto() """Solid style""" ALL = BRANDS | REGULAR | SOLID """All styles""" @classmethod def to_yaml(cls, representer, instance): names = [] for member in cls: if member in instance: names.append(member.name) return representer.represent_scalar(f'!{cls.__name__}', '|'.join(names)) @classmethod def from_yaml(cls, constructor, node): style = None for name in node.value.split('|'): _style = getattr(cls, name) if style is None: style = _style else: style |= _style if style is None: style = Style.NONE return style
[docs]@dataclass class Category: """Category assigned to :class:`Icon` """ name: str """Category name""" label: str """Category label""" icon_names: List[str] = field(default_factory=list) """The icon names in the category""" icons: Dict[str, 'Icon'] = field(default_factory=dict) """Mapping of icon names to :class:`Icon` instances""" yaml_tag: ClassVar[str] = '!Category' @classmethod def to_yaml(cls, representer, instance): d = dataclasses.asdict(instance) del d['icons'] return representer.represent_mapping(cls.yaml_tag, d) @classmethod def from_yaml(cls, constructor, node): kw = constructor.construct_mapping(node) return cls(**kw)
[docs]@dataclass class Icon: """An icon (svg) file """ name: str """Icon name""" label: str """Icon label""" code_point: str """Unicode value for the icon""" styles: Style = Style.NONE """Indicates the :class:`styles <Style>` available""" category_names: set = field(default_factory=set) """Names of :class:`Category` the icon belongs to""" # categories: Dict[str, Category] = field(default_factory=dict) yaml_tag: ClassVar[str] = '!Icon'
[docs] def add_to_category(self, category: Category): """Add the icon to the given :class:`Category` """ self.category_names.add(category.name) category.icons[self.name] = self
# self.categories[category.name] = category def iter_styles(self) -> Iterable[Style]: for style in Style: if style == Style.NONE or style == Style.ALL: continue if style not in self.styles: continue yield style
[docs] def get_svgs(self, icon_root: Path, styles: Optional[Style] = None) -> Iterable[Tuple[Style, Path]]: """Get icon svg filenames matching the given :class:`Style` flags. Arguments: icon_root: The directory containing all icon subdirectories (``'svgs'`` within the fontawesome root) styles: The :class:`Style` flags to filter by. If not given, the instance :attr:`styles` will be used. Yields ------ style : :class:`Style` style flag for the filename filename : :class:`pathlib.Path` The svg filename within ``icon_root`` """ if styles is None: styles = self.styles for style in self.iter_styles(): if style not in styles: continue fn = self.get_svg(icon_root, style) yield style, fn
[docs] def get_svg(self, icon_root: Path, style: Style) -> Path: """Get the icon svg filename with the given style Arguments: icon_root: The directory containing all icon subdirectories (``'svgs'`` within the fontawesome root) style: The style flag Raises: ValueError: if the given style is invalid (:attr:`Style.NONE` or :attr:`Style.ALL`) or the icon is not available in the style """ if style == Style.ALL or style == Style.NONE: raise ValueError('Invalid style flag') if not style & self.styles: raise ValueError(f'Style "{style}" not available for icon') return icon_root / style.name.lower() / f'{self.name}.svg'
[docs] def copy_to_icon_dir(self, icon_root: Path, dest: Path, styles: Optional[Style] = None) -> Iterable[Tuple[Style, Path]]: """Copy the icon svg file into the given destination, maintaining the relative sub-directories Arguments: icon_root: The directory containing all icon subdirectories (``'svgs'`` within the fontawesome root) dest: The destination directory styles: :class:`Style` flags to include (see :meth:`get_svgs`) Yields ------ style : :class:`Style` style flag for the filename filename : :class:`pathlib.Path` The svg filename within ``icon_root`` """ for style, src in self.get_svgs(icon_root, styles): assert style in self.styles pdir = ICON_ROOT / style.name.lower() dst = pdir / src.name if dst != src or not dst.exists(): if not pdir.exists(): pdir.mkdir(parents=True) shutil.copy2(src, dst) yield style, dst
@classmethod def to_yaml(cls, representer, instance): d = dataclasses.asdict(instance) return representer.represent_mapping(cls.yaml_tag, d) @classmethod def from_yaml(cls, constructor, node): kw = constructor.construct_mapping(node) return cls(**kw)
yaml.register_class(Style) yaml.register_class(Category) yaml.register_class(Icon) Categories = Dict[str, Category] Icons = Dict[str, Icon]
[docs]def parse_categories(metadata_dir: Path, icons: Dict[str, Icon]) -> Categories: """Parse icon categories from the fontawesome metadata Arguments: metadata_dir: The fontawesome metadata directory icons: Mapping of :class:`Icon` instances as provided by :func:`parse_icons` Returns: Mapping of :attr:`Category.name` to :class:`Category` """ p = metadata_dir / 'categories.yml' data = yaml.load(p) categories = {} for name, d in data.items(): category = Category(name=name, label=d['label'], icon_names=d['icons']) for icon_name in category.icon_names: icon = icons[icon_name] icon.add_to_category(category) categories[name] = category return categories
[docs]def parse_icons(metadata_dir: Path) -> Icons: """Parse icons from the fontawesome metadata Arguments: metadata_dir: The fontawesome metadata directory Returns: Mapping of :attr:`Icon.name` to :class:`Icon` """ p = metadata_dir / 'icons.yml' data = yaml.load(p) icons = {} for icon_name, d in data.items(): icon = Icon(name=icon_name, label=d['label'], code_point=d['unicode']) for st_name in d['styles']: icon.styles |= getattr(Style, st_name.upper()) icons[icon.name] = icon return icons
[docs]def parse_all(metadata_dir: Path) -> Tuple[Icons, Categories]: """Parse icons and categories using :func:`parse_icons` and :func:`parse_categories` Arguments: metadata_dir: The fontawesome metadata directory Returns ------- icons The parsed icons categories The parsed categories """ logger.debug('parsing metadata') icons = parse_icons(metadata_dir) categories = parse_categories(metadata_dir, icons) return icons, categories
[docs]def build_qml_names(icons: Icons, outfile: Path = ICON_QML_FILE, qtquick_version: str = '2.15'): """Generate a qml document mapping icon names to their :attr:`Icon.code_point` """ def dash_to_camel(s: str) -> str: s = s.lower() parts = s.split('-') parts = ''.join([part.title() for part in parts]) return f'fa{parts}' lines = [ 'pragma Singleton', f'import QtQuick {qtquick_version}', '', 'QtObject {', ' id: root', ] style_lines = {} for icon in icons.values(): name = dash_to_camel(icon.name) prop_line = f'readonly property string {name}: "\\u{icon.code_point}"' for style in icon.iter_styles(): style_name = style.name.lower() _lines = style_lines.get(style_name) if _lines is None: _lines = [ ' readonly property QtObject %s: QtObject {' % (style_name), f' id: {style_name}Obj', f' readonly property string name: "{style_name}"', ] style_lines[style_name] = _lines _lines.append(f' {prop_line}') for _lines in style_lines.values(): _lines.append(' }') lines.extend(_lines) lines.extend(['}', '']) outfile.write_text('\n'.join(lines))
[docs]@logger.catch def build_theme(fa_root: Path, theme_name: str, category_names: Optional[Sequence[str]] = None): """Copy and process fontawesome resources and prep them for the Qt Resouce System Arguments: fa_root: Root directory of the unpacked fontawesome archive (:attr:`FaDownload.archive_dir`) theme_name: The name of the `icon theme`_ to generate formatted according to the `freedesktop specification`_ category_names: A list of fontawesome categories to include. If not provided, use all available categories. .. _icon theme: https://doc.qt.io/qt-5.15/qtquickcontrols2-icons.html#icon-themes .. _freedesktop specification: http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html """ theme_root = RESOURCE_DIR metadata_src_dir = fa_root / 'metadata' icon_src_dir = fa_root / 'svgs' theme_root.mkdir(parents=True, exist_ok=True) icon_style = Style.ALL all_icons, all_categories = parse_all(metadata_src_dir) if category_names is not None: category_names = set(category_names) # if category_names is None: # categories = all_categories # else: # categories = {name:all_categories[name] for name in category_names} all_dirs = set() all_files = set() for license_file in fa_root.glob('LICENSE*'): dst = RESOURCE_DIR / license_file.name logger.debug(f'{license_file} -> {dst}') shutil.copy2(license_file, dst) if not METADATA_DIR.exists(): METADATA_DIR.mkdir() if METADATA_DIR != metadata_src_dir: for meta_src in metadata_src_dir.iterdir(): meta_dst = METADATA_DIR / meta_src.name logger.debug(f'{meta_src} -> {meta_dst}') shutil.copy2(meta_src, meta_dst) qrc_doc = QRCDocument.create(base_path=BASE_PATH) icon_resources = {} logger.debug('copying svgs') no_categories = {} for icon in all_icons.values(): if category_names is not None: matched_categories = category_names & icon.category_names if not len(matched_categories): continue # if not len(icon.category_names): # no_categories[icon.name] = icon # matched_categories = set(categories.keys()) & icon.category_names # if not len(matched_categories): # continue for style, p in icon.copy_to_icon_dir(icon_src_dir, icon_style): assert style != Style.ALL if p in all_files: continue style_name = style.name.lower() resource = icon_resources.get(style_name) if resource is None: resource = qrc_doc.add_child( tag='qresource', prefix=f'/icons/{style_name}', ) icon_resources[style_name] = resource all_files.add(p) resource.add_file(p, alias=p.name) print('\n'.join(no_categories.keys())) logger.debug(f'icon_resources: {icon_resources}') # Build index.theme lines = [ '[Icon Theme]', f'Name={theme_name}', 'Directories={}'.format(','.join(icon_resources.keys())), '', ] for style_name, resource in icon_resources.items(): lines.extend([ f'[{style_name}]', 'Size=48', 'Type=Scalable', 'MinSize=8', 'MaxSize=512', 'Context=Applications', '', ]) theme_file = ICON_ROOT / 'index.theme' theme_file.write_text('\n'.join(lines)) resource = qrc_doc.add_child(tag='qresource', prefix='/icons') resource.add_file(theme_file, alias=theme_file.name) # Copy fonts logger.debug('copying fonts') qrc_resource = qrc_doc.add_child(tag='qresource', prefix='/fonts') # fonts_src = fa_root / 'webfonts' fonts_src = fa_root / 'otfs' for font_src in fonts_src.iterdir(): if font_src.suffix != '.otf': continue font_dst = FONT_ROOT / font_src.name if not font_dst.parent.exists(): font_dst.parent.mkdir(parents=True) shutil.copy2(font_src, font_dst) qrc_resource.add_file(font_dst, alias=font_dst.name) # Build name/unicode map icon_code_points = {} for icon in all_icons.values(): icon_code_points[icon.name] = icon.code_point icon_map_file = FONT_ROOT / 'name-map.json' icon_map_file.write_text(json.dumps(icon_code_points)) qrc_resource.add_file(icon_map_file, alias=icon_map_file.name) # Generate qml definition file build_qml_names(all_icons) qrc_doc.write(RESOURCE_QRC)
def build_rcc(): rcc_bin = PYSIDE_DIR / 'rcc' cmd_str = f'{rcc_bin} -g python -o "{RESOURCE_SCRIPT}" "{RESOURCE_QRC}"' logger.debug(cmd_str) subprocess.run(shlex.split(cmd_str)) @logger.catch def main(**kwargs): rcc_only = kwargs.get('rcc_only', False) if rcc_only: qrc_file = QRCDocument.from_file(RESOURCE_QRC) else: with FaDownload() as fa_root: qrc_file = build_theme(fa_root, 'fa-icons') build_rcc() if __name__ == '__main__': p = argparse.ArgumentParser() # p.add_argument('-d', '--download', dest='download', action='store_true') p.add_argument('-r', '--rcc-only', dest='rcc_only', action='store_true') args = p.parse_args() main(**vars(args))