process.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. package assets
  2. import (
  3. "bytes"
  4. "errors"
  5. "fmt"
  6. "image"
  7. "image/color"
  8. "image/draw"
  9. _ "image/gif" // registers GIF decoding; Decode returns the first frame
  10. "image/jpeg"
  11. "image/png"
  12. "sort"
  13. "github.com/srwiley/oksvg"
  14. "github.com/srwiley/rasterx"
  15. xdraw "golang.org/x/image/draw"
  16. _ "golang.org/x/image/webp" // registers WebP decoding
  17. "git.monoexp.com/bajdev/rekopub/internal/device"
  18. )
  19. // maxPixels rejects images that would take too much memory to decode,
  20. // such as "decompression bombs" with a tiny file but huge dimensions.
  21. const maxPixels = 50_000_000
  22. // errTooSmall marks images of a couple of pixels, which are tracking pixels
  23. // or spacers rather than content.
  24. var errTooSmall = errors.New("image is a tracking pixel or spacer")
  25. // encoded is a processed image ready to go into the book.
  26. type encoded struct {
  27. data []byte
  28. mediaType string
  29. ext string
  30. }
  31. // convert decodes data and returns it prepared for e-ink: transparency
  32. // flattened onto white, converted to grayscale, scaled down to fit the
  33. // profile, and encoded as JPEG (photos) or a 16-level PNG (line art).
  34. // WebP, GIF and SVG input are never passed through.
  35. func convert(data []byte, svg bool, p device.Profile) (*encoded, error) {
  36. img, err := decodeAny(data, svg, p)
  37. if err != nil {
  38. return nil, err
  39. }
  40. gray := scaleToFit(ToGray(img), p.MaxImageWidth, p.MaxImageHeight)
  41. var buf bytes.Buffer
  42. if isLineArt(gray, p.GrayLevels) {
  43. if err := png.Encode(&buf, quantize(gray, p.GrayLevels)); err != nil {
  44. return nil, err
  45. }
  46. return &encoded{data: buf.Bytes(), mediaType: "image/png", ext: ".png"}, nil
  47. }
  48. if err := jpeg.Encode(&buf, gray, &jpeg.Options{Quality: p.JPEGQuality}); err != nil {
  49. return nil, err
  50. }
  51. return &encoded{data: buf.Bytes(), mediaType: "image/jpeg", ext: ".jpg"}, nil
  52. }
  53. // decodeAny decodes image data in any supported format, rasterizing SVG,
  54. // and rejects tracking pixels and spacers.
  55. func decodeAny(data []byte, svg bool, p device.Profile) (image.Image, error) {
  56. var img image.Image
  57. var err error
  58. if svg {
  59. img, err = rasterizeSVG(data, p)
  60. } else {
  61. img, err = decode(data)
  62. }
  63. if err != nil {
  64. return nil, err
  65. }
  66. if b := img.Bounds(); b.Dx() <= 2 || b.Dy() <= 2 {
  67. return nil, errTooSmall
  68. }
  69. return img, nil
  70. }
  71. // decode decodes JPEG, PNG, GIF or WebP data, refusing images whose
  72. // dimensions are too large to decode safely.
  73. func decode(data []byte) (image.Image, error) {
  74. cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
  75. if err != nil {
  76. return nil, fmt.Errorf("unsupported or damaged image: %w", err)
  77. }
  78. if cfg.Width*cfg.Height > maxPixels {
  79. return nil, fmt.Errorf("%s image is too large (%d×%d)", format, cfg.Width, cfg.Height)
  80. }
  81. img, _, err := image.Decode(bytes.NewReader(data))
  82. if err != nil {
  83. return nil, fmt.Errorf("damaged %s image: %w", format, err)
  84. }
  85. return img, nil
  86. }
  87. // rasterizeSVG draws an SVG at up to twice its own size, bounded by the
  88. // profile. The e-readers don't render SVG reliably, so it becomes a bitmap.
  89. // oksvg never loads external resources.
  90. func rasterizeSVG(data []byte, p device.Profile) (img image.Image, err error) {
  91. defer func() {
  92. if r := recover(); r != nil {
  93. err = fmt.Errorf("damaged SVG image: %v", r)
  94. }
  95. }()
  96. icon, err := oksvg.ReadIconStream(bytes.NewReader(data), oksvg.IgnoreErrorMode)
  97. if err != nil {
  98. return nil, fmt.Errorf("damaged SVG image: %w", err)
  99. }
  100. w, h := icon.ViewBox.W, icon.ViewBox.H
  101. if w <= 0 || h <= 0 {
  102. w, h = 800, 600
  103. }
  104. scale := min(2, float64(p.MaxImageWidth)/w, float64(p.MaxImageHeight)/h)
  105. tw, th := max(1, int(w*scale)), max(1, int(h*scale))
  106. icon.SetTarget(0, 0, float64(tw), float64(th))
  107. rgba := image.NewRGBA(image.Rect(0, 0, tw, th))
  108. scanner := rasterx.NewScannerGV(tw, th, rgba, rgba.Bounds())
  109. icon.Draw(rasterx.NewDasher(tw, th, scanner), 1)
  110. return rgba, nil
  111. }
  112. // ToGray composites img over white, so transparent areas don't turn black
  113. // on readers that ignore alpha, and converts it to 8-bit grayscale using
  114. // the BT.601 luma weights.
  115. func ToGray(img image.Image) *image.Gray {
  116. b := img.Bounds()
  117. flat := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
  118. draw.Draw(flat, flat.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
  119. draw.Draw(flat, flat.Bounds(), img, b.Min, draw.Over)
  120. gray := image.NewGray(flat.Bounds())
  121. draw.Draw(gray, gray.Bounds(), flat, image.Point{}, draw.Src)
  122. return gray
  123. }
  124. // scaleToFit shrinks img to fit within maxW×maxH, keeping its aspect
  125. // ratio. Images are never enlarged.
  126. func scaleToFit(img *image.Gray, maxW, maxH int) *image.Gray {
  127. w, h := img.Bounds().Dx(), img.Bounds().Dy()
  128. scale := min(1, float64(maxW)/float64(w), float64(maxH)/float64(h))
  129. if scale >= 1 {
  130. return img
  131. }
  132. dst := image.NewGray(image.Rect(0, 0, max(1, int(float64(w)*scale+0.5)), max(1, int(float64(h)*scale+0.5))))
  133. xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Src, nil)
  134. return dst
  135. }
  136. // lineArtCoverage is the share of pixels that must fall on the most common
  137. // gray levels for an image to count as line art (diagrams, charts,
  138. // screenshots) rather than a photo.
  139. const lineArtCoverage = 0.9
  140. // isLineArt reports whether most of img's pixels use just a few distinct
  141. // gray values, as diagrams and screenshots do. Photos spread across many.
  142. func isLineArt(img *image.Gray, levels int) bool {
  143. var hist [256]int
  144. for _, v := range img.Pix {
  145. hist[v]++
  146. }
  147. counts := hist[:]
  148. sort.Sort(sort.Reverse(sort.IntSlice(counts)))
  149. top := 0
  150. for _, c := range counts[:levels] {
  151. top += c
  152. }
  153. return float64(top) >= lineArtCoverage*float64(len(img.Pix))
  154. }
  155. // quantize maps img onto levels evenly spaced grays, black to white. With
  156. // 16 levels this matches the panels exactly and the PNG encoder writes
  157. // compact 4-bit pixels.
  158. func quantize(img *image.Gray, levels int) *image.Paletted {
  159. pal := make(color.Palette, levels)
  160. step := 255 / (levels - 1)
  161. for i := range pal {
  162. pal[i] = color.Gray{Y: uint8(i * step)}
  163. }
  164. out := image.NewPaletted(img.Bounds(), pal)
  165. for i, v := range img.Pix {
  166. out.Pix[i] = uint8((int(v) + step/2) / step)
  167. }
  168. return out
  169. }