reorder CSS rules for ePUB because FBReader is weird
[librarian.git] / librarian / cover.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of Librarian, licensed under GNU Affero GPLv3 or later.
4 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 #
6 import re
7 from PIL import Image, ImageFont, ImageDraw, ImageFilter
8 from StringIO import StringIO
9 from librarian import get_resource, OutputFile, URLOpener
10
11
12 class Metric(object):
13     """Gets metrics from an object, scaling it by a factor."""
14     def __init__(self, obj, scale):
15         self._obj = obj
16         self._scale = float(scale)
17
18     def __getattr__(self, name):
19         src = getattr(self._obj, name)
20         if src and self._scale:
21             return type(src)(self._scale * src)
22         else:
23             return src
24
25
26 class TextBox(object):
27     """Creates an Image with a series of centered strings."""
28
29     SHADOW_X = 3
30     SHADOW_Y = 3
31     SHADOW_BLUR = 3
32
33     def __init__(self, max_width, max_height, padding_x=None, padding_y=None):
34         if padding_x is None:
35             padding_x = self.SHADOW_X + self.SHADOW_BLUR
36         if padding_y is None:
37             padding_y = self.SHADOW_Y + self.SHADOW_BLUR
38
39         self.max_width = max_width
40         self.max_text_width = max_width - 2 * padding_x
41         self.padding_y = padding_y
42         self.height = padding_y
43         self.img = Image.new('RGBA', (max_width, max_height))
44         self.draw = ImageDraw.Draw(self.img)
45         self.shadow_img = None
46         self.shadow_draw = None
47
48     def skip(self, height):
49         """Skips some vertical space."""
50         self.height += height
51
52     def text(self, text, color='#000', font=None, line_height=20,
53              shadow_color=None):
54         """Writes some centered text."""
55         text = re.sub(r'\s+', ' ', text)
56         if shadow_color:
57             if not self.shadow_img:
58                 self.shadow_img = Image.new('RGBA', self.img.size)
59                 self.shadow_draw = ImageDraw.Draw(self.shadow_img)
60         while text:
61             line = text
62             line_width = self.draw.textsize(line, font=font)[0]
63             while line_width > self.max_text_width:
64                 parts = line.rsplit(' ', 1)
65                 if len(parts) == 1:
66                     line_width = self.max_text_width
67                     break
68                 line = parts[0]
69                 line_width = self.draw.textsize(line, font=font)[0]
70             line = line.strip() + ' '
71
72             pos_x = (self.max_width - line_width) / 2
73
74             if shadow_color:
75                 self.shadow_draw.text(
76                         (pos_x + self.SHADOW_X, self.height + self.SHADOW_Y),
77                         line, font=font, fill=shadow_color
78                 )
79
80             self.draw.text((pos_x, self.height), line, font=font, fill=color)
81             self.height += line_height
82             # go to next line
83             text = text[len(line):]
84
85     def image(self):
86         """Creates the actual Image object."""
87         image = Image.new('RGBA', (self.max_width,
88                                    self.height + self.padding_y))
89         if self.shadow_img:
90             shadow = self.shadow_img.filter(ImageFilter.BLUR)
91             image.paste(shadow, (0, 0), shadow)
92             image.paste(self.img, (0, 0), self.img)
93         else:
94             image.paste(self.img, (0, 0))
95         return image
96
97
98 class Cover(object):
99     """Abstract base class for cover images generator."""
100     width = 600
101     height = 800
102     background_color = '#fff'
103     background_img = None
104
105     author_top = 100
106     author_margin_left = 20
107     author_margin_right = 20
108     author_lineskip = 40
109     author_color = '#000'
110     author_shadow = None
111     author_font_ttf = get_resource('fonts/DejaVuSerif.ttf')
112     author_font_size = 30
113
114     title_top = 100
115     title_margin_left = 20
116     title_margin_right = 20
117     title_lineskip = 54
118     title_color = '#000'
119     title_shadow = None
120     title_font_ttf = get_resource('fonts/DejaVuSerif.ttf')
121     title_font_size = 40
122
123     logo_bottom = None
124     logo_width = None
125     uses_dc_cover = False
126
127     format = 'JPEG'
128     scale = 1
129     scale_after = 1
130
131     exts = {
132         'JPEG': 'jpg',
133         'PNG': 'png',
134         }
135
136     mime_types = {
137         'JPEG': 'image/jpeg',
138         'PNG': 'image/png',
139         }
140
141     def __init__(self, book_info, format=None, width=None, height=None):
142         self.author = ", ".join(auth.readable() for auth in book_info.authors)
143         self.title = book_info.title
144         if format is not None:
145             self.format = format
146         if width and height:
147             self.height = height * self.width / width
148         scale = max(float(width or 0) / self.width, float(height or 0) / self.height)
149         if scale >= 1:
150             self.scale = scale
151         elif scale:
152             self.scale_after = scale
153
154     def pretty_author(self):
155         """Allows for decorating author's name."""
156         return self.author
157
158     def pretty_title(self):
159         """Allows for decorating title."""
160         return self.title
161
162     def image(self):
163         metr = Metric(self, self.scale)
164         img = Image.new('RGB', (metr.width, metr.height), self.background_color)
165
166         if self.background_img:
167             background = Image.open(self.background_img)
168             img.paste(background, None, background)
169             del background
170
171         # WL logo
172         if metr.logo_width:
173             logo = Image.open(get_resource('res/wl-logo.png'))
174             logo = logo.resize((metr.logo_width, logo.size[1] * metr.logo_width / logo.size[0]))
175             img.paste(logo, ((metr.width - metr.logo_width) / 2, img.size[1] - logo.size[1] - metr.logo_bottom))
176
177         top = metr.author_top
178         tbox = TextBox(
179             metr.width - metr.author_margin_left - metr.author_margin_right,
180             metr.height - top,
181             )
182             
183         author_font = ImageFont.truetype(
184             self.author_font_ttf, metr.author_font_size)
185         tbox.text(self.pretty_author(), self.author_color, author_font, metr.author_lineskip, self.author_shadow)
186         text_img = tbox.image()
187         img.paste(text_img, (metr.author_margin_left, top), text_img)
188
189         top += text_img.size[1] + metr.title_top
190         tbox = TextBox(
191             metr.width - metr.title_margin_left - metr.title_margin_right,
192             metr.height - top,
193             )
194         title_font = ImageFont.truetype(
195             self.title_font_ttf, metr.title_font_size)
196         tbox.text(self.pretty_title(), self.title_color, title_font, metr.title_lineskip, self.title_shadow)
197         text_img = tbox.image()
198         img.paste(text_img, (metr.title_margin_left, top), text_img)
199
200         return img
201
202     def final_image(self):
203         img = self.image()
204         if self.scale_after != 1:
205             img = img.resize((
206                     int(round(img.size[0] * self.scale_after)),
207                     int(round(img.size[1] * self.scale_after))),
208                 Image.ANTIALIAS)
209         return img
210
211     def mime_type(self):
212         return self.mime_types[self.format]
213
214     def ext(self):
215         return self.exts[self.format]
216
217     def save(self, *args, **kwargs):
218         default_kwargs = {
219                 'format': self.format,
220                 'quality': 95,
221         }
222         default_kwargs.update(kwargs)
223         return self.final_image().save(*args, **default_kwargs)
224
225     def output_file(self, *args, **kwargs):
226         imgstr = StringIO()
227         self.save(imgstr, *args, **kwargs)
228         return OutputFile.from_string(imgstr.getvalue())
229
230
231 class WLCover(Cover):
232     """Wolne Lektury cover without logos."""
233     width = 600
234     height = 833
235     uses_dc_cover = True
236     author_font_ttf = get_resource('fonts/JunicodeWL-Regular.ttf')
237     author_font_size = 20
238     author_lineskip = 30
239     title_font_ttf = get_resource('fonts/DejaVuSerif-Bold.ttf')
240     title_font_size = 30
241     title_lineskip = 40
242     title_box_width = 350
243
244     box_top_margin = 100
245     box_bottom_margin = 100
246     box_padding_y = 20
247     box_above_line = 10
248     box_below_line = 15
249     box_line_left = 75
250     box_line_right = 275
251     box_line_width = 2
252
253     logo_top = 15
254     logo_width = 140
255
256     bar_width = 35
257     bar_color = '#000'
258     box_position = 'middle'
259     background_color = '#444'
260     author_color = '#444'
261     background_img = get_resource('res/cover.png')
262     format = 'JPEG'
263
264     epoch_colors = {
265         u'Starożytność': '#9e3610',
266         u'Średniowiecze': '#564c09',
267         u'Renesans': '#8ca629',
268         u'Barok': '#a6820a',
269         u'Oświecenie': '#f2802e',
270         u'Romantyzm': '#db4b16',
271         u'Pozytywizm': '#961060',
272         u'Modernizm': '#7784e0',
273         u'Dwudziestolecie międzywojenne': '#3044cf',
274         u'Współczesność': '#06393d',
275     }
276
277     kind_box_position = {
278         u'Liryka': 'top',
279         u'Epika': 'bottom',
280     }
281
282     def __init__(self, book_info, format=None, width=None, height=None, bleed=0):
283         super(WLCover, self).__init__(book_info, format=format, width=width, height=height)
284         # Set box position.
285         self.box_position = book_info.cover_box_position or \
286             self.kind_box_position.get(book_info.kind, self.box_position)
287         # Set bar color.
288         if book_info.cover_bar_color == 'none':
289             self.bar_width = 0
290         else:
291             self.bar_color = book_info.cover_bar_color or \
292                 self.epoch_colors.get(book_info.epoch, self.bar_color)
293         # Set title color.
294         self.title_color = self.epoch_colors.get(book_info.epoch, self.title_color)
295
296         self.bleed = bleed
297         self.box_top_margin += bleed
298         self.box_bottom_margin += bleed
299         self.bar_width += bleed
300
301         if book_info.cover_url:
302             url = book_info.cover_url
303             bg_src = None
304             if bg_src is None:
305                 bg_src = URLOpener().open(url)
306             self.background_img = StringIO(bg_src.read())
307             bg_src.close()
308
309     def pretty_author(self):
310         return self.author.upper()
311
312     def add_box(self, img):
313         if self.box_position == 'none':
314             return img
315
316         metr = Metric(self, self.scale)
317
318         # Write author name.
319         box = TextBox(metr.title_box_width, metr.height, padding_y=metr.box_padding_y)
320         author_font = ImageFont.truetype(
321             self.author_font_ttf, metr.author_font_size)
322         box.text(self.pretty_author(),
323                  font=author_font,
324                  line_height=metr.author_lineskip,
325                  color=self.author_color,
326                  shadow_color=self.author_shadow)
327
328         box.skip(metr.box_above_line)
329         box.draw.line((metr.box_line_left, box.height, metr.box_line_right, box.height),
330                       fill=self.author_color, width=metr.box_line_width)
331         box.skip(metr.box_below_line)
332
333         # Write title.
334         title_font = ImageFont.truetype(
335             self.title_font_ttf, metr.title_font_size)
336         box.text(self.pretty_title(),
337                  line_height=metr.title_lineskip,
338                  font=title_font,
339                  color=self.title_color,
340                  shadow_color=self.title_shadow)
341
342         box_img = box.image()
343
344         # Find box position.
345         if self.box_position == 'top':
346             box_top = metr.box_top_margin
347         elif self.box_position == 'bottom':
348             box_top = metr.height - metr.box_bottom_margin - box_img.size[1]
349         else:   # Middle.
350             box_top = (metr.height - box_img.size[1]) / 2
351
352         box_left = metr.bar_width + (metr.width - metr.bar_width - box_img.size[0]) / 2
353
354         # Draw the white box.
355         ImageDraw.Draw(img).rectangle(
356             (box_left, box_top, box_left + box_img.size[0], box_top + box_img.size[1]), fill='#fff')
357         # Paste the contents into the white box.
358         img.paste(box_img, (box_left, box_top), box_img)
359         return img
360
361     def add_cut_lines(self, img):
362         line_ratio = 0.5
363         if self.bleed == 0:
364             return img
365         metr = Metric(self, self.scale)
366         draw = ImageDraw.Draw(img)
367         for corner_x, corner_y in ((0, 0), (metr.width, 0), (0, metr.height), (metr.width, metr.height)):
368             dir_x = 1 if corner_x == 0 else -1
369             dir_y = 1 if corner_y == 0 else -1
370             for offset in (-1, 0, 1):
371                 draw.line((corner_x, corner_y + dir_y * metr.bleed + offset,
372                            corner_x + dir_x * metr.bleed * line_ratio, corner_y + dir_y * metr.bleed + offset),
373                           fill='black' if offset == 0 else 'white', width=1)
374                 draw.line((corner_x + dir_x * metr.bleed + offset, corner_y,
375                            corner_x + dir_x * metr.bleed + offset, corner_y + dir_y * metr.bleed * line_ratio),
376                           fill='black' if offset == 0 else 'white', width=1)
377         return img
378
379     def image(self):
380         metr = Metric(self, self.scale)
381         img = Image.new('RGB', (metr.width, metr.height), self.background_color)
382         draw = ImageDraw.Draw(img)
383
384         draw.rectangle((0, 0, metr.bar_width, metr.height), fill=self.bar_color)
385
386         if self.background_img:
387             src = Image.open(self.background_img)
388             trg_size = (metr.width - metr.bar_width, metr.height)
389             if src.size[0] * trg_size[1] < src.size[1] * trg_size[0]:
390                 resized = (
391                     trg_size[0],
392                     src.size[1] * trg_size[0] / src.size[0]
393                 )
394                 cut = (resized[1] - trg_size[1]) / 2
395                 src = src.resize(resized, Image.ANTIALIAS)
396                 src = src.crop((0, cut, src.size[0], src.size[1] - cut))
397             else:
398                 resized = (
399                     src.size[0] * trg_size[1] / src.size[1],
400                     trg_size[1],
401                 )
402                 cut = (resized[0] - trg_size[0]) / 2
403                 src = src.resize(resized, Image.ANTIALIAS)
404                 src = src.crop((cut, 0, src.size[0] - cut, src.size[1]))
405
406             img.paste(src, (metr.bar_width, 0))
407             del src
408
409         img = self.add_box(img)
410
411         # img = self.add_cut_lines(img)
412
413         return img
414
415
416 class WLNoBoxCover(WLCover):
417     def add_box(self, img):
418         return img
419
420
421 class LogoWLCover(WLCover):
422     gradient_height = 90
423     gradient_logo_height = 60
424     gradient_logo_margin_right = 30
425     gradient_logo_spacing = 40
426     gradient_color = '#000'
427     gradient_opacity = .6
428     gradient_logos = [
429         'res/wl-logo-white.png',
430         'res/fnp-logo-white.png',
431     ]
432
433     def __init__(self, *args, **kwargs):
434         super(LogoWLCover, self).__init__(*args, **kwargs)
435         self.gradient_height += self.bleed
436         self.gradient_logo_margin_right += self.bleed
437
438     def image(self):
439         img = super(LogoWLCover, self).image()
440         metr = Metric(self, self.scale)
441         gradient = Image.new('RGBA', (metr.width - metr.bar_width, metr.gradient_height), self.gradient_color)
442         gradient_mask = Image.new('L', (metr.width - metr.bar_width, metr.gradient_height))
443         draw = ImageDraw.Draw(gradient_mask)
444         for line in range(0, metr.gradient_height):
445             draw.line(
446                 (0, line, metr.width - metr.bar_width, line),
447                 fill=int(255 * self.gradient_opacity * line / metr.gradient_height))
448         img.paste(gradient, (metr.bar_width, metr.height - metr.gradient_height), mask=gradient_mask)
449
450         cursor = metr.width - metr.gradient_logo_margin_right
451         logo_top = metr.height - metr.gradient_height / 2 - metr.gradient_logo_height / 2 - metr.bleed / 2
452         for logo_path in self.gradient_logos[::-1]:
453             logo = Image.open(get_resource(logo_path))
454             logo = logo.resize(
455                 (logo.size[0] * metr.gradient_logo_height / logo.size[1], metr.gradient_logo_height),
456                 Image.ANTIALIAS)
457             cursor -= logo.size[0]
458             img.paste(logo, (cursor, logo_top), mask=logo)
459             cursor -= metr.gradient_logo_spacing
460
461         return img
462
463
464 class EbookpointCover(LogoWLCover):
465     gradient_logo_height = 58
466     gradient_logo_spacing = 25
467     gradient_logos = [
468         'res/ebookpoint-logo-white.png',
469         'res/wl-logo-white.png',
470         'res/fnp-logo-white.png',
471     ]
472
473
474 class VirtualoCover(Cover):
475     width = 600
476     height = 730
477     author_top = 73
478     title_top = 73
479     logo_bottom = 25
480     logo_width = 250
481
482
483 class PrestigioCover(Cover):
484     width = 580
485     height = 783
486     background_img = get_resource('res/cover-prestigio.png')
487
488     author_top = 446
489     author_margin_left = 118
490     author_margin_right = 62
491     author_lineskip = 60
492     author_color = '#fff'
493     author_shadow = '#000'
494     author_font_ttf = get_resource('fonts/JunicodeWL-Italic.ttf')
495     author_font_size = 50
496
497     title_top = 0
498     title_margin_left = 118
499     title_margin_right = 62
500     title_lineskip = 60
501     title_color = '#fff'
502     title_shadow = '#000'
503     title_font_ttf = get_resource('fonts/JunicodeWL-Italic.ttf')
504     title_font_size = 50
505
506     def pretty_title(self):
507         return u"„%s”" % self.title
508
509
510 class BookotekaCover(Cover):
511     width = 2140
512     height = 2733
513     background_img = get_resource('res/cover-bookoteka.png')
514
515     author_top = 480
516     author_margin_left = 307
517     author_margin_right = 233
518     author_lineskip = 156
519     author_color = '#d9d919'
520     author_font_ttf = get_resource('fonts/JunicodeWL-Regular.ttf')
521     author_font_size = 130
522
523     title_top = 400
524     title_margin_left = 307
525     title_margin_right = 233
526     title_lineskip = 168
527     title_color = '#d9d919'
528     title_font_ttf = get_resource('fonts/JunicodeWL-Regular.ttf')
529     title_font_size = 140
530
531     format = 'PNG'
532
533
534 class GandalfCover(Cover):
535     width = 600
536     height = 730
537     background_img = get_resource('res/cover-gandalf.png')
538     author_font_ttf = get_resource('fonts/JunicodeWL-Regular.ttf')
539     author_font_size = 30
540     title_font_ttf = get_resource('fonts/JunicodeWL-Regular.ttf')
541     title_font_size = 40
542     logo_bottom = 25
543     logo_width = 250
544     format = 'PNG'
545
546
547 class KMLUCover(LogoWLCover):
548     gradient_logo_height = 58
549     gradient_logo_spacing = 25
550     gradient_logos = [
551         'res/kmlu-logo-white.png',
552         'res/wl-logo-white.png',
553         'res/fnp-logo-white.png',
554     ]
555
556
557 COVER_CLASSES = {
558     'default': LogoWLCover,
559     'kmlu': KMLUCover,
560 }
561
562
563 def make_cover(book_info, *args, **kwargs):
564     cover_class = COVER_CLASSES[book_info.cover_class]
565     return cover_class(book_info, *args, **kwargs)