Compare commits

..
4 Commits
Author SHA1 Message Date
legonzaur 45da4c11f4 fix: allow comi.number to be a float 2025-12-30 15:25:50 +01:00
legonzaur 1e23fad6b5 fix: allow Comic.number to be a float 2025-12-30 15:25:46 +01:00
legonzaur 4ebee2a6cf chore: formatting 2025-12-30 15:25:19 +01:00
legonzaur ef4eff4868 fix pyproject toml to work with uv 2025-12-30 15:24:47 +01:00
20 changed files with 1455 additions and 2180 deletions
+24 -71
View File
@@ -1,8 +1,7 @@
# Created by https://github.com/github/gitignore/blob/main/Python.gitignore
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*.py[cod]
*$py.class
# C extensions
@@ -29,8 +28,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
@@ -48,7 +47,7 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
@@ -94,37 +93,22 @@ 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
# 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
#Pipfile.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.toml
poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# 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
#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
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -133,25 +117,11 @@ __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/
@@ -184,41 +154,24 @@ 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/
# 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/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# 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
.ruff_cache/
# PyPI configuration file
.pypirc
# LSP config files
pyrightconfig.json
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
#CBZ
### CBZ ###
*.cbz
*.pdf
*.zip
*.cbr
*cbr
-55
View File
@@ -4,60 +4,6 @@ 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
@@ -286,7 +232,6 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 hyugogirubato
Copyright (c) 2025 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
+57 -233
View File
@@ -4,16 +4,14 @@ 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 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
- 🚀 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
## Installation
@@ -23,60 +21,56 @@ 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 import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
from cbz.comic import ComicInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
from cbz.page import PageInfo
if __name__ == "__main__":
paths = sorted(Path("path/to/your/images").iterdir())
PARENT = Path(__file__).parent
# Load each page from the images folder into a list of PageInfo objects
if __name__ == '__main__':
paths = list(Path('path/to/your/images').iterdir())
# 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 with metadata
# Create a ComicInfo object using ComicInfo.from_pages() method
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.RATING_PENDING,
age_rating=AgeRating.PENDING
)
# Display the comic in the built-in reader
# Show the comic using the show()
comic.show()
# Save directly as a CBZ file
comic.save("your_comic.cbz")
# Or pack to bytes for custom handling
# Pack the comic book content into a CBZ file format
cbz_content = comic.pack()
Path("your_comic.cbz").write_bytes(cbz_content)
```
# 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)
````
## Player
@@ -90,17 +84,17 @@ CBZ includes a command-line player for viewing comic book files in multiple form
### Usage
```shell
````shell
usage: cbzplayer [-h] <file>
CBZ/CBR/PDF comic reader
Launch CBZ player with a comic book file
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
@@ -115,17 +109,6 @@ 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:
@@ -145,47 +128,30 @@ 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 import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
from cbz.comic import ComicInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
from cbz.page import PageInfo
# Example usage:
pages = [
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),
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),
]
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.RATING_PENDING,
age_rating=AgeRating.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()`:
@@ -195,181 +161,39 @@ info = comic.get_info()
print(info)
```
### Packing and Saving
### Packing into CBZ Format
Pack the comic into CBZ format as bytes:
Pack the comic into a CBZ file format:
```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 = ComicInfo.from_cbz("your_comic.cbz")
comic_from_cbz = ComicInfo.from_cbz('/path/to/your_comic.cbz')
```
Load a comic from an existing CBR file (with metadata):
```python
comic = ComicInfo.from_cbr("your_comic.cbr")
comic_from_cbr = ComicInfo.from_cbr('/path/to/your_comic.cbr')
```
Load a comic from a PDF file (images only, no metadata):
```python
comic = ComicInfo.from_pdf("your_comic.pdf")
comic_from_pdf = ComicInfo.from_pdf('/path/to/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>
@@ -380,11 +204,11 @@ See [`CHANGELOG.md`](CHANGELOG.md) for the full version history, including migra
<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](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](https://github.com/hyugogirubato/cbz/blob/main/LICENSE).
You can find a copy of the license in the LICENSE file in the root folder.
### Third-Party Licenses
@@ -400,4 +224,4 @@ This project uses the following third-party libraries:
---
© hyugogirubato
© hyugogirubato 2025
+4 -45
View File
@@ -1,49 +1,8 @@
"""
CBZ - Python library for digital comic book management.
from .comic import ComicInfo
from .page import PageInfo
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"):
# Register optional Pillow plugins
for _plugin in ('pillow_avif', 'pillow_jxl'):
try:
__import__(_plugin)
except ImportError:
+21 -31
View File
@@ -1,47 +1,37 @@
"""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:
"""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."
)
# 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.')
args = parser.parse_args()
path: Path = args.comic_path
if not path.is_file():
print(f"Error: file '{path}' does not exist.", file=sys.stderr)
sys.exit(1)
# 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)
# Create ComicInfo object from comic file
try:
suffix = path.suffix.lower()
if suffix == ".pdf":
comic = ComicInfo.from_pdf(path)
elif suffix == ".cbr":
comic = ComicInfo.from_cbr(path)
if comic_path.suffix == '.pdf':
comic_info = ComicInfo.from_pdf(comic_path)
elif comic_path.suffix == '.cbr':
comic_info = ComicInfo.from_cbr(comic_path)
else:
comic = ComicInfo.from_cbz(path)
comic_info = ComicInfo.from_cbz(comic_path)
comic.show()
except CBZError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Launch the CBZ player
comic_info.show()
except Exception as e:
print(f'Error: {e}')
exit(1)
if __name__ == "__main__":
if __name__ == '__main__':
main()
+223 -274
View File
@@ -1,373 +1,322 @@
"""
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 IMAGE_FORMATS, XML_NAME
from cbz.exceptions import EmptyArchiveError, InvalidMetadataError
from cbz.models import ComicModel, PageModel
from cbz.constants import XML_NAME, COMIC_FIELDS, IMAGE_FORMAT, PAGE_FIELDS
from cbz.models import ComicModel
from cbz.page import PageInfo
logger = logging.getLogger(__name__)
from cbz.utils import repr_attr
@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):
"""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.
"""
ComicInfo class that represents the comic book information and pages.
"""
pages: List[PageInfo] = field(default_factory=list)
def __init__(self, pages: List[PageInfo], **kwargs):
"""
Initialize the ComicInfo instance with pages and additional attributes.
# -- Alternative constructors (factory methods) --
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
@classmethod
def from_pages(cls, pages: List[PageInfo], **kwargs) -> ComicInfo:
"""Create a ComicInfo from a list of pages and metadata.
"""
Create a ComicInfo instance from pages and additional attributes.
Args:
pages: List of PageInfo objects.
**kwargs: Comic metadata (title, series, etc.).
pages (List[PageInfo]): List of PageInfo objects representing the comic pages.
**kwargs: Additional attributes for the comic.
Returns:
New ComicInfo instance.
ComicInfo: An instance of ComicInfo.
"""
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)
return cls(pages, **kwargs)
@classmethod
def from_cbz(cls, path: Union[Path, str]) -> ComicInfo:
"""Load a comic from a CBZ (ZIP) file.
"""
Create a ComicInfo instance from a CBZ file.
Args:
path: Path to the .cbz file.
path (Union[Path, str]): Path to the CBZ file.
Returns:
ComicInfo instance with pages and metadata.
ComicInfo: An instance of ComicInfo.
Raises:
EmptyArchiveError: If the archive contains no images.
ValueError: If the provided path is not a Path object or a string.
"""
return cls._from_archive(path, zipfile.ZipFile)
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
return cls.__unpack_zip(Path(path))
@classmethod
def from_cbr(cls, path: Union[Path, str]) -> ComicInfo:
"""Load a comic from a CBR (RAR) file.
"""
Create a ComicInfo instance from a CBR file.
Args:
path: Path to the .cbr file.
path (Union[Path, str]): Path to the CBR file.
Returns:
ComicInfo instance with pages and metadata.
ComicInfo: An instance of ComicInfo.
Raises:
EmptyArchiveError: If the archive contains no images.
ValueError: If the provided path is not a Path object or a string.
"""
return cls._from_archive(path, rarfile.RarFile)
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
return cls.__unpack_rar(Path(path))
@classmethod
def from_pdf(cls, path: Union[Path, str]) -> ComicInfo:
"""Load a comic from a PDF file (image extraction).
Only images are extracted; PDF metadata is not converted
to ComicInfo metadata.
"""
Create a ComicInfo instance from a PDF file.
Args:
path: Path to the .pdf file.
path (Union[Path, str]): Path to the PDF file.
Returns:
ComicInfo instance with extracted images.
ComicInfo: An instance of ComicInfo.
Raises:
EmptyArchiveError: If the PDF contains no images.
ValueError: If the provided path is not a Path object or a string.
"""
pages: List[PageInfo] = []
reader = PdfReader(Path(path))
for pdf_page in reader.pages:
for image in pdf_page.images:
pages.append(PageInfo.loads(data=image.data))
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
return cls.__unpack_pdf(Path(path))
if not pages:
raise EmptyArchiveError("No valid images found in PDF file")
return cls.from_pages(pages=pages)
# -- 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.
@staticmethod
def __unpack_pdf(path: Path) -> ComicInfo:
"""
Unpack a PDF file and create a ComicInfo instance.
Args:
archive: Open archive object (ZipFile or RarFile).
path (Path): Path to the PDF file.
Returns:
ComicInfo instance.
ComicInfo: An instance of ComicInfo.
"""
pages: List[PageInfo] = []
names = sorted(archive.namelist())
comic_data: dict = {}
reader = PdfReader(path)
for page in reader.pages:
for image in page.images:
pages.append(PageInfo.loads(data=image.data))
# Extract XML metadata
if XML_NAME in names:
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)
assert pages, 'No valid images present in file'
return ComicInfo.from_pages(pages=pages)
# Extract comic fields
comic_kwargs = _extract_fields(comic_data, ComicModel)
@staticmethod
def __extract_info(items: dict, fields: dict) -> dict:
"""
Extract and convert field information from the provided items and fields.
# Extract page information
pages_info = comic_data.get("Pages", {}).get("Page", [])
for i, name in enumerate(names):
suffix = Path(name).suffix
if suffix.lower() not in IMAGE_FORMATS:
logger.warning("Skipping unsupported file: %r", name)
continue
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))
return cls.from_pages(pages=pages, **comic_kwargs)
def get_info(self) -> dict:
"""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.
Args:
items (dict): Dictionary containing item attributes.
fields (dict): Dictionary containing field mappings and types.
Returns:
Structured dictionary for XML serialization.
dict: Dictionary with extracted and converted field information.
"""
comic_info = _serialize_fields(self, ComicModel)
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 = sorted(archive_file.namelist())
comic_info = {}
if XML_NAME in names:
with archive_file.open(XML_NAME, 'r') as f:
comic_info = xmltodict.parse(f.read(), force_list=('Page',)).get('ComicInfo', {})
names.remove(XML_NAME)
comic = ComicInfo.__extract_info(
items=comic_info,
fields=COMIC_FIELDS
)
pages_info = comic_info.get('Pages', {}).get('Page', [])
for i, name in enumerate(names):
suffix = Path(name).suffix
# Some archives may contain folders or hidden files (e.g., ".extra/" or "__MACOSX/.DS_Store")
# that could corrupt the suffix detection and cause invalid image handling.
if suffix not in IMAGE_FORMAT:
logging.warning(f'Skipping unsupported or invalid file: {name!r} (suffix={suffix!r})')
continue
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))
return ComicInfo.from_pages(pages=pages, **comic)
@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)
def get_info(self) -> dict:
"""
Get the comic information as a dictionary.
Returns:
dict: Dictionary containing comic information.
"""
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 = _serialize_fields(page, PageModel)
page_info["@Image"] = i
page_info = __info(
items={k: v for k, v in page.__dict__.items() if not k.startswith('_')},
fields=PAGE_FIELDS)
page_info['@Image'] = i
comic_pages.append(dict(sorted(page_info.items())))
# File metadata
utcnow = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
# 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'
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, compression: int = zipfile.ZIP_STORED) -> bytes:
"""Pack the comic into CBZ format (ZIP archive).
def pack(self, rename: bool = True) -> bytes:
"""
Pack the comic information and pages into a CBZ file format.
Args:
rename: If True, rename pages to sequential format (page-001.jpg).
compression: ZIP compression method (default: ZIP_STORED).
rename (bool): Whether to rename pages to a sequential format (e.g., 'page-001.jpg').
Returns:
Binary data of the CBZ file.
bytes: Bytes representing the packed CBZ file.
"""
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
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')
)
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)
data = buf.getvalue()
buf.close()
return data
packed = zip_buffer.getvalue()
zip_buffer.close()
return packed
def show(self) -> None:
"""Display the comic in the built-in graphical viewer."""
"""
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
from cbz.player import Player
player = Player(self)
player.run()
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.
def save(self, path: Union[Path, str]) -> None:
"""
Save the comic book as a CBZ file to the specified path.
Args:
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).
path (Union[Path, str]): Path where the CBZ file will be saved.
"""
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)
with Path(path).open(mode='wb') as f:
f.write(self.pack())
+120 -66
View File
@@ -1,52 +1,37 @@
"""
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
class StrEnum(str, Enum):
"""Backport of StrEnum for Python < 3.11."""
def __str__(self) -> str:
return self.value
# 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"
})
IMAGE_FORMAT = {
".jpeg",
".jpg", # JPEG (Joint Photographic Experts Group)
".png", # PNG (Portable Network Graphics)
".gif", # GIF (Graphics Interchange Format)
".bmp", # BMP (Bitmap Image File)
".tiff",
".tif", # TIFF (Tagged Image File Format)
".webp", # WebP (Web Picture Format, by Google)
".jxl", # JPEG XL (Next-generation JPEG format)
".avif", # AVIF (AV1 Image File Format, based on AV1 codec)
}
class YesNo(StrEnum):
"""Ternary boolean: Unknown / No / Yes."""
class YesNo(Enum):
UNKNOWN = "Unknown"
NO = "No"
YES = "Yes"
class Manga(StrEnum):
"""Comic reading direction."""
class Manga(Enum):
UNKNOWN = "Unknown"
NO = "No"
YES = "Yes"
YES_AND_RIGHT_TO_LEFT = "YesAndRightToLeft"
RIGHT_LEFT = "YesAndRightToLeft"
class PageType(StrEnum):
"""Page type within the archive."""
class PageType(Enum):
FRONT_COVER = "FrontCover"
INNER_COVER = "InnerCover"
ROUNDUP = "Roundup"
@@ -60,48 +45,60 @@ class PageType(StrEnum):
DELETED = "Deleted"
class AgeRating(StrEnum):
"""Content age rating classification."""
class AgeRating(Enum):
UNKNOWN = "Unknown"
ADULTS_ONLY_18_PLUS = "Adults Only 18+"
EARLY_CHILDHOOD = "Early Childhood"
ADULTS18 = "Adults Only 18+"
CHILDHOOD = "Early Childhood"
EVERYONE = "Everyone"
EVERYONE_10_PLUS = "Everyone 10+"
EVERYONE10 = "Everyone 10+"
G = "G"
KIDS_TO_ADULTS = "Kids to Adults"
KIDS = "Kids to Adults"
M = "M"
MA15_PLUS = "MA15+"
MATURE_17_PLUS = "Mature 17+"
MA15 = "MA15+"
MATURE17 = "Mature 17+"
PG = "PG"
R18_PLUS = "R18+"
RATING_PENDING = "Rating Pending"
R18 = "R18+"
PENDING = "Rating Pending"
TEEN = "Teen"
X18_PLUS = "X18+"
X18 = "X18+"
class Format(StrEnum):
"""Comic publication format."""
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_AND_WHITE = "Black & White"
# 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"
DIRECTORS_CUT = "Director's Cut"
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"
POINT_ONE = "Point 1"
POINT1 = "Point 1"
PREVIEW = "Preview"
PROLOGUE = "Prologue"
REFERENCE = "Reference"
@@ -112,32 +109,89 @@ class Format(StrEnum):
SERIES = "Series"
SKETCH = "Sketch"
SPECIAL = "Special"
TRADE_PAPERBACK = "Trade Paper Back"
# 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 Rating(float):
"""Community rating between 0.0 and 5.0 (None if unset)."""
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)
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)
class LanguageISO(str):
"""Valid ISO language code, validated via the langcodes library."""
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)
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", float),
"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),
}
-21
View File
@@ -1,21 +0,0 @@
"""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."""
+154 -106
View File
@@ -1,122 +1,170 @@
"""
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,
COMIC_FIELDS,
PAGE_FIELDS,
Format,
LanguageISO,
YesNo,
Manga,
PageType,
AgeRating,
LanguageISO,
Rating,
YesNo
PageType,
)
from cbz.utils import verify_attr, default_attr
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
def xml_field(xml_name: str, is_attribute: bool = False, **kwargs: Any) -> Any:
"""Create a dataclass field with XML mapping metadata.
class 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().
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)
if isinstance(value, float):
if value.is_integer():
value = int(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):
"""
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.
Model for representing comic book metadata.
"""
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)
title: str
series: str
number: float
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
# Internal fields (not mapped to XML)
suffix: str = field(default="", repr=False)
name: str = field(default="", repr=False)
def __init__(self, **kwargs):
"""
Initializes a ComicModel instance.
Args:
**kwargs: Keyword arguments used to initialize attributes of the ComicModel.
"""
super(ComicModel, self).__init__(COMIC_FIELDS, **kwargs)
@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).
class PageModel(BaseModel):
"""
Model for representing comic book pages.
"""
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="")
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)
+68 -68
View File
@@ -1,120 +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_FORMATS
from cbz.exceptions import InvalidImageError
from cbz.constants import IMAGE_FORMAT
from cbz.models import PageModel
@dataclass
class PageInfo(PageModel):
"""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).
"""
Model for representing comic book pages with additional content handling capabilities.
"""
_content: bytes = field(default=b"", repr=False, compare=False)
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
@property
def content(self) -> bytes:
"""Binary image data."""
return self._content
"""
Getter property for the content of the page.
Returns:
bytes: The content of the page.
"""
return self.__content
@content.setter
def content(self, value: bytes) -> None:
"""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
"""
Setter property for the content of the page. Automatically extracts image metadata.
def __post_init__(self) -> None:
"""Validate content if provided at initialization."""
if self._content:
self.content = self._content
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)
self.image_size = len(value)
self.__content = value
@classmethod
def loads(cls, data: Union[str, bytes], **kwargs) -> PageInfo:
"""Create a PageInfo from raw bytes or base64-encoded data.
"""
Class method to create a PageInfo instance from bytes or base64-encoded data.
Args:
data: Binary image data or base64-encoded string.
**kwargs: Additional attributes (type, bookmark, etc.).
data (Union[str, bytes]): The data representing the page content.
**kwargs: Additional keyword arguments passed to the PageInfo initializer.
Returns:
PageInfo instance with loaded content.
PageInfo: The created PageInfo instance.
Raises:
InvalidImageError: If the data is empty or invalid.
ValueError: If the data type is not str or bytes.
ValueError: If the data type is neither str nor bytes.
"""
if isinstance(data, str):
data = base64.b64decode(data)
if not isinstance(data, bytes):
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
raise ValueError(f'Expecting Bytes or Base64 input, got {data!r}')
if not data.strip():
raise ValueError(f'Empty or null data provided (length={len(data)})')
return cls(data, **kwargs)
@classmethod
def load(cls, path: Union[Path, str], **kwargs) -> PageInfo:
"""Create a PageInfo from an image file.
"""
Class method to create a PageInfo instance from a file path.
Args:
path: Path to the image file.
**kwargs: Additional attributes (type, bookmark, etc.).
path (Union[Path, str]): The path to the file containing the page content.
**kwargs: Additional keyword arguments passed to the PageInfo initializer.
Returns:
PageInfo instance with file content loaded.
PageInfo: The created PageInfo instance.
Raises:
FileNotFoundError: If the file does not exist.
InvalidImageError: If the image is invalid.
ValueError: If the path type is neither Path nor str.
FileNotFoundError: If the specified file path does not exist.
"""
if not isinstance(path, (Path, str)):
raise ValueError(f'Expecting Path object or path string, got {path!r}')
path = Path(path)
kwargs.setdefault("name", path.name)
return cls.loads(path.read_bytes(), **kwargs)
with path.open(mode='rb') as f:
kwargs.setdefault('name', path.name)
return cls.loads(f.read(), **kwargs)
def show(self) -> None:
"""Display the page in the default image viewer."""
with Image.open(BytesIO(self.content)) as img:
img.show()
"""
Displays the page content using an image viewer.
"""
with Image.open(BytesIO(self.content)) as f:
f.show()
def save(self, path: Union[Path, str]) -> None:
"""Save the page to a file.
"""
Saves the page content to a file.
Args:
path: Destination file path.
path (Union[Path, str]): The path where the content should be saved.
"""
Path(path).write_bytes(self.content)
with Path(path).open(mode='wb') as f:
f.write(self.content)
+262 -152
View File
@@ -1,23 +1,14 @@
"""
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 pathlib import Path
from tkinter import ttk
from typing import Optional
from pathlib import Path
try:
from ctypes import windll
except ImportError:
windll = None
windll = None # windll is not available on this platform
from PIL import Image, ImageTk
@@ -26,288 +17,407 @@ from cbz.page import PageInfo
from cbz.utils import readable_size, ico_to_png
PARENT = Path(__file__).parent
CTRL_KEY = 0x4 # Tkinter event.state bitmask for the Ctrl modifier
CTRL_KEY = 0x4 # Constant for Ctrl key
class Player:
"""Comic reader with Tkinter graphical interface.
"""
A simple comic book player application using Tkinter.
Displays comic pages with navigation, zoom and
a metadata summary page.
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.
Attributes:
comic: The comic to display.
current_page: Index of the current page (-1 = summary).
root: Main Tkinter window.
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.
"""
def __init__(self, comic: ComicInfo) -> None:
"""Initialize the reader with a comic.
def __init__(self, comic_info: ComicInfo):
"""
Initialize the comic player with the given ComicInfo object.
Args:
comic: ComicInfo instance to display.
comic_info (ComicInfo): Object containing comic book information.
"""
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
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
# Window initialization
# Initialize tkinter window
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()
# Main frame
# Create main frame for content
self.main_frame = ttk.Frame(self.root)
self.main_frame.pack(fill=tk.BOTH, expand=True)
# Display canvas
self.canvas = tk.Canvas(self.main_frame, bg="white")
# Create canvas for displaying pages
self.canvas = tk.Canvas(self.main_frame, bg='white')
self.canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# Navigation buttons
# Create navigation buttons
self._create_buttons()
# Summary text widget
# Initialize text widget for the summary page
self._init_summary_text()
# Initial display
# Display initial page content
self.show_page()
# Keyboard shortcuts and events
# Bind keys for navigation and window resize
self._bind_keys()
# Resize tracking
# Initialize the previous window size
self.previous_width = self.root.winfo_width()
self.previous_height = self.root.winfo_height()
self.resize_timer: Optional[str] = None
# Initialize resize timer
self.resize_timer = None
def _set_icon(self) -> None:
"""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)
"""
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)
else:
image = ico_to_png(icon_path)
# Convert ICO to PNG for other operating systems
image = ico_to_png(image_path)
self.root.iconphoto(True, tk.PhotoImage(data=image.getvalue()))
def _get_window_title(self) -> str:
"""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__
"""
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__
def _get_initial_geometry(self) -> str:
"""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
"""
Calculate the initial window size and position based on the first page of the comic or default screen size.
screen_w = int(avail_h * 0.63)
screen_h = int(avail_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
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
# Calculate available space after margins
available_width = self.root.winfo_screenwidth() - 2 * margin_width
available_height = self.root.winfo_screenheight() - 2 * margin_height
scale = min(avail_w / img_w, avail_h / img_h)
init_w = int(img_w * scale * 0.94)
init_h = int(img_h * scale)
# 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)
else:
init_w = screen_w
init_h = screen_h
# If no pages are available, default to screen size
initial_width = screen_width
initial_height = screen_height
if 100 / screen_w * init_w < 50.0:
init_w = screen_w
# 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
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}"
# 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}'
def _create_buttons(self) -> None:
"""Create navigation buttons."""
"""
Create navigation buttons for previous, next, and exit operations.
"""
# Create button frame
button_frame = ttk.Frame(self.main_frame, height=10)
button_frame.pack(side=tk.BOTTOM, pady=10)
self.prev_button = ttk.Button(button_frame, text="Previous", command=self.on_previous)
# Previous page button
self.prev_button = ttk.Button(button_frame, text='Previous', command=self.on_previous)
self.prev_button.pack(side=tk.LEFT, padx=10)
self.page_index_label = ttk.Label(
button_frame, text=f"{self.current_page + 1}/{len(self.comic)}"
)
# 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.pack(side=tk.LEFT, padx=10)
self.next_button = ttk.Button(button_frame, text="Next", command=self.on_next)
# Next page button
self.next_button = ttk.Button(button_frame, text='Next', command=self.one_next)
self.next_button.pack(side=tk.LEFT, padx=10)
exit_button = ttk.Button(button_frame, text="Exit", command=self.root.quit)
# Exit button (optional)
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 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)
"""
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)
self.summary_text.config(state=tk.DISABLED)
def show_page(self) -> None:
"""Display the current page (summary or image)."""
self.canvas.delete("all")
"""
Display the current page content (either summary or image).
"""
# Clear previous content on canvas
self.canvas.delete('all')
# Display page content
if self.current_page == -1:
self._show_summary_page()
elif self.current_page < len(self.comic):
elif self.current_page < len(self.comic_info.pages):
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) else tk.NORMAL
)
self.page_index_label.config(text=f"{self.current_page + 1}/{len(self.comic)}")
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)}')
def _show_summary_page(self) -> None:
"""Display the summary page with metadata."""
"""
Display the summary page with comic information.
"""
# Use update_idletasks to ensure window size is updated before using it
self.root.update_idletasks()
infos = self.comic.get_info()
# Display summary for the first page
infos = self.comic_info.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 an image page centered on the canvas."""
"""
Display the image page centered on the canvas.
"""
# Hide the summary text widget
self.summary_text.place_forget()
if self.img_original is not None:
self.img_original.close()
page: PageInfo = self.comic[self.current_page]
# Display image centered on canvas
page: PageInfo = self.comic_info.pages[self.current_page]
self.img_original = Image.open(BytesIO(page.content))
self._display_image()
def _display_image(self) -> None:
"""Display the image with the current zoom level."""
"""
Display the image on the canvas with zooming capabilities.
"""
# Check if zoom factor is not set; initialize zoom factors
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
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)
# 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)
# Convert the resized image to PhotoImage format compatible with Tkinter canvas
self.canvas.image = ImageTk.PhotoImage(img)
x = max(0, (self.canvas.winfo_width() - new_w) // 2)
y = max(0, (self.canvas.winfo_height() - new_h) // 2)
# 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)
self.canvas.create_image(x, y, anchor=tk.NW, image=self.canvas.image)
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))
# 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))
def _bind_keys(self) -> None:
"""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 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())
self.root.bind("<Control-MouseWheel>", self.on_zoom)
# 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.canvas.bind_all("<MouseWheel>", self._on_mouse_wheel)
self.canvas.bind_all("<Shift-MouseWheel>", self._on_shift_mouse_wheel)
self.root.bind("<KeyPress-plus>", lambda _: self.on_zoom_key(True))
self.root.bind("<KeyPress-minus>", lambda _: self.on_zoom_key(False))
# 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))
def on_zoom_key(self, zoom_in: bool) -> None:
"""Handle keyboard zoom (+ / -)."""
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
event = tk.Event()
event.delta = 120 if zoom_in else -120
event.delta = 120 if positive else -120
self.on_zoom(event)
def on_zoom(self, event: tk.Event) -> None:
"""Handle zoom with Ctrl + mouse wheel."""
if self.current_page == -1:
return
def on_zoom(self, event) -> None:
"""
Handle zooming using 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
min_zoom = min(
# Ensure the zoom-out factor doesn't go below the original image size
min_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
)
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: tk.Event) -> None:
"""Handle vertical scrolling."""
if self.current_page == -1 or (event.state & CTRL_KEY):
return
def _on_mouse_wheel(self, event) -> None:
"""
Handle vertical scroll using Mouse Wheel.
"""
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: tk.Event) -> None:
"""Handle horizontal scrolling."""
if self.current_page == -1 or (event.state & CTRL_KEY):
return
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
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 on_next(self) -> None:
"""Navigate to the next page."""
if self.current_page < len(self.comic) - 1:
def one_next(self) -> None:
"""
Navigate to the next page.
"""
if self.current_page < len(self.comic_info.pages) - 1:
self.current_page += 1
self.zoom_factor = None
self.show_page()
def on_window(self, event: tk.Event) -> None:
"""Handle window resize."""
def on_window(self, event: any) -> None:
"""
Handle window resize event.
"""
# Check if width or height has changed
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:
"""React to window resize with delay."""
if self.current_page == -1:
return
"""
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
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:
"""Start the reader main loop."""
"""
Run the comic player application.
"""
self.root.mainloop()
+94 -20
View File
@@ -1,7 +1,4 @@
"""Utility functions for the CBZ library."""
from __future__ import annotations
from enum import Enum
from io import BytesIO
from pathlib import Path
@@ -9,27 +6,104 @@ 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:
"""Convert a byte size to a human-readable string (KB, MB, etc.)."""
for unit in ("B", "KB", "MB", "GB", "TB"):
"""
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']:
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:
"""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}")
"""
Converts the largest icon in an ICO file to PNG format.
icon: IcoFile = image.ico
max_size = max(icon.sizes(), key=lambda s: s[0] + s[1])
largest = icon.getimage(size=max_size)
Args:
path (Path): Path to the ICO file.
buf = BytesIO()
largest.save(buf, format="PNG")
largest.close()
buf.seek(0)
return buf
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
-615
View File
@@ -1,615 +0,0 @@
# 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.*
+18 -40
View File
@@ -1,15 +1,10 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
[project]
name = "cbz"
version = "4.0.0"
description = "Modern Python library for creating, manipulating and viewing comics in CBZ, CBR and PDF formats."
version = "3.4.5"
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."
license = "MIT"
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
authors = [{name="hyugogirubato", email="65763543+hyugogirubato@users.noreply.github.com"}]
readme = "README.md"
repository = "https://github.com/hyugogirubato/cbz"
keywords = [
"python",
"cbz",
@@ -18,7 +13,7 @@ keywords = [
"ebooks",
"manga",
"comics",
"webtoons"
"webtoons",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -26,49 +21,32 @@ 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" },
{ path = "README.md", format = "sdist" },
{ path = "LICENSE", format = "sdist" },
requires-python = ">=3.8,<4.0"
dependencies= [
"langcodes>=3.4.0",
"Pillow>=10.4.0",
"pypdf >=5.7.0",
"rarfile>=4.2",
"xmltodict>=0.14.2"
]
[tool.poetry.urls]
"Issues" = "https://github.com/hyugogirubato/cbz/issues"
"Changelog" = "https://github.com/hyugogirubato/cbz/blob/main/CHANGELOG.md"
[tool.poetry.dependencies]
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"]
[project.optional-dependencies]
pillow = ["pillow-avif-plugin>=1.5.2", "pillow-jxl-plugin>=1.3.4"]
[tool.poetry.scripts]
cbzplayer = "cbz.__main__:main"
[dependency-groups]
dev = ["pytest>=7.4.0,<9.0.0", "pytest-cov>=5.0.0"]
[[tool.poetry.source]]
name = "localpypi"
url = "https://pypi.org/simple/"
priority = "primary"
[tool.poetry.group.dev.dependencies]
pytest = ">=7.4.0,<10.0.0"
pytest-cov = ">=5.0.0"
[tool.pytest.ini_options]
testpaths = ["tests"]
+25 -23
View File
@@ -1,53 +1,55 @@
"""Test fixtures for the CBZ library."""
from pathlib import Path
import pytest
from cbz.comic import ComicInfo
from cbz.constants import PageType
from cbz.page import PageInfo
from cbz.constants import PageType
@pytest.fixture
def fixtures_dir() -> Path:
"""Path to the test fixtures directory."""
return Path(__file__).parent / "fixtures"
"""Fixture that provides the path to the test fixtures directory."""
return Path(__file__).parent / 'fixtures'
@pytest.fixture
def images_dir(fixtures_dir: Path) -> Path:
"""Path to the test images directory."""
return fixtures_dir / "images"
"""Fixture that provides the path to the test images directory."""
return fixtures_dir / 'images'
@pytest.fixture
def sample_image_path(images_dir: Path) -> Path:
"""Path to a sample test image."""
return images_dir / "page-000.jpg"
"""Fixture that provides a sample image path."""
return images_dir / 'page-000.jpg'
@pytest.fixture
def sample_cbz_file(tmp_path: Path, images_dir: Path) -> Path:
"""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)
]
"""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 = []
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
)
cbz_path = tmp_path / "test_comic.cbz"
cbz_path.write_bytes(comic.pack())
# Save to temporary file
cbz_path = tmp_path / 'test_comic.cbz'
cbz_content = comic.pack()
cbz_path.write_bytes(cbz_content)
return cbz_path
+31 -36
View File
@@ -1,64 +1,59 @@
"""Usage example for the CBZ library."""
from pathlib import Path
from cbz import ComicInfo, PageInfo, PageType, Format, YesNo, Manga, AgeRating
from cbz.comic import ComicInfo
from cbz.constants import PageType, YesNo, Manga, AgeRating, Format
from cbz.page import PageInfo
PARENT = Path(__file__).parent
if __name__ == "__main__":
paths = sorted((PARENT / "fixtures" / "images").iterdir())
if __name__ == '__main__':
paths = list((PARENT / 'fixtures' / 'images').iterdir())
# Load pages with automatic type assignment
# 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 comic with metadata
# Create a ComicInfo object using ComicInfo.from_pages() method
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.YES_AND_RIGHT_TO_LEFT,
age_rating=AgeRating.EVERYONE_10_PLUS,
manga=Manga.RIGHT_LEFT,
age_rating=AgeRating.EVERYONE10,
community_rating=5,
ean="9782490676569",
ean='9782490676569'
)
# 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
# Show the comic using the show()
comic.show()
# Save as CBZ
cbz_path = PARENT / f"{comic.title}.cbz"
comic.save(cbz_path)
print(f"Saved: {cbz_path}")
# 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)
+88 -130
View File
@@ -1,155 +1,166 @@
"""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:
"""Tests for comic creation, loading and serialization."""
"""Test cases for ComicInfo class."""
def test_from_pages_creation(self, images_dir: Path) -> None:
"""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)
]
"""Test creating ComicInfo from pages."""
# Load sample pages
image_paths = sorted(list(images_dir.iterdir()))[:3] # Use first 3 images
pages: List[PageInfo] = []
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) == 3
assert comic[0].type == PageType.FRONT_COVER
assert comic[1].type == PageType.STORY
assert len(comic.pages) == 3
assert comic.pages[0].type == PageType.FRONT_COVER
assert comic.pages[1].type == PageType.STORY
def test_from_cbz_file(self, sample_cbz_file: Path) -> None:
"""Load from a CBZ file."""
"""Test loading ComicInfo from CBZ file."""
comic = ComicInfo.from_cbz(sample_cbz_file)
assert comic is not None
assert len(comic) > 0
assert all(isinstance(page, PageInfo) for page in comic)
assert hasattr(comic, 'pages')
assert len(comic.pages) > 0
assert all(isinstance(page, PageInfo) for page in comic.pages)
def test_pack_cbz(self, images_dir: Path) -> None:
"""Pack into CBZ format."""
"""Test packing comic into CBZ format."""
# Create a simple comic
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:
"""Pack with sequential page renaming."""
"""Test packing comic with 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")
comic = ComicInfo.from_pages(
pages=pages,
title='Rename Test'
)
# Pack with rename option
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:
"""Verify all metadata fields."""
"""Test comic metadata properties."""
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.YES_AND_RIGHT_TO_LEFT,
manga=Manga.RIGHT_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.YES_AND_RIGHT_TO_LEFT
assert comic.manga == Manga.RIGHT_LEFT
assert comic.age_rating == AgeRating.TEEN
assert comic.community_rating == 4
def test_page_count_property(self, images_dir: Path) -> None:
"""Verify page count via len()."""
"""Test that page count returns correct count."""
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) == 4
comic = ComicInfo.from_pages(pages=pages, title='Count Test')
assert len(comic.pages) == 4
def test_empty_pages_list(self) -> None:
"""Create a comic with no pages."""
comic = ComicInfo.from_pages(pages=[], title="Empty Test")
assert comic.title == "Empty Test"
assert len(comic) == 0
"""Test creating comic with empty pages list."""
comic = ComicInfo.from_pages(pages=[], title='Empty Test')
def test_single_page_comic_load(self, images_dir: Path) -> None:
"""Round-trip load of a single-page comic."""
assert comic.title == 'Empty Test'
assert len(comic.pages) == 0
def test_single_page_comic_load(self, images_dir):
"""Test loading comic with a single page."""
image_paths = sorted(list(images_dir.iterdir()))[:1]
pages = [PageInfo.load(path=path) for path in image_paths]
@@ -157,65 +168,12 @@ class TestComicInfo:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir) / "single_page.cbz"
comic.save(temp_path)
data = comic.pack()
with open(temp_path, "wb") as f:
f.write(data)
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
comic_loaded = ComicInfo.from_cbz(temp_path)
assert comic_loaded.title == "Single Page Test"
assert len(comic_loaded.pages) == 1
+194 -133
View File
@@ -1,204 +1,265 @@
"""Tests for data models."""
from cbz.models import BaseModel, ComicModel, PageModel
from cbz.constants import Format, YesNo, Manga, AgeRating, PageType
from dataclasses import fields
import pytest
class TestBaseModel:
"""Test cases for BaseModel class."""
from cbz.constants import (
AgeRating,
Format,
LanguageISO,
Manga,
PageType,
Rating,
YesNo,
)
from cbz.models import ComicModel, PageModel, _get_xml_mapping
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
class TestComicModel:
"""Tests for the ComicModel dataclass."""
"""Test cases for ComicModel class."""
def test_default_values(self) -> None:
"""Correct default values."""
def test_comic_model_creation(self) -> None:
"""Test creating ComicModel with default values."""
model = ComicModel()
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
# 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')
def test_with_values(self) -> None:
"""Creation with specific values."""
def test_comic_model_with_values(self) -> None:
"""Test creating ComicModel 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=LanguageISO("en"),
writer='Test Writer',
publisher='Test Publisher',
language_iso='en',
format=Format.SERIES,
black_white=YesNo.NO,
manga=Manga.YES_AND_RIGHT_TO_LEFT,
age_rating=AgeRating.EVERYONE,
manga=Manga.RIGHT_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.manga == Manga.YES_AND_RIGHT_TO_LEFT
assert model.black_white == YesNo.NO
assert model.manga == Manga.RIGHT_LEFT
assert model.age_rating == AgeRating.EVERYONE
def test_enum_assignment(self) -> None:
"""Enum assignment."""
def test_comic_model_enum_properties(self) -> None:
"""Test that enum properties work correctly."""
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
model.manga = Manga.YES_AND_RIGHT_TO_LEFT
assert model.manga == Manga.YES_AND_RIGHT_TO_LEFT
# Test manga enum
model.manga = Manga.RIGHT_LEFT
assert model.manga == Manga.RIGHT_LEFT
# Test age rating enum
model.age_rating = AgeRating.TEEN
assert model.age_rating == AgeRating.TEEN
def test_metadata_fields(self) -> None:
"""Verify metadata fields."""
def test_comic_model_metadata_fields(self) -> None:
"""Test comic metadata fields."""
model = ComicModel(
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",
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',
alternate_number=2,
alternate_count=10,
alternate_count=10
)
assert model.summary == "Test summary"
assert model.genre == "Adventure"
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.community_rating == 5
assert model.characters == "Hero, Villain"
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.alternate_number == 2
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
assert model.alternate_count == 10
class TestPageModel:
"""Tests for the PageModel dataclass."""
"""Test cases for PageModel class."""
def test_default_values(self) -> None:
"""Correct default values."""
def test_page_model_creation(self) -> None:
"""Test creating PageModel with default values."""
model = 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
# 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
def test_with_values(self) -> None:
"""Creation with specific values."""
def test_page_model_with_values(self) -> None:
"""Test creating PageModel 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 is True
assert model.double
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_all_page_types(self) -> None:
"""All page types are valid."""
for page_type in PageType:
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:
model = PageModel(type=page_type)
assert model.type == page_type
def test_boolean_properties(self) -> None:
"""Boolean double property."""
def test_page_model_boolean_properties(self) -> None:
"""Test boolean properties in PageModel."""
model = PageModel()
# Test double property
model.double = True
assert model.double is True
assert model.double
model.double = False
assert model.double is False
assert not model.double
def test_page_model_numeric_properties(self) -> None:
"""Test numeric properties in PageModel."""
model = PageModel(
image_size=2048000,
image_width=1920,
image_height=1080
)
class TestRating:
"""Tests for the Rating type."""
assert model.image_size == 2048000
assert model.image_width == 1920
assert model.image_height == 1080
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")
# Test that they're integers
assert isinstance(model.image_size, int)
assert isinstance(model.image_width, int)
assert isinstance(model.image_height, int)
+71 -60
View File
@@ -1,19 +1,16 @@
"""Tests for the PageInfo class."""
from pathlib import Path
import pytest
from cbz.constants import PageType
from cbz.exceptions import InvalidImageError
from cbz.page import PageInfo
from cbz.constants import PageType
class TestPageInfo:
"""Tests for page loading, properties and manipulation."""
"""Test cases for PageInfo class."""
def test_load_from_file(self, sample_image_path: Path) -> None:
"""Load from an image file."""
"""Test loading PageInfo from image file."""
page = PageInfo.load(path=sample_image_path)
assert page is not None
@@ -23,49 +20,58 @@ class TestPageInfo:
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
assert page.suffix != ""
assert page.suffix is not None
def test_load_with_page_type(self, sample_image_path: Path) -> None:
"""Load with a specific page type."""
"""Test loading PageInfo with 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:
"""Load with a custom name."""
page = PageInfo.load(path=sample_image_path, name="custom_page.jpg")
assert page.name == "custom_page.jpg"
"""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
def test_page_content_property(self, sample_image_path: Path) -> None:
"""Content property and automatic metadata extraction."""
"""Test page content property getter and setter."""
page = PageInfo.load(path=sample_image_path)
original = page.content
original_content = page.content
assert page.content == original
# Test getter
assert page.content == original_content
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:
"""Correct extraction of image metadata."""
"""Test that image metadata is correctly extracted."""
page = PageInfo.load(path=sample_image_path)
assert page.image_width > 0
assert page.image_height > 0
assert page.image_size > 0
assert page.suffix != ""
# 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 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:
"""Load different image files."""
image_files = list(images_dir.glob("*.jpg"))
"""Test loading different image formats."""
image_files = list(images_dir.glob('*.jpg'))
if not image_files:
pytest.skip("No image files found")
pytest.skip('No image files found in example directory')
for image_path in image_files[:3]:
for image_path in image_files[:3]: # Test first 3 images
page = PageInfo.load(path=image_path)
assert page is not None
@@ -76,57 +82,62 @@ class TestPageInfo:
assert page.image_size > 0
def test_page_type_assignment(self, sample_image_path: Path) -> None:
"""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)
"""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)
assert page.type == page_type
def test_page_creation_from_bytes(self, sample_image_path: Path) -> None:
"""Direct creation from bytes."""
image_bytes = sample_image_path.read_bytes()
"""Test creating PageInfo directly from bytes."""
# Read image file as bytes
with open(sample_image_path, 'rb') as f:
image_bytes = f.read()
page = PageInfo.loads(data=image_bytes, name="test_page.jpg")
# Create page from bytes
page = PageInfo(content=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_page_bookmark_property(self, sample_image_path: Path) -> None:
"""Bookmark property."""
page = PageInfo.load(path=sample_image_path, bookmark="Chapter 1")
assert page.bookmark == "Chapter 1"
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'
# Test without bookmark
page_no_bookmark = PageInfo.load(path=sample_image_path)
assert page_no_bookmark.bookmark == ""
assert hasattr(page_no_bookmark, 'bookmark')
def test_page_double_page_property(self, sample_image_path: Path) -> None:
"""Double page property."""
"""Test page double_page property."""
page = PageInfo.load(path=sample_image_path, double=True)
assert page.double is True
assert page.double
page_no_double = PageInfo.load(path=sample_image_path)
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
assert hasattr(page_no_double, 'double')