mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 18:54:09 -04:00
Start work on new Tweak Book tool
This commit is contained in:
parent
e34477da93
commit
efb83eb6fc
@ -495,6 +495,12 @@ class Container(object): # {{{
|
|||||||
with open(dest, 'wb') as f:
|
with open(dest, 'wb') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
|
def filesize(self, name):
|
||||||
|
if name in self.dirtied:
|
||||||
|
self.commit_item(name)
|
||||||
|
path = self.name_to_abspath(name)
|
||||||
|
return os.path.getsize(path)
|
||||||
|
|
||||||
def open(self, name, mode='rb'):
|
def open(self, name, mode='rb'):
|
||||||
''' Open the file pointed to by name for direct read/write. Note that
|
''' Open the file pointed to by name for direct read/write. Note that
|
||||||
this will commit the file if it is dirtied and remove it from the parse
|
this will commit the file if it is dirtied and remove it from the parse
|
||||||
|
17
src/calibre/gui2/tweak_book/__init__.py
Normal file
17
src/calibre/gui2/tweak_book/__init__.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
|
||||||
|
_current_container = None
|
||||||
|
|
||||||
|
def current_container():
|
||||||
|
return _current_container
|
||||||
|
|
||||||
|
def set_current_container(container):
|
||||||
|
global _current_container
|
||||||
|
_current_container = container
|
163
src/calibre/gui2/tweak_book/file_list.py
Normal file
163
src/calibre/gui2/tweak_book/file_list.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
from PyQt4.Qt import (
|
||||||
|
QWidget, QTreeWidget, QGridLayout, QSize, Qt, QTreeWidgetItem, QIcon,
|
||||||
|
QStyledItemDelegate, QStyle)
|
||||||
|
|
||||||
|
from calibre import guess_type, human_readable
|
||||||
|
from calibre.ebooks.oeb.base import OEB_STYLES
|
||||||
|
from calibre.gui2.tweak_book import current_container
|
||||||
|
|
||||||
|
ICON_SIZE = 24
|
||||||
|
NAME_ROLE = Qt.UserRole
|
||||||
|
|
||||||
|
class ItemDelegate(QStyledItemDelegate):
|
||||||
|
|
||||||
|
def sizeHint(self, option, index):
|
||||||
|
ans = QStyledItemDelegate.sizeHint(self, option, index)
|
||||||
|
top_level = not index.parent().isValid()
|
||||||
|
ans += QSize(0, 20 if top_level else 10)
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def paint(self, painter, option, index):
|
||||||
|
top_level = not index.parent().isValid()
|
||||||
|
hover = option.state & QStyle.State_MouseOver
|
||||||
|
if hover:
|
||||||
|
if top_level:
|
||||||
|
suffix = '\xa0(%d)' % index.model().rowCount(index)
|
||||||
|
else:
|
||||||
|
suffix = '\xa0' + human_readable(current_container().filesize(unicode(index.data(NAME_ROLE).toString())))
|
||||||
|
br = painter.boundingRect(option.rect, Qt.AlignRight|Qt.AlignVCenter, suffix)
|
||||||
|
if top_level and index.row() > 0:
|
||||||
|
option.rect.adjust(0, 5, 0, 0)
|
||||||
|
painter.drawLine(option.rect.topLeft(), option.rect.topRight())
|
||||||
|
option.rect.adjust(0, 1, 0, 0)
|
||||||
|
if hover:
|
||||||
|
option.rect.adjust(0, 0, -br.width(), 0)
|
||||||
|
QStyledItemDelegate.paint(self, painter, option, index)
|
||||||
|
if hover:
|
||||||
|
option.rect.adjust(0, 0, br.width(), 0)
|
||||||
|
painter.drawText(option.rect, Qt.AlignRight|Qt.AlignVCenter, suffix)
|
||||||
|
|
||||||
|
class FileList(QTreeWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
QTreeWidget.__init__(self, parent)
|
||||||
|
self.delegate = ItemDelegate(self)
|
||||||
|
self.setTextElideMode(Qt.ElideMiddle)
|
||||||
|
self.setItemDelegate(self.delegate)
|
||||||
|
self.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
||||||
|
self.header().close()
|
||||||
|
self.setDragEnabled(True)
|
||||||
|
self.setSelectionMode(self.ExtendedSelection)
|
||||||
|
self.viewport().setAcceptDrops(True)
|
||||||
|
self.setDropIndicatorShown(True)
|
||||||
|
self.setDragDropMode(self.InternalMove)
|
||||||
|
self.setAutoScroll(True)
|
||||||
|
self.setAutoScrollMargin(ICON_SIZE*2)
|
||||||
|
self.setDefaultDropAction(Qt.MoveAction)
|
||||||
|
self.setAutoExpandDelay(1000)
|
||||||
|
self.setAnimated(True)
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
self.in_drop_event = False
|
||||||
|
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.customContextMenuRequested.connect(self.show_context_menu)
|
||||||
|
self.root = self.invisibleRootItem()
|
||||||
|
|
||||||
|
def build(self, container):
|
||||||
|
self.clear()
|
||||||
|
self.root = self.invisibleRootItem()
|
||||||
|
self.root.setFlags(Qt.ItemIsDragEnabled)
|
||||||
|
self.categories = {}
|
||||||
|
for category, text, icon in (
|
||||||
|
('text', _('Text'), 'keyboard-prefs.png'),
|
||||||
|
('styles', _('Styles'), 'lookfeel.png'),
|
||||||
|
('images', _('Images'), 'view-image.png'),
|
||||||
|
('fonts', _('Fonts'), 'font.png'),
|
||||||
|
('misc', _('Miscellaneous'), 'mimetypes/dir.png'),
|
||||||
|
):
|
||||||
|
self.categories[category] = i = QTreeWidgetItem(self.root, 0)
|
||||||
|
i.setText(0, text)
|
||||||
|
i.setIcon(0, QIcon(I(icon)))
|
||||||
|
f = i.font(0)
|
||||||
|
f.setBold(True)
|
||||||
|
i.setFont(0, f)
|
||||||
|
i.setData(0, NAME_ROLE, category)
|
||||||
|
flags = Qt.ItemIsEnabled
|
||||||
|
if category == 'text':
|
||||||
|
flags |= Qt.ItemIsDropEnabled
|
||||||
|
i.setFlags(flags)
|
||||||
|
|
||||||
|
processed, seen = set(), {}
|
||||||
|
|
||||||
|
def get_display_name(name, item):
|
||||||
|
parts = name.split('/')
|
||||||
|
text = parts[-1]
|
||||||
|
while text in seen and parts:
|
||||||
|
text = parts.pop() + '/' + text
|
||||||
|
seen[text] = item
|
||||||
|
return text
|
||||||
|
|
||||||
|
for name, linear in container.spine_names:
|
||||||
|
processed.add(name)
|
||||||
|
i = QTreeWidgetItem(self.categories['text'], 1)
|
||||||
|
prefix = '' if linear else '[nl] '
|
||||||
|
if not linear:
|
||||||
|
i.setIcon(self.non_linear_icon)
|
||||||
|
i.setText(0, prefix + get_display_name(name, i))
|
||||||
|
i.setStatusTip(0, _('Full path: ') + name)
|
||||||
|
i.setFlags(Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable)
|
||||||
|
i.setData(0, NAME_ROLE, name)
|
||||||
|
|
||||||
|
font_types = {guess_type('a.'+x)[0] for x in ('ttf', 'otf', 'woff')}
|
||||||
|
|
||||||
|
def get_category(mt):
|
||||||
|
category = 'misc'
|
||||||
|
if mt.startswith('image/'):
|
||||||
|
category = 'images'
|
||||||
|
elif mt in font_types:
|
||||||
|
category = 'fonts'
|
||||||
|
elif mt in OEB_STYLES:
|
||||||
|
category = 'styles'
|
||||||
|
return category
|
||||||
|
|
||||||
|
all_files = list(container.manifest_type_map.iteritems())
|
||||||
|
all_files.append((guess_type('a.opf')[0], [container.opf_name]))
|
||||||
|
|
||||||
|
for name in container.name_path_map:
|
||||||
|
if name in processed:
|
||||||
|
continue
|
||||||
|
processed.add(name)
|
||||||
|
imt = container.mime_map.get(name, guess_type(name)[0])
|
||||||
|
icat = get_category(imt)
|
||||||
|
i = QTreeWidgetItem(self.categories[icat], 1)
|
||||||
|
i.setText(0, get_display_name(name, i))
|
||||||
|
i.setStatusTip(0, _('Full path: ') + name)
|
||||||
|
i.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||||
|
i.setData(0, NAME_ROLE, name)
|
||||||
|
|
||||||
|
for c in self.categories.itervalues():
|
||||||
|
self.expandItem(c)
|
||||||
|
|
||||||
|
def show_context_menu(self, point):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FileListWidget(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
QWidget.__init__(self, parent)
|
||||||
|
self.setLayout(QGridLayout(self))
|
||||||
|
self.file_list = FileList(self)
|
||||||
|
self.layout().addWidget(self.file_list)
|
||||||
|
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
def build(self, container):
|
||||||
|
self.file_list.build(container)
|
||||||
|
|
||||||
|
|
84
src/calibre/gui2/tweak_book/job.py
Normal file
84
src/calibre/gui2/tweak_book/job.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Thread
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from PyQt4.Qt import (QWidget, QVBoxLayout, QLabel, Qt, QPainter, QBrush, QColor)
|
||||||
|
|
||||||
|
from calibre.gui2 import Dispatcher
|
||||||
|
from calibre.gui2.progress_indicator import ProgressIndicator
|
||||||
|
|
||||||
|
class LongJob(Thread):
|
||||||
|
|
||||||
|
daemon = True
|
||||||
|
|
||||||
|
def __init__(self, name, user_text, callback, function, *args, **kwargs):
|
||||||
|
Thread.__init__(self, name=name)
|
||||||
|
self.user_text = user_text
|
||||||
|
self.function = function
|
||||||
|
self.args, self.kwargs = args, kwargs
|
||||||
|
self.result = self.traceback = None
|
||||||
|
self.time_taken = None
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
st = time.time()
|
||||||
|
try:
|
||||||
|
self.result = self.function(*self.args, **self.kwargs)
|
||||||
|
except:
|
||||||
|
import traceback
|
||||||
|
self.traceback = traceback.format_exc()
|
||||||
|
self.time_taken = time.time() - st
|
||||||
|
try:
|
||||||
|
self.callback(self)
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BlockingJob(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
QWidget.__init__(self, parent)
|
||||||
|
l = QVBoxLayout()
|
||||||
|
self.setLayout(l)
|
||||||
|
l.addStretch(10)
|
||||||
|
self.pi = ProgressIndicator(self, 128)
|
||||||
|
l.addWidget(self.pi, alignment=Qt.AlignHCenter)
|
||||||
|
self.msg = QLabel('')
|
||||||
|
l.addSpacing(10)
|
||||||
|
l.addWidget(self.msg, alignment=Qt.AlignHCenter)
|
||||||
|
l.addStretch(10)
|
||||||
|
self.setVisible(False)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.setGeometry(0, 0, self.parent().width(), self.parent().height())
|
||||||
|
self.setVisible(True)
|
||||||
|
self.raise_()
|
||||||
|
self.pi.startAnimation()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.pi.stopAnimation()
|
||||||
|
self.setVisible(False)
|
||||||
|
|
||||||
|
def job_done(self, callback, job):
|
||||||
|
del job.callback
|
||||||
|
self.stop()
|
||||||
|
callback(job)
|
||||||
|
|
||||||
|
def paintEvent(self, ev):
|
||||||
|
p = QPainter(self)
|
||||||
|
p.fillRect(ev.region().boundingRect(), QBrush(QColor(200, 200, 200, 160), Qt.SolidPattern))
|
||||||
|
p.end()
|
||||||
|
QWidget.paintEvent(self, ev)
|
||||||
|
|
||||||
|
def __call__(self, name, user_text, callback, function, *args, **kwargs):
|
||||||
|
self.msg.setText('<h2>%s</h2>' % user_text)
|
||||||
|
job = LongJob(name, user_text, Dispatcher(partial(self.job_done, callback)), function, *args, **kwargs)
|
||||||
|
job.start()
|
||||||
|
self.start()
|
48
src/calibre/gui2/tweak_book/main.py
Normal file
48
src/calibre/gui2/tweak_book/main.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
import sys, os
|
||||||
|
|
||||||
|
from PyQt4.Qt import QIcon
|
||||||
|
|
||||||
|
from calibre.constants import islinux
|
||||||
|
from calibre.gui2 import Application, ORG_NAME, APP_UID
|
||||||
|
from calibre.ptempfile import reset_base_dir
|
||||||
|
from calibre.utils.config import OptionParser
|
||||||
|
from calibre.gui2.tweak_book.ui import Main
|
||||||
|
|
||||||
|
def option_parser():
|
||||||
|
return OptionParser('''\
|
||||||
|
%prog [opts] [path_to_ebook]
|
||||||
|
|
||||||
|
Launch the calibre tweak book tool.
|
||||||
|
''')
|
||||||
|
|
||||||
|
def main(args=sys.argv):
|
||||||
|
# Ensure we can continue to function if GUI is closed
|
||||||
|
os.environ.pop('CALIBRE_WORKER_TEMP_DIR', None)
|
||||||
|
reset_base_dir()
|
||||||
|
|
||||||
|
parser = option_parser()
|
||||||
|
opts, args = parser.parse_args(args)
|
||||||
|
override = 'calibre-tweak-book' if islinux else None
|
||||||
|
app = Application(args, override_program_name=override)
|
||||||
|
app.load_builtin_fonts()
|
||||||
|
app.setWindowIcon(QIcon(I('tweak.png')))
|
||||||
|
Application.setOrganizationName(ORG_NAME)
|
||||||
|
Application.setApplicationName(APP_UID)
|
||||||
|
main = Main(opts)
|
||||||
|
sys.excepthook = main.unhandled_exception
|
||||||
|
main.show()
|
||||||
|
if len(args) > 1:
|
||||||
|
main.open_book(args[1])
|
||||||
|
app.exec_()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
85
src/calibre/gui2/tweak_book/ui.py
Normal file
85
src/calibre/gui2/tweak_book/ui.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
import shutil, tempfile
|
||||||
|
|
||||||
|
from PyQt4.Qt import QDockWidget, Qt, QLabel, QIcon
|
||||||
|
|
||||||
|
from calibre.ebooks.oeb.polish.container import get_container
|
||||||
|
from calibre.ebooks.oeb.polish.main import SUPPORTED
|
||||||
|
from calibre.ptempfile import PersistentTemporaryDirectory
|
||||||
|
from calibre.gui2 import error_dialog
|
||||||
|
from calibre.gui2.main_window import MainWindow
|
||||||
|
from calibre.gui2.tweak_book import set_current_container, current_container
|
||||||
|
from calibre.gui2.tweak_book.file_list import FileListWidget
|
||||||
|
from calibre.gui2.tweak_book.job import BlockingJob
|
||||||
|
from calibre.gui2.tweak_book.undo import GlobalUndoHistory
|
||||||
|
|
||||||
|
def load_book(path_to_ebook, base_tdir):
|
||||||
|
tdir = tempfile.mkdtemp(dir=base_tdir)
|
||||||
|
return get_container(path_to_ebook, tdir=tdir)
|
||||||
|
|
||||||
|
class Main(MainWindow):
|
||||||
|
|
||||||
|
APP_NAME = _('Tweak Book')
|
||||||
|
|
||||||
|
def __init__(self, opts):
|
||||||
|
MainWindow.__init__(self, opts, disable_automatic_gc=True)
|
||||||
|
self.setWindowTitle(self.APP_NAME)
|
||||||
|
self.setWindowIcon(QIcon(I('tweak.png')))
|
||||||
|
self.opts = opts
|
||||||
|
self.tdir = None
|
||||||
|
self.path_to_ebook = None
|
||||||
|
self.container = None
|
||||||
|
self.global_undo = GlobalUndoHistory()
|
||||||
|
self.blocking_job = BlockingJob(self)
|
||||||
|
|
||||||
|
self.file_list_dock = d = QDockWidget(_('&Files Browser'), self)
|
||||||
|
d.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
|
||||||
|
self.file_list = FileListWidget(d)
|
||||||
|
d.setWidget(self.file_list)
|
||||||
|
self.addDockWidget(Qt.LeftDockWidgetArea, d)
|
||||||
|
|
||||||
|
self.status_bar = self.statusBar()
|
||||||
|
self.l = QLabel('Placeholder')
|
||||||
|
|
||||||
|
self.setCentralWidget(self.l)
|
||||||
|
|
||||||
|
def resizeEvent(self, ev):
|
||||||
|
self.blocking_job.resize(ev.size())
|
||||||
|
return super(Main, self).resizeEvent(ev)
|
||||||
|
|
||||||
|
def open_book(self, path):
|
||||||
|
ext = path.rpartition('.')[-1].upper()
|
||||||
|
if ext not in SUPPORTED:
|
||||||
|
return error_dialog(self, _('Unsupported format'),
|
||||||
|
_('Tweaking is only supported for books in the %s formats.'
|
||||||
|
' Convert your book to one of these formats first.') % _(' and ').join(sorted(SUPPORTED)),
|
||||||
|
show=True)
|
||||||
|
|
||||||
|
# TODO: Handle already open, dirtied book
|
||||||
|
|
||||||
|
if self.tdir:
|
||||||
|
shutil.rmtree(self.tdir, ignore_errors=True)
|
||||||
|
self.tdir = PersistentTemporaryDirectory()
|
||||||
|
self.blocking_job('open_book', _('Opening book, please wait...'), self.book_opened, load_book, path, self.tdir)
|
||||||
|
|
||||||
|
def book_opened(self, job):
|
||||||
|
if job.traceback is not None:
|
||||||
|
return error_dialog(self, _('Failed to open book'),
|
||||||
|
_('Failed to open book, click Show details for more information.'),
|
||||||
|
det_msg=job.traceback, show=True)
|
||||||
|
container = job.result
|
||||||
|
set_current_container(container)
|
||||||
|
self.current_metadata = container.mi
|
||||||
|
self.global_undo.open_book(container)
|
||||||
|
self.update_window_title()
|
||||||
|
self.file_list.build(container)
|
||||||
|
|
||||||
|
def update_window_title(self):
|
||||||
|
self.setWindowTitle(self.current_metadata.title + ' [%s] - %s' %(current_container().book_type.upper(), self.APP_NAME))
|
23
src/calibre/gui2/tweak_book/undo.py
Normal file
23
src/calibre/gui2/tweak_book/undo.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
|
||||||
|
class State(object):
|
||||||
|
|
||||||
|
def __init__(self, container):
|
||||||
|
self.container = container
|
||||||
|
self.operation = None
|
||||||
|
|
||||||
|
class GlobalUndoHistory(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.states = []
|
||||||
|
|
||||||
|
def open_book(self, container):
|
||||||
|
self.states = [State(container)]
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user