// Package ebooks parses EPUB files and serves ebook-specific metadata. // // EPUB is a zip with an OPF package document describing metadata, a manifest, // and a spine (reading order). Parsing is done on demand from the file itself; // nothing here depends on a network service. package ebooks import ( "archive/zip" "encoding/xml" "fmt" "io" "path" "strings" ) // Metadata is the subset of EPUB metadata Vellum uses. type Metadata struct { Title string `json:"title"` Authors []string `json:"authors"` Language string `json:"language"` Identifier string `json:"identifier"` Publisher string `json:"publisher"` Date string `json:"date"` Description string `json:"description"` SpineLength int `json:"spine_length"` // CoverHref is the cover image path relative to the OPF directory. CoverHref string `json:"-"` } // containerXML is META-INF/container.xml. type containerXML struct { Rootfiles []struct { FullPath string `xml:"full-path,attr"` } `xml:"rootfiles>rootfile"` } // opfXML is the package document. Field tags match by local name, so // namespaced elements (dc:title, dc:creator) decode without prefix handling. type opfXML struct { Metadata struct { Titles []string `xml:"title"` Creators []string `xml:"creator"` Language string `xml:"language"` Identifier string `xml:"identifier"` Publisher string `xml:"publisher"` Date string `xml:"date"` Description string `xml:"description"` Metas []struct { Name string `xml:"name,attr"` Content string `xml:"content,attr"` Property string `xml:"property,attr"` Value string `xml:",chardata"` } `xml:"meta"` } `xml:"metadata"` Manifest struct { Items []struct { ID string `xml:"id,attr"` Href string `xml:"href,attr"` MediaType string `xml:"media-type,attr"` Properties string `xml:"properties,attr"` } `xml:"item"` } `xml:"manifest"` Spine struct { Itemrefs []struct { IDRef string `xml:"idref,attr"` } `xml:"itemref"` } `xml:"spine"` } // Parse reads an EPUB's metadata and cover location. func Parse(epubPath string) (Metadata, error) { zr, err := zip.OpenReader(epubPath) if err != nil { return Metadata{}, fmt.Errorf("ebooks: open epub: %w", err) } defer zr.Close() opfPath, err := findOPFPath(zr) if err != nil { return Metadata{}, err } data, err := readZipFile(zr, opfPath) if err != nil { return Metadata{}, fmt.Errorf("ebooks: read opf %s: %w", opfPath, err) } var opf opfXML if err := xml.Unmarshal(data, &opf); err != nil { return Metadata{}, fmt.Errorf("ebooks: parse opf: %w", err) } md := Metadata{ Language: strings.TrimSpace(opf.Metadata.Language), Identifier: strings.TrimSpace(opf.Metadata.Identifier), Publisher: strings.TrimSpace(opf.Metadata.Publisher), Date: strings.TrimSpace(opf.Metadata.Date), Description: strings.TrimSpace(opf.Metadata.Description), } for _, t := range opf.Metadata.Titles { if s := strings.TrimSpace(t); s != "" { md.Title = s break } } for _, c := range opf.Metadata.Creators { if s := strings.TrimSpace(c); s != "" { md.Authors = append(md.Authors, s) } } md.SpineLength = len(opf.Spine.Itemrefs) md.CoverHref = coverHref(opf) // Hrefs in the OPF are relative to the OPF's directory. md.CoverHref = joinOPF(path.Dir(opfPath), md.CoverHref) return md, nil } // findOPFPath locates the package document via META-INF/container.xml. func findOPFPath(zr *zip.ReadCloser) (string, error) { data, err := readZipFile(zr, "META-INF/container.xml") if err != nil { return "", fmt.Errorf("ebooks: read container.xml: %w", err) } var c containerXML if err := xml.Unmarshal(data, &c); err != nil { return "", fmt.Errorf("ebooks: parse container.xml: %w", err) } for _, rf := range c.Rootfiles { if p := strings.TrimSpace(rf.FullPath); p != "" { return p, nil } } return "", fmt.Errorf("ebooks: container.xml has no rootfile") } // coverHref picks the cover image from the manifest, trying in order: // the EPUB3 properties="cover-image" marker, the EPUB2 // reference, then any manifest image with "cover" in its id or href. func coverHref(opf opfXML) string { byID := make(map[string]string, len(opf.Manifest.Items)) for _, it := range opf.Manifest.Items { byID[it.ID] = it.Href } for _, it := range opf.Manifest.Items { if hasProp(it.Properties, "cover-image") { return it.Href } } for _, m := range opf.Metadata.Metas { if strings.EqualFold(strings.TrimSpace(m.Name), "cover") { if href, ok := byID[strings.TrimSpace(m.Content)]; ok { return href } } } for _, it := range opf.Manifest.Items { if !strings.HasPrefix(it.MediaType, "image/") { continue } if strings.Contains(strings.ToLower(it.ID), "cover") || strings.Contains(strings.ToLower(it.Href), "cover") { return it.Href } } return "" } func hasProp(props, want string) bool { for _, p := range strings.Fields(props) { if p == want { return true } } return false } // joinOPF joins an OPF-relative href onto the OPF directory, using forward // slashes (zip convention) and refusing to escape the archive root. func joinOPF(dir, href string) string { if href == "" { return "" } joined := path.Clean(path.Join(dir, href)) if strings.HasPrefix(joined, "../") || joined == ".." { return "" } return joined } // readZipFile reads one entry fully. func readZipFile(zr *zip.ReadCloser, name string) ([]byte, error) { f, err := zr.Open(name) if err != nil { return nil, err } defer f.Close() return io.ReadAll(io.LimitReader(f, 16<<20)) // 16 MiB is generous for an OPF } // OpenEntry opens one entry of the archive for streaming. func OpenEntry(epubPath, name string) (io.ReadCloser, *zip.File, error) { zr, err := zip.OpenReader(epubPath) if err != nil { return nil, nil, err } f, err := zr.Open(name) if err != nil { zr.Close() return nil, nil, err } // Wrap so closing the reader also closes the archive. return &entryReadCloser{rc: f, zr: zr}, findZipFile(zr, name), nil } func findZipFile(zr *zip.ReadCloser, name string) *zip.File { for _, f := range zr.File { if f.Name == name { return f } } return nil } type entryReadCloser struct { rc io.ReadCloser zr *zip.ReadCloser } func (e *entryReadCloser) Read(p []byte) (int, error) { return e.rc.Read(p) } func (e *entryReadCloser) Close() error { err := e.rc.Close() _ = e.zr.Close() return err }