// Package comics reads CBZ archives (a zip of page images) and serves pages. // // Pages are read straight out of the archive on demand rather than extracted // to disk. A page is a single image, so it is read into memory and served // through http.ServeContent, which gives Range support for free and avoids // materialising a 200-page archive for a reader who views three pages. package comics import ( "archive/zip" "fmt" "io" "path" "sort" "strings" ) // Kind is the media kind this module owns. const Kind = "comics" // maxPageBytes caps a single page so a malformed archive cannot exhaust memory. const maxPageBytes = 64 << 20 // 64 MiB // imageExts are the page formats a CBZ may contain. var imageExts = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true, ".avif": true, ".bmp": true, } // Page is one page in reading order. type Page struct { Index int `json:"index"` // 1-based Name string `json:"name"` } // ListPages returns the archive's image entries in reading order. // // Ordering is natural, not lexical: "page2.png" sorts before "page10.png", // which lexical ordering gets wrong. Comic archives are usually already // numbered, so this is the difference between a readable book and a shuffled // one. func ListPages(cbzPath string) ([]Page, error) { zr, err := zip.OpenReader(cbzPath) if err != nil { return nil, fmt.Errorf("comics: open archive: %w", err) } defer zr.Close() var names []string for _, f := range zr.File { if f.FileInfo().IsDir() { continue } name := f.Name // Skip macOS resource forks and other hidden metadata. if strings.HasPrefix(path.Base(name), ".") || strings.Contains(name, "__MACOSX/") { continue } if imageExts[strings.ToLower(path.Ext(name))] { names = append(names, name) } } if len(names) == 0 { return nil, fmt.Errorf("comics: archive contains no images") } sort.Slice(names, func(i, j int) bool { return naturalLess(names[i], names[j]) }) pages := make([]Page, len(names)) for i, n := range names { pages[i] = Page{Index: i + 1, Name: n} } return pages, nil } // ReadPage returns the bytes of one page by its archive name. func ReadPage(cbzPath, name string) ([]byte, error) { zr, err := zip.OpenReader(cbzPath) if err != nil { return nil, fmt.Errorf("comics: open archive: %w", err) } defer zr.Close() for _, f := range zr.File { if f.Name != name { continue } if f.UncompressedSize64 > maxPageBytes { return nil, fmt.Errorf("comics: page %q is too large", name) } rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() return io.ReadAll(io.LimitReader(rc, maxPageBytes)) } return nil, fmt.Errorf("comics: page %q not found", name) } // naturalLess compares two strings treating digit runs as numbers. func naturalLess(a, b string) bool { ai, bi := 0, 0 for ai < len(a) && bi < len(b) { ad, bd := isDigit(a[ai]), isDigit(b[bi]) if ad && bd { // Compare the full digit runs numerically. as, bs := ai, bi for ai < len(a) && isDigit(a[ai]) { ai++ } for bi < len(b) && isDigit(b[bi]) { bi++ } an := strings.TrimLeft(a[as:ai], "0") bn := strings.TrimLeft(b[bs:bi], "0") if len(an) != len(bn) { return len(an) < len(bn) } if an != bn { return an < bn } continue } if a[ai] != b[bi] { return a[ai] < b[bi] } ai++ bi++ } return len(a)-ai < len(b)-bi } func isDigit(c byte) bool { return c >= '0' && c <= '9' }