From d3c3e634b93353750c9fc810af9973e64e21de71 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:15:51 -0700 Subject: [PATCH] feat: add ePub thumbnail support (port #387) (#539) * feat: add ePub thumbnail support Co-Authored-By: Jorge Rui Da Silva Barrios <29062316+jorgerui@users.noreply.github.com> * tests: compare epub cover against png snapshot Co-Authored-By: yed * test: optimize epub test file --------- Co-authored-by: Jorge Rui Da Silva Barrios <29062316+jorgerui@users.noreply.github.com> Co-authored-by: yed --- tagstudio/src/core/media_types.py | 26 +++++++++++++++ tagstudio/src/qt/widgets/thumb_renderer.py | 30 +++++++++++++++++- tagstudio/tests/fixtures/sample.epub | Bin 0 -> 4413 bytes .../test_thumb_renderer/test_epub_preview.png | Bin 0 -> 3721 bytes tagstudio/tests/qt/test_thumb_renderer.py | 14 ++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tagstudio/tests/fixtures/sample.epub create mode 100644 tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_epub_preview.png diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index bb190700..04ea5e87 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -23,6 +23,7 @@ class MediaType(str, Enum): DATABASE: str = "database" DISK_IMAGE: str = "disk_image" DOCUMENT: str = "document" + EBOOK: str = "ebook" FONT: str = "font" IMAGE_ANIMATED: str = "image_animated" IMAGE_RAW: str = "image_raw" @@ -160,6 +161,25 @@ class MediaCategories: ".wpd", ".wps", } + _EBOOK_SET: set[str] = { + ".epub", + # ".azw", + # ".azw3", + # ".cb7", + # ".cba", + # ".cbr", + # ".cbt", + # ".cbz", + # ".djvu", + # ".fb2", + # ".ibook", + # ".inf", + # ".kfx", + # ".lit", + # ".mobi", + # ".pdb" + # ".prc", + } _FONT_SET: set[str] = { ".fon", ".otf", @@ -347,6 +367,11 @@ class MediaCategories: extensions=_DOCUMENT_SET, is_iana=False, ) + EBOOK_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.EBOOK, + extensions=_EBOOK_SET, + is_iana=False, + ) FONT_TYPES: MediaCategory = MediaCategory( media_type=MediaType.FONT, extensions=_FONT_SET, @@ -448,6 +473,7 @@ class MediaCategories: DATABASE_TYPES, DISK_IMAGE_TYPES, DOCUMENT_TYPES, + EBOOK_TYPES, FONT_TYPES, IMAGE_ANIMATED_TYPES, IMAGE_RAW_TYPES, diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 2818f378..7cac38fc 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,6 +5,7 @@ import math import struct +import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path @@ -616,6 +617,29 @@ class ThumbRenderer(QObject): logger.error("Couldn't render thumbnail", filepath=filepath, error=e) return im + def _epub_cover(self, filepath: Path) -> Image.Image: + """Extracts and returns the first image found in the ePub file at the given filepath. + + Args: + filepath (Path): The path to the ePub file. + + Returns: + Image: The first image found in the ePub file, or None by default. + """ + im: Image.Image = None + try: + with zipfile.ZipFile(filepath, "r") as zip_file: + for file_name in zip_file.namelist(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data = zip_file.read(file_name) + im = Image.open(BytesIO(image_data)) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + + return im + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: """Render a small font preview ("Aa") thumbnail from a font file. @@ -1045,6 +1069,11 @@ class ThumbRenderer(QObject): image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) if image is not None: image = self._apply_overlay_color(image, UiColor.GREEN) + # Ebooks ======================================================= + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.EBOOK_TYPES, mime_fallback=True + ): + image = self._epub_cover(_filepath) # Blender ====================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True @@ -1060,7 +1089,6 @@ class ThumbRenderer(QObject): ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True ): image = self._source_engine(_filepath) - # No Rendered Thumbnail ======================================== if not _filepath.exists(): raise FileNotFoundError diff --git a/tagstudio/tests/fixtures/sample.epub b/tagstudio/tests/fixtures/sample.epub new file mode 100644 index 0000000000000000000000000000000000000000..b625b67b2848ff0c97a18f213ee167ab52ed1326 GIT binary patch literal 4413 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