1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' 7 8import re, random, unicodedata, numbers 9from collections import namedtuple 10from contextlib import contextmanager 11from math import ceil, sqrt, cos, sin, atan2 12from polyglot.builtins import iteritems, itervalues, string_or_bytes 13from itertools import chain 14 15from qt.core import ( 16 QImage, Qt, QFont, QPainter, QPointF, QTextLayout, QTextOption, 17 QFontMetrics, QTextCharFormat, QColor, QRect, QBrush, QLinearGradient, 18 QPainterPath, QPen, QRectF, QTransform, QRadialGradient 19) 20 21from calibre import force_unicode, fit_image 22from calibre.constants import __appname__, __version__ 23from calibre.ebooks.metadata import fmt_sidx 24from calibre.ebooks.metadata.book.base import Metadata 25from calibre.ebooks.metadata.book.formatter import SafeFormat 26from calibre.gui2 import ensure_app, config, load_builtin_fonts, pixmap_to_data 27from calibre.utils.cleantext import clean_ascii_chars, clean_xml_chars 28from calibre.utils.config import JSONConfig 29 30# Default settings {{{ 31cprefs = JSONConfig('cover_generation') 32cprefs.defaults['title_font_size'] = 120 # px 33cprefs.defaults['subtitle_font_size'] = 80 # px 34cprefs.defaults['footer_font_size'] = 80 # px 35cprefs.defaults['cover_width'] = 1200 # px 36cprefs.defaults['cover_height'] = 1600 # px 37cprefs.defaults['title_font_family'] = None 38cprefs.defaults['subtitle_font_family'] = None 39cprefs.defaults['footer_font_family'] = None 40cprefs.defaults['color_themes'] = {} 41cprefs.defaults['disabled_color_themes'] = [] 42cprefs.defaults['disabled_styles'] = [] 43cprefs.defaults['title_template'] = '<b>{title}' 44cprefs.defaults['subtitle_template'] = '''{series:'test($, strcat("<i>", $, "</i> - ", raw_field("formatted_series_index")), "")'}''' 45cprefs.defaults['footer_template'] = r'''program: 46# Show at most two authors, on separate lines. 47authors = field('authors'); 48num = count(authors, ' & '); 49authors = sublist(authors, 0, 2, ' & '); 50authors = list_re(authors, ' & ', '(.+)', '<b>\1'); 51authors = re(authors, ' & ', '<br>'); 52re(authors, '&&', '&') 53''' 54Prefs = namedtuple('Prefs', ' '.join(sorted(cprefs.defaults))) 55 56_use_roman = None 57 58 59def get_use_roman(): 60 global _use_roman 61 if _use_roman is None: 62 return config['use_roman_numerals_for_series_number'] 63 return _use_roman 64 65 66def set_use_roman(val): 67 global _use_roman 68 _use_roman = bool(val) 69 70# }}} 71 72 73# Draw text {{{ 74Point = namedtuple('Point', 'x y') 75 76 77def parse_text_formatting(text): 78 pos = 0 79 tokens = [] 80 for m in re.finditer(r'</?([a-zA-Z1-6]+)/?>', text): 81 q = text[pos:m.start()] 82 if q: 83 tokens.append((False, q)) 84 tokens.append((True, (m.group(1).lower(), '/' in m.group()[:2]))) 85 pos = m.end() 86 if tokens: 87 if text[pos:]: 88 tokens.append((False, text[pos:])) 89 else: 90 tokens = [(False, text)] 91 92 ranges, open_ranges, text = [], [], [] 93 offset = 0 94 for is_tag, tok in tokens: 95 if is_tag: 96 tag, closing = tok 97 if closing: 98 if open_ranges: 99 r = open_ranges.pop() 100 r[-1] = offset - r[-2] 101 if r[-1] > 0: 102 ranges.append(r) 103 else: 104 if tag in {'b', 'strong', 'i', 'em'}: 105 open_ranges.append([tag, offset, -1]) 106 else: 107 offset += len(tok.replace('&', '&')) 108 text.append(tok) 109 text = ''.join(text) 110 formats = [] 111 for tag, start, length in chain(ranges, open_ranges): 112 fmt = QTextCharFormat() 113 if tag in {'b', 'strong'}: 114 fmt.setFontWeight(QFont.Weight.Bold) 115 elif tag in {'i', 'em'}: 116 fmt.setFontItalic(True) 117 else: 118 continue 119 if length == -1: 120 length = len(text) - start 121 if length > 0: 122 r = QTextLayout.FormatRange() 123 r.format = fmt 124 r.start, r.length = start, length 125 formats.append(r) 126 return text, formats 127 128 129class Block: 130 131 def __init__(self, text='', width=0, font=None, img=None, max_height=100, align=Qt.AlignmentFlag.AlignCenter): 132 self.layouts = [] 133 self._position = Point(0, 0) 134 self.leading = self.line_spacing = 0 135 if font is not None: 136 fm = QFontMetrics(font, img) 137 self.leading = fm.leading() 138 self.line_spacing = fm.lineSpacing() 139 for text in text.split('<br>') if text else (): 140 text, formats = parse_text_formatting(sanitize(text)) 141 l = QTextLayout(unescape_formatting(text), font, img) 142 l.setAdditionalFormats(formats) 143 to = QTextOption(align) 144 to.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) 145 l.setTextOption(to) 146 147 l.beginLayout() 148 height = 0 149 while height + 3*self.leading < max_height: 150 line = l.createLine() 151 if not line.isValid(): 152 break 153 line.setLineWidth(width) 154 height += self.leading 155 line.setPosition(QPointF(0, height)) 156 height += line.height() 157 max_height -= height 158 l.endLayout() 159 if self.layouts: 160 self.layouts.append(self.leading) 161 else: 162 self._position = Point(l.position().x(), l.position().y()) 163 self.layouts.append(l) 164 if self.layouts: 165 self.layouts.append(self.leading) 166 167 @property 168 def height(self): 169 return int(ceil(sum(l if isinstance(l, numbers.Number) else l.boundingRect().height() for l in self.layouts))) 170 171 @property 172 def position(self): 173 return self._position 174 175 @position.setter 176 def position(self, new_pos): 177 (x, y) = new_pos 178 self._position = Point(x, y) 179 if self.layouts: 180 self.layouts[0].setPosition(QPointF(x, y)) 181 y += self.layouts[0].boundingRect().height() 182 for l in self.layouts[1:]: 183 if isinstance(l, numbers.Number): 184 y += l 185 else: 186 l.setPosition(QPointF(x, y)) 187 y += l.boundingRect().height() 188 189 def draw(self, painter): 190 for l in self.layouts: 191 if hasattr(l, 'draw'): 192 # Etch effect for the text 193 painter.save() 194 painter.setRenderHints(QPainter.RenderHint.TextAntialiasing | QPainter.RenderHint.Antialiasing) 195 painter.save() 196 painter.setPen(QColor(255, 255, 255, 125)) 197 l.draw(painter, QPointF(1, 1)) 198 painter.restore() 199 l.draw(painter, QPointF()) 200 painter.restore() 201 202 203def layout_text(prefs, img, title, subtitle, footer, max_height, style): 204 width = img.width() - 2 * style.hmargin 205 title, subtitle, footer = title, subtitle, footer 206 title_font = QFont(prefs.title_font_family or 'Liberation Serif') 207 title_font.setPixelSize(prefs.title_font_size) 208 title_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) 209 title_block = Block(title, width, title_font, img, max_height, style.TITLE_ALIGN) 210 title_block.position = style.hmargin, style.vmargin 211 subtitle_block = Block() 212 if subtitle: 213 subtitle_font = QFont(prefs.subtitle_font_family or 'Liberation Sans') 214 subtitle_font.setPixelSize(prefs.subtitle_font_size) 215 subtitle_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) 216 gap = 2 * title_block.leading 217 mh = max_height - title_block.height - gap 218 subtitle_block = Block(subtitle, width, subtitle_font, img, mh, style.SUBTITLE_ALIGN) 219 subtitle_block.position = style.hmargin, title_block.position.y + title_block.height + gap 220 221 footer_font = QFont(prefs.footer_font_family or 'Liberation Serif') 222 footer_font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) 223 footer_font.setPixelSize(prefs.footer_font_size) 224 footer_block = Block(footer, width, footer_font, img, max_height, style.FOOTER_ALIGN) 225 footer_block.position = style.hmargin, img.height() - style.vmargin - footer_block.height 226 227 return title_block, subtitle_block, footer_block 228 229# }}} 230 231# Format text using templates {{{ 232 233 234def sanitize(s): 235 return unicodedata.normalize('NFC', clean_xml_chars(clean_ascii_chars(force_unicode(s or '')))) 236 237 238_formatter = None 239_template_cache = {} 240 241 242def escape_formatting(val): 243 return val.replace('&', '&').replace('<', '<').replace('>', '>') 244 245 246def unescape_formatting(val): 247 return val.replace('<', '<').replace('>', '>').replace('&', '&') 248 249 250class Formatter(SafeFormat): 251 252 def get_value(self, orig_key, args, kwargs): 253 ans = SafeFormat.get_value(self, orig_key, args, kwargs) 254 return escape_formatting(ans) 255 256 257def formatter(): 258 global _formatter 259 if _formatter is None: 260 _formatter = Formatter() 261 return _formatter 262 263 264def format_fields(mi, prefs): 265 f = formatter() 266 267 def safe_format(field): 268 return f.safe_format( 269 getattr(prefs, field), mi, _('Template error'), mi, template_cache=_template_cache 270 ) 271 return map(safe_format, ('title_template', 'subtitle_template', 'footer_template')) 272 273 274@contextmanager 275def preserve_fields(obj, fields): 276 if isinstance(fields, string_or_bytes): 277 fields = fields.split() 278 null = object() 279 mem = {f:getattr(obj, f, null) for f in fields} 280 try: 281 yield 282 finally: 283 for f, val in iteritems(mem): 284 if val is null: 285 delattr(obj, f) 286 else: 287 setattr(obj, f, val) 288 289 290def format_text(mi, prefs): 291 with preserve_fields(mi, 'authors formatted_series_index'): 292 mi.authors = [a for a in mi.authors if a != _('Unknown')] 293 mi.formatted_series_index = fmt_sidx(mi.series_index or 0, use_roman=get_use_roman()) 294 return tuple(format_fields(mi, prefs)) 295# }}} 296 297 298# Colors {{{ 299ColorTheme = namedtuple('ColorTheme', 'color1 color2 contrast_color1 contrast_color2') 300 301 302def to_theme(x): 303 return {k:v for k, v in zip(ColorTheme._fields[:4], x.split())} 304 305 306fallback_colors = to_theme('ffffff 000000 000000 ffffff') 307 308default_color_themes = { 309 'Earth' : to_theme('e8d9ac c7b07b 564628 382d1a'), 310 'Grass' : to_theme('d8edb5 abc8a4 375d3b 183128'), 311 'Water' : to_theme('d3dcf2 829fe4 00448d 00305a'), 312 'Silver': to_theme('e6f1f5 aab3b6 6e7476 3b3e40'), 313} 314 315 316def theme_to_colors(theme): 317 colors = {k:QColor('#' + theme[k]) for k in ColorTheme._fields} 318 return ColorTheme(**colors) 319 320 321def load_color_themes(prefs): 322 t = default_color_themes.copy() 323 t.update(prefs.color_themes) 324 disabled = frozenset(prefs.disabled_color_themes) 325 ans = [theme_to_colors(v) for k, v in iteritems(t) if k not in disabled] 326 if not ans: 327 # Ignore disabled and return only the builtin color themes 328 ans = [theme_to_colors(v) for k, v in iteritems(default_color_themes)] 329 return ans 330 331 332def color(color_theme, name): 333 ans = getattr(color_theme, name) 334 if not ans.isValid(): 335 ans = QColor('#' + fallback_colors[name]) 336 return ans 337 338# }}} 339 340# Styles {{{ 341 342 343class Style: 344 345 TITLE_ALIGN = SUBTITLE_ALIGN = FOOTER_ALIGN = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop 346 347 def __init__(self, color_theme, prefs): 348 self.load_colors(color_theme) 349 self.calculate_margins(prefs) 350 351 def calculate_margins(self, prefs): 352 self.hmargin = int((50 / 600) * prefs.cover_width) 353 self.vmargin = int((50 / 800) * prefs.cover_height) 354 355 def load_colors(self, color_theme): 356 self.color1 = color(color_theme, 'color1') 357 self.color2 = color(color_theme, 'color2') 358 self.ccolor1 = color(color_theme, 'contrast_color1') 359 self.ccolor2 = color(color_theme, 'contrast_color2') 360 361 362class Cross(Style): 363 364 NAME = 'The Cross' 365 GUI_NAME = _('The Cross') 366 367 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 368 painter.fillRect(rect, self.color1) 369 r = QRect(0, int(title_block.position.y), rect.width(), 370 title_block.height + subtitle_block.height + subtitle_block.line_spacing // 2 + title_block.leading) 371 painter.save() 372 p = QPainterPath() 373 p.addRoundedRect(QRectF(r), 10, 10 * r.width()/r.height(), Qt.SizeMode.RelativeSize) 374 painter.setClipPath(p) 375 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 376 painter.fillRect(r, self.color2) 377 painter.restore() 378 r = QRect(0, 0, int(title_block.position.x), rect.height()) 379 painter.fillRect(r, self.color2) 380 return self.ccolor2, self.ccolor2, self.ccolor1 381 382 383class Half(Style): 384 385 NAME = 'Half and Half' 386 GUI_NAME = _('Half and half') 387 388 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 389 g = QLinearGradient(QPointF(0, 0), QPointF(0, rect.height())) 390 g.setStops([(0, self.color1), (0.7, self.color2), (1, self.color1)]) 391 painter.fillRect(rect, QBrush(g)) 392 return self.ccolor1, self.ccolor1, self.ccolor1 393 394 395def rotate_vector(angle, x, y): 396 return x * cos(angle) - y * sin(angle), x * sin(angle) + y * cos(angle) 397 398 399def draw_curved_line(painter_path, dx, dy, c1_frac, c1_amp, c2_frac, c2_amp): 400 length = sqrt(dx * dx + dy * dy) 401 angle = atan2(dy, dx) 402 c1 = QPointF(*rotate_vector(angle, c1_frac * length, c1_amp * length)) 403 c2 = QPointF(*rotate_vector(angle, c2_frac * length, c2_amp * length)) 404 pos = painter_path.currentPosition() 405 painter_path.cubicTo(pos + c1, pos + c2, pos + QPointF(dx, dy)) 406 407 408class Banner(Style): 409 410 NAME = 'Banner' 411 GUI_NAME = _('Banner') 412 GRADE = 0.07 413 414 def calculate_margins(self, prefs): 415 Style.calculate_margins(self, prefs) 416 self.hmargin = int(0.15 * prefs.cover_width) 417 self.fold_width = int(0.1 * prefs.cover_width) 418 419 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 420 painter.fillRect(rect, self.color1) 421 top = title_block.position.y + 2 422 extra_spacing = subtitle_block.line_spacing // 2 if subtitle_block.line_spacing else title_block.line_spacing // 3 423 height = title_block.height + subtitle_block.height + extra_spacing + title_block.leading 424 right = rect.right() - self.hmargin 425 width = right - self.hmargin 426 427 # Draw main banner 428 p = main = QPainterPath(QPointF(self.hmargin, top)) 429 draw_curved_line(p, rect.width() - 2 * self.hmargin, 0, 0.1, -0.1, 0.9, -0.1) 430 deltax = self.GRADE * height 431 p.lineTo(right + deltax, top + height) 432 right_corner = p.currentPosition() 433 draw_curved_line(p, - width - 2 * deltax, 0, 0.1, 0.05, 0.9, 0.05) 434 left_corner = p.currentPosition() 435 p.closeSubpath() 436 437 # Draw fold rectangles 438 rwidth = self.fold_width 439 yfrac = 0.1 440 width23 = int(0.67 * rwidth) 441 rtop = top + height * yfrac 442 443 def draw_fold(x, m=1, corner=left_corner): 444 ans = p = QPainterPath(QPointF(x, rtop)) 445 draw_curved_line(p, rwidth*m, 0, 0.1, 0.1*m, 0.5, -0.2*m) 446 fold_upper = p.currentPosition() 447 p.lineTo(p.currentPosition() + QPointF(-deltax*m, height)) 448 fold_corner = p.currentPosition() 449 draw_curved_line(p, -rwidth*m, 0, 0.2, -0.1*m, 0.8, -0.1*m) 450 draw_curved_line(p, deltax*m, -height, 0.2, 0.1*m, 0.8, 0.1*m) 451 p = inner_fold = QPainterPath(corner) 452 dp = fold_corner - p.currentPosition() 453 draw_curved_line(p, dp.x(), dp.y(), 0.5, 0.3*m, 1, 0*m) 454 p.lineTo(fold_upper), p.closeSubpath() 455 return ans, inner_fold 456 457 left_fold, left_inner = draw_fold(self.hmargin - width23) 458 right_fold, right_inner = draw_fold(right + width23, m=-1, corner=right_corner) 459 460 painter.save() 461 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 462 pen = QPen(self.ccolor2) 463 pen.setWidth(3) 464 pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) 465 painter.setPen(pen) 466 for r in (left_fold, right_fold): 467 painter.fillPath(r, QBrush(self.color2)) 468 painter.drawPath(r) 469 for r in (left_inner, right_inner): 470 painter.fillPath(r, QBrush(self.color2.darker())) 471 painter.drawPath(r) 472 painter.fillPath(main, QBrush(self.color2)) 473 painter.drawPath(main) 474 painter.restore() 475 return self.ccolor2, self.ccolor2, self.ccolor1 476 477 478class Ornamental(Style): 479 480 NAME = 'Ornamental' 481 GUI_NAME = _('Ornamental') 482 483 # SVG vectors {{{ 484 CORNER_VECTOR = "m 67.791903,64.260958 c -4.308097,-2.07925 -4.086719,-8.29575 0.334943,-9.40552 4.119758,-1.03399 8.732363,5.05239 5.393055,7.1162 -0.55,0.33992 -1,1.04147 -1,1.55902 0,1.59332 2.597425,1.04548 5.365141,-1.1316 1.999416,-1.57274 2.634859,-2.96609 2.634859,-5.7775 0,-9.55787 -9.827495,-13.42961 -24.43221,-9.62556 -3.218823,0.83839 -5.905663,1.40089 -5.970755,1.25 -0.06509,-0.1509 -0.887601,-1.19493 -1.827799,-2.32007 -1.672708,-2.00174 -1.636693,-2.03722 1.675668,-1.65052 1.861815,0.21736 6.685863,-0.35719 10.720107,-1.27678 12.280767,-2.79934 20.195487,-0.0248 22.846932,8.0092 3.187273,9.65753 -6.423297,17.7497 -15.739941,13.25313 z m 49.881417,-20.53932 c -3.19204,-2.701 -3.72967,-6.67376 -1.24009,-9.16334 2.48236,-2.48236 5.35141,-2.67905 7.51523,-0.51523 1.85966,1.85966 2.07045,6.52954 0.37143,8.22857 -2.04025,2.04024 3.28436,1.44595 6.92316,-0.77272 9.66959,-5.89579 0.88581,-18.22422 -13.0777,-18.35516 -5.28594,-0.0496 -10.31098,1.88721 -14.26764,5.4991 -1.98835,1.81509 -2.16454,1.82692 -2.7936,0.18763 -0.40973,-1.06774 0.12141,-2.82197 1.3628,-4.50104 2.46349,-3.33205 1.67564,-4.01299 -2.891784,-2.49938 -2.85998,0.94777 -3.81038,2.05378 -5.59837,6.51495 -1.184469,2.95536 -3.346819,6.86882 -4.805219,8.69657 -1.4584,1.82776 -2.65164,4.02223 -2.65164,4.87662 0,3.24694 -4.442667,0.59094 -5.872557,-3.51085 -1.361274,-3.90495 0.408198,-8.63869 4.404043,-11.78183 5.155844,-4.05558 1.612374,-3.42079 -9.235926,1.65457 -12.882907,6.02725 -16.864953,7.18038 -24.795556,7.18038 -8.471637,0 -13.38802,-1.64157 -17.634617,-5.88816 -2.832233,-2.83224 -3.849773,-4.81378 -4.418121,-8.6038 -1.946289,-12.9787795 8.03227,-20.91713135 19.767685,-15.7259993 5.547225,2.4538018 6.993631,6.1265383 3.999564,10.1557393 -5.468513,7.35914 -15.917883,-0.19431 -10.657807,-7.7041155 1.486298,-2.1219878 1.441784,-2.2225068 -0.984223,-2.2225068 -1.397511,0 -4.010527,1.3130878 -5.806704,2.9179718 -2.773359,2.4779995 -3.265777,3.5977995 -3.265777,7.4266705 0,5.10943 2.254112,8.84197 7.492986,12.40748 8.921325,6.07175 19.286666,5.61396 37.12088,-1.63946 15.35037,-6.24321 21.294999,-7.42408 34.886123,-6.92999 11.77046,0.4279 19.35803,3.05537 24.34054,8.42878 4.97758,5.3681 2.53939,13.58271 -4.86733,16.39873 -4.17361,1.58681 -11.00702,1.19681 -13.31978,-0.76018 z m 26.50156,-0.0787 c -2.26347,-2.50111 -2.07852,-7.36311 0.39995,-10.51398 2.68134,-3.40877 10.49035,-5.69409 18.87656,-5.52426 l 6.5685,0.13301 -7.84029,0.82767 c -8.47925,0.89511 -12.76997,2.82233 -16.03465,7.20213 -1.92294,2.57976 -1.96722,3.00481 -0.57298,5.5 1.00296,1.79495 2.50427,2.81821 4.46514,3.04333 2.92852,0.33623 2.93789,0.32121 1.08045,-1.73124 -1.53602,-1.69728 -1.64654,-2.34411 -0.61324,-3.58916 2.84565,-3.4288 7.14497,-0.49759 5.03976,3.43603 -1.86726,3.48903 -8.65528,4.21532 -11.3692,1.21647 z m -4.17462,-14.20302 c -0.38836,-0.62838 -0.23556,-1.61305 0.33954,-2.18816 1.3439,-1.34389 4.47714,-0.17168 3.93038,1.47045 -0.5566,1.67168 -3.38637,2.14732 -4.26992,0.71771 z m -8.48037,-9.1829 c -12.462,-4.1101 -12.53952,-4.12156 -25.49998,-3.7694 -24.020921,0.65269 -32.338219,0.31756 -37.082166,-1.49417 -5.113999,-1.95305 -8.192504,-6.3647405 -6.485463,-9.2940713 0.566827,-0.972691 1.020091,-1.181447 1.037211,-0.477701 0.01685,0.692606 1.268676,1.2499998 2.807321,1.2499998 1.685814,0 4.868609,1.571672 8.10041,4.0000015 4.221481,3.171961 6.182506,3.999221 9.473089,3.996261 l 4.149585,-0.004 -3.249996,-1.98156 c -3.056252,-1.863441 -4.051566,-3.8760635 -2.623216,-5.3044145 0.794,-0.794 6.188222,1.901516 9.064482,4.5295635 1.858669,1.698271 3.461409,1.980521 10.559493,1.859621 11.30984,-0.19266 20.89052,1.29095 31.97905,4.95208 7.63881,2.52213 11.51931,3.16471 22.05074,3.65141 7.02931,0.32486 13.01836,0.97543 13.30902,1.44571 0.29065,0.47029 -5.2356,0.83436 -12.28056,0.80906 -12.25942,-0.044 -13.34537,-0.2229 -25.30902,-4.16865 z" # noqa 485 # }}} 486 PATH_CACHE = {} 487 VIEWPORT = (400, 500) 488 489 def calculate_margins(self, prefs): 490 self.hmargin = int((51 / self.VIEWPORT[0]) * prefs.cover_width) 491 self.vmargin = int((83 / self.VIEWPORT[1]) * prefs.cover_height) 492 493 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 494 if not self.PATH_CACHE: 495 from calibre.utils.speedups import svg_path_to_painter_path 496 try: 497 self.__class__.PATH_CACHE['corner'] = svg_path_to_painter_path(self.CORNER_VECTOR) 498 except Exception: 499 import traceback 500 traceback.print_exc() 501 p = painter 502 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 503 g = QRadialGradient(QPointF(rect.center()), rect.width()) 504 g.setColorAt(0, self.color1), g.setColorAt(1, self.color2) 505 painter.fillRect(rect, QBrush(g)) 506 painter.save() 507 painter.setWindow(0, 0, *self.VIEWPORT) 508 try: 509 path = self.PATH_CACHE['corner'] 510 except KeyError: 511 path = QPainterPath() 512 pen = p.pen() 513 pen.setColor(self.ccolor1) 514 p.setPen(pen) 515 516 def corner(): 517 b = QBrush(self.ccolor1) 518 p.fillPath(path, b) 519 p.rotate(90), p.translate(100, -100), p.scale(1, -1), p.translate(-103, -97) 520 p.fillPath(path, b) 521 p.setWorldTransform(QTransform()) 522 # Top-left corner 523 corner() 524 # Top right corner 525 p.scale(-1, 1), p.translate(-400, 0), corner() 526 # Bottom left corner 527 p.scale(1, -1), p.translate(0, -500), corner() 528 # Bottom right corner 529 p.scale(-1, -1), p.translate(-400, -500), corner() 530 for y in (28.4, 471.7): 531 p.drawLine(QPointF(160, y), QPointF(240, y)) 532 for x in (31.3, 368.7): 533 p.drawLine(QPointF(x, 155), QPointF(x, 345)) 534 pen.setWidthF(1.8) 535 p.setPen(pen) 536 for y in (23.8, 476.7): 537 p.drawLine(QPointF(160, y), QPointF(240, y)) 538 for x in (26.3, 373.7): 539 p.drawLine(QPointF(x, 155), QPointF(x, 345)) 540 painter.restore() 541 542 return self.ccolor2, self.ccolor2, self.ccolor1 543 544 545class Blocks(Style): 546 547 NAME = 'Blocks' 548 GUI_NAME = _('Blocks') 549 FOOTER_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop 550 551 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 552 painter.fillRect(rect, self.color1) 553 y = rect.height() - rect.height() // 3 554 r = QRect(rect) 555 r.setBottom(y) 556 painter.fillRect(rect, self.color1) 557 r = QRect(rect) 558 r.setTop(y) 559 painter.fillRect(r, self.color2) 560 return self.ccolor1, self.ccolor1, self.ccolor2 561 562 563def all_styles(): 564 return { 565 x.NAME for x in itervalues(globals()) if 566 isinstance(x, type) and issubclass(x, Style) and x is not Style 567 } 568 569 570def load_styles(prefs, respect_disabled=True): 571 disabled = frozenset(prefs.disabled_styles) if respect_disabled else () 572 ans = tuple(x for x in itervalues(globals()) if 573 isinstance(x, type) and issubclass(x, Style) and x is not Style and x.NAME not in disabled) 574 if not ans and disabled: 575 # If all styles have been disabled, ignore the disabling and return all 576 # the styles 577 ans = load_styles(prefs, respect_disabled=False) 578 return ans 579 580# }}} 581 582 583def init_environment(): 584 ensure_app() 585 load_builtin_fonts() 586 587 588def generate_cover(mi, prefs=None, as_qimage=False): 589 init_environment() 590 prefs = prefs or cprefs 591 prefs = {k:prefs.get(k) for k in cprefs.defaults} 592 prefs = Prefs(**prefs) 593 color_theme = random.choice(load_color_themes(prefs)) 594 style = random.choice(load_styles(prefs))(color_theme, prefs) 595 title, subtitle, footer = format_text(mi, prefs) 596 img = QImage(prefs.cover_width, prefs.cover_height, QImage.Format.Format_ARGB32) 597 title_block, subtitle_block, footer_block = layout_text( 598 prefs, img, title, subtitle, footer, img.height() // 3, style) 599 p = QPainter(img) 600 rect = QRect(0, 0, img.width(), img.height()) 601 colors = style(p, rect, color_theme, title_block, subtitle_block, footer_block) 602 for block, color in zip((title_block, subtitle_block, footer_block), colors): 603 p.setPen(color) 604 block.draw(p) 605 p.end() 606 img.setText('Generated cover', '%s %s' % (__appname__, __version__)) 607 if as_qimage: 608 return img 609 return pixmap_to_data(img) 610 611 612def override_prefs(base_prefs, **overrides): 613 ans = {k:overrides.get(k, base_prefs[k]) for k in cprefs.defaults} 614 override_color_theme = overrides.get('override_color_theme') 615 if override_color_theme is not None: 616 all_themes = set(default_color_themes) | set(ans['color_themes']) 617 if override_color_theme in all_themes: 618 all_themes.discard(override_color_theme) 619 ans['disabled_color_themes'] = all_themes 620 override_style = overrides.get('override_style') 621 if override_style is not None: 622 styles = all_styles() 623 if override_style in styles: 624 styles.discard(override_style) 625 ans['disabled_styles'] = styles 626 627 return ans 628 629 630def create_cover(title, authors, series=None, series_index=1, prefs=None, as_qimage=False): 631 ' Create a cover from the specified title, author and series. Any user set' 632 ' templates are ignored, to ensure that the specified metadata is used. ' 633 mi = Metadata(title, authors) 634 if series: 635 mi.series, mi.series_index = series, series_index 636 d = cprefs.defaults 637 prefs = override_prefs( 638 prefs or cprefs, title_template=d['title_template'], subtitle_template=d['subtitle_template'], footer_template=d['footer_template']) 639 return generate_cover(mi, prefs=prefs, as_qimage=as_qimage) 640 641 642def calibre_cover2(title, author_string='', series_string='', prefs=None, as_qimage=False, logo_path=None): 643 init_environment() 644 title, subtitle, footer = '<b>' + escape_formatting(title), '<i>' + escape_formatting(series_string), '<b>' + escape_formatting(author_string) 645 prefs = prefs or cprefs 646 prefs = {k:prefs.get(k) for k in cprefs.defaults} 647 scale = 800. / prefs['cover_height'] 648 scale_cover(prefs, scale) 649 prefs = Prefs(**prefs) 650 img = QImage(prefs.cover_width, prefs.cover_height, QImage.Format.Format_ARGB32) 651 img.fill(Qt.GlobalColor.white) 652 # colors = to_theme('ffffff ffffff 000000 000000') 653 color_theme = theme_to_colors(fallback_colors) 654 655 class CalibeLogoStyle(Style): 656 NAME = GUI_NAME = 'calibre' 657 658 def __call__(self, painter, rect, color_theme, title_block, subtitle_block, footer_block): 659 top = title_block.position.y + 10 660 extra_spacing = subtitle_block.line_spacing // 2 if subtitle_block.line_spacing else title_block.line_spacing // 3 661 height = title_block.height + subtitle_block.height + extra_spacing + title_block.leading 662 top += height + 25 663 bottom = footer_block.position.y - 50 664 logo = QImage(logo_path or I('library.png')) 665 pwidth, pheight = rect.width(), bottom - top 666 scaled, width, height = fit_image(logo.width(), logo.height(), pwidth, pheight) 667 x, y = (pwidth - width) // 2, (pheight - height) // 2 668 rect = QRect(x, top + y, width, height) 669 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) 670 painter.drawImage(rect, logo) 671 return self.ccolor1, self.ccolor1, self.ccolor1 672 style = CalibeLogoStyle(color_theme, prefs) 673 title_block, subtitle_block, footer_block = layout_text( 674 prefs, img, title, subtitle, footer, img.height() // 3, style) 675 p = QPainter(img) 676 rect = QRect(0, 0, img.width(), img.height()) 677 colors = style(p, rect, color_theme, title_block, subtitle_block, footer_block) 678 for block, color in zip((title_block, subtitle_block, footer_block), colors): 679 p.setPen(color) 680 block.draw(p) 681 p.end() 682 img.setText('Generated cover', '%s %s' % (__appname__, __version__)) 683 if as_qimage: 684 return img 685 return pixmap_to_data(img) 686 687 688def message_image(text, width=500, height=400, font_size=20): 689 init_environment() 690 img = QImage(width, height, QImage.Format.Format_ARGB32) 691 img.fill(Qt.GlobalColor.white) 692 p = QPainter(img) 693 f = QFont() 694 f.setPixelSize(font_size) 695 p.setFont(f) 696 r = img.rect().adjusted(10, 10, -10, -10) 697 p.drawText(r, Qt.AlignmentFlag.AlignJustify | Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextWordWrap, text) 698 p.end() 699 return pixmap_to_data(img) 700 701 702def scale_cover(prefs, scale): 703 for x in ('cover_width', 'cover_height', 'title_font_size', 'subtitle_font_size', 'footer_font_size'): 704 prefs[x] = int(scale * prefs[x]) 705 706 707def generate_masthead(title, output_path=None, width=600, height=60, as_qimage=False, font_family=None): 708 init_environment() 709 font_family = font_family or cprefs['title_font_family'] or 'Liberation Serif' 710 img = QImage(width, height, QImage.Format.Format_ARGB32) 711 img.fill(Qt.GlobalColor.white) 712 p = QPainter(img) 713 p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.TextAntialiasing) 714 f = QFont(font_family) 715 f.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) 716 f.setPixelSize((height * 3) // 4), f.setBold(True) 717 p.setFont(f) 718 p.drawText(img.rect(), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, sanitize(title)) 719 p.end() 720 if as_qimage: 721 return img 722 data = pixmap_to_data(img) 723 if output_path is None: 724 return data 725 with open(output_path, 'wb') as f: 726 f.write(data) 727 728 729def test(scale=0.25): 730 from qt.core import QLabel, QPixmap, QMainWindow, QWidget, QScrollArea, QGridLayout 731 from calibre.gui2 import Application 732 app = Application([]) 733 mi = Metadata('Unknown', ['Kovid Goyal', 'John & Doe', 'Author']) 734 mi.series = 'A series & styles' 735 m = QMainWindow() 736 sa = QScrollArea(m) 737 w = QWidget(m) 738 sa.setWidget(w) 739 l = QGridLayout(w) 740 w.setLayout(l), l.setSpacing(30) 741 scale *= w.devicePixelRatioF() 742 labels = [] 743 for r, color in enumerate(sorted(default_color_themes)): 744 for c, style in enumerate(sorted(all_styles())): 745 mi.series_index = c + 1 746 mi.title = 'An algorithmic cover [%s]' % color 747 prefs = override_prefs(cprefs, override_color_theme=color, override_style=style) 748 scale_cover(prefs, scale) 749 img = generate_cover(mi, prefs=prefs, as_qimage=True) 750 img.setDevicePixelRatio(w.devicePixelRatioF()) 751 la = QLabel() 752 la.setPixmap(QPixmap.fromImage(img)) 753 l.addWidget(la, r, c) 754 labels.append(la) 755 m.setCentralWidget(sa) 756 w.resize(w.sizeHint()) 757 m.show() 758 app.exec() 759 760 761if __name__ == '__main__': 762 test() 763