package bookfile
import (
"fmt"
"io"
"strings"
"time"
)
// epub.go: the EPUB 3 package. What the format REQUIRES and this writer supplies, in order: a package
// identifier (dc:identifier, referenced by unique-identifier), dc:title, dc:language, a
// dcterms:modified in the CCYY-MM-DDThh:mm:ssZ form, and a navigation document (the manifest item with
// properties="nav"). Each chapter is one XHTML content document in the spine.
//
// ⚠ The nav document is NOT in the spine, and there is no title page document either. The reader
// (internal/chunk/ingest.go) would now survive both — it excludes a navigation document on its
// properties="nav" and folds an uncovered leading document into chapter one — but this writer does not
// LEAN on those rules: keeping the spine free of non-chapter documents is a guarantee about what is
// written, not a bet on how it will be read, and it is what makes the loaded↔received circle close by
// construction. The book-level notice therefore rides inside the FIRST chapter document, ahead of its
// title (Book.Notice / Blocks).
const (
opfPath = "OEBPS/content.opf"
navEntry = "nav.xhtml"
// modifiedLayout is the ONLY form EPUB 3 admits for dcterms:modified (a UTC timestamp to the second).
modifiedLayout = "2006-01-02T15:04:05Z"
)
// chapterEntry names chapter i's document (0-based) inside OEBPS/. The name carries the dense chapter
// number; reading order is the spine's, not the name's.
func chapterEntry(i int) string { return fmt.Sprintf("ch%d.xhtml", i+1) }
// chapterID is chapter i's manifest id.
func chapterID(i int) string { return fmt.Sprintf("ch%d", i+1) }
// WriteEPUB writes b as an EPUB 3 file to w. It is deterministic: the same Book yields the same bytes.
func WriteEPUB(w io.Writer, b *Book) error {
if err := b.validate(); err != nil {
return err
}
c, err := NewContainer(w, opfPath)
if err != nil {
return err
}
if err := c.Add(opfPath, []byte(packageDocument(b))); err != nil {
return err
}
if err := c.Add("OEBPS/"+navEntry, []byte(navDocument(b))); err != nil {
return err
}
for i := range b.Chapters {
if err := c.Add("OEBPS/"+chapterEntry(i), []byte(chapterDocument(b, i))); err != nil {
return err
}
}
return c.Close()
}
// packageDocument renders the OPF.
func packageDocument(b *Book) string {
lang := xmlText(b.Language)
var sb strings.Builder
sb.WriteString(`
. One block = one block-level element, which is what the engine's // reader turns back into one paragraph. func chapterDocument(b *Book, i int) string { lang := xmlText(b.Language) ch := b.Chapters[i] var sb strings.Builder sb.WriteString(`
" + xmlText(p) + "
\n") } } sb.WriteString("" + xmlText(p) + "
\n") } sb.WriteString("\n\n") return sb.String() } // Modified renders t the way dcterms:modified is written, for a caller that wants to show the value it // is about to put in the file. func Modified(t time.Time) string { return t.UTC().Format(modifiedLayout) }