mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-11-23 23:13:02 -05:00
490 lines
21 KiB
Plaintext
490 lines
21 KiB
Plaintext
# vim:fileencoding=utf-8
|
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
|
from __python__ import bound_methods, hash_literals
|
|
|
|
from ajax import ajax_send
|
|
from dom import set_css, add_extra_css, build_rule, svgicon
|
|
from elementmaker import E
|
|
from gettext import gettext as _
|
|
from utils import html_escape
|
|
from session import get_interface_data, get_device_uuid
|
|
|
|
from modals import error_dialog, warning_dialog
|
|
from book_list.globals import get_session_data, main_js, get_translations
|
|
from book_list.router import push_state, read_book_mode
|
|
from read_book.globals import messenger, iframe_id, current_book, set_current_spine_item
|
|
from read_book.resources import load_resources
|
|
from read_book.overlay import Overlay
|
|
from read_book.search import SearchOverlay, find_in_spine
|
|
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 read_book.goto import get_next_section
|
|
from book_list.theme import get_color, get_font_family
|
|
from utils import parse_url_params, username_key
|
|
|
|
LOADING_DOC = '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
<script type="text/javascript" id="bootstrap">
|
|
__SCRIPT__
|
|
end_script
|
|
</head>
|
|
<body style="font-family: __FONT__">
|
|
<div style="font-size:larger; font-weight: bold; margin-top:48vh; text-align:center">
|
|
__BS__
|
|
</div>
|
|
</body>
|
|
</html>
|
|
'''.replace('end_script', '<' + '/script>') # cannot have a closing script tag as this is embedded inside a script tag in index.html
|
|
|
|
|
|
add_extra_css(def():
|
|
sel = '.book-side-margin'
|
|
ans = build_rule(sel, cursor='pointer', color='rgba(0, 0, 0, 0)', text_align='center', height='100vh', user_select='none', display='flex', align_items='center', justify_content='center')
|
|
ans += build_rule(sel + ':hover', background_color=get_color('window-background') + ' !important', color=get_color('window-foreground') + ' !important')
|
|
ans += build_rule(sel + ':active > svg', color=get_color('window-hover-foreground'), transform='scale(2)')
|
|
return ans
|
|
)
|
|
|
|
|
|
def margin_elem(sd, which, id, onclick):
|
|
sz = sd.get(which, 20)
|
|
fsz = min(max(0, sz - 6), 12)
|
|
ans = E.div(
|
|
style=f'height:{sz}px; overflow: hidden; font-size:{fsz}px; width:100%; padding: 0; display: flex; justify-content: space-between; align-items: center',
|
|
id=id,
|
|
E.div(), E.div()
|
|
)
|
|
if onclick:
|
|
ans.addEventListener('click', onclick)
|
|
return ans
|
|
|
|
|
|
class View:
|
|
|
|
def __init__(self, container, ui):
|
|
self.ui = ui
|
|
self.loaded_resources = {}
|
|
sd = get_session_data()
|
|
left_margin = E.div(svgicon('caret-left'), style='width:{}px;'.format(sd.get('margin_left', 20)), class_='book-side-margin', id='book-left-margin', onclick=self.left_margin_clicked)
|
|
set_left_margin_handler(left_margin)
|
|
right_margin = E.div(svgicon('caret-right'), style='width:{}px;'.format(sd.get('margin_right', 20)), class_='book-side-margin', id='book-right-margin', onclick=self.right_margin_clicked)
|
|
set_right_margin_handler(right_margin)
|
|
container.appendChild(
|
|
E.div(style='max-height: 100vh; width: 100vw; height: 100vh; overflow: hidden; display: flex; align-items: stretch', # container for horizontally aligned panels
|
|
E.div(style='max-height: 100vh; display: flex; flex-direction: column; align-items: stretch; flex-grow:2', # container for iframe and any other panels in the same column
|
|
E.div(style='max-height: 100vh; flex-grow: 2; display:flex; align-items: stretch', # container for iframe and its overlay
|
|
left_margin,
|
|
E.div(style='flex-grow:2; display:flex; align-items:stretch; flex-direction: column', # container for top and bottom margins
|
|
margin_elem(sd, 'margin_top', 'book-top-margin', self.top_margin_clicked),
|
|
E.iframe(id=iframe_id, seamless=True, sandbox='allow-popups allow-scripts', style='flex-grow: 2', allowfullscreen='true'),
|
|
margin_elem(sd, 'margin_bottom', 'book-bottom-margin'),
|
|
),
|
|
right_margin,
|
|
E.div(style='position: absolute; top:0; left:0; width: 100%; pointer-events:none; display:none', id='book-search-overlay'), # search overlay
|
|
E.div(style='position: absolute; top:0; left:0; width: 100%; height: 100%; display:none', id='book-overlay'), # main overlay
|
|
)
|
|
)
|
|
)
|
|
)
|
|
self.search_overlay = SearchOverlay(self)
|
|
self.overlay = Overlay(self)
|
|
self.src_doc = None
|
|
self.iframe_ready = False
|
|
self.pending_load = None
|
|
self.encrypted_communications = False
|
|
self.create_src_doc()
|
|
window.addEventListener('message', self.handle_message, False)
|
|
self.handlers = {
|
|
'ready': self.on_iframe_ready,
|
|
'error': self.on_iframe_error,
|
|
'next_spine_item': self.on_next_spine_item,
|
|
'next_section': self.on_next_section,
|
|
'goto_doc_boundary': def(data): self.goto_doc_boundary(data.start);,
|
|
'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,
|
|
'find_in_spine': self.on_find_in_spine,
|
|
'request_size': self.on_request_size,
|
|
}
|
|
self.currently_showing = {}
|
|
|
|
@property
|
|
def iframe(self):
|
|
return document.getElementById(iframe_id)
|
|
|
|
def left_margin_clicked(self, event):
|
|
if event.button is 0:
|
|
event.preventDefault(), event.stopPropagation()
|
|
self.send_message('next_screen', backwards=True)
|
|
|
|
def right_margin_clicked(self, event):
|
|
if event.button is 0:
|
|
event.preventDefault(), event.stopPropagation()
|
|
self.send_message('next_screen', backwards=False)
|
|
|
|
def top_margin_clicked(self, event):
|
|
if event.button is 0:
|
|
event.preventDefault(), event.stopPropagation()
|
|
self.show_chrome()
|
|
|
|
def forward_gesture(self, gesture):
|
|
self.send_message('gesture_from_margin', gesture=gesture)
|
|
|
|
def iframe_size(self):
|
|
w, h = window.innerWidth, window.innerHeight
|
|
iframe = self.iframe
|
|
w -= 2 * iframe.offsetLeft
|
|
h -= 2 * iframe.offsetTop
|
|
return w, h
|
|
|
|
def on_request_size(self, data):
|
|
# On iOS/Safari window.innerWidth/Height are incorrect inside an iframe
|
|
w, h = self.iframe_size()
|
|
self.send_message('window_size', width=w, height=h)
|
|
|
|
def find(self, text, backwards):
|
|
self.send_message('find', text=text, backwards=backwards, searched_in_spine=False)
|
|
|
|
def on_find_in_spine(self, data):
|
|
if data.searched_in_spine:
|
|
warning_dialog(_('Not found'), _('The text: <i>{}</i> was not found in this book').format(html_escape(data.text)))
|
|
return
|
|
spine = self.book.manifest.spine
|
|
idx = spine.indexOf(self.currently_showing.name)
|
|
if idx < 0:
|
|
error_dialog(_('Missing file'), _(
|
|
'Could not search as the spine item {} is missing from the book').format(self.currently_showing.name))
|
|
return
|
|
names = v'[]'
|
|
item_groups = [range(idx-1, -1, -1), range(spine.length-1, idx, -1)] if data.backwards else [range(idx + 1, spine.length), range(idx)]
|
|
for items in item_groups:
|
|
for i in items:
|
|
names.push(spine[i])
|
|
find_in_spine(names, self.book, self.ui.db, data.text, def(found_in):
|
|
if found_in:
|
|
self.show_name(found_in, initial_position={'type':'search', 'search_data':data, 'replace_history':True})
|
|
else:
|
|
self.send_message('find', text=data.text, backwards=data.backwards, searched_in_spine=True)
|
|
)
|
|
|
|
def bump_font_size(self, data):
|
|
delta = 2 if data.increase else -2
|
|
change_font_size_by(delta)
|
|
|
|
def show_chrome(self):
|
|
self.search_overlay.hide()
|
|
self.overlay.show()
|
|
|
|
def show_search(self):
|
|
self.overlay.hide()
|
|
self.search_overlay.show()
|
|
|
|
def set_margins(self):
|
|
no_margins = self.currently_showing.name is self.book.manifest.title_page_name
|
|
sd = get_session_data()
|
|
margin_left = 0 if no_margins else sd.get('margin_left')
|
|
margin_right = 0 if no_margins else sd.get('margin_right')
|
|
margin_top = 0 if no_margins else sd.get('margin_top')
|
|
margin_bottom = 0 if no_margins else sd.get('margin_bottom')
|
|
max_text_height = sd.get('max_text_height')
|
|
th = window.innerHeight - margin_top - margin_bottom
|
|
if not no_margins and max_text_height > 100 and th > max_text_height:
|
|
extra = (th - max_text_height) // 2
|
|
margin_top += extra
|
|
margin_bottom += extra
|
|
max_text_width = sd.get('max_text_width')
|
|
tw = window.innerWidth - margin_left - margin_right
|
|
if not no_margins and max_text_width > 100 and tw > max_text_width:
|
|
extra = (tw - max_text_width) // 2
|
|
margin_left += extra
|
|
margin_right += extra
|
|
set_css(document.getElementById('book-top-margin'), height=margin_top + 'px')
|
|
set_css(document.getElementById('book-bottom-margin'), height=margin_bottom + 'px')
|
|
def side_margin(which, val):
|
|
m = document.getElementById('book-{}-margin'.format(which))
|
|
if which is 'left':
|
|
# Explicitly set the width of the central panel. This is needed
|
|
# on small screens with chrome, without it sometimes the right
|
|
# margin goes off the screen.
|
|
m.nextSibling.style.maxWidth = 'calc(100vw - {}px)'.format(margin_left + margin_right)
|
|
set_css(m, width=val + 'px')
|
|
val = min(val, 75)
|
|
m.firstChild.style.width = val + 'px'
|
|
m.firstChild.style.height = val + 'px'
|
|
side_margin('left', margin_left), side_margin('right', margin_right)
|
|
|
|
def create_src_doc(self):
|
|
iframe_script = main_js().replace(/is_running_in_iframe\s*=\s*false/, 'is_running_in_iframe = true')
|
|
main_js(None)
|
|
self.src_doc = LOADING_DOC.replace(
|
|
'__BS__', _('Bootstrapping book reader...')).replace(
|
|
'__SCRIPT__', iframe_script).replace(
|
|
'__FONT__', get_font_family().replace('"', '"'))
|
|
|
|
def init_iframe(self, iframe_script):
|
|
self.encrypted_communications = False
|
|
self.iframe.srcdoc = self.src_doc
|
|
|
|
def send_message(self, action, **data):
|
|
data.action = action
|
|
msg = {'data':data, 'encrypted':self.encrypted_communications}
|
|
if self.encrypted_communications:
|
|
msg.data = messenger.encrypt(data)
|
|
self.iframe.contentWindow.postMessage(msg, '*')
|
|
|
|
def handle_message(self, event):
|
|
if event.source is not self.iframe.contentWindow:
|
|
return
|
|
data = event.data
|
|
if self.encrypted_communications:
|
|
try:
|
|
data = messenger.decrypt(data)
|
|
except Exception as e:
|
|
print('Could not process message from iframe:')
|
|
console.log(e)
|
|
return
|
|
func = self.handlers[data.action]
|
|
if func:
|
|
func(data)
|
|
else:
|
|
print('Unknown action in message from iframe to parent: ' + data.action)
|
|
|
|
def on_iframe_ready(self, data):
|
|
messenger.reset()
|
|
w, h = self.iframe_size()
|
|
self.send_message('initialize', secret=messenger.secret, translations=get_translations(), width=w, height=h)
|
|
self.encrypted_communications = True
|
|
self.iframe_ready = True
|
|
self.do_pending_load()
|
|
|
|
def do_pending_load(self):
|
|
if self.pending_load:
|
|
data = self.pending_load
|
|
self.pending_load = None
|
|
self.show_spine_item_stage2(data)
|
|
|
|
def on_iframe_error(self, data):
|
|
self.ui.show_error((data.title or _('There was an error processing the book')), data.msg, data.details)
|
|
|
|
def get_color_scheme(self, apply_to_margins):
|
|
ans = resolve_color_scheme()
|
|
if apply_to_margins:
|
|
for which in 'left top right bottom'.split(' '):
|
|
s = document.getElementById('book-{}-margin'.format(which)).style
|
|
if which is 'top' or which is 'bottom':
|
|
s.color = ans.foreground # Setting a color for the side margins causes the hover arrow to become visible
|
|
s.backgroundColor = ans.background
|
|
return ans
|
|
|
|
def on_resize(self):
|
|
if self.book and self.currently_showing.name:
|
|
sd = get_session_data()
|
|
if sd.get('max_text_width') or sd.get('max_text_height'):
|
|
self.set_margins()
|
|
|
|
def show_loading(self):
|
|
title = self.book.metadata.title
|
|
name = self.currently_showing.name
|
|
self.overlay.show_loading_message(_(
|
|
'Loading <i>{name}</i> from <i>{title}</i>, please wait...').format(name=name, title=title))
|
|
|
|
def hide_loading(self):
|
|
self.overlay.hide_loading_message()
|
|
|
|
def parse_cfi(self, encoded_cfi, book):
|
|
name = cfi = None
|
|
if encoded_cfi and encoded_cfi.startswith('epubcfi(/'):
|
|
cfi = encoded_cfi[len('epubcfi(/'):-1]
|
|
snum, rest = cfi.partition('/')[::2]
|
|
try:
|
|
snum = int(snum)
|
|
except Exception:
|
|
print('Invalid spine number in CFI:', snum)
|
|
if jstype(snum) is 'number':
|
|
name = book.manifest.spine[(int(snum) // 2) - 1] or name
|
|
cfi = '/' + rest
|
|
return name, cfi
|
|
|
|
def display_book(self, book):
|
|
self.overlay.hide()
|
|
self.search_overlay.hide()
|
|
self.book = current_book.book = book
|
|
self.ui.db.update_last_read_time(book)
|
|
self.loaded_resources = {}
|
|
# Ensure the iframe is cleared so that no state is persisted between
|
|
# book loads
|
|
self.iframe_ready = False
|
|
pos = {'replace_history':True}
|
|
unkey = username_key(get_interface_data().username)
|
|
name = book.manifest.spine[0]
|
|
cfi = None
|
|
q = parse_url_params()
|
|
if q.bookpos and q.bookpos.startswith('epubcfi(/'):
|
|
cfi = q.bookpos
|
|
elif book.last_read_position and book.last_read_position[unkey]:
|
|
cfi = book.last_read_position[unkey]
|
|
cfiname, internal_cfi = self.parse_cfi(cfi, book)
|
|
if cfiname and internal_cfi:
|
|
name = cfiname
|
|
pos.type, pos.cfi = 'cfi', internal_cfi
|
|
self.show_name(name, initial_position=pos)
|
|
sd = get_session_data()
|
|
c = sd.get('controls_help_shown_count', 0)
|
|
if c < 1:
|
|
self.overlay.show_controls_help()
|
|
sd.set('controls_help_shown_count', c + 1)
|
|
|
|
def redisplay_book(self):
|
|
self.display_book(self.book)
|
|
|
|
def show_name(self, name, initial_position=None):
|
|
if self.currently_showing.loading:
|
|
return
|
|
sd = get_session_data()
|
|
settings={
|
|
'margin_left': 0 if name is self.book.manifest.title_page_name else sd.get('margin_left'),
|
|
'margin_right': 0 if name is self.book.manifest.title_page_name else sd.get('margin_right'),
|
|
'read_mode': sd.get('read_mode'),
|
|
'columns_per_screen': sd.get('columns_per_screen'),
|
|
'color_scheme': self.get_color_scheme(True),
|
|
'base_font_size': sd.get('base_font_size'),
|
|
}
|
|
initial_position = initial_position or {'replace_history':False}
|
|
self.currently_showing = {'name':name, 'settings':settings, 'initial_position':initial_position, 'loading':True}
|
|
self.show_loading()
|
|
spine = self.book.manifest.spine
|
|
idx = spine.indexOf(name)
|
|
set_current_spine_item(name)
|
|
if idx > -1:
|
|
self.currently_showing.bookpos = 'epubcfi(/{})'.format(2 * (idx +1))
|
|
self.set_margins()
|
|
load_resources(self.ui.db, self.book, name, self.loaded_resources, self.show_spine_item)
|
|
|
|
def goto_doc_boundary(self, start):
|
|
name = self.book.manifest.spine[0 if start else self.book.manifest.spine.length - 1]
|
|
self.show_name(name, initial_position={'type':'frac', 'frac':0 if start else 1, 'replace_history':False})
|
|
|
|
def on_scroll_to_anchor(self, data):
|
|
self.show_name(data.name, initial_position={'type':'anchor', 'anchor':data.frag, 'replace_history':False})
|
|
|
|
def goto_bookpos(self, bookpos):
|
|
cfiname, internal_cfi = self.parse_cfi(bookpos, self.book)
|
|
if cfiname and internal_cfi:
|
|
pos = {}
|
|
name = cfiname
|
|
pos.type, pos.cfi = 'cfi', internal_cfi
|
|
self.show_name(name, initial_position=pos)
|
|
|
|
def goto_named_destination(self, name, frag):
|
|
if self.currently_showing.name is name:
|
|
self.send_message('scroll_to_anchor', frag=frag)
|
|
else:
|
|
spine = self.book.manifest.spine
|
|
idx = spine.indexOf(name)
|
|
if idx is -1:
|
|
error_dialog(_('Destination does not exist'), _(
|
|
'The file {} does not exist in this book').format(name))
|
|
return
|
|
self.show_name(name, initial_position={'type':'anchor', 'anchor':frag, 'replace_history':False})
|
|
|
|
def on_next_spine_item(self, data):
|
|
spine = self.book.manifest.spine
|
|
idx = spine.indexOf(self.currently_showing.name)
|
|
if data.previous:
|
|
if idx is 0:
|
|
return
|
|
idx = min(spine.length - 1, max(idx - 1, 0))
|
|
self.show_name(spine[idx], initial_position={'type':'frac', 'frac':1, 'replace_history':True})
|
|
else:
|
|
if idx is spine.length - 1:
|
|
return
|
|
idx = max(0, min(spine.length - 1, idx + 1))
|
|
self.show_name(spine[idx], initial_position={'type':'frac', 'frac':0, 'replace_history':True})
|
|
|
|
def on_next_section(self, data):
|
|
toc_node = get_next_section(data.forward)
|
|
if toc_node:
|
|
self.goto_named_destination(toc_node.dest, toc_node.frag)
|
|
|
|
def on_update_cfi(self, data):
|
|
if self.overlay.is_visible or self.search_overlay.is_visible:
|
|
# Chrome on Android stupidly resizes the viewport when the on
|
|
# screen keyboard is displayed. This means that the push_state()
|
|
# below causes the overlay to be closed, making it impossible to
|
|
# type anything into text boxes.
|
|
# See https://bugs.chromium.org/p/chromium/issues/detail?id=404315
|
|
return
|
|
self.currently_showing.bookpos = data.cfi
|
|
push_state(self.ui.url_data, replace=data.replace_history, mode=read_book_mode)
|
|
username = get_interface_data().username
|
|
unkey = username_key(username)
|
|
if not self.book.last_read_position:
|
|
self.book.last_read_position = {}
|
|
self.book.last_read_position[unkey] = data.cfi
|
|
self.ui.db.update_last_read_time(self.book)
|
|
lrd = {'device':get_device_uuid(), 'cfi':data.cfi, 'pos_frac':data.progress_frac}
|
|
self.update_read_percent(data.progress_frac)
|
|
key = self.book.key
|
|
if username:
|
|
ajax_send('book-set-last-read-position/{library_id}/{book_id}/{fmt}'.format(
|
|
library_id=key[0], book_id=key[1], fmt=key[2]), lrd, def(end_type, xhr, ev):
|
|
if end_type is not 'load':
|
|
print('Failed to update last read position, AJAX call did not succeed')
|
|
)
|
|
|
|
def update_read_percent(self, pos_frac):
|
|
sd = get_session_data()
|
|
div = document.getElementById('book-bottom-margin')
|
|
if not div:
|
|
return
|
|
pcelem = div.lastChild
|
|
percent = min(100, max(Math.round(pos_frac * 100), 0))
|
|
text = percent + '%'
|
|
if text is not pcelem.textContent:
|
|
pcelem.textContent = text
|
|
pcelem.style.display = 'block' if sd.get('margin_bottom', 20) > 5 else 'none'
|
|
|
|
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
|
|
self.pending_load = resource_data
|
|
if self.iframe_ready:
|
|
self.do_pending_load()
|
|
else:
|
|
self.init_iframe()
|
|
|
|
def show_spine_item_stage2(self, resource_data):
|
|
self.currently_showing.loading = False
|
|
# We cannot encrypt this message because the resource data contains
|
|
# Blob objects which do not survive encryption
|
|
self.encrypted_communications = False
|
|
self.send_message('display',
|
|
resource_data=resource_data, book=self.book, name=self.currently_showing.name,
|
|
initial_position=self.currently_showing.initial_position,
|
|
settings=self.currently_showing.settings,
|
|
)
|
|
self.encrypted_communications = True
|
|
|
|
def on_content_loaded(self, data):
|
|
self.hide_loading()
|
|
frac = data.progress_frac or 0
|
|
self.update_read_percent(frac)
|
|
|
|
def update_font_size(self):
|
|
self.send_message('change_font_size', base_font_size=get_session_data().get('base_font_size'))
|
|
|
|
def update_color_scheme(self):
|
|
cs = self.get_color_scheme(True)
|
|
self.send_message('change_color_scheme', color_scheme=cs)
|