Improved look for Book jacket. Also make it user customizable

This commit is contained in:
Kovid Goyal 2010-09-14 13:34:44 -06:00
commit ac406ab5cf
8 changed files with 356 additions and 120 deletions

View File

@ -0,0 +1,116 @@
/*
** Book Jacket generation
**
** The template for Book Jackets is template.xhtml
** This CSS is inserted into the generated HTML at conversion time
**
** Users can control parts of the presentation of a generated book jacket by
** editing this file and template.xhtml
**
** The general form of a generated Book Jacket:
**
** Title
** Series: series [series_index]
** Published: year_of_publication
** Rating: #_of_stars
** Tags: tag1, tag2, tag3 ...
**
** Comments
**
** If a book does not have Series information, a date of publication, a rating or tags
** the corresponding row is automatically removed from the generated book jacket.
*/
/*
** Banner
** Only affects EPUB, kindle ignores this type of formatting
*/
.cbj_banner {
background: #eee;
border: thin solid black;
margin: 1em;
padding: 1em;
-webkit-border-radius:8px;
}
/*
** Title
*/
.cbj_title {
font-size: x-large;
text-align: center;
}
/*
** Table containing Series, Publication Year, Rating and Tags
*/
table.cbj_header {
width: 100%;
}
/*
** General formatting for banner labels
*/
table.cbj_header td.cbj_label {
font-family: sans-serif;
font-weight: bold;
text-align: right;
width: 40%;
}
/*
** General formatting for banner content
*/
table.cbj_header td.cbj_content {
font-family: sans-serif;
text-align: left;
width:60%;
}
/*
** To skip a banner item (Series|Published|Rating|Tags),
** edit the appropriate CSS rule below.
*/
table.cbj_header tr.cbj_series {
/* Uncomment the next line to remove 'Series' from banner section */
/* display:none; */
}
table.cbj_header tr.cbj_pubdate {
/* Uncomment the next line to remove 'Published' from banner section */
/* display:none; */
}
table.cbj_header tr.cbj_rating {
/* Uncomment the next line to remove 'Rating' from banner section */
/* display:none; */
}
table.cbj_header tr.cbj_tags {
/* Uncomment the next line to remove 'Tags' from banner section */
/* display:none; */
}
hr {
/* This rule controls formatting for any hr elements contained in the jacket */
border-top: 0px solid white;
border-right: 0px solid white;
border-bottom: 2px solid black;
border-left: 0px solid white;
margin-left: 10%;
width: 80%;
}
.cbj_footer {
font-family: sans-serif;
font-size: small;
margin-top: 8px;
text-align: center;
}
.cbj_smallcaps {
font-size: 90%;
}
.cbj_comments {
font-family: sans-serif;
}

View File

@ -0,0 +1,34 @@
<html xmlns="{xmlns}">
<head>
<title>{title_str}</title>
<meta name="calibre-content" content="jacket"/>
<style type="text/css" media="screen">{css}</style>
</head>
<body>
<div class="cbj_banner">
<div class="cbj_title">{title}</div>
<table class="cbj_header">
<tr class="cbj_series">
<td class="cbj_label">{series_label}:</td>
<td class="cbj_content">{series}</td>
</tr>
<tr class="cbj_pubdate">
<td class="cbj_label">{pubdate_label}:</td>
<td class="cbj_content">{pubdate}</td>
</tr>
<tr class="cbj_rating">
<td class="cbj_label">{rating_label}:</td>
<td class="cbj_content">{rating}</td>
</tr>
<tr class="cbj_tags">
<td class="cbj_label">{tags_label}:</td>
<td class="cbj_content">{tags}</td>
</tr>
</table>
<div class="cbj_footer">{footer}</div>
</div>
<hr class="cbj_kindle_banner_hr" />
<div class="cbj_comments">{comments}</div>
</body>
</html>

View File

@ -2342,8 +2342,10 @@ class ITUNES(DriverBase):
if isosx: if isosx:
if DEBUG: if DEBUG:
self.log.info(" deleting '%s' from iDevice" % cached_book['title']) self.log.info(" deleting '%s' from iDevice" % cached_book['title'])
try:
cached_book['dev_book'].delete() cached_book['dev_book'].delete()
except:
self.log.error(" error deleting '%s'" % cached_book['title'])
elif iswindows: elif iswindows:
hit = self._find_device_book(cached_book) hit = self._find_device_book(cached_book)
if hit: if hit:

