From 49845e9434f9bc4cef4826d109aa9bc1daab718d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 13:48:36 -0500 Subject: [PATCH 01/20] Begin splitting out renderers --- src/tagstudio/qt/previews/renderer.py | 86 ++----------------- src/tagstudio/qt/previews/renderer_type.py | 26 ++++++ .../qt/previews/renderers/base_renderer.py | 15 ++++ .../qt/previews/renderers/krita_renderer.py | 40 +++++++++ .../qt/previews/renderers/video_renderer.py | 68 +++++++++++++++ 5 files changed, 156 insertions(+), 79 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderer_type.py create mode 100644 src/tagstudio/qt/previews/renderers/base_renderer.py create mode 100644 src/tagstudio/qt/previews/renderers/krita_renderer.py create mode 100644 src/tagstudio/qt/previews/renderers/video_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index f47d534ac..425a93faa 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -26,7 +26,6 @@ import rawpy import srctools import structlog -from cv2.typing import MatLike from mutagen import flac, id3, mp4 from mutagen._util import MutagenError from PIL import ( @@ -68,11 +67,11 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES from tagstudio.qt.helpers.color_overlay import theme_fg_overlay -from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.gradients import four_corner_gradient from tagstudio.qt.helpers.image_effects import replace_transparent_pixels from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color +from tagstudio.qt.previews.renderer_type import RendererType from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb from tagstudio.qt.previews.vendored.pydub.audio_segment import ( _AudioSegment as AudioSegment, @@ -844,29 +843,6 @@ def _open_doc_thumb(filepath: Path) -> Image.Image | None: return im - @staticmethod - def _krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an Krita file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "preview.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - @staticmethod def _powerpoint_thumb(filepath: Path) -> Image.Image | None: """Extract and render a thumbnail for a Microsoft PowerPoint file. @@ -1334,50 +1310,6 @@ def _text_thumb(filepath: Path) -> Image.Image | None: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im - @staticmethod - def _video_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a video file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - frame: MatLike | None = 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), - ) - # NOTE: 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. - max_frame_seek: int = 10 - for i in range( - 0, - min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), - ): - success, frame = video.read() - if not success: - video.set(cv2.CAP_PROP_POS_FRAMES, i) - else: - break - if frame is not None: - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - im = Image.fromarray(frame) - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - OSError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - def render( self, timestamp: float, @@ -1618,16 +1550,17 @@ def _render( if _filepath and _filepath.is_file(): try: ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower() + + renderer_type = RendererType.get_renderer_type(ext) + logger.debug("[ThumbRenderer]", renderer_type=renderer_type) + if renderer_type: + image = renderer_type.renderer.render(_filepath) + # Ebooks ======================================================= if MediaCategories.is_ext_in_category( ext, MediaCategories.EBOOK_TYPES, mime_fallback=True ): image = self._epub_cover(_filepath, ext) - # Krita ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.KRITA_TYPES, mime_fallback=True - ): - image = self._krita_thumb(_filepath) # VTF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True @@ -1653,11 +1586,6 @@ def _render( # Normal Images -------------------------------------------- else: image = self._image_thumb(_filepath) - # Videos ======================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ): - image = self._video_thumb(_filepath) # PowerPoint Slideshow elif ext in {".pptx"}: image = self._powerpoint_thumb(_filepath) diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py new file mode 100644 index 000000000..9e68180b9 --- /dev/null +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -0,0 +1,26 @@ +from enum import Enum + +from tagstudio.core.media_types import MediaCategories +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer + + +class RendererType(Enum): + KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer + VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer + + def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): + self.__name: str = name + self.media_category: MediaCategories = media_category + self.renderer: type[BaseRenderer] = renderer + + @staticmethod + def get_renderer_type(file_extension: str) -> "RendererType | None": + for renderer_type in RendererType.__members__.values(): + if MediaCategories.is_ext_in_category( + file_extension, renderer_type.media_category, mime_fallback=True + ): + return renderer_type + + return None diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py new file mode 100644 index 000000000..abf2127e3 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from PIL import Image + + +class BaseRenderer(ABC): + @abstractmethod + def __init__(self) -> None: + pass + + @staticmethod + @abstractmethod + def render(path: Path) -> Image.Image | None: + raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py new file mode 100644 index 000000000..e737432f7 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -0,0 +1,40 @@ +import zipfile +from io import BytesIO +from pathlib import Path + +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "preview.png" + + +class KritaRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path) -> Image.Image | None: + """Extract and render a thumbnail for a Krita file. + + Args: + path (Path): The path of the file. + """ + with zipfile.ZipFile(path, "r") as zip_file: + # Check if the file exists in the zip + if thumbnail_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + logger.error("[KritaRenderer] Couldn't render thumbnail", path=path) + + return None diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py new file mode 100644 index 000000000..f2095fd3f --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -0,0 +1,68 @@ +import math +from pathlib import Path + +import cv2 +import structlog +from cv2.typing import MatLike +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError + +from tagstudio.qt.helpers.file_tester import is_readable_video +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + + +class VideoRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path) -> Image.Image | None: + """Render a thumbnail for a video file. + + Args: + path (Path): The path of the file. + """ + try: + if is_readable_video(path): + video = cv2.VideoCapture(str(path), 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), + ) + + # NOTE: 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. + max_frame_seek: int = 10 + frame: MatLike | None = None + + for i in range( + 0, + min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), + ): + success, frame = video.read() + if not success: + video.set(cv2.CAP_PROP_POS_FRAMES, i) + else: + break + + if frame is not None: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return Image.fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logger.error( + "[VideoRenderer] Couldn't render thumbnail", path=path, error=type(e).__name__ + ) + + return None From ef6c061f0e8b4d597b98e9602937fab09fa644b0 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 15:25:56 -0500 Subject: [PATCH 02/20] EBook renderer (and archive file wrappers) --- .../file_wrappers/archive/archive_file.py | 21 +++ .../helpers/file_wrappers/archive/rar_file.py | 23 ++++ .../file_wrappers/archive/seven_zip_file.py | 29 +++++ .../helpers/file_wrappers/archive/tar_file.py | 23 ++++ .../helpers/file_wrappers/archive/zip_file.py | 22 ++++ src/tagstudio/qt/previews/renderer.py | 121 +----------------- src/tagstudio/qt/previews/renderer_type.py | 2 + .../qt/previews/renderers/base_renderer.py | 2 +- .../qt/previews/renderers/ebook_renderer.py | 103 +++++++++++++++ .../qt/previews/renderers/krita_renderer.py | 32 +++-- .../qt/previews/renderers/video_renderer.py | 7 +- 11 files changed, 248 insertions(+), 137 deletions(-) create mode 100644 src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py create mode 100644 src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py create mode 100644 src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py create mode 100644 src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py create mode 100644 src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py create mode 100644 src/tagstudio/qt/previews/renderers/ebook_renderer.py diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py new file mode 100644 index 000000000..5610b1cf4 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Literal + + +class ArchiveFile(ABC): + @abstractmethod + def __init__(self, path: Path, mode: Literal["r"]) -> None: + pass + + @abstractmethod + def get_name_list(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def has_file_name(self, file_name: str) -> bool: + raise NotImplementedError + + @abstractmethod + def read(self, file_name: str) -> bytes: + raise NotImplementedError diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py new file mode 100644 index 000000000..2a0c26708 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -0,0 +1,23 @@ +from pathlib import Path +from typing import Literal + +import rarfile + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class RarFile(ArchiveFile): + """Wrapper around rarfile.RarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode) + + def get_name_list(self) -> list[str]: + return self.__rar_file.namelist() + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes: + return self.__rar_file.read(file_name) diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py new file mode 100644 index 000000000..0d984ba30 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -0,0 +1,29 @@ +from pathlib import Path +from typing import Literal + +import py7zr + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class SevenZipFile(ArchiveFile): + """Wrapper around py7zr.SevenZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode) + + def get_name_list(self) -> list[str]: + return self.__seven_zip_file.namelist() + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes: + # py7zr.SevenZipFile must be reset after every extraction + # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract + self.__seven_zip_file.reset() + + factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB + self.__seven_zip_file.extract(targets=[file_name], factory=factory) + return factory.get(file_name).read() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py new file mode 100644 index 000000000..b79a2da27 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -0,0 +1,23 @@ +import tarfile +from pathlib import Path +from typing import Literal + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class TarFile(ArchiveFile): + """Wrapper around tarfile.TarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode) + + def get_name_list(self) -> list[str]: + return self.__tar_file.getnames() + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes: + return unwrap(self.__tar_file.extractfile(file_name)).read() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py new file mode 100644 index 000000000..3b17c8255 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -0,0 +1,22 @@ +import zipfile +from pathlib import Path +from typing import Literal + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class ZipFile(ArchiveFile): + """Wrapper around zipfile.ZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.__zip_file: zipfile.ZipFile = zipfile.ZipFile(path, mode) + + def get_name_list(self) -> list[str]: + return self.__zip_file.namelist() + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes: + return self.__zip_file.read(file_name) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 425a93faa..b6de61049 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,22 +7,16 @@ import hashlib import math import os -import tarfile -import xml.etree.ElementTree as ET import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, cast from warnings import catch_warnings -from xml.etree.ElementTree import Element import cv2 import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] -import py7zr -import py7zr.io -import rarfile import rawpy import srctools import structlog @@ -94,40 +88,6 @@ logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module') -class _SevenZipFile(py7zr.SevenZipFile): - """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def read(self, name: str) -> bytes: - # SevenZipFile must be reset after every extraction - # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract - self.reset() - factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB - self.extract(targets=[name], factory=factory) - return factory.get(name).read() - - -class _TarFile(tarfile.TarFile): - """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def namelist(self) -> list[str]: - return self.getnames() - - def read(self, name: str) -> bytes: - return unwrap(self.extractfile(name)).read() - - -type _Archive_T = ( - type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile] -) -type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile - - class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" @@ -869,76 +829,6 @@ def _powerpoint_thumb(filepath: Path) -> Image.Image | None: return im - @staticmethod - def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: - """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. - - Args: - filepath (Path): The path to the ePub file. - ext (str): The file extension. - - Returns: - Image: The cover specified in ComicInfo.xml, - the first image found in the ePub file, or None by default. - """ - im: Image.Image | None = None - try: - archiver: _Archive_T = zipfile.ZipFile - if ext == ".cb7": - archiver = _SevenZipFile - elif ext == ".cbr": - archiver = rarfile.RarFile - elif ext == ".cbt": - archiver = _TarFile - - with archiver(filepath, "r") as archive: - if "ComicInfo.xml" in archive.namelist(): - comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover") - if not im: - im = ThumbRenderer.__cover_from_comic_info( - archive, comic_info, "InnerCover" - ) - - if not im: - for file_name in archive.namelist(): - if file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") - ): - image_data = archive.read(file_name) - im = Image.open(BytesIO(image_data)) - break - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - @staticmethod - def __cover_from_comic_info( - archive: _Archive, comic_info: Element, cover_type: str - ) -> Image.Image | None: - """Extract the cover specified in ComicInfo.xml. - - Args: - archive (_Archive): The current ePub file. - comic_info (Element): The parsed ComicInfo.xml. - cover_type (str): The type of cover to load. - - Returns: - Image: The cover specified in ComicInfo.xml. - """ - im: Image.Image | None = None - - cover = comic_info.find(f"./*Page[@Type='{cover_type}']") - if cover is not None: - pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] - page_name = pages[int(unwrap(cover.get("Image")))] - if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): - image_data = archive.read(page_name) - im = Image.open(BytesIO(image_data)) - - return im - def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None: """Render a small font preview ("Aa") thumbnail from a font file. @@ -1551,16 +1441,11 @@ def _render( try: ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower() - renderer_type = RendererType.get_renderer_type(ext) + renderer_type: RendererType | None = RendererType.get_renderer_type(ext) logger.debug("[ThumbRenderer]", renderer_type=renderer_type) if renderer_type: - image = renderer_type.renderer.render(_filepath) + image = renderer_type.renderer.render(_filepath, ext) - # Ebooks ======================================================= - if MediaCategories.is_ext_in_category( - ext, MediaCategories.EBOOK_TYPES, mime_fallback=True - ): - image = self._epub_cover(_filepath, ext) # VTF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 9e68180b9..a5cb8e92f 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -2,11 +2,13 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer class RendererType(Enum): + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py index abf2127e3..f487d530d 100644 --- a/src/tagstudio/qt/previews/renderers/base_renderer.py +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -11,5 +11,5 @@ def __init__(self) -> None: @staticmethod @abstractmethod - def render(path: Path) -> Image.Image | None: + def render(path: Path, extension: str) -> Image.Image | None: raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py new file mode 100644 index 000000000..9c3f4ac49 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -0,0 +1,103 @@ +from io import BytesIO +from pathlib import Path +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +import structlog +from PIL import Image + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile +from tagstudio.qt.helpers.file_wrappers.archive.rar_file import RarFile +from tagstudio.qt.helpers.file_wrappers.archive.seven_zip_file import SevenZipFile +from tagstudio.qt.helpers.file_wrappers.archive.tar_file import TarFile +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "preview.png" + + +class EBookRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path, extension: str) -> Image.Image | None: + """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. + + Args: + path (Path): The path to the ePub file. + extension (str): The file extension. + + Returns: + Image: The cover specified in ComicInfo.xml, + the first image found in the ePub file, or None by default. + """ + try: + archive: ArchiveFile | None = None + match extension: + case ".cb7": + archive = SevenZipFile(path, "r") + case ".cbr": + archive = RarFile(path, "r") + case ".cbt": + archive = TarFile(path, "r") + case _: + archive = ZipFile(path, "r") + + rendered_image: Image.Image | None = None + + # Get the cover from the comic metadata, if present + if "ComicInfo.xml" in archive.get_name_list(): + comic_info = ElementTree.fromstring(archive.read("ComicInfo.xml")) + rendered_image = EBookRenderer.__cover_from_comic_info( + archive, comic_info, "FrontCover" + ) + if not rendered_image: + rendered_image = EBookRenderer.__cover_from_comic_info( + archive, comic_info, "InnerCover" + ) + + # Get the first image present + if not rendered_image: + for file_name in archive.get_name_list(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data = archive.read(file_name) + rendered_image = Image.open(BytesIO(image_data)) + break + + return rendered_image + except Exception as e: + logger.error("[EBookRenderer] Couldn't render thumbnail", path=path, error=e) + + return None + + @staticmethod + def __cover_from_comic_info( + archive: ArchiveFile, comic_info: Element, cover_type: str + ) -> Image.Image | None: + """Extract the cover specified in ComicInfo.xml. + + Args: + archive (ArchiveFile): The current ePub file. + comic_info (Element): The parsed ComicInfo.xml. + cover_type (str): The type of cover to load. + + Returns: + Image: The cover specified in ComicInfo.xml. + """ + cover = comic_info.find(f"./*Page[@Type='{cover_type}']") + if cover is not None: + pages = [ + page_file for page_file in archive.get_name_list() if page_file != "ComicInfo.xml" + ] + page_name = pages[int(unwrap(cover.get("Image")))] + if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): + image_data = archive.read(page_name) + return Image.open(BytesIO(image_data)) + + return None diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index e737432f7..5bd06eb4a 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -17,24 +17,28 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path) -> Image.Image | None: + def render(path: Path, extension: str) -> Image.Image | None: """Extract and render a thumbnail for a Krita file. Args: path (Path): The path of the file. + extension (str): The file extension. """ - with zipfile.ZipFile(path, "r") as zip_file: - # Check if the file exists in the zip - if thumbnail_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data: bytes = zip_file.read(thumbnail_path_within_zip) - embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) - - if embedded_thumbnail: - rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") - rendered_image.paste(embedded_thumbnail) - return rendered_image - else: - logger.error("[KritaRenderer] Couldn't render thumbnail", path=path) + try: + with zipfile.ZipFile(path, "r") as zip_file: + # Check if the file exists in the zip + if thumbnail_path_within_zip in zip_file.namelist(): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[KritaRenderer] Couldn't render thumbnail", path=path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py index f2095fd3f..87269ed28 100644 --- a/src/tagstudio/qt/previews/renderers/video_renderer.py +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -18,11 +18,12 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path) -> Image.Image | None: + def render(path: Path, extension: str) -> Image.Image | None: """Render a thumbnail for a video file. Args: path (Path): The path of the file. + extension (str): The file extension. """ try: if is_readable_video(path): @@ -61,8 +62,6 @@ def render(path: Path) -> Image.Image | None: DecompressionBombError, OSError, ) as e: - logger.error( - "[VideoRenderer] Couldn't render thumbnail", path=path, error=type(e).__name__ - ) + logger.error("[VideoRenderer] Couldn't render thumbnail", path=path, error=e) return None From e979043d29c3d96f90672768aa9684426c33e349 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 15:28:59 -0500 Subject: [PATCH 03/20] Oops, remove that --- src/tagstudio/qt/previews/renderers/ebook_renderer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py index 9c3f4ac49..a1b9aaa38 100644 --- a/src/tagstudio/qt/previews/renderers/ebook_renderer.py +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -16,8 +16,6 @@ logger = structlog.get_logger(__name__) -thumbnail_path_within_zip: str = "preview.png" - class EBookRenderer(BaseRenderer): def __init__(self): From 4e52f8484bcbd7ecf696baa014719324a1139ccb Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 17:47:13 -0500 Subject: [PATCH 04/20] VTF renderer --- src/tagstudio/qt/previews/renderer.py | 25 -------------- src/tagstudio/qt/previews/renderer_type.py | 2 ++ .../qt/previews/renderers/vtf_renderer.py | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 25 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/vtf_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index b6de61049..2ec058468 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -18,7 +18,6 @@ import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] import rawpy -import srctools import structlog from mutagen import flac, id3, mp4 from mutagen._util import MutagenError @@ -761,25 +760,6 @@ def _blender(filepath: Path) -> Image.Image | None: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im - @staticmethod - def _vtf_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for VTF (Valve Texture Format) images. - - Uses the srctools library for reading VTF files. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with open(filepath, "rb") as f: - vtf = srctools.VTF.read(f) - im = vtf.get(frame=0).to_PIL() - - except (ValueError, FileNotFoundError) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - @staticmethod def _open_doc_thumb(filepath: Path) -> Image.Image | None: """Extract and render a thumbnail for an OpenDocument file. @@ -1446,11 +1426,6 @@ def _render( if renderer_type: image = renderer_type.renderer.render(_filepath, ext) - # VTF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True - ): - image = self._vtf_thumb(_filepath) # Images ======================================================= elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index a5cb8e92f..1cb3a6a08 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -5,10 +5,12 @@ from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer +from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer class RendererType(Enum): EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py new file mode 100644 index 000000000..ef44158fd --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import srctools +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + + +class VTFRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path, extension: str) -> Image.Image | None: + """Extract and render a thumbnail for VTF (Valve Texture Format) images. + + Uses the srctools library for reading VTF files. + + Args: + path (Path): The path of the file. + extension (str): The file extension. + """ + try: + with open(path, "rb") as f: + vtf = srctools.VTF.read(f) + return vtf.get(frame=0).to_PIL() + + except (ValueError, FileNotFoundError) as e: + logger.error("[VTFRenderer] Couldn't render thumbnail", path=path, error=e) + + return None From 55c480c0ce5ab2bba376c0f6d770fbd61e7eb831 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 17:59:25 -0500 Subject: [PATCH 05/20] Text renderer --- src/tagstudio/qt/previews/renderer.py | 45 ------------- src/tagstudio/qt/previews/renderer_type.py | 7 +++ .../qt/previews/renderers/text_renderer.py | 63 +++++++++++++++++++ 3 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/text_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 2ec058468..33253511f 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -56,7 +56,6 @@ from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.core.utils.encoding import detect_char_encoding from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES from tagstudio.qt.helpers.color_overlay import theme_fg_overlay @@ -1141,45 +1140,6 @@ def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: # Replace transparent pixels with white (otherwise Background defaults to transparent) return replace_transparent_pixels(im) - @staticmethod - def _text_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a plaintext file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = 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, 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, - FileNotFoundError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - def render( self, timestamp: float, @@ -1457,11 +1417,6 @@ def _render( # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) - # Plain Text =================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True - ): - image = self._text_thumb(_filepath) # Fonts ======================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 1cb3a6a08..bbe583fe8 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -4,16 +4,23 @@ from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.text_renderer import TextRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer class RendererType(Enum): EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer + + # Project files KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer + VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer + TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer + def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): self.__name: str = name self.media_category: MediaCategories = media_category diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py new file mode 100644 index 000000000..d337cd0d6 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -0,0 +1,63 @@ +from pathlib import Path + +import cv2 +import structlog +from PIL import ( + Image, + ImageDraw, + UnidentifiedImageError, +) +from PIL.Image import DecompressionBombError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.core.utils.encoding import detect_char_encoding +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + + +class TextRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path, extension: str) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + path (Path): The path of the file. + extension (str): The file extension. + """ + 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: + # Read text file + encoding = detect_char_encoding(path) + with open(path, encoding=encoding) as text_file: + text = text_file.read(256) + + rendered_image = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(rendered_image) + draw.text((16, 16), text, fill=fg_color) + return rendered_image + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + FileNotFoundError, + ) as e: + logger.error("Couldn't render thumbnail", path=path, error=e) + + return None From 9a72d9382292afe6e40203047d176bb40618b3c9 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 19:16:32 -0500 Subject: [PATCH 06/20] Font renderer --- src/tagstudio/qt/helpers/image_effects.py | 46 +++++ src/tagstudio/qt/previews/renderer.py | 185 ++---------------- src/tagstudio/qt/previews/renderer_type.py | 2 + .../qt/previews/renderers/base_renderer.py | 2 +- .../qt/previews/renderers/ebook_renderer.py | 4 +- .../qt/previews/renderers/font_renderer.py | 137 +++++++++++++ .../qt/previews/renderers/krita_renderer.py | 4 +- .../qt/previews/renderers/text_renderer.py | 4 +- .../qt/previews/renderers/video_renderer.py | 4 +- .../qt/previews/renderers/vtf_renderer.py | 4 +- 10 files changed, 217 insertions(+), 175 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/font_renderer.py diff --git a/src/tagstudio/qt/helpers/image_effects.py b/src/tagstudio/qt/helpers/image_effects.py index ca764744d..b4f960bfe 100644 --- a/src/tagstudio/qt/helpers/image_effects.py +++ b/src/tagstudio/qt/helpers/image_effects.py @@ -5,6 +5,10 @@ import numpy as np from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color def replace_transparent_pixels( @@ -26,3 +30,45 @@ def replace_transparent_pixels( pixel_array = np.asarray(img.convert("RGBA")).copy() pixel_array[pixel_array[:, :, 3] == 0] = color return Image.fromarray(pixel_array) + + +def apply_overlay_color(image: Image.Image, color: UiColor) -> Image.Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + overlay_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + overlay: Image.Image = Image.new(image.mode, image.size, color=overlay_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(overlay, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 33253511f..7e7cb2e7d 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -11,7 +11,7 @@ from copy import deepcopy from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from warnings import catch_warnings import cv2 @@ -27,7 +27,6 @@ ImageDraw, ImageEnhance, ImageFile, - ImageFont, ImageOps, ImageQt, UnidentifiedImageError, @@ -49,20 +48,14 @@ from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer -from tagstudio.core.constants import ( - FONT_SAMPLE_SIZES, - FONT_SAMPLE_TEXT, -) from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories, MediaType from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES -from tagstudio.qt.helpers.color_overlay import theme_fg_overlay from tagstudio.qt.helpers.gradients import four_corner_gradient -from tagstudio.qt.helpers.image_effects import replace_transparent_pixels -from tagstudio.qt.helpers.text_wrapper import wrap_full_text -from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color +from tagstudio.qt.helpers.image_effects import apply_overlay_color, replace_transparent_pixels +from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor from tagstudio.qt.previews.renderer_type import RendererType from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb from tagstudio.qt.previews.vendored.pydub.audio_segment import ( @@ -242,7 +235,7 @@ def _render_mask( im: Image.Image = Image.new( mode="L", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="black", ) draw = ImageDraw.Draw(im) @@ -273,7 +266,7 @@ def _render_edge( # Highlight im_hl: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_hl) @@ -292,7 +285,7 @@ def _render_edge( # Shadow im_sh: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_sh) @@ -337,7 +330,7 @@ def _render_center_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#FF000000", ) @@ -345,13 +338,13 @@ def _render_center_icon( bg: Image.Image bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000FF", ) # Use a background image if provided if bg_image: - bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg_im = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) bg_im = ImageEnhance.Brightness(bg_im).enhance(0.3) # Reduce the brightness bg.paste(bg_im) @@ -412,7 +405,7 @@ def _render_center_icon( ) # Apply color overlay - im = self._apply_overlay_color( + im = apply_overlay_color( im, color, ) @@ -444,23 +437,23 @@ def _render_corner_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) bg: Image.Image # Use a background image if provided if bg_image: - bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) # Create solid background color else: bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000", ) # Apply color overlay - bg = self._apply_overlay_color( + bg = apply_overlay_color( im, color, ) @@ -518,47 +511,6 @@ def _render_corner_icon( return im - def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Image: - """Apply a color overlay effect to an image based on its color channel data. - - Red channel for foreground, green channel for outline, none for background. - - Args: - image (Image.Image): The image to apply an overlay to. - color (UiColor): The name of the ColorType color to use. - """ - bg_color: str = ( - get_ui_color(ColorType.DARK_ACCENT, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.PRIMARY, color) - ) - fg_color: str = ( - get_ui_color(ColorType.PRIMARY, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - ol_color: str = ( - get_ui_color(ColorType.BORDER, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - - bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) - fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) - ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) - - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - - if image.mode == "RGBA": - alpha_bg: Image.Image = bg.copy() - alpha_bg.convert("RGBA") - alpha_bg.putalpha(0) - alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) - bg = alpha_bg - - return bg - def _apply_edge( self, image: Image.Image, @@ -808,101 +760,6 @@ def _powerpoint_thumb(filepath: Path) -> Image.Image | None: return im - def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None: - """Render a small font preview ("Aa") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = 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)) - - m, n = data.shape[:2] - col: np.ndarray = cast(np.ndarray, data.any(0)) - row: np.ndarray = cast(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, UiColor.BLUE) - except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _font_long_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a large font preview ("Alphabet") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - im: Image.Image | None = 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.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, # pyright: ignore[reportArgumentType] - 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: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - @staticmethod def _image_raw_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a RAW image type. @@ -1384,7 +1241,7 @@ def _render( renderer_type: RendererType | None = RendererType.get_renderer_type(ext) logger.debug("[ThumbRenderer]", renderer_type=renderer_type) if renderer_type: - image = renderer_type.renderer.render(_filepath, ext) + image = renderer_type.renderer.render(_filepath, ext, adj_size, is_grid_thumb) # Images ======================================================= elif MediaCategories.is_ext_in_category( @@ -1417,16 +1274,6 @@ def _render( # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) - # Fonts ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - if is_grid_thumb: - # 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 MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True @@ -1436,7 +1283,7 @@ def _render( image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) savable_media_type = False if image is not None: - image = self._apply_overlay_color(image, UiColor.GREEN) + image = apply_overlay_color(image, UiColor.GREEN) # Blender ====================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index bbe583fe8..5a12bb989 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -3,6 +3,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer +from tagstudio.qt.previews.renderers.font_renderer import FontRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer @@ -20,6 +21,7 @@ class RendererType(Enum): VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer + FONT = "font", MediaCategories.FONT_TYPES, FontRenderer def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): self.__name: str = name diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py index f487d530d..5b664e0d3 100644 --- a/src/tagstudio/qt/previews/renderers/base_renderer.py +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -11,5 +11,5 @@ def __init__(self) -> None: @staticmethod @abstractmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py index a1b9aaa38..d967030fa 100644 --- a/src/tagstudio/qt/previews/renderers/ebook_renderer.py +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -22,12 +22,14 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: path (Path): The path to the ePub file. extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. Returns: Image: The cover specified in ComicInfo.xml, diff --git a/src/tagstudio/qt/previews/renderers/font_renderer.py b/src/tagstudio/qt/previews/renderers/font_renderer.py new file mode 100644 index 000000000..6ffc4f0db --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/font_renderer.py @@ -0,0 +1,137 @@ +import math +from pathlib import Path +from typing import cast + +import numpy as np +import structlog +from PIL import ( + Image, + ImageDraw, + ImageFont, +) + +from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.qt.helpers.color_overlay import theme_fg_overlay +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.helpers.text_wrapper import wrap_full_text +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer + +logger = structlog.get_logger(__name__) + + +class FontRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + path (Path): The path of the file. + extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + """ + if is_grid_thumb: + return FontRenderer._font_short_thumb(path, size) + else: + return FontRenderer._font_long_thumb(path, size) + + @staticmethod + def _font_short_thumb(path: Path, size: int) -> Image.Image | None: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + path (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + 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(path, 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)) + + m, n = data.shape[:2] + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_image: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(size // 16) + + orig_x, orig_y = cropped_image.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_image = cropped_image.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_image, + box=(margin, margin + ((size - new_y) // 2)), + ) + return apply_overlay_color(bg, UiColor.BLUE) + except OSError as e: + logger.error("Couldn't render thumbnail", path=path, error=type(e).__name__) + + return None + + @staticmethod + def _font_long_thumb(path: Path, size: int) -> Image.Image | None: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + path (Path): The path of the file. + size (tuple[int,int]): The size of the thumbnail. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + 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.0 + + for font_size in scaled_sizes: + font = ImageFont.truetype(path, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, + font=font, # pyright: ignore[reportArgumentType] + 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] + return theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("[FontRenderer] Couldn't render thumbnail", path=path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index 5bd06eb4a..5ed85390c 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -17,12 +17,14 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: """Extract and render a thumbnail for a Krita file. Args: path (Path): The path of the file. extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. """ try: with zipfile.ZipFile(path, "r") as zip_file: diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py index d337cd0d6..22bf3ea43 100644 --- a/src/tagstudio/qt/previews/renderers/text_renderer.py +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -22,12 +22,14 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: """Render a thumbnail for a plaintext file. Args: path (Path): The path of the file. extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. """ bg_color: str = ( "#1e1e1e" diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py index 87269ed28..8311ae850 100644 --- a/src/tagstudio/qt/previews/renderers/video_renderer.py +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -18,12 +18,14 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: """Render a thumbnail for a video file. Args: path (Path): The path of the file. extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. """ try: if is_readable_video(path): diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py index ef44158fd..7e249be4c 100644 --- a/src/tagstudio/qt/previews/renderers/vtf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -14,7 +14,7 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str) -> Image.Image | None: + def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: """Extract and render a thumbnail for VTF (Valve Texture Format) images. Uses the srctools library for reading VTF files. @@ -22,6 +22,8 @@ def render(path: Path, extension: str) -> Image.Image | None: Args: path (Path): The path of the file. extension (str): The file extension. + size (tuple[int,int]): The size of the thumbnail. + is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. """ try: with open(path, "rb") as f: From 7ef51b3ae458b5f2c3dd1a02b2263ad85aa2c0f7 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 11:10:06 -0500 Subject: [PATCH 07/20] Audio renderer, use a context object for params for each renderer --- src/tagstudio/qt/previews/renderer.py | 162 ++--------------- src/tagstudio/qt/previews/renderer_type.py | 2 + .../qt/previews/renderers/audio_renderer.py | 170 ++++++++++++++++++ .../qt/previews/renderers/base_renderer.py | 12 +- .../qt/previews/renderers/ebook_renderer.py | 22 +-- .../qt/previews/renderers/font_renderer.py | 62 +++---- .../qt/previews/renderers/krita_renderer.py | 14 +- .../qt/previews/renderers/text_renderer.py | 17 +- .../qt/previews/renderers/video_renderer.py | 16 +- .../qt/previews/renderers/vtf_renderer.py | 15 +- 10 files changed, 258 insertions(+), 234 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/audio_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 7e7cb2e7d..e74fce5cc 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -12,15 +12,12 @@ from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING -from warnings import catch_warnings import cv2 import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] import rawpy import structlog -from mutagen import flac, id3, mp4 -from mutagen._util import MutagenError from PIL import ( Image, ImageChops, @@ -57,10 +54,8 @@ from tagstudio.qt.helpers.image_effects import apply_overlay_color, replace_transparent_pixels from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor from tagstudio.qt.previews.renderer_type import RendererType +from tagstudio.qt.previews.renderers.base_renderer import RendererContext from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb -from tagstudio.qt.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, -) from tagstudio.qt.resource_manager import ResourceManager if TYPE_CHECKING: @@ -547,135 +542,6 @@ def _apply_edge( return im - @staticmethod - def _audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Return an album cover thumb from an audio file if a cover is present. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - """ - image: Image.Image | None = None - try: - if not filepath.is_file(): - raise FileNotFoundError - - artwork = None - if ext in [".mp3"]: - id3_tags: id3.ID3 = id3.ID3(filepath) - id3_covers: list = id3_tags.getall("APIC") - if id3_covers: - artwork = Image.open(BytesIO(id3_covers[0].data)) - elif ext in [".flac"]: - flac_tags: flac.FLAC = flac.FLAC(filepath) - flac_covers: list = flac_tags.pictures - if flac_covers: - artwork = Image.open(BytesIO(flac_covers[0].data)) - elif ext in [".mp4", ".m4a", ".aac"]: - mp4_tags: mp4.MP4 = mp4.MP4(filepath) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] - if mp4_covers: - artwork = Image.open(BytesIO(mp4_covers[0])) - if artwork: - image = artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) - return image - - @staticmethod - def _audio_waveform_thumb( - filepath: Path, ext: str, size: int, pixel_ratio: float - ) -> Image.Image | None: - """Render a waveform image from an audio file. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - size (tuple[int,int]): The size of the thumbnail. - pixel_ratio (float): The screen pixel ratio. - """ - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - base_scale: int = 2 - samples_per_bar: int = 3 - size_scaled: int = size * base_scale - allow_small_min: bool = False - im: Image.Image | None = None - - try: - bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(filepath, ext[1:]) - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = (size_scaled) - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) - - im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(im) - - current_x = bar_margin - for item in max_array: - item_height = item / line_ratio - - # If small minimums are not allowed, raise all values - # smaller than the line width to the same value. - if not allow_small_min: - item_height = max(item_height, line_width) - - current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 - - draw.rounded_rectangle( - ( - current_x, - current_y, - (current_x + line_width), - (current_y + item_height), - ), - radius=100 * base_scale, - fill=("#FF0000"), - outline=("#FFFF00"), - width=max(math.ceil(line_width / 6), base_scale), - ) - - current_x = current_x + line_width + bar_margin - - im.resize((size, size), Image.Resampling.BILINEAR) - - except Exception as e: - logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) - - return im - @staticmethod def _blender(filepath: Path) -> Image.Image | None: """Get an emended thumbnail from a Blender file, if a thumbnail is present. @@ -1239,9 +1105,21 @@ def _render( ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower() renderer_type: RendererType | None = RendererType.get_renderer_type(ext) - logger.debug("[ThumbRenderer]", renderer_type=renderer_type) + renderer_context: RendererContext = RendererContext( + path=_filepath, + extension=ext, + size=adj_size, + pixel_ratio=pixel_ratio, + is_grid_thumb=is_grid_thumb, + ) + + logger.debug( + "[ThumbRenderer]", + renderer_type=renderer_type, + renderer_context=renderer_context, + ) if renderer_type: - image = renderer_type.renderer.render(_filepath, ext, adj_size, is_grid_thumb) + image = renderer_type.renderer.render(renderer_context) # Images ======================================================= elif MediaCategories.is_ext_in_category( @@ -1274,16 +1152,6 @@ def _render( # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) - # Audio ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - image = self._audio_album_thumb(_filepath, ext) - if image is None: - image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) - savable_media_type = False - if image is not None: - image = apply_overlay_color(image, UiColor.GREEN) # Blender ====================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 5a12bb989..f06e57caa 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -1,6 +1,7 @@ from enum import Enum from tagstudio.core.media_types import MediaCategories +from tagstudio.qt.previews.renderers.audio_renderer import AudioRenderer from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer @@ -19,6 +20,7 @@ class RendererType(Enum): KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer + AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer FONT = "font", MediaCategories.FONT_TYPES, FontRenderer diff --git a/src/tagstudio/qt/previews/renderers/audio_renderer.py b/src/tagstudio/qt/previews/renderers/audio_renderer.py new file mode 100644 index 000000000..0ad95fac7 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/audio_renderer.py @@ -0,0 +1,170 @@ +import math +from io import BytesIO +from warnings import catch_warnings + +import numpy as np +import structlog +from mutagen import MutagenError, flac, id3, mp4 +from PIL import ( + Image, + ImageDraw, +) +from pydub import AudioSegment + +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class AudioRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an audio file. + + Args: + context (RendererContext): The renderer context. + """ + rendered_image: Image.Image | None = AudioRenderer._audio_album_thumb(context) + + if rendered_image is None: + rendered_image = AudioRenderer._audio_waveform_thumb(context) + if rendered_image is not None: + rendered_image = apply_overlay_color(rendered_image, UiColor.GREEN) + + return rendered_image + + @staticmethod + def _audio_album_thumb(context: RendererContext) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + context (RendererContext): The renderer context. + """ + try: + if not context.path.is_file(): + raise FileNotFoundError + + artwork = None + + # Get cover from .mp3 tags + if context.extension in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(context.path) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + + # Get cover from .flac tags + elif context.extension in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(context.path) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + + # Get cover from .mp4 tags + elif context.extension in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(context.path) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + + return artwork + except ( + FileNotFoundError, + id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + MutagenError, + ) as e: + logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) + + return None + + @staticmethod + def _audio_waveform_thumb(context: RendererContext) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + context (RendererContext): The renderer context. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = context.size * base_scale + allow_small_min: bool = False + + try: + bar_count: int = min(math.floor((context.size // context.pixel_ratio) / 5), 64) + audio = AudioSegment.from_file(context.path, context.extension[1:]) + data = np.frombuffer(buffer=audio._data, dtype=np.int16) + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = size_scaled - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list[int] = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + with catch_warnings(record=True): + if abs(d) > maximum_item: + maximum_item = int(abs(d)) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + rendered_image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(rendered_image) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill="#FF0000", + outline="#FFFF00", + width=max(math.ceil(line_width / 6), base_scale), + ) + + current_x = current_x + line_width + bar_margin + + rendered_image.resize((context.size, context.size), Image.Resampling.BILINEAR) + return rendered_image + + except Exception as e: + logger.error( + "[AudioRenderer] Couldn't render waveform", path=context.path.name, error=e + ) + + return None diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py index 5b664e0d3..983d66758 100644 --- a/src/tagstudio/qt/previews/renderers/base_renderer.py +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -1,9 +1,19 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass from pathlib import Path from PIL import Image +@dataclass(kw_only=True) +class RendererContext: + path: Path + extension: str + size: int + pixel_ratio: float + is_grid_thumb: bool + + class BaseRenderer(ABC): @abstractmethod def __init__(self) -> None: @@ -11,5 +21,5 @@ def __init__(self) -> None: @staticmethod @abstractmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py index d967030fa..aae2c3124 100644 --- a/src/tagstudio/qt/previews/renderers/ebook_renderer.py +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -1,5 +1,4 @@ from io import BytesIO -from pathlib import Path from xml.etree import ElementTree from xml.etree.ElementTree import Element @@ -12,7 +11,7 @@ from tagstudio.qt.helpers.file_wrappers.archive.seven_zip_file import SevenZipFile from tagstudio.qt.helpers.file_wrappers.archive.tar_file import TarFile from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -22,14 +21,11 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: - path (Path): The path to the ePub file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. Returns: Image: The cover specified in ComicInfo.xml, @@ -37,15 +33,15 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. """ try: archive: ArchiveFile | None = None - match extension: + match context.extension: case ".cb7": - archive = SevenZipFile(path, "r") + archive = SevenZipFile(context.path, "r") case ".cbr": - archive = RarFile(path, "r") + archive = RarFile(context.path, "r") case ".cbt": - archive = TarFile(path, "r") + archive = TarFile(context.path, "r") case _: - archive = ZipFile(path, "r") + archive = ZipFile(context.path, "r") rendered_image: Image.Image | None = None @@ -72,7 +68,7 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. return rendered_image except Exception as e: - logger.error("[EBookRenderer] Couldn't render thumbnail", path=path, error=e) + logger.error("[EBookRenderer] Couldn't render thumbnail", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/font_renderer.py b/src/tagstudio/qt/previews/renderers/font_renderer.py index 6ffc4f0db..1344e3027 100644 --- a/src/tagstudio/qt/previews/renderers/font_renderer.py +++ b/src/tagstudio/qt/previews/renderers/font_renderer.py @@ -1,5 +1,4 @@ import math -from pathlib import Path from typing import cast import numpy as np @@ -15,7 +14,7 @@ from tagstudio.qt.helpers.image_effects import apply_overlay_color from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.models.palette import UiColor -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -25,39 +24,35 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Render a thumbnail for a plaintext file. Args: - path (Path): The path of the file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. """ - if is_grid_thumb: - return FontRenderer._font_short_thumb(path, size) + if context.is_grid_thumb: + return FontRenderer._font_short_thumb(context) else: - return FontRenderer._font_long_thumb(path, size) + return FontRenderer._font_long_thumb(context) @staticmethod - def _font_short_thumb(path: Path, size: int) -> Image.Image | None: + def _font_short_thumb(context: RendererContext) -> Image.Image | None: """Render a small font preview ("Aa") thumbnail from a font file. Args: - path (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. + context (RendererContext): The renderer context. """ try: - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + bg = Image.new("RGB", (context.size, context.size), color="#000000") + raw = Image.new("RGB", (context.size * 3, context.size * 3), color="#000000") draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(path, size=size) + font = ImageFont.truetype(context.path, size=context.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), + (context.size // 8, context.size // 8), "Aa", font=font, fill="#FF0000", @@ -76,16 +71,16 @@ def _font_short_thumb(path: Path, size: int) -> Image.Image | None: ] cropped_image: Image.Image = Image.fromarray(cropped_data, "RGB") - margin: int = math.ceil(size // 16) + margin: int = math.ceil(context.size // 16) orig_x, orig_y = cropped_image.size - new_x, new_y = (size, size) + new_x, new_y = (context.size, context.size) if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) + new_x = context.size + new_y = math.ceil(context.size * (orig_y / orig_x)) elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) + new_y = context.size + new_x = math.ceil(context.size * (orig_x / orig_y)) cropped_image = cropped_image.resize( size=(new_x - (margin * 2), new_y - (margin * 2)), @@ -93,37 +88,38 @@ def _font_short_thumb(path: Path, size: int) -> Image.Image | None: ) bg.paste( cropped_image, - box=(margin, margin + ((size - new_y) // 2)), + box=(margin, margin + ((context.size - new_y) // 2)), ) return apply_overlay_color(bg, UiColor.BLUE) except OSError as e: - logger.error("Couldn't render thumbnail", path=path, error=type(e).__name__) + logger.error("Couldn't render thumbnail", path=context.path, error=type(e).__name__) return None @staticmethod - def _font_long_thumb(path: Path, size: int) -> Image.Image | None: + def _font_long_thumb(context: RendererContext) -> Image.Image | None: """Render a large font preview ("Alphabet") thumbnail from a font file. Args: - path (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. + context (RendererContext): The renderer context. """ # Scale the sample font sizes to the preview image # resolution,assuming the sizes are tuned for 256px. try: - scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] - bg = Image.new("RGBA", (size, size), color="#00000000") + scaled_sizes: list[int] = [ + math.floor(x * (context.size / 256)) for x in FONT_SAMPLE_SIZES + ] + bg = Image.new("RGBA", (context.size, context.size), color="#00000000") draw = ImageDraw.Draw(bg) lines_of_padding = 2 y_offset = 0.0 for font_size in scaled_sizes: - font = ImageFont.truetype(path, size=font_size) + font = ImageFont.truetype(context.path, size=font_size) text_wrapped: str = wrap_full_text( FONT_SAMPLE_TEXT, font=font, # pyright: ignore[reportArgumentType] - width=size, + width=context.size, draw=draw, ) draw.multiline_text((0, y_offset), text_wrapped, font=font) @@ -132,6 +128,6 @@ def _font_long_thumb(path: Path, size: int) -> Image.Image | None: )[-1] return theme_fg_overlay(bg, use_alpha=False) except OSError as e: - logger.error("[FontRenderer] Couldn't render thumbnail", path=path, error=e) + logger.error("[FontRenderer] Couldn't render thumbnail", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index 5ed85390c..d29320160 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -1,11 +1,10 @@ import zipfile from io import BytesIO -from pathlib import Path import structlog from PIL import Image -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -17,17 +16,14 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Extract and render a thumbnail for a Krita file. Args: - path (Path): The path of the file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. """ try: - with zipfile.ZipFile(path, "r") as zip_file: + with zipfile.ZipFile(context.path, "r") as zip_file: # Check if the file exists in the zip if thumbnail_path_within_zip in zip_file.namelist(): # Read the specific file into memory @@ -41,6 +37,6 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. else: raise FileNotFoundError except Exception as e: - logger.error("[KritaRenderer] Couldn't render thumbnail", path=path, error=e) + logger.error("[KritaRenderer] Couldn't render thumbnail", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py index 22bf3ea43..3d9598c0d 100644 --- a/src/tagstudio/qt/previews/renderers/text_renderer.py +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -1,5 +1,3 @@ -from pathlib import Path - import cv2 import structlog from PIL import ( @@ -12,7 +10,7 @@ from PySide6.QtGui import QGuiApplication from tagstudio.core.utils.encoding import detect_char_encoding -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -22,14 +20,11 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Render a thumbnail for a plaintext file. Args: - path (Path): The path of the file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. """ bg_color: str = ( "#1e1e1e" @@ -44,8 +39,8 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. try: # Read text file - encoding = detect_char_encoding(path) - with open(path, encoding=encoding) as text_file: + encoding = detect_char_encoding(context.path) + with open(context.path, encoding=encoding) as text_file: text = text_file.read(256) rendered_image = Image.new("RGB", (256, 256), color=bg_color) @@ -60,6 +55,6 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. OSError, FileNotFoundError, ) as e: - logger.error("Couldn't render thumbnail", path=path, error=e) + logger.error("Couldn't render thumbnail", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py index 8311ae850..f1b1bea7b 100644 --- a/src/tagstudio/qt/previews/renderers/video_renderer.py +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -1,5 +1,4 @@ import math -from pathlib import Path import cv2 import structlog @@ -8,7 +7,7 @@ from PIL.Image import DecompressionBombError from tagstudio.qt.helpers.file_tester import is_readable_video -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -18,18 +17,15 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Render a thumbnail for a video file. Args: - path (Path): The path of the file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. """ try: - if is_readable_video(path): - video = cv2.VideoCapture(str(path), cv2.CAP_FFMPEG) + if is_readable_video(context.path): + video = cv2.VideoCapture(str(context.path), cv2.CAP_FFMPEG) # TODO: Move this check to is_readable_video() if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: @@ -64,6 +60,6 @@ def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image. DecompressionBombError, OSError, ) as e: - logger.error("[VideoRenderer] Couldn't render thumbnail", path=path, error=e) + logger.error("[VideoRenderer] Couldn't render thumbnail", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py index 7e249be4c..b17768691 100644 --- a/src/tagstudio/qt/previews/renderers/vtf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -1,10 +1,8 @@ -from pathlib import Path - import srctools import structlog from PIL import Image -from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -14,23 +12,20 @@ def __init__(self): super().__init__() @staticmethod - def render(path: Path, extension: str, size: int, is_grid_thumb: bool) -> Image.Image | None: + def render(context: RendererContext) -> Image.Image | None: """Extract and render a thumbnail for VTF (Valve Texture Format) images. Uses the srctools library for reading VTF files. Args: - path (Path): The path of the file. - extension (str): The file extension. - size (tuple[int,int]): The size of the thumbnail. - is_grid_thumb (bool): Whether the image will be used as a thumbnail in the file grid. + context (RendererContext): The renderer context. """ try: - with open(path, "rb") as f: + with open(context.path, "rb") as f: vtf = srctools.VTF.read(f) return vtf.get(frame=0).to_PIL() except (ValueError, FileNotFoundError) as e: - logger.error("[VTFRenderer] Couldn't render thumbnail", path=path, error=e) + logger.error("[VTFRenderer] Couldn't render thumbnail", path=context.path, error=e) return None From a2c14acd70504dc757babcd29ae87b090489237b Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 13:19:55 -0500 Subject: [PATCH 08/20] Blender renderer --- .../qt/previews/{vendored => renderers}/blender_renderer.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/tagstudio/qt/previews/{vendored => renderers}/blender_renderer.py (100%) diff --git a/src/tagstudio/qt/previews/vendored/blender_renderer.py b/src/tagstudio/qt/previews/renderers/blender_renderer.py similarity index 100% rename from src/tagstudio/qt/previews/vendored/blender_renderer.py rename to src/tagstudio/qt/previews/renderers/blender_renderer.py From 89654b930bd3d01083ac5c33eccbca5db323a58b Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 13:20:50 -0500 Subject: [PATCH 09/20] Blender renderer --- src/tagstudio/qt/previews/renderer.py | 41 ----- src/tagstudio/qt/previews/renderer_type.py | 11 +- .../qt/previews/renderers/blender_renderer.py | 172 +++++++++++------- 3 files changed, 120 insertions(+), 104 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index e74fce5cc..4e1dd2369 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -55,7 +55,6 @@ from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor from tagstudio.qt.previews.renderer_type import RendererType from tagstudio.qt.previews.renderers.base_renderer import RendererContext -from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb from tagstudio.qt.resource_manager import ResourceManager if TYPE_CHECKING: @@ -542,41 +541,6 @@ def _apply_edge( return im - @staticmethod - def _blender(filepath: Path) -> Image.Image | None: - """Get an emended thumbnail from a Blender file, if a thumbnail is present. - - Args: - filepath (Path): The path of the file. - """ - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - im: Image.Image | None = None - 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)) - im = bg - - except ( - AttributeError, - UnidentifiedImageError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": - logger.info( - f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " - f"Doesn't have an embedded thumbnail. ({type(e).__name__})" - ) - - else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - @staticmethod def _open_doc_thumb(filepath: Path) -> Image.Image | None: """Extract and render a thumbnail for an OpenDocument file. @@ -1152,11 +1116,6 @@ def _render( # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) - # Blender ====================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.BLENDER_TYPES, mime_fallback=True - ): - image = self._blender(_filepath) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index f06e57caa..7f0e4596e 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -3,6 +3,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.qt.previews.renderers.audio_renderer import AudioRenderer from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.blender_renderer import BlenderRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer @@ -14,17 +15,23 @@ class RendererType(Enum): EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer - VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer - # Project files KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer + # Model files + BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer + + # Media files VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer + # Text files TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer FONT = "font", MediaCategories.FONT_TYPES, FontRenderer + # Image files + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer + def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): self.__name: str = name self.media_category: MediaCategories = media_category diff --git a/src/tagstudio/qt/previews/renderers/blender_renderer.py b/src/tagstudio/qt/previews/renderers/blender_renderer.py index 012c1503d..7461103f8 100644 --- a/src/tagstudio/qt/previews/renderers/blender_renderer.py +++ b/src/tagstudio/qt/previews/renderers/blender_renderer.py @@ -29,80 +29,130 @@ import struct from io import BufferedReader -from PIL import Image, ImageOps - - -def blend_extract_thumb(path): - rend = b"REND" - test = b"TEST" - - blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 +import structlog +from PIL import Image, ImageOps, UnidentifiedImageError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class BlenderRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Get an emended thumbnail from a Blender file, if a thumbnail is present. + + Args: + context (RendererContext): The renderer context. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + + try: + buffer, width, height = BlenderRenderer.__extract_embedded_thumbnail(str(context.path)) + + if buffer is None: + return None + + embedded_thumbnail = Image.frombuffer( + "RGBA", + (width, height), + buffer, + ) + embedded_thumbnail = ImageOps.flip(embedded_thumbnail) + + rendered_image = Image.new("RGB", embedded_thumbnail.size, color=bg_color) + rendered_image.paste(embedded_thumbnail, mask=embedded_thumbnail.getchannel(3)) + return rendered_image + + except ( + AttributeError, + UnidentifiedImageError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logger.info( + f"[BlenderRenderer] {context.path.name} " + f"doesn't have an embedded thumbnail. ({e})" + ) + + else: + logger.error("Couldn't render thumbnail", path=context.path, error=e) + + return None + + @staticmethod + def __extract_embedded_thumbnail(path) -> tuple[bytes | None, int, int]: + rend = b"REND" + test = b"TEST" + + blender_file: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + + header = blender_file.read(12) + + if header[0:2] == b"\x1f\x8b": # gzip magic + blender_file.close() + blender_file = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + header = blender_file.read(12) + + if not header.startswith(b"BLENDER"): + blender_file.close() + return None, 0, 0 - head = blendfile.read(12) + is_64_bit = header[7] == b"-"[0] - if head[0:2] == b"\x1f\x8b": # gzip magic - blendfile.close() - blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 - head = blendfile.read(12) + # True for PPC, false for X86 + is_big_endian = header[8] == b"V"[0] - if not head.startswith(b"BLENDER"): - blendfile.close() - return None, 0, 0 + # Blender pre-v2.5 had no thumbnails + if header[9:11] <= b"24": + return None, 0, 0 - is_64_bit = head[7] == b"-"[0] + block_header_size = 24 if is_64_bit else 20 + int_endian = ">i" if is_big_endian else " Date: Tue, 18 Nov 2025 13:33:17 -0500 Subject: [PATCH 10/20] PDF renderer --- src/tagstudio/qt/previews/renderer.py | 62 +--------------- src/tagstudio/qt/previews/renderer_type.py | 7 +- .../qt/previews/renderers/pdf_renderer.py | 72 +++++++++++++++++++ 3 files changed, 79 insertions(+), 62 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/pdf_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 4e1dd2369..28c267f71 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -32,17 +32,12 @@ from pillow_heif import register_heif_opener from PySide6.QtCore import ( QBuffer, - QFile, - QFileDevice, - QIODeviceBase, QObject, QSize, - QSizeF, Qt, Signal, ) from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap -from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions from PySide6.QtSvg import QSvgRenderer from tagstudio.core.exceptions import NoRendererError @@ -51,7 +46,7 @@ from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES from tagstudio.qt.helpers.gradients import four_corner_gradient -from tagstudio.qt.helpers.image_effects import apply_overlay_color, replace_transparent_pixels +from tagstudio.qt.helpers.image_effects import apply_overlay_color from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor from tagstudio.qt.previews.renderer_type import RendererType from tagstudio.qt.previews.renderers.base_renderer import RendererContext @@ -698,7 +693,7 @@ def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: # Write the image to a buffer as png buffer: QBuffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) - q_image.save(buffer, "PNG") # type: ignore[call-overload] + q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] # Load the image from the buffer im = Image.new("RGB", (size, size), color="#1e1e1e") @@ -779,54 +774,6 @@ def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: return im - @staticmethod - def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a thumbnail for a PDF file. - - filepath (Path): The path of the file. - size (int): The size of the icon. - """ - im: Image.Image | None = None - - file: QFile = QFile(filepath) - success: bool = file.open( - QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser - ) - if not success: - logger.error("Couldn't render thumbnail", filepath=filepath) - return im - document: QPdfDocument = QPdfDocument() - document.load(file) - file.close() - # Transform page_size in points to pixels with proper aspect ratio - page_size: QSizeF = document.pagePointSize(0) - ratio_hw: float = page_size.height() / page_size.width() - if ratio_hw >= 1: - page_size *= size / page_size.height() - else: - page_size *= size / page_size.width() - # Enlarge image for antialiasing - scale_factor = 2.5 - page_size *= scale_factor - # Render image with no anti-aliasing for speed - render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() - render_options.setRenderFlags( - QPdfDocumentRenderOptions.RenderFlag.TextAliased - | QPdfDocumentRenderOptions.RenderFlag.ImageAliased - | QPdfDocumentRenderOptions.RenderFlag.PathAliased - ) - # Convert QImage to PIL Image - q_image: QImage = document.render(0, page_size.toSize(), render_options) - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - try: - q_image.save(buffer, "PNG") # type: ignore # pyright: ignore - im = Image.open(BytesIO(buffer.buffer().data())) - finally: - buffer.close() - # Replace transparent pixels with white (otherwise Background defaults to transparent) - return replace_transparent_pixels(im) - def render( self, timestamp: float, @@ -1116,11 +1063,6 @@ def _render( # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) - # PDF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PDF_TYPES, mime_fallback=True - ): - image = self._pdf_thumb(_filepath, adj_size) # No Rendered Thumbnail ======================================== if not image: raise NoRendererError diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 7f0e4596e..f14d851aa 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -7,14 +7,13 @@ from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer class RendererType(Enum): - EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer - # Project files KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer @@ -25,6 +24,10 @@ class RendererType(Enum): VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer + # Document files + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer + PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer + # Text files TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer FONT = "font", MediaCategories.FONT_TYPES, FontRenderer diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py new file mode 100644 index 000000000..9fcc5053f --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -0,0 +1,72 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) +from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF +from PySide6.QtGui import QImage +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions + +from tagstudio.qt.helpers.image_effects import replace_transparent_pixels +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class PDFRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a PDF file. + + Args: + context (RendererContext): The renderer context. + """ + try: + file: QFile = QFile(context.path) + success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) + + if not success: + raise FileNotFoundError + + document: QPdfDocument = QPdfDocument() + document.load(file) + file.close() + + # Transform page_size in points to pixels with proper aspect ratio + page_size: QSizeF = document.pagePointSize(0) + ratio_hw: float = page_size.height() / page_size.width() + if ratio_hw >= 1: + page_size *= context.size / page_size.height() + else: + page_size *= context.size / page_size.width() + + # Enlarge image for antialiasing + scale_factor = 2.5 + page_size *= scale_factor + + # Render image with no antialiasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags(QPdfDocumentRenderOptions.RenderFlag.TextAliased) + + # Convert QImage to PIL Image + q_image: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + q_image.save(buffer, "PNG") # type: ignore[unused-ignore] # pyright: ignore + rendered_thumbnail = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(rendered_thumbnail) + + except FileNotFoundError as e: + logger.error("[AudioRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None From f1c60c82124136e82bd086ff4200052842d0b3e6 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 13:54:05 -0500 Subject: [PATCH 11/20] PowerPoint renderer --- src/tagstudio/core/media_types.py | 13 ++++-- .../helpers/file_wrappers/archive/rar_file.py | 14 +++++- .../file_wrappers/archive/seven_zip_file.py | 14 +++++- .../helpers/file_wrappers/archive/tar_file.py | 14 +++++- .../helpers/file_wrappers/archive/zip_file.py | 14 +++++- src/tagstudio/qt/previews/renderer.py | 29 ------------ src/tagstudio/qt/previews/renderer_type.py | 4 +- .../qt/previews/renderers/krita_renderer.py | 7 +-- .../qt/previews/renderers/pdf_renderer.py | 2 +- .../previews/renderers/powerpoint_renderer.py | 45 +++++++++++++++++++ 10 files changed, 115 insertions(+), 41 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/powerpoint_renderer.py diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 8659c389d..cc39c586b 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -51,6 +51,7 @@ class MediaType(str, Enum): PACKAGE = "package" PDF = "pdf" PLAINTEXT = "plaintext" + POWERPOINT = "powerpoint" PRESENTATION = "presentation" PROGRAM = "program" SHADER = "shader" @@ -109,7 +110,6 @@ class MediaCategories: ".psd", } _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} - _KRITA_SET: set[str] = {".kra", ".krz"} _ARCHIVE_SET: set[str] = { ".7z", ".gz", @@ -334,6 +334,7 @@ class MediaCategories: } _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _IWORK_SET: set[str] = {".key", ".pages", ".numbers"} + _KRITA_SET: set[str] = {".kra", ".krz"} _MATERIAL_SET: set[str] = {".mtl"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} _OPEN_DOCUMENT_SET: set[str] = { @@ -375,11 +376,11 @@ class MediaCategories: "license", "readme", } + _POWERPOINT_SET: set[str] = {".pptx"} _PRESENTATION_SET: set[str] = { ".key", ".odp", ".ppt", - ".pptx", } _PROGRAM_SET: set[str] = {".app", ".bin", ".exe"} _SOURCE_ENGINE_SET: set[str] = {".vtf"} @@ -566,9 +567,15 @@ class MediaCategories: is_iana=False, name="plaintext", ) + POWERPOINT_TYPES = MediaCategory( + media_type=MediaType.POWERPOINT, + extensions=_POWERPOINT_SET, + is_iana=False, + name="powerpoint", + ) PRESENTATION_TYPES = MediaCategory( media_type=MediaType.PRESENTATION, - extensions=_PRESENTATION_SET, + extensions=_PRESENTATION_SET | _POWERPOINT_SET, is_iana=False, name="presentation", ) diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py index 2a0c26708..5866327f3 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Literal +from types import TracebackType +from typing import Literal, Self import rarfile @@ -13,6 +14,17 @@ def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode) + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__rar_file.close() + def get_name_list(self) -> list[str]: return self.__rar_file.namelist() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py index 0d984ba30..790b3d126 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Literal +from types import TracebackType +from typing import Literal, Self import py7zr @@ -13,6 +14,17 @@ def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode) + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__seven_zip_file.close() + def get_name_list(self) -> list[str]: return self.__seven_zip_file.namelist() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py index b79a2da27..5be25af3e 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -1,6 +1,7 @@ import tarfile from pathlib import Path -from typing import Literal +from types import TracebackType +from typing import Literal, Self from tagstudio.core.utils.types import unwrap from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile @@ -13,6 +14,17 @@ def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode) + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__tar_file.close() + def get_name_list(self) -> list[str]: return self.__tar_file.getnames() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py index 3b17c8255..720efb04e 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -1,6 +1,7 @@ import zipfile from pathlib import Path -from typing import Literal +from types import TracebackType +from typing import Literal, Self from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile @@ -12,6 +13,17 @@ def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) self.__zip_file: zipfile.ZipFile = zipfile.ZipFile(path, mode) + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__zip_file.close() + def get_name_list(self) -> list[str]: return self.__zip_file.namelist() diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 28c267f71..07c311752 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -559,32 +559,6 @@ def _open_doc_thumb(filepath: Path) -> Image.Image | None: return im - @staticmethod - def _powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "docProps/thumbnail.jpeg" - im: Image.Image | None = None - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - @staticmethod def _image_raw_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a RAW image type. @@ -1052,9 +1026,6 @@ def _render( # Normal Images -------------------------------------------- else: image = self._image_thumb(_filepath) - # PowerPoint Slideshow - elif ext in {".pptx"}: - image = self._powerpoint_thumb(_filepath) # OpenDocument/OpenOffice ====================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index f14d851aa..63652e49e 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -8,6 +8,7 @@ from tagstudio.qt.previews.renderers.font_renderer import FontRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer +from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer @@ -25,8 +26,9 @@ class RendererType(Enum): AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer # Document files - EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer + POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer # Text files TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index d29320160..40861fab3 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -1,9 +1,9 @@ -import zipfile from io import BytesIO import structlog from PIL import Image +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -23,9 +23,10 @@ def render(context: RendererContext) -> Image.Image | None: context (RendererContext): The renderer context. """ try: - with zipfile.ZipFile(context.path, "r") as zip_file: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: # Check if the file exists in the zip - if thumbnail_path_within_zip in zip_file.namelist(): + if zip_file.has_file_name(thumbnail_path_within_zip): # Read the specific file into memory file_data: bytes = zip_file.read(thumbnail_path_within_zip) embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py index 9fcc5053f..1dead1836 100644 --- a/src/tagstudio/qt/previews/renderers/pdf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -59,7 +59,7 @@ def render(context: RendererContext) -> Image.Image | None: buffer: QBuffer = QBuffer() buffer.open(QBuffer.OpenModeFlag.ReadWrite) try: - q_image.save(buffer, "PNG") # type: ignore[unused-ignore] # pyright: ignore + q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] # pyright: ignore rendered_thumbnail = Image.open(BytesIO(buffer.buffer().data())) finally: buffer.close() diff --git a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py new file mode 100644 index 000000000..fba33944b --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py @@ -0,0 +1,45 @@ +from io import BytesIO + +import structlog +from PIL import Image + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "docProps/thumbnail.jpeg" + + +class PowerPointRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error( + "[PowerPointRenderer] Couldn't render thumbnail", path=context.path, error=e + ) + + return None From fde37818f27b7fe10ee40e8541b3f333096647b4 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 14:04:36 -0500 Subject: [PATCH 12/20] OpenDoc renderer --- src/tagstudio/qt/previews/renderer.py | 28 ----------- src/tagstudio/qt/previews/renderer_type.py | 2 + .../previews/renderers/open_doc_renderer.py | 46 +++++++++++++++++++ .../qt/previews/renderers/pdf_renderer.py | 2 +- 4 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/open_doc_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 07c311752..5e0d4c5dc 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -536,29 +536,6 @@ def _apply_edge( return im - @staticmethod - def _open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "Thumbnails/thumbnail.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - @staticmethod def _image_raw_thumb(filepath: Path) -> Image.Image | None: """Render a thumbnail for a RAW image type. @@ -1026,11 +1003,6 @@ def _render( # Normal Images -------------------------------------------- else: image = self._image_thumb(_filepath) - # OpenDocument/OpenOffice ====================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True - ): - image = self._open_doc_thumb(_filepath) # Apple iWork Suite ============================================ elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): image = self._iwork_thumb(_filepath) diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 63652e49e..cc1882aab 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -7,6 +7,7 @@ from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer @@ -26,6 +27,7 @@ class RendererType(Enum): AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer # Document files + OPEN_DOC = "open_doc", MediaCategories.OPEN_DOCUMENT_TYPES, OpenDocRenderer POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer diff --git a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py new file mode 100644 index 000000000..4c32c09a3 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py @@ -0,0 +1,46 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +thumbnail_path_within_zip: str = "Thumbnails/thumbnail.png" + + +class OpenDocRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an OpenDocument file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes = zip_file.read(thumbnail_path_within_zip) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[OpenDocRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py index 1dead1836..84cb76d72 100644 --- a/src/tagstudio/qt/previews/renderers/pdf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -67,6 +67,6 @@ def render(context: RendererContext) -> Image.Image | None: return replace_transparent_pixels(rendered_thumbnail) except FileNotFoundError as e: - logger.error("[AudioRenderer] Couldn't render thumbnail", path=context.path, error=e) + logger.error("[PDFRenderer] Couldn't render thumbnail", path=context.path, error=e) return None From 439f6ae611546f6d447de11bb470601d06255fe7 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 14:21:23 -0500 Subject: [PATCH 13/20] iWork renderer (and fix it straight up just not working) --- src/tagstudio/qt/previews/renderer.py | 42 -------------- src/tagstudio/qt/previews/renderer_type.py | 2 + .../qt/previews/renderers/iwork_renderer.py | 55 +++++++++++++++++++ 3 files changed, 57 insertions(+), 42 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/iwork_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 5e0d4c5dc..86fab53d0 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,7 +7,6 @@ import hashlib import math import os -import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path @@ -654,44 +653,6 @@ def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: buffer.close() return im - @staticmethod - def _iwork_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. - - Args: - filepath (Path): The path of the file. - """ - preview_thumb_dir = "preview.jpg" - quicklook_thumb_dir = "QuickLook/Thumbnail.jpg" - im: Image.Image | None = None - - def get_image(path: str) -> Image.Image | None: - thumb_im: Image.Image | None = None - # Read the specific file into memory - file_data = zip_file.read(path) - thumb_im = Image.open(BytesIO(file_data)) - return thumb_im - - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - thumb: Image.Image | None = None - - # Check if the file exists in the zip - if preview_thumb_dir in zip_file.namelist(): - thumb = get_image(preview_thumb_dir) - elif quicklook_thumb_dir in zip_file.namelist(): - thumb = get_image(quicklook_thumb_dir) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - if thumb: - im = Image.new("RGB", thumb.size, color="#1e1e1e") - im.paste(thumb) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - @staticmethod def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: """Render a thumbnail for an STL file. @@ -1003,9 +964,6 @@ def _render( # Normal Images -------------------------------------------- else: image = self._image_thumb(_filepath) - # Apple iWork Suite ============================================ - elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): - image = self._iwork_thumb(_filepath) # No Rendered Thumbnail ======================================== if not image: raise NoRendererError diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index cc1882aab..c982890dd 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -6,6 +6,7 @@ from tagstudio.qt.previews.renderers.blender_renderer import BlenderRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer +from tagstudio.qt.previews.renderers.iwork_renderer import IWorkRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer @@ -31,6 +32,7 @@ class RendererType(Enum): POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer + IWORK = "iwork", MediaCategories.IWORK_TYPES, IWorkRenderer # Text files TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py new file mode 100644 index 000000000..62a44841e --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -0,0 +1,55 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +preview_thumbnail_path_within_zip: str = "preview.jpg" +quicklook_thumbnail_path_within_zip: str = "QuickLook/Thumbnail.jpg" + + +class IWorkRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. + + Args: + context (RendererContext): The renderer context. + """ + file_name: str = context.path.name + preview_thumbnail_path = f"{file_name}/{preview_thumbnail_path_within_zip}" + quicklook_thumbnail_path = f"{file_name}/{quicklook_thumbnail_path_within_zip}" + + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Preview thumbnail + if zip_file.has_file_name(preview_thumbnail_path): + file_data: bytes = zip_file.read(preview_thumbnail_path) + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + # Quicklook thumbnail + elif zip_file.has_file_name(quicklook_thumbnail_path): + file_data = zip_file.read(quicklook_thumbnail_path) + embedded_thumbnail = Image.open(BytesIO(file_data)) + else: + raise FileNotFoundError + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + except Exception as e: + logger.error("[IWorkRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None From a39172412980987ca4b5e6059e5e72211dc11c64 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 15:02:15 -0500 Subject: [PATCH 14/20] Tweak archive file wrappers to handle cases where the file name in included in the name list --- .../helpers/file_wrappers/archive/rar_file.py | 22 ++++++++++++++--- .../file_wrappers/archive/seven_zip_file.py | 24 +++++++++++++++---- .../helpers/file_wrappers/archive/tar_file.py | 22 ++++++++++++++--- .../helpers/file_wrappers/archive/zip_file.py | 22 ++++++++++++++--- .../qt/previews/renderers/iwork_renderer.py | 13 ++++------ 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py index 5866327f3..2133eaa39 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -12,6 +12,7 @@ class RarFile(ArchiveFile): def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) + self.path = path self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode) def __enter__(self) -> Self: @@ -26,10 +27,25 @@ def __exit__( self.__rar_file.close() def get_name_list(self) -> list[str]: - return self.__rar_file.namelist() + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__rar_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() - def read(self, file_name: str) -> bytes: - return self.__rar_file.read(file_name) + def read(self, file_name: str) -> bytes | None: + try: + for file_path in [file_name, f"{self.path.name}/{file_name}"]: + try: + return self.__rar_file.read(file_path) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py index 790b3d126..db51b2e0a 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -12,6 +12,7 @@ class SevenZipFile(ArchiveFile): def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) + self.path = path self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode) def __enter__(self) -> Self: @@ -26,16 +27,31 @@ def __exit__( self.__seven_zip_file.close() def get_name_list(self) -> list[str]: - return self.__seven_zip_file.namelist() + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__seven_zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() - def read(self, file_name: str) -> bytes: + def read(self, file_name: str) -> bytes | None: # py7zr.SevenZipFile must be reset after every extraction # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract self.__seven_zip_file.reset() factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB - self.__seven_zip_file.extract(targets=[file_name], factory=factory) - return factory.get(file_name).read() + try: + for file_path in [file_name, f"{self.path.name}/{file_name}"]: + try: + self.__seven_zip_file.extract(targets=[file_path], factory=factory) + return factory.get(file_path).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py index 5be25af3e..63e462a64 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -12,6 +12,7 @@ class TarFile(ArchiveFile): def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) + self.path = path self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode) def __enter__(self) -> Self: @@ -26,10 +27,25 @@ def __exit__( self.__tar_file.close() def get_name_list(self) -> list[str]: - return self.__tar_file.getnames() + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__tar_file.getnames(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() - def read(self, file_name: str) -> bytes: - return unwrap(self.__tar_file.extractfile(file_name)).read() + def read(self, file_name: str) -> bytes | None: + try: + for file_path in [file_name, f"{self.path.name}/{file_name}"]: + try: + return unwrap(self.__tar_file.extractfile(file_path)).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py index 720efb04e..118ccfa91 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -11,6 +11,7 @@ class ZipFile(ArchiveFile): def __init__(self, path: Path, mode: Literal["r"]) -> None: super().__init__(path, mode) + self.path = path self.__zip_file: zipfile.ZipFile = zipfile.ZipFile(path, mode) def __enter__(self) -> Self: @@ -25,10 +26,25 @@ def __exit__( self.__zip_file.close() def get_name_list(self) -> list[str]: - return self.__zip_file.namelist() + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() - def read(self, file_name: str) -> bytes: - return self.__zip_file.read(file_name) + def read(self, file_name: str) -> bytes | None: + try: + for file_path in [file_name, f"{self.path.name}/{file_name}"]: + try: + return self.__zip_file.read(file_path) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py index 62a44841e..c5066bcb4 100644 --- a/src/tagstudio/qt/previews/renderers/iwork_renderer.py +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -26,21 +26,18 @@ def render(context: RendererContext) -> Image.Image | None: Args: context (RendererContext): The renderer context. """ - file_name: str = context.path.name - preview_thumbnail_path = f"{file_name}/{preview_thumbnail_path_within_zip}" - quicklook_thumbnail_path = f"{file_name}/{quicklook_thumbnail_path_within_zip}" - try: zip_file: ZipFile with ZipFile(context.path, "r") as zip_file: # Preview thumbnail - if zip_file.has_file_name(preview_thumbnail_path): - file_data: bytes = zip_file.read(preview_thumbnail_path) + logger.debug(zip_file.get_name_list()) + if zip_file.has_file_name(preview_thumbnail_path_within_zip): + file_data: bytes = zip_file.read(preview_thumbnail_path_within_zip) embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) # Quicklook thumbnail - elif zip_file.has_file_name(quicklook_thumbnail_path): - file_data = zip_file.read(quicklook_thumbnail_path) + elif zip_file.has_file_name(quicklook_thumbnail_path_within_zip): + file_data = zip_file.read(quicklook_thumbnail_path_within_zip) embedded_thumbnail = Image.open(BytesIO(file_data)) else: raise FileNotFoundError From 7aa9d1fba9e9c16fff26e4d69680703505d205dd Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 15:05:16 -0500 Subject: [PATCH 15/20] Oops, forgot to remove that --- src/tagstudio/qt/previews/renderers/iwork_renderer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py index c5066bcb4..e9c2d2cc4 100644 --- a/src/tagstudio/qt/previews/renderers/iwork_renderer.py +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -30,7 +30,6 @@ def render(context: RendererContext) -> Image.Image | None: zip_file: ZipFile with ZipFile(context.path, "r") as zip_file: # Preview thumbnail - logger.debug(zip_file.get_name_list()) if zip_file.has_file_name(preview_thumbnail_path_within_zip): file_data: bytes = zip_file.read(preview_thumbnail_path_within_zip) embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) From 2f98c5b60fb1663d1835c50ce85547ff91458236 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 15:24:17 -0500 Subject: [PATCH 16/20] Image renderers (exr not working at the moment) --- src/tagstudio/core/media_types.py | 14 ++ src/tagstudio/qt/previews/renderer.py | 148 +----------------- src/tagstudio/qt/previews/renderer_type.py | 8 + .../previews/renderers/exr_image_renderer.py | 44 ++++++ .../qt/previews/renderers/image_renderer.py | 47 ++++++ .../previews/renderers/raw_image_renderer.py | 38 +++++ .../renderers/vector_image_renderer.py | 56 +++++++ 7 files changed, 208 insertions(+), 147 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/exr_image_renderer.py create mode 100644 src/tagstudio/qt/previews/renderers/image_renderer.py create mode 100644 src/tagstudio/qt/previews/renderers/raw_image_renderer.py create mode 100644 src/tagstudio/qt/previews/renderers/vector_image_renderer.py diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index cc39c586b..c336891f0 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -41,6 +41,7 @@ class MediaType(str, Enum): FONT = "font" IMAGE_ANIMATED = "image_animated" IMAGE_RAW = "image_raw" + IMAGE_EXR = "image_exr" IMAGE_VECTOR = "image_vector" IMAGE = "image" INSTALLER = "installer" @@ -297,15 +298,25 @@ class MediaCategories: ".cr3", ".crw", ".dng", + ".erf", + ".mef", + ".mos", + ".mrw", ".nef", ".nrw", ".orf", + ".pef", ".raf", ".raw", ".rw2", ".srf", ".srf2", + ".sr2", + ".srw", + ".x3f", + ".3fr", } + _IMAGE_EXR_SET: set[str] = {".exr"} _IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"} _IMAGE_RASTER_SET: set[str] = { ".apng", @@ -501,6 +512,9 @@ class MediaCategories: is_iana=False, name="raw image", ) + IMAGE_EXR_TYPES = MediaCategory( + media_type=MediaType.IMAGE_EXR, extensions=_IMAGE_EXR_SET, is_iana=False, name="exr image" + ) IMAGE_VECTOR_TYPES = MediaCategory( media_type=MediaType.IMAGE_VECTOR, extensions=_IMAGE_VECTOR_SET, diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 86fab53d0..fd84a8ce9 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -8,14 +8,10 @@ import math import os from copy import deepcopy -from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING -import cv2 -import numpy as np import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] -import rawpy import structlog from PIL import ( Image, @@ -23,21 +19,18 @@ ImageDraw, ImageEnhance, ImageFile, - ImageOps, ImageQt, UnidentifiedImageError, ) from PIL.Image import DecompressionBombError from pillow_heif import register_heif_opener from PySide6.QtCore import ( - QBuffer, QObject, QSize, Qt, Signal, ) -from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap -from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QGuiApplication, QPixmap from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.ignore import Ignore @@ -535,124 +528,6 @@ def _apply_edge( return im - @staticmethod - def _image_raw_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a RAW image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess(use_camera_wb=True) - im = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except ( - DecompressionBombError, - rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_exr_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a EXR image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - # Load the EXR data to an array and rotate the color space from BGRA -> RGBA - raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) - raw_array[..., :3] = raw_array[..., 2::-1] - - # Correct the gamma of the raw array - gamma = 2.2 - array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) - array = (array_gamma * 255).astype(np.uint8) - - im = Image.fromarray(array, mode="RGBA") - - # Paste solid background - if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") - new_bg.paste(im, mask=im.getchannel(3)) - im = new_bg - - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a standard image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = 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 = unwrap(ImageOps.exif_transpose(im)) - except ( - FileNotFoundError, - UnidentifiedImageError, - DecompressionBombError, - NotImplementedError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: - """Render a thumbnail for a vector image, such as SVG. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = None - # Create an image to draw the svg to and a painter to do the drawing - q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) - q_image.fill("#1e1e1e") - - # Create an svg renderer, then render to the painter - svg: QSvgRenderer = QSvgRenderer(str(filepath)) - - if not svg.isValid(): - raise UnidentifiedImageError - - painter: QPainter = QPainter(q_image) - svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - svg.render(painter) - painter.end() - - # Write the image to a buffer as png - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] - - # Load the image from the buffer - im = Image.new("RGB", (size, size), color="#1e1e1e") - im.paste(Image.open(BytesIO(buffer.data().data()))) - im = im.convert(mode="RGB") - - buffer.close() - return im - @staticmethod def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: """Render a thumbnail for an STL file. @@ -944,27 +819,6 @@ def _render( if renderer_type: image = renderer_type.renderer.render(renderer_context) - # Images ======================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_TYPES, mime_fallback=True - ): - # Raw Images ----------------------------------------------- - if MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True - ): - image = self._image_raw_thumb(_filepath) - # Vector Images -------------------------------------------- - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - image = self._image_vector_thumb(_filepath, adj_size) - # EXR Images ----------------------------------------------- - elif ext in [".exr"]: - image = self._image_exr_thumb(_filepath) - # Normal Images -------------------------------------------- - else: - image = self._image_thumb(_filepath) - # No Rendered Thumbnail ======================================== if not image: raise NoRendererError diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index c982890dd..1f5729281 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -5,13 +5,17 @@ from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer from tagstudio.qt.previews.renderers.blender_renderer import BlenderRenderer from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer +from tagstudio.qt.previews.renderers.exr_image_renderer import EXRImageRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer +from tagstudio.qt.previews.renderers.image_renderer import ImageRenderer from tagstudio.qt.previews.renderers.iwork_renderer import IWorkRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer +from tagstudio.qt.previews.renderers.raw_image_renderer import RawImageRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer +from tagstudio.qt.previews.renderers.vector_image_renderer import VectorImageRenderer from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer @@ -40,6 +44,10 @@ class RendererType(Enum): # Image files VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer + RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer + EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer + VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer + IMAGE = "image", MediaCategories.IMAGE_TYPES, ImageRenderer def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): self.__name: str = name diff --git a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py new file mode 100644 index 000000000..c2c2f35c7 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py @@ -0,0 +1,44 @@ +import cv2 +import numpy as np +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class EXRImageRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a RAW image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + # Load the EXR data to an array and rotate the color space from BGRA -> RGBA + raw_array = cv2.imread(str(context.path), cv2.IMREAD_UNCHANGED) + raw_array[..., :3] = raw_array[..., 2::-1] + + # Correct the gamma of the raw array + gamma = 2.2 + array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) + array = (array_gamma * 255).astype(np.uint8) + + rendered_image: Image.Image = Image.fromarray(array, mode="RGBA") + + # Paste solid background + if rendered_image.mode == "RGBA": + new_bg = Image.new("RGB", rendered_image.size, color="#1e1e1e") + new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) + return new_bg + except Exception as e: + logger.error("[EXRImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/image_renderer.py b/src/tagstudio/qt/previews/renderers/image_renderer.py new file mode 100644 index 000000000..f1ea4c057 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/image_renderer.py @@ -0,0 +1,47 @@ +import structlog +from PIL import ( + Image, + ImageOps, + UnidentifiedImageError, +) +from PIL.Image import DecompressionBombError + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class ImageRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + rendered_image: Image.Image = Image.open(context.path) + + # Convert image to RGBA + if rendered_image.mode != "RGB" and rendered_image.mode != "RGBA": + rendered_image = rendered_image.convert(mode="RGBA") + + if rendered_image.mode == "RGBA": + new_bg = Image.new("RGB", rendered_image.size, color="#1e1e1e") + new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) + rendered_image = new_bg + + return unwrap(ImageOps.exif_transpose(rendered_image)) + except ( + FileNotFoundError, + UnidentifiedImageError, + DecompressionBombError, + NotImplementedError, + ) as e: + logger.error("[ImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/raw_image_renderer.py b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py new file mode 100644 index 000000000..a39110f2a --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py @@ -0,0 +1,38 @@ +import rawpy +import structlog +from PIL import ( + Image, +) +from PIL.Image import DecompressionBombError +from rawpy._rawpy import LibRawFileUnsupportedError, LibRawIOError + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class RawImageRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a RAW image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + with rawpy.imread(str(context.path)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + rendered_image = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + return rendered_image + except (DecompressionBombError, LibRawIOError, LibRawFileUnsupportedError) as e: + logger.error("[RawImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/vector_image_renderer.py b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py new file mode 100644 index 000000000..18df39823 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py @@ -0,0 +1,56 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, + UnidentifiedImageError, +) +from PySide6.QtCore import QBuffer, Qt +from PySide6.QtGui import QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VectorImageRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a vector image, such as SVG. + + Args: + context (RendererContext): The renderer context. + """ + # Create an image to draw the svg to and a painter to do the drawing + q_image: QImage = QImage(context.size, context.size, QImage.Format.Format_ARGB32) + q_image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(context.path)) + + if not svg.isValid(): + raise UnidentifiedImageError + + painter: QPainter = QPainter(q_image) + svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + svg.render(painter) + painter.end() + + # Write the image to a buffer as png + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] + + # Load the image from the buffer + rendered_image: Image.Image = Image.new( + "RGB", (context.size, context.size), color="#1e1e1e" + ) + rendered_image.paste(Image.open(BytesIO(buffer.data().data()))) + rendered_image = rendered_image.convert(mode="RGB") + + buffer.close() + return rendered_image From 029ea8dcc8131eae3928a44f573c515ae8081016 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 16:06:07 -0500 Subject: [PATCH 17/20] Better EXR image handling --- pyproject.toml | 2 + .../controllers/preview_thumb_controller.py | 11 ++++ .../previews/renderers/exr_image_renderer.py | 65 +++++++++++++------ 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4f06cf884..12e98ff6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,10 @@ dependencies = [ "ffmpeg-python~=0.2", "humanfriendly==10.*", "mutagen~=1.47", + "numexpr~=2.14.1", "numpy~=2.2", "opencv_python~=4.11", + "openexr~=3.4.3", "Pillow>=10.2,<=11", "pillow-avif-plugin~=1.5", "pillow-heif~=0.22", diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ecc5d96a2..d81d406f2 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import cv2 +import OpenEXR import rawpy import structlog from PIL import Image, UnidentifiedImageError @@ -54,6 +55,15 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: FileNotFoundError, ): pass + elif MediaCategories.IMAGE_EXR_TYPES.contains(ext, mime_fallback=True): + try: + exr_file = OpenEXR.File(filepath.as_posix()) + part = exr_file.parts[0] + logger.debug("[PreviewThumb]", part=part) + stats.width = part.width() + stats.height = part.height() + except Exception: + pass elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) @@ -69,6 +79,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True): pass # TODO + logger.debug("[PreviewThumb]", stats=stats) return stats def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None: diff --git a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py index c2c2f35c7..da8f1612c 100644 --- a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py +++ b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py @@ -1,10 +1,16 @@ -import cv2 -import numpy as np +from pathlib import Path + +import Imath +import numexpr +import numpy +import OpenEXR import structlog from PIL import ( Image, + ImageOps, ) +from tagstudio.core.utils.types import unwrap from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext logger = structlog.get_logger(__name__) @@ -16,29 +22,50 @@ def __init__(self): @staticmethod def render(context: RendererContext) -> Image.Image | None: - """Render a thumbnail for a RAW image file. + """Render a thumbnail for an EXR image file. Args: context (RendererContext): The renderer context. """ try: - # Load the EXR data to an array and rotate the color space from BGRA -> RGBA - raw_array = cv2.imread(str(context.path), cv2.IMREAD_UNCHANGED) - raw_array[..., :3] = raw_array[..., 2::-1] - - # Correct the gamma of the raw array - gamma = 2.2 - array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) - array = (array_gamma * 255).astype(np.uint8) - - rendered_image: Image.Image = Image.fromarray(array, mode="RGBA") - - # Paste solid background - if rendered_image.mode == "RGBA": - new_bg = Image.new("RGB", rendered_image.size, color="#1e1e1e") - new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) - return new_bg + rendered_image: Image.Image = exr_to_srgb(context.path) + return unwrap(ImageOps.exif_transpose(rendered_image)) except Exception as e: logger.error("[EXRImageRenderer] Couldn't render thumbnail", path=context.path, error=e) return None + + +# https://gist.github.com/arseniy-panfilov/4dc8fc5131277affe64619b1a9d00da0 +FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) + + +def exr_to_array(path: Path): + exr_file = OpenEXR.InputFile(path.as_posix()) + data_window = exr_file.header()["dataWindow"] + + channels = list(exr_file.header()["channels"].keys()) + channels_list = [c for c in ("R", "G", "B", "A") if c in channels] + size = (data_window.max.x - data_window.min.x + 1, data_window.max.y - data_window.min.y + 1) + + color_channels = exr_file.channels(channels_list, FLOAT) + channels_tuple = [numpy.frombuffer(channel, dtype="f") for channel in color_channels] + + return numpy.dstack(channels_tuple).reshape(size + (len(channels_tuple),)) + + +def encode_to_srgb(x): + a = 0.055 # noqa + return numexpr.evaluate("""where( + x <= 0.0031308, + x * 12.92, + (1 + a) * (x ** (1 / 2.4)) - a + )""") + + +def exr_to_srgb(exr_file): + array = exr_to_array(exr_file) + result = encode_to_srgb(array) * 255.0 + present_channels = ["R", "G", "B", "A"][: result.shape[2]] + channels = "".join(present_channels) + return Image.fromarray(result.astype("uint8"), channels) From a7201d94fb7308a8bff3866a82f95f6bb76b674c Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 16:18:08 -0500 Subject: [PATCH 18/20] Tweaks --- src/tagstudio/qt/previews/renderer.py | 58 ++++--------------- src/tagstudio/qt/previews/renderer_type.py | 42 ++++++++------ .../previews/renderers/stl_model_renderer.py | 43 ++++++++++++++ 3 files changed, 80 insertions(+), 63 deletions(-) create mode 100644 src/tagstudio/qt/previews/renderers/stl_model_renderer.py diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index fd84a8ce9..f64508c19 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -528,39 +528,6 @@ def _apply_edge( return im - @staticmethod - def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a thumbnail for an STL file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the icon. - """ - # TODO: Implement. - # The following commented code describes a method for rendering via - # matplotlib. - # This implementation did not play nice with multithreading. - im: Image.Image | None = 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 render( self, timestamp: float, @@ -574,7 +541,7 @@ def render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -784,7 +751,7 @@ def _render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -794,9 +761,7 @@ def _render( """ adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - image: Image.Image | None = None _filepath: Path = Path(filepath) - savable_media_type: bool = True if _filepath and _filepath.is_file(): try: @@ -816,30 +781,31 @@ def _render( renderer_type=renderer_type, renderer_context=renderer_context, ) - if renderer_type: - image = renderer_type.renderer.render(renderer_context) - if not image: + if not renderer_type: raise NoRendererError + image: Image.Image = renderer_type.renderer.render(renderer_context) + if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and savable_media_type and image: + if save_to_file and renderer_type.is_savable_media_type and image: self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") + return image + except ( UnidentifiedImageError, DecompressionBombError, ValueError, ChildProcessError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - image = None - except NoRendererError: - image = None + logger.error( + "[ThumbRenderer] Couldn't render thumbnail", filepath=filepath, error=e + ) - return image + return None def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image: orig_x, orig_y = image.size diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 1f5729281..297aef21f 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -22,38 +22,46 @@ class RendererType(Enum): # Project files - KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer + KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer, True # Model files - BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer + BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer, True # Media files - VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer - AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer + VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer, True + AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer, False # Document files - OPEN_DOC = "open_doc", MediaCategories.OPEN_DOCUMENT_TYPES, OpenDocRenderer - POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer - PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer - EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer - IWORK = "iwork", MediaCategories.IWORK_TYPES, IWorkRenderer + OPEN_DOC = "open_doc", MediaCategories.OPEN_DOCUMENT_TYPES, OpenDocRenderer, True + POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer, True + PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer, True + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer, True + IWORK = "iwork", MediaCategories.IWORK_TYPES, IWorkRenderer, True # Text files - TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer - FONT = "font", MediaCategories.FONT_TYPES, FontRenderer + TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer, True + FONT = "font", MediaCategories.FONT_TYPES, FontRenderer, True # Image files - VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer - RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer - EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer - VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer - IMAGE = "image", MediaCategories.IMAGE_TYPES, ImageRenderer + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer, True + RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer, True + EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer, True + VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer, True + IMAGE = "image", MediaCategories.IMAGE_TYPES, ImageRenderer, True - def __init__(self, name: str, media_category: MediaCategories, renderer: type[BaseRenderer]): + def __init__( + self, + name: str, + media_category: MediaCategories, + renderer: type[BaseRenderer], + is_savable_media_type: bool, + ): self.__name: str = name self.media_category: MediaCategories = media_category self.renderer: type[BaseRenderer] = renderer + self.is_savable_media_type = is_savable_media_type + @staticmethod def get_renderer_type(file_extension: str) -> "RendererType | None": for renderer_type in RendererType.__members__.values(): diff --git a/src/tagstudio/qt/previews/renderers/stl_model_renderer.py b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py new file mode 100644 index 000000000..a09dd7dbb --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py @@ -0,0 +1,43 @@ +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class STLModelRenderer(BaseRenderer): + def __init__(self): + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for an STL file. + + Args: + context (RendererContext): The renderer context. + """ + # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. + # im: Image.Image | None = 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 None From d89a8c32ec43bf8d12cef8a2fdcad8c2869b2f29 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 17:22:00 -0500 Subject: [PATCH 19/20] .. more tweaks --- .../controllers/preview_thumb_controller.py | 2 +- .../helpers/file_wrappers/archive/rar_file.py | 3 +- .../file_wrappers/archive/seven_zip_file.py | 6 +- .../helpers/file_wrappers/archive/tar_file.py | 5 +- .../helpers/file_wrappers/archive/zip_file.py | 5 +- src/tagstudio/qt/previews/renderer.py | 2 +- src/tagstudio/qt/previews/renderer_type.py | 4 +- .../qt/previews/renderers/audio_renderer.py | 256 +++++++++--------- .../qt/previews/renderers/blender_renderer.py | 105 ++++--- .../qt/previews/renderers/ebook_renderer.py | 63 ++--- .../previews/renderers/exr_image_renderer.py | 22 +- .../qt/previews/renderers/font_renderer.py | 197 +++++++------- .../qt/previews/renderers/iwork_renderer.py | 2 +- .../qt/previews/renderers/krita_renderer.py | 2 +- .../previews/renderers/open_doc_renderer.py | 2 +- .../qt/previews/renderers/pdf_renderer.py | 2 +- .../previews/renderers/powerpoint_renderer.py | 2 +- ...e_renderer.py => raster_image_renderer.py} | 6 +- .../previews/renderers/raw_image_renderer.py | 4 +- .../previews/renderers/stl_model_renderer.py | 2 +- .../qt/previews/renderers/text_renderer.py | 4 +- .../renderers/vector_image_renderer.py | 2 +- .../qt/previews/renderers/video_renderer.py | 4 +- .../qt/previews/renderers/vtf_renderer.py | 6 +- 24 files changed, 355 insertions(+), 353 deletions(-) rename src/tagstudio/qt/previews/renderers/{image_renderer.py => raster_image_renderer.py} (89%) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index d81d406f2..8cc539217 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -57,7 +57,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: pass elif MediaCategories.IMAGE_EXR_TYPES.contains(ext, mime_fallback=True): try: - exr_file = OpenEXR.File(filepath.as_posix()) + exr_file = OpenEXR.File(str(filepath)) part = exr_file.parts[0] logger.debug("[PreviewThumb]", part=part) stats.width = part.width() diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py index 2133eaa39..46f4c1084 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -39,8 +39,9 @@ def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] try: - for file_path in [file_name, f"{self.path.name}/{file_name}"]: + for file_path in search_paths: try: return self.__rar_file.read(file_path) except KeyError: diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py index db51b2e0a..bb4f4e16a 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -44,10 +44,12 @@ def read(self, file_name: str) -> bytes | None: self.__seven_zip_file.reset() factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB + + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] try: - for file_path in [file_name, f"{self.path.name}/{file_name}"]: + for file_path in search_paths: try: - self.__seven_zip_file.extract(targets=[file_path], factory=factory) + self.__seven_zip_file.extract(targets=[str(file_path)], factory=factory) return factory.get(file_path).read() except KeyError: continue diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py index 63e462a64..256619595 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -39,10 +39,11 @@ def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] try: - for file_path in [file_name, f"{self.path.name}/{file_name}"]: + for file_path in search_paths: try: - return unwrap(self.__tar_file.extractfile(file_path)).read() + return unwrap(self.__tar_file.extractfile(str(file_path))).read() except KeyError: continue diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py index 118ccfa91..3aa1a64a0 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -38,10 +38,11 @@ def has_file_name(self, file_name: str) -> bool: return file_name in self.get_name_list() def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] try: - for file_path in [file_name, f"{self.path.name}/{file_name}"]: + for file_path in search_paths: try: - return self.__zip_file.read(file_path) + return self.__zip_file.read(str(file_path)) except KeyError: continue diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index f64508c19..ffcf303c4 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -760,7 +760,7 @@ def _render( save_to_file(Path | None): A filepath to optionally save the output to. """ - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + adj_size: int = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) _filepath: Path = Path(filepath) if _filepath and _filepath.is_file(): diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 297aef21f..2642c9dc8 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -7,12 +7,12 @@ from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer from tagstudio.qt.previews.renderers.exr_image_renderer import EXRImageRenderer from tagstudio.qt.previews.renderers.font_renderer import FontRenderer -from tagstudio.qt.previews.renderers.image_renderer import ImageRenderer from tagstudio.qt.previews.renderers.iwork_renderer import IWorkRenderer from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer +from tagstudio.qt.previews.renderers.raster_image_renderer import RasterImageRenderer from tagstudio.qt.previews.renderers.raw_image_renderer import RawImageRenderer from tagstudio.qt.previews.renderers.text_renderer import TextRenderer from tagstudio.qt.previews.renderers.vector_image_renderer import VectorImageRenderer @@ -47,7 +47,7 @@ class RendererType(Enum): RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer, True EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer, True VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer, True - IMAGE = "image", MediaCategories.IMAGE_TYPES, ImageRenderer, True + RASTER_IMAGE = "image", MediaCategories.IMAGE_RASTER_TYPES, RasterImageRenderer, True def __init__( self, diff --git a/src/tagstudio/qt/previews/renderers/audio_renderer.py b/src/tagstudio/qt/previews/renderers/audio_renderer.py index 0ad95fac7..4527cc863 100644 --- a/src/tagstudio/qt/previews/renderers/audio_renderer.py +++ b/src/tagstudio/qt/previews/renderers/audio_renderer.py @@ -19,7 +19,7 @@ class AudioRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -29,142 +29,140 @@ def render(context: RendererContext) -> Image.Image | None: Args: context (RendererContext): The renderer context. """ - rendered_image: Image.Image | None = AudioRenderer._audio_album_thumb(context) + rendered_image: Image.Image | None = _extract_album_cover(context) if rendered_image is None: - rendered_image = AudioRenderer._audio_waveform_thumb(context) + rendered_image = _render_audio_waveform(context) if rendered_image is not None: rendered_image = apply_overlay_color(rendered_image, UiColor.GREEN) return rendered_image - @staticmethod - def _audio_album_thumb(context: RendererContext) -> Image.Image | None: - """Return an album cover thumb from an audio file if a cover is present. - Args: - context (RendererContext): The renderer context. - """ - try: - if not context.path.is_file(): - raise FileNotFoundError - - artwork = None - - # Get cover from .mp3 tags - if context.extension in [".mp3"]: - id3_tags: id3.ID3 = id3.ID3(context.path) - id3_covers: list = id3_tags.getall("APIC") - if id3_covers: - artwork = Image.open(BytesIO(id3_covers[0].data)) - - # Get cover from .flac tags - elif context.extension in [".flac"]: - flac_tags: flac.FLAC = flac.FLAC(context.path) - flac_covers: list = flac_tags.pictures - if flac_covers: - artwork = Image.open(BytesIO(flac_covers[0].data)) - - # Get cover from .mp4 tags - elif context.extension in [".mp4", ".m4a", ".aac"]: - mp4_tags: mp4.MP4 = mp4.MP4(context.path) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] - if mp4_covers: - artwork = Image.open(BytesIO(mp4_covers[0])) - - return artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: - logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) - - return None +def _extract_album_cover(context: RendererContext) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + context (RendererContext): The renderer context. + """ + try: + if not context.path.is_file(): + raise FileNotFoundError + + artwork: Image.Image | None = None + + # Get cover from .mp3 tags + if context.extension in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(context.path) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + + # Get cover from .flac tags + elif context.extension in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(context.path) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + + # Get cover from .mp4 tags + elif context.extension in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(context.path) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + + return artwork + except ( + FileNotFoundError, + id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + MutagenError, + ) as e: + logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) + + return None + + +def _render_audio_waveform(context: RendererContext) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + context (RendererContext): The renderer context. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = context.size * base_scale + allow_small_min: bool = False + + try: + bar_count: int = min(math.floor((context.size // context.pixel_ratio) / 5), 64) + audio = AudioSegment.from_file(context.path, context.extension[1:]) + data = np.frombuffer(buffer=audio._data, dtype=np.int16) + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = size_scaled - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list[int] = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + with catch_warnings(record=True): + if abs(d) > maximum_item: + maximum_item = int(abs(d)) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + rendered_image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(rendered_image) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill="#FF0000", + outline="#FFFF00", + width=max(math.ceil(line_width / 6), base_scale), + ) - @staticmethod - def _audio_waveform_thumb(context: RendererContext) -> Image.Image | None: - """Render a waveform image from an audio file. + current_x = current_x + line_width + bar_margin - Args: - context (RendererContext): The renderer context. - """ - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - base_scale: int = 2 - samples_per_bar: int = 3 - size_scaled: int = context.size * base_scale - allow_small_min: bool = False - - try: - bar_count: int = min(math.floor((context.size // context.pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(context.path, context.extension[1:]) - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = size_scaled - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) - - rendered_image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(rendered_image) - - current_x = bar_margin - for item in max_array: - item_height = item / line_ratio - - # If small minimums are not allowed, raise all values - # smaller than the line width to the same value. - if not allow_small_min: - item_height = max(item_height, line_width) - - current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 - - draw.rounded_rectangle( - ( - current_x, - current_y, - (current_x + line_width), - (current_y + item_height), - ), - radius=100 * base_scale, - fill="#FF0000", - outline="#FFFF00", - width=max(math.ceil(line_width / 6), base_scale), - ) - - current_x = current_x + line_width + bar_margin - - rendered_image.resize((context.size, context.size), Image.Resampling.BILINEAR) - return rendered_image - - except Exception as e: - logger.error( - "[AudioRenderer] Couldn't render waveform", path=context.path.name, error=e - ) + rendered_image.resize((context.size, context.size), Image.Resampling.BILINEAR) + return rendered_image + + except Exception as e: + logger.error("[AudioRenderer] Couldn't render waveform", path=context.path.name, error=e) - return None + return None diff --git a/src/tagstudio/qt/previews/renderers/blender_renderer.py b/src/tagstudio/qt/previews/renderers/blender_renderer.py index 7461103f8..b0c9b7639 100644 --- a/src/tagstudio/qt/previews/renderers/blender_renderer.py +++ b/src/tagstudio/qt/previews/renderers/blender_renderer.py @@ -28,6 +28,7 @@ import os import struct from io import BufferedReader +from pathlib import Path import structlog from PIL import Image, ImageOps, UnidentifiedImageError @@ -40,12 +41,12 @@ class BlenderRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod def render(context: RendererContext) -> Image.Image | None: - """Get an emended thumbnail from a Blender file, if a thumbnail is present. + """Get an embedded thumbnail from a Blender file, if a thumbnail is present. Args: context (RendererContext): The renderer context. @@ -57,19 +58,19 @@ def render(context: RendererContext) -> Image.Image | None: ) try: - buffer, width, height = BlenderRenderer.__extract_embedded_thumbnail(str(context.path)) + buffer, width, height = _extract_embedded_thumbnail(context.path) if buffer is None: return None - embedded_thumbnail = Image.frombuffer( + embedded_thumbnail: Image.Image = Image.frombuffer( "RGBA", (width, height), buffer, ) embedded_thumbnail = ImageOps.flip(embedded_thumbnail) - rendered_image = Image.new("RGB", embedded_thumbnail.size, color=bg_color) + rendered_image: Image.Image = Image.new("RGB", embedded_thumbnail.size, color=bg_color) rendered_image.paste(embedded_thumbnail, mask=embedded_thumbnail.getchannel(3)) return rendered_image @@ -89,70 +90,68 @@ def render(context: RendererContext) -> Image.Image | None: return None - @staticmethod - def __extract_embedded_thumbnail(path) -> tuple[bytes | None, int, int]: - rend = b"REND" - test = b"TEST" - blender_file: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 +def _extract_embedded_thumbnail(path: Path) -> tuple[bytes | None, int, int]: + rend = b"REND" + test = b"TEST" - header = blender_file.read(12) + blender_file: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 - if header[0:2] == b"\x1f\x8b": # gzip magic - blender_file.close() - blender_file = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 - header = blender_file.read(12) + header = blender_file.read(12) - if not header.startswith(b"BLENDER"): - blender_file.close() - return None, 0, 0 + if header[0:2] == b"\x1f\x8b": # gzip magic + blender_file.close() + blender_file = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + header = blender_file.read(12) - is_64_bit = header[7] == b"-"[0] + if not header.startswith(b"BLENDER"): + blender_file.close() + return None, 0, 0 - # True for PPC, false for X86 - is_big_endian = header[8] == b"V"[0] + is_64_bit = header[7] == b"-"[0] - # Blender pre-v2.5 had no thumbnails - if header[9:11] <= b"24": - return None, 0, 0 + # True for PPC, false for X86 + is_big_endian = header[8] == b"V"[0] - block_header_size = 24 if is_64_bit else 20 - int_endian = ">i" if is_big_endian else " None: super().__init__() @staticmethod @@ -47,14 +47,11 @@ def render(context: RendererContext) -> Image.Image | None: # Get the cover from the comic metadata, if present if "ComicInfo.xml" in archive.get_name_list(): - comic_info = ElementTree.fromstring(archive.read("ComicInfo.xml")) - rendered_image = EBookRenderer.__cover_from_comic_info( - archive, comic_info, "FrontCover" - ) + comic_info: Element = ElementTree.fromstring(archive.read("ComicInfo.xml")) + rendered_image = _extract_cover(archive, comic_info, "FrontCover") + if not rendered_image: - rendered_image = EBookRenderer.__cover_from_comic_info( - archive, comic_info, "InnerCover" - ) + rendered_image = _extract_cover(archive, comic_info, "InnerCover") # Get the first image present if not rendered_image: @@ -62,7 +59,7 @@ def render(context: RendererContext) -> Image.Image | None: if file_name.lower().endswith( (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") ): - image_data = archive.read(file_name) + image_data: bytes = archive.read(file_name) rendered_image = Image.open(BytesIO(image_data)) break @@ -72,28 +69,28 @@ def render(context: RendererContext) -> Image.Image | None: return None - @staticmethod - def __cover_from_comic_info( - archive: ArchiveFile, comic_info: Element, cover_type: str - ) -> Image.Image | None: - """Extract the cover specified in ComicInfo.xml. - - Args: - archive (ArchiveFile): The current ePub file. - comic_info (Element): The parsed ComicInfo.xml. - cover_type (str): The type of cover to load. - - Returns: - Image: The cover specified in ComicInfo.xml. - """ - cover = comic_info.find(f"./*Page[@Type='{cover_type}']") - if cover is not None: - pages = [ - page_file for page_file in archive.get_name_list() if page_file != "ComicInfo.xml" - ] - page_name = pages[int(unwrap(cover.get("Image")))] - if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): - image_data = archive.read(page_name) - return Image.open(BytesIO(image_data)) - return None +def _extract_cover( + archive: ArchiveFile, comic_info: Element, cover_type: str +) -> Image.Image | None: + """Extract the cover specified in ComicInfo.xml. + + Args: + archive (ArchiveFile): The current ePub file. + comic_info (Element): The parsed ComicInfo.xml. + cover_type (str): The type of cover to load. + + Returns: + Image: The cover specified in ComicInfo.xml. + """ + cover: Element | None = comic_info.find(f"./*Page[@Type='{cover_type}']") + if cover is not None: + pages: list[str] = [ + page_file for page_file in archive.get_name_list() if page_file != "ComicInfo.xml" + ] + page_name: str = pages[int(unwrap(cover.get("Image")))] + if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): + image_data: bytes = archive.read(page_name) + return Image.open(BytesIO(image_data)) + + return None diff --git a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py index da8f1612c..79b9423fb 100644 --- a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py +++ b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py @@ -5,6 +5,7 @@ import numpy import OpenEXR import structlog +from OpenEXR import InputFile from PIL import ( Image, ImageOps, @@ -17,7 +18,7 @@ class EXRImageRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -40,13 +41,16 @@ def render(context: RendererContext) -> Image.Image | None: FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) -def exr_to_array(path: Path): - exr_file = OpenEXR.InputFile(path.as_posix()) +def exr_to_array(path: Path) -> numpy.ndarray: + exr_file: InputFile = OpenEXR.InputFile(str(path)) data_window = exr_file.header()["dataWindow"] channels = list(exr_file.header()["channels"].keys()) - channels_list = [c for c in ("R", "G", "B", "A") if c in channels] - size = (data_window.max.x - data_window.min.x + 1, data_window.max.y - data_window.min.y + 1) + channels_list: list[str] = [c for c in ("R", "G", "B", "A") if c in channels] + size: tuple[int, int] = ( + data_window.max.x - data_window.min.x + 1, + data_window.max.y - data_window.min.y + 1, + ) color_channels = exr_file.channels(channels_list, FLOAT) channels_tuple = [numpy.frombuffer(channel, dtype="f") for channel in color_channels] @@ -63,9 +67,9 @@ def encode_to_srgb(x): )""") -def exr_to_srgb(exr_file): - array = exr_to_array(exr_file) +def exr_to_srgb(exr_file) -> Image.Image: + array: numpy.ndarray = exr_to_array(exr_file) result = encode_to_srgb(array) * 255.0 - present_channels = ["R", "G", "B", "A"][: result.shape[2]] - channels = "".join(present_channels) + present_channels: list[str] = ["R", "G", "B", "A"][: result.shape[2]] + channels: str = "".join(present_channels) return Image.fromarray(result.astype("uint8"), channels) diff --git a/src/tagstudio/qt/previews/renderers/font_renderer.py b/src/tagstudio/qt/previews/renderers/font_renderer.py index 1344e3027..c1a2438df 100644 --- a/src/tagstudio/qt/previews/renderers/font_renderer.py +++ b/src/tagstudio/qt/previews/renderers/font_renderer.py @@ -8,6 +8,7 @@ ImageDraw, ImageFont, ) +from PIL.ImageFont import FreeTypeFont from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT from tagstudio.qt.helpers.color_overlay import theme_fg_overlay @@ -20,7 +21,7 @@ class FontRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -31,103 +32,101 @@ def render(context: RendererContext) -> Image.Image | None: context (RendererContext): The renderer context. """ if context.is_grid_thumb: - return FontRenderer._font_short_thumb(context) + return _font_short_thumb(context) else: - return FontRenderer._font_long_thumb(context) - - @staticmethod - def _font_short_thumb(context: RendererContext) -> Image.Image | None: - """Render a small font preview ("Aa") thumbnail from a font file. - - Args: - context (RendererContext): The renderer context. - """ - try: - bg = Image.new("RGB", (context.size, context.size), color="#000000") - raw = Image.new("RGB", (context.size * 3, context.size * 3), color="#000000") - draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(context.path, size=context.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( - (context.size // 8, context.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)) - - m, n = data.shape[:2] - col: np.ndarray = cast(np.ndarray, data.any(0)) - row: np.ndarray = cast(np.ndarray, data.any(1)) - cropped_data = np.asarray(raw)[ - row.argmax() : m - row[::-1].argmax(), - col.argmax() : n - col[::-1].argmax(), - ] - cropped_image: Image.Image = Image.fromarray(cropped_data, "RGB") - - margin: int = math.ceil(context.size // 16) - - orig_x, orig_y = cropped_image.size - new_x, new_y = (context.size, context.size) - if orig_x > orig_y: - new_x = context.size - new_y = math.ceil(context.size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = context.size - new_x = math.ceil(context.size * (orig_x / orig_y)) - - cropped_image = cropped_image.resize( - size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, - ) - bg.paste( - cropped_image, - box=(margin, margin + ((context.size - new_y) // 2)), + return _font_long_thumb(context) + + +def _font_short_thumb(context: RendererContext) -> Image.Image | None: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + try: + bg: Image.Image = Image.new("RGB", (context.size, context.size), color="#000000") + raw: Image.Image = Image.new("RGB", (context.size * 3, context.size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font: FreeTypeFont = ImageFont.truetype(context.path, size=context.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( + (context.size // 8, context.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)) + + m, n = data.shape[:2] + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_image: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(context.size // 16) + + orig_x, orig_y = cropped_image.size + new_x, new_y = (context.size, context.size) + if orig_x > orig_y: + new_x = context.size + new_y = math.ceil(context.size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = context.size + new_x = math.ceil(context.size * (orig_x / orig_y)) + + cropped_image = cropped_image.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_image, + box=(margin, margin + ((context.size - new_y) // 2)), + ) + return apply_overlay_color(bg, UiColor.BLUE) + except OSError as e: + logger.error("Couldn't render thumbnail", path=context.path, error=type(e).__name__) + + return None + + +def _font_long_thumb(context: RendererContext) -> Image.Image | None: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + try: + scaled_sizes: list[int] = [math.floor(x * (context.size / 256)) for x in FONT_SAMPLE_SIZES] + bg: Image.Image = Image.new("RGBA", (context.size, context.size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding: int = 2 + y_offset: float = 0.0 + + for font_size in scaled_sizes: + font: FreeTypeFont = ImageFont.truetype(context.path, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, + font=font, # pyright: ignore[reportArgumentType] + width=context.size, + draw=draw, ) - return apply_overlay_color(bg, UiColor.BLUE) - except OSError as e: - logger.error("Couldn't render thumbnail", path=context.path, error=type(e).__name__) - - return None - - @staticmethod - def _font_long_thumb(context: RendererContext) -> Image.Image | None: - """Render a large font preview ("Alphabet") thumbnail from a font file. - - Args: - context (RendererContext): The renderer context. - """ - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - try: - scaled_sizes: list[int] = [ - math.floor(x * (context.size / 256)) for x in FONT_SAMPLE_SIZES - ] - bg = Image.new("RGBA", (context.size, context.size), color="#00000000") - draw = ImageDraw.Draw(bg) - lines_of_padding = 2 - y_offset = 0.0 - - for font_size in scaled_sizes: - font = ImageFont.truetype(context.path, size=font_size) - text_wrapped: str = wrap_full_text( - FONT_SAMPLE_TEXT, - font=font, # pyright: ignore[reportArgumentType] - width=context.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] - return theme_fg_overlay(bg, use_alpha=False) - except OSError as e: - logger.error("[FontRenderer] Couldn't render thumbnail", path=context.path, error=e) - - return None + 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) + except OSError as e: + logger.error("[FontRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py index e9c2d2cc4..9f5e18cc2 100644 --- a/src/tagstudio/qt/previews/renderers/iwork_renderer.py +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -16,7 +16,7 @@ class IWorkRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index 40861fab3..4d97d911e 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -12,7 +12,7 @@ class KritaRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py index 4c32c09a3..24eceb152 100644 --- a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py +++ b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py @@ -15,7 +15,7 @@ class OpenDocRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py index 84cb76d72..dc9abf781 100644 --- a/src/tagstudio/qt/previews/renderers/pdf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -15,7 +15,7 @@ class PDFRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py index fba33944b..0683b7d45 100644 --- a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py +++ b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py @@ -12,7 +12,7 @@ class PowerPointRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/image_renderer.py b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py similarity index 89% rename from src/tagstudio/qt/previews/renderers/image_renderer.py rename to src/tagstudio/qt/previews/renderers/raster_image_renderer.py index f1ea4c057..e0a245d25 100644 --- a/src/tagstudio/qt/previews/renderers/image_renderer.py +++ b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py @@ -12,8 +12,8 @@ logger = structlog.get_logger(__name__) -class ImageRenderer(BaseRenderer): - def __init__(self): +class RasterImageRenderer(BaseRenderer): + def __init__(self) -> None: super().__init__() @staticmethod @@ -31,7 +31,7 @@ def render(context: RendererContext) -> Image.Image | None: rendered_image = rendered_image.convert(mode="RGBA") if rendered_image.mode == "RGBA": - new_bg = Image.new("RGB", rendered_image.size, color="#1e1e1e") + new_bg: Image.Image = Image.new("RGB", rendered_image.size, color="#1e1e1e") new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) rendered_image = new_bg diff --git a/src/tagstudio/qt/previews/renderers/raw_image_renderer.py b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py index a39110f2a..ab8cbaf68 100644 --- a/src/tagstudio/qt/previews/renderers/raw_image_renderer.py +++ b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py @@ -12,7 +12,7 @@ class RawImageRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -25,7 +25,7 @@ def render(context: RendererContext) -> Image.Image | None: try: with rawpy.imread(str(context.path)) as raw: rgb = raw.postprocess(use_camera_wb=True) - rendered_image = Image.frombytes( + rendered_image: Image.Image = Image.frombytes( "RGB", (rgb.shape[1], rgb.shape[0]), rgb, diff --git a/src/tagstudio/qt/previews/renderers/stl_model_renderer.py b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py index a09dd7dbb..e10b5178c 100644 --- a/src/tagstudio/qt/previews/renderers/stl_model_renderer.py +++ b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py @@ -7,7 +7,7 @@ class STLModelRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py index 3d9598c0d..ca333e552 100644 --- a/src/tagstudio/qt/previews/renderers/text_renderer.py +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -16,7 +16,7 @@ class TextRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -43,7 +43,7 @@ def render(context: RendererContext) -> Image.Image | None: with open(context.path, encoding=encoding) as text_file: text = text_file.read(256) - rendered_image = Image.new("RGB", (256, 256), color=bg_color) + rendered_image: Image.Image = Image.new("RGB", (256, 256), color=bg_color) draw = ImageDraw.Draw(rendered_image) draw.text((16, 16), text, fill=fg_color) return rendered_image diff --git a/src/tagstudio/qt/previews/renderers/vector_image_renderer.py b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py index 18df39823..6c53dcfa6 100644 --- a/src/tagstudio/qt/previews/renderers/vector_image_renderer.py +++ b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py @@ -15,7 +15,7 @@ class VectorImageRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py index f1b1bea7b..51f259980 100644 --- a/src/tagstudio/qt/previews/renderers/video_renderer.py +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -13,7 +13,7 @@ class VideoRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -25,7 +25,7 @@ def render(context: RendererContext) -> Image.Image | None: """ try: if is_readable_video(context.path): - video = cv2.VideoCapture(str(context.path), cv2.CAP_FFMPEG) + video: cv2.VideoCapture = cv2.VideoCapture(str(context.path), cv2.CAP_FFMPEG) # TODO: Move this check to is_readable_video() if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py index b17768691..d92bf7cd7 100644 --- a/src/tagstudio/qt/previews/renderers/vtf_renderer.py +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -8,7 +8,7 @@ class VTFRenderer(BaseRenderer): - def __init__(self): + def __init__(self) -> None: super().__init__() @staticmethod @@ -21,8 +21,8 @@ def render(context: RendererContext) -> Image.Image | None: context (RendererContext): The renderer context. """ try: - with open(context.path, "rb") as f: - vtf = srctools.VTF.read(f) + with open(context.path, "rb") as vtf_file: + vtf = srctools.VTF.read(vtf_file) return vtf.get(frame=0).to_PIL() except (ValueError, FileNotFoundError) as e: From cc8b90ed6fdadfea9bd64c37a015ea2e422f393b Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Tue, 18 Nov 2025 17:52:46 -0500 Subject: [PATCH 20/20] Actually handle `NoRendererError`s maybe --- src/tagstudio/qt/previews/renderer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index ffcf303c4..5563cec61 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -804,6 +804,8 @@ def _render( logger.error( "[ThumbRenderer] Couldn't render thumbnail", filepath=filepath, error=e ) + except NoRendererError: + pass return None