From 3d7629bc731286d2721f19cc60bb8bb42e6facf5 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:34:49 -0700 Subject: [PATCH] feat: add pdf thumbnail support (port #378) (#543) * feat: add pdf thumbnail support Co-Authored-By: Heiholf <71659566+heiholf@users.noreply.github.com> * fix: remove redef * tests: add test comparing pdf to png snapshot Co-Authored-By: yed * fix: fix info in docstrings * fix: remove sample png generation * fix: change the pdf snapshot to use a black square * chore: fix whitespace --------- Co-authored-by: Heiholf <71659566+heiholf@users.noreply.github.com> Co-authored-by: yed --- tagstudio/src/qt/helpers/image_effects.py | 27 ++++++ tagstudio/src/qt/widgets/thumb_renderer.py | 81 +++++++++++++++--- tagstudio/tests/fixtures/sample.pdf | Bin 0 -> 5389 bytes .../test_thumb_renderer/test_pdf_preview.png | Bin 0 -> 1860 bytes tagstudio/tests/qt/test_thumb_renderer.py | 11 +++ 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 tagstudio/src/qt/helpers/image_effects.py create mode 100644 tagstudio/tests/fixtures/sample.pdf create mode 100644 tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png diff --git a/tagstudio/src/qt/helpers/image_effects.py b/tagstudio/src/qt/helpers/image_effects.py new file mode 100644 index 00000000..139d5274 --- /dev/null +++ b/tagstudio/src/qt/helpers/image_effects.py @@ -0,0 +1,27 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import numpy as np +from PIL import Image + + +def replace_transparent_pixels( + img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255) +) -> Image.Image: + """Replace (copying/without mutating) all transparent pixels in an image with the color. + + Args: + img (Image.Image): + The source image + color (tuple[int, int, int, int]): + The color (RGBA, 0 to 255) which transparent pixels should be set to. + Defaults to white (255, 255, 255, 255) + + Returns: + Image.Image: + A copy of img with the pixels replaced. + """ + pixel_array = np.asarray(img.convert("RGBA")).copy() + pixel_array[pixel_array[:, :, 3] == 0] = color + return Image.fromarray(pixel_array) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 377a5f73..2818f378 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -28,8 +28,19 @@ from PIL import ( from PIL.Image import DecompressionBombError from pillow_heif import register_avif_opener, register_heif_opener from pydub import exceptions -from PySide6.QtCore import QBuffer, QObject, QSize, Qt, Signal +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 src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT from src.core.media_types import MediaCategories, MediaType @@ -39,6 +50,7 @@ from src.qt.helpers.blender_thumbnailer import blend_thumb from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.gradient import four_corner_gradient +from src.qt.helpers.image_effects import replace_transparent_pixels from src.qt.helpers.text_wrapper import wrap_full_text from src.qt.helpers.vendored.pydub.audio_segment import ( # type: ignore _AudioSegment as AudioSegment, @@ -812,6 +824,52 @@ class ThumbRenderer(QObject): return im + def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: + """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 + + 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) + # 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 + qimage: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + qimage.save(buffer, "PNG") + im = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(im) + def _text_thumb(self, filepath: Path) -> Image.Image: """Render a thumbnail for a plaintext file. @@ -959,17 +1017,17 @@ class ThumbRenderer(QObject): else: image = self._image_thumb(_filepath) # Videos ======================================================= - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ): image = self._video_thumb(_filepath) # Plain Text =================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True ): image = self._text_thumb(_filepath) # Fonts ======================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True ): if is_grid_thumb: @@ -979,7 +1037,7 @@ class ThumbRenderer(QObject): # Large (Full Alphabet) Preview image = self._font_long_thumb(_filepath, adj_size) # Audio ======================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): image = self._audio_album_thumb(_filepath, ext) @@ -987,15 +1045,18 @@ 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) - - # Blender =========================================================== - if MediaCategories.is_ext_in_category( + # 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) # VTF ========================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True ): image = self._source_engine(_filepath) diff --git a/tagstudio/tests/fixtures/sample.pdf b/tagstudio/tests/fixtures/sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0293578a2a59d6e36a2b18472ae9d4bf1aaaf635 GIT binary patch literal 5389 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 X*K972P4S25B6QFYb#*g4bI88|aLs^8 literal 0 HcmV?d00001 diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..0ba9ea61890a903765262c53768b037ed6fb49a4 GIT binary patch literal 1860 zcmeAS@N?(olHy`uVBq!ia0y~yU~B^7FC1(@5rampPzDBeZci7-kcv5PuW#f%;=seQ zaew>o{K&iqPRv|wYZ5jeoHui(JVWIFw~P#LgqRvE6d5{%N2$>uz?lkmq*pVPKcDlK z^TW=WH|HH_b!QMzXJIIG8l^^qfUc?Fdk+7DytHlV41#iu4s!$;j&P1rqd|Z-73@9A X#QRicv4jb*c4Y8$^>bP0l+XkKSx0;n literal 0 HcmV?d00001 diff --git a/tagstudio/tests/qt/test_thumb_renderer.py b/tagstudio/tests/qt/test_thumb_renderer.py index c9f67595..c4f794d4 100644 --- a/tagstudio/tests/qt/test_thumb_renderer.py +++ b/tagstudio/tests/qt/test_thumb_renderer.py @@ -10,6 +10,17 @@ from src.qt.widgets.thumb_renderer import ThumbRenderer from syrupy.extensions.image import PNGImageSnapshotExtension +def test_pdf_preview(cwd, snapshot): + file_path: Path = cwd / "fixtures" / "sample.pdf" + renderer = ThumbRenderer() + img: Image.Image = renderer._pdf_thumb(file_path, 200) + + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + img_bytes.seek(0) + assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) + + def test_svg_preview(cwd, snapshot): file_path: Path = cwd / "fixtures" / "sample.svg" renderer = ThumbRenderer()