View File

@ -28,6 +28,9 @@ class FB2Output(OutputFormatPlugin):
]) ])
def convert(self, oeb_book, output_path, input_plugin, opts, log): def convert(self, oeb_book, output_path, input_plugin, opts, log):
from calibre.ebooks.oeb.transforms.jacket import linearize_jacket
linearize_jacket(oeb_book)
fb2mlizer = FB2MLizer(log) fb2mlizer = FB2MLizer(log)
fb2_content = fb2mlizer.extract_content(oeb_book, opts) fb2_content = fb2mlizer.extract_content(oeb_book, opts)

View File

@ -147,7 +147,6 @@ class CSSFlattener(object):
extra_css=css) extra_css=css)
self.stylizers[item] = stylizer self.stylizers[item] = stylizer
def baseline_node(self, node, stylizer, sizes, csize): def baseline_node(self, node, stylizer, sizes, csize):
csize = stylizer.style(node)['font-size'] csize = stylizer.style(node)['font-size']
if node.text: if node.text:
@ -195,7 +194,7 @@ class CSSFlattener(object):
value = 0.0 value = 0.0
cssdict[property] = "%0.5fem" % (value / fsize) cssdict[property] = "%0.5fem" % (value / fsize)
def flatten_node(self, node, stylizer, names, styles, psize, left=0): def flatten_node(self, node, stylizer, names, styles, psize, item_id, left=0):
if not isinstance(node.tag, basestring) \ if not isinstance(node.tag, basestring) \
or namespace(node.tag) != XHTML_NS: or namespace(node.tag) != XHTML_NS:
return return
@ -287,8 +286,10 @@ class CSSFlattener(object):
if self.lineh and 'line-height' not in cssdict: if self.lineh and 'line-height' not in cssdict:
lineh = self.lineh / psize lineh = self.lineh / psize
cssdict['line-height'] = "%0.5fem" % lineh cssdict['line-height'] = "%0.5fem" % lineh
if (self.context.remove_paragraph_spacing or if (self.context.remove_paragraph_spacing or
self.context.insert_blank_line) and tag in ('p', 'div'): self.context.insert_blank_line) and tag in ('p', 'div'):
if item_id != 'calibre_jacket' or self.context.output_profile.name == 'Kindle':
for prop in ('margin', 'padding', 'border'): for prop in ('margin', 'padding', 'border'):
for edge in ('top', 'bottom'): for edge in ('top', 'bottom'):
cssdict['%s-%s'%(prop, edge)] = '0pt' cssdict['%s-%s'%(prop, edge)] = '0pt'
@ -296,6 +297,7 @@ class CSSFlattener(object):
cssdict['margin-top'] = cssdict['margin-bottom'] = '0.5em' cssdict['margin-top'] = cssdict['margin-bottom'] = '0.5em'
if self.context.remove_paragraph_spacing: if self.context.remove_paragraph_spacing:
cssdict['text-indent'] = "%1.1fem" % self.context.remove_paragraph_spacing_indent_size cssdict['text-indent'] = "%1.1fem" % self.context.remove_paragraph_spacing_indent_size
if cssdict: if cssdict:
items = cssdict.items() items = cssdict.items()
items.sort() items.sort()
@ -314,7 +316,7 @@ class CSSFlattener(object):
if 'style' in node.attrib: if 'style' in node.attrib:
del node.attrib['style'] del node.attrib['style']
for child in node: for child in node:
self.flatten_node(child, stylizer, names, styles, psize, left) self.flatten_node(child, stylizer, names, styles, psize, item_id, left)
def flatten_head(self, item, stylizer, href): def flatten_head(self, item, stylizer, href):
html = item.data html = item.data
@ -361,7 +363,7 @@ class CSSFlattener(object):
stylizer = self.stylizers[item] stylizer = self.stylizers[item]
body = html.find(XHTML('body')) body = html.find(XHTML('body'))
fsize = self.context.dest.fbase fsize = self.context.dest.fbase
self.flatten_node(body, stylizer, names, styles, fsize) self.flatten_node(body, stylizer, names, styles, fsize, item.id)
items = [(key, val) for (val, key) in styles.items()] items = [(key, val) for (val, key) in styles.items()]
items.sort() items.sort()
css = ''.join(".%s {\n%s;\n}\n\n" % (key, val) for key, val in items) css = ''.join(".%s {\n%s;\n}\n\n" % (key, val) for key, val in items)

