textmachine/backend/internal/bookfile/container.go

77 lines
3.1 KiB
Go

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 := `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="` + xmlText(rootfile) + `" media-type="application/oebps-package+xml"/>
</rootfiles>
</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
}