Add documentation and an example editor plugin

This commit is contained in:
Kovid Goyal 2014-07-24 18:48:45 +05:30
parent cde4bbb670
commit 5dabfbd549
10 changed files with 290 additions and 6 deletions

View File

@ -59,6 +59,8 @@ and query the books database in |app|.
You can download this plugin from `interface_demo_plugin.zip <http://calibre-ebook.com/downloads/interface_demo_plugin.zip>`_
.. _import_name_txt:
The first thing to note is that this zip file has a lot more files in it, explained below, pay particular attention to
``plugin-import-name-interface_demo.txt``.
@ -180,6 +182,73 @@ You can see the ``prefs`` object being used in main.py:
.. literalinclude:: plugin_examples/interface_demo/main.py
:pyobject: DemoDialog.config
Edit Book plugins
------------------------------------------
Now let's change gears for a bit and look at creating a plugin to add tools to
the |app| book editor. The plugin is available here:
`editor_demo_plugin.zip <http://calibre-ebook.com/downloads/editor_demo_plugin.zip>`_.
The first step, as for all plugins is to create the
import name empty txt file, as described :ref:`above <import_name_txt>`.
We shall name the file ``plugin-import-name-editor_plugin_demo.txt``.
Now we create the mandatory ``__init__.py`` file that contains metadata about
the plugin -- its name, author, version, etc.
.. literalinclude:: plugin_examples/editor_demo/__init__.py
:lines: 8-
A single editor plugin can provide multiple tools each tool corresponds to a
single button in the toolbar and entry in the :guilabel:`Plugins` menu in the
editor. These can have sub-menus in case the tool has multiple related actions.
The tools must all be defined in the file ``main.py`` in your plugin. Every
tool is a class that inherits from the
:class:`calibre.gui2.tweak_book.plugin.Tool` class. Let's look at ``main.py``
from the demo plugin, the source code is heavily commented and should be
self-explanatory. Read the API documents of the
:class:`calibre.gui2.tweak_book.plugin.Tool` class for more details.
main.py
^^^^^^^^^
Here we will see the definition of a single tool that does a does a couple of
simple things that demonstrate the editor API most plugins will use.
.. literalinclude:: plugin_examples/editor_demo/main.py
:lines: 8-
Let's break down ``main.py``. We see that it defines a single tool, named
*Magnify fonts*. This tool will ask the user for a number and multiply all font
sizes in the book by that number.
The first important thing is the tool name which you must set to some
relatively unique string as it will be used as the key for this tool.
The next important entry point is the
:meth:`calibre.gui2.tweak_book.plugin.Tool.create_action`. This method creates
the QAction objects that appear in the plugins toolbar and plugin menu.
It also, optionally, assigns a keyboard shortcut that the user can customize.
The triggered signal from the QAction is connected to the ask_user() method
that asks the user for the font size multiplier, and then runs the
magnification code.
The magnification code is well commented and fairly simple. The main things to
note are that you get a reference to the editor window as ``self.gui`` and the
editor *Boss* as ``self.boss``. The *Boss* is the object that controls the editor
user interface. It has many useful methods, that are documented in the
:class:`calibre.gui2.tweak_book.boss.Boss` class.
Finally, there is ``self.current_container`` which is a reference to the book
being edited as a :class:`calibre.ebooks.oeb.polish.container.Container`
object. This represents the book as a collection of its constituent
HTML/CSS/image files and has convenience methods for doing many useful things.
The container object and various useful utility functions that can be reused in
your plugin code are documented in :ref:`polish_api`.
Adding translations to your plugin
--------------------------------------

View File

