141 lines
5.3 KiB
Go
141 lines
5.3 KiB
Go
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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="` + lang + `">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:identifier id="pub-id">` + xmlText(b.Identifier) + `</dc:identifier>
|
|
<dc:title>` + xmlText(strings.TrimSpace(b.Title)) + `</dc:title>
|
|
<dc:language>` + lang + `</dc:language>
|
|
<meta property="dcterms:modified">` + b.Modified.UTC().Format(modifiedLayout) + `</meta>
|
|
`)
|
|
if b.Description != "" {
|
|
sb.WriteString(` <dc:description>` + xmlText(b.Description) + `</dc:description>` + "\n")
|
|
}
|
|
sb.WriteString(` </metadata>
|
|
<manifest>
|
|
<item id="nav" href="` + navEntry + `" media-type="application/xhtml+xml" properties="nav"/>
|
|
`)
|
|
for i := range b.Chapters {
|
|
sb.WriteString(` <item id="` + chapterID(i) + `" href="` + chapterEntry(i) + `" media-type="application/xhtml+xml"/>` + "\n")
|
|
}
|
|
sb.WriteString(" </manifest>\n <spine>\n")
|
|
for i := range b.Chapters {
|
|
sb.WriteString(` <itemref idref="` + chapterID(i) + `"/>` + "\n")
|
|
}
|
|
sb.WriteString(" </spine>\n</package>\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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="` + lang + `" lang="` + lang + `">
|
|
<head>
|
|
<title>` + xmlText(strings.TrimSpace(b.Title)) + `</title>
|
|
</head>
|
|
<body>
|
|
<nav epub:type="toc">
|
|
<h1>` + xmlText(strings.TrimSpace(b.Title)) + `</h1>
|
|
<ol>
|
|
`)
|
|
for i, ch := range b.Chapters {
|
|
sb.WriteString(` <li><a href="` + chapterEntry(i) + `">` + xmlText(ch.Title) + `</a></li>` + "\n")
|
|
}
|
|
sb.WriteString(" </ol>\n </nav>\n</body>\n</html>\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// chapterDocument renders chapter i as an XHTML content document: the blocks of Blocks(i), the title as
|
|
// <h1> and every other block as <p>. 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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="` + lang + `" lang="` + lang + `">
|
|
<head>
|
|
<title>` + xmlText(ch.Title) + `</title>
|
|
</head>
|
|
<body>
|
|
`)
|
|
if i == 0 {
|
|
for _, p := range b.Notice {
|
|
sb.WriteString(" <p>" + xmlText(p) + "</p>\n")
|
|
}
|
|
}
|
|
sb.WriteString(" <h1>" + xmlText(ch.Title) + "</h1>\n")
|
|
for _, p := range ch.Paragraphs {
|
|
sb.WriteString(" <p>" + xmlText(p) + "</p>\n")
|
|
}
|
|
sb.WriteString("</body>\n</html>\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) }
|