mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 18:54:09 -04:00
Start work on new cover generation framework
Text rendering works using Qt's text layout engine which should automatically support RTL languages, fallback fonts, etc.
This commit is contained in:
parent
16e8366e1a
commit
a2cd4594fb
266
src/calibre/ebooks/covers.py
Normal file
266
src/calibre/ebooks/covers.py
Normal file
@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from contextlib import contextmanager
|
||||
from math import ceil
|
||||
from future_builtins import map
|
||||
from itertools import chain
|
||||
|
||||
from PyQt5.Qt import (
|
||||
QImage, Qt, QFont, QPainter, QPointF, QTextLayout, QTextOption,
|
||||
QFontMetrics, QTextCharFormat
|
||||
)
|
||||
|
||||
from calibre import force_unicode
|
||||
from calibre.ebooks.metadata import fmt_sidx
|
||||
from calibre.ebooks.metadata.book.formatter import SafeFormat
|
||||
from calibre.gui2 import ensure_app, config, load_builtin_fonts
|
||||
from calibre.utils.cleantext import clean_ascii_chars, clean_xml_chars
|
||||
from calibre.utils.config import JSONConfig
|
||||
|
||||
# Default settings {{{
|
||||
cprefs = JSONConfig('cover_generation')
|
||||
cprefs.defaults['title_font_size'] = 60 # px
|
||||
cprefs.defaults['subtitle_font_size'] = 40 # px
|
||||
cprefs.defaults['footer_font_size'] = 60 # px
|
||||
cprefs.defaults['cover_width'] = 600 # px
|
||||
cprefs.defaults['cover_height'] = 800 # px
|
||||
cprefs.defaults['title_font_family'] = 'Liberation Serif'
|
||||
cprefs.defaults['subtitle_font_family'] = 'Liberation Sans'
|
||||
cprefs.defaults['footer_font_family'] = 'Liberation Sans'
|
||||
cprefs.defaults['title_template'] = '<b>{title}'
|
||||
cprefs.defaults['subtitle_template'] = '''{series:'test($, strcat("<i>", $, "</i> - ", raw_field("formatted_series_index")), "")'}'''
|
||||
cprefs.defaults['footer_template'] = '''program:
|
||||
# Show at most two authors, on separate lines.
|
||||
authors = field('authors');
|
||||
num = count(authors, ' & ');
|
||||
authors = cmp(num, 2, authors, authors, sublist(authors, 0, 2, ' & '));
|
||||
authors = re(authors, ' & ', '<br>');
|
||||
re(authors, '&&', '&')
|
||||
'''
|
||||
Prefs = namedtuple('Prefs', ' '.join(sorted(cprefs.defaults)))
|
||||
# }}}
|
||||
|
||||
# Draw text {{{
|
||||
def parse_text_formatting(text):
|
||||
pos = 0
|
||||
tokens = []
|
||||
for m in re.finditer(r'</?([a-zA-Z1-6]+)/?>', text):
|
||||
q = text[pos:m.start()]
|
||||
if q:
|
||||
tokens.append((False, q))
|
||||
tokens.append((True, (m.group(1).lower(), '/' in m.group()[:2])))
|
||||
pos = m.end()
|
||||
if tokens:
|
||||
if text[pos:]:
|
||||
tokens.append((False, text[pos:]))
|
||||
else:
|
||||
tokens = [(False, text)]
|
||||
|
||||
ranges, open_ranges, text = [], [], []
|
||||
offset = 0
|
||||
for is_tag, tok in tokens:
|
||||
if is_tag:
|
||||
tag, closing = tok
|
||||
if closing:
|
||||
if open_ranges:
|
||||
r = open_ranges.pop()
|
||||
r[-1] = offset - r[-2]
|
||||
if r[-1] > 0:
|
||||
ranges.append(r)
|
||||
else:
|
||||
if tag in {'b', 'strong', 'i', 'em'}:
|
||||
open_ranges.append([tag, offset, -1])
|
||||
else:
|
||||
offset += len(tok)
|
||||
text.append(tok)
|
||||
text = ''.join(text)
|
||||
formats = []
|
||||
for tag, start, length in chain(ranges, open_ranges):
|
||||
fmt = QTextCharFormat()
|
||||
if tag in {'b', 'strong'}:
|
||||
fmt.setFontWeight(QFont.Bold)
|
||||
elif tag in {'i', 'em'}:
|
||||
fmt.setFontItalic(True)
|
||||
else:
|
||||
continue
|
||||
if length == -1:
|
||||
length = len(text) - start
|
||||
if length > 0:
|
||||
r = QTextLayout.FormatRange()
|
||||
r.format = fmt
|
||||
r.start, r.length = start, length
|
||||
formats.append(r)
|
||||
return text, formats
|
||||
|
||||
class Block(object):
|
||||
|
||||
def __init__(self, text='', width=0, font=None, img=None, max_height=100):
|
||||
self.layouts = []
|
||||
self._position = 0, 0
|
||||
self.leading = 0
|
||||
for text in text.split('<br>') if text else ():
|
||||
text, formats = parse_text_formatting(text)
|
||||
l = QTextLayout(text, font, img)
|
||||
l.setAdditionalFormats(formats)
|
||||
to = QTextOption(Qt.AlignHCenter | Qt.AlignTop)
|
||||
to.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
|
||||
l.setTextOption(to)
|
||||
|
||||
fm = QFontMetrics(font, img)
|
||||
l.beginLayout()
|
||||
height, leading = 0, fm.leading()
|
||||
while height + 3*leading < max_height:
|
||||
line = l.createLine()
|
||||
if not line.isValid():
|
||||
break
|
||||
line.setLineWidth(width)
|
||||
height += leading
|
||||
line.setPosition(QPointF(0, height))
|
||||
height += line.height()
|
||||
max_height -= height
|
||||
l.endLayout()
|
||||
if self.layouts:
|
||||
self.layouts.append(leading)
|
||||
else:
|
||||
self._position = l.position().x(), l.position().y()
|
||||
self.layouts.append(l)
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return int(ceil(sum(l if isinstance(l, (int, float)) else l.boundingRect().height() for l in self.layouts)))
|
||||
|
||||
@dynamic_property
|
||||
def position(self):
|
||||
def fget(self):
|
||||
return self._position
|
||||
def fset(self, (x, y)):
|
||||
self._position = x, y
|
||||
if self.layouts:
|
||||
self.layouts[0].setPosition(QPointF(x, y))
|
||||
y += self.layouts[0].boundingRect().height()
|
||||
for l in self.layouts[1:]:
|
||||
if isinstance(l, (int, float)):
|
||||
y += l
|
||||
else:
|
||||
l.setPosition(QPointF(x, y))
|
||||
y += l.boundingRect().height()
|
||||
return property(fget=fget, fset=fset)
|
||||
|
||||
def draw(self, painter):
|
||||
for l in self.layouts:
|
||||
if hasattr(l, 'draw'):
|
||||
l.draw(painter, QPointF())
|
||||
|
||||
def layout_text(prefs, img, title, subtitle, footer, max_height, hmargin=50, vmargin=50):
|
||||
width = img.width() - 2 * hmargin
|
||||
title_font = QFont(prefs.title_font_family)
|
||||
title_font.setPixelSize(prefs.title_font_size)
|
||||
title_block = Block(title, width, title_font, img, max_height)
|
||||
title_block.position = hmargin, vmargin
|
||||
subtitle_block = Block()
|
||||
if subtitle:
|
||||
subtitle_font = QFont(prefs.subtitle_font_family)
|
||||
subtitle_font.setPixelSize(prefs.subtitle_font_size)
|
||||
gap = 2 * title_block.leading
|
||||
mh = max_height - title_block.height - gap
|
||||
subtitle_block = Block(subtitle, width, subtitle_font, img, mh)
|
||||
subtitle_block.position = hmargin, title_block.position[0] + title_block.height + gap
|
||||
|
||||
footer_font = QFont(prefs.footer_font_family)
|
||||
footer_font.setPixelSize(prefs.footer_font_size)
|
||||
footer_block = Block(footer, width, footer_font, img, max_height)
|
||||
footer_block.position = hmargin, img.height() - vmargin - footer_block.height
|
||||
|
||||
return title_block, subtitle_block, footer_block
|
||||
|
||||
# }}}
|
||||
|
||||
# Format text using templates {{{
|
||||
def fill_background(prefs, img):
|
||||
img.fill(Qt.white)
|
||||
|
||||
def sanitize(s):
|
||||
return clean_xml_chars(clean_ascii_chars(force_unicode(s or '')))
|
||||
|
||||
_formatter = None
|
||||
_template_cache = {}
|
||||
|
||||
def formatter():
|
||||
global _formatter
|
||||
if _formatter is None:
|
||||
_formatter = SafeFormat()
|
||||
return _formatter
|
||||
|
||||
def format_fields(mi, prefs):
|
||||
f = formatter()
|
||||
def safe_format(field):
|
||||
return sanitize(f.safe_format(
|
||||
getattr(prefs, field), mi, _('Template error'), mi, template_cache=_template_cache
|
||||
))
|
||||
return map(safe_format, ('title_template', 'subtitle_template', 'footer_template'))
|
||||
|
||||
@contextmanager
|
||||
def preserve_fields(obj, fields):
|
||||
if isinstance(fields, basestring):
|
||||
fields = fields.split()
|
||||
null = object()
|
||||
mem = {f:getattr(obj, f, null) for f in fields}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for f, val in mem.iteritems():
|
||||
if val is null:
|
||||
delattr(obj, f)
|
||||
else:
|
||||
setattr(obj, f, val)
|
||||
|
||||
def format_text(mi, prefs):
|
||||
with preserve_fields(mi, 'authors formatted_series_index'):
|
||||
mi.authors = [a for a in mi.authors if a != _('Unknown')]
|
||||
mi.formatted_series_index = fmt_sidx(mi.series_index or 0, use_roman=config['use_roman_numerals_for_series_number'])
|
||||
return tuple(format_fields(mi, prefs))
|
||||
# }}}
|
||||
|
||||
def generate_cover(mi, prefs=None):
|
||||
ensure_app()
|
||||
load_builtin_fonts()
|
||||
prefs = prefs or cprefs
|
||||
prefs = {k:prefs.get(k) for k in cprefs.defaults}
|
||||
prefs = Prefs(**prefs)
|
||||
title, subtitle, footer = format_text(mi, prefs)
|
||||
img = QImage(prefs.cover_width, prefs.cover_height, QImage.Format_ARGB32)
|
||||
fill_background(prefs, img)
|
||||
hmargin = vmargin = 50
|
||||
title_block, subtitle_block, footer_block = layout_text(
|
||||
prefs, img, title, subtitle, footer, img.height() // 3, hmargin, vmargin)
|
||||
p = QPainter(img)
|
||||
for block in (title_block, subtitle_block, footer_block):
|
||||
block.draw(p)
|
||||
p.end()
|
||||
return img
|
||||
|
||||
def test():
|
||||
from PyQt5.Qt import QLabel, QApplication, QPixmap, QMainWindow
|
||||
from calibre.ebooks.metadata.book.base import Metadata
|
||||
app = QApplication([])
|
||||
mi = Metadata('Test title for מתכוני מיצים', ['Author One', 'Author A. Two', 'Author'])
|
||||
mi.series = 'A Series of Tests'
|
||||
mi.series_index = 3
|
||||
img = generate_cover(mi)
|
||||
l = QLabel()
|
||||
l.setPixmap(QPixmap.fromImage(img))
|
||||
m = QMainWindow()
|
||||
m.setCentralWidget(l)
|
||||
m.show()
|
||||
app.exec_()
|
||||
|
||||
if __name__ == '__main__':
|
||||
test()
|
Loading…
x
Reference in New Issue
Block a user