mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-09-08 03:52:47 +02:00
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:
committed by
GitHub
parent
3e9296c1db
commit
658b7840c4
+11
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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,5 +1,5 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from threading import Lock
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"mode": "pil",
|
||||
"path": "qt/images/file_icons/spreadsheet.png"
|
||||
},
|
||||
"text": {
|
||||
"plaintext": {
|
||||
"mode": "pil",
|
||||
"path": "qt/images/file_icons/text.png"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
|
||||
SPDX-License-Identifier: OFL-1.1
|
||||
+19
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user