All formats

archive/zip

LanguageGo
LicenseBSD-3-Clause (Go standard library)
Repositorygo.googlesource.com/go
Documentationpkg.go.dev/archive/zip
Depends onnothing (standard library)
Read / writeyes / yes (ZIP, ZIP64; no encryption)

archive/zip reads the ZIP central directory when you open a file, so r.File lists every entry and f.Open() decompresses one entry on demand. *zip.Reader also implements fs.FS, so fs.ReadFile(r, "001.jpg") works. zip.Writer creates archives with stored or deflated entries.

It supports only the Store and Deflate methods out of the box (others can be registered) and no encryption, which covers practically every CBZ file.

Installation#

Nothing to install: import "archive/zip".

Basic usage: list pages in natural order#

Go
package main

import (
	"archive/zip"
	"cmp"
	"fmt"
	"log"
	"os"
	"path"
	"slices"
	"strings"
)

var imageExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
	".webp": true, ".bmp": true, ".avif": true, ".jxl": true,
}

func isPage(name string) bool {
	name = strings.ReplaceAll(name, "\\", "/")
	if strings.Contains(name, "__MACOSX/") {
		return false
	}
	base := path.Base(name)
	return !strings.HasPrefix(base, ".") && imageExts[strings.ToLower(path.Ext(base))]
}

func isDigit(c byte) bool { return c >= '0' && c <= '9' }

func splitDigits(s string) (string, string) {
	i := 0
	for i < len(s) && isDigit(s[i]) {
		i++
	}
	return s[:i], s[i:]
}

// naturalCompare orders "2.jpg" before "10.jpg", ignoring case.
func naturalCompare(a, b string) int {
	a, b = strings.ToLower(a), strings.ToLower(b)
	for a != "" && b != "" {
		if isDigit(a[0]) && isDigit(b[0]) {
			da, ra := splitDigits(a)
			db, rb := splitDigits(b)
			da, db = strings.TrimLeft(da, "0"), strings.TrimLeft(db, "0")
			if c := cmp.Compare(len(da), len(db)); c != 0 {
				return c
			}
			if c := strings.Compare(da, db); c != 0 {
				return c
			}
			a, b = ra, rb
			continue
		}
		if a[0] != b[0] {
			return cmp.Compare(a[0], b[0])
		}
		a, b = a[1:], b[1:]
	}
	return cmp.Compare(len(a), len(b))
}

func pages(r *zip.Reader) []*zip.File {
	var res []*zip.File
	for _, f := range r.File {
		if !f.FileInfo().IsDir() && isPage(f.Name) {
			res = append(res, f)
		}
	}
	slices.SortFunc(res, func(a, b *zip.File) int { return naturalCompare(a.Name, b.Name) })
	return res
}

func main() {
	r, err := zip.OpenReader(os.Args[1])
	if err != nil {
		log.Fatal(err)
	}
	defer r.Close()
	for i, f := range pages(&r.Reader) {
		fmt.Printf("%3d %s (%d bytes)\n", i+1, f.Name, f.UncompressedSize64)
	}
}

How to extract the cover#

The cover is the first page in natural order:

Go
func extractCover(cbz string) error {
	r, err := zip.OpenReader(cbz)
	if err != nil {
		return err
	}
	defer r.Close()
	ps := pages(&r.Reader)
	if len(ps) == 0 {
		return fmt.Errorf("no pages in %s", cbz)
	}
	data, err := fs.ReadFile(r, ps[0].Name) // *zip.ReadCloser is an fs.FS
	if err != nil {
		return err
	}
	return os.WriteFile("cover"+path.Ext(ps[0].Name), data, 0o644)
}

To decode the page, pass bytes.NewReader(data) to image.Decode with image/jpeg and image/png imported (golang.org/x/image/webp for WebP).

How to read ComicInfo.xml#

Map the elements you need to a struct; encoding/xml matches field names to element names:

Go
import "encoding/xml"

type ComicInfo struct {
	Title     string
	Series    string
	Number    string
	Year      int
	Writer    string
	Publisher string
	PageCount int
	Manga     string // "YesAndRightToLeft" means right-to-left reading
	Pages     []struct {
		Image int    `xml:"Image,attr"` // index in sorted page order
		Type  string `xml:"Type,attr"`
	} `xml:"Pages>Page"`
}

func readComicInfo(r *zip.Reader) (*ComicInfo, error) {
	for _, f := range r.File {
		if !strings.EqualFold(f.Name, "ComicInfo.xml") {
			continue
		}
		rc, err := f.Open()
		if err != nil {
			return nil, err
		}
		defer rc.Close()
		var ci ComicInfo
		if err := xml.NewDecoder(rc).Decode(&ci); err != nil {
			return nil, err
		}
		return &ci, nil
	}
	return nil, nil // no metadata
}

Find the cover page from the metadata:

Go
for _, p := range ci.Pages {
	if strings.Contains(p.Type, "FrontCover") {
		fmt.Println("cover:", pages(r)[p.Image].Name)
	}
}

How to create a CBZ from a folder of images#

Use zip.Store for pages (they’re already compressed) and zero-padded names:

Go
func makeCbz(dir, out string) error {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return err
	}
	var files []string
	for _, e := range entries {
		if e.Type().IsRegular() && isPage(e.Name()) {
			files = append(files, e.Name())
		}
	}
	slices.SortFunc(files, naturalCompare)

	f, err := os.Create(out)
	if err != nil {
		return err
	}
	defer f.Close()
	zw := zip.NewWriter(f)
	add := func(src, name string, method uint16) error {
		w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: method, Modified: time.Now()})
		if err != nil {
			return err
		}
		in, err := os.Open(filepath.Join(dir, src))
		if err != nil {
			return err
		}
		defer in.Close()
		_, err = io.Copy(w, in)
		return err
	}
	for i, name := range files {
		if err := add(name, fmt.Sprintf("%03d%s", i+1, strings.ToLower(path.Ext(name))), zip.Store); err != nil {
			return err
		}
	}
	if _, err := os.Stat(filepath.Join(dir, "ComicInfo.xml")); err == nil {
		if err := add("ComicInfo.xml", "ComicInfo.xml", zip.Deflate); err != nil {
			return err
		}
	}
	return zw.Close() // writes the central directory
}

Tips#