From c6a26001654826f9c199bbfb71d0c40d3533130c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:36:43 -0700 Subject: [PATCH] convert most remaining thumbnail renderers to classes --- src/tagstudio/core/media_types.py | 51 ++- src/tagstudio/core/utils/sanitized_attr.py | 14 + src/tagstudio/core/utils/singleton.py | 2 +- src/tagstudio/previews/base_preview.py | 1 + src/tagstudio/previews/effects.py | 10 +- src/tagstudio/previews/file_renderer.py | 320 +++--------------- .../renderers/apple_embedded_preview.py | 44 +++ src/tagstudio/previews/renderers/archive.py | 57 ++-- src/tagstudio/previews/renderers/audio.py | 6 +- src/tagstudio/previews/renderers/blender.py | 6 +- .../previews/renderers/clip_studio.py | 29 +- src/tagstudio/previews/renderers/code.py | 5 +- src/tagstudio/previews/renderers/ebook.py | 35 +- src/tagstudio/previews/renderers/font.py | 79 +++-- .../previews/renderers/krita_preview.py | 37 ++ .../previews/renderers/medibang_paint.py | 27 +- .../renderers/open_document_preview.py | 37 ++ .../previews/renderers/paint_dot_net.py | 32 +- src/tagstudio/previews/renderers/pdf.py | 33 +- .../previews/renderers/powerpoint_preview.py | 37 ++ .../previews/renderers/raster_image.py | 73 ++-- .../previews/renderers/raw_image_preview.py | 60 ++++ src/tagstudio/previews/renderers/text.py | 1 + .../previews/renderers/vector_image.py | 38 ++- src/tagstudio/previews/renderers/video.py | 36 +- .../previews/vendored/pydub/audio_segment.py | 2 + .../qt/views/styles/color_overlay.py | 2 - 27 files changed, 647 insertions(+), 427 deletions(-) create mode 100644 src/tagstudio/core/utils/sanitized_attr.py create mode 100644 src/tagstudio/previews/renderers/apple_embedded_preview.py create mode 100644 src/tagstudio/previews/renderers/krita_preview.py create mode 100644 src/tagstudio/previews/renderers/open_document_preview.py create mode 100644 src/tagstudio/previews/renderers/powerpoint_preview.py create mode 100644 src/tagstudio/previews/renderers/raw_image_preview.py diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 4dbed40f..647aa270 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -10,6 +10,8 @@ from pathlib import Path import structlog +from tagstudio.core.utils.sanitized_attr import SanitizedAttr + logger = structlog.get_logger(__name__) @@ -77,7 +79,7 @@ class MediaTypeGroup: # return False -class MediaTypes: +class MediaTypes(metaclass=SanitizedAttr): # TODO: Implement filename equivalencies # TODO: Implement in_all_groups or something, if needed # TODO: Implement collision detection (e.g. ".ts") @@ -165,6 +167,7 @@ clip_studio_paint = MediaTypeGroup( _Type(".cmc", [SEARCH, RENDER]), ], ) +MediaTypes.register(clip_studio_paint) # Krita ---------------------------------------------------------------------------------------- krita = MediaTypeGroup( @@ -174,12 +177,15 @@ krita = MediaTypeGroup( _Type(".krz", [SEARCH, RENDER]), ], ) +MediaTypes.register(krita) # MediBang Paint / FireAlpaca ------------------------------------------------------------------ medibang_paint = MediaTypeGroup("medibang_paint", [_Type(".mdp", [SEARCH, RENDER])]) +MediaTypes.register(medibang_paint) # Paint.NET ------------------------------------------------------------------------------------ paint_dot_net = MediaTypeGroup("paint_dot_net", [_Type(".pdn", [SEARCH, RENDER])]) +MediaTypes.register(paint_dot_net) # Archives ------------------------------------------------------------------------------------- archive = MediaTypeGroup( @@ -194,6 +200,7 @@ archive = MediaTypeGroup( _Type(".zip", [SEARCH, RENDER]), ], ) +MediaTypes.register(archive) # eBooks ------------------------------------------------------------------------------------- ebook = MediaTypeGroup( @@ -219,6 +226,7 @@ ebook = MediaTypeGroup( _Type(".mscz", RENDER), ], ) +MediaTypes.register(ebook) # Fonts -------------------------------------------------------------------------------------- font = MediaTypeGroup( @@ -232,6 +240,7 @@ font = MediaTypeGroup( _Type(".woff2", [SEARCH, RENDER]), ], ) +MediaTypes.register(font) # Office & Documents --------------------------------------------------------------------------- document = MediaTypeGroup( @@ -269,6 +278,7 @@ open_document = MediaTypeGroup( _Type(".ora", [SEARCH, RENDER]), ], ) +MediaTypes.register(open_document) iwork = MediaTypeGroup( "iwork", @@ -279,16 +289,26 @@ iwork = MediaTypeGroup( _Type(".pxd", RENDER), ], ) +MediaTypes.register(iwork) + +powerpoint = MediaTypeGroup( + "office.powerpoint", + [ + _Type(".ppt", SEARCH), + _Type(".pptx", [SEARCH, RENDER]), + ], +) +MediaTypes.register(powerpoint) presentation = MediaTypeGroup( "presentation", [ _Type(".key", SEARCH), _Type(".odp", SEARCH), - _Type(".ppt", SEARCH), - _Type(".pptx", [SEARCH, RENDER]), - ], + ] + + powerpoint.types, ) +MediaTypes.register(presentation) spreadsheet = MediaTypeGroup( "spreadsheet", @@ -300,6 +320,7 @@ spreadsheet = MediaTypeGroup( _Type(".xlsx", SEARCH), ], ) +MediaTypes.register(spreadsheet) # 3D ------------------------------------------------------------------------------------------- model = MediaTypeGroup( @@ -311,12 +332,16 @@ model = MediaTypeGroup( _Type(".stl", SEARCH), ], ) +MediaTypes.register(model) + material = MediaTypeGroup( "material", [ _Type(".mtl", SEARCH), ], ) +MediaTypes.register(material) + shader = MediaTypeGroup( "shader", [ @@ -329,6 +354,7 @@ shader = MediaTypeGroup( _Type(".vsh", SEARCH), ], ) +MediaTypes.register(shader) # System & Misc ------------------------------------------------------------------------------ database = MediaTypeGroup( @@ -342,6 +368,8 @@ database = MediaTypeGroup( _Type(".sqlite3", SEARCH), ], ) +MediaTypes.register(database) + disk_image = MediaTypeGroup( "disk_image", [ @@ -351,6 +379,8 @@ disk_image = MediaTypeGroup( _Type(".iso", SEARCH), ], ) +MediaTypes.register(disk_image) + installer = MediaTypeGroup( "installer", [ @@ -359,6 +389,8 @@ installer = MediaTypeGroup( _Type(".msix", SEARCH), ], ) +MediaTypes.register(installer) + package = MediaTypeGroup( "package", [ @@ -371,6 +403,8 @@ package = MediaTypeGroup( _Type(".xapk", SEARCH), ], ) +MediaTypes.register(package) + program = MediaTypeGroup( "program", [ @@ -379,6 +413,8 @@ program = MediaTypeGroup( _Type(".exe", SEARCH), ], ) +MediaTypes.register(program) + shortcut = MediaTypeGroup( "shortcut", [ @@ -387,15 +423,18 @@ shortcut = MediaTypeGroup( _Type(".url", SEARCH), ], ) +MediaTypes.register(shortcut) # Valve Source Engine -------------------------------------------------------------------------- source_engine = MediaTypeGroup( "source_engine", [_Type(".vtf", [SEARCH, RENDER])], ) +MediaTypes.register(source_engine) # MIDI ----------------------------------------------------------------------------------------- midi = MediaTypeGroup("midi", [_Type([".mid", ".midi"], SEARCH)]) +MediaTypes.register(midi) # Audio ---------------------------------------------------------------------------------------- audio = MediaTypeGroup( @@ -439,6 +478,7 @@ raw_image = MediaTypeGroup( _Type(".srf2", [SEARCH, RENDER]), ], ) +MediaTypes.register(raw_image) # Raster Images -------------------------------------------------------------------------------- raster_image = MediaTypeGroup( @@ -469,6 +509,7 @@ raster_image = MediaTypeGroup( _Type([".tif", ".tiff"], [SEARCH, RENDER]), ], ) +MediaTypes.register(raster_image) # Vector Images -------------------------------------------------------------------------------- vector_image = MediaTypeGroup( @@ -481,6 +522,7 @@ vector_image = MediaTypeGroup( _Type(".svgz", SEARCH), ], ) +MediaTypes.register(vector_image) # Animated Images ------------------------------------------------------------------------------ animated_image = MediaTypeGroup( @@ -667,6 +709,7 @@ video = MediaTypeGroup( _Type(".wmv", [SEARCH, RENDER]), ], ) +MediaTypes.register(video) # ------------------ diff --git a/src/tagstudio/core/utils/sanitized_attr.py b/src/tagstudio/core/utils/sanitized_attr.py new file mode 100644 index 00000000..67bc6ae3 --- /dev/null +++ b/src/tagstudio/core/utils/sanitized_attr.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +from typing import Any + + +class SanitizedAttr(type): + def __getattr__(cls, name: str) -> Any: # pyright: ignore[reportExplicitAny] + sanitized = name.replace(".", "_") + + if sanitized == name: + raise AttributeError(f"'{type(cls).__name__}' object has no attribute '{name}'") + + return getattr(cls, sanitized) diff --git a/src/tagstudio/core/utils/singleton.py b/src/tagstudio/core/utils/singleton.py index 82b4518d..383632d7 100644 --- a/src/tagstudio/core/utils/singleton.py +++ b/src/tagstudio/core/utils/singleton.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT from threading import Lock diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py index e5264378..4d909bd2 100644 --- a/src/tagstudio/previews/base_preview.py +++ b/src/tagstudio/previews/base_preview.py @@ -24,6 +24,7 @@ class BasePreview: def render( cls, filepath: Path, + is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float, diff --git a/src/tagstudio/previews/effects.py b/src/tagstudio/previews/effects.py index 8dcce647..93b094f0 100644 --- a/src/tagstudio/previews/effects.py +++ b/src/tagstudio/previews/effects.py @@ -1,9 +1,11 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +from PIL.Image import Image +from PIL.Image import new as new_image + from tagstudio.core.enums import Theme from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color -from PIL import ImageDraw, ImageFont, UnidentifiedImageError -from PIL.Image import DecompressionBombError, Image, Resampling -from PIL.Image import new as new_image -from PIL.Image import open as open_image # TODO: Split out Qt color palette stuff from anything needed by the core. diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 142e1f34..8ef7dc30 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -5,17 +5,13 @@ import contextlib import hashlib import math -from collections.abc import Callable from copy import deepcopy -from functools import partial from pathlib import Path import structlog from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError -from PIL.Image import DecompressionBombError from tagstudio.core.enums import Theme -from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import ( @@ -29,34 +25,27 @@ from tagstudio.core.utils.types import unwrap from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.effects import apply_overlay_color from tagstudio.previews.gradients import four_corner_gradient -from tagstudio.previews.renderers.archive import ( - apple_embedded_thumb, - archive_thumb, - krita_thumb, - open_doc_thumb, - powerpoint_thumb, -) +from tagstudio.previews.renderers.apple_embedded_preview import AppleEmbeddedPreview +from tagstudio.previews.renderers.archive import ArchivePreview from tagstudio.previews.renderers.audio import AudioPreview -from tagstudio.previews.renderers.blender import BlenderPreview, _blender_thumb -from tagstudio.previews.renderers.clip_studio import clip_studio_thumb +from tagstudio.previews.renderers.blender import BlenderPreview +from tagstudio.previews.renderers.clip_studio import ClipStudioPaintPreview from tagstudio.previews.renderers.code import CodePreview -from tagstudio.previews.renderers.ebook import epub_thumb -from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb -from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb -from tagstudio.previews.renderers.paint_dot_net import paint_dot_net_thumb -from tagstudio.previews.renderers.pdf import pdf_thumb -from tagstudio.previews.renderers.raster_image import ( - exr_image_thumb, - raster_image_thumb, - raw_image_thumb, -) -from tagstudio.previews.renderers.source_engine import vtf_thumb +from tagstudio.previews.renderers.ebook import EbookPreview +from tagstudio.previews.renderers.font import FontPreview +from tagstudio.previews.renderers.krita_preview import KritaPreview +from tagstudio.previews.renderers.medibang_paint import MediBangPaintPreview +from tagstudio.previews.renderers.open_document_preview import OpenDocumentPreview +from tagstudio.previews.renderers.paint_dot_net import PaintDotNetPreview +from tagstudio.previews.renderers.pdf import PdfPreview +from tagstudio.previews.renderers.powerpoint_preview import PowerPointPreview +from tagstudio.previews.renderers.raster_image import RasterImagePreview +from tagstudio.previews.renderers.raw_image_preview import RawImagePreview from tagstudio.previews.renderers.text import ( TextPreview, - text_thumb, ) -from tagstudio.previews.renderers.vector_image import vector_image_thumb -from tagstudio.previews.renderers.video import video_thumb +from tagstudio.previews.renderers.vector_image import VectorImagePreview +from tagstudio.previews.renderers.video import VideoPreview from tagstudio.qt.app_settings import ( DEFAULT_CACHED_THUMB_RES, MAX_CACHED_THUMB_RES, @@ -65,7 +54,7 @@ from tagstudio.qt.app_settings import ( ) from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.resource_manager import ResourceManager -from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor, get_ui_color +from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor ImageFile.LOAD_TRUNCATED_IMAGES = True Image.MAX_IMAGE_PIXELS = None @@ -626,7 +615,7 @@ class FileRenderer: size=(thumb_res, thumb_res), dpi_scale=1, theme=theme, - is_thumb=is_thumb, + is_small=is_thumb, cache_filename=file_name, ) @@ -707,7 +696,7 @@ class FileRenderer: size: tuple[int, int], dpi_scale: float, theme: Theme = Theme.DARK, - is_thumb: bool = False, + is_small: bool = False, cache_filename: Path | None = None, ) -> Image.Image | None: """Render a thumbnail or preview image. @@ -719,7 +708,7 @@ class FileRenderer: size (tuple[int, int]): The unmodified base size of the thumbnail. dpi_scale (float): The screen pixel ratio. theme (Theme): A theme enum to determine the light/dark theme. - is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants. + is_small (bool): Is this specifically a thumbnail? Use for specifying small variants. cache_filename (Path | None): An optional filename to use to save to the cache. """ @@ -728,43 +717,51 @@ class FileRenderer: image: Image.Image | None = None is_savable_type: bool = True - # Ordered groups of file renderers. - # A file extension is rendered with the first group it's found in. - # TODO: Dynamically import these from the renderers/ directory at runtime, # And allow user-created ones from an external directory. previews: list[type[BasePreview]] = [ - # ArchivePreview, + RawImagePreview, + VectorImagePreview, + RasterImagePreview, + VideoPreview, AudioPreview, - BlenderPreview, - # ClipStudioPaintPreview, CodePreview, - # EbookPreview, - # FontPreview, - # IWorkPreview, - # KritaPreview, - # MediBangPaintPreview, - # PaintDotNetPreview, - # RasterImagePreview, - # RawImagePreview, TextPreview, - # VectorImagePreview, + PdfPreview, + ArchivePreview, + FontPreview, + BlenderPreview, + EbookPreview, + KritaPreview, + OpenDocumentPreview, + ClipStudioPaintPreview, + MediBangPaintPreview, + PaintDotNetPreview, + PowerPointPreview, + AppleEmbeddedPreview, ] if filepath and filepath.is_file(): try: ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() for preview in previews: - media_type: MediaTypeGroup = getattr(MediaTypes, preview.media_type_name) - if media_type.contains(ext, Context.RENDER): - logger.info(f"{ext} in: {media_type.renderable}") - image = preview.render( - filepath=filepath, - theme=theme, - size=(scaled_size, scaled_size), - dpi_scale=dpi_scale, + try: + media_type: MediaTypeGroup = getattr(MediaTypes, preview.media_type_name) + if media_type.contains(ext, Context.RENDER): + logger.info(f"{ext} in: {media_type.renderable}") + image = preview.render( + filepath=filepath, + is_small=is_small, + theme=theme, + size=(scaled_size, scaled_size), + dpi_scale=dpi_scale, + ) + break + except AttributeError: + logger.error( + f"[FileRenderer] " + f"Attribute '{preview.media_type_name}' not registered with MediaTypes", ) - break if image: image = self._resize_image(image, (scaled_size, scaled_size)) @@ -776,219 +773,6 @@ class FileRenderer: return image - # -------------------------------- new old - - # render_groups: list[tuple[MediaTypeGroup, Callable[..., Image.Image | None]]] = [ - # (MediaTypes.raw_image, partial(raw_image_thumb, filepath)), - # (MediaTypes.raster_image, partial(raster_image_thumb, filepath)), - # (MediaTypes.vector_image, partial(vector_image_thumb, filepath, scaled_size)), - # ( - # getattr(MediaTypes, CodePreview.media_type_name), - # partial(CodePreview.render, filepath), - # ), - # ( - # getattr(MediaTypes, TextPreview.media_type_name), - # partial(TextPreview.render, filepath), - # ), - # (MediaTypes.audio, partial(audio_thumb, filepath, scaled_size, dpi_scale)), - # (MediaTypes.video, partial(video_thumb, filepath)), - # ( - # MediaTypes.font, - # partial(font_small_thumb if is_thumb else font_full_preview, filepath, scaled_size), - # ), - # (MediaTypes.archive, partial(archive_thumb, filepath)), - # (MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)), - # (MediaTypes.ebook, partial(epub_thumb, filepath)), - # (MediaTypes.iwork, partial(apple_embedded_thumb, filepath)), - # (MediaTypes.blender, partial(_blender_thumb, filepath)), - # (MediaTypes.krita, partial(krita_thumb, filepath)), - # (MediaTypes.clip_studio_paint, partial(clip_studio_thumb, filepath)), - # (MediaTypes.paint_dot_net, partial(paint_dot_net_thumb, filepath)), - # (MediaTypes.medibang_paint, partial(medibang_paint_thumb, filepath)), - # (MediaTypes.binary, partial(raster_image_thumb, filepath)), - # ] - # if filepath and filepath.is_file(): - # try: - # ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() - # for media_type, renderer in render_groups: - # if media_type.contains(ext, Context.RENDER): - # logger.info(f"{ext} in: {media_type.renderable}") - # image = renderer() - - # # TODO: Remove the need for these extra steps - # if image and ext in MediaTypes.audio.renderable: - # # TODO: Differentiate between album art and waveform - # image = self._apply_overlay_color(image, UiColor.GREEN, theme) - # is_savable_type = False - # elif image and ext in MediaTypes.font.renderable: - # # TODO: Differentiate between ful preview and small preview - # image = self._apply_overlay_color(image, UiColor.BLUE, theme) - - # break - - # if not image: - # logger.warning(f"Could not render {ext}") - # raise NoRendererError - - # if image: - # image = self._resize_image(image, (scaled_size, scaled_size)) - - # if cache_filename and is_savable_type and image and cache: - # cache.save_image(image, cache_filename, mode="RGBA") - # except ( - # AssertionError, - # ChildProcessError, - # DecompressionBombError, - # UnidentifiedImageError, - # ValueError, - # ) as e: - # logger.error( - # "[FileRenderer] Couldn't render thumbnail", - # filepath=filepath, - # error=type(e).__name__, - # ) - # image = None - # except NoRendererError: - # image = None - - # return image - - # # ---------- old old - - # if filepath and filepath.is_file(): - # try: - # ext: str = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() - # # eBooks =========================================================================== - # if MediaCategories.is_ext_in_category( - # ext, MediaCategories.EBOOK_TYPES, mime_fallback=True - # ): - # image = epub_thumb(filepath, ext) - # # Krita ============================================================================ - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.KRITA_TYPES, mime_fallback=True - # ): - # image = krita_thumb(filepath) - # # Clip Studio Paint ================================================================ - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES - # ): - # image = clip_studio_thumb(filepath) - # # VTF ============================================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True - # ): - # image = vtf_thumb(filepath) - # # 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 = raw_image_thumb(filepath) - # # Vector Images ---------------------------------------------------------------- - # elif ext in MediaTypes.vector_image.renderable: - # image = vector_image_thumb(filepath, scaled_size) - # # EXR Images ------------------------------------------------------------------- - # elif ext in [".exr"]: - # image = exr_image_thumb(filepath) - # # Normal Images ---------------------------------------------------------------- - # else: - # image = raster_image_thumb(filepath) - # # Videos =========================================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - # ): - # image = video_thumb(filepath) - # # PowerPoint ======================================================================= - # elif ext in {".pptx"}: - # image = powerpoint_thumb(filepath) - # # OpenDocument/OpenOffice ========================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True - # ): - # image = open_doc_thumb(filepath) - # # Apple iWork + Creator Studio ===================================================== - # elif ( - # MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES) - # or ext == ".pxd" - # ): - # image = apple_embedded_thumb(filepath) - # # Plain Text ======================================================================= - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True - # ): - # image = text_thumb(filepath) - # # Fonts ============================================================================ - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.FONT_TYPES, mime_fallback=True - # ): - # if is_thumb: - # # Short (Aa) Preview - # image = font_small_thumb(filepath, scaled_size) - # if image is not None: - # image = self._apply_overlay_color(image, UiColor.BLUE, theme) - # else: - # # Large (Full Alphabet) Preview - # image = font_full_preview(filepath, scaled_size) - # # Audio ======================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - # ): - # image = audio_album_thumb(filepath, ext) - # if image is None: - # image = audio_waveform_thumb(filepath, ext, scaled_size, dpi_scale) - # is_savable_type = False - # if image is not None: - # image = self._apply_overlay_color(image, UiColor.GREEN, theme) - # # Blender ====================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.BLENDER_TYPES, mime_fallback=True - # ): - # image = _blender_thumb(filepath) - # # PDF ========================================================== - # elif MediaCategories.is_ext_in_category( - # ext, MediaCategories.PDF_TYPES, mime_fallback=True - # ): - # image = pdf_thumb(filepath, scaled_size, ext) - # # Archives ===================================================== - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES): - # image = archive_thumb(filepath, ext=ext) - # # MDIPACK ====================================================== - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES): - # image = medibang_paint_thumb(filepath) - # # Paint.NET ==================================================== - # elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES): - # image = paint_dot_net_thumb(filepath) - # # No Rendered Thumbnail ======================================== - # if not image: - # raise NoRendererError - - # if image: - # image = self._resize_image(image, (scaled_size, scaled_size)) - - # if cache_filename and is_savable_type and image and cache: - # cache.save_image(image, cache_filename, mode="RGBA") - - # except ( - # AssertionError, - # ChildProcessError, - # DecompressionBombError, - # UnidentifiedImageError, - # ValueError, - # ) as e: - # logger.error( - # "[FileRenderer] Couldn't render thumbnail", - # filepath=filepath, - # error=type(e).__name__, - # ) - # image = None - # except NoRendererError: - # image = None - - # return image - def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image: orig_x, orig_y = image.size new_x, new_y = size diff --git a/src/tagstudio/previews/renderers/apple_embedded_preview.py b/src/tagstudio/previews/renderers/apple_embedded_preview.py new file mode 100644 index 00000000..4a511149 --- /dev/null +++ b/src/tagstudio/previews/renderers/apple_embedded_preview.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class AppleEmbeddedPreview(BasePreview): + media_type_name = "iwork" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return apple_embedded_thumb(filepath) + + +def apple_embedded_thumb(filepath: Path) -> Image | None: + """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).""" + image_names: list[str] = [ + "preview.jpg", + "QuickLook/Preview.heic", + "QuickLook/Thumbnail.jpg", + "QuickLook/Thumbnail.heic", + "QuickLook/Thumbnail.webp", + "QuickLook/Icon.webp", + ] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/archive.py b/src/tagstudio/previews/renderers/archive.py index 01623a40..61e489ed 100644 --- a/src/tagstudio/previews/renderers/archive.py +++ b/src/tagstudio/previews/renderers/archive.py @@ -6,16 +6,18 @@ import tarfile import zipfile from io import BytesIO from pathlib import Path -from typing import Literal +from typing import Literal, override import py7zr import py7zr.io import rarfile import structlog -from PIL import Image +from PIL.Image import Image +from tagstudio.core.enums import Theme from tagstudio.core.media_types import MediaCategories from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) @@ -23,6 +25,22 @@ logger = structlog.get_logger(__name__) type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile +class ArchivePreview(BasePreview): + media_type_name = "archive" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return archive_thumb(filepath) + + class SevenZipFile(py7zr.SevenZipFile): """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" @@ -81,7 +99,7 @@ def open_archive(filepath: Path) -> Archive: return archiver(filepath, "r") -def first_image_in_archive(archive: Archive) -> Image.Image | None: +def first_image_in_archive(archive: Archive) -> Image | None: """Find and extract the first renderable image in the archive. Args: @@ -102,7 +120,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None: def archive_thumb( filepath: Path, image_names: list[Path] | list[str] | None = None, -) -> Image.Image | None: +) -> Image | None: """Extract an embedded preview image from an archive. Args: @@ -131,34 +149,3 @@ def archive_thumb( except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return None - - -def apple_embedded_thumb(filepath: Path) -> Image.Image | None: - """Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio).""" - image_names: list[str] = [ - "preview.jpg", - "QuickLook/Preview.heic", - "QuickLook/Thumbnail.jpg", - "QuickLook/Thumbnail.heic", - "QuickLook/Thumbnail.webp", - "QuickLook/Icon.webp", - ] - return archive_thumb(filepath, image_names) - - -def krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Krita file.""" - image_names = ["preview.png"] - return archive_thumb(filepath, image_names) - - -def open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file.""" - image_names = ["Thumbnails/thumbnail.png"] - return archive_thumb(filepath, image_names) - - -def powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file.""" - image_names = ["docProps/thumbnail.jpeg"] - return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index 50692923..4800c00c 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -12,8 +12,8 @@ import numpy as np import structlog from mutagen import flac, id3, mp4 from mutagen._util import MutagenError -from PIL import ImageDraw, ImageFont, UnidentifiedImageError -from PIL.Image import DecompressionBombError, Image, Resampling +from PIL import ImageDraw +from PIL.Image import Image, Resampling from PIL.Image import new as new_image from PIL.Image import open as open_image @@ -36,6 +36,7 @@ class AudioPreview(BasePreview): def render( cls, filepath: Path, + is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float, @@ -93,6 +94,7 @@ class AudioPreview(BasePreview): Args: filepath (Path): The path of the file. + theme (Theme): The system color theme. size (int): The size of the thumbnail. dpi_scale (float): The screen pixel ratio. """ diff --git a/src/tagstudio/previews/renderers/blender.py b/src/tagstudio/previews/renderers/blender.py index a043b269..71ef8b4a 100644 --- a/src/tagstudio/previews/renderers/blender.py +++ b/src/tagstudio/previews/renderers/blender.py @@ -6,7 +6,8 @@ from pathlib import Path from typing import override import structlog -from PIL.Image import Image, new +from PIL.Image import Image +from PIL.Image import new as new_image from tagstudio.core.enums import Theme from tagstudio.previews.base_preview import BasePreview @@ -23,6 +24,7 @@ class BlenderPreview(BasePreview): def render( cls, filepath: Path, + is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float, @@ -41,7 +43,7 @@ def _blender_thumb(filepath: Path, theme: Theme) -> Image | None: im: Image | None = None try: if (blend_image := blend_thumb(str(filepath))) is not None: - bg = new("RGB", blend_image.size, color=bg_color) + bg = new_image("RGB", blend_image.size, color=bg_color) bg.paste(blend_image, mask=blend_image.getchannel(3)) im = bg else: diff --git a/src/tagstudio/previews/renderers/clip_studio.py b/src/tagstudio/previews/renderers/clip_studio.py index ec031f5c..d4ba3483 100644 --- a/src/tagstudio/previews/renderers/clip_studio.py +++ b/src/tagstudio/previews/renderers/clip_studio.py @@ -5,14 +5,35 @@ import sqlite3 from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import open as open_image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview logger = structlog.get_logger(__name__) -def clip_studio_thumb(filepath: Path) -> Image.Image | None: +class ClipStudioPaintPreview(BasePreview): + media_type_name = "clip_studio_paint" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return clip_studio_thumb(filepath) + + +def clip_studio_thumb(filepath: Path) -> Image | None: """Extract the thumbnail from the SQLite database embedded in a .clip file. Args: @@ -21,7 +42,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None: Returns: Image: The embedded thumbnail, if extractable. """ - im: Image.Image | None = None + im: Image | None = None try: with open(filepath, "rb") as f: blob = f.read() @@ -33,7 +54,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None: conn.deserialize(blob[sqlite_index:]) thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone() if thumbnail: - im = Image.open(BytesIO(thumbnail[0])) + im = open_image(BytesIO(thumbnail[0])) conn.close() except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/code.py b/src/tagstudio/previews/renderers/code.py index c535424d..55187094 100644 --- a/src/tagstudio/previews/renderers/code.py +++ b/src/tagstudio/previews/renderers/code.py @@ -6,9 +6,7 @@ from pathlib import Path from typing import override import structlog -from PIL.Image import ( - Image, -) +from PIL.Image import Image from pygments.style import Style from pygments.token import ( Comment, @@ -98,6 +96,7 @@ class CodePreview(BasePreview): def render( cls, filepath: Path, + is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float, diff --git a/src/tagstudio/previews/renderers/ebook.py b/src/tagstudio/previews/renderers/ebook.py index b7d572c4..f1b30e43 100644 --- a/src/tagstudio/previews/renderers/ebook.py +++ b/src/tagstudio/previews/renderers/ebook.py @@ -5,20 +5,39 @@ import xml.etree.ElementTree as ET from io import BytesIO from pathlib import Path +from typing import override from xml.etree.ElementTree import Element import structlog -from PIL import Image +from PIL.Image import Image +from tagstudio.core.enums import Theme from tagstudio.core.media_types import MediaCategories from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.renderers.archive import Archive, first_image_in_archive, open_archive from tagstudio.previews.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) -def epub_thumb(filepath: Path) -> Image.Image | None: +class EbookPreview(BasePreview): + media_type_name = "ebook" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return epub_thumb(filepath) + + +def epub_thumb(filepath: Path) -> Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: @@ -29,14 +48,14 @@ def epub_thumb(filepath: Path) -> Image.Image | None: Image: The cover specified in ComicInfo.xml, the first image found in the ePub file, or None by default. """ - im: Image.Image | None = None + im: Image | None = None try: with open_archive(filepath) as archive: if "ComicInfo.xml" in archive.namelist(): comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = _cover_from_comic_info(archive, comic_info, "FrontCover") + im = cover_from_comic_info(archive, comic_info, "FrontCover") if not im: - im = _cover_from_comic_info(archive, comic_info, "InnerCover") + im = cover_from_comic_info(archive, comic_info, "InnerCover") if not im: im = first_image_in_archive(archive) @@ -46,9 +65,7 @@ def epub_thumb(filepath: Path) -> Image.Image | None: return im -def _cover_from_comic_info( - archive: Archive, comic_info: Element, cover_type: str -) -> Image.Image | None: +def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None: """Extract the cover specified in ComicInfo.xml. Args: @@ -59,7 +76,7 @@ def _cover_from_comic_info( Returns: Image: The cover specified in ComicInfo.xml. """ - im: Image.Image | None = None + im: Image | None = None cover = comic_info.find(f"./*Page[@Type='{cover_type}']") if cover is not None: diff --git a/src/tagstudio/previews/renderers/font.py b/src/tagstudio/previews/renderers/font.py index 4b39351c..54cd84aa 100644 --- a/src/tagstudio/previews/renderers/font.py +++ b/src/tagstudio/previews/renderers/font.py @@ -4,37 +4,65 @@ import math from pathlib import Path -from typing import cast +from typing import cast, override import numpy as np import structlog -from PIL import Image, ImageDraw, ImageFont +from PIL import ImageDraw, ImageFont +from PIL.Image import Image, Resampling, fromarray +from PIL.Image import new as new_image from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.effects import apply_overlay_color from tagstudio.qt.helpers.text_wrapper import wrap_full_text from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay +from tagstudio.qt.views.styles.palette import UiColor logger = structlog.get_logger(__name__) -def font_small_thumb(filepath: Path, size: int) -> Image.Image | None: +class FontPreview(BasePreview): + media_type_name = "font" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return ( + font_small_thumb(filepath, theme, size) + if is_small + else font_full_preview(filepath, size) + ) + + +def font_small_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None: """Render a small font preview ("Aa") thumbnail from a font file. Args: filepath (Path): The path of the file. + theme (Theme): The system color theme. size (tuple[int,int]): The size of the thumbnail. """ - im: Image.Image | None = None + # TODO: Support for non-square images + im: Image | None = None try: - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 3, size * 3), color="#000000") + bg = new_image("RGB", size, color="#000000") + raw = new_image("RGB", (size[0] * 3, size[1] * 3), color="#000000") draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(filepath, size=size) + font = ImageFont.truetype(filepath, size=size[0]) # 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), + (size[0] // 8, size[1] // 8), "Aa", font=font, fill="#FF0000", @@ -51,34 +79,37 @@ def font_small_thumb(filepath: Path, size: int) -> Image.Image | None: row.argmax() : m - row[::-1].argmax(), col.argmax() : n - col[::-1].argmax(), ] - cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") + cropped_im: Image = fromarray(cropped_data, "RGB") - margin: int = math.ceil(size // 16) + margin: int = math.ceil(size[0] // 16) orig_x, orig_y = cropped_im.size - new_x, new_y = (size, size) + new_x, new_y = size if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) + new_x = size[0] + new_y = math.ceil(size[1] * (orig_y / orig_x)) elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) + new_y = size[1] + new_x = math.ceil(size[0] * (orig_x / orig_y)) cropped_im = cropped_im.resize( size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, + resample=Resampling.BILINEAR, ) bg.paste( cropped_im, - box=(margin, margin + ((size - new_y) // 2)), + box=(margin, margin + ((size[1] - new_y) // 2)), ) im = bg + im = apply_overlay_color(im, UiColor.BLUE, theme) + except OSError as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im -def font_full_preview(filepath: Path, size: int) -> Image.Image | None: +def font_full_preview(filepath: Path, size: tuple[int, int]) -> Image | None: """Render a large font preview ("Alphabet") thumbnail from a font file. Args: @@ -87,21 +118,25 @@ def font_full_preview(filepath: Path, size: int) -> Image.Image | None: """ # Scale the sample font sizes to the preview image # resolution,assuming the sizes are tuned for 256px. - im: Image.Image | None = None + im: Image | None = None + # TODO: Support for non-square images 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 * (size[0] / 256)) for x in FONT_SAMPLE_SIZES] + bg = new_image("RGBA", 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, width=size, draw=draw) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, font=font, width=size[0], 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] + # TODO: Separate from any Qt stuff im = auto_theme_overlay(bg, use_alpha=False) except OSError as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/krita_preview.py b/src/tagstudio/previews/renderers/krita_preview.py new file mode 100644 index 00000000..c9971422 --- /dev/null +++ b/src/tagstudio/previews/renderers/krita_preview.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class KritaPreview(BasePreview): + media_type_name = "krita" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return krita_thumb(filepath) + + +def krita_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for a Krita file.""" + image_names = ["preview.png"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/medibang_paint.py b/src/tagstudio/previews/renderers/medibang_paint.py index d0beb1aa..f1d6c8e1 100644 --- a/src/tagstudio/previews/renderers/medibang_paint.py +++ b/src/tagstudio/previews/renderers/medibang_paint.py @@ -7,16 +7,35 @@ import struct import xml.etree.ElementTree as ET import zlib from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image, frombytes +from tagstudio.core.enums import Theme from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import BasePreview logger = structlog.get_logger(__name__) -def medibang_paint_thumb(filepath: Path) -> Image.Image | None: +class MediBangPaintPreview(BasePreview): + media_type_name = "medibang_paint" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return medibang_paint_thumb(filepath) + + +def medibang_paint_thumb(filepath: Path) -> Image | None: """Extract the thumbnail from a .mdp file. Args: @@ -25,7 +44,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None: Returns: Image: The embedded thumbnail. """ - im: Image.Image | None = None + im: Image | None = None try: with open(filepath, "rb") as f: magic = struct.unpack("<7sx", f.read(8))[0] @@ -50,7 +69,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None: if pac_header[2] == 1: thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4]) - im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA") + im = frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA") break except Exception as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) diff --git a/src/tagstudio/previews/renderers/open_document_preview.py b/src/tagstudio/previews/renderers/open_document_preview.py new file mode 100644 index 00000000..3f517161 --- /dev/null +++ b/src/tagstudio/previews/renderers/open_document_preview.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class OpenDocumentPreview(BasePreview): + media_type_name = "open_document" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return open_doc_thumb(filepath) + + +def open_doc_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for an OpenDocument file.""" + image_names = ["Thumbnails/thumbnail.png"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/paint_dot_net.py b/src/tagstudio/previews/renderers/paint_dot_net.py index c362c7d1..30673db2 100644 --- a/src/tagstudio/previews/renderers/paint_dot_net.py +++ b/src/tagstudio/previews/renderers/paint_dot_net.py @@ -7,14 +7,36 @@ import struct import xml.etree.ElementTree as ET from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import new as new_image +from PIL.Image import open as open_image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview logger = structlog.get_logger(__name__) -def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: +class PaintDotNetPreview(BasePreview): + media_type_name = "paint_dot_net" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return paint_dot_net_thumb(filepath) + + +def paint_dot_net_thumb(filepath: Path) -> Image | None: """Extract the base64-encoded thumbnail from a .pdn file header. Args: @@ -23,7 +45,7 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: Returns: Image: the decoded PNG thumbnail or None by default. """ - im: Image.Image | None = None + im: Image | None = None with open(filepath, "rb") as f: try: # First 4 bytes are the magic number @@ -39,9 +61,9 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None: encoded_png = thumb_element.get("png") if encoded_png: decoded_png = base64.b64decode(encoded_png) - im = Image.open(BytesIO(decoded_png)) + im = open_image(BytesIO(decoded_png)) if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg except Exception as e: diff --git a/src/tagstudio/previews/renderers/pdf.py b/src/tagstudio/previews/renderers/pdf.py index f5fb14e0..8f95242d 100644 --- a/src/tagstudio/previews/renderers/pdf.py +++ b/src/tagstudio/previews/renderers/pdf.py @@ -7,26 +7,46 @@ from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import Image +from PIL.Image import Image +from PIL.Image import open as open_image from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF from PySide6.QtGui import QImage from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview from tagstudio.qt.views.styles.image_effects import replace_transparent_pixels logger = structlog.get_logger(__name__) -def pdf_thumb(filepath: Path, size: int) -> Image.Image | None: +class PdfPreview(BasePreview): + media_type_name = "pdf" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return pdf_thumb(filepath, size) + + +def pdf_thumb(filepath: Path, size: tuple[int, int]) -> Image | None: """Render a thumbnail for a PDF or Adobe Illustrator file. filepath (Path): The path of the file. size (int): The size of the icon. ext (str): The file extension. """ - im: Image.Image | None = None + im: Image | None = None file: QFile = QFile(filepath) success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser) @@ -39,10 +59,11 @@ def pdf_thumb(filepath: Path, size: int) -> Image.Image | None: # 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() + # TODO: Make compatible with non-square images if ratio_hw >= 1: - page_size *= size / page_size.height() + page_size *= size[0] / page_size.height() else: - page_size *= size / page_size.width() + page_size *= size[0] / page_size.width() # Enlarge image for anti-aliasing scale_factor = 2.5 page_size *= scale_factor @@ -59,7 +80,7 @@ def pdf_thumb(filepath: Path, size: int) -> Image.Image | None: buffer.open(QBuffer.OpenModeFlag.ReadWrite) try: q_image.save(buffer, "PNG") # pyright: ignore - im = Image.open(BytesIO(buffer.buffer().data())) + im = open_image(BytesIO(buffer.buffer().data())) finally: buffer.close() # Replace transparent pixels with white (otherwise Background defaults to transparent) diff --git a/src/tagstudio/previews/renderers/powerpoint_preview.py b/src/tagstudio/previews/renderers/powerpoint_preview.py new file mode 100644 index 00000000..d868f1be --- /dev/null +++ b/src/tagstudio/previews/renderers/powerpoint_preview.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import Image + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.renderers.archive import archive_thumb + +logger = structlog.get_logger(__name__) + + +class PowerPointPreview(BasePreview): + media_type_name = "office.powerpoint" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return powerpoint_thumb(filepath) + + +def powerpoint_thumb(filepath: Path) -> Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file.""" + image_names = ["docProps/thumbnail.jpeg"] + return archive_thumb(filepath, image_names) diff --git a/src/tagstudio/previews/renderers/raster_image.py b/src/tagstudio/previews/renderers/raster_image.py index 67e9ccea..0e030046 100644 --- a/src/tagstudio/previews/renderers/raster_image.py +++ b/src/tagstudio/previews/renderers/raster_image.py @@ -5,20 +5,20 @@ import os from io import BytesIO from pathlib import Path +from typing import override import cv2 import numpy as np -import rawpy import structlog -from PIL import Image, ImageOps, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import ImageOps, UnidentifiedImageError +from PIL.Image import DecompressionBombError, Image, fromarray +from PIL.Image import new as new_image +from PIL.Image import open as open_image from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType] -from rawpy import ( - LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] - LibRawIOError, # pyright: ignore[reportPrivateImportUsage] -) +from tagstudio.core.enums import Theme from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import BasePreview logger = structlog.get_logger(__name__) @@ -31,13 +31,29 @@ register_heif_opener() os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" -def raster_image_thumb(filepath: Path) -> Image.Image | None: +class RasterImagePreview(BasePreview): + media_type_name = "image.raster" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return raster_image_thumb(filepath) + + +def raster_image_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a standard image type. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None try: if filepath.suffix.lower() == ".exr": return exr_image_thumb(filepath) @@ -55,13 +71,13 @@ def raster_image_thumb(filepath: Path) -> Image.Image | None: return im -def exr_image_thumb(filepath: Path) -> Image.Image | None: +def exr_image_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a EXR image type. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: 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) @@ -73,11 +89,11 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None: 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") + im = fromarray(array, mode="RGBA") # Paste solid background if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") + new_bg = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg @@ -86,32 +102,7 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None: return im -def raw_image_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, - LibRawFileUnsupportedError, - LibRawIOError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - -def image_from_bytes(image_data: BytesIO) -> Image.Image: +def image_from_bytes(image_data: BytesIO) -> Image: """Load a raster image and add a background if it's transparent. Args: @@ -120,11 +111,11 @@ def image_from_bytes(image_data: BytesIO) -> Image.Image: Returns: Image.Image: The loaded raster image, with a background if needed. """ - im: Image.Image = Image.open(image_data) + im: Image = open_image(image_data) 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 = new_image("RGB", im.size, color="#1e1e1e") new_bg.paste(im, mask=im.getchannel(3)) im = new_bg return unwrap(ImageOps.exif_transpose(im)) diff --git a/src/tagstudio/previews/renderers/raw_image_preview.py b/src/tagstudio/previews/renderers/raw_image_preview.py new file mode 100644 index 00000000..f6df8776 --- /dev/null +++ b/src/tagstudio/previews/renderers/raw_image_preview.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import rawpy +import structlog +from PIL.Image import DecompressionBombError, Image, frombytes +from rawpy import ( + LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage] + LibRawIOError, # pyright: ignore[reportPrivateImportUsage] +) + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview + +logger = structlog.get_logger(__name__) + + +class RawImagePreview(BasePreview): + media_type_name = "image.raw" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return raw_image_thumb(filepath) + + +def raw_image_thumb(filepath: Path) -> Image | None: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image | None = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + im = frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except ( + DecompressionBombError, + LibRawFileUnsupportedError, + LibRawIOError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + return im diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py index 6a9d79cd..006121f3 100644 --- a/src/tagstudio/previews/renderers/text.py +++ b/src/tagstudio/previews/renderers/text.py @@ -82,6 +82,7 @@ class TextPreview(BasePreview): def render( cls, filepath: Path, + is_small: bool, theme: Theme, size: tuple[int, int], dpi_scale: float, diff --git a/src/tagstudio/previews/renderers/vector_image.py b/src/tagstudio/previews/renderers/vector_image.py index a0c2d2b3..1883d3b2 100644 --- a/src/tagstudio/previews/renderers/vector_image.py +++ b/src/tagstudio/previews/renderers/vector_image.py @@ -6,29 +6,49 @@ from io import BytesIO from pathlib import Path +from typing import override import structlog -from PIL import ( - Image, - UnidentifiedImageError, -) +from PIL import UnidentifiedImageError +from PIL.Image import Image +from PIL.Image import new as new_image +from PIL.Image import open as open_image from PySide6.QtCore import QBuffer, Qt from PySide6.QtGui import QImage, QPainter from PySide6.QtSvg import QSvgRenderer +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview + logger = structlog.get_logger(__name__) -def vector_image_thumb(filepath: Path, size: int) -> Image.Image: +class VectorImagePreview(BasePreview): + media_type_name = "image.vector" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return vector_image_thumb(filepath, size) + + +def vector_image_thumb(filepath: Path, size: tuple[int, int]) -> 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 + im: 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: QImage = QImage(size[0], size[1], QImage.Format.Format_ARGB32) q_image.fill("#1e1e1e") # Create an svg renderer, then render to the painter @@ -48,8 +68,8 @@ def vector_image_thumb(filepath: Path, size: int) -> Image.Image: q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType] # Load the image from the buffer - im = Image.new("RGB", (size, size), color="#1e1e1e") - im.paste(Image.open(BytesIO(buffer.data().data()))) + im = new_image("RGB", size, color="#1e1e1e") + im.paste(open_image(BytesIO(buffer.data().data()))) im = im.convert(mode="RGB") buffer.close() diff --git a/src/tagstudio/previews/renderers/video.py b/src/tagstudio/previews/renderers/video.py index 8ea8b4db..930ea323 100644 --- a/src/tagstudio/previews/renderers/video.py +++ b/src/tagstudio/previews/renderers/video.py @@ -4,25 +4,44 @@ import math from pathlib import Path +from typing import override import cv2 import structlog from cv2.typing import MatLike -from PIL import Image, UnidentifiedImageError -from PIL.Image import DecompressionBombError +from PIL import UnidentifiedImageError +from PIL.Image import DecompressionBombError, Image, fromarray +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.video_tester import is_readable_video logger = structlog.get_logger(__name__) -def video_thumb(filepath: Path) -> Image.Image | None: +class VideoPreview(BasePreview): + media_type_name = "video" + + @override + @classmethod + def render( + cls, + filepath: Path, + is_small: bool, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return video_thumb(filepath) + + +def video_thumb(filepath: Path) -> Image | None: """Render a thumbnail for a video file. Args: filepath (Path): The path of the file. """ - im: Image.Image | None = None + im: Image | None = None frame: MatLike | None = None try: if is_readable_video(filepath): @@ -49,7 +68,12 @@ def video_thumb(filepath: Path) -> Image.Image | None: 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: + im = fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) return im diff --git a/src/tagstudio/previews/vendored/pydub/audio_segment.py b/src/tagstudio/previews/vendored/pydub/audio_segment.py index bdb08b63..d672f948 100644 --- a/src/tagstudio/previews/vendored/pydub/audio_segment.py +++ b/src/tagstudio/previews/vendored/pydub/audio_segment.py @@ -83,6 +83,8 @@ def classproperty(func): AUDIO_FILE_EXT_ALIASES = { "m4a": "mp4", "wave": "wav", + "aif": "aiff", + "aifc": "aiff", } WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"]) diff --git a/src/tagstudio/qt/views/styles/color_overlay.py b/src/tagstudio/qt/views/styles/color_overlay.py index 3a394547..b5097c0b 100644 --- a/src/tagstudio/qt/views/styles/color_overlay.py +++ b/src/tagstudio/qt/views/styles/color_overlay.py @@ -6,9 +6,7 @@ from PIL import Image from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication -from tagstudio.core.enums import Theme from tagstudio.previews.gradients import linear_gradient -from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color # TODO: Consolidate the built-in QT theme values with the values # here, in enums.py, and in palette.py.