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'])
cached_book['dev_book'].delete() try:
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,15 +286,18 @@ 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'):
for prop in ('margin', 'padding', 'border'): if item_id != 'calibre_jacket' or self.context.output_profile.name == 'Kindle':
for edge in ('top', 'bottom'): for prop in ('margin', 'padding', 'border'):
cssdict['%s-%s'%(prop, edge)] = '0pt' for edge in ('top', 'bottom'):
cssdict['%s-%s'%(prop, edge)] = '0pt'
if self.context.insert_blank_line: if self.context.insert_blank_line:
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,139 +6,210 @@ __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"> path = XPath('//h:img[@src]')
<head> removed = 0
<title>%(title)s</title> for img in path(item.data):
<meta name="calibre-content" content="jacket"/> if removed >= limit:
</head> break
<body> href = item.abshref(img.get('src'))
<div class="calibre_rescale_100"> image = self.oeb.manifest.hrefs.get(href, None)
<div style="text-align:center"> if image is not None:
<h1 class="calibre_rescale_180">%(title)s</h1> self.oeb.manifest.remove(image)
<h2 class="calibre_rescale_140">%(jacket)s</h2> img.getparent().remove(img)
<div class="calibre_rescale_100">%(series)s</div> removed += 1
<div class="calibre_rescale_100">%(rating)s</div> return removed
<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): def remove_first_image(self):
path = XPath('//h:img[@src]') for item in self.oeb.spine:
for i, item in enumerate(self.oeb.spine): removed = self.remove_images(item)
if i > 2: break if removed > 0:
for img in path(item.data): self.log('Removed first image')
href = item.abshref(img.get('src')) break
image = self.oeb.manifest.hrefs.get(href, None)
if image is not None:
self.log('Removing first image', img.get('src'))
self.oeb.manifest.remove(image)
img.getparent().remove(img)
return
def get_rating(self, rating):
ans = ''
if rating is None:
return
try:
num = float(rating)/2
except:
return ans
num = max(0, num)
num = min(num, 5)
if num < 1:
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))
ans = 'Rating: ' + ''.join(repeat('<img style="vertical-align:text-top" alt="star" src="%s" />'%href, num))
return ans
def insert_metadata(self, mi): def insert_metadata(self, mi):
self.log('Inserting metadata into book...') self.log('Inserting metadata into book...')
comments = mi.comments
if not comments:
try:
comments = unicode(self.oeb.metadata.description[0])
except:
comments = ''
if not comments.strip():
comments = ''
orig_comments = comments
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:
series += escape(' [%s]'%mi.format_series_index())
if not mi.series:
series = ''
tags = mi.tags
if not tags:
try:
tags = map(unicode, self.oeb.metadata.subject)
except:
tags = []
if tags:
tags = '<b>Tags: </b>' + self.opts.dest.tags_to_string(tags)
else:
tags = ''
try:
title = mi.title if mi.title else unicode(self.oeb.metadata.title[0])
except:
title = _('Unknown')
def generate_html(comments): fname = 'star.png'
return self.JACKET_TEMPLATE%dict(xmlns=XPNSMAP['h'], img = I(fname, data=True)
title=escape(title), comments=comments,
jacket=escape(_('Book Jacket')), series=series,
tags=tags, rating=self.get_rating(mi.rating))
id, href = self.oeb.manifest.generate('jacket', 'jacket.xhtml')
from calibre.ebooks.oeb.base import RECOVER_PARSER, XPath
try:
root = etree.fromstring(generate_html(comments), parser=RECOVER_PARSER)
except:
root = etree.fromstring(generate_html(escape(orig_comments)),
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:
continue
if found is None:
item = self.oeb.manifest.add(id, href, guess_type(href)[0], data=root)
self.oeb.spine.insert(0, item, True)
else:
self.log('Found existing book jacket, replacing...')
found.data = root
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): 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.oeb, self.opts, self.log = oeb, opts, oeb.log
self.remove_existing_jacket()
if opts.remove_first_image: if opts.remove_first_image:
self.remove_first_image() self.remove_first_image()
if opts.insert_metadata: if opts.insert_metadata:
self.insert_metadata(metadata) self.insert_metadata(metadata)
# Render Jacket {{{
def get_rating(rating, href):
ans = ''
try:
num = float(rating)/2
except:
return ans
num = max(0, num)
num = min(num, 5)
if num < 1:
return ans
if href is not None:
ans = ' '.join(repeat(
'<img style="vertical-align:text-bottom" alt="star" src="%s" />'%
href, int(num)))
else:
ans = u' '.join(u'\u2605')
return ans
def render_jacket(mi, output_profile, star_href=None,
alt_title=_('Unknown'), alt_tags=[], alt_comments=''):
css = P('jacket/stylesheet.css', data=True).decode('utf-8')
try:
title_str = mi.title if mi.title else alt_title
except:
title_str = _('Unknown')
title = '<span class="title">%s</span>' % (escape(title_str))
series = escape(mi.series if mi.series else '')
if mi.series and mi.series_index is not None:
series += escape(' [%s]'%mi.format_series_index())
if not mi.series:
series = ''
try:
pubdate = strftime(u'%Y', mi.pubdate.timetuple())
except:
pubdate = ''
rating = get_rating(mi.rating, star_href)
tags = mi.tags if mi.tags else alt_tags
if tags:
tags = output_profile.tags_to_string(tags)
else:
tags = ''
comments = mi.comments if mi.comments else alt_comments
comments = comments.strip()
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):
args = dict(xmlns=XHTML_NS,
title_str=title_str,
css=css,
title=title,
pubdate_label=_('Published'), pubdate=pubdate,
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:
root = etree.fromstring(generate_html(comments), parser=RECOVER_PARSER)
except:
try:
root = etree.fromstring(generate_html(escape(orig_comments)),
parser=RECOVER_PARSER)
except:
root = etree.fromstring(generate_html(''),
parser=RECOVER_PARSER)
return 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

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'):