Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/library/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Preview support for office documents or well-known project file formats varies b
| OpenDocument Spreadsheet | `.ods`, `.fods` | Embedded thumbnail |
| OpenDocument Text | `.odt`, `.fodt` | Embedded thumbnail |
| Pages (Apple iWork) | `.pages` | Embedded thumbnail |
| Paint.NET | `.pdn` | 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" } |
Expand Down
11 changes: 10 additions & 1 deletion src/tagstudio/core/media_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class MediaType(str, Enum):
MODEL = "model"
OPEN_DOCUMENT = "open_document"
PACKAGE = "package"
PAINT_DOT_NET = "paint_dot_net"
PDF = "pdf"
PLAINTEXT = "plaintext"
PRESENTATION = "presentation"
Expand Down Expand Up @@ -358,6 +359,7 @@ class MediaCategories:
".pkg",
".xapk",
}
_PAINT_DOT_NET_SET: set[str] = {".pdn"}
_PDF_SET: set[str] = {".pdf"}
_PLAINTEXT_SET: set[str] = {
".csv",
Expand Down Expand Up @@ -554,6 +556,12 @@ class MediaCategories:
is_iana=False,
name="package",
)
PAINT_DOT_NET_TYPES = MediaCategory(
media_type=MediaType.PAINT_DOT_NET,
extensions=_PAINT_DOT_NET_SET,
is_iana=False,
name="paint.net",
)
PDF_TYPES = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET,
Expand Down Expand Up @@ -643,6 +651,7 @@ class MediaCategories:
MODEL_TYPES,
OPEN_DOCUMENT_TYPES,
PACKAGE_TYPES,
PAINT_DOT_NET_TYPES,
PDF_TYPES,
PLAINTEXT_TYPES,
PRESENTATION_TYPES,
Expand Down Expand Up @@ -679,7 +688,7 @@ def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool =

Args:
ext (str): File extension with a leading "." and in all lowercase.
media_cat (MediaCategory): The MediaCategory to to check for extension membership.
media_cat (MediaCategory): The MediaCategory to check for extension membership.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
return media_cat.contains(ext, mime_fallback)
47 changes: 44 additions & 3 deletions src/tagstudio/qt/previews/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio


import base64
import contextlib
import hashlib
import math
import os
import struct
import tarfile
import xml.etree.ElementTree as ET
import zipfile
Expand Down Expand Up @@ -1378,6 +1380,42 @@ def _video_thumb(filepath: Path) -> Image.Image | None:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im

@staticmethod
def _pdn_thumb(filepath: Path) -> Image.Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.

Args:
filepath (Path): The path of the .pdn file.

Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
if f.read(4) != b"PDN3":
return im

# Header length is a little-endian 24-bit int
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
if thumb_element is None:
return im

encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
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

def render(
self,
timestamp: float,
Expand All @@ -1391,7 +1429,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.
Expand Down Expand Up @@ -1504,7 +1542,7 @@ def fetch_cached_image(file_name: Path):
save_to_file=file_name,
)

# If the normal renderer failed, fallback the the defaults
# If the normal renderer failed, fallback the defaults
# (with native non-cached sizing!)
if not image:
image = (
Expand Down Expand Up @@ -1601,7 +1639,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.
Expand Down Expand Up @@ -1704,6 +1742,9 @@ def _render(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size)
# Paint.NET ====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES):
image = self._pdn_thumb(_filepath)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError
Expand Down