package bookfile
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"regexp"
"strings"
"testing"
"time"
)
// bookfile_test.go pins the writer's two load-bearing properties — determinism and a container the
// engine's own reader round-trips — and the EPUB 3 shape epubcheck was run against (the stand proof).
func sampleBook() *Book {
return &Book{
Identifier: "urn:textmachine:book:test",
Title: "Тест & <проба>",
Language: "ru",
Modified: time.Date(2026, 8, 24, 1, 15, 0, 0, time.UTC),
Chapters: []Chapter{
{Title: "Глава 1", Paragraphs: []string{"Первый абзац.", "Второй — с «кавычками» & <тегом>."}},
{Title: "2", Paragraphs: []string{"Одна строка."}},
},
}
}
func epubBytes(t *testing.T, b *Book) []byte {
t.Helper()
var buf bytes.Buffer
if err := WriteEPUB(&buf, b); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func zipEntries(t *testing.T, data []byte) (*zip.Reader, map[string]string) {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(rc)
rc.Close()
out[f.Name] = string(b)
}
return zr, out
}
func TestContainerIsOCFConformant(t *testing.T) {
data := epubBytes(t, sampleBook())
zr, entries := zipEntries(t, data)
// The mimetype's LOCAL header carries its CRC and sizes itself: general-purpose flag bit 3 (a data
// descriptor after the entry) is clear, and the extra-field length is zero — the plain form a reader
// that identifies the file by its first bytes expects. Bytes 6–7 are the flags, 28–29 the extra length.
if flags := uint16(data[6]) | uint16(data[7])<<8; flags&0x8 != 0 {
t.Errorf("mimetype local header has the data-descriptor flag set (flags=%#x)", flags)
}
if extra := uint16(data[28]) | uint16(data[29])<<8; extra != 0 {
t.Errorf("mimetype local header carries an extra field of %d bytes", extra)
}
if string(data[30:38]) != "mimetype" || string(data[38:58]) != "application/epub+zip" {
t.Errorf("the mimetype entry is not at the fixed OCF offsets: %q", data[30:58])
}
if zr.File[0].Name != "mimetype" || zr.File[0].Method != zip.Store {
t.Fatalf("mimetype must be the first, STORED entry; got %q method %d", zr.File[0].Name, zr.File[0].Method)
}
if entries["mimetype"] != "application/epub+zip" {
t.Fatalf("mimetype = %q", entries["mimetype"])
}
if !strings.Contains(entries["META-INF/container.xml"], `full-path="OEBPS/content.opf"`) {
t.Fatalf("container.xml must point at the OPF:\n%s", entries["META-INF/container.xml"])
}
for _, f := range zr.File {
if !f.Modified.IsZero() && f.Modified.Unix() != -2208988800 { // zero MS-DOS time decodes to 1980-00-00 → 1979/1980 epochs vary; a real clock reading is what is forbidden
if f.Modified.Year() > 1981 {
t.Errorf("entry %s carries a clock timestamp %v — the file would differ between builds", f.Name, f.Modified)
}
}
}
}
type opfDoc struct {
UniqueID string `xml:"unique-identifier,attr"`
Metadata struct {
Identifier struct {
ID string `xml:"id,attr"`
Value string `xml:",chardata"`
} `xml:"identifier"`
Title string `xml:"title"`
Language string `xml:"language"`
Description string `xml:"description"`
Meta []struct {
Property string `xml:"property,attr"`
Value string `xml:",chardata"`
} `xml:"meta"`
} `xml:"metadata"`
Manifest []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
Properties string `xml:"properties,attr"`
} `xml:"manifest>item"`
Spine []struct {
IDRef string `xml:"idref,attr"`
} `xml:"spine>itemref"`
}
func parseOPF(t *testing.T, entries map[string]string) opfDoc {
t.Helper()
var doc opfDoc
if err := xml.Unmarshal([]byte(entries["OEBPS/content.opf"]), &doc); err != nil {
t.Fatalf("OPF does not parse: %v\n%s", err, entries["OEBPS/content.opf"])
}
return doc
}
func TestEPUBCarriesTheFiveRequiredThingsAndKeepsNavOutOfTheSpine(t *testing.T) {
b := sampleBook()
b.Description = "⚠ 1/2"
_, entries := zipEntries(t, epubBytes(t, b))
doc := parseOPF(t, entries)
if doc.Metadata.Identifier.Value != b.Identifier || doc.Metadata.Identifier.ID != doc.UniqueID {
t.Errorf("dc:identifier %+v must be the one unique-identifier=%q names", doc.Metadata.Identifier, doc.UniqueID)
}
if doc.Metadata.Title != b.Title {
t.Errorf("dc:title = %q, want %q (escaped in the file, intact when parsed)", doc.Metadata.Title, b.Title)
}
padded := sampleBook()
padded.Title = " T "
if _, e := zipEntries(t, epubBytes(t, padded)); !strings.Contains(e["OEBPS/content.opf"], "
of a chapter document, in order — what the engine's reader
// turns into paragraphs.
var blockRE = regexp.MustCompile(`(?s)<(h1|p)>(.*?)(?:h1|p)>`)
func blocksOf(doc string) []string {
var out []string
for _, m := range blockRE.FindAllStringSubmatch(doc, -1) {
out = append(out, m[2])
}
return out
}
func TestChapterDocumentsCarryExactlyBlocks(t *testing.T) {
b := sampleBook()
b.Notice = []string{"⚠ 1/2", "⚠ +3"}
_, entries := zipEntries(t, epubBytes(t, b))
for i := range b.Chapters {
got := blocksOf(entries["OEBPS/"+chapterEntry(i)])
want := b.Blocks(i)
if len(got) != len(want) {
t.Fatalf("chapter %d: %d blocks in the document, Blocks() says %d\n%v\n%v", i+1, len(got), len(want), got, want)
}
for j := range want {
if got[j] != xmlText(want[j]) {
t.Errorf("chapter %d block %d = %q, want %q", i+1, j, got[j], xmlText(want[j]))
}
}
}
// The notice is in the FIRST document only, ahead of its title.
if first := blocksOf(entries["OEBPS/ch1.xhtml"]); first[0] != "⚠ 1/2" || first[2] != xmlText("Глава 1") {
t.Errorf("first document must open with the notice, then the title: %v", first)
}
if second := blocksOf(entries["OEBPS/ch2.xhtml"]); second[0] != "2" {
t.Errorf("second document must open with its title, no notice: %v", second)
}
}
func TestEPUBAndTXTAreDeterministic(t *testing.T) {
b := sampleBook()
first, second := epubBytes(t, b), epubBytes(t, b)
if !bytes.Equal(first, second) {
t.Fatal("two EPUB builds of one Book differ")
}
var t1, t2 bytes.Buffer
if err := WriteTXT(&t1, b); err != nil {
t.Fatal(err)
}
if err := WriteTXT(&t2, b); err != nil {
t.Fatal(err)
}
if !bytes.Equal(t1.Bytes(), t2.Bytes()) {
t.Fatal("two TXT builds of one Book differ")
}
}
func TestTextIsEscapedAndControlCharactersAreDropped(t *testing.T) {
b := sampleBook()
b.Chapters[0].Paragraphs = []string{"a < b & c > d \"q\"", "bad\x00\x0bchars\x7f kept"}
if got := CleanText("bad\x00\x0bchars\x7f kept\xff"); got != "badchars\x7f kept\uFFFD" {
t.Errorf("CleanText = %q", got)
}
_, entries := zipEntries(t, epubBytes(t, b))
doc := entries["OEBPS/ch1.xhtml"]
for _, want := range []string{"a < b & c > d "q"", "badchars\x7f kept"} {
if !strings.Contains(doc, want) {
t.Errorf("document lacks %q:\n%s", want, doc)
}
}
if strings.ContainsAny(doc, "\x00\x0b") {
t.Error("an XML-illegal control character reached the document")
}
if !strings.Contains(entries["OEBPS/content.opf"], "