From 86cb606dc99addf4287ed27047617bdaef8a5ce6 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 29 May 2012 20:03:56 +0530 Subject: [PATCH] E-book viewer: The Table of contents panel now tracks the current position in the book. As you scroll through the book, the entry you are currently on is highlighted in the Table fo Contents panel. Fixes #995489 ([Enhancement] Track and Display Book Chapters) --- src/calibre/ebooks/oeb/iterator/spine.py | 2 +- src/calibre/gui2/viewer/documentview.py | 58 ++++++++++++++++--- src/calibre/gui2/viewer/javascript.py | 4 +- src/calibre/gui2/viewer/main.py | 39 +++++++++---- src/calibre/gui2/viewer/toc.py | 73 ++++++++++++++++++++++-- 5 files changed, 151 insertions(+), 25 deletions(-) diff --git a/src/calibre/ebooks/oeb/iterator/spine.py b/src/calibre/ebooks/oeb/iterator/spine.py index 659a2500ea..9445da8def 100644 --- a/src/calibre/ebooks/oeb/iterator/spine.py +++ b/src/calibre/ebooks/oeb/iterator/spine.py @@ -29,7 +29,7 @@ def anchor_map(html): ans = {} for match in re.finditer( r'''(?:id|name)\s*=\s*['"]([^'"]+)['"]''', html): - anchor = match.group(0) + anchor = match.group(1) ans[anchor] = ans.get(anchor, match.start()) return ans diff --git a/src/calibre/gui2/viewer/documentview.py b/src/calibre/gui2/viewer/documentview.py index 7c07deea4f..650911d9ca 100644 --- a/src/calibre/gui2/viewer/documentview.py +++ b/src/calibre/gui2/viewer/documentview.py @@ -4,15 +4,14 @@ __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' __docformat__ = 'restructuredtext en' # Imports {{{ -import os, math, glob +import os, math, glob, json from base64 import b64encode from functools import partial -from PyQt4.Qt import (QSize, QSizePolicy, QUrl, SIGNAL, Qt, - QPainter, QPalette, QBrush, QFontDatabase, QDialog, - QColor, QPoint, QImage, QRegion, QIcon, - pyqtSignature, QAction, QMenu, - pyqtSignal, QSwipeGesture, QApplication) +from PyQt4.Qt import (QSize, QSizePolicy, QUrl, SIGNAL, Qt, pyqtProperty, + QPainter, QPalette, QBrush, QFontDatabase, QDialog, QColor, QPoint, + QImage, QRegion, QIcon, pyqtSignature, QAction, QMenu, QString, + pyqtSignal, QSwipeGesture, QApplication) from PyQt4.QtWebKit import QWebPage, QWebView, QWebSettings from calibre.gui2.viewer.flip import SlideFlip @@ -60,7 +59,14 @@ class Document(QWebPage): # {{{ def __init__(self, shortcuts, parent=None, debug_javascript=False): QWebPage.__init__(self, parent) self.setObjectName("py_bridge") + # Use this to pass arbitrary JSON encodable objects between python and + # javascript. In python get/set the value as: self.bridge_value. In + # javascript, get/set the value as: py_bridge.value + self.bridge_value = None + self.debug_javascript = debug_javascript + self.anchor_positions = {} + self.index_anchors = set() self.current_language = None self.loaded_javascript = False self.js_loader = JavaScriptLoader( @@ -125,6 +131,14 @@ class Document(QWebPage): # {{{ def add_window_objects(self): self.mainFrame().addToJavaScriptWindowObject("py_bridge", self) + self.javascript(''' + py_bridge.__defineGetter__('value', function() { + return JSON.parse(this._pass_json_value); + }); + py_bridge.__defineSetter__('value', function(val) { + this._pass_json_value = JSON.stringify(val); + }); + ''') self.loaded_javascript = False def load_javascript_libraries(self): @@ -143,6 +157,16 @@ class Document(QWebPage): # {{{ if self.hyphenate and getattr(self, 'loaded_lang', ''): self.javascript('do_hyphenation("%s")'%self.loaded_lang) + def _pass_json_value_getter(self): + val = json.dumps(self.bridge_value) + return QString(val) + + def _pass_json_value_setter(self, value): + self.bridge_value = json.loads(unicode(value)) + + _pass_json_value = pyqtProperty(QString, fget=_pass_json_value_getter, + fset=_pass_json_value_setter) + def after_load(self): self.set_bottom_padding(0) self.fit_images() @@ -153,6 +177,18 @@ class Document(QWebPage): # {{{ 'document.body.style.marginRight').toString()) if self.in_fullscreen_mode: self.switch_to_fullscreen_mode() + self.read_anchor_positions(use_cache=False) + + def read_anchor_positions(self, use_cache=True): + self.bridge_value = tuple(self.index_anchors) + self.javascript(u''' + py_bridge.value = book_indexing.anchor_positions(py_bridge.value, %s); + '''%('true' if use_cache else 'false')) + self.anchor_positions = self.bridge_value + if not isinstance(self.anchor_positions, dict): + # Some weird javascript error happened + self.anchor_positions = {} + return self.anchor_positions def switch_to_fullscreen_mode(self): self.in_fullscreen_mode = True @@ -531,6 +567,13 @@ class DocumentView(QWebView): # {{{ load_html(path, self, codec=path.encoding, mime_type=getattr(path, 'mime_type', None), pre_load_callback=callback) + entries = set() + for ie in getattr(path, 'index_entries', []): + if ie.start_anchor: + entries.add(ie.start_anchor) + if ie.end_anchor: + entries.add(ie.end_anchor) + self.document.index_anchors = entries self.turn_off_internal_scrollbars() def initialize_scrollbar(self): @@ -572,7 +615,8 @@ class DocumentView(QWebView): # {{{ if spine_index > -1: self.document.set_reference_prefix('%d.'%(spine_index+1)) if scrolled: - self.manager.scrolled(self.document.scroll_fraction) + self.manager.scrolled(self.document.scroll_fraction, + onload=True) self.turn_off_internal_scrollbars() if self.flipper.isVisible(): diff --git a/src/calibre/gui2/viewer/javascript.py b/src/calibre/gui2/viewer/javascript.py index 18dd516a8b..c4814cc04e 100644 --- a/src/calibre/gui2/viewer/javascript.py +++ b/src/calibre/gui2/viewer/javascript.py @@ -29,10 +29,11 @@ class JavaScriptLoader(object): CS = { 'cfi':'ebooks.oeb.display.cfi', + 'indexing':'ebooks.oeb.display.indexing', } ORDER = ('jquery', 'jquery_scrollTo', 'bookmarks', 'referencing', 'images', - 'hyphenation', 'hyphenator', 'cfi',) + 'hyphenation', 'hyphenator', 'cfi', 'indexing',) def __init__(self, dynamic_coffeescript=False): @@ -64,6 +65,7 @@ class JavaScriptLoader(object): os.path.exists(calibre.__file__)) ans = compiled_coffeescript(src, dynamic=dynamic).decode('utf-8') self._cache[name] = ans + return ans def __call__(self, evaljs, lang, default_lang): diff --git a/src/calibre/gui2/viewer/main.py b/src/calibre/gui2/viewer/main.py index 2b7dc8b41d..f368dfd270 100644 --- a/src/calibre/gui2/viewer/main.py +++ b/src/calibre/gui2/viewer/main.py @@ -236,7 +236,7 @@ class EbookViewer(MainWindow, Ui_EbookViewer): x:self.goto_page(x/100.)) self.search.search.connect(self.find) self.search.focus_to_library.connect(lambda: self.view.setFocus(Qt.OtherFocusReason)) - self.toc.clicked[QModelIndex].connect(self.toc_clicked) + self.toc.pressed[QModelIndex].connect(self.toc_clicked) self.reference.goto.connect(self.goto) self.bookmarks_menu = QMenu() @@ -494,16 +494,18 @@ class EbookViewer(MainWindow, Ui_EbookViewer): self.load_path(self.iterator.spine[spine_index]) def toc_clicked(self, index): - item = self.toc_model.itemFromIndex(index) - if item.abspath is not None: - if not os.path.exists(item.abspath): - return error_dialog(self, _('No such location'), - _('The location pointed to by this item' - ' does not exist.'), show=True) - url = QUrl.fromLocalFile(item.abspath) - if item.fragment: - url.setFragment(item.fragment) - self.link_clicked(url) + if QApplication.mouseButtons() & Qt.LeftButton: + item = self.toc_model.itemFromIndex(index) + if item.abspath is not None: + if not os.path.exists(item.abspath): + return error_dialog(self, _('No such location'), + _('The location pointed to by this item' + ' does not exist.'), show=True) + url = QUrl.fromLocalFile(item.abspath) + if item.fragment: + url.setFragment(item.fragment) + self.link_clicked(url) + self.view.setFocus(Qt.OtherFocusReason) def selection_changed(self, selected_text): self.selected_text = selected_text.strip() @@ -644,6 +646,7 @@ class EbookViewer(MainWindow, Ui_EbookViewer): self.current_page = self.iterator.spine[index] self.current_index = index self.set_page_number(self.view.scroll_fraction) + QTimer.singleShot(100, self.update_indexing_state) if self.pending_search is not None: self.do_search(self.pending_search, self.pending_search_dir=='backwards') @@ -859,8 +862,20 @@ class EbookViewer(MainWindow, Ui_EbookViewer): self.pos.set_value(page) self.set_vscrollbar_value(page) - def scrolled(self, frac): + def scrolled(self, frac, onload=False): self.set_page_number(frac) + if not onload: + ap = self.view.document.read_anchor_positions() + self.update_indexing_state(ap) + + def update_indexing_state(self, anchor_positions=None): + if hasattr(self, 'current_index'): + if anchor_positions is None: + anchor_positions = self.view.document.read_anchor_positions() + items = self.toc_model.update_indexing_state(self.current_index, + self.view.document.ypos, anchor_positions) + if items: + self.toc.scrollTo(items[-1].index()) def next_document(self): if (hasattr(self, 'current_index') and self.current_index < diff --git a/src/calibre/gui2/viewer/toc.py b/src/calibre/gui2/viewer/toc.py index b702f46577..d05c2048e7 100644 --- a/src/calibre/gui2/viewer/toc.py +++ b/src/calibre/gui2/viewer/toc.py @@ -8,35 +8,100 @@ __copyright__ = '2012, Kovid Goyal ' __docformat__ = 'restructuredtext en' import re -from PyQt4.Qt import QStandardItem, QStandardItemModel, Qt +from PyQt4.Qt import (QStandardItem, QStandardItemModel, Qt, QFont, + QApplication) from calibre.ebooks.metadata.toc import TOC as MTOC class TOCItem(QStandardItem): - def __init__(self, toc): + def __init__(self, spine, toc, depth, all_items): text = toc.text if text: text = re.sub(r'\s', ' ', text) + self.title = text QStandardItem.__init__(self, text if text else '') self.abspath = toc.abspath self.fragment = toc.fragment + all_items.append(self) + p = QApplication.palette() + self.base = p.base() + self.alternate_base = p.alternateBase() + self.bold_font = QFont(self.font()) + self.bold_font.setBold(True) + self.normal_font = self.font() for t in toc: - self.appendRow(TOCItem(t)) + self.appendRow(TOCItem(spine, t, depth+1, all_items)) self.setFlags(Qt.ItemIsEnabled|Qt.ItemIsSelectable) + spos = 0 + for i, si in enumerate(spine): + if si == self.abspath: + spos = i + break + am = getattr(spine[i], 'anchor_map', {}) + frag = self.fragment if (self.fragment and self.fragment in am) else None + self.starts_at = spos + self.start_anchor = frag + self.depth = depth + self.is_being_viewed = False @classmethod def type(cls): return QStandardItem.UserType+10 + def update_indexing_state(self, spine_index, scroll_pos, anchor_map): + is_being_viewed = False + if spine_index >= self.starts_at and spine_index <= self.ends_at: + start_pos = anchor_map.get(self.start_anchor, 0) + psp = [anchor_map.get(x, 0) for x in self.possible_end_anchors] + if self.ends_at == spine_index: + psp = [x for x in psp if x >= start_pos] + end_pos = min(psp) if psp else (scroll_pos+1 if self.ends_at == + spine_index else 0) + if spine_index > self.starts_at and spine_index < self.ends_at: + is_being_viewed = True + elif spine_index == self.starts_at and scroll_pos >= start_pos: + if spine_index != self.ends_at or scroll_pos < end_pos: + is_being_viewed = True + elif spine_index == self.ends_at and scroll_pos < end_pos: + if spine_index != self.starts_at or scroll_pos >= start_pos: + is_being_viewed = True + changed = is_being_viewed != self.is_being_viewed + self.is_being_viewed = is_being_viewed + if changed: + self.setFont(self.bold_font if is_being_viewed else self.normal_font) + self.setBackground(self.alternate_base if is_being_viewed else + self.base) + class TOC(QStandardItemModel): def __init__(self, spine, toc=None): QStandardItemModel.__init__(self) if toc is None: toc = MTOC() + self.all_items = depth_first = [] for t in toc: - self.appendRow(TOCItem(t)) + self.appendRow(TOCItem(spine, t, 0, depth_first)) self.setHorizontalHeaderItem(0, QStandardItem(_('Table of Contents'))) + for x in depth_first: + possible_enders = [ t for t in depth_first if t.depth <= x.depth + and t.starts_at >= x.starts_at and t is not x] + if possible_enders: + min_spine = min(t.starts_at for t in possible_enders) + possible_enders = { t.fragment for t in possible_enders if + t.starts_at == min_spine } + else: + min_spine = len(spine) - 1 + possible_enders = set() + x.ends_at = min_spine + x.possible_end_anchors = possible_enders + + def update_indexing_state(self, *args): + items_being_viewed = [] + for t in self.all_items: + t.update_indexing_state(*args) + if t.is_being_viewed: + items_being_viewed.append(t) + return items_being_viewed