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 }