build.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. // Package build packages an extracted article as an EPUB.
  2. package build
  3. import (
  4. "bytes"
  5. _ "embed"
  6. "encoding/base64"
  7. "fmt"
  8. "html/template"
  9. "strings"
  10. "time"
  11. epub "github.com/go-shiori/go-epub"
  12. "github.com/google/uuid"
  13. "golang.org/x/net/html"
  14. "golang.org/x/net/html/atom"
  15. "git.monoexp.com/bajdev/rekopub/internal/assets"
  16. "git.monoexp.com/bajdev/rekopub/internal/clean"
  17. "git.monoexp.com/bajdev/rekopub/internal/extract"
  18. )
  19. //go:embed chapter.tmpl
  20. var chapterTmpl string
  21. //go:embed style.css
  22. var styleCSS []byte
  23. var chapter = template.Must(template.New("chapter").Parse(chapterTmpl))
  24. // DisplayDate formats a publication date as shown in the byline and on the
  25. // cover, or returns "" for the zero time.
  26. func DisplayDate(t time.Time) string {
  27. if t.IsZero() {
  28. return ""
  29. }
  30. return t.Format("2 January 2006")
  31. }
  32. // Build returns the EPUB for a, whose cleaned content is body (from
  33. // clean.Content) with images already processed into images. cover is a
  34. // JPEG cover image, or nil for none.
  35. func Build(a *extract.Article, body *html.Node, images []assets.Image, cover []byte) ([]byte, error) {
  36. // go-epub stages files in a temporary folder that it removes after each
  37. // write. Don't switch it to epub.MemoryFS: on Windows that loses every
  38. // file except mimetype, producing an empty book.
  39. e, err := epub.NewEpub(a.Title)
  40. if err != nil {
  41. return nil, err
  42. }
  43. // Derive the identifier from the URL, so rebuilding the same page gives
  44. // the same book identity and readers treat it as the same book.
  45. id := "urn:uuid:" + uuid.NewSHA1(uuid.NameSpaceURL, []byte(a.URL)).String()
  46. e.SetIdentifier(id)
  47. e.SetLang(a.Language)
  48. if a.Author != "" {
  49. e.SetAuthor(a.Author)
  50. }
  51. if a.Description != "" {
  52. e.SetDescription(a.Description)
  53. }
  54. css, err := e.AddCSS("data:text/css;base64,"+base64.StdEncoding.EncodeToString(styleCSS), "style.css")
  55. if err != nil {
  56. return nil, fmt.Errorf("adding stylesheet: %w", err)
  57. }
  58. if cover != nil {
  59. p, err := e.AddImage("data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(cover), "cover.jpg")
  60. if err != nil {
  61. return nil, fmt.Errorf("adding cover: %w", err)
  62. }
  63. if err := e.SetCover(p, ""); err != nil {
  64. return nil, fmt.Errorf("adding cover: %w", err)
  65. }
  66. }
  67. if err := addImages(e, body, images); err != nil {
  68. return nil, err
  69. }
  70. headings := anchorHeadings(body)
  71. chapter, err := chapterBody(a, body)
  72. if err != nil {
  73. return nil, err
  74. }
  75. if _, err := e.AddSection(chapter, a.Title, chapterFile, css); err != nil {
  76. return nil, err
  77. }
  78. var raw bytes.Buffer
  79. if _, err := e.WriteTo(&raw); err != nil {
  80. return nil, err
  81. }
  82. return finish(raw.Bytes(), id, a, headings)
  83. }
  84. // addImages stores each image in the book and points the <img> elements,
  85. // whose src is the image's filename, at the stored file.
  86. func addImages(e *epub.Epub, body *html.Node, images []assets.Image) error {
  87. paths := map[string]string{}
  88. for _, img := range images {
  89. src := "data:" + img.MediaType + ";base64," + base64.StdEncoding.EncodeToString(img.Data)
  90. p, err := e.AddImage(src, img.Filename)
  91. if err != nil {
  92. return fmt.Errorf("adding image %s: %w", img.Filename, err)
  93. }
  94. paths[img.Filename] = p
  95. }
  96. // Only images processed by the assets package may be in the book; any
  97. // other <img> would point at a remote resource, so it is removed.
  98. var unknown []*html.Node
  99. var walk func(*html.Node)
  100. walk = func(n *html.Node) {
  101. if n.Type == html.ElementNode && n.DataAtom == atom.Img {
  102. known := false
  103. for i, a := range n.Attr {
  104. if p, ok := paths[a.Val]; ok && a.Key == "src" {
  105. n.Attr[i].Val, known = p, true
  106. }
  107. }
  108. if !known {
  109. unknown = append(unknown, n)
  110. }
  111. }
  112. for c := n.FirstChild; c != nil; c = c.NextSibling {
  113. walk(c)
  114. }
  115. }
  116. walk(body)
  117. for _, n := range unknown {
  118. n.Parent.RemoveChild(n)
  119. }
  120. return nil
  121. }
  122. // chapterBody renders the chapter: a header with the title, byline and
  123. // source link, followed by the article content.
  124. func chapterBody(a *extract.Article, body *html.Node) (string, error) {
  125. content, err := clean.Render(body)
  126. if err != nil {
  127. return "", err
  128. }
  129. var b strings.Builder
  130. err = chapter.Execute(&b, struct {
  131. Lang, Title, Byline, URL string
  132. Content template.HTML
  133. }{
  134. Lang: a.Language,
  135. Title: a.Title,
  136. Byline: byline(a),
  137. URL: a.URL,
  138. Content: template.HTML(content), // sanitized and checked by clean.Content
  139. })
  140. if err != nil {
  141. return "", fmt.Errorf("rendering chapter: %w", err)
  142. }
  143. return b.String(), nil
  144. }
  145. // byline joins the author, site name and publication date, skipping any
  146. // that are unknown.
  147. func byline(a *extract.Article) string {
  148. var parts []string
  149. if a.Author != "" {
  150. parts = append(parts, a.Author)
  151. }
  152. if a.SiteName != "" {
  153. parts = append(parts, a.SiteName)
  154. }
  155. if d := DisplayDate(a.Published); d != "" {
  156. parts = append(parts, d)
  157. }
  158. return strings.Join(parts, " · ")
  159. }