mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-01-16 17:10:19 -05:00
558 lines
21 KiB
Plaintext
558 lines
21 KiB
Plaintext
# vim:fileencoding=utf-8
|
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
|
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 <div>. 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
|
|
# <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
|
|
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
|
|
,
|
|
}
|