@ -0,0 +1,19 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
from calibre.customize import EditBookToolPlugin
class DemoPlugin(EditBookToolPlugin):
name = 'Edit Book plugin demo'
version = (1, 0, 0)
author = 'Kovid Goyal'
supported_platforms = ['windows', 'osx', 'linux']
description = 'A demonstration of the plugin interface for the ebook editor'
minimum_calibre_version = (1, 46, 0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,123 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import re
from PyQt4.Qt import QAction, QInputDialog
from cssutils.css import CSSRule
# The base class that all tools must inherit from
from calibre.gui2.tweak_book.plugin import Tool
from calibre import force_unicode
from calibre.gui2 import error_dialog
from calibre.ebooks.oeb.polish.container import OEB_DOCS, OEB_STYLES, serialize
class DemoTool(Tool):
#: Set this to a unique name it will be used as a key
name = 'demo-tool'
#: If True the user can choose to place this tool in the plugins toolbar
allowed_in_toolbar = True
#: If True the user can choose to place this tool in the plugins menu
allowed_in_menu = True
def create_action(self, for_toolbar=True):
# Create an action, this will be added to the plugins toolbar and
# the plugins menu
ac = QAction(get_icons('images/icon.png'), 'Magnify fonts', self.gui) # noqa
if not for_toolbar:
# Register a keyboard shortcut for this toolbar action. We only
# register it for the action created for the menu, not the toolbar,
# to avoid a double trigger
self.register_shortcut(ac, 'magnify-fonts-tool', default_keys=('Ctrl+Shift+Alt+D',))
ac.triggered.connect(self.ask_user)
return ac
def ask_user(self):
# Ask the user for a factor by which to multiply all font sizes
factor, ok = QInputDialog.getDouble(
self.gui, 'Enter a magnification factor', 'Allow font sizes in the book will be multiplied by the specified factor',
value=2, min=0.1, max=4
)
if ok:
# Ensure any in progress editing the user is doing is present in the container
self.boss.commit_all_editors_to_container()
try:
self.magnify_fonts(factor)
except Exception:
# Something bad happened report the error to the user
import traceback
error_dialog(self.gui, _('Failed to magnify fonts'), _(
'Failed to magnify fonts, click "Show details" for more info'),
det_msg=traceback.format_exc(), show=True)
# Revert to the saved restore point
self.boss.revert_requested(self.boss.global_undo.previous_container)
else:
# Show the user what changes we have made, allowing her to
# revert them if necessary
self.boss.show_current_diff()
# Update the editor UI to take into account all the changes we
# have made
self.boss.apply_container_update_to_gui()
def magnify_fonts(self, factor):
# Magnify all font sizes defined in the book by the specified factor
# First we create a restore point so that the user can undo all changes
# we make.
self.boss.add_savepoint('Before: Magnify fonts')
container = self.current_container # The book being edited as a container object
# Iterate over all style declarations int he book, this means css
# stylesheets, <style> tags and style="" attributes
for name, media_type in container.mime_map.iteritems():
if media_type in OEB_STYLES:
# A stylesheet. Parsed stylesheets are cssutils CSSStylesheet
# objects.
self.magnify_stylesheet(container.parsed(name), factor)
container.dirty(name) # Tell the container that we have changed the stylesheet
elif media_type in OEB_DOCS:
# A HTML file. Parsed HTML files are lxml elements
for style_tag in container.parsed(name).xpath('//*[local-name="style"]'):
if style_tag.text and style_tag.get('type', None) in {None, 'text/css'}:
# We have an inline CSS <style> tag, parse it into a
# stylesheet object
sheet = container.parse_css(style_tag.text)
self.magnify_stylesheet(sheet, factor)
style_tag.text = serialize(sheet, 'text/css', pretty_print=True)
container.dirty(name) # Tell the container that we have changed the stylesheet
for elem in container.parsed(name).xpath('//*[@style]'):
# Process inline style attributes
block = container.parse_css(elem.get('style'), is_declaration=True)
self.magnify_declaration(block, factor)
elem.set('style', force_unicode(block.getCssText(separator=' '), 'utf-8'))
def magnify_stylesheet(self, sheet, factor):
# Magnify all fonts in the specified stylesheet by the specified
# factor.
for rule in sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE):
self.magnify_declaration(rule.style, factor)
def magnify_declaration(self, style, factor):
# Magnify all fonts in the specified style declaration by the specified
# factor
val = style.getPropertyValue('font-size')
if not val:
return
# see if the font-size contains a number
num = re.search(r'[0-9.]+', val)
if num is not None:
num = num.group()
val = val.replace(num, '%f' % (float(num) * factor))
style.setProperty('font-size', val)
# We should also be dealing with the font shorthand property and
# font sizes specified as non numbers, but those are left as exercises
# for the reader

View File

@ -195,3 +195,13 @@ Viewer plugins
:show-inheritance:
:members:
:member-order: bysource
Edit Book plugins
--------------------
.. autoclass:: calibre.gui2.tweak_book.plugin.Tool
:show-inheritance:
:members:
:member-order: bysource

View File

@ -120,3 +120,16 @@ Working with the Table of Contents
.. autofunction:: create_inline_toc
Controlling the editor's user interface
-----------------------------------------
The ebook editor's user interface is controlled by a single global *Boss*
object. This has many useful methods that can be used in plugin code to
perform common tasks.
.. module:: calibre.gui2.tweak_book.boss
.. autoclass:: Boss
:members:

View File

