| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- # -----------------------------------------------------------------------------
- # Copyright (c) 2005-2023, PyInstaller Development Team.
- #
- # Distributed under the terms of the GNU General Public License (version 2
- # or later) with exception for distributing the bootloader.
- #
- # The full license is in the file COPYING.txt, distributed with this software.
- #
- # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
- # -----------------------------------------------------------------------------
- import io
- import os
- import re
- import struct
- import pathlib
- from PyInstaller import log as logging
- from PyInstaller.archive.writers import SplashWriter
- from PyInstaller.building import splash_templates
- from PyInstaller.building.datastruct import Target
- from PyInstaller.building.utils import _check_guts_eq, _check_guts_toc, misc
- from PyInstaller.compat import is_aix, is_darwin
- from PyInstaller.depend import bindepend
- logger = logging.getLogger(__name__)
- class Splash(Target):
- """
- Bundles the required resources for the splash screen into a file, which will be included in the CArchive.
- A Splash has two outputs, one is itself and one is stored in splash.binaries. Both need to be passed to other
- build targets in order to enable the splash screen.
- """
- def __init__(self, image_file, binaries, datas, **kwargs):
- """
- :param str image_file:
- A path-like object to the image to be used. Only the PNG file format is supported.
- .. note:: If a different file format is supplied and PIL (Pillow) is installed, the file will be converted
- automatically.
- .. note:: *Windows*: The color ``'magenta'`` / ``'#ff00ff'`` must not be used in the image or text, as it is
- used by splash screen to indicate transparent areas. Use a similar color (e.g., ``'#ff00fe'``) instead.
- .. note:: If PIL (Pillow) is installed and the image is bigger than max_img_size, the image will be resized
- to fit into the specified area.
- :param list binaries:
- The TOC list of binaries the Analysis build target found. This TOC includes all extension modules and their
- binary dependencies. This is required to determine whether the user's program uses `tkinter`.
- :param list datas:
- The TOC list of data the Analysis build target found. This TOC includes all data-file dependencies of the
- modules. This is required to check if all splash screen requirements can be bundled.
- :keyword text_pos:
- An optional two-integer tuple that represents the origin of the text on the splash screen image. The
- origin of the text is its lower left corner. A unit in the respective coordinate system is a pixel of the
- image, its origin lies in the top left corner of the image. This parameter also acts like a switch for
- the text feature. If omitted, no text will be displayed on the splash screen. This text will be used to
- show textual progress in onefile mode.
- :type text_pos: Tuple[int, int]
- :keyword text_size:
- The desired size of the font. If the size argument is a positive number, it is interpreted as a size in
- points. If size is a negative number, its absolute value is interpreted as a size in pixels. Default: ``12``
- :type text_size: int
- :keyword text_font:
- An optional name of a font for the text. This font must be installed on the user system, otherwise the
- system default font is used. If this parameter is omitted, the default font is also used.
- :keyword text_color:
- An optional color for the text. HTML color codes (``'#40e0d0'``) and color names (``'turquoise'``) are
- supported. Default: ``'black'``
- (Windows: the color ``'magenta'`` / ``'#ff00ff'`` is used to indicate transparency, and should not be used)
- :type text_color: str
- :keyword text_default:
- The default text which will be displayed before the extraction starts. Default: ``"Initializing"``
- :type text_default: str
- :keyword full_tk:
- By default Splash bundles only the necessary files for the splash screen (some tk components). This
- options enables adding full tk and making it a requirement, meaning all tk files will be unpacked before
- the splash screen can be started. This is useful during development of the splash screen script.
- Default: ``False``
- :type full_tk: bool
- :keyword minify_script:
- The splash screen is created by executing an Tcl/Tk script. This option enables minimizing the script,
- meaning removing all non essential parts from the script. Default: ``True``
- :keyword name:
- An optional alternative filename for the .res file. If not specified, a name is generated.
- :type name: str
- :keyword script_name:
- An optional alternative filename for the Tcl script, that will be generated. If not specified, a name is
- generated.
- :type script_name: str
- :keyword max_img_size:
- Maximum size of the splash screen image as a tuple. If the supplied image exceeds this limit, it will be
- resized to fit the maximum width (to keep the original aspect ratio). This option can be disabled by
- setting it to None. Default: ``(760, 480)``
- :type max_img_size: Tuple[int, int]
- :keyword always_on_top:
- Force the splashscreen to be always on top of other windows. If disabled, other windows (e.g., from other
- applications) can cover the splash screen by user bringing them to front. This might be useful for
- frozen applications with long startup times. Default: ``True``
- :type always_on_top: bool
- :keyword center:
- Splash screen centering mode: ``'default'``, ``'primary'``, ``'virtual'``, or ``'active'``. In the default
- mode, the splash screen script computes the position using screen dimensions obtained via Tk's ``winfo``
- command, which has platform-specific behavior in multi-monitor setups; on Windows, it seems to return the
- size of the primary monitor, while on other platforms, it seems to return the size of the whole virtual
- screen. In other modes, the bootloader attempts to query the target screen size (and position) using
- platform-specific low-level API, and supply the information to splash screen script; in ``primary`` mode,
- the size of primary screen is queried, and in ``virtual`` mode, the size of whole virtual screen is queried.
- The ``active`` mode is supported only on Windows; the bootloader attempts to obtain position of mouse cursor
- at the time when application is launched, and queries the size (and position) of the corresponding screen.
- If the required information cannot be obtained and exposed by the bootloader, the splash screen script
- falls back to the information provided by the ``winfo`` command. Default: ``'default'``
- :type center: str
- """
- from PyInstaller.config import CONF
- from PyInstaller.utils.hooks.tcl_tk import tcltk_info
- Target.__init__(self)
- # Splash screen is not supported on macOS. It operates in a secondary thread and macOS disallows UI operations
- # in any thread other than main.
- if is_darwin:
- raise SystemExit("ERROR: Splash screen is not supported on macOS.")
- # Ensure tkinter (and thus Tcl/Tk) is available.
- if not tcltk_info.available:
- raise SystemExit(
- "ERROR: Your platform does not support the splash screen feature, since tkinter is not installed. "
- "Please install tkinter and try again."
- )
- # Check if the Tcl/Tk version is supported.
- logger.info("Verifying Tcl/Tk compatibility with splash screen requirements")
- self._check_tcl_tk_compatibility(tcltk_info)
- # Make image path relative to .spec file
- if not os.path.isabs(image_file):
- image_file = os.path.join(CONF['specpath'], image_file)
- image_file = os.path.normpath(image_file)
- if not os.path.exists(image_file):
- raise ValueError("Image file '%s' not found" % image_file)
- # Copy all arguments
- self.image_file = image_file
- self.full_tk = kwargs.get("full_tk", False)
- self.name = kwargs.get("name", None)
- self.script_name = kwargs.get("script_name", None)
- self.minify_script = kwargs.get("minify_script", True)
- self.max_img_size = kwargs.get("max_img_size", (760, 480))
- self.center = kwargs.get("center", "default").lower()
- if self.center not in self._CENTER_MODES:
- valid_modes = list(self._CENTER_MODES.keys())
- raise ValueError(f"Invalid center mode {self.center!r}! Must be one of: {valid_modes!r}")
- # text options
- self.text_pos = kwargs.get("text_pos", None)
- self.text_size = kwargs.get("text_size", 12)
- self.text_font = kwargs.get("text_font", "TkDefaultFont")
- self.text_color = kwargs.get("text_color", "black")
- self.text_default = kwargs.get("text_default", "Initializing")
- # always-on-top behavior
- self.always_on_top = kwargs.get("always_on_top", True)
- # Save the generated file separately so that it is not necessary to generate the data again and again
- root = os.path.splitext(self.tocfilename)[0]
- if self.name is None:
- self.name = root + '.res'
- if self.script_name is None:
- self.script_name = root + '_script.tcl'
- # Internal variables
- # Store path to _tkinter extension module, so that guts check can detect if the path changed for some reason.
- self._tkinter_file = tcltk_info.tkinter_extension_file
- # Calculated / analysed values
- self.uses_tkinter = self._uses_tkinter(self._tkinter_file, binaries)
- logger.debug("Program uses tkinter: %r", self.uses_tkinter)
- self.script = self.generate_script()
- self.tcl_lib = tcltk_info.tcl_shared_library # full path to shared library
- self.tk_lib = tcltk_info.tk_shared_library
- assert self.tcl_lib is not None
- assert self.tk_lib is not None
- logger.debug("Using Tcl shared library: %r", self.tcl_lib)
- logger.debug("Using Tk shared library: %r", self.tk_lib)
- self.splash_requirements = set([
- # NOTE: the implicit assumption here is that Tcl and Tk shared library are collected into top-level
- # application directory, which, at tme moment, is true in practically all cases.
- os.path.basename(self.tcl_lib),
- os.path.basename(self.tk_lib),
- # The list of requirements below is based on the current implementation of splash screen script. If you want
- # to extend the splash screen functionality and run into Tcl/Tk errors, chances are that additional Tk
- # components need to be added here.
- #
- # NOTE: these paths use the *destination* layout for Tcl/Tk scripts, which uses unversioned tcl and tk
- # directories (see `PyInstaller.utils.hooks.tcl_tk.collect_tcl_tk_files`).
- os.path.join(tcltk_info.TCL_ROOTNAME, "init.tcl"),
- # Core Tk
- os.path.join(tcltk_info.TK_ROOTNAME, "license.terms"),
- os.path.join(tcltk_info.TK_ROOTNAME, "text.tcl"),
- os.path.join(tcltk_info.TK_ROOTNAME, "tk.tcl"),
- # Used for customizable font
- os.path.join(tcltk_info.TK_ROOTNAME, "ttk", "ttk.tcl"),
- os.path.join(tcltk_info.TK_ROOTNAME, "ttk", "fonts.tcl"),
- os.path.join(tcltk_info.TK_ROOTNAME, "ttk", "cursors.tcl"),
- os.path.join(tcltk_info.TK_ROOTNAME, "ttk", "utils.tcl"),
- ])
- if tcltk_info.tk_version >= (9, 0):
- self.splash_requirements.update([
- os.path.join(tcltk_info.TK_ROOTNAME, "scaling.tcl"),
- os.path.join(tcltk_info.TK_ROOTNAME, "tclIndex"), # required for auto-load of scaling.tcl
- ])
- logger.info("Collect Tcl/Tk data files for the splash screen")
- tcltk_tree = tcltk_info.data_files # 3-element tuple TOC
- if self.full_tk:
- # The user wants a full copy of Tk, so make all Tk files a requirement.
- self.splash_requirements.update(entry[0] for entry in tcltk_tree)
- # Scan for binary dependencies of the Tcl/Tk shared libraries, and add them to `binaries` TOC list (which
- # should really be called `dependencies` as it is not limited to binaries. But it is too late now, and
- # existing spec files depend on this naming). We specify these binary dependencies (which include the
- # Tcl and Tk shared libraries themselves) even if the user's program uses tkinter and they would be collected
- # anyway; let the collection mechanism deal with potential duplicates.
- tcltk_libs = [(os.path.basename(src_name), src_name, 'BINARY') for src_name in (self.tcl_lib, self.tk_lib)]
- self.binaries = bindepend.binary_dependency_analysis(tcltk_libs)
- # Put all shared library dependencies in `splash_requirements`, so they are made available in onefile mode.
- self.splash_requirements.update(entry[0] for entry in self.binaries)
- # If the user's program does not use tkinter, add resources from Tcl/Tk tree to the dependencies list.
- # Do so only for the resources that are part of splash requirements.
- if not self.uses_tkinter:
- self.binaries.extend(entry for entry in tcltk_tree if entry[0] in self.splash_requirements)
- # Check if all requirements were found.
- collected_files = set(entry[0] for entry in (binaries + datas + self.binaries))
- def _filter_requirement(filename):
- if filename not in collected_files:
- # Item is not bundled, so warn the user about it. This actually may happen on some tkinter installations
- # that are missing the license.terms file - as this file has no effect on operation of splash screen,
- # suppress the warning for it.
- if os.path.basename(filename) == 'license.terms':
- return False
- logger.warning(
- "The local Tcl/Tk installation is missing the file %s. The behavior of the splash screen is "
- "therefore undefined and may be unsupported.", filename
- )
- return False
- return True
- # Remove all files which were not found.
- self.splash_requirements = set(filter(_filter_requirement, self.splash_requirements))
- logger.debug("Splash Requirements: %s", self.splash_requirements)
- # On AIX, the Tcl and Tk shared libraries might in fact be ar archives with shared object inside it, and need to
- # be `dlopen`'ed with full name (for example, `libtcl.a(libtcl.so.8.6)` and `libtk.a(libtk.so.8.6)`. So if the
- # library's suffix is .a, adjust the name accordingly, assuming fixed format for the shared object name.
- # Adjust the names at the end of this method, because preceding steps use `self.tcl_lib` and `self.tk_lib` for
- # filesystem-based operations and need the original filenames.
- if is_aix:
- _, ext = os.path.splitext(self.tcl_lib)
- if ext == '.a':
- tcl_major, tcl_minor = tcltk_info.tcl_version
- self.tcl_lib += f"(libtcl.so.{tcl_major}.{tcl_minor})"
- _, ext = os.path.splitext(self.tk_lib)
- if ext == '.a':
- tk_major, tk_minor = tcltk_info.tk_version
- self.tk_lib += f"(libtk.so.{tk_major}.{tk_minor})"
- self.__postinit__()
- _CENTER_MODES = {
- 'default': SplashWriter._SPLASH_CENTER_DEFAULT,
- 'primary': SplashWriter._SPLASH_CENTER_PRIMARY_SCREEN,
- 'virtual': SplashWriter._SPLASH_CENTER_VIRTUAL_SCREEN,
- 'active': SplashWriter._SPLASH_CENTER_ACTIVE_SCREEN,
- }
- _GUTS = (
- # input parameters
- ('image_file', _check_guts_eq),
- ('name', _check_guts_eq),
- ('script_name', _check_guts_eq),
- ('text_pos', _check_guts_eq),
- ('text_size', _check_guts_eq),
- ('text_font', _check_guts_eq),
- ('text_color', _check_guts_eq),
- ('text_default', _check_guts_eq),
- ('always_on_top', _check_guts_eq),
- ('full_tk', _check_guts_eq),
- ('minify_script', _check_guts_eq),
- ('max_img_size', _check_guts_eq),
- ('center', _check_guts_eq),
- # calculated/analysed values
- ('uses_tkinter', _check_guts_eq),
- ('script', _check_guts_eq),
- ('tcl_lib', _check_guts_eq),
- ('tk_lib', _check_guts_eq),
- ('splash_requirements', _check_guts_eq),
- ('binaries', _check_guts_toc),
- # internal value
- # Check if the tkinter installation changed. This is theoretically possible if someone uses two different python
- # installations of the same version.
- ('_tkinter_file', _check_guts_eq),
- )
- def _check_guts(self, data, last_build):
- if Target._check_guts(self, data, last_build):
- return True
- # Check if the image has been modified.
- if misc.mtime(self.image_file) > last_build:
- logger.info("Building %s because file %s changed", self.tocbasename, self.image_file)
- return True
- return False
- def assemble(self):
- logger.info("Building Splash %s", self.name)
- # Check if PIL/pillow is available.
- try:
- from PIL import Image as PILImage
- except ImportError:
- PILImage = None
- # Required to pass tcltk_info.TK_ROOTNAME to SplashWriter
- from PyInstaller.utils.hooks.tcl_tk import tcltk_info
- # Function to resize a given image to fit into the area defined by max_img_size.
- def _resize_image(_image, _orig_size):
- if PILImage:
- _w, _h = _orig_size
- _ratio_w = self.max_img_size[0] / _w
- if _ratio_w < 1:
- # Image width exceeds limit
- _h = int(_h * _ratio_w)
- _w = self.max_img_size[0]
- _ratio_h = self.max_img_size[1] / _h
- if _ratio_h < 1:
- # Image height exceeds limit
- _w = int(_w * _ratio_h)
- _h = self.max_img_size[1]
- # If a file is given it will be open
- if isinstance(_image, PILImage.Image):
- _img = _image
- else:
- _img = PILImage.open(_image)
- _img_resized = _img.resize((_w, _h))
- # Save image into a stream
- _image_stream = io.BytesIO()
- _img_resized.save(_image_stream, format='PNG')
- _img.close()
- _img_resized.close()
- _image_data = _image_stream.getvalue()
- logger.info("Resized image %s from dimensions %r to (%d, %d)", self.image_file, _orig_size, _w, _h)
- return _image_data
- else:
- raise ValueError(
- "The splash image dimensions (w: %d, h: %d) exceed max_img_size (w: %d, h:%d), but the image "
- "cannot be resized due to missing PIL.Image! Either install the Pillow package, adjust the "
- "max_img_size, or use an image of compatible dimensions." %
- (_orig_size[0], _orig_size[1], self.max_img_size[0], self.max_img_size[1])
- )
- # Open image file
- image_file = open(self.image_file, 'rb')
- # Check header of the file to identify it
- if image_file.read(8) == b'\x89PNG\r\n\x1a\n':
- # self.image_file is a PNG file
- image_file.seek(16)
- img_size = (struct.unpack("!I", image_file.read(4))[0], struct.unpack("!I", image_file.read(4))[0])
- if img_size > self.max_img_size:
- # The image exceeds the maximum image size, so resize it
- image = _resize_image(self.image_file, img_size)
- else:
- image = os.path.abspath(self.image_file)
- elif PILImage:
- # Pillow is installed, meaning the image can be converted automatically
- img = PILImage.open(self.image_file, mode='r')
- if img.size > self.max_img_size:
- image = _resize_image(img, img.size)
- else:
- image_data = io.BytesIO()
- img.save(image_data, format='PNG')
- img.close()
- image = image_data.getvalue()
- logger.info("Converted image %s to PNG format", self.image_file)
- else:
- raise ValueError(
- "The image %s needs to be converted to a PNG file, but PIL.Image is not available! Either install the "
- "Pillow package, or use a PNG image for you splash screen." % (self.image_file,)
- )
- image_file.close()
- SplashWriter(
- self.name,
- self.splash_requirements,
- os.path.basename(self.tcl_lib), # tcl86t.dll
- os.path.basename(self.tk_lib), # tk86t.dll
- tcltk_info.TCL_ROOTNAME,
- tcltk_info.TK_ROOTNAME,
- image,
- self.script,
- self._CENTER_MODES[self.center],
- )
- @staticmethod
- def _check_tcl_tk_compatibility(tcltk_info):
- tcl_version = tcltk_info.tcl_version # (major, minor) tuple
- tk_version = tcltk_info.tk_version
- if is_darwin and tcltk_info.is_macos_system_framework:
- # Outdated Tcl/Tk 8.5 system framework is not supported.
- raise SystemExit(
- "ERROR: The splash screen feature does not support macOS system framework version of Tcl/Tk."
- )
- # Test if tcl/tk version is supported
- if tcl_version < (8, 6) or tk_version < (8, 6):
- logger.warning(
- "The installed Tcl/Tk (%d.%d / %d.%d) version might not work with the splash screen feature of the "
- "bootloader, which was tested against Tcl/Tk 8.6", *tcl_version, *tk_version
- )
- # This should be impossible, since tcl/tk is released together with the same version number, but just in case
- if tcl_version != tk_version:
- logger.warning(
- "The installed version of Tcl (%d.%d) and Tk (%d.%d) do not match. PyInstaller is tested against "
- "matching versions", *tcl_version, *tk_version
- )
- # Ensure that Tcl is built with multi-threading support.
- if not tcltk_info.tcl_threaded:
- # This is a feature breaking problem, so exit.
- raise SystemExit(
- "ERROR: The installed Tcl version is not threaded. PyInstaller only supports the splash screen "
- "using threaded Tcl."
- )
- # Ensure that Tcl and Tk shared libraries are available
- if tcltk_info.tcl_shared_library is None or tcltk_info.tk_shared_library is None:
- message = \
- "ERROR: Could not determine the path to Tcl and/or Tk shared library, " \
- "which are required for splash screen."
- if not tcltk_info.tkinter_extension_file:
- message += (
- " The _tkinter module appears to be a built-in, which likely means that python was built with "
- "statically-linked Tcl/Tk libraries and is incompatible with splash screen."
- )
- raise SystemExit(message)
- def generate_script(self):
- """
- Generate the script for the splash screen.
- If minify_script is True, all unnecessary parts will be removed.
- """
- d = {}
- if self.text_pos is not None:
- logger.debug("Add text support to splash screen")
- d.update({
- 'pad_x': self.text_pos[0],
- 'pad_y': self.text_pos[1],
- 'color': self.text_color,
- 'font': self.text_font,
- 'font_size': self.text_size,
- 'default_text': self.text_default,
- })
- script = splash_templates.build_script(text_options=d, always_on_top=self.always_on_top)
- if self.minify_script:
- # Remove any documentation, empty lines and unnecessary spaces
- script = '\n'.join(
- line for line in map(lambda line: line.strip(), script.splitlines())
- if not line.startswith('#') # documentation
- and line # empty lines
- )
- # Remove unnecessary spaces
- script = re.sub(' +', ' ', script)
- # Write script to disk, so that it is transparent to the user what script is executed.
- with open(self.script_name, "w", encoding="utf-8") as script_file:
- script_file.write(script)
- return script
- @staticmethod
- def _uses_tkinter(tkinter_file, binaries):
- # Test for _tkinter extension instead of tkinter module, because user might use a different wrapping library for
- # Tk. Use `pathlib.PurePath` in comparisons to account for case normalization and separator normalization.
- tkinter_file = pathlib.PurePath(tkinter_file)
- for dest_name, src_name, typecode in binaries:
- if pathlib.PurePath(src_name) == tkinter_file:
- return True
- return False
|