package build import ( "archive/zip" "bytes" "encoding/xml" "fmt" "io" "strconv" "strings" "golang.org/x/net/html" "golang.org/x/net/html/atom" "git.monoexp.com/bajdev/rekopub/internal/extract" ) // chapterFile is the chapter's filename; go-epub stores it under xhtml/. const chapterFile = "chapter.xhtml" // Paths go-epub uses inside the book. finish fails if any is missing, so a // change in go-epub's layout shows up in tests rather than in broken books. const ( opfPath = "EPUB/package.opf" navPath = "EPUB/nav.xhtml" ncxPath = "EPUB/toc.ncx" ) // heading is a section heading listed in the table of contents. type heading struct { level int // 2 or 3 text string id string } // anchorHeadings gives every

and

in body an id, so the table of // contents can link to it, and returns them in order. The sanitizer removed // the page's own ids, so these can't clash. func anchorHeadings(body *html.Node) []heading { var hs []heading var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && (n.DataAtom == atom.H2 || n.DataAtom == atom.H3) { if text := strings.Join(strings.Fields(textOf(n)), " "); text != "" { id := "sec-" + strconv.Itoa(len(hs)+1) n.Attr = append(n.Attr, html.Attribute{Key: "id", Val: id}) level := 2 if n.DataAtom == atom.H3 { level = 3 } hs = append(hs, heading{level: level, text: text, id: id}) } return } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(body) return hs } // finish rewrites the files go-epub gets wrong or can't express: // // - the OPF gains dc:publisher (the site), dc:source (the URL) and // dc:date (publication date); // - the EPUB 3 nav document and the NCX list the article's sections. // go-epub can only list whole files, and its NCX has no dtb:uid, which // Kobo's RMSDK renderer reads for plain EPUBs. func finish(book []byte, id string, a *extract.Article, headings []heading) ([]byte, error) { return rewriteZip(book, map[string]func([]byte) []byte{ opfPath: func(opf []byte) []byte { return addMetadata(opf, a) }, navPath: func([]byte) []byte { return navDocument(a, headings) }, ncxPath: func([]byte) []byte { return ncxDocument(id, a, headings) }, }) } // rewriteZip copies the zip archive data, replacing the named entries with // the output of their edit functions. Other entries, including the // uncompressed mimetype that must come first, are copied byte for byte. func rewriteZip(data []byte, edits map[string]func([]byte) []byte) ([]byte, error) { zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, err } var out bytes.Buffer zw := zip.NewWriter(&out) done := map[string]bool{} for _, f := range zr.File { edit, ok := edits[f.Name] if !ok { if err := zw.Copy(f); err != nil { return nil, err } continue } rc, err := f.Open() if err != nil { return nil, err } old, err := io.ReadAll(rc) rc.Close() if err != nil { return nil, err } w, err := zw.CreateHeader(&zip.FileHeader{Name: f.Name, Method: zip.Deflate, Modified: f.Modified}) if err != nil { return nil, err } if _, err := w.Write(edit(old)); err != nil { return nil, err } done[f.Name] = true } for name := range edits { if !done[name] { return nil, fmt.Errorf("book is missing %s", name) } } if err := zw.Close(); err != nil { return nil, err } return out.Bytes(), nil } // addMetadata inserts publisher, source and date elements into the OPF // metadata. func addMetadata(opf []byte, a *extract.Article) []byte { var extra strings.Builder element := func(name, value string) { if value != "" { fmt.Fprintf(&extra, " <%s>%s\n", name, esc(value), name) } } element("dc:publisher", a.SiteName) element("dc:source", a.URL) if !a.Published.IsZero() { element("dc:date", a.Published.UTC().Format("2006-01-02")) } s := string(opf) marker := `` i := strings.Index(s, marker) if i < 0 { i = strings.Index(s, "") } if i < 0 { return opf } // Insert at the start of the marker's line, keeping the indentation. line := strings.LastIndex(s[:i], "\n") + 1 return []byte(s[:line] + extra.String() + s[line:]) } // tocEntry is a node in the table of contents tree. type tocEntry struct { text, href string children []*tocEntry } // toc returns the table of contents: the article, with its

sections // and their

subsections beneath it. Paths are relative to the EPUB // folder, where the nav document and NCX live. func toc(a *extract.Article, headings []heading) *tocEntry { href := "xhtml/" + chapterFile root := &tocEntry{text: a.Title, href: href} var section *tocEntry for _, h := range headings { e := &tocEntry{text: h.text, href: href + "#" + h.id} if h.level == 3 && section != nil { section.children = append(section.children, e) continue } root.children = append(root.children, e) if h.level == 2 { section = e } } return root } func navDocument(a *extract.Article, headings []heading) []byte { var b strings.Builder var list func(es []*tocEntry, indent string) list = func(es []*tocEntry, indent string) { b.WriteString(indent + "
    \n") for _, e := range es { fmt.Fprintf(&b, "%s
  1. %s", indent, esc(e.href), esc(e.text)) if len(e.children) > 0 { // an empty
      is invalid b.WriteString("\n") list(e.children, indent+" ") b.WriteString(indent + " ") } b.WriteString("\n") } b.WriteString(indent + "
    \n") } lang := esc(a.Language) fmt.Fprintf(&b, ` %s \n \n\n") return []byte(b.String()) } func ncxDocument(id string, a *extract.Article, headings []heading) []byte { root := toc(a, headings) depth := 1 for _, e := range root.children { depth = max(depth, 2) if len(e.children) > 0 { depth = 3 } } var b strings.Builder fmt.Fprintf(&b, ` %s `, esc(a.Language), esc(id), depth, esc(a.Title)) order := 0 var point func(e *tocEntry, indent string) point = func(e *tocEntry, indent string) { order++ fmt.Fprintf(&b, "%s\n", indent, order, order) fmt.Fprintf(&b, "%s %s\n", indent, esc(e.text)) fmt.Fprintf(&b, "%s \n", indent, esc(e.href)) for _, c := range e.children { point(c, indent+" ") } fmt.Fprintf(&b, "%s\n", indent) } point(root, " ") b.WriteString(" \n\n") return []byte(b.String()) } // esc escapes s for XML text and attribute values. func esc(s string) string { var b strings.Builder xml.EscapeText(&b, []byte(s)) return b.String() } func textOf(n *html.Node) string { var b strings.Builder var rec func(*html.Node) rec = func(n *html.Node) { if n.Type == html.TextNode { b.WriteString(n.Data) } for c := n.FirstChild; c != nil; c = c.NextSibling { rec(c) } } rec(n) return b.String() }