@ -88,6 +88,7 @@ class Boss(QObject):
self.ignore_preview_to_editor_sync = False
setup_cssutils_serialization()
_boss = self
self.gui = parent
def __call__(self, gui):
self.gui = gui
@ -221,6 +222,11 @@ class Boss(QObject):
self.gui.blocking_job('import_book', _('Importing book, please wait...'), self.book_opened, func, src, dest, tdir=self.mkdtemp())
def open_book(self, path=None, edit_file=None, clear_notify_data=True):
'''
Open the ebook at ``path`` for editing. Will show an error if the ebook is not in a supported format or the current book has unsaved changes.
:param edit_file: The name of a file inside the newly openend book to start editing.
'''
if not self._check_before_open():
return
if not hasattr(path, 'rpartition'):
@ -314,6 +320,7 @@ class Boss(QObject):
self.gui.file_list.build(container)
def apply_container_update_to_gui(self):
' Update all the components of the user interface to reflect the latest data in the current book container '
self.refresh_file_list()
self.update_global_history_actions()
self.update_editors_from_container()
@ -581,6 +588,7 @@ class Boss(QObject):
ac.setText(text + ' "%s"'%(getattr(gu, x + '_msg') or '...'))
def add_savepoint(self, msg):
' Create a restore checkpoint with the name specified as ``msg`` '
self.commit_all_editors_to_container()
nc = clone_container(current_container(), self.mkdtemp())
self.global_undo.add_savepoint(nc, msg)
@ -588,6 +596,7 @@ class Boss(QObject):
self.update_global_history_actions()
def rewind_savepoint(self):
' Undo the previous creation of a restore checkpoint, useful if you create a checkpoint, then abort the operation with no changes '
container = self.global_undo.rewind_savepoint()
if container is not None:
set_current_container(container)
@ -613,6 +622,12 @@ class Boss(QObject):
return d
def show_current_diff(self, allow_revert=True, to_container=None):
'''
Show the changes to the book from its last checkpointed state
:param allow_revert: If True the diff dialog will have a button to allow the user to revert all changes
:param to_container: A container object to compare the current container to. If None, the previously checkpointed container is used
'''
self.commit_all_editors_to_container()
d = self.create_diff_dialog()
d.revert_requested.connect(partial(self.revert_requested, self.global_undo.previous_container))
@ -644,6 +659,7 @@ class Boss(QObject):
# }}}
def set_modified(self):
' Mark the book as having been modified '
self.gui.action_save.setEnabled(True)
def fix_html(self, current):
@ -750,7 +766,7 @@ class Boss(QObject):
self.gui.central.pre_fill_search(text)
def search(self, action, overrides=None):
' Run a search/replace '
# Run a search/replace
sp = self.gui.central.search_panel
# Ensure the search panel is visible
sp.setVisible(True)
@ -767,13 +783,13 @@ class Boss(QObject):
self.gui, self.show_editor, self.edit_file, self.show_current_diff, self.add_savepoint, self.rewind_savepoint, self.set_modified)
def find_word(self, word, locations):
' Go to a word from the spell check dialog '
# Go to a word from the spell check dialog
ed = self.gui.central.current_editor
name = editor_name(ed)
find_next_word(word, locations, ed, name, self.gui, self.show_editor, self.edit_file)
def next_spell_error(self):
' Go to the next spelling error '
# Go to the next spelling error
ed = self.gui.central.current_editor
name = editor_name(ed)
find_next_error(ed, name, self.gui, self.show_editor, self.edit_file)
@ -855,6 +871,9 @@ class Boss(QObject):
self.gui.file_list.build(container)
def commit_all_editors_to_container(self):
''' Commit any changes that the user has made to files open in editors to
the container. You should call this method before performing any
actions on the current container '''
changed = False
with BusyCursor():
for name, ed in editors.iteritems():
@ -865,6 +884,7 @@ class Boss(QObject):
return changed
def save_book(self):
' Save the book. Saving is performed in the background '
c = current_container()
for name, ed in editors.iteritems():
if ed.is_modified or not ed.is_synced_to_container:
@ -1094,6 +1114,7 @@ class Boss(QObject):
self.ignore_preview_to_editor_sync = False
def sync_preview_to_editor(self):
' Sync the position of the preview panel to the current cursor position in the current editor '
if self.ignore_preview_to_editor_sync:
return
ed = self.gui.central.current_editor
@ -1103,6 +1124,7 @@ class Boss(QObject):
self.gui.preview.sync_to_editor(name, ed.current_tag())
def sync_live_css_to_editor(self):
' Sync the Live CSS panel to the current cursor position in the current editor '
ed = self.gui.central.current_editor
if ed is not None:
name = editor_name(ed)
@ -1135,6 +1157,11 @@ class Boss(QObject):
self.gui.central.add_editor(name, editor)
def edit_file(self, name, syntax=None, use_template=None):
''' Open the file specified by name in an editor
:param syntax: The media type of the file, for example, ``'text/html'``. If not specified it is guessed from the file extension.
:param use_template: A template to initialize the opened editor with
'''
editor = editors.get(name, None)
if editor is None:
syntax = syntax or syntax_from_mime(name, guess_type(name))
@ -1156,6 +1183,7 @@ class Boss(QObject):
return editor
def show_editor(self, name):
' Show the editor that is editing the file specified by ``name`` '
self.gui.central.show_editor(editors[name])
editors[name].set_focus()
@ -1270,6 +1298,7 @@ class Boss(QObject):
self.close_editor(name)
def close_editor(self, name):
' Close the editor that is editing the file specified by ``name`` '
editor = editors.pop(name)
self.gui.central.close_editor(editor)
editor.break_cycles()

