zipfile
| Language | Python |
|---|---|
| License | PSF License (Python standard library) |
| Repository | github.com/python/cpython |
| Documentation | docs.python.org/3/library/zipfile.html |
| Depends on | nothing (standard library; zlib, bz2, lzma modules for compression) |
| Read / write | yes / yes (ZIP, ZIP64; reads legacy ZipCrypto, no AES) |
zipfile is Python’s built-in ZIP module. ZipFile.namelist() returns every entry name from the central directory, and zf.read(name) decompresses one entry, so you can show page 200 without touching the others. Writing with ZIP_STORED creates a CBZ without recompressing images.
Everything comic-specific (natural sort, cover, ComicInfo.xml) takes a few lines, shown below. For metadata in all comic formats, or CBR/CB7/CBT support, use comicbox.
Installation#
Nothing to install: import zipfile.
Basic usage: list pages in natural order#
import re
import sys
import zipfile
from pathlib import PurePosixPath
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".jxl"}
def is_page(name: str) -> bool:
name = name.replace("\\", "/")
if "__MACOSX/" in name:
return False
p = PurePosixPath(name)
return not p.name.startswith(".") and p.suffix.lower() in IMAGE_EXTS
def natural_key(name: str):
# "Page 10.jpg" -> ["page ", 10, ".jpg"]: digit runs compare as numbers
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
def list_pages(zf: zipfile.ZipFile) -> list[str]:
names = [i.filename for i in zf.infolist() if not i.is_dir() and is_page(i.filename)]
return sorted(names, key=natural_key)
with zipfile.ZipFile(sys.argv[1]) as zf:
for n, name in enumerate(list_pages(zf), 1):
print(f"{n:3} {name} ({zf.getinfo(name).file_size} bytes)")
The natural_key trick (split on digit runs, convert them to int) is the standard way to sort pages in Python. The natsort package does the same with more edge cases handled.
How to extract the cover#
The cover is the first page in natural order:
with zipfile.ZipFile("comic.cbz") as zf:
pages = list_pages(zf)
if pages:
cover = pages[0]
data = zf.read(cover)
with open("cover" + PurePosixPath(cover).suffix.lower(), "wb") as f:
f.write(data)
To make a thumbnail, open the bytes with Pillow: Image.open(io.BytesIO(data)).thumbnail((300, 450)).
How to read ComicInfo.xml#
import xml.etree.ElementTree as ET
def read_comic_info(zf: zipfile.ZipFile) -> dict | None:
name = next((n for n in zf.namelist() if n.lower() == "comicinfo.xml"), None)
if name is None:
return None
root = ET.fromstring(zf.read(name))
info = {child.tag: (child.text or "").strip() for child in root if child.tag != "Pages"}
info["Pages"] = [dict(p.attrib) for p in root.iterfind("Pages/Page")]
return info
with zipfile.ZipFile("comic.cbz") as zf:
info = read_comic_info(zf)
if info:
print(f"{info.get('Series')} #{info.get('Number')}: {info.get('Title')}")
print("right to left:", info.get("Manga") == "YesAndRightToLeft")
# Image is the 0-based index into the sorted page list
covers = [int(p["Image"]) for p in info["Pages"] if "FrontCover" in p.get("Type", "")]
if covers:
print("cover:", list_pages(zf)[covers[0]])
ElementTree doesn’t resolve external entities, so it’s safe for untrusted ComicInfo.xml. For maximum safety with hostile input use the defusedxml package.
How to create a CBZ from a folder of images#
import zipfile
from pathlib import Path
def make_cbz(folder: str, out: str) -> None:
files = sorted((p for p in Path(folder).iterdir() if p.is_file() and is_page(p.name)),
key=lambda p: natural_key(p.name))
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_STORED) as zf:
for i, p in enumerate(files, 1):
zf.write(p, arcname=f"{i:03}{p.suffix.lower()}") # 001.jpg, 002.jpg, ...
info = Path(folder) / "ComicInfo.xml"
if info.exists():
zf.write(info, arcname="ComicInfo.xml", compress_type=zipfile.ZIP_DEFLATED)
make_cbz("Batman 001", "Batman 001.cbz")
How to add or replace ComicInfo.xml#
zipfile can’t delete or replace an entry in place. Copy the archive to a new file, skipping the old metadata:
import os
import zipfile
def set_comic_info(cbz: str, xml: str) -> None:
tmp = cbz + ".tmp"
with zipfile.ZipFile(cbz) as src, zipfile.ZipFile(tmp, "w") as dst:
for item in src.infolist():
if item.filename.lower() != "comicinfo.xml":
dst.writestr(item, src.read(item)) # keeps the entry's compression
dst.writestr("ComicInfo.xml", xml, compress_type=zipfile.ZIP_DEFLATED)
os.replace(tmp, cbz)
Tips#
- Names in old archives that don’t set the UTF-8 flag are decoded as CP437. Python 3.11+ accepts
zipfile.ZipFile(path, metadata_encoding="cp932")for Japanese archives. zf.open(name)returns a file object for streaming a large page without reading it all at once.- Check
zf.getinfo(name).file_sizebefore reading untrusted files to avoid decompression bombs.