316 lines
13 KiB
Go
316 lines
13 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"
|
|
"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 <i>, 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 <i> — 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 <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()
|
|
}
|
|
|
|
// xmlEmphasis renders one text block for an EPUB document: XML-escaped, with the pair's emphasis markers
|
|
// turned into <i> 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("<i>" + xmlText(text) + "</i>")
|
|
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()
|
|
}
|