View File

@ -6,61 +6,99 @@ __license__ = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import textwrap import sys
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
from itertools import repeat from itertools import repeat
from lxml import etree from lxml import etree
from calibre.ebooks.oeb.base import XPath, XPNSMAP from calibre import guess_type, strftime
from calibre import guess_type from calibre.constants import __appname__, __version__
from calibre.ebooks.BeautifulSoup import BeautifulSoup
from calibre.ebooks.oeb.base import XPath, XHTML_NS, XHTML
from calibre.library.comments import comments_to_html from calibre.library.comments import comments_to_html
from calibre.utils.magick.draw import save_cover_data_to
JACKET_XPATH = '//h:meta[@name="calibre-content" and @content="jacket"]'
class Jacket(object): class Jacket(object):
''' '''
Book jacket manipulation. Remove first image and insert comments at start of Book jacket manipulation. Remove first image and insert comments at start of
book. book.
''' '''
JACKET_TEMPLATE = textwrap.dedent(u'''\ def remove_images(self, item, limit=1):
<html xmlns="%(xmlns)s">
<head>
<title>%(title)s</title>
<meta name="calibre-content" content="jacket"/>
</head>
<body>
<div class="calibre_rescale_100">
<div style="text-align:center">
<h1 class="calibre_rescale_180">%(title)s</h1>
<h2 class="calibre_rescale_140">%(jacket)s</h2>
<div class="calibre_rescale_100">%(series)s</div>
<div class="calibre_rescale_100">%(rating)s</div>
<div class="calibre_rescale_100">%(tags)s</div>
</div>
<div style="margin-top:2em" class="calibre_rescale_100">
%(comments)s
</div>
</div>
</body>
</html>
''')
def remove_first_image(self):
path = XPath('//h:img[@src]') path = XPath('//h:img[@src]')
for i, item in enumerate(self.oeb.spine): removed = 0
if i > 2: break
for img in path(item.data): for img in path(item.data):
if removed >= limit:
break
href = item.abshref(img.get('src')) href = item.abshref(img.get('src'))
image = self.oeb.manifest.hrefs.get(href, None) image = self.oeb.manifest.hrefs.get(href, None)
if image is not None: if image is not None:
self.log('Removing first image', img.get('src'))
self.oeb.manifest.remove(image) self.oeb.manifest.remove(image)
img.getparent().remove(img) img.getparent().remove(img)
return removed += 1
return removed
def get_rating(self, rating): def remove_first_image(self):
for item in self.oeb.spine:
removed = self.remove_images(item)
if removed > 0:
self.log('Removed first image')
break
def insert_metadata(self, mi):
self.log('Inserting metadata into book...')
fname = 'star.png'
img = I(fname, data=True)
if self.opts.output_profile.short_name == 'kindle':
fname = 'star.jpg'
img = save_cover_data_to(img, fname,
return_data=True)
id, href = self.oeb.manifest.generate('calibre_jacket_star', fname)
self.oeb.manifest.add(id, href, guess_type(fname)[0], data=img)
try:
tags = map(unicode, self.oeb.metadata.subject)
except:
tags = []
root = render_jacket(mi, self.opts.output_profile, star_href=href,
alt_title=unicode(self.oeb.metadata.title[0]), alt_tags=tags,
alt_comments=unicode(self.oeb.metadata.description[0]))
id, href = self.oeb.manifest.generate('calibre_jacket', 'jacket.xhtml')
item = self.oeb.manifest.add(id, href, guess_type(href)[0], data=root)
self.oeb.spine.insert(0, item, True)
def remove_existing_jacket(self):
for x in self.oeb.spine[:4]:
if XPath(JACKET_XPATH)(x.data):
self.remove_images(x, limit=sys.maxint)
self.oeb.manifest.remove(x)
break
def __call__(self, oeb, opts, metadata):
'''
Add metadata in jacket.xhtml if specified in opts
If not specified, remove previous jacket instance
'''
self.oeb, self.opts, self.log = oeb, opts, oeb.log
self.remove_existing_jacket()
if opts.remove_first_image:
self.remove_first_image()
if opts.insert_metadata:
self.insert_metadata(metadata)
# Render Jacket {{{
def get_rating(rating, href):
ans = '' ans = ''
if rating is None:
return
try: try:
num = float(rating)/2 num = float(rating)/2
except: except:
@ -69,76 +107,109 @@ class Jacket(object):
num = min(num, 5) num = min(num, 5)
if num < 1: if num < 1:
return ans return ans
id, href = self.oeb.manifest.generate('star', 'star.png')
self.oeb.manifest.add(id, href, 'image/png', data=I('star.png', data=True)) if href is not None:
ans = 'Rating: ' + ''.join(repeat('<img style="vertical-align:text-top" alt="star" src="%s" />'%href, num)) ans = ' '.join(repeat(
'<img style="vertical-align:text-bottom" alt="star" src="%s" />'%
href, int(num)))
else:
ans = u' '.join(u'\u2605')
return ans return ans
def insert_metadata(self, mi):
self.log('Inserting metadata into book...') def render_jacket(mi, output_profile, star_href=None,
comments = mi.comments alt_title=_('Unknown'), alt_tags=[], alt_comments=''):
if not comments: css = P('jacket/stylesheet.css', data=True).decode('utf-8')
try: try:
comments = unicode(self.oeb.metadata.description[0]) title_str = mi.title if mi.title else alt_title
except: except:
comments = '' title_str = _('Unknown')
if not comments.strip(): title = '<span class="title">%s</span>' % (escape(title_str))
comments = ''
orig_comments = comments series = escape(mi.series if mi.series else '')
if comments:
comments = comments_to_html(comments)
series = '<b>Series: </b>' + escape(mi.series if mi.series else '')
if mi.series and mi.series_index is not None: if mi.series and mi.series_index is not None:
series += escape(' [%s]'%mi.format_series_index()) series += escape(' [%s]'%mi.format_series_index())
if not mi.series: if not mi.series:
series = '' series = ''
tags = mi.tags
if not tags:
try: try:
tags = map(unicode, self.oeb.metadata.subject) pubdate = strftime(u'%Y', mi.pubdate.timetuple())
except: except:
tags = [] pubdate = ''
rating = get_rating(mi.rating, star_href)
tags = mi.tags if mi.tags else alt_tags
if tags: if tags:
tags = '<b>Tags: </b>' + self.opts.dest.tags_to_string(tags) tags = output_profile.tags_to_string(tags)
else: else:
tags = '' tags = ''
try:
title = mi.title if mi.title else unicode(self.oeb.metadata.title[0]) comments = mi.comments if mi.comments else alt_comments
except: comments = comments.strip()
title = _('Unknown') orig_comments = comments
if comments:
comments = comments_to_html(comments)
footer = 'B<span class="cbj_smallcaps">OOK JACKET GENERATED BY %s %s</span>' % (__appname__.upper(),__version__)
def generate_html(comments): def generate_html(comments):
return self.JACKET_TEMPLATE%dict(xmlns=XPNSMAP['h'], args = dict(xmlns=XHTML_NS,
title=escape(title), comments=comments, title_str=title_str,
jacket=escape(_('Book Jacket')), series=series, css=css,
tags=tags, rating=self.get_rating(mi.rating)) title=title,
id, href = self.oeb.manifest.generate('jacket', 'jacket.xhtml') pubdate_label=_('Published'), pubdate=pubdate,
from calibre.ebooks.oeb.base import RECOVER_PARSER, XPath series_label=_('Series'), series=series,
rating_label=_('Rating'), rating=rating,
tags_label=_('Tags'), tags=tags,
comments=comments,
footer = footer)
generated_html = P('jacket/template.xhtml',
data=True).decode('utf-8').format(**args)
# Post-process the generated html to strip out empty header items
soup = BeautifulSoup(generated_html)
if not series:
series_tag = soup.find('tr', attrs={'class':'cbj_series'})
series_tag.extract()
if not rating:
rating_tag = soup.find('tr', attrs={'class':'cbj_rating'})
rating_tag.extract()
if not tags:
tags_tag = soup.find('tr', attrs={'class':'cbj_tags'})
tags_tag.extract()
if not pubdate:
pubdate_tag = soup.find('tr', attrs={'class':'cbj_pubdate'})
pubdate_tag.extract()
if output_profile.short_name != 'kindle':
hr_tag = soup.find('hr', attrs={'class':'cbj_kindle_banner_hr'})
hr_tag.extract()
return soup.renderContents(None)
from calibre.ebooks.oeb.base import RECOVER_PARSER
try: try:
root = etree.fromstring(generate_html(comments), parser=RECOVER_PARSER) root = etree.fromstring(generate_html(comments), parser=RECOVER_PARSER)
except: except:
try:
root = etree.fromstring(generate_html(escape(orig_comments)), root = etree.fromstring(generate_html(escape(orig_comments)),
parser=RECOVER_PARSER) parser=RECOVER_PARSER)
jacket = XPath('//h:meta[@name="calibre-content" and @content="jacket"]')
found = None
for item in list(self.oeb.spine)[:4]:
try:
if jacket(item.data):
found = item
break
except: except:
continue root = etree.fromstring(generate_html(''),
if found is None: parser=RECOVER_PARSER)
item = self.oeb.manifest.add(id, href, guess_type(href)[0], data=root) return root
self.oeb.spine.insert(0, item, True)
else:
self.log('Found existing book jacket, replacing...')
found.data = root
# }}}
def linearize_jacket(oeb):
for x in oeb.spine[:4]:
if XPath(JACKET_XPATH)(x.data):
for e in XPath('//h:table|//h:tr|//h:th')(x.data):
e.tag = XHTML('div')
for e in XPath('//h:td')(x.data):
e.tag = XHTML('span')
break
def __call__(self, oeb, opts, metadata):
self.oeb, self.opts, self.log = oeb, opts, oeb.log
if opts.remove_first_image:
self.remove_first_image()
if opts.insert_metadata:
self.insert_metadata(metadata)

