From bb014a56bb48a9975a787d77fb0f2ccdcc2a97b9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 3 May 2012 16:34:09 +0530 Subject: [PATCH] Tweak Book: Allow tweaking of KF8 MOBI files --- resources/default_tweaks.py | 6 - src/calibre/ebooks/mobi/tweak.py | 5 +- src/calibre/gui2/actions/tweak_epub.py | 325 +++++++++++++++++++++---- src/calibre/gui2/dialogs/tweak_epub.py | 130 ---------- src/calibre/gui2/dialogs/tweak_epub.ui | 107 -------- 5 files changed, 285 insertions(+), 288 deletions(-) delete mode 100755 src/calibre/gui2/dialogs/tweak_epub.py delete mode 100644 src/calibre/gui2/dialogs/tweak_epub.ui diff --git a/resources/default_tweaks.py b/resources/default_tweaks.py index 7f258afdc9..b4ce1f1589 100644 --- a/resources/default_tweaks.py +++ b/resources/default_tweaks.py @@ -490,12 +490,6 @@ save_original_format = True # how many should be shown, here. gui_view_history_size = 15 -#: When using the 'Tweak Book' action, which format to prefer -# When tweaking a book that has multiple formats, calibre picks one -# automatically. By default EPUB is preferred to HTMLZ. If you would like to -# prefer HTMLZ to EPUB for tweaking, change this to 'htmlz' -tweak_book_prefer = 'epub' - #: Change the font size of book details in the interface # Change the font size at which book details are rendered in the side panel and # comments are rendered in the metadata edit dialog. Set it to a positive or diff --git a/src/calibre/ebooks/mobi/tweak.py b/src/calibre/ebooks/mobi/tweak.py index 248ed97261..8cd0c2ccf6 100644 --- a/src/calibre/ebooks/mobi/tweak.py +++ b/src/calibre/ebooks/mobi/tweak.py @@ -52,7 +52,10 @@ def explode(path, dest, question=lambda x:True): kf8_type = header.kf8_type if kf8_type is None: - raise BadFormat('This MOBI file does not contain a KF8 format book') + raise BadFormat(_('This MOBI file does not contain a KF8 format ' + 'book. KF8 is the new format from Amazon. calibre can ' + 'only tweak MOBI files that contain KF8 books. Older ' + 'MOBI files without KF8 are not tweakable.')) if kf8_type == 'joint': if not question(_('This MOBI file contains both KF8 and ' diff --git a/src/calibre/gui2/actions/tweak_epub.py b/src/calibre/gui2/actions/tweak_epub.py index 02fc327f38..0d5f8ec8f5 100755 --- a/src/calibre/gui2/actions/tweak_epub.py +++ b/src/calibre/gui2/actions/tweak_epub.py @@ -5,70 +5,307 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import os +import os, weakref, shutil -from calibre.gui2 import error_dialog +from PyQt4.Qt import (QDialog, QVBoxLayout, QHBoxLayout, QRadioButton, QFrame, + QPushButton, QLabel, QGroupBox, QGridLayout, QIcon, QSize, QTimer) + +from calibre import as_unicode +from calibre.constants import isosx +from calibre.gui2 import error_dialog, question_dialog, open_local_file from calibre.gui2.actions import InterfaceAction -from calibre.gui2.dialogs.tweak_epub import TweakEpub -from calibre.utils.config import tweaks +from calibre.ptempfile import (PersistentTemporaryDirectory, + PersistentTemporaryFile) +from calibre.utils.config import prefs + +class TweakBook(QDialog): + + def __init__(self, parent, book_id, fmts, db): + QDialog.__init__(self, parent) + self.book_id, self.fmts, self.db_ref = book_id, fmts, weakref.ref(db) + self._exploded = None + self._cleanup_dirs = [] + self._cleanup_files = [] + + self.setup_ui() + self.setWindowTitle(_('Tweak Book') + ' - ' + db.title(book_id, + index_is_id=True)) + + button = self.fmt_choice_buttons[0] + of = prefs['output_format'].upper() + for x in self.fmt_choice_buttons: + if unicode(x.text()) == of: + button = x + break + button.setChecked(True) + + self.init_state() + for button in self.fmt_choice_buttons: + button.toggled.connect(self.init_state) + + def init_state(self, *args): + self._exploded = None + self.preview_button.setEnabled(False) + self.rebuild_button.setEnabled(False) + self.explode_button.setEnabled(True) + + def setup_ui(self): # {{{ + self._g = g = QHBoxLayout(self) + self.setLayout(g) + self._l = l = QVBoxLayout() + g.addLayout(l) + + fmts = sorted(x.upper() for x in self.fmts) + self.fmt_choice_box = QGroupBox(_('Choose the format to tweak:'), self) + self._fl = fl = QHBoxLayout() + self.fmt_choice_box.setLayout(self._fl) + self.fmt_choice_buttons = [QRadioButton(x, self) for x in fmts] + for x in self.fmt_choice_buttons: + fl.addWidget(x, stretch=10 if x is self.fmt_choice_buttons[-1] else + 0) + l.addWidget(self.fmt_choice_box) + self.fmt_choice_box.setVisible(len(fmts) > 1) + + self.help_label = QLabel(_('''\ +

