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 struct Cover 17 { 18 /** Formats you can generate covers in. */ 19 enum Format 20 { 21 /// SVG image format 22 svg, 23 /// PNG image format. This is recommended. 24 png 25 } 26 27 /// The book this cover is for 28 Book book; 29 30 /// The format for this cover. 31 Format format = Format.png; 32 33 /** 34 * Preferred fonts for the book's cover. 35 * 36 * These should be ordered by priority: the first option will be used if possible, falling back 37 * to the second, falling back to the third, etc. 38 * 39 * The last resort is hard-coded as Sans. 40 */ 41 string[] fontPreferences; 42 43 /** 44 * Target size for generated covers. 45 * 46 * The defaults are taken from Kindle Direct Publishing's recommendations. 47 */ 48 uint width = 1600, height = 2560; 49 50 /** 51 * The name of the program that generated this ebook. 52 * 53 * Leave null to omit the generator line. 54 */ 55 string generator; 56 } 57 58 version (Have_gtk_d) 59 { 60 import cairo.Context; 61 import cairo.Surface; 62 63 /** 64 * Render the cover into an attachment. 65 */ 66 Attachment render(Cover cover) 67 { 68 final switch (cover.format) with (Cover.Format) 69 { 70 case png: 71 return pngCover(cover); 72 case svg: 73 return svgCover(cover); 74 } 75 } 76 77 /// Visible for testing 78 Attachment svgCover(Cover cover) 79 { 80 import std.array : Appender; 81 Appender!string s; 82 s.reserve(1000); 83 s ~= `<?xml version="1.0" encoding="UTF-8" standalone="no"?> 84 <svg width="350" height="475"> 85 <rect x="10" 86 y="10" 87 width="330" 88 height="455" 89 stroke="black" 90 stroke-width="3" 91 fill="#cceeff" 92 stroke-linecap="round"/> 93 <text text-anchor="middle" 94 x="175" 95 y="75" 96 font-size="30" 97 font-weight="600" 98 font-family="serif" 99 stroke-width="2" stroke-opacity="0.5" stroke="#000000" fill="#000000">`; 100 s ~= cover.book.title; 101 s ~= `</text> 102 <text text-anchor="middle" x="175" y="135" font-size="15">`; 103 s ~= cover.book.author; 104 s ~= `</text> 105 </svg> 106 `; 107 return Attachment( 108 "cover", 109 "cover.svg", 110 "image/svg+xml", 111 cast(const(ubyte[]))s.data); 112 } 113 114 /// Visible for testing 115 Attachment pngCover(Cover cover) 116 { 117 import std.file : read, remove; 118 auto tmpFileName = "/tmp/" ~ cover.book.id ~ ".png"; 119 renderPngCover(cover, tmpFileName); 120 auto content = tmpFileName.read; 121 import std.stdio : writefln; 122 writefln("cover image generated at %s; read %s bytes", tmpFileName, content.length); 123 tmpFileName.remove; 124 return Attachment( 125 "cover", 126 "cover.png", 127 "image/png", 128 cast(const(ubyte[]))content); 129 } 130 131 /// Visible for testing 132 void renderCover(Cover cover, Surface surface) @trusted 133 { 134 import std.file; 135 import std.experimental.logger; 136 import cairo.c.types; 137 138 auto context = Context.create(surface); 139 context.save; 140 141 // A nice neutral gray background 142 context.rectangle(0, 0, cover.width, cover.height); 143 context.setSourceRgb(0.95, 0.95, 0.95); 144 context.fill; 145 context.restore; 146 147 // A heavy red border 148 context.save; 149 context.setSourceRgb(0.55, 0.1, 0.1); 150 context.setLineWidth(30); 151 auto margin = cover.width * 0.05; 152 context.rectangle(margin, margin, cover.width - (2 * margin), cover.height - (2 * margin)); 153 154 context.stroke; 155 context.restore; 156 157 // Title, author, generator 158 context.save; 159 160 context.selectFontFace( 161 "Sans", 162 CairoFontSlant.NORMAL, 163 CairoFontWeight.BOLD); 164 165 foreach (font; cover.fontPreferences) 166 { 167 context.selectFontFace( 168 font, 169 CairoFontSlant.NORMAL, 170 CairoFontWeight.BOLD); 171 if (context.status == CairoStatus.SUCCESS) 172 { 173 infof("successfully chose font %s", font); 174 break; 175 } 176 infof("failed to select font %s; falling back", font); 177 } 178 179 auto titleScale = drawText(context, cover, cover.book.title, cover.height * 0.25, 90); 180 drawText(context, cover, cover.book.author, cover.height * 0.5, titleScale * 0.8); 181 if (cover.generator) 182 { 183 drawText( 184 context, 185 cover, 186 "Generated by " ~ cover.generator, 187 cover.height * 0.9, 188 titleScale * 0.5); 189 } 190 191 context.restore; 192 } 193 194 /// Visible for testing 195 public void renderPngCover(Cover cover, string filename) @trusted 196 { 197 import cairo.ImageSurface; 198 auto surface = ImageSurface.create(CairoFormat.ARGB32, cover.width, cover.height); 199 renderCover(cover, surface); 200 surface.writeToPng(filename); 201 } 202 203 /// Visible for testing 204 public void renderSvgCover(Cover cover, string filename) @trusted 205 { 206 import cairo.SvgSurface; 207 auto surface = SvgSurface.create(filename, cover.width, cover.height); 208 renderCover(cover, surface); 209 surface.flush; 210 surface.finish; 211 } 212 213 private double drawText(Context context, Cover cover, string text, double y, double scale) 214 @trusted 215 { 216 // TODO split text onto multiple lines? 217 double happyWidth = cover.width * 0.8; 218 double actualWidth; 219 while (scale > 5) 220 { 221 context.setFontSize(scale); 222 cairo_text_extents_t extents; 223 context.textExtents(text, &extents); 224 actualWidth = extents.width; 225 if (actualWidth <= happyWidth) 226 { 227 auto dx = (cover.width - actualWidth) * 0.5; 228 infof("writing text %s at size %s at position (%s, %s)", text, scale, dx, y); 229 context.setFontSize(scale); 230 context.moveTo(dx, y); 231 break; 232 } 233 scale -= 5; 234 } 235 236 context.textPath(text); 237 context.setSourceRgb(0.2, 0.25, 0.55); 238 context.fillPreserve; 239 context.setSourceRgb(0, 0, 0); 240 context.setLineWidth(scale * 0.025); 241 context.stroke; 242 return scale; 243 } 244 }