zip
| Language | Rust |
|---|---|
| License | MIT |
| Repository | github.com/zip-rs/zip2 |
| Documentation | docs.rs/zip |
| Depends on | compression crates behind Cargo features (deflate, bzip2, zstd, xz, lzma, aes-crypto) |
| Read / write | yes / yes (ZIP, ZIP64, AES; Deflate, Deflate64, BZip2, Zstd, LZMA, XZ, PPMd) |
zip reads and writes ZIP archives. ZipArchive::new parses the central directory, then by_index or by_name gives a reader for one entry, so a comic viewer decodes only the page on screen. ZipWriter writes entries with any supported method; use Stored for images.
The crate lives in the zip-rs/zip2 repository (it was mvdnes/zip-rs) and releases often (major versions change small parts of the API). The examples use the 9.x API with SimpleFileOptions.
Installation#
Shell
cargo add zip
# for ComicInfo.xml
cargo add roxmltree
Basic usage: list pages in natural order#
Rust
use std::cmp::Ordering;
use std::fs::File;
fn is_page(name: &str) -> bool {
let name = name.replace('\\', "/");
if name.contains("__MACOSX/") {
return false;
}
let base = name.rsplit('/').next().unwrap_or("");
if base.starts_with('.') {
return false;
}
match base.rsplit_once('.') {
Some((_, ext)) => matches!(
ext.to_ascii_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp" | "avif" | "jxl"
),
None => false,
}
}
/// Natural, case-insensitive order: "2.jpg" < "10.jpg".
fn natural_cmp(a: &str, b: &str) -> Ordering {
let (a, b) = (a.to_lowercase(), b.to_lowercase());
let (mut x, mut y) = (a.as_bytes(), b.as_bytes());
while !x.is_empty() && !y.is_empty() {
if x[0].is_ascii_digit() && y[0].is_ascii_digit() {
let nx = x.iter().take_while(|c| c.is_ascii_digit()).count();
let ny = y.iter().take_while(|c| c.is_ascii_digit()).count();
let dx = &x[x[..nx].iter().take_while(|&&c| c == b'0').count()..nx];
let dy = &y[y[..ny].iter().take_while(|&&c| c == b'0').count()..ny];
let ord = dx.len().cmp(&dy.len()).then_with(|| dx.cmp(dy));
if ord != Ordering::Equal {
return ord;
}
x = &x[nx..];
y = &y[ny..];
} else {
if x[0] != y[0] {
return x[0].cmp(&y[0]);
}
x = &x[1..];
y = &y[1..];
}
}
x.len().cmp(&y.len())
}
fn pages<R: std::io::Read + std::io::Seek>(zip: &zip::ZipArchive<R>) -> Vec<String> {
let mut pages: Vec<String> = zip
.file_names()
.filter_map(|n| n.ok()) // names are decoded on the fly; skip undecodable ones
.filter(|n| !n.ends_with('/') && is_page(n))
.map(|n| n.into_owned())
.collect();
pages.sort_by(|a, b| natural_cmp(a, b));
pages
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args().nth(1).ok_or("usage: pages <file.cbz>")?;
let zip = zip::ZipArchive::new(File::open(path)?)?;
for (i, name) in pages(&zip).iter().enumerate() {
println!("{:3} {}", i + 1, name);
}
Ok(())
}
How to read a page and extract the cover#
Rust
use std::io::Read;
fn read_entry<R: Read + std::io::Seek>(zip: &mut zip::ZipArchive<R>, name: &str)
-> zip::result::ZipResult<Vec<u8>> {
let mut f = zip.by_name(name)?;
let mut data = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut data)?;
Ok(data)
}
fn extract_cover(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut zip = zip::ZipArchive::new(File::open(path)?)?;
let first = pages(&zip).into_iter().next().ok_or("no pages")?;
let data = read_entry(&mut zip, &first)?;
let ext = first.rsplit_once('.').map(|(_, e)| e.to_lowercase()).unwrap_or_default();
std::fs::write(format!("cover.{ext}"), data)?;
Ok(())
}
Decode the bytes with the image crate: image::load_from_memory(&data)?.
How to read ComicInfo.xml#
Rust
fn print_comic_info(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut zip = zip::ZipArchive::new(File::open(path)?)?;
let Some(name) = zip
.file_names()
.filter_map(|n| n.ok())
.find(|n| n.eq_ignore_ascii_case("ComicInfo.xml"))
.map(|n| n.into_owned())
else {
return Ok(()); // no metadata
};
let xml = String::from_utf8(read_entry(&mut zip, &name)?)?;
let doc = roxmltree::Document::parse(&xml)?;
let root = doc.root_element();
let get = |tag: &str| {
root.children().find(|n| n.has_tag_name(tag)).and_then(|n| n.text()).unwrap_or("")
};
println!("{} #{}: {}", get("Series"), get("Number"), get("Title"));
println!("pages: {}, manga: {}", get("PageCount"), get("Manga"));
// <Pages><Page Image="0" Type="FrontCover"/>: Image is the index in sorted page order
let all = pages(&zip);
for p in root.descendants().filter(|n| n.has_tag_name("Page")) {
if p.attribute("Type").unwrap_or("").contains("FrontCover") {
let idx: usize = p.attribute("Image").unwrap_or("0").parse()?;
println!("cover: {}", all.get(idx).map(String::as_str).unwrap_or("?"));
}
}
Ok(())
}
How to create a CBZ from a folder of images#
Rust
use std::io::Write;
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
fn make_cbz(dir: &str, out: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut files: Vec<String> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| is_page(n))
.collect();
files.sort_by(|a, b| natural_cmp(a, b));
let mut zip = zip::ZipWriter::new(File::create(out)?);
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (i, name) in files.iter().enumerate() {
let ext = name.rsplit_once('.').map(|(_, e)| e.to_lowercase()).unwrap_or_default();
zip.start_file(format!("{:03}.{ext}", i + 1), stored)?;
zip.write_all(&std::fs::read(format!("{dir}/{name}"))?)?;
}
let info = format!("{dir}/ComicInfo.xml");
if std::path::Path::new(&info).exists() {
zip.start_file("ComicInfo.xml", SimpleFileOptions::default())?; // Deflate
zip.write_all(&std::fs::read(&info)?)?;
}
zip.finish()?;
Ok(())
}
Tips#
- Disable the features you don’t need to cut build time:
zip = { version = "9", default-features = false, features = ["deflate"] }handles practically every CBZ. ZipArchiveneedsRead + Seek. For a CBZ in memory, wrap the bytes instd::io::Cursor.- Check
f.size()against a limit beforeread_to_endwhen the file is untrusted.