View File

@ -2523,6 +2523,10 @@ class EPUB_MOBI(CatalogPlugin):
# Fetch the database as a dictionary # Fetch the database as a dictionary
self.booksBySeries = self.plugin.search_sort_db(self.db, self.opts) self.booksBySeries = self.plugin.search_sort_db(self.db, self.opts)
if not self.booksBySeries:
self.opts.generate_series = False
self.opts.log(" no series found in selected books, cancelling series generation")
return
friendly_name = "Series" friendly_name = "Series"

View File

@ -5,12 +5,14 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import os
from calibre.utils.magick import Image, DrawingWand, create_canvas from calibre.utils.magick import Image, DrawingWand, create_canvas
from calibre.constants import __appname__, __version__ from calibre.constants import __appname__, __version__
from calibre import fit_image from calibre import fit_image
def save_cover_data_to(data, path, bgcolor='white', resize_to=None): def save_cover_data_to(data, path, bgcolor='white', resize_to=None,
return_data=False):
''' '''
Saves image in data to path, in the format specified by the path Saves image in data to path, in the format specified by the path
extension. Composes the image onto a blank canvas so as to extension. Composes the image onto a blank canvas so as to
@ -22,6 +24,8 @@ def save_cover_data_to(data, path, bgcolor='white', resize_to=None):
img.size = (resize_to[0], resize_to[1]) img.size = (resize_to[0], resize_to[1])
canvas = create_canvas(img.size[0], img.size[1], bgcolor) canvas = create_canvas(img.size[0], img.size[1], bgcolor)
canvas.compose(img) canvas.compose(img)
if return_data:
return canvas.export(os.path.splitext(path)[1][1:])
canvas.save(path) canvas.save(path)
def thumbnail(data, width=120, height=120, bgcolor='white', fmt='jpg'): def thumbnail(data, width=120, height=120, bgcolor='white', fmt='jpg'):