Initial commit of img2cbz script and documentation

This commit is contained in:
2026-07-01 15:53:13 -04:00
commit 6f9e2e509f
3 changed files with 572 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store
*.cbz
test_comic_workspace/
+74
View File
@@ -0,0 +1,74 @@
# img2cbz: Folder to CBZ Archive Converter
`img2cbz` is a powerful, user-friendly Python command-line utility to convert folders of images (including WebP, JPEG, PNG, GIF, BMP, and TIFF) into CBZ (Comic Book Zip) archives. It is located at [img2cbz](file:///var/home/david/.local/bin/img2cbz) and is globally runnable from the terminal.
## Key Features
- **Multi-Format Support**: Processes `.jpg`, `.jpeg`, `.png`, `.webp`, `.gif`, `.bmp`, and `.tiff`.
- **Natural Sorting (Alphanumeric)**: Sorts filenames naturally (e.g., `page_2.png` comes before `page_10.png` rather than alphabetical order).
- **Parallel Image Conversion**: Convert images to `webp`, `jpg`, or `png` in parallel using multithreading.
- **Space Saving**: Converting images to WebP can compress the CBZ size by 30-70% with virtually no loss in quality.
- **Maximum Compatibility**: Convert WebP/PNG to JPEG with automatic transparency flattening (pasting transparent images over a solid white background to avoid black-background issues).
- **Recursive Mode (`-r`)**: Scan a parent directory recursively to locate all directories that contain image files, converting each folder into its own CBZ archive.
- **Dry-Run Mode (`-d`)**: Safely test commands and see what files would be processed and where archives would be saved without making modifications.
- **Interactive Mode**: If run without arguments, it launches a step-by-step interactive setup wizard guiding you through configuration.
- **Verify & Clean (`--clean`)**: Safely deletes source folders only after verifying the created CBZ archive is complete, uncorrupted, and has a matching file count.
---
## Command-Line Arguments Reference
| Option | Long Option | Description |
| :--- | :--- | :--- |
| `DIR` | | One or more folders containing images to convert. |
| `-r` | `--recursive` | Find subdirectories containing images and convert each to a CBZ. |
| `-o` | `--output-dir` | Path to save CBZ files (defaults to next to each source folder). |
| `-e` | `--extensions` | Comma-separated list of image extensions to scan (default: all). |
| `-c` | `--convert` | Convert images to `webp`, `jpg`, or `png` before archiving. |
| `-q` | `--quality` | Compression quality (`1`-`100`) for WebP/JPEG conversions (default: `85`). |
| `-j` | `--jobs` | Number of parallel threads to use for conversion (default: CPU core count). |
| `--overwrite` | | Replace existing `.cbz` files if they exist. |
| `--clean` | | Delete source folders after successful CBZ creation & integrity checks. |
| `-d` | `--dry-run` | Show planned archive creations and image conversions without executing them. |
| `--verbose` | | Print debug information. |
| `--quiet` | | Suppress standard log output (only outputs errors). |
---
## Common Usage Examples
### 1. Simple Single-Folder Conversion
Create `Chapter_01.cbz` in the same directory as `Chapter_01/`:
```bash
img2cbz Chapter_01/
```
### 2. Recursive Multi-Folder Conversion
Convert all subdirectories containing images in a series folder (e.g., `Manga_Series/Volume_1/Chapter_1/`, `Manga_Series/Volume_1/Chapter_2/`) into separate CBZ archives (`Chapter_1.cbz`, `Chapter_2.cbz`):
```bash
img2cbz -r Manga_Series/
```
### 3. Compress Archives to WebP
Convert all images to space-saving WebP format with `80%` quality during archiving:
```bash
img2cbz -c webp -q 80 Chapter_01/
```
### 4. Output to a Specific Directory
Save all created CBZ archives in a separate `Comics/` folder instead of next to the source directories:
```bash
img2cbz -r -o ~/Comics/ Manga_Series/
```
### 5. Safe Conversion & Cleanup
Convert to WebP, save to a separate directory, verify archives, and then delete the original uncompressed source folders:
```bash
img2cbz -r -c webp -o ~/Comics/ --clean Manga_Series/
```
### 6. Dry Run (Test First)
Preview which folders and images would be processed by a command:
```bash
img2cbz -r -c webp -o ~/Comics/ --clean -d Manga_Series/
```
Executable
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""
img2cbz - A command-line tool to convert folders of images (including WebP) into CBZ comic archives.
"""
import os
import sys
import re
import argparse
import logging
import shutil
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from PIL import Image
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S"
)
logger = logging.getLogger("img2cbz")
# Natural sort key for sorting filenames alphanumeric style
# e.g., page_1.png, page_2.png, page_10.png
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
def get_image_files(directory, extensions):
"""Scan directory for files matching the allowed extensions and return them sorted naturally."""
path = Path(directory)
files = []
for f in path.iterdir():
if f.is_file() and f.suffix.lower().lstrip('.') in extensions:
files.append(f)
files.sort(key=lambda x: natural_sort_key(x.name))
return files
def find_image_dirs(root_dir, extensions):
"""Walk directories recursively and return those containing at least one image."""
image_dirs = []
for dirpath, _, filenames in os.walk(root_dir):
has_images = any(
Path(f).suffix.lower().lstrip('.') in extensions
for f in filenames
)
if has_images:
image_dirs.append(Path(dirpath).resolve())
# Sort them to keep order deterministic
image_dirs.sort(key=lambda p: natural_sort_key(str(p)))
return image_dirs
def convert_image(src_path, dest_path, target_format, quality=85):
"""Convert an image to a target format, handling transparency and other mode variations."""
try:
with Image.open(src_path) as img:
# Handle alpha channel / transparency for JPEG conversion
if target_format.lower() in ("jpg", "jpeg") and img.mode in ("RGBA", "LA", "P"):
if img.mode == "P":
img = img.convert("RGBA")
# Create a solid white background
bg = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "RGBA":
bg.paste(img, mask=img.split()[3])
else:
bg.paste(img, mask=img.convert("RGBA").split()[3])
img = bg
elif img.mode == "RGBA" and target_format.lower() in ("png", "webp"):
# PNG and WebP support transparency, keep as is
pass
else:
# Convert grayscale or other modes to RGB for standard format saving
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
# Save arguments
save_args = {}
if target_format.lower() in ("jpg", "jpeg", "webp"):
save_args["quality"] = quality
# Create target folder if it doesn't exist
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Map 'JPG' to 'JPEG' for Pillow
pil_format = target_format.upper()
if pil_format == "JPG":
pil_format = "JPEG"
img.save(dest_path, format=pil_format, **save_args)
return True
except Exception as e:
logger.error(f"Failed to convert {src_path.name} to {target_format.upper()}: {e}")
return False
def process_directory(source_dir, output_dir, extensions, convert_format, quality, overwrite, clean, dry_run, num_jobs):
"""Process a single folder of images and create a CBZ archive."""
source_path = Path(source_dir).resolve()
# 1. Gather files
images = get_image_files(source_path, extensions)
if not images:
logger.warning(f"No matching images in '{source_path}'")
return False
# 2. Determine output destination
cbz_name = f"{source_path.name}.cbz"
if output_dir:
dest_cbz = Path(output_dir).resolve() / cbz_name
else:
dest_cbz = source_path.parent / cbz_name
logger.info(f"Found {len(images)} images in '{source_path}'")
logger.info(f"Target CBZ path: '{dest_cbz}'")
if dest_cbz.exists():
if overwrite:
if not dry_run:
logger.info(f"Output file '{dest_cbz}' already exists. Overwriting...")
else:
logger.error(f"Output file '{dest_cbz}' already exists. Use --overwrite to replace it.")
return False
if dry_run:
logger.info(f"[DRY-RUN] Would create CBZ archive at '{dest_cbz}' with {len(images)} images.")
if convert_format:
logger.info(f"[DRY-RUN] Would convert all images to {convert_format.upper()} (quality: {quality}) first.")
if clean:
logger.info(f"[DRY-RUN] Would delete source directory '{source_path}' after verification.")
return True
# 3. Create ZIP/CBZ
success = False
temp_dir = None
try:
files_to_archive = []
if convert_format:
temp_dir = tempfile.TemporaryDirectory(prefix="img2cbz_")
logger.info(f"Converting images to {convert_format.upper()} in parallel...")
tasks = []
for img_path in images:
# Retain the base filename but use the target format extension
new_name = f"{img_path.stem}.{convert_format.lower()}"
dest_path = Path(temp_dir.name) / new_name
tasks.append((img_path, dest_path, convert_format, quality))
converted_files = []
failed_count = 0
max_workers = num_jobs if num_jobs else os.cpu_count() or 4
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_img = {
executor.submit(convert_image, src, dest, fmt, q): (src, dest)
for src, dest, fmt, q in tasks
}
completed = 0
total = len(tasks)
for future in as_completed(future_to_img):
src, dest = future_to_img[future]
completed += 1
try:
if future.result():
converted_files.append(dest)
else:
failed_count += 1
except Exception as exc:
logger.error(f"Image {src.name} generated an exception during conversion: {exc}")
failed_count += 1
# Periodically report progress
if completed % max(1, total // 5) == 0 or completed == total:
logger.info(f" Progress: {completed}/{total} ({completed*100//total}%)")
if failed_count > 0:
logger.error(f"Failed to convert {failed_count} images. Aborting archive creation for '{source_path}'")
return False
# Sort converted files naturally to maintain page ordering
converted_files.sort(key=lambda x: natural_sort_key(x.name))
files_to_archive = [(f, f.name) for f in converted_files]
else:
# Add images directly
files_to_archive = [(f, f.name) for f in images]
# Write to Zip Archive
logger.info(f"Writing to archive '{dest_cbz}'...")
dest_cbz.parent.mkdir(parents=True, exist_ok=True)
# Decide compression
try:
import zlib
compression_type = zipfile.ZIP_DEFLATED
except ImportError:
compression_type = zipfile.ZIP_STORED
logger.warning("zlib module not found. Compression is disabled; creating stored CBZ.")
with zipfile.ZipFile(dest_cbz, 'w', compression_type) as zip_file:
for file_path, arc_name in files_to_archive:
zip_file.write(file_path, arcname=arc_name)
# 4. Verify Archive Integrity
logger.info("Verifying CBZ archive integrity...")
with zipfile.ZipFile(dest_cbz, 'r') as zip_file:
bad_file = zip_file.testzip()
if bad_file:
raise zipfile.BadZipFile(f"CRC check failed for: {bad_file}")
zip_contents = zip_file.namelist()
if len(zip_contents) != len(files_to_archive):
raise zipfile.BadZipFile(
f"File count mismatch. Expected {len(files_to_archive)} files, found {len(zip_contents)} in ZIP."
)
logger.info(f"Successfully created and verified '{dest_cbz}'")
success = True
except Exception as e:
logger.error(f"Failed to create CBZ archive for '{source_path}': {e}")
if dest_cbz.exists():
try:
dest_cbz.unlink()
logger.info(f"Cleaned up partial/corrupted archive '{dest_cbz}'")
except Exception as cleanup_err:
logger.error(f"Could not delete partial archive: {cleanup_err}")
return False
finally:
if temp_dir:
temp_dir.cleanup()
# 5. Clean up source directory if requested and successful
if success and clean:
logger.info(f"Cleaning up source directory '{source_path}'...")
try:
shutil.rmtree(source_path)
logger.info(f"Successfully deleted source directory '{source_path}'")
except Exception as e:
logger.error(f"Failed to delete source directory '{source_path}': {e}")
logger.warning("Archive is verified and safe, but source directory could not be cleaned up completely.")
return success
def run_interactive():
"""Prompt the user for inputs interactively when no CLI arguments are supplied."""
print("==================================================")
print(" img2cbz: Interactive Archive Creator ")
print("==================================================")
# 1. Ask for directory path
while True:
d = input("Enter directory path to convert: ").strip()
if not d:
print("Error: Directory path cannot be empty.")
continue
p = Path(d)
if not p.exists():
print(f"Error: Path '{d}' does not exist.")
continue
if not p.is_dir():
print(f"Error: '{d}' is not a directory.")
continue
break
dirs = [str(p.resolve())]
# 2. Ask if recursive scan is desired
recursive = False
choice = input("Scan subdirectories recursively? (y/N): ").strip().lower()
if choice in ("y", "yes"):
recursive = True
# 3. Ask for output directory (optional)
out_dir = input("Enter output directory (press Enter to save next to source folders): ").strip()
if out_dir:
out_dir = str(Path(out_dir).resolve())
else:
out_dir = None
# 4. Ask if they want image conversion
convert = None
choice = input("Convert images before archiving? (y/N): ").strip().lower()
if choice in ("y", "yes"):
while True:
fmt = input("Choose conversion format (webp, jpg, png) [default: webp]: ").strip().lower()
if not fmt:
convert = "webp"
break
elif fmt in ("webp", "jpg", "png"):
convert = fmt
break
else:
print("Error: Invalid choice. Choose webp, jpg, or png.")
# 5. Ask for quality if convert format is webp/jpg
quality = 85
if convert in ("webp", "jpg"):
q_str = input("Enter compression quality (1-100) [default: 85]: ").strip()
if q_str.isdigit():
quality = int(q_str)
# 6. Ask for cleanup mode
clean = False
choice = input("Delete source folders after successful archiving? (y/N): ").strip().lower()
if choice in ("y", "yes"):
# Double check to prevent accidental loss
double_check = input("Are you absolutely sure you want to delete source directories? (y/N): ").strip().lower()
if double_check in ("y", "yes"):
clean = True
# 7. Overwrite existing CBZs?
overwrite = False
choice = input("Overwrite existing CBZ archives if they exist? (y/N): ").strip().lower()
if choice in ("y", "yes"):
overwrite = True
# Default options for the rest
extensions = "jpg,jpeg,png,webp,gif,bmp,tiff"
dry_run = False
jobs = None
verbose = False
quiet = False
print("==================================================")
return argparse.Namespace(
dirs=dirs,
recursive=recursive,
output_dir=out_dir,
extensions=extensions,
convert=convert,
quality=quality,
overwrite=overwrite,
clean=clean,
dry_run=dry_run,
jobs=jobs,
verbose=verbose,
quiet=quiet
)
def main():
parser = argparse.ArgumentParser(
description="Convert folders of image files, including WebP, into CBZ archives."
)
parser.add_argument(
"dirs",
metavar="DIR",
nargs="+",
help="One or more directories containing image files to convert."
)
parser.add_argument(
"-r", "--recursive",
action="store_true",
help="Scan subdirectories recursively. Converts each folder that contains images into its own CBZ."
)
parser.add_argument(
"-o", "--output-dir",
help="Directory to save the generated CBZ files. Defaults to the parent folder of each source directory."
)
parser.add_argument(
"-e", "--extensions",
default="jpg,jpeg,png,webp,gif,bmp,tiff",
help="Comma-separated list of image file extensions to search for (default: jpg,jpeg,png,webp,gif,bmp,tiff)."
)
parser.add_argument(
"-c", "--convert",
choices=["jpg", "png", "webp"],
help="Convert all images to the specified format before archiving (ideal for resizing or ensuring compatibility)."
)
parser.add_argument(
"-q", "--quality",
type=int,
default=85,
help="Image quality (1-100) if converting to WebP or JPEG (default: 85)."
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing CBZ archives if they already exist."
)
parser.add_argument(
"--clean",
action="store_true",
help="Delete the source directory after successful archive creation and verification."
)
parser.add_argument(
"-d", "--dry-run",
action="store_true",
help="Perform a dry run. Show what files/folders would be processed without making modifications."
)
parser.add_argument(
"-j", "--jobs",
type=int,
default=None,
help="Number of threads for parallel image conversion (default: CPU core count)."
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show verbose output (debug level logging)."
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress all standard logging, showing errors only."
)
if len(sys.argv) == 1:
try:
args = run_interactive()
except (KeyboardInterrupt, EOFError):
print("\nOperation cancelled.")
sys.exit(0)
else:
args = parser.parse_args()
# Configure log levels
if args.quiet:
logger.setLevel(logging.ERROR)
elif args.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
# Standardize extensions list
extensions = [ext.strip().lower().lstrip('.') for ext in args.extensions.split(",")]
# Validate directories
valid_dirs = []
for d in args.dirs:
p = Path(d)
if not p.exists():
logger.error(f"Path '{d}' does not exist.")
sys.exit(1)
if not p.is_dir():
logger.error(f"Path '{d}' is not a directory.")
sys.exit(1)
valid_dirs.append(p)
# Gather target directories to convert
target_dirs = []
if args.recursive:
for p in valid_dirs:
target_dirs.extend(find_image_dirs(p, extensions))
# De-duplicate while preserving order
seen = set()
target_dirs = [d for d in target_dirs if not (d in seen or seen.add(d))]
else:
target_dirs = [p.resolve() for p in valid_dirs]
if not target_dirs:
logger.warning("No directories to process.")
sys.exit(0)
logger.info(f"Target directories identified for conversion: {len(target_dirs)}")
# Process each directory
successful_archives = 0
failed_archives = 0
for d in target_dirs:
logger.info("-" * 50)
try:
success = process_directory(
source_dir=d,
output_dir=args.output_dir,
extensions=extensions,
convert_format=args.convert,
quality=args.quality,
overwrite=args.overwrite,
clean=args.clean,
dry_run=args.dry_run,
num_jobs=args.jobs
)
if success:
successful_archives += 1
else:
failed_archives += 1
except Exception as e:
logger.exception(f"Unhandled exception while processing '{d}': {e}")
failed_archives += 1
logger.info("=" * 50)
logger.info(f"Summary: {successful_archives} archives successfully created, {failed_archives} failed.")
if failed_archives > 0:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()