calibre/src/pyj/read_book/paged_mode.pyj
2016-04-29 19:59:50 +05:30

485 lines
19 KiB
Plaintext

# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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.globals import get_boss
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 = screen_width = screen_height = cols_per_screen = gap = col_and_gap = 0
is_full_screen_layout = False
def column_at(xpos):
# Return the (zero-based) number of the column that contains xpos
sw = document.body.scrollWidth
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 = []
maxh = screen_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) * 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, 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, 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
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 < 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()
n = cols_per_screen = opts.cols_per_screen
# 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 = window.innerWidth
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_side or 0
gap = 2*sm + ((ww + 2*sm) % n) # Ensure ww + gap is a multiple of n
col_width = ((ww + gap) // n) - gap
screen_height = window.innerHeight
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='none',
margin='0', border_width='0', padding='0',
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')
# 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
# <div>, 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
is_full_screen_layout = (only_img or has_svg) and single_screen and (document.body.scrollWidth < 2*ww + 10)
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()
)
ncols = (document.body.scrollWidth + gap) / col_and_gap
if ncols is not Math.floor(ncols):
n = Math.floor(ncols)
dw = (n*col_width + (n-1)*gap)
document.body.width = dw + 'px'
print('WARNING: column layout broken', {'col_with':col_width, 'gap':gap, 'scrollWidth':document.body.scrollWidth, 'ncols':ncols, 'delta':document.body.scrollWidth - dw})
_in_paged_mode = True
fit_images()
return gap
def scroll_to_column(number, animated=False, notify=False, duration=1000):
pos = number * col_and_gap
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_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
scroll_to_column(column_at(xpos), animated=animated, notify=notify, duration=duration)
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)
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)*col_and_gap) - br.left
if br.bottom < br.top:
br.bottom = window.innerHeight
height = min(br.bottom, window.innerHeight) - br.top
left -= c * col_and_gap
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 + 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
c = column_at(window.pageXOffset + 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(window.pageXOffset + 10)
ncols = (document.body.scrollWidth + 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 = 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 + col_and_gap
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 - col_and_gap
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 <svg> 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+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:
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 - 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 > document.body.scrollWidth:
continue
deltax = col_and_gap // 25
deltay = window.innerHeight // 25
cury = 0
while cury < window.innerHeight:
curx = left
while curx < right - gap:
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
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 onkeydown(evt):
handled = False
key = get_key(evt)
if key is 'up' or key is 'down':
handled = True
if evt.ctrlKey:
goto_boundary(-1 if key is 'up' else 1)
else:
smooth_y_scroll(key is 'up')
elif key is 'left' or key is 'right':
handled = True
if evt.ctrlKey:
window.scrollTo(0 if key is 'left' else document_width(), window.pageYOffset)
else:
window.scrollBy(-15 if key is 'left' else 15, 0)
elif key is 'home' or key is 'end':
handled = True
if evt.ctrlKey:
get_boss().send_message('goto_doc_boundary', start=key is 'home')
else:
if key is 'home':
window.scrollTo(0, 0)
else:
window.scrollTo(document.body.scrollWidth, 0)
elif key is 'pageup' or key is 'pagedown' or key is 'space':
handled = True
scroll_by_page(-1 if key is 'pageup' else 1)
if handled:
evt.preventDefault()