refactor: media types rework, dynamically load preview renderers (#1498)

* feat!: establish new MediaType system

* further additions and changes

* begin making renderers classes, improve text thumbnails

* continue renderer work

* convert most remaining thumbnail renderers to classes

* feat: dynamically load preview renderers

* refactor: use individual ext registration, continued progress

* refactor: finish converting old types to new registration system

* chore: alphabetize lists

* fix: only use search context for searches

* refactor: remove legacy filetype equivalent usages

* feat: add aliases for type groups, used in search

* refactor: use new MediaTypes system in archive renderers

* fix: add missing types

* refactor: remove last uses of legacy media type system

* refactor: move media type search context init to new file

* fix: fix import formatting, add missing aliases

* chore: remove leftover logging

* chore: small tweaks and comments

* refactor: wrap filetype init in function, call in driver

* docs: add and update docstrings

* fix: use all intended fallback icons for each media type

* fix: fix misc issues

* tests: add test cases for media types

* refactor: rename register_all to register_types

* refactor: wrap preview renderer type registrations in methods
This commit is contained in:
Travis Abendshien
2026-09-07 16:06:56 -07:00
committed by GitHub
parent 3e9296c1db
commit 658b7840c4
51 changed files with 2837 additions and 1347 deletions
+11
View File
@@ -55,3 +55,14 @@ path = [
]
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ["src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf"]
SPDX-FileCopyrightText = "(c) 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)"
SPDX-License-Identifier = "OFL-1.1"
[[annotations]]
path = ["src/tagstudio/resources/fonts/JetBrainsMono/**"]
SPDX-FileCopyrightText = "(c) 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)"
SPDX-License-Identifier = "OFL-1.1"
+4 -3
View File
@@ -21,15 +21,18 @@ dependencies = [
"mutagen~=1.47",
"numpy~=2.2",
"opencv_python~=4.11",
"Pillow>=10.2,<12",
"pillow-heif~=1.5.0",
"pillow-jxl-plugin~=1.3",
"Pillow>=10.2,<12",
"py7zr~=1.1.3",
"pydantic~=2.10",
"pydub~=0.25",
"Pygments~=2.21",
"PySide6==6.11.2",
"rarfile==4.2",
"rawpy~=0.27",
"requests~=2.31.0",
"semver~=3.0.4",
"Send2Trash>=1.8,<3",
"SQLAlchemy~=2.0",
"srctools~=2.6",
@@ -38,8 +41,6 @@ dependencies = [
"typing_extensions~=4.13",
"ujson~=5.10",
"wcmatch==10.*",
"requests~=2.31.0",
"semver~=3.0.4",
]
[project.gui-scripts]
+3
View File
@@ -10,6 +10,7 @@ from PySide6.QtCore import QSettings
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.enums import AppCacheItems
from tagstudio.core.library.alchemy.library import LibraryStatus
from tagstudio.core.query_lang.file_groups import register_types
from tagstudio.qt.app_settings import AppSettings
logger = structlog.get_logger(__name__)
@@ -21,6 +22,8 @@ class DriverMixin:
# TODO: AppSettings is Qt-specific and should not be in a base driver class.
settings: AppSettings
register_types() # Register all filetypes for the SEARCH context.
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
"""Check if the path of library is valid."""
library_path: Path | None = None
+8 -1
View File
@@ -31,7 +31,7 @@ class TagClickActionOption(enum.IntEnum):
DEFAULT = OPEN_EDIT
class Theme(enum.StrEnum):
class ThemePalette(enum.StrEnum):
COLOR_BG_DARK = "#65000000"
COLOR_BG_LIGHT = "#22000000"
COLOR_DARK_LABEL = "#DD000000"
@@ -44,6 +44,13 @@ class Theme(enum.StrEnum):
COLOR_FORBIDDEN_BG = "#65440D12"
class Theme(enum.IntEnum):
DARK = 0
LIGHT = 1
SYSTEM = 2
DEFAULT = SYSTEM
class OpenStatus(enum.IntEnum):
NOT_FOUND = 0
SUCCESS = 1
+20 -14
View File
@@ -13,7 +13,7 @@ from sqlalchemy.sql.operators import ilike_op
from tagstudio.core.library.alchemy.constants import TAG_CHILDREN_ID_QUERY
from tagstudio.core.library.alchemy.joins import TagEntry
from tagstudio.core.library.alchemy.models import Entry, Tag, TagAlias
from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
from tagstudio.core.media_types import MediaTypeGroup, MediaTypes
from tagstudio.core.query_lang.ast import (
AST,
ANDList,
@@ -24,6 +24,7 @@ from tagstudio.core.query_lang.ast import (
ORList,
Property,
)
from tagstudio.core.query_lang.file_groups import SEARCH
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
@@ -34,13 +35,6 @@ else:
logger = structlog.get_logger(__name__)
def get_filetype_equivalency_list(item: str) -> list[str] | set[str]:
for s in FILETYPE_EQUIVALENTS:
if item in s:
return s
return [item]
class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
def __init__(self, lib: Library) -> None:
super().__init__()
@@ -95,15 +89,27 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
)
return Entry.path.regexp_match(re.escape(node.value))
elif node.type == ConstraintType.MediaType:
extensions: set[str] = set[str]()
for media_cat in MediaCategories.ALL_CATEGORIES:
if node.value == media_cat.name:
extensions = extensions | media_cat.extensions
break
key = (
MediaTypes.get_group_key_from_name(
node.value, case_sensitive=False, ignore_whitespace=True
)
or node.value
)
media_type: MediaTypeGroup | None = getattr(MediaTypes, key, None)
extensions: set[str] = (
media_type.context_sets.get(SEARCH, set()) if media_type else set()
)
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
elif node.type == ConstraintType.FileType:
# NOTE: Entries store their suffix without a leading dot, the MediaTypes system includes
# the leading dot (if any), and this search system should take in either variant.
return or_(
*[Entry.suffix.ilike(ft) for ft in get_filetype_equivalency_list(node.value)]
*[
Entry.suffix.ilike(ft.removeprefix("."))
for ft in MediaTypes.get_equivalent_exts(f".{node.value.removeprefix('.')}")
]
)
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
if node.value.lower() == "untagged":
+8 -1
View File
@@ -105,7 +105,14 @@ class RefreshTracker:
shell=True,
encoding="UTF-8",
)
compiled_ignore_path.unlink()
try:
compiled_ignore_path.unlink()
except Exception as e:
logger.error(
"[Refresh] Could not remove compiled ignore path",
path=compiled_ignore_path,
error=e,
)
if result.stderr:
logger.error(result.stderr)
+278 -687
View File
@@ -1,714 +1,305 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import enum
import mimetypes
from dataclasses import dataclass
from pathlib import Path
import re
from typing import Any
import structlog
from tagstudio.core.utils.sanitized_attr import SanitizedAttr
logger = structlog.get_logger(__name__)
FILETYPE_EQUIVALENTS = [
{"aif", "aiff", "aifc"},
{"html", "htm", "xhtml", "shtml", "dhtml"},
{"jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"},
{"json", "jsonc", "json5"},
{"md", "markdown", "mkd", "rmd"},
{"tar.gz", "tgz"},
{"xml", "xul"},
{"yaml", "yml"},
]
def slugify(text: str) -> str:
"""Return a sanitized string with no whitespace or hyphens."""
# Replace non-word characters with underscores, strip whitespace and make lowercase
text = re.sub(r"\W", "_", text.strip().lower())
# Replace remaining spaces and hyphens with underscores
text = re.sub(r"[\s-]+", "_", text)
return text
class MediaType(enum.StrEnum):
"""Names of media types."""
ADOBE_PHOTOSHOP = "adobe_photoshop"
AFFINITY_PHOTO = "affinity_photo"
ARCHIVE = "archive"
AUDIO_MIDI = "audio_midi"
AUDIO = "audio"
BLENDER = "blender"
CLIP_STUDIO_PAINT = "clip_studio_paint"
CODE = "code"
DATABASE = "database"
DISK_IMAGE = "disk_image"
DOCUMENT = "document"
EBOOK = "ebook"
FONT = "font"
IMAGE_ANIMATED = "image_animated"
IMAGE_RAW = "image_raw"
IMAGE_VECTOR = "image_vector"
IMAGE = "image"
INSTALLER = "installer"
IWORK = "iwork"
MATERIAL = "material"
MDIPACK = "mdipack"
MODEL = "model"
OPEN_DOCUMENT = "open_document"
PACKAGE = "package"
PAINT_DOT_NET = "paint_dot_net"
PDF = "pdf"
PLAINTEXT = "plaintext"
PRESENTATION = "presentation"
PROGRAM = "program"
SHADER = "shader"
SHORTCUT = "shortcut"
SOURCE_ENGINE = "source_engine"
SPREADSHEET = "spreadsheet"
TEXT = "text"
VIDEO = "video"
@dataclass(frozen=True)
class MediaCategory:
"""An object representing a category of media.
Includes a MediaType identifier, extensions set, and IANA status flag.
class FileType:
"""A description of a single file type, along with an associated context.
Args:
media_type (MediaType): The MediaType Enum representing this category.
extensions (set[str]): The set of file extensions associated with this category.
Includes leading ".", all lowercase, and does not need to be unique to this category.
is_iana (bool): Represents whether this is an IANA registered category.
exts (str | list[str]): The file extention(s), including a leading dot if there is one.
Passing a list of extensions will treat them as equivalent/interchangeable.
E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention.
"""
media_type: MediaType
extensions: set[str]
name: str
is_iana: bool = False
def __init__(self, exts: str | list[str], contexts: str | list[str]) -> None:
self.exts: set[str]
self.contexts: set[str]
def contains(self, ext: str, mime_fallback: bool = False) -> bool:
"""Check if an extension is a member of this MediaCategory.
if isinstance(exts, str):
self.exts = set([exts])
else:
self.exts = set(exts)
if isinstance(contexts, str):
self.contexts = set([contexts])
else:
self.contexts = set(contexts)
class MediaTypeGroup:
"""A named group of FileTypes and context associations that represents a media group.
For example, "Image" files may be represented by a MediaTypeGroup, consisting of FileType
objects that are associated with individual extensions such as ".jpg" and ".png".
"""
def __init__(self, key: str, types: list[FileType]) -> None:
"""Initialize the MediaTypeGroup.
Args:
ext (str): File extension with a leading "." and in all lowercase.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
key (str): A key for the name of this group.
Dots are used to separate group levels (e.g. "microsoft.office.word").
This key is slugified before being used as an attribute.
types (list[FileType]): A list of FileType objects to include in the group.
"""
if ext in self.extensions:
return True
elif mime_fallback and self.is_iana:
mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type is not None and mime_type.startswith(self.media_type.value):
return True
return False
self.context_sets: dict[str, set[str]] = {}
self.name_aliases: list[str] = []
self.key = key
self.types: list[FileType] = []
self.add_types(types)
class MediaCategories:
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
".psd",
}
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
_KRITA_SET: set[str] = {".kra", ".krz"}
_ARCHIVE_SET: set[str] = {
".7z",
".gz",
".rar",
".s7z",
".tar",
".tgz",
".zip",
}
_AUDIO_MIDI_SET: set[str] = {
".mid",
".midi",
}
_AUDIO_SET: set[str] = {
".aac",
".aif",
".aifc",
".aiff",
".alac",
".caf",
".flac",
".m4a",
".m4p",
".mp3",
".mpeg4",
".ogg",
".wav",
".wma",
}
_BLENDER_SET: set[str] = {
".blen_tc",
".blend",
".blend1",
".blend2",
".blend3",
".blend4",
".blend5",
".blend6",
".blend7",
".blend8",
".blend9",
".blend10",
".blend11",
".blend12",
".blend13",
".blend14",
".blend15",
".blend16",
".blend17",
".blend18",
".blend19",
".blend20",
".blend21",
".blend22",
".blend23",
".blend24",
".blend25",
".blend26",
".blend27",
".blend28",
".blend29",
".blend30",
".blend31",
".blend32",
}
_CLIP_STUDIO_PAINT_SET: set[str] = {".clip"}
_CODE_SET: set[str] = {
".bat",
".cfg",
".conf",
".cpp",
".cs",
".csh",
".css",
".d",
".dhtml",
".fgd",
".fish",
".gitignore",
".h",
".hpp",
".htm",
".html",
".inf",
".ini",
".js",
".json",
".json5",
".jsonc",
".jsx",
".kv3",
".lua",
".meta",
".nix",
".nu",
".nut",
".php",
".plist",
".prefs",
".ps1",
".py",
".pyi",
".qml",
".qrc",
".qss",
".rs",
".sh",
".shtml",
".sip",
".spec",
".tcl",
".timestamp",
".toml",
".ts",
".tsx",
".vcfg",
".vdf",
".vmt",
".vqlayout",
".vsc",
".vsnd_template",
".xhtml",
".xml",
".xul",
".yaml",
".yml",
}
_DATABASE_SET: set[str] = {
".accdb",
".mdb",
".pdb",
".sqlite",
".sqlite3",
}
_DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx", ".iso"}
_DOCUMENT_SET: set[str] = {
".doc",
".docm",
".docx",
".dot",
".dotm",
".dotx",
".odt",
".pages",
".pdf",
".pxd",
".rtf",
".tex",
".wpd",
".wps",
}
_EBOOK_SET: set[str] = {
".azw",
".azw3",
".cb7",
".cba",
".cbr",
".cbt",
".cbz",
".djvu",
".epub",
".fb2",
".ibook",
".inf",
".kfx",
".lit",
".mobi",
".pdb",
".prc",
}
_FONT_SET: set[str] = {
".fon",
".otf",
".ttc",
".ttf",
".woff",
".woff2",
}
_IMAGE_ANIMATED_SET: set[str] = {
".apng",
".gif",
".webp",
}
_IMAGE_RAW_SET: set[str] = {
".arw",
".cr2",
".cr3",
".crw",
".dng",
".nef",
".nrw",
".orf",
".r3d",
".raf",
".raw",
".rw2",
".srf",
".srf2",
}
_IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"}
_IMAGE_RASTER_SET: set[str] = {
".apng",
".avif",
".bmp",
".exr",
".gif",
".heic",
".heif",
".icns",
".j2k",
".jfif",
".jp2",
".jpeg_large",
".jpeg",
".jpg_large",
".jpg",
".jpg2",
".jxl",
".png",
".psb",
".psd",
".tif",
".tiff",
".webp",
}
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
_IWORK_SET: set[str] = {".key", ".numbers", ".pages"}
_MATERIAL_SET: set[str] = {".mtl"}
_MDIPACK_SET: set[str] = {".mdp"}
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
_OPEN_DOCUMENT_SET: set[str] = {
".fodg",
".fodp",
".fods",
".fodt",
".mscz",
".odf",
".odg",
".odp",
".ods",
".odt",
".ora",
}
_PACKAGE_SET: set[str] = {
".aab",
".akp",
".apk",
".apkm",
".apks",
".pkg",
".xapk",
}
_PAINT_DOT_NET_SET: set[str] = {".pdn"}
_PDF_SET: set[str] = {".pdf"}
_PLAINTEXT_SET: set[str] = {
".csv",
".i3u",
".lang",
".lock",
".log",
".markdown",
".md",
".mkd",
".rmd",
".text",
".txt",
"contributing",
"license",
"readme",
}
_PRESENTATION_SET: set[str] = {
".key",
".odp",
".ppt",
".pptx",
}
_PROGRAM_SET: set[str] = {".app", ".bin", ".exe"}
_SOURCE_ENGINE_SET: set[str] = {".vtf"}
_SHADER_SET: set[str] = {
".effect",
".frag",
".fsh",
".glsl",
".shader",
".vert",
".vsh",
}
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
_SPREADSHEET_SET: set[str] = {
".csv",
".numbers",
".ods",
".xls",
".xlsx",
}
_VIDEO_SET: set[str] = {
".3gp",
".avi",
".flv",
".gifv",
".hevc",
".m4p",
".m4v",
".mkv",
".mov",
".mp4",
".webm",
".wmv",
".ts",
}
ADOBE_PHOTOSHOP_TYPES = MediaCategory(
media_type=MediaType.ADOBE_PHOTOSHOP,
extensions=_ADOBE_PHOTOSHOP_SET,
is_iana=False,
name="photoshop",
)
AFFINITY_PHOTO_TYPES = MediaCategory(
media_type=MediaType.AFFINITY_PHOTO,
extensions=_AFFINITY_PHOTO_SET,
is_iana=False,
name="affinity photo",
)
ARCHIVE_TYPES = MediaCategory(
media_type=MediaType.ARCHIVE,
extensions=_ARCHIVE_SET,
is_iana=False,
name="archive",
)
AUDIO_MIDI_TYPES = MediaCategory(
media_type=MediaType.AUDIO_MIDI,
extensions=_AUDIO_MIDI_SET,
is_iana=False,
name="audio midi",
)
AUDIO_TYPES = MediaCategory(
media_type=MediaType.AUDIO,
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
is_iana=True,
name="audio",
)
BLENDER_TYPES = MediaCategory(
media_type=MediaType.BLENDER,
extensions=_BLENDER_SET,
is_iana=False,
name="blender",
)
CLIP_STUDIO_PAINT_TYPES = MediaCategory(
media_type=MediaType.CLIP_STUDIO_PAINT,
extensions=_CLIP_STUDIO_PAINT_SET,
is_iana=False,
name="clip studio paint",
)
CODE_TYPES = MediaCategory(
media_type=MediaType.CODE,
extensions=_CODE_SET,
is_iana=False,
name="code",
)
DATABASE_TYPES = MediaCategory(
media_type=MediaType.DATABASE,
extensions=_DATABASE_SET,
is_iana=False,
name="database",
)
DISK_IMAGE_TYPES = MediaCategory(
media_type=MediaType.DISK_IMAGE,
extensions=_DISK_IMAGE_SET,
is_iana=False,
name="disk image",
)
DOCUMENT_TYPES = MediaCategory(
media_type=MediaType.DOCUMENT,
extensions=_DOCUMENT_SET,
is_iana=False,
name="document",
)
EBOOK_TYPES = MediaCategory(
media_type=MediaType.EBOOK,
extensions=_EBOOK_SET,
is_iana=False,
name="ebook",
)
FONT_TYPES = MediaCategory(
media_type=MediaType.FONT,
extensions=_FONT_SET,
is_iana=True,
name="font",
)
IMAGE_ANIMATED_TYPES = MediaCategory(
media_type=MediaType.IMAGE_ANIMATED,
extensions=_IMAGE_ANIMATED_SET,
is_iana=False,
name="animated image",
)
IMAGE_RAW_TYPES = MediaCategory(
media_type=MediaType.IMAGE_RAW,
extensions=_IMAGE_RAW_SET,
is_iana=False,
name="raw image",
)
IMAGE_VECTOR_TYPES = MediaCategory(
media_type=MediaType.IMAGE_VECTOR,
extensions=_IMAGE_VECTOR_SET,
is_iana=False,
name="vector image",
)
IMAGE_RASTER_TYPES = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_IMAGE_RASTER_SET,
is_iana=False,
name="raster image",
)
IMAGE_TYPES = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_IMAGE_RASTER_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
is_iana=True,
name="image",
)
INSTALLER_TYPES = MediaCategory(
media_type=MediaType.INSTALLER,
extensions=_INSTALLER_SET,
is_iana=False,
name="installer",
)
IWORK_TYPES = MediaCategory(
media_type=MediaType.IWORK,
extensions=_IWORK_SET,
is_iana=False,
name="iwork",
)
MATERIAL_TYPES = MediaCategory(
media_type=MediaType.MATERIAL,
extensions=_MATERIAL_SET,
is_iana=False,
name="material",
)
MDIPACK_TYPES = MediaCategory(
media_type=MediaType.MDIPACK,
extensions=_MDIPACK_SET,
is_iana=False,
name="mdipack",
)
MODEL_TYPES = MediaCategory(
media_type=MediaType.MODEL,
extensions=_MODEL_SET,
is_iana=True,
name="model",
)
OPEN_DOCUMENT_TYPES = MediaCategory(
media_type=MediaType.OPEN_DOCUMENT,
extensions=_OPEN_DOCUMENT_SET,
is_iana=False,
name="open document",
)
PACKAGE_TYPES = MediaCategory(
media_type=MediaType.PACKAGE,
extensions=_PACKAGE_SET,
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 | _ADOBE_ILLUSTRATOR_SET,
is_iana=False,
name="pdf",
)
PLAINTEXT_TYPES = MediaCategory(
media_type=MediaType.PLAINTEXT,
extensions=_PLAINTEXT_SET | _CODE_SET,
is_iana=False,
name="plaintext",
)
PRESENTATION_TYPES = MediaCategory(
media_type=MediaType.PRESENTATION,
extensions=_PRESENTATION_SET,
is_iana=False,
name="presentation",
)
PROGRAM_TYPES = MediaCategory(
media_type=MediaType.PROGRAM,
extensions=_PROGRAM_SET,
is_iana=False,
name="program",
)
SHADER_TYPES = MediaCategory(
media_type=MediaType.SHADER,
extensions=_SHADER_SET,
is_iana=False,
name="shader",
)
SHORTCUT_TYPES = MediaCategory(
media_type=MediaType.SHORTCUT,
extensions=_SHORTCUT_SET,
is_iana=False,
name="shortcut",
)
SOURCE_ENGINE_TYPES = MediaCategory(
media_type=MediaType.SOURCE_ENGINE,
extensions=_SOURCE_ENGINE_SET,
is_iana=False,
name="source engine",
)
SPREADSHEET_TYPES = MediaCategory(
media_type=MediaType.SPREADSHEET,
extensions=_SPREADSHEET_SET,
is_iana=False,
name="spreadsheet",
)
TEXT_TYPES = MediaCategory(
media_type=MediaType.TEXT,
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
is_iana=True,
name="text",
)
VIDEO_TYPES = MediaCategory(
media_type=MediaType.VIDEO,
extensions=_VIDEO_SET,
is_iana=True,
name="video",
)
KRITA_TYPES = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_KRITA_SET,
is_iana=False,
name="krita",
)
ALL_CATEGORIES = [
ADOBE_PHOTOSHOP_TYPES,
AFFINITY_PHOTO_TYPES,
ARCHIVE_TYPES,
AUDIO_MIDI_TYPES,
AUDIO_TYPES,
BLENDER_TYPES,
CLIP_STUDIO_PAINT_TYPES,
DATABASE_TYPES,
DISK_IMAGE_TYPES,
DOCUMENT_TYPES,
EBOOK_TYPES,
FONT_TYPES,
IMAGE_ANIMATED_TYPES,
IMAGE_RAW_TYPES,
IMAGE_TYPES,
IMAGE_VECTOR_TYPES,
INSTALLER_TYPES,
IWORK_TYPES,
MATERIAL_TYPES,
MDIPACK_TYPES,
MODEL_TYPES,
OPEN_DOCUMENT_TYPES,
PACKAGE_TYPES,
PAINT_DOT_NET_TYPES,
PDF_TYPES,
PLAINTEXT_TYPES,
PRESENTATION_TYPES,
PROGRAM_TYPES,
CODE_TYPES,
SHADER_TYPES,
SHORTCUT_TYPES,
SOURCE_ENGINE_TYPES,
SPREADSHEET_TYPES,
TEXT_TYPES,
VIDEO_TYPES,
KRITA_TYPES,
]
@staticmethod
def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
"""Return a set of MediaTypes given a file extension.
def add_types(self, types: list[FileType]) -> None:
"""Add one or more types to the group.
Args:
ext (str): File extension with a leading "." and in all lowercase.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
types (list[FileType]): A list of FileType objects to add to the group.
"""
media_types: set[MediaType] = set()
for type_ in types:
updated_types: set[FileType] = set()
for existing_type in self.types:
# If there's any overlap between the extensions, it's the same type
if not existing_type.exts.isdisjoint(type_.exts):
existing_type.contexts |= type_.contexts
existing_type.exts |= type_.exts
updated_types.add(type_)
if type_ not in updated_types:
self.types.append(type_)
for cat in MediaCategories.ALL_CATEGORIES:
if cat.contains(ext, mime_fallback):
media_types.add(cat.media_type)
contexts_ = [type_.contexts] if isinstance(type_.contexts, str) else type_.contexts
for context in contexts_:
self.context_sets.setdefault(context, set())
for ext in type_.exts:
self.context_sets[context].add(ext)
return media_types
@staticmethod
def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool:
"""Check if an extension is a member of a MediaCategory.
def contains(self, ext: str, context: str) -> bool:
"""Return true if the group contains an extention under a given context, otherwise False.
Args:
ext (str): File extension with a leading "." and in all lowercase.
media_cat (MediaCategory): The MediaCategory to check for extension membership.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
ext (str): The file extention to check for in the group.
context (str): The context to check for group membership under.
"""
return media_cat.contains(ext, mime_fallback)
equivalent_exts = MediaTypes.equivalent_exts.get(ext) or [ext]
return any(e in self.context_sets.get(context, []) for e in equivalent_exts)
class MediaTypes(metaclass=SanitizedAttr):
"""A singleton class that manages registered media types and their relationships."""
_chained_groups: dict[str, set[str]] = {}
_name_to_key_map: dict[str, str] = {}
all_groups: list[MediaTypeGroup] = []
equivalent_exts: dict[str, set[str]] = {}
@classmethod
def _snapshot(cls) -> dict[str, Any]:
"""Return a snapshot of the class's attributes. Used in tests."""
return {attr: getattr(cls, attr) for attr in dir(cls) if not attr.startswith("__")}
@classmethod
def _restore(cls, attrs: dict[str, Any]) -> None:
"""Restore the state of the class from a snapshot. Used in tests."""
attrs_to_delete: list[str] = []
for name in cls.__dict__:
if not name.startswith("__"):
try:
setattr(cls, name, attrs[name])
except KeyError:
attrs_to_delete.append(name)
for attr in attrs_to_delete:
delattr(cls, attr)
@classmethod
def add_name_aliases(cls, group_key: str, names: str | list[str]) -> None:
"""Adds one or more user-facing names for a MediaTypeGroup.
For example, "Adobe" and "Adobe Photoshop" would be proper group names.
Args:
group_key (str): The name key associated with a MediaTypeGroup.
If a group with this group_key does not exist, it will be created.
names (str | list[str]): One or more user-facing names for the group.
"""
group = getattr(MediaTypes, group_key, None)
if group is None:
cls.register(group_key, [], [])
group = getattr(MediaTypes, group_key, None)
if not isinstance(group, MediaTypeGroup):
return
if isinstance(names, str):
names = [names]
for name in names:
group.name_aliases.append(name)
# Map the name and common variants of the name to the group key.
name_no_whitespace = name.replace(" ", "").replace("-", "").replace("_", "")
name_no_space_lower = name_no_whitespace.lower()
cls._name_to_key_map[name] = group_key
cls._name_to_key_map[name.lower()] = group_key
cls._name_to_key_map[name_no_whitespace] = group_key
cls._name_to_key_map[name_no_space_lower] = group_key
@classmethod
def chain_group(cls, parent_group: str, child_groups: str | list[str]) -> None:
"""Chain groups so when a type is added to a child group it's also added to a parent group.
Args:
parent_group (str): The group that will also update whenever a child group is updated.
If the group doesn't exist, it will be created.
child_groups (str | list[str]): Groups that tell a parent group to update as well.
If the group doesn't exist, it will be created.
"""
if isinstance(child_groups, str):
child_groups = [child_groups]
# If the groups don't exist, register it.
if getattr(MediaTypes, slugify(parent_group), None) is None:
cls.register(parent_group, [], [])
for child_group in child_groups:
if getattr(MediaTypes, slugify(child_group), None) is None:
cls.register(child_group, [], [])
cls._chained_groups.setdefault(parent_group, set())
for c_group in child_groups:
cls._chained_groups[parent_group].add(c_group)
@classmethod
def find(cls, ext: str, context: str) -> list[MediaTypeGroup]:
"""Return a list of MediaTypeGroups this extention is found in with the given context.
Args:
ext (str): The file extention to check for in the group.
context (str): The context to check for group membership under.
"""
groups: list[MediaTypeGroup] = []
for group in cls.all_groups:
for type_ in group.types:
equivalent_exts = cls.equivalent_exts.get(ext) or [ext]
for e in equivalent_exts:
if e in type_.exts and context in type_.contexts:
groups.append(group)
break
return groups
@classmethod
def contains(cls, group_key: str, ext: str, context: str) -> bool:
"""A passthrough method for using `MediaTypeGroup.contains()` given a group name.
If the group does not exist, this raises an `AttributeError`.
Args:
group_key (str): The name key or attribute name for the MediaTypeGroup.
ext (str): The file extention to check for in the group.
context (str): The context to check for group membership under.
"""
group: MediaTypeGroup | None = getattr(MediaTypes, slugify(group_key), None)
if not group:
raise AttributeError(f"'{slugify(group_key)} is not registered with MediaTypes.")
return group.contains(ext, context)
@classmethod
def get_group_key_from_name(
cls, name: str, case_sensitive: bool = True, ignore_whitespace: bool = False
) -> str | None:
"""Attempt to return a group key given a proper name for the group.
Args:
name (str): The user-facing name of a group, or name key.
case_sensitive (bool): Should the name be treated with case sensitivity?
ignore_whitespace (bool): Should whitespace in the name be ignored?
"""
if not case_sensitive:
name = name.lower()
if ignore_whitespace:
name = name.replace(" ", "").replace("-", "").replace("_", "")
return cls._name_to_key_map.get(name)
@classmethod
def register(cls, group_key: str, ext: str | list[str], contexts: str | list[str]) -> None:
"""Create and register or update a existing MediaTypeGroup inside MediaTypes.
Args:
group_key (str): group_key (str): The name key of the MediaTypeGroup.
ext (str | list[str]): One or more file extensions, including leading dot.
Passing a list of extensions will treat them as equivalent/interchangeable.
E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention.
contexts (list[str] | str): One or more contexts to register the extension(s) under.
"""
# Sanitize and homogenize arguments
attr_name = slugify(group_key)
if attr_name in _FORBIDDEN_NAMES:
raise AttributeError(f"{attr_name}' collides with an internal attribute.")
if isinstance(ext, str):
ext = [ext]
if isinstance(contexts, str):
contexts = [contexts]
# Check for existing group or create new one
group = getattr(MediaTypes, attr_name, None)
assert isinstance(group, MediaTypeGroup) or group is None
if group is None:
group = MediaTypeGroup(group_key, [])
group.add_types([FileType(ext, contexts)])
setattr(MediaTypes, attr_name, group)
cls.all_groups.append(group)
else:
group.add_types([FileType(ext, contexts)])
# Store any file extention equivalents
if len(ext) > 1:
for e in ext:
cls.equivalent_exts.setdefault(e, set(ext))
# Create any chained groups from dot notations (e.g. "adobe.photoshop")
name_parts = group_key.split(".")
for i in range(1, len(name_parts)):
parent = ".".join(name_parts[:i])
child = ".".join(name_parts[: i + 1])
cls.chain_group(parent, [child])
# Update any chained groups
chained_groups: set[str] = set()
for k, v in cls._chained_groups.items():
if group_key in v:
chained_groups.add(k)
if chained_groups:
for c_name in chained_groups:
cls.register(c_name, ext, contexts)
@classmethod
def get_equivalent_exts(cls, ext: str) -> set[str]:
"""Return a set of equivalent file extensions given an extention, including itself.
Args:
ext (str): The file extension, including a leading dot (if there is one).
"""
return cls.equivalent_exts.get(ext, {ext})
_FORBIDDEN_NAMES = set(dir(MediaTypes))
@@ -0,0 +1,778 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import structlog
from tagstudio.core.media_types import MediaTypes
logger = structlog.get_logger(__name__)
SEARCH = "SEARCH" # MediaType Context
def register_types() -> None:
"""Register all internally configured filetype groups with the MediaTypes system."""
# Vendor.Suite.Product =========================================================================
# These groups are designed so that searching for either the vendor, suite, or product
# will return file types only under that group level.
# Initial Miscellaneous Chaining ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
MediaTypes.chain_group(
"office",
[
"adobe.acrobat",
"apple.iwork",
"microsoft.office",
"open_document",
],
)
MediaTypes.add_name_aliases("office", ["Office", "Office Suite"])
MediaTypes.chain_group(
"document",
[
"adobe.acrobat",
"apple.iwork.pages",
"microsoft.office.word",
"open_document.document",
"typesetting",
],
)
MediaTypes.chain_group(
"presentation",
[
"apple.iwork.keynote",
"microsoft.office.powerpoint",
"open_document.presentation",
],
)
MediaTypes.chain_group(
"spreadsheet",
[
"apple.iwork.numbers",
"microsoft.office.excel",
"open_document.spreadsheet",
],
)
# Adobe --------------------------------------------------------------------
MediaTypes.add_name_aliases("adobe", "Adobe")
# Adobe Acrobat/Reader
MediaTypes.add_name_aliases(
"adobe.acrobat",
[
"Acrobat",
"Adobe Acrobat",
"Adobe Reader",
"PDF",
"Reader",
],
)
MediaTypes.register("adobe.acrobat", ".fdf", SEARCH)
MediaTypes.register("adobe.acrobat", ".pdf", SEARCH)
MediaTypes.register("adobe.acrobat", ".pdx", SEARCH)
MediaTypes.register("adobe.acrobat", ".ps", SEARCH)
MediaTypes.register("adobe.acrobat", ".xfdf", SEARCH)
MediaTypes.register("adobe.acrobat", ".xps", SEARCH)
# Adobe Illustrator
MediaTypes.add_name_aliases("adobe.illustrator", ["Illustrator", "Adobe Illustrator"])
MediaTypes.register("adobe.illustrator", ".ai", SEARCH)
# Adobe Photoshop
MediaTypes.add_name_aliases("adobe.photoshop", ["Photoshop", "Adobe Photoshop"])
MediaTypes.register("adobe.photoshop", ".pdd", SEARCH)
MediaTypes.register("adobe.photoshop", ".psb", SEARCH)
MediaTypes.register("adobe.photoshop", ".psd", SEARCH)
# Affinity -----------------------------------------------------------------
# NOTE: Affinity suite products with generic names (e.g. "Photo") should not have those
# names be standalone aliases as they will conflict with other, more common names.
MediaTypes.add_name_aliases("affinity", "Affinity")
MediaTypes.register("affinity", ".af", SEARCH)
# Affinity Designer
MediaTypes.add_name_aliases("affinity.designer", ["Designer", "Affinity Designer"])
MediaTypes.register("affinity.designer", ".afdesign", SEARCH)
# Affinity Photo
MediaTypes.add_name_aliases("affinity.photo", "Affinity Photo")
MediaTypes.register("affinity.photo", ".afphoto", SEARCH)
# Affinity Publisher
MediaTypes.add_name_aliases("affinity.publisher", "Affinity Publisher")
MediaTypes.register("affinity.publisher", [".afpublisher", ".afpub"], SEARCH)
# Apple & iWork ------------------------------------------------------------
MediaTypes.add_name_aliases("apple", "Apple")
MediaTypes.add_name_aliases("apple.iwork", "iWork")
MediaTypes.add_name_aliases("apple.creator_studio", ["Apple Creator Studio", "Creator Studio"])
# NOTE: iWork is a subset of Creator Studio
MediaTypes.chain_group("apple.creator_studio", "apple.iwork")
# Apple Books
MediaTypes.add_name_aliases("apple.books", ["Apple Books", "Apple iBooks", "iBooks"])
MediaTypes.register("apple.books", ".ibook", SEARCH)
# Keynote (iWork + Apple Creator Studio)
MediaTypes.add_name_aliases(
"apple.iwork.keynote",
[
"Keynote",
"Apple Keynote",
"Apple iWork Keynote",
"iWork Keynote",
],
)
MediaTypes.register("apple.iwork.keynote", ".key", SEARCH)
# Numbers (iWork + Apple Creator Studio)
MediaTypes.add_name_aliases(
"apple.iwork.numbers",
[
"Numbers",
"Apple Numbers",
"Apple iWork Numbers",
"iWork Numbers",
],
)
MediaTypes.register("apple.iwork.numbers", ".numbers", SEARCH)
# Pages (iWork + Apple Creator Studio)
MediaTypes.add_name_aliases(
"apple.iwork.pages",
[
"Pages",
"Apple Pages",
"Apple iWork Pages",
"iWork Pages",
],
)
MediaTypes.register("apple.iwork.pages", ".pages", SEARCH)
# Pixelmator Pro (Apple Creator Studio)
MediaTypes.add_name_aliases(
"apple.creator_studio.pixelmator",
[
"Apple Pixelmator Pro",
"Apple Pixelmator",
"Pixelmator Pro",
"Pixelmator",
],
)
MediaTypes.register("apple.creator_studio.pixelmator", ".pxd", SEARCH)
# Autodesk -----------------------------------------------------------------
MediaTypes.add_name_aliases("autodesk", "Autodesk")
MediaTypes.register("autodesk", ".3ds", SEARCH)
MediaTypes.register("autodesk", ".fbx", SEARCH)
# Blender ------------------------------------------------------------------
MediaTypes.add_name_aliases("blender", "Blender")
MediaTypes.register("blender", ".blen_tc", SEARCH)
MediaTypes.register("blender", ".blend", SEARCH)
# Numbered Blender auto-backup files (.blend1 - .blend32)
MediaTypes.register("blender", [f".blend{i}" for i in range(1, 33)], SEARCH)
# Clip Studio Paint --------------------------------------------------------
MediaTypes.add_name_aliases("clip_studio_paint", ["Clip Studio", "Clip Studio Paint"])
MediaTypes.register("clip_studio_paint", ".clip", SEARCH)
MediaTypes.register("clip_studio_paint", ".cmc", SEARCH)
MediaTypes.register("clip_studio_paint", ".lip", SEARCH)
# Corel --------------------------------------------------------------------
MediaTypes.add_name_aliases("corel.wordperfect", ["WordPerfect", "Corel WordPerfect"])
MediaTypes.register("corel.wordperfect", ".wpd", SEARCH)
MediaTypes.add_name_aliases("corel", "Corel")
# GIMP ---------------------------------------------------------------------
MediaTypes.add_name_aliases("gimp", "GIMP")
MediaTypes.register("gimp", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc.
MediaTypes.register("gimp", ".xcf", SEARCH)
# Krita --------------------------------------------------------------------
# NOTE: As more KDE apps potentially get added, this might need to go under a KDE group.
MediaTypes.add_name_aliases("krita", ["Krita", "KDE Krita"])
MediaTypes.register("krita", ".kra", SEARCH)
MediaTypes.register("krita", ".krz", SEARCH)
MediaTypes.register("krita", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc.
# MediBang Paint / FireAlpaca ----------------------------------------------
MediaTypes.add_name_aliases("medibang_paint", ["FireAlpaca", "MediBang Paint", "MediBang"])
MediaTypes.register("medibang_paint", ".mdp", SEARCH)
# Microsoft Office ---------------------------------------------------------
MediaTypes.add_name_aliases(
"microsoft.office",
[
"Microsoft 365",
"Microsoft Office 365",
"Microsoft Office",
"MS Office",
"Office 365",
],
)
MediaTypes.register("microsoft.office", ".wdb", SEARCH) # Microsoft Works Database
MediaTypes.add_name_aliases(
"microsoft.office.access",
[
"Access",
"Microsoft Access",
"Microsoft Office Access",
"Office Access",
],
)
MediaTypes.register("microsoft.office.access", ".accdb", SEARCH)
MediaTypes.register("microsoft.office.access", ".mdb", SEARCH)
MediaTypes.add_name_aliases(
"microsoft.office.excel",
[
"Excel",
"Microsoft Excel",
"Microsoft Office Excel",
"Office Excel",
],
)
MediaTypes.register("microsoft.office.excel", ".xlr", SEARCH)
MediaTypes.register("microsoft.office.excel", ".xls", SEARCH)
MediaTypes.register("microsoft.office.excel", ".xlsx", SEARCH)
MediaTypes.add_name_aliases(
"microsoft.office.powerpoint",
[
"PowerPoint",
"Microsoft PowerPoint",
"Microsoft Office PowerPoint",
"Office PowerPoint",
],
)
MediaTypes.register("microsoft.office.powerpoint", ".pot", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".potm", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".potx", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".ppam", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".pps", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".ppsm", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".ppsx", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".ppt", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".pptm", SEARCH)
MediaTypes.register("microsoft.office.powerpoint", ".pptx", SEARCH)
MediaTypes.add_name_aliases(
"microsoft.office.word",
[
"Word",
"Microsoft Word",
"Microsoft Office Word",
"Office Word",
],
)
MediaTypes.register("microsoft.office.word", ".doc", SEARCH)
MediaTypes.register("microsoft.office.word", ".docm", SEARCH)
MediaTypes.register("microsoft.office.word", ".docx", SEARCH)
MediaTypes.register("microsoft.office.word", ".dot", SEARCH)
MediaTypes.register("microsoft.office.word", ".dotm", SEARCH)
MediaTypes.register("microsoft.office.word", ".dotx", SEARCH)
MediaTypes.register("microsoft.office.word", ".wps", SEARCH)
# MuseScore ----------------------------------------------------------------
MediaTypes.add_name_aliases("musescore", ["MuseScore", "MuseScore Studio"])
MediaTypes.register("musescore", ".mscz", SEARCH)
# OpenDocument -------------------------------------------------------------
MediaTypes.add_name_aliases("open_document", ["LibreOffice", "OpenDocument", "OpenOffice"])
MediaTypes.register("open_document", ".fodg", SEARCH)
MediaTypes.register("open_document", ".odf", SEARCH)
MediaTypes.register("open_document", ".odg", SEARCH)
MediaTypes.register("open_document.document", ".fodt", SEARCH)
MediaTypes.register("open_document.document", ".odt", SEARCH)
MediaTypes.register("open_document.presentation", ".fodp", SEARCH)
MediaTypes.register("open_document.presentation", ".odp", SEARCH)
MediaTypes.register("open_document.spreadsheet", ".fods", SEARCH)
MediaTypes.register("open_document.spreadsheet", ".ods", SEARCH)
# Paint.NET ----------------------------------------------------------------
MediaTypes.add_name_aliases("paint_dot_net", ["Paint.NET", "PaintDotNet"])
MediaTypes.register("paint_dot_net", ".pdn", SEARCH)
# Unity Game Engine --------------------------------------------------------
MediaTypes.add_name_aliases("unity", ["Unity Engine", "Unity"])
MediaTypes.register("unity", ".meta", SEARCH)
# Valve Source Engine ------------------------------------------------------
MediaTypes.add_name_aliases(
"source_engine",
[
"Source 2 Engine",
"Source Engine",
"Valve Source 2 Engine",
"Valve Source Engine",
],
)
MediaTypes.register("source_engine", ".fgd", SEARCH)
MediaTypes.register("source_engine", ".gi", SEARCH)
MediaTypes.register("source_engine", ".kv3", SEARCH)
MediaTypes.register("source_engine", ".nut", SEARCH)
MediaTypes.register("source_engine", ".vcfg", SEARCH)
MediaTypes.register("source_engine", ".vdf", SEARCH)
MediaTypes.register("source_engine", ".vmt", SEARCH)
MediaTypes.register("source_engine", ".vqlayout", SEARCH)
MediaTypes.register("source_engine", ".vsc", SEARCH)
MediaTypes.register("source_engine", ".vsnd_template", SEARCH)
MediaTypes.register("source_engine", ".vtf", SEARCH)
# General Media Types ==========================================================================
# These are general groups for media types based on the file formats and uses themselves, rather
# than the vendors. Extensions may be duplicated here if they belong in both sections.
# 3D Models & Materials ----------------------------------------------------
MediaTypes.add_name_aliases("material", "Material")
MediaTypes.register("material", ".mtl", SEARCH)
MediaTypes.add_name_aliases("model", ["3D Model", "3D Object", "Model", "Object"])
MediaTypes.register("model", ".3ds", SEARCH)
MediaTypes.register("model", ".3mf", SEARCH)
MediaTypes.register("model", ".fbx", SEARCH)
MediaTypes.register("model", ".obj", SEARCH)
MediaTypes.register("model", ".stl", SEARCH)
# Archives -----------------------------------------------------------------
MediaTypes.add_name_aliases("archive", ["Archive", "Compressed"])
MediaTypes.register("archive", ".cba", SEARCH) # Also under "ebook.comic"
# RAR
MediaTypes.add_name_aliases(
"archive.rar",
[
"RAR Archive",
"RAR",
"WinRAR",
"WinRAR Archive",
],
)
MediaTypes.register("archive.rar", ".cbr", SEARCH) # Also under "ebook.comic"
MediaTypes.register("archive.rar", ".rar", SEARCH)
MediaTypes.register("archive.rar", ".rev", SEARCH)
# tar
MediaTypes.add_name_aliases(
"archive.tar",
[
"Tape Archive",
"tar Archive",
"tarball",
"tar",
],
)
MediaTypes.register("archive.tar", ".tar", SEARCH)
MediaTypes.register("archive.tar", [".tar.bz", ".tb2", ".tbz", ".tbz2", ".tz2"], SEARCH)
MediaTypes.register("archive.tar", [".tar.gz", ".taz", ".tgz"], SEARCH)
MediaTypes.register("archive.tar", [".tar.lzma", ".tlz"], SEARCH)
MediaTypes.register("archive.tar", [".tar.xz", ".txz"], SEARCH)
MediaTypes.register("archive.tar", [".tar.zst", ".tzst"], SEARCH)
MediaTypes.register("archive.tar", ".cbt", SEARCH) # Also under "ebook.comic"
# ZIP
MediaTypes.add_name_aliases(
"archive.zip",
[
"7-Zip Archive",
"7-Zip",
"SevenZip Archive",
"SevenZip",
"WinZIP Archive",
"WinZIP",
"Zip Archive",
"ZIP",
"ZIP File",
],
)
MediaTypes.register("archive.zip", ".7z", SEARCH)
MediaTypes.register("archive.zip", ".cb7", SEARCH) # Also under "ebook.comic"
MediaTypes.register("archive.zip", ".cbz", SEARCH) # Also under "ebook.comic"
MediaTypes.register("archive.zip", ".gz", SEARCH)
MediaTypes.register("archive.zip", ".s7z", SEARCH)
MediaTypes.register("archive.zip", ".zip", SEARCH)
MediaTypes.register("archive.zip", ".zipx", SEARCH)
# Audio --------------------------------------------------------------------
MediaTypes.add_name_aliases("audio", "Audio")
MediaTypes.register("audio", ".aac", SEARCH)
MediaTypes.register("audio", ".aifc", SEARCH)
MediaTypes.register("audio", ".alac", SEARCH)
MediaTypes.register("audio", ".caf", SEARCH)
MediaTypes.register("audio", ".flac", SEARCH)
MediaTypes.register("audio", ".m4a", SEARCH)
MediaTypes.register("audio", ".m4p", SEARCH)
MediaTypes.register("audio", ".m4r", SEARCH)
MediaTypes.register("audio", ".mp3", SEARCH)
MediaTypes.register("audio", ".ogg", SEARCH)
MediaTypes.register("audio", ".wma", SEARCH)
MediaTypes.register("audio", [".aif", ".aiff"], SEARCH)
MediaTypes.register("audio", [".wav", ".wave"], SEARCH)
# MIDI
MediaTypes.add_name_aliases("audio.midi", ["MIDI", "General MIDI"])
MediaTypes.register("audio.midi", [".mid", ".midi"], SEARCH)
# Binary -------------------------------------------------------------------
MediaTypes.add_name_aliases("binary", "Binary")
MediaTypes.register("binary", ".aab", SEARCH)
MediaTypes.register("binary", ".dll", SEARCH)
MediaTypes.register("binary", ".dylib", SEARCH)
MediaTypes.register("binary", ".exe", SEARCH)
MediaTypes.register("binary", ".o", SEARCH)
MediaTypes.register("binary", ".pyc", SEARCH)
MediaTypes.register("binary", ".pyd", SEARCH)
MediaTypes.register("binary", ".pyo", SEARCH)
# Databases ----------------------------------------------------------------
MediaTypes.add_name_aliases("database", ["Database", "DB"])
MediaTypes.register("database", ".db", SEARCH)
MediaTypes.register("database", ".pdb", SEARCH)
MediaTypes.register("database", ".sqlite", SEARCH)
MediaTypes.register("database", ".sqlite3", SEARCH)
MediaTypes.register("database", ".wdb", SEARCH)
# Documents ----------------------------------------------------------------
MediaTypes.add_name_aliases("document", ["Document", "Text Document", "Word Processor"])
# Disk Images --------------------------------------------------------------
MediaTypes.add_name_aliases("disk_image", ["Disk Image", "Disc Image"])
MediaTypes.register("disk_image", ".bios", SEARCH)
MediaTypes.register("disk_image", ".dmg", SEARCH)
MediaTypes.register("disk_image", ".fhdx", SEARCH)
MediaTypes.register("disk_image", ".iso", SEARCH)
MediaTypes.register("disk_image", ".udf", SEARCH)
# eBooks & Comics ----------------------------------------------------------
MediaTypes.add_name_aliases("ebook", "eBook")
MediaTypes.register("ebook", ".azw", SEARCH)
MediaTypes.register("ebook", ".azw3", SEARCH)
MediaTypes.register("ebook", ".djvu", SEARCH)
MediaTypes.register("ebook", ".epub", SEARCH)
MediaTypes.register("ebook", ".fb2", SEARCH)
MediaTypes.register("ebook", ".ibook", SEARCH) # Also under "apple.books"
MediaTypes.register("ebook", ".kfx", SEARCH)
MediaTypes.register("ebook", ".lit", SEARCH)
MediaTypes.register("ebook", ".mobi", SEARCH)
MediaTypes.register("ebook", ".prc", SEARCH)
# Comic Book Archives
MediaTypes.add_name_aliases("ebook.comic", ["Comic Archive", "Comic Book Archive", "Comic"])
MediaTypes.register("ebook.comic", ".cb7", SEARCH)
MediaTypes.register("ebook.comic", ".cba", SEARCH)
MediaTypes.register("ebook.comic", ".cbr", SEARCH)
MediaTypes.register("ebook.comic", ".cbt", SEARCH)
MediaTypes.register("ebook.comic", ".cbz", SEARCH)
# Fonts --------------------------------------------------------------------
MediaTypes.add_name_aliases("font", "Font")
MediaTypes.register("font", ".fon", SEARCH)
MediaTypes.register("font", ".otf", SEARCH)
MediaTypes.register("font", ".ttc", SEARCH)
MediaTypes.register("font", ".ttf", SEARCH)
MediaTypes.register("font", ".woff", SEARCH)
MediaTypes.register("font", ".woff2", SEARCH)
# Images -------------------------------------------------------------------
MediaTypes.add_name_aliases("image", ["Image", "Photo", "Picture"])
# Raster Images
MediaTypes.add_name_aliases("image.raster", "Raster Image")
MediaTypes.register("image.raster", ".apng", SEARCH)
MediaTypes.register("image.raster", ".avif", SEARCH)
MediaTypes.register("image.raster", ".bmp", SEARCH)
MediaTypes.register("image.raster", ".exr", SEARCH)
MediaTypes.register("image.raster", ".gif", SEARCH)
MediaTypes.register("image.raster", ".jxl", SEARCH)
MediaTypes.register("image.raster", ".png", SEARCH)
MediaTypes.register("image.raster", ".webp", SEARCH)
MediaTypes.register("image.raster", [".heic", ".heif"], SEARCH)
MediaTypes.register("image.raster", [".j2k", ".jp2", ".jpg2"], SEARCH)
MediaTypes.register(
"image.raster",
[
".jfif",
".jpeg_large",
".jpeg",
".jpg_large",
".jpg",
],
SEARCH,
)
MediaTypes.register("image.raster", [".tif", ".tiff"], SEARCH)
# Icons
MediaTypes.add_name_aliases("image.raster.icon", "Icon")
MediaTypes.register("image.raster.icon", ".icns", SEARCH)
MediaTypes.register("image.raster.icon", ".ico", SEARCH)
MediaTypes.register("image.raster.icon", ".icon", SEARCH)
# Raw Images
MediaTypes.add_name_aliases("image.raster.raw", ["Digital Negative", "Raw Image", "Raw"])
MediaTypes.register("image.raster.raw", ".arw", SEARCH)
MediaTypes.register("image.raster.raw", ".cr2", SEARCH)
MediaTypes.register("image.raster.raw", ".cr3", SEARCH)
MediaTypes.register("image.raster.raw", ".crw", SEARCH)
MediaTypes.register("image.raster.raw", ".dng", SEARCH)
MediaTypes.register("image.raster.raw", ".nef", SEARCH)
MediaTypes.register("image.raster.raw", ".nrw", SEARCH)
MediaTypes.register("image.raster.raw", ".orf", SEARCH)
MediaTypes.register("image.raster.raw", ".r3d", SEARCH)
MediaTypes.register("image.raster.raw", ".raf", SEARCH)
MediaTypes.register("image.raster.raw", ".raw", SEARCH)
MediaTypes.register("image.raster.raw", ".rw2", SEARCH)
MediaTypes.register("image.raster.raw", ".srf", SEARCH)
MediaTypes.register("image.raster.raw", ".srf2", SEARCH)
# Vector Images
MediaTypes.add_name_aliases(
"image.vector",
[
"Scalable Vector Graphic",
"Scalable Vector",
"Vector Graphic",
"Vector Image",
"Vector",
],
)
MediaTypes.register("image.vector", ".eps", SEARCH)
MediaTypes.register("image.vector", ".epsf", SEARCH)
MediaTypes.register("image.vector", ".epsi", SEARCH)
MediaTypes.register("image.vector", ".svg", SEARCH)
MediaTypes.register("image.vector", ".svgz", SEARCH)
# Animated Images
MediaTypes.add_name_aliases("image.animated", ["Animated Image", "Animated"])
MediaTypes.register("image.animated", ".gif", SEARCH)
MediaTypes.register("image.animated", ".apng", SEARCH)
MediaTypes.register("image.animated", ".webp", SEARCH)
MediaTypes.register("image.animated", ".jxl", SEARCH)
# Presentations ------------------------------------------------------------
MediaTypes.add_name_aliases("presentation", ["Presentation", "Slide Show", "Slides"])
MediaTypes.register("presentation", ".fodp", SEARCH)
MediaTypes.register("presentation", ".key", SEARCH)
MediaTypes.register("presentation", ".odp", SEARCH)
MediaTypes.register("presentation", ".pot", SEARCH)
MediaTypes.register("presentation", ".potm", SEARCH)
MediaTypes.register("presentation", ".potx", SEARCH)
MediaTypes.register("presentation", ".ppam", SEARCH)
MediaTypes.register("presentation", ".pps", SEARCH)
MediaTypes.register("presentation", ".ppsm", SEARCH)
MediaTypes.register("presentation", ".ppsx", SEARCH)
MediaTypes.register("presentation", ".ppt", SEARCH)
MediaTypes.register("presentation", ".pptm", SEARCH)
MediaTypes.register("presentation", ".pptx", SEARCH)
# Programs, Installers, & Packages -----------------------------------------
MediaTypes.add_name_aliases("program", ["App", "Application", "Executable", "Program"])
MediaTypes.register("program", ".apk", SEARCH)
MediaTypes.register("program", ".apkm", SEARCH)
MediaTypes.register("program", ".apks", SEARCH)
MediaTypes.register("program", ".app", SEARCH)
MediaTypes.register("program", ".appx", SEARCH)
MediaTypes.register("program", ".bin", SEARCH)
MediaTypes.register("program", ".exe", SEARCH)
MediaTypes.register("program", ".msi", SEARCH)
MediaTypes.register("program", ".msix", SEARCH)
MediaTypes.register("program", ".pkg", SEARCH)
MediaTypes.register("program", ".xapk", SEARCH)
# Rich Text ----------------------------------------------------------------
MediaTypes.add_name_aliases("rich_text", ["Rich Text", "Rich Text Document"])
MediaTypes.register("rich_text", ".rtf", SEARCH)
# Shaders ------------------------------------------------------------------
MediaTypes.add_name_aliases("shader", "Shader")
MediaTypes.register("shader", ".effect", SEARCH)
MediaTypes.register("shader", ".frag", SEARCH)
MediaTypes.register("shader", ".fsh", SEARCH)
MediaTypes.register("shader", ".glsl", SEARCH)
MediaTypes.register("shader", ".shader", SEARCH)
MediaTypes.register("shader", ".vert", SEARCH)
MediaTypes.register("shader", ".vsh", SEARCH)
# Shell Script -------------------------------------------------------------
MediaTypes.add_name_aliases("shell", ["Shell Script", "Shell"])
MediaTypes.register("shell", ".bat", SEARCH)
MediaTypes.register("shell", ".csh", SEARCH)
MediaTypes.register("shell", ".fish", SEARCH)
MediaTypes.register("shell", ".nu", SEARCH)
MediaTypes.register("shell", ".ps1", SEARCH)
MediaTypes.register("shell", ".sh", SEARCH)
MediaTypes.register("shell", "activate", SEARCH)
# Shortcuts ----------------------------------------------------------------
MediaTypes.add_name_aliases("shortcut", "Shortcut")
MediaTypes.register("shortcut", ".desktop", SEARCH)
MediaTypes.register("shortcut", ".lnk", SEARCH)
MediaTypes.register("shortcut", ".url", SEARCH)
# Spreadsheets -------------------------------------------------------------
MediaTypes.add_name_aliases("spreadsheet", ["Spreadsheet", "Sheet"])
MediaTypes.register("spreadsheet", ".csv", SEARCH)
# Plaintext ----------------------------------------------------------------
# NOTE: If extensions here can be grouped or moved to more specific categories, do that.
# Something like a "Code" group may be considered, but that may be too subjective.
MediaTypes.add_name_aliases("plaintext", "Plaintext")
MediaTypes.register("plaintext", ".cfg", SEARCH)
MediaTypes.register("plaintext", ".conf", SEARCH)
MediaTypes.register("plaintext", ".config", SEARCH)
MediaTypes.register("plaintext", ".gitignore", SEARCH)
MediaTypes.register("plaintext", ".i3u", SEARCH)
MediaTypes.register("plaintext", ".lang", SEARCH)
MediaTypes.register("plaintext", ".lock", SEARCH)
MediaTypes.register("plaintext", ".log", SEARCH)
MediaTypes.register("plaintext", ".plist", SEARCH)
MediaTypes.register("plaintext", ".prefs", SEARCH)
MediaTypes.register("plaintext", ".spec", SEARCH)
MediaTypes.register("plaintext", ".theme", SEARCH)
MediaTypes.register("plaintext", ".timestamp", SEARCH)
MediaTypes.register("plaintext", "contributing", SEARCH)
MediaTypes.register("plaintext", "license", SEARCH)
MediaTypes.register("plaintext", "readme", SEARCH)
MediaTypes.register("plaintext", [".editorconfig", ".inf", ".ini"], SEARCH)
MediaTypes.register("plaintext", [".patch", ".diff"], SEARCH)
MediaTypes.register("plaintext", [".txt", ".text"], SEARCH)
MediaTypes.register("plaintext", ["pkginfo", ".pkginfo"], SEARCH)
# C
MediaTypes.add_name_aliases("plaintext.c", "C")
MediaTypes.register("plaintext.c", ".c", SEARCH)
MediaTypes.register("plaintext.c", ".h", SEARCH)
# C++
MediaTypes.add_name_aliases("plaintext.cpp", ["C++", "CPP"])
MediaTypes.register("plaintext.cpp", ".cpp", SEARCH)
MediaTypes.register("plaintext.cpp", ".h", SEARCH)
MediaTypes.register("plaintext.cpp", ".hpp", SEARCH)
# C#
MediaTypes.add_name_aliases("plaintext.csharp", ["C#", "C Sharp"])
MediaTypes.register("plaintext.csharp", ".cs", SEARCH)
# CSS
MediaTypes.add_name_aliases("plaintext.css", "CSS")
MediaTypes.register("plaintext.css", ".css", SEARCH)
MediaTypes.register("plaintext.css", ".less", SEARCH)
MediaTypes.register("plaintext.css", ".qss", SEARCH)
MediaTypes.register("plaintext.css", ".sass", SEARCH)
MediaTypes.register("plaintext.css", ".scss", SEARCH)
MediaTypes.register("plaintext.css", ".styl", SEARCH)
# D
MediaTypes.add_name_aliases("plaintext.d", "D")
MediaTypes.register("plaintext.d", ".d", SEARCH)
MediaTypes.register("plaintext.d", ".h", SEARCH)
# HTML
MediaTypes.add_name_aliases("plaintext.html", "HTML")
MediaTypes.register("plaintext.html", [".dhtml", ".htm", ".html", ".shtml", ".xhtml"], SEARCH)
# JavaScript
MediaTypes.chain_group("plaintext.javascript", "plaintext.typescript")
MediaTypes.add_name_aliases("plaintext.javascript", ["JavaScript", "JS"])
MediaTypes.register("plaintext.javascript", ".cjs", SEARCH)
MediaTypes.register("plaintext.javascript", ".js", SEARCH)
MediaTypes.register("plaintext.javascript", ".jsx", SEARCH)
MediaTypes.register("plaintext.javascript", ".mjs", SEARCH)
# JSON
MediaTypes.add_name_aliases("plaintext.json", "JSON")
MediaTypes.register("plaintext.json", [".json", ".json5", ".jsonc", ".jsonl"], SEARCH)
# Lua
MediaTypes.add_name_aliases("plaintext.lua", "Lua")
MediaTypes.register("plaintext.lua", ".lua", SEARCH)
# Markdown
MediaTypes.add_name_aliases("plaintext.markdown", ["Markdown", "MD"])
MediaTypes.register("plaintext.markdown", [".markdown", ".md", ".mkd", ".rmd"], SEARCH)
# Nix
MediaTypes.add_name_aliases("plaintext.nix", "Nix")
MediaTypes.register("plaintext.nix", ".nix", SEARCH)
# PHP
MediaTypes.add_name_aliases("plaintext.php", "PHP")
MediaTypes.register("plaintext.php", ".php", SEARCH)
# Qt
MediaTypes.add_name_aliases("plaintext.qt", "Qt")
MediaTypes.register("plaintext.qt", ".qml", SEARCH)
MediaTypes.register("plaintext.qt", ".qrc", SEARCH)
# Rust
MediaTypes.add_name_aliases("plaintext.rust", "Rust")
MediaTypes.register("plaintext.rust", ".rs", SEARCH)
# Tcl
MediaTypes.add_name_aliases("plaintext.tcl", "Tcl")
MediaTypes.register("plaintext.tcl", ".tcl", SEARCH)
# TOML
MediaTypes.add_name_aliases("plaintext.toml", "TOML")
MediaTypes.register("plaintext.toml", ".toml", SEARCH)
# TypeScript
MediaTypes.add_name_aliases("plaintext.typescript", "TypeScript")
MediaTypes.register("plaintext.typescript", ".cts", SEARCH)
MediaTypes.register("plaintext.typescript", ".ts", SEARCH)
MediaTypes.register("plaintext.typescript", ".mts", SEARCH)
MediaTypes.register("plaintext.typescript", ".tsx", SEARCH)
# XML
MediaTypes.add_name_aliases("plaintext.xml", "XML")
MediaTypes.register("plaintext.xml", [".xml", ".xul"], SEARCH)
# YAML
MediaTypes.add_name_aliases("plaintext.yaml", "YAML")
MediaTypes.register("plaintext.yaml", [".yaml", ".yml"], SEARCH)
# Python -------------------------------------------------------------------
MediaTypes.add_name_aliases("python", "Python")
MediaTypes.register("python", ".ipynb", SEARCH)
MediaTypes.register("python", ".py", SEARCH)
MediaTypes.register("python", ".pyc", SEARCH)
MediaTypes.register("python", ".pyd", SEARCH)
MediaTypes.register("python", ".pyi", SEARCH)
MediaTypes.register("python", ".pyo", SEARCH)
MediaTypes.register("python", ".sip", SEARCH)
# Typesetting --------------------------------------------------------------
MediaTypes.add_name_aliases("typesetting", ["Typesetting", "Typesetter"])
# TeX/LaTeX
MediaTypes.add_name_aliases("typesetting.latex", ["LaTeX", "TeX"])
MediaTypes.register("typesetting.latex", ".tex", SEARCH)
# Typst
MediaTypes.add_name_aliases("typesetting.typst", "Typst")
MediaTypes.register("typesetting.typst", ".typ", SEARCH)
# Video --------------------------------------------------------------------
MediaTypes.add_name_aliases("video", "Video")
MediaTypes.register("video", ".3gp", SEARCH)
MediaTypes.register("video", ".avi", SEARCH)
MediaTypes.register("video", ".flv", SEARCH)
MediaTypes.register("video", ".gifv", SEARCH)
MediaTypes.register("video", ".hevc", SEARCH)
MediaTypes.register("video", ".m4v", SEARCH)
MediaTypes.register("video", ".mkv", SEARCH)
MediaTypes.register("video", ".mov", SEARCH)
MediaTypes.register("video", ".mp4", SEARCH)
MediaTypes.register("video", ".webm", SEARCH)
MediaTypes.register("video", ".wmv", SEARCH)
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from typing import Any
class SanitizedAttr(type):
def __getattr__(cls, name: str) -> Any: # pyright: ignore[reportExplicitAny]
sanitized = name.replace(".", "_")
if sanitized == name:
raise AttributeError(f"'{type(cls).__name__}' object has no attribute '{name}'")
return getattr(cls, sanitized)
+1 -1
View File
@@ -1,5 +1,5 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from threading import Lock
+50
View File
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
# pyright: standard
from pathlib import Path
from PIL.Image import Image
from tagstudio.core.enums import Theme
RENDER = "RENDER" # MediaType Context
class BasePreview:
"""A base preview renderer class.
Attributes:
_fallback_icon (str): The name of the fallback icon resource to use, if needed.
media_type_name (str): Used for identifying the MediaType.
priority (int): Render priority over other Preview classes.
"""
_fallback_icon: str = ""
media_type_name: str
priority: int = 50
def __init__(self) -> None:
pass
@classmethod
def register_types(cls) -> None:
pass
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
raise NotImplementedError
@classmethod
def icon_name(cls) -> str:
"""Get the name of the fallback icon resource associated with this renderer."""
return cls._fallback_icon or cls.media_type_name
+52
View File
@@ -0,0 +1,52 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from PIL.Image import Image
from PIL.Image import new as new_image
from tagstudio.core.enums import Theme
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
# TODO: Split out Qt color palette stuff from anything needed by the core.
def apply_overlay_color(image: Image, color: UiColor, theme: Theme) -> Image:
"""Apply a color overlay effect to an image based on its color channel data.
Red channel for foreground, green channel for outline, none for background.
Args:
image (Image.Image): The image to apply an overlay to.
color (UiColor): The name of the ColorType color to use.
theme (Theme): A theme enum to determine the light/dark theme.
"""
bg_color: str = (
get_ui_color(ColorType.DARK_ACCENT, color)
if theme == Theme.DARK
else get_ui_color(ColorType.PRIMARY, color)
)
fg_color: str = (
get_ui_color(ColorType.PRIMARY, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
ol_color: str = (
get_ui_color(ColorType.BORDER, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
bg: Image = new_image(image.mode, image.size, color=bg_color)
fg: Image = new_image(image.mode, image.size, color=fg_color)
ol: Image = new_image(image.mode, image.size, color=ol_color)
bg.paste(fg, (0, 0), mask=image.getchannel(0))
bg.paste(ol, (0, 0), mask=image.getchannel(1))
if image.mode == "RGBA":
alpha_bg: Image = bg.copy()
alpha_bg.convert("RGBA")
alpha_bg.putalpha(0)
alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3))
bg = alpha_bg
return bg
+104 -233
View File
@@ -1,57 +1,37 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import contextlib
import hashlib
import importlib
import inspect
import math
import pkgutil
from copy import deepcopy
from pathlib import Path
import structlog
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.core.exceptions import NoRendererError
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.media_types import MediaTypeGroup, MediaTypes, slugify
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.effects import apply_overlay_color
from tagstudio.previews.gradients import four_corner_gradient
from tagstudio.previews.renderers.archive import (
apple_embedded_thumb,
archive_thumb,
krita_thumb,
open_doc_thumb,
powerpoint_thumb,
)
from tagstudio.previews.renderers.audio import audio_album_thumb, audio_waveform_thumb
from tagstudio.previews.renderers.blender import blender_thumb
from tagstudio.previews.renderers.clip_studio import clip_studio_thumb
from tagstudio.previews.renderers.ebook import epub_thumb
from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb
from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb
from tagstudio.previews.renderers.paint_dot_net import paint_dot_net_thumb
from tagstudio.previews.renderers.pdf import pdf_thumb
from tagstudio.previews.renderers.raster_image import (
exr_image_thumb,
raster_image_thumb,
raw_image_thumb,
)
from tagstudio.previews.renderers.source_engine import vtf_thumb
from tagstudio.previews.renderers.text import text_thumb
from tagstudio.previews.renderers.vector_image import vector_image_thumb
from tagstudio.previews.renderers.video import video_thumb
from tagstudio.qt.app_settings import (
DEFAULT_CACHED_THUMB_RES,
MAX_CACHED_THUMB_RES,
MIN_CACHED_THUMB_RES,
AppSettings,
Theme,
)
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor, get_ui_color
from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor
ImageFile.LOAD_TRUNCATED_IMAGES = True
Image.MAX_IMAGE_PIXELS = None
@@ -60,11 +40,55 @@ Image.MAX_IMAGE_PIXELS = None
logger = structlog.get_logger(__name__)
# TODO: Allow user-created preview renderers from an external directory.
def _get_preview_renderers() -> list[type[BasePreview]]:
"""Discover all BasePreview subclasses in src/tagstudio/previews/renderers.
Classes are sorted by their priority (descending), falling back to alphabetical order.
"""
found: list[type[BasePreview]] = []
from tagstudio.previews import renderers # pyright: ignore
for module_info in sorted(pkgutil.iter_modules(renderers.__path__), key=lambda m: m.name):
module = importlib.import_module(f"{renderers.__name__}.{module_info.name}")
for _, obj in inspect.getmembers(module, inspect.isclass):
if (
issubclass(obj, BasePreview)
and obj is not BasePreview
and obj.__module__ == module.__name__
):
obj.register_types()
found.append(obj)
break
found.sort(key=lambda cls: cls.priority, reverse=True)
return found
class FileRenderer:
"""A class for rendering image previews and thumbnails from files."""
rm: ResourceManager = ResourceManager()
cached_img_ext: str = ".webp"
_rm: ResourceManager = ResourceManager()
_cached_img_ext: str = ".webp"
_preview_renderers: list[type[BasePreview]] = _get_preview_renderers()
# Map of media group keys to preview renderer priorities.
_media_group_priorities: dict[str, int] = {}
for pr in _preview_renderers:
_media_group_priorities[pr.media_type_name] = pr.priority
# Map of media group name keys to preferred icons declared in preview renderers.
_media_group_icons: dict[str, str] = {}
for pr in _preview_renderers:
_media_group_icons[pr.media_type_name] = pr.icon_name()
for pr in _preview_renderers:
logger.info(
"[FileRenderer] Loaded Preview Renderer",
name=pr.__name__,
media_type=pr.media_type_name,
priority=pr.priority,
)
def __init__(self, library: Library, settings: AppSettings) -> None:
super().__init__()
@@ -89,26 +113,18 @@ class FileRenderer:
url (Path): The file url to assess. "$LOADING" will return the loading graphic.
"""
ext = url.suffix.lower()
types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True)
# Manual icon overrides.
if ext in {".gif", ".vtf"}:
return MediaType.IMAGE
elif ext in {".dll", ".pyc", ".o", ".dylib"}:
return MediaType.PROGRAM
elif ext in {".mscz"}: # noqa: SIM114
return MediaType.TEXT
# Loop though the specific (non-IANA) categories and return the string
# name of the first matching category found.
for cat in MediaCategories.ALL_CATEGORIES:
if not cat.is_iana and cat.media_type in types:
return cat.media_type.value
# If the type is broader (IANA registered) then search those types.
for cat in MediaCategories.ALL_CATEGORIES:
if cat.is_iana and cat.media_type in types:
return cat.media_type.value
groups = MediaTypes.find(ext, SEARCH) # Fallback icons use the SEARCH context
groups.sort( # Sort by priority and most specific dot-separated subgroup.
key=lambda g: (
g.key.count("."),
self._media_group_priorities.get(g.key, BasePreview.priority),
),
reverse=True,
)
for group in groups:
slug = slugify(self._media_group_icons.get(group.key, group.key))
if self._rm.get(slug, silent_fail=True):
return slug
return "file_generic"
@@ -354,10 +370,10 @@ class FileRenderer:
fg: Image.Image = Image.new("RGB", size=size, color="#00FF00")
# Get icon by name
icon = self.rm.get(name)
icon = self._rm.get(name)
assert isinstance(icon, Image.Image) or icon is None
if not icon:
icon = self.rm.file_generic
icon = self._rm.file_generic
# Resize icon to fit icon_ratio
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
@@ -373,7 +389,7 @@ class FileRenderer:
)
# Apply color overlay
im = self._apply_overlay_color(im, color, theme)
im = apply_overlay_color(im, color, theme)
return im
@@ -420,7 +436,7 @@ class FileRenderer:
color="#000000",
)
# Apply color overlay
bg = self._apply_overlay_color(im, color, theme)
bg = apply_overlay_color(im, color, theme)
# Paste background color with rounded rectangle mask onto blank image
im.paste(
@@ -440,10 +456,10 @@ class FileRenderer:
fg: Image.Image = Image.new("RGB", size=size, color=primary_color)
# Get icon by name
icon = self.rm.get(name)
icon = self._rm.get(name)
assert isinstance(icon, Image.Image)
if not icon:
icon = self.rm.file_generic
icon = self._rm.file_generic
# Resize icon to fit icon_ratio
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
@@ -457,48 +473,6 @@ class FileRenderer:
return im
def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) -> Image.Image:
"""Apply a color overlay effect to an image based on its color channel data.
Red channel for foreground, green channel for outline, none for background.
Args:
image (Image.Image): The image to apply an overlay to.
color (UiColor): The name of the ColorType color to use.
theme (Theme): A theme enum to determine the light/dark theme.
"""
bg_color: str = (
get_ui_color(ColorType.DARK_ACCENT, color)
if theme == Theme.DARK
else get_ui_color(ColorType.PRIMARY, color)
)
fg_color: str = (
get_ui_color(ColorType.PRIMARY, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
ol_color: str = (
get_ui_color(ColorType.BORDER, color)
if theme == Theme.DARK
else get_ui_color(ColorType.LIGHT_ACCENT, color)
)
bg: Image.Image = Image.new(image.mode, image.size, color=bg_color)
fg: Image.Image = Image.new(image.mode, image.size, color=fg_color)
ol: Image.Image = Image.new(image.mode, image.size, color=ol_color)
bg.paste(fg, (0, 0), mask=image.getchannel(0))
bg.paste(ol, (0, 0), mask=image.getchannel(1))
if image.mode == "RGBA":
alpha_bg: Image.Image = bg.copy()
alpha_bg.convert("RGBA")
alpha_bg.putalpha(0)
alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3))
bg = alpha_bg
return bg
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
def _apply_edge(
self,
@@ -596,7 +570,7 @@ class FileRenderer:
padding_factor = 18
im_ = im
icon: Image.Image = self.rm.ignored
icon: Image.Image = self._rm.ignored
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
im_.paste(
im=icon.resize(
@@ -634,7 +608,7 @@ class FileRenderer:
mod_time = str(filepath.stat().st_mtime_ns)
hashable_str: str = f"{str(filepath)}{mod_time}"
hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8)
file_name = Path(f"{hash_value}{FileRenderer.cached_img_ext}")
file_name = Path(f"{hash_value}{FileRenderer._cached_img_ext}")
image = fetch_cached_image(file_name)
if not image and self.settings.generate_thumbs:
@@ -654,7 +628,7 @@ class FileRenderer:
size=(thumb_res, thumb_res),
dpi_scale=1,
theme=theme,
is_thumb=is_thumb,
is_small=is_thumb,
cache_filename=file_name,
)
@@ -735,159 +709,56 @@ class FileRenderer:
size: tuple[int, int],
dpi_scale: float,
theme: Theme = Theme.DARK,
is_thumb: bool = False,
is_small: bool = False,
cache_filename: Path | None = None,
) -> Image.Image | None:
"""Render a thumbnail or preview image.
Args:
cache (CacheManager | None): A cache manager instance.
timestamp (float): The timestamp for which this job was dispatched.
filepath (str | Path): The path of the file to render a thumbnail for.
size (tuple[int, int]): The unmodified base size of the thumbnail.
dpi_scale (float): The screen pixel ratio.
theme (Theme): A theme enum to determine the light/dark theme.
is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants.
is_small (bool): Is this specifically a thumbnail? Use for specifying small variants.
cache_filename (Path | None): An optional filename to use to save to the cache.
"""
filepath = Path(filepath) if isinstance(filepath, str) else filepath
scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale)
image: Image.Image | None = None
filepath_: Path = Path(filepath)
is_savable_type: bool = True
if filepath_ and filepath_.is_file():
if filepath and filepath.is_file():
try:
ext: str = filepath_.suffix.lower() if filepath_.suffix else filepath_.stem.lower()
# eBooks ===========================================================================
if MediaCategories.is_ext_in_category(
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
):
image = epub_thumb(filepath_, ext)
# Krita ============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
):
image = krita_thumb(filepath_)
# Clip Studio Paint ================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES
):
image = clip_studio_thumb(filepath_)
# VTF ==============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
):
image = vtf_thumb(filepath_)
# Images ===========================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
):
# Raw Images -------------------------------------------------------------------
if MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
):
image = raw_image_thumb(filepath_)
# Vector Images ----------------------------------------------------------------
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
):
image = vector_image_thumb(filepath_, scaled_size)
# EXR Images -------------------------------------------------------------------
elif ext in [".exr"]:
image = exr_image_thumb(filepath_)
# Normal Images ----------------------------------------------------------------
else:
image = raster_image_thumb(filepath_)
# Videos ===========================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
):
image = video_thumb(filepath_)
# PowerPoint =======================================================================
elif ext in {".pptx"}:
image = powerpoint_thumb(filepath_)
# OpenDocument/OpenOffice ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
):
image = open_doc_thumb(filepath_)
# Apple iWork + Creator Studio =====================================================
elif (
MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES)
or ext == ".pxd"
):
image = apple_embedded_thumb(filepath_)
# Plain Text =======================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
):
image = text_thumb(filepath_)
# Fonts ============================================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.FONT_TYPES, mime_fallback=True
):
if is_thumb:
# Short (Aa) Preview
image = font_small_thumb(filepath_, scaled_size)
if image is not None:
image = self._apply_overlay_color(image, UiColor.BLUE, theme)
else:
# Large (Full Alphabet) Preview
image = font_full_preview(filepath_, scaled_size)
# Audio ========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
):
image = audio_album_thumb(filepath_, ext)
if image is None:
image = audio_waveform_thumb(filepath_, ext, scaled_size, dpi_scale)
is_savable_type = False
if image is not None:
image = self._apply_overlay_color(image, UiColor.GREEN, theme)
# Blender ======================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
):
image = blender_thumb(filepath_)
# PDF ==========================================================
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.PDF_TYPES, mime_fallback=True
):
image = pdf_thumb(filepath_, scaled_size, ext)
# Archives =====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
image = archive_thumb(filepath_, ext=ext)
# MDIPACK ======================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES):
image = medibang_paint_thumb(filepath_)
# Paint.NET ====================================================
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES):
image = paint_dot_net_thumb(filepath_)
# No Rendered Thumbnail ========================================
if not image:
raise NoRendererError
ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
for preview in FileRenderer._preview_renderers:
media_type: MediaTypeGroup | None = getattr(
MediaTypes, preview.media_type_name, None
)
if media_type is None:
logger.error(
f"[FileRenderer] "
f"Attribute '{preview.media_type_name}' not registered with MediaTypes",
)
break
if media_type.contains(ext, RENDER):
image = preview.render(
filepath=filepath,
is_small=is_small,
theme=theme,
size=(scaled_size, scaled_size),
dpi_scale=dpi_scale,
)
break
if image:
image = self._resize_image(image, (scaled_size, scaled_size))
if cache_filename and is_savable_type and image and cache:
cache.save_image(image, cache_filename, mode="RGBA")
except (
AssertionError,
ChildProcessError,
DecompressionBombError,
UnidentifiedImageError,
ValueError,
) as e:
logger.error(
"[FileRenderer] Couldn't render thumbnail",
filepath=filepath,
error=type(e).__name__,
)
image = None
except NoRendererError:
except Exception as e:
logger.error("[FileRenderer] Couldn't render thumbnail", filepath=filepath, error=e)
image = None
return image
@@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL.Image import Image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.archive import archive_thumb
logger = structlog.get_logger(__name__)
class AppleEmbeddedPreview(BasePreview):
media_type_name = "apple.embedded"
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("apple.embedded", ".pxd", RENDER)
MediaTypes.register("apple.embedded", ".pages", RENDER)
MediaTypes.register("apple.embedded", ".numbers", RENDER)
MediaTypes.register("apple.embedded", ".key", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return cls.apple_embedded_thumb(filepath)
@classmethod
def apple_embedded_thumb(cls, filepath: Path) -> Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
return archive_thumb(filepath, cls.image_names)
+39 -40
View File
@@ -6,16 +6,18 @@ import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Literal
from typing import Literal, override
import py7zr
import py7zr.io
import rarfile
import structlog
from PIL import Image
from PIL.Image import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
@@ -23,6 +25,34 @@ logger = structlog.get_logger(__name__)
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
class ArchivePreview(BasePreview):
media_type_name = "archive"
@override
@classmethod
def register_types(cls) -> None:
# NOTE: Filetype equivalents (i.e. ".tar.gz" == ".tgz") are already declared internally.
MediaTypes.register("archive", ".7z", RENDER)
MediaTypes.register("archive", ".gz", RENDER)
MediaTypes.register("archive", ".rar", RENDER)
MediaTypes.register("archive", ".s7z", RENDER)
MediaTypes.register("archive", ".tar", RENDER)
MediaTypes.register("archive", ".zip", RENDER)
MediaTypes.register("archive", ".tar.gz", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return archive_thumb(filepath)
class SevenZipFile(py7zr.SevenZipFile):
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
@@ -60,7 +90,7 @@ class TarFile:
self.tar.__exit__(*args)
def open_archive(filepath: Path, ext: str = "") -> Archive:
def open_archive(filepath: Path) -> Archive:
"""Open an archive with its corresponding archiver.
Args:
@@ -70,6 +100,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive:
Returns:
Archive: The opened archive.
"""
ext = filepath.suffix.lower()
archiver: type[Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = SevenZipFile
@@ -80,7 +111,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive:
return archiver(filepath, "r")
def first_image_in_archive(archive: Archive) -> Image.Image | None:
def first_image_in_archive(archive: Archive) -> Image | None:
"""Find and extract the first renderable image in the archive.
Args:
@@ -91,7 +122,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None:
"""
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
ext = Path(file_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(image_data))
@@ -101,8 +132,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None:
def archive_thumb(
filepath: Path,
image_names: list[Path] | list[str] | None = None,
ext: str = "",
) -> Image.Image | None:
) -> Image | None:
"""Extract an embedded preview image from an archive.
Args:
@@ -114,7 +144,7 @@ def archive_thumb(
Image: The first image found in the archive.
"""
try:
with open_archive(filepath, ext) as archive:
with open_archive(filepath) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
return first_image_in_archive(archive)
@@ -131,34 +161,3 @@ def archive_thumb(
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return None
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
return archive_thumb(filepath, image_names)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names)
+156 -109
View File
@@ -1,149 +1,196 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import math
from io import BytesIO
from pathlib import Path
from typing import override
from warnings import catch_warnings
import numpy as np
import structlog
from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from PIL import ImageDraw
from PIL.Image import Image, Resampling
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.effects import apply_overlay_color
from tagstudio.previews.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
from tagstudio.qt.views.styles.palette import UiColor
logger = structlog.get_logger(__name__)
def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
class AudioPreview(BasePreview):
media_type_name = "audio"
priority = 70
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
"""
image: Image.Image | None = None
try:
if not filepath.is_file():
raise FileNotFoundError
@override
@classmethod
def register_types(cls) -> None:
# NOTE: Filetype equivalents (i.e. ".aif" == ".aif") are already declared internally.
MediaTypes.register("audio", ".aac", RENDER)
MediaTypes.register("audio", ".aif", RENDER)
MediaTypes.register("audio", ".aifc", RENDER)
MediaTypes.register("audio", ".caf", RENDER)
MediaTypes.register("audio", ".flac", RENDER)
MediaTypes.register("audio", ".m4a", RENDER)
MediaTypes.register("audio", ".m4p", RENDER)
MediaTypes.register("audio", ".m4r", RENDER)
MediaTypes.register("audio", ".mp3", RENDER)
MediaTypes.register("audio", ".ogg", RENDER)
MediaTypes.register("audio", ".wav", RENDER)
MediaTypes.register("audio", ".wma", RENDER)
artwork = None
if ext in [".mp3"]:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = Image.open(BytesIO(id3_covers[0].data))
elif ext in [".flac"]:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = Image.open(BytesIO(flac_covers[0].data))
elif ext in [".mp4", ".m4a", ".aac"]:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = Image.open(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError,
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb(
filepath, theme, size, dpi_scale
)
def audio_waveform_thumb(
filepath: Path, ext: str, size: int, pixel_ratio: float
) -> Image.Image | None:
"""Render a waveform image from an audio file.
@staticmethod
def audio_album_thumb(filepath: Path) -> Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
size (tuple[int,int]): The size of the thumbnail.
pixel_ratio (float): The screen pixel ratio.
"""
# BASE_SCALE used for drawing on a larger image and resampling down
# to provide an antialiased effect.
base_scale: int = 2
samples_per_bar: int = 3
size_scaled: int = size * base_scale
allow_small_min: bool = False
im: Image.Image | None = None
Args:
filepath (Path): The path of the file.
"""
image: Image | None = None
ext = filepath.suffix.lower()
try:
if not filepath.is_file():
raise FileNotFoundError
try:
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType]
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
artwork = None
if ext in {".mp3", ".aif", ".aiff"}:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = open_image(BytesIO(id3_covers[0].data))
elif ext in {".flac"}:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = open_image(BytesIO(flac_covers[0].data))
elif ext in {".mp4", ".m4a", ".aac", ".alac"}:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = open_image(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError,
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
@staticmethod
def audio_waveform_thumb(
filepath: Path, theme: Theme, size: tuple[int, int], dpi_scale: float
) -> Image | None:
"""Render a waveform image from an audio file.
for i in range(-1, len(data_indices)):
d = data[math.ceil(data_indices[i]) - 1]
if count < samples_per_bar:
count = count + 1
with catch_warnings(record=True):
if abs(d) > maximum_item:
maximum_item = int(abs(d))
else:
max_array.append(maximum_item)
Args:
filepath (Path): The path of the file.
theme (Theme): The system color theme.
size (int): The size of the thumbnail.
dpi_scale (float): The screen pixel ratio.
"""
# BASE_SCALE used for drawing on a larger image and resampling down
# to provide an antialiased effect.
base_scale: int = 2
samples_per_bar: int = 3
size_scaled: int = size[0] * base_scale # TODO: Allow for non-square sizes
allow_small_min: bool = False
im: Image | None = None
if maximum_item > highest_line:
highest_line = maximum_item
try:
bar_count: int = min(math.floor((size[0] // dpi_scale) / 5), 64)
audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[1:]) # pyright: ignore[reportUnknownVariableType]
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
maximum_item = 0
count = 1
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
line_ratio = max(highest_line / bar_height, 1)
for i in range(-1, len(data_indices)):
d = data[math.ceil(data_indices[i]) - 1]
if count < samples_per_bar:
count = count + 1
with catch_warnings(record=True):
if abs(d) > maximum_item:
maximum_item = int(abs(d))
else:
max_array.append(maximum_item)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
if maximum_item > highest_line:
highest_line = maximum_item
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
maximum_item = 0
count = 1
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
line_ratio = max(highest_line / bar_height, 1)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
im = new_image("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
current_x = current_x + line_width + bar_margin
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
im.resize((size, size), Image.Resampling.BILINEAR)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
return im
current_x = current_x + line_width + bar_margin
im.resize(size, Resampling.BILINEAR)
im = apply_overlay_color(im, UiColor.GREEN, theme)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
return im
+36 -12
View File
@@ -1,34 +1,58 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PIL.Image import Image
from PIL.Image import new as new_image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb
logger = structlog.get_logger(__name__)
def blender_thumb(filepath: Path) -> Image.Image | None:
class BlenderPreview(BasePreview):
media_type_name = "blender"
priority = 40
@override
@classmethod
def register_types(cls) -> None:
# NOTE: Filetype equivalents (i.e. ".blend1" == ".blend32") are already declared internally.
MediaTypes.register("blender", ".blend", RENDER)
MediaTypes.register("blender", ".blend1", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return _blender_thumb(filepath, theme)
def _blender_thumb(filepath: Path, theme: Theme) -> Image | None:
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
Args:
filepath (Path): The path of the file.
theme (Theme): The system color theme.
"""
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
im: Image.Image | None = None
bg_color: str = "#1e1e1e" if theme == Theme.DARK else "#FFFFFF"
im: Image | None = None
try:
if (blend_image := blend_thumb(str(filepath))) is not None:
bg = Image.new("RGB", blend_image.size, color=bg_color)
bg = new_image("RGB", blend_image.size, color=bg_color)
bg.paste(blend_image, mask=blend_image.getchannel(3))
im = bg
else:
@@ -5,14 +5,41 @@
import sqlite3
from io import BytesIO
from pathlib import Path
from typing import override
import structlog
from PIL import Image
from PIL.Image import Image
from PIL.Image import open as open_image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
class ClipStudioPaintPreview(BasePreview):
media_type_name = "clip_studio_paint"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("clip_studio_paint", ".clip", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return clip_studio_thumb(filepath)
def clip_studio_thumb(filepath: Path) -> Image | None:
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
Args:
@@ -21,7 +48,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None:
Returns:
Image: The embedded thumbnail, if extractable.
"""
im: Image.Image | None = None
im: Image | None = None
try:
with open(filepath, "rb") as f:
blob = f.read()
@@ -33,7 +60,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None:
conn.deserialize(blob[sqlite_index:])
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
if thumbnail:
im = Image.open(BytesIO(thumbnail[0]))
im = open_image(BytesIO(thumbnail[0]))
conn.close()
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
+240
View File
@@ -0,0 +1,240 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL.Image import Image
from pygments.style import Style
from pygments.token import (
Comment,
Error,
Generic,
Keyword,
Literal,
Name,
Number,
Operator,
Other,
Punctuation,
String,
Text,
Whitespace,
)
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.text import text_thumb
logger = structlog.get_logger(__name__)
class CodePreview(BasePreview):
media_type_name = "code"
priority = 60
@override
@classmethod
def register_types(cls) -> None:
# NOTE: Filetype equivalents (i.e. ".ini" == ".inf") are already declared internally.
# CSS
MediaTypes.register("code", ".css", RENDER)
MediaTypes.register("code", ".less", RENDER)
MediaTypes.register("code", ".qss", RENDER)
MediaTypes.register("code", ".sass", RENDER)
MediaTypes.register("code", ".scss", RENDER)
MediaTypes.register("code", ".styl", RENDER)
# C
MediaTypes.register("code", ".c", RENDER)
MediaTypes.register("code", ".h", RENDER)
# C++
MediaTypes.register("code", ".cpp", RENDER)
MediaTypes.register("code", ".hpp", RENDER)
# C#
MediaTypes.register("code", ".cs", RENDER)
# D
MediaTypes.register("code", ".d", RENDER)
# HTML
MediaTypes.register("code", ".html", RENDER)
# JavaScript
MediaTypes.register("code", ".cjs", RENDER)
MediaTypes.register("code", ".js", RENDER)
MediaTypes.register("code", ".jsx", RENDER)
MediaTypes.register("code", ".mjs", RENDER)
# JSON
MediaTypes.register("code", ".json", RENDER)
# Lua
MediaTypes.register("code", ".lua", RENDER)
# Markdown
MediaTypes.register("code", ".md", RENDER)
# Nix
MediaTypes.register("code", ".nix", RENDER)
# PHP
MediaTypes.register("code", ".php", RENDER)
# Qt
MediaTypes.register("code", ".qml", RENDER)
MediaTypes.register("code", ".qrc", RENDER)
# Rust
MediaTypes.register("code", ".rs", RENDER)
# TCL
MediaTypes.register("code", ".tcl", RENDER)
# Python
MediaTypes.register("code", ".ipynb", RENDER)
MediaTypes.register("code", ".py", RENDER)
MediaTypes.register("code", ".pyi", RENDER)
MediaTypes.register("code", ".sip", RENDER)
# Shaders
MediaTypes.register("code", ".effect", RENDER)
MediaTypes.register("code", ".frag", RENDER)
MediaTypes.register("code", ".fsh", RENDER)
MediaTypes.register("code", ".glsl", RENDER)
MediaTypes.register("code", ".shader", RENDER)
MediaTypes.register("code", ".vert", RENDER)
MediaTypes.register("code", ".vsh", RENDER)
# Shell Script
MediaTypes.register("code", ".bat", RENDER)
MediaTypes.register("code", ".csh", RENDER)
MediaTypes.register("code", ".fish", RENDER)
MediaTypes.register("code", ".nu", RENDER)
MediaTypes.register("code", ".ps1", RENDER)
MediaTypes.register("code", ".sh", RENDER)
MediaTypes.register("code", "activate", RENDER)
# Shortcuts
MediaTypes.register("code", ".desktop", RENDER)
MediaTypes.register("code", ".lnk", RENDER)
MediaTypes.register("code", ".url", RENDER)
# TOML
MediaTypes.register("code", ".ini", RENDER)
MediaTypes.register("code", ".toml", RENDER)
# TypeScript
MediaTypes.register("code", ".cts", RENDER)
MediaTypes.register("code", ".ts", RENDER)
MediaTypes.register("code", ".mts", RENDER)
MediaTypes.register("code", ".tsx", RENDER)
# Valve Source Engine
MediaTypes.register("code", ".fgd", RENDER)
MediaTypes.register("code", ".gi", RENDER)
MediaTypes.register("code", ".kv3", RENDER)
MediaTypes.register("code", ".nut", RENDER)
MediaTypes.register("code", ".vcfg", RENDER)
MediaTypes.register("code", ".vdf", RENDER)
MediaTypes.register("code", ".vqlayout", RENDER)
MediaTypes.register("code", ".vsc", RENDER)
MediaTypes.register("code", ".vsnd_template", RENDER)
# XML
MediaTypes.register("code", ".xml", RENDER)
# YAML
MediaTypes.register("code", ".yaml", RENDER)
# Misc
MediaTypes.register("code", ".cfg", RENDER)
MediaTypes.register("code", ".conf", RENDER)
MediaTypes.register("code", ".config", RENDER)
MediaTypes.register("code", ".csv", RENDER)
MediaTypes.register("code", ".gitignore", RENDER)
MediaTypes.register("code", ".lock", RENDER)
MediaTypes.register("code", ".log", RENDER)
MediaTypes.register("code", ".meta", RENDER)
MediaTypes.register("code", ".patch", RENDER)
MediaTypes.register("code", ".pkginfo", RENDER)
MediaTypes.register("code", ".plist", RENDER)
MediaTypes.register("code", ".prefs", RENDER)
MediaTypes.register("code", ".spec", RENDER)
MediaTypes.register("code", ".tex", RENDER)
MediaTypes.register("code", ".theme", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return text_thumb(filepath, size, CodeStyle)
class CodeStyle(Style):
# TODO: Use different syntax highlighting for different filetypes.
background = "#111111"
foreground = "#f8f8f2"
selection = "#44475a"
comment = "#6272a4"
cyan = "#8be9fd"
green = "#50fa7b"
orange = "#ffb86c"
pink = "#ff79c6"
purple = "#bd93f9"
red = "#ff5555"
yellow = "#f1fa8c"
deletion = "#8b080b"
background_color = background
highlight_color = selection
line_number_color = yellow
line_number_background_color = selection
line_number_special_color = green
line_number_special_background_color = comment
styles = {
Whitespace: foreground,
Comment: comment,
Comment.Preproc: pink,
Generic: foreground,
Generic.Deleted: deletion,
Generic.Emph: "underline",
Generic.Heading: "bold",
Generic.Inserted: "bold",
Generic.Output: selection,
Generic.EmphStrong: "underline",
Generic.Subheading: "bold",
Error: foreground,
Keyword: pink,
Keyword.Constant: pink,
Keyword.Declaration: cyan + " italic",
Keyword.Type: cyan,
Literal: foreground,
Name: foreground,
Name.Attribute: green,
Name.Builtin: cyan + " italic",
Name.Builtin.Pseudo: foreground,
Name.Class: green,
Name.Function: green,
Name.Label: cyan + " italic",
Name.Tag: pink,
Name.Variable: cyan + " italic",
Number: orange,
Operator: pink,
Other: foreground,
Punctuation: foreground,
String: purple,
Text: foreground,
}
+49 -12
View File
@@ -5,20 +5,59 @@
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from typing import override
from xml.etree.ElementTree import Element
import structlog
from PIL import Image
from PIL.Image import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.previews.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
class EbookPreview(BasePreview):
media_type_name = "ebook"
priority = 40
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("ebook", ".azw", RENDER)
MediaTypes.register("ebook", ".azw3", RENDER)
MediaTypes.register("ebook", ".cb7", RENDER)
MediaTypes.register("ebook", ".cba", RENDER)
MediaTypes.register("ebook", ".cbr", RENDER)
MediaTypes.register("ebook", ".cbt", RENDER)
MediaTypes.register("ebook", ".cbz", RENDER)
MediaTypes.register("ebook", ".djvu", RENDER)
MediaTypes.register("ebook", ".epub", RENDER)
MediaTypes.register("ebook", ".fb2", RENDER)
MediaTypes.register("ebook", ".ibook", RENDER)
MediaTypes.register("ebook", ".kfx", RENDER)
MediaTypes.register("ebook", ".lit", RENDER)
MediaTypes.register("ebook", ".mobi", RENDER)
MediaTypes.register("ebook", ".prc", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return epub_thumb(filepath)
def epub_thumb(filepath: Path) -> Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
@@ -29,14 +68,14 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image | None = None
im: Image | None = None
try:
with open_archive(filepath, ext) as archive:
with open_archive(filepath) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = _cover_from_comic_info(archive, comic_info, "FrontCover")
im = cover_from_comic_info(archive, comic_info, "FrontCover")
if not im:
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
im = cover_from_comic_info(archive, comic_info, "InnerCover")
if not im:
im = first_image_in_archive(archive)
@@ -46,9 +85,7 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
return im
def _cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
@@ -59,14 +96,14 @@ def _cover_from_comic_info(
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
im: Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
ext = Path(page_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
if MediaTypes.image_raster.contains(ext, RENDER):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = image_from_bytes(BytesIO(image_data))
+65 -22
View File
@@ -4,37 +4,73 @@
import math
from pathlib import Path
from typing import cast
from typing import cast, override
import numpy as np
import structlog
from PIL import Image, ImageDraw, ImageFont
from PIL import ImageDraw, ImageFont
from PIL.Image import Image, Resampling, fromarray
from PIL.Image import new as new_image
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.effects import apply_overlay_color
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.palette import UiColor
logger = structlog.get_logger(__name__)
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
class FontPreview(BasePreview):
media_type_name = "font"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("font", ".otf", RENDER)
MediaTypes.register("font", ".ttc", RENDER)
MediaTypes.register("font", ".ttf", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return (
font_small_thumb(filepath, theme, size)
if is_small
else font_full_preview(filepath, size)
)
def font_small_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
theme (Theme): The system color theme.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
# TODO: Support for non-square images
im: Image | None = None
try:
bg = Image.new("RGB", (size, size), color="#000000")
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
bg = new_image("RGB", size, color="#000000")
raw = new_image("RGB", (size[0] * 3, size[1] * 3), color="#000000")
draw = ImageDraw.Draw(raw)
font = ImageFont.truetype(filepath, size=size)
font = ImageFont.truetype(filepath, size=size[0])
# NOTE: While a stroke effect is desired, the text
# method only allows for outer strokes, which looks
# a bit weird when rendering fonts.
draw.text(
(size // 8, size // 8),
(size[0] // 8, size[1] // 8),
"Aa",
font=font,
fill="#FF0000",
@@ -51,34 +87,37 @@ def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
row.argmax() : m - row[::-1].argmax(),
col.argmax() : n - col[::-1].argmax(),
]
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
cropped_im: Image = fromarray(cropped_data, "RGB")
margin: int = math.ceil(size // 16)
margin: int = math.ceil(size[0] // 16)
orig_x, orig_y = cropped_im.size
new_x, new_y = (size, size)
new_x, new_y = size
if orig_x > orig_y:
new_x = size
new_y = math.ceil(size * (orig_y / orig_x))
new_x = size[0]
new_y = math.ceil(size[1] * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = size
new_x = math.ceil(size * (orig_x / orig_y))
new_y = size[1]
new_x = math.ceil(size[0] * (orig_x / orig_y))
cropped_im = cropped_im.resize(
size=(new_x - (margin * 2), new_y - (margin * 2)),
resample=Image.Resampling.BILINEAR,
resample=Resampling.BILINEAR,
)
bg.paste(
cropped_im,
box=(margin, margin + ((size - new_y) // 2)),
box=(margin, margin + ((size[1] - new_y) // 2)),
)
im = bg
im = apply_overlay_color(im, UiColor.BLUE, theme)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
def font_full_preview(filepath: Path, size: tuple[int, int]) -> Image | None:
"""Render a large font preview ("Alphabet") thumbnail from a font file.
Args:
@@ -87,21 +126,25 @@ def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
"""
# Scale the sample font sizes to the preview image
# resolution,assuming the sizes are tuned for 256px.
im: Image.Image | None = None
im: Image | None = None
# TODO: Support for non-square images
try:
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
bg = Image.new("RGBA", (size, size), color="#00000000")
scaled_sizes: list[int] = [math.floor(x * (size[0] / 256)) for x in FONT_SAMPLE_SIZES]
bg = new_image("RGBA", size, color="#00000000")
draw = ImageDraw.Draw(bg)
lines_of_padding = 2
y_offset = 0.0
for font_size in scaled_sizes:
font = ImageFont.truetype(filepath, size=font_size)
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
text_wrapped: str = wrap_full_text(
FONT_SAMPLE_TEXT, font=font, width=size[0], draw=draw
)
draw.multiline_text((0, y_offset), text_wrapped, font=font)
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
# TODO: Separate from any Qt stuff
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
+44
View File
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL.Image import Image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.archive import archive_thumb
logger = structlog.get_logger(__name__)
class KritaPreview(BasePreview):
media_type_name = "krita"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("krita", ".kra", RENDER)
MediaTypes.register("krita", ".krz", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return krita_thumb(filepath)
def krita_thumb(filepath: Path) -> Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names)
@@ -7,16 +7,41 @@ import struct
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
from typing import override
import structlog
from PIL import Image
from PIL.Image import Image, frombytes
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
class MediBangPaintPreview(BasePreview):
media_type_name = "medibang_paint"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("medibang_paint", ".mdp", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return medibang_paint_thumb(filepath)
def medibang_paint_thumb(filepath: Path) -> Image | None:
"""Extract the thumbnail from a .mdp file.
Args:
@@ -25,7 +50,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
Returns:
Image: The embedded thumbnail.
"""
im: Image.Image | None = None
im: Image | None = None
try:
with open(filepath, "rb") as f:
magic = struct.unpack("<7sx", f.read(8))[0]
@@ -50,7 +75,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
if pac_header[2] == 1:
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
im = frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL.Image import Image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.archive import archive_thumb
logger = structlog.get_logger(__name__)
class OpenDocumentPreview(BasePreview):
media_type_name = "open_document"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("open_document", ".fodg", RENDER)
MediaTypes.register("open_document", ".fodp", RENDER)
MediaTypes.register("open_document", ".fods", RENDER)
MediaTypes.register("open_document", ".fodt", RENDER)
MediaTypes.register("open_document", ".mscz", RENDER)
MediaTypes.register("open_document", ".odf", RENDER)
MediaTypes.register("open_document", ".odg", RENDER)
MediaTypes.register("open_document", ".odp", RENDER)
MediaTypes.register("open_document", ".ods", RENDER)
MediaTypes.register("open_document", ".odt", RENDER)
MediaTypes.register("open_document", ".ora", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return open_doc_thumb(filepath)
def open_doc_thumb(filepath: Path) -> Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names)
@@ -7,14 +7,42 @@ import struct
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from typing import override
import structlog
from PIL import Image
from PIL.Image import Image
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
class PaintDotNetPreview(BasePreview):
media_type_name = "paint_dot_net"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("paint_dot_net", ".pdn", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return paint_dot_net_thumb(filepath)
def paint_dot_net_thumb(filepath: Path) -> Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.
Args:
@@ -23,7 +51,7 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
im: Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
@@ -39,9 +67,9 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
im = open_image(BytesIO(decoded_png))
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg = new_image("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
+35 -7
View File
@@ -7,26 +7,53 @@
from io import BytesIO
from pathlib import Path
from typing import override
import structlog
from PIL import Image
from PIL.Image import Image
from PIL.Image import open as open_image
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.qt.views.styles.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
class PdfPreview(BasePreview):
media_type_name = "pdf"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("pdf", ".pdf", RENDER)
MediaTypes.register("pdf", ".ai", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return pdf_thumb(filepath, size)
def pdf_thumb(filepath: Path, size: tuple[int, int]) -> Image | None:
"""Render a thumbnail for a PDF or Adobe Illustrator file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
im: Image | None = None
file: QFile = QFile(filepath)
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
@@ -39,12 +66,13 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
# 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()
# TODO: Make compatible with non-square images
if ratio_hw >= 1:
page_size *= size / page_size.height()
page_size *= size[0] / page_size.height()
else:
page_size *= size / page_size.width()
page_size *= size[0] / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
scale_factor = 2.5
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
@@ -59,7 +87,7 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
try:
q_image.save(buffer, "PNG") # pyright: ignore
im = Image.open(BytesIO(buffer.buffer().data()))
im = open_image(BytesIO(buffer.buffer().data()))
finally:
buffer.close()
# Replace transparent pixels with white (otherwise Background defaults to transparent)
@@ -0,0 +1,44 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import structlog
from PIL.Image import Image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.renderers.archive import archive_thumb
logger = structlog.get_logger(__name__)
class PowerPointPreview(BasePreview):
_fallback_icon = "presentation"
media_type_name = "microsoft.office.powerpoint"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("microsoft.office.powerpoint", ".pptx", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return powerpoint_thumb(filepath)
def powerpoint_thumb(filepath: Path) -> Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names)
@@ -1,24 +1,25 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import os
from io import BytesIO
from pathlib import Path
from typing import override
import cv2
import numpy as np
import rawpy
import structlog
from PIL import Image, ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PIL import ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError, Image, fromarray
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
@@ -31,33 +32,73 @@ register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
def raster_image_thumb(filepath: Path) -> Image.Image | None:
class RasterImagePreview(BasePreview):
media_type_name = "image.raster"
@override
@classmethod
def register_types(cls) -> None:
# NOTE: Filetype equivalents (i.e. ".jpg" == ".jpeg") are already declared internally.
MediaTypes.register("image.raster", ".apng", RENDER)
MediaTypes.register("image.raster", ".avif", RENDER)
MediaTypes.register("image.raster", ".bmp", RENDER)
MediaTypes.register("image.raster", ".png", RENDER)
MediaTypes.register("image.raster", ".exr", RENDER)
MediaTypes.register("image.raster", ".gif", RENDER)
MediaTypes.register("image.raster", ".jxl", RENDER)
MediaTypes.register("image.raster", ".psd", RENDER)
MediaTypes.register("image.raster", ".webp", RENDER)
MediaTypes.register("image.raster", ".heif", RENDER)
MediaTypes.register("image.raster", ".jpg2", RENDER)
MediaTypes.register("image.raster", ".jpeg", RENDER)
MediaTypes.register("image.raster", ".tiff", RENDER)
MediaTypes.register("image.raster", ".icns", RENDER)
MediaTypes.register("image.raster", ".ico", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return raster_image_thumb(filepath)
def raster_image_thumb(filepath: Path) -> Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
im: Image | None = None
try:
if filepath.suffix.lower() == ".exr":
return exr_image_thumb(filepath)
with filepath.open("rb") as file:
im = image_from_bytes(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
DecompressionBombError,
FileNotFoundError,
NotImplementedError,
OSError,
UnidentifiedImageError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def exr_image_thumb(filepath: Path) -> Image.Image | None:
def exr_image_thumb(filepath: Path) -> Image | None:
"""Render a thumbnail for a EXR image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
im: Image | None = None
try:
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
@@ -69,11 +110,11 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None:
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
array = (array_gamma * 255).astype(np.uint8)
im = Image.fromarray(array, mode="RGBA")
im = fromarray(array, mode="RGBA")
# Paste solid background
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg = new_image("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
@@ -82,32 +123,7 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None:
return im
def raw_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawIOError,
LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_from_bytes(image_data: BytesIO) -> Image.Image:
def image_from_bytes(image_data: BytesIO) -> Image:
"""Load a raster image and add a background if it's transparent.
Args:
@@ -116,11 +132,11 @@ def image_from_bytes(image_data: BytesIO) -> Image.Image:
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
im: Image = open_image(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg = new_image("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pathlib import Path
from typing import override
import rawpy
import structlog
from PIL.Image import DecompressionBombError, Image, frombytes
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
class RawImagePreview(BasePreview):
media_type_name = "image.raster.raw"
priority = 60
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("image.raster.raw", ".arw", RENDER)
MediaTypes.register("image.raster.raw", ".cr2", RENDER)
MediaTypes.register("image.raster.raw", ".cr3", RENDER)
MediaTypes.register("image.raster.raw", ".crw", RENDER)
MediaTypes.register("image.raster.raw", ".dng", RENDER)
MediaTypes.register("image.raster.raw", ".nef", RENDER)
MediaTypes.register("image.raster.raw", ".nrw", RENDER)
MediaTypes.register("image.raster.raw", ".orf", RENDER)
MediaTypes.register("image.raster.raw", ".r3d", RENDER)
MediaTypes.register("image.raster.raw", ".raf", RENDER)
MediaTypes.register("image.raster.raw", ".raw", RENDER)
MediaTypes.register("image.raster.raw", ".rw2", RENDER)
MediaTypes.register("image.raster.raw", ".srf", RENDER)
MediaTypes.register("image.raster.raw", ".srf2", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return raw_image_thumb(filepath)
def raw_image_thumb(filepath: Path) -> Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawFileUnsupportedError,
LibRawIOError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
@@ -1,17 +1,45 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
# TODO: Remove this file from the project, turning it into an external plugin.
from pathlib import Path
from typing import override
import srctools
import structlog
from PIL import Image
from PIL.Image import Image
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def vtf_thumb(filepath: Path) -> Image.Image | None:
class SourceEnginePreview(BasePreview):
media_type_name = "source_engine"
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("source_engine", ".vtf", RENDER)
MediaTypes.register("code", ".vmt", RENDER) # Fallback
@classmethod
@override
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return vtf_thumb(filepath)
def vtf_thumb(filepath: Path) -> Image | None:
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
Uses the srctools library for reading VTF files.
@@ -19,7 +47,7 @@ def vtf_thumb(filepath: Path) -> Image.Image | None:
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
im: Image | None = None
try:
with open(filepath, "rb") as f:
vtf = srctools.VTF.read(f)
+142 -27
View File
@@ -1,55 +1,170 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import io
import textwrap
from math import ceil
from pathlib import Path
from typing import override
import cv2
import structlog
from PIL import Image, ImageDraw, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PIL import ImageFont, UnidentifiedImageError
from PIL.Image import DecompressionBombError, Image, Resampling
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from pygments import highlight
from pygments.formatters import ImageFormatter
from pygments.lexers import PythonLexer # pyright: ignore[reportUnknownVariableType]
from pygments.style import Style
from pygments.token import (
Comment,
Error,
Generic,
Keyword,
Literal,
Name,
Number,
Operator,
Other,
Punctuation,
String,
Text,
)
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.utils.encoding import detect_char_encoding
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def text_thumb(filepath: Path) -> Image.Image | None:
class TextPreview(BasePreview):
media_type_name = "plaintext"
font = ImageFont.load_default(20)
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("plaintext", ".i3u", RENDER)
MediaTypes.register("plaintext", "contributing", RENDER)
MediaTypes.register("plaintext", "license", RENDER)
MediaTypes.register("plaintext", "readme", RENDER)
MediaTypes.register("plaintext", [".txt", ".text"], RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return text_thumb(
filepath=filepath,
size=size,
style=TextDarkStyle if theme == Theme.DARK else TextLightStyle,
)
class TextLightStyle(Style):
background = "#FFFFFF"
foreground = "#000000"
background_color = background
styles = {
Generic: foreground + " bold",
Text: foreground + " bold",
Literal: foreground + " bold",
String: foreground + " bold",
}
class TextDarkStyle(Style):
background = "#111111"
foreground = "#FFFFFF"
background_color = background
styles = {
Generic: foreground,
Text: foreground,
Literal: foreground,
String: foreground,
Comment: foreground,
Error: foreground,
Keyword: foreground,
Name: foreground,
Number: foreground,
Operator: foreground,
Other: foreground,
Punctuation: foreground,
}
def text_thumb(
filepath: Path,
size: tuple[int, int],
style: type[Style],
) -> Image | None:
"""Render a thumbnail for a plaintext file.
Args:
filepath (Path): The path of the file.
size (str): The final size for the image.
style (str): The pygments style class to use.
"""
im: Image.Image | None = None
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
fg_color: str = (
"#FFFFFF"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#111111"
)
im: Image | None = None
try:
encoding = detect_char_encoding(filepath)
encoding: str = detect_char_encoding(filepath) or "utf-8"
with open(filepath, encoding=encoding) as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color=bg_color)
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, fill=fg_color)
im = bg
text = text_file.read(1024)
wrapped_text = "\n".join(
"\n".join(textwrap.wrap(line, width=40)) for line in text.splitlines()
)
# TODO: Get this path from the ResourceManager, when that can handle fonts.
font_path = str(
Path(__file__).parents[2] / "resources/fonts/JetBrainsMono/JetBrainsMono.ttf"
)
# logger.info(font_path)
image_bytes = highlight(
wrapped_text,
PythonLexer(),
ImageFormatter(
encoding=encoding,
font_name=font_path,
font_size=32,
line_numbers=False,
style=style,
image_pad=48,
),
)
im_text = open_image(io.BytesIO(image_bytes))
ratio_w = size[0] / im_text.width
im_text = Image.resize(
im_text,
(ceil(im_text.width * ratio_w), ceil(im_text.height * ratio_w)),
Resampling.BILINEAR,
)
bg = new_image("RGB", size, color=style.background_color)
Image.paste(bg, im_text, (0, 0))
return bg
except (
UnidentifiedImageError,
cv2.error,
DecompressionBombError,
UnicodeDecodeError,
OSError,
FileNotFoundError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
return im
@@ -6,29 +6,56 @@
from io import BytesIO
from pathlib import Path
from typing import override
import structlog
from PIL import (
Image,
UnidentifiedImageError,
)
from PIL import UnidentifiedImageError
from PIL.Image import Image
from PIL.Image import new as new_image
from PIL.Image import open as open_image
from PySide6.QtCore import QBuffer, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
logger = structlog.get_logger(__name__)
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
class VectorImagePreview(BasePreview):
media_type_name = "image.vector"
priority = 70
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("image.vector", ".svg", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return vector_image_thumb(filepath, size)
def vector_image_thumb(filepath: Path, size: tuple[int, int]) -> Image:
"""Render a thumbnail for a vector image, such as SVG.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
im: Image | None = None
# Create an image to draw the svg to and a painter to do the drawing
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
q_image: QImage = QImage(size[0], size[1], QImage.Format.Format_ARGB32)
q_image.fill("#1e1e1e")
# Create an svg renderer, then render to the painter
@@ -48,8 +75,8 @@ def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
# Load the image from the buffer
im = Image.new("RGB", (size, size), color="#1e1e1e")
im.paste(Image.open(BytesIO(buffer.data().data())))
im = new_image("RGB", size, color="#1e1e1e")
im.paste(open_image(BytesIO(buffer.data().data())))
im = im.convert(mode="RGB")
buffer.close()
+49 -7
View File
@@ -1,28 +1,65 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
import math
from pathlib import Path
from typing import override
import cv2
import structlog
from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PIL import UnidentifiedImageError
from PIL.Image import DecompressionBombError, Image, fromarray
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.video_tester import is_readable_video
logger = structlog.get_logger(__name__)
def video_thumb(filepath: Path) -> Image.Image | None:
class VideoPreview(BasePreview):
media_type_name = "video"
priority = 70
@override
@classmethod
def register_types(cls) -> None:
MediaTypes.register("video", ".3gp", RENDER)
MediaTypes.register("video", ".avi", RENDER)
MediaTypes.register("video", ".flv", RENDER)
MediaTypes.register("video", ".gifv", RENDER)
MediaTypes.register("video", ".hevc", RENDER)
MediaTypes.register("video", ".m4p", RENDER)
MediaTypes.register("video", ".m4v", RENDER)
MediaTypes.register("video", ".mkv", RENDER)
MediaTypes.register("video", ".mov", RENDER)
MediaTypes.register("video", ".mp4", RENDER)
MediaTypes.register("video", ".webm", RENDER)
MediaTypes.register("video", ".wmv", RENDER)
@override
@classmethod
def render(
cls,
filepath: Path,
is_small: bool,
theme: Theme,
size: tuple[int, int],
dpi_scale: float,
) -> Image | None:
return video_thumb(filepath)
def video_thumb(filepath: Path) -> Image | None:
"""Render a thumbnail for a video file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
im: Image | None = None
frame: MatLike | None = None
try:
if is_readable_video(filepath):
@@ -49,7 +86,12 @@ def video_thumb(filepath: Path) -> Image.Image | None:
break
if frame is not None:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
im = fromarray(frame)
except (
UnidentifiedImageError,
cv2.error,
DecompressionBombError,
OSError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
@@ -82,7 +82,10 @@ def classproperty(func):
AUDIO_FILE_EXT_ALIASES = {
"m4a": "mp4",
"m4r": "mp4",
"wave": "wav",
"aif": "aiff",
"aifc": "aiff",
}
WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"])
+2 -9
View File
@@ -4,7 +4,7 @@
import platform
from datetime import datetime
from enum import Enum, IntEnum, StrEnum
from enum import Enum, StrEnum
from pathlib import Path
from typing import override
@@ -12,7 +12,7 @@ import structlog
import toml
from pydantic import BaseModel, Field
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption, Theme
logger = structlog.get_logger(__name__)
@@ -32,13 +32,6 @@ MAX_CACHED_THUMB_RES = 1024 # Pixels
DEFAULT_CACHED_THUMB_RES = 256 # Pixels
class Theme(IntEnum):
DARK = 0
LIGHT = 1
SYSTEM = 2
DEFAULT = SYSTEM
class Splash(StrEnum):
DEFAULT = "default"
RANDOM = "random"
+9 -13
View File
@@ -12,13 +12,11 @@ import structlog
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import QSize
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.previews.video_tester import is_readable_video
from tagstudio.qt.mixed.file_attributes import FileAttributeData
from tagstudio.qt.utils.file_opener import open_file
@@ -45,7 +43,7 @@ class PreviewThumb(PreviewThumbView):
if filepath.is_dir():
pass
elif MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True):
elif MediaTypes.contains("image.raster.raw", ext, SEARCH):
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess()
@@ -58,7 +56,7 @@ class PreviewThumb(PreviewThumbView):
FileNotFoundError,
):
pass
elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True):
elif MediaTypes.contains("image.raster", ext, SEARCH):
try:
image = Image.open(str(filepath))
stats.width = image.width
@@ -70,7 +68,7 @@ class PreviewThumb(PreviewThumbView):
UnidentifiedImageError,
) as e:
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True):
elif MediaTypes.contains("image.vector", ext, SEARCH):
pass # TODO
return stats
@@ -118,9 +116,7 @@ class PreviewThumb(PreviewThumbView):
ext = filepath.suffix.lower()
# Video
if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video(
filepath
):
if MediaTypes.contains("video", ext, SEARCH) and is_readable_video(filepath):
size: QSize | None = None
try:
success, size = self.__get_video_res(str(filepath))
@@ -131,10 +127,10 @@ class PreviewThumb(PreviewThumbView):
return self._display_video(filepath, size)
# Audio
elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True):
elif MediaTypes.contains("audio", ext, SEARCH):
return self._display_audio(filepath)
# Animated Images
elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True):
elif MediaTypes.contains("image.animated", ext, SEARCH):
if (ret := self.__get_gif_data(filepath)) and (
stats := self._display_gif(ret[0], ret[1])
) is not None:
+3 -3
View File
@@ -64,8 +64,8 @@ class ProgressWidget(QWidget):
self.show()
r = CustomRunnable(lambda: iterator.run())
r.done.connect(
runnable = CustomRunnable(lambda: iterator.run())
runnable.done.connect(
lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks])
)
QThreadPool.globalInstance().start(r)
QThreadPool.globalInstance().start(runnable)
+3 -4
View File
@@ -18,7 +18,8 @@ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.utils.str_formatting import format_duration
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
@@ -178,9 +179,7 @@ class FileAttributes(QWidget):
try:
file_size = format_size(filepath.stat().st_size)
if MediaCategories.is_ext_in_category(
ext, MediaCategories.FONT_TYPES, mime_fallback=True
):
if MediaTypes.contains("font", ext, SEARCH):
font = ImageFont.truetype(filepath)
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
except (FileNotFoundError, OSError) as e:
+9 -7
View File
@@ -15,7 +15,8 @@ from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxL
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories, MediaType
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.platform_strings import open_file_str, trash_term
from tagstudio.i18n.translations import Translations
@@ -361,12 +362,11 @@ class ItemThumb(FlowWidget):
ext = filename.suffix.lower()
if ext and ext.startswith(".") is False:
ext = "." + ext
media_types: set[MediaType] = MediaCategories.get_types(ext)
if (
not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES)
not MediaTypes.contains("image.raster", ext, SEARCH)
or MediaTypes.contains("image.raster.raw", ext, SEARCH)
or MediaTypes.contains("image.vector", ext, SEARCH)
or MediaTypes.contains("adobe.photoshop", ext, SEARCH)
or ext
in [
".apng",
@@ -380,7 +380,9 @@ class ItemThumb(FlowWidget):
if ext or filename.stem:
self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper())
show_ext_badge = True
if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types:
if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains(
"audio", ext, SEARCH
):
show_count_badge = True
self.ext_badge.setHidden(not show_ext_badge)
+3 -3
View File
@@ -22,7 +22,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
from tagstudio.core.enums import Theme
from tagstudio.core.enums import ThemePalette
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.modal import Modal
@@ -56,9 +56,9 @@ class TagColorManager(QWidget):
self.root_layout.setContentsMargins(6, 6, 6, 6)
panel_bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
self.title_label = QLabel()
+12 -22
View File
@@ -48,7 +48,8 @@ from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
@@ -1371,29 +1372,18 @@ class QtDriver(DriverMixin, QObject):
map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100))
)
elif query_type == "mediatype":
single_word_completions = map(
lambda x: prefix + "mediatype:" + x.name,
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
single_word_completions_quoted = map(
lambda x: prefix + 'mediatype:"' + x.name + '"',
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
multi_word_completions = map(
lambda x: prefix + 'mediatype:"' + x.name + '"',
filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES),
)
all_completions = [
single_word_completions,
single_word_completions_quoted,
multi_word_completions,
]
completion_list = [j for i in all_completions for j in i]
completion_list = []
for group in MediaTypes.all_groups:
for alias in group.name_aliases:
if " " in alias:
completion_list.append(f'{prefix}mediatype:"{alias}"')
else:
completion_list.append(f"{prefix}mediatype:{alias}")
completion_list.append(f'{prefix}mediatype:"{alias}"')
elif query_type == "filetype":
extensions_list: set[str] = set()
for media_cat in MediaCategories.ALL_CATEGORIES:
extensions_list = extensions_list | media_cat.extensions
for group in MediaTypes.all_groups:
extensions_list |= group.context_sets.get(SEARCH, set())
completion_list = list(
map(lambda x: prefix + "filetype:" + x.replace(".", ""), extensions_list)
)
+4 -2
View File
@@ -58,11 +58,12 @@ class ResourceManager:
return RESOURCE_FOLDER / "resources" / resource_path
def get(self, id: str):
def get(self, id: str, silent_fail: bool = False):
"""Get a resource from the ResourceManager.
Args:
id (str): The name of the resource.
silent_fail (bool): Don't log if the resource can not be found.
Returns:
bytes: When the data is in byte format.
@@ -85,7 +86,8 @@ class ResourceManager:
if resource_path is None:
raise FileNotFoundError
except (FileNotFoundError, AttributeError) as e:
logger.error("[ResourceManager]: Could not find resource", id=id, error=e)
if not silent_fail:
logger.error("[ResourceManager]: Could not find resource", id=id, error=e)
return None
file_path = RESOURCE_FOLDER / "resources" / resource_path
+1 -1
View File
@@ -163,7 +163,7 @@
"mode": "pil",
"path": "qt/images/file_icons/spreadsheet.png"
},
"text": {
"plaintext": {
"mode": "pil",
"path": "qt/images/file_icons/text.png"
},
+22 -10
View File
@@ -4,6 +4,7 @@
import math
import time
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING, override
@@ -13,7 +14,6 @@ from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaType
from tagstudio.i18n.platform_strings import open_file_str, trash_term
from tagstudio.i18n.translations import Translations
from tagstudio.qt.mixed.file_attributes import FileAttributeData
@@ -30,6 +30,16 @@ logger = structlog.get_logger(__name__)
THUMB_SIZE_FACTOR = 2
class PreviewType(Enum):
"""Enum for which of the Inspector's stacked pages should be shown for a file."""
ANIMATED = auto()
AUDIO = auto()
IMAGE = auto()
TEXT = auto()
VIDEO = auto()
# TODO: Use newer MVC style guidelines
class PreviewThumbView(QWidget):
"""The Preview Panel Widget."""
@@ -200,8 +210,8 @@ class PreviewThumbView(QWidget):
if m:
m.setScaledSize(adj_size)
def __switch_preview(self, preview: MediaType | None) -> None:
if preview in [MediaType.AUDIO, MediaType.VIDEO]:
def __switch_preview(self, preview: PreviewType | None) -> None:
if preview in [PreviewType.AUDIO, PreviewType.VIDEO]:
self.__media_player.show()
self.__image_layout.setCurrentWidget(self.__media_player_page)
self.check_ffmpeg.emit(True) # noqa: FBT003
@@ -210,15 +220,17 @@ class PreviewThumbView(QWidget):
self.__media_player.hide()
self.check_ffmpeg.emit(False) # noqa: FBT003
if preview in [MediaType.IMAGE, MediaType.AUDIO]:
if preview in [PreviewType.IMAGE, PreviewType.AUDIO]:
self.__button_wrapper.show()
self.__image_layout.setCurrentWidget(
self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page
self.__preview_img_page
if preview == PreviewType.IMAGE
else self.__media_player_page
)
else:
self.__button_wrapper.hide()
if preview == MediaType.IMAGE_ANIMATED:
if preview == PreviewType.ANIMATED:
self.__preview_gif.show()
self.__image_layout.setCurrentWidget(self.__preview_gif_page)
else:
@@ -251,7 +263,7 @@ class PreviewThumbView(QWidget):
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
self.__should_render_on_resize = False
self.__switch_preview(MediaType.VIDEO)
self.__switch_preview(PreviewType.VIDEO)
self.__update_media_player(filepath)
stats = FileAttributeData()
@@ -270,7 +282,7 @@ class PreviewThumbView(QWidget):
return stats
def _display_audio(self, filepath: Path) -> FileAttributeData:
self.__switch_preview(MediaType.AUDIO)
self.__switch_preview(PreviewType.AUDIO)
self.__render_thumb(filepath)
self.__update_media_player(filepath)
return FileAttributeData()
@@ -300,7 +312,7 @@ class PreviewThumbView(QWidget):
return None
# The animation has more than 1 frame, continue displaying it as an animation
self.__switch_preview(MediaType.IMAGE_ANIMATED)
self.__switch_preview(PreviewType.ANIMATED)
self.resizeEvent(
QResizeEvent(
QSize(stats.width, stats.height),
@@ -315,7 +327,7 @@ class PreviewThumbView(QWidget):
def _display_image(self, filepath: Path):
"""Renders the given file as an image, no matter its media type."""
self.__switch_preview(MediaType.IMAGE)
self.__switch_preview(PreviewType.IMAGE)
self.__render_thumb(filepath)
def hide_preview(self) -> None:
+23 -23
View File
@@ -5,7 +5,7 @@
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QGuiApplication
from tagstudio.core.enums import Theme
from tagstudio.core.enums import ThemePalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.library.alchemy.models import Tag
from tagstudio.qt.views.styles.palette import (
@@ -55,14 +55,14 @@ def button_style() -> str:
"""Style used for common QPushButtons."""
return f"""
QPushButton{{
background-color: {Theme.COLOR_BG.value};
background-color: {ThemePalette.COLOR_BG.value};
border-radius: 6px;
font-weight: 500;
text-align: center;
padding: 0px 12px;
}}
QPushButton::hover{{
background-color: {Theme.COLOR_HOVER.value};
background-color: {ThemePalette.COLOR_HOVER.value};
border-style: solid;
border-width: 2px;
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
@@ -84,7 +84,7 @@ def button_style() -> str:
padding: 0px 8px;
}}
QPushButton::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
background-color: {ThemePalette.COLOR_DISABLED_BG.value};
}}
"""
@@ -92,9 +92,9 @@ def button_style() -> str:
def line_edit_style_main() -> str:
"""Style used for common QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -118,7 +118,7 @@ def line_edit_style_main() -> str:
padding: 0px 2px;
}}
QLineEdit::disabled{{
background-color: {Theme.COLOR_DISABLED_BG.value};
background-color: {ThemePalette.COLOR_DISABLED_BG.value};
}}
"""
@@ -259,10 +259,10 @@ def container_style() -> str:
border-radius: 4px;
}}
QWidget#fieldContainer::hover{{
background-color: {Theme.COLOR_HOVER.value};
background-color: {ThemePalette.COLOR_HOVER.value};
}}
QWidget#fieldContainer::pressed{{
background-color: {Theme.COLOR_PRESSED.value};
background-color: {ThemePalette.COLOR_PRESSED.value};
}}
"""
@@ -271,9 +271,9 @@ def form_content_style() -> str:
return f"""
QLabel{{
background-color: {
Theme.COLOR_BG.value
ThemePalette.COLOR_BG.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
};
border-radius: 3px;
font-weight: 500;
@@ -340,9 +340,9 @@ def list_button_style(
def properties_style() -> str:
"""Style used for small labels such as file properties."""
label_bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
else ThemePalette.COLOR_DARK_LABEL.value
)
return f"""
@@ -447,9 +447,9 @@ def title_line_edit_style() -> str:
def inset_container_style(object_name: str = "") -> str:
"""Used for darkened inset areas."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -464,9 +464,9 @@ def inset_container_style(object_name: str = "") -> str:
def autofill_scroll_top_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -482,9 +482,9 @@ def autofill_scroll_top_style(object_name: str = "") -> str:
def autofill_scroll_top_focus_style(object_name: str = "") -> str:
"""Used autofill lists positioned on top of line edits."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -502,9 +502,9 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str:
def autofill_line_edit_style() -> str:
"""Used for QLineEdits."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -525,9 +525,9 @@ def autofill_line_edit_style() -> str:
def autofill_line_edit_top_style() -> str:
"""Used for QLineEdits when there's a top autofill section present."""
bg_color = (
Theme.COLOR_BG_DARK.value
ThemePalette.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
else ThemePalette.COLOR_BG_LIGHT.value
)
return f"""
@@ -1,2 +0,0 @@
SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
SPDX-License-Identifier: OFL-1.1
+19 -1
View File
@@ -1,6 +1,9 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
# pyright: reportUnusedFunction=false
import sys
from collections.abc import Callable, Generator
@@ -13,6 +16,7 @@ from PySide6.QtWidgets import QScrollArea
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.fields import TextField
from tagstudio.core.media_types import MediaTypes
CWD = Path(__file__).parent
# this needs to be above `src` imports
@@ -148,11 +152,25 @@ def entry_full(library: Library):
@pytest.fixture(autouse=True)
def _init_qtbot(qtbot: QtBot): # pyright: ignore[reportUnusedFunction]
def _init_qtbot(qtbot: QtBot):
"""Ensures that a QtBot is initialized for all subsequent tests, regardless of order."""
return qtbot
@pytest.fixture(autouse=True)
def _reset_media_types():
"""Snapshot the MediaTypes state before each test, then restore it after."""
pre_snapshop = MediaTypes._snapshot()
yield
post_snapshop = MediaTypes._snapshot()
if pre_snapshop != post_snapshop:
MediaTypes._restore(pre_snapshop)
assert pre_snapshop == MediaTypes._snapshot(), "The MediaTypes state was not restored!"
@pytest.fixture
def qt_driver(library: Library, library_dir: Path):
class Args:
+85
View File
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from pytestqt.exceptions import pytest
from tagstudio.core.media_types import MediaTypes
def test_register_and_contains():
MediaTypes.register("zzztest.basic", ".zzzfoo", "SEARCH")
assert MediaTypes.contains("zzztest.basic", ".zzzfoo", "SEARCH")
assert not MediaTypes.contains("zzztest.basic", ".zzzfoo", "RENDER")
def test_additive_register():
MediaTypes.register("zzztest.additive", ".zzzfoo", "SEARCH")
MediaTypes.register("zzztest.additive", ".zzzbar", "SEARCH")
assert MediaTypes.contains("zzztest.additive", ".zzzfoo", "SEARCH")
assert MediaTypes.contains("zzztest.additive", ".zzzbar", "SEARCH")
def test_contains_missing_group_raises_error():
with pytest.raises(AttributeError, match=r"is not registered"):
MediaTypes.contains("zzztest.does_not_exist", ".zzzfoo", "SEARCH")
def test_dot_notation_chains_to_parents():
MediaTypes.register("zzztest.chain.parent.child", ".zzzchild", "SEARCH")
assert MediaTypes.contains("zzztest.chain.parent.child", ".zzzchild", "SEARCH")
assert MediaTypes.contains("zzztest.chain.parent", ".zzzchild", "SEARCH")
assert MediaTypes.contains("zzztest.chain", ".zzzchild", "SEARCH")
def test_explicit_chain_group():
MediaTypes.chain_group("zzztest.composite", ["zzztest.composite_child"])
MediaTypes.register("zzztest.composite_child", ".zzzcomposite", "SEARCH")
assert MediaTypes.contains("zzztest.composite", ".zzzcomposite", "SEARCH")
def test_equivalent_extensions():
MediaTypes.register("zzztest.equiv", [".zzzone", ".zzztwo"], "SEARCH")
assert MediaTypes.get_equivalent_exts(".zzzone") == {".zzzone", ".zzztwo"}
assert MediaTypes.get_equivalent_exts(".zzztwo") == {".zzzone", ".zzztwo"}
assert MediaTypes.contains("zzztest.equiv", ".zzzone", "SEARCH")
assert MediaTypes.contains("zzztest.equiv", ".zzztwo", "SEARCH")
def test_get_equivalent_exts_defaults_to_itself():
assert MediaTypes.get_equivalent_exts(".zzzunregistered") == {".zzzunregistered"}
def test_find():
MediaTypes.register("zzztest.find_a", ".zzzfind", "SEARCH")
MediaTypes.register("zzztest.find_b", ".zzzfind", "RENDER")
search_keys = {group.key for group in MediaTypes.find(".zzzfind", "SEARCH")}
render_keys = {group.key for group in MediaTypes.find(".zzzfind", "RENDER")}
assert "zzztest.find_a" in search_keys
assert "zzztest.find_a" not in render_keys
assert "zzztest.find_b" in render_keys
assert "zzztest.find_b" not in search_keys
def test_add_name_aliases_and_lookup():
MediaTypes.register("zzztest.alias_target", ".zzzalias", "SEARCH")
MediaTypes.add_name_aliases("zzztest.alias_target", ["ZZZ Test Group", "zzztest"])
assert MediaTypes.get_group_key_from_name("ZZZ Test Group") == "zzztest.alias_target"
assert MediaTypes.get_group_key_from_name("zzz test group", case_sensitive=False) == (
"zzztest.alias_target"
)
assert (
MediaTypes.get_group_key_from_name(
"zzztestGroup", case_sensitive=False, ignore_whitespace=True
)
== "zzztest.alias_target"
)
assert MediaTypes.get_group_key_from_name("Not A Real Alias") is None
+1 -1
View File
@@ -9,7 +9,7 @@ from unittest.mock import Mock
import pytest
from PySide6.QtCore import Qt
from tagstudio.qt.app_settings import Theme
from tagstudio.core.enums import Theme
@pytest.mark.parametrize(