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 }