Skip to content

Commit 84cf470

Browse files
authored
feat: render archive thumbnails (#1194)
* feat: render archive thumbnails. * fix: pass mode to tarfile.open.
1 parent 44cf02d commit 84cf470

File tree

2 files changed

+86
-23
lines changed

2 files changed

+86
-23
lines changed

docs/preview-support.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ Preview support for office documents or well-known project file formats varies b
9696
| Photoshop | `.psd` | Flattened image render |
9797
| PowerPoint (Microsoft Office) | `.pptx`, `.ppt` | Embedded thumbnail :material-alert-circle:{ title="If available in file" } |
9898

99+
### :material-archive: Archives
100+
101+
Archive thumbnails will display the first image from the archive within the Preview Panel.
102+
103+
| Filetype | Extensions |
104+
|----------|----------------|
105+
| 7-Zip | `.7z`, `.s7z` |
106+
| RAR | `.rar` |
107+
| Tar | `.tar`, `.tgz` |
108+
| Zip | `.zip` |
109+
99110
### :material-book: eBooks
100111

101112
| Filetype | Extensions | Preview Type |

src/tagstudio/qt/previews/renderer.py

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -113,22 +113,28 @@ def read(self, name: str) -> bytes:
113113
return factory.get(name).read()
114114

115115

116-
class _TarFile(tarfile.TarFile):
116+
class _TarFile:
117117
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
118118

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

122124
def namelist(self) -> list[str]:
123-
return self.getnames()
125+
return self.tar.getnames()
124126

125127
def read(self, name: str) -> bytes:
126-
return unwrap(self.extractfile(name)).read()
128+
return unwrap(self.tar.extractfile(name)).read()
129+
130+
def __enter__(self) -> "_TarFile":
131+
self.tar = tarfile.open(self.filepath, self.mode).__enter__()
132+
return self
133+
134+
def __exit__(self, *args) -> None:
135+
self.tar.__exit__(*args)
127136

128137

129-
type _Archive_T = (
130-
type[zipfile.ZipFile] | type[rarfile.RarFile] | type[_SevenZipFile] | type[_TarFile]
131-
)
132138
type _Archive = zipfile.ZipFile | rarfile.RarFile | _SevenZipFile | _TarFile
133139

134140

@@ -910,15 +916,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None:
910916
"""
911917
im: Image.Image | None = None
912918
try:
913-
archiver: _Archive_T = zipfile.ZipFile
914-
if ext == ".cb7":
915-
archiver = _SevenZipFile
916-
elif ext == ".cbr":
917-
archiver = rarfile.RarFile
918-
elif ext == ".cbt":
919-
archiver = _TarFile
920-
921-
with archiver(filepath, "r") as archive:
919+
with ThumbRenderer.__open_archive(filepath, ext) as archive:
922920
if "ComicInfo.xml" in archive.namelist():
923921
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
924922
im = ThumbRenderer.__cover_from_comic_info(archive, comic_info, "FrontCover")
@@ -928,13 +926,7 @@ def _epub_cover(filepath: Path, ext: str) -> Image.Image | None:
928926
)
929927

930928
if not im:
931-
for file_name in archive.namelist():
932-
if file_name.lower().endswith(
933-
(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")
934-
):
935-
image_data = archive.read(file_name)
936-
im = Image.open(BytesIO(image_data))
937-
break
929+
im = ThumbRenderer.__first_image(archive)
938930
except Exception as e:
939931
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
940932

@@ -966,6 +958,63 @@ def __cover_from_comic_info(
966958

967959
return im
968960

961+
@staticmethod
962+
def _archive_thumb(filepath: Path, ext: str) -> Image.Image | None:
963+
"""Extract the first image found in the archive.
964+
965+
Args:
966+
filepath (Path): The path to the archive.
967+
ext (str): The file extension.
968+
969+
Returns:
970+
Image: The first image found in the archive.
971+
"""
972+
im: Image.Image | None = None
973+
try:
974+
with ThumbRenderer.__open_archive(filepath, ext) as archive:
975+
im = ThumbRenderer.__first_image(archive)
976+
except Exception as e:
977+
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
978+
979+
return im
980+
981+
@staticmethod
982+
def __open_archive(filepath: Path, ext: str) -> _Archive:
983+
"""Open an archive with its corresponding archiver.
984+
985+
Args:
986+
filepath (Path): The path to the archive.
987+
ext (str): The file extension.
988+
989+
Returns:
990+
_Archive: The opened archive.
991+
"""
992+
archiver: type[_Archive] = zipfile.ZipFile
993+
if ext in {".7z", ".cb7", ".s7z"}:
994+
archiver = _SevenZipFile
995+
elif ext in {".cbr", ".rar"}:
996+
archiver = rarfile.RarFile
997+
elif ext in {".cbt", ".tar", ".tgz"}:
998+
archiver = _TarFile
999+
return archiver(filepath, "r")
1000+
1001+
@staticmethod
1002+
def __first_image(archive: _Archive) -> Image.Image | None:
1003+
"""Find and extract the first renderable image in the archive.
1004+
1005+
Args:
1006+
archive (_Archive): The current archive.
1007+
1008+
Returns:
1009+
Image: The first renderable image in the archive.
1010+
"""
1011+
for file_name in archive.namelist():
1012+
if file_name.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg")):
1013+
image_data = archive.read(file_name)
1014+
return Image.open(BytesIO(image_data))
1015+
1016+
return None
1017+
9691018
def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image | None:
9701019
"""Render a small font preview ("Aa") thumbnail from a font file.
9711020
@@ -1819,6 +1868,9 @@ def _render(
18191868
ext, MediaCategories.PDF_TYPES, mime_fallback=True
18201869
):
18211870
image = self._pdf_thumb(_filepath, adj_size)
1871+
# Archives =====================================================
1872+
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
1873+
image = self._archive_thumb(_filepath, ext)
18221874
# MDIPACK ======================================================
18231875
elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES):
18241876
image = self._mdp_thumb(_filepath)

0 commit comments

Comments
 (0)