Source code for msl.qt.convert

"""
Functions to convert objects.
"""
import ctypes
import os
import sys
import textwrap
from math import fabs
from math import floor
from math import isinf
from math import isnan
from math import log10

try:
    import clr  # noqa: clr not listed in project requirements
except (ImportError, RuntimeError):
    # A RuntimeError will occur if pythonnet is installed,
    # but there is no .NET runtime installed
    clr = None

from . import Qt
from . import QtCore
from . import QtGui
from . import QtWidgets
from . import application
from . import binding
from .characters import MICRO
from .constants import SI_PREFIX_MAP

__all__ = (
    'icon_to_base64',
    'number_to_si',
    'print_base64',
    'rescale_icon',
    'si_to_number',
    'to_qcolor',
    'to_qfont',
    'to_qicon',
)


[docs]def to_qfont(*args): """Convert the input argument(s) into a :class:`QtGui.QFont`. Parameters ---------- args The argument(s) to convert to a :class:`QtGui.QFont`. * If :class:`int` or :class:`float` then the point size. * If :class:`str` then the font family name(s). * If :class:`QtGui.QFont` then returns a copy. * If multiple arguments then - family name(s), point size - family name(s), point size, weight - family name(s), point size, weight, is italic Returns ------- :class:`QtGui.QFont` The input argument(s) converted to a :class:`QtGui.QFont`. Examples -------- >>> font = to_qfont(48) >>> font = to_qfont(23.4) >>> font = to_qfont('Ariel') >>> font = to_qfont('Ariel', 16) >>> font = to_qfont('Ariel', 16, QtGui.QFont.Weight.Bold) >>> font = to_qfont('Ariel', 16, 50, True) If you are using Qt 6.1+ then you can specify multiple family names >>> font = to_qfont('Ariel', 'Papyrus') >>> font = to_qfont('Ariel', 'Papyrus', 'Helvetica [Cronyx]', 16) >>> font = to_qfont('Ariel', 'Helvetica', 16, QtGui.QFont.Weight.Bold) >>> font = to_qfont('Ariel', 'Papyrus', 'Times', 16, QtGui.QFont.Weight.Bold, True) """ if not args: return QtGui.QFont() def filter_families(options=None): # as of version 6.1 the QFont constructor uses # QStringList &families # instead of # QString &family if options is None: options = args families = [] rest = [] for option in options: if isinstance(option, str): families.append(option) else: rest.append(option) if families and binding.qt_version_info[:2] < (6, 1): return families[0], rest return families, rest def parse_tuple(a): families, rest = filter_families(a) if not families: raise TypeError('The first argument(s) must be family name(s)') if not rest: return QtGui.QFont(families) elif len(rest) == 1: return QtGui.QFont(families, pointSize=int(rest[0])) elif len(rest) == 2: return QtGui.QFont(families, pointSize=int(rest[0]), weight=int(rest[1])) else: return QtGui.QFont(families, pointSize=int(rest[0]), weight=int(rest[1]), italic=bool(rest[2])) if len(args) == 1: value = args[0] if isinstance(value, QtGui.QFont): return QtGui.QFont(value) elif isinstance(value, int): f = QtGui.QFont() f.setPointSize(value) return f elif isinstance(value, float): f = QtGui.QFont() f.setPointSizeF(value) return f elif isinstance(value, str): return QtGui.QFont(filter_families()[0]) elif isinstance(value, (list, tuple)): return parse_tuple(value) else: raise TypeError(f'Cannot create a QFont from {value!r}') else: return parse_tuple(args)
[docs]def to_qcolor(*args): """Convert the input argument(s) into a :class:`QtGui.QColor`. Parameters ---------- args The argument(s) to convert to a :class:`QtGui.QColor`. * R, G, B, [A] :math:`\\rightarrow` values can be :class:`int` 0-255 or :class:`float` 0.0-1.0 * (R, G, B, [A]) :math:`\\rightarrow` :class:`tuple` of :class:`int` 0-255 or :class:`float` 0.0-1.0 * :class:`int` or :obj:`QtCore.Qt.GlobalColor` :math:`\\rightarrow` a pre-defined enum value * :class:`float` :math:`\\rightarrow` a greyscale value between 0.0-1.0 * :class:`QtGui.QColor` :math:`\\rightarrow` returns a copy * :class:`str` :math:`\\rightarrow` see `here <https://doc.qt.io/qt-6/qcolor.html#fromString>`_ for examples Returns ------- :class:`QtGui.QColor` The input argument(s) converted to a :class:`QtGui.QColor`. Examples -------- >>> color = to_qcolor(48, 127, 69) >>> color = to_qcolor((48, 127, 69)) >>> color = to_qcolor(0.5) # greyscale -> (127, 127, 127, 255) >>> color = to_qcolor(0.2, 0.45, 0.3, 0.5) >>> color = to_qcolor('red') >>> color = to_qcolor(Qt.GlobalColor.darkBlue) >>> color = to_qcolor(15) # 15 == Qt.GlobalColor.darkBlue """ if not args: return QtGui.QColor() def ensure_255(value): # ensure that a value is between 0 and 255 if value <= 1 and isinstance(value, float): value = int(value * 255) return min(max(value, 0), 255) if len(args) == 1: arg = args[0] if isinstance(arg, str): return QtGui.QColor(arg) elif isinstance(arg, QtGui.QColor): return QtGui.QColor(arg) elif isinstance(arg, (list, tuple)): return QtGui.QColor(*tuple(ensure_255(v) for v in arg)) elif isinstance(arg, float): val = ensure_255(arg) return QtGui.QColor(val, val, val) elif isinstance(arg, (int, Qt.GlobalColor)): return QtGui.QColor(Qt.GlobalColor(arg)) else: raise TypeError(f'Cannot convert {args!r} to a QColor') else: return QtGui.QColor(*tuple(ensure_255(v) for v in args))
[docs]def number_to_si(number, unicode=True): """Convert a number to be represented with an SI prefix. The hecto (h), deka (da), deci (d) and centi (c) prefixes are not used. Parameters ---------- number : :class:`int` or :class:`float` The number to convert. unicode : :class:`bool`, optional Whether to use the unicode \u00B5 symbol for micro. Returns ------- :class:`float` The number rescaled. :class:`str` The SI prefix. Examples -------- >>> number_to_si(0.0123) (12.3, 'm') >>> number_to_si(123456.789) (123.456789, 'k') >>> number_to_si(712.123e14) (71.2123, 'P') >>> number_to_si(1.23e-13) (123.0, 'f') """ if isnan(number) or isinf(number) or number == 0: return number, '' n = int(floor(log10(fabs(number)) / 3)) if n == 0: return number, '' if unicode and n == -2: si = MICRO else: try: si = SI_PREFIX_MAP[n] except KeyError: if n < -10: si, n = 'q', -10 elif n > 10: si, n = 'Q', 10 else: assert False, 'Should never get here' return number * 10 ** (-3 * n), si
[docs]def si_to_number(string): """Convert a string with an SI prefix to a number. Parameters ---------- string : :class:`str` The string to convert. Returns ------- :class:`float` The number. Examples -------- >>> si_to_number('12.3m') 0.0123 >>> si_to_number('123.456789k') 123456.789 >>> si_to_number('71.2123P') 7.12123e+16 >>> si_to_number('123f') 1.23e-13 """ string_ = string.strip() if not string_ or string_ == 'nan' or string_.endswith('inf'): # let the builtin implementation handle an empty string # nan would be mistaken for the nano (n) SI prefix # +/-inf would be mistaken for the femto (f) SI prefix return float(string_) prefix = string_[-1] if prefix == MICRO: prefix = 'u' for n, value in SI_PREFIX_MAP.items(): if prefix == value: return float(string_[:-1]) * 10 ** (3 * n) return float(string_)
[docs]def to_qicon(obj, *, size=None, aspect_mode=Qt.AspectRatioMode.KeepAspectRatio): """Convert the input object to a :class:`QtGui.QIcon`. Parameters ---------- obj The object to be converted to a :class:`QtGui.QIcon`. The data type of `obj` can be one of: * :class:`QtGui.QIcon` * :class:`QtGui.QPixmap` * :class:`QtGui.QImage` * `QtWidgets.QStyle.StandardPixmap <https://doc.qt.io/qt-6/qstyle.html#StandardPixmap-enum>`_: One of the built-in Qt pixmaps. Example:: to_qicon(QtWidgets.QStyle.SP_TitleBarMenuButton) to_qicon(14) # the QtWidgets.QStyle.SP_TrashIcon enum value * :class:`QtCore.QByteArray`: A `Base64 <https://en.wikipedia.org/wiki/Base64>`_ representation of an encoded icon. See :func:`icon_to_base64`. * :class:`str`: The path to an icon file or an icon embedded in a DLL or EXE file. If `obj` is a path to an icon file and only the filename is specified then the directories in :data:`sys.path` and :data:`os.environ['PATH'] <os.environ>` are also used to search for the icon file. If `obj` refers to an icon in a Windows DLL/EXE file then `obj` is the path to the DLL/EXE file and the icon index separated by the ``|`` character. The following examples illustrate the various ways to request an icon by passing in a :class:`str` argument:: # provide the full path to the icon file to_qicon('D:/code/resources/icons/msl.png') to_qicon('D:/code/resources/icons/photon.png') # insert the folder where the icons are located in to sys.path sys.path.insert(0, 'D:/code/resources/icons/') # so now only the filename needs to be specified to load the icon to_qicon('msl.png') to_qicon('photon.png') # load icon 23 from the Windows shell32.dll file to_qicon('C:/Windows/System32/shell32.dll|23') # load icon 0 from the Windows explorer.exe file to_qicon('C:/Windows/explorer.exe|0') # it is assumed that the DLL/EXE file is located in a default directory: # - a DLL file in C:/Windows/System32/ # - an EXE file in C:/Windows/ # so the following is a simplified way to load an icon in a DLL file to_qicon('shell32|23') to_qicon('imageres|1') to_qicon('compstui|51') # and the following is a simplified way to load an icon in an EXE file to_qicon('explorer|0') size : :class:`int`, :class:`float`, :class:`tuple` of :class:`int` or :class:`QtCore.QSize`, optional Rescale the icon to the specified `size`. If the value is :data:`None` then do not rescale the icon. If an :class:`int` then set the width and the height to be the `size` value. If a :class:`float` then a scaling factor. If a :class:`tuple` then the (width, height) values. aspect_mode : :attr:`QtCore.Qt.AspectRatioMode`, optional How to maintain the aspect ratio if rescaling. The default mode is to keep the aspect ratio. Returns ------- :class:`QtGui.QIcon` The input object converted to a :class:`QtGui.QIcon`. Raises ------ OSError If the icon cannot be found. TypeError If the data type of `obj` or `size` is not supported. Example ------- To view the standard icons that come with Qt and that come with Windows run:: >>> from msl.examples.qt import ShowStandardIcons # doctest: +SKIP >>> ShowStandardIcons() # doctest: +SKIP """ app = application() # noqa: local varibale app is not used _icon = None if isinstance(obj, QtGui.QIcon): _icon = obj elif isinstance(obj, str): if '|' in obj: # then loading an icon from a Windows DLL/EXE file _icon = to_qicon(icon_to_base64(obj)) elif os.path.isfile(obj): _icon = QtGui.QIcon(obj) else: search_paths = sys.path + os.environ['PATH'].split(os.pathsep) for path in search_paths: full_path = os.path.join(path, obj) if os.path.isfile(full_path): _icon = QtGui.QIcon(full_path) break if _icon is None: raise OSError(f'Cannot find icon file {obj!r}') elif isinstance(obj, QtWidgets.QStyle.StandardPixmap): app = application() _icon = QtGui.QIcon(app.style().standardIcon(obj)) elif isinstance(obj, int): std_icons = [value for name, value in vars(QtWidgets.QStyle).items() if name.startswith('SP_')] if binding.name == 'PyQt6': std_icons = [i.value for i in std_icons] if obj in std_icons: app = application() _icon = QtGui.QIcon(app.style().standardIcon(QtWidgets.QStyle.StandardPixmap(obj))) else: raise OSError(f'Invalid QStyle.StandardPixmap enum value of {obj}') elif isinstance(obj, QtGui.QPixmap): _icon = QtGui.QIcon(obj) elif isinstance(obj, QtGui.QImage): _icon = QtGui.QIcon(QtGui.QPixmap.fromImage(obj)) elif isinstance(obj, (bytes, bytearray, QtCore.QByteArray)): img = QtGui.QImage() img.loadFromData(QtCore.QByteArray.fromBase64(obj)) _icon = QtGui.QIcon(QtGui.QPixmap.fromImage(img)) if _icon is None: raise TypeError(f'Icon object has unsupported data type {type(obj)}') if size is None: return _icon return QtGui.QIcon(rescale_icon(_icon, size, aspect_mode=aspect_mode))
[docs]def icon_to_base64(icon, *, fmt='png'): """Convert an icon to a :class:`QtCore.QByteArray` encoded as Base64_. This function is useful if you want to save icons in a database, use it in a data URI scheme_, or if you want to use icons in your GUI and rather than loading icons from a file on the hard disk you define your icons in a Python module as Base64_ variables. Loading the icons from the hard disk means that you must also distribute the icons with your Python code if you share your code. .. _Base64: https://en.wikipedia.org/wiki/Base64 .. _scheme: https://en.wikipedia.org/wiki/Data_URI_scheme Parameters ---------- icon An icon with a data type that is handled by :func:`to_qicon`. fmt : :class:`str`, optional The icon format to use when converting. The supported values are: ``BMP``, ``JPG``, ``JPEG`` and ``PNG``. Returns ------- :class:`QtCore.QByteArray` The Base64_ representation of the icon. Raises ------ OSError If the icon file cannot be found. ValueError If the icon format, `fmt`, to use for converting is not supported. """ fmt = fmt.upper() if fmt not in ['BMP', 'JPG', 'JPEG', 'PNG']: raise ValueError(f'Invalid format {fmt!r}. Must be one of: BMP, JPG, JPEG, PNG') if isinstance(icon, str) and '|' in icon: # extract an icon from a Windows DLL/EXE file # uses ctypes and the .NET Framework to convert the icon to base64 # import here in case pythonnet is not installed if clr is None: raise ImportError('requires pythonnet, run: pip install pythonnet') clr.AddReference('System.Drawing') # noqa: clr.AddReference import System # noqa from System.Drawing.Imaging import ImageFormat # noqa shell32 = ctypes.windll.shell32 img_fmts = { 'BMP': ImageFormat.Bmp, 'JPG': ImageFormat.Jpeg, 'JPEG': ImageFormat.Jpeg, 'PNG': ImageFormat.Png, } s = icon.split('|') path = s[0] icon_index = int(s[1]) if icon_index < 0: raise OSError('The icon index must be >= 0') if not os.path.isfile(path): err_msg = f'Cannot find DLL/EXE file {s[0]!r}' if os.path.split(path)[0]: # then it wasn't just the filename that was specified raise OSError(err_msg) filename = os.path.splitext(os.path.basename(path))[0] path = f'C:/Windows/System32/{filename}.dll' if not os.path.isfile(path): path = f'C:/Windows/{filename}.exe' if not os.path.isfile(path): raise OSError(err_msg) # extract the handle to the "large" icon path_ptr = ctypes.c_char_p(path.encode()) handle_large = ctypes.c_int() res = shell32.ExtractIconExA(path_ptr, icon_index, ctypes.byref(handle_large), ctypes.c_void_p(), 1) if res != 1: max_index = shell32.ExtractIconExA(path_ptr, -1, ctypes.c_void_p(), ctypes.c_void_p(), 0) - 1 raise OSError(f'Requested icon {icon_index}, the maximum icon index allowed is {max_index}') # get the icon bitmap and convert it to base64 handle = System.Int32(handle_large.value) handle_ptr = System.IntPtr.op_Explicit(handle) bmp = System.Drawing.Bitmap.FromHicon(handle_ptr) stream = System.IO.MemoryStream() bmp.Save(stream, img_fmts[fmt]) base = QtCore.QByteArray(System.Convert.ToBase64String(stream.GetBuffer()).encode()) # clean up ctypes.windll.user32.DestroyIcon(handle_large) stream.Dispose() return base icon = to_qicon(icon) try: size = icon.availableSizes()[-1] # use the largest size as the default size except IndexError: raise ValueError('Cannot determine a size of the QIcon.') from None pixmap = icon.pixmap(size) array = QtCore.QByteArray() buffer = QtCore.QBuffer(array) buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buffer, fmt) buffer.close() return array.toBase64()
[docs]def rescale_icon(icon, size, *, aspect_mode=Qt.AspectRatioMode.KeepAspectRatio): """Rescale an icon. Parameters ---------- icon Any object that is supported by :func:`to_qicon`. size : :class:`int`, :class:`float`, :class:`tuple` of :class:`int` or :class:`QtCore.QSize` Rescale the icon to the specified `size`. If an :class:`int` then set the width and the height to be the `size` value. If a :class:`float` then a scaling factor. If a :class:`tuple` then the (width, height) values. aspect_mode : :attr:`QtCore.Qt.AspectRatioMode`, optional How to maintain the aspect ratio if rescaling. The default mode is to keep the aspect ratio. Returns ------- :class:`QtGui.QPixmap` The rescaled icon. """ if isinstance(icon, QtGui.QIcon): try: pixmap = icon.pixmap(icon.availableSizes()[-1]) except IndexError: raise ValueError('Cannot automatically determine a size from the QIcon. Specify the size.') from None elif isinstance(icon, QtGui.QPixmap): pixmap = icon else: return rescale_icon(to_qicon(icon), size, aspect_mode=aspect_mode) default_size = pixmap.size() if isinstance(size, int): size = QtCore.QSize(size, size) elif isinstance(size, float): size = QtCore.QSize(int(default_size.width()*size), int(default_size.height()*size)) elif isinstance(size, (list, tuple)): if len(size) != 2: raise ValueError('The size must be in the form (width, height)') size = QtCore.QSize(size[0], size[1]) elif not isinstance(size, QtCore.QSize): raise TypeError(f'Unsupported "size" type of {type(size)!r}') if (size.width() != default_size.width()) or (size.height() != default_size.height()): # PyQt uses aspectRatioMode as a kwarg (this is what the Qt docs indicate) however # PySide uses aspectMode. Therefore, don't specify a kwarg but use a positional # argument since both APIs have the aspect ratio mode as the second argument if # the first argument is a QSize pixmap = pixmap.scaled(size, aspect_mode) return pixmap