From 3f34835bac3c1568e01efc5a6b7ca639d0739c02 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 28 Apr 2016 15:37:47 +0530 Subject: [PATCH] Port the paged layout code to RapydScript --- src/pyj/read_book/iframe.pyj | 5 + src/pyj/read_book/paged_mode.pyj | 456 +++++++++++++++++++++++++++++++ src/pyj/utils.pyj | 39 +++ 3 files changed, 500 insertions(+) create mode 100644 src/pyj/read_book/paged_mode.pyj diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index 8b4c9f807f..f767e25d51 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -8,6 +8,7 @@ from gettext import install from read_book.globals import set_boss, set_current_spine_item, current_layout_mode, current_spine_item, set_layout_mode 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 +from read_book.paged_mode import layout as paged_layout, scroll_to_fraction as paged_scroll_to_fraction from read_book.settings import apply_settings from utils import debounce @@ -75,10 +76,14 @@ class Boss: window.addEventListener('keydown', self.onkeydown) if current_layout_mode() is 'flow': flow_layout() + else: + paged_layout() csi = current_spine_item() if csi.initial_scroll_fraction is not None: if current_layout_mode() is 'flow': flow_to_scroll_fraction(csi.initial_scroll_fraction) + else: + paged_scroll_to_fraction(csi.initial_scroll_fraction) def update_cfi(self): pass # TODO: Update CFI diff --git a/src/pyj/read_book/paged_mode.pyj b/src/pyj/read_book/paged_mode.pyj new file mode 100644 index 0000000000..40e02f1c52 --- /dev/null +++ b/src/pyj/read_book/paged_mode.pyj @@ -0,0 +1,456 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal +from __python__ import hash_literals + +from dom import set_css +from elementmaker import E +from keycodes import get_key +from read_book.cfi import scroll_to as cfi_scroll_to, at_point as cfi_at_point, at_current as cfi_at_current +from read_book.settings import opts +import traceback +from utils import 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 = page_width = screen_width = side_margin = page_height = margin_top = margin_bottom = 0 +is_full_screen_layout = False + +def column_at(xpos): + # Return the number of the column that contains xpos + return xpos // page_width + +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 = [] + maxh = page_height + for img in document.getElementsByTagName('img'): + previously_limited = get_elem_data(img, 'width-limited', False) + data = get_elem_data(img, 'img-data', None) + br = img.getBoundingClientRect() + 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) * page_width + rleft = left - col - side_margin + width = br.right - br.left + rright = rleft + width + col_width = page_width - 2*side_margin + 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, max_width in images: + img.style.setProperty('max-width', max_width+'px') + set_elem_data(img, 'width-limited', True) + + for img in vimages: + data = get_elem_data(img, 'img-data', None) + set_css(img, 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 layout(is_single_page): + nonlocal _in_paged_mode, col_width, page_width, page_height, side_margin, screen_width, margin_top, margin_bottom, is_full_screen_layout + body_style = window.getComputedStyle(document.body) + first_layout = False + if not _in_paged_mode: + 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 < window.innerHeight + 75) + first_layout = True + if not single_screen and opts.cols_per_screen > 1: + num = opts.cols_per_screen - 1 + while num > 0: + num -= 1 + create_page_div() + + ww = window.innerWidth + + # Calculate the column width so that cols_per_screen columns fit in the + # window in such a way the right margin of the last column is <= + # side_margin (it may be less if the window width is not a + # multiple of n*(col_width+2*side_margin). + + n = opts.cols_per_screen + adjust = ww - (ww // n) * n + # Ensure that the margins are large enough that the adjustment does not + # cause them to become negative semidefinite + sm = max(2*adjust, opts.margin_side) + # Minimum column width, for the cases when the window is too + # narrow + col_width = max(100, ((ww - adjust)/n) - 2*sm) + if opts.max_col_width > 0 and col_width > opts.max_col_width: + # Increase the side margin to ensure that col_width is no larger + # than opts.max_col_width + sm += Math.ceil( (col_width - opts.max_col_width) / 2*n ) + col_width = max(100, ((ww - adjust)/n) - 2*sm) + page_width = col_width + 2*sm + side_margin = sm + screen_width = page_width * opts.cols_per_screen + + set_css(document.body, column_gap=2*sm + 'px', column_width=col_width + 'px', column_rule='0px inset blue', + min_width='0', max_width='none', min_height='0', max_height='none', + margin='0', border_width='0', padding='0 ' + sm + 'px', + width=(window.innerWidth - 2*sm) + 'px', height=window.innerHeight + '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') + # Remove any webkit specified default margin from the first child of body + # Otherwise, you could end up with an effective negative margin, I dont + # understand exactly why, but see: + # https://bugs.launchpad.net/calibre/+bug/1082640 for an example + c = first_child(document.body) + if c: + c.style.setProperty('-webkit-margin-before', '0') + # 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 + has_svg = document.getElementsByTagName('svg').length > 0 + only_img = document.getElementsByTagName('img').length is 1 and document.getElementsByTagName('div').length < 3 and document.getElementsByTagName('p').length < 2 + # We only set full_screen_layout if scrollWidth is in (body_width, + # 2*body_width) as if it is <= body_width scrolling will work + # anyway and if it is >= 2*body_width it can't be a full screen + # layout + body_width = document.body.offsetWidth + 2 * sm + is_full_screen_layout = (only_img or has_svg) and single_screen and document.body.scrollWidth > body_width and document.body.scrollWidth < 2 * body_width + if is_single_page: + is_full_screen_layout = True + + # Prevent the TAB key from shifting focus as it causes partial scrolling + document.documentElement.addEventListener('keydown', def (evt): + if get_key(evt) is 'tab': + evt.preventDefault() + ) + + # Ensure the body width is an exact multiple of the column widths so that + # the browser does not adjust the column widths + # body_width = ncols * col_width + (ncols-1) * 2 * sm + ncols = document.body.scrollWidth / page_width + if ncols is not Math.floor(ncols) and not is_full_screen_layout: + set_css(document.body, width=Math.floor(ncols) * page_width - 2 * sm) + + _in_paged_mode = True + fit_images() + return sm + +def scroll_to_fraction(frac): + # Scroll to the position represented by frac (number between 0 and 1) + xpos = Math.floor(document.body.scrollWidth * frac) + scroll_to_xpos(xpos) + +def scroll_to_xpos(xpos, animated=False, notify=False, duration=1000): + # Scroll so that the column containing xpos is the left most column in + # the viewport + if type(xpos) is not 'number': + print(xpos, 'is not a number, cannot scroll to it!') + return + if is_full_screen_layout: + window.scrollTo(0, 0) + return + pos = Math.floor(xpos/page_width) * page_width + limit = document.body.scrollWidth - screen_width + pos = min(pos, limit) + if animated: + animated_scroll(pos, duration=1000, notify=notify) + else: + window.scrollTo(pos, 0) + +def scroll_to_column(number): + scroll_to_xpos(number * page_width + 10) + +current_scroll_animation = None + +def animated_scroll(pos, duration=1000, notify=True): + # Scroll the window to X-position pos in an animated fashion over + # duration milliseconds. + nonlocal current_scroll_animation + delta = pos - window.pageXOffset + interval = 50 + steps = Math.floor(duration/interval) + step_size = Math.floor(delta/steps) + current_scroll_animation = {'target':pos, 'step_size':step_size, 'interval':interval, 'notify':notify, 'fn': def(): + a = current_scroll_animation + npos = window.pageXOffset + a.step_size + completed = False + if abs(npos - a.target) < abs(a.step_size): + completed = True + npos = a.target + window.scrollTo(npos, 0) + if not completed: + setTimeout(a.fn, a.interval) + } + current_scroll_animation.fn() + +def column_location(elem): + # Return the location of elem relative to its containing column. + # WARNING: This method may cause the viewport to scroll (to workaround + # a bug in WebKit). + br = elem.getBoundingClientRect() + # Because of a bug in WebKit's getBoundingClientRect() in column + # mode, this position can be inaccurate, see + # https://bugs.launchpad.net/calibre/+bug/1202390 for a test case. + # The usual symptom of the inaccuracy is br.top is highly negative. + if br.top < -100: + # We have to actually scroll the element into view to get its + # position + elem.scrollIntoView() + left, top = viewport_to_document(elem.scrollLeft, elem.scrollTop, elem.ownerDocument) + else: + left, top = viewport_to_document(br.left, br.top, elem.ownerDocument) + c = column_at(left) + width = min(br.right, (c+1)*page_width) - br.left + if br.bottom < br.top: + br.bottom = window.innerHeight + height = min(br.bottom, window.innerHeight) - br.top + left -= c*page_width + return {'column':c, 'left':left, 'top':top, 'width':width, 'height':height} + +def column_boundaries(): + # Return the column numbers at the left edge and after the right edge + # of the viewport + l = column_at(window.pageXOffset + 10) + return l, l + opts.cols_per_screen + +def current_pos(frac): + # The current scroll position as a fraction between 0 and 1 + limit = document.body.scrollWidth - window.innerWidth + if limit <= 0: + return 0.0 + return window.pageXOffset / limit + +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 + x = window.pageXOffset + max(10, side_margin) + return Math.floor(x/page_width) * page_width + +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 opts.cols_per_screen > 1: + width_left = document.body.scrollWidth - (window.pageXOffset + window.innerWidth) + pages_left = width_left / page_width + if Math.ceil(pages_left) < opts.cols_per_screen: + return -1 # Only blank, dummy pages left + limit = document.body.scrollWidth - window.innerWidth + if ans > limit: + ans = limit if window.pageXOffset < 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 window.pageXOffset > 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 + page_width + limit = document.body.scrollWidth - window.innerWidth + if ans > limit: + ans = limit if window.pageXOffset < 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 - page_width + if ans < 0: + ans = 0 if window.pageXOffset > 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 + # TODO: Re-enable this once you have added mathjax support + # if window.mathjax?.math_present + # # MathJax links to children of SVG tags and scrollIntoView doesn't + # # work properly for them, so if this link points to something + # # inside an tag we instead scroll the parent of the svg tag + # # into view. + # parent = elem + # while parent and parent?.tagName?.toLowerCase() != 'svg' + # parent = parent.parentNode + # if parent?.tagName?.toLowerCase() == 'svg' + # elem = parent.parentNode + elem.scrollIntoView() + 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+side_margin, 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: + 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, job_id=-1): + # Jump to the position indicated by the specified conformal fragment + # indicator (requires the cfi.coffee library). 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: + window.scrollTo(0, y) + # if window.py_bridge + # window.py_bridge.jump_to_cfi_finished(job_id) + ) + +def current_cfi(): + # The Conformal Fragment Identifier at the current position, returns + # null if it could not be calculated. + ans = None + # TODO: uncomment after mathjax is implemented + # if window.mathjax?.math_present and not window.mathjax?.math_loaded: + # # If MathJax is loading, it is changing the DOM, so we cannot + # # reliably generate a CFI + # return ans + if in_paged_mode: + c = current_column_location() + for x in c, c-page_width, c+page_width: + # Try the current column, the previous column and the next + # column. Each column is tried from top to bottom. + left, right = x, x + page_width + if left < 0 or right > document.body.scrollWidth: + continue + deltax = page_width // 25 + deltay = window.innerHeight // 25 + cury = 0 + while cury < window.innerHeight - this.effective_margin_bottom: + curx = left + side_margin + while curx < right - side_margin: + cfi = cfi_at_point(curx-window.pageXOffset, 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 diff --git a/src/pyj/utils.pyj b/src/pyj/utils.pyj index 891fc7961f..893e4f8068 100644 --- a/src/pyj/utils.pyj +++ b/src/pyj/utils.pyj @@ -2,6 +2,8 @@ # License: GPL v3 Copyright: 2015, Kovid Goyal from __python__ import hash_literals +from encodings import hexlify + def debounce(func, wait, immediate=False): # Returns a function, that, as long as it continues to be invoked, will not # be triggered. The function will be called after it stops being called for @@ -89,6 +91,43 @@ def document_width(): html = document.documentElement return max(document.body.scrollWidth, document.body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth) +_data_ns = None + +def data_ns(name): + nonlocal _data_ns + if _data_ns is None: + rand = Uint8Array(12) + window.crypto.getRandomValues(rand) + _data_ns = 'data-' + hexlify(rand) + '-' + return _data_ns + name + +def get_elem_data(elem, name, defval): + ans = elem.getAttribute(data_ns(name)) + if ans is None: + return defval ? None + return JSON.parse(ans) + +def set_elem_data(elem, name, val): + elem.setAttribute(data_ns(name), JSON.stringify(val)) + +def viewport_to_document(x, y, doc): + # Convert x, y from the viewport (window) co-ordinate system to the + # document (body) co-ordinate system + doc = doc or window.document + topdoc = window.top.document + while doc is not topdoc: + # We are in a frame + frame = doc.defaultView.frameElement + rect = frame.getBoundingClientRect() + x += rect.left + y += rect.top + doc = frame.ownerDocument + win = doc.defaultView + wx, wy = win.pageXOffset, win.pageYOffset + x += wx + y += wy + return x, y + if __name__ is '__main__': print(fmt_sidx(10), fmt_sidx(1.2)) print(list(map(human_readable, [1, 1024.0, 1025, 1024*1024*2.3])))