1
0

cover.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. // Package cover draws book covers for e-ink readers.
  2. package cover
  3. import (
  4. "bytes"
  5. "image"
  6. "image/color"
  7. "image/draw"
  8. "image/jpeg"
  9. "strings"
  10. "sync"
  11. xdraw "golang.org/x/image/draw"
  12. "golang.org/x/image/font"
  13. "golang.org/x/image/font/gofont/gobold"
  14. "golang.org/x/image/font/gofont/goregular"
  15. "golang.org/x/image/font/opentype"
  16. "golang.org/x/image/math/fixed"
  17. "git.monoexp.com/bajdev/rekopub/internal/assets"
  18. "git.monoexp.com/bajdev/rekopub/internal/device"
  19. )
  20. // Details is the text shown on a cover.
  21. type Details struct {
  22. Title string
  23. SiteName string // may be empty
  24. Date string // already formatted; may be empty
  25. }
  26. // Render draws a grayscale JPEG cover at the profile's cover size. With a
  27. // photo, the photo fills the top of the cover and the title sits on white
  28. // below it; without one, the cover is typographic. Either way the title is
  29. // large, because library thumbnails on e-ink readers are small.
  30. func Render(d Details, photo image.Image, p device.Profile) ([]byte, error) {
  31. w, h := p.CoverWidth, p.CoverHeight
  32. img := image.NewGray(image.Rect(0, 0, w, h))
  33. draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
  34. margin := w / 14
  35. textWidth := w - 2*margin
  36. sub := subtitle(d)
  37. if photo != nil {
  38. photoHeight := h * 3 / 5
  39. drawPhoto(img, image.Rect(0, 0, w, photoHeight), photo)
  40. top := photoHeight + margin*2/3
  41. bottom := h - margin
  42. if sub != "" {
  43. bottom -= drawLines(img, regular(), []string{sub}, 46, margin, bottom-46, color.Black, true)
  44. bottom -= margin / 3
  45. }
  46. face, lines := fitTitle(d.Title, textWidth, bottom-top, 104, 52)
  47. drawWrapped(img, face, lines, margin, top)
  48. } else {
  49. // A black band across the top with the site name, a large title, and
  50. // the date at the bottom.
  51. band := h / 9
  52. draw.Draw(img, image.Rect(0, 0, w, band), image.Black, image.Point{}, draw.Src)
  53. if d.SiteName != "" {
  54. name := truncate(regular(), 54, d.SiteName, textWidth)
  55. drawLines(img, regular(), []string{name}, 54, margin, band/2+18, color.White, false)
  56. }
  57. top := band + margin*3/2
  58. bottom := h - margin*2
  59. face, lines := fitTitle(d.Title, textWidth, bottom-top, 140, 64)
  60. end := drawWrapped(img, face, lines, margin, top)
  61. rule := end + margin*3/4
  62. draw.Draw(img, image.Rect(margin, rule, margin+w/4, rule+8), image.Black, image.Point{}, draw.Src)
  63. if d.Date != "" {
  64. drawLines(img, regular(), []string{d.Date}, 46, margin, rule+margin, color.Black, false)
  65. }
  66. }
  67. var buf bytes.Buffer
  68. if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
  69. return nil, err
  70. }
  71. return buf.Bytes(), nil
  72. }
  73. // subtitle is "Site · Date", skipping whichever is missing.
  74. func subtitle(d Details) string {
  75. var parts []string
  76. for _, s := range []string{d.SiteName, d.Date} {
  77. if s = strings.TrimSpace(s); s != "" {
  78. parts = append(parts, s)
  79. }
  80. }
  81. return strings.Join(parts, " · ")
  82. }
  83. // drawPhoto scales photo to cover r completely, cropping the overflow
  84. // evenly, and draws it in grayscale.
  85. func drawPhoto(dst *image.Gray, r image.Rectangle, photo image.Image) {
  86. gray := assets.ToGray(photo)
  87. sw, sh := gray.Bounds().Dx(), gray.Bounds().Dy()
  88. scale := max(float64(r.Dx())/float64(sw), float64(r.Dy())/float64(sh))
  89. cw, ch := int(float64(r.Dx())/scale), int(float64(r.Dy())/scale)
  90. x0, y0 := (sw-cw)/2, (sh-ch)/2
  91. xdraw.CatmullRom.Scale(dst, r, gray, image.Rect(x0, y0, x0+cw, y0+ch), xdraw.Src, nil)
  92. }
  93. // fitTitle picks the largest font size, from maxSize down to minSize, at
  94. // which the title wraps into the given box. At the smallest size, lines
  95. // that don't fit are dropped and the last kept line ends with "…".
  96. func fitTitle(title string, width, height int, maxSize, minSize float64) (font.Face, []string) {
  97. title = strings.Join(strings.Fields(title), " ")
  98. if title == "" {
  99. title = "Untitled"
  100. }
  101. for size := maxSize; ; size -= 4 {
  102. face := faceFor(bold(), size)
  103. lines := wrap(face, title, width)
  104. lineHeight := int(size * 1.18)
  105. if len(lines)*lineHeight <= height || size-4 < minSize {
  106. if keep := max(1, height/lineHeight); len(lines) > keep {
  107. lines = lines[:keep]
  108. lines[keep-1] = truncate(bold(), size, lines[keep-1]+" …", width)
  109. }
  110. return face, lines
  111. }
  112. }
  113. }
  114. // wrap breaks text into lines no wider than width, splitting words that
  115. // are too long on their own.
  116. func wrap(face font.Face, text string, width int) []string {
  117. var lines []string
  118. line := ""
  119. for _, word := range strings.Fields(text) {
  120. for font.MeasureString(face, word).Ceil() > width {
  121. // Split an overlong word at the widest prefix that fits.
  122. n := len([]rune(word)) - 1
  123. for n > 1 && font.MeasureString(face, string([]rune(word)[:n])).Ceil() > width {
  124. n--
  125. }
  126. if line != "" {
  127. lines = append(lines, line)
  128. line = ""
  129. }
  130. lines = append(lines, string([]rune(word)[:n]))
  131. word = string([]rune(word)[n:])
  132. }
  133. candidate := strings.TrimSpace(line + " " + word)
  134. if line != "" && font.MeasureString(face, candidate).Ceil() > width {
  135. lines = append(lines, line)
  136. candidate = word
  137. }
  138. line = candidate
  139. }
  140. if line != "" {
  141. lines = append(lines, line)
  142. }
  143. return lines
  144. }
  145. // truncate shortens s with "…" until it fits within width.
  146. func truncate(f *opentype.Font, size float64, s string, width int) string {
  147. face := faceFor(f, size)
  148. if font.MeasureString(face, s).Ceil() <= width {
  149. return s
  150. }
  151. r := []rune(strings.TrimSuffix(s, " …"))
  152. for len(r) > 1 && font.MeasureString(face, string(r)+"…").Ceil() > width {
  153. r = r[:len(r)-1]
  154. }
  155. return strings.TrimSpace(string(r)) + "…"
  156. }
  157. // drawWrapped draws lines starting with the first line's top at y, and
  158. // returns the y just below the last line.
  159. func drawWrapped(dst *image.Gray, face font.Face, lines []string, x, y int) int {
  160. m := face.Metrics()
  161. lineHeight := (m.Ascent + m.Descent).Ceil() * 118 / 100
  162. baseline := y + m.Ascent.Ceil()
  163. for _, line := range lines {
  164. (&font.Drawer{Dst: dst, Src: image.Black, Face: face, Dot: fixed.P(x, baseline)}).DrawString(line)
  165. baseline += lineHeight
  166. }
  167. return baseline - lineHeight + m.Descent.Ceil()
  168. }
  169. // drawLines draws single lines at baseline y in the given font and colour,
  170. // and returns the height used. With fromBottom, y is the bottom edge.
  171. func drawLines(dst *image.Gray, f *opentype.Font, lines []string, size float64, x, y int, c color.Color, fromBottom bool) int {
  172. face := faceFor(f, size)
  173. m := face.Metrics()
  174. if fromBottom {
  175. y -= m.Descent.Ceil()
  176. }
  177. for _, line := range lines {
  178. (&font.Drawer{Dst: dst, Src: image.NewUniform(c), Face: face, Dot: fixed.P(x, y)}).DrawString(line)
  179. }
  180. return (m.Ascent + m.Descent).Ceil()
  181. }
  182. var (
  183. bold = sync.OnceValue(func() *opentype.Font { return mustParse(gobold.TTF) })
  184. regular = sync.OnceValue(func() *opentype.Font { return mustParse(goregular.TTF) })
  185. )
  186. // mustParse parses an embedded font; it can only fail if the font data is
  187. // corrupt, which is a build problem.
  188. func mustParse(ttf []byte) *opentype.Font {
  189. f, err := opentype.Parse(ttf)
  190. if err != nil {
  191. panic(err)
  192. }
  193. return f
  194. }
  195. func faceFor(f *opentype.Font, size float64) font.Face {
  196. face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
  197. if err != nil {
  198. panic(err) // only fails for invalid options, which are constant here
  199. }
  200. return face
  201. }