From 49845e9434f9bc4cef4826d109aa9bc1daab718d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 17 Nov 2025 13:48:36 -0500 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] .. 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/25] 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 From 75b043c856b87cf2180c0686824ae3a1e2cfa2f8 Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sun, 23 Nov 2025 14:59:52 -0500 Subject: [PATCH 21/25] Tweak media types and better document them --- docs/preview-support.md | 153 ++++++++++-------- src/tagstudio/core/media_types.py | 26 ++- .../controllers/preview_thumb_controller.py | 2 +- src/tagstudio/qt/previews/renderer_type.py | 32 ++-- 4 files changed, 127 insertions(+), 86 deletions(-) diff --git a/docs/preview-support.md b/docs/preview-support.md index e7d22a43c..86db84f4e 100644 --- a/docs/preview-support.md +++ b/docs/preview-support.md @@ -10,79 +10,89 @@ TagStudio offers built-in preview and thumbnail support for a wide variety of fi Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Preview Panel. Animated images will play in the Preview Panel. -| Filetype | Extensions | Animation | -| -------------------- | -------------------------------------------------- | :---------------------------------: | -| Animated PNG | `.apng` | :material-check-circle:{.lg .green} | -| Apple Icon Image | `.icns` | :material-minus-circle:{.lg .gray} | -| AVIF | `.avif` | :material-minus-circle:{.lg .gray} | -| Bitmap | `.bmp` | :material-minus-circle:{.lg .gray} | -| GIF | `.gif` | :material-check-circle:{.lg .green} | -| HEIF | `.heif`, `.heic` | :material-minus-circle:{.lg .gray} | -| JPEG | `.jpeg`, `.jpg`, `.jfif`, `.jif`, `.jpg_large`[^1] | :material-minus-circle:{.lg .gray} | -| JPEG-XL | `.jxl` | :material-close-circle:{.lg .red} | -| OpenEXR | `.exr` | :material-minus-circle:{.lg .gray} | -| OpenRaster | `.ora` | :material-minus-circle:{.lg .gray} | -| PNG | `.png` | :material-minus-circle:{.lg .gray} | -| SVG | `.svg` | :material-close-circle:{.lg .red} | -| TIFF | `.tiff`, `.tif` | :material-minus-circle:{.lg .gray} | -| Valve Texture Format | `.vtf` | :material-close-circle:{.lg .red} | -| WebP | `.webp` | :material-check-circle:{.lg .green} | -| Windows Icon | `.ico` | :material-minus-circle:{.lg .gray} | +| Filetype | Extensions | Animation | +|----------------------|-----------------------------------------------------------------------------------------|-------------------------------------| +| Animated PNG | `.apng` | :material-check-circle:{.lg .green} | +| Apple Icon Image | `.icns` | :material-minus-circle:{.lg .gray} | +| AVIF | `.avif` | :material-minus-circle:{.lg .gray} | +| Bitmap | `.bmp` | :material-minus-circle:{.lg .gray} | +| GIF | `.gif` | :material-check-circle:{.lg .green} | +| HEIF | `.heif`, `.heic` | :material-minus-circle:{.lg .gray} | +| JPEG | `.jpeg`, `.jpg`, `.jpe`, `.jif`, `.jfif`, `.jfi`, `.jpeg_large`[^1]`, `.jpg_large`[^1], | :material-minus-circle:{.lg .gray} | +| JPEG 2000 | `.jp2`, `.j2k`, `.jpf`, `.jpm`, `.jpg2`, `.j2c`, `.jpc`, `.jpx`, `.mj2` | :material-minus-circle:{.lg .gray} | +| JPEG-XL | `.jxl` | :material-close-circle:{.lg .red} | +| OpenEXR | `.exr` | :material-minus-circle:{.lg .gray} | +| OpenRaster | `.ora` | :material-minus-circle:{.lg .gray} | +| PNG | `.png` | :material-minus-circle:{.lg .gray} | +| SVG | `.svg` | :material-close-circle:{.lg .red} | +| TIFF | `.tiff`, `.tif` | :material-minus-circle:{.lg .gray} | +| Valve Texture Format | `.vtf` | :material-close-circle:{.lg .red} | +| WebP | `.webp` | :material-check-circle:{.lg .green} | +| Windows Icon | `.ico` | :material-minus-circle:{.lg .gray} | #### :material-image-outline: RAW Images -| Filetype | Extensions | -| -------------------------------- | ---------------------- | -| Camera Image File Format (Canon) | `.crw`, `.cr2`, `.cr3` | -| Digital Negative | `.dng` | -| Fuji RAW | `.raf` | -| Nikon RAW | `.nef`, `.nrw` | -| Olympus RAW | `.orf` | -| Panasonic RAW | `.raw`, `.rw2` | -| Sony RAW | `.arw` | +| Filetype | Extensions | +|----------------------------------|----------------------------------------| +| Aptus RAW | `.mos` | +| Camera Image File Format (Canon) | `.crw`, `.cr2`, `.cr3` | +| Digital Negative | `.dng` | +| Epson RAW | `.erf` | +| Fuji RAW | `.raf` | +| Hasselblad RAW | `.3fr` | +| Kodak RAW | `.dcs`, `.dcr`, `.drf`, `.k25`, `.kdc` | +| Mamiya RAW | `.mef` | +| Minolta RAW | `.mrw`, `.mdc` | +| Nikon RAW | `.nef`, `.nrw` | +| Olympus RAW | `.orf` | +| Panasonic RAW | `.raw`, `.rw2` | +| Pentax RAW | `.pef` | +| Samsung RAW | `.srw` | +| Sigma RAW | `.x3f` | +| Sony RAW | `.arw`, `.srf`, `.srf2`, `.sr2` | ### :material-movie-open: Videos Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#third-party-dependencies) installed on your system. | Filetype | Extensions | Dependencies | -| --------------------- | ----------------------- | :----------: | -| 3GP | `.3gp` | FFmpeg | -| AVI | `.avi` | FFmpeg | -| AVIF | `.avif` | FFmpeg | -| FLV | `.flv` | FFmpeg | -| HEVC | `.hevc` | FFmpeg | -| Matroska | `.mkv` | FFmpeg | -| MP4 | `.mp4` , `.m4p` | FFmpeg | -| MPEG Transport Stream | `.ts` | FFmpeg | -| QuickTime | `.mov`, `.movie`, `.qt` | FFmpeg | -| WebM | `.webm` | FFmpeg | -| WMV | `.wmv` | FFmpeg | +|-----------------------|-------------------------|--------------| +| 3GP | `.3gp` | FFmpeg | +| AVI | `.avi` | FFmpeg | +| AVIF | `.avif` | FFmpeg | +| FLV | `.flv` | FFmpeg | +| GIFV | `.gifv` | FFmpeg | +| HEVC | `.hevc` | FFmpeg | +| Matroska | `.mkv` | FFmpeg | +| M4V | `.m4v` | FFmpeg | +| MP4 | `.mp4` , `.m4p` | FFmpeg | +| MPEG Transport Stream | `.ts` | FFmpeg | +| QuickTime | `.mov`, `.movie`, `.qt` | FFmpeg | +| WebM | `.webm` | FFmpeg | +| WMV | `.wmv` | FFmpeg | ### :material-sine-wave: Audio Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#third-party-dependencies) installed on your system. Audio waveforms are currently not cached. -| Filetype | Extensions | Dependencies | -| ------------------- | ------------------------ | :----------: | -| AAC | `.aac`, `.m4a` | FFmpeg | -| AIFF | `.aiff`, `.aif`, `.aifc` | FFmpeg | -| Apple Lossless[^2] | `.alac`, `.aac` | FFmpeg | -| FLAC | `.flac` | FFmpeg | -| MP3 | `.mp3` | FFmpeg | -| Ogg | `.ogg` | FFmpeg | -| WAVE | `.wav`, `.wave` | FFmpeg | -| Windows Media Audio | `.wma` | FFmpeg | +| Filetype | Extensions | Dependencies | +|---------------------|--------------------------|----------------| +| AAC | `.aac`, `.m4a` | FFmpeg | +| AIFF | `.aiff`, `.aif`, `.aifc` | FFmpeg | +| Apple Lossless[^2] | `.alac`, `.aac`, `.caf` | FFmpeg | +| FLAC | `.flac` | FFmpeg | +| MP3 | `.mp3` | FFmpeg | +| Ogg | `.ogg` | FFmpeg | +| WAVE | `.wav`, `.wave` | FFmpeg | +| Windows Media Audio | `.wma` | FFmpeg | ### :material-file-chart: Documents Preview support for office documents or well-known project file formats varies by the format and whether or not embedded thumbnails are available to be read from. OpenDocument-based files are typically supported. | Filetype | Extensions | Preview Type | -| ----------------------------- | --------------------- | -------------------------------------------------------------------------- | -| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +|-------------------------------|-----------------------|----------------------------------------------------------------------------| | Keynote (Apple iWork) | `.key` | Embedded thumbnail | -| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | | MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | | Numbers (Apple iWork) | `.numbers` | Embedded thumbnail | | OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail | @@ -90,13 +100,12 @@ Preview support for office documents or well-known project file formats varies b | OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail | | Pages (Apple iWork) | `.pages` | Embedded thumbnail | | PDF | `.pdf` | First page render | -| Photoshop | `.psd` | Flattened image render | | PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | ### :material-book: eBooks | Filetype | Extensions | Preview Type | -| ------------------ | --------------------- | ---------------------------- | +|--------------------|-----------------------|------------------------------| | EPUB | `.epub` | Embedded cover | | Comic Book Archive | `.cbr`, `.cbt` `.cbz` | Embedded cover or first page | @@ -106,12 +115,24 @@ Preview support for office documents or well-known project file formats varies b !!! failure "3D Model Support" TagStudio does not currently support previews for 3D model files *(outside of Blender project embedded thumbnails)*. This is on our [roadmap](roadmap.md#uiux) for a future release. +| Filetype | Extensions | Preview Type | +|-----------------|-----------------------------------|----------------------------------------------------------------------------| +| Blender | `.blend`, `.blend<#>`, `.blen_tc` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | + +### :material-layers: Project + +| Filetype | Extensions | Preview Type | +|----------------------------|----------------|----------------------------------------------------------------------------| +| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Photoshop | `.psd`, `.psb` | Flattened image render | + + ### :material-format-font: Fonts Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel. | Filetype | Extensions | -| -------------------- | ----------------- | +|----------------------|-------------------| | OpenType Font | `.otf`, `.otc` | | TrueType Font | `.ttf`, `.ttc` | | Web Open Font Format | `.woff`, `.woff2` | @@ -124,20 +145,20 @@ Font thumbnails will use a "Aa" example preview of the font, with a full alphanu Text files render the first 256 bytes of text information to an image preview for thumbnails and the Preview Panel. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features. -| Filetype | Extensions | Syntax Highlighting | -| ---------- | --------------------------------------------- | :--------------------------------: | -| CSV | `.csv` | :material-close-circle:{.lg .red} | -| HTML | `.html`, `.htm`, `.xhtml`, `.shtml`, `.dhtml` | :material-close-circle:{.lg .red} | -| JSON | `.json`, `.jsonc`, `.json5` | :material-close-circle:{.lg .red} | -| Markdown | `.md`, `.markdown`, `.mkd`, `.rmd` | :material-close-circle:{.lg .red} | -| Plain Text | `.txt`, `.text` | :material-minus-circle:{.lg .gray} | -| TOML | `.toml` | :material-close-circle:{.lg .red} | -| XML | `.xml`, `.xul` | :material-close-circle:{.lg .red} | -| YAML | `.yaml`, `.yml` | :material-close-circle:{.lg .red} | +| Filetype | Extensions | Syntax Highlighting | +|------------|-----------------------------------------------|-------------------------------------| +| CSV | `.csv` | :material-close-circle:{.lg .red} | +| HTML | `.html`, `.htm`, `.xhtml`, `.shtml`, `.dhtml` | :material-close-circle:{.lg .red} | +| JSON | `.json`, `.jsonc`, `.json5` | :material-close-circle:{.lg .red} | +| Markdown | `.md`, `.markdown`, `.mkd`, `.rmd` | :material-close-circle:{.lg .red} | +| Plain Text | `.txt`, `.text` | :material-minus-circle:{.lg .gray} | +| TOML | `.toml` | :material-close-circle:{.lg .red} | +| XML | `.xml`, `.xul` | :material-close-circle:{.lg .red} | +| YAML | `.yaml`, `.yml` | :material-close-circle:{.lg .red} | [^1]: - The `.jpg_large` extension is unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it. + The `.jpg_large` and `.jpeg_large` extensions are unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since these mangled extensions are still in circulation, TagStudio supports them. [^2]: Apple Lossless traditionally uses `.m4a` and `.caf` containers, but may unofficially use the `.alac` extension. The `.m4a` container is also used for separate compressed audio codecs. diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index c336891f0..a772c13d7 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -297,8 +297,14 @@ class MediaCategories: ".cr2", ".cr3", ".crw", + ".dcs", + ".dcr", ".dng", + ".drf", ".erf", + ".k25", + ".kdc", + ".mdc", ".mef", ".mos", ".mrw", @@ -327,20 +333,29 @@ class MediaCategories: ".heic", ".heif", ".icns", - ".j2k", + ".jpeg", + ".jpg", + ".jpe", + ".jif", ".jfif", - ".jp2", + ".jfi", ".jpeg_large", - ".jpeg", ".jpg_large", - ".jpg", + ".jp2", + ".j2k", + ".jpf", + ".jpm", ".jpg2", + ".j2c", + ".jpc", + ".jpx", + ".mj2", ".jxl", ".png", ".psb", ".psd", - ".tif", ".tiff", + ".tif", ".webp", } _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} @@ -422,6 +437,7 @@ class MediaCategories: ".m4v", ".mkv", ".mov", + ".movie", ".mp4", ".webm", ".wmv", diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 8cc539217..0d0cca60d 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -50,7 +50,7 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: stats.width = image.width stats.height = image.height except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] + rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] FileNotFoundError, ): diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 2642c9dc8..a9bf35d76 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -21,33 +21,37 @@ class RendererType(Enum): - # Project files - KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer, True - - # Model files - BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer, True + # Image files + RASTER_IMAGE = "image", MediaCategories.IMAGE_RASTER_TYPES, RasterImageRenderer, True + VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer, True + EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer, True + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer, True + RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer, True # Media files VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer, True AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer, False + # Project files + KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer, True + # Document files 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, True + # eBook files + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer, True + + # Model files + BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer, True + + # Font files FONT = "font", MediaCategories.FONT_TYPES, FontRenderer, True - # Image files - 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 - RASTER_IMAGE = "image", MediaCategories.IMAGE_RASTER_TYPES, RasterImageRenderer, True + # Text files + TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer, True def __init__( self, From b9e8202fc3378cb6dd3189e4bb251b6280a1a49e Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Sun, 23 Nov 2025 21:50:40 -0500 Subject: [PATCH 22/25] Handle cases where a file extension has multiple possible renderers --- src/tagstudio/qt/previews/renderer.py | 16 +++++++++++----- src/tagstudio/qt/previews/renderer_type.py | 7 +++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 5563cec61..53c710134 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -767,7 +767,7 @@ def _render( try: ext: str = _filepath.suffix.lower() if _filepath.suffix else _filepath.stem.lower() - renderer_type: RendererType | None = RendererType.get_renderer_type(ext) + renderer_types: list[RendererType] = list(RendererType.get_renderer_types(ext)) renderer_context: RendererContext = RendererContext( path=_filepath, extension=ext, @@ -778,19 +778,25 @@ def _render( logger.debug( "[ThumbRenderer]", - renderer_type=renderer_type, + renderer_types=renderer_types, renderer_context=renderer_context, ) - if not renderer_type: + if len(renderer_types) == 0: raise NoRendererError - image: Image.Image = renderer_type.renderer.render(renderer_context) + image: Image.Image | None = None + used_renderer_type: RendererType | None = None + for renderer_type in renderer_types: + used_renderer_type = renderer_type + image = renderer_type.renderer.render(renderer_context) + if image is not None: + break if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and renderer_type.is_savable_media_type and image: + if save_to_file and used_renderer_type.is_savable_media_type and image: self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") return image diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index a9bf35d76..0d9fb7a98 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from enum import Enum from tagstudio.core.media_types import MediaCategories @@ -67,11 +68,9 @@ def __init__( self.is_savable_media_type = is_savable_media_type @staticmethod - def get_renderer_type(file_extension: str) -> "RendererType | None": + def get_renderer_types(file_extension: str) -> Iterator["RendererType"]: 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 + yield renderer_type From 1aa9bd9ad458ddb286126d9171fbbc70fe134a7d Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Mon, 24 Nov 2025 12:53:27 -0500 Subject: [PATCH 23/25] Remove debug logs --- src/tagstudio/qt/controllers/preview_thumb_controller.py | 2 -- src/tagstudio/qt/previews/renderer.py | 6 ------ 2 files changed, 8 deletions(-) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index 0d0cca60d..315089690 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -59,7 +59,6 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: try: exr_file = OpenEXR.File(str(filepath)) part = exr_file.parts[0] - logger.debug("[PreviewThumb]", part=part) stats.width = part.width() stats.height = part.height() except Exception: @@ -79,7 +78,6 @@ 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/renderer.py b/src/tagstudio/qt/previews/renderer.py index 53c710134..e5e564c7d 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -776,12 +776,6 @@ def _render( is_grid_thumb=is_grid_thumb, ) - logger.debug( - "[ThumbRenderer]", - renderer_types=renderer_types, - renderer_context=renderer_context, - ) - if len(renderer_types) == 0: raise NoRendererError From b68e63adcd1ce5c9e253fdd14e03e5553f22beec Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 26 Nov 2025 10:14:57 -0500 Subject: [PATCH 24/25] Fix pyright errors --- src/tagstudio/qt/previews/renderer.py | 7 ++++++- src/tagstudio/qt/previews/renderer_type.py | 6 +++--- .../qt/previews/renderers/audio_renderer.py | 10 ++-------- .../qt/previews/renderers/ebook_renderer.py | 17 +++++++++++++++-- .../qt/previews/renderers/iwork_renderer.py | 8 +++++++- .../qt/previews/renderers/krita_renderer.py | 5 ++++- .../qt/previews/renderers/open_doc_renderer.py | 5 ++++- .../previews/renderers/powerpoint_renderer.py | 5 ++++- 8 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index e5e564c7d..13793659a 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -790,7 +790,12 @@ def _render( if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and used_renderer_type.is_savable_media_type and image: + if ( + save_to_file + and used_renderer_type + and used_renderer_type.is_savable_media_type + and image + ): self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") return image diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py index 0d9fb7a98..a1a72b938 100644 --- a/src/tagstudio/qt/previews/renderer_type.py +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -1,7 +1,7 @@ from collections.abc import Iterator from enum import Enum -from tagstudio.core.media_types import MediaCategories +from tagstudio.core.media_types import MediaCategories, MediaCategory 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 @@ -57,12 +57,12 @@ class RendererType(Enum): def __init__( self, name: str, - media_category: MediaCategories, + media_category: MediaCategory, renderer: type[BaseRenderer], is_savable_media_type: bool, ): self.__name: str = name - self.media_category: MediaCategories = media_category + self.media_category: MediaCategory = media_category self.renderer: type[BaseRenderer] = renderer self.is_savable_media_type = is_savable_media_type diff --git a/src/tagstudio/qt/previews/renderers/audio_renderer.py b/src/tagstudio/qt/previews/renderers/audio_renderer.py index 4527cc863..ce07f97dc 100644 --- a/src/tagstudio/qt/previews/renderers/audio_renderer.py +++ b/src/tagstudio/qt/previews/renderers/audio_renderer.py @@ -4,7 +4,7 @@ import numpy as np import structlog -from mutagen import MutagenError, flac, id3, mp4 +from mutagen import flac, id3, mp4 from PIL import ( Image, ImageDraw, @@ -73,13 +73,7 @@ def _extract_album_cover(context: RendererContext) -> Image.Image | None: artwork = Image.open(BytesIO(mp4_covers[0])) return artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: + except Exception as e: logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) return None diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py index ccfb3ecf8..bc7839e96 100644 --- a/src/tagstudio/qt/previews/renderers/ebook_renderer.py +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -45,9 +45,19 @@ def render(context: RendererContext) -> Image.Image | None: rendered_image: Image.Image | None = None + logger.debug("Epub") + logger.debug(archive.get_name_list()) + # Get the cover from the comic metadata, if present if "ComicInfo.xml" in archive.get_name_list(): - comic_info: Element = ElementTree.fromstring(archive.read("ComicInfo.xml")) + logger.debug("Found ComicInfo.xml!") + + comic_info_bytes: bytes | None = archive.read("ComicInfo.xml") + if comic_info_bytes is None: + raise OSError + + comic_info: Element = ElementTree.fromstring(comic_info_bytes.decode("utf-8")) + logger.debug(comic_info) rendered_image = _extract_cover(archive, comic_info, "FrontCover") if not rendered_image: @@ -59,7 +69,10 @@ def render(context: RendererContext) -> Image.Image | None: if file_name.lower().endswith( (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") ): - image_data: bytes = archive.read(file_name) + image_data: bytes | None = archive.read(file_name) + if image_data is None: + raise OSError + rendered_image = Image.open(BytesIO(image_data)) break diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py index 9f5e18cc2..960028985 100644 --- a/src/tagstudio/qt/previews/renderers/iwork_renderer.py +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -31,12 +31,18 @@ def render(context: RendererContext) -> Image.Image | None: with ZipFile(context.path, "r") as zip_file: # Preview thumbnail if zip_file.has_file_name(preview_thumbnail_path_within_zip): - file_data: bytes = zip_file.read(preview_thumbnail_path_within_zip) + file_data: bytes | None = zip_file.read(preview_thumbnail_path_within_zip) + if file_data is None: + raise OSError + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) # Quicklook thumbnail elif zip_file.has_file_name(quicklook_thumbnail_path_within_zip): file_data = zip_file.read(quicklook_thumbnail_path_within_zip) + if file_data is None: + raise OSError + embedded_thumbnail = Image.open(BytesIO(file_data)) else: raise FileNotFoundError diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py index 4d97d911e..abddb1846 100644 --- a/src/tagstudio/qt/previews/renderers/krita_renderer.py +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -28,7 +28,10 @@ def render(context: RendererContext) -> Image.Image | None: # 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) + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) if embedded_thumbnail: diff --git a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py index 24eceb152..28b19e30a 100644 --- a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py +++ b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py @@ -31,7 +31,10 @@ def render(context: RendererContext) -> Image.Image | None: # 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) + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) if embedded_thumbnail: diff --git a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py index 0683b7d45..7d529e572 100644 --- a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py +++ b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py @@ -28,7 +28,10 @@ def render(context: RendererContext) -> Image.Image | None: # 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) + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) if embedded_thumbnail: From 1cd0a7bed15e949760793c805afde7171599ccdf Mon Sep 17 00:00:00 2001 From: TrigamDev Date: Wed, 26 Nov 2025 10:49:02 -0500 Subject: [PATCH 25/25] Fix more pyright errors --- .../qt/helpers/file_wrappers/archive/archive_file.py | 2 +- .../qt/helpers/file_wrappers/archive/seven_zip_file.py | 3 ++- src/tagstudio/qt/previews/renderers/ebook_renderer.py | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py index 5610b1cf4..2544613fd 100644 --- a/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py @@ -17,5 +17,5 @@ def has_file_name(self, file_name: str) -> bool: raise NotImplementedError @abstractmethod - def read(self, file_name: str) -> bytes: + def read(self, file_name: str) -> bytes | None: raise NotImplementedError 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 bb4f4e16a..e34ddd0c7 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 @@ -3,6 +3,7 @@ from typing import Literal, Self import py7zr +from py7zr import io from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile @@ -43,7 +44,7 @@ def read(self, file_name: str) -> bytes | None: # 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 + factory = io.BytesIOFactory(limit=10485760) # 10 MiB search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] try: diff --git a/src/tagstudio/qt/previews/renderers/ebook_renderer.py b/src/tagstudio/qt/previews/renderers/ebook_renderer.py index bc7839e96..c8dbac674 100644 --- a/src/tagstudio/qt/previews/renderers/ebook_renderer.py +++ b/src/tagstudio/qt/previews/renderers/ebook_renderer.py @@ -103,7 +103,10 @@ def _extract_cover( ] 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) + image_data: bytes | None = archive.read(page_name) + if image_data is None: + raise OSError + return Image.open(BytesIO(image_data)) return None