Vellum/internal/modules/comics/comic.go
mithral c3b7db7114 Phase 4: CBZ comics module and reader
- 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.
2026-10-09 21:07:59 -04:00

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' }