diff --git a/README.md b/README.md index 111ec14..64ae4d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CBZ -CBZ is a Python library designed for creating, manipulating, and viewing comic book files in CBZ format. It offers a straightforward interface to pack comic pages into CBZ archives, extract metadata, and display comics using a built-in player. +CBZ is a Python library designed for creating, manipulating, and viewing comic book files in CBZ, CBR, and PDF formats. It offers a straightforward interface to pack comic pages into CBZ archives, extract metadata from CBZ and CBR files, and display comics using a built-in player. ## Features @@ -8,8 +8,9 @@ CBZ is a Python library designed for creating, manipulating, and viewing comic b - 📚 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 files to retrieve comic information -- 🛠️ Built-in player for viewing CBZ comics +- 📦 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 @@ -73,7 +74,15 @@ if __name__ == '__main__': ## Player -CBZ includes a command-line player for viewing CBZ comic book files. Simply run cbzplayer to launch the player with the specified CBZ file. +CBZ includes a command-line player for viewing comic book files in multiple formats. Simply run `cbzplayer ` to launch the player with the specified comic book file. + +### Supported Formats + +- **CBZ** (Comic Book ZIP) - Standard ZIP archives containing images and metadata +- **CBR** (Comic Book RAR) - RAR archives containing images and metadata +- **PDF** - Portable Document Format files with embedded images (images only, no metadata) + +### Usage ````shell usage: cbzplayer [-h] @@ -81,13 +90,36 @@ usage: cbzplayer [-h] Launch CBZ player with a comic book file positional arguments: - Path to the CBZ comic book file. + Path to the CBZ, CBR, or PDF comic book file. options: -h, --help show this help message and exit - ```` +### Examples + +```shell +# View a CBZ file +cbzplayer my_comic.cbz + +# View a CBR file +cbzplayer my_comic.cbr + +# View a PDF file +cbzplayer my_comic.pdf +``` + +### Requirements for CBR Support + +CBR file support requires: +- The `rarfile` Python package (automatically installed with CBZ) +- An external RAR extraction tool such as: + - `unrar` (recommended) - Available in most package managers + - `rar` - Commercial RAR archiver + - `7zip` - Free alternative with RAR support + +For installation instructions and compatibility details, see the [rarfile documentation](https://github.com/markokr/rarfile). + ## Detailed Usage ### Creating a ComicInfo Object @@ -135,14 +167,30 @@ Pack the comic into a CBZ file format: cbz_content = comic.pack() ``` -### Unpacking from CBZ +### Loading from Different Formats -Load a comic from an existing CBZ file: +Load a comic from an existing CBZ file (with metadata): ```python comic_from_cbz = ComicInfo.from_cbz('/path/to/your_comic.cbz') ``` +Load a comic from an existing CBR file (with metadata): + +```python +comic_from_cbr = ComicInfo.from_cbr('/path/to/your_comic.cbr') +``` + +Load a comic from a PDF file (images only, no metadata): + +```python +comic_from_pdf = ComicInfo.from_pdf('/path/to/your_comic.pdf') +``` + +**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. + ## Contributors hyugogirubato @@ -156,9 +204,9 @@ comic_from_cbz = ComicInfo.from_cbz('/path/to/your_comic.cbz') ## Licensing -This software is licensed under the terms of [MIT License](https://github.com/hyugogirubato/cbz/blob/main/LICENSE). +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. -* * * +* * * -© hyugogirubato 2024 \ No newline at end of file +© hyugogirubato 2024 diff --git a/cbz/__main__.py b/cbz/__main__.py index 51758a8..91d6eba 100644 --- a/cbz/__main__.py +++ b/cbz/__main__.py @@ -8,7 +8,7 @@ from cbz.comic import ComicInfo def main() -> None: # Parse command-line arguments parser = argparse.ArgumentParser(description='Launch CBZ player with a comic book file') - parser.add_argument('comic_path', type=Path, metavar='', help='Path to the CBZ comic book file.') + parser.add_argument('comic_path', type=Path, metavar='', help='Path to the CBZ, CBR, or PDF comic book file.') args = parser.parse_args() # Validate the provided path @@ -17,9 +17,14 @@ def main() -> None: print(f'Error: The file "{comic_path}" does not exist or is not a valid file.') exit(1) - # Create ComicInfo object from CBZ file + # Create ComicInfo object from comic file try: - comic_info = ComicInfo.from_pdf(comic_path) if comic_path.suffix == '.pdf' else ComicInfo.from_cbz(comic_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_info = ComicInfo.from_cbz(comic_path) # Launch the CBZ player comic_info.show() except Exception as e: diff --git a/cbz/comic.py b/cbz/comic.py index d004ff5..0bc83fd 100644 --- a/cbz/comic.py +++ b/cbz/comic.py @@ -8,8 +8,9 @@ from io import BytesIO from typing import Union from pathlib import Path -from pypdf import PdfReader +import rarfile import xmltodict +from pypdf import PdfReader from cbz.constants import XML_NAME, COMIC_FIELDS, IMAGE_FORMAT, PAGE_FIELDS from cbz.models import ComicModel @@ -66,7 +67,25 @@ class ComicInfo(ComicModel): """ if not isinstance(path, (Path, str)): raise ValueError(f'Expecting Path object or path string, got {path!r}') - return cls.__unpack_zip(path) + return cls.__unpack_zip(Path(path)) + + @classmethod + def from_cbr(cls, path: Union[Path, str]) -> ComicInfo: + """ + Create a ComicInfo instance from a CBR file. + + Args: + path (Union[Path, str]): Path to the CBR file. + + Returns: + ComicInfo: An instance of ComicInfo. + + Raises: + ValueError: If the provided path is not a Path object or a string. + """ + if not isinstance(path, (Path, str)): + raise ValueError(f'Expecting Path object or path string, got {path!r}') + return cls.__unpack_rar(Path(path)) @classmethod def from_pdf(cls, path: Union[Path, str]) -> ComicInfo: @@ -84,7 +103,7 @@ class ComicInfo(ComicModel): """ if not isinstance(path, (Path, str)): raise ValueError(f'Expecting Path object or path string, got {path!r}') - return cls.__unpack_pdf(path) + return cls.__unpack_pdf(Path(path)) @staticmethod def __unpack_pdf(path: Path) -> ComicInfo: @@ -106,6 +125,80 @@ class ComicInfo(ComicModel): assert pages, 'No valid images present in file' return ComicInfo.from_pages(pages=pages) + @staticmethod + def __extract_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, 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: zipfile.ZipFile | rarfile.RarFile) -> ComicInfo: + """ + Common logic for processing archive files (CBZ/CBR). + + Args: + archive_file: Archive file object (ZipFile or RarFile) + + Returns: + ComicInfo: An instance of ComicInfo. + """ + pages = [] + names = 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()).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 + if suffix: + assert suffix in IMAGE_FORMAT, f'Unsupported image format: {suffix}' + with archive_file.open(name, 'r') as f: + page_info = {} + if i < len(pages_info): + page_info = ComicInfo.__extract_info( + items=pages_info[i], + fields=PAGE_FIELDS + ) + page_info['name'] = Path(name).name + pages.append(PageInfo.loads(data=f.read(), **page_info)) + + 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: """ @@ -117,56 +210,8 @@ class ComicInfo(ComicModel): Returns: ComicInfo: An instance of ComicInfo. """ - - 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, field_type) in fields.items(): - if field_key in items: - content[key] = field_type(items[field_key]) - return content - - pages = [] - with zipfile.ZipFile(path, 'r', zipfile.ZIP_STORED) as zf: - names = zf.namelist() - comic_info = {} - - if XML_NAME in names: - with zf.open(XML_NAME, 'r') as f: - comic_info = xmltodict.parse(f.read()).get('ComicInfo', {}) - names.remove(XML_NAME) - - comic = __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 - if suffix: - assert suffix in IMAGE_FORMAT, f'Unsupported image format: {suffix}' - with zf.open(name, 'r') as f: - page_info = {} - if i < len(pages_info): - page_info = __info( - items=pages_info[i], - fields=PAGE_FIELDS - ) - page_info['name'] = Path(f.name).name - pages.append(PageInfo.loads(data=f.read(), **page_info)) - - return ComicInfo.from_pages(pages=pages, **comic) + return ComicInfo.__process_archive(zf) def get_info(self) -> dict: """ diff --git a/pyproject.toml b/pyproject.toml index 1fa3e14..d82bce4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "cbz" version = "3.3.7" -description = "CBZ simplifies creating, managing, and viewing comic book files in CBZ format, offering seamless packaging, metadata handling, and built-in viewing capabilities" +description = "CBZ simplifies creating, managing, and viewing comic book files in CBZ, CBR, and PDF formats, offering seamless packaging, metadata handling for CBZ/CBR files, and built-in viewing capabilities" license = "MIT" authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"] readme = "README.md" -keywords = ["python", "cbz", "ebooks", "manga", "comics", "webtoons"] +keywords = ["python", "cbz", "cbr", "pdf", "ebooks", "manga", "comics", "webtoons"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -17,12 +17,12 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Utilities", - "Topic :: Software Development :: Libraries :: Python Modules" + "Topic :: Software Development :: Libraries :: Python Modules", ] include = [ { path = "CHANGELOG.md", format = "sdist" }, { path = "README.md", format = "sdist" }, - { path = "LICENSE", format = "sdist" } + { path = "LICENSE", format = "sdist" }, ] [tool.poetry.urls] @@ -35,6 +35,7 @@ xmltodict = ">=0.13.0" langcodes = ">=3.4.0" Pillow = ">=10.4.0" pypdf = ">=5.6.1" +rarfile = ">=4.0" [tool.poetry.scripts] cbzplayer = "cbz.__main__:main"