diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 93f54e611..bb2660509 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -5,7 +5,7 @@ on: [ push, pull_request ] jobs: pytest: name: Run tests - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout repo @@ -19,13 +19,13 @@ jobs: - name: Install system dependencies run: | - # dont run update, it is slow + # dont run update, it is slow # sudo apt-get update sudo apt-get install -y --no-install-recommends \ libxkbcommon-x11-0 \ x11-utils \ libyaml-dev \ - libegl1-mesa \ + libegl1 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ diff --git a/README.md b/README.md index 84b5992b4..9e6dc7e90 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks ### What Features Are You Planning on Adding? > [!IMPORTANT] -> See the [Planned Features](/docs/updates/planned_features.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features. +> See the [Roadmap](/docs/updates/roadmap.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features. Of the several features I have planned for the project, these are broken up into “priority” features and “future” features. Priority features were originally intended for the first public release, however are currently absent from the Alpha v9.x.x builds. diff --git a/docs/index.md b/docs/index.md index 9f8240b4c..16cefdec1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,6 @@ title: Home ![TagStudio Alpha](assets/github_header.png) - TagStudio is a photo & file organization application with an underlying system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
@@ -19,11 +18,11 @@ TagStudio is a photo & file organization application with an underlying system t ## Goals -- To achieve a portable, privacy-oriented, open, extensible, and feature-rich system of organizing and rediscovering files. -- To provide powerful methods for organization, notably the concept of tag composition, or “taggable tags”. -- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or otherwise requiring them to change their existing file structures and workflows. -- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. -- To make the darn thing look like nice, too. It’s 2024, not 1994. +- To achieve a portable, privacy-oriented, open, extensible, and feature-rich system of organizing and rediscovering files. +- To provide powerful methods for organization, notably the concept of tag composition, or “taggable tags”. +- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or otherwise requiring them to change their existing file structures and workflows. +- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries. +- To make the darn thing look like nice, too. It’s 2024, not 1994. ## Priorities @@ -32,16 +31,20 @@ TagStudio is a photo & file organization application with an underlying system t 3. **The application.** If nothing else, TagStudio the application serves as the first (and so far only) implementation for this system of metadata management. This has the responsibility of doing the idea justice and showing just what’s possible when it comes to user file management. 4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time. +## Feature Roadmap + +The [feature roadmap](updates/roadmap.md) lists all of the planned core features for TagStudio to be considered "feature complete" along with estimated release milestones. The development and testing of these features takes priority over all other requested or submitted features unless they are later added to this roadmap. This helps ensure that TagStudio eventually sees a full release and becomes more usable by more people more quickly. + ## Current Features -- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. -- Add metadata to your library entries, including: - - Name, Author, Artist (Single-Line Text Fields) - - Description, Notes (Multiline Text Fields) - - Tags, Meta Tags, Content Tags (Tag Boxes) -- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from. -- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `) -- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`. +- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location. +- Add metadata to your library entries, including: + - Name, Author, Artist (Single-Line Text Fields) + - Description, Notes (Multiline Text Fields) + - Tags, Meta Tags, Content Tags (Tag Boxes) +- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from. +- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: `) +- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`. ## Important Updates diff --git a/docs/updates/planned_features.md b/docs/updates/planned_features.md deleted file mode 100644 index cf6da56dc..000000000 --- a/docs/updates/planned_features.md +++ /dev/null @@ -1,59 +0,0 @@ -# Planned Features - -The following lists outline the planned major and minor features for TagStudio, in no particular order. - -# Major Features - -- [ ] [SQL Database Migration](../updates/db_migration.md) -- [ ] Multiple Directory Support -- [ ] [Tags Categories](../library/tag_categories.md) -- [ ] [Entry Groups](../library/entry_groups.md) -- [ ] [Tag Overrides](../library/tag_overrides.md) -- [ ] Tagging Panel - - [ ] Top Tags - - [ ] Recent Tags - - [ ] Tag Search - - [ ] Pinned Tags -- [ ] Configurable Default Fields (May be part of [Macros](../utilities/macro.md)) -- [ ] Deep File Extension Control -- [ ] Settings Menu -- [ ] Custom User Colors -- [ ] Search Engine Rework - - [ ] Boolean Search - - [ ] Tag Objects In Search - - [ ] Search For Fields - - [ ] Sortable Search Results -- [ ] Automatic Entry Relinking - - [ ] Detect Renames - - [ ] Detect Moves -- [ ] Thumbnail Caching -- [ ] User-Defined Fields -- [ ] Exportable Library/Tag Data - - [ ] Exportable Human-Readable Library - - [ ] Exportable/Importable Human-Readable “Tag Packs” - - [ ] Exportable/Importable Color Palettes -- [ ] Configurable Thumbnail Labels - - [ ] Toggle Extension Label - - [ ] Toggle File Size Label -- [ ] Configurable Thumbnail Tag Badges - - [ ] Customize tags that appear instead of just “Archive” and “Favorite” -- [ ] OCR Search - -## Minor Features - -- [ ] Deleting Tags -- [ ] Merging Tags -- [ ] Tag Icons -- [ ] Tag/Field Copy + Paste -- [ ] Collage UI -- [ ] Resizable Thumbnail Grid -- [ ] Draggable Files Outside The Program -- [ ] File Property Caching -- [ ] 3D Previews -- [ ] Audio Waveform Previews - - [ ] Toggle Between Waveform And Album Artwork -- [ ] PDF Previews -- [ ] SVG Previews -- [ ] Full Video Player -- [ ] Duration Properties For Video + Audio Files -- [ ] Optional Starter Tag Packs diff --git a/docs/updates/roadmap.md b/docs/updates/roadmap.md new file mode 100644 index 000000000..680f6a644 --- /dev/null +++ b/docs/updates/roadmap.md @@ -0,0 +1,267 @@ +# Roadmap + +This checklist details the current and remaining features required at a minimum for TagStudio to be considered “Feature Complete”. This list is _not_ a definitive list for additional feature requests and PRs as they come in, but rather an outline of my personal core feature set intended for TagStudio. + +## Priorities + +Features are broken up into the following priority levels, with nested priorities referencing their relative priority for the overall feature (i.e. A [LOW] priority feature can have a [HIGH] priority element but it otherwise still a [LOW] priority item overall): + +- [HIGH] - Core feature +- [MEDIUM] - Important but not necessary +- [LOW] - Just nice to have + +## Core Feature List + +- [ ] Tags [HIGH] + - [x] ID-based, not string based [HIGH] + - [x] Tag name [HIGH] + - [x] Tag alias list, aka alternate names [HIGH] + - [x] Tag shorthand (specific short alias for displaying) [HIGH] + - [x] Parent/Inheritance subtags [HIGH] + - [ ] Composition/HAS subtags [HIGH] + - [ ] Deleting Tags [HIGH] [#148](https://github.com/TagStudioDev/TagStudio/issues/148) + - [ ] Merging Tags [HIGH] [#12](https://github.com/TagStudioDev/TagStudio/issues/12) + - [ ] Tag Icons [HIGH] [#195](https://github.com/TagStudioDev/TagStudio/issues/195) + - [ ] Small Icons [HIGH] + - [ ] Large Icons for Profiles [MEDIUM] + - [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH] + - [ ] User Defined Icons [HIGH] + - [ ] Multiple Languages for Tag Strings [MEDIUM] + - [ ] User-defined tag colors [HIGH] [#264](https://github.com/TagStudioDev/TagStudio/issues/264) + - [ ] ID based, not string or hex [HIGH] + - [ ] Color name [HIGH] + - [ ] Color value (hex) [HIGH] + - [ ] Existing colors are now a set of base colors [HIGH] + - [ ] Editable [MEDIUM] + - [ ] Non-removable [HIGH] + - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] + - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] + - [ ] Title is tag name [HIGH] + - [ ] Title has tag color [MEDIUM] + - [ ] Tag marked as category does not display as a tag itself [HIGH] + - [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM] + - [ ] Per-file overrides of subtags [HIGH] +- [ ] Tag Packs [MEDIUM] [#3](https://github.com/TagStudioDev/TagStudio/issues/3) + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] + - [ ] Conflict resolution [HIGH] + - [ ] Color Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] +- [ ] Exportable Library Data [HIGH] [#47](https://github.com/TagStudioDev/TagStudio/issues/47) + - [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH] +- [ ] [Macros](../utilities/macro.md) [HIGH] + - [ ] Sharable Macros [MEDIUM] + - [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH] + - [ ] Exportable [HIGH] + - [ ] Importable [HIGH] + - [ ] Triggers [HIGH] + - [ ] On new file [HIGH] + - [ ] On library refresh [HIGH] + - [...] + - [ ] Actions [HIGH] + - [ ] Add tag(s) [HIGH] + - [ ] Add field(s) [HIGH] + - [ ] Set field content [HIGH] + - [ ] [...] +- [ ] Settings Menu [HIGH] + - [ ] Application Settings [HIGH] + - [ ] Stored in system user folder/designated folder [HIGH] + - [ ] Library Settings [HIGH] + - [ ] Stored in `.TagStudio` folder [HIGH] +- [ ] Multiple Root Directories per Library [HIGH] [#295](https://github.com/TagStudioDev/TagStudio/issues/295) +- [ ] [Entry groups](../library/entry_groups.md) [HIGH] + - [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH] + - [ ] Ability to number entries within group [HIGH] + - [ ] Ability to set sorting method for group [HIGH] + - [ ] Ability to set custom thumbnail for group [HIGH] + - [ ] Group is treated as entry with tags and metadata [HIGH] + - [ ] Nested groups [MEDIUM] +- [ ] Fields [HIGH] + - [x] Text Boxes [HIGH] + - [x] Text Lines [HIGH] + - [ ] Dates [HIGH] [#213](https://github.com/TagStudioDev/TagStudio/issues/213) + - [ ] GPS Location [LOW] + - [ ] Custom field names [HIGH] [#18](https://github.com/TagStudioDev/TagStudio/issues/18) +- [ ] Search engine [HIGH] [#325](https://github.com/TagStudioDev/TagStudio/issues/325) + - [ ] Boolean operators [HIGH] [#225](https://github.com/TagStudioDev/TagStudio/issues/225), [#314](https://github.com/TagStudioDev/TagStudio/issues/314) + - [ ] Tag objects + autocomplete [HIGH] [#476 (Autocomplete)](https://github.com/TagStudioDev/TagStudio/issues/476) + - [ ] Filename search [HIGH] + - [ ] Filetype search [HIGH] + - [ ] Field content search [HIGH] [#272](https://github.com/TagStudioDev/TagStudio/issues/272) + - [ ] HAS operator for composition tags [HIGH] + - [ ] OCR search [LOW] + - [ ] Fuzzy Search [LOW] [#400](https://github.com/TagStudioDev/TagStudio/issues/400) + - [ ] Sortable results [HIGH] [#68](https://github.com/TagStudioDev/TagStudio/issues/68) + - [ ] Sort by relevance [HIGH] + - [ ] Sort by date created [HIGH] + - [ ] Sort by date modified [HIGH] + - [ ] Sort by date taken (photos) [MEDIUM] + - [ ] Sort by file size [HIGH] + - [ ] Sort by file dimension (images/video) [LOW] +- [ ] Automatic Entry Relinking [HIGH] [#36](https://github.com/TagStudioDev/TagStudio/issues/36) + - [ ] Detect Renames [HIGH] + - [ ] Detect Moves [HIGH] + - [ ] Detect Deletions [HIGH] +- [ ] Image Collages [LOW] [#91](https://github.com/TagStudioDev/TagStudio/issues/91) + - [ ] UI [HIGH] +- [ ] Tagging Panel [HIGH] + - [ ] Top Tags [HIGH] + - [ ] Recent Tags [HIGH] + - [ ] Tag Search [HIGH] + - [ ] Pinned Tags [HIGH] +- [ ] Configurable Thumbnails [MEDIUM] + - [ ] Toggle File Extension Label [MEDIUM] + - [ ] Toggle Duration Label [MEDIUM] + - [ ] Custom Tag Badges [LOW] +- [ ] Thumbnails [HIGH] + - [ ] File Duration Label [HIGH] + - [ ] 3D Model Previews [LOW] + - [ ] STL Previews [HIGH] [#351](https://github.com/TagStudioDev/TagStudio/issues/351) +- [ ] File Preview Panel [HIGH] + - [ ] Video Playback [HIGH] + - [x] Play/Pause [HIGH] + - [x] Loop [HIGH] + - [x] Toggle Autoplay [MEDIUM] + - [ ] Volume Control [HIGH] + - [x] Toggle Mute [HIGH] + - [ ] Timeline scrubber [HIGH] + - [ ] Fullscreen [MEDIUM] + - [ ] Audio Playback [HIGH] [#450](https://github.com/TagStudioDev/TagStudio/issues/450) + - [ ] Play/Pause [HIGH] + - [ ] Loop [HIGH] + - [ ] Toggle Autoplay [MEDIUM] + - [ ] Volume Control [HIGH] + - [ ] Toggle Mute [HIGH] + - [x] Timeline scrubber [HIGH] + - [ ] Fullscreen [MEDIUM] +- [ ] Optimizations [HIGH] + - [ ] Thumbnail caching [HIGH] [#104](https://github.com/TagStudioDev/TagStudio/issues/104) + - [ ] File property indexes [HIGH] + +## Version Milestones + +These version milestones are rough estimations for when the previous core features will be added. For a more definitive idea for when features are coming, please reference the current GitHub [milestones](https://github.com/TagStudioDev/TagStudio/milestones). + +### 9.5 (Alpha) + +- [ ] SQL backend [HIGH] +- [ ] Multiple Root Directories per Library [HIGH] +- [ ] Tags [HIGH] + - [ ] Deleting Tags [HIGH] + - [ ] Merging Tags [HIGH] + - [ ] User-defined tag colors [HIGH] + - [ ] ID based, not string or hex [HIGH] + - [ ] Color name [HIGH] + - [ ] Color value (hex) [HIGH] + - [ ] Existing colors are now a set of base colors [HIGH] + - [ ] Editable [MEDIUM] + - [ ] Non-removable [HIGH] +- [ ] Search engine [HIGH] + - [ ] Boolean operators [HIGH] + - [ ] Tag objects + autocomplete [HIGH] + - [ ] Filename search [HIGH] + - [ ] Filetype search [HIGH] + - [ ] Field content search [HIGH] + - [ ] Sortable results [HIGH] + - [ ] Sort by relevance [HIGH] + - [ ] Sort by date created [HIGH] + - [ ] Sort by date modified [HIGH] + - [ ] Sort by date taken (photos) [MEDIUM] + - [ ] Sort by file size [HIGH] + - [ ] Sort by file dimension (images/video) [LOW] +- [ ] Settings Menu [HIGH] + - [ ] Application Settings [HIGH] + - [ ] Stored in system user folder/designated folder [HIGH] + - [ ] Library Settings [HIGH] + - [ ] Stored in `.TagStudio` folder [HIGH] +- [ ] Optimizations [HIGH] + - [ ] Thumbnail caching [HIGH] + +### 9.6 (Alpha) + +- [ ] Tags [HIGH] + - [ ] Composition/HAS subtags [HIGH] + - [ ] Tag Icons [HIGH] + - [ ] Small Icons [HIGH] + - [ ] Large Icons for Profiles [MEDIUM] + - [ ] Built-in Icon Packs (i.e. Boxicons) [HIGH] + - [ ] User Defined Icons [HIGH] + - [ ] Multiple Languages for Tag Strings [MEDIUM] + - [ ] [Tag Categories](../library/tag_categories.md) [HIGH] + - [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH] + - [ ] Title is tag name [HIGH] + - [ ] Title has tag color [MEDIUM] + - [ ] Tag marked as category does not display as a tag itself [HIGH] + - [ ] [Tag Overrides](../library/tag_overrides.md) [MEDIUM] + - [ ] Per-file overrides of subtags [HIGH] +- [ ] Fields [HIGH] + - [ ] Dates [HIGH] + - [ ] Custom field names [HIGH] + +### 9.7 (Alpha) + +- [ ] Configurable Thumbnails [MEDIUM] + - [ ] Toggle File Extension Label [MEDIUM] + - [ ] Toggle Duration Label [MEDIUM] + - [ ] Custom Tag Badges [LOW] +- [ ] Thumbnails [HIGH] + - [ ] File Duration Label [HIGH] +- [ ] [Entry groups](../library/entry_groups.md) [HIGH] + - [ ] Groups for files/entries where the same entry can be in multiple groups [HIGH] + - [ ] Ability to number entries within group [HIGH] + - [ ] Ability to set sorting method for group [HIGH] + - [ ] Ability to set custom thumbnail for group [HIGH] + - [ ] Group is treated as entry with tags and metadata [HIGH] + - [ ] Nested groups [MEDIUM] +- [ ] Tagging Panel [HIGH] + - [ ] Top Tags [HIGH] + - [ ] Recent Tags [HIGH] + - [ ] Tag Search [HIGH] + - [ ] Pinned Tags [HIGH] + +### 9.8 (Possible Beta) + +- [ ] Automatic Entry Relinking [HIGH] + - [ ] Detect Renames [HIGH] + - [ ] Detect Moves [HIGH] + - [ ] Detect Deletions [HIGH] +- [ ] [Macros](../utilities/macro.md) [HIGH] + - [ ] Sharable Macros [MEDIUM] + - [ ] Standard notation format (i.e. JSON) contacting macro instructions [HIGH] + - [ ] Exportable [HIGH] + - [ ] Importable [HIGH] + - [ ] Triggers [HIGH] + - [ ] On new file [HIGH] + - [ ] On library refresh [HIGH] + - [...] + - [ ] Actions [HIGH] + - [ ] Add tag(s) [HIGH] + - [ ] Add field(s) [HIGH] + - [ ] Set field content [HIGH] + - [ ] [...] + +### 9.9 (Possible Beta) + +- [ ] Tag Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] + - [ ] Conflict resolution [HIGH] + - [ ] Color Packs [MEDIUM] + - [ ] Human-readable (i.e. JSON) files containing tag data [HIGH] + - [ ] Importable [HIGH] + - [ ] Exportable [HIGH] +- [ ] Exportable Library Data [HIGH] + - [ ] Standard notation format (i.e. JSON) contacting all library data [HIGH] + +### 10.0 (Possible Beta/Full Release) + +- [ ] All remaining [HIGH] and optional [MEDIUM] features + +### Post 10.0 + +- [ ] Core Library/API +- [ ] Plugin Support diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py index bb1907009..04ea5e872 100644 --- a/tagstudio/src/core/media_types.py +++ b/tagstudio/src/core/media_types.py @@ -23,6 +23,7 @@ class MediaType(str, Enum): DATABASE: str = "database" DISK_IMAGE: str = "disk_image" DOCUMENT: str = "document" + EBOOK: str = "ebook" FONT: str = "font" IMAGE_ANIMATED: str = "image_animated" IMAGE_RAW: str = "image_raw" @@ -160,6 +161,25 @@ class MediaCategories: ".wpd", ".wps", } + _EBOOK_SET: set[str] = { + ".epub", + # ".azw", + # ".azw3", + # ".cb7", + # ".cba", + # ".cbr", + # ".cbt", + # ".cbz", + # ".djvu", + # ".fb2", + # ".ibook", + # ".inf", + # ".kfx", + # ".lit", + # ".mobi", + # ".pdb" + # ".prc", + } _FONT_SET: set[str] = { ".fon", ".otf", @@ -347,6 +367,11 @@ class MediaCategories: extensions=_DOCUMENT_SET, is_iana=False, ) + EBOOK_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.EBOOK, + extensions=_EBOOK_SET, + is_iana=False, + ) FONT_TYPES: MediaCategory = MediaCategory( media_type=MediaType.FONT, extensions=_FONT_SET, @@ -448,6 +473,7 @@ class MediaCategories: DATABASE_TYPES, DISK_IMAGE_TYPES, DOCUMENT_TYPES, + EBOOK_TYPES, FONT_TYPES, IMAGE_ANIMATED_TYPES, IMAGE_RAW_TYPES, diff --git a/tagstudio/src/qt/helpers/image_effects.py b/tagstudio/src/qt/helpers/image_effects.py new file mode 100644 index 000000000..139d5274b --- /dev/null +++ b/tagstudio/src/qt/helpers/image_effects.py @@ -0,0 +1,27 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import numpy as np +from PIL import Image + + +def replace_transparent_pixels( + img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255) +) -> Image.Image: + """Replace (copying/without mutating) all transparent pixels in an image with the color. + + Args: + img (Image.Image): + The source image + color (tuple[int, int, int, int]): + The color (RGBA, 0 to 255) which transparent pixels should be set to. + Defaults to white (255, 255, 255, 255) + + Returns: + Image.Image: + A copy of img with the pixels replaced. + """ + pixel_array = np.asarray(img.convert("RGBA")).copy() + pixel_array[pixel_array[:, :, 3] == 0] = color + return Image.fromarray(pixel_array) diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index 83eb1c579..7cac38fcd 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -5,6 +5,7 @@ import math import struct +import zipfile from copy import deepcopy from io import BytesIO from pathlib import Path @@ -28,8 +29,20 @@ from PIL.Image import DecompressionBombError from pillow_heif import register_avif_opener, register_heif_opener from pydub import exceptions -from PySide6.QtCore import QObject, QSize, Qt, Signal -from PySide6.QtGui import QGuiApplication, QPixmap +from PySide6.QtCore import ( + QBuffer, + QFile, + QFileDevice, + QIODeviceBase, + QObject, + QSize, + QSizeF, + Qt, + Signal, +) +from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap +from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions +from PySide6.QtSvg import QSvgRenderer from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT from src.core.media_types import MediaCategories, MediaType from src.core.palette import ColorType, UiColor, get_ui_color @@ -38,6 +51,7 @@ from src.qt.helpers.color_overlay import theme_fg_overlay from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.gradient import four_corner_gradient +from src.qt.helpers.image_effects import replace_transparent_pixels from src.qt.helpers.text_wrapper import wrap_full_text from src.qt.helpers.vendored.pydub.audio_segment import ( # type: ignore _AudioSegment as AudioSegment, @@ -603,6 +617,29 @@ def _source_engine(self, filepath: Path) -> Image.Image: logger.error("Couldn't render thumbnail", filepath=filepath, error=e) return im + def _epub_cover(self, filepath: Path) -> Image.Image: + """Extracts and returns the first image found in the ePub file at the given filepath. + + Args: + filepath (Path): The path to the ePub file. + + Returns: + Image: The first image found in the ePub file, or None by default. + """ + im: Image.Image = None + try: + with zipfile.ZipFile(filepath, "r") as zip_file: + for file_name in zip_file.namelist(): + if file_name.lower().endswith( + (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg") + ): + image_data = zip_file.read(file_name) + im = Image.open(BytesIO(image_data)) + except Exception as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + + return im + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: """Render a small font preview ("Aa") thumbnail from a font file. @@ -750,8 +787,33 @@ def _image_vector_thumb(self, filepath: Path, size: int) -> Image.Image: filepath (Path): The path of the file. size (tuple[int,int]): The size of the thumbnail. """ - # TODO: Implement. im: Image.Image = None + # Create an image to draw the svg to and a painter to do the drawing + image: QImage = QImage(size, size, QImage.Format.Format_ARGB32) + image.fill("#1e1e1e") + + # Create an svg renderer, then render to the painter + svg: QSvgRenderer = QSvgRenderer(str(filepath)) + + if not svg.isValid(): + raise UnidentifiedImageError + + painter: QPainter = QPainter(image) + svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) + svg.render(painter) + painter.end() + + # Write the image to a buffer as png + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + image.save(buffer, "PNG") + + # Load the image from the buffer + im = Image.new("RGB", (size, size), color="#1e1e1e") + im.paste(Image.open(BytesIO(buffer.data().data()))) + im = im.convert(mode="RGB") + + buffer.close() return im def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: @@ -786,6 +848,52 @@ def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: return im + def _pdf_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for a PDF file. + + filepath (Path): The path of the file. + size (int): The size of the icon. + """ + im: Image.Image = None + + file: QFile = QFile(filepath) + success: bool = file.open( + QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser + ) + if not success: + logger.error("Couldn't render thumbnail", filepath=filepath) + return im + document: QPdfDocument = QPdfDocument() + document.load(file) + # 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() + if ratio_hw >= 1: + page_size *= size / page_size.height() + else: + page_size *= size / page_size.width() + # Enlarge image for antialiasing + scale_factor = 2.5 + page_size *= scale_factor + # Render image with no anti-aliasing for speed + render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions() + render_options.setRenderFlags( + QPdfDocumentRenderOptions.RenderFlag.TextAliased + | QPdfDocumentRenderOptions.RenderFlag.ImageAliased + | QPdfDocumentRenderOptions.RenderFlag.PathAliased + ) + # Convert QImage to PIL Image + qimage: QImage = document.render(0, page_size.toSize(), render_options) + buffer: QBuffer = QBuffer() + buffer.open(QBuffer.OpenModeFlag.ReadWrite) + try: + qimage.save(buffer, "PNG") + im = Image.open(BytesIO(buffer.buffer().data())) + finally: + buffer.close() + # Replace transparent pixels with white (otherwise Background defaults to transparent) + return replace_transparent_pixels(im) + def _text_thumb(self, filepath: Path) -> Image.Image: """Render a thumbnail for a plaintext file. @@ -924,6 +1032,7 @@ def render( ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True ): image = self._image_raw_thumb(_filepath) + # Vector Images -------------------------------------------- elif MediaCategories.is_ext_in_category( ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True ): @@ -932,17 +1041,17 @@ def render( else: image = self._image_thumb(_filepath) # Videos ======================================================= - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ): image = self._video_thumb(_filepath) # Plain Text =================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True ): image = self._text_thumb(_filepath) # Fonts ======================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.FONT_TYPES, mime_fallback=True ): if is_grid_thumb: @@ -952,7 +1061,7 @@ def render( # Large (Full Alphabet) Preview image = self._font_long_thumb(_filepath, adj_size) # Audio ======================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.AUDIO_TYPES, mime_fallback=True ): image = self._audio_album_thumb(_filepath, ext) @@ -960,19 +1069,26 @@ def render( image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) if image is not None: image = self._apply_overlay_color(image, UiColor.GREEN) - - # Blender =========================================================== - if MediaCategories.is_ext_in_category( + # Ebooks ======================================================= + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.EBOOK_TYPES, mime_fallback=True + ): + image = self._epub_cover(_filepath) + # Blender ====================================================== + elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): image = self._blender(_filepath) - + # PDF ========================================================== + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.PDF_TYPES, mime_fallback=True + ): + image = self._pdf_thumb(_filepath, adj_size) # VTF ========================================================== - if MediaCategories.is_ext_in_category( + elif MediaCategories.is_ext_in_category( ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True ): image = self._source_engine(_filepath) - # No Rendered Thumbnail ======================================== if not _filepath.exists(): raise FileNotFoundError diff --git a/tagstudio/tests/fixtures/sample.epub b/tagstudio/tests/fixtures/sample.epub new file mode 100644 index 000000000..b625b67b2 Binary files /dev/null and b/tagstudio/tests/fixtures/sample.epub differ diff --git a/tagstudio/tests/fixtures/sample.pdf b/tagstudio/tests/fixtures/sample.pdf new file mode 100644 index 000000000..0293578a2 Binary files /dev/null and b/tagstudio/tests/fixtures/sample.pdf differ diff --git a/tagstudio/tests/fixtures/sample.svg b/tagstudio/tests/fixtures/sample.svg new file mode 100644 index 000000000..99c924a86 --- /dev/null +++ b/tagstudio/tests/fixtures/sample.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_epub_preview.png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_epub_preview.png new file mode 100644 index 000000000..2b5a25815 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_epub_preview.png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png new file mode 100644 index 000000000..0ba9ea618 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_pdf_preview.png differ diff --git a/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_svg_preview.png b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_svg_preview.png new file mode 100644 index 000000000..ebd904314 Binary files /dev/null and b/tagstudio/tests/qt/__snapshots__/test_thumb_renderer/test_svg_preview.png differ diff --git a/tagstudio/tests/qt/test_thumb_renderer.py b/tagstudio/tests/qt/test_thumb_renderer.py new file mode 100644 index 000000000..f3679629d --- /dev/null +++ b/tagstudio/tests/qt/test_thumb_renderer.py @@ -0,0 +1,46 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import io +from pathlib import Path + +from PIL import Image +from src.qt.widgets.thumb_renderer import ThumbRenderer +from syrupy.extensions.image import PNGImageSnapshotExtension + + +def test_epub_preview(cwd, snapshot): + file_path: Path = cwd / "fixtures" / "sample.epub" + tr = ThumbRenderer() + img: Image.Image = tr._epub_cover(file_path) + + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + img_bytes.seek(0) + + assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) + + +def test_pdf_preview(cwd, snapshot): + file_path: Path = cwd / "fixtures" / "sample.pdf" + renderer = ThumbRenderer() + img: Image.Image = renderer._pdf_thumb(file_path, 200) + + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + img_bytes.seek(0) + + assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension) + + +def test_svg_preview(cwd, snapshot): + file_path: Path = cwd / "fixtures" / "sample.svg" + renderer = ThumbRenderer() + img: Image.Image = renderer._image_vector_thumb(file_path, 200) + + img_bytes = io.BytesIO() + img.save(img_bytes, format="PNG") + img_bytes.seek(0) + + assert img_bytes.read() == snapshot(extension_class=PNGImageSnapshotExtension)