- comics: reads pages straight out of the archive on demand. No extraction pass, so a 200-page CBZ is not materialised for a reader who views three pages. Pages are read into memory and served via http.ServeContent, which gives Range for free. - natural page ordering: page2 sorts before page10. Lexical ordering shuffles a numbered archive, which is the difference between a readable book and a scrambled one. - skips __MACOSX and dotfiles; rejects archives with no images. - cover is the first page. - media.Item gained ModTime (from the backing file) so page responses carry a Last-Modified and support conditional GETs. - web: ComicReader with a bounded prefetch window (two pages each way), page jump, keyboard nav, and progress stored as a page number. Verified: 3-page CBZ served in natural order, out-of-range 404, cover from page 1, progress round-trip. svelte-check 0/0.
139 lines
3.4 KiB
Go
139 lines
3.4 KiB
Go
// 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' }
|