Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add pdf thumbnail support (port #378) #543

Merged
merged 9 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions tagstudio/src/qt/helpers/image_effects.py
Original file line number Diff line number Diff line change
@@ -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)
81 changes: 71 additions & 10 deletions tagstudio/src/qt/widgets/thumb_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,19 @@
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
Expand All @@ -39,6 +50,7 @@
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,
Expand Down Expand Up @@ -812,6 +824,52 @@ def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image:

return im

def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to have a test for this. Rather than explaining what and how, I made the test, so feel free to cherry-pick/merge it into the branch. But it looks like the final png isnt really transparent 🤔

b1bae1f

"""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.

Expand Down Expand Up @@ -959,17 +1017,17 @@ def render(
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:
Expand All @@ -979,23 +1037,26 @@ def render(
# 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)
if image is None:
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)
Expand Down
Binary file added tagstudio/tests/fixtures/sample.pdf
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions tagstudio/tests/qt/test_thumb_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
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()
Expand Down