About Tweak Book

+

Tweak Book allows you to fine tune the appearance of an ebook by + making small changes to its internals. In order to use Tweak Book, + you need to know a little bit about HTML and CSS, technologies that + are used in ebooks. Follow the steps:

+
+
    +
  1. Click "Explode Book": This will "explode" the book into its + individual internal components.
  2. +
  3. Right click on any individual file and select "Open with..." to + edit it in your favorite text editor.
  4. +
  5. When you are done Tweaking: close the file browser window + and the editor windows you used to make your tweaks. Then click + the "Rebuild Book" button, to update the book in your calibre + library.
  6. +
''')) + self.help_label.setWordWrap(True) + self._fr = QFrame() + self._fr.setFrameShape(QFrame.VLine) + g.addWidget(self._fr) + g.addWidget(self.help_label) + + self._b = b = QGridLayout() + left, top, right, bottom = b.getContentsMargins() + top += top + b.setContentsMargins(left, top, right, bottom) + l.addLayout(b, stretch=10) + + self.explode_button = QPushButton(QIcon(I('wizard.png')), _('&Explode Book')) + self.preview_button = QPushButton(QIcon(I('view.png')), _('&Preview Book')) + self.cancel_button = QPushButton(QIcon(I('window-close.png')), _('&Cancel')) + self.rebuild_button = QPushButton(QIcon(I('exec.png')), _('&Rebuild Book')) + + self.explode_button.setToolTip( + _('Explode the book to edit its components')) + self.preview_button.setToolTip( + _('Preview the result of your tweaks')) + self.cancel_button.setToolTip( + _('Abort without saving any changes')) + self.rebuild_button.setToolTip( + _('Save your changes and update the book in the calibre library')) + + a = b.addWidget + a(self.explode_button, 0, 0, 1, 1) + a(self.preview_button, 0, 1, 1, 1) + a(self.cancel_button, 1, 0, 1, 1) + a(self.rebuild_button, 1, 1, 1, 1) + + for x in ('explode', 'preview', 'cancel', 'rebuild'): + getattr(self, x+'_button').clicked.connect(getattr(self, x)) + + self.msg = QLabel('dummy', self) + self.msg.setVisible(False) + self.msg.setStyleSheet(''' + QLabel { + text-align: center; + background-color: white; + color: black; + border-width: 1px; + border-style: solid; + border-radius: 20px; + font-size: x-large; + font-weight: bold; + } + ''') + + self.resize(self.sizeHint() + QSize(40, 10)) + # }}} + + def show_msg(self, msg): + self.msg.setText(msg) + self.msg.resize(self.size() - QSize(50, 25)) + self.msg.move((self.width() - self.msg.width())//2, + (self.height() - self.msg.height())//2) + self.msg.setVisible(True) + + def hide_msg(self): + self.msg.setVisible(False) + + def explode(self): + self.show_msg(_('Exploding, please wait...')) + QTimer.singleShot(5, self.do_explode) + + def ask_question(self, msg): + return question_dialog(self, _('Are you sure?'), msg) + + def do_explode(self): + from calibre.ebooks.tweak import get_tools, Error, WorkerError + tdir = PersistentTemporaryDirectory('_tweak_explode') + self._cleanup_dirs.append(tdir) + det_msg = None + try: + src = self.db.format(self.book_id, self.current_format, + index_is_id=True, as_path=True) + self._cleanup_files.append(src) + exploder = get_tools(self.current_format)[0] + opf = exploder(src, tdir, question=self.ask_question) + except WorkerError as e: + det_msg = e.orig_tb + except Error as e: + return error_dialog(self, _('Failed to unpack'), + (_('Could not explode the %s file.')%self.current_format) + ' ' + + as_unicode(e), show=True) + except: + import traceback + det_msg = traceback.format_exc() + finally: + self.hide_msg() + + if det_msg is not None: + return error_dialog(self, _('Failed to unpack'), + _('Could not explode the %s file. Click "Show Details" for ' + 'more information.')%self.current_format, det_msg=det_msg, + show=True) + + if opf is None: + # The question was answered with No + return + + self._exploded = tdir + self.explode_button.setEnabled(False) + self.preview_button.setEnabled(True) + self.rebuild_button.setEnabled(True) + open_local_file(tdir) + + def rebuild_it(self): + from calibre.ebooks.tweak import get_tools, WorkerError + src_dir = self._exploded + det_msg = None + of = PersistentTemporaryFile('_tweak_rebuild.'+self.current_format.lower()) + of.close() + of = of.name + self._cleanup_files.append(of) + try: + rebuilder = get_tools(self.current_format)[1] + rebuilder(src_dir, of) + except WorkerError as e: + det_msg = e.orig_tb + except: + import traceback + det_msg = traceback.format_exc() + finally: + self.hide_msg() + + if det_msg is not None: + error_dialog(self, _('Failed to rebuild file'), + _('Failed to rebuild %s. For more information, click ' + '"Show details".')%self.current_format, + det_msg=det_msg, show=True) + return None + + return of + + def preview(self): + self.show_msg(_('Rebuilding, please wait...')) + QTimer.singleShot(5, self.do_preview) + + def do_preview(self): + rebuilt = self.rebuild_it() + if rebuilt is not None: + self.parent().iactions['View']._view_file(rebuilt) + + def rebuild(self): + self.show_msg(_('Rebuilding, please wait...')) + QTimer.singleShot(5, self.do_rebuild) + + def do_rebuild(self): + rebuilt = self.rebuild_it() + if rebuilt is not None: + fmt = os.path.splitext(rebuilt)[1][1:].upper() + with open(rebuilt, 'rb') as f: + self.db.add_format(self.book_id, fmt, f, index_is_id=True) + self.accept() + + def cancel(self): + self.reject() + + def cleanup(self): + if isosx and self._exploded: + try: + import appscript + self.finder = appscript.app('Finder') + self.finder.Finder_windows[os.path.basename(self._exploded)].close() + except: + pass + + for f in self._cleanup_files: + try: + os.remove(f) + except: + pass + + for d in self._cleanup_dirs: + try: + shutil.rmtree(d) + except: + pass + + @property + def db(self): + return self.db_ref() + + @property + def current_format(self): + for b in self.fmt_choice_buttons: + if b.isChecked(): + return unicode(b.text()) class TweakEpubAction(InterfaceAction): name = 'Tweak ePub' action_spec = (_('Tweak Book'), 'trim.png', - _('Make small changes to ePub or HTMLZ format books'), + _('Make small changes to ePub, HTMLZ or AZW3 format books'), _('T')) dont_add_to = frozenset(['context-menu-device']) action_type = 'current' def genesis(self): - self.qaction.triggered.connect(self.edit_epub_in_situ) + self.qaction.triggered.connect(self.tweak_book) - def edit_epub_in_situ(self, *args): + def tweak_book(self): row = self.gui.library_view.currentIndex() if not row.isValid(): return error_dialog(self.gui, _('Cannot tweak Book'), _('No book selected'), show=True) book_id = self.gui.library_view.model().id(row) - - # Confirm 'EPUB' in formats - try: - path_to_epub = self.gui.library_view.model().db.format( - book_id, 'EPUB', index_is_id=True, as_path=True) - except: - path_to_epub = None - - # Confirm 'HTMLZ' in formats - try: - path_to_htmlz = self.gui.library_view.model().db.format( - book_id, 'HTMLZ', index_is_id=True, as_path=True) - except: - path_to_htmlz = None - - if not path_to_epub and not path_to_htmlz: - return error_dialog(self.gui, _('Cannot tweak Book'), - _('The book must be in ePub or HTMLZ format to tweak.' - '\n\nFirst convert the book to ePub or HTMLZ.'), + db = self.gui.library_view.model().db + fmts = db.formats(book_id, index_is_id=True) or '' + fmts = [x.lower().strip() for x in fmts.split(',')] + tweakable_fmts = set(fmts).intersection({'epub', 'htmlz', 'azw3', + 'mobi', 'azw'}) + if not tweakable_fmts: + return error_dialog(self.gui, _('Cannot Tweak Book'), + _('The book must be in ePub, HTMLZ or AZW3 formats to tweak.' + '\n\nFirst convert the book to one of these formats.'), show=True) - - # Launch modal dialog waiting for user to tweak or cancel - if tweaks['tweak_book_prefer'] == 'htmlz': - path_to_book = path_to_htmlz or path_to_epub - else: - path_to_book = path_to_epub or path_to_htmlz - - dlg = TweakEpub(self.gui, path_to_book) - if dlg.exec_() == dlg.Accepted: - self.update_db(book_id, dlg._output) + dlg = TweakBook(self.gui, book_id, tweakable_fmts, db) + dlg.exec_() dlg.cleanup() - os.remove(path_to_book) - def update_db(self, book_id, rebuilt): - ''' - Update the calibre db with the tweaked epub - ''' - fmt = os.path.splitext(rebuilt)[1][1:].upper() - self.gui.library_view.model().db.add_format(book_id, fmt, - open(rebuilt, 'rb'), index_is_id=True) diff --git a/src/calibre/gui2/dialogs/tweak_epub.py b/src/calibre/gui2/dialogs/tweak_epub.py deleted file mode 100755 index 503b1f45d3..0000000000 --- a/src/calibre/gui2/dialogs/tweak_epub.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python -# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai -from __future__ import with_statement - -__license__ = 'GPL v3' -__copyright__ = '2010, Kovid Goyal ' -__docformat__ = 'restructuredtext en' - -import os, shutil -from itertools import repeat, izip -from calibre.utils.zipfile import ZipFile, ZIP_DEFLATED, ZIP_STORED - -from PyQt4.Qt import QDialog - -from calibre.constants import isosx -from calibre.gui2 import open_local_file, error_dialog -from calibre.gui2.dialogs.tweak_epub_ui import Ui_Dialog -from calibre.libunzip import extract as zipextract -from calibre.ptempfile import (PersistentTemporaryDirectory, - PersistentTemporaryFile) - -class TweakEpub(QDialog, Ui_Dialog): - ''' - Display controls for tweaking ePubs - - ''' - - def __init__(self, parent, epub): - QDialog.__init__(self, parent) - - self._epub = epub - self._exploded = None - self._output = None - self.ishtmlz = epub.lower().endswith('.htmlz') - self.rebuilt_name = 'rebuilt.' + ('htmlz' if self.ishtmlz else 'epub') - - # Run the dialog setup generated from tweak_epub.ui - self.setupUi(self) - for x, props in [(self, ['windowTitle']), (self.label, ['text'])]+\ - list(izip([self.cancel_button, self.explode_button, - self.rebuild_button, self.preview_button], - repeat(['text', 'statusTip', 'toolTip']))): - for prop in props: - val = unicode(getattr(x, prop)()) - val = val.format('HTMLZ' if self.ishtmlz else 'ePub') - prop = 'set' + prop[0].upper() + prop[1:] - getattr(x, prop)(val) - - self.cancel_button.clicked.connect(self.reject) - self.explode_button.clicked.connect(self.explode) - self.rebuild_button.clicked.connect(self.rebuild) - self.preview_button.clicked.connect(self.preview) - - # Position update dialog overlaying top left of app window - parent_loc = parent.pos() - self.move(parent_loc.x(),parent_loc.y()) - - self.gui = parent - self._preview_files = [] - - def cleanup(self): - if isosx: - try: - import appscript - self.finder = appscript.app('Finder') - self.finder.Finder_windows[os.path.basename(self._exploded)].close() - except: - # appscript fails to load on 10.4 - pass - - # Delete directory containing exploded ePub - if self._exploded is not None: - shutil.rmtree(self._exploded, ignore_errors=True) - for x in self._preview_files: - try: - os.remove(x) - except: - pass - - def display_exploded(self): - ''' - Generic subprocess launch of native file browser - User can use right-click to 'Open with ...' - ''' - open_local_file(self._exploded) - - def explode(self, *args): - if self._exploded is None: - self._exploded = PersistentTemporaryDirectory("_exploded", prefix='') - zipextract(self._epub, self._exploded) - self.display_exploded() - self.rebuild_button.setEnabled(True) - self.explode_button.setEnabled(False) - - def do_rebuild(self, src): - with ZipFile(src, 'w', compression=ZIP_DEFLATED) as zf: - # Write mimetype - mt = os.path.join(self._exploded, 'mimetype') - if os.path.exists(mt): - zf.write(mt, 'mimetype', compress_type=ZIP_STORED) - # Write everything else - exclude_files = ['.DS_Store','mimetype','iTunesMetadata.plist',self.rebuilt_name] - for root, dirs, files in os.walk(self._exploded): - for fn in files: - if fn in exclude_files: - continue - absfn = os.path.join(root, fn) - zfn = os.path.relpath(absfn, - self._exploded).replace(os.sep, '/') - zf.write(absfn, zfn) - - def preview(self): - if not self._exploded: - msg = _('You must first explode the %s before previewing.') - msg = msg%('HTMLZ' if self.ishtmlz else 'ePub') - return error_dialog(self, _('Cannot preview'), msg, show=True) - - tf = PersistentTemporaryFile('.htmlz' if self.ishtmlz else '.epub') - tf.close() - self._preview_files.append(tf.name) - - self.do_rebuild(tf.name) - - self.gui.iactions['View']._view_file(tf.name) - - def rebuild(self, *args): - self._output = os.path.join(self._exploded, self.rebuilt_name) - self.do_rebuild(self._output) - return QDialog.accept(self) - diff --git a/src/calibre/gui2/dialogs/tweak_epub.ui b/src/calibre/gui2/dialogs/tweak_epub.ui deleted file mode 100644 index 9f14a1b275..0000000000 --- a/src/calibre/gui2/dialogs/tweak_epub.ui +++ /dev/null @@ -1,107 +0,0 @@ - - - Dialog - - - Qt::NonModal - - - - 0 - 0 - 382 - 265 - - - - Tweak {0} - - - false - - - false - - - - - - <p>Explode the {0} to display contents in a file browser window. To tweak individual files, right-click, then 'Open with...' your editor of choice. When tweaks are complete, close the file browser window <b>and the editor windows you used to edit files in the ePub</b>.</p><p>Rebuild the ePub, updating your calibre library.</p> - - - true - - - - - - - Display contents of exploded {0} - - - Display contents of exploded {0} - - - &Explode {0} - - - - :/images/wizard.png:/images/wizard.png - - - - - - - Discard changes - - - Discard changes - - - &Cancel - - - - :/images/window-close.png:/images/window-close.png - - - - - - - false - - - Rebuild {0} from exploded contents - - - Rebuild {0} from exploded contents - - - &Rebuild {0} - - - - :/images/exec.png:/images/exec.png - - - - - - - &Preview {0} - - - - :/images/view.png:/images/view.png - - - - - - - - - -