diff --git a/docs/preview-support.md b/docs/preview-support.md index e7d22a43c..86db84f4e 100644 --- a/docs/preview-support.md +++ b/docs/preview-support.md @@ -10,79 +10,89 @@ TagStudio offers built-in preview and thumbnail support for a wide variety of fi Images will generate thumbnails the first time they are viewed or since the last time they were modified. Thumbnails are used in the grid view, but not in the Preview Panel. Animated images will play in the Preview Panel. -| Filetype | Extensions | Animation | -| -------------------- | -------------------------------------------------- | :---------------------------------: | -| Animated PNG | `.apng` | :material-check-circle:{.lg .green} | -| Apple Icon Image | `.icns` | :material-minus-circle:{.lg .gray} | -| AVIF | `.avif` | :material-minus-circle:{.lg .gray} | -| Bitmap | `.bmp` | :material-minus-circle:{.lg .gray} | -| GIF | `.gif` | :material-check-circle:{.lg .green} | -| HEIF | `.heif`, `.heic` | :material-minus-circle:{.lg .gray} | -| JPEG | `.jpeg`, `.jpg`, `.jfif`, `.jif`, `.jpg_large`[^1] | :material-minus-circle:{.lg .gray} | -| JPEG-XL | `.jxl` | :material-close-circle:{.lg .red} | -| OpenEXR | `.exr` | :material-minus-circle:{.lg .gray} | -| OpenRaster | `.ora` | :material-minus-circle:{.lg .gray} | -| PNG | `.png` | :material-minus-circle:{.lg .gray} | -| SVG | `.svg` | :material-close-circle:{.lg .red} | -| TIFF | `.tiff`, `.tif` | :material-minus-circle:{.lg .gray} | -| Valve Texture Format | `.vtf` | :material-close-circle:{.lg .red} | -| WebP | `.webp` | :material-check-circle:{.lg .green} | -| Windows Icon | `.ico` | :material-minus-circle:{.lg .gray} | +| Filetype | Extensions | Animation | +|----------------------|-----------------------------------------------------------------------------------------|-------------------------------------| +| Animated PNG | `.apng` | :material-check-circle:{.lg .green} | +| Apple Icon Image | `.icns` | :material-minus-circle:{.lg .gray} | +| AVIF | `.avif` | :material-minus-circle:{.lg .gray} | +| Bitmap | `.bmp` | :material-minus-circle:{.lg .gray} | +| GIF | `.gif` | :material-check-circle:{.lg .green} | +| HEIF | `.heif`, `.heic` | :material-minus-circle:{.lg .gray} | +| JPEG | `.jpeg`, `.jpg`, `.jpe`, `.jif`, `.jfif`, `.jfi`, `.jpeg_large`[^1]`, `.jpg_large`[^1], | :material-minus-circle:{.lg .gray} | +| JPEG 2000 | `.jp2`, `.j2k`, `.jpf`, `.jpm`, `.jpg2`, `.j2c`, `.jpc`, `.jpx`, `.mj2` | :material-minus-circle:{.lg .gray} | +| JPEG-XL | `.jxl` | :material-close-circle:{.lg .red} | +| OpenEXR | `.exr` | :material-minus-circle:{.lg .gray} | +| OpenRaster | `.ora` | :material-minus-circle:{.lg .gray} | +| PNG | `.png` | :material-minus-circle:{.lg .gray} | +| SVG | `.svg` | :material-close-circle:{.lg .red} | +| TIFF | `.tiff`, `.tif` | :material-minus-circle:{.lg .gray} | +| Valve Texture Format | `.vtf` | :material-close-circle:{.lg .red} | +| WebP | `.webp` | :material-check-circle:{.lg .green} | +| Windows Icon | `.ico` | :material-minus-circle:{.lg .gray} | #### :material-image-outline: RAW Images -| Filetype | Extensions | -| -------------------------------- | ---------------------- | -| Camera Image File Format (Canon) | `.crw`, `.cr2`, `.cr3` | -| Digital Negative | `.dng` | -| Fuji RAW | `.raf` | -| Nikon RAW | `.nef`, `.nrw` | -| Olympus RAW | `.orf` | -| Panasonic RAW | `.raw`, `.rw2` | -| Sony RAW | `.arw` | +| Filetype | Extensions | +|----------------------------------|----------------------------------------| +| Aptus RAW | `.mos` | +| Camera Image File Format (Canon) | `.crw`, `.cr2`, `.cr3` | +| Digital Negative | `.dng` | +| Epson RAW | `.erf` | +| Fuji RAW | `.raf` | +| Hasselblad RAW | `.3fr` | +| Kodak RAW | `.dcs`, `.dcr`, `.drf`, `.k25`, `.kdc` | +| Mamiya RAW | `.mef` | +| Minolta RAW | `.mrw`, `.mdc` | +| Nikon RAW | `.nef`, `.nrw` | +| Olympus RAW | `.orf` | +| Panasonic RAW | `.raw`, `.rw2` | +| Pentax RAW | `.pef` | +| Samsung RAW | `.srw` | +| Sigma RAW | `.x3f` | +| Sony RAW | `.arw`, `.srf`, `.srf2`, `.sr2` | ### :material-movie-open: Videos Video thumbnails will default to the closest viable frame from the middle of the video. Both thumbnail generation and video playback in the Preview Panel requires [FFmpeg](install.md#third-party-dependencies) installed on your system. | Filetype | Extensions | Dependencies | -| --------------------- | ----------------------- | :----------: | -| 3GP | `.3gp` | FFmpeg | -| AVI | `.avi` | FFmpeg | -| AVIF | `.avif` | FFmpeg | -| FLV | `.flv` | FFmpeg | -| HEVC | `.hevc` | FFmpeg | -| Matroska | `.mkv` | FFmpeg | -| MP4 | `.mp4` , `.m4p` | FFmpeg | -| MPEG Transport Stream | `.ts` | FFmpeg | -| QuickTime | `.mov`, `.movie`, `.qt` | FFmpeg | -| WebM | `.webm` | FFmpeg | -| WMV | `.wmv` | FFmpeg | +|-----------------------|-------------------------|--------------| +| 3GP | `.3gp` | FFmpeg | +| AVI | `.avi` | FFmpeg | +| AVIF | `.avif` | FFmpeg | +| FLV | `.flv` | FFmpeg | +| GIFV | `.gifv` | FFmpeg | +| HEVC | `.hevc` | FFmpeg | +| Matroska | `.mkv` | FFmpeg | +| M4V | `.m4v` | FFmpeg | +| MP4 | `.mp4` , `.m4p` | FFmpeg | +| MPEG Transport Stream | `.ts` | FFmpeg | +| QuickTime | `.mov`, `.movie`, `.qt` | FFmpeg | +| WebM | `.webm` | FFmpeg | +| WMV | `.wmv` | FFmpeg | ### :material-sine-wave: Audio Audio thumbnails will default to embedded cover art (if any) and fallback to generated waveform thumbnails. Audio file playback is supported in the Preview Panel if you have [FFmpeg](install.md#third-party-dependencies) installed on your system. Audio waveforms are currently not cached. -| Filetype | Extensions | Dependencies | -| ------------------- | ------------------------ | :----------: | -| AAC | `.aac`, `.m4a` | FFmpeg | -| AIFF | `.aiff`, `.aif`, `.aifc` | FFmpeg | -| Apple Lossless[^2] | `.alac`, `.aac` | FFmpeg | -| FLAC | `.flac` | FFmpeg | -| MP3 | `.mp3` | FFmpeg | -| Ogg | `.ogg` | FFmpeg | -| WAVE | `.wav`, `.wave` | FFmpeg | -| Windows Media Audio | `.wma` | FFmpeg | +| Filetype | Extensions | Dependencies | +|---------------------|--------------------------|----------------| +| AAC | `.aac`, `.m4a` | FFmpeg | +| AIFF | `.aiff`, `.aif`, `.aifc` | FFmpeg | +| Apple Lossless[^2] | `.alac`, `.aac`, `.caf` | FFmpeg | +| FLAC | `.flac` | FFmpeg | +| MP3 | `.mp3` | FFmpeg | +| Ogg | `.ogg` | FFmpeg | +| WAVE | `.wav`, `.wave` | FFmpeg | +| Windows Media Audio | `.wma` | FFmpeg | ### :material-file-chart: Documents Preview support for office documents or well-known project file formats varies by the format and whether or not embedded thumbnails are available to be read from. OpenDocument-based files are typically supported. | Filetype | Extensions | Preview Type | -| ----------------------------- | --------------------- | -------------------------------------------------------------------------- | -| Blender | `.blend`, `.blend<#>` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +|-------------------------------|-----------------------|----------------------------------------------------------------------------| | Keynote (Apple iWork) | `.key` | Embedded thumbnail | -| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | | MuseScore | `.mscz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | | Numbers (Apple iWork) | `.numbers` | Embedded thumbnail | | OpenDocument Presentation | `.odp`, `.fodp` | Embedded thumbnail | @@ -90,13 +100,12 @@ Preview support for office documents or well-known project file formats varies b | OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail | | Pages (Apple iWork) | `.pages` | Embedded thumbnail | | PDF | `.pdf` | First page render | -| Photoshop | `.psd` | Flattened image render | | PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | ### :material-book: eBooks | Filetype | Extensions | Preview Type | -| ------------------ | --------------------- | ---------------------------- | +|--------------------|-----------------------|------------------------------| | EPUB | `.epub` | Embedded cover | | Comic Book Archive | `.cbr`, `.cbt` `.cbz` | Embedded cover or first page | @@ -106,12 +115,24 @@ Preview support for office documents or well-known project file formats varies b !!! failure "3D Model Support" TagStudio does not currently support previews for 3D model files *(outside of Blender project embedded thumbnails)*. This is on our [roadmap](roadmap.md#uiux) for a future release. +| Filetype | Extensions | Preview Type | +|-----------------|-----------------------------------|----------------------------------------------------------------------------| +| Blender | `.blend`, `.blend<#>`, `.blen_tc` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | + +### :material-layers: Project + +| Filetype | Extensions | Preview Type | +|----------------------------|----------------|----------------------------------------------------------------------------| +| Krita[^3] | `.kra`, `.krz` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } | +| Photoshop | `.psd`, `.psb` | Flattened image render | + + ### :material-format-font: Fonts Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel. | Filetype | Extensions | -| -------------------- | ----------------- | +|----------------------|-------------------| | OpenType Font | `.otf`, `.otc` | | TrueType Font | `.ttf`, `.ttc` | | Web Open Font Format | `.woff`, `.woff2` | @@ -124,20 +145,20 @@ Font thumbnails will use a "Aa" example preview of the font, with a full alphanu Text files render the first 256 bytes of text information to an image preview for thumbnails and the Preview Panel. Improved thumbnails, full scrollable text, and syntax highlighting are on our [roadmap](roadmap.md#uiux) for future features. -| Filetype | Extensions | Syntax Highlighting | -| ---------- | --------------------------------------------- | :--------------------------------: | -| CSV | `.csv` | :material-close-circle:{.lg .red} | -| HTML | `.html`, `.htm`, `.xhtml`, `.shtml`, `.dhtml` | :material-close-circle:{.lg .red} | -| JSON | `.json`, `.jsonc`, `.json5` | :material-close-circle:{.lg .red} | -| Markdown | `.md`, `.markdown`, `.mkd`, `.rmd` | :material-close-circle:{.lg .red} | -| Plain Text | `.txt`, `.text` | :material-minus-circle:{.lg .gray} | -| TOML | `.toml` | :material-close-circle:{.lg .red} | -| XML | `.xml`, `.xul` | :material-close-circle:{.lg .red} | -| YAML | `.yaml`, `.yml` | :material-close-circle:{.lg .red} | +| Filetype | Extensions | Syntax Highlighting | +|------------|-----------------------------------------------|-------------------------------------| +| CSV | `.csv` | :material-close-circle:{.lg .red} | +| HTML | `.html`, `.htm`, `.xhtml`, `.shtml`, `.dhtml` | :material-close-circle:{.lg .red} | +| JSON | `.json`, `.jsonc`, `.json5` | :material-close-circle:{.lg .red} | +| Markdown | `.md`, `.markdown`, `.mkd`, `.rmd` | :material-close-circle:{.lg .red} | +| Plain Text | `.txt`, `.text` | :material-minus-circle:{.lg .gray} | +| TOML | `.toml` | :material-close-circle:{.lg .red} | +| XML | `.xml`, `.xul` | :material-close-circle:{.lg .red} | +| YAML | `.yaml`, `.yml` | :material-close-circle:{.lg .red} | [^1]: - The `.jpg_large` extension is unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it. + The `.jpg_large` and `.jpeg_large` extensions are unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since these mangled extensions are still in circulation, TagStudio supports them. [^2]: Apple Lossless traditionally uses `.m4a` and `.caf` containers, but may unofficially use the `.alac` extension. The `.m4a` container is also used for separate compressed audio codecs. diff --git a/pyproject.toml b/pyproject.toml index cb095d849..387a894c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,11 @@ dependencies = [ "ffmpeg-python~=0.2", "humanfriendly==10.*", "mutagen~=1.47", + "numexpr~=2.14.1", "numpy~=2.2", "opencv_python~=4.11", + "openexr~=3.4.3", + "pillow-avif-plugin~=1.5", "Pillow>=10.2,<12", "pillow-heif~=0.22", "pillow-jxl-plugin~=1.3", diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 8659c389d..a772c13d7 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -41,6 +41,7 @@ class MediaType(str, Enum): FONT = "font" IMAGE_ANIMATED = "image_animated" IMAGE_RAW = "image_raw" + IMAGE_EXR = "image_exr" IMAGE_VECTOR = "image_vector" IMAGE = "image" INSTALLER = "installer" @@ -51,6 +52,7 @@ class MediaType(str, Enum): PACKAGE = "package" PDF = "pdf" PLAINTEXT = "plaintext" + POWERPOINT = "powerpoint" PRESENTATION = "presentation" PROGRAM = "program" SHADER = "shader" @@ -109,7 +111,6 @@ class MediaCategories: ".psd", } _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} - _KRITA_SET: set[str] = {".kra", ".krz"} _ARCHIVE_SET: set[str] = { ".7z", ".gz", @@ -296,16 +297,32 @@ class MediaCategories: ".cr2", ".cr3", ".crw", + ".dcs", + ".dcr", ".dng", + ".drf", + ".erf", + ".k25", + ".kdc", + ".mdc", + ".mef", + ".mos", + ".mrw", ".nef", ".nrw", ".orf", + ".pef", ".raf", ".raw", ".rw2", ".srf", ".srf2", + ".sr2", + ".srw", + ".x3f", + ".3fr", } + _IMAGE_EXR_SET: set[str] = {".exr"} _IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"} _IMAGE_RASTER_SET: set[str] = { ".apng", @@ -316,24 +333,34 @@ class MediaCategories: ".heic", ".heif", ".icns", - ".j2k", + ".jpeg", + ".jpg", + ".jpe", + ".jif", ".jfif", - ".jp2", + ".jfi", ".jpeg_large", - ".jpeg", ".jpg_large", - ".jpg", + ".jp2", + ".j2k", + ".jpf", + ".jpm", ".jpg2", + ".j2c", + ".jpc", + ".jpx", + ".mj2", ".jxl", ".png", ".psb", ".psd", - ".tif", ".tiff", + ".tif", ".webp", } _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} _IWORK_SET: set[str] = {".key", ".pages", ".numbers"} + _KRITA_SET: set[str] = {".kra", ".krz"} _MATERIAL_SET: set[str] = {".mtl"} _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} _OPEN_DOCUMENT_SET: set[str] = { @@ -375,11 +402,11 @@ class MediaCategories: "license", "readme", } + _POWERPOINT_SET: set[str] = {".pptx"} _PRESENTATION_SET: set[str] = { ".key", ".odp", ".ppt", - ".pptx", } _PROGRAM_SET: set[str] = {".app", ".bin", ".exe"} _SOURCE_ENGINE_SET: set[str] = {".vtf"} @@ -410,6 +437,7 @@ class MediaCategories: ".m4v", ".mkv", ".mov", + ".movie", ".mp4", ".webm", ".wmv", @@ -500,6 +528,9 @@ class MediaCategories: is_iana=False, name="raw image", ) + IMAGE_EXR_TYPES = MediaCategory( + media_type=MediaType.IMAGE_EXR, extensions=_IMAGE_EXR_SET, is_iana=False, name="exr image" + ) IMAGE_VECTOR_TYPES = MediaCategory( media_type=MediaType.IMAGE_VECTOR, extensions=_IMAGE_VECTOR_SET, @@ -566,9 +597,15 @@ class MediaCategories: is_iana=False, name="plaintext", ) + POWERPOINT_TYPES = MediaCategory( + media_type=MediaType.POWERPOINT, + extensions=_POWERPOINT_SET, + is_iana=False, + name="powerpoint", + ) PRESENTATION_TYPES = MediaCategory( media_type=MediaType.PRESENTATION, - extensions=_PRESENTATION_SET, + extensions=_PRESENTATION_SET | _POWERPOINT_SET, is_iana=False, name="presentation", ) diff --git a/src/tagstudio/qt/controllers/preview_thumb_controller.py b/src/tagstudio/qt/controllers/preview_thumb_controller.py index ecc5d96a2..315089690 100644 --- a/src/tagstudio/qt/controllers/preview_thumb_controller.py +++ b/src/tagstudio/qt/controllers/preview_thumb_controller.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import cv2 +import OpenEXR import rawpy import structlog from PIL import Image, UnidentifiedImageError @@ -49,11 +50,19 @@ def __get_image_stats(self, filepath: Path) -> FileAttributeData: stats.width = image.width stats.height = image.height except ( - rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] + rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] FileNotFoundError, ): pass + elif MediaCategories.IMAGE_EXR_TYPES.contains(ext, mime_fallback=True): + try: + exr_file = OpenEXR.File(str(filepath)) + part = exr_file.parts[0] + stats.width = part.width() + stats.height = part.height() + except Exception: + pass elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True): try: image = Image.open(str(filepath)) diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py new file mode 100644 index 000000000..2544613fd --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/archive_file.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Literal + + +class ArchiveFile(ABC): + @abstractmethod + def __init__(self, path: Path, mode: Literal["r"]) -> None: + pass + + @abstractmethod + def get_name_list(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + def has_file_name(self, file_name: str) -> bool: + raise NotImplementedError + + @abstractmethod + def read(self, file_name: str) -> bytes | None: + raise NotImplementedError diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py new file mode 100644 index 000000000..46f4c1084 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/rar_file.py @@ -0,0 +1,52 @@ +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +import rarfile + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class RarFile(ArchiveFile): + """Wrapper around rarfile.RarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__rar_file: rarfile.RarFile = rarfile.RarFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__rar_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__rar_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return self.__rar_file.read(file_path) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py new file mode 100644 index 000000000..e34ddd0c7 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/seven_zip_file.py @@ -0,0 +1,60 @@ +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +import py7zr +from py7zr import io + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class SevenZipFile(ArchiveFile): + """Wrapper around py7zr.SevenZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__seven_zip_file: py7zr.SevenZipFile = py7zr.SevenZipFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__seven_zip_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__seven_zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + # py7zr.SevenZipFile must be reset after every extraction + # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract + self.__seven_zip_file.reset() + + factory = io.BytesIOFactory(limit=10485760) # 10 MiB + + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + self.__seven_zip_file.extract(targets=[str(file_path)], factory=factory) + return factory.get(file_path).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py new file mode 100644 index 000000000..256619595 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/tar_file.py @@ -0,0 +1,52 @@ +import tarfile +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class TarFile(ArchiveFile): + """Wrapper around tarfile.TarFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__tar_file: tarfile.TarFile = tarfile.TarFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__tar_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__tar_file.getnames(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return unwrap(self.__tar_file.extractfile(str(file_path))).read() + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py new file mode 100644 index 000000000..3aa1a64a0 --- /dev/null +++ b/src/tagstudio/qt/helpers/file_wrappers/archive/zip_file.py @@ -0,0 +1,51 @@ +import zipfile +from pathlib import Path +from types import TracebackType +from typing import Literal, Self + +from tagstudio.qt.helpers.file_wrappers.archive.archive_file import ArchiveFile + + +class ZipFile(ArchiveFile): + """Wrapper around zipfile.ZipFile.""" + + def __init__(self, path: Path, mode: Literal["r"]) -> None: + super().__init__(path, mode) + self.path = path + self.__zip_file: zipfile.ZipFile = zipfile.ZipFile(path, mode) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception_value: BaseException | None, + exception_traceback: TracebackType | None, + ) -> None: + self.__zip_file.close() + + def get_name_list(self) -> list[str]: + without_own_file_name: map = map( + lambda file_name: file_name.replace(f"{self.path.name}/", ""), + self.__zip_file.namelist(), + ) + without_empty_items: filter = filter(None, without_own_file_name) + + return list(without_empty_items) + + def has_file_name(self, file_name: str) -> bool: + return file_name in self.get_name_list() + + def read(self, file_name: str) -> bytes | None: + search_paths: list[Path] = [Path(file_name), Path(self.path.name, file_name)] + try: + for file_path in search_paths: + try: + return self.__zip_file.read(str(file_path)) + except KeyError: + continue + + return None + except KeyError as e: + raise e diff --git a/src/tagstudio/qt/helpers/image_effects.py b/src/tagstudio/qt/helpers/image_effects.py index ca764744d..b4f960bfe 100644 --- a/src/tagstudio/qt/helpers/image_effects.py +++ b/src/tagstudio/qt/helpers/image_effects.py @@ -5,6 +5,10 @@ import numpy as np from PIL import Image +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color def replace_transparent_pixels( @@ -26,3 +30,45 @@ def replace_transparent_pixels( pixel_array = np.asarray(img.convert("RGBA")).copy() pixel_array[pixel_array[:, :, 3] == 0] = color return Image.fromarray(pixel_array) + + +def apply_overlay_color(image: Image.Image, color: UiColor) -> Image.Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + overlay_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + overlay: Image.Image = Image.new(image.mode, image.size, color=overlay_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(overlay, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg diff --git a/src/tagstudio/qt/previews/renderer.py b/src/tagstudio/qt/previews/renderer.py index 566ab7aed..13793659a 100644 --- a/src/tagstudio/qt/previews/renderer.py +++ b/src/tagstudio/qt/previews/renderer.py @@ -7,75 +7,41 @@ import hashlib import math import os -import tarfile -import xml.etree.ElementTree as ET -import zipfile from copy import deepcopy -from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast -from warnings import catch_warnings -from xml.etree.ElementTree import Element - -import cv2 -import numpy as np -import py7zr -import py7zr.io -import rarfile -import rawpy -import srctools +from typing import TYPE_CHECKING + +import pillow_avif # noqa: F401 # pyright: ignore[reportUnusedImport] import structlog -from cv2.typing import MatLike -from mutagen import flac, id3, mp4 -from mutagen._util import MutagenError from PIL import ( Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, - ImageFont, - ImageOps, ImageQt, UnidentifiedImageError, ) from PIL.Image import DecompressionBombError from pillow_heif import register_heif_opener from PySide6.QtCore import ( - QBuffer, - QFile, - QFileDevice, - QIODeviceBase, QObject, QSize, - QSizeF, Qt, Signal, ) -from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap -from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions -from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QGuiApplication, QPixmap -from tagstudio.core.constants import ( - FONT_SAMPLE_SIZES, - FONT_SAMPLE_TEXT, -) from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.ignore import Ignore from tagstudio.core.media_types import MediaCategories, MediaType -from tagstudio.core.utils.encoding import detect_char_encoding from tagstudio.core.utils.types import unwrap from tagstudio.qt.global_settings import DEFAULT_CACHED_IMAGE_RES -from tagstudio.qt.helpers.color_overlay import theme_fg_overlay -from tagstudio.qt.helpers.file_tester import is_readable_video from tagstudio.qt.helpers.gradients import four_corner_gradient -from tagstudio.qt.helpers.image_effects import replace_transparent_pixels -from tagstudio.qt.helpers.text_wrapper import wrap_full_text -from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor, get_ui_color -from tagstudio.qt.previews.vendored.blender_renderer import blend_thumb -from tagstudio.qt.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, -) +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.models.palette import UI_COLORS, ColorType, UiColor +from tagstudio.qt.previews.renderer_type import RendererType +from tagstudio.qt.previews.renderers.base_renderer import RendererContext from tagstudio.qt.resource_manager import ResourceManager if TYPE_CHECKING: @@ -94,40 +60,6 @@ logger.exception('[ThumbRenderer] Could not import the "pillow_jxl" module') -class _SevenZipFile(py7zr.SevenZipFile): - """Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def read(self, name: str) -> bytes: - # SevenZipFile must be reset after every extraction - # See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract - self.reset() - factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB - self.extract(targets=[name], factory=factory) - return factory.get(name).read() - - -class _TarFile(tarfile.TarFile): - """Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API.""" - - def __init__(self, filepath: Path, mode: Literal["r"]) -> None: - super().__init__(filepath, mode) - - def namelist(self) -> list[str]: - return self.getnames() - - def read(self, name: str) -> bytes: - return unwrap(self.extractfile(name)).read() - - -type _Archive_T = ( - type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile] -) -type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile - - class ThumbRenderer(QObject): """A class for rendering image and file thumbnails.""" @@ -284,7 +216,7 @@ def _render_mask( im: Image.Image = Image.new( mode="L", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="black", ) draw = ImageDraw.Draw(im) @@ -315,7 +247,7 @@ def _render_edge( # Highlight im_hl: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_hl) @@ -334,7 +266,7 @@ def _render_edge( # Shadow im_sh: Image.Image = Image.new( mode="RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) draw = ImageDraw.Draw(im_sh) @@ -379,7 +311,7 @@ def _render_center_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#FF000000", ) @@ -387,13 +319,13 @@ def _render_center_icon( bg: Image.Image bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000FF", ) # Use a background image if provided if bg_image: - bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg_im = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) bg_im = ImageEnhance.Brightness(bg_im).enhance(0.3) # Reduce the brightness bg.paste(bg_im) @@ -454,7 +386,7 @@ def _render_center_icon( ) # Apply color overlay - im = self._apply_overlay_color( + im = apply_overlay_color( im, color, ) @@ -486,23 +418,23 @@ def _render_corner_icon( # Create larger blank image based on smooth_factor im: Image.Image = Image.new( "RGBA", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#00000000", ) bg: Image.Image # Use a background image if provided if bg_image: - bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # type: ignore + bg = Image.Image.resize(bg_image, size=([d * smooth_factor for d in size])) # Create solid background color else: bg = Image.new( "RGB", - size=tuple([d * smooth_factor for d in size]), # type: ignore + size=([d * smooth_factor for d in size]), color="#000000", ) # Apply color overlay - bg = self._apply_overlay_color( + bg = apply_overlay_color( im, color, ) @@ -560,47 +492,6 @@ def _render_corner_icon( return im - def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Image: - """Apply a color overlay effect to an image based on its color channel data. - - Red channel for foreground, green channel for outline, none for background. - - Args: - image (Image.Image): The image to apply an overlay to. - color (UiColor): The name of the ColorType color to use. - """ - bg_color: str = ( - get_ui_color(ColorType.DARK_ACCENT, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.PRIMARY, color) - ) - fg_color: str = ( - get_ui_color(ColorType.PRIMARY, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - ol_color: str = ( - get_ui_color(ColorType.BORDER, color) - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else get_ui_color(ColorType.LIGHT_ACCENT, color) - ) - - bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) - fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) - ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) - - bg.paste(fg, (0, 0), mask=image.getchannel(0)) - bg.paste(ol, (0, 0), mask=image.getchannel(1)) - - if image.mode == "RGBA": - alpha_bg: Image.Image = bg.copy() - alpha_bg.convert("RGBA") - alpha_bg.putalpha(0) - alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) - bg = alpha_bg - - return bg - def _apply_edge( self, image: Image.Image, @@ -637,746 +528,6 @@ def _apply_edge( return im - @staticmethod - def _audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None: - """Return an album cover thumb from an audio file if a cover is present. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - """ - image: Image.Image | None = None - try: - if not filepath.is_file(): - raise FileNotFoundError - - artwork = None - if ext in [".mp3"]: - id3_tags: id3.ID3 = id3.ID3(filepath) - id3_covers: list = id3_tags.getall("APIC") - if id3_covers: - artwork = Image.open(BytesIO(id3_covers[0].data)) - elif ext in [".flac"]: - flac_tags: flac.FLAC = flac.FLAC(filepath) - flac_covers: list = flac_tags.pictures - if flac_covers: - artwork = Image.open(BytesIO(flac_covers[0].data)) - elif ext in [".mp4", ".m4a", ".aac"]: - mp4_tags: mp4.MP4 = mp4.MP4(filepath) - mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] - if mp4_covers: - artwork = Image.open(BytesIO(mp4_covers[0])) - if artwork: - image = artwork - except ( - FileNotFoundError, - id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage] - mp4.MP4MetadataError, - mp4.MP4StreamInfoError, - MutagenError, - ) as e: - logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__) - return image - - @staticmethod - def _audio_waveform_thumb( - filepath: Path, ext: str, size: int, pixel_ratio: float - ) -> Image.Image | None: - """Render a waveform image from an audio file. - - Args: - filepath (Path): The path of the file. - ext (str): The file extension (with leading "."). - size (tuple[int,int]): The size of the thumbnail. - pixel_ratio (float): The screen pixel ratio. - """ - # BASE_SCALE used for drawing on a larger image and resampling down - # to provide an antialiased effect. - base_scale: int = 2 - samples_per_bar: int = 3 - size_scaled: int = size * base_scale - allow_small_min: bool = False - im: Image.Image | None = None - - try: - bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) - audio = AudioSegment.from_file(filepath, ext[1:]) - data = np.frombuffer(buffer=audio._data, dtype=np.int16) - data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) - bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 - line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale - bar_height: float = (size_scaled) - (size_scaled // bar_margin) - - count: int = 0 - maximum_item: int = 0 - max_array: list[int] = [] - highest_line: int = 0 - - for i in range(-1, len(data_indices)): - d = data[math.ceil(data_indices[i]) - 1] - if count < samples_per_bar: - count = count + 1 - with catch_warnings(record=True): - if abs(d) > maximum_item: - maximum_item = int(abs(d)) - else: - max_array.append(maximum_item) - - if maximum_item > highest_line: - highest_line = maximum_item - - maximum_item = 0 - count = 1 - - line_ratio = max(highest_line / bar_height, 1) - - im = Image.new("RGB", (size_scaled, size_scaled), color="#000000") - draw = ImageDraw.Draw(im) - - current_x = bar_margin - for item in max_array: - item_height = item / line_ratio - - # If small minimums are not allowed, raise all values - # smaller than the line width to the same value. - if not allow_small_min: - item_height = max(item_height, line_width) - - current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 - - draw.rounded_rectangle( - ( - current_x, - current_y, - (current_x + line_width), - (current_y + item_height), - ), - radius=100 * base_scale, - fill=("#FF0000"), - outline=("#FFFF00"), - width=max(math.ceil(line_width / 6), base_scale), - ) - - current_x = current_x + line_width + bar_margin - - im.resize((size, size), Image.Resampling.BILINEAR) - - except Exception as e: - logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__) - - return im - - @staticmethod - def _blender(filepath: Path) -> Image.Image | None: - """Get an emended thumbnail from a Blender file, if a thumbnail is present. - - Args: - filepath (Path): The path of the file. - """ - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - im: Image.Image | None = None - try: - blend_image = blend_thumb(str(filepath)) - - bg = Image.new("RGB", blend_image.size, color=bg_color) - bg.paste(blend_image, mask=blend_image.getchannel(3)) - im = bg - - except ( - AttributeError, - UnidentifiedImageError, - TypeError, - ) as e: - if str(e) == "expected string or buffer": - logger.info( - f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " - f"Doesn't have an embedded thumbnail. ({type(e).__name__})" - ) - - else: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _vtf_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for VTF (Valve Texture Format) images. - - Uses the srctools library for reading VTF files. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with open(filepath, "rb") as f: - vtf = srctools.VTF.read(f) - im = vtf.get(frame=0).to_PIL() - - except (ValueError, FileNotFoundError) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _open_doc_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an OpenDocument file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "Thumbnails/thumbnail.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - - @staticmethod - def _krita_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an Krita file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "preview.png" - im: Image.Image | None = None - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - return im - - @staticmethod - def _powerpoint_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for a Microsoft PowerPoint file. - - Args: - filepath (Path): The path of the file. - """ - file_path_within_zip = "docProps/thumbnail.jpeg" - im: Image.Image | None = None - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - # Check if the file exists in the zip - if file_path_within_zip in zip_file.namelist(): - # Read the specific file into memory - file_data = zip_file.read(file_path_within_zip) - thumb_im = Image.open(BytesIO(file_data)) - if thumb_im: - im = Image.new("RGB", thumb_im.size, color="#1e1e1e") - im.paste(thumb_im) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - - @staticmethod - def _epub_cover(filepath: Path, ext: str) -> Image.Image | None: - """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. - - Args: - filepath (Path): The path to the ePub file. - ext (str): The file extension. - - Returns: - Image: The cover specified in ComicInfo.xml, - the first image found in the ePub file, or None by default. - """ - im: Image.Image | None = None - try: - archiver: _Archive_T = zipfile.ZipFile - if ext == ".cb7": - archiver = _SevenZipFile - elif ext == ".cbr": - archiver = rarfile.RarFile - elif ext == ".cbt": - archiver = _TarFile - - with archiver(filepath, "r") as archive: - if "ComicInfo.xml" in archive.namelist(): - comic_info = ET.fromstring(archive.read("ComicInfo.xml")) - im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover") - if not im: - im = ThumbRenderer.__cover_from_comic_info( - archive, comic_info, "InnerCover" - ) - - if not im: - for file_name in archive.namelist(): - if file_name.lower().endswith( - (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") - ): - image_data = archive.read(file_name) - im = Image.open(BytesIO(image_data)) - break - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - - return im - - @staticmethod - def __cover_from_comic_info( - archive: _Archive, comic_info: Element, cover_type: str - ) -> Image.Image | None: - """Extract the cover specified in ComicInfo.xml. - - Args: - archive (_Archive): The current ePub file. - comic_info (Element): The parsed ComicInfo.xml. - cover_type (str): The type of cover to load. - - Returns: - Image: The cover specified in ComicInfo.xml. - """ - im: Image.Image | None = None - - cover = comic_info.find(f"./*Page[@Type='{cover_type}']") - if cover is not None: - pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] - page_name = pages[int(unwrap(cover.get("Image")))] - if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): - image_data = archive.read(page_name) - im = Image.open(BytesIO(image_data)) - - return im - - def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None: - """Render a small font preview ("Aa") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = None - try: - bg = Image.new("RGB", (size, size), color="#000000") - raw = Image.new("RGB", (size * 3, size * 3), color="#000000") - draw = ImageDraw.Draw(raw) - font = ImageFont.truetype(filepath, size=size) - # NOTE: While a stroke effect is desired, the text - # method only allows for outer strokes, which looks - # a bit weird when rendering fonts. - draw.text( - (size // 8, size // 8), - "Aa", - font=font, - fill="#FF0000", - # stroke_width=math.ceil(size / 96), - # stroke_fill="#FFFF00", - ) - # NOTE: Change to getchannel(1) if using an outline. - data = np.asarray(raw.getchannel(0)) - - m, n = data.shape[:2] - col: np.ndarray = cast(np.ndarray, data.any(0)) - row: np.ndarray = cast(np.ndarray, data.any(1)) - cropped_data = np.asarray(raw)[ - row.argmax() : m - row[::-1].argmax(), - col.argmax() : n - col[::-1].argmax(), - ] - cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB") - - margin: int = math.ceil(size // 16) - - orig_x, orig_y = cropped_im.size - new_x, new_y = (size, size) - if orig_x > orig_y: - new_x = size - new_y = math.ceil(size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = size - new_x = math.ceil(size * (orig_x / orig_y)) - - cropped_im = cropped_im.resize( - size=(new_x - (margin * 2), new_y - (margin * 2)), - resample=Image.Resampling.BILINEAR, - ) - bg.paste( - cropped_im, - box=(margin, margin + ((size - new_y) // 2)), - ) - im = self._apply_overlay_color(bg, UiColor.BLUE) - except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _font_long_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a large font preview ("Alphabet") thumbnail from a font file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - # Scale the sample font sizes to the preview image - # resolution,assuming the sizes are tuned for 256px. - im: Image.Image | None = None - try: - scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES] - bg = Image.new("RGBA", (size, size), color="#00000000") - draw = ImageDraw.Draw(bg) - lines_of_padding = 2 - y_offset = 0.0 - - for font_size in scaled_sizes: - font = ImageFont.truetype(filepath, size=font_size) - text_wrapped: str = wrap_full_text( - FONT_SAMPLE_TEXT, - font=font, # pyright: ignore[reportArgumentType] - width=size, - draw=draw, - ) - draw.multiline_text((0, y_offset), text_wrapped, font=font) - y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( - (0, 0), "A", font=font - )[-1] - im = theme_fg_overlay(bg, use_alpha=False) - except OSError as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_raw_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a RAW image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - with rawpy.imread(str(filepath)) as raw: - rgb = raw.postprocess(use_camera_wb=True) - im = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except ( - DecompressionBombError, - rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue] - rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue] - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_exr_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a EXR image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - # Load the EXR data to an array and rotate the color space from BGRA -> RGBA - raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED) - raw_array[..., :3] = raw_array[..., 2::-1] - - # Correct the gamma of the raw array - gamma = 2.2 - array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma) - array = (array_gamma * 255).astype(np.uint8) - - im = Image.fromarray(array, mode="RGBA") - - # Paste solid background - if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") - new_bg.paste(im, mask=im.getchannel(3)) - im = new_bg - - except Exception as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a standard image type. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - try: - im = Image.open(filepath) - if im.mode != "RGB" and im.mode != "RGBA": - im = im.convert(mode="RGBA") - if im.mode == "RGBA": - new_bg = Image.new("RGB", im.size, color="#1e1e1e") - new_bg.paste(im, mask=im.getchannel(3)) - im = new_bg - im = unwrap(ImageOps.exif_transpose(im)) - except ( - FileNotFoundError, - UnidentifiedImageError, - DecompressionBombError, - NotImplementedError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _image_vector_thumb(filepath: Path, size: int) -> Image.Image: - """Render a thumbnail for a vector image, such as SVG. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the thumbnail. - """ - im: Image.Image | None = None - # Create an image to draw the svg to and a painter to do the drawing - q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) - q_image.fill("#1e1e1e") - - # Create an svg renderer, then render to the painter - svg: QSvgRenderer = QSvgRenderer(str(filepath)) - - if not svg.isValid(): - raise UnidentifiedImageError - - painter: QPainter = QPainter(q_image) - svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - svg.render(painter) - painter.end() - - # Write the image to a buffer as png - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - q_image.save(buffer, "PNG") # type: ignore[call-overload] - - # Load the image from the buffer - im = Image.new("RGB", (size, size), color="#1e1e1e") - im.paste(Image.open(BytesIO(buffer.data().data()))) - im = im.convert(mode="RGB") - - buffer.close() - return im - - @staticmethod - def _iwork_thumb(filepath: Path) -> Image.Image | None: - """Extract and render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. - - Args: - filepath (Path): The path of the file. - """ - preview_thumb_dir = "preview.jpg" - quicklook_thumb_dir = "QuickLook/Thumbnail.jpg" - im: Image.Image | None = None - - def get_image(path: str) -> Image.Image | None: - thumb_im: Image.Image | None = None - # Read the specific file into memory - file_data = zip_file.read(path) - thumb_im = Image.open(BytesIO(file_data)) - return thumb_im - - try: - with zipfile.ZipFile(filepath, "r") as zip_file: - thumb: Image.Image | None = None - - # Check if the file exists in the zip - if preview_thumb_dir in zip_file.namelist(): - thumb = get_image(preview_thumb_dir) - elif quicklook_thumb_dir in zip_file.namelist(): - thumb = get_image(quicklook_thumb_dir) - else: - logger.error("Couldn't render thumbnail", filepath=filepath) - - if thumb: - im = Image.new("RGB", thumb.size, color="#1e1e1e") - im.paste(thumb) - except zipfile.BadZipFile as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=e) - - return im - - @staticmethod - def _model_stl_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a thumbnail for an STL file. - - Args: - filepath (Path): The path of the file. - size (tuple[int,int]): The size of the icon. - """ - # TODO: Implement. - # The following commented code describes a method for rendering via - # matplotlib. - # This implementation did not play nice with multithreading. - im: Image.Image | None = None - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') - - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) - - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # im = Image.open(img_buf) - - return im - - @staticmethod - def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None: - """Render a thumbnail for a PDF file. - - filepath (Path): The path of the file. - size (int): The size of the icon. - """ - im: Image.Image | None = None - - file: QFile = QFile(filepath) - success: bool = file.open( - QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser - ) - if not success: - logger.error("Couldn't render thumbnail", filepath=filepath) - return im - document: QPdfDocument = QPdfDocument() - document.load(file) - file.close() - # Transform page_size in points to pixels with proper aspect ratio - page_size: QSizeF = document.pagePointSize(0) - ratio_hw: float = page_size.height() / page_size.width() - if ratio_hw >= 1: - page_size *= size / page_size.height() - else: - page_size *= size / page_size.width() - # Enlarge image for antialiasing - scale_factor = 2.5 - page_size *= scale_factor - # Render image with no anti-aliasing for speed - render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() - render_options.setRenderFlags( - QPdfDocumentRenderOptions.RenderFlag.TextAliased - | QPdfDocumentRenderOptions.RenderFlag.ImageAliased - | QPdfDocumentRenderOptions.RenderFlag.PathAliased - ) - # Convert QImage to PIL Image - q_image: QImage = document.render(0, page_size.toSize(), render_options) - buffer: QBuffer = QBuffer() - buffer.open(QBuffer.OpenModeFlag.ReadWrite) - try: - q_image.save(buffer, "PNG") # type: ignore # pyright: ignore - im = Image.open(BytesIO(buffer.buffer().data())) - finally: - buffer.close() - # Replace transparent pixels with white (otherwise Background defaults to transparent) - return replace_transparent_pixels(im) - - @staticmethod - def _text_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a plaintext file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - fg_color: str = ( - "#FFFFFF" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#111111" - ) - - try: - encoding = detect_char_encoding(filepath) - with open(filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - im = bg - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - UnicodeDecodeError, - OSError, - FileNotFoundError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - @staticmethod - def _video_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a video file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - frame: MatLike | None = None - try: - if is_readable_video(filepath): - video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG) - # TODO: Move this check to is_readable_video() - if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set( - cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), - ) - # NOTE: Depending on the video format, compression, and - # frame count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - max_frame_seek: int = 10 - for i in range( - 0, - min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), - ): - success, frame = video.read() - if not success: - video.set(cv2.CAP_PROP_POS_FRAMES, i) - else: - break - if frame is not None: - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - im = Image.fromarray(frame) - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - OSError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - def render( self, timestamp: float, @@ -1390,7 +541,7 @@ def render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -1600,7 +751,7 @@ def _render( """Render a thumbnail or preview image. Args: - timestamp (float): The timestamp for which this this job was dispatched. + timestamp (float): The timestamp for which this job was dispatched. filepath (str | Path): The path of the file to render a thumbnail for. base_size (tuple[int,int]): The unmodified base size of the thumbnail. pixel_ratio (float): The screen pixel ratio. @@ -1609,122 +760,59 @@ def _render( save_to_file(Path | None): A filepath to optionally save the output to. """ - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) - image: Image.Image | None = None + adj_size: int = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) _filepath: Path = Path(filepath) - savable_media_type: bool = True 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 = self._epub_cover(_filepath, ext) - # Krita ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.KRITA_TYPES, mime_fallback=True - ): - image = self._krita_thumb(_filepath) - # VTF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True - ): - image = self._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 = self._image_raw_thumb(_filepath) - # Vector Images -------------------------------------------- - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True - ): - image = self._image_vector_thumb(_filepath, adj_size) - # EXR Images ----------------------------------------------- - elif ext in [".exr"]: - image = self._image_exr_thumb(_filepath) - # Normal Images -------------------------------------------- - else: - image = self._image_thumb(_filepath) - # Videos ======================================================= - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.VIDEO_TYPES, mime_fallback=True - ): - image = self._video_thumb(_filepath) - # PowerPoint Slideshow - elif ext in {".pptx"}: - image = self._powerpoint_thumb(_filepath) - # OpenDocument/OpenOffice ====================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True - ): - image = self._open_doc_thumb(_filepath) - # Apple iWork Suite ============================================ - elif MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES): - image = self._iwork_thumb(_filepath) - # Plain Text =================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True - ): - image = self._text_thumb(_filepath) - # Fonts ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.FONT_TYPES, mime_fallback=True - ): - if is_grid_thumb: - # Short (Aa) Preview - image = self._font_short_thumb(_filepath, adj_size) - else: - # Large (Full Alphabet) Preview - image = self._font_long_thumb(_filepath, adj_size) - # Audio ======================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.AUDIO_TYPES, mime_fallback=True - ): - image = self._audio_album_thumb(_filepath, ext) - if image is None: - image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) - savable_media_type = False - if image is not None: - image = self._apply_overlay_color(image, UiColor.GREEN) - # Blender ====================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.BLENDER_TYPES, mime_fallback=True - ): - image = self._blender(_filepath) - # PDF ========================================================== - elif MediaCategories.is_ext_in_category( - ext, MediaCategories.PDF_TYPES, mime_fallback=True - ): - image = self._pdf_thumb(_filepath, adj_size) - # No Rendered Thumbnail ======================================== - if not image: + + renderer_types: list[RendererType] = list(RendererType.get_renderer_types(ext)) + renderer_context: RendererContext = RendererContext( + path=_filepath, + extension=ext, + size=adj_size, + pixel_ratio=pixel_ratio, + is_grid_thumb=is_grid_thumb, + ) + + if len(renderer_types) == 0: raise NoRendererError + image: Image.Image | None = None + used_renderer_type: RendererType | None = None + for renderer_type in renderer_types: + used_renderer_type = renderer_type + image = renderer_type.renderer.render(renderer_context) + if image is not None: + break + if image: image = self._resize_image(image, (adj_size, adj_size)) - if save_to_file and savable_media_type and image: + if ( + save_to_file + and used_renderer_type + and used_renderer_type.is_savable_media_type + and image + ): self.driver.cache_manager.save_image(image, save_to_file, mode="RGBA") + return image + except ( UnidentifiedImageError, DecompressionBombError, ValueError, ChildProcessError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - image = None + logger.error( + "[ThumbRenderer] Couldn't render thumbnail", filepath=filepath, error=e + ) except NoRendererError: - image = None + pass - return image + return None def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image: orig_x, orig_y = image.size diff --git a/src/tagstudio/qt/previews/renderer_type.py b/src/tagstudio/qt/previews/renderer_type.py new file mode 100644 index 000000000..a1a72b938 --- /dev/null +++ b/src/tagstudio/qt/previews/renderer_type.py @@ -0,0 +1,76 @@ +from collections.abc import Iterator +from enum import Enum + +from tagstudio.core.media_types import MediaCategories, MediaCategory +from tagstudio.qt.previews.renderers.audio_renderer import AudioRenderer +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer +from tagstudio.qt.previews.renderers.blender_renderer import BlenderRenderer +from tagstudio.qt.previews.renderers.ebook_renderer import EBookRenderer +from tagstudio.qt.previews.renderers.exr_image_renderer import EXRImageRenderer +from tagstudio.qt.previews.renderers.font_renderer import FontRenderer +from tagstudio.qt.previews.renderers.iwork_renderer import IWorkRenderer +from tagstudio.qt.previews.renderers.krita_renderer import KritaRenderer +from tagstudio.qt.previews.renderers.open_doc_renderer import OpenDocRenderer +from tagstudio.qt.previews.renderers.pdf_renderer import PDFRenderer +from tagstudio.qt.previews.renderers.powerpoint_renderer import PowerPointRenderer +from tagstudio.qt.previews.renderers.raster_image_renderer import RasterImageRenderer +from tagstudio.qt.previews.renderers.raw_image_renderer import RawImageRenderer +from tagstudio.qt.previews.renderers.text_renderer import TextRenderer +from tagstudio.qt.previews.renderers.vector_image_renderer import VectorImageRenderer +from tagstudio.qt.previews.renderers.video_renderer import VideoRenderer +from tagstudio.qt.previews.renderers.vtf_renderer import VTFRenderer + + +class RendererType(Enum): + # Image files + RASTER_IMAGE = "image", MediaCategories.IMAGE_RASTER_TYPES, RasterImageRenderer, True + VECTOR_IMAGE = "vector_image", MediaCategories.IMAGE_VECTOR_TYPES, VectorImageRenderer, True + EXR_IMAGE = "exr_image", MediaCategories.IMAGE_EXR_TYPES, EXRImageRenderer, True + VTF = "vtf", MediaCategories.SOURCE_ENGINE_TYPES, VTFRenderer, True + RAW_IMAGE = "raw_image", MediaCategories.IMAGE_RAW_TYPES, RawImageRenderer, True + + # Media files + VIDEO = "video", MediaCategories.VIDEO_TYPES, VideoRenderer, True + AUDIO = "audio", MediaCategories.AUDIO_TYPES, AudioRenderer, False + + # Project files + KRITA = "krita", MediaCategories.KRITA_TYPES, KritaRenderer, True + + # Document files + OPEN_DOC = "open_doc", MediaCategories.OPEN_DOCUMENT_TYPES, OpenDocRenderer, True + POWERPOINT = "powerpoint", MediaCategories.POWERPOINT_TYPES, PowerPointRenderer, True + PDF = "pdf", MediaCategories.PDF_TYPES, PDFRenderer, True + IWORK = "iwork", MediaCategories.IWORK_TYPES, IWorkRenderer, True + + # eBook files + EBOOK = "ebook", MediaCategories.EBOOK_TYPES, EBookRenderer, True + + # Model files + BLENDER = "blender", MediaCategories.BLENDER_TYPES, BlenderRenderer, True + + # Font files + FONT = "font", MediaCategories.FONT_TYPES, FontRenderer, True + + # Text files + TEXT = "text", MediaCategories.PLAINTEXT_TYPES, TextRenderer, True + + def __init__( + self, + name: str, + media_category: MediaCategory, + renderer: type[BaseRenderer], + is_savable_media_type: bool, + ): + self.__name: str = name + self.media_category: MediaCategory = media_category + self.renderer: type[BaseRenderer] = renderer + + self.is_savable_media_type = is_savable_media_type + + @staticmethod + def get_renderer_types(file_extension: str) -> Iterator["RendererType"]: + for renderer_type in RendererType.__members__.values(): + if MediaCategories.is_ext_in_category( + file_extension, renderer_type.media_category, mime_fallback=True + ): + yield renderer_type diff --git a/src/tagstudio/qt/previews/renderers/audio_renderer.py b/src/tagstudio/qt/previews/renderers/audio_renderer.py new file mode 100644 index 000000000..ce07f97dc --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/audio_renderer.py @@ -0,0 +1,162 @@ +import math +from io import BytesIO +from warnings import catch_warnings + +import numpy as np +import structlog +from mutagen import flac, id3, mp4 +from PIL import ( + Image, + ImageDraw, +) +from pydub import AudioSegment + +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class AudioRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an audio file. + + Args: + context (RendererContext): The renderer context. + """ + rendered_image: Image.Image | None = _extract_album_cover(context) + + if rendered_image is None: + rendered_image = _render_audio_waveform(context) + if rendered_image is not None: + rendered_image = apply_overlay_color(rendered_image, UiColor.GREEN) + + return rendered_image + + +def _extract_album_cover(context: RendererContext) -> Image.Image | None: + """Return an album cover thumb from an audio file if a cover is present. + + Args: + context (RendererContext): The renderer context. + """ + try: + if not context.path.is_file(): + raise FileNotFoundError + + artwork: Image.Image | None = None + + # Get cover from .mp3 tags + if context.extension in [".mp3"]: + id3_tags: id3.ID3 = id3.ID3(context.path) + id3_covers: list = id3_tags.getall("APIC") + if id3_covers: + artwork = Image.open(BytesIO(id3_covers[0].data)) + + # Get cover from .flac tags + elif context.extension in [".flac"]: + flac_tags: flac.FLAC = flac.FLAC(context.path) + flac_covers: list = flac_tags.pictures + if flac_covers: + artwork = Image.open(BytesIO(flac_covers[0].data)) + + # Get cover from .mp4 tags + elif context.extension in [".mp4", ".m4a", ".aac"]: + mp4_tags: mp4.MP4 = mp4.MP4(context.path) + mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportAssignmentType] + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + + return artwork + except Exception as e: + logger.error("[AudioRenderer] Couldn't read album artwork", path=context.path, error=e) + + return None + + +def _render_audio_waveform(context: RendererContext) -> Image.Image | None: + """Render a waveform image from an audio file. + + Args: + context (RendererContext): The renderer context. + """ + # BASE_SCALE used for drawing on a larger image and resampling down + # to provide an antialiased effect. + base_scale: int = 2 + samples_per_bar: int = 3 + size_scaled: int = context.size * base_scale + allow_small_min: bool = False + + try: + bar_count: int = min(math.floor((context.size // context.pixel_ratio) / 5), 64) + audio = AudioSegment.from_file(context.path, context.extension[1:]) + data = np.frombuffer(buffer=audio._data, dtype=np.int16) + data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar) + bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2 + line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale + bar_height: float = size_scaled - (size_scaled // bar_margin) + + count: int = 0 + maximum_item: int = 0 + max_array: list[int] = [] + highest_line: int = 0 + + for i in range(-1, len(data_indices)): + d = data[math.ceil(data_indices[i]) - 1] + if count < samples_per_bar: + count = count + 1 + with catch_warnings(record=True): + if abs(d) > maximum_item: + maximum_item = int(abs(d)) + else: + max_array.append(maximum_item) + + if maximum_item > highest_line: + highest_line = maximum_item + + maximum_item = 0 + count = 1 + + line_ratio = max(highest_line / bar_height, 1) + + rendered_image = Image.new("RGB", (size_scaled, size_scaled), color="#000000") + draw = ImageDraw.Draw(rendered_image) + + current_x = bar_margin + for item in max_array: + item_height = item / line_ratio + + # If small minimums are not allowed, raise all values + # smaller than the line width to the same value. + if not allow_small_min: + item_height = max(item_height, line_width) + + current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2 + + draw.rounded_rectangle( + ( + current_x, + current_y, + (current_x + line_width), + (current_y + item_height), + ), + radius=100 * base_scale, + fill="#FF0000", + outline="#FFFF00", + width=max(math.ceil(line_width / 6), base_scale), + ) + + current_x = current_x + line_width + bar_margin + + rendered_image.resize((context.size, context.size), Image.Resampling.BILINEAR) + return rendered_image + + except Exception as e: + logger.error("[AudioRenderer] Couldn't render waveform", path=context.path.name, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/base_renderer.py b/src/tagstudio/qt/previews/renderers/base_renderer.py new file mode 100644 index 000000000..983d66758 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/base_renderer.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + + +@dataclass(kw_only=True) +class RendererContext: + path: Path + extension: str + size: int + pixel_ratio: float + is_grid_thumb: bool + + +class BaseRenderer(ABC): + @abstractmethod + def __init__(self) -> None: + pass + + @staticmethod + @abstractmethod + def render(context: RendererContext) -> Image.Image | None: + raise NotImplementedError diff --git a/src/tagstudio/qt/previews/renderers/blender_renderer.py b/src/tagstudio/qt/previews/renderers/blender_renderer.py new file mode 100644 index 000000000..b0c9b7639 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/blender_renderer.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# + + +## This file is a modified script that gets the thumbnail data stored in a blend file + + +import gzip +import os +import struct +from io import BufferedReader +from pathlib import Path + +import structlog +from PIL import Image, ImageOps, UnidentifiedImageError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class BlenderRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Get an embedded thumbnail from a Blender file, if a thumbnail is present. + + Args: + context (RendererContext): The renderer context. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + + try: + buffer, width, height = _extract_embedded_thumbnail(context.path) + + if buffer is None: + return None + + embedded_thumbnail: Image.Image = Image.frombuffer( + "RGBA", + (width, height), + buffer, + ) + embedded_thumbnail = ImageOps.flip(embedded_thumbnail) + + rendered_image: Image.Image = Image.new("RGB", embedded_thumbnail.size, color=bg_color) + rendered_image.paste(embedded_thumbnail, mask=embedded_thumbnail.getchannel(3)) + return rendered_image + + except ( + AttributeError, + UnidentifiedImageError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logger.info( + f"[BlenderRenderer] {context.path.name} " + f"doesn't have an embedded thumbnail. ({e})" + ) + + else: + logger.error("Couldn't render thumbnail", path=context.path, error=e) + + return None + + +def _extract_embedded_thumbnail(path: Path) -> tuple[bytes | None, int, int]: + rend = b"REND" + test = b"TEST" + + blender_file: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + + header = blender_file.read(12) + + if header[0:2] == b"\x1f\x8b": # gzip magic + blender_file.close() + blender_file = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + header = blender_file.read(12) + + if not header.startswith(b"BLENDER"): + blender_file.close() + return None, 0, 0 + + is_64_bit = header[7] == b"-"[0] + + # True for PPC, false for X86 + is_big_endian = header[8] == b"V"[0] + + # Blender pre-v2.5 had no thumbnails + if header[9:11] <= b"24": + return None, 0, 0 + + block_header_size = 24 if is_64_bit else 20 + int_endian = ">i" if is_big_endian else " None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. + + Args: + context (RendererContext): The renderer context. + + Returns: + Image: The cover specified in ComicInfo.xml, + the first image found in the ePub file, or None by default. + """ + try: + archive: ArchiveFile | None = None + match context.extension: + case ".cb7": + archive = SevenZipFile(context.path, "r") + case ".cbr": + archive = RarFile(context.path, "r") + case ".cbt": + archive = TarFile(context.path, "r") + case _: + archive = ZipFile(context.path, "r") + + rendered_image: Image.Image | None = None + + logger.debug("Epub") + logger.debug(archive.get_name_list()) + + # Get the cover from the comic metadata, if present + if "ComicInfo.xml" in archive.get_name_list(): + logger.debug("Found ComicInfo.xml!") + + comic_info_bytes: bytes | None = archive.read("ComicInfo.xml") + if comic_info_bytes is None: + raise OSError + + comic_info: Element = ElementTree.fromstring(comic_info_bytes.decode("utf-8")) + logger.debug(comic_info) + rendered_image = _extract_cover(archive, comic_info, "FrontCover") + + if not rendered_image: + rendered_image = _extract_cover(archive, comic_info, "InnerCover") + + # Get the first image present + if not rendered_image: + for file_name in archive.get_name_list(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data: bytes | None = archive.read(file_name) + if image_data is None: + raise OSError + + rendered_image = Image.open(BytesIO(image_data)) + break + + return rendered_image + except Exception as e: + logger.error("[EBookRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None + + +def _extract_cover( + archive: ArchiveFile, comic_info: Element, cover_type: str +) -> Image.Image | None: + """Extract the cover specified in ComicInfo.xml. + + Args: + archive (ArchiveFile): The current ePub file. + comic_info (Element): The parsed ComicInfo.xml. + cover_type (str): The type of cover to load. + + Returns: + Image: The cover specified in ComicInfo.xml. + """ + cover: Element | None = comic_info.find(f"./*Page[@Type='{cover_type}']") + if cover is not None: + pages: list[str] = [ + page_file for page_file in archive.get_name_list() if page_file != "ComicInfo.xml" + ] + page_name: str = pages[int(unwrap(cover.get("Image")))] + if page_name.endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")): + image_data: bytes | None = archive.read(page_name) + if image_data is None: + raise OSError + + return Image.open(BytesIO(image_data)) + + return None diff --git a/src/tagstudio/qt/previews/renderers/exr_image_renderer.py b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py new file mode 100644 index 000000000..79b9423fb --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/exr_image_renderer.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import Imath +import numexpr +import numpy +import OpenEXR +import structlog +from OpenEXR import InputFile +from PIL import ( + Image, + ImageOps, +) + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class EXRImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an EXR image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + rendered_image: Image.Image = exr_to_srgb(context.path) + return unwrap(ImageOps.exif_transpose(rendered_image)) + except Exception as e: + logger.error("[EXRImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None + + +# https://gist.github.com/arseniy-panfilov/4dc8fc5131277affe64619b1a9d00da0 +FLOAT = Imath.PixelType(Imath.PixelType.FLOAT) + + +def exr_to_array(path: Path) -> numpy.ndarray: + exr_file: InputFile = OpenEXR.InputFile(str(path)) + data_window = exr_file.header()["dataWindow"] + + channels = list(exr_file.header()["channels"].keys()) + channels_list: list[str] = [c for c in ("R", "G", "B", "A") if c in channels] + size: tuple[int, int] = ( + data_window.max.x - data_window.min.x + 1, + data_window.max.y - data_window.min.y + 1, + ) + + color_channels = exr_file.channels(channels_list, FLOAT) + channels_tuple = [numpy.frombuffer(channel, dtype="f") for channel in color_channels] + + return numpy.dstack(channels_tuple).reshape(size + (len(channels_tuple),)) + + +def encode_to_srgb(x): + a = 0.055 # noqa + return numexpr.evaluate("""where( + x <= 0.0031308, + x * 12.92, + (1 + a) * (x ** (1 / 2.4)) - a + )""") + + +def exr_to_srgb(exr_file) -> Image.Image: + array: numpy.ndarray = exr_to_array(exr_file) + result = encode_to_srgb(array) * 255.0 + present_channels: list[str] = ["R", "G", "B", "A"][: result.shape[2]] + channels: str = "".join(present_channels) + return Image.fromarray(result.astype("uint8"), channels) diff --git a/src/tagstudio/qt/previews/renderers/font_renderer.py b/src/tagstudio/qt/previews/renderers/font_renderer.py new file mode 100644 index 000000000..c1a2438df --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/font_renderer.py @@ -0,0 +1,132 @@ +import math +from typing import cast + +import numpy as np +import structlog +from PIL import ( + Image, + ImageDraw, + ImageFont, +) +from PIL.ImageFont import FreeTypeFont + +from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from tagstudio.qt.helpers.color_overlay import theme_fg_overlay +from tagstudio.qt.helpers.image_effects import apply_overlay_color +from tagstudio.qt.helpers.text_wrapper import wrap_full_text +from tagstudio.qt.models.palette import UiColor +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class FontRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + context (RendererContext): The renderer context. + """ + if context.is_grid_thumb: + return _font_short_thumb(context) + else: + return _font_long_thumb(context) + + +def _font_short_thumb(context: RendererContext) -> Image.Image | None: + """Render a small font preview ("Aa") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + try: + bg: Image.Image = Image.new("RGB", (context.size, context.size), color="#000000") + raw: Image.Image = Image.new("RGB", (context.size * 3, context.size * 3), color="#000000") + draw = ImageDraw.Draw(raw) + font: FreeTypeFont = ImageFont.truetype(context.path, size=context.size) + + # NOTE: While a stroke effect is desired, the text + # method only allows for outer strokes, which looks + # a bit weird when rendering fonts. + draw.text( + (context.size // 8, context.size // 8), + "Aa", + font=font, + fill="#FF0000", + # stroke_width=math.ceil(size / 96), + # stroke_fill="#FFFF00", + ) + # NOTE: Change to getchannel(1) if using an outline. + data = np.asarray(raw.getchannel(0)) + + m, n = data.shape[:2] + col: np.ndarray = cast(np.ndarray, data.any(0)) + row: np.ndarray = cast(np.ndarray, data.any(1)) + cropped_data = np.asarray(raw)[ + row.argmax() : m - row[::-1].argmax(), + col.argmax() : n - col[::-1].argmax(), + ] + cropped_image: Image.Image = Image.fromarray(cropped_data, "RGB") + + margin: int = math.ceil(context.size // 16) + + orig_x, orig_y = cropped_image.size + new_x, new_y = (context.size, context.size) + if orig_x > orig_y: + new_x = context.size + new_y = math.ceil(context.size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = context.size + new_x = math.ceil(context.size * (orig_x / orig_y)) + + cropped_image = cropped_image.resize( + size=(new_x - (margin * 2), new_y - (margin * 2)), + resample=Image.Resampling.BILINEAR, + ) + bg.paste( + cropped_image, + box=(margin, margin + ((context.size - new_y) // 2)), + ) + return apply_overlay_color(bg, UiColor.BLUE) + except OSError as e: + logger.error("Couldn't render thumbnail", path=context.path, error=type(e).__name__) + + return None + + +def _font_long_thumb(context: RendererContext) -> Image.Image | None: + """Render a large font preview ("Alphabet") thumbnail from a font file. + + Args: + context (RendererContext): The renderer context. + """ + # Scale the sample font sizes to the preview image + # resolution,assuming the sizes are tuned for 256px. + try: + scaled_sizes: list[int] = [math.floor(x * (context.size / 256)) for x in FONT_SAMPLE_SIZES] + bg: Image.Image = Image.new("RGBA", (context.size, context.size), color="#00000000") + draw = ImageDraw.Draw(bg) + lines_of_padding: int = 2 + y_offset: float = 0.0 + + for font_size in scaled_sizes: + font: FreeTypeFont = ImageFont.truetype(context.path, size=font_size) + text_wrapped: str = wrap_full_text( + FONT_SAMPLE_TEXT, + font=font, # pyright: ignore[reportArgumentType] + width=context.size, + draw=draw, + ) + draw.multiline_text((0, y_offset), text_wrapped, font=font) + y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox( + (0, 0), "A", font=font + )[-1] + return theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("[FontRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/iwork_renderer.py b/src/tagstudio/qt/previews/renderers/iwork_renderer.py new file mode 100644 index 000000000..960028985 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/iwork_renderer.py @@ -0,0 +1,57 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +preview_thumbnail_path_within_zip: str = "preview.jpg" +quicklook_thumbnail_path_within_zip: str = "QuickLook/Thumbnail.jpg" + + +class IWorkRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an Apple iWork (Pages, Numbers, Keynote) file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Preview thumbnail + if zip_file.has_file_name(preview_thumbnail_path_within_zip): + file_data: bytes | None = zip_file.read(preview_thumbnail_path_within_zip) + if file_data is None: + raise OSError + + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + # Quicklook thumbnail + elif zip_file.has_file_name(quicklook_thumbnail_path_within_zip): + file_data = zip_file.read(quicklook_thumbnail_path_within_zip) + if file_data is None: + raise OSError + + embedded_thumbnail = Image.open(BytesIO(file_data)) + else: + raise FileNotFoundError + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + except Exception as e: + logger.error("[IWorkRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/krita_renderer.py b/src/tagstudio/qt/previews/renderers/krita_renderer.py new file mode 100644 index 000000000..abddb1846 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/krita_renderer.py @@ -0,0 +1,46 @@ +from io import BytesIO + +import structlog +from PIL import Image + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "preview.png" + + +class KritaRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for a Krita file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[KritaRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/open_doc_renderer.py b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py new file mode 100644 index 000000000..28b19e30a --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/open_doc_renderer.py @@ -0,0 +1,49 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +thumbnail_path_within_zip: str = "Thumbnails/thumbnail.png" + + +class OpenDocRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an OpenDocument file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error("[OpenDocRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/pdf_renderer.py b/src/tagstudio/qt/previews/renderers/pdf_renderer.py new file mode 100644 index 000000000..dc9abf781 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/pdf_renderer.py @@ -0,0 +1,72 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, +) +from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF +from PySide6.QtGui import QImage +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions + +from tagstudio.qt.helpers.image_effects import replace_transparent_pixels +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class PDFRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a PDF file. + + Args: + context (RendererContext): The renderer context. + """ + try: + file: QFile = QFile(context.path) + success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) + + if not success: + raise FileNotFoundError + + document: QPdfDocument = QPdfDocument() + document.load(file) + file.close() + + # Transform page_size in points to pixels with proper aspect ratio + page_size: QSizeF = document.pagePointSize(0) + ratio_hw: float = page_size.height() / page_size.width() + if ratio_hw >= 1: + page_size *= context.size / page_size.height() + else: + page_size *= context.size / page_size.width() + + # Enlarge image for antialiasing + scale_factor = 2.5 + page_size *= scale_factor + + # Render image with no antialiasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags(QPdfDocumentRenderOptions.RenderFlag.TextAliased) + + # Convert QImage to PIL Image + q_image: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] # pyright: ignore + rendered_thumbnail = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(rendered_thumbnail) + + except FileNotFoundError as e: + logger.error("[PDFRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py new file mode 100644 index 000000000..7d529e572 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/powerpoint_renderer.py @@ -0,0 +1,48 @@ +from io import BytesIO + +import structlog +from PIL import Image + +from tagstudio.qt.helpers.file_wrappers.archive.zip_file import ZipFile +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + +thumbnail_path_within_zip: str = "docProps/thumbnail.jpeg" + + +class PowerPointRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for a Microsoft PowerPoint file. + + Args: + context (RendererContext): The renderer context. + """ + try: + zip_file: ZipFile + with ZipFile(context.path, "r") as zip_file: + # Check if the file exists in the zip + if zip_file.has_file_name(thumbnail_path_within_zip): + # Read the specific file into memory + file_data: bytes | None = zip_file.read(thumbnail_path_within_zip) + if file_data is None: + raise OSError + + embedded_thumbnail: Image.Image = Image.open(BytesIO(file_data)) + + if embedded_thumbnail: + rendered_image = Image.new("RGB", embedded_thumbnail.size, color="#1e1e1e") + rendered_image.paste(embedded_thumbnail) + return rendered_image + else: + raise FileNotFoundError + except Exception as e: + logger.error( + "[PowerPointRenderer] Couldn't render thumbnail", path=context.path, error=e + ) + + return None diff --git a/src/tagstudio/qt/previews/renderers/raster_image_renderer.py b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py new file mode 100644 index 000000000..e0a245d25 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/raster_image_renderer.py @@ -0,0 +1,47 @@ +import structlog +from PIL import ( + Image, + ImageOps, + UnidentifiedImageError, +) +from PIL.Image import DecompressionBombError + +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class RasterImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for an image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + rendered_image: Image.Image = Image.open(context.path) + + # Convert image to RGBA + if rendered_image.mode != "RGB" and rendered_image.mode != "RGBA": + rendered_image = rendered_image.convert(mode="RGBA") + + if rendered_image.mode == "RGBA": + new_bg: Image.Image = Image.new("RGB", rendered_image.size, color="#1e1e1e") + new_bg.paste(rendered_image, mask=rendered_image.getchannel(3)) + rendered_image = new_bg + + return unwrap(ImageOps.exif_transpose(rendered_image)) + except ( + FileNotFoundError, + UnidentifiedImageError, + DecompressionBombError, + NotImplementedError, + ) as e: + logger.error("[ImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/raw_image_renderer.py b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py new file mode 100644 index 000000000..ab8cbaf68 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/raw_image_renderer.py @@ -0,0 +1,38 @@ +import rawpy +import structlog +from PIL import ( + Image, +) +from PIL.Image import DecompressionBombError +from rawpy._rawpy import LibRawFileUnsupportedError, LibRawIOError + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class RawImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a RAW image file. + + Args: + context (RendererContext): The renderer context. + """ + try: + with rawpy.imread(str(context.path)) as raw: + rgb = raw.postprocess(use_camera_wb=True) + rendered_image: Image.Image = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + return rendered_image + except (DecompressionBombError, LibRawIOError, LibRawFileUnsupportedError) as e: + logger.error("[RawImageRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/stl_model_renderer.py b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py new file mode 100644 index 000000000..e10b5178c --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/stl_model_renderer.py @@ -0,0 +1,43 @@ +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class STLModelRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for an STL file. + + Args: + context (RendererContext): The renderer context. + """ + # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. + # im: Image.Image | None = None + # # Create a new plot + # matplotlib.use('agg') + # figure = plt.figure() + # axes = figure.add_subplot(projection='3d') + + # # Load the STL files and add the vectors to the plot + # your_mesh = mesh.Mesh.from_file(_filepath) + + # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) + # poly_collection.set_color((0,0,1)) # play with color + # scale = your_mesh.points.flatten() + # axes.auto_scale_xyz(scale, scale, scale) + # axes.add_collection3d(poly_collection) + # # plt.show() + # img_buf = io.BytesIO() + # plt.savefig(img_buf, format='png') + # im = Image.open(img_buf) + + return None diff --git a/src/tagstudio/qt/previews/renderers/text_renderer.py b/src/tagstudio/qt/previews/renderers/text_renderer.py new file mode 100644 index 000000000..ca333e552 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/text_renderer.py @@ -0,0 +1,60 @@ +import cv2 +import structlog +from PIL import ( + Image, + ImageDraw, + UnidentifiedImageError, +) +from PIL.Image import DecompressionBombError +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from tagstudio.core.utils.encoding import detect_char_encoding +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class TextRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a plaintext file. + + Args: + context (RendererContext): The renderer context. + """ + bg_color: str = ( + "#1e1e1e" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#FFFFFF" + ) + fg_color: str = ( + "#FFFFFF" + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else "#111111" + ) + + try: + # Read text file + encoding = detect_char_encoding(context.path) + with open(context.path, encoding=encoding) as text_file: + text = text_file.read(256) + + rendered_image: Image.Image = Image.new("RGB", (256, 256), color=bg_color) + draw = ImageDraw.Draw(rendered_image) + draw.text((16, 16), text, fill=fg_color) + return rendered_image + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + UnicodeDecodeError, + OSError, + FileNotFoundError, + ) as e: + logger.error("Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/vector_image_renderer.py b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py new file mode 100644 index 000000000..6c53dcfa6 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vector_image_renderer.py @@ -0,0 +1,56 @@ +from io import BytesIO + +import structlog +from PIL import ( + Image, + UnidentifiedImageError, +) +from PySide6.QtCore import QBuffer, Qt +from PySide6.QtGui import QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VectorImageRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a vector image, such as SVG. + + Args: + context (RendererContext): The renderer context. + """ + # Create an image to draw the svg to and a painter to do the drawing + q_image: QImage = QImage(context.size, context.size, QImage.Format.Format_ARGB32) + q_image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(context.path)) + + if not svg.isValid(): + raise UnidentifiedImageError + + painter: QPainter = QPainter(q_image) + svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + svg.render(painter) + painter.end() + + # Write the image to a buffer as png + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + q_image.save(buffer, "PNG") # type: ignore[call-overload,unused-ignore] + + # Load the image from the buffer + rendered_image: Image.Image = Image.new( + "RGB", (context.size, context.size), color="#1e1e1e" + ) + rendered_image.paste(Image.open(BytesIO(buffer.data().data()))) + rendered_image = rendered_image.convert(mode="RGB") + + buffer.close() + return rendered_image diff --git a/src/tagstudio/qt/previews/renderers/video_renderer.py b/src/tagstudio/qt/previews/renderers/video_renderer.py new file mode 100644 index 000000000..51f259980 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/video_renderer.py @@ -0,0 +1,65 @@ +import math + +import cv2 +import structlog +from cv2.typing import MatLike +from PIL import Image, UnidentifiedImageError +from PIL.Image import DecompressionBombError + +from tagstudio.qt.helpers.file_tester import is_readable_video +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VideoRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Render a thumbnail for a video file. + + Args: + context (RendererContext): The renderer context. + """ + try: + if is_readable_video(context.path): + video: cv2.VideoCapture = cv2.VideoCapture(str(context.path), cv2.CAP_FFMPEG) + + # TODO: Move this check to is_readable_video() + if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0: + raise cv2.error("File is invalid or has 0 frames") + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + + # NOTE: Depending on the video format, compression, and + # frame count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + max_frame_seek: int = 10 + frame: MatLike | None = None + + for i in range( + 0, + min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))), + ): + success, frame = video.read() + if not success: + video.set(cv2.CAP_PROP_POS_FRAMES, i) + else: + break + + if frame is not None: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + return Image.fromarray(frame) + except ( + UnidentifiedImageError, + cv2.error, + DecompressionBombError, + OSError, + ) as e: + logger.error("[VideoRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/renderers/vtf_renderer.py b/src/tagstudio/qt/previews/renderers/vtf_renderer.py new file mode 100644 index 000000000..d92bf7cd7 --- /dev/null +++ b/src/tagstudio/qt/previews/renderers/vtf_renderer.py @@ -0,0 +1,31 @@ +import srctools +import structlog +from PIL import Image + +from tagstudio.qt.previews.renderers.base_renderer import BaseRenderer, RendererContext + +logger = structlog.get_logger(__name__) + + +class VTFRenderer(BaseRenderer): + def __init__(self) -> None: + super().__init__() + + @staticmethod + def render(context: RendererContext) -> Image.Image | None: + """Extract and render a thumbnail for VTF (Valve Texture Format) images. + + Uses the srctools library for reading VTF files. + + Args: + context (RendererContext): The renderer context. + """ + try: + with open(context.path, "rb") as vtf_file: + vtf = srctools.VTF.read(vtf_file) + return vtf.get(frame=0).to_PIL() + + except (ValueError, FileNotFoundError) as e: + logger.error("[VTFRenderer] Couldn't render thumbnail", path=context.path, error=e) + + return None diff --git a/src/tagstudio/qt/previews/vendored/blender_renderer.py b/src/tagstudio/qt/previews/vendored/blender_renderer.py deleted file mode 100644 index 012c1503d..000000000 --- a/src/tagstudio/qt/previews/vendored/blender_renderer.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 - -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -# - - -## This file is a modified script that gets the thumbnail data stored in a blend file - - -import gzip -import os -import struct -from io import BufferedReader - -from PIL import Image, ImageOps - - -def blend_extract_thumb(path): - rend = b"REND" - test = b"TEST" - - blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 - - head = blendfile.read(12) - - if head[0:2] == b"\x1f\x8b": # gzip magic - blendfile.close() - blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 - head = blendfile.read(12) - - if not head.startswith(b"BLENDER"): - blendfile.close() - return None, 0, 0 - - is_64_bit = head[7] == b"-"[0] - - # true for PPC, false for X86 - is_big_endian = head[8] == b"V"[0] - - # blender pre 2.5 had no thumbs - if head[9:11] <= b"24": - return None, 0, 0 - - sizeof_bhead = 24 if is_64_bit else 20 - int_endian = ">i" if is_big_endian else "