All formats

java.util.zip

LanguageJava
LicenseGPL-2.0 with Classpath Exception (part of OpenJDK)
Repositorygithub.com/openjdk/jdk
Documentationjava.util.zip Javadoc
Depends onnothing (built into the JDK)
Read / writeyes / yes (ZIP, ZIP64; no encryption)

java.util.zip is the ZIP support built into every JDK. ZipFile reads the central directory and opens any entry directly, which is what a comic reader needs. ZipOutputStream writes new archives. The Classpath Exception means you can use it in any application, including closed-source ones.

Limitations: no encryption, and stored (uncompressed) entries in ZipOutputStream need their size and CRC-32 set before writing. Apache Commons Compress removes that restriction.

Installation#

Nothing to install: java.util.zip is part of java.base.

Basic usage: list pages in natural order#

Java
import java.io.IOException;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

public class Comic {
    static final Set<String> IMAGE_EXTS =
        Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "jxl");

    static boolean isPage(String path) {
        path = path.replace('\\', '/');
        if (path.contains("__MACOSX/")) return false;
        String name = path.substring(path.lastIndexOf('/') + 1);
        int dot = name.lastIndexOf('.');
        return !name.isEmpty() && name.charAt(0) != '.' && dot > 0
            && IMAGE_EXTS.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT));
    }

    // natural, case-insensitive order: "2.jpg" < "10.jpg"
    static int naturalCompare(String a, String b) {
        int i = 0, j = 0;
        while (i < a.length() && j < b.length()) {
            char ca = a.charAt(i), cb = b.charAt(j);
            if (Character.isDigit(ca) && Character.isDigit(cb)) {
                int ie = i, je = j;
                while (ie < a.length() && Character.isDigit(a.charAt(ie))) ie++;
                while (je < b.length() && Character.isDigit(b.charAt(je))) je++;
                String na = a.substring(i, ie).replaceFirst("^0+(?=.)", "");
                String nb = b.substring(j, je).replaceFirst("^0+(?=.)", "");
                if (na.length() != nb.length()) return na.length() - nb.length();
                int c = na.compareTo(nb);
                if (c != 0) return c;
                i = ie;
                j = je;
            } else {
                int c = Character.compare(Character.toLowerCase(ca), Character.toLowerCase(cb));
                if (c != 0) return c;
                i++;
                j++;
            }
        }
        return (a.length() - i) - (b.length() - j);
    }

    static List<ZipEntry> pages(ZipFile zip) {
        List<ZipEntry> pages = new ArrayList<>();
        zip.stream().filter(e -> !e.isDirectory() && isPage(e.getName())).forEach(pages::add);
        pages.sort((x, y) -> naturalCompare(x.getName(), y.getName()));
        return pages;
    }

    public static void main(String[] args) throws IOException {
        try (ZipFile zip = new ZipFile(args[0])) {
            List<ZipEntry> pages = pages(zip);
            for (int i = 0; i < pages.size(); i++)
                System.out.printf("%3d %s%n", i + 1, pages.get(i).getName());
        }
    }
}

How to extract the cover#

The cover is the first page in natural order:

Java
try (ZipFile zip = new ZipFile("comic.cbz")) {
    List<ZipEntry> pages = Comic.pages(zip);
    if (!pages.isEmpty()) {
        ZipEntry cover = pages.get(0);
        String ext = cover.getName().substring(cover.getName().lastIndexOf('.'));
        try (InputStream in = zip.getInputStream(cover)) {
            Files.copy(in, Path.of("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
        }
    }
}

To decode it, pass the stream to ImageIO.read(in) (JPEG, PNG, GIF, BMP built in; WebP needs a plugin such as TwelveMonkeys).

How to read ComicInfo.xml#

Java
import java.io.InputStream;
import java.util.zip.*;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.*;

static void printComicInfo(ZipFile zip) throws Exception {
    ZipEntry entry = zip.stream()
        .filter(e -> e.getName().equalsIgnoreCase("ComicInfo.xml"))
        .findFirst().orElse(null);
    if (entry == null) return;

    DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
    f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);  // no XXE
    Element ci;
    try (InputStream in = zip.getInputStream(entry)) {
        ci = f.newDocumentBuilder().parse(in).getDocumentElement();
    }
    System.out.println(text(ci, "Series") + " #" + text(ci, "Number") + ": " + text(ci, "Title"));
    System.out.println("Pages: " + text(ci, "PageCount") + ", manga: " + text(ci, "Manga"));

    NodeList list = ci.getElementsByTagName("Page");
    for (int i = 0; i < list.getLength(); i++) {
        Element p = (Element) list.item(i);
        if (p.getAttribute("Type").contains("FrontCover"))
            System.out.println("cover is page index " + p.getAttribute("Image"));
    }
}

static String text(Element parent, String tag) {
    NodeList n = parent.getElementsByTagName(tag);
    return n.getLength() > 0 ? n.item(0).getTextContent() : "";
}

The Image attribute is the 0-based index into the sorted page list, not a file name.

How to create a CBZ from a folder of images#

Stored entries need size and CRC-32 up front, so compute them first. This is cheap compared with compressing:

Java
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;

static void makeCbz(Path dir, Path out) throws IOException {
    List<Path> files;
    try (var s = Files.list(dir)) {
        files = new ArrayList<>(s.filter(p -> Comic.isPage(p.getFileName().toString())).toList());
    }
    files.sort((a, b) -> Comic.naturalCompare(a.getFileName().toString(), b.getFileName().toString()));

    try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(out))) {
        for (int i = 0; i < files.size(); i++) {
            String file = files.get(i).getFileName().toString();
            String name = String.format("%03d%s", i + 1, file.substring(file.lastIndexOf('.')).toLowerCase());
            byte[] data = Files.readAllBytes(files.get(i));
            CRC32 crc = new CRC32();
            crc.update(data);
            ZipEntry e = new ZipEntry(name);
            e.setMethod(ZipEntry.STORED);  // pages are already compressed
            e.setSize(data.length);
            e.setCompressedSize(data.length);
            e.setCrc(crc.getValue());
            zos.putNextEntry(e);
            zos.write(data);
            zos.closeEntry();
        }
        Path info = dir.resolve("ComicInfo.xml");
        if (Files.exists(info)) {
            zos.putNextEntry(new ZipEntry("ComicInfo.xml"));  // DEFLATED by default
            Files.copy(info, zos);
            zos.closeEntry();
        }
    }
}

Tips#