textmachine/backend/internal/bookfile/epub.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) }