1
0

finish.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. package build
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "encoding/xml"
  6. "fmt"
  7. "io"
  8. "strconv"
  9. "strings"
  10. "golang.org/x/net/html"
  11. "golang.org/x/net/html/atom"
  12. "git.monoexp.com/bajdev/rekopub/internal/extract"
  13. )
  14. // chapterFile is the chapter's filename; go-epub stores it under xhtml/.
  15. const chapterFile = "chapter.xhtml"
  16. // Paths go-epub uses inside the book. finish fails if any is missing, so a
  17. // change in go-epub's layout shows up in tests rather than in broken books.
  18. const (
  19. opfPath = "EPUB/package.opf"
  20. navPath = "EPUB/nav.xhtml"
  21. ncxPath = "EPUB/toc.ncx"
  22. )
  23. // heading is a section heading listed in the table of contents.
  24. type heading struct {
  25. level int // 2 or 3
  26. text string
  27. id string
  28. }
  29. // anchorHeadings gives every <h2> and <h3> in body an id, so the table of
  30. // contents can link to it, and returns them in order. The sanitizer removed
  31. // the page's own ids, so these can't clash.
  32. func anchorHeadings(body *html.Node) []heading {
  33. var hs []heading
  34. var walk func(*html.Node)
  35. walk = func(n *html.Node) {
  36. if n.Type == html.ElementNode && (n.DataAtom == atom.H2 || n.DataAtom == atom.H3) {
  37. if text := strings.Join(strings.Fields(textOf(n)), " "); text != "" {
  38. id := "sec-" + strconv.Itoa(len(hs)+1)
  39. n.Attr = append(n.Attr, html.Attribute{Key: "id", Val: id})
  40. level := 2
  41. if n.DataAtom == atom.H3 {
  42. level = 3
  43. }
  44. hs = append(hs, heading{level: level, text: text, id: id})
  45. }
  46. return
  47. }
  48. for c := n.FirstChild; c != nil; c = c.NextSibling {
  49. walk(c)
  50. }
  51. }
  52. walk(body)
  53. return hs
  54. }
  55. // finish rewrites the files go-epub gets wrong or can't express:
  56. //
  57. // - the OPF gains dc:publisher (the site), dc:source (the URL) and
  58. // dc:date (publication date);
  59. // - the EPUB 3 nav document and the NCX list the article's sections.
  60. // go-epub can only list whole files, and its NCX has no dtb:uid, which
  61. // Kobo's RMSDK renderer reads for plain EPUBs.
  62. func finish(book []byte, id string, a *extract.Article, headings []heading) ([]byte, error) {
  63. return rewriteZip(book, map[string]func([]byte) []byte{
  64. opfPath: func(opf []byte) []byte { return addMetadata(opf, a) },
  65. navPath: func([]byte) []byte { return navDocument(a, headings) },
  66. ncxPath: func([]byte) []byte { return ncxDocument(id, a, headings) },
  67. })
  68. }
  69. // rewriteZip copies the zip archive data, replacing the named entries with
  70. // the output of their edit functions. Other entries, including the
  71. // uncompressed mimetype that must come first, are copied byte for byte.
  72. func rewriteZip(data []byte, edits map[string]func([]byte) []byte) ([]byte, error) {
  73. zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  74. if err != nil {
  75. return nil, err
  76. }
  77. var out bytes.Buffer
  78. zw := zip.NewWriter(&out)
  79. done := map[string]bool{}
  80. for _, f := range zr.File {
  81. edit, ok := edits[f.Name]
  82. if !ok {
  83. if err := zw.Copy(f); err != nil {
  84. return nil, err
  85. }
  86. continue
  87. }
  88. rc, err := f.Open()
  89. if err != nil {
  90. return nil, err
  91. }
  92. old, err := io.ReadAll(rc)
  93. rc.Close()
  94. if err != nil {
  95. return nil, err
  96. }
  97. w, err := zw.CreateHeader(&zip.FileHeader{Name: f.Name, Method: zip.Deflate, Modified: f.Modified})
  98. if err != nil {
  99. return nil, err
  100. }
  101. if _, err := w.Write(edit(old)); err != nil {
  102. return nil, err
  103. }
  104. done[f.Name] = true
  105. }
  106. for name := range edits {
  107. if !done[name] {
  108. return nil, fmt.Errorf("book is missing %s", name)
  109. }
  110. }
  111. if err := zw.Close(); err != nil {
  112. return nil, err
  113. }
  114. return out.Bytes(), nil
  115. }
  116. // addMetadata inserts publisher, source and date elements into the OPF
  117. // metadata.
  118. func addMetadata(opf []byte, a *extract.Article) []byte {
  119. var extra strings.Builder
  120. element := func(name, value string) {
  121. if value != "" {
  122. fmt.Fprintf(&extra, " <%s>%s</%s>\n", name, esc(value), name)
  123. }
  124. }
  125. element("dc:publisher", a.SiteName)
  126. element("dc:source", a.URL)
  127. if !a.Published.IsZero() {
  128. element("dc:date", a.Published.UTC().Format("2006-01-02"))
  129. }
  130. s := string(opf)
  131. marker := `<meta property="dcterms:modified">`
  132. i := strings.Index(s, marker)
  133. if i < 0 {
  134. i = strings.Index(s, "</metadata>")
  135. }
  136. if i < 0 {
  137. return opf
  138. }
  139. // Insert at the start of the marker's line, keeping the indentation.
  140. line := strings.LastIndex(s[:i], "\n") + 1
  141. return []byte(s[:line] + extra.String() + s[line:])
  142. }
  143. // tocEntry is a node in the table of contents tree.
  144. type tocEntry struct {
  145. text, href string
  146. children []*tocEntry
  147. }
  148. // toc returns the table of contents: the article, with its <h2> sections
  149. // and their <h3> subsections beneath it. Paths are relative to the EPUB
  150. // folder, where the nav document and NCX live.
  151. func toc(a *extract.Article, headings []heading) *tocEntry {
  152. href := "xhtml/" + chapterFile
  153. root := &tocEntry{text: a.Title, href: href}
  154. var section *tocEntry
  155. for _, h := range headings {
  156. e := &tocEntry{text: h.text, href: href + "#" + h.id}
  157. if h.level == 3 && section != nil {
  158. section.children = append(section.children, e)
  159. continue
  160. }
  161. root.children = append(root.children, e)
  162. if h.level == 2 {
  163. section = e
  164. }
  165. }
  166. return root
  167. }
  168. func navDocument(a *extract.Article, headings []heading) []byte {
  169. var b strings.Builder
  170. var list func(es []*tocEntry, indent string)
  171. list = func(es []*tocEntry, indent string) {
  172. b.WriteString(indent + "<ol>\n")
  173. for _, e := range es {
  174. fmt.Fprintf(&b, "%s <li><a href=\"%s\">%s</a>", indent, esc(e.href), esc(e.text))
  175. if len(e.children) > 0 { // an empty <ol> is invalid
  176. b.WriteString("\n")
  177. list(e.children, indent+" ")
  178. b.WriteString(indent + " ")
  179. }
  180. b.WriteString("</li>\n")
  181. }
  182. b.WriteString(indent + "</ol>\n")
  183. }
  184. lang := esc(a.Language)
  185. fmt.Fprintf(&b, `<?xml version="1.0" encoding="UTF-8"?>
  186. <!DOCTYPE html>
  187. <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="%s" xml:lang="%s">
  188. <head>
  189. <title>%s</title>
  190. </head>
  191. <body>
  192. <nav epub:type="toc" id="toc">
  193. <h1>Contents</h1>
  194. `, lang, lang, esc(a.Title))
  195. list([]*tocEntry{toc(a, headings)}, " ")
  196. b.WriteString(" </nav>\n </body>\n</html>\n")
  197. return []byte(b.String())
  198. }
  199. func ncxDocument(id string, a *extract.Article, headings []heading) []byte {
  200. root := toc(a, headings)
  201. depth := 1
  202. for _, e := range root.children {
  203. depth = max(depth, 2)
  204. if len(e.children) > 0 {
  205. depth = 3
  206. }
  207. }
  208. var b strings.Builder
  209. fmt.Fprintf(&b, `<?xml version="1.0" encoding="UTF-8"?>
  210. <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="%s">
  211. <head>
  212. <meta name="dtb:uid" content="%s"/>
  213. <meta name="dtb:depth" content="%d"/>
  214. <meta name="dtb:totalPageCount" content="0"/>
  215. <meta name="dtb:maxPageNumber" content="0"/>
  216. </head>
  217. <docTitle>
  218. <text>%s</text>
  219. </docTitle>
  220. <navMap>
  221. `, esc(a.Language), esc(id), depth, esc(a.Title))
  222. order := 0
  223. var point func(e *tocEntry, indent string)
  224. point = func(e *tocEntry, indent string) {
  225. order++
  226. fmt.Fprintf(&b, "%s<navPoint id=\"navpoint-%d\" playOrder=\"%d\">\n", indent, order, order)
  227. fmt.Fprintf(&b, "%s <navLabel><text>%s</text></navLabel>\n", indent, esc(e.text))
  228. fmt.Fprintf(&b, "%s <content src=\"%s\"/>\n", indent, esc(e.href))
  229. for _, c := range e.children {
  230. point(c, indent+" ")
  231. }
  232. fmt.Fprintf(&b, "%s</navPoint>\n", indent)
  233. }
  234. point(root, " ")
  235. b.WriteString(" </navMap>\n</ncx>\n")
  236. return []byte(b.String())
  237. }
  238. // esc escapes s for XML text and attribute values.
  239. func esc(s string) string {
  240. var b strings.Builder
  241. xml.EscapeText(&b, []byte(s))
  242. return b.String()
  243. }
  244. func textOf(n *html.Node) string {
  245. var b strings.Builder
  246. var rec func(*html.Node)
  247. rec = func(n *html.Node) {
  248. if n.Type == html.TextNode {
  249. b.WriteString(n.Data)
  250. }
  251. for c := n.FirstChild; c != nil; c = c.NextSibling {
  252. rec(c)
  253. }
  254. }
  255. rec(n)
  256. return b.String()
  257. }