1 /** 2 * Cover generation. 3 * 4 * This requires a dependency on GTK to provide Cairo. 5 */ 6 module epub.cover; 7 8 import epub.books; 9 import std.experimental.logger; 10 11 @safe: 12 13 /** 14 * A Cover is a description of what sort of cover to generate for a book. 15 */ 16 class Cover 17 { 18 /** Formats you can generate covers in. */ 19 enum Format 20 { 21 /// Standard HTML 22 html, 23 /// SVG image format 24 svg, 25 /// PNG image format. This is recommended. 26 png 27 } 28 29 /// The preferred format for this cover. May be ignored. 30 Format format = Format.svg; 31 32 /** 33 * Preferred fonts for the book's cover. 34 * 35 * These should be ordered by priority: the first option will be used if possible, falling back 36 * to the second, falling back to the third, etc. 37 * 38 * The last resort is hard-coded as Sans. 39 */ 40 string[] fontPreferences; 41 42 /** 43 * Target size for generated covers. 44 * 45 * The defaults are taken from Kindle Direct Publishing's recommendations. 46 */ 47 uint width = 1600, height = 2560; 48 49 /** 50 * The name of the program that generated this ebook. 51 * 52 * Leave null to omit the generator line. 53 */ 54 string generator; 55 } 56 57 void addTitlePage(ref Book book) 58 { 59 import std..string : format; 60 61 Chapter chapter; 62 chapter.title = "Title Page"; 63 chapter.showInTOC = false; 64 book.chapters = [chapter] ~ book.chapters; 65 66 if (book.cover.format == Cover.Format.html) 67 { 68 chapter.content = `<?xml version="1.0" encoding="UTF-8"?> 69 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" 70 "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 71 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" > 72 <head> 73 <title>Title Page</title> 74 <style type="text/css"> 75 body { 76 text-align: center; 77 } 78 h1 { 79 padding-bottom: 5pt; 80 border-bottom: 1pt solid black; 81 margin-bottom: 20pt; 82 } 83 </style> 84 </head> 85 <body> 86 <h1>%s</h1> 87 <h3>%s</h3> 88 </body> 89 </html>`.format(book.title, book.author); 90 } 91 92 auto coverImage = render(book, book.cover); 93 book.attachments ~= coverImage; 94 chapter.content = `<?xml version="1.0" encoding="UTF-8"?> 95 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" 96 "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 97 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" > 98 <head> 99 </head> 100 <body> 101 <img src="%s" /> 102 </body> 103 </html>`.format(coverImage.filename); 104 } 105 106 /** 107 * Render the cover into an attachment. 108 */ 109 Attachment render(Book book, Cover cover) 110 { 111 final switch (cover.format) with (Cover.Format) 112 { 113 case html: 114 return Attachment.init; 115 case png: 116 return pngCover(book, cover); 117 case svg: 118 return svgCover(book, cover); 119 } 120 } 121 122 private: 123 124 version (Have_gtk_d) 125 { 126 import cairo.Context; 127 import cairo.Surface; 128 129 Attachment pngCover(Cover cover) 130 { 131 import std.file : read, remove, tempDir; 132 import std.path : buildPath; 133 134 auto tmpFileName = buildPath(tempDir, book.id ~ ".png"); 135 renderPngCover(book, cover, tmpFileName); 136 auto content = tmpFileName.read; 137 tmpFileName.remove; 138 return Attachment( 139 "cover", 140 "cover.png", 141 "image/png", 142 cast(const(ubyte[]))content); 143 } 144 145 void renderCover(Book book, Cover cover, Surface surface) @trusted 146 { 147 import std.file; 148 import std.experimental.logger; 149 import cairo.c.types; 150 151 auto context = Context.create(surface); 152 context.save; 153 154 // A nice neutral gray background 155 context.rectangle(0, 0, cover.width, cover.height); 156 context.setSourceRgb(0.95, 0.95, 0.95); 157 context.fill; 158 context.restore; 159 160 // A heavy red border 161 context.save; 162 context.setSourceRgb(0.55, 0.1, 0.1); 163 context.setLineWidth(30); 164 auto margin = cover.width * 0.05; 165 context.rectangle(margin, margin, cover.width - (2 * margin), cover.height - (2 * margin)); 166 167 context.stroke; 168 context.restore; 169 170 // Title, author, generator 171 context.save; 172 173 context.selectFontFace( 174 "Sans", 175 CairoFontSlant.NORMAL, 176 CairoFontWeight.BOLD); 177 178 foreach (font; cover.fontPreferences) 179 { 180 context.selectFontFace( 181 font, 182 CairoFontSlant.NORMAL, 183 CairoFontWeight.BOLD); 184 if (context.status == CairoStatus.SUCCESS) 185 { 186 infof("successfully chose font %s", font); 187 break; 188 } 189 infof("failed to select font %s; falling back", font); 190 } 191 192 auto titleScale = drawText(context, cover, book.title, cover.height * 0.25, 90); 193 drawText(context, cover, book.author, cover.height * 0.5, titleScale * 0.8); 194 if (cover.generator) 195 { 196 drawText( 197 context, 198 cover, 199 "Generated by " ~ cover.generator, 200 cover.height * 0.9, 201 titleScale * 0.5); 202 } 203 204 context.restore; 205 } 206 207 /// Visible for testing 208 public void renderPngCover(Book book, Cover cover, string filename) @trusted 209 { 210 import cairo.ImageSurface; 211 auto surface = ImageSurface.create(CairoFormat.ARGB32, cover.width, cover.height); 212 renderCover(book, cover, surface); 213 surface.writeToPng(filename); 214 } 215 216 /// Visible for testing 217 public void renderSvgCover(Book book, Cover cover, string filename) @trusted 218 { 219 import cairo.SvgSurface; 220 auto surface = SvgSurface.create(filename, cover.width, cover.height); 221 renderCover(book, cover, surface); 222 surface.flush; 223 surface.finish; 224 } 225 226 double drawText(Context context, Cover cover, string text, double y, double scale) 227 @trusted 228 { 229 // TODO split text onto multiple lines? 230 double happyWidth = cover.width * 0.8; 231 double actualWidth; 232 while (scale > 5) 233 { 234 context.setFontSize(scale); 235 cairo_text_extents_t extents; 236 context.textExtents(text, &extents); 237 actualWidth = extents.width; 238 if (actualWidth <= happyWidth) 239 { 240 auto dx = (cover.width - actualWidth) * 0.5; 241 infof("writing text %s at size %s at position (%s, %s)", text, scale, dx, y); 242 context.setFontSize(scale); 243 context.moveTo(dx, y); 244 break; 245 } 246 scale -= 5; 247 } 248 249 context.textPath(text); 250 context.setSourceRgb(0.2, 0.25, 0.55); 251 context.fillPreserve; 252 context.setSourceRgb(0, 0, 0); 253 context.setLineWidth(scale * 0.025); 254 context.stroke; 255 return scale; 256 } 257 } 258 else 259 { 260 261 Attachment pngCover(Book book, Cover cover) 262 { 263 return svgCover(book, cover); 264 } 265 266 Attachment svgCover(Book book, Cover cover) 267 { 268 import std.array : Appender; 269 Appender!string s; 270 s.reserve(1000); 271 s ~= `<?xml version="1.0" encoding="UTF-8" standalone="no"?> 272 <svg width="350" height="475"> 273 <rect x="10" 274 y="10" 275 width="330" 276 height="455" 277 stroke="black" 278 stroke-width="3" 279 fill="#cceeff" 280 stroke-linecap="round"/> 281 <text text-anchor="middle" 282 x="175" 283 y="75" 284 font-size="30" 285 font-weight="600" 286 font-family="`; 287 foreach (font; cover.fontPreferences) 288 { 289 s ~= font; 290 s ~= ","; 291 } 292 s ~= `serif" 293 stroke-width="2" 294 stroke-opacity="0.5" 295 stroke="#000000" 296 fill="#000000">`; 297 s ~= book.title; 298 s ~= `</text> 299 <text text-anchor="middle" x="175" y="135" font-size="15">`; 300 s ~= book.author; 301 s ~= `</text> 302 </svg>`; 303 return Attachment( 304 "cover", 305 "cover.svg", 306 "image/svg+xml", 307 cast(const(ubyte[]))s.data); 308 } 309 310 }