diff --git a/backend/internal/bookfile/cover.go b/backend/internal/bookfile/cover.go
new file mode 100644
index 0000000..2fa2f1a
--- /dev/null
+++ b/backend/internal/bookfile/cover.go
@@ -0,0 +1,125 @@
+package bookfile
+
+import (
+ "archive/zip"
+ "encoding/xml"
+ "errors"
+ "io"
+ "net/url"
+ "path"
+ "strings"
+)
+
+func CBZCover(f io.ReaderAt, size int64) ([]byte, string, error) {
+ idx, err := PageIndex(f, size)
+ if err != nil {
+ return nil, "", err
+ }
+ if len(idx) == 0 {
+ return nil, "", errors.New("no page images")
+ }
+ img, err := ReadEntry(f, size, idx[0])
+ return img, strings.ToLower(path.Ext(idx[0])), err
+}
+
+func readZipEntry(zr *zip.Reader, name string) ([]byte, error) {
+ for _, zf := range zr.File {
+ if zf.Name == name {
+ rc, err := zf.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer rc.Close()
+ return io.ReadAll(io.LimitReader(rc, 8<<20))
+ }
+ }
+ return nil, errors.New("entry not found: " + name)
+}
+
+type opfDoc struct {
+ XMLName xml.Name `xml:"package"`
+ Metadata struct {
+ Meta []struct {
+ Name string `xml:"name,attr"`
+ Content string `xml:"content,attr"`
+ } `xml:"meta"`
+ } `xml:"metadata"`
+ Manifest struct {
+ Items []struct {
+ ID string `xml:"id,attr"`
+ Href string `xml:"href,attr"`
+ Properties string `xml:"properties,attr"`
+ MediaType string `xml:"media-type,attr"`
+ } `xml:"item"`
+ } `xml:"manifest"`
+}
+
+func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) {
+ zr, err := zip.NewReader(f, size)
+ if err != nil {
+ return nil, "", ErrNotZip
+ }
+ cont, err := readZipEntry(zr, "META-INF/container.xml")
+ if err != nil {
+ return nil, "", err
+ }
+ var root struct {
+ XMLName xml.Name `xml:"container"`
+ RootFiles []struct {
+ FullPath string `xml:"full-path,attr"`
+ } `xml:"rootfiles>rootfile"`
+ }
+ if err := xml.Unmarshal(cont, &root); err != nil || len(root.RootFiles) == 0 {
+ return nil, "", errors.New("bad container.xml")
+ }
+ opfPath := root.RootFiles[0].FullPath
+ opfData, err := readZipEntry(zr, opfPath)
+ if err != nil {
+ return nil, "", err
+ }
+ var doc opfDoc
+ if err := xml.Unmarshal(opfData, &doc); err != nil {
+ return nil, "", errors.New("bad opf")
+ }
+ metaCoverID := ""
+ for _, m := range doc.Metadata.Meta {
+ if m.Name == "cover" {
+ metaCoverID = m.Content
+ }
+ }
+ href := ""
+ for _, it := range doc.Manifest.Items {
+ if strings.Contains(it.Properties, "cover-image") {
+ href = it.Href
+ break
+ }
+ if metaCoverID != "" && it.ID == metaCoverID {
+ href = it.Href
+ break
+ }
+ }
+ if href == "" {
+ for _, it := range doc.Manifest.Items {
+ if strings.HasPrefix(it.MediaType, "image/") && strings.Contains(strings.ToLower(it.ID), "cover") {
+ href = it.Href
+ break
+ }
+ }
+ }
+ if href == "" {
+ return nil, "", errors.New("no cover found")
+ }
+ unescaped, e := url.PathUnescape(href)
+ if e == nil {
+ href = unescaped
+ }
+ name := path.Join(path.Dir(opfPath), href)
+ if name == "." {
+ name = href
+ }
+ img, err := readZipEntry(zr, name)
+ if err != nil {
+ return nil, "", err
+ }
+ return img, strings.ToLower(path.Ext(name)), nil
+}
diff --git a/backend/internal/bookfile/cover_test.go b/backend/internal/bookfile/cover_test.go
new file mode 100644
index 0000000..097e60a
--- /dev/null
+++ b/backend/internal/bookfile/cover_test.go
@@ -0,0 +1,75 @@
+package bookfile
+
+import (
+ "archive/zip"
+ "bytes"
+ "testing"
+)
+
+func TestCBZCoverIsFirstPage(t *testing.T) {
+ buf := &bytes.Buffer{}
+ zw := zip.NewWriter(buf)
+ w, _ := zw.Create("02.jpg")
+ w.Write([]byte("second"))
+ w, _ = zw.Create("01.jpg")
+ w.Write([]byte("first"))
+ zw.Close()
+ r := bytes.NewReader(buf.Bytes())
+ img, ext, err := CBZCover(r, int64(r.Len()))
+ if err != nil || string(img) != "first" || ext != ".jpg" {
+ t.Fatalf("%q %q %v", img, ext, err)
+ }
+}
+
+func zipEntries(t *testing.T, kv map[string]string) *bytes.Reader {
+ t.Helper()
+ buf := &bytes.Buffer{}
+ zw := zip.NewWriter(buf)
+ for name, data := range kv {
+ w, _ := zw.Create(name)
+ w.Write([]byte(data))
+ }
+ zw.Close()
+ return bytes.NewReader(buf.Bytes())
+}
+
+const containerXML = ``
+
+func opf(manifest, spine string) string {
+ return `` +
+ spine + `` + manifest + ``
+}
+
+func TestEPUBCoverProperties(t *testing.T) {
+ r := zipEntries(t, map[string]string{
+ "META-INF/container.xml": containerXML,
+ "OEBPS/content.opf": opf(` `, ""),
+ "OEBPS/img/cover one.png": "PNGDATA",
+ })
+ img, ext, err := EPUBCover(r, int64(r.Len()))
+ if err != nil || string(img) != "PNGDATA" || ext != ".png" {
+ t.Fatalf("%q %q %v", img, ext, err)
+ }
+}
+
+func TestEPUBCoverMetaID(t *testing.T) {
+ r := zipEntries(t, map[string]string{
+ "META-INF/container.xml": containerXML,
+ "OEBPS/content.opf": opf(` `, ``),
+ "OEBPS/art.jpg": "JPGDATA",
+ })
+ img, _, err := EPUBCover(r, int64(r.Len()))
+ if err != nil || string(img) != "JPGDATA" {
+ t.Fatalf("%q %v", img, err)
+ }
+}
+
+func TestEPUBNoCover(t *testing.T) {
+ r := zipEntries(t, map[string]string{
+ "META-INF/container.xml": containerXML,
+ "OEBPS/content.opf": opf(` `, ""),
+ })
+ if _, _, err := EPUBCover(r, int64(r.Len())); err == nil {
+ t.Fatal("want no-cover error")
+ }
+}