From a991c5e36d0a7569809887e36b37a2d3c4f6c4df Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 12 Mar 2014 13:53:38 +0530 Subject: [PATCH] Edit Book: Add a tool to easily insert hyperlinks (click the insert hyperlink button on the toolbar) --- src/calibre/gui2/tweak_book/boss.py | 9 +- .../gui2/tweak_book/editor/smart/html.py | 26 ++- src/calibre/gui2/tweak_book/editor/text.py | 4 + src/calibre/gui2/tweak_book/editor/widget.py | 8 + src/calibre/gui2/tweak_book/widgets.py | 216 +++++++++++++++++- src/calibre/utils/matcher.py | 4 +- 6 files changed, 253 insertions(+), 14 deletions(-) diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index c20520445c..de605790fb 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -37,8 +37,8 @@ from calibre.gui2.tweak_book.toc import TOCEditor from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook from calibre.gui2.tweak_book.preferences import Preferences -from calibre.gui2.tweak_book.widgets import RationalizeFolders, MultiSplit, ImportForeign -from calibre.gui2.tweak_book.widgets import QuickOpen +from calibre.gui2.tweak_book.widgets import ( + RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink) _diff_dialogs = [] @@ -641,6 +641,11 @@ class Boss(QObject): chosen_name = chosen_image_is_external[0] href = current_container().name_to_href(chosen_name, edname) ed.insert_image(href) + elif action[0] == 'insert_hyperlink': + self.commit_all_editors_to_container() + d = InsertLink(current_container(), edname, parent=self.gui) + if d.exec_() == d.Accepted: + ed.insert_hyperlink(d.href) else: ed.action_triggered(action) diff --git a/src/calibre/gui2/tweak_book/editor/smart/html.py b/src/calibre/gui2/tweak_book/editor/smart/html.py index 40d905bcbd..bd4a5b568f 100644 --- a/src/calibre/gui2/tweak_book/editor/smart/html.py +++ b/src/calibre/gui2/tweak_book/editor/smart/html.py @@ -12,6 +12,7 @@ from . import NullSmarts from PyQt4.Qt import QTextEdit +from calibre import prepare_string_for_xml from calibre.gui2 import error_dialog get_offset = itemgetter(0) @@ -128,6 +129,20 @@ def rename_tag(cursor, opening_tag, closing_tag, new_name, insert=False): cursor.insertText(text) cursor.endEditBlock() +def ensure_not_within_tag_definition(cursor): + ''' Ensure the cursor is not inside a tag definition <>. Returns True iff the cursor was moved. ''' + block, offset = cursor.block(), cursor.positionInBlock() + b, boundary = next_tag_boundary(block, offset, forward=False) + if b is None: + return False + if boundary.is_start: + # We are inside a tag + block, boundary = next_tag_boundary(block, offset) + if block is not None: + cursor.setPosition(block.position() + boundary.offset + 1) + return True + return False + class HTMLSmarts(NullSmarts): def get_extra_selections(self, editor): @@ -180,4 +195,13 @@ class HTMLSmarts(NullSmarts): return error_dialog(editor, _('No found'), _( 'No suitable block level tag was found to rename'), show=True) - + def insert_hyperlink(self, editor, target): + c = editor.textCursor() + if c.hasSelection(): + c.insertText('') # delete any existing selected text + ensure_not_within_tag_definition(c) + c.insertText('' % prepare_string_for_xml(target, True)) + p = c.position() + c.insertText('') + c.setPosition(p) # ensure cursor is positioned inside the newly created tag + editor.setTextCursor(c) diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index fb4f39183a..cccb274c97 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -602,6 +602,10 @@ class TextEdit(PlainTextEdit): c.setPosition(left + len(text), c.KeepAnchor) self.setTextCursor(c) + def insert_hyperlink(self, target): + if hasattr(self.smarts, 'insert_hyperlink'): + self.smarts.insert_hyperlink(self, target) + def keyPressEvent(self, ev): if ev.key() == Qt.Key_X and ev.modifiers() == Qt.AltModifier: if self.replace_possible_unicode_sequence(): diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index a15514b28a..d4c7de956a 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -56,6 +56,9 @@ def register_text_editor_actions(reg, palette): ac = reg('view-image', _('&Insert image'), ('insert_resource', 'image'), 'insert-image', (), _('Insert an image into the text')) ac.setToolTip(_('

Insert image

Insert an image into the text')) + ac = reg('insert-link', _('Insert &hyperlink'), ('insert_hyperlink',), 'insert-hyperlink', (), _('Insert hyperlink')) + ac.setToolTip(_('

Insert hyperlink

