All formats

archives (mholt/archives)

LanguageGo
LicenseMIT
Repositorygithub.com/mholt/archives
Documentationpkg.go.dev/github.com/mholt/archives
Depends onpure Go; uses bodgit/sevenzip, nwaples/rardecode and klauspost/compress
Read / writeyes / yes (ZIP and TAR read/write; RAR and 7z read only)

archives (the successor of mholt/archiver) gives one API for ZIP, TAR, RAR and 7z. archives.FileSystem opens a file and returns an fs.FS, so you list and read pages with the standard fs.WalkDir and fs.ReadFile whether the comic is a CBZ, CBR or CB7. The format is detected from the content, so a .cbr that is really ZIP still opens.

The library is pre-1.0 (v0.1.x): the API may still change between minor versions. If you only handle CBZ, the standard archive/zip has no dependencies.

Installation#

Shell
go get github.com/mholt/archives

Basic usage: list pages in natural order#

isPage and naturalCompare are the helpers from the archive/zip page: an image-extension filter that skips __MACOSX and dotfiles, and a natural, case-insensitive compare.

Go
package main

import (
	"context"
	"fmt"
	"io/fs"
	"log"
	"os"
	"slices"

	"github.com/mholt/archives"
)

func listPages(fsys fs.FS) ([]string, error) {
	var pages []string
	err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && isPage(p) {
			pages = append(pages, p)
		}
		return nil
	})
	slices.SortFunc(pages, naturalCompare)
	return pages, err
}

func main() {
	ctx := context.Background()
	fsys, err := archives.FileSystem(ctx, os.Args[1], nil) // CBZ, CBR, CB7 or CBT
	if err != nil {
		log.Fatal(err)
	}
	pages, err := listPages(fsys)
	if err != nil {
		log.Fatal(err)
	}
	for i, p := range pages {
		fmt.Printf("%3d %s\n", i+1, p)
	}
}

How to extract the cover#

Go
pages, err := listPages(fsys)
if err == nil && len(pages) > 0 {
	data, err := fs.ReadFile(fsys, pages[0])
	if err != nil {
		log.Fatal(err)
	}
	os.WriteFile("cover"+path.Ext(pages[0]), data, 0o644)
}

How to read ComicInfo.xml#

The name’s case varies between files, so look it up in the root directory listing:

Go
type ComicInfo struct {
	Title, Series, Number, Writer, Manga string
	PageCount                            int
	Pages                                []struct {
		Image int    `xml:"Image,attr"`
		Type  string `xml:"Type,attr"`
	} `xml:"Pages>Page"`
}

func readComicInfo(fsys fs.FS) (*ComicInfo, error) {
	entries, err := fs.ReadDir(fsys, ".")
	if err != nil {
		return nil, err
	}
	for _, e := range entries {
		if strings.EqualFold(e.Name(), "ComicInfo.xml") {
			data, err := fs.ReadFile(fsys, e.Name())
			if err != nil {
				return nil, err
			}
			var ci ComicInfo
			return &ci, xml.Unmarshal(data, &ci)
		}
	}
	return nil, nil
}

How to create a CBZ from a folder of images#

FilesFromDisk maps files on disk to names in the archive. SelectiveCompression stores files that are already compressed (JPEG, PNG, by extension) and deflates the rest:

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

	m := map[string]string{}
	for i, n := range names {
		m[filepath.Join(dir, n)] = fmt.Sprintf("%03d%s", i+1, strings.ToLower(path.Ext(n)))
	}
	if _, err := os.Stat(filepath.Join(dir, "ComicInfo.xml")); err == nil {
		m[filepath.Join(dir, "ComicInfo.xml")] = "ComicInfo.xml"
	}
	files, err := archives.FilesFromDisk(ctx, nil, m)
	if err != nil {
		return err
	}
	f, err := os.Create(out)
	if err != nil {
		return err
	}
	defer f.Close()
	return archives.Zip{SelectiveCompression: true}.Archive(ctx, f, files)
}

How to stream through an archive without fs.FS#

Extract calls a function for every entry in archive order. This is the fastest way to read all pages of a solid RAR or 7z, and it works on a non-seekable stream for TAR:

Go
f, _ := os.Open("comic.cbz")
defer f.Close()
format, stream, err := archives.Identify(ctx, "comic.cbz", f)
if err != nil {
	log.Fatal(err)
}
ex, ok := format.(archives.Extractor)
if !ok {
	log.Fatal("not an archive")
}
err = ex.Extract(ctx, stream, func(ctx context.Context, fi archives.FileInfo) error {
	if fi.IsDir() || !isPage(fi.NameInArchive) {
		return nil
	}
	rc, err := fi.Open()
	if err != nil {
		return err
	}
	defer rc.Close()
	data, err := io.ReadAll(rc)
	fmt.Println(fi.NameInArchive, len(data))
	return err
})

Tips#