Tweak Book: Allow tweaking of KF8 MOBI files

This commit is contained in:
Kovid Goyal 2012-05-03 16:34:09 +05:30
parent 61aa60ab07
commit bb014a56bb
5 changed files with 285 additions and 288 deletions

View File

@ -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

View File

@ -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 '

View File

@ -5,70 +5,307 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__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(_('''\
<h2>About Tweak Book</h2>
<p>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:</p>
<br>
<ol>
<li>Click "Explode Book": This will "explode" the book into its
individual internal components.<br></li>
<li>Right click on any individual file and select "Open with..." to
edit it in your favorite text editor.<br></li>
<li>When you are done Tweaking: <b>close the file browser window
and the editor windows you used to make your tweaks</b>. Then click
the "Rebuild Book" button, to update the book in your calibre
library.</li>
</ol>'''))
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)

View File

@ -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 <kovid@kovidgoyal.net>'
__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)

View File

@ -1,107 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="windowModality">
<enum>Qt::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>382</width>
<height>265</height>
</rect>
</property>
<property name="windowTitle">
<string>Tweak {0}</string>
</property>
<property name="sizeGripEnabled">
<bool>false</bool>
</property>
<property name="modal">
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="2">
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;p&gt;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 &lt;b&gt;and the editor windows you used to edit files in the ePub&lt;/b&gt;.&lt;/p&gt;&lt;p&gt;Rebuild the ePub, updating your calibre library.&lt;/p&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="explode_button">
<property name="toolTip">
<string>Display contents of exploded {0}</string>
</property>
<property name="statusTip">
<string>Display contents of exploded {0}</string>
</property>
<property name="text">
<string>&amp;Explode {0}</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/wizard.png</normaloff>:/images/wizard.png</iconset>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="cancel_button">
<property name="toolTip">
<string>Discard changes</string>
</property>
<property name="statusTip">
<string>Discard changes</string>
</property>
<property name="text">
<string>&amp;Cancel</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/window-close.png</normaloff>:/images/window-close.png</iconset>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="rebuild_button">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Rebuild {0} from exploded contents</string>
</property>
<property name="statusTip">
<string>Rebuild {0} from exploded contents</string>
</property>
<property name="text">
<string>&amp;Rebuild {0}</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/exec.png</normaloff>:/images/exec.png</iconset>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="preview_button">
<property name="text">
<string>&amp;Preview {0}</string>
</property>
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/view.png</normaloff>:/images/view.png</iconset>
</property>
</widget>
</item>
</layout>
</widget>
<resources>
<include location="../../../../resources/images.qrc"/>
</resources>
<connections/>
</ui>