1
0

assets.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. // Package assets downloads the images in an article and prepares them for
  2. // e-ink screens.
  3. package assets
  4. import (
  5. "bytes"
  6. "context"
  7. "crypto/sha1"
  8. "encoding/hex"
  9. "errors"
  10. "fmt"
  11. "image"
  12. "io"
  13. "mime"
  14. "net/http"
  15. "net/url"
  16. "strings"
  17. "github.com/vincent-petithory/dataurl"
  18. "golang.org/x/net/html"
  19. "golang.org/x/net/html/atom"
  20. "golang.org/x/sync/errgroup"
  21. "git.monoexp.com/bajdev/rekopub/internal/device"
  22. "git.monoexp.com/bajdev/rekopub/internal/fetch"
  23. )
  24. // Image is a processed image to add to the book.
  25. type Image struct {
  26. Filename string // e.g. "3fa2c1d9e0ab.jpg", named by content hash
  27. MediaType string // image/jpeg or image/png
  28. Data []byte
  29. }
  30. // Processor downloads and converts images.
  31. type Processor struct {
  32. Client *http.Client
  33. UserAgent string
  34. MaxBytes int64 // largest image accepted
  35. Concurrency int // downloads at once
  36. Profile device.Profile
  37. // Warn is called for every image that couldn't be used. It may be nil.
  38. Warn func(msg string)
  39. }
  40. // New returns a Processor using client and the e-ink profile, with the
  41. // limits from PLAN.md: 10 MB per image and 8 downloads at once.
  42. func New(client *http.Client, userAgent string) *Processor {
  43. return &Processor{
  44. Client: client,
  45. UserAgent: userAgent,
  46. MaxBytes: 10 << 20,
  47. Concurrency: 8,
  48. Profile: device.EInk,
  49. }
  50. }
  51. // Process downloads every image in body, converts it for e-ink and points
  52. // its <img> at the new file. The returned images are in document order,
  53. // one per distinct image. An image that can't be used is replaced by its
  54. // alt text, or removed if it has none; that is never an error. Only
  55. // cancellation of ctx stops processing.
  56. func (p *Processor) Process(ctx context.Context, body *html.Node, page *url.URL) ([]Image, error) {
  57. var imgs []*html.Node
  58. var sources []string // distinct src values, in document order
  59. seen := map[string]bool{}
  60. walk(body, func(n *html.Node) {
  61. if n.DataAtom != atom.Img {
  62. return
  63. }
  64. imgs = append(imgs, n)
  65. if src := attr(n, "src"); src != "" && !seen[src] {
  66. seen[src] = true
  67. sources = append(sources, src)
  68. }
  69. })
  70. results := make([]*encoded, len(sources))
  71. errs := make([]error, len(sources))
  72. g, gctx := errgroup.WithContext(ctx)
  73. g.SetLimit(max(1, p.Concurrency))
  74. for i, src := range sources {
  75. g.Go(func() error {
  76. results[i], errs[i] = p.load(gctx, src, page)
  77. return nil
  78. })
  79. }
  80. g.Wait()
  81. if err := ctx.Err(); err != nil {
  82. return nil, err
  83. }
  84. // Name files by content hash, so the same image found under different
  85. // URLs is stored once.
  86. var images []Image
  87. filenames := map[string]string{} // src → filename
  88. byHash := map[string]bool{}
  89. for i, src := range sources {
  90. if errs[i] != nil {
  91. if !errors.Is(errs[i], errTooSmall) {
  92. p.warn(fmt.Sprintf("image %s skipped: %v", short(src), errs[i]))
  93. }
  94. continue
  95. }
  96. sum := sha1.Sum(results[i].data)
  97. name := hex.EncodeToString(sum[:])[:12] + results[i].ext
  98. filenames[src] = name
  99. if !byHash[name] {
  100. byHash[name] = true
  101. images = append(images, Image{Filename: name, MediaType: results[i].mediaType, Data: results[i].data})
  102. }
  103. }
  104. for _, n := range imgs {
  105. if name, ok := filenames[attr(n, "src")]; ok {
  106. n.Attr = []html.Attribute{{Key: "src", Val: name}, {Key: "alt", Val: attr(n, "alt")}}
  107. } else {
  108. replaceWithAlt(n)
  109. }
  110. }
  111. return images, nil
  112. }
  113. // Decode fetches src (an http(s) or data: URL) and decodes it without
  114. // converting it, for uses such as the cover. SVG is rasterized.
  115. func (p *Processor) Decode(ctx context.Context, src string, page *url.URL) (image.Image, error) {
  116. data, svg, err := p.fetch(ctx, src, page)
  117. if err != nil {
  118. return nil, err
  119. }
  120. return decodeAny(data, svg, p.Profile)
  121. }
  122. // load fetches src (an http(s) or data: URL) and converts it.
  123. func (p *Processor) load(ctx context.Context, src string, page *url.URL) (*encoded, error) {
  124. data, svg, err := p.fetch(ctx, src, page)
  125. if err != nil {
  126. return nil, err
  127. }
  128. return convert(data, svg, p.Profile)
  129. }
  130. // fetch returns the bytes of src and whether they are SVG.
  131. func (p *Processor) fetch(ctx context.Context, src string, page *url.URL) (data []byte, svg bool, err error) {
  132. var mediaType string
  133. if strings.HasPrefix(src, "data:") {
  134. du, err := dataurl.DecodeString(src)
  135. if err != nil {
  136. return nil, false, fmt.Errorf("bad data URL: %w", err)
  137. }
  138. data, mediaType = du.Data, du.ContentType()
  139. } else {
  140. data, mediaType, err = p.download(ctx, src, page)
  141. if err != nil {
  142. return nil, false, err
  143. }
  144. }
  145. return data, mediaType == "image/svg+xml" || looksLikeSVG(data), nil
  146. }
  147. // download fetches an image, sending the article as Referer because some
  148. // image hosts refuse requests without one.
  149. func (p *Processor) download(ctx context.Context, src string, page *url.URL) ([]byte, string, error) {
  150. req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
  151. if err != nil {
  152. return nil, "", err
  153. }
  154. if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
  155. return nil, "", fmt.Errorf("unsupported URL scheme %q", req.URL.Scheme)
  156. }
  157. req.Header.Set("User-Agent", p.UserAgent)
  158. req.Header.Set("Accept", "image/jpeg,image/png,image/webp,image/gif,image/svg+xml;q=0.9,*/*;q=0.5")
  159. req.Header.Set("Referer", page.String())
  160. resp, err := p.Client.Do(req)
  161. if err != nil {
  162. return nil, "", fetch.Explain(err)
  163. }
  164. defer resp.Body.Close()
  165. if resp.StatusCode < 200 || resp.StatusCode > 299 {
  166. return nil, "", fmt.Errorf("server returned %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
  167. }
  168. data, err := io.ReadAll(io.LimitReader(resp.Body, p.MaxBytes+1))
  169. if err != nil {
  170. return nil, "", err
  171. }
  172. if int64(len(data)) > p.MaxBytes {
  173. return nil, "", fmt.Errorf("larger than %d MB", p.MaxBytes>>20)
  174. }
  175. mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
  176. if mediaType != "" && !strings.HasPrefix(mediaType, "image/") && mediaType != "application/octet-stream" {
  177. return nil, "", fmt.Errorf("not an image (content type %q)", mediaType)
  178. }
  179. return data, mediaType, nil
  180. }
  181. // looksLikeSVG reports whether data starts like an SVG document, for
  182. // servers that send SVG with a generic content type.
  183. func looksLikeSVG(data []byte) bool {
  184. head := bytes.ToLower(data[:min(len(data), 1024)])
  185. return bytes.Contains(head, []byte("<svg"))
  186. }
  187. // replaceWithAlt swaps an unusable image for "[Image: alt text]", or
  188. // removes it when there is no alt text. A <span> is valid everywhere an
  189. // <img> is.
  190. func replaceWithAlt(n *html.Node) {
  191. parent := n.Parent
  192. if parent == nil {
  193. return
  194. }
  195. if alt := strings.TrimSpace(attr(n, "alt")); alt != "" {
  196. span := &html.Node{Type: html.ElementNode, Data: "span", DataAtom: atom.Span,
  197. Attr: []html.Attribute{{Key: "class", Val: "missing-image"}}}
  198. span.AppendChild(&html.Node{Type: html.TextNode, Data: "[Image: " + alt + "]"})
  199. parent.InsertBefore(span, n)
  200. }
  201. parent.RemoveChild(n)
  202. }
  203. func (p *Processor) warn(msg string) {
  204. if p.Warn != nil {
  205. p.Warn(msg)
  206. }
  207. }
  208. // short trims long image URLs (and data URLs) for warnings.
  209. func short(src string) string {
  210. if strings.HasPrefix(src, "data:") {
  211. return "(embedded data URL)"
  212. }
  213. if len(src) > 100 {
  214. return src[:97] + "..."
  215. }
  216. return src
  217. }
  218. func attr(n *html.Node, key string) string {
  219. for _, a := range n.Attr {
  220. if a.Key == key {
  221. return a.Val
  222. }
  223. }
  224. return ""
  225. }
  226. func walk(n *html.Node, f func(*html.Node)) {
  227. if n.Type == html.ElementNode {
  228. f(n)
  229. }
  230. for c := n.FirstChild; c != nil; c = c.NextSibling {
  231. walk(c, f)
  232. }
  233. }