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
11 changes: 11 additions & 0 deletions docs/preview-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ Preview support for office documents or well-known project file formats varies b
| Photoshop | `.psd` | Flattened image render |
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |

### :material-archive: Archives

Archive thumbnails will display the first image from the archive within the Preview Panel.

| Filetype | Extensions |
|----------|----------------|
| 7-Zip | `.7z`, `.s7z` |
| RAR | `.rar` |
| Tar | `.tar`, `.tgz` |
| Zip | `.zip` |

### :material-book: eBooks

| Filetype | Extensions | Preview Type |
Expand Down
98 changes: 75 additions & 23 deletions src/tagstudio/qt/previews/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,22 +110,28 @@ def read(self, name: str) -> bytes:
return factory.get(name).read()


class _TarFile(tarfile.TarFile):
class _TarFile:
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""

def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
self.tar: tarfile.TarFile
self.filepath = filepath
self.mode = mode

def namelist(self) -> list[str]:
return self.getnames()
return self.tar.getnames()

def read(self, name: str) -> bytes:
return unwrap(self.extractfile(name)).read()
return unwrap(self.tar.extractfile(name)).read()

def __enter__(self) -> "_TarFile":
self.tar = tarfile.open(self.filepath, self.mode).__enter__()
return self

def __exit__(self, *args) -> None:
self.tar.__exit__(*args)


type _Archive_T = (
type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile]
)
type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile


Expand Down Expand Up @@ -907,15 +913,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None:
"""
im: Image.Image | None = None
try:
archiver: _Archive_T = zipfile.ZipFile
if ext == ".cb7":
archiver = _SevenZipFile
elif ext == ".cbr":
archiver = rarfile.RarFile
elif ext == ".cbt":
archiver = _TarFile

with archiver(filepath, "r") as archive:
with ThumbRenderer.__open_archive(filepath, ext) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover")
Expand All @@ -925,13 +923,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None:
)

if not im:
for file_name in archive.namelist():
if file_name.lower().endswith(
(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")
):
image_data = archive.read(file_name)
im = Image.open(BytesIO(image_data))
break
im = ThumbRenderer.__first_image(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)

Expand Down Expand Up @@ -963,6 +955,63 @@ def __cover_from_comic_info(

return im

@staticmethod
def _archive_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Extract the first image found in the archive.

Args:
filepath (Path): The path to the archive.
ext (str): The file extension.

Returns:
Image: The first image found in the archive.
"""
im: Image.Image | None = None
try:
with ThumbRenderer.__open_archive(filepath, ext) as archive:
im = ThumbRenderer.__first_image(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)

return im

@staticmethod
def __open_archive(filepath: Path, ext: str) -> _Archive:
"""Open an archive with its corresponding archiver.

Args:
filepath (Path): The path to the archive.
ext (str): The file extension.

Returns:
_Archive: The opened archive.
"""
archiver: type[_Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = _SevenZipFile
elif ext in {".cbr", ".rar"}:
archiver = rarfile.RarFile
elif ext in {".cbt", ".tar", ".tgz"}:
archiver = _TarFile
return archiver(filepath, "r")

@staticmethod
def __first_image(archive: _Archive) -> Image.Image | None:
"""Find and extract the first renderable image in the archive.

Args:
archive (_Archive): The current archive.

Returns:
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist():
if file_name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")):
image_data = archive.read(file_name)
return Image.open(BytesIO(image_data))

return None

def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.

Expand Down Expand Up @@ -1704,6 +1753,9 @@ def _render(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = self._pdf_thumb(_filepath, adj_size)
# Archives =====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
image = self._archive_thumb(_filepath, ext)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError
Expand Down