convert most remaining thumbnail renderers to classes

This commit is contained in:
Travis Abendshien
2026-08-22 13:36:43 -07:00
parent 57bf2a5ea5
commit c6a2600165
27 changed files with 647 additions and 427 deletions
+47 -4
View File
@@ -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)
# ------------------
@@ -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)
+1 -1
View File
@@ -1,5 +1,5 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from threading import Lock
+1
View File
@@ -24,6 +24,7 @@ class BasePreview:
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
+6 -4
View File
@@ -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.
+52 -268
View File
@@ -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
@@ -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)
+22 -35
View File
@@ -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)
+4 -2
View File
@@ -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.
"""
+4 -2
View File
@@ -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:
@@ -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__)
+2 -3
View File
@@ -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,
+26 -9
View File
@@ -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:
+57 -22
View File
@@ -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__)
@@ -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)
@@ -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__)
@@ -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)
@@ -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:
+27 -6
View File
@@ -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)
@@ -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)
@@ -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))
@@ -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
+1
View File
@@ -82,6 +82,7 @@ class TextPreview(BasePreview):
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
@@ -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()
+30 -6
View File
@@ -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
@@ -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"])
@@ -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.