forked from mirrors/cbz
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a622ac7b7 | ||
|
|
5ae089239a | ||
|
|
95472d4ac4 | ||
|
|
0ecf0f843b | ||
|
|
4d4d367791 | ||
|
|
caead0a957 | ||
|
|
d37be87c2f | ||
|
|
0b011a7efc | ||
|
|
62b8cf5739 | ||
|
|
871716971a | ||
|
|
597cb41b00 | ||
|
|
a489ae17fb | ||
|
|
abc69d4673 | ||
|
|
6a1111a6bb | ||
|
|
220f19fe46 | ||
|
|
bbab9ba3b3 | ||
|
|
5824a8428a | ||
|
|
23f7e37561 | ||
|
|
5cc4732238 | ||
|
|
9d5d539311 | ||
|
|
f8274ae26b | ||
|
|
c045c548de | ||
|
|
7355e5edc0 | ||
|
|
5eee972d75 | ||
|
|
3604a4e98f | ||
|
|
19c1b01ea6 |
@@ -10,10 +10,10 @@ jobs:
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
+71
-24
@@ -1,7 +1,8 @@
|
||||
### Python ###
|
||||
# Created by https://github.com/github/gitignore/blob/main/Python.gitignore
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
@@ -28,8 +29,8 @@ share/python-wheels/
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
@@ -47,7 +48,7 @@ htmlcov/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
@@ -93,22 +94,37 @@ ipython_config.py
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
@@ -117,11 +133,25 @@ __pypackages__/
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -154,24 +184,41 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# ruff
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
### CBZ ###
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
#CBZ
|
||||
*.cbz
|
||||
*.pdf
|
||||
*.zip
|
||||
*cbr
|
||||
*.cbr
|
||||
@@ -4,6 +4,87 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [4.0.0] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Dataclass-based models (`ComicModel`, `PageModel`) with automatic XML field mapping via `xml_field()` metadata.
|
||||
- Sequence protocol on `ComicInfo`: `len(comic)`, `comic[i]`, `comic[0:3]`, `for page in comic`, `page in comic`.
|
||||
- Custom exception hierarchy: `CBZError`, `InvalidImageError`, `EmptyArchiveError`, `InvalidMetadataError`, `UnsupportedFormatError`.
|
||||
- `__version__` attribute exported from the `cbz` package.
|
||||
- Simplified top-level imports: `from cbz import ComicInfo, PageInfo, PageType, Format, ...`.
|
||||
- Complete CBZ format RFC specification in `docs/RFC-CBZ.md`.
|
||||
- `save()` now writes directly to disk (more memory-efficient for large archives).
|
||||
- Type hints cached with `lru_cache` for faster archive loading with many pages.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Python 3.9+ required** (previously 3.8+).
|
||||
- `ComicModel` and `PageModel` rewritten as `@dataclass` classes (replaces custom `BaseModel` with field dictionaries).
|
||||
- All enumerations now inherit from `StrEnum` (backported `str, Enum` mixin for Python < 3.11).
|
||||
- Optional numeric fields (`number`, `year`, `volume`, etc.) now default to `None` instead of `-1`.
|
||||
- `Rating` validates `0.0-5.0` with `ValueError` instead of `assert` (default `0.0` instead of `-1`).
|
||||
- `LanguageISO` raises `ValueError` instead of `AssertionError` for invalid codes.
|
||||
- Removed `utils.verify_attr`, `utils.default_attr`, `utils.repr_attr` (replaced by dataclass defaults and enum handling).
|
||||
- Removed `COMIC_FIELDS` and `PAGE_FIELDS` dictionaries (replaced by dataclass field metadata).
|
||||
- `save()` writes directly to a file-backed `ZipFile` instead of going through an in-memory buffer.
|
||||
- All documentation, docstrings and comments rewritten in English.
|
||||
|
||||
### Renamed
|
||||
|
||||
- `Manga.RIGHT_LEFT` -> `Manga.YES_AND_RIGHT_TO_LEFT`
|
||||
- `AgeRating.ADULTS18` -> `AgeRating.ADULTS_ONLY_18_PLUS`
|
||||
- `AgeRating.CHILDHOOD` -> `AgeRating.EARLY_CHILDHOOD`
|
||||
- `AgeRating.EVERYONE10` -> `AgeRating.EVERYONE_10_PLUS`
|
||||
- `AgeRating.KIDS` -> `AgeRating.KIDS_TO_ADULTS`
|
||||
- `AgeRating.MA15` -> `AgeRating.MA15_PLUS`
|
||||
- `AgeRating.MATURE17` -> `AgeRating.MATURE_17_PLUS`
|
||||
- `AgeRating.R18` -> `AgeRating.R18_PLUS`
|
||||
- `AgeRating.PENDING` -> `AgeRating.RATING_PENDING`
|
||||
- `AgeRating.X18` -> `AgeRating.X18_PLUS`
|
||||
- `Format.BLACK_WHITE` -> `Format.BLACK_AND_WHITE`
|
||||
- `Format.DIRECTOR_CUT` -> `Format.DIRECTORS_CUT`
|
||||
- `Format.TRADE_PAPER_BACK` -> `Format.TRADE_PAPERBACK`
|
||||
- `Format.POINT1` -> `Format.POINT_ONE`
|
||||
- `IMAGE_FORMAT` -> `IMAGE_FORMATS`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Image resource leak in the player when navigating pages.
|
||||
- ICO-to-PNG conversion resource leak in `utils.ico_to_png`.
|
||||
- `data.strip()` in `PageInfo.loads()` no longer copies the entire byte string for validation.
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [chase-roohms](https://github.com/chase-roohms)
|
||||
|
||||
## [3.4.5] - 2025-10-19
|
||||
|
||||
### Changed
|
||||
|
||||
- More flexible suffix checking to support complex archive paths.
|
||||
- Improved validation in `PageInfo.load()` to handle empty or invalid data correctly.
|
||||
|
||||
## [3.4.4] - 2025-09-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added `[pillow]` extension to support `.jxl` and `.avif` images.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Files in the input archives are now sorted alphabetically.
|
||||
|
||||
## [3.4.3] - 2025-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump actions/checkout from 4 to 5.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix ComicInfo XML parsing for single page comic.
|
||||
|
||||
## [3.4.2] - 2025-07-26
|
||||
|
||||
### Added
|
||||
@@ -205,6 +286,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
- Initial release.
|
||||
|
||||
[4.0.0]: https://github.com/hyugogirubato/cbz/releases/tag/v4.0.0
|
||||
[3.4.5]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.5
|
||||
[3.4.4]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.4
|
||||
[3.4.3]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.3
|
||||
[3.4.2]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.2
|
||||
[3.4.1]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.1
|
||||
[3.4.0]: https://github.com/hyugogirubato/cbz/releases/tag/v3.4.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hyugogirubato
|
||||
Copyright (c) 2026 hyugogirubato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -4,14 +4,16 @@ CBZ is a Python library designed for creating, manipulating, and viewing comic b
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Seamless Installation via [pip](#installation)
|
||||
- 📚 Pack images into CBZ format for comics and manga
|
||||
- 📝 Extract and manage title, series, format, and more
|
||||
- 🖼️ Handle comic pages with attributes like type and format
|
||||
- 📦 Unpack CBZ and CBR files to retrieve comic information, or extract images from PDF files
|
||||
- 🛠️ Built-in player for viewing CBZ, CBR, and PDF comics
|
||||
- 📚 Full CBR (RAR) format support for reading existing archives
|
||||
- ❤️ Fully Open-Source! Pull Requests Welcome
|
||||
- Seamless installation via [pip](#installation)
|
||||
- Pack images into CBZ format for comics and manga
|
||||
- Extract and manage metadata: title, series, format, and more
|
||||
- Handle comic pages with attributes like type, dimensions, and bookmarks
|
||||
- Unpack CBZ and CBR files to retrieve comic information, or extract images from PDF files
|
||||
- Built-in player for viewing CBZ, CBR, and PDF comics
|
||||
- Sequence protocol: iterate, index, and slice comic pages directly
|
||||
- Dataclass-based models with automatic XML mapping and strict type validation
|
||||
- Image support: JPEG, PNG, GIF, BMP, TIFF, WebP, JPEG XL, AVIF
|
||||
- Fully open-source! Pull requests welcome
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -21,56 +23,60 @@ Install CBZ from PyPI using pip:
|
||||
pip install cbz
|
||||
```
|
||||
|
||||
With AVIF and JPEG XL support:
|
||||
|
||||
```shell
|
||||
pip install cbz[pillow]
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a quick example of how to create a CBZ file from a series of images:
|
||||
|
||||
````python
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
|
||||
from cbz.page import PageInfo
|
||||
from cbz import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
|
||||
|
||||
PARENT = Path(__file__).parent
|
||||
if __name__ == "__main__":
|
||||
paths = sorted(Path("path/to/your/images").iterdir())
|
||||
|
||||
if __name__ == '__main__':
|
||||
paths = list(Path('path/to/your/images').iterdir())
|
||||
|
||||
# Load each page from the 'images' folder into a list of PageInfo objects
|
||||
# Load each page from the images folder into a list of PageInfo objects
|
||||
pages = [
|
||||
PageInfo.load(
|
||||
path=path,
|
||||
type=PageType.FRONT_COVER if i == 0 else PageType.BACK_COVER if i == len(paths) - 1 else PageType.STORY
|
||||
type=(
|
||||
PageType.FRONT_COVER if i == 0
|
||||
else PageType.BACK_COVER if i == len(paths) - 1
|
||||
else PageType.STORY
|
||||
),
|
||||
)
|
||||
for i, path in enumerate(paths)
|
||||
]
|
||||
|
||||
# Create a ComicInfo object using ComicInfo.from_pages() method
|
||||
# Create a ComicInfo object with metadata
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Your Comic Title',
|
||||
series='Your Comic Series',
|
||||
title="Your Comic Title",
|
||||
series="Your Comic Series",
|
||||
number=1,
|
||||
language_iso='en',
|
||||
language_iso="en",
|
||||
format=Format.WEB_COMIC,
|
||||
black_white=YesNo.NO,
|
||||
manga=Manga.NO,
|
||||
age_rating=AgeRating.PENDING
|
||||
age_rating=AgeRating.RATING_PENDING,
|
||||
)
|
||||
|
||||
# Show the comic using the show()
|
||||
# Display the comic in the built-in reader
|
||||
comic.show()
|
||||
|
||||
# Pack the comic book content into a CBZ file format
|
||||
# Save directly as a CBZ file
|
||||
comic.save("your_comic.cbz")
|
||||
|
||||
# Or pack to bytes for custom handling
|
||||
cbz_content = comic.pack()
|
||||
|
||||
# Define the path where the CBZ file will be saved
|
||||
cbz_path = PARENT / 'your_comic.cbz'
|
||||
|
||||
# Write the CBZ content to the specified path
|
||||
cbz_path.write_bytes(cbz_content)
|
||||
````
|
||||
Path("your_comic.cbz").write_bytes(cbz_content)
|
||||
```
|
||||
|
||||
## Player
|
||||
|
||||
@@ -84,17 +90,17 @@ CBZ includes a command-line player for viewing comic book files in multiple form
|
||||
|
||||
### Usage
|
||||
|
||||
````shell
|
||||
```shell
|
||||
usage: cbzplayer [-h] <file>
|
||||
|
||||
Launch CBZ player with a comic book file
|
||||
CBZ/CBR/PDF comic reader
|
||||
|
||||
positional arguments:
|
||||
<file> Path to the CBZ, CBR, or PDF comic book file.
|
||||
<file> Path to the CBZ, CBR or PDF comic book file.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
````
|
||||
-h, --help show this help message and exit
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -109,6 +115,17 @@ cbzplayer my_comic.cbr
|
||||
cbzplayer my_comic.pdf
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|---------------------|-------------------|
|
||||
| Left / Right arrows | Navigate pages |
|
||||
| + / - | Zoom in / out |
|
||||
| Ctrl+Q | Quit |
|
||||
| Mouse wheel | Vertical scroll |
|
||||
| Shift+Mouse wheel | Horizontal scroll |
|
||||
| Ctrl+Mouse wheel | Zoom |
|
||||
|
||||
### Requirements for CBR Support
|
||||
|
||||
CBR file support requires:
|
||||
@@ -128,30 +145,47 @@ For installation instructions and compatibility details, see the [rarfile docume
|
||||
The `ComicInfo` class represents a comic book with metadata and pages. It supports initialization from a list of `PageInfo` objects:
|
||||
|
||||
```python
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
|
||||
from cbz.page import PageInfo
|
||||
from cbz import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
|
||||
|
||||
# Example usage:
|
||||
pages = [
|
||||
PageInfo.load(path='/path/to/page1.jpg', type=PageType.FRONT_COVER),
|
||||
PageInfo.load(path='/path/to/page2.jpg', type=PageType.STORY),
|
||||
PageInfo.load(path='/path/to/page3.jpg', type=PageType.BACK_COVER),
|
||||
PageInfo.load(path="page1.jpg", type=PageType.FRONT_COVER),
|
||||
PageInfo.load(path="page2.jpg", type=PageType.STORY),
|
||||
PageInfo.load(path="page3.jpg", type=PageType.BACK_COVER),
|
||||
]
|
||||
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='My Comic',
|
||||
series='Comic Series',
|
||||
title="My Comic",
|
||||
series="Comic Series",
|
||||
number=1,
|
||||
language_iso='en',
|
||||
language_iso="en",
|
||||
format=Format.WEB_COMIC,
|
||||
black_white=YesNo.NO,
|
||||
manga=Manga.NO,
|
||||
age_rating=AgeRating.PENDING
|
||||
age_rating=AgeRating.RATING_PENDING,
|
||||
)
|
||||
```
|
||||
|
||||
You can also create pages directly from bytes or base64-encoded data:
|
||||
|
||||
```python
|
||||
page = PageInfo.loads(data=image_bytes, name="page.jpg", type=PageType.STORY)
|
||||
```
|
||||
|
||||
### Sequence Protocol
|
||||
|
||||
`ComicInfo` implements the full sequence protocol, so you can interact with pages directly:
|
||||
|
||||
```python
|
||||
len(comic) # Number of pages
|
||||
comic[0] # First page
|
||||
comic[-1] # Last page
|
||||
comic[1:3] # Slice of pages
|
||||
for page in comic: # Iteration
|
||||
print(page.image_width, page.image_height)
|
||||
page in comic # Containment check
|
||||
```
|
||||
|
||||
### Extracting Metadata
|
||||
|
||||
Retrieve comic information as a dictionary using `get_info()`:
|
||||
@@ -161,39 +195,181 @@ info = comic.get_info()
|
||||
print(info)
|
||||
```
|
||||
|
||||
### Packing into CBZ Format
|
||||
### Packing and Saving
|
||||
|
||||
Pack the comic into a CBZ file format:
|
||||
Pack the comic into CBZ format as bytes:
|
||||
|
||||
```python
|
||||
cbz_content = comic.pack()
|
||||
```
|
||||
|
||||
Or save directly to disk (more memory-efficient for large archives):
|
||||
|
||||
```python
|
||||
comic.save("output.cbz")
|
||||
```
|
||||
|
||||
### Loading from Different Formats
|
||||
|
||||
Load a comic from an existing CBZ file (with metadata):
|
||||
|
||||
```python
|
||||
comic_from_cbz = ComicInfo.from_cbz('/path/to/your_comic.cbz')
|
||||
comic = ComicInfo.from_cbz("your_comic.cbz")
|
||||
```
|
||||
|
||||
Load a comic from an existing CBR file (with metadata):
|
||||
|
||||
```python
|
||||
comic_from_cbr = ComicInfo.from_cbr('/path/to/your_comic.cbr')
|
||||
comic = ComicInfo.from_cbr("your_comic.cbr")
|
||||
```
|
||||
|
||||
Load a comic from a PDF file (images only, no metadata):
|
||||
|
||||
```python
|
||||
comic_from_pdf = ComicInfo.from_pdf('/path/to/your_comic.pdf')
|
||||
comic = ComicInfo.from_pdf("your_comic.pdf")
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
**Notes:**
|
||||
|
||||
- CBR support requires an external RAR extraction tool. For detailed compatibility information and advanced configuration, see the [rarfile documentation](https://github.com/markokr/rarfile).
|
||||
- PDF files only provide image content; comic metadata (title, series, etc.) is not available from PDF files.
|
||||
|
||||
### Page Properties
|
||||
|
||||
Each `PageInfo` object exposes the following properties, automatically extracted from the image content:
|
||||
|
||||
```python
|
||||
page = comic[0]
|
||||
|
||||
page.content # bytes - raw image data
|
||||
page.image_width # int - width in pixels
|
||||
page.image_height # int - height in pixels
|
||||
page.image_size # int - file size in bytes
|
||||
page.suffix # str - file extension (.jpg, .png, etc.)
|
||||
page.name # str - original file name
|
||||
page.type # PageType - page type (FrontCover, Story, etc.)
|
||||
page.bookmark # str - bookmark / chapter name
|
||||
page.double # bool - double page spread
|
||||
```
|
||||
|
||||
### Metadata Fields
|
||||
|
||||
All [ComicInfo.xml](docs/RFC-CBZ.md) v2.1 metadata fields are supported as dataclass attributes:
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
|--------------------------|--------------------|-----------|------------------------------------------|
|
||||
| `title` | `str` | `""` | Issue title |
|
||||
| `series` | `str` | `""` | Series name |
|
||||
| `number` | `Optional[int]` | `None` | Issue number |
|
||||
| `count` | `Optional[int]` | `None` | Total number of issues |
|
||||
| `volume` | `Optional[int]` | `None` | Volume number |
|
||||
| `year` | `Optional[int]` | `None` | Publication year |
|
||||
| `month` | `Optional[int]` | `None` | Publication month |
|
||||
| `day` | `Optional[int]` | `None` | Publication day |
|
||||
| `writer` | `str` | `""` | Writer(s), comma-separated |
|
||||
| `penciller` | `str` | `""` | Pencil artist(s) |
|
||||
| `inker` | `str` | `""` | Inker(s) |
|
||||
| `colorist` | `str` | `""` | Colorist(s) |
|
||||
| `letterer` | `str` | `""` | Letterer(s) |
|
||||
| `cover_artist` | `str` | `""` | Cover artist(s) |
|
||||
| `editor` | `str` | `""` | Editor(s) |
|
||||
| `translator` | `str` | `""` | Translator(s) |
|
||||
| `publisher` | `str` | `""` | Publisher |
|
||||
| `imprint` | `str` | `""` | Publisher imprint |
|
||||
| `genre` | `str` | `""` | Genre(s), comma-separated |
|
||||
| `tags` | `str` | `""` | Tags, comma-separated |
|
||||
| `web` | `str` | `""` | Web URL |
|
||||
| `language_iso` | `LanguageISO` | `""` | ISO language code (e.g., `"en"`, `"fr"`) |
|
||||
| `format` | `Format` | `UNKNOWN` | Publication format |
|
||||
| `black_white` | `YesNo` | `UNKNOWN` | Black and white |
|
||||
| `manga` | `Manga` | `UNKNOWN` | Manga / reading direction |
|
||||
| `age_rating` | `AgeRating` | `UNKNOWN` | Content age rating |
|
||||
| `community_rating` | `Optional[Rating]` | `None` | Community rating (0.0-5.0) |
|
||||
| `summary` | `str` | `""` | Synopsis / description |
|
||||
| `characters` | `str` | `""` | Character names, comma-separated |
|
||||
| `teams` | `str` | `""` | Team names, comma-separated |
|
||||
| `locations` | `str` | `""` | Locations, comma-separated |
|
||||
| `story_arc` | `str` | `""` | Story arc name |
|
||||
| `story_arc_number` | `Optional[int]` | `None` | Position in story arc |
|
||||
| `main_character_or_team` | `str` | `""` | Primary character or team |
|
||||
| `scan_information` | `str` | `""` | Scan / digitization notes |
|
||||
| `ean` | `str` | `""` | EAN / ISBN |
|
||||
| `book_price` | `str` | `""` | Cover price |
|
||||
|
||||
### Enumerations
|
||||
|
||||
```python
|
||||
from cbz import PageType, Format, YesNo, Manga, AgeRating
|
||||
|
||||
# Page types
|
||||
PageType.FRONT_COVER # Front cover
|
||||
PageType.STORY # Story page (default)
|
||||
PageType.BACK_COVER # Back cover
|
||||
PageType.INNER_COVER # Inner cover / dust jacket
|
||||
PageType.ADVERTISEMENT # Advertisement
|
||||
PageType.EDITORIAL # Editorial / credits
|
||||
PageType.LETTERS # Letters page
|
||||
PageType.PREVIEW # Preview of upcoming issues
|
||||
PageType.ROUNDUP # Recap / summary
|
||||
PageType.OTHER # Other
|
||||
PageType.DELETED # Marked for deletion
|
||||
|
||||
# Publication formats
|
||||
Format.SERIES # Regular series
|
||||
Format.GRAPHIC_NOVEL # Graphic novel
|
||||
Format.WEB_COMIC # Webcomic
|
||||
Format.ONE_SHOT # One-shot
|
||||
Format.TRADE_PAPERBACK # Trade paperback
|
||||
Format.ANNUAL # Annual
|
||||
Format.ANTHOLOGY # Anthology
|
||||
Format.LIMITED_SERIES # Limited series
|
||||
Format.MAGAZINE # Magazine
|
||||
# ... and more
|
||||
|
||||
# Reading direction
|
||||
Manga.UNKNOWN # Not specified
|
||||
Manga.NO # Western (left to right)
|
||||
Manga.YES # Manga
|
||||
Manga.YES_AND_RIGHT_TO_LEFT # Manga (right to left)
|
||||
|
||||
# Age ratings
|
||||
AgeRating.UNKNOWN # Not rated
|
||||
AgeRating.EVERYONE # All ages
|
||||
AgeRating.TEEN # Teens
|
||||
AgeRating.MATURE_17_PLUS # Mature 17+
|
||||
AgeRating.RATING_PENDING # Rating pending
|
||||
# ... and more
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The library provides a hierarchy of specific exceptions:
|
||||
|
||||
```python
|
||||
from cbz import CBZError, InvalidImageError, EmptyArchiveError, InvalidMetadataError
|
||||
|
||||
try:
|
||||
comic = ComicInfo.from_cbz("corrupted.cbz")
|
||||
except InvalidMetadataError:
|
||||
print("ComicInfo.xml is invalid or corrupted")
|
||||
except EmptyArchiveError:
|
||||
print("No valid images found in the archive")
|
||||
except InvalidImageError:
|
||||
print("An image in the archive could not be read")
|
||||
except CBZError:
|
||||
print("General CBZ error")
|
||||
```
|
||||
|
||||
## Format Specification
|
||||
|
||||
A complete RFC specification of the CBZ format is available in [`docs/RFC-CBZ.md`](docs/RFC-CBZ.md).
|
||||
|
||||
The ComicInfo.xml XSD schemas (v1.0, v2.0, v2.1) are in [`docs/schema/`](docs/schema/).
|
||||
|
||||
## Changelog
|
||||
|
||||
See [`CHANGELOG.md`](CHANGELOG.md) for the full version history, including migration notes for v4.0.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/hyugogirubato"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/65763543?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="hyugogirubato"/></a>
|
||||
@@ -204,11 +380,11 @@ comic_from_pdf = ComicInfo.from_pdf('/path/to/your_comic.pdf')
|
||||
<a href="https://github.com/domenicoblanco"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9018104?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="domenicoblanco"/></a>
|
||||
<a href="https://github.com/RivMt"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40086827?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="RivMt"/></a>
|
||||
<a href="https://github.com/flolep2607"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/24566964?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="flolep2607"/></a>
|
||||
<a href="https://github.com/chase-roohms"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/131704514?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="chase-roohms"/></a>
|
||||
|
||||
## Licensing
|
||||
|
||||
This software is licensed under the terms of [MIT License](https://github.com/hyugogirubato/cbz/blob/main/LICENSE).
|
||||
You can find a copy of the license in the LICENSE file in the root folder.
|
||||
This software is licensed under the terms of [MIT License](LICENSE). You can find a copy of the license in the LICENSE file in the root folder.
|
||||
|
||||
### Third-Party Licenses
|
||||
|
||||
@@ -216,10 +392,12 @@ This project uses the following third-party libraries:
|
||||
|
||||
- **[langcodes](https://pypi.org/project/langcodes/)** - MIT License
|
||||
- **[Pillow](https://pypi.org/project/Pillow/)** - HPND License
|
||||
- **[pillow-avif-plugin](https://pypi.org/project/pillow-avif-plugin/)** - MIT License
|
||||
- **[pillow-jxl-plugin](https://pypi.org/project/pillow-jxl-plugin/)** - MIT License
|
||||
- **[pypdf](https://pypi.org/project/pypdf/)** - BSD License
|
||||
- **[rarfile](https://pypi.org/project/rarfile/)** - ISC License
|
||||
- **[xmltodict](https://pypi.org/project/xmltodict/)** - MIT License
|
||||
|
||||
* * *
|
||||
---
|
||||
|
||||
© hyugogirubato 2025
|
||||
© hyugogirubato
|
||||
|
||||
+50
-2
@@ -1,2 +1,50 @@
|
||||
from .comic import ComicInfo
|
||||
from .page import PageInfo
|
||||
"""
|
||||
CBZ - Python library for digital comic book management.
|
||||
|
||||
Supports creating, manipulating and viewing CBZ (Comic Book ZIP),
|
||||
CBR (Comic Book RAR) and PDF files.
|
||||
"""
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.page import PageInfo
|
||||
from cbz.constants import (
|
||||
AgeRating,
|
||||
Format,
|
||||
LanguageISO,
|
||||
Manga,
|
||||
PageType,
|
||||
Rating,
|
||||
YesNo
|
||||
)
|
||||
from cbz.exceptions import (
|
||||
CBZError,
|
||||
EmptyArchiveError,
|
||||
InvalidImageError,
|
||||
InvalidMetadataError,
|
||||
UnsupportedFormatError
|
||||
)
|
||||
|
||||
__version__ = "4.0.0"
|
||||
__all__ = [
|
||||
"ComicInfo",
|
||||
"PageInfo",
|
||||
"AgeRating",
|
||||
"Format",
|
||||
"LanguageISO",
|
||||
"Manga",
|
||||
"PageType",
|
||||
"Rating",
|
||||
"YesNo",
|
||||
"CBZError",
|
||||
"EmptyArchiveError",
|
||||
"InvalidImageError",
|
||||
"InvalidMetadataError",
|
||||
"UnsupportedFormatError"
|
||||
]
|
||||
|
||||
# Load optional Pillow plugins (AVIF, JPEG XL)
|
||||
for _plugin in ("pillow_avif", "pillow_jxl"):
|
||||
try:
|
||||
__import__(_plugin)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
+31
-21
@@ -1,37 +1,47 @@
|
||||
import argparse
|
||||
"""CLI entry point for the CBZ comic reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.exceptions import CBZError
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser(description='Launch CBZ player with a comic book file')
|
||||
parser.add_argument('comic_path', type=Path, metavar='<file>', help='Path to the CBZ, CBR, or PDF comic book file.')
|
||||
"""Launch the comic reader with the specified file."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CBZ/CBR/PDF comic reader"
|
||||
)
|
||||
parser.add_argument(
|
||||
"comic_path",
|
||||
type=Path,
|
||||
metavar="<file>",
|
||||
help="Path to the CBZ, CBR or PDF comic book file."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate the provided path
|
||||
comic_path = args.comic_path
|
||||
if not comic_path.is_file():
|
||||
print(f'Error: The file "{comic_path}" does not exist or is not a valid file.')
|
||||
exit(1)
|
||||
path: Path = args.comic_path
|
||||
if not path.is_file():
|
||||
print(f"Error: file '{path}' does not exist.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create ComicInfo object from comic file
|
||||
try:
|
||||
if comic_path.suffix == '.pdf':
|
||||
comic_info = ComicInfo.from_pdf(comic_path)
|
||||
elif comic_path.suffix == '.cbr':
|
||||
comic_info = ComicInfo.from_cbr(comic_path)
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
comic = ComicInfo.from_pdf(path)
|
||||
elif suffix == ".cbr":
|
||||
comic = ComicInfo.from_cbr(path)
|
||||
else:
|
||||
comic_info = ComicInfo.from_cbz(comic_path)
|
||||
comic = ComicInfo.from_cbz(path)
|
||||
|
||||
# Launch the CBZ player
|
||||
comic_info.show()
|
||||
except Exception as e:
|
||||
print(f'Error: {e}')
|
||||
exit(1)
|
||||
comic.show()
|
||||
except CBZError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+265
-208
@@ -1,316 +1,373 @@
|
||||
"""
|
||||
Main CBZ comic management module.
|
||||
|
||||
Provides the ComicInfo class to create, load, manipulate and
|
||||
save comics in CBZ, CBR and PDF formats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import typing
|
||||
import zipfile
|
||||
|
||||
from dataclasses import dataclass, field, fields
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
from typing import Union, List
|
||||
from pathlib import Path
|
||||
from typing import Iterator, List, Union
|
||||
|
||||
import rarfile
|
||||
import xmltodict
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from cbz.constants import XML_NAME, COMIC_FIELDS, IMAGE_FORMAT, PAGE_FIELDS
|
||||
from cbz.models import ComicModel
|
||||
from cbz.constants import IMAGE_FORMATS, XML_NAME
|
||||
from cbz.exceptions import EmptyArchiveError, InvalidMetadataError
|
||||
from cbz.models import ComicModel, PageModel
|
||||
from cbz.page import PageInfo
|
||||
from cbz.utils import repr_attr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _resolve_type_hints(model_cls: type) -> dict:
|
||||
"""Resolve dataclass type annotations to actual types.
|
||||
|
||||
Handles optional types (Optional[X] / Union[X, None]) by
|
||||
extracting the base type.
|
||||
"""
|
||||
hints = typing.get_type_hints(model_cls)
|
||||
resolved = {}
|
||||
for name, hint in hints.items():
|
||||
origin = getattr(hint, "__origin__", None)
|
||||
if origin is Union:
|
||||
# Optional[int] == Union[int, None] -> extract the non-None type
|
||||
args = [a for a in hint.__args__ if a is not type(None)]
|
||||
resolved[name] = args[0] if args else str
|
||||
else:
|
||||
resolved[name] = hint
|
||||
return resolved
|
||||
|
||||
|
||||
def _extract_fields(items: dict, model_cls: type) -> dict:
|
||||
"""Extract and convert XML fields to Python model fields.
|
||||
|
||||
Iterates over the dataclass fields of model_cls, looks up the
|
||||
XML correspondence in items, and converts values to the correct type.
|
||||
|
||||
Args:
|
||||
items: Dictionary of parsed XML elements/attributes.
|
||||
model_cls: Dataclass class (ComicModel or PageModel).
|
||||
|
||||
Returns:
|
||||
Dictionary {python_name: converted_value}.
|
||||
"""
|
||||
type_hints = _resolve_type_hints(model_cls)
|
||||
result = {}
|
||||
for f in fields(model_cls):
|
||||
xml_name = f.metadata.get("xml_name")
|
||||
if xml_name and xml_name in items:
|
||||
raw = items[xml_name]
|
||||
try:
|
||||
field_type = type_hints.get(f.name, str)
|
||||
result[f.name] = field_type(raw)
|
||||
except (ValueError, KeyError):
|
||||
logger.warning("Unable to convert field %s=%r", xml_name, raw)
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_fields(obj: object, model_cls: type) -> dict:
|
||||
"""Serialize object fields to an XML dictionary.
|
||||
|
||||
Ignores fields with default/empty values (empty strings, None, UNKNOWN).
|
||||
|
||||
Args:
|
||||
obj: Model instance.
|
||||
model_cls: Dataclass class of the model.
|
||||
|
||||
Returns:
|
||||
Dictionary {xml_name: value} ready for XML serialization.
|
||||
"""
|
||||
result = {}
|
||||
for f in fields(model_cls):
|
||||
xml_name = f.metadata.get("xml_name")
|
||||
if not xml_name:
|
||||
continue
|
||||
|
||||
value = getattr(obj, f.name)
|
||||
|
||||
# Skip default / empty values
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value:
|
||||
continue
|
||||
if isinstance(value, Enum) and value == f.default:
|
||||
continue
|
||||
|
||||
# Convert enums to their string value
|
||||
if isinstance(value, Enum):
|
||||
result[xml_name] = value.value
|
||||
else:
|
||||
result[xml_name] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComicInfo(ComicModel):
|
||||
"""
|
||||
ComicInfo class that represents the comic book information and pages.
|
||||
"""Represents a complete comic with its metadata and pages.
|
||||
|
||||
Supports creation from images, loading from CBZ/CBR/PDF archives,
|
||||
serialization to CBZ and display via the built-in viewer.
|
||||
|
||||
Implements the sequence protocol for page access:
|
||||
- len(comic) returns the number of pages
|
||||
- comic[i] returns page i
|
||||
- for page in comic: iterates over pages
|
||||
|
||||
Attributes:
|
||||
pages: List of comic pages.
|
||||
"""
|
||||
|
||||
def __init__(self, pages: List[PageInfo], **kwargs):
|
||||
"""
|
||||
Initialize the ComicInfo instance with pages and additional attributes.
|
||||
pages: List[PageInfo] = field(default_factory=list)
|
||||
|
||||
Args:
|
||||
pages (List[PageInfo]): List of PageInfo objects representing the comic pages.
|
||||
**kwargs: Additional attributes for the comic.
|
||||
|
||||
Attributes:
|
||||
pages (List[PageInfo]): Stores the comic pages.
|
||||
"""
|
||||
super(ComicInfo, self).__init__(**kwargs)
|
||||
self.pages = pages
|
||||
# -- Alternative constructors (factory methods) --
|
||||
|
||||
@classmethod
|
||||
def from_pages(cls, pages: List[PageInfo], **kwargs) -> ComicInfo:
|
||||
"""
|
||||
Create a ComicInfo instance from pages and additional attributes.
|
||||
"""Create a ComicInfo from a list of pages and metadata.
|
||||
|
||||
Args:
|
||||
pages (List[PageInfo]): List of PageInfo objects representing the comic pages.
|
||||
**kwargs: Additional attributes for the comic.
|
||||
pages: List of PageInfo objects.
|
||||
**kwargs: Comic metadata (title, series, etc.).
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
New ComicInfo instance.
|
||||
"""
|
||||
return cls(pages, **kwargs)
|
||||
return cls(pages=pages, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _from_archive(cls, path: Union[Path, str], opener: type) -> ComicInfo:
|
||||
"""Load a comic from an archive file (CBZ or CBR).
|
||||
|
||||
Args:
|
||||
path: Path to the archive file.
|
||||
opener: Archive class (zipfile.ZipFile or rarfile.RarFile).
|
||||
|
||||
Returns:
|
||||
ComicInfo instance with pages and metadata.
|
||||
"""
|
||||
with opener(Path(path), "r") as archive:
|
||||
return cls._process_archive(archive)
|
||||
|
||||
@classmethod
|
||||
def from_cbz(cls, path: Union[Path, str]) -> ComicInfo:
|
||||
"""
|
||||
Create a ComicInfo instance from a CBZ file.
|
||||
"""Load a comic from a CBZ (ZIP) file.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): Path to the CBZ file.
|
||||
path: Path to the .cbz file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
ComicInfo instance with pages and metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided path is not a Path object or a string.
|
||||
EmptyArchiveError: If the archive contains no images.
|
||||
"""
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
||||
return cls.__unpack_zip(Path(path))
|
||||
return cls._from_archive(path, zipfile.ZipFile)
|
||||
|
||||
@classmethod
|
||||
def from_cbr(cls, path: Union[Path, str]) -> ComicInfo:
|
||||
"""
|
||||
Create a ComicInfo instance from a CBR file.
|
||||
"""Load a comic from a CBR (RAR) file.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): Path to the CBR file.
|
||||
path: Path to the .cbr file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
ComicInfo instance with pages and metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided path is not a Path object or a string.
|
||||
EmptyArchiveError: If the archive contains no images.
|
||||
"""
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
||||
return cls.__unpack_rar(Path(path))
|
||||
return cls._from_archive(path, rarfile.RarFile)
|
||||
|
||||
@classmethod
|
||||
def from_pdf(cls, path: Union[Path, str]) -> ComicInfo:
|
||||
"""
|
||||
Create a ComicInfo instance from a PDF file.
|
||||
"""Load a comic from a PDF file (image extraction).
|
||||
|
||||
Only images are extracted; PDF metadata is not converted
|
||||
to ComicInfo metadata.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): Path to the PDF file.
|
||||
path: Path to the .pdf file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
ComicInfo instance with extracted images.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided path is not a Path object or a string.
|
||||
"""
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
||||
return cls.__unpack_pdf(Path(path))
|
||||
|
||||
@staticmethod
|
||||
def __unpack_pdf(path: Path) -> ComicInfo:
|
||||
"""
|
||||
Unpack a PDF file and create a ComicInfo instance.
|
||||
|
||||
Args:
|
||||
path (Path): Path to the PDF file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
EmptyArchiveError: If the PDF contains no images.
|
||||
"""
|
||||
pages: List[PageInfo] = []
|
||||
reader = PdfReader(path)
|
||||
for page in reader.pages:
|
||||
for image in page.images:
|
||||
reader = PdfReader(Path(path))
|
||||
for pdf_page in reader.pages:
|
||||
for image in pdf_page.images:
|
||||
pages.append(PageInfo.loads(data=image.data))
|
||||
|
||||
assert pages, 'No valid images present in file'
|
||||
return ComicInfo.from_pages(pages=pages)
|
||||
if not pages:
|
||||
raise EmptyArchiveError("No valid images found in PDF file")
|
||||
return cls.from_pages(pages=pages)
|
||||
|
||||
@staticmethod
|
||||
def __extract_info(items: dict, fields: dict) -> dict:
|
||||
"""
|
||||
Extract and convert field information from the provided items and fields.
|
||||
# -- Sequence protocol --
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Number of pages in the comic."""
|
||||
return len(self.pages)
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Access a page by index or slice."""
|
||||
return self.pages[index]
|
||||
|
||||
def __iter__(self) -> Iterator[PageInfo]:
|
||||
"""Iterate over comic pages."""
|
||||
return iter(self.pages)
|
||||
|
||||
def __contains__(self, page: PageInfo) -> bool:
|
||||
"""Check if a page belongs to the comic."""
|
||||
return page in self.pages
|
||||
|
||||
# -- Internal methods --
|
||||
|
||||
@classmethod
|
||||
def _process_archive(cls, archive) -> ComicInfo:
|
||||
"""Common processing for CBZ and CBR archives.
|
||||
|
||||
Extracts the ComicInfo.xml file if present, then loads
|
||||
images sorted alphabetically.
|
||||
|
||||
Args:
|
||||
items (dict): Dictionary containing item attributes.
|
||||
fields (dict): Dictionary containing field mappings and types.
|
||||
archive: Open archive object (ZipFile or RarFile).
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with extracted and converted field information.
|
||||
ComicInfo instance.
|
||||
"""
|
||||
content = {}
|
||||
for key, (field_key, field_type) in fields.items():
|
||||
if field_key in items:
|
||||
content[key] = field_type(items[field_key])
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def __process_archive(archive_file: Union[zipfile.ZipFile, rarfile.RarFile]) -> ComicInfo:
|
||||
"""
|
||||
Common logic for processing archive files (CBZ/CBR).
|
||||
|
||||
Args:
|
||||
archive_file (Union[ZipFile, RarFile]): Archive file object (ZipFile or RarFile)
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
"""
|
||||
pages = []
|
||||
names = archive_file.namelist()
|
||||
comic_info = {}
|
||||
pages: List[PageInfo] = []
|
||||
names = sorted(archive.namelist())
|
||||
comic_data: dict = {}
|
||||
|
||||
# Extract XML metadata
|
||||
if XML_NAME in names:
|
||||
with archive_file.open(XML_NAME, 'r') as f:
|
||||
comic_info = xmltodict.parse(f.read()).get('ComicInfo', {})
|
||||
with archive.open(XML_NAME, "r") as f:
|
||||
try:
|
||||
comic_data = xmltodict.parse(
|
||||
f.read(), force_list=("Page",)
|
||||
).get("ComicInfo", {})
|
||||
except Exception as e:
|
||||
raise InvalidMetadataError(f"XML parsing error: {e}") from e
|
||||
names.remove(XML_NAME)
|
||||
|
||||
comic = ComicInfo.__extract_info(
|
||||
items=comic_info,
|
||||
fields=COMIC_FIELDS
|
||||
)
|
||||
# Extract comic fields
|
||||
comic_kwargs = _extract_fields(comic_data, ComicModel)
|
||||
|
||||
# Extract page information
|
||||
pages_info = comic_data.get("Pages", {}).get("Page", [])
|
||||
|
||||
pages_info = comic_info.get('Pages', {}).get('Page', [])
|
||||
for i, name in enumerate(names):
|
||||
suffix = Path(name).suffix
|
||||
if suffix:
|
||||
assert suffix in IMAGE_FORMAT, f'Unsupported image format: {suffix}'
|
||||
with archive_file.open(name, 'r') as f:
|
||||
page_info = {}
|
||||
if i < len(pages_info):
|
||||
page_info = ComicInfo.__extract_info(
|
||||
items=pages_info[i],
|
||||
fields=PAGE_FIELDS
|
||||
)
|
||||
page_info['name'] = Path(name).name
|
||||
pages.append(PageInfo.loads(data=f.read(), **page_info))
|
||||
if suffix.lower() not in IMAGE_FORMATS:
|
||||
logger.warning("Skipping unsupported file: %r", name)
|
||||
continue
|
||||
|
||||
return ComicInfo.from_pages(pages=pages, **comic)
|
||||
with archive.open(name, "r") as f:
|
||||
page_kwargs: dict = {}
|
||||
if i < len(pages_info):
|
||||
page_kwargs = _extract_fields(pages_info[i], PageModel)
|
||||
page_kwargs["name"] = Path(name).name
|
||||
pages.append(PageInfo.loads(data=f.read(), **page_kwargs))
|
||||
|
||||
@staticmethod
|
||||
def __unpack_rar(path: Path) -> ComicInfo:
|
||||
"""
|
||||
Unpack a CBR file and create a ComicInfo instance.
|
||||
|
||||
Args:
|
||||
path (Path): Path to the CBR file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
"""
|
||||
with rarfile.RarFile(path, 'r') as rf:
|
||||
return ComicInfo.__process_archive(rf)
|
||||
|
||||
@staticmethod
|
||||
def __unpack_zip(path: Path) -> ComicInfo:
|
||||
"""
|
||||
Unpack a CBZ file and create a ComicInfo instance.
|
||||
|
||||
Args:
|
||||
path (Path): Path to the CBZ file.
|
||||
|
||||
Returns:
|
||||
ComicInfo: An instance of ComicInfo.
|
||||
"""
|
||||
with zipfile.ZipFile(path, 'r', zipfile.ZIP_STORED) as zf:
|
||||
return ComicInfo.__process_archive(zf)
|
||||
return cls.from_pages(pages=pages, **comic_kwargs)
|
||||
|
||||
def get_info(self) -> dict:
|
||||
"""
|
||||
Get the comic information as a dictionary.
|
||||
"""Return comic metadata as an XML-ready dictionary.
|
||||
|
||||
Generates a dictionary ready for XML serialization via xmltodict,
|
||||
including comic metadata, file information and page details.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing comic information.
|
||||
Structured dictionary for XML serialization.
|
||||
"""
|
||||
comic_info = _serialize_fields(self, ComicModel)
|
||||
|
||||
def __info(items: dict, fields: dict) -> dict:
|
||||
"""
|
||||
Extract and convert field information from the provided items and fields.
|
||||
|
||||
Args:
|
||||
items (dict): Dictionary containing item attributes.
|
||||
fields (dict): Dictionary containing field mappings and types.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with extracted and converted field information.
|
||||
"""
|
||||
content = {}
|
||||
for key, (field_key, _) in fields.items():
|
||||
item = items.get(key)
|
||||
if item and not (isinstance(item, Enum) and item.name == 'UNKNOWN' or item == -1):
|
||||
content[field_key] = repr_attr(item)
|
||||
return content
|
||||
|
||||
comic_info = __info(
|
||||
items={k: v for k, v in self.__dict__.items() if not k.startswith('_')},
|
||||
fields=COMIC_FIELDS)
|
||||
|
||||
# Build page information
|
||||
comic_pages = []
|
||||
for i, page in enumerate(self.pages):
|
||||
page_info = __info(
|
||||
items={k: v for k, v in page.__dict__.items() if not k.startswith('_')},
|
||||
fields=PAGE_FIELDS)
|
||||
page_info['@Image'] = i
|
||||
page_info = _serialize_fields(page, PageModel)
|
||||
page_info["@Image"] = i
|
||||
comic_pages.append(dict(sorted(page_info.items())))
|
||||
|
||||
# https://github.com/anansi-project/rfcs/issues/3#issuecomment-671631676
|
||||
utcnow = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
|
||||
# File metadata
|
||||
utcnow = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
comic_info.update({
|
||||
'@xmlns:xsd': 'http://www.w3.org/2001/XMLSchema',
|
||||
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
'FileSize': comic_info.get('FileSize', sum(p.image_size for p in self.pages)),
|
||||
'FileCreationTime': comic_info.get('FileCreationTime', utcnow),
|
||||
'FileModifiedTime': comic_info.get('FileModifiedTime', utcnow),
|
||||
'PageCount': len(self.pages),
|
||||
'Pages': {'Page': comic_pages}
|
||||
"@xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
|
||||
"@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||
"FileSize": comic_info.get("FileSize", sum(p.image_size for p in self.pages)),
|
||||
"FileCreationTime": comic_info.get("FileCreationTime", utcnow),
|
||||
"FileModifiedTime": comic_info.get("FileModifiedTime", utcnow),
|
||||
"PageCount": len(self.pages),
|
||||
"Pages": {"Page": comic_pages},
|
||||
})
|
||||
return comic_info
|
||||
|
||||
def pack(self, rename: bool = True) -> bytes:
|
||||
"""
|
||||
Pack the comic information and pages into a CBZ file format.
|
||||
def pack(self, rename: bool = True, compression: int = zipfile.ZIP_STORED) -> bytes:
|
||||
"""Pack the comic into CBZ format (ZIP archive).
|
||||
|
||||
Args:
|
||||
rename (bool): Whether to rename pages to a sequential format (e.g., 'page-001.jpg').
|
||||
rename: If True, rename pages to sequential format (page-001.jpg).
|
||||
compression: ZIP compression method (default: ZIP_STORED).
|
||||
|
||||
Returns:
|
||||
bytes: Bytes representing the packed CBZ file.
|
||||
Binary data of the CBZ file.
|
||||
"""
|
||||
zip_buffer = BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_STORED) as zf:
|
||||
content = xmltodict.unparse({'ComicInfo': self.get_info()}, pretty=True)
|
||||
zf.writestr(
|
||||
XML_NAME,
|
||||
content.replace('></Page>', ' />').encode('utf-8')
|
||||
)
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression) as zf:
|
||||
# Write XML metadata
|
||||
xml_content = xmltodict.unparse({"ComicInfo": self.get_info()}, pretty=True)
|
||||
zf.writestr(XML_NAME, xml_content.replace("></Page>", " />").encode("utf-8"))
|
||||
|
||||
# Write pages
|
||||
for i, page in enumerate(self.pages):
|
||||
name = page.name
|
||||
# If the page does not have a name or renaming is enabled, generate a sequential name for the page.
|
||||
if not name or rename:
|
||||
name = f'page-{i + 1:03d}{page.suffix}'
|
||||
name = f"page-{i + 1:03d}{page.suffix}"
|
||||
zf.writestr(name, page.content)
|
||||
|
||||
packed = zip_buffer.getvalue()
|
||||
zip_buffer.close()
|
||||
return packed
|
||||
data = buf.getvalue()
|
||||
buf.close()
|
||||
return data
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
Display the comic using the Player class.
|
||||
|
||||
This method initializes a Player instance with the comic information
|
||||
and starts the player to show the comic.
|
||||
"""
|
||||
# Avoid circular import
|
||||
"""Display the comic in the built-in graphical viewer."""
|
||||
from cbz.player import Player
|
||||
player = Player(self)
|
||||
player.run()
|
||||
|
||||
def save(self, path: Union[Path, str]) -> None:
|
||||
"""
|
||||
Save the comic book as a CBZ file to the specified path.
|
||||
def save(self, path: Union[Path, str], rename: bool = True,
|
||||
compression: int = zipfile.ZIP_STORED) -> None:
|
||||
"""Save the comic as a CBZ file directly to disk.
|
||||
|
||||
More memory-efficient than pack() for large comics since it
|
||||
writes directly to the file without an intermediate buffer.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): Path where the CBZ file will be saved.
|
||||
path: Destination file path for the .cbz file.
|
||||
rename: If True, rename pages to sequential format (page-001.jpg).
|
||||
compression: ZIP compression method (default: ZIP_STORED).
|
||||
"""
|
||||
with Path(path).open(mode='wb') as f:
|
||||
f.write(self.pack())
|
||||
with zipfile.ZipFile(Path(path), "w", compression) as zf:
|
||||
xml_content = xmltodict.unparse({"ComicInfo": self.get_info()}, pretty=True)
|
||||
zf.writestr(XML_NAME, xml_content.replace("></Page>", " />").encode("utf-8"))
|
||||
|
||||
for i, page in enumerate(self.pages):
|
||||
name = page.name
|
||||
if not name or rename:
|
||||
name = f"page-{i + 1:03d}{page.suffix}"
|
||||
zf.writestr(name, page.content)
|
||||
|
||||
+120
-168
@@ -1,191 +1,143 @@
|
||||
"""
|
||||
Constants and types for the CBZ/ComicInfo format.
|
||||
|
||||
Defines enumerations, supported types and field mappings
|
||||
conforming to the ComicInfo.xml v2.1 schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
from langcodes import Language
|
||||
|
||||
XML_NAME = 'ComicInfo.xml'
|
||||
IMAGE_FORMAT = {
|
||||
'.jpeg', '.jpg', # Joint Photographic Experts Group
|
||||
'.png', # Portable Network Graphics
|
||||
'.gif', # Graphics Interchange Format
|
||||
'.bmp', # Bitmap Image File
|
||||
'.tiff', '.tif', # Tagged Image File Format
|
||||
'.webp' # Web Picture Format
|
||||
}
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
"""Backport of StrEnum for Python < 3.11."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class YesNo(Enum):
|
||||
UNKNOWN = 'Unknown'
|
||||
NO = 'No'
|
||||
YES = 'Yes'
|
||||
# Name of the XML metadata file inside the CBZ archive
|
||||
XML_NAME = "ComicInfo.xml"
|
||||
|
||||
# Supported image formats in CBZ archives
|
||||
IMAGE_FORMATS: frozenset = frozenset({
|
||||
".jpeg", ".jpg", ".png", ".gif", ".bmp",
|
||||
".tiff", ".tif", ".webp", ".jxl", ".avif"
|
||||
})
|
||||
|
||||
|
||||
class Manga(Enum):
|
||||
UNKNOWN = 'Unknown'
|
||||
NO = 'No'
|
||||
YES = 'Yes'
|
||||
RIGHT_LEFT = 'YesAndRightToLeft'
|
||||
class YesNo(StrEnum):
|
||||
"""Ternary boolean: Unknown / No / Yes."""
|
||||
UNKNOWN = "Unknown"
|
||||
NO = "No"
|
||||
YES = "Yes"
|
||||
|
||||
|
||||
class PageType(Enum):
|
||||
FRONT_COVER = 'FrontCover'
|
||||
INNER_COVER = 'InnerCover'
|
||||
ROUNDUP = 'Roundup'
|
||||
STORY = 'Story'
|
||||
ADVERTISEMENT = 'Advertisement'
|
||||
EDITORIAL = 'Editorial'
|
||||
LETTERS = 'Letters'
|
||||
PREVIEW = 'Preview'
|
||||
BACK_COVER = 'BackCover'
|
||||
OTHER = 'Other'
|
||||
DELETED = 'Deleted'
|
||||
class Manga(StrEnum):
|
||||
"""Comic reading direction."""
|
||||
UNKNOWN = "Unknown"
|
||||
NO = "No"
|
||||
YES = "Yes"
|
||||
YES_AND_RIGHT_TO_LEFT = "YesAndRightToLeft"
|
||||
|
||||
|
||||
class AgeRating(Enum):
|
||||
UNKNOWN = 'Unknown'
|
||||
ADULTS18 = 'Adults Only 18+'
|
||||
CHILDHOOD = 'Early Childhood'
|
||||
EVERYONE = 'Everyone'
|
||||
EVERYONE10 = 'Everyone 10+'
|
||||
G = 'G'
|
||||
KIDS = 'Kids to Adults'
|
||||
M = 'M'
|
||||
MA15 = 'MA15+'
|
||||
MATURE17 = 'Mature 17+'
|
||||
PG = 'PG'
|
||||
R18 = 'R18+'
|
||||
PENDING = 'Rating Pending'
|
||||
TEEN = 'Teen'
|
||||
X18 = 'X18+'
|
||||
class PageType(StrEnum):
|
||||
"""Page type within the archive."""
|
||||
FRONT_COVER = "FrontCover"
|
||||
INNER_COVER = "InnerCover"
|
||||
ROUNDUP = "Roundup"
|
||||
STORY = "Story"
|
||||
ADVERTISEMENT = "Advertisement"
|
||||
EDITORIAL = "Editorial"
|
||||
LETTERS = "Letters"
|
||||
PREVIEW = "Preview"
|
||||
BACK_COVER = "BackCover"
|
||||
OTHER = "Other"
|
||||
DELETED = "Deleted"
|
||||
|
||||
|
||||
class Format(Enum):
|
||||
UNKNOWN = 'Unknown'
|
||||
# ONE_SHOT = '1 Shot'
|
||||
# ONE_SHOT = '1/2',
|
||||
# ONE_SHOT = '1-Shot'
|
||||
ANNOTATION = 'Annotation'
|
||||
# ANNOTATIONS = 'Annotations'
|
||||
ANNUAL = 'Annual'
|
||||
ANTHOLOGY = 'Anthology'
|
||||
# BLACK_WHITE = 'B&W'
|
||||
# BLACK_WHITE = 'B/W'
|
||||
# BLACK_WHITE = 'B&&W'
|
||||
BLACK_WHITE = 'Black & White'
|
||||
# BOX_SET = 'Box Set'
|
||||
BOX_SET = 'Box-Set'
|
||||
CROSSOVER = 'Crossover'
|
||||
DIRECTOR_CUT = 'Director\'s Cut'
|
||||
EPILOGUE = 'Epilogue'
|
||||
EVENT = 'Event'
|
||||
FCBD = 'FCBD'
|
||||
FLYER = 'Flyer'
|
||||
# GIANT_SIZE = 'Giant'
|
||||
# GIANT_SIZE = 'Giant Size'
|
||||
GIANT_SIZE = 'Giant-Size'
|
||||
GRAPHIC_NOVEL = 'Graphic Novel'
|
||||
# HARDCOVER = 'Hardcover'
|
||||
HARDCOVER = 'Hard-Cover'
|
||||
# KING_SIZE = 'King'
|
||||
# KING_SIZE = 'King Size'
|
||||
KING_SIZE = 'King-Size'
|
||||
LIMITED_SERIES = 'Limited Series'
|
||||
MAGAZINE = 'Magazine'
|
||||
NSFW = 'NSFW'
|
||||
# ONE_SHOT = 'One Shot'
|
||||
ONE_SHOT = 'One-Shot'
|
||||
POINT1 = 'Point 1'
|
||||
PREVIEW = 'Preview'
|
||||
PROLOGUE = 'Prologue'
|
||||
REFERENCE = 'Reference'
|
||||
REVIEW = 'Review'
|
||||
REVIEWED = 'Reviewed'
|
||||
SCANLATION = 'Scanlation'
|
||||
SCRIPT = 'Script'
|
||||
SERIES = 'Series'
|
||||
SKETCH = 'Sketch'
|
||||
SPECIAL = 'Special'
|
||||
# TRADE_PAPER_BACK = 'TPB'
|
||||
TRADE_PAPER_BACK = 'Trade Paper Back'
|
||||
# WEB_COMIC = 'WebComic'
|
||||
WEB_COMIC = 'Web Comic'
|
||||
# YEAR_ONE = 'Year 1'
|
||||
YEAR_ONE = 'Year One'
|
||||
class AgeRating(StrEnum):
|
||||
"""Content age rating classification."""
|
||||
UNKNOWN = "Unknown"
|
||||
ADULTS_ONLY_18_PLUS = "Adults Only 18+"
|
||||
EARLY_CHILDHOOD = "Early Childhood"
|
||||
EVERYONE = "Everyone"
|
||||
EVERYONE_10_PLUS = "Everyone 10+"
|
||||
G = "G"
|
||||
KIDS_TO_ADULTS = "Kids to Adults"
|
||||
M = "M"
|
||||
MA15_PLUS = "MA15+"
|
||||
MATURE_17_PLUS = "Mature 17+"
|
||||
PG = "PG"
|
||||
R18_PLUS = "R18+"
|
||||
RATING_PENDING = "Rating Pending"
|
||||
TEEN = "Teen"
|
||||
X18_PLUS = "X18+"
|
||||
|
||||
|
||||
class Format(StrEnum):
|
||||
"""Comic publication format."""
|
||||
UNKNOWN = "Unknown"
|
||||
ANNOTATION = "Annotation"
|
||||
ANNUAL = "Annual"
|
||||
ANTHOLOGY = "Anthology"
|
||||
BLACK_AND_WHITE = "Black & White"
|
||||
BOX_SET = "Box-Set"
|
||||
CROSSOVER = "Crossover"
|
||||
DIRECTORS_CUT = "Director's Cut"
|
||||
EPILOGUE = "Epilogue"
|
||||
EVENT = "Event"
|
||||
FCBD = "FCBD"
|
||||
FLYER = "Flyer"
|
||||
GIANT_SIZE = "Giant-Size"
|
||||
GRAPHIC_NOVEL = "Graphic Novel"
|
||||
HARDCOVER = "Hard-Cover"
|
||||
KING_SIZE = "King-Size"
|
||||
LIMITED_SERIES = "Limited Series"
|
||||
MAGAZINE = "Magazine"
|
||||
NSFW = "NSFW"
|
||||
ONE_SHOT = "One-Shot"
|
||||
POINT_ONE = "Point 1"
|
||||
PREVIEW = "Preview"
|
||||
PROLOGUE = "Prologue"
|
||||
REFERENCE = "Reference"
|
||||
REVIEW = "Review"
|
||||
REVIEWED = "Reviewed"
|
||||
SCANLATION = "Scanlation"
|
||||
SCRIPT = "Script"
|
||||
SERIES = "Series"
|
||||
SKETCH = "Sketch"
|
||||
SPECIAL = "Special"
|
||||
TRADE_PAPERBACK = "Trade Paper Back"
|
||||
WEB_COMIC = "Web Comic"
|
||||
YEAR_ONE = "Year One"
|
||||
|
||||
|
||||
class Rating(float):
|
||||
"""Community rating between 0.0 and 5.0 (None if unset)."""
|
||||
|
||||
def __new__(cls, value: Union[int, float] = -1) -> float:
|
||||
assert -1 <= float(value) <= 5, f'Rating must be between 0 and 5, input {value}'
|
||||
return super().__new__(cls, value)
|
||||
def __new__(cls, value: Union[int, float] = 0.0) -> Rating:
|
||||
val = float(value)
|
||||
if not (0.0 <= val <= 5.0):
|
||||
raise ValueError(f"Rating must be between 0.0 and 5.0, got {value}")
|
||||
return super().__new__(cls, val)
|
||||
|
||||
|
||||
class LanguageISO(str):
|
||||
"""Valid ISO language code, validated via the langcodes library."""
|
||||
|
||||
def __new__(cls, value: str = '') -> str:
|
||||
assert not value or Language.get(str(value)).is_valid(), f'Invalid {value} language'
|
||||
return super().__new__(cls, value)
|
||||
|
||||
|
||||
COMIC_FIELDS = {
|
||||
'title': ('Title', str),
|
||||
'series': ('Series', str),
|
||||
'number': ('Number', int),
|
||||
'count': ('Count', int),
|
||||
'volume': ('Volume', int),
|
||||
'alternate_series': ('AlternateSeries', str),
|
||||
'alternate_number': ('AlternateNumber', int),
|
||||
'alternate_count': ('AlternateCount', int),
|
||||
'summary': ('Summary', str),
|
||||
'notes': ('Notes', str),
|
||||
'year': ('Year', int),
|
||||
'month': ('Month', int),
|
||||
'day': ('Day', int),
|
||||
'writer': ('Writer', str),
|
||||
'penciller': ('Penciller', str),
|
||||
'inker': ('Inker', str),
|
||||
'colorist': ('Colorist', str),
|
||||
'letterer': ('Letterer', str),
|
||||
'cover_artist': ('CoverArtist', str),
|
||||
'editor': ('Editor', str),
|
||||
'translator': ('Translator', str),
|
||||
'publisher': ('Publisher', str),
|
||||
'imprint': ('Imprint', str),
|
||||
'genre': ('Genre', str),
|
||||
'tags': ('Tags', str),
|
||||
'web': ('Web', str),
|
||||
'format': ('Format', Format),
|
||||
'ean': ('EAN', str),
|
||||
'black_white': ('BlackAndWhite', YesNo),
|
||||
'manga': ('Manga', Manga),
|
||||
'characters': ('Characters', str),
|
||||
'teams': ('Teams', str),
|
||||
'locations': ('Locations', str),
|
||||
'scan_information': ('ScanInformation', str),
|
||||
'story_arc': ('StoryArc', str),
|
||||
'story_arc_number': ('StoryArcNumber', int),
|
||||
'series_group': ('SeriesGroup', str),
|
||||
'age_rating': ('AgeRating', AgeRating),
|
||||
'main_character_or_team': ('MainCharacterOrTeam', str),
|
||||
'review': ('Review', str),
|
||||
'language_iso': ('LanguageISO', LanguageISO),
|
||||
'community_rating': ('CommunityRating', Rating),
|
||||
'added': ('Added', str),
|
||||
'released': ('Released', str),
|
||||
'file_size': ('FileSize', int),
|
||||
'file_modified_time': ('FileModifiedTime', str),
|
||||
'file_creation_time': ('FileCreationTime', str),
|
||||
'book_price': ('BookPrice', str),
|
||||
'custom_values_store': ('CustomValuesStore', str)
|
||||
}
|
||||
|
||||
PAGE_FIELDS = {
|
||||
'image': ('@Image', int),
|
||||
'type': ('@Type', PageType),
|
||||
'double': ('@DoublePage', bool),
|
||||
'key': ('@Key', str),
|
||||
'bookmark': ('@Bookmark', str),
|
||||
'image_size': ('@ImageSize', int),
|
||||
'image_width': ('@ImageWidth', int),
|
||||
'image_height': ('@ImageHeight', int)
|
||||
}
|
||||
def __new__(cls, value: str = "") -> LanguageISO:
|
||||
val = str(value)
|
||||
if val:
|
||||
try:
|
||||
if not Language.get(val).is_valid():
|
||||
raise ValueError(f"Invalid ISO language code: {value!r}")
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid ISO language code: {value!r}") from e
|
||||
return super().__new__(cls, val)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""CBZ library-specific exceptions."""
|
||||
|
||||
|
||||
class CBZError(Exception):
|
||||
"""Base class for all CBZ errors."""
|
||||
|
||||
|
||||
class InvalidImageError(CBZError):
|
||||
"""The provided image is invalid or in an unsupported format."""
|
||||
|
||||
|
||||
class EmptyArchiveError(CBZError):
|
||||
"""The archive contains no valid images."""
|
||||
|
||||
|
||||
class InvalidMetadataError(CBZError):
|
||||
"""The ComicInfo.xml metadata is invalid or corrupted."""
|
||||
|
||||
|
||||
class UnsupportedFormatError(CBZError):
|
||||
"""The file format is not supported."""
|
||||
+111
-134
@@ -1,145 +1,122 @@
|
||||
from cbz.constants import COMIC_FIELDS, PAGE_FIELDS, Format, YesNo, Manga, AgeRating, LanguageISO, Rating, PageType
|
||||
from cbz.utils import verify_attr, default_attr
|
||||
"""
|
||||
Data models for ComicInfo metadata.
|
||||
|
||||
Uses dataclasses with field metadata for automatic mapping
|
||||
to/from the ComicInfo XML format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import Any, Optional
|
||||
|
||||
from cbz.constants import (
|
||||
AgeRating,
|
||||
Format,
|
||||
LanguageISO,
|
||||
Manga,
|
||||
PageType,
|
||||
Rating,
|
||||
YesNo
|
||||
)
|
||||
|
||||
|
||||
class BaseModel:
|
||||
def xml_field(xml_name: str, is_attribute: bool = False, **kwargs: Any) -> Any:
|
||||
"""Create a dataclass field with XML mapping metadata.
|
||||
|
||||
def __init__(self, fields: dict, **kwargs):
|
||||
"""
|
||||
Initializes the BaseModel instance.
|
||||
|
||||
Args:
|
||||
fields (dict): A dictionary mapping attribute names to tuples containing attribute display names
|
||||
and their expected types.
|
||||
**kwargs: Additional keyword arguments for initializing attributes.
|
||||
|
||||
Attributes:
|
||||
__fields (dict): Stores the fields dictionary passed during initialization.
|
||||
"""
|
||||
self.__fields = fields
|
||||
for key, (_, field_type) in self.__fields.items():
|
||||
# Set default values for each attribute based on its type
|
||||
setattr(self, key, kwargs.get(key, default_attr(field_type)))
|
||||
|
||||
def __setattr__(self, key: str, value: any) -> None:
|
||||
"""
|
||||
Sets the value of an attribute and verifies its type.
|
||||
|
||||
Args:
|
||||
key (str): The name of the attribute to set.
|
||||
value (any): The value to assign to the attribute.
|
||||
|
||||
Raises:
|
||||
TypeError: If the assigned value does not match the expected type for the attribute.
|
||||
"""
|
||||
try:
|
||||
field_type = self.__fields[key][1]
|
||||
# Convert value to the specified type if necessary
|
||||
if field_type not in (int, str, bool):
|
||||
value = field_type(value)
|
||||
# Verify that the assigned value matches the expected type
|
||||
verify_attr(field_type, key, value)
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
super().__setattr__(key, value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the object.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the object, displaying its class name and attribute key-value pairs.
|
||||
"""
|
||||
return '{name}({items})'.format(
|
||||
name=self.__class__.__name__,
|
||||
items=', '.join([f'{k}={repr(v)}' for k, v in self.__dict__.items() if not k.startswith('_')])
|
||||
)
|
||||
|
||||
|
||||
class ComicModel(BaseModel):
|
||||
Args:
|
||||
xml_name: Corresponding XML element or attribute name.
|
||||
is_attribute: True if this is an XML attribute (@ prefix), False for an element.
|
||||
**kwargs: Additional arguments passed to dataclasses.field().
|
||||
"""
|
||||
Model for representing comic book metadata.
|
||||
metadata = {"xml_name": xml_name, "is_attribute": is_attribute}
|
||||
return field(metadata=metadata, **kwargs)
|
||||
|
||||
|
||||
def _get_xml_mapping(cls: type) -> dict:
|
||||
"""Return the mapping {python_name: (xml_name, type, is_attribute)} for a dataclass."""
|
||||
mapping = {}
|
||||
for f in fields(cls):
|
||||
if "xml_name" in f.metadata:
|
||||
mapping[f.name] = (f.metadata["xml_name"], f.type, f.metadata.get("is_attribute", False))
|
||||
return mapping
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageModel:
|
||||
"""Data model for a comic page.
|
||||
|
||||
Each field corresponds to an XML attribute in the <Page/> element.
|
||||
Default values represent the absence of data.
|
||||
"""
|
||||
title: str
|
||||
series: str
|
||||
number: int
|
||||
count: int
|
||||
volume: int
|
||||
alternate_series: str
|
||||
alternate_number: int
|
||||
alternate_count: int
|
||||
summary: str
|
||||
notes: str
|
||||
year: int
|
||||
month: int
|
||||
day: int
|
||||
writer: str
|
||||
penciller: str
|
||||
inker: str
|
||||
colorist: str
|
||||
letterer: str
|
||||
cover_artist: str
|
||||
editor: str
|
||||
translator: str
|
||||
publisher: str
|
||||
imprint: str
|
||||
genre: str
|
||||
tags: str
|
||||
web: str
|
||||
format: Format
|
||||
ean: str
|
||||
black_white: YesNo
|
||||
manga: Manga
|
||||
characters: str
|
||||
teams: str
|
||||
locations: str
|
||||
scan_information: str
|
||||
story_arc: str
|
||||
story_arc_number: int
|
||||
series_group: str
|
||||
age_rating: AgeRating
|
||||
main_character_or_team: str
|
||||
review: str
|
||||
language_iso: LanguageISO
|
||||
community_rating: Rating
|
||||
added: str
|
||||
released: str
|
||||
file_size: int
|
||||
file_modified_time: str
|
||||
file_creation_time: str
|
||||
book_price: str
|
||||
custom_values_store: str
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes a ComicModel instance.
|
||||
type: PageType = xml_field("@Type", is_attribute=True, default=PageType.STORY)
|
||||
double: bool = xml_field("@DoublePage", is_attribute=True, default=False)
|
||||
image_size: int = xml_field("@ImageSize", is_attribute=True, default=0)
|
||||
key: str = xml_field("@Key", is_attribute=True, default="")
|
||||
bookmark: str = xml_field("@Bookmark", is_attribute=True, default="")
|
||||
image_width: int = xml_field("@ImageWidth", is_attribute=True, default=0)
|
||||
image_height: int = xml_field("@ImageHeight", is_attribute=True, default=0)
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments used to initialize attributes of the ComicModel.
|
||||
"""
|
||||
super(ComicModel, self).__init__(COMIC_FIELDS, **kwargs)
|
||||
# Internal fields (not mapped to XML)
|
||||
suffix: str = field(default="", repr=False)
|
||||
name: str = field(default="", repr=False)
|
||||
|
||||
|
||||
class PageModel(BaseModel):
|
||||
@dataclass
|
||||
class ComicModel:
|
||||
"""Data model for comic metadata.
|
||||
|
||||
Each field corresponds to an XML element in <ComicInfo>.
|
||||
None values indicate missing data (not serialized to XML).
|
||||
"""
|
||||
Model for representing comic book pages.
|
||||
"""
|
||||
type: PageType
|
||||
double: bool
|
||||
image_size: int
|
||||
key: str
|
||||
bookmark: str
|
||||
image_width: int
|
||||
image_height: int
|
||||
suffix: str
|
||||
name: str
|
||||
__content: bytes
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes a PageModel instance.
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments used to initialize attributes of the PageModel.
|
||||
"""
|
||||
super(PageModel, self).__init__(PAGE_FIELDS, **kwargs)
|
||||
title: str = xml_field("Title", default="")
|
||||
series: str = xml_field("Series", default="")
|
||||
number: Optional[int] = xml_field("Number", default=None)
|
||||
count: Optional[int] = xml_field("Count", default=None)
|
||||
volume: Optional[int] = xml_field("Volume", default=None)
|
||||
alternate_series: str = xml_field("AlternateSeries", default="")
|
||||
alternate_number: Optional[int] = xml_field("AlternateNumber", default=None)
|
||||
alternate_count: Optional[int] = xml_field("AlternateCount", default=None)
|
||||
summary: str = xml_field("Summary", default="")
|
||||
notes: str = xml_field("Notes", default="")
|
||||
year: Optional[int] = xml_field("Year", default=None)
|
||||
month: Optional[int] = xml_field("Month", default=None)
|
||||
day: Optional[int] = xml_field("Day", default=None)
|
||||
writer: str = xml_field("Writer", default="")
|
||||
penciller: str = xml_field("Penciller", default="")
|
||||
inker: str = xml_field("Inker", default="")
|
||||
colorist: str = xml_field("Colorist", default="")
|
||||
letterer: str = xml_field("Letterer", default="")
|
||||
cover_artist: str = xml_field("CoverArtist", default="")
|
||||
editor: str = xml_field("Editor", default="")
|
||||
translator: str = xml_field("Translator", default="")
|
||||
publisher: str = xml_field("Publisher", default="")
|
||||
imprint: str = xml_field("Imprint", default="")
|
||||
genre: str = xml_field("Genre", default="")
|
||||
tags: str = xml_field("Tags", default="")
|
||||
web: str = xml_field("Web", default="")
|
||||
format: Format = xml_field("Format", default=Format.UNKNOWN)
|
||||
ean: str = xml_field("EAN", default="")
|
||||
black_white: YesNo = xml_field("BlackAndWhite", default=YesNo.UNKNOWN)
|
||||
manga: Manga = xml_field("Manga", default=Manga.UNKNOWN)
|
||||
characters: str = xml_field("Characters", default="")
|
||||
teams: str = xml_field("Teams", default="")
|
||||
locations: str = xml_field("Locations", default="")
|
||||
scan_information: str = xml_field("ScanInformation", default="")
|
||||
story_arc: str = xml_field("StoryArc", default="")
|
||||
story_arc_number: Optional[int] = xml_field("StoryArcNumber", default=None)
|
||||
series_group: str = xml_field("SeriesGroup", default="")
|
||||
age_rating: AgeRating = xml_field("AgeRating", default=AgeRating.UNKNOWN)
|
||||
main_character_or_team: str = xml_field("MainCharacterOrTeam", default="")
|
||||
review: str = xml_field("Review", default="")
|
||||
language_iso: LanguageISO = xml_field("LanguageISO", default=LanguageISO(""))
|
||||
community_rating: Optional[Rating] = xml_field("CommunityRating", default=None)
|
||||
added: str = xml_field("Added", default="")
|
||||
released: str = xml_field("Released", default="")
|
||||
file_size: Optional[int] = xml_field("FileSize", default=None)
|
||||
file_modified_time: str = xml_field("FileModifiedTime", default="")
|
||||
file_creation_time: str = xml_field("FileCreationTime", default="")
|
||||
book_price: str = xml_field("BookPrice", default="")
|
||||
custom_values_store: str = xml_field("CustomValuesStore", default="")
|
||||
|
||||
+68
-66
@@ -1,118 +1,120 @@
|
||||
"""
|
||||
Comic page management module.
|
||||
|
||||
Provides the PageInfo class to load, manipulate and save
|
||||
individual comic pages (images).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from io import BytesIO
|
||||
from typing import Union, Optional
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cbz.constants import IMAGE_FORMAT
|
||||
from cbz.constants import IMAGE_FORMATS
|
||||
from cbz.exceptions import InvalidImageError
|
||||
from cbz.models import PageModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageInfo(PageModel):
|
||||
"""
|
||||
Model for representing comic book pages with additional content handling capabilities.
|
||||
"""Represents a comic page with its image content.
|
||||
|
||||
Inherits from PageModel for XML metadata and adds binary image
|
||||
content management. Image metadata (dimensions, size, format)
|
||||
is automatically extracted when content is assigned.
|
||||
|
||||
Attributes:
|
||||
_content: Binary image data (accessed via the content property).
|
||||
"""
|
||||
|
||||
def __init__(self, content: bytes, name: Optional[str] = None, **kwargs):
|
||||
"""
|
||||
Initializes a PageInfo instance.
|
||||
|
||||
Args:
|
||||
content (bytes): The content of the page in bytes.
|
||||
name (Optional[str]): The name of the page (default is None).
|
||||
**kwargs: Additional keyword arguments passed to the base class initializer.
|
||||
"""
|
||||
super(PageInfo, self).__init__(**kwargs)
|
||||
self.name = name
|
||||
self.content = content
|
||||
_content: bytes = field(default=b"", repr=False, compare=False)
|
||||
|
||||
@property
|
||||
def content(self) -> bytes:
|
||||
"""
|
||||
Getter property for the content of the page.
|
||||
|
||||
Returns:
|
||||
bytes: The content of the page.
|
||||
"""
|
||||
return self.__content
|
||||
"""Binary image data."""
|
||||
return self._content
|
||||
|
||||
@content.setter
|
||||
def content(self, value: bytes) -> None:
|
||||
"""
|
||||
Setter property for the content of the page. Automatically extracts image metadata.
|
||||
|
||||
Args:
|
||||
value (bytes): The content of the page in bytes.
|
||||
"""
|
||||
with Image.open(BytesIO(value)) as f:
|
||||
self.suffix = f'.{f.format.lower()}'
|
||||
assert self.suffix in IMAGE_FORMAT, f'Unsupported image format: {self.suffix}'
|
||||
self.image_width = int(f.width)
|
||||
self.image_height = int(f.height)
|
||||
"""Set content and automatically extract image metadata."""
|
||||
try:
|
||||
with Image.open(BytesIO(value)) as img:
|
||||
self.suffix = f".{img.format.lower()}"
|
||||
if self.suffix not in IMAGE_FORMATS:
|
||||
raise InvalidImageError(f"Unsupported image format: {self.suffix}")
|
||||
self.image_width = img.width
|
||||
self.image_height = img.height
|
||||
except InvalidImageError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise InvalidImageError(f"Unable to read image: {e}") from e
|
||||
self.image_size = len(value)
|
||||
self.__content = value
|
||||
self._content = value
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate content if provided at initialization."""
|
||||
if self._content:
|
||||
self.content = self._content
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes], **kwargs) -> PageInfo:
|
||||
"""
|
||||
Class method to create a PageInfo instance from bytes or base64-encoded data.
|
||||
"""Create a PageInfo from raw bytes or base64-encoded data.
|
||||
|
||||
Args:
|
||||
data (Union[str, bytes]): The data representing the page content.
|
||||
**kwargs: Additional keyword arguments passed to the PageInfo initializer.
|
||||
data: Binary image data or base64-encoded string.
|
||||
**kwargs: Additional attributes (type, bookmark, etc.).
|
||||
|
||||
Returns:
|
||||
PageInfo: The created PageInfo instance.
|
||||
PageInfo instance with loaded content.
|
||||
|
||||
Raises:
|
||||
ValueError: If the data type is neither str nor bytes.
|
||||
InvalidImageError: If the data is empty or invalid.
|
||||
ValueError: If the data type is not str or bytes.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f'Expecting Bytes or Base64 input, got {data!r}')
|
||||
return cls(data, **kwargs)
|
||||
raise ValueError(f"Expected bytes or base64 str, got {type(data).__name__}")
|
||||
if not data or data.isspace():
|
||||
raise InvalidImageError("Empty or null image data")
|
||||
|
||||
page = cls(**kwargs)
|
||||
page.content = data
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str], **kwargs) -> PageInfo:
|
||||
"""
|
||||
Class method to create a PageInfo instance from a file path.
|
||||
"""Create a PageInfo from an image file.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): The path to the file containing the page content.
|
||||
**kwargs: Additional keyword arguments passed to the PageInfo initializer.
|
||||
path: Path to the image file.
|
||||
**kwargs: Additional attributes (type, bookmark, etc.).
|
||||
|
||||
Returns:
|
||||
PageInfo: The created PageInfo instance.
|
||||
PageInfo instance with file content loaded.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path type is neither Path nor str.
|
||||
FileNotFoundError: If the specified file path does not exist.
|
||||
FileNotFoundError: If the file does not exist.
|
||||
InvalidImageError: If the image is invalid.
|
||||
"""
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f'Expecting Path object or path string, got {path!r}')
|
||||
path = Path(path)
|
||||
with path.open(mode='rb') as f:
|
||||
kwargs.setdefault('name', path.name)
|
||||
return cls(f.read(), **kwargs)
|
||||
kwargs.setdefault("name", path.name)
|
||||
return cls.loads(path.read_bytes(), **kwargs)
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
Displays the page content using an image viewer.
|
||||
"""
|
||||
with Image.open(BytesIO(self.content)) as f:
|
||||
f.show()
|
||||
"""Display the page in the default image viewer."""
|
||||
with Image.open(BytesIO(self.content)) as img:
|
||||
img.show()
|
||||
|
||||
def save(self, path: Union[Path, str]) -> None:
|
||||
"""
|
||||
Saves the page content to a file.
|
||||
"""Save the page to a file.
|
||||
|
||||
Args:
|
||||
path (Union[Path, str]): The path where the content should be saved.
|
||||
path: Destination file path.
|
||||
"""
|
||||
with Path(path).open(mode='wb') as f:
|
||||
f.write(self.content)
|
||||
Path(path).write_bytes(self.content)
|
||||
|
||||
+152
-262
@@ -1,14 +1,23 @@
|
||||
"""
|
||||
Tkinter-based graphical comic reader.
|
||||
|
||||
Provides a viewing interface with page navigation, zoom,
|
||||
scrolling and metadata display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
|
||||
from io import BytesIO
|
||||
from tkinter import ttk
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from ctypes import windll
|
||||
except ImportError:
|
||||
windll = None # windll is not available on this platform
|
||||
windll = None
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
@@ -17,407 +26,288 @@ from cbz.page import PageInfo
|
||||
from cbz.utils import readable_size, ico_to_png
|
||||
|
||||
PARENT = Path(__file__).parent
|
||||
CTRL_KEY = 0x4 # Constant for Ctrl key
|
||||
CTRL_KEY = 0x4 # Tkinter event.state bitmask for the Ctrl modifier
|
||||
|
||||
|
||||
class Player:
|
||||
"""
|
||||
A simple comic book player application using Tkinter.
|
||||
"""Comic reader with Tkinter graphical interface.
|
||||
|
||||
This class initializes a graphical user interface (GUI) using Tkinter
|
||||
to display comic book pages (either as images or summaries). It supports
|
||||
navigation between pages, window resizing, and basic comic book information
|
||||
display.
|
||||
Displays comic pages with navigation, zoom and
|
||||
a metadata summary page.
|
||||
|
||||
Attributes:
|
||||
comic_info (ComicInfo): Object containing comic book information.
|
||||
current_page (int): Index of the currently displayed page (-1 for summary).
|
||||
root (tk.Tk): Tkinter root window instance.
|
||||
main_frame (ttk.Frame): Frame for holding main content.
|
||||
canvas (tk.Canvas): Canvas for displaying comic book pages.
|
||||
prev_button (ttk.Button): Button for navigating to the previous page.
|
||||
next_button (ttk.Button): Button for navigating to the next page.
|
||||
summary_text (tk.Text): Text widget for displaying comic book information summary.
|
||||
page_index_label (ttk.Label): Label showing the current page index.
|
||||
previous_width (int): Previous width of the main window.
|
||||
previous_height (int): Previous height of the main window.
|
||||
resize_timer (int or None): Timer ID for handling window resize delay.
|
||||
max_zoom_factor (float or None): Maximum allowable zoom factor based on canvas size and image dimensions.
|
||||
zoom_factor (float or None): Current zoom factor for image display.
|
||||
img_original (PIL.Image.Image or None): Placeholder for the original image to be displayed.
|
||||
comic: The comic to display.
|
||||
current_page: Index of the current page (-1 = summary).
|
||||
root: Main Tkinter window.
|
||||
"""
|
||||
|
||||
def __init__(self, comic_info: ComicInfo):
|
||||
"""
|
||||
Initialize the comic player with the given ComicInfo object.
|
||||
def __init__(self, comic: ComicInfo) -> None:
|
||||
"""Initialize the reader with a comic.
|
||||
|
||||
Args:
|
||||
comic_info (ComicInfo): Object containing comic book information.
|
||||
comic: ComicInfo instance to display.
|
||||
"""
|
||||
self.comic_info = comic_info
|
||||
self.current_page = -1 # Track the current page index
|
||||
self.max_zoom_factor = None # Maximum allowable zoom factor based on canvas size and image dimensions
|
||||
self.zoom_factor = None # Initial zoom factor (None means no zoom initially)
|
||||
self.img_original = None # Placeholder for the original image
|
||||
self.comic = comic
|
||||
self.current_page: int = -1
|
||||
self.max_zoom_factor: Optional[float] = None
|
||||
self.zoom_factor: Optional[float] = None
|
||||
self.img_original: Optional[Image.Image] = None
|
||||
|
||||
# Initialize tkinter window
|
||||
# Window initialization
|
||||
self.root = tk.Tk()
|
||||
self.root.title(self._get_window_title())
|
||||
|
||||
# Set initial window size
|
||||
self.root.geometry(self._get_initial_geometry())
|
||||
|
||||
# Set the application icon
|
||||
self._set_icon()
|
||||
|
||||
# Create main frame for content
|
||||
# Main frame
|
||||
self.main_frame = ttk.Frame(self.root)
|
||||
self.main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create canvas for displaying pages
|
||||
self.canvas = tk.Canvas(self.main_frame, bg='white')
|
||||
# Display canvas
|
||||
self.canvas = tk.Canvas(self.main_frame, bg="white")
|
||||
self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Create navigation buttons
|
||||
# Navigation buttons
|
||||
self._create_buttons()
|
||||
|
||||
# Initialize text widget for the summary page
|
||||
# Summary text widget
|
||||
self._init_summary_text()
|
||||
|
||||
# Display initial page content
|
||||
# Initial display
|
||||
self.show_page()
|
||||
|
||||
# Bind keys for navigation and window resize
|
||||
# Keyboard shortcuts and events
|
||||
self._bind_keys()
|
||||
|
||||
# Initialize the previous window size
|
||||
# Resize tracking
|
||||
self.previous_width = self.root.winfo_width()
|
||||
self.previous_height = self.root.winfo_height()
|
||||
|
||||
# Initialize resize timer
|
||||
self.resize_timer = None
|
||||
self.resize_timer: Optional[str] = None
|
||||
|
||||
def _set_icon(self) -> None:
|
||||
"""
|
||||
Set the application icon for the window.
|
||||
"""
|
||||
image_path = PARENT / 'cbz.ico'
|
||||
if os.name == 'nt':
|
||||
# Set the application ID and icon on Windows
|
||||
windll.shell32.SetCurrentProcessExplicitAppUserModelID('cbz.player')
|
||||
self.root.iconbitmap(image_path)
|
||||
"""Set the application icon."""
|
||||
icon_path = PARENT / "cbz.ico"
|
||||
if os.name == "nt" and windll is not None:
|
||||
windll.shell32.SetCurrentProcessExplicitAppUserModelID("cbz.player")
|
||||
self.root.iconbitmap(icon_path)
|
||||
else:
|
||||
# Convert ICO to PNG for other operating systems
|
||||
image = ico_to_png(image_path)
|
||||
image = ico_to_png(icon_path)
|
||||
self.root.iconphoto(True, tk.PhotoImage(data=image.getvalue()))
|
||||
|
||||
def _get_window_title(self) -> str:
|
||||
"""
|
||||
Get the window title based on the comic series and title.
|
||||
|
||||
Returns:
|
||||
str: Window title.
|
||||
"""
|
||||
if self.comic_info.series and self.comic_info.title:
|
||||
return f'{self.comic_info.series} - {self.comic_info.title}'
|
||||
return self.comic_info.title or self.__class__.__name__
|
||||
"""Build the window title."""
|
||||
if self.comic.series and self.comic.title:
|
||||
return f"{self.comic.series} - {self.comic.title}"
|
||||
return self.comic.title or self.__class__.__name__
|
||||
|
||||
def _get_initial_geometry(self) -> str:
|
||||
"""
|
||||
Calculate the initial window size and position based on the first page of the comic or default screen size.
|
||||
"""Calculate the initial window geometry."""
|
||||
margin_w = self.root.winfo_screenwidth() * 0.1
|
||||
margin_h = self.root.winfo_screenheight() * 0.1
|
||||
avail_w = self.root.winfo_screenwidth() - 2 * margin_w
|
||||
avail_h = self.root.winfo_screenheight() - 2 * margin_h
|
||||
|
||||
Returns:
|
||||
str: Initial window geometry string in the format 'widthxheight+x_position+y_position'.
|
||||
"""
|
||||
# Determine initial window size based on first page or default
|
||||
margin_width = self.root.winfo_screenwidth() * 0.1
|
||||
margin_height = self.root.winfo_screenheight() * 0.1
|
||||
screen_w = int(avail_h * 0.63)
|
||||
screen_h = int(avail_h)
|
||||
|
||||
# Calculate available space after margins
|
||||
available_width = self.root.winfo_screenwidth() - 2 * margin_width
|
||||
available_height = self.root.winfo_screenheight() - 2 * margin_height
|
||||
if self.comic.pages:
|
||||
# Use minimum dimensions across all pages
|
||||
img_w = self.comic.pages[0].image_width
|
||||
img_h = self.comic.pages[0].image_height
|
||||
for page in self.comic.pages[1:]:
|
||||
if page.image_width <= img_w and page.image_height <= img_h:
|
||||
img_w = page.image_width
|
||||
img_h = page.image_height
|
||||
|
||||
# Default screen size for initialization
|
||||
screen_width = int(available_height * 0.63)
|
||||
screen_height = int(available_height)
|
||||
|
||||
# Calculate scaling factors for width and height
|
||||
if self.comic_info.pages:
|
||||
# Get the minimum dimensions from all pages
|
||||
image_width = self.comic_info.pages[0].image_width
|
||||
image_height = self.comic_info.pages[0].image_height
|
||||
for page in self.comic_info.pages[1:]:
|
||||
if page.image_width <= image_width and page.image_height <= image_height:
|
||||
image_width = page.image_width
|
||||
image_height = page.image_height
|
||||
|
||||
scale_width = available_width / image_width
|
||||
scale_height = available_height / image_height
|
||||
|
||||
# Choose the smaller scaling factor to maintain proportions
|
||||
scale_factor = min(scale_width, scale_height)
|
||||
|
||||
# Calculate new dimensions
|
||||
initial_width = int(image_width * scale_factor * 0.94)
|
||||
initial_height = int(image_height * scale_factor)
|
||||
scale = min(avail_w / img_w, avail_h / img_h)
|
||||
init_w = int(img_w * scale * 0.94)
|
||||
init_h = int(img_h * scale)
|
||||
else:
|
||||
# If no pages are available, default to screen size
|
||||
initial_width = screen_width
|
||||
initial_height = screen_height
|
||||
init_w = screen_w
|
||||
init_h = screen_h
|
||||
|
||||
# Ensure that the window's initial width is at least 50% of the screen width
|
||||
if 100 / screen_width * initial_width < 50.0:
|
||||
initial_width = screen_width
|
||||
if 100 / screen_w * init_w < 50.0:
|
||||
init_w = screen_w
|
||||
|
||||
# Calculate initial position to center the window on the screen
|
||||
x = self.root.winfo_screenwidth() // 2 - initial_width // 2
|
||||
y = self.root.winfo_screenheight() // 2 - initial_height // 2
|
||||
|
||||
return f'{initial_width}x{initial_height}+{x}+{y}'
|
||||
x = self.root.winfo_screenwidth() // 2 - init_w // 2
|
||||
y = self.root.winfo_screenheight() // 2 - init_h // 2
|
||||
return f"{init_w}x{init_h}+{x}+{y}"
|
||||
|
||||
def _create_buttons(self) -> None:
|
||||
"""
|
||||
Create navigation buttons for previous, next, and exit operations.
|
||||
"""
|
||||
# Create button frame
|
||||
"""Create navigation buttons."""
|
||||
button_frame = ttk.Frame(self.main_frame, height=10)
|
||||
button_frame.pack(side=tk.BOTTOM, pady=10)
|
||||
|
||||
# Previous page button
|
||||
self.prev_button = ttk.Button(button_frame, text='Previous', command=self.on_previous)
|
||||
self.prev_button = ttk.Button(button_frame, text="Previous", command=self.on_previous)
|
||||
self.prev_button.pack(side=tk.LEFT, padx=10)
|
||||
|
||||
# Page index label
|
||||
self.page_index_label = ttk.Label(button_frame, text=f'{self.current_page + 1}/{len(self.comic_info.pages)}')
|
||||
self.page_index_label = ttk.Label(
|
||||
button_frame, text=f"{self.current_page + 1}/{len(self.comic)}"
|
||||
)
|
||||
self.page_index_label.pack(side=tk.LEFT, padx=10)
|
||||
|
||||
# Next page button
|
||||
self.next_button = ttk.Button(button_frame, text='Next', command=self.one_next)
|
||||
self.next_button = ttk.Button(button_frame, text="Next", command=self.on_next)
|
||||
self.next_button.pack(side=tk.LEFT, padx=10)
|
||||
|
||||
# Exit button (optional)
|
||||
exit_button = ttk.Button(button_frame, text='Exit', command=self.root.quit)
|
||||
exit_button = ttk.Button(button_frame, text="Exit", command=self.root.quit)
|
||||
exit_button.pack(side=tk.RIGHT, padx=10)
|
||||
|
||||
def _init_summary_text(self) -> None:
|
||||
"""
|
||||
Initialize the summary text widget for displaying comic information.
|
||||
"""
|
||||
self.summary_text = tk.Text(self.canvas, wrap=tk.WORD, bg='white', bd=0, padx=10, pady=10)
|
||||
self.summary_text.tag_configure('bold', font=('Arial', 13, 'bold'))
|
||||
self.summary_text.tag_configure('normal', font=('Arial', 12), lmargin1=10, lmargin2=10)
|
||||
"""Initialize the metadata summary text widget."""
|
||||
self.summary_text = tk.Text(self.canvas, wrap=tk.WORD, bg="white", bd=0, padx=10, pady=10)
|
||||
self.summary_text.tag_configure("bold", font=("Arial", 13, "bold"))
|
||||
self.summary_text.tag_configure("normal", font=("Arial", 12), lmargin1=10, lmargin2=10)
|
||||
self.summary_text.config(state=tk.DISABLED)
|
||||
|
||||
def show_page(self) -> None:
|
||||
"""
|
||||
Display the current page content (either summary or image).
|
||||
"""
|
||||
# Clear previous content on canvas
|
||||
self.canvas.delete('all')
|
||||
"""Display the current page (summary or image)."""
|
||||
self.canvas.delete("all")
|
||||
|
||||
# Display page content
|
||||
if self.current_page == -1:
|
||||
self._show_summary_page()
|
||||
elif self.current_page < len(self.comic_info.pages):
|
||||
elif self.current_page < len(self.comic):
|
||||
self._show_image_page()
|
||||
|
||||
# Update navigation button states based on current page index
|
||||
self.prev_button.config(state=tk.DISABLED if self.current_page == -1 else tk.NORMAL)
|
||||
self.next_button.config(state=tk.DISABLED if self.current_page + 1 == len(self.comic_info.pages) else tk.NORMAL)
|
||||
|
||||
# Update page index label text
|
||||
self.page_index_label.config(text=f'{self.current_page + 1}/{len(self.comic_info.pages)}')
|
||||
self.next_button.config(
|
||||
state=tk.DISABLED if self.current_page + 1 == len(self.comic) else tk.NORMAL
|
||||
)
|
||||
self.page_index_label.config(text=f"{self.current_page + 1}/{len(self.comic)}")
|
||||
|
||||
def _show_summary_page(self) -> None:
|
||||
"""
|
||||
Display the summary page with comic information.
|
||||
"""
|
||||
# Use update_idletasks to ensure window size is updated before using it
|
||||
"""Display the summary page with metadata."""
|
||||
self.root.update_idletasks()
|
||||
|
||||
# Display summary for the first page
|
||||
infos = self.comic_info.get_info()
|
||||
infos = self.comic.get_info()
|
||||
|
||||
self.summary_text.config(state=tk.NORMAL)
|
||||
self.summary_text.delete('1.0', tk.END)
|
||||
self.summary_text.delete("1.0", tk.END)
|
||||
|
||||
for key, value in infos.items():
|
||||
if not (key.startswith('@') or key == 'Pages'):
|
||||
if key == 'FileSize':
|
||||
if not (key.startswith("@") or key == "Pages"):
|
||||
if key == "FileSize":
|
||||
value = readable_size(value)
|
||||
self.summary_text.insert(tk.END, f'{key}\n', 'bold')
|
||||
self.summary_text.insert(tk.END, f'{value}\n\n', 'normal')
|
||||
self.summary_text.insert(tk.END, f"{key}\n", "bold")
|
||||
self.summary_text.insert(tk.END, f"{value}\n\n", "normal")
|
||||
|
||||
self.summary_text.config(state=tk.DISABLED)
|
||||
self.summary_text.place(relwidth=1, relheight=1)
|
||||
|
||||
def _show_image_page(self) -> None:
|
||||
"""
|
||||
Display the image page centered on the canvas.
|
||||
"""
|
||||
# Hide the summary text widget
|
||||
"""Display an image page centered on the canvas."""
|
||||
self.summary_text.place_forget()
|
||||
|
||||
# Display image centered on canvas
|
||||
page: PageInfo = self.comic_info.pages[self.current_page]
|
||||
if self.img_original is not None:
|
||||
self.img_original.close()
|
||||
page: PageInfo = self.comic[self.current_page]
|
||||
self.img_original = Image.open(BytesIO(page.content))
|
||||
self._display_image()
|
||||
|
||||
def _display_image(self) -> None:
|
||||
"""
|
||||
Display the image on the canvas with zooming capabilities.
|
||||
"""
|
||||
# Check if zoom factor is not set; initialize zoom factors
|
||||
"""Display the image with the current zoom level."""
|
||||
if not self.zoom_factor:
|
||||
# Determine maximum zoom factor based on canvas size and original image size
|
||||
self.max_zoom_factor = min(
|
||||
self.canvas.winfo_width() / self.img_original.width,
|
||||
self.canvas.winfo_height() / self.img_original.height
|
||||
self.canvas.winfo_height() / self.img_original.height,
|
||||
)
|
||||
self.zoom_factor = self.max_zoom_factor
|
||||
|
||||
# Calculate new dimensions of the image based on current zoom factor
|
||||
new_width = int(self.img_original.width * self.zoom_factor)
|
||||
new_height = int(self.img_original.height * self.zoom_factor)
|
||||
img = self.img_original.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
new_w = int(self.img_original.width * self.zoom_factor)
|
||||
new_h = int(self.img_original.height * self.zoom_factor)
|
||||
img = self.img_original.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert the resized image to PhotoImage format compatible with Tkinter canvas
|
||||
self.canvas.image = ImageTk.PhotoImage(img)
|
||||
|
||||
# Calculate the position to center the image on the canvas
|
||||
x = max(0, (self.canvas.winfo_width() - new_width) // 2)
|
||||
y = max(0, (self.canvas.winfo_height() - new_height) // 2)
|
||||
x = max(0, (self.canvas.winfo_width() - new_w) // 2)
|
||||
y = max(0, (self.canvas.winfo_height() - new_h) // 2)
|
||||
self.canvas.create_image(x, y, anchor=tk.NW, image=self.canvas.image)
|
||||
|
||||
# Configure the scroll region of the canvas to allow scrolling if image is larger than canvas
|
||||
canvas_width = max(new_width, self.canvas.winfo_width())
|
||||
canvas_height = max(new_height, self.canvas.winfo_height())
|
||||
self.canvas.config(scrollregion=(0, 0, canvas_width, canvas_height))
|
||||
canvas_w = max(new_w, self.canvas.winfo_width())
|
||||
canvas_h = max(new_h, self.canvas.winfo_height())
|
||||
self.canvas.config(scrollregion=(0, 0, canvas_w, canvas_h))
|
||||
|
||||
def _bind_keys(self) -> None:
|
||||
"""
|
||||
Bind keys for navigation (left/right arrow keys) and window events.
|
||||
"""
|
||||
# Bind arrow keys for navigation
|
||||
self.root.bind('<Left>', lambda event: self.on_previous())
|
||||
self.root.bind('<Right>', lambda event: self.one_next())
|
||||
"""Configure keyboard shortcuts and events."""
|
||||
self.root.bind("<Left>", lambda _: self.on_previous())
|
||||
self.root.bind("<Right>", lambda _: self.on_next())
|
||||
self.root.bind("<Control-q>", lambda _: self.root.quit())
|
||||
self.root.bind("<Configure>", lambda e: self.on_window(e))
|
||||
|
||||
# Bind Ctrl+Q to exit application
|
||||
self.root.bind('<Control-q>', lambda event: self.root.quit())
|
||||
# Bind the configure event to handle window resize
|
||||
self.root.bind('<Configure>', lambda event: self.on_window(event))
|
||||
|
||||
# Bind mouse wheel events for zooming
|
||||
self.root.bind('<Control-MouseWheel>', self.on_zoom)
|
||||
self.root.bind("<Control-MouseWheel>", self.on_zoom)
|
||||
self.canvas.bind_all("<MouseWheel>", self._on_mouse_wheel)
|
||||
self.canvas.bind_all("<Shift-MouseWheel>", self._on_shift_mouse_wheel)
|
||||
|
||||
# Bind zoom controls to keyboard keys
|
||||
self.root.bind('<KeyPress-plus>', lambda event: self.on_zoom_key(True))
|
||||
self.root.bind('<KeyPress-minus>', lambda event: self.on_zoom_key(False))
|
||||
self.root.bind("<KeyPress-plus>", lambda _: self.on_zoom_key(True))
|
||||
self.root.bind("<KeyPress-minus>", lambda _: self.on_zoom_key(False))
|
||||
|
||||
def on_zoom_key(self, positive: bool) -> None:
|
||||
"""
|
||||
Handle zooming triggered by keyboard keys ('+' for zoom in, '-' for zoom out).
|
||||
|
||||
Args:
|
||||
positive (bool): True for zoom in, False for zoom out.
|
||||
"""
|
||||
# Create a synthetic event object to simulate mouse wheel scrolling
|
||||
def on_zoom_key(self, zoom_in: bool) -> None:
|
||||
"""Handle keyboard zoom (+ / -)."""
|
||||
event = tk.Event()
|
||||
event.delta = 120 if positive else -120
|
||||
event.delta = 120 if zoom_in else -120
|
||||
self.on_zoom(event)
|
||||
|
||||
def on_zoom(self, event) -> None:
|
||||
"""
|
||||
Handle zooming using Ctrl + Mouse Wheel.
|
||||
"""
|
||||
if self.current_page == -1: return
|
||||
def on_zoom(self, event: tk.Event) -> None:
|
||||
"""Handle zoom with Ctrl + mouse wheel."""
|
||||
if self.current_page == -1:
|
||||
return
|
||||
|
||||
# Adjust zoom factor based on mouse wheel direction
|
||||
if event.delta > 0:
|
||||
# Increase zoom factor by 10% for zoom in
|
||||
self.zoom_factor *= 1.1
|
||||
elif event.delta < 0:
|
||||
# Decrease zoom factor by 10% for zoom out
|
||||
self.zoom_factor /= 1.1
|
||||
|
||||
# Ensure the zoom-out factor doesn't go below the original image size
|
||||
min_zoom_factor = min(
|
||||
min_zoom = min(
|
||||
self.canvas.winfo_width() / self.img_original.width,
|
||||
self.canvas.winfo_height() / self.img_original.height
|
||||
self.canvas.winfo_height() / self.img_original.height,
|
||||
)
|
||||
if self.zoom_factor < min_zoom:
|
||||
self.zoom_factor = min_zoom
|
||||
|
||||
if self.zoom_factor < min_zoom_factor:
|
||||
self.zoom_factor = min_zoom_factor
|
||||
|
||||
# Update the displayed image with the new zoom factor
|
||||
self._display_image()
|
||||
|
||||
def _on_mouse_wheel(self, event) -> None:
|
||||
"""
|
||||
Handle vertical scroll using Mouse Wheel.
|
||||
"""
|
||||
if self.current_page == -1 or (event.state & CTRL_KEY): return
|
||||
def _on_mouse_wheel(self, event: tk.Event) -> None:
|
||||
"""Handle vertical scrolling."""
|
||||
if self.current_page == -1 or (event.state & CTRL_KEY):
|
||||
return
|
||||
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
|
||||
def _on_shift_mouse_wheel(self, event) -> None:
|
||||
"""
|
||||
Handle horizontal scroll using Shift + Mouse Wheel.
|
||||
"""
|
||||
if self.current_page == -1 or (event.state & CTRL_KEY): return
|
||||
def _on_shift_mouse_wheel(self, event: tk.Event) -> None:
|
||||
"""Handle horizontal scrolling."""
|
||||
if self.current_page == -1 or (event.state & CTRL_KEY):
|
||||
return
|
||||
self.canvas.xview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
|
||||
def on_previous(self) -> None:
|
||||
"""
|
||||
Navigate to the previous page.
|
||||
"""
|
||||
if self.current_page == -1: return
|
||||
"""Navigate to the previous page."""
|
||||
if self.current_page == -1:
|
||||
return
|
||||
self.current_page -= 1
|
||||
self.zoom_factor = None
|
||||
self.show_page()
|
||||
|
||||
def one_next(self) -> None:
|
||||
"""
|
||||
Navigate to the next page.
|
||||
"""
|
||||
if self.current_page < len(self.comic_info.pages) - 1:
|
||||
def on_next(self) -> None:
|
||||
"""Navigate to the next page."""
|
||||
if self.current_page < len(self.comic) - 1:
|
||||
self.current_page += 1
|
||||
self.zoom_factor = None
|
||||
self.show_page()
|
||||
|
||||
def on_window(self, event: any) -> None:
|
||||
"""
|
||||
Handle window resize event.
|
||||
"""
|
||||
# Check if width or height has changed
|
||||
def on_window(self, event: tk.Event) -> None:
|
||||
"""Handle window resize."""
|
||||
if event.width != self.previous_width or event.height != self.previous_height:
|
||||
self.previous_width = event.width
|
||||
self.previous_height = event.height
|
||||
|
||||
# Cancel any existing resize timer
|
||||
if self.resize_timer:
|
||||
self.root.after_cancel(self.resize_timer)
|
||||
|
||||
# Set a new timer to call show_page after a delay
|
||||
self.resize_timer = self.root.after(200, self._on_resize)
|
||||
|
||||
def _on_resize(self) -> None:
|
||||
"""
|
||||
Handle resizing of the window and adjust image display accordingly.
|
||||
"""
|
||||
if self.current_page == -1: return
|
||||
|
||||
# Reset zoom factor to None if it's equal to the maximum zoom factor
|
||||
"""React to window resize with delay."""
|
||||
if self.current_page == -1:
|
||||
return
|
||||
if self.zoom_factor == self.max_zoom_factor:
|
||||
self.zoom_factor = None
|
||||
|
||||
# Update the displayed image with the current zoom factor
|
||||
self._display_image()
|
||||
|
||||
# Reset the resize timer
|
||||
self.resize_timer = None
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Run the comic player application.
|
||||
"""
|
||||
"""Start the reader main loop."""
|
||||
self.root.mainloop()
|
||||
|
||||
+20
-94
@@ -1,4 +1,7 @@
|
||||
from enum import Enum
|
||||
"""Utility functions for the CBZ library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,104 +9,27 @@ from PIL import Image
|
||||
from PIL.IcoImagePlugin import IcoFile
|
||||
|
||||
|
||||
def default_attr(value: any) -> any:
|
||||
"""
|
||||
Provides a default value based on the expected type of attribute.
|
||||
|
||||
Args:
|
||||
value (any): Expected type or class of the attribute.
|
||||
|
||||
Returns:
|
||||
any: Default value appropriate for the specified type or class.
|
||||
- For Enum types: Returns the 'UNKNOWN' member if available, otherwise the first member.
|
||||
- For int or float: Returns -1.
|
||||
- For bool: Returns False.
|
||||
- For str: Returns an empty string.
|
||||
- For other types: Invokes the callable (assuming it's a function or callable object).
|
||||
"""
|
||||
if issubclass(value, Enum):
|
||||
keys = [i.name for i in list(value)]
|
||||
return value['UNKNOWN' if 'UNKNOWN' in keys else 'STORY']
|
||||
elif value in (int, float):
|
||||
return -1
|
||||
elif value == bool:
|
||||
return False
|
||||
elif value == str:
|
||||
return ''
|
||||
else:
|
||||
return value()
|
||||
|
||||
|
||||
def verify_attr(expected_type: any, key: str, value: any) -> None:
|
||||
"""
|
||||
Verifies if the provided value matches the expected type.
|
||||
|
||||
Args:
|
||||
expected_type (any): Expected type of the attribute.
|
||||
key (str): Name of the attribute.
|
||||
value (any): Value to be verified against the expected type.
|
||||
|
||||
Raises:
|
||||
TypeError: If the provided value does not match the expected type.
|
||||
"""
|
||||
if not isinstance(value, expected_type):
|
||||
raise TypeError(f'Expected type {expected_type} for attribute "{key}", but got {type(value)}')
|
||||
|
||||
|
||||
def repr_attr(value: any) -> any:
|
||||
"""
|
||||
Provides a representation of the attribute's value.
|
||||
|
||||
Args:
|
||||
value (any): Value of the attribute.
|
||||
|
||||
Returns:
|
||||
any: Representation of the attribute's value.
|
||||
- For Enum types: Returns the value of the Enum.
|
||||
- For other types: Returns the value itself.
|
||||
"""
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
return value
|
||||
|
||||
|
||||
def readable_size(size: int, decimal: int = 2) -> str:
|
||||
"""
|
||||
Converts a file size in bytes to a human-readable string format.
|
||||
|
||||
Args:
|
||||
size (int): The size in bytes.
|
||||
decimal (int): Number of decimal places to display (default is 2).
|
||||
|
||||
Returns:
|
||||
str: Human-readable string representation of the size.
|
||||
"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
"""Convert a byte size to a human-readable string (KB, MB, etc.)."""
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024:
|
||||
return f'{size:.{decimal}f} {unit}'
|
||||
return f"{size:.{decimal}f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.{decimal}f} PB"
|
||||
|
||||
|
||||
def ico_to_png(path: Path) -> BytesIO:
|
||||
"""
|
||||
Converts the largest icon in an ICO file to PNG format.
|
||||
"""Convert the largest icon in an ICO file to PNG format."""
|
||||
with Image.open(BytesIO(path.read_bytes())) as image:
|
||||
if image.format != "ICO":
|
||||
raise ValueError(f"Expected ICO format, got {image.format}")
|
||||
|
||||
Args:
|
||||
path (Path): Path to the ICO file.
|
||||
icon: IcoFile = image.ico
|
||||
max_size = max(icon.sizes(), key=lambda s: s[0] + s[1])
|
||||
largest = icon.getimage(size=max_size)
|
||||
|
||||
Returns:
|
||||
BytesIO: In-memory PNG file of the largest icon.
|
||||
"""
|
||||
# Open the ICO file and read its content
|
||||
image = Image.open(BytesIO(path.read_bytes()))
|
||||
assert image.format == 'ICO', 'Unsupported image format'
|
||||
|
||||
# Get the ICO file object and find the largest icon size
|
||||
icon: IcoFile = image.ico
|
||||
max_size = max(icon.sizes(), key=lambda x: x[0] + x[1])
|
||||
largest_image = icon.getimage(size=max_size)
|
||||
|
||||
# Save the largest icon as PNG format to an in-memory BytesIO object
|
||||
content = BytesIO()
|
||||
largest_image.save(content, format='PNG')
|
||||
return content
|
||||
buf = BytesIO()
|
||||
largest.save(buf, format="PNG")
|
||||
largest.close()
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
# RFC: CBZ (Comic Book ZIP) Format Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Status:** Informational
|
||||
**Date:** 2026-04-06
|
||||
**Authors:** hyugogirubato, contributors
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
This document specifies the CBZ (Comic Book ZIP) file format, a widely-adopted standard for packaging and distributing digital comic books, manga, and graphic novels. CBZ files are ZIP archives containing sequential image files and an optional XML metadata descriptor (`ComicInfo.xml`). This RFC consolidates existing community practices, the ComicInfo XML schema (versions 1.0 through 2.1), and implementation experience into a single authoritative reference.
|
||||
|
||||
## 2. Status of This Document
|
||||
|
||||
This document is an informational specification. It describes the CBZ format as implemented by major comic book readers and management tools including ComicRack, Kavita, Komga, Calibre, YACReader, and others. The format originated from the ComicRack application and has been extended by the community through the anansi-project.
|
||||
|
||||
## 3. Terminology
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
|
||||
|
||||
| Term | Definition |
|
||||
|-------------------|---------------------------------------------------------------------------------|
|
||||
| **CBZ** | Comic Book ZIP - A ZIP archive containing comic book pages as images |
|
||||
| **CBR** | Comic Book RAR - Similar format using RAR compression (read-only in most tools) |
|
||||
| **ComicInfo.xml** | XML metadata descriptor file embedded in the archive |
|
||||
| **Page** | A single image representing one page of the comic |
|
||||
| **Double Page** | A single image representing a two-page spread |
|
||||
|
||||
## 4. File Format Overview
|
||||
|
||||
### 4.1 MIME Type
|
||||
|
||||
```
|
||||
application/vnd.comicbook+zip
|
||||
```
|
||||
|
||||
The file extension is `.cbz` (case-insensitive).
|
||||
|
||||
### 4.2 Archive Structure
|
||||
|
||||
A CBZ file is a standard ZIP archive (as defined in PKWARE APPNOTE) with the following structure:
|
||||
|
||||
```
|
||||
archive.cbz (ZIP)
|
||||
+-- ComicInfo.xml (OPTIONAL - metadata descriptor)
|
||||
+-- page-001.jpg (image file)
|
||||
+-- page-002.jpg (image file)
|
||||
+-- page-003.png (image file)
|
||||
+-- ...
|
||||
```
|
||||
|
||||
### 4.3 Archive Requirements
|
||||
|
||||
1. The archive MUST be a valid ZIP file conforming to PKWARE's ZIP Application Note.
|
||||
2. The archive SHOULD use `ZIP_STORED` (no compression) as the compression method, since image formats already employ their own compression. Using `ZIP_DEFLATED` is permitted but offers negligible size reduction at a performance cost.
|
||||
3. The archive MUST contain at least one image file.
|
||||
4. The archive MAY contain a `ComicInfo.xml` metadata file at the root level.
|
||||
5. The archive MUST NOT contain executable files, scripts, or other non-image/non-metadata content at the root level. Subdirectories (e.g., `__MACOSX/`) SHOULD be ignored by readers.
|
||||
|
||||
### 4.4 File Naming
|
||||
|
||||
1. Image files SHOULD be named to sort in reading order using standard lexicographic (alphabetical) sorting.
|
||||
2. The RECOMMENDED naming convention is zero-padded sequential names: `page-001.ext`, `page-002.ext`, etc.
|
||||
3. Readers MUST sort image files alphabetically (case-insensitive) to determine page order when no `ComicInfo.xml` page index is present.
|
||||
4. The metadata file MUST be named exactly `ComicInfo.xml` (case-sensitive).
|
||||
|
||||
## 5. Supported Image Formats
|
||||
|
||||
### 5.1 Required Support
|
||||
|
||||
Conforming readers MUST support the following image formats:
|
||||
|
||||
| Format | Extensions | MIME Type |
|
||||
|--------|-----------------|--------------|
|
||||
| JPEG | `.jpg`, `.jpeg` | `image/jpeg` |
|
||||
| PNG | `.png` | `image/png` |
|
||||
|
||||
### 5.2 Recommended Support
|
||||
|
||||
Conforming readers SHOULD support:
|
||||
|
||||
| Format | Extensions | MIME Type |
|
||||
|--------|-----------------|--------------|
|
||||
| GIF | `.gif` | `image/gif` |
|
||||
| BMP | `.bmp` | `image/bmp` |
|
||||
| WebP | `.webp` | `image/webp` |
|
||||
| TIFF | `.tiff`, `.tif` | `image/tiff` |
|
||||
|
||||
### 5.3 Optional Support
|
||||
|
||||
Conforming readers MAY support:
|
||||
|
||||
| Format | Extensions | MIME Type |
|
||||
|---------|------------|--------------|
|
||||
| JPEG XL | `.jxl` | `image/jxl` |
|
||||
| AVIF | `.avif` | `image/avif` |
|
||||
|
||||
### 5.4 Image Guidelines
|
||||
|
||||
1. All image files within a single archive SHOULD use the same format for consistency.
|
||||
2. Images SHOULD be stored in their original resolution without additional compression artifacts.
|
||||
3. For optimal compatibility, JPEG is RECOMMENDED for photographic/scanned content, and PNG for digitally-produced content with sharp edges or transparency.
|
||||
4. Readers MUST determine page dimensions from the actual image data, not from metadata alone.
|
||||
|
||||
## 6. ComicInfo.xml Metadata Specification
|
||||
|
||||
### 6.1 Overview
|
||||
|
||||
The `ComicInfo.xml` file is an XML document that describes the comic book's metadata and page structure. It follows the ComicInfo schema originally developed for the ComicRack application.
|
||||
|
||||
### 6.2 XML Declaration
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
```
|
||||
|
||||
The file MUST use UTF-8 encoding.
|
||||
|
||||
### 6.3 Root Element
|
||||
|
||||
```xml
|
||||
|
||||
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
...
|
||||
</ComicInfo>
|
||||
```
|
||||
|
||||
The root element MUST be `<ComicInfo>`. The XML namespace declarations are RECOMMENDED for schema validation but not strictly required for parsing.
|
||||
|
||||
### 6.4 Comic Metadata Elements
|
||||
|
||||
All metadata elements are OPTIONAL. They appear as child elements of `<ComicInfo>` in the order specified below. Absent elements indicate unknown or unset values.
|
||||
|
||||
#### 6.4.1 Identification
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|-------------------|-------------|---------|----------------------------------------------------------------------------|
|
||||
| `Title` | `xs:string` | `""` | Title of the comic issue |
|
||||
| `Series` | `xs:string` | `""` | Name of the comic series |
|
||||
| `Number` | `xs:string` | `""` | Issue number within the series (string to support "1.5", "Annual 1", etc.) |
|
||||
| `Count` | `xs:int` | `-1` | Total number of issues in the series (-1 = unknown) |
|
||||
| `Volume` | `xs:int` | `-1` | Volume number (-1 = unknown) |
|
||||
| `AlternateSeries` | `xs:string` | `""` | Alternate series name (for crossovers, reprints) |
|
||||
| `AlternateNumber` | `xs:string` | `""` | Issue number in the alternate series |
|
||||
| `AlternateCount` | `xs:int` | `-1` | Total issues in the alternate series |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- `Number` is typed as string in the schema to accommodate non-integer issue numbers (e.g., "0.5", "Annual 3", "#1/2").
|
||||
- When `Count` is -1, the series is ongoing or the total is unknown.
|
||||
|
||||
#### 6.4.2 Descriptive
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|-------------|-------------|---------|-----------------------------------------------|
|
||||
| `Summary` | `xs:string` | `""` | Synopsis or description of the issue |
|
||||
| `Notes` | `xs:string` | `""` | Free-form notes (scan quality, source, etc.) |
|
||||
| `Genre` | `xs:string` | `""` | Genre(s), comma-separated |
|
||||
| `Tags` | `xs:string` | `""` | Tags/keywords, comma-separated |
|
||||
| `Web` | `xs:string` | `""` | URL to the comic's web page or source |
|
||||
| `EAN` | `xs:string` | `""` | European Article Number (ISBN/barcode) |
|
||||
| `BookPrice` | `xs:string` | `""` | Cover price (free-form string, e.g., "$3.99") |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- `Genre` and `Tags` use comma-separated values. Readers SHOULD trim whitespace around each value.
|
||||
- `Web` SHOULD contain a valid URL but no validation is required.
|
||||
|
||||
#### 6.4.3 Publication Date
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|---------|----------|---------|-----------------------------|
|
||||
| `Year` | `xs:int` | `-1` | Publication year (4 digits) |
|
||||
| `Month` | `xs:int` | `-1` | Publication month (1-12) |
|
||||
| `Day` | `xs:int` | `-1` | Publication day (1-31) |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- A value of -1 indicates the date component is unknown.
|
||||
- Readers SHOULD NOT assume Month and Day are present when Year is set.
|
||||
|
||||
#### 6.4.4 Credits
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|---------------|-------------|---------|-----------------------------------|
|
||||
| `Writer` | `xs:string` | `""` | Writer(s), comma-separated |
|
||||
| `Penciller` | `xs:string` | `""` | Pencil artist(s), comma-separated |
|
||||
| `Inker` | `xs:string` | `""` | Inker(s), comma-separated |
|
||||
| `Colorist` | `xs:string` | `""` | Colorist(s), comma-separated |
|
||||
| `Letterer` | `xs:string` | `""` | Letterer(s), comma-separated |
|
||||
| `CoverArtist` | `xs:string` | `""` | Cover artist(s), comma-separated |
|
||||
| `Editor` | `xs:string` | `""` | Editor(s), comma-separated |
|
||||
| `Translator` | `xs:string` | `""` | Translator(s), comma-separated |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- All credit fields support multiple names as comma-separated values.
|
||||
- The `Translator` field was added in schema v2.1.
|
||||
|
||||
#### 6.4.5 Publisher
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|-------------|-------------|---------|----------------------------------|
|
||||
| `Publisher` | `xs:string` | `""` | Publisher name |
|
||||
| `Imprint` | `xs:string` | `""` | Publisher's imprint or sub-label |
|
||||
|
||||
#### 6.4.6 Classification
|
||||
|
||||
| Element | Type | Allowed Values | Default | Description |
|
||||
|-----------------|-------------|---------------------------------------------|-----------|----------------------------------------------------|
|
||||
| `Format` | `xs:string` | (see section 6.5) | `""` | Publication format |
|
||||
| `BlackAndWhite` | `YesNo` | `Unknown`, `No`, `Yes` | `Unknown` | Whether the comic is black and white |
|
||||
| `Manga` | `Manga` | `Unknown`, `No`, `Yes`, `YesAndRightToLeft` | `Unknown` | Whether the comic is manga (and reading direction) |
|
||||
| `AgeRating` | `AgeRating` | (see section 6.7) | `Unknown` | Content age rating |
|
||||
| `LanguageISO` | `xs:string` | ISO 639 codes | `""` | Language code (e.g., "en", "fr", "ja") |
|
||||
|
||||
#### 6.4.7 Characters and Story
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|-----------------------|-------------|---------|----------------------------------|
|
||||
| `Characters` | `xs:string` | `""` | Character names, comma-separated |
|
||||
| `Teams` | `xs:string` | `""` | Team names, comma-separated |
|
||||
| `Locations` | `xs:string` | `""` | Location names, comma-separated |
|
||||
| `MainCharacterOrTeam` | `xs:string` | `""` | Primary character or team |
|
||||
| `StoryArc` | `xs:string` | `""` | Story arc name |
|
||||
| `StoryArcNumber` | `xs:string` | `""` | Position within the story arc |
|
||||
| `SeriesGroup` | `xs:string` | `""` | Series grouping or universe |
|
||||
|
||||
#### 6.4.8 Community
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|-------------------|-------------|---------|-------------------------------------------------|
|
||||
| `CommunityRating` | `Rating` | (none) | Community rating (0.0-5.0, single decimal) |
|
||||
| `Review` | `xs:string` | `""` | Review text |
|
||||
| `ScanInformation` | `xs:string` | `""` | Information about the scan/digitization process |
|
||||
|
||||
#### 6.4.9 File Metadata
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|--------------------|-------------|---------|-------------------------------------------|
|
||||
| `PageCount` | `xs:int` | `0` | Number of pages in the archive |
|
||||
| `FileSize` | `xs:int` | `""` | Total size of image files in bytes |
|
||||
| `FileCreationTime` | `xs:string` | `""` | Archive creation timestamp (ISO 8601) |
|
||||
| `FileModifiedTime` | `xs:string` | `""` | Archive modification timestamp (ISO 8601) |
|
||||
| `Added` | `xs:string` | `""` | Date added to library |
|
||||
| `Released` | `xs:string` | `""` | Release/publication date |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Timestamps SHOULD use ISO 8601 format: `YYYY-MM-DDThh:mm:ss.sssZ`
|
||||
- `PageCount` MUST match the actual number of image files in the archive.
|
||||
- `FileSize` represents the total uncompressed size of all image files, NOT the archive size.
|
||||
|
||||
#### 6.4.10 Custom Data
|
||||
|
||||
| Element | Type | Default | Description |
|
||||
|---------------------|-------------|---------|--------------------------------------------------|
|
||||
| `CustomValuesStore` | `xs:string` | `""` | Application-specific custom data (opaque string) |
|
||||
|
||||
### 6.5 Format Enumeration
|
||||
|
||||
The `Format` element accepts any string value, but the following are the recognized standard values:
|
||||
|
||||
```
|
||||
Annotation, Annual, Anthology, Black & White, Box-Set, Crossover,
|
||||
Director's Cut, Epilogue, Event, FCBD, Flyer, Giant-Size, Graphic Novel,
|
||||
Hard-Cover, King-Size, Limited Series, Magazine, NSFW, One-Shot, Point 1,
|
||||
Preview, Prologue, Reference, Review, Reviewed, Scanlation, Script,
|
||||
Series, Sketch, Special, Trade Paper Back, Web Comic, Year One
|
||||
```
|
||||
|
||||
Readers SHOULD accept these values case-insensitively. Alternative spellings exist in the wild (e.g., "TPB" for "Trade Paper Back", "WebComic" for "Web Comic") and readers SHOULD handle them gracefully.
|
||||
|
||||
### 6.6 Manga Enumeration
|
||||
|
||||
| Value | Description |
|
||||
|---------------------|-----------------------------------------------------|
|
||||
| `Unknown` | Reading direction not specified |
|
||||
| `No` | Western-style left-to-right reading |
|
||||
| `Yes` | Manga-style (may be left-to-right or right-to-left) |
|
||||
| `YesAndRightToLeft` | Manga with explicit right-to-left reading order |
|
||||
|
||||
Readers SHOULD use this value to determine the reading direction for page navigation and double-page spread orientation.
|
||||
|
||||
### 6.7 AgeRating Enumeration
|
||||
|
||||
| Value | Description |
|
||||
|-------------------|----------------------------------|
|
||||
| `Unknown` | Not rated |
|
||||
| `Adults Only 18+` | Adult content only |
|
||||
| `Early Childhood` | Suitable for very young children |
|
||||
| `Everyone` | Suitable for all ages |
|
||||
| `Everyone 10+` | Suitable for ages 10 and up |
|
||||
| `G` | General audiences |
|
||||
| `Kids to Adults` | Suitable for children to adults |
|
||||
| `M` | Mature content |
|
||||
| `MA15+` | Mature audiences 15+ |
|
||||
| `Mature 17+` | Mature audiences 17+ |
|
||||
| `PG` | Parental guidance suggested |
|
||||
| `R18+` | Restricted 18+ |
|
||||
| `Rating Pending` | Rating has not been assigned |
|
||||
| `Teen` | Suitable for teens |
|
||||
| `X18+` | Explicit content 18+ |
|
||||
|
||||
### 6.8 Rating Type
|
||||
|
||||
The `CommunityRating` element uses a decimal type:
|
||||
|
||||
- Minimum value: `0.0`
|
||||
- Maximum value: `5.0`
|
||||
- Precision: 1 decimal place (schema v2.1) or 2 decimal places (schema v2.0)
|
||||
|
||||
### 6.9 Pages Element
|
||||
|
||||
The `<Pages>` element contains an ordered list of `<Page>` elements describing each page in the archive.
|
||||
|
||||
```xml
|
||||
|
||||
<Pages>
|
||||
<Page Image="0" Type="FrontCover" ImageSize="245760" ImageWidth="800" ImageHeight="1200"/>
|
||||
<Page Image="1" Type="Story" DoublePage="false" ImageSize="198432" ImageWidth="800" ImageHeight="1200"/>
|
||||
<Page Image="2" Type="BackCover" ImageSize="210944" ImageWidth="800" ImageHeight="1200"/>
|
||||
</Pages>
|
||||
```
|
||||
|
||||
#### 6.9.1 Page Attributes
|
||||
|
||||
| Attribute | Type | Required | Default | Description |
|
||||
|---------------|-----------------|----------|---------|------------------------------------------------------|
|
||||
| `Image` | `xs:int` | **YES** | - | Zero-based index of the page in the sorted file list |
|
||||
| `Type` | `ComicPageType` | No | `Story` | Type/role of the page |
|
||||
| `DoublePage` | `xs:boolean` | No | `false` | Whether this is a double-page spread |
|
||||
| `ImageSize` | `xs:long` | No | `0` | Image file size in bytes |
|
||||
| `Key` | `xs:string` | No | `""` | Unique key for the page |
|
||||
| `Bookmark` | `xs:string` | No | `""` | Bookmark/chapter name for this page |
|
||||
| `ImageWidth` | `xs:int` | No | `-1` | Image width in pixels |
|
||||
| `ImageHeight` | `xs:int` | No | `-1` | Image height in pixels |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- `Image` is the ONLY required attribute. It MUST be a zero-based index corresponding to the page's position in the alphabetically-sorted list of image files.
|
||||
- `Bookmark` was added in schema v2.0.
|
||||
- Readers SHOULD use actual image dimensions from the file rather than relying solely on `ImageWidth`/`ImageHeight` metadata.
|
||||
|
||||
#### 6.9.2 ComicPageType Enumeration
|
||||
|
||||
| Value | Description |
|
||||
|-----------------|--------------------------------------------|
|
||||
| `FrontCover` | Front cover image |
|
||||
| `InnerCover` | Inner cover or dust jacket |
|
||||
| `Roundup` | Recap or summary page |
|
||||
| `Story` | Regular story page (default) |
|
||||
| `Advertisement` | Advertisement page |
|
||||
| `Editorial` | Editorial or credits page |
|
||||
| `Letters` | Letters to the editor page |
|
||||
| `Preview` | Preview of upcoming issues |
|
||||
| `BackCover` | Back cover image |
|
||||
| `Other` | Other content not fitting above categories |
|
||||
| `Deleted` | Page marked for deletion (not displayed) |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- The `FrontCover` page is typically used by readers as the thumbnail/cover image for the comic.
|
||||
- Pages of type `Deleted` SHOULD NOT be displayed to the user but MAY be retained in the archive.
|
||||
|
||||
## 7. Schema Evolution
|
||||
|
||||
### 7.1 Version History
|
||||
|
||||
| Version | Key Changes |
|
||||
|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **1.0** | Initial schema. Core metadata fields, basic page info. `Manga` typed as `YesNo`. No `Bookmark`, `Tags`, `Translator`, `StoryArcNumber`, `AgeRating`, `CommunityRating`, `MainCharacterOrTeam`, `Review`, `Added`, `Released`, `FileSize`, `FileModifiedTime`, `FileCreationTime`, `BookPrice`, `CustomValuesStore`. |
|
||||
| **2.0** | Added `Characters`, `Teams`, `Locations`, `ScanInformation`, `StoryArc`, `SeriesGroup`, `AgeRating`, `CommunityRating`, `MainCharacterOrTeam`, `Review`. `Manga` gets its own type with `YesAndRightToLeft`. `Bookmark` added to pages. `Rating` type has 2 decimal places. |
|
||||
| **2.1** | Added `Translator`, `Tags`, `StoryArcNumber`, `Added`, `Released`, `FileSize`, `FileModifiedTime`, `FileCreationTime`, `BookPrice`, `CustomValuesStore`. `Rating` precision reduced to 1 decimal place. `Day` field added to date. |
|
||||
|
||||
### 7.2 Compatibility Guidelines
|
||||
|
||||
1. Writers SHOULD generate metadata conforming to the latest schema version (2.1).
|
||||
2. Readers MUST gracefully handle unknown elements by ignoring them (forward compatibility).
|
||||
3. Readers MUST handle missing optional elements by using specified defaults (backward compatibility).
|
||||
4. Writers SHOULD NOT include elements with default/empty values to minimize file size.
|
||||
|
||||
## 8. Related Formats
|
||||
|
||||
### 8.1 CBR (Comic Book RAR)
|
||||
|
||||
- Extension: `.cbr`
|
||||
- MIME Type: `application/vnd.comicbook-rar`
|
||||
- Identical internal structure but using RAR archive format instead of ZIP.
|
||||
- Requires external RAR extraction tools (e.g., `unrar`, `7zip`).
|
||||
- CBZ is RECOMMENDED over CBR for new archives due to the ubiquity of ZIP support.
|
||||
|
||||
### 8.2 CB7 (Comic Book 7z)
|
||||
|
||||
- Extension: `.cb7`
|
||||
- MIME Type: `application/x-cb7`
|
||||
- Uses 7-Zip archive format. Less common.
|
||||
|
||||
### 8.3 CBT (Comic Book TAR)
|
||||
|
||||
- Extension: `.cbt`
|
||||
- MIME Type: `application/x-cbt`
|
||||
- Uses TAR archive format. No compression. Rare.
|
||||
|
||||
### 8.4 PDF
|
||||
|
||||
- PDF files can contain embedded images and are sometimes used for digital comics.
|
||||
- PDF does not natively support ComicInfo metadata.
|
||||
- Conversion from PDF to CBZ involves extracting images; metadata must be added separately.
|
||||
|
||||
## 9. Implementation Considerations
|
||||
|
||||
### 9.1 Reading a CBZ File
|
||||
|
||||
A conforming reader MUST implement the following algorithm:
|
||||
|
||||
1. Open the file as a ZIP archive.
|
||||
2. List all entries and sort them alphabetically (case-insensitive).
|
||||
3. Check for `ComicInfo.xml` at the root level:
|
||||
- If present, parse it and extract metadata.
|
||||
- If absent or malformed, proceed with image-only mode.
|
||||
4. Filter entries to include only files with supported image extensions.
|
||||
5. Ignore directories, hidden files (starting with `.`), and OS-specific metadata folders (e.g., `__MACOSX/`).
|
||||
6. Load images in sorted order as the page sequence.
|
||||
7. If `ComicInfo.xml` contains a `<Pages>` section, use the `Image` attribute to map metadata to pages.
|
||||
|
||||
### 9.2 Writing a CBZ File
|
||||
|
||||
A conforming writer MUST implement the following:
|
||||
|
||||
1. Create a new ZIP archive with `ZIP_STORED` compression (RECOMMENDED).
|
||||
2. Add `ComicInfo.xml` as the first entry (RECOMMENDED for fast metadata access).
|
||||
3. Add image files in reading order with sequential, zero-padded names.
|
||||
4. Ensure `PageCount` matches the number of image files.
|
||||
5. Set `Image` attributes in `<Page>` elements as zero-based indices matching the sorted file order.
|
||||
6. Use UTF-8 encoding for the XML file.
|
||||
7. Self-close `<Page>` elements: use `<Page ... />` not `<Page ...></Page>`.
|
||||
|
||||
### 9.3 Character Encoding
|
||||
|
||||
1. `ComicInfo.xml` MUST use UTF-8 encoding.
|
||||
2. File names within the archive SHOULD use UTF-8 encoding.
|
||||
3. All text content in metadata fields MUST be valid UTF-8.
|
||||
|
||||
### 9.4 Error Handling
|
||||
|
||||
1. Readers SHOULD be lenient in parsing: accept minor deviations from the schema.
|
||||
2. Unknown XML elements SHOULD be silently ignored.
|
||||
3. Malformed `ComicInfo.xml` SHOULD NOT prevent reading the images.
|
||||
4. Unsupported image formats SHOULD be skipped with a warning.
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
1. Readers MUST validate ZIP entries to prevent path traversal attacks (e.g., entries with `../` in their names).
|
||||
2. Readers SHOULD impose reasonable limits on image dimensions and file sizes to prevent resource exhaustion.
|
||||
3. Readers MUST NOT execute any content from the archive, even if it appears to be a script or executable.
|
||||
4. `CustomValuesStore` content MUST be treated as opaque and untrusted.
|
||||
5. URLs in the `Web` field SHOULD NOT be automatically fetched without user consent.
|
||||
|
||||
## 11. Complete Example
|
||||
|
||||
### 11.1 Minimal ComicInfo.xml
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<Title>Example Issue #1</Title>
|
||||
<Series>Example Series</Series>
|
||||
<Number>1</Number>
|
||||
<PageCount>3</PageCount>
|
||||
<Pages>
|
||||
<Page Image="0" Type="FrontCover"/>
|
||||
<Page Image="1" Type="Story"/>
|
||||
<Page Image="2" Type="BackCover"/>
|
||||
</Pages>
|
||||
</ComicInfo>
|
||||
```
|
||||
|
||||
### 11.2 Full ComicInfo.xml
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<Title>The Amazing Adventure #1</Title>
|
||||
<Series>The Amazing Adventure</Series>
|
||||
<Number>1</Number>
|
||||
<Count>12</Count>
|
||||
<Volume>1</Volume>
|
||||
<Summary>The hero embarks on an incredible journey through uncharted territory.</Summary>
|
||||
<Notes>Scanned from first printing</Notes>
|
||||
<Year>2024</Year>
|
||||
<Month>6</Month>
|
||||
<Day>15</Day>
|
||||
<Writer>Jane Smith</Writer>
|
||||
<Penciller>John Doe</Penciller>
|
||||
<Inker>John Doe</Inker>
|
||||
<Colorist>Alice Johnson</Colorist>
|
||||
<Letterer>Bob Williams</Letterer>
|
||||
<CoverArtist>John Doe</CoverArtist>
|
||||
<Editor>Carol Brown</Editor>
|
||||
<Translator>Pierre Dupont</Translator>
|
||||
<Publisher>Example Comics</Publisher>
|
||||
<Imprint>Example Prestige</Imprint>
|
||||
<Genre>Adventure, Fantasy</Genre>
|
||||
<Tags>hero, quest, dragons</Tags>
|
||||
<Web>https://example.com/amazing-adventure-1</Web>
|
||||
<Format>Series</Format>
|
||||
<EAN>9781234567890</EAN>
|
||||
<BlackAndWhite>No</BlackAndWhite>
|
||||
<Manga>No</Manga>
|
||||
<Characters>Hero, Sidekick, Villain</Characters>
|
||||
<Teams>The Adventurers</Teams>
|
||||
<Locations>The Kingdom, Dragon Mountain</Locations>
|
||||
<ScanInformation>600dpi scan, cleaned and leveled</ScanInformation>
|
||||
<StoryArc>The Dragon Wars</StoryArc>
|
||||
<StoryArcNumber>1</StoryArcNumber>
|
||||
<SeriesGroup>Example Universe</SeriesGroup>
|
||||
<AgeRating>Teen</AgeRating>
|
||||
<CommunityRating>4.5</CommunityRating>
|
||||
<MainCharacterOrTeam>Hero</MainCharacterOrTeam>
|
||||
<Review>Excellent start to a new series</Review>
|
||||
<LanguageISO>en</LanguageISO>
|
||||
<Added>2024-06-20T10:30:00.000Z</Added>
|
||||
<Released>2024-06-15T00:00:00.000Z</Released>
|
||||
<FileSize>15728640</FileSize>
|
||||
<FileCreationTime>2024-06-20T10:30:00.000Z</FileCreationTime>
|
||||
<FileModifiedTime>2024-06-20T10:30:00.000Z</FileModifiedTime>
|
||||
<PageCount>24</PageCount>
|
||||
<Pages>
|
||||
<Page Image="0" Type="FrontCover" ImageSize="524288" ImageWidth="1600" ImageHeight="2400" Bookmark="Cover"/>
|
||||
<Page Image="1" Type="Story" ImageSize="491520" ImageWidth="1600" ImageHeight="2400" Bookmark="Chapter 1"/>
|
||||
<Page Image="2" Type="Story" DoublePage="true" ImageSize="983040" ImageWidth="3200" ImageHeight="2400"/>
|
||||
<!-- ... pages 3-22 ... -->
|
||||
<Page Image="23" Type="BackCover" ImageSize="458752" ImageWidth="1600" ImageHeight="2400"/>
|
||||
</Pages>
|
||||
</ComicInfo>
|
||||
```
|
||||
|
||||
## 12. XSD Schema Reference (v2.1)
|
||||
|
||||
The normative XML Schema Definition for version 2.1 is provided in `docs/schema/v2.1/ComicInfo.xsd`.
|
||||
|
||||
Key type definitions:
|
||||
|
||||
```xml
|
||||
<!-- YesNo enumeration -->
|
||||
<xs:simpleType name="YesNo">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Unknown"/>
|
||||
<xs:enumeration value="No"/>
|
||||
<xs:enumeration value="Yes"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- Manga enumeration -->
|
||||
<xs:simpleType name="Manga">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Unknown"/>
|
||||
<xs:enumeration value="No"/>
|
||||
<xs:enumeration value="Yes"/>
|
||||
<xs:enumeration value="YesAndRightToLeft"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- Rating type (0.0 to 5.0) -->
|
||||
<xs:simpleType name="Rating">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:minInclusive value="0"/>
|
||||
<xs:maxInclusive value="5"/>
|
||||
<xs:fractionDigits value="1"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- Page type enumeration -->
|
||||
<xs:simpleType name="ComicPageType">
|
||||
<xs:list>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="FrontCover"/>
|
||||
<xs:enumeration value="InnerCover"/>
|
||||
<xs:enumeration value="Roundup"/>
|
||||
<xs:enumeration value="Story"/>
|
||||
<xs:enumeration value="Advertisement"/>
|
||||
<xs:enumeration value="Editorial"/>
|
||||
<xs:enumeration value="Letters"/>
|
||||
<xs:enumeration value="Preview"/>
|
||||
<xs:enumeration value="BackCover"/>
|
||||
<xs:enumeration value="Other"/>
|
||||
<xs:enumeration value="Deleted"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:list>
|
||||
</xs:simpleType>
|
||||
```
|
||||
|
||||
## 13. References
|
||||
|
||||
- **PKWARE ZIP Application Note** - ZIP file format specification
|
||||
- **ComicRack** - Original creator of the ComicInfo.xml format
|
||||
- **anansi-project** - Community maintenance of the ComicInfo schema (https://anansi-project.github.io)
|
||||
- **RFC 2119** - Key words for use in RFCs
|
||||
- **ISO 639** - Language code standards
|
||||
- **ISO 8601** - Date and time format
|
||||
|
||||
## 14. Acknowledgments
|
||||
|
||||
This specification was compiled from the collective work of the comic book reader community, including the original ComicRack application by cYo Soft, the anansi-project contributors, and the numerous developers who have implemented CBZ support in their applications.
|
||||
|
||||
---
|
||||
|
||||
*This document is provided as-is for informational purposes. The CBZ format is a community standard without a formal standards body. Implementers should prioritize interoperability with existing tools over strict schema conformance.*
|
||||
+16
-6
@@ -4,8 +4,8 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "cbz"
|
||||
version = "3.4.2"
|
||||
description = "CBZ simplifies creating, managing, and viewing comic book files in CBZ, CBR, and PDF formats, offering seamless packaging, metadata handling and built-in viewing capabilities."
|
||||
version = "4.0.0"
|
||||
description = "Modern Python library for creating, manipulating and viewing comics in CBZ, CBR and PDF formats."
|
||||
license = "MIT"
|
||||
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
|
||||
readme = "README.md"
|
||||
@@ -18,7 +18,7 @@ keywords = [
|
||||
"ebooks",
|
||||
"manga",
|
||||
"comics",
|
||||
"webtoons",
|
||||
"webtoons"
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -26,8 +26,14 @@ classifiers = [
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Utilities",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
include = [
|
||||
{ path = "CHANGELOG.md", format = "sdist" },
|
||||
@@ -40,12 +46,17 @@ include = [
|
||||
"Changelog" = "https://github.com/hyugogirubato/cbz/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.8,<4.0"
|
||||
python = ">=3.9,<4.0"
|
||||
langcodes = ">=3.4.0"
|
||||
Pillow = ">=10.4.0"
|
||||
pypdf = ">=5.7.0"
|
||||
rarfile = ">=4.2"
|
||||
xmltodict = ">=0.14.2"
|
||||
pillow-avif-plugin = { version = ">=1.5.2", optional = true }
|
||||
pillow-jxl-plugin = { version = ">=1.3.4", optional = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
pillow = ["pillow-avif-plugin", "pillow-jxl-plugin"]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
cbzplayer = "cbz.__main__:main"
|
||||
@@ -55,9 +66,8 @@ name = "localpypi"
|
||||
url = "https://pypi.org/simple/"
|
||||
priority = "primary"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = ">=7.4.0,<9.0.0"
|
||||
pytest = ">=7.4.0,<10.0.0"
|
||||
pytest-cov = ">=5.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
+23
-25
@@ -1,55 +1,53 @@
|
||||
"""Test fixtures for the CBZ library."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.page import PageInfo
|
||||
from cbz.constants import PageType
|
||||
from cbz.page import PageInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
"""Fixture that provides the path to the test fixtures directory."""
|
||||
return Path(__file__).parent / 'fixtures'
|
||||
"""Path to the test fixtures directory."""
|
||||
return Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def images_dir(fixtures_dir: Path) -> Path:
|
||||
"""Fixture that provides the path to the test images directory."""
|
||||
return fixtures_dir / 'images'
|
||||
"""Path to the test images directory."""
|
||||
return fixtures_dir / "images"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image_path(images_dir: Path) -> Path:
|
||||
"""Fixture that provides a sample image path."""
|
||||
return images_dir / 'page-000.jpg'
|
||||
"""Path to a sample test image."""
|
||||
return images_dir / "page-000.jpg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_cbz_file(tmp_path: Path, images_dir: Path) -> Path:
|
||||
"""Fixture that creates a sample CBZ file for testing."""
|
||||
# Load sample pages
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:3] # Use first 3 images
|
||||
pages = []
|
||||
"""Create a temporary CBZ file for testing."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:3]
|
||||
pages = [
|
||||
PageInfo.load(
|
||||
path=path,
|
||||
type=PageType.FRONT_COVER if i == 0 else PageType.STORY,
|
||||
)
|
||||
for i, path in enumerate(image_paths)
|
||||
]
|
||||
|
||||
for i, path in enumerate(image_paths):
|
||||
page_type = PageType.FRONT_COVER if i == 0 else PageType.STORY
|
||||
page = PageInfo.load(path=path, type=page_type)
|
||||
pages.append(page)
|
||||
|
||||
# Create comic from pages
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Test Comic',
|
||||
series='Test Series',
|
||||
title="Test Comic",
|
||||
series="Test Series",
|
||||
number=1,
|
||||
volume=1,
|
||||
year=2024
|
||||
year=2024,
|
||||
)
|
||||
|
||||
# Save to temporary file
|
||||
cbz_path = tmp_path / 'test_comic.cbz'
|
||||
cbz_content = comic.pack()
|
||||
cbz_path.write_bytes(cbz_content)
|
||||
|
||||
cbz_path = tmp_path / "test_comic.cbz"
|
||||
cbz_path.write_bytes(comic.pack())
|
||||
return cbz_path
|
||||
|
||||
+36
-31
@@ -1,59 +1,64 @@
|
||||
"""Usage example for the CBZ library."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
|
||||
from cbz.page import PageInfo
|
||||
from cbz import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
|
||||
|
||||
PARENT = Path(__file__).parent
|
||||
|
||||
if __name__ == '__main__':
|
||||
paths = list((PARENT / 'fixtures' / 'images').iterdir())
|
||||
if __name__ == "__main__":
|
||||
paths = sorted((PARENT / "fixtures" / "images").iterdir())
|
||||
|
||||
# Load each page from the 'images' folder into a list of PageInfo objects
|
||||
# Load pages with automatic type assignment
|
||||
pages = [
|
||||
PageInfo.load(
|
||||
path=path,
|
||||
type=PageType.FRONT_COVER if i == 0 else PageType.BACK_COVER if i == len(paths) - 1 else PageType.STORY
|
||||
type=(
|
||||
PageType.FRONT_COVER if i == 0
|
||||
else PageType.BACK_COVER if i == len(paths) - 1
|
||||
else PageType.STORY
|
||||
),
|
||||
)
|
||||
for i, path in enumerate(paths)
|
||||
]
|
||||
|
||||
# Create a ComicInfo object using ComicInfo.from_pages() method
|
||||
# Create comic with metadata
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='T1 - Arrête de me chauffer, Nagatoro',
|
||||
series='Arrête de me chauffer, Nagatoro',
|
||||
title="T1 - Arrête de me chauffer, Nagatoro",
|
||||
series="Arrête de me chauffer, Nagatoro",
|
||||
number=1,
|
||||
count=8,
|
||||
volume=1,
|
||||
summary='Nagatoro est en seconde. Pleine d\u2019assurance, joueuse, moqueuse, elle se d\u00e9couvre un jour un passe-temps favori : martyriser son \u201cSenpai\u201d, lyc\u00e9en de premi\u00e8re timide et mal dans sa peau. Nagatoro taquine, agace, aguiche, va parfois trop loin... mais qu\u2019a-t-elle vraiment derri\u00e8re la t\u00eate ? Et si derri\u00e8re ses moqueries elle cachait une v\u00e9ritable affection ? Et si finalement, ses farces permettaient \u00e0 Senpai de s\u2019affirmer ?',
|
||||
summary="Nagatoro est en seconde. Pleine d\u2019assurance, joueuse, moqueuse, elle se d\u00e9couvre un jour un passe-temps favori : martyriser son \u201cSenpai\u201d, lyc\u00e9en de premi\u00e8re timide et mal dans sa peau. Nagatoro taquine, agace, aguiche, va parfois trop loin... mais qu\u2019a-t-elle vraiment derri\u00e8re la t\u00eate ? Et si derri\u00e8re ses moqueries elle cachait une v\u00e9ritable affection ? Et si finalement, ses farces permettaient \u00e0 Senpai de s\u2019affirmer ?",
|
||||
year=2021,
|
||||
month=3,
|
||||
day=12,
|
||||
writer='Nanashi',
|
||||
inker='Nanashi',
|
||||
editor='Noeve Grafx',
|
||||
publisher='Noeve Grafx',
|
||||
imprint='Noeve Grafx',
|
||||
genre='Shonen',
|
||||
web='http://www.izneo.com/en/manga/shonen/arrete-de-me-chauffer-nagatoro-37560/arrete-de-me-chauffer-nagatoro-86232',
|
||||
language_iso='fr',
|
||||
writer="Nanashi",
|
||||
inker="Nanashi",
|
||||
editor="Noeve Grafx",
|
||||
publisher="Noeve Grafx",
|
||||
imprint="Noeve Grafx",
|
||||
genre="Shonen",
|
||||
web="http://www.izneo.com/en/manga/shonen/arrete-de-me-chauffer-nagatoro-37560/arrete-de-me-chauffer-nagatoro-86232",
|
||||
language_iso="fr",
|
||||
format=Format.PREVIEW,
|
||||
black_white=YesNo.YES,
|
||||
manga=Manga.RIGHT_LEFT,
|
||||
age_rating=AgeRating.EVERYONE10,
|
||||
manga=Manga.YES_AND_RIGHT_TO_LEFT,
|
||||
age_rating=AgeRating.EVERYONE_10_PLUS,
|
||||
community_rating=5,
|
||||
ean='9782490676569'
|
||||
ean="9782490676569",
|
||||
)
|
||||
|
||||
# Show the comic using the show()
|
||||
# Use the sequence protocol
|
||||
print(f"Number of pages: {len(comic)}")
|
||||
for i, page in enumerate(comic):
|
||||
print(f" Page {i}: {page.image_width}x{page.image_height} ({page.type})")
|
||||
|
||||
# Display in the built-in reader
|
||||
comic.show()
|
||||
|
||||
# Pack the comic book content into a CBZ file format
|
||||
cbz_content = comic.pack(rename=True)
|
||||
|
||||
# Define the path where the CBZ file will be saved
|
||||
cbz_path = PARENT / f'{comic.title}.cbz'
|
||||
|
||||
# Write the CBZ content to the specified path
|
||||
cbz_path.write_bytes(cbz_content)
|
||||
# Save as CBZ
|
||||
cbz_path = PARENT / f"{comic.title}.cbz"
|
||||
comic.save(cbz_path)
|
||||
print(f"Saved: {cbz_path}")
|
||||
|
||||
+140
-78
@@ -1,159 +1,221 @@
|
||||
"""Tests for the ComicInfo class."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from cbz.comic import ComicInfo
|
||||
from cbz.constants import AgeRating, Format, Manga, PageType, YesNo
|
||||
from cbz.page import PageInfo
|
||||
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
|
||||
|
||||
|
||||
class TestComicInfo:
|
||||
"""Test cases for ComicInfo class."""
|
||||
"""Tests for comic creation, loading and serialization."""
|
||||
|
||||
def test_from_pages_creation(self, images_dir: Path) -> None:
|
||||
"""Test creating ComicInfo from pages."""
|
||||
# Load sample pages
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:3] # Use first 3 images
|
||||
pages: List[PageInfo] = []
|
||||
"""Create a ComicInfo from pages."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:3]
|
||||
pages = [
|
||||
PageInfo.load(
|
||||
path=path,
|
||||
type=PageType.FRONT_COVER if i == 0 else PageType.STORY,
|
||||
)
|
||||
for i, path in enumerate(image_paths)
|
||||
]
|
||||
|
||||
for i, path in enumerate(image_paths):
|
||||
page_type = PageType.FRONT_COVER if i == 0 else PageType.STORY
|
||||
page = PageInfo.load(path=path, type=page_type)
|
||||
pages.append(page)
|
||||
|
||||
# Create comic from pages
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Test Comic',
|
||||
series='Test Series',
|
||||
title="Test Comic",
|
||||
series="Test Series",
|
||||
number=1,
|
||||
volume=1,
|
||||
year=2024
|
||||
year=2024,
|
||||
)
|
||||
|
||||
assert comic.title == 'Test Comic'
|
||||
assert comic.series == 'Test Series'
|
||||
assert comic.title == "Test Comic"
|
||||
assert comic.series == "Test Series"
|
||||
assert comic.number == 1
|
||||
assert comic.volume == 1
|
||||
assert comic.year == 2024
|
||||
assert len(comic.pages) == 3
|
||||
assert comic.pages[0].type == PageType.FRONT_COVER
|
||||
assert comic.pages[1].type == PageType.STORY
|
||||
assert len(comic) == 3
|
||||
assert comic[0].type == PageType.FRONT_COVER
|
||||
assert comic[1].type == PageType.STORY
|
||||
|
||||
def test_from_cbz_file(self, sample_cbz_file: Path) -> None:
|
||||
"""Test loading ComicInfo from CBZ file."""
|
||||
"""Load from a CBZ file."""
|
||||
comic = ComicInfo.from_cbz(sample_cbz_file)
|
||||
|
||||
assert comic is not None
|
||||
assert hasattr(comic, 'pages')
|
||||
assert len(comic.pages) > 0
|
||||
assert all(isinstance(page, PageInfo) for page in comic.pages)
|
||||
assert len(comic) > 0
|
||||
assert all(isinstance(page, PageInfo) for page in comic)
|
||||
|
||||
def test_pack_cbz(self, images_dir: Path) -> None:
|
||||
"""Test packing comic into CBZ format."""
|
||||
# Create a simple comic
|
||||
"""Pack into CBZ format."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:2]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Pack Test',
|
||||
series='Test Series'
|
||||
title="Pack Test",
|
||||
series="Test Series",
|
||||
)
|
||||
|
||||
# Pack to CBZ
|
||||
cbz_content = comic.pack()
|
||||
|
||||
assert isinstance(cbz_content, bytes)
|
||||
assert len(cbz_content) > 0
|
||||
|
||||
def test_pack_with_rename(self, images_dir: Path) -> None:
|
||||
"""Test packing comic with page renaming."""
|
||||
"""Pack with sequential page renaming."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:2]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Rename Test'
|
||||
)
|
||||
|
||||
# Pack with rename option
|
||||
comic = ComicInfo.from_pages(pages=pages, title="Rename Test")
|
||||
cbz_content = comic.pack(rename=True)
|
||||
|
||||
assert isinstance(cbz_content, bytes)
|
||||
assert len(cbz_content) > 0
|
||||
|
||||
def test_comic_metadata_properties(self, images_dir: Path) -> None:
|
||||
"""Test comic metadata properties."""
|
||||
"""Verify all metadata fields."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:1]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title='Metadata Test',
|
||||
series='Test Series',
|
||||
title="Metadata Test",
|
||||
series="Test Series",
|
||||
number=5,
|
||||
count=10,
|
||||
volume=2,
|
||||
summary='Test summary',
|
||||
summary="Test summary",
|
||||
year=2023,
|
||||
month=6,
|
||||
day=15,
|
||||
writer='Test Writer',
|
||||
penciller='Test Penciller',
|
||||
inker='Test Inker',
|
||||
colorist='Test Colorist',
|
||||
letterer='Test Letterer',
|
||||
cover_artist='Test Cover Artist',
|
||||
editor='Test Editor',
|
||||
publisher='Test Publisher',
|
||||
imprint='Test Imprint',
|
||||
genre='Test Genre',
|
||||
language_iso='en',
|
||||
writer="Test Writer",
|
||||
penciller="Test Penciller",
|
||||
inker="Test Inker",
|
||||
colorist="Test Colorist",
|
||||
letterer="Test Letterer",
|
||||
cover_artist="Test Cover Artist",
|
||||
editor="Test Editor",
|
||||
publisher="Test Publisher",
|
||||
imprint="Test Imprint",
|
||||
genre="Test Genre",
|
||||
language_iso="en",
|
||||
format=Format.SERIES,
|
||||
black_white=YesNo.NO,
|
||||
manga=Manga.RIGHT_LEFT,
|
||||
manga=Manga.YES_AND_RIGHT_TO_LEFT,
|
||||
age_rating=AgeRating.TEEN,
|
||||
community_rating=4
|
||||
community_rating=4,
|
||||
)
|
||||
|
||||
assert comic.title == 'Metadata Test'
|
||||
assert comic.series == 'Test Series'
|
||||
assert comic.title == "Metadata Test"
|
||||
assert comic.series == "Test Series"
|
||||
assert comic.number == 5
|
||||
assert comic.count == 10
|
||||
assert comic.volume == 2
|
||||
assert comic.summary == 'Test summary'
|
||||
assert comic.summary == "Test summary"
|
||||
assert comic.year == 2023
|
||||
assert comic.month == 6
|
||||
assert comic.day == 15
|
||||
assert comic.writer == 'Test Writer'
|
||||
assert comic.penciller == 'Test Penciller'
|
||||
assert comic.inker == 'Test Inker'
|
||||
assert comic.colorist == 'Test Colorist'
|
||||
assert comic.letterer == 'Test Letterer'
|
||||
assert comic.cover_artist == 'Test Cover Artist'
|
||||
assert comic.editor == 'Test Editor'
|
||||
assert comic.publisher == 'Test Publisher'
|
||||
assert comic.imprint == 'Test Imprint'
|
||||
assert comic.genre == 'Test Genre'
|
||||
assert comic.language_iso == 'en'
|
||||
assert comic.writer == "Test Writer"
|
||||
assert comic.penciller == "Test Penciller"
|
||||
assert comic.inker == "Test Inker"
|
||||
assert comic.colorist == "Test Colorist"
|
||||
assert comic.letterer == "Test Letterer"
|
||||
assert comic.cover_artist == "Test Cover Artist"
|
||||
assert comic.editor == "Test Editor"
|
||||
assert comic.publisher == "Test Publisher"
|
||||
assert comic.imprint == "Test Imprint"
|
||||
assert comic.genre == "Test Genre"
|
||||
assert comic.language_iso == "en"
|
||||
assert comic.format == Format.SERIES
|
||||
assert comic.black_white == YesNo.NO
|
||||
assert comic.manga == Manga.RIGHT_LEFT
|
||||
assert comic.manga == Manga.YES_AND_RIGHT_TO_LEFT
|
||||
assert comic.age_rating == AgeRating.TEEN
|
||||
assert comic.community_rating == 4
|
||||
|
||||
def test_page_count_property(self, images_dir: Path) -> None:
|
||||
"""Test that page count returns correct count."""
|
||||
"""Verify page count via len()."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:4]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(pages=pages, title='Count Test')
|
||||
|
||||
assert len(comic.pages) == 4
|
||||
comic = ComicInfo.from_pages(pages=pages, title="Count Test")
|
||||
assert len(comic) == 4
|
||||
|
||||
def test_empty_pages_list(self) -> None:
|
||||
"""Test creating comic with empty pages list."""
|
||||
comic = ComicInfo.from_pages(pages=[], title='Empty Test')
|
||||
"""Create a comic with no pages."""
|
||||
comic = ComicInfo.from_pages(pages=[], title="Empty Test")
|
||||
assert comic.title == "Empty Test"
|
||||
assert len(comic) == 0
|
||||
|
||||
assert comic.title == 'Empty Test'
|
||||
assert len(comic.pages) == 0
|
||||
def test_single_page_comic_load(self, images_dir: Path) -> None:
|
||||
"""Round-trip load of a single-page comic."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:1]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(pages=pages, title="Single Page Test")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir) / "single_page.cbz"
|
||||
comic.save(temp_path)
|
||||
assert temp_path.exists()
|
||||
|
||||
loaded = ComicInfo.from_cbz(temp_path)
|
||||
assert loaded.title == "Single Page Test"
|
||||
assert len(loaded) == 1
|
||||
|
||||
def test_sequence_protocol(self, images_dir: Path) -> None:
|
||||
"""Verify sequence protocol (iteration, indexing)."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:3]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(pages=pages, title="Sequence Test")
|
||||
|
||||
# Iteration
|
||||
count = 0
|
||||
for page in comic:
|
||||
assert isinstance(page, PageInfo)
|
||||
count += 1
|
||||
assert count == 3
|
||||
|
||||
# Indexing
|
||||
first = comic[0]
|
||||
assert isinstance(first, PageInfo)
|
||||
last = comic[-1]
|
||||
assert isinstance(last, PageInfo)
|
||||
|
||||
# Slicing
|
||||
subset = comic[0:2]
|
||||
assert len(subset) == 2
|
||||
|
||||
# Containment
|
||||
assert first in comic
|
||||
|
||||
def test_get_info(self, images_dir: Path) -> None:
|
||||
"""Verify metadata serialization."""
|
||||
image_paths = sorted(list(images_dir.iterdir()))[:2]
|
||||
pages = [PageInfo.load(path=path) for path in image_paths]
|
||||
|
||||
comic = ComicInfo.from_pages(
|
||||
pages=pages,
|
||||
title="Info Test",
|
||||
series="Test Series",
|
||||
year=2024,
|
||||
)
|
||||
|
||||
info = comic.get_info()
|
||||
assert info["Title"] == "Info Test"
|
||||
assert info["Series"] == "Test Series"
|
||||
assert info["Year"] == 2024
|
||||
assert info["PageCount"] == 2
|
||||
assert "Pages" in info
|
||||
assert len(info["Pages"]["Page"]) == 2
|
||||
|
||||
def test_none_defaults(self) -> None:
|
||||
"""Verify optional fields default to None."""
|
||||
comic = ComicInfo.from_pages(pages=[])
|
||||
assert comic.number is None
|
||||
assert comic.count is None
|
||||
assert comic.volume is None
|
||||
assert comic.year is None
|
||||
assert comic.community_rating is None
|
||||
|
||||
+133
-194
@@ -1,265 +1,204 @@
|
||||
from cbz.models import BaseModel, ComicModel, PageModel
|
||||
from cbz.constants import Format, YesNo, Manga, AgeRating, PageType
|
||||
"""Tests for data models."""
|
||||
|
||||
from dataclasses import fields
|
||||
|
||||
class TestBaseModel:
|
||||
"""Test cases for BaseModel class."""
|
||||
import pytest
|
||||
|
||||
def test_base_model_creation(self) -> None:
|
||||
"""Test creating BaseModel with fields."""
|
||||
test_fields = {
|
||||
'test_str': ('Test String', str),
|
||||
'test_int': ('Test Integer', int),
|
||||
'test_bool': ('Test Boolean', bool)
|
||||
}
|
||||
|
||||
model = BaseModel(fields=test_fields)
|
||||
|
||||
# Check default values are set
|
||||
assert hasattr(model, 'test_str')
|
||||
assert hasattr(model, 'test_int')
|
||||
assert hasattr(model, 'test_bool')
|
||||
|
||||
def test_base_model_with_kwargs(self) -> None:
|
||||
"""Test creating BaseModel with keyword arguments."""
|
||||
test_fields = {
|
||||
'title': ('Title', str),
|
||||
'number': ('Number', int),
|
||||
'published': ('Published', bool)
|
||||
}
|
||||
|
||||
model = BaseModel(
|
||||
fields=test_fields,
|
||||
title='Test Title',
|
||||
number=42,
|
||||
published=True
|
||||
)
|
||||
|
||||
assert model.title == 'Test Title'
|
||||
assert model.number == 42
|
||||
assert model.published
|
||||
|
||||
def test_attribute_type_verification(self) -> None:
|
||||
"""Test that attribute types are verified on assignment."""
|
||||
test_fields = {
|
||||
'count': ('Count', int),
|
||||
'name': ('Name', str)
|
||||
}
|
||||
|
||||
model = BaseModel(fields=test_fields)
|
||||
|
||||
# Valid assignments
|
||||
model.count = 10
|
||||
model.name = 'Test'
|
||||
|
||||
assert model.count == 10
|
||||
assert model.name == 'Test'
|
||||
|
||||
def test_repr_method(self) -> None:
|
||||
"""Test string representation of BaseModel."""
|
||||
test_fields = {
|
||||
'title': ('Title', str)
|
||||
}
|
||||
|
||||
model = BaseModel(fields=test_fields, title='Test')
|
||||
repr_str = repr(model)
|
||||
|
||||
assert isinstance(repr_str, str)
|
||||
assert 'BaseModel' in repr_str
|
||||
from cbz.constants import (
|
||||
AgeRating,
|
||||
Format,
|
||||
LanguageISO,
|
||||
Manga,
|
||||
PageType,
|
||||
Rating,
|
||||
YesNo,
|
||||
)
|
||||
from cbz.models import ComicModel, PageModel, _get_xml_mapping
|
||||
|
||||
|
||||
class TestComicModel:
|
||||
"""Test cases for ComicModel class."""
|
||||
"""Tests for the ComicModel dataclass."""
|
||||
|
||||
def test_comic_model_creation(self) -> None:
|
||||
"""Test creating ComicModel with default values."""
|
||||
def test_default_values(self) -> None:
|
||||
"""Correct default values."""
|
||||
model = ComicModel()
|
||||
|
||||
# Check that comic-specific attributes exist
|
||||
assert hasattr(model, 'title')
|
||||
assert hasattr(model, 'series')
|
||||
assert hasattr(model, 'number')
|
||||
assert hasattr(model, 'volume')
|
||||
assert hasattr(model, 'year')
|
||||
assert hasattr(model, 'month')
|
||||
assert hasattr(model, 'day')
|
||||
assert model.title == ""
|
||||
assert model.series == ""
|
||||
assert model.number is None
|
||||
assert model.count is None
|
||||
assert model.volume is None
|
||||
assert model.year is None
|
||||
assert model.format == Format.UNKNOWN
|
||||
assert model.black_white == YesNo.UNKNOWN
|
||||
assert model.manga == Manga.UNKNOWN
|
||||
assert model.age_rating == AgeRating.UNKNOWN
|
||||
assert model.community_rating is None
|
||||
|
||||
def test_comic_model_with_values(self) -> None:
|
||||
"""Test creating ComicModel with specific values."""
|
||||
def test_with_values(self) -> None:
|
||||
"""Creation with specific values."""
|
||||
model = ComicModel(
|
||||
title='Test Comic',
|
||||
series='Test Series',
|
||||
title="Test Comic",
|
||||
series="Test Series",
|
||||
number=1,
|
||||
volume=1,
|
||||
year=2024,
|
||||
month=6,
|
||||
day=15,
|
||||
writer='Test Writer',
|
||||
publisher='Test Publisher',
|
||||
language_iso='en',
|
||||
writer="Test Writer",
|
||||
publisher="Test Publisher",
|
||||
language_iso=LanguageISO("en"),
|
||||
format=Format.SERIES,
|
||||
black_white=YesNo.NO,
|
||||
manga=Manga.RIGHT_LEFT,
|
||||
age_rating=AgeRating.EVERYONE
|
||||
manga=Manga.YES_AND_RIGHT_TO_LEFT,
|
||||
age_rating=AgeRating.EVERYONE,
|
||||
)
|
||||
|
||||
assert model.title == 'Test Comic'
|
||||
assert model.series == 'Test Series'
|
||||
assert model.title == "Test Comic"
|
||||
assert model.series == "Test Series"
|
||||
assert model.number == 1
|
||||
assert model.volume == 1
|
||||
assert model.year == 2024
|
||||
assert model.month == 6
|
||||
assert model.day == 15
|
||||
assert model.writer == 'Test Writer'
|
||||
assert model.publisher == 'Test Publisher'
|
||||
assert model.language_iso == 'en'
|
||||
assert model.format == Format.SERIES
|
||||
assert model.black_white == YesNo.NO
|
||||
assert model.manga == Manga.RIGHT_LEFT
|
||||
assert model.age_rating == AgeRating.EVERYONE
|
||||
assert model.manga == Manga.YES_AND_RIGHT_TO_LEFT
|
||||
|
||||
def test_comic_model_enum_properties(self) -> None:
|
||||
"""Test that enum properties work correctly."""
|
||||
def test_enum_assignment(self) -> None:
|
||||
"""Enum assignment."""
|
||||
model = ComicModel()
|
||||
|
||||
# Test format enum
|
||||
model.format = Format.PREVIEW
|
||||
assert model.format == Format.PREVIEW
|
||||
|
||||
# Test yes/no enum
|
||||
model.black_white = YesNo.YES
|
||||
assert model.black_white == YesNo.YES
|
||||
|
||||
# Test manga enum
|
||||
model.manga = Manga.RIGHT_LEFT
|
||||
assert model.manga == Manga.RIGHT_LEFT
|
||||
model.manga = Manga.YES_AND_RIGHT_TO_LEFT
|
||||
assert model.manga == Manga.YES_AND_RIGHT_TO_LEFT
|
||||
|
||||
# Test age rating enum
|
||||
model.age_rating = AgeRating.TEEN
|
||||
assert model.age_rating == AgeRating.TEEN
|
||||
|
||||
def test_comic_model_metadata_fields(self) -> None:
|
||||
"""Test comic metadata fields."""
|
||||
def test_metadata_fields(self) -> None:
|
||||
"""Verify metadata fields."""
|
||||
model = ComicModel(
|
||||
summary='Test summary',
|
||||
notes='Test notes',
|
||||
genre='Adventure',
|
||||
web='http://example.com',
|
||||
ean='1234567890123',
|
||||
community_rating=5,
|
||||
main_character_or_team='Hero',
|
||||
characters='Hero, Villain',
|
||||
teams='Justice League',
|
||||
locations='Metropolis',
|
||||
scan_information='Scanned by Test',
|
||||
story_arc='Origin Story',
|
||||
series_group='DC Comics',
|
||||
alternate_series='Alternate Universe',
|
||||
summary="Test summary",
|
||||
notes="Test notes",
|
||||
genre="Adventure",
|
||||
web="http://example.com",
|
||||
ean="1234567890123",
|
||||
community_rating=Rating(5),
|
||||
main_character_or_team="Hero",
|
||||
characters="Hero, Villain",
|
||||
teams="Justice League",
|
||||
locations="Metropolis",
|
||||
scan_information="Scanned by Test",
|
||||
story_arc="Origin Story",
|
||||
series_group="DC Comics",
|
||||
alternate_series="Alternate Universe",
|
||||
alternate_number=2,
|
||||
alternate_count=10
|
||||
alternate_count=10,
|
||||
)
|
||||
|
||||
assert model.summary == 'Test summary'
|
||||
assert model.notes == 'Test notes'
|
||||
assert model.genre == 'Adventure'
|
||||
assert model.web == 'http://example.com'
|
||||
assert model.ean == '1234567890123'
|
||||
assert model.summary == "Test summary"
|
||||
assert model.genre == "Adventure"
|
||||
assert model.community_rating == 5
|
||||
assert model.main_character_or_team == 'Hero'
|
||||
assert model.characters == 'Hero, Villain'
|
||||
assert model.teams == 'Justice League'
|
||||
assert model.locations == 'Metropolis'
|
||||
assert model.scan_information == 'Scanned by Test'
|
||||
assert model.story_arc == 'Origin Story'
|
||||
assert model.series_group == 'DC Comics'
|
||||
assert model.alternate_series == 'Alternate Universe'
|
||||
assert model.characters == "Hero, Villain"
|
||||
assert model.alternate_number == 2
|
||||
assert model.alternate_count == 10
|
||||
|
||||
def test_xml_mapping(self) -> None:
|
||||
"""Verify XML mapping."""
|
||||
mapping = _get_xml_mapping(ComicModel)
|
||||
assert "title" in mapping
|
||||
assert mapping["title"][0] == "Title"
|
||||
|
||||
def test_all_fields_have_xml_mapping(self) -> None:
|
||||
"""All annotated fields have an XML mapping."""
|
||||
mapping = _get_xml_mapping(ComicModel)
|
||||
for f in fields(ComicModel):
|
||||
if "xml_name" in f.metadata:
|
||||
assert f.name in mapping
|
||||
|
||||
|
||||
class TestPageModel:
|
||||
"""Test cases for PageModel class."""
|
||||
"""Tests for the PageModel dataclass."""
|
||||
|
||||
def test_page_model_creation(self) -> None:
|
||||
"""Test creating PageModel with default values."""
|
||||
def test_default_values(self) -> None:
|
||||
"""Correct default values."""
|
||||
model = PageModel()
|
||||
|
||||
# Check that page-specific attributes exist
|
||||
assert hasattr(model, 'image')
|
||||
assert hasattr(model, 'type')
|
||||
assert hasattr(model, 'double')
|
||||
assert hasattr(model, 'image_size')
|
||||
assert hasattr(model, 'key')
|
||||
assert hasattr(model, 'bookmark')
|
||||
assert hasattr(model, 'image_width')
|
||||
assert hasattr(model, 'image_height')
|
||||
assert hasattr(model, 'image_size')
|
||||
# Note: format is not a base field in PageModel
|
||||
assert model.type == PageType.STORY
|
||||
assert model.double is False
|
||||
assert model.image_size == 0
|
||||
assert model.key == ""
|
||||
assert model.bookmark == ""
|
||||
assert model.image_width == 0
|
||||
assert model.image_height == 0
|
||||
|
||||
def test_page_model_with_values(self) -> None:
|
||||
"""Test creating PageModel with specific values."""
|
||||
def test_with_values(self) -> None:
|
||||
"""Creation with specific values."""
|
||||
model = PageModel(
|
||||
type=PageType.FRONT_COVER,
|
||||
double=True,
|
||||
image_size=1024000,
|
||||
key='cover',
|
||||
bookmark='Chapter 1',
|
||||
key="cover",
|
||||
bookmark="Chapter 1",
|
||||
image_width=800,
|
||||
image_height=1200,
|
||||
)
|
||||
|
||||
assert model.type == PageType.FRONT_COVER
|
||||
assert model.double
|
||||
assert model.double is True
|
||||
assert model.image_size == 1024000
|
||||
assert model.key == 'cover'
|
||||
assert model.bookmark == 'Chapter 1'
|
||||
assert model.key == "cover"
|
||||
assert model.bookmark == "Chapter 1"
|
||||
assert model.image_width == 800
|
||||
assert model.image_height == 1200
|
||||
|
||||
def test_page_model_page_types(self) -> None:
|
||||
"""Test different page types."""
|
||||
page_types = [
|
||||
PageType.FRONT_COVER,
|
||||
PageType.INNER_COVER,
|
||||
PageType.ROUNDUP,
|
||||
PageType.STORY,
|
||||
PageType.ADVERTISEMENT,
|
||||
PageType.EDITORIAL,
|
||||
PageType.LETTERS,
|
||||
PageType.PREVIEW,
|
||||
PageType.BACK_COVER,
|
||||
PageType.OTHER,
|
||||
PageType.DELETED
|
||||
]
|
||||
|
||||
for page_type in page_types:
|
||||
def test_all_page_types(self) -> None:
|
||||
"""All page types are valid."""
|
||||
for page_type in PageType:
|
||||
model = PageModel(type=page_type)
|
||||
assert model.type == page_type
|
||||
|
||||
def test_page_model_boolean_properties(self) -> None:
|
||||
"""Test boolean properties in PageModel."""
|
||||
def test_boolean_properties(self) -> None:
|
||||
"""Boolean double property."""
|
||||
model = PageModel()
|
||||
|
||||
# Test double property
|
||||
model.double = True
|
||||
assert model.double
|
||||
|
||||
assert model.double is True
|
||||
model.double = False
|
||||
assert not model.double
|
||||
assert model.double is False
|
||||
|
||||
def test_page_model_numeric_properties(self) -> None:
|
||||
"""Test numeric properties in PageModel."""
|
||||
model = PageModel(
|
||||
image_size=2048000,
|
||||
image_width=1920,
|
||||
image_height=1080
|
||||
)
|
||||
|
||||
assert model.image_size == 2048000
|
||||
assert model.image_width == 1920
|
||||
assert model.image_height == 1080
|
||||
class TestRating:
|
||||
"""Tests for the Rating type."""
|
||||
|
||||
# Test that they're integers
|
||||
assert isinstance(model.image_size, int)
|
||||
assert isinstance(model.image_width, int)
|
||||
assert isinstance(model.image_height, int)
|
||||
def test_valid_rating(self) -> None:
|
||||
"""Valid ratings (0-5)."""
|
||||
assert Rating(0) == 0.0
|
||||
assert Rating(2.5) == 2.5
|
||||
assert Rating(5) == 5.0
|
||||
|
||||
def test_invalid_rating(self) -> None:
|
||||
"""Invalid ratings raise ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Rating(-1)
|
||||
with pytest.raises(ValueError):
|
||||
Rating(6)
|
||||
|
||||
|
||||
class TestLanguageISO:
|
||||
"""Tests for the LanguageISO type."""
|
||||
|
||||
def test_valid_language(self) -> None:
|
||||
"""Valid language codes."""
|
||||
assert LanguageISO("en") == "en"
|
||||
assert LanguageISO("fr") == "fr"
|
||||
assert LanguageISO("ja") == "ja"
|
||||
|
||||
def test_empty_language(self) -> None:
|
||||
"""Empty language code is allowed."""
|
||||
assert LanguageISO("") == ""
|
||||
|
||||
def test_invalid_language(self) -> None:
|
||||
"""Invalid language code raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
LanguageISO("zzzzzzz")
|
||||
|
||||
+59
-70
@@ -1,16 +1,19 @@
|
||||
"""Tests for the PageInfo class."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cbz.page import PageInfo
|
||||
from cbz.constants import PageType
|
||||
from cbz.exceptions import InvalidImageError
|
||||
from cbz.page import PageInfo
|
||||
|
||||
|
||||
class TestPageInfo:
|
||||
"""Test cases for PageInfo class."""
|
||||
"""Tests for page loading, properties and manipulation."""
|
||||
|
||||
def test_load_from_file(self, sample_image_path: Path) -> None:
|
||||
"""Test loading PageInfo from image file."""
|
||||
"""Load from an image file."""
|
||||
page = PageInfo.load(path=sample_image_path)
|
||||
|
||||
assert page is not None
|
||||
@@ -20,58 +23,49 @@ class TestPageInfo:
|
||||
assert page.image_width > 0
|
||||
assert page.image_height > 0
|
||||
assert page.image_size > 0
|
||||
assert page.suffix is not None
|
||||
assert page.suffix != ""
|
||||
|
||||
def test_load_with_page_type(self, sample_image_path: Path) -> None:
|
||||
"""Test loading PageInfo with specific page type."""
|
||||
"""Load with a specific page type."""
|
||||
page = PageInfo.load(path=sample_image_path, type=PageType.FRONT_COVER)
|
||||
|
||||
assert page.type == PageType.FRONT_COVER
|
||||
|
||||
def test_load_with_custom_name(self, sample_image_path: Path) -> None:
|
||||
"""Test loading PageInfo with custom name."""
|
||||
custom_name = 'custom_page.jpg'
|
||||
page = PageInfo.load(path=sample_image_path, name=custom_name)
|
||||
|
||||
assert page.name == custom_name
|
||||
"""Load with a custom name."""
|
||||
page = PageInfo.load(path=sample_image_path, name="custom_page.jpg")
|
||||
assert page.name == "custom_page.jpg"
|
||||
|
||||
def test_page_content_property(self, sample_image_path: Path) -> None:
|
||||
"""Test page content property getter and setter."""
|
||||
"""Content property and automatic metadata extraction."""
|
||||
page = PageInfo.load(path=sample_image_path)
|
||||
original_content = page.content
|
||||
original = page.content
|
||||
|
||||
# Test getter
|
||||
assert page.content == original_content
|
||||
assert page.content == original
|
||||
assert isinstance(page.content, bytes)
|
||||
|
||||
# Test that content is properly set and metadata extracted
|
||||
assert page.image_width > 0
|
||||
assert page.image_height > 0
|
||||
assert page.image_size > 0
|
||||
|
||||
def test_image_metadata_extraction(self, sample_image_path: Path) -> None:
|
||||
"""Test that image metadata is correctly extracted."""
|
||||
"""Correct extraction of image metadata."""
|
||||
page = PageInfo.load(path=sample_image_path)
|
||||
|
||||
# Check that all image metadata properties are set
|
||||
assert hasattr(page, 'image_width') and page.image_width > 0
|
||||
assert hasattr(page, 'image_height') and page.image_height > 0
|
||||
assert hasattr(page, 'image_size') and page.image_size > 0
|
||||
assert hasattr(page, 'suffix') and page.suffix is not None
|
||||
|
||||
# Verify dimensions make sense for an image
|
||||
assert page.image_width > 0
|
||||
assert page.image_height > 0
|
||||
assert page.image_size > 0
|
||||
assert page.suffix != ""
|
||||
assert isinstance(page.image_width, int)
|
||||
assert isinstance(page.image_height, int)
|
||||
assert isinstance(page.image_size, int)
|
||||
|
||||
def test_multiple_image_formats(self, images_dir: Path) -> None:
|
||||
"""Test loading different image formats."""
|
||||
image_files = list(images_dir.glob('*.jpg'))
|
||||
"""Load different image files."""
|
||||
image_files = list(images_dir.glob("*.jpg"))
|
||||
|
||||
if not image_files:
|
||||
pytest.skip('No image files found in example directory')
|
||||
pytest.skip("No image files found")
|
||||
|
||||
for image_path in image_files[:3]: # Test first 3 images
|
||||
for image_path in image_files[:3]:
|
||||
page = PageInfo.load(path=image_path)
|
||||
|
||||
assert page is not None
|
||||
@@ -82,62 +76,57 @@ class TestPageInfo:
|
||||
assert page.image_size > 0
|
||||
|
||||
def test_page_type_assignment(self, sample_image_path: Path) -> None:
|
||||
"""Test different page type assignments."""
|
||||
page_types = [
|
||||
PageType.FRONT_COVER,
|
||||
PageType.INNER_COVER,
|
||||
PageType.ROUNDUP,
|
||||
PageType.STORY,
|
||||
PageType.ADVERTISEMENT,
|
||||
PageType.EDITORIAL,
|
||||
PageType.LETTERS,
|
||||
PageType.PREVIEW,
|
||||
PageType.BACK_COVER,
|
||||
PageType.OTHER,
|
||||
PageType.DELETED
|
||||
]
|
||||
|
||||
for page_type in page_types:
|
||||
page = PageInfo.load(path=sample_image_path, type=page_type)
|
||||
"""Assignment of all page types."""
|
||||
image_bytes = sample_image_path.read_bytes()
|
||||
for page_type in PageType:
|
||||
page = PageInfo.loads(data=image_bytes, type=page_type)
|
||||
assert page.type == page_type
|
||||
|
||||
def test_page_creation_from_bytes(self, sample_image_path: Path) -> None:
|
||||
"""Test creating PageInfo directly from bytes."""
|
||||
# Read image file as bytes
|
||||
with open(sample_image_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
"""Direct creation from bytes."""
|
||||
image_bytes = sample_image_path.read_bytes()
|
||||
|
||||
# Create page from bytes
|
||||
page = PageInfo(content=image_bytes, name='test_page.jpg')
|
||||
page = PageInfo.loads(data=image_bytes, name="test_page.jpg")
|
||||
|
||||
assert page.content == image_bytes
|
||||
assert page.name == 'test_page.jpg'
|
||||
assert page.name == "test_page.jpg"
|
||||
assert page.image_width > 0
|
||||
assert page.image_height > 0
|
||||
assert page.image_size > 0
|
||||
|
||||
def test_repr_string(self, sample_image_path: Path) -> None:
|
||||
"""Test string representation of PageInfo."""
|
||||
page = PageInfo.load(path=sample_image_path, type=PageType.STORY)
|
||||
|
||||
repr_str = repr(page)
|
||||
assert 'PageInfo' in repr_str
|
||||
assert isinstance(repr_str, str)
|
||||
|
||||
def test_page_bookmark_property(self, sample_image_path: Path) -> None:
|
||||
"""Test page bookmark property."""
|
||||
# Test with bookmark
|
||||
page = PageInfo.load(path=sample_image_path, bookmark='Chapter 1')
|
||||
assert page.bookmark == 'Chapter 1'
|
||||
"""Bookmark property."""
|
||||
page = PageInfo.load(path=sample_image_path, bookmark="Chapter 1")
|
||||
assert page.bookmark == "Chapter 1"
|
||||
|
||||
# Test without bookmark
|
||||
page_no_bookmark = PageInfo.load(path=sample_image_path)
|
||||
assert hasattr(page_no_bookmark, 'bookmark')
|
||||
assert page_no_bookmark.bookmark == ""
|
||||
|
||||
def test_page_double_page_property(self, sample_image_path: Path) -> None:
|
||||
"""Test page double_page property."""
|
||||
"""Double page property."""
|
||||
page = PageInfo.load(path=sample_image_path, double=True)
|
||||
assert page.double
|
||||
assert page.double is True
|
||||
|
||||
page_no_double = PageInfo.load(path=sample_image_path)
|
||||
assert hasattr(page_no_double, 'double')
|
||||
assert page_no_double.double is False
|
||||
|
||||
def test_invalid_data_raises_error(self) -> None:
|
||||
"""Invalid data raises InvalidImageError."""
|
||||
with pytest.raises(InvalidImageError):
|
||||
PageInfo.loads(data=b"not an image")
|
||||
|
||||
def test_empty_data_raises_error(self) -> None:
|
||||
"""Empty data raises InvalidImageError."""
|
||||
with pytest.raises(InvalidImageError):
|
||||
PageInfo.loads(data=b" ")
|
||||
|
||||
def test_save_and_reload(self, sample_image_path: Path, tmp_path: Path) -> None:
|
||||
"""Save and reload a page."""
|
||||
page = PageInfo.load(path=sample_image_path)
|
||||
save_path = tmp_path / "saved_page.jpg"
|
||||
page.save(save_path)
|
||||
|
||||
reloaded = PageInfo.load(path=save_path)
|
||||
assert reloaded.image_width == page.image_width
|
||||
assert reloaded.image_height == page.image_height
|
||||
assert reloaded.image_size == page.image_size
|
||||
|
||||
Reference in New Issue
Block a user