// 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"
"unicode"
"unicode/utf8"
)
// 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
// ItalicOpen / ItalicClose are the marker glyphs the chapter text carries around an emphasised run —
// the pair's own (lang.InlineMarkup), as the ingest wrote them and the model gave them back. Empty
// means the text carries no markers and every writer below is byte-identical to one that never knew
// about them.
//
// ⛔ BOTH FORMATS ANSWER FOR THEM, but they do not answer equally, and the asymmetry is worth knowing
// before trusting a green run: the EPUB turns the pair into , while the text file re-spells it as
// *…* — and for a pair whose glyphs ARE the asterisk that second mapping is a byte no-op. So a
// mis-paired marker is visible in the EPUB and invisible in the .txt, and the shipping pair is exactly
// that case. A marker forgotten by one writer reaches a READER as punctuation, after the run and after
// the money, in the file they open.
ItalicOpen, ItalicClose string
}
// emphasisSpans walks s and yields its emphasised runs, pairing an opening marker with the next closing
// one. An UNPAIRED marker is left as literal text and stops the walk: the text came back from a model,
// and a model that wrote one marker too many must not be able to produce an unbalanced — an EPUB
// document that does not parse is a book nobody can open, which is a worse failure than a stray asterisk.
func (b *Book) emphasisSpans(s string, emit func(text string, italic bool)) {
open, closeMark := b.ItalicOpen, b.ItalicClose
if open == "" || closeMark == "" {
emit(s, false)
return
}
// ⛔ WITH ONE GLYPH ON BOTH SIDES, POSITION IS THE ONLY WAY TO TELL AN OPENER FROM A CLOSER, and
// pairing left to right without asking is how a paragraph comes out inverted: one marker lost by the
// model turns `сноску* и сказал *важно*` into emphasis on « и сказал » — the reader's book then
// emphasises words the model did not, and drops the ones it did, in a file that parses perfectly.
//
// The test is the ONE thing the ingest guarantees BY CONSTRUCTION (inlineRules.wrap trims the run, then
// puts the markers against the text): the INNER side of a marker is never whitespace. An opener is
// therefore followed by a non-space and a closer preceded by one; a glyph that fails its side is the
// author's own punctuation or a marker the model lost, and it stays literal.
//
// ⚠ WHAT THIS DELIBERATELY DOES NOT TEST, because the first version of this rule did and it cost 68 of
// this book's 4842 runs: the OUTER side. «An opener never follows a letter» reads plausible and is
// false — italics land on a full stop (`all*.*`), on a comma, inside a word (`Fucks*sakes*`) — and
// rejecting those openers made their closers open instead, inverting everything after them.
//
// ⚠ AND WHAT IT CANNOT DO: tell the author's asterisk from ours when both sides happen to be text
// (`5*5=25 и 3*3=9`). That is answered by MEASUREMENT, not by cleverness — the ingest counts the pair's
// glyphs in the source and prints them as `source_marker_glyphs` (0 on this book), and a pair whose
// books carry the glyph should choose another one.
same := open == closeMark
opensHere := func(s string, i int) bool {
if !same {
return true
}
rest := s[i+len(open):]
if rest == "" {
return false
}
r, _ := utf8.DecodeRuneInString(rest)
return !unicode.IsSpace(r)
}
closesHere := func(s string, i int) bool {
if !same || i == 0 {
return true
}
r, _ := utf8.DecodeLastRuneInString(s[:i])
return !unicode.IsSpace(r)
}
for {
i := strings.Index(s, open)
for i >= 0 && !opensHere(s, i) {
if next := strings.Index(s[i+len(open):], open); next >= 0 {
i += len(open) + next
} else {
i = -1
}
}
if i < 0 {
break
}
j := -1
for at := i + len(open); ; {
k := strings.Index(s[at:], closeMark)
if k < 0 {
break
}
if closesHere(s, at+k) {
j = at + k - (i + len(open))
break
}
at += k + len(closeMark)
}
if j < 0 {
break
}
emit(s[:i], false)
// An EMPTY span is not emphasis, and swallowing it loses what the model wrote: with one glyph on
// both sides `**word**` pairs into two empty spans and the word arrives unmarked and unmarkable.
// The glyphs go back as literal text, so the reader sees what came out of the model.
if j == 0 {
emit(open+closeMark, false)
} else {
emit(s[i+len(open):i+len(open)+j], true)
}
s = s[i+len(open)+j+len(closeMark):]
}
emit(s, false)
}
// 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() } // xmlEmphasis renders one text block for an EPUB document: XML-escaped, with the pair's emphasis markers // turned into elements. Escaping happens INSIDE the spans, so a marker can never smuggle markup — // the only tags in the output are the ones this function writes. func (b *Book) xmlEmphasis(s string) string { if b.ItalicOpen == "" || b.ItalicClose == "" { return xmlText(s) } var sb strings.Builder b.emphasisSpans(CleanText(s), func(text string, italic bool) { if text == "" { return } if italic { sb.WriteString("" + xmlText(text) + "") return } sb.WriteString(xmlText(text)) }) return sb.String() } // txtEmphasis renders one text block for the plain-text book: the pair's markers re-spelled as the // asterisk pair plain text has always used for emphasis. A pair whose glyphs ARE the asterisk gets its own // bytes back unchanged. // // ⚠ The alternative — dropping the markers — was rejected because it is the one choice that loses // something the reader could have had: plain text cannot show italics, but `*слово*` is legible as // emphasis to every reader of a .txt file, and an unmarked word is simply the author's distinction gone. func (b *Book) PlainEmphasis(s string) string { return b.txtEmphasis(s) } func (b *Book) txtEmphasis(s string) string { if b.ItalicOpen == "" || b.ItalicClose == "" { return CleanText(s) } var sb strings.Builder b.emphasisSpans(CleanText(s), func(text string, italic bool) { if text == "" { return } if italic { sb.WriteString("*" + text + "*") return } sb.WriteString(text) }) return sb.String() }