// 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

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

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() }