From 447b5e6894f0c80eb4453e8739ede6ef057913e6 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Tue, 20 Aug 2024 23:37:19 -0700 Subject: [PATCH] feat(ui): add more default media types and icons Add additional default icons for: - Blender - Presentation - Program - Spreadsheet Add/expand additional media types: - PDF - Packages --- .../qt/images/file_icons/document.png | Bin 8832 -> 9200 bytes .../qt/images/file_icons/presentation.png | Bin 0 -> 11326 bytes .../qt/images/file_icons/program.png | Bin 0 -> 6748 bytes .../qt/images/file_icons/spreadsheet.png | Bin 0 -> 5721 bytes tagstudio/src/core/media_types.py | 20 +- tagstudio/src/qt/resources.json | 16 + tagstudio/src/qt/widgets/thumb_renderer.py | 1100 ++++++++++------- 7 files changed, 677 insertions(+), 459 deletions(-) create mode 100644 tagstudio/resources/qt/images/file_icons/presentation.png create mode 100644 tagstudio/resources/qt/images/file_icons/program.png create mode 100644 tagstudio/resources/qt/images/file_icons/spreadsheet.png diff --git a/tagstudio/resources/qt/images/file_icons/document.png b/tagstudio/resources/qt/images/file_icons/document.png index dddf93b059110da283455dfccf4b1c8d4b1bddd8..a3dacb01efdab57f803bbe8ec4c9f911ca5f04e9 100644 GIT binary patch delta 7482 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 D_sM(q literal 8832 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 D#e&zb diff --git a/tagstudio/resources/qt/images/file_icons/presentation.png b/tagstudio/resources/qt/images/file_icons/presentation.png new file mode 100644 index 0000000000000000000000000000000000000000..86a3b37c9eb6cee6ae219c1e09028d526b081d9a GIT binary patch literal 11326 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 literal 0 HcmV?d00001 diff --git a/tagstudio/resources/qt/images/file_icons/program.png b/tagstudio/resources/qt/images/file_icons/program.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d64c1a18fa8a470ea01be63627f60c90ffa238 GIT binary patch literal 6748 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 literal 0 HcmV?d00001 diff --git a/tagstudio/resources/qt/images/file_icons/spreadsheet.png b/tagstudio/resources/qt/images/file_icons/spreadsheet.png new file mode 100644 index 0000000000000000000000000000000000000000..fb1dbeac285d357ab0ba845488832201b204d5a1 GIT binary patch literal 5721 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 literal 0 HcmV?d00001 diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index 47b9721b..449aa8ae 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -30,6 +30,7 @@ class MediaType(str, Enum): MATERIAL: str = "material" MODEL: str = "model" PACKAGE: str = "package" + PDF: str = "pdf" PLAINTEXT: str = "plaintext" PRESENTATION: str = "presentation" PROGRAM: str = "program" @@ -205,7 +206,18 @@ class MediaCategories: _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _MATERIAL_SET: set[str] = {".mtl"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} - _PACKAGE_SET: set[str] = {".pkg"} + _PACKAGE_SET: set[str] = { + ".aab", + ".akp", + ".apk", + ".apkm", + ".apks", + ".pkg", + ".xapk", + } + _PDF_SET: set[str] = { + ".pdf", + } _PLAINTEXT_SET: set[str] = { ".bat", ".css", @@ -340,6 +352,11 @@ class MediaCategories: extensions=_PACKAGE_SET, is_iana=False, ) + PDF_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PDF, + extensions=_PDF_SET, + is_iana=False, + ) PLAINTEXT_TYPES: MediaCategory = MediaCategory( media_type=MediaType.PLAINTEXT, extensions=_PLAINTEXT_SET, @@ -394,6 +411,7 @@ class MediaCategories: MATERIAL_TYPES, MODEL_TYPES, PACKAGE_TYPES, + PDF_TYPES, PLAINTEXT_TYPES, PRESENTATION_TYPES, PROGRAM_TYPES, diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index b27b7e36..967fe5af 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -31,6 +31,10 @@ "path": "qt/images/file_icons/affinity_photo.png", "mode": "pil" }, + "blender": { + "path": "qt/images/file_icons/blender.png", + "mode": "pil" + }, "document": { "path": "qt/images/file_icons/document.png", "mode": "pil" @@ -55,6 +59,18 @@ "path": "qt/images/file_icons/model.png", "mode": "pil" }, + "presentation": { + "path": "qt/images/file_icons/presentation.png", + "mode": "pil" + }, + "program": { + "path": "qt/images/file_icons/program.png", + "mode": "pil" + }, + "spreadsheet": { + "path": "qt/images/file_icons/spreadsheet.png", + "mode": "pil" + }, "text": { "path": "qt/images/file_icons/text.png", "mode": "pil" diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index bb352c5a..ffa577ed 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,62 +5,54 @@ import logging import math -import cv2 -import rawpy -import numpy as np -from pillow_heif import register_heif_opener, register_avif_opener -from PIL import ( - Image, - UnidentifiedImageError, - ImageQt, - ImageDraw, - ImageFont, - ImageOps, - ImageFile, -) from io import BytesIO from pathlib import Path + +import cv2 +import numpy as np +import rawpy +from mutagen import MutagenError, flac, id3, mp4 +from PIL import ( + Image, + ImageChops, + ImageDraw, + ImageEnhance, + ImageFile, + ImageFont, + ImageOps, + ImageQt, + UnidentifiedImageError, +) from PIL.Image import DecompressionBombError +from pillow_heif import register_avif_opener, register_heif_opener from pydub import AudioSegment, exceptions -from mutagen import id3, flac, mp4, MutagenError -from PySide6.QtCore import Qt, QObject, Signal, QSize +from PySide6.QtCore import QObject, QSize, Qt, Signal from PySide6.QtGui import QGuiApplication, QPixmap -from src.qt.resource_manager import ResourceManager +from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.media_types import MediaCategories, MediaType +from src.core.palette import ColorType, get_ui_color +from src.core.utils.encoding import detect_char_encoding +from src.qt.helpers.blender_thumbnailer import blend_thumb from src.qt.helpers.color_overlay import theme_fg_overlay +from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.gradient import four_corner_gradient_background from src.qt.helpers.text_wrapper import wrap_full_text -from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT -from src.core.media_types import MediaType, MediaCategories -from src.core.utils.encoding import detect_char_encoding -from src.core.palette import ColorType, get_ui_color -from src.qt.helpers.blender_thumbnailer import blend_thumb -from src.qt.helpers.file_tester import is_readable_video - +from src.qt.resource_manager import ResourceManager ImageFile.LOAD_TRUNCATED_IMAGES = True -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" - logging.basicConfig(format="%(message)s", level=logging.INFO) register_heif_opener() register_avif_opener() class ThumbRenderer(QObject): + """A class for rendering image and file thumbnails.""" + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # Cached thumbnail elements. - # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) - thumb_masks: dict = {} - thumb_borders: dict = {} - - # Key: ("name", "color", 512, 512, 1.25) - icons: dict = {} - thumb_loading_512: Image.Image = Image.open( Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" ) @@ -73,42 +65,88 @@ class ThumbRenderer(QObject): math.floor(12 * font_pixel_ratio), ) - @staticmethod - def _get_mask(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def __init__(self) -> None: + super().__init__() + + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple (Ex. (512, 512, 1.25)) + self.thumb_masks: dict = {} + self.raised_edges: dict = {} + + # Key: ("name", "color", 512, 512, 1.25) + self.icons: dict = {} + + def _get_resource_id(self, url: Path) -> str: + """Return the name of the icon resource to use for a file type. + Special terms will return special resources. + + Args: + url (Path): The file url to assess. "$LOADING" will return the loading graphic. + """ + ext = url.suffix.lower() + types: set[MediaType] = MediaCategories.get_types(ext, True) + + # Loop though the specific (non-IANA) categories and return the string + # name of the first matching category found. + for cat in MediaCategories.ALL_CATEGORIES: + if not cat.is_iana: + if cat.media_type in types: + return cat.media_type.value + + # If the type is broader (IANA registered) then search those types. + for cat in MediaCategories.ALL_CATEGORIES: + if cat.is_iana: + if cat.media_type in types: + return cat.media_type.value + + return "file_generic" + + def _get_mask(self, size: tuple[int, int], pixel_ratio: float) -> Image.Image: """ Returns a thumbnail mask given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ - item: Image.Image = ThumbRenderer.thumb_masks.get((*size, pixel_ratio)) + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio)) if not item: - item = ThumbRenderer._render_mask(size, pixel_ratio) - ThumbRenderer.thumb_masks[(*size, pixel_ratio)] = item + item = self._render_mask(size, pixel_ratio) + self.thumb_masks[(*size, pixel_ratio)] = item return item - @staticmethod - def _get_hl_border(size: tuple[int, int], pixel_ratio: float) -> Image.Image: + def _get_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: """ - Returns a thumbnail border given a size and pixel ratio. + Returns a thumbnail raised edge graphic given a size and pixel ratio. If one is not already cached, then a new one will be rendered. """ - item: Image.Image = ThumbRenderer.thumb_borders.get((*size, pixel_ratio)) + item: tuple[Image.Image, Image.Image] = self.raised_edges.get( + (*size, pixel_ratio) + ) if not item: - item = ThumbRenderer._render_hl_border(size, pixel_ratio) - ThumbRenderer.thumb_borders[(*size, pixel_ratio)] = item + item = self._render_edge(size, pixel_ratio) + self.raised_edges[(*size, pixel_ratio)] = item + else: + logging.info("using cached edge") return item - @staticmethod def _get_icon( - name: str, color: str, size: tuple[int, int], pixel_ratio: float + self, name: str, color: str, size: tuple[int, int], pixel_ratio: float = 1.0 ) -> Image.Image: - item: Image.Image = ThumbRenderer.icons.get((name, color, *size, pixel_ratio)) + """Retrieves a new or cached icon. + + Args: + name (str): The name of the icon resource. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + """ + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) if not item: - item = ThumbRenderer._render_icon(name, color, size, pixel_ratio) - ThumbRenderer.thumb_borders[(name, *color, size, pixel_ratio)] = item + item = self._render_icon(name, color, size, pixel_ratio) + self.raised_edges[(name, *color, size, pixel_ratio)] = item return item - @staticmethod - def _render_mask(size: tuple[int, int], pixel_ratio) -> Image.Image: + def _render_mask(self, size: tuple[int, int], pixel_ratio) -> Image.Image: """Renders a thumbnail mask.""" smooth_factor: int = 2 radius_factor: int = 8 @@ -130,33 +168,97 @@ class ThumbRenderer(QObject): ) return im - @staticmethod - def _render_hl_border(size: tuple[int, int], pixel_ratio) -> Image.Image: + def _render_edge( + self, size: tuple[int, int], pixel_ratio + ) -> tuple[Image.Image, Image.Image]: """Renders a thumbnail highlight border.""" + logging.info("rendering edge") smooth_factor: int = 2 radius_factor: int = 8 - im: Image.Image = Image.new( + width: int = math.floor(pixel_ratio * 2) + + # Highlight + im_hl: Image.Image = Image.new( mode="RGBA", size=tuple([d * smooth_factor for d in size]), # type: ignore color="#00000000", ) - draw = ImageDraw.Draw(im) + draw = ImageDraw.Draw(im_hl) draw.rounded_rectangle( - (0, 0) + tuple([d - 1 for d in im.size]), - radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + (width, width) + tuple([d - (width + 1) for d in im_hl.size]), + radius=math.ceil( + (radius_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 3) + ), fill=None, outline="white", - width=math.floor(pixel_ratio * 2), + width=width, ) - im = im.resize( + im_hl = im_hl.resize( size, resample=Image.Resampling.BILINEAR, ) - return im - @staticmethod + # Shadow + im_sh: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_sh) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im_sh.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + fill=None, + outline="black", + width=width, + ) + im_sh = im_sh.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + # sh_bg = sh_bg.resize( + # size, + # resample=Image.Resampling.BILINEAR, + # ) + + # Shadow + # sh_bg: Image.Image = Image.new( + # mode="RGBA", + # size=tuple([d * smooth_factor for d in size]), # type: ignore + # color="black", + # ) + # sh_inner_mask: Image.Image = Image.new( + # mode="RGBA", + # size=tuple([d * smooth_factor for d in size]), # type: ignore + # color="red", + # ) + # draw = ImageDraw.Draw(sh_inner_mask) + # draw.rounded_rectangle( + # (0, 0) + tuple([d - 1 for d in sh_bg.size]), + # radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + # fill="black", + # outline="red", + # width=width, + # ) + # sh_bg.putalpha(sh_inner_mask.getchannel(0)) + # # sh_bg = sh_bg.resize( + # # size, + # # resample=Image.Resampling.BILINEAR, + # # ) + + # alpha_mask: Image.Image = self._get_mask(sh_bg.size, pixel_ratio) + # im_sh = Image.new("RGBA", sh_bg.size, "#00000000") + # im_sh.paste(sh_bg, mask=alpha_mask.getchannel(0)) + + # im_sh = im_sh.resize( + # size, + # resample=Image.Resampling.BILINEAR, + # ) + + return (im_hl, im_sh) + def _render_icon( - name: str, color: str, size: tuple[int, int], pixel_ratio: float + self, name: str, color: str, size: tuple[int, int], pixel_ratio: float ) -> Image.Image: smooth_factor: int = math.ceil(2 * pixel_ratio) radius_factor: int = 8 @@ -180,7 +282,7 @@ class ThumbRenderer(QObject): im.paste( bg, (0, 0), - mask=ThumbRenderer._get_mask( + mask=self._get_mask( tuple([d * smooth_factor for d in size]), # type: ignore (pixel_ratio * smooth_factor), ), @@ -204,9 +306,9 @@ class ThumbRenderer(QObject): fg: Image.Image = Image.new("RGB", size=size, color="#00FF00") # Get icon by name - icon: Image.Image = ThumbRenderer.rm.get(name) + icon: Image.Image = self.rm.get(name) if not icon: - icon = ThumbRenderer.rm.get("file_generic") + icon = self.rm.get("file_generic") if not icon: icon = Image.new(mode="RGBA", size=(32, 32), color="magenta") @@ -228,15 +330,14 @@ class ThumbRenderer(QObject): ) # Apply color overlay - im = ThumbRenderer._apply_overlay_color( + im = self._apply_overlay_color( im, color, ) return im - @staticmethod - def _apply_overlay_color(image: Image.Image, color: str) -> Image.Image: + def _apply_overlay_color(self, image: Image.Image, color: str) -> Image.Image: """Apply a gradient effect over an an image. Red channel for foreground, green channel for outline, none for background.""" bg_color: str = ( @@ -271,330 +372,34 @@ class ThumbRenderer(QObject): return bg - @staticmethod - def get_icon_resource(url: Path) -> str: - """Return the name of the icon resource to use for a file type. + def _apply_edge(self, image: Image.Image, edge: tuple[Image.Image, Image.Image]): + """Apply a given edge effect to an image. Args: - url (Path): The file url to assess. + image (Image.Image): The image to apply the edge to. + edge (Image.Image): The edge image to apply. """ - ext = url.suffix.lower() - types: set[MediaType] = MediaCategories.get_types(ext, True) + logging.info("applying edge") + im: Image.Image = image + im_hl, im_sh = edge - # Loop though the specific (non-IANA) categories and return the string - # name of the first matching category found. - for cat in MediaCategories.ALL_CATEGORIES: - if not cat.is_iana: - if cat.media_type in types: - return cat.media_type.value + # Configure and apply a soft light overlay. + # This makes up the bulk of the effect. + # edge_soft = im_hl.copy() + im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(0.75)) + im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3)) - # If the type is broader (IANA registered) then search those types. - for cat in MediaCategories.ALL_CATEGORIES: - if cat.is_iana: - if cat.media_type in types: - return cat.media_type.value + # Configure and apply a hard light overlay. + # This helps with contrast. + # edge_hard = im_sh.copy() + # edge_hard.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) + im_sh.putalpha(ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(0.75)) + im.paste(im_sh, mask=im_sh.getchannel(3)) + # im.paste(edge_hard, mask=im_sh.getchannel(3)) - return "file_generic" + return im - def render( - self, - timestamp: float, - filepath: str | Path, - base_size: tuple[int, int], - pixel_ratio: float, - is_loading=False, - gradient=False, - update_on_ratio_change=False, - ): - """Internal renderer. Renders an entry/element thumbnail for the GUI.""" - loading_thumb: Image.Image = ThumbRenderer.thumb_loading_512 - - image: Image.Image = None - pixmap: QPixmap = None - final: Image.Image = None - _filepath: Path = Path(filepath) - resampling_method = Image.Resampling.BILINEAR - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - fg_color: str = ( - "#FFFFFF" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#111111" - ) - - if ThumbRenderer.font_pixel_ratio != pixel_ratio: - ThumbRenderer.font_pixel_ratio = pixel_ratio - ThumbRenderer.ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.font_pixel_ratio), - ) - - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Light: - loading_thumb = theme_fg_overlay(loading_thumb) - - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - if is_loading: - final = loading_thumb.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - qim = ImageQt.ImageQt(final) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) - if update_on_ratio_change: - self.updated_ratio.emit(1) - elif _filepath: - try: - ext: str = _filepath.suffix.lower() - # Images ======================================================= - if MediaType.IMAGE in MediaCategories.get_types(ext, True): - # Raw Images ----------------------------------------------- - if MediaType.IMAGE_RAW in MediaCategories.get_types(ext, True): - try: - with rawpy.imread(str(_filepath)) as raw: - rgb = raw.postprocess() - image = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ) as e: - logging.info( - f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {_filepath.name} ({type(e).__name__})" - ) - - # Normal Images -------------------------------------------- - else: - try: - image = Image.open(_filepath) - if image.mode != "RGB" and image.mode != "RGBA": - image = image.convert(mode="RGBA") - if image.mode == "RGBA": - new_bg = Image.new("RGB", image.size, color="#1e1e1e") - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - - image = ImageOps.exif_transpose(image) - except DecompressionBombError as e: - logging.info( - f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - # Videos ======================================================= - elif MediaType.VIDEO in MediaCategories.get_types(ext, True): - if is_readable_video(_filepath): - video = cv2.VideoCapture(str(_filepath), cv2.CAP_FFMPEG) - # TODO: Move this check to is_readable_video() - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - else: - image = ThumbRenderer._get_icon( - name="file_generic", - color="red", - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) - - # Plain Text =================================================== - elif MediaType.PLAINTEXT in MediaCategories.get_types(ext): - encoding = detect_char_encoding(_filepath) - with open(_filepath, "r", encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - image = bg - # Fonts ======================================================== - elif MediaType.FONT in MediaCategories.get_types(ext, True): - if gradient: - # Short (Aa) Preview - image = self._font_preview_short(_filepath, adj_size) - else: - # Large (Full Alphabet) Preview - image = self._font_preview_long(_filepath, adj_size) - # Audio ======================================================== - elif MediaType.AUDIO in MediaCategories.get_types(ext, True): - image = self._album_artwork(_filepath, ext) - if image is None: - image = self._audio_waveform( - _filepath, ext, adj_size, pixel_ratio - ) - if image is not None: - image = ThumbRenderer._apply_overlay_color(image, "green") - - # 3D =========================================================== - # elif extension == 'stl': - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # image = Image.open(img_buf) - - # Blender =========================================================== - elif MediaType.BLENDER in MediaCategories.get_types(ext): - try: - blend_image = blend_thumb(str(_filepath)) - - bg = Image.new("RGB", blend_image.size, color=bg_color) - bg.paste(blend_image, mask=blend_image.getchannel(3)) - image = bg - - except ( - AttributeError, - UnidentifiedImageError, - FileNotFoundError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": - logging.info( - f"[ThumbRenderer]{ERROR} {_filepath.name} Doesn't have thumbnail saved. ({type(e).__name__})" - ) - - else: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - - # No Rendered Thumbnail ======================================== - if not image: - raise UnidentifiedImageError - - orig_x, orig_y = image.size - new_x, new_y = (adj_size, adj_size) - - if orig_x > orig_y: - new_x = adj_size - new_y = math.ceil(adj_size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = adj_size - new_x = math.ceil(adj_size * (orig_x / orig_y)) - - if update_on_ratio_change: - self.updated_ratio.emit(new_x / new_y) - - resampling_method = ( - Image.Resampling.NEAREST - if max(image.size[0], image.size[1]) - < max(base_size[0], base_size[1]) - else Image.Resampling.BILINEAR - ) - image = image.resize((new_x, new_y), resample=resampling_method) - if gradient: - mask: Image.Image = ThumbRenderer._get_mask( - (adj_size, adj_size), pixel_ratio - ) - hl: Image.Image = ThumbRenderer._get_hl_border( - (adj_size, adj_size), pixel_ratio - ) - final = four_corner_gradient_background(image, adj_size, mask, hl) - else: - scalar = 4 - rec: Image.Image = Image.new( - "RGB", - tuple([d * scalar for d in image.size]), # type: ignore - "black", - ) - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0) + tuple([d - 1 for d in rec.size]), - (base_size[0] // 32) * scalar * pixel_ratio, - fill="red", - ) - rec = rec.resize( - tuple([d // scalar for d in rec.size]), - resample=Image.Resampling.BILINEAR, - ) - final = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=rec.getchannel(0)) - except FileNotFoundError as e: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = ThumbRenderer._get_icon( - name="broken_link_icon", - color="red", - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - UnicodeDecodeError, - OSError, - ) as e: - # if e is not UnicodeDecodeError: - logging.info( - f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" - ) - - if update_on_ratio_change: - self.updated_ratio.emit(1) - final = ThumbRenderer._get_icon( - name=ThumbRenderer.get_icon_resource(_filepath), - # name="file_generic", - color="", - size=(adj_size, adj_size), - pixel_ratio=pixel_ratio, - ) - qim = ImageQt.ImageQt(final) - if image: - image.close() - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixel_ratio) - - if pixmap: - self.updated.emit( - timestamp, - pixmap, - QSize( - math.ceil(adj_size / pixel_ratio), - math.ceil(final.size[1] / pixel_ratio), - ), - _filepath.suffix.lower(), - ) - - else: - self.updated.emit( - timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower() - ) - - def _album_artwork(self, filepath: Path, ext: str) -> Image.Image | None: + def _audio_album_thumb(self, filepath: Path, ext: str) -> Image.Image | None: """Gets an album cover from an audio file if one is present.""" image: Image.Image = None try: @@ -626,21 +431,21 @@ class ThumbRenderer(QObject): MutagenError, ) as e: logging.error( - f"[ThumbRenderer]{ERROR}: Couldn't read album artwork for {filepath.name} ({type(e).__name__})" + f"[ThumbRenderer][ERROR]: Couldn't read album artwork for {filepath.name} ({type(e).__name__})" ) return image - def _audio_waveform( + def _audio_waveform_thumb( self, filepath: Path, ext: str, size: int, pixel_ratio: float ) -> Image.Image | None: - """Renders a waveform image from an audio file.""" + """Render a waveform image from an audio file.""" # BASE_SCALE used for drawing on a larger image and resampling down # to provide an antialiased effect. BASE_SCALE: int = 2 size_scaled: int = size * BASE_SCALE ALLOW_SMALL_MIN: bool = False SAMPLES_PER_BAR: int = 3 - image: Image.Image = None + im: Image.Image = None try: BARS: int = min(math.floor((size // pixel_ratio) / 5), 64) @@ -674,8 +479,8 @@ class ThumbRenderer(QObject): line_ratio = max(highest_line / BAR_HEIGHT, 1) - image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(image) + im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(im) current_x = BAR_MARGIN for item in max_array: @@ -705,83 +510,462 @@ class ThumbRenderer(QObject): current_x = current_x + LINE_WIDTH + BAR_MARGIN - image.resize((size, size), Image.Resampling.BILINEAR) + im.resize((size, size), Image.Resampling.BILINEAR) except exceptions.CouldntDecodeError as e: logging.error( - f"[ThumbRenderer]{ERROR}: Couldn't render waveform for {filepath.name} ({type(e).__name__})" + f"[ThumbRenderer][WAVEFORM][ERROR]: Couldn't render waveform for {filepath.name} ({type(e).__name__})" ) - return image + return im - def _font_preview_short(self, filepath: Path, size: int) -> Image.Image: - """Renders a small font preview ("Aa") thumbnail from a font file.""" - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 2, size * 2), color="#000000") - draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(filepath, size=size) - # NOTE: While a stroke effect is desired, the text - # method only allows for outer strokes, which looks - # a bit weird when rendering fonts. - draw.text( - (size // 8, size // 8), - "Aa", - font=font, - fill="#FF0000", - # stroke_width=math.ceil(size / 96), - # stroke_fill="#FFFF00", + def _blender(self, filepath: Path) -> Image.Image: + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" ) - # NOTE: Change to getchannel(1) if using an outline. - data = np.asarray(raw.getchannel(0)) + im: Image.Image = None + try: + blend_image = blend_thumb(str(filepath)) - m, n = data.shape[:2] - col: np.ndarray = data.any(0) - row: np.ndarray = data.any(1) - cropped_data = np.asarray(raw)[ - row.argmax() : m - row[::-1].argmax(), - col.argmax() : n - col[::-1].argmax(), - ] - cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg - margin: int = math.ceil(size // 16) + except ( + AttributeError, + UnidentifiedImageError, + FileNotFoundError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logging.info( + f"[ThumbRenderer][BLENDER][INFO] {filepath.name} Doesn't have an embedded thumbnail. ({type(e).__name__})" + ) - orig_x, orig_y = cropped_im.size - new_x, new_y = (size, size) - if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) + else: + logging.error( + f"[ThumbRenderer][BLENDER][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im - cropped_im = cropped_im.resize( - size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, - ) - bg.paste( - cropped_im, - box=(margin, margin + ((size - new_y) // 2)), - ) - return ThumbRenderer._apply_overlay_color(bg, "purple") + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a small font preview ("Aa") thumbnail from a font file.""" + im: Image.Image = None + try: + bg = Image.new("RGB", (size, size), color="#000000") + raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font = ImageFont.truetype(filepath, size=size) + # NOTE: While a stroke effect is desired, the text + # method only allows for outer strokes, which looks + # a bit weird when rendering fonts. + draw.text( + (size // 8, size // 8), + "Aa", + font=font, + fill="#FF0000", + # stroke_width=math.ceil(size / 96), + # stroke_fill="#FFFF00", + ) + # NOTE: Change to getchannel(1) if using an outline. + data = np.asarray(raw.getchannel(0)) - def _font_preview_long(self, filepath: Path, size: int) -> Image.Image: - """Renders a large font preview ("Alphabet") thumbnail from a font file.""" + m, n = data.shape[:2] + col: np.ndarray = data.any(0) + row: np.ndarray = data.any(1) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(size // 16) + + orig_x, orig_y = cropped_im.size + new_x, new_y = (size, size) + if orig_x > orig_y: + new_x = size + new_y = math.ceil(size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = size + new_x = math.ceil(size * (orig_x / orig_y)) + + cropped_im = cropped_im.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_im, + box=(margin, margin + ((size - new_y) // 2)), + ) + im = self._apply_overlay_color(bg, "purple") + except OSError as e: + logging.info( + f"[ThumbRenderer][FONT][ERROR] Couldn't Render thumbnail for font {filepath.name} ({type(e).__name__})" + ) + return im + + def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a large font preview ("Alphabet") thumbnail from a font file.""" # Scale the sample font sizes to the preview image # resolution,assuming the sizes are tuned for 256px. - scaled_sizes: list[int] = [ - math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES - ] - bg = Image.new("RGBA", (size, size), color="#00000000") - draw = ImageDraw.Draw(bg) - lines_of_padding = 2 - y_offset = 0 + im: Image.Image = None + try: + scaled_sizes: list[int] = [ + math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES + ] + bg = Image.new("RGBA", (size, size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding = 2 + y_offset = 0 - for font_size in scaled_sizes: - font = ImageFont.truetype(filepath, size=font_size) - text_wrapped: str = wrap_full_text( - FONT_SAMPLE_TEXT, font=font, width=size, draw=draw + for font_size in scaled_sizes: + font = ImageFont.truetype(filepath, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, font=font, width=size, draw=draw + ) + draw.multiline_text((0, y_offset), text_wrapped, font=font) + y_offset += ( + len(text_wrapped.split("\n")) + lines_of_padding + ) * draw.textbbox((0, 0), "A", font=font)[-1] + im = theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logging.info( + f"[ThumbRenderer][FONT][ERROR] Couldn't Render thumbnail for font {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_raw_thumb(self, filepath: Path) -> Image.Image: + im: Image.Image = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + im = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except DecompressionBombError as e: + logging.info( + f"[ThumbRenderer][RAW][WARNING] Couldn't Render thumbnail for {filepath.name} ({type(e).__name__})" + ) + except ( + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, + ) as e: + logging.info( + f"[ThumbRenderer][RAW][ERROR] Couldn't Render thumbnail for raw image {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_thumb(self, filepath: Path) -> Image.Image: + im: Image.Image = None + try: + im = Image.open(filepath) + if im.mode != "RGB" and im.mode != "RGBA": + im = im.convert(mode="RGBA") + if im.mode == "RGBA": + new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg.paste(im, mask=im.getchannel(3)) + im = new_bg + + im = ImageOps.exif_transpose(im) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + logging.error( + f"[ThumbRenderer][IMAGE][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def _image_vector_thumb(self, filepath: Path, size: int) -> Image.Image: + # TODO: Implement. + im: Image.Image = None + return im + + def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + # TODO: Implement. + im: Image.Image = None + # # Create a new plot + # matplotlib.use('agg') + # figure = plt.figure() + # axes = figure.add_subplot(projection='3d') + + # # Load the STL files and add the vectors to the plot + # your_mesh = mesh.Mesh.from_file(_filepath) + + # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) + # poly_collection.set_color((0,0,1)) # play with color + # scale = your_mesh.points.flatten() + # axes.auto_scale_xyz(scale, scale, scale) + # axes.add_collection3d(poly_collection) + # # plt.show() + # img_buf = io.BytesIO() + # plt.savefig(img_buf, format='png') + # im = Image.open(img_buf) + + return im + + def _text_thumb(self, filepath: Path, size: int) -> Image.Image: + im: Image.Image = None + + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#111111" + ) + + try: + encoding = detect_char_encoding(filepath) + with open(filepath, "r", encoding=encoding) as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, fill=fg_color) + im = bg + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + ) as e: + logging.info( + f"[ThumbRenderer][TEXT][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def _video_thumb(self, filepath: Path) -> Image.Image: + im: Image.Image = None + try: + if is_readable_video(filepath): + video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) + # TODO: Move this check to is_readable_video() + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + im = Image.fromarray(frame) + # else: + # im = self._get_icon( + # name="file_generic", + # color="red", + # size=(size, size), + # pixel_ratio=pixel_ratio, + # ) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logging.error( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})" + ) + return im + + def render( + self, + timestamp: float, + filepath: str | Path, + base_size: tuple[int, int], + pixel_ratio: float, + is_loading=False, + gradient=False, + update_on_ratio_change=False, + ): + """Internal renderer. Renders an entry/element thumbnail for the GUI.""" + loading_thumb: Image.Image = ThumbRenderer.thumb_loading_512 + + image: Image.Image = None + pixmap: QPixmap = None + final: Image.Image = None + _filepath: Path = Path(filepath) + resampling_method = Image.Resampling.BILINEAR + + if ThumbRenderer.font_pixel_ratio != pixel_ratio: + ThumbRenderer.font_pixel_ratio = pixel_ratio + ThumbRenderer.ext_font = ImageFont.truetype( + Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", + math.floor(12 * ThumbRenderer.font_pixel_ratio), + ) + + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Light: + loading_thumb = theme_fg_overlay(loading_thumb) + + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + if is_loading: + final = loading_thumb.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) + qim = ImageQt.ImageQt(final) + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixel_ratio) + if update_on_ratio_change: + self.updated_ratio.emit(1) + elif _filepath: + try: + ext: str = _filepath.suffix.lower() + # Images ======================================================= + if MediaType.IMAGE in MediaCategories.get_types(ext, True): + # Raw Images ----------------------------------------------- + if MediaType.IMAGE_RAW in MediaCategories.get_types(ext, True): + image = self._image_raw_thumb(_filepath) + elif MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext, True): + image = self._image_vector_thumb(_filepath, adj_size) + # Normal Images -------------------------------------------- + else: + image = self._image_thumb(_filepath) + # Videos ======================================================= + elif MediaType.VIDEO in MediaCategories.get_types(ext, True): + image = self._video_thumb(_filepath) + # Plain Text =================================================== + elif MediaType.PLAINTEXT in MediaCategories.get_types(ext): + image = self._text_thumb(_filepath, adj_size) + # Fonts ======================================================== + elif MediaType.FONT in MediaCategories.get_types(ext, True): + if gradient: + # Short (Aa) Preview + image = self._font_short_thumb(_filepath, adj_size) + else: + # Large (Full Alphabet) Preview + image = self._font_long_thumb(_filepath, adj_size) + # Audio ======================================================== + elif MediaType.AUDIO in MediaCategories.get_types(ext, True): + image = self._audio_album_thumb(_filepath, ext) + if image is None: + image = self._audio_waveform_thumb( + _filepath, ext, adj_size, pixel_ratio + ) + if image is not None: + image = self._apply_overlay_color(image, "green") + + # Blender =========================================================== + elif MediaType.BLENDER in MediaCategories.get_types(ext): + image = self._blender(_filepath) + + # No Rendered Thumbnail ======================================== + if not image: + raise UnidentifiedImageError + + orig_x, orig_y = image.size + new_x, new_y = (adj_size, adj_size) + + if orig_x > orig_y: + new_x = adj_size + new_y = math.ceil(adj_size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = adj_size + new_x = math.ceil(adj_size * (orig_x / orig_y)) + + if update_on_ratio_change: + self.updated_ratio.emit(new_x / new_y) + + resampling_method = ( + Image.Resampling.NEAREST + if max(image.size[0], image.size[1]) + < max(base_size[0], base_size[1]) + else Image.Resampling.BILINEAR + ) + image = image.resize((new_x, new_y), resample=resampling_method) + if gradient: + mask: Image.Image = self._get_mask( + (adj_size, adj_size), pixel_ratio + ) + edge: tuple[Image.Image, Image.Image] = self._get_edge( + (adj_size, adj_size), pixel_ratio + ) + final = self._apply_edge( + four_corner_gradient_background( + image, (adj_size, adj_size), mask + ), + edge, + ) + else: + scalar = 4 + mask: Image.Image = self._get_mask(image.size, pixel_ratio) + # rec: Image.Image = Image.new( + # "RGB", + # tuple([d * scalar for d in image.size]), # type: ignore + # "black", + # ) + # draw = ImageDraw.Draw(rec) + # draw.rounded_rectangle( + # (0, 0) + tuple([d - 1 for d in rec.size]), + # (base_size[0] // 32) * scalar * pixel_ratio, + # fill="red", + # ) + # rec = rec.resize( + # tuple([d // scalar for d in rec.size]), + # resample=Image.Resampling.BILINEAR, + # ) + final = Image.new("RGBA", image.size, (0, 0, 0, 0)) + final.paste(image, mask=mask.getchannel(0)) + + except FileNotFoundError as e: + logging.info( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + if update_on_ratio_change: + self.updated_ratio.emit(1) + final = self._get_icon( + name="broken_link_icon", + color="red", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + logging.info( + f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})" + ) + + if update_on_ratio_change: + self.updated_ratio.emit(1) + final = self._get_icon( + name=self._get_resource_id(_filepath), + # name="file_generic", + color="", + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) + qim = ImageQt.ImageQt(final) + if image: + image.close() + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixel_ratio) + + if pixmap: + self.updated.emit( + timestamp, + pixmap, + QSize( + math.ceil(adj_size / pixel_ratio), + math.ceil(final.size[1] / pixel_ratio), + ), + _filepath.suffix.lower(), + ) + + else: + self.updated.emit( + timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower() ) - draw.multiline_text((0, y_offset), text_wrapped, font=font) - y_offset += ( - len(text_wrapped.split("\n")) + lines_of_padding - ) * draw.textbbox((0, 0), "A", font=font)[-1] - return theme_fg_overlay(bg, use_alpha=False)