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(` ` + xmlText(b.Identifier) + ` ` + xmlText(strings.TrimSpace(b.Title)) + ` ` + lang + ` ` + b.Modified.UTC().Format(modifiedLayout) + ` `) if b.Description != "" { sb.WriteString(` ` + xmlText(b.Description) + `` + "\n") } sb.WriteString(` `) for i := range b.Chapters { sb.WriteString(` ` + "\n") } sb.WriteString(" \n \n") for i := range b.Chapters { sb.WriteString(` ` + "\n") } sb.WriteString(" \n\n") return sb.String() } // navDocument renders the EPUB 3 navigation document: a toc nav whose entries are the chapters, in spine // order, each linking to its document by the chapter's Title. func navDocument(b *Book) string { lang := xmlText(b.Language) var sb strings.Builder sb.WriteString(` ` + xmlText(strings.TrimSpace(b.Title)) + ` \n\n\n") return sb.String() } // chapterDocument renders chapter i as an XHTML content document: the blocks of Blocks(i), the title as //

and every other block as

. 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(ch.Title) + ` `) if i == 0 { for _, p := range b.Notice { sb.WriteString("

" + xmlText(p) + "

\n") } } sb.WriteString("

" + xmlText(ch.Title) + "

\n") for _, p := range ch.Paragraphs { 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) }