diff --git a/resources/jacket/stylesheet.css b/resources/jacket/stylesheet.css new file mode 100644 index 0000000000..8dee8edc3c --- /dev/null +++ b/resources/jacket/stylesheet.css @@ -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; + } diff --git a/resources/jacket/template.xhtml b/resources/jacket/template.xhtml new file mode 100644 index 0000000000..93e12983e8 --- /dev/null +++ b/resources/jacket/template.xhtml @@ -0,0 +1,34 @@ + + + {title_str} + + + + +
+
{title}
+ + + + + + + + + + + + + + + + + +
{series_label}:{series}
{pubdate_label}:{pubdate}
{rating_label}:{rating}
{tags_label}:{tags}
+ +
+
+
{comments}
+ + + diff --git a/src/calibre/devices/apple/driver.py b/src/calibre/devices/apple/driver.py index e318d368ff..c9bc04a242 100644 --- a/src/calibre/devices/apple/driver.py +++ b/src/calibre/devices/apple/driver.py @@ -2342,8 +2342,10 @@ class ITUNES(DriverBase): if isosx: if DEBUG: 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: hit = self._find_device_book(cached_book) if hit: diff --git a/src/calibre/ebooks/fb2/output.py b/src/calibre/ebooks/fb2/output.py index d0125afe89..d6c7a25a90 100644 --- a/src/calibre/ebooks/fb2/output.py +++ b/src/calibre/ebooks/fb2/output.py @@ -28,6 +28,9 @@ class FB2Output(OutputFormatPlugin): ]) 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) fb2_content = fb2mlizer.extract_content(oeb_book, opts) diff --git a/src/calibre/ebooks/oeb/transforms/flatcss.py b/src/calibre/ebooks/oeb/transforms/flatcss.py index ffdc641d1e..7212bd33c6 100644 --- a/src/calibre/ebooks/oeb/transforms/flatcss.py +++ b/src/calibre/ebooks/oeb/transforms/flatcss.py @@ -147,7 +147,6 @@ class CSSFlattener(object): extra_css=css) self.stylizers[item] = stylizer - def baseline_node(self, node, stylizer, sizes, csize): csize = stylizer.style(node)['font-size'] if node.text: @@ -195,7 +194,7 @@ class CSSFlattener(object): value = 0.0 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) \ or namespace(node.tag) != XHTML_NS: return @@ -287,15 +286,18 @@ class CSSFlattener(object): if self.lineh and 'line-height' not in cssdict: lineh = self.lineh / psize cssdict['line-height'] = "%0.5fem" % lineh + if (self.context.remove_paragraph_spacing or self.context.insert_blank_line) and tag in ('p', 'div'): - for prop in ('margin', 'padding', 'border'): - for edge in ('top', 'bottom'): - cssdict['%s-%s'%(prop, edge)] = '0pt' + if item_id != 'calibre_jacket' or self.context.output_profile.name == 'Kindle': + for prop in ('margin', 'padding', 'border'): + for edge in ('top', 'bottom'): + cssdict['%s-%s'%(prop, edge)] = '0pt' if self.context.insert_blank_line: cssdict['margin-top'] = cssdict['margin-bottom'] = '0.5em' if self.context.remove_paragraph_spacing: cssdict['text-indent'] = "%1.1fem" % self.context.remove_paragraph_spacing_indent_size + if cssdict: items = cssdict.items() items.sort() @@ -314,7 +316,7 @@ class CSSFlattener(object): if 'style' in node.attrib: del node.attrib['style'] 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): html = item.data @@ -361,7 +363,7 @@ class CSSFlattener(object): stylizer = self.stylizers[item] body = html.find(XHTML('body')) 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.sort() css = ''.join(".%s {\n%s;\n}\n\n" % (key, val) for key, val in items) diff --git a/src/calibre/ebooks/oeb/transforms/jacket.py b/src/calibre/ebooks/oeb/transforms/jacket.py index fec4d230c3..dc1d2fea41 100644 --- a/src/calibre/ebooks/oeb/transforms/jacket.py +++ b/src/calibre/ebooks/oeb/transforms/jacket.py @@ -6,139 +6,210 @@ __license__ = 'GPL v3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import textwrap +import sys from xml.sax.saxutils import escape from itertools import repeat from lxml import etree -from calibre.ebooks.oeb.base import XPath, XPNSMAP -from calibre import guess_type +from calibre import guess_type, strftime +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.utils.magick.draw import save_cover_data_to + +JACKET_XPATH = '//h:meta[@name="calibre-content" and @content="jacket"]' + class Jacket(object): ''' Book jacket manipulation. Remove first image and insert comments at start of book. ''' - JACKET_TEMPLATE = textwrap.dedent(u'''\ - - - %(title)s - - - -
-
-

%(title)s

-

%(jacket)s

-
%(series)s
-
%(rating)s
-
%(tags)s
-
-
- %(comments)s -
-
- - - ''') + def remove_images(self, item, limit=1): + path = XPath('//h:img[@src]') + removed = 0 + for img in path(item.data): + if removed >= limit: + break + href = item.abshref(img.get('src')) + image = self.oeb.manifest.hrefs.get(href, None) + if image is not None: + self.oeb.manifest.remove(image) + img.getparent().remove(img) + removed += 1 + return removed def remove_first_image(self): - path = XPath('//h:img[@src]') - for i, item in enumerate(self.oeb.spine): - if i > 2: break - for img in path(item.data): - href = item.abshref(img.get('src')) - 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('star'%href, num)) - return ans + 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...') - 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 = '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 = '' - tags = mi.tags - if not tags: - try: - tags = map(unicode, self.oeb.metadata.subject) - except: - tags = [] - if tags: - tags = 'Tags: ' + 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): - return self.JACKET_TEMPLATE%dict(xmlns=XPNSMAP['h'], - 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 + 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 = '' + 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( + 'star'% + 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 = '%s' % (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 = 'BOOK JACKET GENERATED BY %s %s' % (__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 + diff --git a/src/calibre/library/catalog.py b/src/calibre/library/catalog.py index bd2160aff1..ef7569bd88 100644 --- a/src/calibre/library/catalog.py +++ b/src/calibre/library/catalog.py @@ -2523,6 +2523,10 @@ class EPUB_MOBI(CatalogPlugin): # Fetch the database as a dictionary 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" diff --git a/src/calibre/utils/magick/draw.py b/src/calibre/utils/magick/draw.py index 82a0237b8d..301bf9912a 100644 --- a/src/calibre/utils/magick/draw.py +++ b/src/calibre/utils/magick/draw.py @@ -5,12 +5,14 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' +import os from calibre.utils.magick import Image, DrawingWand, create_canvas from calibre.constants import __appname__, __version__ 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 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]) canvas = create_canvas(img.size[0], img.size[1], bgcolor) canvas.compose(img) + if return_data: + return canvas.export(os.path.splitext(path)[1][1:]) canvas.save(path) def thumbnail(data, width=120, height=120, bgcolor='white', fmt='jpg'):