165 lines
6.8 KiB
Go
165 lines
6.8 KiB
Go
// Package bookfile writes a translated book as a READER's file — the artifact the engine hands out — from
|
|
// a neutral document model: EPUB 3 for a reading app (epub.go) and plain text (txt.go), both from the same
|
|
// Book. It is the engine's only writer of a book, and it is deliberately language- and pair-blind: every
|
|
// word a reader sees (a chapter title, a hole marker, the not-whole notice) arrives in the model as DATA
|
|
// decided upstream; nothing here knows what a chapter is called in any language (12-go-style-notes §0).
|
|
//
|
|
// Two properties are load-bearing and proven by tests rather than promised:
|
|
// - DETERMINISM: the same Book yields byte-identical files across processes. Nothing reads a clock or
|
|
// a filesystem timestamp; the zip carries zero timestamps; entries are written in a fixed order.
|
|
// - ROUND TRIP: the engine's own ingest (internal/chunk) reads an EPUB written here back into exactly
|
|
// the blocks Blocks() lays down, chapter by chapter — which is what closes the "loaded → received"
|
|
// circle the reader path was built on.
|
|
//
|
|
// The OCF container half (container.go) is ALSO what the ingest test fixture builds on
|
|
// (internal/chunk/chunktest): one zip/OCF builder, two callers, so the fixture the reader is pinned
|
|
// against and the file the writer ships cannot drift apart in the container layer.
|
|
package bookfile
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Formats are the file formats this package writes, in the order a build produces them. A format name
|
|
// is also the file extension.
|
|
var Formats = []string{"epub", "txt"}
|
|
|
|
// KnownFormat reports whether name is one of Formats.
|
|
func KnownFormat(name string) bool {
|
|
for _, f := range Formats {
|
|
if f == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Book is the document model both writers render.
|
|
type Book struct {
|
|
// Identifier is the package identifier (dc:identifier, the OPF's unique-identifier). It is derived
|
|
// from the book's id by the caller; never minted from a clock or a random source.
|
|
Identifier string
|
|
// Title is dc:title, spelled exactly as the book config spells it. It may be the source-language
|
|
// display name — the writer has no translation of it and invents none.
|
|
Title string
|
|
// Language is dc:language and the documents' xml:lang: the TARGET language tag, from the book's data.
|
|
Language string
|
|
// Modified is dcterms:modified. It is a value from the run's data, never the wall clock, so that two
|
|
// builds of one store are byte-identical (a zero value is rejected: a missing fact must be decided
|
|
// upstream, not silently rendered as year one).
|
|
Modified time.Time
|
|
// Description is dc:description; "" omits the element. The assembler puts the not-whole notice here
|
|
// so a library shelf that shows descriptions shows it too.
|
|
Description string
|
|
// Notice are the book-level paragraphs a reader sees on OPENING the book — before the first chapter's
|
|
// title, inside the first spine document (a separate title document would be read by ingest as a
|
|
// chapter of its own), and at the head of the text file. Empty for a book with nothing to declare.
|
|
Notice []string
|
|
// Chapters in reading order; at least one (an EPUB with an empty spine is not an EPUB).
|
|
Chapters []Chapter
|
|
}
|
|
|
|
// Chapter is one spine document.
|
|
type Chapter struct {
|
|
// Title is the chapter's heading: the <h1> of its document, the text of its nav entry, its heading
|
|
// line in the text file. Required — an empty nav link is an invalid EPUB — and decided upstream
|
|
// (the engine's deterministic heading, or the bare chapter number when there is none).
|
|
Title string
|
|
// Paragraphs are the chapter's text blocks in order, one <p> each. A hole marker is an ordinary
|
|
// paragraph here: the model carries what the assembler decided to say, and nothing about why.
|
|
Paragraphs []string
|
|
}
|
|
|
|
// Blocks returns chapter i's text blocks in the order its document carries them: the book Notice (first
|
|
// chapter only), the Title, then the Paragraphs. It is the ONE definition the EPUB writer emits from and
|
|
// the round-trip proof compares against — what ingest reads back as paragraphs is exactly this list.
|
|
func (b *Book) Blocks(i int) []string {
|
|
ch := b.Chapters[i]
|
|
out := make([]string, 0, len(b.Notice)+1+len(ch.Paragraphs))
|
|
if i == 0 {
|
|
out = append(out, b.Notice...)
|
|
}
|
|
out = append(out, ch.Title)
|
|
out = append(out, ch.Paragraphs...)
|
|
return out
|
|
}
|
|
|
|
// validate refuses a Book the writers could only render into an invalid file. Every failure is the
|
|
// caller's to fix upstream; the writers never paper over one. Titles are judged AFTER CleanText — the
|
|
// form they reach the file in: a title of nothing but control characters renders as an empty dc:title
|
|
// or an empty nav link, and both are invalid EPUB.
|
|
func (b *Book) validate() error {
|
|
switch {
|
|
case b.Identifier == "":
|
|
return errors.New("bookfile: the book has no identifier")
|
|
case strings.TrimSpace(CleanText(b.Title)) == "":
|
|
return errors.New("bookfile: the book has no title")
|
|
case strings.TrimSpace(b.Language) == "":
|
|
return errors.New("bookfile: the book has no language")
|
|
case b.Modified.IsZero():
|
|
return errors.New("bookfile: the book has no modification time (it must come from the run's data, not be left unset)")
|
|
case len(b.Chapters) == 0:
|
|
return errors.New("bookfile: the book has no chapters (an empty spine is not an EPUB)")
|
|
}
|
|
for i, ch := range b.Chapters {
|
|
if strings.TrimSpace(CleanText(ch.Title)) == "" {
|
|
return fmt.Errorf("bookfile: chapter %d has no title (an empty nav entry is an invalid EPUB)", i+1)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanText makes s carriable by BOTH formats: invalid UTF-8 replaced, and the code points XML 1.0
|
|
// forbids (C0 controls other than tab/newline/return, U+FFFE/U+FFFF) dropped — a control character that
|
|
// reached the export text would make an XHTML chapter unparseable to every reader, and is junk in a text
|
|
// file too. The assembler applies it to every paragraph once, so the EPUB and the text file say the same.
|
|
func CleanText(s string) string {
|
|
s = strings.ToValidUTF8(s, "\uFFFD")
|
|
if !strings.ContainsFunc(s, isXMLIllegal) {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
if !isXMLIllegal(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isXMLIllegal(r rune) bool {
|
|
switch {
|
|
case r == '\t' || r == '\n' || r == '\r':
|
|
return false
|
|
case r < 0x20, r == 0xFFFE, r == 0xFFFF:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// xmlText renders s as XML character data: CleanText, then the three markup characters escaped, plus the
|
|
// quote so the same function serves attribute values.
|
|
func xmlText(s string) string {
|
|
s = CleanText(s)
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
switch r {
|
|
case '&':
|
|
b.WriteString("&")
|
|
case '<':
|
|
b.WriteString("<")
|
|
case '>':
|
|
b.WriteString(">")
|
|
case '"':
|
|
b.WriteString(""")
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|