Skip to content

Commit 3d7629b

Browse files
CyanVoxelHeiholfyedpodtrzitko
authored
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 <yedpodtrzitko@users.noreply.github.com> * 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 <yedpodtrzitko@users.noreply.github.com>
1 parent 9255a86 commit 3d7629b

File tree

5 files changed

+109
-10
lines changed

5 files changed

+109
-10
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
2+
# Licensed under the GPL-3.0 License.
3+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
4+
5+
import numpy as np
6+
from PIL import Image
7+
8+
9+
def replace_transparent_pixels(
10+
img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255)
11+
) -> Image.Image:
12+
"""Replace (copying/without mutating) all transparent pixels in an image with the color.
13+
14+
Args:
15+
img (Image.Image):
16+
The source image
17+
color (tuple[int, int, int, int]):
18+
The color (RGBA, 0 to 255) which transparent pixels should be set to.
19+
Defaults to white (255, 255, 255, 255)
20+
21+
Returns:
22+
Image.Image:
23+
A copy of img with the pixels replaced.
24+
"""
25+
pixel_array = np.asarray(img.convert("RGBA")).copy()
26+
pixel_array[pixel_array[:, :, 3] == 0] = color
27+
return Image.fromarray(pixel_array)

tagstudio/src/qt/widgets/thumb_renderer.py

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,19 @@
2828
from PIL.Image import DecompressionBombError
2929
from pillow_heif import register_avif_opener, register_heif_opener
3030
from pydub import exceptions
31-
from PySide6.QtCore import QBuffer, QObject, QSize, Qt, Signal
31+
from PySide6.QtCore import (
32+
QBuffer,
33+
QFile,
34+
QFileDevice,
35+
QIODeviceBase,
36+
QObject,
37+
QSize,
38+
QSizeF,
39+
Qt,
40+
Signal,
41+
)
3242
from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap
43+
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
3344
from PySide6.QtSvg import QSvgRenderer
3445
from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
3546
from src.core.media_types import MediaCategories, MediaType
@@ -39,6 +50,7 @@
3950
from src.qt.helpers.color_overlay import theme_fg_overlay
4051
from src.qt.helpers.file_tester import is_readable_video
4152
from src.qt.helpers.gradient import four_corner_gradient
53+
from src.qt.helpers.image_effects import replace_transparent_pixels
4254
from src.qt.helpers.text_wrapper import wrap_full_text
4355
from src.qt.helpers.vendored.pydub.audio_segment import ( # type: ignore
4456
_AudioSegment as AudioSegment,
@@ -812,6 +824,52 @@ def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image:
812824

813825
return im
814826

827+
def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image:
828+
"""Render a thumbnail for a PDF file.
829+
830+
filepath (Path): The path of the file.
831+
size (int): The size of the icon.
832+
"""
833+
im: Image.Image = None
834+
835+
file: QFile = QFile(filepath)
836+
success: bool = file.open(
837+
QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser
838+
)
839+
if not success:
840+
logger.error("Couldn't render thumbnail", filepath=filepath)
841+
return im
842+
document: QPdfDocument = QPdfDocument()
843+
document.load(file)
844+
# Transform page_size in points to pixels with proper aspect ratio
845+
page_size: QSizeF = document.pagePointSize(0)
846+
ratio_hw: float = page_size.height() / page_size.width()
847+
if ratio_hw >= 1:
848+
page_size *= size / page_size.height()
849+
else:
850+
page_size *= size / page_size.width()
851+
# Enlarge image for antialiasing
852+
scale_factor = 2.5
853+
page_size *= scale_factor
854+
# Render image with no anti-aliasing for speed
855+
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
856+
render_options.setRenderFlags(
857+
QPdfDocumentRenderOptions.RenderFlag.TextAliased
858+
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
859+
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
860+
)
861+
# Convert QImage to PIL Image
862+
qimage: QImage = document.render(0, page_size.toSize(), render_options)
863+
buffer: QBuffer = QBuffer()
864+
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
865+
try:
866+
qimage.save(buffer, "PNG")
867+
im = Image.open(BytesIO(buffer.buffer().data()))
868+
finally:
869+
buffer.close()
870+
# Replace transparent pixels with white (otherwise Background defaults to transparent)
871+
return replace_transparent_pixels(im)
872+
815873
def _text_thumb(self, filepath: Path) -> Image.Image:
816874
"""Render a thumbnail for a plaintext file.
817875
@@ -959,17 +1017,17 @@ def render(
9591017
else:
9601018
image = self._image_thumb(_filepath)
9611019
# Videos =======================================================
962-
if MediaCategories.is_ext_in_category(
1020+
elif MediaCategories.is_ext_in_category(
9631021
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
9641022
):
9651023
image = self._video_thumb(_filepath)
9661024
# Plain Text ===================================================
967-
if MediaCategories.is_ext_in_category(
1025+
elif MediaCategories.is_ext_in_category(
9681026
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
9691027
):
9701028
image = self._text_thumb(_filepath)
9711029
# Fonts ========================================================
972-
if MediaCategories.is_ext_in_category(
1030+
elif MediaCategories.is_ext_in_category(
9731031
ext, MediaCategories.FONT_TYPES, mime_fallback=True
9741032
):
9751033
if is_grid_thumb:
@@ -979,23 +1037,26 @@ def render(
9791037
# Large (Full Alphabet) Preview
9801038
image = self._font_long_thumb(_filepath, adj_size)
9811039
# Audio ========================================================
982-
if MediaCategories.is_ext_in_category(
1040+
elif MediaCategories.is_ext_in_category(
9831041
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
9841042
):
9851043
image = self._audio_album_thumb(_filepath, ext)
9861044
if image is None:
9871045
image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio)
9881046
if image is not None:
9891047
image = self._apply_overlay_color(image, UiColor.GREEN)
990-
991-
# Blender ===========================================================
992-
if MediaCategories.is_ext_in_category(
1048+
# Blender ======================================================
1049+
elif MediaCategories.is_ext_in_category(
9931050
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
9941051
):
9951052
image = self._blender(_filepath)
996-
1053+
# PDF ==========================================================
1054+
elif MediaCategories.is_ext_in_category(
1055+
ext, MediaCategories.PDF_TYPES, mime_fallback=True
1056+
):
1057+
image = self._pdf_thumb(_filepath, adj_size)
9971058
# VTF ==========================================================
998-
if MediaCategories.is_ext_in_category(
1059+
elif MediaCategories.is_ext_in_category(
9991060
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
10001061
):
10011062
image = self._source_engine(_filepath)

tagstudio/tests/fixtures/sample.pdf

5.26 KB
Binary file not shown.
Loading

tagstudio/tests/qt/test_thumb_renderer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010
from syrupy.extensions.image import PNGImageSnapshotExtension
1111

1212

13+
def test_pdf_preview(cwd, snapshot):
14+
file_path: Path = cwd / "fixtures" / "sample.pdf"
15+
renderer = ThumbRenderer()
16+
img: Image.Image = renderer._pdf_thumb(file_path, 200)
17+
18+
img_bytes = io.BytesIO()
19+
img.save(img_bytes, format="PNG")
20+
img_bytes.seek(0)
21+
assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension)
22+
23+
1324
def test_svg_preview(cwd, snapshot):
1425
file_path: Path = cwd / "fixtures" / "sample.svg"
1526
renderer = ThumbRenderer()

0 commit comments

Comments
 (0)