package bookfile import ( "archive/zip" "fmt" "hash/crc32" "io" ) // container.go: the OCF container — the zip layout every EPUB shares, independent of what the package // inside says. It is the one place the layout is spelled, for the shipping writer (epub.go) and for the // ingest test fixture (internal/chunk/chunktest) alike. // mimetypeBody is the fixed content of the OCF `mimetype` entry. const mimetypeBody = "application/epub+zip" // Container is an OCF container under construction. Entries land in the order they are added, with // ZERO timestamps (archive/zip leaves the MS-DOS date fields at zero and writes no extended-timestamp // extra field when FileHeader.Modified is unset), so the bytes depend on the entries alone — never on the // clock or on a filesystem — which is what makes a build reproducible across processes. type Container struct { zw *zip.Writer } // NewContainer starts an OCF container on w whose package document (the OPF) will be added at // rootfile — a path inside the zip such as "OEBPS/content.opf". It writes the two entries OCF fixes: // `mimetype` FIRST and STORED (uncompressed, so a reader can identify the file by its first bytes), // then META-INF/container.xml pointing at rootfile. The caller adds rootfile itself with Add. func NewContainer(w io.Writer, rootfile string) (*Container, error) { zw := zip.NewWriter(w) // CreateRaw with the CRC and sizes given up front: the local header then carries them itself and // no data descriptor follows the entry. OCF's letter is met either way, but a reader that // identifies the file by its first bytes wants the plain form, and the 20 bytes cost nothing. body := []byte(mimetypeBody) mw, err := zw.CreateRaw(&zip.FileHeader{ Name: "mimetype", Method: zip.Store, CRC32: crc32.ChecksumIEEE(body), CompressedSize64: uint64(len(body)), UncompressedSize64: uint64(len(body)), }) if err != nil { return nil, fmt.Errorf("bookfile: container mimetype: %w", err) } if _, err := mw.Write(body); err != nil { return nil, fmt.Errorf("bookfile: container mimetype: %w", err) } c := &Container{zw: zw} container := ` ` if err := c.Add("META-INF/container.xml", []byte(container)); err != nil { return nil, err } return c, nil } // Add writes one entry (deflated, zero timestamp) at name — a slash-separated path inside the zip. func (c *Container) Add(name string, data []byte) error { w, err := c.zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate}) if err != nil { return fmt.Errorf("bookfile: container entry %s: %w", name, err) } if _, err := w.Write(data); err != nil { return fmt.Errorf("bookfile: container entry %s: %w", name, err) } return nil } // Close finishes the zip (central directory). The container is complete only after it returns nil. func (c *Container) Close() error { if err := c.zw.Close(); err != nil { return fmt.Errorf("bookfile: close container: %w", err) } return nil }