# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal from __python__ import hash_literals import traceback from elementmaker import E from select import word_at_point from dom import set_css from read_book.cfi import ( at_current as cfi_at_current, at_point as cfi_at_point, scroll_to as cfi_scroll_to ) from read_book.globals import get_boss from read_book.settings import opts from read_book.viewport import scroll_viewport from utils import document_width, get_elem_data, set_elem_data, viewport_to_document def first_child(parent): c = parent.firstChild count = 0 while c?.nodeType is not Node.ELEMENT_NODE and count < 20: c = c?.nextSibling count += 1 if c?.nodeType is Node.ELEMENT_NODE: return c def has_start_text(elem): # Returns true if elem has some non-whitespace text before its first child # element for c in elem.childNodes: if c.nodeType is not Node.TEXT_NODE: break if c.nodeType is Node.TEXT_NODE and c.nodeValue and /\S/.test(c.nodeValue): return True return False def handle_rtl_body(body_style): # Make the body and root nodes have direction ltr so that column layout # works as expected if body_style.direction is "rtl": for node in document.body.childNodes: if node.nodeType is Node.ELEMENT_NODE and window.getComputedStyle(node).direction is "rtl": node.style.setProperty("direction", "rtl") document.body.style.direction = "ltr" document.documentElement.style.direction = 'ltr' def create_page_div(elem): div = E('blank-page-div', ' \n ') document.body.appendChild(div) set_css(div, break_before='always', display='block', white_space='pre', background_color='transparent', background_image='none', border_width='0', float='none', position='static') _in_paged_mode = False def in_paged_mode(): return _in_paged_mode col_width = screen_width = screen_height = cols_per_screen = gap = col_and_gap = number_of_cols = 0 is_full_screen_layout = False def reset_paged_mode_globals(): nonlocal _in_paged_mode, col_width, col_and_gap, screen_height, gap, screen_width, is_full_screen_layout, cols_per_screen, number_of_cols scroll_viewport.reset_globals() col_width = screen_width = screen_height = cols_per_screen = gap = col_and_gap = number_of_cols = 0 is_full_screen_layout = _in_paged_mode = False def column_at(xpos): # Return the (zero-based) number of the column that contains xpos sw = scroll_viewport.paged_content_width() if xpos >= sw - col_and_gap: xpos = sw - col_width + 10 return (xpos + gap) // col_and_gap def fit_images(): # Ensure no images are wider than the available width in a column. Note # that this method use getBoundingClientRect() which means it will # force a relayout if the render tree is dirty. images = [] vimages = [] img_tags = document.getElementsByTagName('img') bounding_rects = v'[]' for img_tag in img_tags: bounding_rects.push(img_tag.getBoundingClientRect()) maxh = screen_height for i in range(img_tags.length): img = img_tags[i] br = bounding_rects[i] previously_limited = get_elem_data(img, 'width-limited', False) data = get_elem_data(img, 'img-data', None) if data is None: data = {'left':br.left, 'right':br.right, 'height':br.height, 'display': img.style.display} set_elem_data(img, 'img-data', data) left = viewport_to_document(br.left, 0, img.ownerDocument)[0] col = column_at(left) * col_and_gap rleft = left - col width = br.right - br.left rright = rleft + width if previously_limited or rright > col_width: images.push([img, col_width - rleft]) previously_limited = get_elem_data(img, 'height-limited', False) if previously_limited or br.height > maxh: vimages.push(img) if previously_limited: set_css(img, break_before='auto', display=data.display) set_css(img, break_inside='avoid') for img_tag, max_width in images: img_tag.style.setProperty('max-width', max_width+'px') set_elem_data(img_tag, 'width-limited', True) for img_tag in vimages: data = get_elem_data(img_tag, 'img-data', None) set_css(img_tag, break_before='always', max_height=maxh+'px') if data.height > maxh: # This is needed to force the image onto a new page, without # it, the webkit algorithm may still decide to split the image # by keeping it part of its parent block img.style.setProperty('display', 'block') set_elem_data(img, 'height-limited', True) def calc_columns_per_screen(): cps = opts.columns_per_screen or {} cps = cps.landscape if scroll_viewport.width() > scroll_viewport.height() else cps.portrait try: cps = int(cps) except: cps = 0 if not cps: cps = int(Math.floor(scroll_viewport.width() / 500.0)) cps = max(1, min(cps or 1, 20)) return cps def layout(is_single_page): nonlocal _in_paged_mode, col_width, col_and_gap, screen_height, gap, screen_width, is_full_screen_layout, cols_per_screen body_style = window.getComputedStyle(document.body) first_layout = not _in_paged_mode cps = calc_columns_per_screen() if first_layout: handle_rtl_body(body_style) # Check if the current document is a full screen layout like # cover, if so we treat it specially. single_screen = (document.body.scrollHeight < scroll_viewport.height() + 75) first_layout = True has_svg = document.getElementsByTagName('svg').length > 0 imgs = document.getElementsByTagName('img') only_img = imgs.length is 1 and document.getElementsByTagName('div').length < 3 and document.getElementsByTagName('p').length < 2 if only_img and window.getComputedStyle(imgs[0]).zIndex < 0: # Needed for some stupidly coded fixed layout EPUB comics, see for # instance: https://bugs.launchpad.net/calibre/+bug/1667357 imgs[0].style.zIndex = '0' if not single_screen and cps > 1: num = cps - 1 elems = document.querySelectorAll('body > *') if elems.length == 1: # Workaround for the case when the content is wrapped in a # 100% height
. This causes the generated page divs to # not be in the correct location, at least in WebKit. See # https://bugs.launchpad.net/bugs/1594657 for an example. elems[0].style.height = 'auto' while num > 0: num -= 1 create_page_div() n = cols_per_screen = cps # Calculate the column width so that cols_per_screen columns fit exactly in # the window width, with their separator margins ww = col_width = screen_width = scroll_viewport.width() gap = 0 if n > 1: # Adjust the side margin so that the window width satisfies # col_width * n + (n-1) * 2 * side_margin = window_width sm = opts.margin_left + opts.margin_right gap = sm + ((ww + sm) % n) # Ensure ww + gap is a multiple of n col_width = ((ww + gap) // n) - gap screen_height = scroll_viewport.height() col_and_gap = col_width + gap set_css(document.body, column_gap=gap + 'px', column_width=col_width + 'px', column_rule='0px inset blue', min_width='0', max_width='none', min_height='0', max_height='100vh', column_fill='auto', margin='0', border_width='0', padding='0', box_sizing='content-box', width=screen_width + 'px', height=screen_height + 'px' ) # Without this, webkit bleeds the margin of the first block(s) of body # above the columns, which causes them to effectively be added to the # page margins (the margin collapse algorithm) document.body.style.setProperty('-webkit-margin-collapse', 'separate') c = first_child(document.body) if c: # Remove page breaks on the first few elements to prevent blank pages # at the start of a chapter set_css(c, break_before='avoid') if c.tagName.toLowerCase() is 'div': c2 = first_child(c) if c2 and not has_start_text(c): # Common pattern of all content being enclosed in a wrapper #
, see for example: https://bugs.launchpad.net/bugs/1366074 # In this case, we also modify the first child of the div # as long as there was no text before it. set_css(c2, break_before='avoid') if first_layout: # Because of a bug in webkit column mode, svg elements defined with # width 100% are wider than body and lead to a blank page after the # current page (when cols_per_screen == 1). Similarly img elements # with height=100% overflow the first column is_full_screen_layout = (only_img or has_svg) and single_screen and (scroll_viewport.paged_content_width() < 2*ww + 10) if is_single_page: is_full_screen_layout = True # Some browser engine, WebKit at least, adjust column widths to please # themselves, unless the container width is an exact multiple, so we check # for that and manually set the container widths. def check_column_widths(): nonlocal number_of_cols ncols = number_of_cols = (scroll_viewport.paged_content_width() + gap) / col_and_gap if ncols is not Math.floor(ncols): n = number_of_cols = Math.floor(ncols) dw = n*col_width + (n-1)*gap data = {'col_width':col_width, 'gap':gap, 'scrollWidth':scroll_viewport.paged_content_width(), 'ncols':ncols, 'desired_width':dw} return data data = check_column_widths() if data: dw = data.desired_width for elem in document.documentElement, document.body: set_css(elem, max_width=dw + 'px', min_width=dw + 'px') data = check_column_widths() if data: print('WARNING: column layout broken, probably because there is some non-reflowable content in the book that is wider than the column width', data) _in_paged_mode = True fit_images() return gap def current_scroll_offset(): return scroll_viewport.x() def scroll_to_offset(x): scroll_viewport.scroll_to(x, 0) def scroll_to_column(number, notify=False, duration=1000): pos = number * col_and_gap limit = scroll_viewport.paged_content_width() - screen_width pos = min(pos, limit) scroll_to_offset(pos) def scroll_to_xpos(xpos, notify=False, duration=1000): # Scroll so that the column containing xpos is the left most column in # the viewport if jstype(xpos) is not 'number': print(xpos, 'is not a number, cannot scroll to it!') return if is_full_screen_layout: scroll_to_offset(0) return scroll_to_column(column_at(xpos), notify=notify, duration=duration) def scroll_to_fraction(frac): # Scroll to the position represented by frac (number between 0 and 1) xpos = Math.floor(scroll_viewport.paged_content_width() * frac) scroll_to_xpos(xpos) def column_boundaries(): # Return the column numbers at the left edge and after the right edge # of the viewport l = column_at(current_scroll_offset() + 10) return l, l + cols_per_screen def current_column_location(): # The location of the left edge of the left most column currently # visible in the viewport if is_full_screen_layout: return 0 c = column_at(current_scroll_offset() + 10) return c * col_and_gap def next_screen_location(): # The position to scroll to for the next screen (which could contain # more than one pages). Returns -1 if no further scrolling is possible. if is_full_screen_layout: return -1 cc = current_column_location() ans = cc + screen_width if cols_per_screen > 1: current_col = column_at(current_scroll_offset() + 10) ncols = (scroll_viewport.paged_content_width() + gap) // col_and_gap cols_left = ncols - (current_col + cols_per_screen) if cols_left < cols_per_screen: return -1 # Only blank, dummy pages left limit = scroll_viewport.paged_content_width() - scroll_viewport.width() if limit < col_and_gap: return -1 if ans > limit: ans = limit if current_scroll_offset() < limit else -1 return ans def previous_screen_location(): # The position to scroll to for the previous screen (which could contain # more than one pages). Returns -1 if no further scrolling is possible. if is_full_screen_layout: return -1 cc = current_column_location() ans = cc - screen_width if ans < 0: # We ignore small scrolls (less than 15px) when going to previous # screen ans = 0 if current_scroll_offset() > 15 else -1 return ans def next_col_location(): # The position to scroll to for the next column (same as # next_screen_location() if columns per screen == 1). Returns -1 if no # further scrolling is possible. if is_full_screen_layout: return -1 cc = current_column_location() ans = cc + col_and_gap limit = scroll_viewport.paged_content_width() - scroll_viewport.width() if ans > limit: ans = limit if current_scroll_offset() < limit else -1 return ans def previous_col_location(): # The position to scroll to for the previous column (same as # previous_screen_location() if columns per screen == 1). Returns -1 if # no further scrolling is possible. if is_full_screen_layout: return -1 cc = current_column_location() ans = cc - col_and_gap if ans < 0: ans = 0 if current_scroll_offset() > 0 else -1 return ans def jump_to_anchor(name): # Jump to the element identified by anchor name. Ensures that the left # most column in the viewport is the column containing the start of the # element and that the scroll position is at the start of the column. elem = document.getElementById(name) if not elem: elems = document.getElementsByName(name) if elems: elem = elems[0] if not elem: return scroll_to_elem(elem) def scroll_to_elem(elem): scroll_viewport.scroll_into_view(elem) scroll_viewport.reset_transforms() # needed for viewport_to_document() if in_paged_mode: # Ensure we are scrolled to the column containing elem # Because of a bug in WebKit's getBoundingClientRect() in column # mode, this position can be inaccurate, see # https://bugs.launchpad.net/calibre/+bug/1132641 for a test case. # The usual symptom of the inaccuracy is br.top is highly negative. br = elem.getBoundingClientRect() if br.top < -100: # This only works because of the preceding call to # elem.scrollIntoView(). However, in some cases it gives # inaccurate results, so we prefer the bounding client rect, # when possible. left = elem.scrollLeft else: left = br.left scroll_to_xpos(viewport_to_document( left+2, elem.scrollTop, elem.ownerDocument)[0]) def snap_to_selection(): # Ensure that the viewport is positioned at the start of the column # containing the start of the current selection if in_paged_mode: scroll_viewport.reset_transforms() # needed for viewport_to_document() sel = window.getSelection() r = sel.getRangeAt(0).getBoundingClientRect() node = sel.anchorNode left = viewport_to_document(r.left, r.top, doc=node.ownerDocument)[0] # Ensure we are scrolled to the column containing the start of the # selection scroll_to_xpos(left+5) def jump_to_cfi(cfi): # Jump to the position indicated by the specified conformal fragment # indicator. When in paged mode, the # scroll is performed so that the column containing the position # pointed to by the cfi is the left most column in the viewport cfi_scroll_to(cfi, def(x, y): if in_paged_mode: scroll_to_xpos(x) else: scroll_viewport.scroll_to(0, y) ) def current_cfi(): # The Conformal Fragment Identifier at the current position, returns # null if it could not be calculated. ans = None if in_paged_mode: c = current_column_location() for x in c, c - col_and_gap, c + col_and_gap: # Try the current column, the previous column and the next # column. Each column is tried from top to bottom. left, right = x, x + col_and_gap if left < 0 or right > scroll_viewport.paged_content_width(): continue deltax = col_and_gap // 25 deltay = scroll_viewport.height() // 25 cury = 0 while cury < scroll_viewport.height(): curx = left while curx < right - gap: cfi = cfi_at_point(curx-current_scroll_offset(), cury-window.pageYOffset) if cfi: # print('Viewport cfi:', cfi) return cfi curx += deltax cury += deltay else: try: ans = cfi_at_current() or None except: traceback.print_exc() # if ans: # print('Viewport cfi:', ans) return ans def progress_frac(frac): # The current scroll position as a fraction between 0 and 1 if in_paged_mode: limit = scroll_viewport.paged_content_width() - scroll_viewport.width() if limit <= 0: return 0.0 return current_scroll_offset() / limit limit = document.body.scrollHeight - scroll_viewport.height() if limit <= 0: return 0.0 return window.pageYOffset / limit def onwheel(evt): if evt.deltaY: backward = evt.deltaY < 0 x = previous_col_location() if backward else next_col_location() if x is -1: get_boss().send_message('next_spine_item', previous=backward) else: scroll_to_xpos(x) def scroll_by_page(backward, by_screen): if by_screen: pos = previous_screen_location() if backward else next_screen_location() pages = cols_per_screen else: pos = previous_col_location() if backward else next_col_location() pages = 1 if pos is -1: # dont report human scroll since we dont know if a full page was # scrolled or not get_boss().send_message('next_spine_item', previous=backward) else: if not backward: scrolled_frac = (pages / number_of_cols) if number_of_cols > 0 else 0 get_boss().report_human_scroll(scrolled_frac) else: get_boss().report_human_scroll() scroll_to_xpos(pos) def handle_shortcut(sc_name, evt): if sc_name is 'up': scroll_by_page(True, True) return True if sc_name is 'down': scroll_by_page(False, True) return True if sc_name is 'start_of_file': get_boss().report_human_scroll() scroll_to_offset(0) return True if sc_name is 'end_of_file': get_boss().report_human_scroll() scroll_to_offset(document_width()) return True if sc_name is 'left': scroll_by_page(True, False) return True if sc_name is 'right': scroll_by_page(False, False) return True if sc_name is 'start_of_book': get_boss().report_human_scroll() get_boss().send_message('goto_doc_boundary', start=True) return True if sc_name is 'end_of_book': get_boss().report_human_scroll() get_boss().send_message('goto_doc_boundary', start=False) return True if sc_name is 'pageup': scroll_by_page(True, True) return True if sc_name is 'pagedown': scroll_by_page(False, True) return True return False def handle_gesture(gesture): if gesture.type is 'swipe': if gesture.axis is 'vertical': if not gesture.active: get_boss().send_message('next_section', forward=gesture.direction is 'up') else: if not gesture.active or gesture.is_held: scroll_by_page(gesture.direction is 'right', True) elif gesture.type is 'prev-page': scroll_by_page(True, False) elif gesture.type is 'next-page': scroll_by_page(False, False) elif gesture.type is 'long-tap': r = word_at_point(gesture.viewport_x, gesture.viewport_y) if r: s = document.getSelection() s.removeAllRanges() s.addRange(r) get_boss().send_message('lookup_word', word=str(r)) 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(current_scroll_offset() + 10) if pos < first: return -1 if pos < first + cols_per_screen: return 0 return 1 , 'cmp': def cmp(a, b): return a - b , }