textmachine/backend/internal/bookfile/model.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 — before the reader learned to fold an uncovered leading document into chapter one), 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("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
default:
b.WriteRune(r)
}
}
return b.String()
}