View File

@ -12,7 +12,7 @@ from PyQt4.Qt import QToolButton
from calibre import prints
from calibre.customize.ui import all_edit_book_tool_plugins
from calibre.gui2.tweak_book import tprefs
from calibre.gui2.tweak_book import tprefs, current_container
from calibre.gui2.tweak_book.boss import get_boss
class Tool(object):
@ -39,6 +39,11 @@ class Tool(object):
' The main window of the user interface '
return self.boss.gui
@property
def current_container(self):
' Return the current :class:`calibre.ebooks.oeb.polish.container.Container` object that represents the book being edited. '
return current_container()
def register_shortcut(self, qaction, unique_name, default_keys=(), short_text=None, description=None, **extra_data):
'''
Register a keyboard shortcut that will trigger the specified ``qaction``. This keyboard shortcut
@ -103,12 +108,17 @@ def load_plugin_tools(plugin):
def plugin_action_sid(plugin, tool, for_toolbar=True):
return plugin.name + tool.name + ('toolbar' if for_toolbar else 'menu')
plugin_toolbar_actions = []
def create_plugin_action(plugin, tool, for_toolbar, actions=None, toolbar_actions=None, plugin_menu_actions=None):
try:
ac = tool.create_action(for_toolbar=for_toolbar)
if ac is None:
raise RuntimeError('create_action() failed to return an action')
except Exception:
prints('Failed to create action for tool:', tool.name)
import traceback
traceback.print_stack()
traceback.print_exc()
return
sid = plugin_action_sid(plugin, tool, for_toolbar)
if actions is not None and sid in actions:
@ -120,6 +130,7 @@ def create_plugin_action(plugin, tool, for_toolbar, actions=None, toolbar_action
if for_toolbar:
if toolbar_actions is not None:
toolbar_actions[sid] = ac
plugin_toolbar_actions.append(ac)
ac.popup_mode = {'instant':QToolButton.InstantPopup, 'button':QToolButton.MenuButtonPopup}.get(
tool.toolbar_button_popup_mode, QToolButton.DelayedPopup)
else:
@ -127,9 +138,15 @@ def create_plugin_action(plugin, tool, for_toolbar, actions=None, toolbar_action
plugin_menu_actions.append(ac)
return ac
_tool_memory = [] # Needed to prevent the tool object from being garbage collected
def create_plugin_actions(actions, toolbar_actions, plugin_menu_actions):
del _tool_memory[:]
del plugin_toolbar_actions[:]
for plugin in all_edit_book_tool_plugins():
for tool in load_plugin_tools(plugin):
_tool_memory.append(tool)
if tool.allowed_in_toolbar:
create_plugin_action(plugin, tool, True, actions, toolbar_actions, plugin_menu_actions)
if tool.allowed_in_menu:
@ -141,3 +158,4 @@ def install_plugin(plugin):
sid = plugin_action_sid(plugin, tool, True)
if sid not in tprefs['global_plugins_toolbar']:
tprefs['global_plugins_toolbar'] = tprefs['global_plugins_toolbar'] + [sid]

View File

@ -400,13 +400,16 @@ class ToolbarSettings(QWidget):
return unicode(self.bars.itemData(self.bars.currentIndex()).toString())
def build_lists(self):
from calibre.gui2.tweak_book.plugin import plugin_toolbar_actions
self.available.clear(), self.current.clear()
name = self.current_name
if not name:
return
items = self.current_settings[name]
applied = set(items)
if name.startswith('global_'):
if name == 'global_plugins_toolbar':
all_items = {x.sid:x for x in plugin_toolbar_actions}
elif name.startswith('global_'):
all_items = toolbar_actions
elif name == 'editor_common_toolbar':
all_items = {x:actions[x] for x in tprefs.defaults[name] if x}