mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-25 14:54:17 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd6e427e02 | |||
| 0c21c45cb4 | |||
| d2bf445a13 | |||
| 8995ef6caf | |||
| 068add29df |
+16
-6
@@ -117,7 +117,7 @@ Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/
|
||||
!!! tip "PR Scope"
|
||||
If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_
|
||||
|
||||
### :material-check-circle: Workflow Checks
|
||||
### Workflow Checks
|
||||
|
||||
When pushing your code, several automated [workflows](https://github.com/TagStudioDev/TagStudio/tree/main/.github/workflows) will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. These checks currently include:
|
||||
|
||||
@@ -126,7 +126,7 @@ When pushing your code, several automated [workflows](https://github.com/TagStud
|
||||
- [Pytest](developing.md#pytest) tests
|
||||
- REUSE [license compliance](#licenses)
|
||||
|
||||
## :material-timer-play: Runtime Requirements
|
||||
### Runtime Requirements
|
||||
|
||||
Code must function on all of the supported operating systems and versions:
|
||||
|
||||
@@ -134,8 +134,18 @@ Code must function on all of the supported operating systems and versions:
|
||||
- macOS 14.0+
|
||||
- Common Linux distributions and versions
|
||||
|
||||
Final submitted code must **_NOT:_**
|
||||
## :material-file-document: Documentation Guidelines
|
||||
|
||||
- Contain superfluous or unnecessary logging statements
|
||||
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
|
||||
- Cause undesirable visual glitches or artifacts on screen
|
||||
Documentation contributions include anything inside of the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/).
|
||||
|
||||
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
|
||||
- Follow the folder structure pattern
|
||||
- Don't add images or other media with excessively large file sizes
|
||||
- Provide alt text for embedded media
|
||||
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
|
||||
|
||||
## :material-translate: Translation Guidelines
|
||||
|
||||
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
|
||||
|
||||
_Translation guidelines coming soon._
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ From there, Git will automatically run through the hooks during commit actions!
|
||||
|
||||
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). Some reference `.envrc` files are provided in the repository at [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
|
||||
Two currently available are for [Nix](#nix-nixos) and [uv](#installing-with-uv), to use one:
|
||||
Two currently available are for [Nix](#nixos) and [uv](#installing-with-uv), to use one:
|
||||
|
||||
```sh
|
||||
ln -s .envrc-$variant .envrc
|
||||
|
||||
+88
-179
@@ -6,207 +6,116 @@ icon: material/sign-text
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! abstract "Prerequisite Reading"
|
||||
This guide assumes you've read the [Developing](developing.md) and [Contributing](contributing.md) pages first.
|
||||
|
||||
# :material-sign-text: Style Guide
|
||||
|
||||
## :material-script-text: General Principles
|
||||
## Formatting
|
||||
|
||||
- Write **clear**, **concise**, and **modular** code.
|
||||
- If the purpose of a peice of code is not obvious, a **short comment** should help explain it.
|
||||
- Remember to follow the rest of the [contribution guidelines](contributing.md)!
|
||||
Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._
|
||||
|
||||
---
|
||||
- Do your best to write clear, concise, and modular code.
|
||||
- This should include making methods private by default (e.g. `__method()`)
|
||||
- Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted
|
||||
- Keep a maximum column width of no more than **100** characters.
|
||||
- Code comments should be used to help describe sections of code that can't speak for themselves.
|
||||
- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add.
|
||||
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
|
||||
- Imports should be ordered alphabetically.
|
||||
- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
|
||||
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
|
||||
- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check.
|
||||
- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)!
|
||||
|
||||
## :material-text-box-check: Formatting
|
||||
### Modules & Implementations
|
||||
|
||||
Linting in Python files is mostly taken care of by [ruff](developing.md#ruff) and is based on the rules declared in `pyproject.toml` and `.editorconfig`.
|
||||
- **Do not** modify legacy library code in the `src/core/library/json/` directory
|
||||
- Avoid direct calls to `os`
|
||||
- Use `Pathlib` library instead of `os.path`
|
||||
- Use `platform.system()` instead of `os.name` and `sys.platform`
|
||||
- Don't prepend local imports with `tagstudio`, stick to `src`
|
||||
- Use the `logger` system instead of `print` statements
|
||||
- Avoid nested f-strings
|
||||
- Use HTML-like tags inside Qt widgets over stylesheets where possible
|
||||
|
||||
Final submitted code must **_NOT:_**
|
||||
|
||||
- Contain superfluous or unnecessary logging statements
|
||||
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
|
||||
- Cause undesirable visual glitches or artifacts on screen
|
||||
|
||||
### Formatter Configs
|
||||
|
||||
TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](https://github.com/TagStudioDev/TagStudio/blob/main/.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](https://github.com/TagStudioDev/TagStudio/blob/main/.prettierrc.toml)) for formatting files other than .py files (Markdown, JSON, YAML, HTML, CSS, etc.). If editing these types of files it's recommended that you use a formatter that supports EditorConfig or has its settings matched to the EditorConfig and Prettier configs. Lastly, please pay attention to the `prettier-ignore` flags in present in some files if you are not using Prettier, as formatting these sections will break formatting used elsewhere such as the [MkDocs site](https://docs.tagstud.io/).
|
||||
|
||||
### :material-code-braces-box: Syntax Guidelines
|
||||
## Qt
|
||||
|
||||
- Python files should always follow the [**PEP 8**]() style guide conventions, unless specifically allowed otherwise.
|
||||
- The most notable exception in our project is the line length limit of **100** characters, which is enforced via ruff.
|
||||
- Internal Qt methods also use `camelCase` instead of `snake_case`, so overrides of those are commonly seen in the codebase.
|
||||
- Classes and attributes considered to be "[private](https://docs.python.org/3/tutorial/classes.html#private-variables)" should be prepended with a **single underscore** (e.g. `_internal_method()`).
|
||||
- If _functionally necessary_, an attribute name may be prepended with a double underscore to trigger "[name mangling](https://docs.python.org/3/reference/expressions.html#private-name-mangling)" (e.g. `__mangled_method()`).
|
||||
- Classes and methods should contain [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings _(this style is enforced via ruff)_.
|
||||
- Lists and JSON keys should be ordered by their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) unless otherwise specified or readily indicated.
|
||||
- Some files have some or all of their attributes sorted. Please respect any established patterns like these in files you modify.
|
||||
As of writing this section, the QT part of the code base is quite unstructured and the View and Controller parts are completely intermixed[^1]. This makes maintenance, fixes and general understanding of the code base quite challenging, because the interesting parts you are looking for are entangled in a bunch of repetitive UI setup code. To address this we are aiming to more strictly separate the view and controller aspects of the QT frontend.
|
||||
|
||||
---
|
||||
The general structure of the QT code base should look like this:
|
||||
|
||||
## :material-filter-cog: Modules & Systems
|
||||
|
||||
### :fontawesome-brands-python: Python Modules
|
||||
|
||||
- Use `Pathlib` library instead of `os.path`
|
||||
- Use `platform.system()` instead of `os.name` or `sys.platform`
|
||||
- Avoid nested f-strings
|
||||
|
||||
### :material-tag: TagStudio Systems
|
||||
|
||||
- Translation keys can be accessed via bracket notation (e.g. `Translations["translation_key"]`) or with the `Translations.format()` method when a value needs to be passed to a placeholder in the translation.
|
||||
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `GlobalSettings` instances.
|
||||
- Use HTML-like tags inside strings over explicit stylesheets where possible. The `Style` class provides several handy methods for formatting text with these.
|
||||
- Use the `format` method in the stylesheets class to format text headers.
|
||||
|
||||
---
|
||||
|
||||
## :material-folder-file: Project Layout
|
||||
|
||||
### :material-engine: Core <small>Backend</small>
|
||||
|
||||
Code that is integral to the core functionality of TagStudio and is UI-independent belongs under the `core/` directory. It's possible that some code that serves the UI can go here, as long as its purpose is to serve _any_ UI and is independent from Qt (e.g. file preview rendering).
|
||||
|
||||
```yaml title="Core Backend Directory Example"
|
||||
core/
|
||||
│
|
||||
├── library/ # The TagStudio library system
|
||||
│ │
|
||||
│ ├── alchemy/ # Current SQLite backend w/ SQLAlchemy ORM
|
||||
│ │
|
||||
│ ├── json/ # Read-only legacy JSON library system, kept for migrations
|
||||
│ │
|
||||
│ ├── query_lang/ # The query parser
|
||||
│ │
|
||||
│ │ # Library files that do not involve the SQLAlchemy ORM
|
||||
│ │ # NOTE: Future Non-SQLAlchemy library files will be placed here
|
||||
│ ├── refresh.py
|
||||
│ └── ...
|
||||
│
|
||||
└── utils/ # Utility classes and functions for the core
|
||||
```
|
||||
qt
|
||||
├── controllers
|
||||
│ ├── widgets
|
||||
│ │ └── preview_panel_controller.py
|
||||
│ └── main_window_controller.py
|
||||
├── views
|
||||
│ ├── widgets
|
||||
│ │ └── preview_panel_view.py
|
||||
│ └── main_window_view.py
|
||||
├── ts_qt.py
|
||||
└── mixed.py
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! danger "Read-Only Legacy Code"
|
||||
**Do not modify** legacy library code in the `src/core/library/json/` directory!
|
||||
In this structure there are the `views` and `controllers` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
|
||||
|
||||
---
|
||||
Typically the classes should look like this:
|
||||
|
||||
### :material-button-cursor: App UI <small>Frontend</small>
|
||||
```py
|
||||
# my_cool_widget_view.py
|
||||
class MyCoolWidgetView(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.__button = QPushButton()
|
||||
self.__color_dropdown = QComboBox()
|
||||
# ...
|
||||
self.__connect_callbacks()
|
||||
|
||||
The application UI code is stored in the `qt/` directory, and contains all code specific to the Qt frontend. Qt widgets are built using an [MVC](https://www.geeksforgeeks.org/software-engineering/mvc-framework-introduction/) pattern, which is described in-depth below:
|
||||
def __connect_callbacks(self):
|
||||
self.__button.clicked.connect(self._button_click_callback)
|
||||
self.__color_dropdown.currentIndexChanged.connect(
|
||||
lambda idx: self._color_dropdown_callback(self.__color_dropdown.itemData(idx))
|
||||
)
|
||||
|
||||
#### MVC Pattern
|
||||
|
||||
- **Models** are usually just objects from the [library](#core-backend).
|
||||
- The **controller** interacts with these, the _view_ does **not**.
|
||||
- **Views** are Qt layout classes that **_only_** contain the **layout** and **styling** for one or more widgets.
|
||||
- Class names are appended with `View`, and filenames appended with `_view`.
|
||||
- Not to be used standalone, but as the layouts for one or more controllers.
|
||||
- Some logic is acceptable in these classes if it serves to modularize the layout and allows controllers to influence how the layout is initialized.
|
||||
- **Reusable Layouts**
|
||||
- If a layout class is **_not meant_** to act as a view but instead be a generic layout, it belongs in the `views/layouts/` directory and the file should be appended with `_layout`.
|
||||
- **Styling Classes**
|
||||
- If a class is purely a source of reusable styling, it belongs in the `views/styles/` directory.
|
||||
|
||||
- **Controllers** are complete widgets or base classes for complete widgets.
|
||||
- Controller files simply take on the name of the final widget they create.
|
||||
- This also creates naming parity with other widgets that simply extend existing Qt widgets with additional logic.
|
||||
|
||||
```yaml title="Qt Frontend Directory Example"
|
||||
qt/
|
||||
│
|
||||
├── controllers/ # Widgets implementing views or extending other widgets
|
||||
│ ├── tag_suggest_box.py # Extends from `suggest_box.py`
|
||||
│ ├── suggest_box.py # Implements `suggest_box_view.py`
|
||||
│ ├── main_window.py
|
||||
│ └── ...
|
||||
│
|
||||
├── mixed/ # Files yet to be refactored into controllers and views
|
||||
│
|
||||
├── views/ # Everything related to widget layouts and appearances
|
||||
│ │
|
||||
│ ├── layouts/ # Layouts meant to be reused on their own inside other layouts
|
||||
│ │ ├── flow_layout.py
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── styles/ # Classes specific for styling
|
||||
│ │ ├── palette.py
|
||||
│ │ ├── stylesheets.py
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ │ # Views (layouts) that get implemented by controllers (widgets)
|
||||
│ ├── main_window_view.py
|
||||
│ ├── suggest_box_view.py
|
||||
│ └── ...
|
||||
│
|
||||
│ # Frontend classes that aren't related to widgets, like managers
|
||||
├── resource_manager.py
|
||||
├── cache_manager.py
|
||||
├── ts_qt.py # Qt Driver
|
||||
└── ...
|
||||
def _button_click_callback(self):
|
||||
raise NotImplementedError()
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning "Pre-MVC UI Code"
|
||||
**Do not add** new files to the `qt/mixed/` directory! These files have yet to to be refactored per the current MVC style guidelines and the directory will be **removed** once those migrations have concluded.
|
||||
```py
|
||||
# my_cool_widget_controller.py
|
||||
class MyCoolWidget(MyCoolWidgetView):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
Observe the following key aspects of the example below:
|
||||
def _button_click_callback(self):
|
||||
print("Button was clicked!")
|
||||
|
||||
- The **view** extends from a Qt layout class, and the **controller** simply extends from QWidget.
|
||||
- The **controller** is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code.
|
||||
- The **view's** widgets that are intended to be controlled by the controller are **public**, while the **controller's** methods are largely **private**.
|
||||
def _color_dropdown_callback(self, color: Color):
|
||||
print(f"The selected color is now: {color}")
|
||||
```
|
||||
|
||||
Observe the following key aspects of this example:
|
||||
|
||||
- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code
|
||||
- The UI elements are in private variables
|
||||
- This enforces that the controller shouldn't directly access UI elements
|
||||
- Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc.
|
||||
- Instead of `_get_color()` there could also be a `_color` method marked with `@property`
|
||||
- The callback methods are already defined as protected methods with NotImplementedErrors
|
||||
- Defines the interface the callbacks
|
||||
- Enforces that UI events be handled
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! example "MVC-Separated Widget Example"
|
||||
!!! tip
|
||||
A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.
|
||||
|
||||
```py title="views/my_cool_widget_view.py"
|
||||
class MyCoolWidgetView(QVBoxLayout):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.button = QPushButton()
|
||||
self.color_dropdown = QComboBox()
|
||||
|
||||
self.addWidget(self.button)
|
||||
self.addWidget(self.color_dropdown)
|
||||
```
|
||||
|
||||
```py title="controllers/my_cool_widget.py"
|
||||
class MyCoolWidget(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setLayout(MyCoolWidgetView())
|
||||
self._connect_callbacks()
|
||||
|
||||
def _connect_callbacks(self):
|
||||
self.layout().button.clicked.connect(self._button_click_callback)
|
||||
self.layout().color_dropdown.currentIndexChanged.connect(
|
||||
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx)))
|
||||
|
||||
def _button_click_callback(self):
|
||||
print("Button was clicked!")
|
||||
|
||||
def _color_dropdown_callback(self, color: Color):
|
||||
print(f"The selected color is now: {color}")
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Tip for Logic Placement"
|
||||
A good rule of thumb is: If there's **conditional logic** after a widget has been created, it should probably go in a **controller**.
|
||||
|
||||
---
|
||||
|
||||
## :material-file-document: Documentation
|
||||
|
||||
Documentation contributions include anything inside the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). Some files such as the `CHANGELOG.md`, `CONTRIBUTING.md`, and `STYLE.md` are symlinked in the repo root from the `docs/` folder.
|
||||
|
||||
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
|
||||
- Follow the folder structure pattern
|
||||
- Don't add images or other media with excessively large file sizes
|
||||
- Provide alt text for embedded media
|
||||
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
|
||||
|
||||
---
|
||||
|
||||
## :material-translate: Translations
|
||||
|
||||
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
|
||||
|
||||
- Do not change text inside placeholders
|
||||
- Do not change the style tags inside translations
|
||||
- Use the glossary for term definitions
|
||||
[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ filterwarnings = [
|
||||
ignore = [
|
||||
".venv/**",
|
||||
"src/tagstudio/core/library/json/",
|
||||
"src/tagstudio/qt/previews/vendored/pydub/",
|
||||
"src/tagstudio/renderers/vendored/pydub/",
|
||||
]
|
||||
include = ["src/tagstudio", "tests"]
|
||||
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
@@ -117,7 +117,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["D", "E402"]
|
||||
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
|
||||
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
@@ -305,6 +305,7 @@ class MediaCategories:
|
||||
".nef",
|
||||
".nrw",
|
||||
".orf",
|
||||
".r3d",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.previews.vendored.probe import probe
|
||||
from tagstudio.renderers.vendored.probe import probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str):
|
||||
return False
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
"drma",
|
||||
"drms",
|
||||
"drmi",
|
||||
]:
|
||||
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
|
||||
return False
|
||||
except ffmpeg.Error:
|
||||
return False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import tarfile
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import py7zr
|
||||
import py7zr.io
|
||||
import rarfile
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.renderers.raster_image import image_from_bytes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
|
||||
|
||||
|
||||
class SevenZipFile(py7zr.SevenZipFile):
|
||||
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
|
||||
|
||||
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
|
||||
super().__init__(filepath, mode)
|
||||
|
||||
def read(self, name: str) -> bytes:
|
||||
# SevenZipFile must be reset after every extraction
|
||||
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
|
||||
self.reset()
|
||||
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
|
||||
self.extract(targets=[name], factory=factory)
|
||||
return factory.get(name).read()
|
||||
|
||||
|
||||
class TarFile:
|
||||
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
|
||||
|
||||
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
|
||||
self.tar: tarfile.TarFile
|
||||
self.filepath = filepath
|
||||
self.mode: Literal["r"] = mode
|
||||
|
||||
def namelist(self) -> list[str]:
|
||||
return self.tar.getnames()
|
||||
|
||||
def read(self, name: str) -> bytes:
|
||||
return unwrap(self.tar.extractfile(name)).read()
|
||||
|
||||
def __enter__(self) -> "TarFile":
|
||||
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
|
||||
self.tar.__exit__(*args)
|
||||
|
||||
|
||||
def open_archive(filepath: Path, ext: str = "") -> Archive:
|
||||
"""Open an archive with its corresponding archiver.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the archive.
|
||||
ext (str): The file extension.
|
||||
|
||||
Returns:
|
||||
Archive: The opened archive.
|
||||
"""
|
||||
archiver: type[Archive] = zipfile.ZipFile
|
||||
if ext in {".7z", ".cb7", ".s7z"}:
|
||||
archiver = SevenZipFile
|
||||
elif ext in {".cbr", ".rar"}:
|
||||
archiver = rarfile.RarFile
|
||||
elif ext in {".cbt", ".tar", ".tgz"}:
|
||||
archiver = TarFile
|
||||
return archiver(filepath, "r")
|
||||
|
||||
|
||||
def first_image_in_archive(archive: Archive) -> Image.Image | None:
|
||||
"""Find and extract the first renderable image in the archive.
|
||||
|
||||
Args:
|
||||
archive (Archive): The current archive.
|
||||
|
||||
Returns:
|
||||
Image: The first renderable image in the archive.
|
||||
"""
|
||||
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
|
||||
ext = Path(file_name).suffix
|
||||
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
|
||||
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
|
||||
return image_from_bytes(BytesIO(image_data))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def archive_thumb(
|
||||
filepath: Path, ext: str = "", image_names: list[Path] | list[str] | None = None
|
||||
) -> Image.Image | None:
|
||||
"""Extract an embedded preview image from an archive.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the archive.
|
||||
ext (str): The file extension. Used to help determine more specific archive type.
|
||||
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
|
||||
|
||||
Returns:
|
||||
Image: The first image found in the archive.
|
||||
"""
|
||||
try:
|
||||
with open_archive(filepath, ext) 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)
|
||||
|
||||
for image_name in image_names:
|
||||
if image_name in archive.namelist():
|
||||
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
|
||||
return image_from_bytes(BytesIO(file_data))
|
||||
|
||||
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=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=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=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=image_names)
|
||||
@@ -0,0 +1,149 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
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 tagstudio.renderers.vendored.pydub.audio_segment import (
|
||||
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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, # pyright: ignore[reportPrivateImportUsage]
|
||||
mp4.MP4MetadataError,
|
||||
mp4.MP4StreamInfoError,
|
||||
MutagenError,
|
||||
) as e:
|
||||
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
|
||||
return image
|
||||
|
||||
|
||||
def audio_waveform_thumb(
|
||||
filepath: Path, ext: str, size: int, pixel_ratio: float
|
||||
) -> Image.Image | None:
|
||||
"""Render a waveform image from an audio file.
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
count: int = 0
|
||||
maximum_item: int = 0
|
||||
max_array: list[int] = []
|
||||
highest_line: int = 0
|
||||
|
||||
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)
|
||||
|
||||
if maximum_item > highest_line:
|
||||
highest_line = maximum_item
|
||||
|
||||
maximum_item = 0
|
||||
count = 1
|
||||
|
||||
line_ratio = max(highest_line / bar_height, 1)
|
||||
|
||||
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
current_x = bar_margin
|
||||
for item in max_array:
|
||||
item_height = item / line_ratio
|
||||
|
||||
# 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)
|
||||
|
||||
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
|
||||
|
||||
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 = current_x + line_width + bar_margin
|
||||
|
||||
im.resize((size, size), Image.Resampling.BILINEAR)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,43 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from tagstudio.renderers.vendored.blender_renderer import (
|
||||
blend_thumb, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def blender_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
bg_color: str = (
|
||||
"#1e1e1e"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#FFFFFF"
|
||||
)
|
||||
im: Image.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.paste(blend_image, mask=blend_image.getchannel(3))
|
||||
im = bg
|
||||
else:
|
||||
logger.info(
|
||||
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
|
||||
"Doesn't have an embedded thumbnail."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import base64
|
||||
import sqlite3
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .clip file.
|
||||
|
||||
Returns:
|
||||
Image: The embedded thumbnail, if extractable.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
blob = f.read()
|
||||
sqlite_index = blob.find(b"SQLite format 3")
|
||||
if sqlite_index == -1:
|
||||
return im
|
||||
|
||||
with sqlite3.connect(":memory:") as conn:
|
||||
conn.deserialize(blob[sqlite_index:])
|
||||
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
|
||||
if thumbnail:
|
||||
im = Image.open(BytesIO(thumbnail[0]))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
|
||||
|
||||
def pdn_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the base64-encoded thumbnail from a .pdn file header.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .pdn file.
|
||||
|
||||
Returns:
|
||||
Image: the decoded PNG thumbnail or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
with open(filepath, "rb") as f:
|
||||
try:
|
||||
# First 4 bytes are the magic number
|
||||
if f.read(4) != b"PDN3":
|
||||
return im
|
||||
|
||||
# Header length is a little-endian 24-bit int
|
||||
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
|
||||
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
|
||||
if thumb_element is None:
|
||||
return im
|
||||
|
||||
encoded_png = thumb_element.get("png")
|
||||
if encoded_png:
|
||||
decoded_png = base64.b64decode(encoded_png)
|
||||
im = Image.open(BytesIO(decoded_png))
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,73 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive
|
||||
from tagstudio.renderers.raster_image import image_from_bytes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the ePub file.
|
||||
ext (str): The file extension.
|
||||
|
||||
Returns:
|
||||
Image: The cover specified in ComicInfo.xml,
|
||||
the first image found in the ePub file, or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open_archive(filepath, ext) 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")
|
||||
if not im:
|
||||
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
|
||||
|
||||
if not im:
|
||||
im = first_image_in_archive(archive)
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
|
||||
|
||||
def _cover_from_comic_info(
|
||||
archive: Archive, comic_info: Element, cover_type: str
|
||||
) -> Image.Image | None:
|
||||
"""Extract the cover specified in ComicInfo.xml.
|
||||
|
||||
Args:
|
||||
archive (Archive): The current ePub file.
|
||||
comic_info (Element): The parsed ComicInfo.xml.
|
||||
cover_type (str): The type of cover to load.
|
||||
|
||||
Returns:
|
||||
Image: The cover specified in ComicInfo.xml.
|
||||
"""
|
||||
im: Image.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):
|
||||
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
|
||||
im = image_from_bytes(BytesIO(image_data))
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,108 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
|
||||
"""Render a small font preview ("Aa") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
bg = Image.new("RGB", (size, size), color="#000000")
|
||||
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
|
||||
draw = ImageDraw.Draw(raw)
|
||||
font = ImageFont.truetype(filepath, size=size)
|
||||
# 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),
|
||||
"Aa",
|
||||
font=font,
|
||||
fill="#FF0000",
|
||||
# stroke_width=math.ceil(size / 96),
|
||||
# stroke_fill="#FFFF00",
|
||||
)
|
||||
# NOTE: Change to getchannel(1) if using an outline.
|
||||
data = np.asarray(raw.getchannel(0))
|
||||
|
||||
m, n = data.shape[:2]
|
||||
col: np.ndarray = cast(np.ndarray, data.any(0))
|
||||
row: np.ndarray = cast(np.ndarray, data.any(1))
|
||||
cropped_data = np.asarray(raw)[
|
||||
row.argmax() : m - row[::-1].argmax(),
|
||||
col.argmax() : n - col[::-1].argmax(),
|
||||
]
|
||||
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
|
||||
|
||||
margin: int = math.ceil(size // 16)
|
||||
|
||||
orig_x, orig_y = cropped_im.size
|
||||
new_x, new_y = (size, size)
|
||||
if orig_x > orig_y:
|
||||
new_x = size
|
||||
new_y = math.ceil(size * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = size
|
||||
new_x = math.ceil(size * (orig_x / orig_y))
|
||||
|
||||
cropped_im = cropped_im.resize(
|
||||
size=(new_x - (margin * 2), new_y - (margin * 2)),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
bg.paste(
|
||||
cropped_im,
|
||||
box=(margin, margin + ((size - new_y) // 2)),
|
||||
)
|
||||
im = bg
|
||||
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:
|
||||
"""Render a large font preview ("Alphabet") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
# Scale the sample font sizes to the preview image
|
||||
# resolution,assuming the sizes are tuned for 256px.
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
|
||||
bg = Image.new("RGBA", (size, 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)
|
||||
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]
|
||||
im = auto_theme_overlay(bg, use_alpha=False)
|
||||
except OSError as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,58 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import os
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the thumbnail from a .mdp file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .mdp file.
|
||||
|
||||
Returns:
|
||||
Image: The embedded thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
magic = struct.unpack("<7sx", f.read(8))[0]
|
||||
if magic != b"mdipack":
|
||||
return im
|
||||
|
||||
bin_header = struct.unpack("<LLL", f.read(12))
|
||||
xml_header = ET.fromstring(f.read(bin_header[1]))
|
||||
mdibin_count = len(xml_header.findall("./*Layer")) + 1
|
||||
for _ in range(mdibin_count):
|
||||
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
|
||||
if not pac_header[6].startswith(b"thumb"):
|
||||
f.seek(pac_header[3], os.SEEK_CUR)
|
||||
continue
|
||||
|
||||
thumb_element = unwrap(xml_header.find("Thumb"))
|
||||
dimensions = (
|
||||
int(unwrap(thumb_element.get("width"))),
|
||||
int(unwrap(thumb_element.get("height"))),
|
||||
)
|
||||
thumb_blob = f.read(pac_header[3])
|
||||
if pac_header[2] == 1:
|
||||
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
|
||||
|
||||
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,66 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
|
||||
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
|
||||
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
|
||||
|
||||
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.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
|
||||
|
||||
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)
|
||||
file.close()
|
||||
# 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 anti-aliasing
|
||||
scale_factor = 2.5 if ext in {".pdf"} else 1
|
||||
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
|
||||
q_image: QImage = document.render(0, page_size.toSize(), render_options)
|
||||
buffer: QBuffer = QBuffer()
|
||||
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||
try:
|
||||
q_image.save(buffer, "PNG") # pyright: ignore
|
||||
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)
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
try:
|
||||
import pillow_jxl # noqa: F401 # pyright: ignore
|
||||
except ImportError as e:
|
||||
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
|
||||
|
||||
register_heif_opener()
|
||||
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||
|
||||
|
||||
def raster_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a standard image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with filepath.open("rb") as file:
|
||||
im = image_from_bytes(BytesIO(file.read()))
|
||||
except (
|
||||
FileNotFoundError,
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
NotImplementedError,
|
||||
) 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:
|
||||
"""Render a thumbnail for a EXR image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.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)
|
||||
assert raw_array is not None
|
||||
raw_array[..., :3] = raw_array[..., 2::-1]
|
||||
|
||||
# Correct the gamma of the raw array
|
||||
gamma = 2.2
|
||||
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")
|
||||
|
||||
# Paste solid background
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
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:
|
||||
"""Load a raster image and add a background if it's transparent.
|
||||
|
||||
Args:
|
||||
image_data (BytesIO): The binary image data.
|
||||
|
||||
Returns:
|
||||
Image.Image: The loaded raster image, with a background if needed.
|
||||
"""
|
||||
im: Image.Image = Image.open(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.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
return unwrap(ImageOps.exif_transpose(im))
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import srctools
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vtf_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
|
||||
|
||||
Uses the srctools library for reading VTF files.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
vtf = srctools.VTF.read(f)
|
||||
im = vtf.get(frame=0).to_PIL()
|
||||
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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 tagstudio.core.utils.encoding import detect_char_encoding
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def text_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a plaintext file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
try:
|
||||
encoding = detect_char_encoding(filepath)
|
||||
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
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
UnicodeDecodeError,
|
||||
OSError,
|
||||
FileNotFoundError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
|
||||
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import (
|
||||
Image,
|
||||
UnidentifiedImageError,
|
||||
)
|
||||
from PySide6.QtCore import QBuffer, Qt
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vector_image_thumb(filepath: Path, size: int) -> Image.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
|
||||
# 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.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(q_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)
|
||||
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 = im.convert(mode="RGB")
|
||||
|
||||
buffer.close()
|
||||
return im
|
||||
+1
-5
@@ -1,11 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
# <pep8 compliant>
|
||||
|
||||
|
||||
## This file is a modified script that gets the thumbnail data stored in a blend file
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
|
||||
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from pydub
|
||||
@@ -43,7 +43,7 @@ from pydub.utils import (
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen
|
||||
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
|
||||
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
|
||||
|
||||
basestring = str
|
||||
xrange = range
|
||||
@@ -256,7 +256,7 @@ class _AudioSegment:
|
||||
self.sample_width = 4
|
||||
self.frame_width = self.channels * self.sample_width
|
||||
|
||||
super(_AudioSegment, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def raw_data(self):
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from pydub
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from cv2.typing import MatLike
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def video_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a video file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
frame: MatLike | None = None
|
||||
try:
|
||||
if is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
# TODO: Move this check to is_readable_video()
|
||||
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
|
||||
raise cv2.error("File is invalid or has 0 frames")
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
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:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
Reference in New Issue
Block a user