// 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" "github.com/mithral/vellum/internal/natsort" ) // 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 natsort.Less(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 moved to internal/natsort, shared with the scanner's track // ordering.