diff --git a/src/calibre/ebooks/oeb/polish/toc.py b/src/calibre/ebooks/oeb/polish/toc.py index 4a9ddd504a..6869541a39 100644 --- a/src/calibre/ebooks/oeb/polish/toc.py +++ b/src/calibre/ebooks/oeb/polish/toc.py @@ -99,18 +99,23 @@ class TOC(object): def __str__(self): return b'\n'.join([x.encode('utf-8') for x in self.get_lines()]) - @property - def as_dict(self): + def to_dict(self, node_counter=None): ans = { 'title':self.title, 'dest':self.dest, 'frag':self.frag, - 'children':[c.as_dict for c in self.children] + 'children':[c.to_dict(node_counter) for c in self.children] } if self.dest_exists is not None: ans['dest_exists'] = self.dest_exists if self.dest_error is not None: ans['dest_error'] = self.dest_error + if node_counter is not None: + ans['id'] = next(node_counter) return ans + @property + def as_dict(self): + return self.to_dict() + def child_xpath(tag, name): return tag.xpath('./*[calibre:lower-case(local-name()) = "%s"]'%name) @@ -748,4 +753,3 @@ def create_inline_toc(container, title=None): f.write(raw) set_guide_item(container, 'toc', title, name, frag='calibre_generated_inline_toc') return name - diff --git a/src/calibre/srv/books.py b/src/calibre/srv/books.py index 45c9c99554..0f3b1cb060 100644 --- a/src/calibre/srv/books.py +++ b/src/calibre/srv/books.py @@ -31,6 +31,7 @@ def abspath(x): x = '\\\\?\\' + os.path.abspath(x) return x + _books_cache_dir = None @@ -50,9 +51,10 @@ def books_cache_dir(): def book_hash(library_uuid, book_id, fmt, size, mtime): - raw = dumps((library_uuid, book_id, fmt.upper(), size, mtime), RENDER_VERSION) + raw = dumps((library_uuid, book_id, fmt.upper(), size, mtime, RENDER_VERSION)) return sha1(raw).hexdigest().decode('ascii') + staging_cleaned = False @@ -82,6 +84,7 @@ def queue_job(ctx, copy_format_to, bhash, fmt, book_id, size, mtime): queued_jobs[bhash] = job_id return job_id + last_final_clean_time = 0 @@ -175,6 +178,7 @@ def book_file(ctx, rd, book_id, fmt, size, mtime, name): raise raise HTTPNotFound('No book file with hash: %s and name: %s' % (bhash, name)) + mathjax_lock = Lock() mathjax_manifest = None diff --git a/src/calibre/srv/render_book.py b/src/calibre/srv/render_book.py index bf1be52618..05d85d0920 100644 --- a/src/calibre/srv/render_book.py +++ b/src/calibre/srv/render_book.py @@ -55,6 +55,7 @@ def decode_url(x): parts = x.split('#', 1) return decode_component(parts[0]), (parts[1] if len(parts) > 1 else '') + absolute_units = frozenset('px mm cm pt in pc q'.split()) length_factors = {'mm':2.8346456693, 'cm':28.346456693, 'in': 72, 'pc': 12, 'q':0.708661417325} @@ -114,6 +115,21 @@ def has_ancestor(elem, q): return False +def anchor_map(root): + ans = [] + seen = set() + for elem in root.xpath('//*[@id or @name]'): + eid = elem.get('id') + if not eid and elem.tag.endswith('}a'): + eid = elem.get('name') + if eid: + elem.set('id', eid) + if eid and eid not in seen: + ans.append(eid) + seen.add(eid) + return ans + + def get_length(root): strip_space = re.compile(r'\s+') ans = 0 @@ -136,6 +152,19 @@ def get_length(root): return ans +def toc_anchor_map(toc): + ans = defaultdict(list) + + def process_node(node): + name = node['dest'] + if name: + ans[name].append({'id':node['id'], 'frag':node['frag']}) + tuple(map(process_node, node['children'])) + + process_node(toc) + return dict(ans) + + class Container(ContainerBase): tweak_mode = True @@ -150,10 +179,11 @@ class Container(ContainerBase): name == 'mimetype' } raster_cover_name, titlepage_name = self.create_cover_page(input_fmt.lower()) + toc = get_toc(self).to_dict(count()) self.book_render_data = data = { 'version': RENDER_VERSION, - 'toc':get_toc(self).as_dict, + 'toc':toc, 'spine':[name for name, is_linear in self.spine_names], 'link_uid': uuid4(), 'book_hash': book_hash, @@ -163,6 +193,7 @@ class Container(ContainerBase): 'has_maths': False, 'total_length': 0, 'spine_length': 0, + 'toc_anchor_map': toc_anchor_map(toc), } # Mark the spine as dirty since we have to ensure it is normalized for name in data['spine']: @@ -188,6 +219,7 @@ class Container(ContainerBase): ans['has_maths'] = hm = check_for_maths(root) if hm: self.book_render_data['has_maths'] = True + ans['anchor_map'] = anchor_map(root) return ans data['files'] = {name:manifest_data(name) for name in set(self.name_path_map) - excluded_names} self.commit() @@ -467,5 +499,6 @@ def html_as_dict(root): def render(pathtoebook, output_dir, book_hash=None): Container(pathtoebook, output_dir, book_hash=book_hash) + if __name__ == '__main__': render(sys.argv[-2], sys.argv[-1]) diff --git a/src/pyj/read_book/flow_mode.pyj b/src/pyj/read_book/flow_mode.pyj index bdea2f3648..b074b03d7c 100644 --- a/src/pyj/read_book/flow_mode.pyj +++ b/src/pyj/read_book/flow_mode.pyj @@ -5,7 +5,7 @@ from __python__ import hash_literals, bound_methods from dom import set_css from read_book.globals import get_boss from keycodes import get_key -from utils import document_height, document_width +from utils import document_height, document_width, viewport_to_document def flow_to_scroll_fraction(frac): window.scrollTo(0, document_height() * frac) @@ -162,3 +162,27 @@ def handle_gesture(gesture): scroll_by_page(True) elif gesture.type is 'next-page': scroll_by_page(False) + +anchor_funcs = { + 'pos_for_elem': def pos_for_elem(elem): + if not elem: + return 0, 0 + br = elem.getBoundingClientRect() + x, y = viewport_to_document(br.left, br.top, elem.ownerDocument) + return y, x + , + 'visibility': def visibility(pos): + y, x = pos + if y < window.pageYOffset: + return -1 + if y < window.pageYOffset + window.innerHeight: + if x < window.pageXOffset: + return -1 + if x < window.pageXOffset + window.innerWidth: + return 0 + return 1 + , + 'cmp': def cmp(a, b): + return (a[0] - b[0]) or (a[1] - b[1]) + , +} diff --git a/src/pyj/read_book/globals.pyj b/src/pyj/read_book/globals.pyj index 442d902112..717925ad04 100644 --- a/src/pyj/read_book/globals.pyj +++ b/src/pyj/read_book/globals.pyj @@ -35,22 +35,25 @@ messenger = Messenger() iframe_id = 'read-book-iframe' uid = 'calibre-' + hexlify(random_bytes(12)) -_layout_mode = 'flow' def current_layout_mode(): - return _layout_mode + return current_layout_mode.value +current_layout_mode.value = 'flow' def set_layout_mode(val): - nonlocal _layout_mode - _layout_mode = val + current_layout_mode.value = val -_current_spine_item = None def current_spine_item(): - return _current_spine_item + return current_spine_item.value +current_spine_item.value = None def set_current_spine_item(val): - nonlocal _current_spine_item - _current_spine_item = val + current_spine_item.value = val +def toc_anchor_map(): + return toc_anchor_map.value + +def set_toc_anchor_map(val): + toc_anchor_map.value = val default_color_schemes = { 'white':{'foreground':'#000000', 'background':'#ffffff', 'name':_('White')}, diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 062651d644..4dca43d742 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -8,16 +8,17 @@ from gettext import install, gettext as _ from read_book.cfi import at_current, scroll_to as scroll_to_cfi from read_book.globals import set_boss, set_current_spine_item, current_layout_mode, current_spine_item, set_layout_mode from read_book.mathjax import apply_mathjax +from read_book.toc import update_visible_toc_anchors from read_book.resources import finalize_resources, unserialize_html from read_book.flow_mode import ( flow_to_scroll_fraction, flow_onwheel, flow_onkeydown, layout as flow_layout, handle_gesture as flow_handle_gesture, - scroll_by_page as flow_scroll_by_page + scroll_by_page as flow_scroll_by_page, anchor_funcs as flow_anchor_funcs ) from read_book.paged_mode import ( layout as paged_layout, scroll_to_fraction as paged_scroll_to_fraction, onwheel as paged_onwheel, onkeydown as paged_onkeydown, scroll_to_elem, jump_to_cfi as paged_jump_to_cfi, handle_gesture as paged_handle_gesture, - scroll_by_page as paged_scroll_by_page + scroll_by_page as paged_scroll_by_page, anchor_funcs as paged_anchor_funcs ) from read_book.settings import apply_settings, opts from read_book.touch import create_handlers as create_touch_handlers @@ -104,6 +105,7 @@ class IframeBoss: self._handle_gesture = flow_handle_gesture self.to_scroll_fraction = flow_to_scroll_fraction self.jump_to_cfi = scroll_to_cfi + self.anchor_funcs = flow_anchor_funcs else: self.do_layout = paged_layout self.handle_wheel = paged_onwheel @@ -111,6 +113,7 @@ class IframeBoss: self.to_scroll_fraction = paged_scroll_to_fraction self.jump_to_cfi = paged_jump_to_cfi self._handle_gesture = paged_handle_gesture + self.anchor_funcs = paged_anchor_funcs apply_settings(data.settings) set_current_spine_item({'name':data.name, 'is_first':index is 0, 'is_last':index is spine.length - 1, 'initial_position':data.initial_position}) self.last_cfi = None @@ -174,7 +177,7 @@ class IframeBoss: def content_loaded_stage2(self): self.connect_links() - window.addEventListener('scroll', debounce(self.update_cfi, 1000)) + window.addEventListener('scroll', debounce(self.onscroll, 1000)) window.addEventListener('resize', debounce(self.onresize, 500)) window.addEventListener('wheel', self.onwheel) window.addEventListener('keydown', self.onkeydown) @@ -188,7 +191,7 @@ class IframeBoss: self.scroll_to_anchor(ipos.anchor) elif ipos.type is 'cfi': self.jump_to_cfi(ipos.cfi) - self.update_cfi() + self.onscroll() self.send_message('content_loaded') def update_cfi(self): @@ -203,6 +206,14 @@ class IframeBoss: self.send_message('update_cfi', cfi=cfi, replace_history=self.replace_history_on_next_cfi_update) self.replace_history_on_next_cfi_update = True + def update_toc_position(self): + visible_anchors = update_visible_toc_anchors(self.book.manifest.toc_anchor_map, self.anchor_funcs) + self.send_message('update_toc_position', visible_anchors=visible_anchors) + + def onscroll(self): + self.update_cfi() + self.update_toc_position() + def onresize(self): if current_layout_mode() is not 'flow': self.do_layout() @@ -211,6 +222,7 @@ class IframeBoss: if cfi: paged_jump_to_cfi('/' + cfi) self.update_cfi() + self.update_toc_position() def onwheel(self, evt): evt.preventDefault() diff --git a/src/pyj/read_book/paged_mode.pyj b/src/pyj/read_book/paged_mode.pyj index e69b43e629..17083135d0 100644 --- a/src/pyj/read_book/paged_mode.pyj +++ b/src/pyj/read_book/paged_mode.pyj @@ -513,3 +513,25 @@ def handle_gesture(gesture): scroll_by_page(True, False) elif gesture.type is 'next-page': scroll_by_page(False, False) + + +anchor_funcs = { + 'pos_for_elem': def pos_for_elem(elem): + if not elem: + return 0 + br = elem.getBoundingClientRect() + x = viewport_to_document(br.left, br.top, elem.ownerDocument)[0] + return column_at(x) + , + 'visibility': def visibility(pos): + first = column_at(window.pageXOffset + 10) + if pos < first: + return -1 + if pos < first + cols_per_screen: + return 0 + return 1 + , + 'cmp': def cmp(a, b): + return a - b + , +} diff --git a/src/pyj/read_book/toc.pyj b/src/pyj/read_book/toc.pyj index 3985919a5f..e70797613e 100644 --- a/src/pyj/read_book/toc.pyj +++ b/src/pyj/read_book/toc.pyj @@ -8,13 +8,50 @@ from elementmaker import E from gettext import gettext as _ from modals import error_dialog from widgets import create_tree, find_text_in_tree, scroll_tree_item_into_view +from read_book.globals import toc_anchor_map, set_toc_anchor_map, current_spine_item, current_layout_mode + + +def update_visible_toc_nodes(visible_anchors): + update_visible_toc_nodes.data = visible_anchors +update_visible_toc_nodes.data = {} + + +def get_highlighted_toc_nodes(toc, parent_map, id_map): + data = update_visible_toc_nodes.data + ans = {} + if data.has_visible: + ans = data.visible_anchors + elif data.before: + ans[data.before] = True + for node_id in Object.keys(ans): + pid = parent_map[node_id] + while pid: + ans[pid] = True + pid = parent_map[pid] + return ans + def create_toc_tree(toc, onclick): + parent_map, id_map = {}, {} + + def process_node(node, parent): + id_map[node.id] = node + parent_map[node.id] = parent + for c in node.children: + process_node(c, node) + + process_node(toc) + highlighted_toc_nodes = get_highlighted_toc_nodes(toc, parent_map, id_map) + def populate_data(node, li, a): li.dataset.tocDest = node.dest or '' li.dataset.tocFrag = node.frag or '' - a.textContent = node.title or '' + title = node.title or '' + if highlighted_toc_nodes[node.id]: + a.appendChild(E.b(E.i(title))) + else: + a.textContent = title return create_tree(toc, populate_data, onclick) @@ -47,3 +84,45 @@ def create_toc_panel(book, container, onclick, onclose): search_bar = create_search_bar(do_search.bind(toc_panel_id), 'search-book-toc', button=search_button, placeholder=t) set_css(search_bar, flex_grow='10', margin_right='1em') container.appendChild(E.div(style='margin: 1ex 1em; display: flex;', search_bar, search_button)) + + +def current_toc_anchor_map(tam, anchor_funcs): + current_map = toc_anchor_map() + if not (current_map and current_map.layout_mode is current_layout_mode() and current_map.width is window.innerWidth and current_map.height is window.innerHeight): + name = current_spine_item().name + am = {} + anchors = v'[]' + for anchor in (tam[name] or v'[]'): + val = anchor_funcs.pos_for_elem() + if anchor.frag: + elem = document.getElementById(anchor.frag) + if elem: + val = anchor_funcs.pos_for_elem(elem) + am[anchor.id] = val + anchors.push(anchor.id) + anchors.sort(def (a, b): anchor_funcs.cmp(am[a], am[b]);) + sort_map = {aid: i for i, aid in enumerate(anchors)} + + current_map = {'layout_mode': current_layout_mode, 'width': window.innerWidth, 'height': window.innerHeight, 'pos_map': am, 'sort_map':sort_map} + set_toc_anchor_map(current_map) + return current_map + + +def update_visible_toc_anchors(toc_anchor_map, anchor_funcs): + tam = current_toc_anchor_map(toc_anchor_map, anchor_funcs) + prev = before = after = None + visible_anchors = {} + has_visible = False + for anchor_id in tam.pos_map: + pos = tam.pos_map[anchor_id] + visibility = anchor_funcs.visibility(pos) + if visibility is 0: + before = prev + has_visible = True + visible_anchors[anchor_id] = True + elif visibility > 0: + after = anchor_id + break + prev = anchor_id + + return {'visible_anchors':visible_anchors, 'has_visible':has_visible, 'before':before, 'after':after} diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index eae7d74766..1885c016f2 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -12,6 +12,7 @@ from read_book.overlay import Overlay from read_book.prefs.colors import resolve_color_scheme from read_book.prefs.font_size import change_font_size_by from read_book.touch import set_left_margin_handler, set_right_margin_handler +from read_book.toc import update_visible_toc_nodes from book_list.theme import get_color from utils import parse_url_params, username_key @@ -82,6 +83,7 @@ class View: 'goto_doc_boundary': self.goto_doc_boundary, 'scroll_to_anchor': self.on_scroll_to_anchor, 'update_cfi': self.on_update_cfi, + 'update_toc_position': self.on_update_toc_position, 'content_loaded': self.on_content_loaded, 'show_chrome': self.show_chrome, 'bump_font_size': self.bump_font_size, @@ -314,6 +316,9 @@ class View: self.book.last_read_position[unkey] = data.cfi self.ui.db.update_last_read_time(self.book) + def on_update_toc_position(self, data): + update_visible_toc_nodes(data.visible_anchors) + def show_spine_item(self, resource_data): self.loaded_resources = resource_data # Re-init the iframe to ensure any changes made to the environment by the last spine item are lost