Insert a hyperlink into the text')) + for i, name in enumerate(('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p')): text = ('&' + name) if name == 'p' else (name[0] + '&' + name[1]) desc = _('Convert the paragraph to <%s>') % name @@ -141,6 +144,9 @@ class Editor(QMainWindow): def insert_image(self, href): self.editor.insert_image(href) + def insert_hyperlink(self, href): + self.editor.insert_hyperlink(href) + def undo(self): self.editor.undo() @@ -195,6 +201,8 @@ class Editor(QMainWindow): b.addAction(actions['pretty-current']) if self.syntax in {'html', 'css'}: b.addAction(actions['insert-image']) + if self.syntax == 'html': + b.addAction(actions['insert-hyperlink']) if self.syntax == 'html': self.format_bar = b = self.addToolBar(_('Format text')) for x in ('bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', 'color', 'background-color'): diff --git a/src/calibre/gui2/tweak_book/widgets.py b/src/calibre/gui2/tweak_book/widgets.py index b770ca825d..43793e9bda 100644 --- a/src/calibre/gui2/tweak_book/widgets.py +++ b/src/calibre/gui2/tweak_book/widgets.py @@ -12,13 +12,18 @@ from itertools import izip from PyQt4.Qt import ( QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout, QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget, - QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption) + QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption, + QAbstractListModel, QModelIndex, QVariant, QStyledItemDelegate, QStyle, + QListView, QTextDocument, QSize) from calibre import prepare_string_for_xml -from calibre.gui2 import error_dialog, choose_files, choose_save_file +from calibre.gui2 import error_dialog, choose_files, choose_save_file, NONE from calibre.gui2.tweak_book import tprefs +from calibre.utils.icu import primary_sort_key from calibre.utils.matcher import get_char, Matcher +ROOT = QModelIndex() + class Dialog(QDialog): def __init__(self, title, name, parent=None): @@ -230,6 +235,15 @@ class ImportForeign(Dialog): # {{{ # Quick Open {{{ +def make_highlighted_text(emph, text, positions): + positions = sorted(set(positions) - {-1}, reverse=True) + text = prepare_string_for_xml(text) + for p in positions: + ch = get_char(text, p) + text = '%s%s%s' % (text[:p], emph, ch, text[p+len(ch):]) + return text + + class Results(QWidget): EMPH = "color:magenta; font-weight:bold" @@ -310,12 +324,7 @@ class Results(QWidget): self.update() def make_text(self, text, positions): - positions = sorted(set(positions) - {-1}, reverse=True) - text = prepare_string_for_xml(text) - for p in positions: - ch = get_char(text, p) - text = '%s%s%s' % (text[:p], self.EMPH, ch, text[p+len(ch):]) - text = QStaticText(text) + text = QStaticText(make_highlighted_text(self.EMPH, text, positions)) text.setTextOption(self.text_option) text.setTextFormat(Qt.RichText) return text @@ -411,7 +420,7 @@ class QuickOpen(Dialog): text = unicode(text).strip() self.help_label.setVisible(False) self.results.setVisible(True) - matches = self.matcher(text) + matches = self.matcher(text, limit=100) self.results(matches) self.matches = tuple(matches) @@ -437,6 +446,193 @@ class QuickOpen(Dialog): # }}} +# Filterable names list {{{ + +class NamesDelegate(QStyledItemDelegate): + + def sizeHint(self, option, index): + ans = QStyledItemDelegate.sizeHint(self, option, index) + ans.setHeight(ans.height() + 10) + return ans + + def paint(self, painter, option, index): + QStyledItemDelegate.paint(self, painter, option, index) + text, positions = index.data(Qt.UserRole).toPyObject() + self.initStyleOption(option, index) + painter.save() + painter.setFont(option.font) + p = option.palette + c = p.HighlightedText if option.state & QStyle.State_Selected else p.Text + group = (p.Active if option.state & QStyle.State_Active else p.Inactive) + c = p.color(group, c) + painter.setClipRect(option.rect) + if positions is None or -1 in positions: + painter.setPen(c) + painter.drawText(option.rect, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextSingleLine, text) + else: + to = QTextOption() + to.setWrapMode(to.NoWrap) + to.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + positions = sorted(set(positions) - {-1}, reverse=True) + text = '%s' % make_highlighted_text(Results.EMPH, text, positions) + doc = QTextDocument() + c = 'rgb(%d, %d, %d)'%c.getRgb()[:3] + doc.setDefaultStyleSheet(' body { color: %s }'%c) + doc.setHtml(text) + doc.setDefaultFont(option.font) + doc.setDocumentMargin(0.0) + doc.setDefaultTextOption(to) + height = doc.size().height() + painter.translate(option.rect.left(), option.rect.top() + (max(0, option.rect.height() - height) // 2)) + doc.drawContents(painter) + painter.restore() + +class NamesModel(QAbstractListModel): + + filtered = pyqtSignal(object) + + def __init__(self, names, parent=None): + self.items = [] + QAbstractListModel.__init__(self, parent) + self.set_names(names) + + def set_names(self, names): + self.names = names + self.matcher = Matcher(names) + self.filter('') + + def rowCount(self, parent=ROOT): + return len(self.items) + + def data(self, index, role): + if role == Qt.UserRole: + return QVariant(self.items[index.row()]) + if role == Qt.DisplayRole: + return QVariant('\xa0' * 20) + return NONE + + def filter(self, query): + query = unicode(query or '') + if not query: + self.items = tuple((text, None) for text in self.names) + else: + self.items = tuple(self.matcher(query).iteritems()) + self.reset() + self.filtered.emit(not bool(query)) + +def create_filterable_names_list(names, filter_text=None, parent=None): + nl = QListView(parent) + nl.m = m = NamesModel(names, parent=nl) + m.filtered.connect(lambda all_items: nl.scrollTo(m.index(0))) + nl.setModel(m) + nl.d = NamesDelegate(nl) + nl.setItemDelegate(nl.d) + f = QLineEdit(parent) + f.setPlaceholderText(filter_text or '') + f.textEdited.connect(m.filter) + return nl, f + +# }}} + +# Insert Link {{{ +class InsertLink(Dialog): + + def __init__(self, container, source_name, parent=None): + self.container = container + self.source_name = source_name + Dialog.__init__(self, _('Insert Hyperlink'), 'insert-hyperlink', parent=parent) + self.anchor_cache = {} + + def sizeHint(self): + return QSize(800, 600) + + def setup_ui(self): + self.l = l = QVBoxLayout(self) + self.setLayout(l) + + self.h = h = QHBoxLayout() + l.addLayout(h) + + names = [n for n, linear in self.container.spine_names] + fn, f = create_filterable_names_list(names, filter_text=_('Filter files'), parent=self) + self.file_names, self.file_names_filter = fn, f + fn.selectionModel().selectionChanged.connect(self.selected_file_changed) + self.fnl = fnl = QVBoxLayout() + self.la1 = la = QLabel(_('Choose a &file to link to:')) + la.setBuddy(fn) + fnl.addWidget(la), fnl.addWidget(fn), fnl.addWidget(f) + h.addLayout(fnl), h.setStretch(0, 2) + + fn, f = create_filterable_names_list([], filter_text=_('Filter locations'), parent=self) + self.anchor_names, self.anchor_names_filter = fn, f + fn.selectionModel().selectionChanged.connect(self.update_target) + fn.doubleClicked.connect(self.accept, type=Qt.QueuedConnection) + self.anl = fnl = QVBoxLayout() + self.la2 = la = QLabel(_('Choose a &location (anchor) in the file:')) + la.setBuddy(fn) + fnl.addWidget(la), fnl.addWidget(fn), fnl.addWidget(f) + h.addLayout(fnl), h.setStretch(1, 1) + + self.tl = tl = QHBoxLayout() + self.la3 = la = QLabel(_('&Target:')) + tl.addWidget(la) + self.target = t = QLineEdit(self) + la.setBuddy(t) + tl.addWidget(t) + l.addLayout(tl) + + l.addWidget(self.bb) + + def selected_file_changed(self, *args): + rows = list(self.file_names.selectionModel().selectedRows()) + if not rows: + self.anchor_names.model().set_names([]) + else: + name, positions = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject() + self.populate_anchors(name) + + def populate_anchors(self, name): + if name not in self.anchor_cache: + from calibre.ebooks.oeb.base import XHTML_NS + root = self.container.parsed(name) + self.anchor_cache[name] = sorted( + (set(root.xpath('//*/@id')) | set(root.xpath('//h:a/@name', namespaces={'h':XHTML_NS}))) - {''}, key=primary_sort_key) + self.anchor_names.model().set_names(self.anchor_cache[name]) + self.update_target() + + def update_target(self): + rows = list(self.file_names.selectionModel().selectedRows()) + if not rows: + return + name = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject()[0] + if name == self.source_name: + href = '' + else: + href = self.container.name_to_href(name, self.source_name) + frag = '' + rows = list(self.anchor_names.selectionModel().selectedRows()) + if rows: + anchor = self.anchor_names.model().data(rows[0], Qt.UserRole).toPyObject()[0] + if anchor: + frag = '#' + anchor + href += frag + self.target.setText(href or '#') + + @property + def href(self): + return unicode(self.target.text()).strip() + + @classmethod + def test(cls): + import sys + from calibre.ebooks.oeb.polish.container import get_container + c = get_container(sys.argv[-1], tweak_mode=True) + d = cls(c, next(c.spine_names)[0]) + if d.exec_() == d.Accepted: + print (d.href) + +# }}} + if __name__ == '__main__': app = QApplication([]) - QuickOpen.test() + InsertLink.test() diff --git a/src/calibre/utils/matcher.py b/src/calibre/utils/matcher.py index d4c7c8c162..895d29082a 100644 --- a/src/calibre/utils/matcher.py +++ b/src/calibre/utils/matcher.py @@ -92,7 +92,7 @@ class Matcher(object): self.scorers = [scorer(tuple(map(itemgetter(1), task_items))) for task_items in tasks] self.sort_keys = None - def __call__(self, query): + def __call__(self, query, limit=None): query = normalize('NFC', unicode(query)) with wlock: for i, scorer in enumerate(self.scorers): @@ -119,6 +119,8 @@ class Matcher(object): raise Exception('Failed to score items: %s' % error) items = sorted(((-scores[i], item, positions[i]) for i, item in enumerate(self.items)), key=itemgetter(0)) + if limit is not None: + del items[limit:] return OrderedDict(x[1:] for x in filter(itemgetter(0), items)) def get_items_from_dir(basedir, acceptq=lambda x: True):